diff --git a/README.md b/README.md index 3e369e4..9840453 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ so they live in one place, independent of either codebase. website/ docs from the website core (Node/Express + MariaDB + React/Vite) modules/ docs for installable game modules — one directory per module id link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS) +rust-link/ docs from the Rust bridge (Oxide plugin + Rust sidecar) android/ docs from the native Android client (Kotlin + Jetpack Compose) installer/ docs for the installer that deploys a shard's bridge components ci/ cross-cutting CI/quality notes @@ -76,6 +77,17 @@ particular game; a module is what makes it a site *for* one. | [link-README.md](link/link-README.md) | Snapshot of the link repo's README | | [PROJECT_TREE.md](link/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout | +### `rust-link/` + +The same pair of contracts as `link/`, for Rust rather than Ultima Online: an Oxide plugin that +dials out to a sidecar, and a sidecar the website reads. The two bridges are **independent** — they +share a shape and nothing else, so neither document is a fallback for the other. + +| Doc | What it covers | +|---|---| +| [PROTOCOL.md](rust-link/PROTOCOL.md) | **Canonical** — the game link and the website API, the four declaration sites of the wire version, and what protocol 1 defines | +| [INTEGRATION.md](rust-link/INTEGRATION.md) | Standing the bridge up by hand, and which of the three components is wrong when it does not work | + ### `android/` | Doc | What it covers | |---|---| diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index ad91073..9978838 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -749,7 +749,7 @@ Each phase ends with its findings written down, as every workstream here does. | # | Phase | Repos | Done when | |---|---|---|---| | 0 | **The rig.** ✅ **Done 2026-09-15 — as built and findings in §12.** Updated to the current wipe (the script was fixed *again*, properly), Oxide re-laid, base set installed, the grant path proven end to end and both zone transitions observed live with a player connected. **Both criteria met** | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it | -| 1 | **Protocol 1, three skeletons, and every bundle seam at once.** Plugin: bounded drop-oldest queue, one writer thread, tagged reconnect epoch, dial-out. Sidecar: listener, SQLite, always-on token auth, version header, rpc correlation. Module: `id: rust`, `/rust` on all three tiers (R14), `schema.sql` **and `purge.sql`**, the **`extensions`** declaration (§11.3), per-server sidecar tokens through **`ctx.secretBox`** (§11.4), the vite aliases and shims, `checkExternals`, `checkImports`, the swagger fragment and its staleness check, explicit `onBoot`/`onShutdown`, `capabilities` | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton | +| 1 | **Protocol 1, three skeletons, and every bundle seam at once.** ✅ **Done 2026-09-15 — as built and findings in §13.** Plugin, sidecar and module all exist and all three were exercised against the live rig; three org-lead decisions (§13.0), five defects only a running server found (§13.3), and a correction to §11.3 (§13.2). **Both criteria met** | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton | | 2 | **Packaging and release.** `release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` | | 3 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, and a wipe does not erase a player's history | | 4 | **The first pages.** Server list as the landing page, `/rust/servers/:id` beneath it, killfeed, leaderboard; nav rows; the UI kit (`PublicLayout` `shell`, `PageHeader` props); `capabilities`; the `site.footer.status` slot (R13) | Module-Rust | The site renders the last thing each server said while every server is off | @@ -1118,6 +1118,8 @@ Like `mounts`, it is a statement of surface that the loader holds against realit `admin.users.detail` and `site.footer.status` are declared there as well as registered. Phase 1 adds it to the list of `module.json` fields that must be got right. +> **Corrected in phase 1 (§13.2), and it is half wrong.** The loader checks only that a named slot EXISTS (`loader.js:681` → `registries.hasSlot`); `checkDeclared` covers `mounts` **alone**, so a declaration with nothing behind it loads cleanly and means nothing. And only ONE of R13's two slots can be declared here at all — `admin.users.detail` is the only server slot core declares, while `site.footer.status` is a CLIENT slot registered from the chunk, and naming it in `extensions` fails the load outright. + ### 11.4 The `ctx` members the plan had never named `ctx` has **29 members** (§2.3). The plan named a handful. The ones that change work: @@ -1348,4 +1350,165 @@ And a reason to hold that boundary harder than R18 states: **`oxide/data/` is wh own permission store** (`oxide.users.data`, `oxide.groups.data`). A config editor that strayed one directory over would be editing R2's mirror underneath itself. +## 13. Phase 1 as built — the transport, 2026-09-15 + +**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; all five guards are +green on the module skeleton. Three repositories have their first commits: +[`Rust-Link`][rl], [`Rust-Plugins`][rp], [`Module-Rust`][mr], plus +[`rust-link/PROTOCOL.md`](../../rust-link/PROTOCOL.md) and +[`INTEGRATION.md`](../../rust-link/INTEGRATION.md) here. + +### 13.0 The three org-lead decisions this phase needed + +None of them were settled by §2, and each would have been expensive to reverse later. + +- **D1 — the Rust bridge's docs live at `docs/rust-link/`**, a new top-level directory mirroring + `docs/link/`, rather than under `modules/rust/`. It keeps the `uo`/`link` symmetry and keeps + *module* docs separate from *bridge* docs, which are different contracts with different audiences. +- **D2 — loopback is the only trust boundary on the game link**, exactly as on the ServUO bridge: + no token between plugin and sidecar. The alternative was argued on the grounds that Rust servers + are far more often on GSPs than ServUO shards are, so binding to something other than `127.0.0.1` + is a realistic operator need. **Overruled**, and the consequence is written into the plugin's + class docs and `PROTOCOL.md` §1.1: moving that bind puts an unauthenticated command channel on the + network, and it is documented as the mistake rather than defended against. +- **D3 — the plugin reads its settings from Oxide's own config file** + (`oxide/config/RunicGateway.json`) rather than a standalone `Bridge.cfg`-shaped file. It is + idiomatic for Oxide, and it lands inside R18's phase-7b config editor for free. The argument + against — that editing `Host`/`Port` from the website could cut the link carrying the edit — is + real and is now phase 7b's problem to guard rather than a reason for a second config mechanism. + +### 13.1 What phase 1 deliberately did NOT register + +The module registers **routes on three tiers and the two lifecycle hooks, and nothing else**. No +Team provider, no triggers, no audiences, no engagement seeds, no notification streams, no event +budgets/leases/actions/option sources, no extension slots. + +That is asserted by a test (`nothing is registered that has nothing behind it yet`) so that removing +it is deliberate. The reasoning is worth keeping: **a declared trigger nothing emits and a declared +slot nothing fills are both surfaces an operator can configure and then wait on**, which is worse +than an absent one, because the absence is visible. + +### 13.2 §11.3 was half wrong about `extensions` + +§11.3 reads `module.json`'s `extensions` as *"declared, not just registered… like `mounts`, held +against reality by the loader"*. Reading the loader says otherwise, in two ways: + +1. **The loader never checks that a declared slot was filled.** `loader.js:681` asks only + `registries.hasSlot(slot)` — does this slot *exist*. `checkDeclared` covers `mounts` **alone**, + in both directions. So a declaration with nothing behind it loads cleanly and means nothing. +2. **Only one of R13's two slots can be declared there at all.** `admin.users.detail` is the only + server slot core declares (`router/v1/admin/users.router.js:202` is the sole `declareSlot` call + outside the registry). `site.footer.status` is a **client** slot, registered from the chunk — + naming it in `extensions` fails the load with `unknown extension slot "site.footer.status"`. + +So the field is written when phase 6 registers the server half, and never before. Phase 4's +`site.footer.status` work does not touch it. + +### 13.3 Five defects the rig found that no test could + +Each of these was written from source reading, shipped, and then corrected by a live Rust server. +The hit rate is the phase-0 lesson repeating. + +1. **A disconnect was silent in the game console.** The link teardown log sat in `LinkLoop`'s + `catch`, and a connection that ends because the *reader* saw EOF leaves the writer to exit + cleanly — nothing throws, so nothing is logged. The fix captures `_connected` at the top of the + `finally`. Worth generalising: **a log in a catch block only covers the failures that throw**, and + an orderly shutdown of a peer is not one of them. +2. **`Unload` blocked the main thread for 1.9 seconds**, which Oxide reports as + `Calling 'Unload' on 'RunicGateway v0.1.0' took 1918ms`. The reconnect backoff was + `Thread.Sleep`, and `Unload` joins the link thread — so every plugin reload froze the server for + up to the backoff. Waiting on the same `AutoResetEvent` that `Unload` already signals makes it + immediate. **The ServUO plugin has the same `Thread.Sleep`**, and it is survivable there only + because ServUO does not hot-reload the way Oxide does. +3. **Mono's `SocketException.Message` is NUL-padded.** A connect refusal came back with ~200 `\0` + bytes in the middle of the sentence, from a fixed-size OS buffer. `\0` is not whitespace, so + `Trim()` does not touch it and neither does a whitespace-only collapse — the log line looks like + it contains a huge run of spaces and no amount of trimming removes it. The flattener has to treat + `char.IsControl` as a separator too. It took `od -c` on the log to see this at all. +4. **`bootId` regenerated on every PLUGIN load, not every SERVER start.** A fresh `Guid` at `Init` + meant `oxide.reload RunicGateway` announced a brand-new boot — and §11.1's whole reconcile design + hangs off that value, so every reload would have asked core to sweep its entire resource ledger + for a world that never moved. It is now `Process.StartTime`, which is exact, identical on every + read, and changes when and only when the thing it names changes. **Verified by reloading the + plugin twice and watching the id hold** (`boot-20260915T194502Z`, matching the process). +5. **A four-connection SQLite pool over `:memory:` hands out four empty databases.** An in-memory + database is per *connection*, so the schema created on the first pooled connection is invisible + to the second. It presents as `no such table` from a random subset of queries. The sidecar now + caps the pool at one connection for an in-memory path — which is the only coherent reading of + `:memory:` and is what makes it usable at all. The ServUO sidecar never hit this because it only + ever opens a file. + +### 13.4 Two things the kit's own template got wrong for this module + +Both are feedback for phase 19, and both are the kit being right about the general case and specific +about the wrong detail. + +- **`registration.test.js` reads one page by NAME** (`src/routes/public/Clan.jsx`) to check that + every declared slot is rendered somewhere. A module that declares no slots — as phase 1 does — dies + on `ENOENT` before reaching the loop that would have been empty. Generalised here to scan every + file under `src/routes`. +- **`test/_fakes.js` supplies `validator: {}`.** The template's routers never use express-validator, + so `{}` is enough for them; an admin router that builds validation chains at file scope cannot be + *required* with it. The fake now holds the real library, for the same reason it holds a real + express Router: a fake of either would only ever test the fake. + +The kit was also **right in a way worth recording**: `noGameConnection.test.js`'s header predicts, in +so many words, that a module adding a sidecar client will see this check go red and tells the reader +to narrow it rather than delete it — naming `sidecarClient.js` as the file to allow. That is exactly +what happened, on the first run, and the fix was the one line the header names. + +### 13.5 The player tier is thin on purpose + +R14 puts the module on all three tiers from the start, and the loader holds `mounts` against what is +registered in both directions — so the declaration and the registration land together or not at all. + +What the player tier will carry is the signed-in view of a server: the viewer's linked Steam +identity, their presence, their entitlements. None of that exists before phase 6. So the one route +there answers the server list on the authenticated tier, delegating to the **same model** the public +tier uses, so the two cannot drift while they are meant to be the same. + +That is a real route rather than a placeholder: it is the address the app and the SPA will call, and +it starts answering correctly now rather than moving later. + +### 13.6 Three timeouts in a row, and the ordering is load-bearing + +``` +sidecar RPC reply timeout (10s) < module client timeout (12s) < an action's budgetMs +``` + +Core classifies a `budgetMs` overrun as retryable **unconditionally** — it cannot ask the action, +which is still awaiting a socket. So an action whose budget does not exceed the module's client +timeout can never report `retry: false`, and that branch is unreachable code. Phase 13's actions +must derive their budgets from `sidecarClient.TIMEOUT_MS` rather than writing a number beside it. + +The first module this project shipped got this the wrong way round and retried a verb it had +explicitly refused, which is why all three values are now written down in one place +(`PROTOCOL.md` §4.4) instead of three. + +### 13.7 The rig, as it stands after phase 1 + +The phase-0 rig plus the bridge. `RGProbe.cs` is still installed beside `RunicGateway.cs` and is +still useful — its `rg.checkperm` / `rg.zoneonme` commands are phase 7 and phase 12 instruments. + +``` +D:\rust\oxide\plugins\ Clans.cs Kits.cs PopupNotifications.cs ZoneManager.cs + RGProbe.cs RunicGateway.cs +rust-link-sidecar game 127.0.0.1:7799 · web 127.0.0.1:8090 · its own scratch config +website (edge) modules/rust/ installed as a directory; server row "main" +``` + +A dependency-free WebSocket RCON driver was rebuilt this phase (the phase-0 one lived in a scratchpad +that did not survive). `rcon.web 1`, port 28016 — the password is in `D:\rust\start.bat`. It is the +only way to reach `rg.link` without a console session, and phase 7 will need it again. + +**The phase-0 ceiling still stands and still blocks phase 7:** no console session can observe a +permission gate, because every plugin's check short-circuits without a `BasePlayer`, and an admin +account bypasses most of them non-uniformly. A second, non-admin Steam account has to be arranged +before phase 7 — it is the one prerequisite this rig cannot satisfy on its own. + +[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link +[rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins +[mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust + [kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit diff --git a/rust-link/INTEGRATION.md b/rust-link/INTEGRATION.md new file mode 100644 index 0000000..aca817a --- /dev/null +++ b/rust-link/INTEGRATION.md @@ -0,0 +1,204 @@ +# rust-link — standing the bridge up + +**Operator- and developer-facing.** How to get a Rust server, a sidecar and a website talking, and +how to tell which of the three is wrong when they are not. The contract itself is +[`PROTOCOL.md`](PROTOCOL.md). + +There is no installer support for Rust yet — that is a later phase — so everything here is done by +hand. When the installer gains `--game rust`, this page becomes the fallback path rather than the +only one. + +--- + +## 1. What you need + +| Piece | Where it comes from | +|---|---| +| A Rust dedicated server with **Oxide** | umod.org | +| `RunicGateway.cs` | [Rust-Plugins](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins), `overlay/oxide/plugins/` | +| `rust-link-sidecar` | [Rust-Link](https://gitea.whitlocktech.com/RunicGateway/Rust-Link) | +| A Runic Gateway website with `module-rust` installed | [Module-Rust](https://gitea.whitlocktech.com/RunicGateway/Module-Rust) | + +**One game server, one sidecar, on that server's own host.** Six servers means six of the first two +pairs and six rows in the website's admin panel. + +The module also expects four third-party Oxide plugins to be present for the features that follow +the bridge itself — `Clans`, `Kits`, `PopupNotifications` and `ZoneManager`, all from k1lly0u on +umod.org. The bridge works without them; the features that read them do not. + +--- + +## 2. The order that works + +Sidecar first, then plugin, then website. Any order eventually converges — the plugin retries for +ever and the website polls — but this one gives you a readable log at each step instead of three +components all reporting that something else is missing. + +### 2.1 The sidecar + +```bash +rust-link-sidecar --print-config +``` + +This resolves the configuration exactly as a normal start would: it writes `sidecar.toml` if it is +missing, generates and saves an auth token if there is none, and prints the whole thing as JSON — +**including the token in clear text**, which is the point. Keep that token; the website needs it and +there is no second way to read it back. + +```json +{ + "component": "rust-link-sidecar", + "protocol": 1, + "config_path": "/etc/runicgateway/rust-main.toml", + "game": { "bind": "127.0.0.1:7799", "server_id": "" }, + "web": { "bind": "127.0.0.1:8090", "auth_token": "…", "ws_path": "/ws" }, + "store": { "path": "/var/lib/runicgateway/rust-link.db" } +} +``` + +Then start it. On a host running more than one game server, give each sidecar its own +`--config`, its own ports and its own database file. + +**`[game].bind` stays on loopback.** There is no token on the game link — the plugin and the sidecar +share a host and `127.0.0.1` *is* the authentication. Moving that bind to a routable address puts an +unauthenticated command channel on the network. + +**`[web].bind` is the one you may need to move**, because the website is usually on another host. +Behind TLS and a firewall: the token is the only thing guarding it. + +Check it: + +```bash +curl http://127.0.0.1:8090/health +{"status":"degraded","protocol":1,"plugin_connected":false,"database":"ok","uptime":"0m","last_event":null} +``` + +`degraded` with `plugin_connected: false` is exactly right at this point — nothing is connected yet. + +### 2.2 The plugin + +```bash +cp RunicGateway.cs /path/to/rust/oxide/plugins/ +``` + +Oxide compiles and loads it on the write. Watch `oxide/logs/`: + +``` +[Info] RunicGateway was compiled successfully in 2295ms +[Info] [Runic Gateway] protocol 1, serverId 'main', sidecar 127.0.0.1:7799 +[Info] [Runic Gateway] connected to 127.0.0.1:7799 +``` + +The first load also writes `oxide/config/RunicGateway.json`. Set `ServerId` before you go further: + +```json +{ "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "main" } +``` + +**`ServerId` is this server's identity as the website knows it, and it is permanent.** It is not +derived from the hostname on purpose — an operator renames a server for a season, and the site must +not lose its history for it. Changing it later orphans everything recorded under the old one. + +Now `/health` should read: + +```json +{"status":"ok","protocol":1,"plugin_connected":true,"database":"ok","uptime":"2m","last_event":"…"} +``` + +If it does not, ask the game server: + +``` +rg.link +protocol=1 serverId=main connected=True depth=0 sent=3 dropped=0 received=2 +connects=1 writeErrors=0 bootId=boot-20260915T194502Z +``` + +### 2.3 The website + +**Admin → Rust → add a server.** Four values: + +| Field | Value | +|---|---| +| Id | the slug every URL carries. Match `ServerId` in the plugin config | +| Name | what visitors see | +| Sidecar base URL | `http://:8090` | +| Sidecar token | the `auth_token` from `--print-config` | + +**The token is write-only.** It is stored encrypted and never returned to any client; the panel +reports only whether one is set. A save that leaves the field blank keeps the stored one — so +renaming a server does not mean re-pasting a credential. + +Then press **Test**, which probes the sidecar and reports what came back: + +```json +{ "ok": true, "status": "ok", + "sidecar": { "status": "ok", "protocol": 1, "plugin_connected": true, … } } +``` + +Within a poll interval the server appears at `/rust/servers`. + +--- + +## 3. When it does not work + +A wrong URL, a wrong token and a mismatched protocol version all present as *"the site says my +server is offline"*. The **Test** button is what separates them, and its `status` is the whole +diagnosis: + +| `status` | What is wrong | Where to look | +|---|---|---| +| `ok` | nothing | — | +| `no-token` | the admin form was saved without one | Admin → Rust | +| `unauthorized` | the token does not match | `--print-config` on the sidecar host | +| `protocol-mismatch` | the sidecar and the module speak different versions | upgrade one of them; the body names both numbers | +| `timeout` | the sidecar answered too slowly, or not at all | the sidecar's own log | +| `transport-error` | nothing is listening at that address | the base URL, the firewall, whether the sidecar is running | +| `http-` | something answered, and it was not a sidecar | usually a reverse proxy in front of the wrong thing | + +Two failures that look alike and are not: + +- **`plugin_connected: false` with an otherwise healthy sidecar** — the bridge is fine and the game + is not talking to it. Check the plugin is loaded (`oxide.plugins`) and `rg.link` on the game + server. +- **The server is listed but reads `stale`** — something reported once and has not since. The row + says what was true when it was written; nothing has written it since. Either the poll is failing + (the website's log) or the sidecar stopped (its own). + +### 3.1 The failures that are supposed to happen + +Three things look like breakage and are the design: + +- **Killing the sidecar does not disturb the game.** The plugin logs `sidecar link lost; + reconnecting` and retries with backoff, buffering into a bounded queue that drops its oldest + entries rather than growing. The game does not stall, and `Emit` never touches a socket. +- **Starting the plugin before the sidecar logs one line and then goes quiet.** `cannot reach the + sidecar: … — retrying quietly until it answers`, printed once per load rather than every few + seconds. A wrong `Host` or `Port` looks exactly like this, which is why it is printed at all. +- **The website renders with every game server off.** The server list, the player counts and the + last-reported times all come from stored state. A page that 500s because a socket is closed would + be a module that made the site's availability depend on the game's. + +--- + +## 4. Running more than one server + +Each pair is fully independent: its own ports, its own `sidecar.toml`, its own database file, its +own token, its own row on the website. + +Set `[game].server_id` in each `sidecar.toml` to match that server's plugin config. It is a +**cross-check**, not a second source of truth — the plugin's announcement wins — and it exists to +catch exactly one mistake: two game servers pointed at one sidecar by a copied config, which is +silent in every other design and produces one server's history under another's name. When it fires +you get a warning naming both ids. + +--- + +## 5. Upgrading + +The four declaration sites in [`PROTOCOL.md` §2](PROTOCOL.md#2-versioning) must agree. In practice +that means upgrading the sidecar and the plugin **together**, because the game link has no version +check of its own and a mismatched plugin mis-parses rather than refusing. + +The website is the forgiving half: it sends its version on every request and a sidecar that +disagrees answers `409` with both numbers, so a module ahead of or behind its sidecar reports a +named fault rather than misbehaving. diff --git a/rust-link/PROTOCOL.md b/rust-link/PROTOCOL.md new file mode 100644 index 0000000..3eab58c --- /dev/null +++ b/rust-link/PROTOCOL.md @@ -0,0 +1,337 @@ +# 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.