# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is **Tachyon** is a clean-room, protocol-compatible **voice server** for the Rec Room client (build 20250718.01) — in-room proximity voice over Dissonance → NGO → a RecRoom encrypt layer → LiteNetLib. It is its own protocol (not Photon; unrelated to the sibling LuxonServer beyond shared clean-room posture). Two real clients can hear each other through it, plaintext, with no client patch. Compatibility logic is derived only from public MIT sources (NGO, the community LiteNetLib transport, LiteNetLib) and black-box traffic observation. Client static analysis is done by a **separate agent** on a dumped image; questions to it live in `docs/client-agent-request-*.md`. `docs/ngo-litenetlib-wire.md` and `docs/tachyon-findings.md` are the authoritative wire spec, tagged `[PROVEN]`/`[UNVERIFIED]` — read them before touching protocol code, and keep them in sync when the wire understanding changes. ## The implementation One server: `src/` → **`tachyon`** (C++23). Transport is the `vendor/LiteNetLibPP` submodule; hashing is the official single-header xxHash in `third_party/`; the recflare room lookup uses libcurl. (A C#/.NET reference implementation existed earlier and proved the protocol; it has been removed — the wire knowledge now lives in `docs/`. Old commits still have it if ever needed.) ## Build & run ```sh git submodule update --init --recursive # LiteNetLibPP is a pinned submodule cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j ./build/tachyon --port 7777 # fork + dissonance + 0x11 layer + rooms are defaults ``` Configure applies `patches/0001-tachyon-packet-layer-hook.patch` to the submodule working tree idempotently, so the submodule shows as "dirty" afterward — **that is expected**, not a mistake (reset with `git -C vendor/LiteNetLibPP checkout .`). Needs CMake ≥3.16, a C++23 compiler, **`libcurl-devel`** (room lookup) and **`openssl-devel`** (blob decryption). Settings come from a `key = value` **config file** (`--config `, see `tachyon.conf.example`); CLI flags override it. Flags: `--port` / `--no-fork` / `--no-dissonance` / `--no-encrypt` / `--no-rooms` / `--room-url ` / `--room-timeout ` / `--private-key ` / `--ds-count-width N`; logging `-v`/`-q`/`--log-level error|warn|info|debug` (default info; per-message/relay spam is debug-only). Runs as a service: logs to stdout, exits 0 on SIGINT/SIGTERM. ### Testing `./build/tachyon --selftest [batch.hex]` is the offline test (no network, no curl): it checks bit-packing round-trips, the message-id hashes, batch build↔parse with hash verification (fork + stock), and the Dissonance primitives; an optional hex file is fed through the batch parser. Exit 0 = pass, 1 = failure (CI-friendly). Beyond that, validation is against the real client — `tachyon -v` prints per-relay/id diagnostics, and `tools/decode_lnl.py ` decodes LiteNetLib hex dumps. ## Architecture — the protocol stack (bottom-up) Each layer wraps the one below. 1. **LiteNetLib 1.x transport, ProtocolId 13.** Reliable/unreliable UDP via LiteNetLibPP. Connect handshake, ping/pong, MTU, fragmentation are the library's job. 2. **RecRoom encrypt layer** — a trailing-**marker** byte, below LiteNetLib framing. Plaintext appends `0x11` outbound and strips it inbound; `0x2A` (encrypted) is dropped (Burst cipher unidentified). The whole handshake runs key-null, so plaintext is sufficient. LiteNetLibPP has no packet-layer hook, so `patches/` adds generic `outbound_layer`/`inbound_layer` `std::function`s to `net_manager` at the socket boundary; `main.cpp` installs the marker through them. 3. **NGO forked batch.** Rec Room forks NGO's batch header to **24 bytes** (BatchCount@16); `BatchHash` is **XXHash64 over bytes [24:end]**. Messages are bit-packed `type` + `size` + body. The session's **message-id space is defined by the version list the server echoes in ConnectionApproved** (`MESSAGE_ORDER`) — the client rebuilds its mapping from that, so the echo must be verbatim. 4. **Tachyon handshake.** JSON in the NGO ConnectionData: `AI` (AccountId, plaintext) plus `AT`/`VB`/`CKA`/`CIA`/`CPK`, all RSA-encrypted to a 1024-bit public key hardcoded in the client (`VB`/`CKA`/`CIA` are single 128-byte blocks; `AT` is larger — hybrid/chunked). With the original Rec Room key these are opaque, but with a **patched client** (its embedded modulus swapped for our own keypair) the server decrypts them using the matching private key (`src/crypto.cpp`, `RSAKeyValue` XML via `--private-key`/config; `rsa_key::decrypt` tries PKCS#1 then OAEP). The server's ConnectionApproved payload must contain `{"DR":"Carrot"}`; `KT` (a session key) is currently **omitted**, leaving the client key-null → plaintext voice (`0x11`). Minting `KT` would move the client to the encrypted path (`0x2A`), which needs the transport cipher confirmed first. 5. **Dissonance session** (Placeholder Software stock v8.x). Rides in an NGO NamedMessage: `[u16 NamedMessageType LE][i32 len LE][Dissonance packet]`. **Dissonance is BIG-ENDIAN** — a deliberate exception; everything else on the wire is little-endian. Magic `0x8bc7`. The server answers `HandshakeRequest`→`HandshakeResponse` (assigns a client id), relays `ClientState` for roster discovery, and handles voice via **ServerRelay unwrap**. ### Relay model (the core design) Star topology: the server is a **dumb relay** and clients decide who hears them. A speaking client sends `ServerRelayUnreliable` = `session + recipientCount + recipientIds(u16 BE) + innerLen + inner VoiceData`. The server **unwraps** it and delivers the inner packet to each named Dissonance client id — it must NOT forward the wrapper. This is the whole reason voice works. **Room enforcement is server-side, keyed on recflare (implemented).** There is no room id anywhere on the wire — the ConnectionData JSON is only `AI`/`AT`/`VB`/`CKA`/`CIA`/`CPK` (`AI` = AccountId; the rest are RSA-encrypted auth/crypto blobs, opaque). So the server resolves the room from the AccountId: at handshake, `GET ?id=` → a bare numeric `roomInstanceId` (`src/room_lookup.cpp`, libcurl; **not cached** — a player's room changes when they switch rooms, and a reconnect must see the new one). Each room gets its **own Dissonance session id** (`m_session_by_room`), and the relay scopes `ClientState` roster, `ServerRelay` delivery, `RemoveClient`, and broadcast to the same `client_state.room`. A lookup failure or a `0` result **isolates** the client in a unique room of its own (fail-safe: it hears no one). `--no-rooms` reverts to one shared session for the whole port. Key proven fact behind this: the client routes voice by **the ds id the server assigns in HandshakeResponse** (a relay showed `account=205 senderId=1`), and it honors both that ds client id and the u32 session we hand it — which is why per-room sessions work as an enforcement layer (a cross-room packet is also dropped client-side as wrong-session). Matchmaking separately points a room's clients at a voice server via `VoiceServerId`. ### Reconnect-stable ids — an invariant to preserve Rec Room clients drop and reconnect frequently. Two mechanisms keep rosters consistent; **do not regress them**: - Dissonance client ids are **stable per player**, keyed on the Tachyon AccountId (`AI`), so a reconnect reuses its id (`m_ds_id_by_account`) instead of orphaning a ghost. - An **owner-per-id** map (`m_ds_owner`): the newest handshake owns an id, so a superseded old peer's delayed disconnect does not broadcast `RemoveClient` for an id now held by the reconnected client, and relay delivers only to the current owner. - `RemoveClient` is Dissonance message type **10** (2026-09-27, read from the 2025 client's receive switch, which accepts 1..11 only). It was 0 here until then: every "player left" was ignored as an unknown header, rosters never shrank, and a returning player's speech was discarded against the stale entry's timing ("missed target activation time ... discarding") until the listener rejoined. Symptom in Player.log: endless "Failed to find player for voice playback: N". Deployed by `UploadUpdate.bat` via `Deploy/update-tachyon.sh` (rebuilds only when `src/` changed). Untrusted-input paths (batch loop, version list) are bounds-checked (`wire::try_get_*`); keep new parsing of client data guarded — the server is exposed on UDP. ### Not yet implemented / known caveats - The room lookup is **synchronous** on the handshake path (blocks up to `--room-timeout`); make it async if join storms matter. - No object replication / NetworkVariable / scene management (the client build omits those types).