From 8e2a394875533a86b63f9d195cdf9948bb86ab2c Mon Sep 17 00:00:00 2001 From: Alexandre Paez Sequeira Date: Tue, 16 Jun 2026 17:49:26 -0300 Subject: [PATCH 01/19] docs(MHG-1132): start protocol RE notes; confirm fork base --- RE-NOTES.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 RE-NOTES.md diff --git a/RE-NOTES.md b/RE-NOTES.md new file mode 100644 index 0000000..9b10094 --- /dev/null +++ b/RE-NOTES.md @@ -0,0 +1,36 @@ +# MHG-1132 Hytale protocol RE notes + +## Fork base + +**Chosen base:** `SantioMC/hyproxy` @ tag `1.3` = commit `20975557532e456b113e61403ed62a2abd3fc3b6` +(this is the current HEAD of `SantioMC/hyproxy` `main`; tag `1.3` points at the same commit). + +**Reasoning:** +- The deployed proxy engine (`services/repositories/hytale-proxy/server.jar`) and the lobby's + backend plugin (`services/repositories/hytale-image/runtime/mods/hyproxy-backend-1.3.jar`) both + ship the `ac.eva.hyproxy` package (e.g. `ac.eva.hyproxy.Main`, `ac.eva.hyproxy.HyProxy`, + `ac.eva.hyproxy.plugin.HyProxyBackendPlugin`), matching this codebase's `group = "ac.eva"`. +- The backend jar is literally named `hyproxy-backend-1.3.jar`, and `build.gradle.kts` declares + `version = "1.3"`. The deployed artifacts therefore correspond to the `1.3` release. +- `SantioMC/hyproxy` is the actively maintained fork (last push 2026-06-01) and its `upstream` + remote is `xyzeva/hyproxy` (frozen 2026-04-21). We fork the live fork (Santio) so we inherit + its more recent state while still being able to diff against xyzeva upstream if needed. + +**gh fork outcome:** +- `gh repo fork SantioMC/hyproxy --org Minehut --fork-name hyproxy --clone=false` succeeded. +- Verified: `Minehut/hyproxy` exists, `isFork: true`, parent `SantioMC/hyproxy`, default branch `main`. +- `Minehut/hyproxy` did not previously exist; the actor is an active admin of the `Minehut` org. +- Cloned into `services/repositories/hyproxy`; working branch: + `alexandresequeira/mhg-1132-update-protocol` (HEAD `2097555`). + +## Packet IDs (current vs hyproxy) + +## Connect layout + +## Auth packets + +## ClientReferral / transfer + +## Handshake sequence + +## Live byte captures From 6f1c6d899b25d3cee5c653e0466f73598d3b4253 Mon Sep 17 00:00:00 2001 From: Alexandre Paez Sequeira Date: Tue, 16 Jun 2026 17:51:45 -0300 Subject: [PATCH 02/19] docs(MHG-1132): record container build recipe; baseline build reproduces deployed engine --- RE-NOTES.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/RE-NOTES.md b/RE-NOTES.md index 9b10094..d850b79 100644 --- a/RE-NOTES.md +++ b/RE-NOTES.md @@ -23,6 +23,27 @@ - Cloned into `services/repositories/hyproxy`; working branch: `alexandresequeira/mhg-1132-update-protocol` (HEAD `2097555`). +## Build environment + +The local host has no JDK (only JRE 21, no `javac`); the proxy needs JDK 25. Build inside a +container instead of changing the host. **Recipe used for all `./gradlew` builds in this ticket:** + +```bash +cd services/repositories/hyproxy +docker run --rm -v "$PWD":/work -w /work \ + -v hyproxy-gradle-cache:/root/.gradle \ + eclipse-temurin:25-jdk bash -lc 'sh ./gradlew --no-daemon ' +``` + +(`gradlew` may have lost its +x bit in git; invoke it with `sh ./gradlew`. Build outputs under +`build/` end up root-owned because the container runs as root — they are gitignored, so this is +cosmetic; `chown` if a host tool needs to read them.) + +**Baseline build verified:** `:proxy:build -x test` → BUILD SUCCESSFUL. The shadow jar +`proxy/build/libs/hyproxy-1.3.jar` is 61,198,355 B vs the deployed +`hytale-proxy/server.jar` 61,198,368 B — a ~13-byte (manifest/timestamp) difference, confirming +the fork base reproduces the deployed engine. + ## Packet IDs (current vs hyproxy) ## Connect layout From 34c25e97b1320f99c91e0d357e5c68579bd2325d Mon Sep 17 00:00:00 2001 From: Alexandre Paez Sequeira Date: Wed, 17 Jun 2026 08:23:39 -0300 Subject: [PATCH 03/19] docs(MHG-1132): document current Hytale 0.5.5 handshake/transfer protocol from server jar --- .gitignore | 4 +- RE-NOTES.md | 254 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b809f65..df1a768 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,6 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store +# Local-only licensed Hytale server build (RE + backend compile) +backend-plugin/libs/HytaleServer.jar diff --git a/RE-NOTES.md b/RE-NOTES.md index d850b79..259403d 100644 --- a/RE-NOTES.md +++ b/RE-NOTES.md @@ -44,14 +44,268 @@ cosmetic; `chown` if a host tool needs to read them.) `hytale-proxy/server.jar` 61,198,368 B — a ~13-byte (manifest/timestamp) difference, confirming the fork base reproduces the deployed engine. +## Decompile setup (Task 2) + +- **Server jar:** `/tmp/HytaleServer.jar`, Hytale server build **0.5.5**, NOT obfuscated, + packages under `com.hypixel.hytale.*`. Class file timestamps are `2026-06-16`. +- Jar placed at `backend-plugin/libs/HytaleServer.jar` and gitignored (licensed code, never + committed). Verified `git check-ignore` matches and it is not staged. +- **Decompiler used:** **Vineflower 1.12.0** (`vineflower-1.12.0.jar`). The + `releases/latest/download/vineflower.jar` asset 404s; pulled `vineflower-1.12.0.jar` from the + release API instead. Output is clean and complete; CFR fallback was not needed. + Vineflower was run via the `eclipse-temurin:25-jdk` container (host has no JDK 25; class files + are Java 25 bytecode). +- Only the protocol + connection-handler classes were extracted (not the full 123 MB jar): + `com/hypixel/hytale/protocol/*`, `.../server/core/io/handlers/*`, + `.../server/core/io/netty/*`, `.../server/core/io/PacketHandler*`. + +### backend-plugin compile result: **FAIL** (valuable finding — API drift) + +`:backend-plugin:build -x test` → `BUILD FAILED`, `:compileJava` 2 errors. The Hytale +`com.hypixel.hytale.*` server API the backend plugin was written against has moved: + +``` +HyProxyBackendPlugin.java:101: error: no suitable method found for + sendProxyMessage(ChannelConnection,ProxyCommunicationMessage) + this.sendProxyMessage(playerRef.getPacketHandler().getChannel(), message); + method ...sendProxyMessage(PlayerRef,ProxyCommunicationMessage) is not applicable + (ChannelConnection cannot be converted to PlayerRef) + method ...sendProxyMessage(Channel,ProxyCommunicationMessage) is not applicable + (ChannelConnection cannot be converted to Channel) + +HyProxyBackendPlugin.java:98: error: cannot find symbol + this.sendProxyMessage(Universe.get().getPlayers().getFirst(), message); + symbol: method getFirst() + location: interface Collection +``` + +Two concrete API changes in 0.5.5 vs the build hyproxy targeted: +1. `PlayerRef.getPacketHandler().getChannel()` now returns a `ChannelConnection`, not a + `Channel`/`PlayerRef`. The backend's `sendProxyMessage(...)` overloads accept `PlayerRef` or + `Channel` but not `ChannelConnection` — the channel type changed. +2. `Universe.get().getPlayers()` now returns a bare `Collection` (no longer a + `List`/`SequencedCollection`), so `.getFirst()` no longer exists. + +These are deferred to the backend-plugin fix task (do not fix here). The proxy module +(`:proxy:build`) is unaffected by these and still builds. + ## Packet IDs (current vs hyproxy) +Source of truth: `com.hypixel.hytale.protocol.PacketRegistry` (server jar). IDs are assigned in +a single static-init table via `register(direction, channel, id, name, type, fixedBlockSize, +maxSize, compressed, validate, deserialize, toObject)`. There is no annotation/`getId()`-derived +scheme — the central table is authoritative; each packet class also exposes a matching +`PACKET_ID` constant and `getId()`. Direction is `ToServer` / `ToClient` / `Both`. + +Handshake-relevant packets: + +| Packet | Server id | Dir | hyproxy id | Match? | +|--------------------------------|-----------|-----------|------------|--------| +| Connect | 0 | ToServer | 0 | ✅ (id only — layout differs, see below) | +| ClientDisconnect | 1 | ToServer | 1 | ✅ | +| ServerDisconnect | 2 | ToClient | 2 | ✅ | +| Ping | 3 | ToClient | — missing | ⚠️ | +| Pong | 4 | ToServer | — missing | ⚠️ | +| AuthGrant | 11 | ToClient | 11 | ✅ | +| AuthToken | 12 | ToServer | 12 | ✅ | +| ServerAuthToken | 13 | ToClient | 13 | ✅ | +| ConnectAccept | 14 | ToClient | 14 | ✅ | +| PasswordResponse | 15 | ToServer | — missing | ⚠️ | +| PasswordAccepted | 16 | ToClient | — missing | ⚠️ | +| PasswordRejected | 17 | ToClient | — missing | ⚠️ | +| ClientReferral | 18 | ToClient | 18 | ✅ | +| ServerMessage | 210 | ToClient | 210 | ✅ | +| ChatMessage | 211 | ToServer | 211 | ✅ | +| ServerInfo | 223 | ToClient | 223 | ✅ | +| InsecurePlayerOptions | 363 | ToServer | — missing | ⚠️ | +| RequestInsecurePlayerOptions | 364 | ToClient | — missing | ⚠️ | + +**All ids hyproxy registers still match the server.** No id renumbering. The gaps are *missing* +packets, not wrong ids: +- **Ping (3) / Pong (4):** keepalive after connect; not strictly part of the auth handshake but + the client may send Pong; hyproxy has no decoder (will hit "unexpected packet"). Low priority. +- **InsecurePlayerOptions (363) / RequestInsecurePlayerOptions (364):** these belong to the + DEVELOPMENT/OFFLINE (unauthenticated) flow only. The proxy runs an AUTHENTICATED flow, so it + does NOT need these — but note the client's username+uuid now live HERE in dev mode, not in + Connect (see Connect layout). Relevant only if we ever support offline mode. +- **Password* (15/16/17):** password-protected-server sub-flow that runs AFTER auth, before + setup. Only used if the backend has a server password. Optional for the proxy, but the client + CAN send PasswordResponse, so a decoder may be needed for password-protected backends. + ## Connect layout +`com.hypixel.hytale.protocol.packets.connection.Connect` (id 0, ToServer, NOT compressed, +FIXED_BLOCK_SIZE=30, VARIABLE_BLOCK_START=46, MAX_SIZE=37972). Little-endian throughout. +Offset-table layout: a 1-byte null-bitfield, a fixed block, then **4** int32-LE offset slots, +then the variable block at byte 46. Each offset is relative to byte 46 (varBlockStart); +`-1`/out-of-range means absent. + +Wire layout (offsets are from start of packet body): + +| off | size | field | notes | +|-----|------|------------------|-------| +| 0 | 1 | nullBits | bit0=identityToken present, bit1=referralData present, bit2=referralSource present | +| 1 | 4 | protocolCrc | int32 LE. **Server validates `protocolCrc == 1316766548` (0x4E7F3D14)**; mismatch → QUIC app-close ClientOutdated/ServerOutdated. | +| 5 | 4 | protocolBuildNumber | int32 LE. Server compares against `serverBuild = 100` only to pick the outdated-direction error message; the CRC check is the real gate. | +| 9 | 20 | clientVersion | fixed 20-byte ASCII string (space/null padded), read via `readFixedAsciiString(buf, 9, 20)`. | +| 29 | 1 | clientType | enum byte: 0=Game, 1=Editor (`ClientType.fromValue`; value ≥ 2 is invalid). | +| 30 | 4 | offset: identityToken | int32 LE, rel to byte 46. Only read if nullBits bit0 set. String, max 8192, UTF-8. | +| 34 | 4 | offset: language | int32 LE, rel to byte 46. ALWAYS present (no null bit). ASCII, max 16. | +| 38 | 4 | offset: referralData | int32 LE. Only if bit1 set. byte[], max 4096. | +| 42 | 4 | offset: referralSource | int32 LE. Only if bit2 set. `HostAddress`. | +| 46+ | var | variable block | varint-len-prefixed payloads at the above offsets. | + +`HostAddress` = `int16-LE port` + `varString host` (UTF-8, max 256). (FIXED_BLOCK_SIZE=2.) + +### Diff vs hyproxy `io/packet/impl/auth/Connect.java` — **THIS IS THE CORE BUG** + +hyproxy's `Connect` is built for an OLDER protocol and is structurally wrong: + +1. **hyproxy has a `UUID uuid` field and a `String username` field in Connect — the current + Connect has NEITHER.** hyproxy reads a UUID (16 bytes) immediately after `clientType` (at + byte 30), then reads **5** offset slots: `username, identityToken, language, referralData, + referralSource`. The real Connect reads **4** offset slots starting at byte 30: + `identityToken, language, referralData, referralSource`, with the variable block at byte 46. +2. Because of the extra UUID + extra username offset slot, **every field after `clientType` is + misaligned**: hyproxy reads 16 bytes of UUID + a username offset where the server put the + identityToken offset, the language offset, etc. The fixed block is 30 in both, but hyproxy's + var-block start is `30 + 16(uuid) + 5*4 = 66`, while the server's is `46`. This is why a + current client completes QUIC/TLS but the proxy fails to decode the first packet. +3. **username/uuid moved.** In the current protocol the client's username + uuid are no longer in + Connect at all. In AUTHENTICATED mode they come from the validated JWT identity/access-token + claims (see Handshake). In DEVELOPMENT/OFFLINE mode they arrive later in + `InsecurePlayerOptions` (id 363: uuid@1, username offset@17, optional skin). +4. **Fix direction:** rewrite hyproxy `Connect` to: nullBits, protocolCrc, protocolBuildNumber, + clientVersion(20 ASCII), clientType(byte), then 4 LE offset slots (identityToken, language, + referralData, referralSource), var block at 46. Drop `uuid`/`username` from the packet; obtain + them from JWT claims in the auth handler (the auth handler ALREADY derives uuid/username from + claims — `InboundInitialPacketHandler` currently mis-sources them from `connect.getUuid()` / + `connect.getUsername()`, which must change). Also validate `protocolCrc == 0x4E7F3D14`. + hyproxy currently also reads `language` with max 128; server max is 16 (cosmetic). + ## Auth packets +All four auth packets use the same offset-table scheme (nullBits + N int32-LE slots + var block). +Current server layouts: + +- **AuthGrant** (id 11, ToClient, fixed=1, varStart=9, max=49171): nullBits(bit0=authorizationGrant, + bit1=serverIdentityToken); slot@1 → `authorizationGrant` (String, max 4096); slot@5 → + `serverIdentityToken` (String, max 8192). Both UTF-8, both nullable. + → hyproxy `AuthGrant(grant, serverIdentityToken)` — **matches.** +- **AuthToken** (id 12, ToServer, fixed=1, varStart=9, max=49171): nullBits(bit0=accessToken, + bit1=serverAuthorizationGrant); slot@1 → `accessToken` (String, max 8192); slot@5 → + `serverAuthorizationGrant` (String, max 4096). → hyproxy reads `accessToken` + + `serverAuthorizationGrant` — **matches.** +- **ServerAuthToken** (id 13, ToClient, fixed=1, varStart=9, max=32851): nullBits(bit0=serverAccessToken, + bit1=passwordChallenge); slot@1 → `serverAccessToken` (String, max 8192); slot@5 → + `passwordChallenge` (**byte[], max 64**). → hyproxy constructs `ServerAuthToken(token, null)`. + **Field set matches**, but note field2 is the **passwordChallenge byte[]**, not another token — + hyproxy sends `null` for it (fine; only set when the backend requires a password). +- **ConnectAccept** (id 14, ToClient, fixed=1, varStart=1, max=70): nullBits(bit0=passwordChallenge); + inline (NOT offset-table) optional `passwordChallenge` byte[] (varint-len, max 64) right after + the null byte. → hyproxy `ConnectAccept` deserialize/serialize — **matches exactly.** + (ConnectAccept is part of the DEV flow, sent after InsecurePlayerOptions; not used by the + authenticated proxy path.) + +**Password\* packets (not yet in hyproxy):** +- PasswordResponse (id 15, ToServer): client sends a `hash` byte[] (SHA-256 of challenge+password). +- PasswordAccepted (id 16, ToClient): empty (fixed=0). +- PasswordRejected (id 17, ToClient): new `passwordChallenge` byte[] + `attemptsRemaining` int. + +Net: no auth-packet field renames/removals on the authenticated path. AuthGrant/AuthToken/ +ServerAuthToken are wire-compatible. Only Connect is broken. + ## ClientReferral / transfer +`com.hypixel.hytale.protocol.packets.auth.ClientReferral` (id 18, **ToClient only** — server→client, +fixed=1, varStart=9, max=5141). Offset-table: nullBits(bit0=hostTo, bit1=data) + 2 int32-LE slots: + +| off | field | type | notes | +|-----|---------|------|-------| +| 0 | nullBits | byte | bit0=hostTo present, bit1=data present | +| 1 | offset: hostTo | int32 LE rel to byte 9 | `HostAddress` = int16-LE port + varString host(UTF-8 max 256) | +| 5 | offset: data | int32 LE rel to byte 9 | byte[], max 4096 — the signed referral blob | +| 9+ | var block | + +**Transfer/redirect encoding:** the server tells the client to reconnect elsewhere by sending +ClientReferral with `hostTo` (target host:port) and an opaque signed `data` blob (≤4096 bytes). +The client then opens a fresh connection to `hostTo` and replays that blob back in the **`Connect` +packet's `referralData` field** (with `referralSource` = the address it was referred from). The +new server's `InitialPacketHandler.handle(Connect)` validates: referralData present ⇒ +referralSource must be non-null with a non-empty host, and referralData length ≤ 4096, else it +rejects. So the round-trip is: `ServerB→client ClientReferral{hostTo, data}` then +`client→ServerA(hostTo) Connect{referralData=data, referralSource=...}`. + +→ hyproxy `impl/ClientReferral` is server→client, serialize-only (`deserialize` throws +UnsupportedOperationException), nullBits + 2 LE offset slots [hostTo, data] + var block. **Matches +the current layout exactly.** The signed-blob construction/validation lives in hyproxy's +`SecretMessageUtil` (referral data is validated in `InboundInitialPacketHandler` against the proxy +secret + uuid) — unchanged by this protocol update. No layout change needed for ClientReferral. + ## Handshake sequence +Reconstructed from the server's connection handlers (`InitialPacketHandler`, `HandshakeHandler` / +`AuthenticationPacketHandler`, `PasswordPacketHandler`, `SetupPacketHandler`). + +**Who speaks first: the CLIENT.** After QUIC/TLS is established, the server installs an +`InitialPacketHandler` and *waits*. It does NOT send ServerInfo / ConnectAccept / +RequestInsecurePlayerOptions first. The client must send `Connect` (id 0) as the first application +packet. (`InitialPacketHandler.disconnect` even silently drops the connection if it closes before +any Connect arrives.) **hyproxy's assumption that the client speaks first with Connect is CORRECT.** + +`InitialPacketHandler` accepts only ids {0 Connect, 1 ClientDisconnect, 363 InsecurePlayerOptions}; +anything else → "unexpectedPacket" disconnect. + +### Authenticated flow (what the proxy runs) + +``` +QUIC/TLS established + client → server : Connect (id 0) [client speaks first] + server validates protocolCrc == 0x4E7F3D14 (1316766548) + server: AuthMode == AUTHENTICATED && identityToken present + → hand off to AuthenticationPacketHandler (extends HandshakeHandler) + server validates identityToken JWT (subject/username/scope: hytale:client|hytale:editor) + server → client : AuthGrant (id 11) {authorizationGrant, serverIdentityToken} + client → server : AuthToken (id 12) {accessToken, serverAuthorizationGrant} + server validates access-token JWT (uuid+username must match identity claims); + requires serverAuthorizationGrant (mutual auth) else disconnect; + server exchanges the grant for a serverAccessToken via the session service + server → client : ServerAuthToken (id 13) {serverAccessToken, optional passwordChallenge} + → hand off to PasswordPacketHandler + [if passwordChallenge present:] + client → server : PasswordResponse (id 15){hash} + server → client : PasswordAccepted (16) | PasswordRejected (17){newChallenge, attemptsLeft} + [else / on accept:] → hand off to SetupPacketHandler (world setup begins) +``` + +This is **exactly the flow hyproxy implements** (`InboundInitialPacketHandler` → +`InboundAuthPacketHandler`: Connect → AuthGrant → AuthToken → ServerAuthToken → forwarding). +**The handshake ORDERING does not need to change.** What is broken is purely the **Connect packet +decode** (see Connect layout) and where the proxy sources uuid/username: + +- hyproxy `InboundInitialPacketHandler.handle(Connect)` reads `connect.getUuid()` / + `connect.getUsername()` — these fields don't exist in the current Connect. After fixing + Connect, the proxy must take uuid/username from the JWT identity-token claims (it already + validates them in `InboundAuthPacketHandler.activated()`), NOT from the Connect packet. +- The proxy never enters the dev/offline branch (no RequestInsecurePlayerOptions / + InsecurePlayerOptions / ConnectAccept on the authenticated path), so those packets are not + required for the core join, though decoders for Ping/Pong and Password* may be added for + robustness/password-protected backends. + +### Development / offline flow (NOT used by the proxy, for reference) + +``` + client → server : Connect (id 0) (no identityToken, or AuthMode != AUTHENTICATED) + server → client : RequestInsecurePlayerOptions (id 364) + client → server : InsecurePlayerOptions (id 363){uuid, username, optional skin} + server → client : ConnectAccept (id 14){optional passwordChallenge} + → PasswordPacketHandler (same Password* sub-flow) → SetupPacketHandler +``` +This is where uuid + username live when there is no auth (they were REMOVED from Connect and put +into InsecurePlayerOptions). Confirms the Connect-layout change is intentional, not a decode +artifact. + ## Live byte captures + +(Deferred to Task 4 — raw-byte logging + live handshake capture. Static RE above is sufficient to +drive the Connect-layout fix.) From 526d3f205b1012113b228214992693c049e66735 Mon Sep 17 00:00:00 2001 From: Alexandre Paez Sequeira Date: Wed, 17 Jun 2026 08:30:29 -0300 Subject: [PATCH 04/19] fix(MHG-1132): rewrite Connect to Hytale 0.5.5 layout; source identity from JWT; gated byte logging --- .../java/ac/eva/hyproxy/io/PacketDecoder.java | 7 +++ .../inbound/InboundInitialPacketHandler.java | 48 +++++++++++++++++-- .../OutboundInitialPacketHandler.java | 4 +- .../hyproxy/io/packet/impl/auth/Connect.java | 36 +++++--------- 4 files changed, 63 insertions(+), 32 deletions(-) diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java index 38a6fde..e020d98 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java @@ -36,6 +36,13 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { return; } + if (Boolean.getBoolean("hyproxy.debugBytes")) { + int frameLength = 8 + payloadLength; + log.info("INBOUND frame ({}B):\n{}", frameLength, + io.netty.buffer.ByteBufUtil.prettyHexDump(in, originalReaderIndex, + Math.min(frameLength, 256))); + } + if (packetInfo == null) { out.add(in.copy(originalReaderIndex, 8 + payloadLength)); in.skipBytes(payloadLength); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java index 9e52f7e..a545df2 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java @@ -1,5 +1,6 @@ package ac.eva.hyproxy.io.handler.inbound; +import ac.eva.hyproxy.auth.JWTVerifier; import ac.eva.hyproxy.io.packet.impl.ClientDisconnect; import io.netty.buffer.Unpooled; import lombok.RequiredArgsConstructor; @@ -13,6 +14,7 @@ import ac.eva.hyproxy.player.HyProxyPlayer; import java.util.Locale; +import java.util.UUID; @Slf4j @RequiredArgsConstructor @@ -21,7 +23,43 @@ public class InboundInitialPacketHandler implements HytalePacketHandler { @Override public boolean handle(Connect connect) { - if (connection.getProxy().getPlayerByProfileId(connect.getUuid()) != null) { + // Protocol mismatch gate. The current Hytale 0.5.5 client/server validate the CRC; a + // mismatch means the client is outdated relative to this proxy's protocol. Single, + // clearly-labelled check so it can be disabled trivially if it ever rejects a valid client. + if (connect.getProtocolCrc() != Connect.CURRENT_PROTOCOL_CRC) { + connection.disconnect("Client outdated: protocol mismatch (expected CRC 0x" + + Integer.toHexString(Connect.CURRENT_PROTOCOL_CRC) + ")"); + return true; + } + + // The current Connect packet no longer carries uuid/username on the wire. We source the + // player's identity from the identity-token JWT instead. Online mode is required: no token + // means we cannot identify the player. + String identityToken = connect.getIdentityToken(); + if (identityToken == null) { + connection.disconnect("This proxy only supports online mode players!"); + return true; + } + + JWTVerifier.IdentityTokenClaims claims = connection.getProxy().getJwtVerifier().validateIdentityToken(identityToken); + if (claims == null) { + connection.disconnect("Invalid or expired identity token"); + return true; + } + + UUID profileId = claims.getSubjectAsUUID(); + if (profileId == null) { + connection.disconnect("Invalid identity token: missing or malformed subject"); + return true; + } + + String username = claims.username(); + if (username == null || username.isEmpty()) { + connection.disconnect("Invalid identity token: missing username"); + return true; + } + + if (connection.getProxy().getPlayerByProfileId(profileId) != null) { connection.disconnect("You are already connected to this proxy!"); return true; } @@ -31,9 +69,9 @@ public boolean handle(Connect connect) { player.setProtocolCrc(connect.getProtocolCrc()); player.setProtocolBuildNumber(connect.getProtocolBuildNumber()); player.setClientVersion(connect.getClientVersion()); - player.setProfileId(connect.getUuid()); - player.setUsername(connect.getUsername()); - player.setIdentityToken(connect.getIdentityToken()); + player.setProfileId(profileId); + player.setUsername(username); + player.setIdentityToken(identityToken); player.setLanguage(connect.getLanguage()); player.setClientType(connect.getClientType()); @@ -41,7 +79,7 @@ public boolean handle(Connect connect) { if (referralData != null) { SecretMessageUtil.BackendReferralMessage referralMessage = SecretMessageUtil.validateAndDecodeReferralData( Unpooled.copiedBuffer(referralData), - connect.getUuid(), + profileId, connection.getProxy().getConfiguration().getProxySecret() ); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java index 4716737..cae33da 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java @@ -30,10 +30,8 @@ public void connected() { player.getProtocolBuildNumber(), player.getClientVersion(), player.getClientType(), - player.getProfileId(), - player.getLanguage(), null, - player.getUsername(), + player.getLanguage(), SecretMessageUtil.generatePlayerInfoReferral(new SecretMessageUtil.BackendPlayerInfoMessage( player.getProfileId(), player.getUsername(), diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java index 132cb38..df1d5a4 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java @@ -14,20 +14,23 @@ import ac.eva.hyproxy.common.util.VarIntUtil; import java.nio.charset.StandardCharsets; -import java.util.UUID; @Getter @RequiredArgsConstructor @ToString public class Connect implements Packet { + /** + * The protocol CRC the current Hytale 0.5.5 client/server validate against. + * The server rejects any Connect whose {@code protocolCrc} differs from this value. + */ + public static final int CURRENT_PROTOCOL_CRC = 0x4E7F3D14; + private final int protocolCrc; private final int protocolBuildNumber; private final String clientVersion; private final ClientType clientType; - private final UUID uuid; - private final @Nullable String language; private final @Nullable String identityToken; - private final String username; + private final @Nullable String language; private final byte @Nullable [] referralData; private final @Nullable HostAddress referralSource; @@ -47,9 +50,7 @@ public static Connect deserialize(ByteBuf buf) { String clientVersion = new String(clientVersionBytes, StandardCharsets.US_ASCII); ClientType clientType = ClientType.getById(buf.readByte()); - UUID uuid = ProtocolUtil.readUUID(buf); - int usernameOffset = buf.readIntLE(); int identityTokenOffset = buf.readIntLE(); int languageOffset = buf.readIntLE(); int referralDataOffset = buf.readIntLE(); @@ -58,28 +59,21 @@ public static Connect deserialize(ByteBuf buf) { int readViaOffsets = 0; - int absoluteUsernameOffset = varsOffset + usernameOffset; - Pair varString = ProtocolUtil.readVarString(buf, absoluteUsernameOffset, 16); - String username = varString.left(); - readViaOffsets += varString.right(); - String identityToken = null; if ((nullBits & 0x1) != 0) { int offset = varsOffset + identityTokenOffset; - varString = ProtocolUtil.readVarString(buf, offset, 8192); + Pair varString = ProtocolUtil.readVarString(buf, offset, 8192); identityToken = varString.left(); readViaOffsets += varString.right(); } - String language = null; - + // language is always present (no null bit) int offset = varsOffset + languageOffset; - varString = ProtocolUtil.readVarString(buf, offset, 128); - language = varString.left(); + Pair varString = ProtocolUtil.readVarString(buf, offset, 16); + String language = varString.left(); readViaOffsets += varString.right(); - byte[] referralData = null; if ((nullBits & 0x2) != 0) { @@ -110,7 +104,7 @@ public static Connect deserialize(ByteBuf buf) { buf.readerIndex(varsOffset + readViaOffsets); - return new Connect(protocolCrc, protocolBuildNumber, clientVersion, clientType, uuid, language, identityToken, username, referralData, referralSource); + return new Connect(protocolCrc, protocolBuildNumber, clientVersion, clientType, identityToken, language, referralData, referralSource); } @Override @@ -134,10 +128,7 @@ public void serialize(ByteBuf buf) { buf.writeIntLE(this.protocolBuildNumber); buf.writeBytes(this.clientVersion.getBytes(StandardCharsets.UTF_8)); buf.writeByte(this.clientType.getId()); - ProtocolUtil.writeUUID(buf, this.uuid); - int usernameOffsetSlot = buf.writerIndex(); - buf.writeIntLE(-1); int identityTokenOffsetSlot = buf.writerIndex(); buf.writeIntLE(-1); int languageOffsetSlot = buf.writerIndex(); @@ -149,9 +140,6 @@ public void serialize(ByteBuf buf) { int varsOffset = buf.writerIndex(); - buf.setIntLE(usernameOffsetSlot, buf.writerIndex() - varsOffset); - ProtocolUtil.writeVarString(buf, this.username); - if (this.identityToken != null) { buf.setIntLE(identityTokenOffsetSlot, buf.writerIndex() - varsOffset); ProtocolUtil.writeVarString(buf, this.identityToken); From b6f13406ecd5be414a809153ba0e795807ec72a4 Mon Sep 17 00:00:00 2001 From: Alexandre Paez Sequeira Date: Wed, 17 Jun 2026 08:36:36 -0300 Subject: [PATCH 05/19] fix(MHG-1132): harden Connect serialize (fixed 20B ASCII version, null-safe language) and clientType decode --- .../handler/inbound/InboundInitialPacketHandler.java | 7 +++++++ .../ac/eva/hyproxy/io/packet/impl/auth/Connect.java | 11 +++++++++-- .../main/java/ac/eva/hyproxy/io/proto/ClientType.java | 6 +++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java index a545df2..91ab48a 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java @@ -32,6 +32,13 @@ public boolean handle(Connect connect) { return true; } + // A malformed/unsupported clientType byte decodes to null (ClientType.getById is + // out-of-range-safe). Reject cleanly instead of NPEing downstream. + if (connect.getClientType() == null) { + connection.disconnect("Invalid client type"); + return true; + } + // The current Connect packet no longer carries uuid/username on the wire. We source the // player's identity from the identity-token JWT instead. Online mode is required: no token // means we cannot identify the player. diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java index df1d5a4..dfa5647 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java @@ -126,7 +126,12 @@ public void serialize(ByteBuf buf) { buf.writeByte(nullBits); buf.writeIntLE(this.protocolCrc); buf.writeIntLE(this.protocolBuildNumber); - buf.writeBytes(this.clientVersion.getBytes(StandardCharsets.UTF_8)); + // clientVersion is a fixed 20-byte ASCII field; pad/truncate to exactly 20 bytes so the + // fixed block stays aligned (must match deserialize's US_ASCII 20-byte read). + byte[] clientVersionBytes = new byte[20]; + byte[] clientVersionSrc = this.clientVersion.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(clientVersionSrc, 0, clientVersionBytes, 0, Math.min(clientVersionSrc.length, 20)); + buf.writeBytes(clientVersionBytes); buf.writeByte(this.clientType.getId()); int identityTokenOffsetSlot = buf.writerIndex(); @@ -145,8 +150,10 @@ public void serialize(ByteBuf buf) { ProtocolUtil.writeVarString(buf, this.identityToken); } + // language is always present on the wire (no null bit); write empty rather than risk an + // NPE in writeVarString if a Connect is ever constructed without one. buf.setIntLE(languageOffsetSlot, buf.writerIndex() - varsOffset); - ProtocolUtil.writeVarString(buf, this.language); + ProtocolUtil.writeVarString(buf, this.language != null ? this.language : ""); if (this.referralData != null) { buf.setIntLE(referralDataOffsetSlot, buf.writerIndex() - varsOffset); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/proto/ClientType.java b/proxy/src/main/java/ac/eva/hyproxy/io/proto/ClientType.java index a7082e3..fb2281c 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/proto/ClientType.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/proto/ClientType.java @@ -10,6 +10,10 @@ public byte getId() { } public static ClientType getById(byte id) { - return ClientType.values()[id]; + ClientType[] values = ClientType.values(); + if (id < 0 || id >= values.length) { + return null; + } + return values[id]; } } From 0283da9f3847a907741c78570c477280df907579 Mon Sep 17 00:00:00 2001 From: Alexandre Paez Sequeira Date: Wed, 17 Jun 2026 08:38:34 -0300 Subject: [PATCH 06/19] fix(MHG-1132): adapt backend-plugin to Hytale 0.5.5 API (ChannelConnection, Collection players) --- .../ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java b/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java index a0b057f..312cbaa 100644 --- a/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java +++ b/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java @@ -1,6 +1,7 @@ package ac.eva.hyproxy.plugin; import com.hypixel.hytale.event.EventPriority; +import com.hypixel.hytale.protocol.io.ChannelConnection; import com.hypixel.hytale.protocol.packets.auth.AuthGrant; import com.hypixel.hytale.server.core.HytaleServer; import com.hypixel.hytale.server.core.Message; @@ -95,12 +96,20 @@ private byte[] getProxySecret() { } public void sendProxyMessage(ProxyCommunicationMessage message) { - this.sendProxyMessage(Universe.get().getPlayers().getFirst(), message); + Universe.get().getPlayers().stream().findFirst() + .ifPresent(playerRef -> this.sendProxyMessage(playerRef, message)); } public void sendProxyMessage(PlayerRef playerRef, ProxyCommunicationMessage message) { this.sendProxyMessage(playerRef.getPacketHandler().getChannel(), message); } + public void sendProxyMessage(ChannelConnection channel, ProxyCommunicationMessage message) { + channel.writeAndFlush(new AuthGrant( + null, + ProxyCommunicationUtil.serializeMessage(message) + )); + } + public void sendProxyMessage(Channel channel, ProxyCommunicationMessage message) { channel.writeAndFlush(new AuthGrant( null, From 96b3674d9543203229d93e5e02942daa58f6fb3c Mon Sep 17 00:00:00 2001 From: Alexandre Paez Sequeira Date: Wed, 17 Jun 2026 09:13:33 -0300 Subject: [PATCH 07/19] fix(MHG-1132): drop protocolCrc gate (varies per client build; backend validates) --- .../handler/inbound/InboundInitialPacketHandler.java | 11 +++-------- .../ac/eva/hyproxy/io/packet/impl/auth/Connect.java | 6 ------ 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java index 91ab48a..8173600 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java @@ -23,14 +23,9 @@ public class InboundInitialPacketHandler implements HytalePacketHandler { @Override public boolean handle(Connect connect) { - // Protocol mismatch gate. The current Hytale 0.5.5 client/server validate the CRC; a - // mismatch means the client is outdated relative to this proxy's protocol. Single, - // clearly-labelled check so it can be disabled trivially if it ever rejects a valid client. - if (connect.getProtocolCrc() != Connect.CURRENT_PROTOCOL_CRC) { - connection.disconnect("Client outdated: protocol mismatch (expected CRC 0x" - + Integer.toHexString(Connect.CURRENT_PROTOCOL_CRC) + ")"); - return true; - } + // Note: we intentionally do NOT gate on protocolCrc here. The CRC varies between Hytale + // client builds and the backend server validates it authoritatively; the proxy just + // decodes and forwards (player.protocolCrc is passed through to the backend Connect). // A malformed/unsupported clientType byte decodes to null (ClientType.getById is // out-of-range-safe). Reject cleanly instead of NPEing downstream. diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java index dfa5647..79d6962 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java @@ -19,12 +19,6 @@ @RequiredArgsConstructor @ToString public class Connect implements Packet { - /** - * The protocol CRC the current Hytale 0.5.5 client/server validate against. - * The server rejects any Connect whose {@code protocolCrc} differs from this value. - */ - public static final int CURRENT_PROTOCOL_CRC = 0x4E7F3D14; - private final int protocolCrc; private final int protocolBuildNumber; private final String clientVersion; From f8f668c481a4c51079db62ae6a6155c650603725 Mon Sep 17 00:00:00 2001 From: Alexandre Paez Sequeira Date: Wed, 17 Jun 2026 10:33:17 -0300 Subject: [PATCH 08/19] fix(MHG-1132): source uuid from identity token, username from access token; defer player registration --- proxy/src/main/java/ac/eva/hyproxy/HyProxy.java | 8 ++++++-- .../inbound/InboundAuthPacketHandler.java | 11 +++++++---- .../inbound/InboundInitialPacketHandler.java | 16 +++++++--------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java b/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java index b8c4a2f..33c5bea 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java +++ b/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java @@ -438,11 +438,15 @@ public void registerPlayer(HyProxyPlayer player) { * internal: please don't call this! */ public void unregisterPlayer(HyProxyPlayer player) { + // Idempotent: a connection can drop mid-auth before the player is registered, because + // registration is deferred until the username is known from the access token. if (this.getPlayerByProfileId(player.getProfileId(), true) == null) { - throw new IllegalArgumentException("player profile id " + player.getProfileId() + " not registered"); + return; } this.playersByProfileId.remove(player.getProfileId()); - this.playersByUsername.remove(player.getUsername().toLowerCase(Locale.ROOT)); + if (player.getUsername() != null) { + this.playersByUsername.remove(player.getUsername().toLowerCase(Locale.ROOT)); + } } public String getServerCertFingerprint() { diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java index e7cfde7..e171ac4 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java @@ -103,10 +103,9 @@ public boolean handle(AuthToken authToken) { return true; } - if (!tokenUsername.equals(player.getUsername())) { - connection.disconnect("Invalid token claims: username mismatch"); - return true; - } + // The access token is the source of truth for the username — it is absent from both the + // Connect packet and the identity token, so set it on the player here. + player.setUsername(tokenUsername); String serverAuthGrant = authToken.getServerAuthorizationGrant(); @@ -146,6 +145,10 @@ private void onAuthenticated(String serverAccessToken) { return; } + // Register now that the username is known (set from the access token in handle(AuthToken)). + // Registration is deferred from Connect handling because the username is unavailable there. + connection.getProxy().registerPlayer(player); + PlayerAuthSuccessEvent event = connection.getProxy().getEventBus().fire(new PlayerAuthSuccessEvent( player, false diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java index 8173600..225cc6d 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java @@ -30,6 +30,7 @@ public boolean handle(Connect connect) { // A malformed/unsupported clientType byte decodes to null (ClientType.getById is // out-of-range-safe). Reject cleanly instead of NPEing downstream. if (connect.getClientType() == null) { + log.warn("rejecting Connect: invalid client type"); connection.disconnect("Invalid client type"); return true; } @@ -39,27 +40,27 @@ public boolean handle(Connect connect) { // means we cannot identify the player. String identityToken = connect.getIdentityToken(); if (identityToken == null) { + log.warn("rejecting Connect: no identity token (offline mode not supported)"); connection.disconnect("This proxy only supports online mode players!"); return true; } JWTVerifier.IdentityTokenClaims claims = connection.getProxy().getJwtVerifier().validateIdentityToken(identityToken); if (claims == null) { + log.warn("rejecting Connect: identity token failed validation (validateIdentityToken returned null)"); connection.disconnect("Invalid or expired identity token"); return true; } UUID profileId = claims.getSubjectAsUUID(); if (profileId == null) { + log.warn("rejecting Connect: identity token missing/malformed subject"); connection.disconnect("Invalid identity token: missing or malformed subject"); return true; } - - String username = claims.username(); - if (username == null || username.isEmpty()) { - connection.disconnect("Invalid identity token: missing username"); - return true; - } + // The username is NOT in the Connect packet nor the identity token; it arrives in the + // access token (AuthToken), where it is set on the player. Proxy registration is therefore + // deferred until then (see InboundAuthPacketHandler.onAuthenticated). if (connection.getProxy().getPlayerByProfileId(profileId) != null) { connection.disconnect("You are already connected to this proxy!"); @@ -72,7 +73,6 @@ public boolean handle(Connect connect) { player.setProtocolBuildNumber(connect.getProtocolBuildNumber()); player.setClientVersion(connect.getClientVersion()); player.setProfileId(profileId); - player.setUsername(username); player.setIdentityToken(identityToken); player.setLanguage(connect.getLanguage()); player.setClientType(connect.getClientType()); @@ -120,8 +120,6 @@ public boolean handle(Connect connect) { connection.setPlayer(player); - connection.getProxy().registerPlayer(player); - log.info("authenticating player {}", this.connection.getIdentifier()); connection.setPacketHandler(new InboundAuthPacketHandler(this.connection)); return true; From 7b07003b884b0f09df8e19771997179def04d63b Mon Sep 17 00:00:00 2001 From: Alexandre Paez Sequeira Date: Wed, 17 Jun 2026 10:33:17 -0300 Subject: [PATCH 09/19] feat(MHG-1132): implement proxy->backend insecure-options handshake; forward undecodable packets raw --- .../eva/hyproxy/io/HytalePacketHandler.java | 6 +++ .../java/ac/eva/hyproxy/io/PacketDecoder.java | 6 +++ .../OutboundInitialPacketHandler.java | 12 +++++ .../eva/hyproxy/io/packet/PacketRegistry.java | 2 + .../impl/auth/InsecurePlayerOptions.java | 54 +++++++++++++++++++ .../auth/RequestInsecurePlayerOptions.java | 26 +++++++++ 6 files changed, 106 insertions(+) create mode 100644 proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java create mode 100644 proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/RequestInsecurePlayerOptions.java diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/HytalePacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/HytalePacketHandler.java index 932e07d..8f5ba63 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/HytalePacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/HytalePacketHandler.java @@ -38,6 +38,12 @@ default boolean handle(ServerAuthToken serverAuthToken) { default boolean handle(ConnectAccept connectAccept) { return false; } + default boolean handle(RequestInsecurePlayerOptions requestInsecurePlayerOptions) { + return false; + } + default boolean handle(InsecurePlayerOptions insecurePlayerOptions) { + return false; + } default boolean handle(ClientReferral referral) { return false; } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java index e020d98..5970038 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java @@ -53,6 +53,12 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { try { Packet packet = packetInfo.deserializeFunction().apply(payload); out.add(packet); + } catch (Exception e) { + // A stale/unsupported packet layout must not kill the connection. The proxy only needs + // to *decode* handshake packets; everything else can be relayed opaquely. Forward the + // raw frame (same path as an unregistered packet) instead of throwing. + log.warn("failed to decode packet id {} ({}); forwarding raw frame", packetId, e.toString()); + out.add(in.copy(originalReaderIndex, 8 + payloadLength)); } finally { payload.release(); } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java index cae33da..f97cfba 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java @@ -9,6 +9,8 @@ import ac.eva.hyproxy.io.HytalePacketHandler; import ac.eva.hyproxy.io.packet.impl.auth.Connect; import ac.eva.hyproxy.io.packet.impl.auth.ConnectAccept; +import ac.eva.hyproxy.io.packet.impl.auth.InsecurePlayerOptions; +import ac.eva.hyproxy.io.packet.impl.auth.RequestInsecurePlayerOptions; import ac.eva.hyproxy.player.HyProxyPlayer; import ac.eva.hyproxy.util.NettyUtil; @@ -43,6 +45,16 @@ public void connected() { )); } + @Override + public boolean handle(RequestInsecurePlayerOptions request) { + // Insecure-mode backend asks for the player's identity (no longer carried in Connect). Send + // it on the player's behalf; the hyproxy-backend mod authorizes the connection via the + // signed referral we already included in Connect. + HyProxyPlayer player = connection.ensurePlayer(); + connection.send(new InsecurePlayerOptions(player.getProfileId(), player.getUsername())); + return true; + } + @Override public boolean handle(ConnectAccept connectAccept) { log.info("starting forwarding for {} to backend {}", connection.getIdentifier(), backend.getInfo().id()); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/PacketRegistry.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/PacketRegistry.java index d61e9cc..f5b4777 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/PacketRegistry.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/PacketRegistry.java @@ -27,6 +27,8 @@ public class PacketRegistry { register(new PacketInfo(13, ServerAuthToken.class, ServerAuthToken::deserialize)); register(new PacketInfo(14, ConnectAccept.class, ConnectAccept::deserialize)); register(new PacketInfo(18, ClientReferral.class, ClientReferral::deserialize)); + register(new PacketInfo(363, InsecurePlayerOptions.class, InsecurePlayerOptions::deserialize)); + register(new PacketInfo(364, RequestInsecurePlayerOptions.class, RequestInsecurePlayerOptions::deserialize)); register(new PacketInfo(210, ServerMessage.class, ServerMessage::deserialize)); register(new PacketInfo(211, ChatMessage.class, ChatMessage::deserialize)); register(new PacketInfo(223, ServerInfo.class, ServerInfo::deserialize)); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java new file mode 100644 index 0000000..a2cbc01 --- /dev/null +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java @@ -0,0 +1,54 @@ +package ac.eva.hyproxy.io.packet.impl.auth; + +import io.netty.buffer.ByteBuf; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import ac.eva.hyproxy.io.HytalePacketHandler; +import ac.eva.hyproxy.io.packet.Packet; +import ac.eva.hyproxy.common.util.ProtocolUtil; + +import java.util.UUID; + +/** + * Client -> server (id 363). In insecure mode the player's identity is carried here rather than in + * Connect. The proxy sends this on the player's behalf to a backend in response to + * {@link RequestInsecurePlayerOptions}. Skin is omitted (proxy backends authenticate via the signed + * referral handled by the hyproxy-backend plugin). + * + * Wire layout (little-endian): nullBits(1, bit0=skin present), uuid(16), then 2 int32-LE offset + * slots [username, skin] relative to the variable block at byte 25. + */ +@Getter +@RequiredArgsConstructor +@ToString +public class InsecurePlayerOptions implements Packet { + private final UUID uuid; + private final String username; + + public static InsecurePlayerOptions deserialize(ByteBuf buf) { + // The proxy only ever sends this packet; it does not receive it. + throw new UnsupportedOperationException(); + } + + @Override + public boolean handle(HytalePacketHandler handler) { + return handler.handle(this); + } + + @Override + public void serialize(ByteBuf buf) { + buf.writeByte(0); // nullBits: no skin + ProtocolUtil.writeUUID(buf, this.uuid); + + int usernameOffsetSlot = buf.writerIndex(); + buf.writeIntLE(-1); + int skinOffsetSlot = buf.writerIndex(); + buf.writeIntLE(-1); // skin absent + + int varsOffset = buf.writerIndex(); + + buf.setIntLE(usernameOffsetSlot, buf.writerIndex() - varsOffset); + ProtocolUtil.writeVarString(buf, this.username); + } +} diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/RequestInsecurePlayerOptions.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/RequestInsecurePlayerOptions.java new file mode 100644 index 0000000..d222837 --- /dev/null +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/RequestInsecurePlayerOptions.java @@ -0,0 +1,26 @@ +package ac.eva.hyproxy.io.packet.impl.auth; + +import io.netty.buffer.ByteBuf; +import ac.eva.hyproxy.io.HytalePacketHandler; +import ac.eva.hyproxy.io.packet.Packet; + +/** + * Server -> client (id 364). Sent by an insecure-mode backend after the proxy's Connect to ask the + * proxy for the player's {@link InsecurePlayerOptions} (uuid/username). Empty payload. + */ +public class RequestInsecurePlayerOptions implements Packet { + + public static RequestInsecurePlayerOptions deserialize(ByteBuf buf) { + return new RequestInsecurePlayerOptions(); + } + + @Override + public boolean handle(HytalePacketHandler handler) { + return handler.handle(this); + } + + @Override + public void serialize(ByteBuf buf) { + // empty payload + } +} From 3c9f8fb6bcf1e6e78e489113c2512d617654dde5 Mon Sep 17 00:00:00 2001 From: Alexandre Paez Sequeira Date: Wed, 17 Jun 2026 10:33:17 -0300 Subject: [PATCH 10/19] fix(MHG-1132): backend plugin derives backend name from SERVER_ID env --- .../ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java b/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java index 312cbaa..fbe5630 100644 --- a/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java +++ b/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java @@ -62,7 +62,7 @@ private void onPlayerSetupConnect(PlayerSetupConnectEvent event) { buf, event.getUuid(), event.getUsername(), - this.config.get().getBackendName(), + getBackendName(), secret ); @@ -79,6 +79,17 @@ private void onPlayerSetupConnect(PlayerSetupConnectEvent event) { } } + /** + * The backend id the proxy signs referrals with is this server's id. The proxy uses + * {@code backend.getInfo().id()} (the id registered with the Hytale API), which in our + * deployment is the {@code SERVER_ID} env (the pod name). Prefer that env over the config + * default ("main") so a backend doesn't need a hand-written config.json per deployment. + */ + private String getBackendName() { + String serverId = System.getenv("SERVER_ID"); + return serverId != null && !serverId.isEmpty() ? serverId : config.get().getBackendName(); + } + private byte[] getProxySecret() { byte[] proxySecret = System.getenv("HYPROXY_SECRET") != null ? System.getenv("HYPROXY_SECRET").getBytes(StandardCharsets.UTF_8) : null; From d667675b7b61cb423ae8c0c0697b224753bf0d2b Mon Sep 17 00:00:00 2001 From: SantioMC Date: Mon, 22 Jun 2026 17:39:42 -0400 Subject: [PATCH 11/19] misc: cleanup & rebase properly --- .gitignore | 2 -- .../ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java | 13 +------------ proxy/src/main/java/ac/eva/hyproxy/HyProxy.java | 3 +-- .../main/java/ac/eva/hyproxy/io/PacketDecoder.java | 8 +++----- .../handler/inbound/InboundAuthPacketHandler.java | 6 ------ .../inbound/InboundInitialPacketHandler.java | 8 +------- .../outbound/OutboundInitialPacketHandler.java | 3 --- .../ac/eva/hyproxy/io/packet/impl/auth/Connect.java | 4 +--- .../io/packet/impl/auth/InsecurePlayerOptions.java | 1 - 9 files changed, 7 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index df1a768..bed6ff2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,5 +39,3 @@ bin/ ### Mac OS ### .DS_Store -# Local-only licensed Hytale server build (RE + backend compile) -backend-plugin/libs/HytaleServer.jar diff --git a/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java b/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java index 3c1c51d..3905643 100644 --- a/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java +++ b/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java @@ -76,18 +76,7 @@ private void onPlayerSetupConnect(PlayerSetupConnectEvent event) { event.setReason(Message.raw("internal error while verifying player information")); } } - - /** - * The backend id the proxy signs referrals with is this server's id. The proxy uses - * {@code backend.getInfo().id()} (the id registered with the Hytale API), which in our - * deployment is the {@code SERVER_ID} env (the pod name). Prefer that env over the config - * default ("main") so a backend doesn't need a hand-written config.json per deployment. - */ - private String getBackendName() { - String serverId = System.getenv("SERVER_ID"); - return serverId != null && !serverId.isEmpty() ? serverId : config.get().getBackendName(); - } - + private byte[] getProxySecret() { byte[] proxySecret = System.getenv("HYPROXY_SECRET") != null ? System.getenv("HYPROXY_SECRET").getBytes(StandardCharsets.UTF_8) : null; diff --git a/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java b/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java index 33c5bea..f1f653b 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java +++ b/proxy/src/main/java/ac/eva/hyproxy/HyProxy.java @@ -438,11 +438,10 @@ public void registerPlayer(HyProxyPlayer player) { * internal: please don't call this! */ public void unregisterPlayer(HyProxyPlayer player) { - // Idempotent: a connection can drop mid-auth before the player is registered, because - // registration is deferred until the username is known from the access token. if (this.getPlayerByProfileId(player.getProfileId(), true) == null) { return; } + this.playersByProfileId.remove(player.getProfileId()); if (player.getUsername() != null) { this.playersByUsername.remove(player.getUsername().toLowerCase(Locale.ROOT)); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java index 5970038..8e0b2f8 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java @@ -4,6 +4,7 @@ import ac.eva.hyproxy.io.packet.Packet; import ac.eva.hyproxy.io.packet.PacketRegistry; import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.ByteToMessageDecoder; import lombok.extern.slf4j.Slf4j; @@ -36,10 +37,10 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { return; } - if (Boolean.getBoolean("hyproxy.debugBytes")) { + if (System.getProperty("hyproxy.debugBytes").equals("true")) { int frameLength = 8 + payloadLength; log.info("INBOUND frame ({}B):\n{}", frameLength, - io.netty.buffer.ByteBufUtil.prettyHexDump(in, originalReaderIndex, + ByteBufUtil.prettyHexDump(in, originalReaderIndex, Math.min(frameLength, 256))); } @@ -54,9 +55,6 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { Packet packet = packetInfo.deserializeFunction().apply(payload); out.add(packet); } catch (Exception e) { - // A stale/unsupported packet layout must not kill the connection. The proxy only needs - // to *decode* handshake packets; everything else can be relayed opaquely. Forward the - // raw frame (same path as an unregistered packet) instead of throwing. log.warn("failed to decode packet id {} ({}); forwarding raw frame", packetId, e.toString()); out.add(in.copy(originalReaderIndex, 8 + payloadLength)); } finally { diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java index e171ac4..7bb906f 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundAuthPacketHandler.java @@ -103,10 +103,7 @@ public boolean handle(AuthToken authToken) { return true; } - // The access token is the source of truth for the username — it is absent from both the - // Connect packet and the identity token, so set it on the player here. player.setUsername(tokenUsername); - String serverAuthGrant = authToken.getServerAuthorizationGrant(); if (serverAuthGrant == null || serverAuthGrant.isEmpty()) { @@ -145,10 +142,7 @@ private void onAuthenticated(String serverAccessToken) { return; } - // Register now that the username is known (set from the access token in handle(AuthToken)). - // Registration is deferred from Connect handling because the username is unavailable there. connection.getProxy().registerPlayer(player); - PlayerAuthSuccessEvent event = connection.getProxy().getEventBus().fire(new PlayerAuthSuccessEvent( player, false diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java index 225cc6d..b2bf6d8 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java @@ -35,9 +35,6 @@ public boolean handle(Connect connect) { return true; } - // The current Connect packet no longer carries uuid/username on the wire. We source the - // player's identity from the identity-token JWT instead. Online mode is required: no token - // means we cannot identify the player. String identityToken = connect.getIdentityToken(); if (identityToken == null) { log.warn("rejecting Connect: no identity token (offline mode not supported)"); @@ -58,10 +55,7 @@ public boolean handle(Connect connect) { connection.disconnect("Invalid identity token: missing or malformed subject"); return true; } - // The username is NOT in the Connect packet nor the identity token; it arrives in the - // access token (AuthToken), where it is set on the player. Proxy registration is therefore - // deferred until then (see InboundAuthPacketHandler.onAuthenticated). - + if (connection.getProxy().getPlayerByProfileId(profileId) != null) { connection.disconnect("You are already connected to this proxy!"); return true; diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java index f97cfba..9261d46 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java @@ -47,9 +47,6 @@ public void connected() { @Override public boolean handle(RequestInsecurePlayerOptions request) { - // Insecure-mode backend asks for the player's identity (no longer carried in Connect). Send - // it on the player's behalf; the hyproxy-backend mod authorizes the connection via the - // signed referral we already included in Connect. HyProxyPlayer player = connection.ensurePlayer(); connection.send(new InsecurePlayerOptions(player.getProfileId(), player.getUsername())); return true; diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java index 79d6962..1cc911c 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java @@ -62,7 +62,6 @@ public static Connect deserialize(ByteBuf buf) { readViaOffsets += varString.right(); } - // language is always present (no null bit) int offset = varsOffset + languageOffset; Pair varString = ProtocolUtil.readVarString(buf, offset, 16); String language = varString.left(); @@ -120,6 +119,7 @@ public void serialize(ByteBuf buf) { buf.writeByte(nullBits); buf.writeIntLE(this.protocolCrc); buf.writeIntLE(this.protocolBuildNumber); + // clientVersion is a fixed 20-byte ASCII field; pad/truncate to exactly 20 bytes so the // fixed block stays aligned (must match deserialize's US_ASCII 20-byte read). byte[] clientVersionBytes = new byte[20]; @@ -144,8 +144,6 @@ public void serialize(ByteBuf buf) { ProtocolUtil.writeVarString(buf, this.identityToken); } - // language is always present on the wire (no null bit); write empty rather than risk an - // NPE in writeVarString if a Connect is ever constructed without one. buf.setIntLE(languageOffsetSlot, buf.writerIndex() - varsOffset); ProtocolUtil.writeVarString(buf, this.language != null ? this.language : ""); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java index a2cbc01..0edf839 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java @@ -27,7 +27,6 @@ public class InsecurePlayerOptions implements Packet { private final String username; public static InsecurePlayerOptions deserialize(ByteBuf buf) { - // The proxy only ever sends this packet; it does not receive it. throw new UnsupportedOperationException(); } From 66268f0770bde4aa141c5884c28dbc3f7a6d524f Mon Sep 17 00:00:00 2001 From: SantioMC Date: Mon, 22 Jun 2026 17:40:26 -0400 Subject: [PATCH 12/19] misc: remove ai documentation --- RE-NOTES.md | 311 ---------------------------------------------------- 1 file changed, 311 deletions(-) delete mode 100644 RE-NOTES.md diff --git a/RE-NOTES.md b/RE-NOTES.md deleted file mode 100644 index 259403d..0000000 --- a/RE-NOTES.md +++ /dev/null @@ -1,311 +0,0 @@ -# MHG-1132 Hytale protocol RE notes - -## Fork base - -**Chosen base:** `SantioMC/hyproxy` @ tag `1.3` = commit `20975557532e456b113e61403ed62a2abd3fc3b6` -(this is the current HEAD of `SantioMC/hyproxy` `main`; tag `1.3` points at the same commit). - -**Reasoning:** -- The deployed proxy engine (`services/repositories/hytale-proxy/server.jar`) and the lobby's - backend plugin (`services/repositories/hytale-image/runtime/mods/hyproxy-backend-1.3.jar`) both - ship the `ac.eva.hyproxy` package (e.g. `ac.eva.hyproxy.Main`, `ac.eva.hyproxy.HyProxy`, - `ac.eva.hyproxy.plugin.HyProxyBackendPlugin`), matching this codebase's `group = "ac.eva"`. -- The backend jar is literally named `hyproxy-backend-1.3.jar`, and `build.gradle.kts` declares - `version = "1.3"`. The deployed artifacts therefore correspond to the `1.3` release. -- `SantioMC/hyproxy` is the actively maintained fork (last push 2026-06-01) and its `upstream` - remote is `xyzeva/hyproxy` (frozen 2026-04-21). We fork the live fork (Santio) so we inherit - its more recent state while still being able to diff against xyzeva upstream if needed. - -**gh fork outcome:** -- `gh repo fork SantioMC/hyproxy --org Minehut --fork-name hyproxy --clone=false` succeeded. -- Verified: `Minehut/hyproxy` exists, `isFork: true`, parent `SantioMC/hyproxy`, default branch `main`. -- `Minehut/hyproxy` did not previously exist; the actor is an active admin of the `Minehut` org. -- Cloned into `services/repositories/hyproxy`; working branch: - `alexandresequeira/mhg-1132-update-protocol` (HEAD `2097555`). - -## Build environment - -The local host has no JDK (only JRE 21, no `javac`); the proxy needs JDK 25. Build inside a -container instead of changing the host. **Recipe used for all `./gradlew` builds in this ticket:** - -```bash -cd services/repositories/hyproxy -docker run --rm -v "$PWD":/work -w /work \ - -v hyproxy-gradle-cache:/root/.gradle \ - eclipse-temurin:25-jdk bash -lc 'sh ./gradlew --no-daemon ' -``` - -(`gradlew` may have lost its +x bit in git; invoke it with `sh ./gradlew`. Build outputs under -`build/` end up root-owned because the container runs as root — they are gitignored, so this is -cosmetic; `chown` if a host tool needs to read them.) - -**Baseline build verified:** `:proxy:build -x test` → BUILD SUCCESSFUL. The shadow jar -`proxy/build/libs/hyproxy-1.3.jar` is 61,198,355 B vs the deployed -`hytale-proxy/server.jar` 61,198,368 B — a ~13-byte (manifest/timestamp) difference, confirming -the fork base reproduces the deployed engine. - -## Decompile setup (Task 2) - -- **Server jar:** `/tmp/HytaleServer.jar`, Hytale server build **0.5.5**, NOT obfuscated, - packages under `com.hypixel.hytale.*`. Class file timestamps are `2026-06-16`. -- Jar placed at `backend-plugin/libs/HytaleServer.jar` and gitignored (licensed code, never - committed). Verified `git check-ignore` matches and it is not staged. -- **Decompiler used:** **Vineflower 1.12.0** (`vineflower-1.12.0.jar`). The - `releases/latest/download/vineflower.jar` asset 404s; pulled `vineflower-1.12.0.jar` from the - release API instead. Output is clean and complete; CFR fallback was not needed. - Vineflower was run via the `eclipse-temurin:25-jdk` container (host has no JDK 25; class files - are Java 25 bytecode). -- Only the protocol + connection-handler classes were extracted (not the full 123 MB jar): - `com/hypixel/hytale/protocol/*`, `.../server/core/io/handlers/*`, - `.../server/core/io/netty/*`, `.../server/core/io/PacketHandler*`. - -### backend-plugin compile result: **FAIL** (valuable finding — API drift) - -`:backend-plugin:build -x test` → `BUILD FAILED`, `:compileJava` 2 errors. The Hytale -`com.hypixel.hytale.*` server API the backend plugin was written against has moved: - -``` -HyProxyBackendPlugin.java:101: error: no suitable method found for - sendProxyMessage(ChannelConnection,ProxyCommunicationMessage) - this.sendProxyMessage(playerRef.getPacketHandler().getChannel(), message); - method ...sendProxyMessage(PlayerRef,ProxyCommunicationMessage) is not applicable - (ChannelConnection cannot be converted to PlayerRef) - method ...sendProxyMessage(Channel,ProxyCommunicationMessage) is not applicable - (ChannelConnection cannot be converted to Channel) - -HyProxyBackendPlugin.java:98: error: cannot find symbol - this.sendProxyMessage(Universe.get().getPlayers().getFirst(), message); - symbol: method getFirst() - location: interface Collection -``` - -Two concrete API changes in 0.5.5 vs the build hyproxy targeted: -1. `PlayerRef.getPacketHandler().getChannel()` now returns a `ChannelConnection`, not a - `Channel`/`PlayerRef`. The backend's `sendProxyMessage(...)` overloads accept `PlayerRef` or - `Channel` but not `ChannelConnection` — the channel type changed. -2. `Universe.get().getPlayers()` now returns a bare `Collection` (no longer a - `List`/`SequencedCollection`), so `.getFirst()` no longer exists. - -These are deferred to the backend-plugin fix task (do not fix here). The proxy module -(`:proxy:build`) is unaffected by these and still builds. - -## Packet IDs (current vs hyproxy) - -Source of truth: `com.hypixel.hytale.protocol.PacketRegistry` (server jar). IDs are assigned in -a single static-init table via `register(direction, channel, id, name, type, fixedBlockSize, -maxSize, compressed, validate, deserialize, toObject)`. There is no annotation/`getId()`-derived -scheme — the central table is authoritative; each packet class also exposes a matching -`PACKET_ID` constant and `getId()`. Direction is `ToServer` / `ToClient` / `Both`. - -Handshake-relevant packets: - -| Packet | Server id | Dir | hyproxy id | Match? | -|--------------------------------|-----------|-----------|------------|--------| -| Connect | 0 | ToServer | 0 | ✅ (id only — layout differs, see below) | -| ClientDisconnect | 1 | ToServer | 1 | ✅ | -| ServerDisconnect | 2 | ToClient | 2 | ✅ | -| Ping | 3 | ToClient | — missing | ⚠️ | -| Pong | 4 | ToServer | — missing | ⚠️ | -| AuthGrant | 11 | ToClient | 11 | ✅ | -| AuthToken | 12 | ToServer | 12 | ✅ | -| ServerAuthToken | 13 | ToClient | 13 | ✅ | -| ConnectAccept | 14 | ToClient | 14 | ✅ | -| PasswordResponse | 15 | ToServer | — missing | ⚠️ | -| PasswordAccepted | 16 | ToClient | — missing | ⚠️ | -| PasswordRejected | 17 | ToClient | — missing | ⚠️ | -| ClientReferral | 18 | ToClient | 18 | ✅ | -| ServerMessage | 210 | ToClient | 210 | ✅ | -| ChatMessage | 211 | ToServer | 211 | ✅ | -| ServerInfo | 223 | ToClient | 223 | ✅ | -| InsecurePlayerOptions | 363 | ToServer | — missing | ⚠️ | -| RequestInsecurePlayerOptions | 364 | ToClient | — missing | ⚠️ | - -**All ids hyproxy registers still match the server.** No id renumbering. The gaps are *missing* -packets, not wrong ids: -- **Ping (3) / Pong (4):** keepalive after connect; not strictly part of the auth handshake but - the client may send Pong; hyproxy has no decoder (will hit "unexpected packet"). Low priority. -- **InsecurePlayerOptions (363) / RequestInsecurePlayerOptions (364):** these belong to the - DEVELOPMENT/OFFLINE (unauthenticated) flow only. The proxy runs an AUTHENTICATED flow, so it - does NOT need these — but note the client's username+uuid now live HERE in dev mode, not in - Connect (see Connect layout). Relevant only if we ever support offline mode. -- **Password* (15/16/17):** password-protected-server sub-flow that runs AFTER auth, before - setup. Only used if the backend has a server password. Optional for the proxy, but the client - CAN send PasswordResponse, so a decoder may be needed for password-protected backends. - -## Connect layout - -`com.hypixel.hytale.protocol.packets.connection.Connect` (id 0, ToServer, NOT compressed, -FIXED_BLOCK_SIZE=30, VARIABLE_BLOCK_START=46, MAX_SIZE=37972). Little-endian throughout. -Offset-table layout: a 1-byte null-bitfield, a fixed block, then **4** int32-LE offset slots, -then the variable block at byte 46. Each offset is relative to byte 46 (varBlockStart); -`-1`/out-of-range means absent. - -Wire layout (offsets are from start of packet body): - -| off | size | field | notes | -|-----|------|------------------|-------| -| 0 | 1 | nullBits | bit0=identityToken present, bit1=referralData present, bit2=referralSource present | -| 1 | 4 | protocolCrc | int32 LE. **Server validates `protocolCrc == 1316766548` (0x4E7F3D14)**; mismatch → QUIC app-close ClientOutdated/ServerOutdated. | -| 5 | 4 | protocolBuildNumber | int32 LE. Server compares against `serverBuild = 100` only to pick the outdated-direction error message; the CRC check is the real gate. | -| 9 | 20 | clientVersion | fixed 20-byte ASCII string (space/null padded), read via `readFixedAsciiString(buf, 9, 20)`. | -| 29 | 1 | clientType | enum byte: 0=Game, 1=Editor (`ClientType.fromValue`; value ≥ 2 is invalid). | -| 30 | 4 | offset: identityToken | int32 LE, rel to byte 46. Only read if nullBits bit0 set. String, max 8192, UTF-8. | -| 34 | 4 | offset: language | int32 LE, rel to byte 46. ALWAYS present (no null bit). ASCII, max 16. | -| 38 | 4 | offset: referralData | int32 LE. Only if bit1 set. byte[], max 4096. | -| 42 | 4 | offset: referralSource | int32 LE. Only if bit2 set. `HostAddress`. | -| 46+ | var | variable block | varint-len-prefixed payloads at the above offsets. | - -`HostAddress` = `int16-LE port` + `varString host` (UTF-8, max 256). (FIXED_BLOCK_SIZE=2.) - -### Diff vs hyproxy `io/packet/impl/auth/Connect.java` — **THIS IS THE CORE BUG** - -hyproxy's `Connect` is built for an OLDER protocol and is structurally wrong: - -1. **hyproxy has a `UUID uuid` field and a `String username` field in Connect — the current - Connect has NEITHER.** hyproxy reads a UUID (16 bytes) immediately after `clientType` (at - byte 30), then reads **5** offset slots: `username, identityToken, language, referralData, - referralSource`. The real Connect reads **4** offset slots starting at byte 30: - `identityToken, language, referralData, referralSource`, with the variable block at byte 46. -2. Because of the extra UUID + extra username offset slot, **every field after `clientType` is - misaligned**: hyproxy reads 16 bytes of UUID + a username offset where the server put the - identityToken offset, the language offset, etc. The fixed block is 30 in both, but hyproxy's - var-block start is `30 + 16(uuid) + 5*4 = 66`, while the server's is `46`. This is why a - current client completes QUIC/TLS but the proxy fails to decode the first packet. -3. **username/uuid moved.** In the current protocol the client's username + uuid are no longer in - Connect at all. In AUTHENTICATED mode they come from the validated JWT identity/access-token - claims (see Handshake). In DEVELOPMENT/OFFLINE mode they arrive later in - `InsecurePlayerOptions` (id 363: uuid@1, username offset@17, optional skin). -4. **Fix direction:** rewrite hyproxy `Connect` to: nullBits, protocolCrc, protocolBuildNumber, - clientVersion(20 ASCII), clientType(byte), then 4 LE offset slots (identityToken, language, - referralData, referralSource), var block at 46. Drop `uuid`/`username` from the packet; obtain - them from JWT claims in the auth handler (the auth handler ALREADY derives uuid/username from - claims — `InboundInitialPacketHandler` currently mis-sources them from `connect.getUuid()` / - `connect.getUsername()`, which must change). Also validate `protocolCrc == 0x4E7F3D14`. - hyproxy currently also reads `language` with max 128; server max is 16 (cosmetic). - -## Auth packets - -All four auth packets use the same offset-table scheme (nullBits + N int32-LE slots + var block). -Current server layouts: - -- **AuthGrant** (id 11, ToClient, fixed=1, varStart=9, max=49171): nullBits(bit0=authorizationGrant, - bit1=serverIdentityToken); slot@1 → `authorizationGrant` (String, max 4096); slot@5 → - `serverIdentityToken` (String, max 8192). Both UTF-8, both nullable. - → hyproxy `AuthGrant(grant, serverIdentityToken)` — **matches.** -- **AuthToken** (id 12, ToServer, fixed=1, varStart=9, max=49171): nullBits(bit0=accessToken, - bit1=serverAuthorizationGrant); slot@1 → `accessToken` (String, max 8192); slot@5 → - `serverAuthorizationGrant` (String, max 4096). → hyproxy reads `accessToken` + - `serverAuthorizationGrant` — **matches.** -- **ServerAuthToken** (id 13, ToClient, fixed=1, varStart=9, max=32851): nullBits(bit0=serverAccessToken, - bit1=passwordChallenge); slot@1 → `serverAccessToken` (String, max 8192); slot@5 → - `passwordChallenge` (**byte[], max 64**). → hyproxy constructs `ServerAuthToken(token, null)`. - **Field set matches**, but note field2 is the **passwordChallenge byte[]**, not another token — - hyproxy sends `null` for it (fine; only set when the backend requires a password). -- **ConnectAccept** (id 14, ToClient, fixed=1, varStart=1, max=70): nullBits(bit0=passwordChallenge); - inline (NOT offset-table) optional `passwordChallenge` byte[] (varint-len, max 64) right after - the null byte. → hyproxy `ConnectAccept` deserialize/serialize — **matches exactly.** - (ConnectAccept is part of the DEV flow, sent after InsecurePlayerOptions; not used by the - authenticated proxy path.) - -**Password\* packets (not yet in hyproxy):** -- PasswordResponse (id 15, ToServer): client sends a `hash` byte[] (SHA-256 of challenge+password). -- PasswordAccepted (id 16, ToClient): empty (fixed=0). -- PasswordRejected (id 17, ToClient): new `passwordChallenge` byte[] + `attemptsRemaining` int. - -Net: no auth-packet field renames/removals on the authenticated path. AuthGrant/AuthToken/ -ServerAuthToken are wire-compatible. Only Connect is broken. - -## ClientReferral / transfer - -`com.hypixel.hytale.protocol.packets.auth.ClientReferral` (id 18, **ToClient only** — server→client, -fixed=1, varStart=9, max=5141). Offset-table: nullBits(bit0=hostTo, bit1=data) + 2 int32-LE slots: - -| off | field | type | notes | -|-----|---------|------|-------| -| 0 | nullBits | byte | bit0=hostTo present, bit1=data present | -| 1 | offset: hostTo | int32 LE rel to byte 9 | `HostAddress` = int16-LE port + varString host(UTF-8 max 256) | -| 5 | offset: data | int32 LE rel to byte 9 | byte[], max 4096 — the signed referral blob | -| 9+ | var block | - -**Transfer/redirect encoding:** the server tells the client to reconnect elsewhere by sending -ClientReferral with `hostTo` (target host:port) and an opaque signed `data` blob (≤4096 bytes). -The client then opens a fresh connection to `hostTo` and replays that blob back in the **`Connect` -packet's `referralData` field** (with `referralSource` = the address it was referred from). The -new server's `InitialPacketHandler.handle(Connect)` validates: referralData present ⇒ -referralSource must be non-null with a non-empty host, and referralData length ≤ 4096, else it -rejects. So the round-trip is: `ServerB→client ClientReferral{hostTo, data}` then -`client→ServerA(hostTo) Connect{referralData=data, referralSource=...}`. - -→ hyproxy `impl/ClientReferral` is server→client, serialize-only (`deserialize` throws -UnsupportedOperationException), nullBits + 2 LE offset slots [hostTo, data] + var block. **Matches -the current layout exactly.** The signed-blob construction/validation lives in hyproxy's -`SecretMessageUtil` (referral data is validated in `InboundInitialPacketHandler` against the proxy -secret + uuid) — unchanged by this protocol update. No layout change needed for ClientReferral. - -## Handshake sequence - -Reconstructed from the server's connection handlers (`InitialPacketHandler`, `HandshakeHandler` / -`AuthenticationPacketHandler`, `PasswordPacketHandler`, `SetupPacketHandler`). - -**Who speaks first: the CLIENT.** After QUIC/TLS is established, the server installs an -`InitialPacketHandler` and *waits*. It does NOT send ServerInfo / ConnectAccept / -RequestInsecurePlayerOptions first. The client must send `Connect` (id 0) as the first application -packet. (`InitialPacketHandler.disconnect` even silently drops the connection if it closes before -any Connect arrives.) **hyproxy's assumption that the client speaks first with Connect is CORRECT.** - -`InitialPacketHandler` accepts only ids {0 Connect, 1 ClientDisconnect, 363 InsecurePlayerOptions}; -anything else → "unexpectedPacket" disconnect. - -### Authenticated flow (what the proxy runs) - -``` -QUIC/TLS established - client → server : Connect (id 0) [client speaks first] - server validates protocolCrc == 0x4E7F3D14 (1316766548) - server: AuthMode == AUTHENTICATED && identityToken present - → hand off to AuthenticationPacketHandler (extends HandshakeHandler) - server validates identityToken JWT (subject/username/scope: hytale:client|hytale:editor) - server → client : AuthGrant (id 11) {authorizationGrant, serverIdentityToken} - client → server : AuthToken (id 12) {accessToken, serverAuthorizationGrant} - server validates access-token JWT (uuid+username must match identity claims); - requires serverAuthorizationGrant (mutual auth) else disconnect; - server exchanges the grant for a serverAccessToken via the session service - server → client : ServerAuthToken (id 13) {serverAccessToken, optional passwordChallenge} - → hand off to PasswordPacketHandler - [if passwordChallenge present:] - client → server : PasswordResponse (id 15){hash} - server → client : PasswordAccepted (16) | PasswordRejected (17){newChallenge, attemptsLeft} - [else / on accept:] → hand off to SetupPacketHandler (world setup begins) -``` - -This is **exactly the flow hyproxy implements** (`InboundInitialPacketHandler` → -`InboundAuthPacketHandler`: Connect → AuthGrant → AuthToken → ServerAuthToken → forwarding). -**The handshake ORDERING does not need to change.** What is broken is purely the **Connect packet -decode** (see Connect layout) and where the proxy sources uuid/username: - -- hyproxy `InboundInitialPacketHandler.handle(Connect)` reads `connect.getUuid()` / - `connect.getUsername()` — these fields don't exist in the current Connect. After fixing - Connect, the proxy must take uuid/username from the JWT identity-token claims (it already - validates them in `InboundAuthPacketHandler.activated()`), NOT from the Connect packet. -- The proxy never enters the dev/offline branch (no RequestInsecurePlayerOptions / - InsecurePlayerOptions / ConnectAccept on the authenticated path), so those packets are not - required for the core join, though decoders for Ping/Pong and Password* may be added for - robustness/password-protected backends. - -### Development / offline flow (NOT used by the proxy, for reference) - -``` - client → server : Connect (id 0) (no identityToken, or AuthMode != AUTHENTICATED) - server → client : RequestInsecurePlayerOptions (id 364) - client → server : InsecurePlayerOptions (id 363){uuid, username, optional skin} - server → client : ConnectAccept (id 14){optional passwordChallenge} - → PasswordPacketHandler (same Password* sub-flow) → SetupPacketHandler -``` -This is where uuid + username live when there is no auth (they were REMOVED from Connect and put -into InsecurePlayerOptions). Confirms the Connect-layout change is intentional, not a decode -artifact. - -## Live byte captures - -(Deferred to Task 4 — raw-byte logging + live handshake capture. Static RE above is sufficient to -drive the Connect-layout fix.) From 09e1d25fe2183d2ff2a5362c11d13be488f5423a Mon Sep 17 00:00:00 2001 From: SantioMC Date: Tue, 23 Jun 2026 16:28:45 -0400 Subject: [PATCH 13/19] feat: log when failing to validate --- .../java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java b/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java index 3905643..cf0bf93 100644 --- a/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java +++ b/backend-plugin/src/main/java/ac/eva/hyproxy/plugin/HyProxyBackendPlugin.java @@ -67,6 +67,11 @@ private void onPlayerSetupConnect(PlayerSetupConnectEvent event) { if (message == null) { event.setCancelled(true); event.setReason(Message.raw("invalid player info message (is your proxy secret and backend id valid?)")); + getLogger().at(Level.WARNING).log( + "failed to parse player info message, likely an invalid secret or backend (secret=<%d bytes>, backend=%s)", + this.getProxySecret().length, + this.getBackendName() + ); return; } From 91e0bbc8f5ead3d18efaea580f65ac14c45b516e Mon Sep 17 00:00:00 2001 From: SantioMC Date: Thu, 25 Jun 2026 20:10:40 -0400 Subject: [PATCH 14/19] feat: fix NPE & skins --- .../java/ac/eva/hyproxy/auth/JWTVerifier.java | 13 ++- .../java/ac/eva/hyproxy/io/PacketDecoder.java | 13 ++- .../inbound/InboundInitialPacketHandler.java | 2 + .../OutboundInitialPacketHandler.java | 2 +- .../impl/auth/InsecurePlayerOptions.java | 20 +++- .../ac/eva/hyproxy/io/proto/PlayerSkin.java | 103 ++++++++++++++++++ .../ac/eva/hyproxy/player/HyProxyPlayer.java | 3 + 7 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 proxy/src/main/java/ac/eva/hyproxy/io/proto/PlayerSkin.java diff --git a/proxy/src/main/java/ac/eva/hyproxy/auth/JWTVerifier.java b/proxy/src/main/java/ac/eva/hyproxy/auth/JWTVerifier.java index 1d265b4..324f331 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/auth/JWTVerifier.java +++ b/proxy/src/main/java/ac/eva/hyproxy/auth/JWTVerifier.java @@ -16,6 +16,7 @@ import java.security.cert.X509Certificate; import java.text.ParseException; import java.time.Instant; +import java.util.Map; import java.util.UUID; @Slf4j @@ -107,6 +108,12 @@ public class JWTVerifier { } JWTClaimsSet claimsSet = jwt.getJWTClaimsSet(); + + // todo: this is stupid, and should be cleaned up + Map profile = claimsSet.getJSONObjectClaim("profile"); + Object skinClaim = profile != null ? profile.get("skin") : null; + String skin = skinClaim != null ? skinClaim.toString() : null; + IdentityTokenClaims claims = new IdentityTokenClaims( claimsSet.getIssuer(), claimsSet.getSubject(), @@ -114,7 +121,8 @@ public class JWTVerifier { claimsSet.getIssueTime() != null ? claimsSet.getIssueTime().toInstant().getEpochSecond() : null, claimsSet.getExpirationTime() != null ? claimsSet.getExpirationTime().toInstant().getEpochSecond() : null, claimsSet.getNotBeforeTime() != null ? claimsSet.getNotBeforeTime().toInstant().getEpochSecond() : null, - claimsSet.getStringClaim("scope") + claimsSet.getStringClaim("scope"), + skin ); if (!claims.issuer().equals(HytaleSessionServiceClient.SESSIONS_ISSUER)) { @@ -176,7 +184,8 @@ public record IdentityTokenClaims( @Nullable Long issuedAt, @Nullable Long expiresAt, @Nullable Long notBefore, - @Nullable String scope + @Nullable String scope, + @Nullable String skin ) { public @Nullable UUID getSubjectAsUUID() { if (this.subject == null) return null; diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java index 8e0b2f8..e1f131f 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java @@ -13,6 +13,7 @@ @Slf4j public class PacketDecoder extends ByteToMessageDecoder { + private static final boolean DEBUG_PACKETS = Boolean.getBoolean("hyproxy.debugBytes"); private static final int MAX_PAYLOAD_LENGTH = 1677721600; @Override @@ -37,11 +38,13 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { return; } - if (System.getProperty("hyproxy.debugBytes").equals("true")) { - int frameLength = 8 + payloadLength; - log.info("INBOUND frame ({}B):\n{}", frameLength, - ByteBufUtil.prettyHexDump(in, originalReaderIndex, - Math.min(frameLength, 256))); + if (DEBUG_PACKETS) { + final int frameLength = 8 + payloadLength; + log.info( + "INBOUND frame ({}B):\n{}", + frameLength, + ByteBufUtil.prettyHexDump(in, originalReaderIndex, Math.min(frameLength, 256)) + ); } if (packetInfo == null) { diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java index b2bf6d8..00ed067 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java @@ -11,6 +11,7 @@ import ac.eva.hyproxy.io.HytaleConnection; import ac.eva.hyproxy.io.HytalePacketHandler; import ac.eva.hyproxy.io.packet.impl.auth.Connect; +import ac.eva.hyproxy.io.proto.PlayerSkin; import ac.eva.hyproxy.player.HyProxyPlayer; import java.util.Locale; @@ -70,6 +71,7 @@ public boolean handle(Connect connect) { player.setIdentityToken(identityToken); player.setLanguage(connect.getLanguage()); player.setClientType(connect.getClientType()); + player.setSkin(PlayerSkin.fromJson(claims.skin())); byte[] referralData = connect.getReferralData(); if (referralData != null) { diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java index 9261d46..547a5ac 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/outbound/OutboundInitialPacketHandler.java @@ -48,7 +48,7 @@ public void connected() { @Override public boolean handle(RequestInsecurePlayerOptions request) { HyProxyPlayer player = connection.ensurePlayer(); - connection.send(new InsecurePlayerOptions(player.getProfileId(), player.getUsername())); + connection.send(new InsecurePlayerOptions(player.getProfileId(), player.getUsername(), player.getSkin())); return true; } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java index 0edf839..451183d 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java @@ -4,8 +4,10 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.ToString; +import org.jspecify.annotations.Nullable; import ac.eva.hyproxy.io.HytalePacketHandler; import ac.eva.hyproxy.io.packet.Packet; +import ac.eva.hyproxy.io.proto.PlayerSkin; import ac.eva.hyproxy.common.util.ProtocolUtil; import java.util.UUID; @@ -25,6 +27,7 @@ public class InsecurePlayerOptions implements Packet { private final UUID uuid; private final String username; + private final @Nullable PlayerSkin skin; public static InsecurePlayerOptions deserialize(ByteBuf buf) { throw new UnsupportedOperationException(); @@ -37,17 +40,24 @@ public boolean handle(HytalePacketHandler handler) { @Override public void serialize(ByteBuf buf) { - buf.writeByte(0); // nullBits: no skin + final byte nullBits = (byte) (this.skin != null ? 0x1 : 0x0); + + buf.writeByte(nullBits); ProtocolUtil.writeUUID(buf, this.uuid); - int usernameOffsetSlot = buf.writerIndex(); + final int usernameOffsetSlot = buf.writerIndex(); + buf.writeIntLE(-1); + final int skinOffsetSlot = buf.writerIndex(); buf.writeIntLE(-1); - int skinOffsetSlot = buf.writerIndex(); - buf.writeIntLE(-1); // skin absent - int varsOffset = buf.writerIndex(); + final int varsOffset = buf.writerIndex(); buf.setIntLE(usernameOffsetSlot, buf.writerIndex() - varsOffset); ProtocolUtil.writeVarString(buf, this.username); + + if (this.skin != null) { + buf.setIntLE(skinOffsetSlot, buf.writerIndex() - varsOffset); + this.skin.serialize(buf); + } } } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/proto/PlayerSkin.java b/proxy/src/main/java/ac/eva/hyproxy/io/proto/PlayerSkin.java new file mode 100644 index 0000000..b3c201f --- /dev/null +++ b/proxy/src/main/java/ac/eva/hyproxy/io/proto/PlayerSkin.java @@ -0,0 +1,103 @@ +package ac.eva.hyproxy.io.proto; + +import ac.eva.hyproxy.common.util.ProtocolUtil; +import com.nimbusds.jose.util.JSONObjectUtils; +import io.netty.buffer.ByteBuf; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; + +import java.text.ParseException; +import java.util.Arrays; +import java.util.Map; + +/** + * A player's cosmetic appearance, mirroring the engine's {@code PlayerSkin} structure. The proxy + * treats it as an opaque pass-through: each part is an optional id sourced from the signed identity + * token's {@code profile.skin} claim and re-serialized into {@link ac.eva.hyproxy.io.packet.impl.auth.InsecurePlayerOptions} + * so insecure-mode backends can render the player. The backend is responsible for validating the parts. + * + *

