Files
Campus-25/static/Servers/Tachyon/docs/tachyon-findings.md
T
2026-10-14 06:59:51 +02:00

10 KiB
Raw Blame History

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.