# W3CS wire protocol, version 1

All integers use little-endian byte order. A WebRTC DataChannel message holds
one complete envelope. The relay does not combine envelopes. Ordered reliable
resources, ordered reliable recovery frames, unordered expiring live deltas,
and input use separate SCTP streams. This preserves complete geometry epochs
without making disposable live frames wait for retransmission.

The live-delta stream is unordered with `maxPacketLifeTime=250` ms. The relay
keeps its buffered amount between a 256 KiB minimum and a 1 MiB maximum, with
the active limit derived from measured bandwidth and RTT. Input messages use a
separate DataChannel and an independent signaling-WebSocket copy. A monotonic
input identifier makes the two paths idempotent.

## Envelope

| Offset | Size | Field |
| ---: | ---: | --- |
| 0 | 4 | ASCII magic `W3CS` |
| 4 | 1 | protocol version (`1`) |
| 5 | 1 | message kind |
| 6 | 2 | flags |
| 8 | 4 | session identifier |
| 12 | 4 | monotonically increasing sequence |
| 16 | 4 | frame identifier, or zero for non-frame messages |
| 20 | 2 | fragment index |
| 22 | 2 | fragment count |
| 24 | 4 | payload byte count |
| 28 | 4 | CRC-32 of the transmitted payload |

The fixed header is 32 bytes. `fragment_count` is in the range 1-4096.
`fragment_index` is less than `fragment_count`.

Message kinds are `HELLO` (1), `RESOURCE` (2), `FRAME` (3), `REPAIR` (4),
`ACK` (5), and `ERROR` (6).

Flags are `COMPRESSED` (bit 0), `KEYFRAME` (bit 1), `LAST` (bit 2), and
`GEOMETRY_ANCHOR` (bit 3). The compressed payload starts with a 32-bit
uncompressed size, an 8-bit codec, and three zero padding bytes. Codec 1 is
raw DEFLATE and remains for the Python reference relay. Codec 2 is Zstandard.
Codec 3 is Zstandard with a frame dictionary: the header carries one extra
32-bit frame identifier, and the decoder uses that frame's decoded payload as
the dictionary. The browser decodes codecs 2 and 3 with the vendored
`web/vendor/zstd-wasm` build. A decoder rejects an unknown codec and rejects
a size above the kind-specific limit before it allocates memory.

An `ERROR` message carries a UTF-8 JSON payload on the reliable channel. The
recorder sends `{"error":"capture-violation","method":"..."}` when the game
uses a D3D9 path the command stream cannot represent. The browser then fails
closed into the video fallback. Silent divergence is not permitted.

`HELLO` currently carries only the producer identity JSON. The required-
opcode negotiation described below is not implemented yet.

## Implemented record opcodes

| Opcode | Record |
| ---: | --- |
| 1 | complete small frame-state snapshot |
| 2 | create buffer |
| 4 | create texture |
| 5 | update texture |
| 6 | destroy resource |
| 7 | define raw content blob |
| 8 | define full XOR delta blob |
| 9 | define compact XOR-mask delta blob |
| 10 | reset content-blob cache |
| 16-29 | fixed-function state changes and clear |
| 32 | non-indexed draw |
| 33 | indexed draw |

## Record stream

A reassembled payload contains zero or more records. Each record starts with:

| Size | Field |
| ---: | --- |
| 1 | opcode |
| 1 | flags |
| 2 | reserved, must be zero |
| 4 | record payload byte count |

Unknown opcodes can be skipped by length. Required opcodes are declared in
`HELLO`. A client rejects a session when it does not implement a required
opcode.

Resource records use a 32-bit resource identifier and 32-bit generation.
Draw records reference both values. The browser does not render a frame that
references an absent generation. It sends `REPAIR`, and the relay sends a
reliable resource snapshot.

Each `FRAME` payload starts with a complete snapshot of the small fixed-
function state. This includes the selected render states, transforms, FVF,
stream bindings, index binding, texture binding, material, active directional
light, viewport, and clear color. Later records contain changes and draws.
This makes the next complete frame usable after an earlier frame is lost.

After decompression, a relayed `FRAME` payload starts with a 32-bit reliable
resource-sequence dependency. The record stream follows that value. The
browser does not parse or render the frame until it has processed the ordered
resource channel through that sequence. It drops the frame after 100 ms if
the dependency has not arrived. This prevents cross-DataChannel reordering
from exposing a draw before its texture or content blob.

## Limits

The browser and relay enforce these version-1 limits:

| Item | Limit |
| --- | ---: |
| transmitted fragment payload | 60 KiB |
| reassembled frame | 4 MiB |
| reassembled resource message | 64 MiB |
| records per frame | 16,384 |
| draws per frame | 4,096 |
| live resources | 16,384 |
| one buffer | 64 MiB |
| one texture dimension | 8,192 pixels |
| decoded texture storage | 256 MiB |
| incomplete frame reassembly lifetime | 500 ms |

A limit violation closes command mode and activates the existing video
fallback. It does not close the game session.

## WebTransport planes (QUIC)

When the browser supports WebTransport, the command planes leave WebRTC and
ride one QUIC connection to the wt-bridge sidecar
(`https://<host>:4443/seat/<N>`). The sidecar consumes the relay's local
WebSocket planes (loopback TCP: lossless, ordered) and re-planes the
byte-identical envelope messages onto QUIC. Audio stays on its WebRTC peer.