Wire layout (little-endian): nullBits(3, one bit per part in {@link #PART_KEYS} order), then 20 + * int32-LE offset slots (-1 when the part is absent) relative to the variable block at byte 83, + * followed by each present part as a var-ascii string. + * + * @author santio + */ +@Slf4j +public class PlayerSkin { + private static final int NULL_BITS_SIZE = 3; + + // order is significant: it defines each part's nullBits bit and offset slot, and must match the engine + private static final String[] PART_KEYS = { + "bodyCharacteristic", "underwear", "face", "eyes", "ears", "mouth", "facialHair", "haircut", + "eyebrows", "pants", "overpants", "undertop", "overtop", "shoes", "headAccessory", "faceAccessory", + "earAccessory", "skinFeature", "gloves", "cape" + }; + + private final @Nullable String[] parts; + + private PlayerSkin(@Nullable String[] parts) { + this.parts = parts; + } + + /** + * Parses the {@code profile.skin} JSON carried in the identity token into a skin. + * @param json the raw skin json, or null/empty when the player has no skin + * @return the parsed skin, or null when absent or unparseable + */ + public static @Nullable PlayerSkin fromJson(@Nullable String json) { + if (json == null || json.isEmpty()) { + return null; + } + + final Map skin; + try { + skin = JSONObjectUtils.parse(json); + } catch (ParseException e) { + log.warn("failed to parse skin json from identity token", e); + return null; + } + + final String[] parts = new String[PART_KEYS.length]; + for (int i = 0; i < PART_KEYS.length; i++) { + if (skin.get(PART_KEYS[i]) instanceof String part) { + parts[i] = part; + } + } + + return new PlayerSkin(parts); + } + + public void serialize(ByteBuf buf) { + final byte[] nullBits = new byte[NULL_BITS_SIZE]; + for (int i = 0; i < this.parts.length; i++) { + if (this.parts[i] != null) { + nullBits[i >> 3] |= (byte) (1 << (i & 7)); + } + } + + buf.writeBytes(nullBits); + + final int slotsStart = buf.writerIndex(); + for (int i = 0; i < this.parts.length; i++) { + buf.writeIntLE(0); + } + + final int varsOffset = buf.writerIndex(); + for (int i = 0; i < this.parts.length; i++) { + final int slot = slotsStart + i * Integer.BYTES; + + if (this.parts[i] == null) { + buf.setIntLE(slot, -1); + continue; + } + + buf.setIntLE(slot, buf.writerIndex() - varsOffset); + ProtocolUtil.writeVarString(buf, this.parts[i]); + } + } + + @Override + public String toString() { + return "PlayerSkin" + Arrays.toString(this.parts); + } +} diff --git a/proxy/src/main/java/ac/eva/hyproxy/player/HyProxyPlayer.java b/proxy/src/main/java/ac/eva/hyproxy/player/HyProxyPlayer.java index 85c9748..45c52aa 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/player/HyProxyPlayer.java +++ b/proxy/src/main/java/ac/eva/hyproxy/player/HyProxyPlayer.java @@ -13,6 +13,7 @@ import ac.eva.hyproxy.io.proto.ClientType; import ac.eva.hyproxy.io.proto.DisconnectType; import ac.eva.hyproxy.io.proto.NetworkChannel; +import ac.eva.hyproxy.io.proto.PlayerSkin; import ac.eva.hyproxy.message.Message; import ac.eva.hyproxy.player.permission.PlayerPermissionProvider; import com.google.common.collect.ImmutableSet; @@ -55,6 +56,8 @@ public class HyProxyPlayer implements CommandSender { private ClientType clientType; @Setter private @Nullable HyProxyBackend referredBackend; + @Setter + private @Nullable PlayerSkin skin; @Setter private boolean authenticated = false; From dd4b4cbef89e248b7544abefe682ff643c2ea9d7 Mon Sep 17 00:00:00 2001 From: SantioMC Date: Fri, 26 Jun 2026 18:12:17 -0400 Subject: [PATCH 15/19] fix: dont forward failed packets & formattedmessage --- .../eva/hyproxy/common/util/ProtocolUtil.java | 2 +- .../java/ac/eva/hyproxy/io/PacketDecoder.java | 3 +- .../io/proto/message/FormattedMessage.java | 80 ++++++++++++------- .../proto/message/FormattedMessageImage.java | 4 +- .../java/ac/eva/hyproxy/message/Message.java | 2 +- 5 files changed, 58 insertions(+), 33 deletions(-) diff --git a/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java b/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java index 629eaa7..399e97d 100644 --- a/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java +++ b/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java @@ -43,7 +43,7 @@ public Pair readVarString(ByteBuf buf, int offset, int maxLengt data[i] = buf.getByte(offset + varIntLength + i); } - return Pair.of(new String(data, StandardCharsets.US_ASCII), varIntLength + data.length); + return Pair.of(new String(data, StandardCharsets.UTF_8), varIntLength + data.length); } public String readVarString(ByteBuf buf, int maxLength, Charset charset) { diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java index e1f131f..baea168 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java @@ -58,8 +58,7 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { Packet packet = packetInfo.deserializeFunction().apply(payload); out.add(packet); } catch (Exception e) { - log.warn("failed to decode packet id {} ({}); forwarding raw frame", packetId, e.toString()); - out.add(in.copy(originalReaderIndex, 8 + payloadLength)); + log.warn("failed to decode packet id {} - dropping packet", packetId, e); } finally { payload.release(); } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java index 51bb241..bc277df 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java @@ -33,13 +33,20 @@ public class FormattedMessage { private boolean markupEnabled; public static FormattedMessage deserialize(ByteBuf buf) { - byte nullBits = buf.readByte(); - MaybeBool bold = MaybeBool.getById(buf.readByte()); - MaybeBool italic = MaybeBool.getById(buf.readByte()); - MaybeBool monospace = MaybeBool.getById(buf.readByte()); - MaybeBool underlined = MaybeBool.getById(buf.readByte()); + byte nullBits0 = buf.readByte(); + byte nullBits1 = buf.readByte(); + + byte boldByte = buf.readByte(); + byte italicByte = buf.readByte(); + byte monospaceByte = buf.readByte(); + byte underlinedByte = buf.readByte(); boolean markupEnabled = buf.readByte() != 0; + MaybeBool bold = (nullBits0 & 0x1) != 0 ? MaybeBool.fromBool(boldByte != 0) : MaybeBool.NULL; + MaybeBool italic = (nullBits0 & 0x2) != 0 ? MaybeBool.fromBool(italicByte != 0) : MaybeBool.NULL; + MaybeBool monospace = (nullBits0 & 0x4) != 0 ? MaybeBool.fromBool(monospaceByte != 0) : MaybeBool.NULL; + MaybeBool underlined = (nullBits0 & 0x8) != 0 ? MaybeBool.fromBool(underlinedByte != 0) : MaybeBool.NULL; + int rawTextOffset = buf.readIntLE(); int messageIdOffset = buf.readIntLE(); int childrenOffset = buf.readIntLE(); @@ -54,7 +61,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { int readViaOffsets = 0; String rawText = null; - if ((nullBits & 0x1) != 0) { + if ((nullBits0 & 0x10) != 0) { int offset = varsOffset + rawTextOffset; Pair varString = ProtocolUtil.readVarString(buf, offset, 128); rawText = varString.left(); @@ -62,7 +69,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { } String messageId = null; - if ((nullBits & 0x2) != 0) { + if ((nullBits0 & 0x20) != 0) { int offset = varsOffset + messageIdOffset; Pair varString = ProtocolUtil.readVarString(buf, offset, 128); messageId = varString.left(); @@ -70,7 +77,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { } FormattedMessage[] children = null; - if ((nullBits & 0x4) != 0) { + if ((nullBits0 & 0x40) != 0) { int oldOffset = buf.readerIndex(); int offset = varsOffset + childrenOffset; @@ -96,7 +103,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { } Map params = null; - if ((nullBits & 0x8) != 0) { + if ((nullBits0 & 0x80) != 0) { int oldOffset = buf.readerIndex(); int offset = varsOffset + paramsOffset; @@ -125,7 +132,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { Map messageParams = null; - if ((nullBits & 16) != 0) { + if ((nullBits1 & 0x1) != 0) { int oldOffset = buf.readerIndex(); int offset = varsOffset + messageParamsOffset; @@ -153,7 +160,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { } String color = null; - if ((nullBits & 32) != 0) { + if ((nullBits1 & 0x2) != 0) { int offset = varsOffset + colorOffset; Pair varString = ProtocolUtil.readVarString(buf, offset, 32); color = varString.left(); @@ -161,7 +168,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { } String link = null; - if ((nullBits & 64) != 0) { + if ((nullBits1 & 0x4) != 0) { int offset = varsOffset + linksOffset; Pair varString = ProtocolUtil.readVarString(buf, offset, 1024); link = varString.left(); @@ -169,7 +176,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { } FormattedMessageImage image = null; - if ((nullBits & 128) != 0) { + if ((nullBits1 & 0x8) != 0) { int offset = varsOffset + imageOffset; Pair pair = FormattedMessageImage.deserialize(buf, offset); image = pair.left(); @@ -195,44 +202,63 @@ public static FormattedMessage deserialize(ByteBuf buf) { } public void serialize(ByteBuf buf) { - byte nullBits = 0; + byte nullBits0 = 0; + byte nullBits1 = 0; + + if (this.bold != MaybeBool.NULL) { + nullBits0 = (byte) (nullBits0 | 0x1); + } + + if (this.italic != MaybeBool.NULL) { + nullBits0 = (byte) (nullBits0 | 0x2); + } + + if (this.monospace != MaybeBool.NULL) { + nullBits0 = (byte) (nullBits0 | 0x4); + } + + if (this.underlined != MaybeBool.NULL) { + nullBits0 = (byte) (nullBits0 | 0x8); + } + if (this.rawText != null) { - nullBits = (byte) (nullBits | 1); + nullBits0 = (byte) (nullBits0 | 0x10); } if (this.messageId != null) { - nullBits = (byte) (nullBits | 2); + nullBits0 = (byte) (nullBits0 | 0x20); } if (this.children != null) { - nullBits = (byte) (nullBits | 4); + nullBits0 = (byte) (nullBits0 | 0x40); } if (this.params != null) { - nullBits = (byte) (nullBits | 8); + nullBits0 = (byte) (nullBits0 | 0x80); } if (this.messageParams != null) { - nullBits = (byte) (nullBits | 16); + nullBits1 = (byte) (nullBits1 | 0x1); } if (this.color != null) { - nullBits = (byte) (nullBits | 32); + nullBits1 = (byte) (nullBits1 | 0x2); } if (this.link != null) { - nullBits = (byte) (nullBits | 64); + nullBits1 = (byte) (nullBits1 | 0x4); } if (this.image != null) { - nullBits = (byte) (nullBits | 128); + nullBits1 = (byte) (nullBits1 | 0x8); } - buf.writeByte(nullBits); - buf.writeByte(this.bold.getId()); - buf.writeByte(this.italic.getId()); - buf.writeByte(this.monospace.getId()); - buf.writeByte(this.underlined.getId()); + buf.writeByte(nullBits0); + buf.writeByte(nullBits1); + buf.writeByte(this.bold == MaybeBool.TRUE ? 1 : 0); + buf.writeByte(this.italic == MaybeBool.TRUE ? 1 : 0); + buf.writeByte(this.monospace == MaybeBool.TRUE ? 1 : 0); + buf.writeByte(this.underlined == MaybeBool.TRUE ? 1 : 0); buf.writeByte(this.markupEnabled ? 1 : 0); int rawTextOffsetSlot = buf.writerIndex(); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessageImage.java b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessageImage.java index c4ff1ce..f2ef792 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessageImage.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessageImage.java @@ -13,13 +13,13 @@ public record FormattedMessageImage( public static Pair deserialize(ByteBuf buf, int offset) { int width = buf.getIntLE(offset); int height = buf.getIntLE(offset + 4); - Pair varString = ProtocolUtil.readVarString(buf, offset, 4096); + Pair varString = ProtocolUtil.readVarString(buf, offset + 8, 4096); return Pair.of(new FormattedMessageImage( varString.left(), width, height - ), 4 + 4 + varString.right()); + ), 8 + varString.right()); } public void serialize(ByteBuf buf) { diff --git a/proxy/src/main/java/ac/eva/hyproxy/message/Message.java b/proxy/src/main/java/ac/eva/hyproxy/message/Message.java index d056fbe..78eac7f 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/message/Message.java +++ b/proxy/src/main/java/ac/eva/hyproxy/message/Message.java @@ -166,7 +166,7 @@ public Message italic(boolean italic) { } public Message monospace(boolean monospace) { - this.formatted.setItalic(MaybeBool.fromBool(monospace)); + this.formatted.setMonospace(MaybeBool.fromBool(monospace)); return this; } From 19895381dce14364c5ba07962276275e180aa3be Mon Sep 17 00:00:00 2001 From: SantioMC Date: Thu, 2 Jul 2026 15:12:06 -0400 Subject: [PATCH 16/19] misc: more protocol corrections --- .../ac/eva/hyproxy/common/util/ProtocolUtil.java | 5 +++-- .../eva/hyproxy/io/packet/impl/auth/AuthGrant.java | 4 ++-- .../hyproxy/io/packet/impl/auth/ServerAuthToken.java | 1 + .../eva/hyproxy/io/packet/impl/setup/ServerInfo.java | 4 ++-- .../hyproxy/io/proto/message/FormattedMessage.java | 12 ++++++------ 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java b/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java index 399e97d..b655527 100644 --- a/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java +++ b/common/src/main/java/ac/eva/hyproxy/common/util/ProtocolUtil.java @@ -19,8 +19,9 @@ public class ProtocolUtil { public static final ChannelFutureListener CLOSE_ON_COMPLETE = ProtocolUtil::closeApplicationOnComplete; public void writeVarString(ByteBuf buf, String str) { - writeVarString(buf, str, StandardCharsets.US_ASCII); + writeVarString(buf, str, StandardCharsets.UTF_8); } + public void writeVarString(ByteBuf buf, String str, Charset charset) { byte[] bytes = str.getBytes(charset); VarIntUtil.write(buf, bytes.length); @@ -28,7 +29,7 @@ public void writeVarString(ByteBuf buf, String str, Charset charset) { } public String readVarString(ByteBuf buf, int maxLength) { - return readVarString(buf, maxLength, StandardCharsets.US_ASCII); + return readVarString(buf, maxLength, StandardCharsets.UTF_8); } public Pair readVarString(ByteBuf buf, int offset, int maxLength) { diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/AuthGrant.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/AuthGrant.java index 68c5fcf..c85d12d 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/AuthGrant.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/AuthGrant.java @@ -34,7 +34,7 @@ public static AuthGrant deserialize(ByteBuf buf) { if ((nullBits & 0x1) != 0) { int offset = varsOffset + authorizationGrantOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 128); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096); authorizationGrant = varString.left(); readViaOffsets += varString.right(); } @@ -43,7 +43,7 @@ public static AuthGrant deserialize(ByteBuf buf) { if ((nullBits & 0x2) != 0) { int offset = varsOffset + serverIdentityTokenOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 128); + Pair varString = ProtocolUtil.readVarString(buf, offset, 8192); serverIdentityToken = varString.left(); readViaOffsets += varString.right(); } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/ServerAuthToken.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/ServerAuthToken.java index a95c789..efcc9a8 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/ServerAuthToken.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/ServerAuthToken.java @@ -89,6 +89,7 @@ public void serialize(ByteBuf buf) { if (this.passwordChallenge != null) { buf.setIntLE(passwordChallengeOffsetSlot, buf.writerIndex() - varsOffset); + VarIntUtil.write(buf, this.passwordChallenge.length); buf.writeBytes(this.passwordChallenge); } } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java index 822a717..472d0c0 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java @@ -33,7 +33,7 @@ public static ServerInfo deserialize(ByteBuf buf) { String serverName = null; if ((nullBits & 0x1) != 0) { int offset = varsOffset + serverNameOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 100); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); serverName = varString.left(); readViaOffsets += varString.right(); } @@ -41,7 +41,7 @@ public static ServerInfo deserialize(ByteBuf buf) { String motd = null; if ((nullBits & 0x2) != 0) { int offset = varsOffset + motdOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 500); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); motd = varString.left(); readViaOffsets += varString.right(); } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java index bc277df..231d674 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java @@ -63,7 +63,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { String rawText = null; if ((nullBits0 & 0x10) != 0) { int offset = varsOffset + rawTextOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 128); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); rawText = varString.left(); readViaOffsets += varString.right(); } @@ -71,7 +71,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { String messageId = null; if ((nullBits0 & 0x20) != 0) { int offset = varsOffset + messageIdOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 128); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); messageId = varString.left(); readViaOffsets += varString.right(); } @@ -120,7 +120,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { for (int i = 0; i < length; i++) { int oldParamOffset = buf.readerIndex(); - String key = ProtocolUtil.readVarString(buf, 128); + String key = ProtocolUtil.readVarString(buf, 4096000); ParamValue value = ParamValue.deserialize(buf); params.put(key, value); @@ -149,7 +149,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { for (int i = 0; i < length; i++) { int oldParamOffset = buf.readerIndex(); - String key = ProtocolUtil.readVarString(buf, 128); + String key = ProtocolUtil.readVarString(buf, 4096000); FormattedMessage value = FormattedMessage.deserialize(buf); messageParams.put(key, value); @@ -162,7 +162,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { String color = null; if ((nullBits1 & 0x2) != 0) { int offset = varsOffset + colorOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 32); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); color = varString.left(); readViaOffsets += varString.right(); } @@ -170,7 +170,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { String link = null; if ((nullBits1 & 0x4) != 0) { int offset = varsOffset + linksOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 1024); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); link = varString.left(); readViaOffsets += varString.right(); } From 34ba0a7e7075271eb0f95d58f380e4bc15db12cd Mon Sep 17 00:00:00 2001 From: Alexandre Paez Date: Tue, 21 Jul 2026 14:39:36 -0300 Subject: [PATCH 17/19] fix(MHPL-615): cap QUIC UDP payload size for Cloudflare Spectrum (#3) Players intermittently disconnect when the Hytale proxy runs behind Cloudflare Spectrum. Spectrum does not fragment UDP and drops any datagram too large to forward. The client-facing QUIC codec advertised the quiche default max_udp_payload_size (65527) and ran DPLPMTUD (discoverPmtu=true), so datagrams grew past the Spectrum-forwardable size and were blackholed, stalling sessions until the 60s idle timeout dropped the player. Cap the datagram size in both directions and disable PMTU probing, both driven by new config keys so the value can be tuned per deployment without a rebuild: - max-udp-payload-size (default 1200, QUIC's universal floor) sets both maxRecvUdpPayloadSize (advertised to the client, caps client->proxy, the direction Spectrum blackholes) and maxSendUdpPayloadSize (caps proxy->client) - discover-pmtu (default false) drives discoverPmtu(...) Behind Spectrum start at 1200 and raise toward 1350 once stability is confirmed. For direct (non-Spectrum) UDP exposure set discover-pmtu=true. --- .../hyproxy/config/HyProxyConfiguration.java | 14 +++++++++++++- .../io/QuicChannelInboundHandlerAdapter.java | 14 +++++++++++++- proxy/src/main/resources/default-config.toml | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/proxy/src/main/java/ac/eva/hyproxy/config/HyProxyConfiguration.java b/proxy/src/main/java/ac/eva/hyproxy/config/HyProxyConfiguration.java index 2631e79..d6c3485 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/config/HyProxyConfiguration.java +++ b/proxy/src/main/java/ac/eva/hyproxy/config/HyProxyConfiguration.java @@ -43,6 +43,13 @@ public class HyProxyConfiguration { private Map backends; private Map> permissions; + // QUIC UDP datagram sizing. maxUdpPayloadSize caps datagrams in both directions so + // they survive the path MTU; discoverPmtu toggles DPLPMTUD probing. See MHPL-615: + // both are tuned down when the proxy sits behind Cloudflare Spectrum, which drops + // oversized/fragmented UDP instead of forwarding it. + private int maxUdpPayloadSize; + private boolean discoverPmtu; + public InetSocketAddress getBind() { return AddressUtil.parseAndResolveAddress(bind); } @@ -184,6 +191,9 @@ public static HyProxyConfiguration load(HyProxy proxy, Path configFilePath) thro String initialBackend = config.getOrElse("initial-backend", "main"); boolean proxyCommunicationEnabled = config.getOrElse("proxy-communication", true); + int maxUdpPayloadSize = config.getIntOrElse("max-udp-payload-size", 1200); + boolean discoverPmtu = config.getOrElse("discover-pmtu", false); + CommentedConfig backendConfig = config.get("backends"); Map backends = backendConfig.valueMap() .entrySet() @@ -208,7 +218,9 @@ public static HyProxyConfiguration load(HyProxy proxy, Path configFilePath) thro initialBackend, proxyCommunicationEnabled, backends, - permissions + permissions, + maxUdpPayloadSize, + discoverPmtu ); } } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/QuicChannelInboundHandlerAdapter.java b/proxy/src/main/java/ac/eva/hyproxy/io/QuicChannelInboundHandlerAdapter.java index 4df89d7..480374c 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/QuicChannelInboundHandlerAdapter.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/QuicChannelInboundHandlerAdapter.java @@ -12,6 +12,7 @@ import org.jspecify.annotations.Nullable; import ac.eva.hyproxy.HyProxy; import ac.eva.hyproxy.common.util.ProtocolUtil; +import ac.eva.hyproxy.config.HyProxyConfiguration; import ac.eva.hyproxy.io.channel.InboundChannelInitializer; import ac.eva.hyproxy.io.proto.DisconnectType; @@ -33,6 +34,9 @@ public boolean isSharable() { @Override public void channelActive(ChannelHandlerContext ctx) { + HyProxyConfiguration config = this.proxy.getConfiguration(); + int maxUdpPayloadSize = config.getMaxUdpPayloadSize(); + ChannelHandler handler = new QuicServerCodecBuilder() .sslContext(this.sslContext) .tokenHandler(InsecureQuicTokenHandler.INSTANCE) @@ -45,7 +49,15 @@ public void channelActive(ChannelHandlerContext ctx) { .initialMaxStreamDataBidirectionalLocal(128 * 1024) .initialMaxStreamDataBidirectionalRemote(128 * 1024) .initialMaxStreamsBidirectional(8) - .discoverPmtu(true) + // MHPL-615: cap the QUIC datagram size so packets survive the path MTU. + // maxRecvUdpPayloadSize is advertised to the client as our + // max_udp_payload_size, forcing it to never send us datagrams larger than + // this (the inbound direction that Cloudflare Spectrum blackholes); the + // send cap bounds our outbound datagrams. discoverPmtu is off behind + // Spectrum so DPLPMTUD does not grow datagrams past the forwardable size. + .maxRecvUdpPayloadSize(maxUdpPayloadSize) + .maxSendUdpPayloadSize(maxUdpPayloadSize) + .discoverPmtu(config.isDiscoverPmtu()) .congestionControlAlgorithm(QuicCongestionControlAlgorithm.BBR) .handler(new ChannelInboundHandlerAdapter() { @Override diff --git a/proxy/src/main/resources/default-config.toml b/proxy/src/main/resources/default-config.toml index b8ad967..165b971 100644 --- a/proxy/src/main/resources/default-config.toml +++ b/proxy/src/main/resources/default-config.toml @@ -9,6 +9,23 @@ proxy-secret-file = "proxy.secret" # if we should bind on ipv6 too, heavily recommended ipv6-support = true +# maximum QUIC UDP payload size (bytes) used with clients. this caps the datagram +# size in BOTH directions: it is advertised to the client as our max_udp_payload_size +# (so the client never sends us anything larger) and it bounds our own outgoing +# datagrams. keep it small enough to survive the whole path MTU. +# IMPORTANT (MHPL-615): Cloudflare Spectrum does NOT fragment UDP and silently drops +# any datagram too large to forward, so when the proxy is behind Spectrum this must +# stay under the Spectrum edge->origin forwardable size. 1200 is QUIC's universal +# floor (always deliverable); raise toward 1350 to recover throughput once stability +# is confirmed. must be >= 1200. +max-udp-payload-size = 1200 + +# whether QUIC should probe for a larger path MTU (DPLPMTUD). keep this off behind +# Cloudflare Spectrum: probing grows datagrams past the Spectrum-forwardable size and, +# with ICMP "packet too big" filtered on anycast paths, those packets get blackholed. +# only enable for direct (non-Spectrum) UDP exposure where the path MTU is trustworthy. +discover-pmtu = false + # initial backend or backend set we should in order try to connect the user to. initial-backend = "main" From 28e024ba684fc1fa0b77fc3f356e375c7b6b239b Mon Sep 17 00:00:00 2001 From: Alexandre Paez Sequeira Date: Wed, 22 Jul 2026 10:30:59 -0300 Subject: [PATCH 18/19] fix: address upstream review feedback on protocol update Addresses the review on xyzeva/hyproxy#5. Security: - PacketDecoder: stop swallowing deserialization failures. Let them propagate so the netty pipeline closes the connection instead of letting a client spam malformed packets while staying connected. Protocol string limits (were 4096000): - FormattedMessage: rawText 4096, messageId 256, param keys 256, color 256, link 4096 - ServerInfo: serverName 256, motd 4096 Applied to all 8 sites, not only the 4 flagged, to stay consistent. Noise removal: - Drop javadoc from internal classes (PlayerSkin, InsecurePlayerOptions, RequestInsecurePlayerOptions) - Drop `final` on locals; it is not used elsewhere in the codebase - Drop verbose explainer comments in Connect and InboundInitialPacketHandler - Drop log.warn on rejected pre-auth connections (console spam vector) - Lowercase internal disconnect messages, matching the existing "invalid referral data" convention Intentionally unchanged: the username now comes from the verified access token rather than a Connect field, because the 0.5.5 Connect packet no longer carries a username. registerPlayer moving after auth, the unregisterPlayer early return and the username null guard all follow from that. Restoring the old equality check would compare against null and reject every login; the UUID cross-check against the token is retained. --- .../java/ac/eva/hyproxy/io/PacketDecoder.java | 4 +--- .../inbound/InboundInitialPacketHandler.java | 18 ++++---------- .../hyproxy/io/packet/impl/auth/Connect.java | 4 +--- .../impl/auth/InsecurePlayerOptions.java | 17 ++++--------- .../auth/RequestInsecurePlayerOptions.java | 4 ---- .../io/packet/impl/setup/ServerInfo.java | 4 ++-- .../ac/eva/hyproxy/io/proto/PlayerSkin.java | 24 +++++-------------- .../io/proto/message/FormattedMessage.java | 12 +++++----- 8 files changed, 24 insertions(+), 63 deletions(-) diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java index baea168..f4621ba 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/PacketDecoder.java @@ -39,7 +39,7 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { } if (DEBUG_PACKETS) { - final int frameLength = 8 + payloadLength; + int frameLength = 8 + payloadLength; log.info( "INBOUND frame ({}B):\n{}", frameLength, @@ -57,8 +57,6 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { try { Packet packet = packetInfo.deserializeFunction().apply(payload); out.add(packet); - } catch (Exception e) { - log.warn("failed to decode packet id {} - dropping packet", packetId, e); } finally { payload.release(); } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java index 00ed067..7c43647 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/handler/inbound/InboundInitialPacketHandler.java @@ -24,39 +24,29 @@ public class InboundInitialPacketHandler implements HytalePacketHandler { @Override public boolean handle(Connect connect) { - // Note: we intentionally do NOT gate on protocolCrc here. The CRC varies between Hytale - // client builds and the backend server validates it authoritatively; the proxy just - // decodes and forwards (player.protocolCrc is passed through to the backend Connect). - - // A malformed/unsupported clientType byte decodes to null (ClientType.getById is - // out-of-range-safe). Reject cleanly instead of NPEing downstream. if (connect.getClientType() == null) { - log.warn("rejecting Connect: invalid client type"); - connection.disconnect("Invalid client type"); + connection.disconnect("invalid client type"); return true; } String identityToken = connect.getIdentityToken(); if (identityToken == null) { - log.warn("rejecting Connect: no identity token (offline mode not supported)"); connection.disconnect("This proxy only supports online mode players!"); return true; } JWTVerifier.IdentityTokenClaims claims = connection.getProxy().getJwtVerifier().validateIdentityToken(identityToken); if (claims == null) { - log.warn("rejecting Connect: identity token failed validation (validateIdentityToken returned null)"); - connection.disconnect("Invalid or expired identity token"); + connection.disconnect("invalid or expired identity token"); return true; } UUID profileId = claims.getSubjectAsUUID(); if (profileId == null) { - log.warn("rejecting Connect: identity token missing/malformed subject"); - connection.disconnect("Invalid identity token: missing or malformed subject"); + connection.disconnect("invalid identity token: missing or malformed subject"); return true; } - + if (connection.getProxy().getPlayerByProfileId(profileId) != null) { connection.disconnect("You are already connected to this proxy!"); return true; diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java index 1cc911c..4ed1733 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/Connect.java @@ -119,9 +119,7 @@ public void serialize(ByteBuf buf) { buf.writeByte(nullBits); buf.writeIntLE(this.protocolCrc); buf.writeIntLE(this.protocolBuildNumber); - - // clientVersion is a fixed 20-byte ASCII field; pad/truncate to exactly 20 bytes so the - // fixed block stays aligned (must match deserialize's US_ASCII 20-byte read). + byte[] clientVersionBytes = new byte[20]; byte[] clientVersionSrc = this.clientVersion.getBytes(StandardCharsets.US_ASCII); System.arraycopy(clientVersionSrc, 0, clientVersionBytes, 0, Math.min(clientVersionSrc.length, 20)); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java index 451183d..c777127 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/InsecurePlayerOptions.java @@ -12,15 +12,6 @@ import java.util.UUID; -/** - * Client -> server (id 363). In insecure mode the player's identity is carried here rather than in - * Connect. The proxy sends this on the player's behalf to a backend in response to - * {@link RequestInsecurePlayerOptions}. Skin is omitted (proxy backends authenticate via the signed - * referral handled by the hyproxy-backend plugin). - * - * Wire layout (little-endian): nullBits(1, bit0=skin present), uuid(16), then 2 int32-LE offset - * slots [username, skin] relative to the variable block at byte 25. - */ @Getter @RequiredArgsConstructor @ToString @@ -40,17 +31,17 @@ public boolean handle(HytalePacketHandler handler) { @Override public void serialize(ByteBuf buf) { - final byte nullBits = (byte) (this.skin != null ? 0x1 : 0x0); + byte nullBits = (byte) (this.skin != null ? 0x1 : 0x0); buf.writeByte(nullBits); ProtocolUtil.writeUUID(buf, this.uuid); - final int usernameOffsetSlot = buf.writerIndex(); + int usernameOffsetSlot = buf.writerIndex(); buf.writeIntLE(-1); - final int skinOffsetSlot = buf.writerIndex(); + int skinOffsetSlot = buf.writerIndex(); buf.writeIntLE(-1); - final int varsOffset = buf.writerIndex(); + int varsOffset = buf.writerIndex(); buf.setIntLE(usernameOffsetSlot, buf.writerIndex() - varsOffset); ProtocolUtil.writeVarString(buf, this.username); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/RequestInsecurePlayerOptions.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/RequestInsecurePlayerOptions.java index d222837..7b1993d 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/RequestInsecurePlayerOptions.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/auth/RequestInsecurePlayerOptions.java @@ -4,10 +4,6 @@ import ac.eva.hyproxy.io.HytalePacketHandler; import ac.eva.hyproxy.io.packet.Packet; -/** - * Server -> client (id 364). Sent by an insecure-mode backend after the proxy's Connect to ask the - * proxy for the player's {@link InsecurePlayerOptions} (uuid/username). Empty payload. - */ public class RequestInsecurePlayerOptions implements Packet { public static RequestInsecurePlayerOptions deserialize(ByteBuf buf) { diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java index 472d0c0..3f1d345 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/packet/impl/setup/ServerInfo.java @@ -33,7 +33,7 @@ public static ServerInfo deserialize(ByteBuf buf) { String serverName = null; if ((nullBits & 0x1) != 0) { int offset = varsOffset + serverNameOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); + Pair varString = ProtocolUtil.readVarString(buf, offset, 256); serverName = varString.left(); readViaOffsets += varString.right(); } @@ -41,7 +41,7 @@ public static ServerInfo deserialize(ByteBuf buf) { String motd = null; if ((nullBits & 0x2) != 0) { int offset = varsOffset + motdOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096); motd = varString.left(); readViaOffsets += varString.right(); } diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/proto/PlayerSkin.java b/proxy/src/main/java/ac/eva/hyproxy/io/proto/PlayerSkin.java index b3c201f..7bddc34 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/proto/PlayerSkin.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/proto/PlayerSkin.java @@ -10,18 +10,6 @@ import java.util.Arrays; import java.util.Map; -/** - * A player's cosmetic appearance, mirroring the engine's {@code PlayerSkin} structure. The proxy - * treats it as an opaque pass-through: each part is an optional id sourced from the signed identity - * token's {@code profile.skin} claim and re-serialized into {@link ac.eva.hyproxy.io.packet.impl.auth.InsecurePlayerOptions} - * so insecure-mode backends can render the player. The backend is responsible for validating the parts. - * - *

Wire layout (little-endian): nullBits(3, one bit per part in {@link #PART_KEYS} order), then 20 - * int32-LE offset slots (-1 when the part is absent) relative to the variable block at byte 83, - * followed by each present part as a var-ascii string. - * - * @author santio - */ @Slf4j public class PlayerSkin { private static final int NULL_BITS_SIZE = 3; @@ -49,7 +37,7 @@ private PlayerSkin(@Nullable String[] parts) { return null; } - final Map skin; + Map skin; try { skin = JSONObjectUtils.parse(json); } catch (ParseException e) { @@ -57,7 +45,7 @@ private PlayerSkin(@Nullable String[] parts) { return null; } - final String[] parts = new String[PART_KEYS.length]; + String[] parts = new String[PART_KEYS.length]; for (int i = 0; i < PART_KEYS.length; i++) { if (skin.get(PART_KEYS[i]) instanceof String part) { parts[i] = part; @@ -68,7 +56,7 @@ private PlayerSkin(@Nullable String[] parts) { } public void serialize(ByteBuf buf) { - final byte[] nullBits = new byte[NULL_BITS_SIZE]; + byte[] nullBits = new byte[NULL_BITS_SIZE]; for (int i = 0; i < this.parts.length; i++) { if (this.parts[i] != null) { nullBits[i >> 3] |= (byte) (1 << (i & 7)); @@ -77,14 +65,14 @@ public void serialize(ByteBuf buf) { buf.writeBytes(nullBits); - final int slotsStart = buf.writerIndex(); + int slotsStart = buf.writerIndex(); for (int i = 0; i < this.parts.length; i++) { buf.writeIntLE(0); } - final int varsOffset = buf.writerIndex(); + int varsOffset = buf.writerIndex(); for (int i = 0; i < this.parts.length; i++) { - final int slot = slotsStart + i * Integer.BYTES; + int slot = slotsStart + i * Integer.BYTES; if (this.parts[i] == null) { buf.setIntLE(slot, -1); diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java index 231d674..55d6076 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/proto/message/FormattedMessage.java @@ -63,7 +63,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { String rawText = null; if ((nullBits0 & 0x10) != 0) { int offset = varsOffset + rawTextOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096); rawText = varString.left(); readViaOffsets += varString.right(); } @@ -71,7 +71,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { String messageId = null; if ((nullBits0 & 0x20) != 0) { int offset = varsOffset + messageIdOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); + Pair varString = ProtocolUtil.readVarString(buf, offset, 256); messageId = varString.left(); readViaOffsets += varString.right(); } @@ -120,7 +120,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { for (int i = 0; i < length; i++) { int oldParamOffset = buf.readerIndex(); - String key = ProtocolUtil.readVarString(buf, 4096000); + String key = ProtocolUtil.readVarString(buf, 256); ParamValue value = ParamValue.deserialize(buf); params.put(key, value); @@ -149,7 +149,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { for (int i = 0; i < length; i++) { int oldParamOffset = buf.readerIndex(); - String key = ProtocolUtil.readVarString(buf, 4096000); + String key = ProtocolUtil.readVarString(buf, 256); FormattedMessage value = FormattedMessage.deserialize(buf); messageParams.put(key, value); @@ -162,7 +162,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { String color = null; if ((nullBits1 & 0x2) != 0) { int offset = varsOffset + colorOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); + Pair varString = ProtocolUtil.readVarString(buf, offset, 256); color = varString.left(); readViaOffsets += varString.right(); } @@ -170,7 +170,7 @@ public static FormattedMessage deserialize(ByteBuf buf) { String link = null; if ((nullBits1 & 0x4) != 0) { int offset = varsOffset + linksOffset; - Pair varString = ProtocolUtil.readVarString(buf, offset, 4096000); + Pair varString = ProtocolUtil.readVarString(buf, offset, 4096); link = varString.left(); readViaOffsets += varString.right(); } From c0035b59539f78e53d5bb4f367a85030ff3035e8 Mon Sep 17 00:00:00 2001 From: Santio Date: Thu, 10 Sep 2026 08:46:30 -0400 Subject: [PATCH 19/19] fix: chunk loading lag (#4) --- gradlew | 0 .../ac/eva/hyproxy/io/HytaleConnection.java | 29 +++++++++++++++++++ 2 files changed, 29 insertions(+) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/proxy/src/main/java/ac/eva/hyproxy/io/HytaleConnection.java b/proxy/src/main/java/ac/eva/hyproxy/io/HytaleConnection.java index caf0b78..1d16b3a 100644 --- a/proxy/src/main/java/ac/eva/hyproxy/io/HytaleConnection.java +++ b/proxy/src/main/java/ac/eva/hyproxy/io/HytaleConnection.java @@ -82,6 +82,35 @@ public void channelInactive(ChannelHandlerContext ctx) { } } + @Override + public void channelWritabilityChanged(ChannelHandlerContext ctx) { + QuicStreamChannel streamChannel = (QuicStreamChannel) ctx.channel(); + NetworkChannel networkChannel = this.channelsByStreamId.getOrDefault(streamChannel.streamId(), NetworkChannel.DEFAULT); + + QuicStreamChannel peerStream = this.getPeerStream(networkChannel); + if (peerStream != null && peerStream.isActive()) { + peerStream.config().setAutoRead(streamChannel.isWritable()); + } + + ctx.fireChannelWritabilityChanged(); + } + + private @Nullable QuicStreamChannel getPeerStream(NetworkChannel networkChannel) { + if (this.player == null) { + return null; + } + + HytaleConnection peer = this.player.getInboundConnection() == this + ? this.player.getOutboundConnection() + : this.player.getInboundConnection(); + + if (peer == null) { + return null; + } + + return peer.streams.get(networkChannel); + } + public String getIdentifier() { if (this.hasPlayer()) { return player.getIdentifier();