10 KiB
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)
- Read the encrypt-layer 9-byte format + null-key path (
client-agent-request-2.md). now. - Pin ProtocolId / HeaderSize from
fieldDefaultValues. - Confirm how the approval JSON reaches the client (
RecRoom_ConnectionInfo?). - Read the key-combination function at
0x8277880. - Client to
Connected: LiteNetLib + NGO ConnectionRequest/Approved +"Carrot". - 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.