the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
@@ -0,0 +1,286 @@
# 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.