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 as held against reality by the loader in the way 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
This commit is contained in:
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)
|
website/ docs from the website core (Node/Express + MariaDB + React/Vite)
|
||||||
modules/ docs for installable game modules — one directory per module id
|
modules/ docs for installable game modules — one directory per module id
|
||||||
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
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)
|
android/ docs from the native Android client (Kotlin + Jetpack Compose)
|
||||||
installer/ docs for the installer that deploys a shard's bridge components
|
installer/ docs for the installer that deploys a shard's bridge components
|
||||||
ci/ cross-cutting CI/quality notes
|
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 |
|
| [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 |
|
| [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/`
|
### `android/`
|
||||||
| Doc | What it covers |
|
| 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 |
|
| # | 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 |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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
|
`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.
|
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
|
### 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:
|
`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
|
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.
|
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
|
[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