195 lines
10 KiB
Markdown
195 lines
10 KiB
Markdown
# 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.
|