Files
docs/rust-link/PROTOCOL.md
wtclaude bd83b34614 docs(modules): module-rust phase 1 as built — the transport, and three org-lead decisions
Adds docs/rust-link/, the canonical spec for the Rust bridge: PROTOCOL.md (the
game link and the website API) and INTEGRATION.md (standing it up by hand, and
which of the three components is wrong when it does not work). A new top-level
directory mirroring link/, which is decision D1 below — it keeps the uo/link
symmetry and keeps module docs separate from bridge docs.

Records phase 1 in PLAN.md as section 13. Both criteria met: a server.hello
produced by the live Rust rig travelled game → sidecar → module → the public
website API, killing the sidecar left the game untouched, and all five guards are
green on the module skeleton.

Three org-lead decisions this phase needed, none settled by section 2:

* D1 — the bridge docs live at docs/rust-link/.
* D2 — loopback is the ONLY trust boundary on the game link, no token, exactly
  as on the ServUO bridge. Argued the other way on the grounds that Rust servers
  are far more often on GSPs; overruled, and the consequence is now written down
  as the mistake rather than defended against.
* D3 — the plugin reads Oxide's own config file, so it lands inside the phase-7b
  config editor for free. The argument against — that editing Host/Port from the
  website could cut the link carrying the edit — becomes that phase's guard
  rather than a reason for a second config mechanism.

Section 11.3 is corrected in place: it read module.json's "extensions" array as
held against reality by the loader in the way "mounts" is. Only half true. The
loader checks that a named slot EXISTS and never that the module filled it —
checkDeclared covers "mounts" alone — and only ONE of R13's two slots can be
declared there at all, because site.footer.status is a CLIENT slot and naming it
fails the load outright.

Section 13.3 records five defects a running server found that no test could,
including a bootId that regenerated on every plugin load rather than every server
start — which would have asked core to reconcile its whole ledger on every
oxide.reload, for a world that never moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 19:55:35 -05:00

15 KiB
Raw Blame History

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/, 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.

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.

{
  "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 <t>, X-Api-Key: <t>, or ?token=<t> — 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 11000
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.

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.

{
  "Host": "127.0.0.1",
  "Port": 7799,
  "QueueCap": 5000,
  "ServerId": "main"
}

6.2 The sidecar — sidecar.toml

Resolved as --config <PATH>, 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.