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,71 @@
# Follow-up request: the RecRoomClientEncryptLayer 9-byte format
Your Tachyon findings resolved the handshake failure. Reconciled against the server side:
- The client's inbound layer strips 9 bytes off **every** datagram before LiteNetLib parses it.
- Our stock server sent a 15-byte ConnectAccept; after the client strips 9 it is 6 bytes, so
`NetConnectAcceptPacket.FromData` fails `Size != 15` and returns null — the exact silent
ignore-and-retry-to-timeout we observed.
- Confirmed on the server: a stock server absorbs the client's over-long ConnectRequest (the 9
extra bytes fold into variable-length connect data), which is why the server logged
"peer connected" while the client never did.
So the server needs the matching layer. We have built it as a configurable
`PacketLayerBase` with `ExtraPacketSizeForLayer = 9`, and proved the plumbing: with the same
9-byte framing on both ends the handshake completes; with it on the server only, a stock
client fails identically to the game today.
**The one thing blocking a live test is the exact 9-byte format and the null-key behaviour.**
This is §3 / step 1 of your own task doc, and it is now the whole critical path.
## Q1 — `RecRoomClientEncryptLayer.ProcessOutBoundPacket` (RVA `0x735D760`)
Full disassembly / decompile. Specifically:
- Are the 9 bytes **prepended** (written at the front, payload shifted) or **appended**
(written after the existing payload)? The offset math in the method answers this.
- What are the 9 bytes when the session key field (`DAKIIEIEKNI`, `NetPacket`+`0x18`) is
**null** — the state during the whole LiteNetLib + NGO handshake, before
`RecRoom_SetEncryptionInfo` runs? Zero fill? A counter? A magic constant? Copied from
somewhere in the packet?
- When the key is **non-null**, what transforms the payload — AES over the body, and the 9
bytes carry a nonce/tag? We need the outbound path, but the null-key branch is what unblocks
the handshake.
- Does it branch on packet property (e.g. treat ConnectRequest/Accept differently from
Channeled data), or is it uniform for every datagram?
## Q2 — `RecRoomClientEncryptLayer.ProcessInboundPacket` (RVA `0x735DF90`)
Full disassembly / decompile. Specifically:
- Does it **validate** the 9 bytes (compare a MAC/magic/counter and drop on mismatch), or just
**strip** them? This is the single most important question: if it only strips when the key is
null, a zero-fill server reply connects immediately; if it validates, we must reproduce the
exact bytes.
- Same prepend-vs-append question from the read side (which 9 bytes are removed, front or back).
- The null-key branch specifically — what happens to a datagram that arrives before a key is set
(which is every handshake packet). Does it early-out, strip-only, or reject?
- Any length guard (e.g. drop if `size < 9`).
## Q3 — the two unidentified layer methods
`0x735DC20` and `0x735E140` are listed as unidentified members of the layer. One-line summary
of each — likely a key setter (the sink for `RecRoom_SetEncryptionInfo`) and/or a
nonce/counter helper. If one installs the key into `DAKIIEIEKNI`, note its signature.
## Q4 — the `.cctor` at `0x735E220`
If the layer has static state (a fixed magic, a base nonce, an RNG seed), it is initialised
here. Dump any constants it writes.
## Why this precise scoping
We are one bounded read from a live handshake. If Q2's null-key path is strip-only, the layer
we have built connects the real client as-is and we move straight to the NGO ConnectionRequest
(where your Tachyon JSON / `"Carrot"` findings take over). If it validates, we need the byte
recipe before any wire test is meaningful — a guess here is the "plausible-but-wrong costs more
than not-found" trap you flagged.
Report bytes and offsets, not prose. Where the null-key path is genuinely absent (i.e. the
layer assumes a key is always set), say so — that itself would be a finding, implying the key
is established out-of-band before the first datagram.
@@ -0,0 +1,73 @@
# Request #3: the forked NGO batch/message framing and ConnectionApproved
The LiteNetLib layer is solved and the client now completes the transport handshake against
our server and sends its NGO ConnectionRequest. We decoded it and confirmed the Tachyon JSON
(`AI/AT/VB/CKA/CIA/CPK`). But the fork changed NGO's batch and message framing, so we cannot
send a ConnectionApproved the client will accept without the exact serialization.
## What we already worked out from the wire (for your cross-check)
Decoded from a real 2041-byte ConnectionRequest batch:
```
[0:2] magic u16 = 0x1160
[2:4] count u16 = 0 (stock BatchCount, sent as 0)
[4:8] size u32 = 2041 (total datagram length)
[8:16] hash u64 = 0x3faa53cee8c5e338 (= XXHash64 over bytes [24:2041], VERIFIED)
[16:24] u64 = 1 (fork field; equals message count here)
[24] bitpacked = 1 (message type: ConnectionRequest, client-local idx)
[25:27] bitpacked = 2014 (message size)
[27] 0x81 (version-header preamble byte)
[28:63] 7 × { u32 hash ; 1 byte version=0 }
[63:68] 00 00 00 00 01 (5 bytes, not in stock NGO)
[68:76] u64 ConfigHash
[76:80] u32 = 1961 (ConnData length)
[80:2041] Tachyon JSON
```
So the batch hash covers `[24:end]` (message stream), NOT stock's `[16:alignedLength]`, and
the batch is NOT padded to 8 bytes. Two message-body bytes are unexplained: the `0x81`
preamble, and the `00 00 00 00 01`.
## Questions (in priority order)
**Q1 — forked batch header & hash, in `NetworkMessageManager`.**
Confirm the header layout above. Specifically:
- Is the `u64` at `[16:24]` the message count, a tick, or something else? What writes it?
- Confirm `BatchHash = XXHash64(buffer + 24, size - 24)` (or give the real range/seed).
- **Does the RECEIVER validate `BatchHash` and reject on mismatch, or log-and-continue?**
This decides whether our outbound hash must be exact.
- Does the receiver iterate messages by the `u64` count, by `BatchCount`, or by position?
**Q2 — the version-header preamble.**
In the forked `ConnectionRequestMessage.Serialize` (and the shared version-header code), what
is written before the version table? Stock writes `BytePacker.WriteValueBitPacked(N)` (N=7 ->
`0x39`). We see a single `0x81`. And what is the `00 00 00 00 01` written AFTER the table,
before `ConfigHash`? Give the exact field sequence of the forbidden segment as this build
serializes it.
**Q3 — `ConnectionApprovedMessage` serialization (the thing we must send).**
Full `Serialize` (server→client) for this build. We need every field and its encoding, in
order: the version header (same preamble as Q2?), `OwnerClientId`, `NetworkTick`, any
`ConnectedClientIds`, `sceneObjectCount`, and anything the fork added. Also the message
**type index** the client expects for ConnectionApproved in the pre-approval (local) space,
and how `Deserialize` reads it — because the client's `Deserialize` is what accepts or rejects
our packet.
**Q4 — how the approval reaches Tachyon.**
Earlier finding: `NetworkManager.RecRoom_ConnectionInfo` is a fork addition, hypothesised to
carry the approval JSON (`PMFAAFNFMAL`, the `"Carrot"` / `SessionKeyB` blob). Confirm whether
the approval JSON rides inside `ConnectionApprovedMessage`'s ConnectionData, in
`RecRoom_ConnectionInfo`, or elsewhere — i.e. where in the server→client bytes we must place
`{"DR":"Carrot",...}`.
**Q5 — key-null path for the session.**
Does the client require a non-null `SessionKeyB` to proceed, or will it accept an approval with
`"Carrot"` and no key and stay plaintext (marker `0x11`)? This decides whether we can defer the
RSA keypair patch and run voice unencrypted.
## Output
Decompiled `Serialize`/`Deserialize` bodies where possible, else exact field order + encodings
+ byte offsets. Distinguish read from inferred. If a field genuinely is not present, say so —
do not fill it from stock NGO; the fork has already diverged in three places above.
@@ -0,0 +1,58 @@
# Request #4: the Dissonance session handshake (server side)
Milestone reached: the real client completes the full LiteNetLib + encrypt-layer + forked-NGO +
Tachyon handshake against our server, connects without crashing, and reports "connected to
voice" — all in plaintext (marker `0x11`; omitting `KT` left the client key-null and its
post-connect RSA callback is evidently caught).
Now the client sends a **Dissonance handshake** over an NGO `NamedMessage` and repeats it every
~4 seconds because our server does not answer:
```
NGO NamedMessage, hash = 0xc78b0000000f0001
Dissonance payload (13 bytes) = 04 01 00 00 03 c0 00 00 bb 80 00 02 32
^^ type? ^^^^^ ^^^^^
0x03c0=960 0xbb80=48000 (Opus frame / sample rate)
```
**Confirmed empirically:** with a real client connected and the user actively speaking, the
client sends ONLY this handshake, repeating every ~4s, and no voice/UnnamedMessage/other
channel ever appears. So voice transmission is hard-gated on this handshake being answered —
this is the sole remaining blocker to plaintext voice.
Confident partial decode of the 13-byte payload (unverified field names): leading `0x04` =
message type; `0x03c0` = 960 = Opus frame size; `0xbb80` = 48000 = sample rate. The rest
(`01 00 00`, `00 00`, `00 02 32`) is unidentified.
To progress to actual voice, the server must send the Dissonance handshake reply. Questions,
from `DissonanceVoip.dll` / `Dissonance.Integrations.Unity_NFGO.NfgoCommsNetwork` /
`DissonanceNetworkManagerInjector` (`0x826A370`):
**Q1 — the NamedMessage channel.** What is `0xc78b0000000f0001`? Is it the hash of a Dissonance
channel name that NGO's `CustomMessagingManager` registered, and does the server reply on the
same channel/hash? Give the name if you can recover it.
**Q2 — the handshake message format.** Decode the 13-byte payload: what is the leading `0x04`
(a Dissonance `MessageTypes` enum?), and the full field layout. Confirm the 960/48000 reading
and identify the rest.
**Q3 — the server reply.** What does the client expect back — a Dissonance `HandshakeResponse`
that assigns it a player/session id? Give that message's type byte and field layout, and how
the client matches it to its request. This is the specific thing that will stop the 4s retry
loop and move the client into its connected voice state.
**Q4 — steady-state voice.** Once handshked, what does a voice packet look like (type byte,
whether it carries the player id and a sequence, and whether the audio body is opaque Opus we
can relay without parsing)? For proximity voice a relay ideally forwards voice bodies verbatim
between clients in a room — confirm that is viable, i.e. the server does not need to re-sign or
re-encode.
**Q5 — session/room membership.** How does the client learn about other players (join/leave)?
Is there a server broadcast the relay must send when a second client connects, so the two
clients hear each other?
## Output
Message-type bytes, field offsets, and the exact server reply bytes where possible. Dissonance
is a known asset (Placeholder Software) — if the protocol matches a documented/derivable
version, say which. Distinguish read from inferred.
@@ -0,0 +1,44 @@
# Request #5: exact Dissonance packet field encodings (client's PacketReader)
Request #4 landed it: NamedMessage = `u16 NamedMessageType LE` + `i32 length LE` + Dissonance
packet; reply on type 2 (DissonanceToClient); Dissonance is stock v8.x, magic `0x8bc7`
big-endian. We've implemented a best-effort HandshakeResponse from the docs' field lists and
are testing it live. This request is the authoritative fallback for the byte-exact encodings the
docs don't specify — read from the CLIENT's `PacketReader` (what parses what we send), which is
the ground truth for what we must produce.
Dissonance networking is in the un-indexed regions you noted; the reader methods are
`AJLLGKJMHIO` (PacketReader) / `BKJPKJLIHFE` @ 0x3136430 (magic check) and the `BaseClient`
handshake handler.
**Q1 — `ReadHandshakeResponse` exact layout.** After magic + type(5): confirm the order and
encoding of session id (u32?), assigned client id (u16?), and the three lists (clients, rooms,
listeners). For each list: is the count a **byte, u16, or u32** (big-endian?), and what is each
entry's layout? For the client list entry: name + codec settings + id — in what order, and how
is the **string (name)** encoded (length prefix width + UTF8? null sentinel?). Does the client
list include the joining client itself or only others?
**Q2 — `ReadClientState` layout.** Fields: name, client id, codec settings, rooms list. Same
questions — string encoding, list count width, field order. This is what we broadcast when a
second client joins so the two see each other.
**Q3 — `ReadVoiceData` header.** After magic+type(2)+session: sender client id (u16?), the
8-bit flags bitfield, the sequence number (u16?), and the "channels this voice is addressed to"
list (count width + per-channel layout). We want to relay the body verbatim — confirm the whole
packet after the session id can be forwarded unchanged to other clients, or if the server must
rewrite the sender id / channel list.
**Q4 — the `CodecSettings` block.** Confirm `byte codec, u32 frameSize, u32 sampleRate` (BE),
matching the captured HandshakeRequest (`01`, 960, 48000). Is there a trailing field? The
captured request had `00 02 32` after the codec settings that we read as the player name —
confirm the name encoding from `ReadHandshakeRequest`.
**Q5 — client id / session semantics.** Does the server pick the session id freely (any nonzero
u32)? What range/rule for assigned client ids (start value, reserved ids)? The client kicks on
"wrong session ID" for subsequent packets — confirm it stores the session from HandshakeResponse
and echoes it, so our chosen value just has to be consistent.
## Output
Byte offsets, field order, count widths, endianness, string encoding. This is the last layer —
once these are exact, plaintext voice relay is a direct implementation.
@@ -0,0 +1,106 @@
# Request to the client-side agent (dumped game image)
## Context
We have a third-party NGO/LiteNetLib server that the game connects to. The LiteNetLib
transport handshake **fails in a specific, reproducible way**, and we've narrowed it to one
thing: the client silently ignores our `ConnectAccept`.
Evidence, so you know what's already excluded:
- The client's `ConnectRequest` is byte-identical in shape to a stock LiteNetLib 1.x request:
`protocolId = 13`, `HeaderSize = 18`, `addrSize = 16`, plus **3 bytes of connect data
`01 00 11`**. So the client's outbound path is stock or near-stock.
- Our server replies with a stock 15-byte `ConnectAccept` whose `connectionTime` matches.
- The client ignores it and resends `ConnectRequest` ~11 times, all with the *same*
`connectionTime` — i.e. one attempt being retried, never leaving `ConnectionState.Outgoing`.
- Meanwhile our server thinks it is connected, pings, gets no Pong, and times out at 5s.
- A synthetic stock-LiteNetLib-1.2.0 client, run from the *same machine and NAT* as the game,
connects to the same server successfully. So this is not the network, not NAT, and not our
server. It is client-side.
- We reproduced the exact symptom against a stock client by making the accept the wrong
length: `NetConnectAcceptPacket.FromData` begins `if (packet.Size != Size) return null`,
which yields precisely this silent-ignore-and-retry behaviour.
**Working hypothesis: the game's bundled LiteNetLib is forked and expects a `ConnectAccept`
that differs from stock — most likely a different length.** We need the exact layout.
The bundled LiteNetLib lives inside `Netcode.Transports.LiteNetLib.dll` (reported earlier as
116 types, "LiteNetLib itself bundled inside"). Third-party assemblies appear to have kept
their real names, so these types should be findable by name rather than by obfuscated slug.
## Stock reference (LiteNetLib 1.2.0, MIT) — diff against this
```csharp
internal sealed class NetConnectAcceptPacket
{
public const int Size = 15;
public readonly long ConnectionTime;
public readonly byte ConnectionNumber;
public readonly int PeerId;
public readonly bool PeerNetworkChanged;
public static NetConnectAcceptPacket FromData(NetPacket packet)
{
if (packet.Size != Size) return null;
long connectionId = BitConverter.ToInt64(packet.RawData, 1);
byte connectionNumber = packet.RawData[9];
if (connectionNumber >= NetConstants.MaxConnectionNumber) return null;
byte isReused = packet.RawData[10];
if (isReused > 1) return null;
int peerId = BitConverter.ToInt32(packet.RawData, 11);
if (peerId < 0) return null;
return new NetConnectAcceptPacket(connectionId, connectionNumber, peerId, isReused == 1);
}
public static NetPacket Make(long connectTime, byte connectNum, int localPeerId)
{
var packet = new NetPacket(PacketProperty.ConnectAccept, 0);
FastBitConverter.GetBytes(packet.RawData, 1, connectTime);
packet.RawData[9] = connectNum;
FastBitConverter.GetBytes(packet.RawData, 11, localPeerId);
return packet;
}
}
```
Stock wire layout: `[0]` property (6) | `[1..8]` connectionTime | `[9]` connectionNumber |
`[10]` isReused | `[11..14]` peerId.
## What we need
**Q1 (highest value — this likely ends the investigation).**
`LiteNetLib.NetConnectAcceptPacket` in the bundled copy:
- the value of `Size`;
- every field and its byte offset;
- the full body of `FromData`, especially every early-return/rejection condition;
- the full signature and body of `Make`.
If `Size != 15`, tell us what the extra bytes are and where `Make` writes them.
**Q2.** `LiteNetLib.NetConstants.ProtocolId`. We derived 13 from the wire; confirm from the
image. Also `MaxConnectionNumber` if it differs from stock (4).
**Q3.** Is `NetManager` constructed with a `PacketLayerBase` (a second constructor argument,
or a non-null `_extraPacketLayer` field)? If yes, name the layer type and give
`ExtraPacketSizeForLayer` plus the bodies of `ProcessInboundPacket` / `ProcessOutBoundPacket`.
We ruled a layer out by inference from wire bytes; we want it confirmed from the image.
**Q4.** `LiteNetLib.NetConnectRequestPacket.HeaderSize`, and **what writes the 3 connect-data
bytes `01 00 11`**. Find the `NetManager.Connect(...)` call site — expected to be in the NGO
voice session wrapper (`BAOFAOBLAMJ`) or in `LiteNetLibTransport` — and show how that
`NetDataWriter` is populated. If those bytes are a version/handshake field, we need to know
whether the server is expected to validate or echo them.
**Q5 (only if cheap).** In `NetPeer`, the method handling an inbound accept (stock name
`ProcessConnectAccept`): list every condition that returns false. That is the definitive list
of reasons our accept could be dropped.
## Output format
For each question: the decompiled source if you have it, otherwise field offsets and constant
values. Give **byte offsets and exact integer values** rather than prose. Where the fork
matches stock, just say "matches stock" — we only need the diff.
If something genuinely isn't present in the image, say so explicitly. Do not infer the layout
from stock LiteNetLib or reason about what it "should" be — we already have stock, and a
plausible-but-wrong layout costs us more than a "not found".
@@ -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.
@@ -0,0 +1,194 @@
# Tachyon voice server — findings from client static analysis
Source: static analysis of Rec Room build `20250718.01`. Every RVA and obfuscated name below
is valid only for that build and must be re-derived by shape for any other. Facts are tagged
**[PROVEN]** (read from the image) or **[UNVERIFIED]** (hypothesis — confirm before building on it).
This file is the reference; the wire mechanics we have implemented/verified live in
`ngo-litenetlib-wire.md`. Open reads are tracked in `client-agent-request-2.md`.
## Architecture
**[PROVEN]** Dedicated server, star topology, server relays audio. The client can only ever be
a client: `NetworkManager.StartHost`/`StartServer` have zero call sites;
`LiteNetLibTransport.StartServer` is dead code; `StartClient` binds an ephemeral port and only
connects outbound. Dissonance role selection always resolves to client.
## Protocol stack
```
UDP
└─ RecRoomClientEncryptLayer +9 bytes/datagram, always present [PROVEN]
└─ LiteNetLib 1.x ProtocolId/HeaderSize UNVERIFIED
└─ NGO 1.6+ (trimmed) 7 message types, no objects/vars/scenes [PROVEN]
└─ Tachyon handshake JSON in the NGO connection payload
└─ Dissonance 8.x over NGO named/unnamed messages
```
## LiteNetLib layer (task doc section 4)
**[PROVEN]** Bundled inside `Netcode.Transports.LiteNetLib.dll`, name-obfuscated except
`LiteNetLibTransport`. `NetConnectAcceptPacket` matches stock exactly: `Size == 15`,
connectionNumber at `[9]` (reject `>= 4`), isReused at `[10]` (reject `> 1`), peerId at
`[11..14]` (reject negative). `ProcessConnectAccept` matches stock: requires state `Outgoing`
and `ConnectionTime == peer.ConnectTime`.
**[UNVERIFIED]** `NetConstants.ProtocolId` and `NetConnectRequestPacket.HeaderSize` — live in
metadata `fieldDefaultValues`, not yet walked. Our capture pins ProtocolId to **13** from the
wire, but that is not yet confirmed from the image. The 3 connect-data bytes `01 00 11` were
not traced to their writer.
## Encrypt layer (task doc section 3) — SOLVED
`RecRoomClientEncryptLayer` (`MGHAOMEMAHF`), always installed. **[PROVEN, read from image]** It
is a variable-length **trailing-marker** scheme — `ExtraPacketSizeForLayer = 9` is the
worst-case reservation, not the bytes added. The marker is always the LAST byte.
Method map (the task doc had inbound/outbound swapped — corrected):
| RVA | method |
|---|---|
| `0x735DC20` | `ProcessOutBoundPacket` |
| `0x735D760` | `ProcessInboundPacket` |
| `0x735DF90` | `SetKey` (copies exactly 32 bytes, AES-256 sized) |
| `0x735E140` | `ClearKey` (nulls key on disconnect) |
Outbound: `key == null` → append one byte `0x11`, `length += 1`. `key != null` → append 8-byte
RNG nonce + marker `0x2A` (`length += 9`) and encrypt the payload. No branch on packet property.
Inbound: read last byte. `0x11` → strip 1 (no key needed). `0x2A` → decrypt, needs key,
requires `length > 9`, strip 9. Any other marker → **drop** (this is why our earlier
`--accept-extra 00` sweep silently failed — `0x00` is an unknown marker).
**The whole LiteNetLib + NGO handshake runs key == null, so plaintext `0x11` is all that is
needed to reach NGO.** Implemented and verified end-to-end in
`src/main.cpp` (marker lambdas + `patches/`) (`--encrypt-layer`): the ConnectAccept goes out as 16
bytes (15 + `0x11`), the client accepts, and marker-framed NGO data flows.
**[UNVERIFIED]** The encrypted-path cipher. The transform (`0x73653E0`) is Burst-compiled and
dispatches through function pointers; the 32-byte key does not by itself prove AES. Not needed
for the handshake. It only matters if the session actually sets a key — which the Tachyon
approval flow controls, so keeping the client key-null (plaintext `0x11` throughout) is the
path worth checking before touching the cipher.
**[PROVEN deduction]** The connect-data bytes we saw as `01 00 11` are really `01 00` (2 bytes)
plus the layer's trailing `0x11` marker.
## NGO layer (task doc section 5)
**[PROVEN]** Trimmed to 7 message types, no NetworkObject/Behaviour/Variable/spawn/scene. We do
not reimplement NGO — just handshake, time sync, and a named/unnamed relay. NGO type names are
not obfuscated. Rec Room forked `NetworkManager` with `RecRoom_ConnectionInfo` get/set.
**[UNVERIFIED]** Hypothesis: `RecRoom_ConnectionInfo` carries the approval JSON to the client,
since stock `ConnectionApprovedMessage` has no arbitrary payload. Accessors are shared stubs —
call-xref is unreliable here.
## Tachyon handshake (task doc section 6)
Session wrapper `BAOFAOBLAMJ` = `TachyonClient`, `RecRoom.Networking.PhotonImpl.Runtime`.
**[PROVEN] Client to server** (NGO connection-request payload), `HCNIOOINPCA` `0x82786B0`,
JSON type `PAFCNOLGBNM`, all keys `[JsonProperty]` `Required.Always`, `byte[]` as base64:
| key | member | type |
|---|---|---|
| `AI` | AccountId | string |
| `AT` | AccessToken | byte[] |
| `VB` | VerificationBlob | byte[] |
| `CKA` | ClientKeyA | byte[] |
| `CIA` | ClientIVA | byte[] |
| `CPK` | ClientPublicKey | byte[] (RSA CspBlob) |
**[PROVEN] Server to client** (approval payload), `CDJMICBEALM` =
`NetworkManager_OnClientConnectedCallback` `0x8277880`, JSON type `PMFAAFNFMAL`:
| key | member | type | note |
|---|---|---|---|
| `DR` | Reason | string | `Required.Always`; **must equal `"Carrot"`** |
| `KT` | SessionKeyB | byte[] | `Required.Default`; RSA-encrypted to the client's `CPK` |
Split-key: transport key = f(client `CKA`/`CIA`, server `SessionKeyB`). **[UNVERIFIED]** the
combination function — read `0x8277880`.
**[PROVEN] The RSA blocker.** `DMIBBCKIGCG` (`.cctor` `0x827A820`) holds a hardcoded 1024-bit
RSA **public** key; the client encrypts `AT`/`VB`/`CKA`/`CIA` to it. A working server needs the
matching **private** key, which is Rec Room's and not present in the client. Recommended route:
generate our own 1024-bit keypair and **patch the embedded modulus literal** in the client
(plain string in `.data`), coordinated with the client patcher so both ship the same keypair.
Key material and the RSA modulus are NOT committed to this repo — supplied at runtime.
## CPK is opaque — the RSA blocker still stands
The client-agent's "you can mint KT without Rec Room's private key" does **not** hold against
the capture. The `CPK` in the request JSON is 160 bytes of high-entropy data: no
`PUBLICKEYBLOB` header (`0x06 0x02`), no `RSA1` magic, and an even low byte — so it is not a
plaintext RSA modulus or CspBlob. **CPK is itself RSA-encrypted** (to Rec Room's key, like
AT/VB/CKA/CIA). A third-party server cannot read it, so it cannot encrypt a `KT` the client
will decrypt. The `KT`-minting path is therefore not viable without Rec Room's private key.
Consequence: to run voice, the client-patch route is the real option — neuter the connected
callback's encryption setup / the layer's `SetKey` so the transport key stays null and the
layer keeps emitting plaintext `0x11`. This is independent of reaching NGO "Connected", which
does not need a valid `KT` at all (ConnectionApproved.Deserialize does no validation).
## NGO handshake: implemented
The server now speaks the fork batch format (`--fork`) and sends a ConnectionApproved:
24-byte header (`BatchCount` at offset 16, hash `XXHash64` over `[24:]`), message type 0
(ConnectionApproved in the client's pre-approval local order), the client's 8-entry version
header echoed verbatim, `OwnerClientId`, `NetworkTick`, and a length-prefixed
`{"DR":"Carrot"}` payload. Verified offline against the real captured request: the fork parser
confirms the batch hash and our reply round-trips. `KT` is omitted (CPK opaque), so the
client's post-connect callback will throw on `RSA.Decrypt` *after* the Connected event — which
is the expected boundary of what a patch-free server can reach.
## Dissonance (task doc section 7)
**[PROVEN — live]** After NGO Connected, the real client sends a Dissonance handshake as an NGO
`NamedMessage` (our message index 3), plaintext (marker `0x11`), and **repeats it every ~4s
because the server does not answer**:
```
NamedMessage hash = 0xc78b0000000f0001
payload (13 bytes) = 04 01 00 00 03 c0 00 00 bb 80 00 02 32
```
`0x04` looks like a Dissonance message-type byte; `03c0` = 960 and `bb80` = 48000 decode as the
Opus frame size and sample rate, so this is the client advertising its audio config and waiting
for a server handshake reply. This is the next layer to implement — see
`docs/client-agent-request-4.md`. For a proximity relay the *voice* packets can still be
forwarded opaque; only the session handshake needs server logic.
**[PROVEN]** `NfgoCommsNetwork` present, role from NGO client flags; integration rewired onto
raw named/unnamed messages (no NetworkBehaviour). Derive the handshake reply from
`NfgoCommsNetwork` methods, `DissonanceNetworkManagerInjector` `0x826A370`, and `DissonanceVoip.dll`.
## MILESTONE (2026-08-21): full handshake works, plaintext, no client patch
The server (`reference`, `--encrypt-layer --fork`) completes the entire stack against the
real client: LiteNetLib + encrypt layer + forked NGO + Tachyon `"Carrot"` approval. The game
**connects, does not crash, and reports "connected to voice."** Traffic is plaintext `0x11` —
omitting `KT` left the client key-null (its post-connect RSA callback is caught, non-fatal), so
**no client patch is needed** for plaintext voice. What remains is the Dissonance session
protocol above, then multi-client relay.
## Matchmaking DTO (task doc section 8)
**[PROVEN]** `GET https://match.<host>/player/connection-info?roomInstanceId={long}` returns a
bare 9-key DTO including `VoiceConnectionInfo` (`"<ip>:<port>"`) and `VoiceServerId`. Client
advertises `VoiceServerVersion` in the `matchmake/v2/room` body; server returns the allocated
voice server here. Empty strings for both voice fields = "no voice", definitely accepted — so
voice can ship dark while the handshake is brought up.
## Order of work (from the task doc)
1. Read the encrypt-layer 9-byte format + null-key path (`client-agent-request-2.md`). **now.**
2. Pin ProtocolId / HeaderSize from `fieldDefaultValues`.
3. Confirm how the approval JSON reaches the client (`RecRoom_ConnectionInfo`?).
4. Read the key-combination function at `0x8277880`.
5. Client to `Connected`: LiteNetLib + NGO ConnectionRequest/Approved + `"Carrot"`.
6. Time sync, then the Dissonance relay.
Steps 1–4 are each a bounded read that silently invalidates the implementation if guessed —
land them as written findings before server code.