docs(modules): module-rust phase 1 as built — the transport, and three org-lead decisions #252
12
README.md
12
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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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
|
||||
|
||||
204
rust-link/INTEGRATION.md
Normal file
204
rust-link/INTEGRATION.md
Normal file
@@ -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://<sidecar host>: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-<code>` | 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.
|
||||
337
rust-link/PROTOCOL.md
Normal file
337
rust-link/PROTOCOL.md
Normal file
@@ -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 <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 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 <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.
|
||||
Reference in New Issue
Block a user