# NGO 1.x over LiteNetLib — wire notes for the voice relay Derived entirely from public MIT sources: `Unity-Technologies/com.unity.netcode.gameobjects` (tag `ngo/1.14.1`, cross-checked against `ngo/1.6.0`) and `Unity-Technologies/multiplayer-community-contributions` (`Transports/com.community.netcode.transport.litenetlib`). No compatibility logic here is taken from a decompiled client; the client-side observations are listed separately as open questions to be settled by black-box capture. ## 1. Transport layer LiteNetLib, **ProtocolId 13** — the `1.x` line. This is confirmed directly from a live capture: the client's `ConnectRequest` carries `protocolId = 13` at bytes `[1..4]`. Note this is **not** the LiteNetLib copy vendored inside the community transport package, which is still on the `0.9.5.x` / ProtocolId 11 line. A server built against the package's bundled sources will have every connect dropped in `NetManager` before any callback fires. Build against LiteNetLib 1.x. `ConnectRequest` layout (LNL 1.x, `NetConnectRequestPacket`, header 18 bytes): ``` [0] property (ConnectRequest = 5) | connectionNumber << 5 [1..4] protocolId = 13 [5..12] connectionTime (long) [13..16] peerId (int) [17] addrSize (16 for IPv4, 28 for IPv6) [18..] target SocketAddress, then connect data ``` Community transport behaviour the server must mirror: | aspect | value | |---|---| | connect payload | **3 bytes, observed `01 00 11`** — see below | | connection requests | accepted unconditionally (`request.Accept()`) | | clientId | `peer.Id`, `+1` on the server | | server clientId | `0` | | delivery mapping | Unreliable→`Unreliable`, …Sequenced→`Sequenced`, Reliable*→`ReliableOrdered` | | channel | always 0; LNL encodes `channel*4 + deliveryMethod` in its own header | The observed connect payload does **not** parse as LiteNetLib's `Put(string)` — an empty key writes `00 00`, and reading `01 00` as a length prefix (`size + 1 == 1`) leaves the trailing `11` unaccounted for. So the client is not using `Connect(address, port, key)` with a plain string; it passes a custom 3-byte blob. Because the transport accepts connection requests unconditionally, our server does not need to interpret it — but it must not reject on it. ## 2. Batch framing Every datagram payload is one batch: ``` offset 0 ushort Magic = 0x1160 offset 2 ushort BatchCount offset 4 int BatchSize (== padded total length) offset 8 ulong BatchHash (XXHash64, seed 0) offset 16 ... messages pad to a multiple of 8 bytes ``` `BatchHash` covers bytes `[16 .. BatchSize)` — i.e. **including the tail padding**. The receiver hashes `receivedLength - 16`, so sender and receiver agree only if the datagram arrives byte-exact. ## 3. Message framing ``` bitpacked uint MessageType (index into the negotiated id space) bitpacked uint MessageSize (payload bytes, excluding this header) byte[] payload ``` ### Bit-packed integers 32-bit: low 3 bits of the first byte hold the byte count `n`. If the value needs ≥30 bits, the first byte is literal `5` followed by a raw LE `uint32`. Otherwise `v <<= 3`, `n = usedByteCount(v)`, write `v | n` in `n` LE bytes. 64-bit: identical with 4 bits and a `9` escape; `v <<= 4`. Signed values are zig-zag encoded first (`(v << 1) ^ (v >> 31)`). Arrays written with `WriteValueSafe` are a **raw LE `int` length** followed by raw elements — not bit-packed. ## 4. The id space (the important part) Message ids are **not** a fixed table. Each side builds a local list of its message types, sorted ordinal by `Type.FullName`, then moves `ConnectionApprovedMessage` and `ConnectionRequestMessage` to the front (`PrioritizeMessageOrder`). Because `…ConnectionApprovedMessage` sorts before `…ConnectionRequestMessage`: > **Index 0 is always ConnectionApproved and index 1 always ConnectionRequest**, for any > build, no matter which other messages were trimmed out. That is what lets the server parse the client's very first message without knowing anything about the client's message set. Then `ConnectionApprovedMessage` carries the server's hash list, and the client calls `SetServerMessageOrder` with it — **the server's ordering becomes the id space for the rest of the session**. The server therefore *chooses* the numbering and never has to discover Rec Room's internal ordering. Message identity is `XXHash32(Type.FullName)` over UTF-8, seed 0: | index | hash | type | |---|---|---| | 0 | `0xa5e29a7d` | `Unity.Netcode.ConnectionApprovedMessage` | | 1 | `0xb31736b2` | `Unity.Netcode.ConnectionRequestMessage` | | 2 | `0xb81cf6d3` | `Unity.Netcode.DisconnectReasonMessage` | | 3 | `0x978922a6` | `Unity.Netcode.NamedMessage` | | 4 | `0x179751e9` | `Unity.Netcode.ServerLogMessage` | | 5 | `0xc6887f42` | `Unity.Netcode.TimeSyncMessage` | | 6 | `0x46f5a397` | `Unity.Netcode.UnnamedMessage` | ## 5. Handshake **Client → server, `ConnectionRequestMessage`** (id 1, version 0 in every 1.x release): ``` bitpacked int count count × { raw uint Hash ; bitpacked int Version } raw ulong ConfigHash [ byte[] ConnectionData ] only when the server has ConnectionApproval enabled ``` `ConfigHash` is `XXHash64` over, in order: `ProtocolVersion` (ushort, game-set), the literal `NetworkConstants.PROTOCOL_VERSION`, sorted prefab hashes (only when `ForceSamePrefabs`), `TickRate` (uint), then bools `ConnectionApproval`, `ForceSamePrefabs`, `EnableSceneManagement`, `EnsureNetworkVariableLengthSafety`, and `RpcHashSize`. A mismatch disconnects the client. `PROTOCOL_VERSION` is `"15.0.0"` across **all** of NGO 1.6 → 2.0, so the config hash does *not* identify the NGO version. It only constrains the game's own `ProtocolVersion`, tick rate and flags — a small enough tuple to brute-force from one captured hash. **Server → client, `ConnectionApprovedMessage`** (id 0): ``` bitpacked int count ← "forbidden segment", must come first count × { raw uint Hash ; bitpacked int Version } bitpacked ulong OwnerClientId bitpacked int NetworkTick raw int len + len × raw ulong ← ConnectedClientIds, only if targetVersion >= 1 raw uint sceneObjectCount ← always 0 for us ``` Version discipline: serialize using the version *the client advertised* for that message. `ConnectionApprovedMessage.Version` is `0` in NGO 1.6–1.7 and `1` (`k_VersionAddClientIds`) from 1.8.0 onward — implement both branches and switch on the negotiated value rather than pinning a release. Every other message in our set is version `0` across the whole 1.x line. ## 6. Session messages `TimeSyncMessage` — `bitpacked int Tick`, sent unreliable at tick rate. `NamedMessage` — `raw ulong Hash` then **opaque bytes to the end of the message**. Dissonance's own protocol rides inside those bytes; a proximity relay forwards them without parsing. `UnnamedMessage` is the same minus the leading hash. ## 7c. Forked NGO batch format (from a decoded ConnectionRequest) The client's NGO batching is **not stock**. Decoded from a real 2041-byte ConnectionRequest (`docs/client-agent-request-3.md` has the capture). Layout: ``` [0:2] magic u16 = 0x1160 [2:4] count u16 = 0 (stock BatchCount field, unused/zero) [4:8] size u32 = total datagram length [8:16] hash u64 = XXHash64 over [24:end] <-- NOTE: from 24, not 16 [16:24] u64 = 1 (fork field; == message count in this sample) [24:] message stream ``` Message: `bitpacked type` (1 = ConnectionRequest in the client's local index), `bitpacked size`, then body. The hash covers the message stream `[24:end]` exactly. ConnectionRequest body (`[27:end]`, size 2014) vs stock: ``` 0x81 <- 1 byte where stock writes bitpacked version-count (7 -> 0x39) 7 × { u32 hash ; 1 byte version=0 } version table (our 7 message hashes, all v0) 00 00 00 00 01 <- 5 bytes not present in stock, before ConfigHash u64 ConfigHash u32 ConnData length (1961) ConnData = Tachyon JSON (AI/AT/VB/CKA/CIA/CPK) ``` The `0x81` preamble and the 5-byte field are in the shared "version header" (forbidden segment), so ConnectionApproved must reproduce them. Their meaning, and the exact ConnectionApproved serialization, are the last unknowns — see `docs/client-agent-request-3.md`. ## 7b. Live progress (real client) - **LiteNetLib handshake: SOLVED against the real client.** With the trailing-marker layer, the client reaches `Connected` and holds it — Ping/Pong and MTU discovery both flow, no timeout. The client only disconnects (`RemoteConnectionClose`) after ~10s of no NGO approval. - **NGO ConnectionRequest: reached.** The client sends a fragmented Channeled batch (3 LNL fragments, ~2041 bytes reassembled) carrying the Tachyon handshake JSON (base64 `CKA`/`CIA`/ `CPK`, ends `=="}`). - **Open anomaly:** that batch parses as `magic=0x1160, count=0, size=2041`, and the batch hash does not match a stock XXHash64 over `[16..len]`. A count of 0 with a full message payload means the batch header layout and/or hash range differs from stock NGO 1.6–1.14, OR the fork frames the connection payload non-standardly. This must be resolved before ConnectionApproved, because a wrong batch header means the client rejects our approval too. Use `--dump-batches` to capture the full bytes for offline decode. ## 7. State of the evidence Confirmed from the capture in `traffic.txt` (decode with `tools/decode_lnl.py`): - ProtocolId 13 → LiteNetLib 1.x. - A 3-byte connect payload `01 00 11`, not the stock empty key. - Target `66.228.47.217:4499`. - MTU probing at 1024, consistent with the LNL 1.x `PossibleMtu` table. The capture's failure mode is unambiguous and is **entirely below NGO**: 11 `ConnectRequest`s all carrying the *same* `connectionTime` (one connect attempt, resent), each answered by a `ConnectAccept`, pings going out with **no Pong and no MtuOk in reply**, and the ping sequence resetting 1‑4 → 1‑4 → 1‑4 as the peer times out at `DisconnectTimeout` (~5s) and retries. **Not one Channeled or Unreliable data packet appears**, so nothing in sections 2–6 has yet been exercised against the real client. Whatever answered that capture accepts the connection and then stops speaking LiteNetLib. Using a real LiteNetLib library (LiteNetLibPP) in the server — rather than hand-rolling the handshake — makes Ping/Pong, MTU discovery and keepalive correct for free, which is exactly the failure that capture shows. Still open: 1. **`ConfigHash` inputs.** Capture one `ConnectionRequestMessage` and brute-force the tuple in §5 to recover `ProtocolVersion`, `TickRate` and the flags exactly. Nothing in the current capture reaches this point. 2. **Client message set and versions.** The advertised hash/version table in that request is a direct readout of the client's message set; the server logs it. 3. **The truncated byte.** Upstream `LiteNetLibTransport.OnNetworkReceive` hands NGO `UserDataSize - 1`, with the comment "the last byte sent is used to indicate the channel" — but the matching `Send` stopped appending that byte in commit `b32ffdd`. LNL carries the channel in its own header, so upstream HEAD drops a real payload byte and the batch hash cannot verify. Rec Room's build must diverge somewhere. The server tests both hypotheses per packet and reports which one matches. 4. **What the 3 connect-data bytes mean.** Not blocking, since the server accepts unconditionally, but more captures would settle it. ## 8. Transport-level encryption (Rec Room fork) — CORRECTED An earlier revision of this doc argued from wire bytes that there was **no datagram-level packet layer**. That was wrong. Client static analysis (`LiteNetLibTransport.Initialize`) shows a `RecRoomClientEncryptLayer` is **always** installed, with `ExtraPacketSizeForLayer = 9`. It adds 9 bytes to every outbound datagram and strips 9 from every inbound one, **below** LiteNetLib — so it wraps the handshake, not just NGO payloads. The clean-looking captures were app-level packet logs taken below the layer, not raw wire. ### Why this is the handshake failure - Client strips 9 bytes off every inbound datagram before LiteNetLib parses it. - A stock 15-byte ConnectAccept becomes 6 bytes → `NetConnectAcceptPacket.FromData` fails `Size != 15` → returns null → **silent ignore**. The client stays in `Outgoing`, resends ConnectRequest with the same `connectionTime`, and times out at ~5s. Exactly the observed loop. - A stock server, lacking the layer, still *accepts* the client's over-long ConnectRequest because the 9 extra bytes fold into variable-length connect data — which is why the server saw "peer connected" while the client never did. - Fix magnitude: a correct ConnectAccept is `15 + 9 = 24` bytes on the wire. Verified in the server: a `PacketLayerBase` with `ExtraPacketSizeForLayer = 9` on both ends completes the handshake; on the server only (stock client) it fails identically to the game. See `src/main.cpp` (marker lambdas + `patches/`) and `--encrypt-layer`. ### The format is a trailing marker byte — SOLVED Read from the image (see `docs/tachyon-findings.md`). The layer is variable-length; the marker is the **last** byte of every datagram. Key-null (the whole handshake): outbound appends one byte `0x11`; inbound strips it. Key-set: append 8-byte nonce + marker `0x2A` and encrypt. Any other trailing marker is dropped. So a ConnectAccept must be `15 + 1 = 16` bytes on the wire with a trailing `0x11`. Implemented in `src/main.cpp` (marker lambdas + `patches/`) (`--encrypt-layer`) and verified end-to-end. ### Payload encryption (post-handshake) Once `RecRoom_SetEncryptionInfo(clientId, key)` runs, the same layer encrypts NGO payloads with a split-key AES scheme: client contributes `CKA`/`CIA`, server contributes `SessionKeyB` (RSA-encrypted to the client's `CPK`), combined in a function at `0x8277880` (not yet read). The handshake to establish that key is JSON in the NGO connection payload — the Tachyon handshake, see `docs/tachyon-findings.md`. **Key material is deliberately not committed.** It is supplied at runtime.