Stream layout, server to browser. Every unidirectional stream starts with
one tag byte, then length-prefixed messages (`u32 LE size` + envelope bytes
exactly as a DataChannel would deliver them):

| Tag | Plane | Lifetime |
| ---: | --- | --- |
| 1 | frame | one stream PER FRAME |
| 2 | resource | connection |
| 3 | recovery | connection |

The per-frame stream is the point of the migration: a dependent frame that
is superseded while QUIC flow control still blocks its stream is cancelled
with RESET_STREAM, so one loss or stall never head-of-line blocks later
frames. Keyframes and geometry anchors are never cancelled. A reset stream
leaves partial fragments that the reassembler's lifetime limit reclaims.

Browser to server:

* control bidirectional stream: length-prefixed JSON. The browser opens it
  and sends `{"t":"hello","session":...}`; the bridge answers
  `{"t":"wtReady",...}` only after every relay plane is attached, or
  `{"t":"wtError",...}`. After the handshake it carries reliable input
  events (buttons, keys, wheel).
* datagrams: pointer-move events as bare JSON. Loss needs no repair - the
  page races every input over its other lanes and the relay dedupes by
  `inputId`, applying only the newest stateful input.

The bridge forwards input to the relay's `/input` WebSocket (text
messages, same JSON as the control DataChannel). Plane backpressure uses
the existing ack protocol: the bridge acks each relay message (text byte
count) only after its QUIC stream write returns, so relay-side in-flight
tracks what QUIC flow control has not yet accepted.

The page requests `{"type":"frameTransport","mode":"webtransport"}` over
signaling, which the relay treats exactly like the `websocket` fallback:
frames prefer the (bridge-held) frame WebSocket from the first frame. If
the QUIC attempt fails or dies, the WebRTC DataChannels remain the live
planes and nothing else changes.

### Resource-vs-frame scheduling

All planes share one QUIC connection, and quic-go round-robins the
congestion window byte-for-byte between streams - a full resource stream
takes bandwidth from every frame stream. The relay therefore runs the
resource plane behind frames:

* **Rate.** The reliable-lane token bucket paces resources at
  `path * 0.9 - frame_spend` once a path ceiling is known, with a 2 Mbps
  floor and the 64 Mbps microburst ceiling otherwise. A ceiling is engaged
  ONLY from path-limited evidence: the governor's backpressure-learned
  capacity, or the receive rate reported during client delivery
  congestion (a saturated path is the one moment an app-limited receive
  counter measures true capacity). Ordinary receive samples never set it -
  pacing to an app-limited rate self-pins, because a paced sender bounds
  what the client can receive. Healthy reports fade an engaged ceiling up
  15%/s and release it after a 12 s hold.
* **Window.** In-flight resource bytes (sent, un-acked by the bridge) are
  capped at ~1.5x the path BDP while a ceiling is engaged, 4 MiB
  otherwise. quic-go's `Write` blocks until bytes are packed into
  packets, so the bridge ack genuinely tracks wire progress and the cap
  bounds how much resource data can crowd the connection at once.
* **Yield.** Resources stop entirely (except dependency bytes) while the
  frame buffer shows congestion, for 2 s after a delivery-congested
  client report, and while recovery bytes are in flight - a recovery
  unfreezes the viewer and outranks background churn.
* **Dependency bypass.** Bytes up to the pending frame's declared
  `required_resource_sequence` ignore the bucket and every yield (they
  still debit the bucket, which may go negative): a frame's dependencies
  are frame bytes, and pacing must never delay the frame gate.

The capture-rate backpressure that shrinks churn measures the resource
backlog in TIME at the paced budget (cut when > ~2.5 s behind), since
pacing intentionally keeps the byte backlog at the relay.

## Capture transport: shared-memory ring

The proxy-to-relay capture stream rides a single-producer single-consumer
byte ring on tmpfs (`/dev/shm/w3cs-ring-seatN`; the game maps the same
file as `Z:\dev\shm\...` through wine's CreateFileMapping, which is a real
MAP_SHARED mmap). The byte format is IDENTICAL to the legacy capture
file - length-prefixed w3cs envelopes - so the ring is purely a transport
change: no disk writeback (~30 Mbps and ~1 GB per session gone), no read
syscalls, and no torn tails (the producer publishes `write_pos` once per
complete message, so a killed writer never exposes a partial packet).

Header layout, positions, and the full lifecycle contract live in
`native/w3cs_ring.h`. Rules of the road:

* The relay owns the ring: creates and initializes it at boot (magic
  written last), resets positions at each session start (the window where
  no writer exists), and consumes with the same adaptive 2/50 ms cadence
  as the file tail - but each empty poll is one userspace atomic load.
* The proxy is the sole producer, attaches read-write with retries until
  the header validates, and re-reads `write_pos` from the shared header
  on every message (never cached), so a CRIU-restored proxy continues
  exactly where the live ring stands.
* A full ring (reader dead for >5 s) is treated like a broken pipe:
  disconnect, clear the bounded queue, re-attach - the capture file's
  failure semantic.
* The append file remains the fallback: without `W3_D3D9_RING_FILE` the
  proxy writes the file, and the relay always tails both sources (the
  idle one costs a backed-off poll), which keeps pre-ring CRIU
  checkpoints streaming unmodified.
