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

128 lines
8.7 KiB
Markdown

# 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 <path>`, see `tachyon.conf.example`);
CLI flags override it. Flags: `--port` / `--no-fork` / `--no-dissonance` / `--no-encrypt` /
`--no-rooms` / `--room-url <u>` / `--room-timeout <ms>` / `--private-key <xml-file>` /
`--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 <capture>` 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 <room-url>?id=<AI>` → 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).