Files
2026-10-14 06:59:51 +02:00

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