Files
Campus-25/static/Servers/Tachyon/docs/ngo-litenetlib-wire.md
T
2026-10-14 06:59:51 +02:00

287 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.