# rust-link — the wire protocol **Canonical.** This document defines the two contracts that make up the Rust bridge. Code in three repositories is held against it, and a change here is a change in all of them. | Contract | Between | Transport | |---|---|---| | The **game link** | the Oxide bridge plugin ↔ the sidecar | loopback TCP, newline-delimited JSON | | The **website API** | the sidecar ↔ `module-rust` | HTTP + WebSocket, bearer token | Mirrors [`link/`](../link/PLAN.md), which is the same pair of contracts for Ultima Online. Where this document is silent, that one is not a fallback: the two protocols are independent and share only their shape. --- ## 1. Why the game does not listen **The plugin is the TCP client; the sidecar owns the listener.** A Rust server therefore opens no extra port, and the only component the website can reach is the sidecar. This is inherited unchanged from the ServUO bridge — the footing changed (Oxide hooks instead of game source) and the invariant did not. ``` Rust server + Oxide (Rust-Plugins, C#) │ the plugin DIALS OUT · 127.0.0.1:7799 · newline-delimited JSON, bidirectional ▼ rust-link sidecar (Rust-Link) ← the only network-facing bridge component │ WebSocket (live feed) + REST (point-in-time reads), bearer-token auth ▼ module-rust, inside a website core ``` **One game server, one sidecar, on that server's own host.** A community running six servers runs six pairs; `module-rust` holds six clients and the website core never learns there is more than one. Nothing in the sidecar is multiplexed and nothing in it should become multiplexed — the `serverId` on every frame exists so the *module* can tell its clients apart, not so the sidecar can. ### 1.1 Loopback is the trust boundary on the game link There is **no token on the game link**. The plugin and the sidecar share a host, and the sidecar binds `127.0.0.1` — that is the authentication, exactly as on the ServUO bridge. Binding `[game].bind` to a routable address puts an unauthenticated command channel on the network. The website-facing surface is the opposite: authentication there is **always on** and cannot be turned off. The sidecar generates and persists a token on first start, so there is no state in which it is listening without one. --- ## 2. Versioning The wire version is a single integer, declared in **four** places that must agree: | Where | Repo | |---|---| | `PROTOCOL_VERSION` in `sidecar/src/main.rs` | Rust-Link | | `ProtocolVersion` in `overlay/oxide/plugins/RunicGateway.cs` | Rust-Plugins | | `protocol` in `overlay.toml` | Rust-Plugins | | `PROTOCOL_VERSION` in `server/sidecarClient.js` | Module-Rust | Bump all four in the same change as the emitters, together with this document. **The two halves of the contract enforce it differently, and the asymmetry is the reason `overlay.toml` exists at all:** - On the **website API** the check is live. Every response carries `X-RustLink-Version`; a client that declares a different one in its request header is refused `409` with both numbers in the body, rather than served something it will mis-parse. - On the **game link** there is no such check, and a mismatched plugin would simply mis-parse. The plugin announces its protocol in `server.hello`, which is readable only after the game server has booted with it loaded — far too late for an installer to refuse a bad pairing. So `overlay.toml` declares it statically, and the installer refuses to pair an overlay and a sidecar whose numbers disagree. A bump landing in one repo and not the others fails to compose rather than half-deploying. --- ## 3. Protocol 1 — the transport Everything phase 1 defines, and deliberately nothing more. ### 3.1 Framing Newline-delimited JSON over TCP, both directions, UTF-8. One complete JSON object per line, no embedded newlines. - **Outbound frames** (plugin → sidecar) carry `kind`. - **Inbound frames** (sidecar → plugin) carry `cmd`. Both ends cap an inbound line at **1 MiB**. An over-long line is **discarded, not buffered**, and the connection stays up: a single malformed frame is not a reason to tear down a link that live events are flowing over, and a dropped reply simply times out on the caller's side and is re-requested. The cap exists from protocol 1 rather than being added after the first large frame arrives. An unbounded read facing a peer that will one day send a map image is a memory-exhaustion shape we would be inventing ourselves. ### 3.2 `server.hello` — plugin → sidecar Sent on **every successful connect**, not once at game-server start. The sidecar restarts independently of the game, so anything it needs up front has to be re-sent per connection. ```json { "kind": "server.hello", "t": 1789510452152, "protocol": 1, "serverId": "main", "bootId": "boot-20260915T194502Z", "plugin": "0.1.0", "hostname": "Test Server", "description": "No server description has been provided.", "level": "Procedural Map", "seed": 1234, "worldSize": 4000, "maxPlayers": 10, "players": 0, "joining": 0, "queued": 0, "uptimeSec": 8947, "saveCreatedAt": "2026-09-15T19:58:17Z" } ``` | Field | Meaning | |---|---| | `t` | epoch milliseconds, stamped when the world was read | | `serverId` | this server's stable identity across wipes and restarts, from the plugin's config. **Not derived from the hostname** — an operator renames a server for a season and the site must not lose its history for it | | `bootId` | see §3.2.1 | | `saveCreatedAt` | when the current save was created. **Raw material for a wipe id, not a wipe id** — deriving one is the website's job and is not yet specified, and emitting a guess now would bake a wrong one into stored rows | Everything from `hostname` down is read from `ConVar.Server` and `BasePlayer.activePlayerList` on the game's main thread. A field the game cannot answer is **absent**, never zero. #### 3.2.1 `bootId` identifies the server PROCESS It is the server process's start instant, formatted `boot-yyyyMMddTHHmmssZ`, and it must change **when and only when the world started over**. That makes three things it is deliberately not: - **Not a fresh value per plugin load.** `oxide.reload RunicGateway` must not change it. The website watches this value to tell a game restart — where everything an event put in the world is gone — from a bridge reconnect, which loses nothing; a plugin reload is the second kind, and a boot id regenerated at `Init` would ask the site to reconcile its whole ledger for no news. - **Not the sidecar's identity.** The sidecar restarting is invisible to the world. - **Not the wipe.** A wipe is `saveCreatedAt` changing; a restart is not a wipe. The plugin reads it from `Process.StartTime`, which is exact and identical on every read. ### 3.3 `ping` / `pong` — the heartbeat The sidecar sends `{"cmd":"ping"}` every 30 seconds while a plugin is connected; the plugin answers `{"kind":"pong","t":…}`. A `pong` is **never persisted**. It only moves the sidecar's `last_event`, which is the whole point: a Rust server with nobody on it is very quiet, and without a heartbeat "the game has said nothing for six hours" would be indistinguishable from "the link died six hours ago". ### 3.4 `server.status` — the request/reply verb The one correlated round trip in protocol 1. It exists so the correlation path is exercised by something before anything depends on it. ``` sidecar → plugin {"cmd":"server.status","reqId":"r-1"} plugin → sidecar {"kind":"server.status","reqId":"r-1","t":…, …the §3.2 body…} ``` **Correlation is by `reqId`, a process-unique counter minted by the sidecar.** The plugin echoes it verbatim and **only when one was supplied**: a reply that invented one would be routed to nobody, and a reply that omitted one the caller sent would leave that caller waiting out its whole timeout. `server.hello` and `server.status` share a body by construction, in one function in the plugin. They differ in what wraps them, not in what they say about the server, and letting them drift is how a site ends up showing two different player counts. ### 3.5 `link.down` — the sidecar's own observation Not a frame the plugin sends. When a plugin connection ends the sidecar synthesises `{"kind":"link.down"}` onto its broadcast channel, so the website sees the drop without polling. It is **never persisted**: it is this process's observation, not something the game said. --- ## 4. The website API Served by the sidecar. Everything except `/health` requires the token, which may arrive as `Authorization: Bearer `, `X-Api-Key: `, or `?token=` — the last so browser WebSocket clients, which cannot set handshake headers, can still authenticate. The compare is constant-time. Every response carries `X-RustLink-Version`, including `/health` and including error responses. | Route | Backed by | Notes | |---|---|---| | `GET /health` | — | **Unauthenticated**, so monitoring can reach it | | `GET /server` | the store | The last `server.hello`. **`204` when the game has never connected** | | `GET /events?kind=&limit=` | the store | Newest first; `limit` clamped to 1–1000 | | `GET /status` | the plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply | | `GET /ws` | broadcast | The live feed; sends `{"kind":"ws.hello","protocol":1}` on connect | ### 4.1 The split between store-backed and live is deliberate The store-backed reads answer **while the game server is off**, which is what lets the website render a server list during a wipe or a restart. `/status` is the one route that fails when the game is down, because "what is it doing right now" has no stale answer worth giving. ### 4.2 `204` is an answer `GET /server` answers `204`, not `200` with a null, when the game has never connected. "We have never heard from this server" and "this server reports nothing" are different answers, and a client that cannot tell them apart renders a server that does not exist. `module-rust` maps the two onto distinct stored states (`reachable` without `online`, versus neither). ### 4.3 Status codes carry the diagnosis A wrong URL, a wrong token and a mismatched protocol all present to an operator as "the site says my server is offline", and each has a different fix. The codes keep them apart: | Code | Means | Where the fix is | |---|---|---| | `409` | protocol mismatch, both numbers in the body | upgrade one component | | `401` | wrong or missing token | the admin form | | `503` | no plugin connected | the game server | | `504` | the plugin did not reply in time | the game server, differently | | *(transport error)* | nothing is listening | the sidecar, or the URL | ### 4.4 The RPC timeout is a ceiling on every later command budget The sidecar waits **10 seconds** for a correlated reply (`rpc::REPLY_TIMEOUT`). `module-rust`'s own client waits **12 seconds** (`TIMEOUT_MS`). Core's event dispatcher classifies a `budgetMs` overrun as retryable **unconditionally** — it cannot ask the action, which is still awaiting a socket. So an action whose `budgetMs` does not exceed the module's client timeout can never report `retry: false`, and that code is unreachable. The ordering is: ``` sidecar RPC timeout (10s) < module client timeout (12s) < an action's budgetMs ``` Derive one from another rather than writing all three down independently. --- ## 5. What the plugin owes the game Three rules, and each has a failure behind it. They are the ServUO bridge's, unchanged. 1. **`Emit` is called from the main thread. It formats nothing, blocks on nothing, and touches no socket.** It enqueues and returns. A slow, wedged, or absent sidecar cannot stall the game. 2. **One link thread owns the socket.** A single writer keeps event ordering intact. It reconnects with bounded backoff, and the backoff waits on a handle rather than sleeping — an uninterruptible sleep there is a stall of up to the backoff on every plugin reload, on the main thread. 3. **A reader thread parses inbound lines and marshals each to the main thread** via `Interface.Oxide.NextTick`. The reader touches no Unity object, no `BasePlayer` and no `ConVar`. The outbound queue is **bounded, drop-oldest**: on overflow the oldest record goes and is counted, because telemetry is worth less than the server's memory. ### 5.1 Diagnosing the link ``` rg.link ``` from the game server's console or over RCON: ``` protocol=1 serverId=main connected=True depth=0 sent=3 dropped=0 received=2 connects=1 writeErrors=0 bootId=boot-20260915T194502Z ``` This separates "the plugin is not loaded", "the plugin cannot reach the sidecar" and "the website cannot reach the sidecar", which look identical from the site. --- ## 6. Configuration ### 6.1 The plugin — `oxide/config/RunicGateway.json` Written by Oxide on first load; edited like any other plugin's config. ```json { "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "main" } ``` ### 6.2 The sidecar — `sidecar.toml` Resolved as `--config `, else `$RUSTLINK_CONFIG`, else `./sidecar.toml`. Environment variables override the file. | Key | Env | Default | |---|---|---| | `[game].bind` | `RUSTLINK_GAME_BIND` | `127.0.0.1:7799` | | `[game].server_id` | `RUSTLINK_SERVER_ID` | *(empty)* | | `[web].bind` | `RUSTLINK_WEB_BIND` | `127.0.0.1:8090` | | `[web].auth_token` | `RUSTLINK_WEB_TOKEN` | *(generated on first start)* | | `[store].path` | `RUSTLINK_DB_PATH` | `rust-link.db` | Two things about those are load-bearing: - **A relative `[store].path` resolves against the directory holding `sidecar.toml`**, not the working directory. A service manager's working directory must not decide where the database lands — on Windows that can be `%SystemRoot%\System32`, or a silently redirected VirtualStore copy. - **`[game].server_id` is a cross-check, not a second source of truth.** The plugin announces its own `serverId` and that is the authority; when both are set and they disagree, the sidecar logs the disagreement loudly and keeps the plugin's. Two game servers pointed at one sidecar by a copied config is the mistake this catches, and it is silent in every other design. `rust-link-sidecar --print-config` resolves the configuration exactly as a normal start would — writing the file and generating the token if they are missing — and prints it as JSON on stdout, **including the token in clear text**. That is the supported way for an installer to read it back. --- ## 7. What is deliberately not here yet Protocol 1 is the transport. Every one of these arrives with the phase that needs it, and each is a version bump: - the read path — player events, kills, clans, presence - identity and the in-game link code - the permission mirror - leases, budgets and the event actions - the map image over the asset-bridge shape The rule that governs all of them: **the sidecar is a dumb forwarder.** It defines no schema for a frame's contents, so a version that adds fields to an event needs no change there — only one that adds a new *indexed* column does.