docs(modules): the Rust dry run reaches the game through a MOD, not RCON

Overruled by the org lead: RCON is not used. Rust gets the same three-part shape
UO has - a plugin inside the game that dials out, a sidecar that persists before
it forwards, a module that talks only to the sidecar - and the plugin is a mod
loaded by the server's mod framework, exposing data through hooks.

The document had RCON as its premise, so the correction reaches further than the
transport paragraph:

- The reason Rust is a good second game changes. It was "its server speaks a
  protocol nobody has to write". It is now "its server is a BINARY" - the
  opposite of ServUO, which is source a shard owner compiles - so the way in is
  a published mod API and the shard-dials-out invariant has to survive that
  change of footing. It does, unchanged, which is a stronger result than the
  one the document originally claimed.
- The announce leg sends a command down the socket the mod already holds,
  rather than calling rcon.say.
- The provider refuses when no mod is connected, not when RCON is unreachable.
- Two hooks answer questions UO had to work for: a wipe arrives as an event, and
  membership is real-time - so this module's Team provider is event-driven with
  a baseline on connect rather than sweep-driven. The provider contract does not
  change by a line, which is the part worth keeping: core never needed to know
  how the data arrives.

The 2026-08-12 correction block stays and a second one is added beside it rather
than editing the history out - this document's own convention, and the thing that
makes it worth reading twice. It also records what the correction COSTS: this
project no longer has a worked example of "a game that already speaks a
remote-control protocol, so its sidecar is thin".

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-19 04:09:41 -05:00
parent 12594f2b13
commit 4e98ed6bd5

View File

@@ -12,8 +12,10 @@ design comes first, because a finding is only worth anything with the design tha
Rust was chosen because it is unlike Ultima Online in the ways most likely to break assumptions: Rust was chosen because it is unlike Ultima Online in the ways most likely to break assumptions:
it **wipes** every month, a community runs **several servers** rather than one shard, its identity it **wipes** every month, a community runs **several servers** rather than one shard, its identity
is **Steam**, and its server speaks a **protocol nobody has to write** — RCON over WebSocket, built is **Steam**, and its server is **not source you can compile** — ServUO's overlay is C# a shard owner
in. If the contract survives that, "game-agnostic" means something. builds into their own server, and a Rust server is a binary nobody outside Facepunch patches. The way
in is a **mod**: a plugin loaded by the server's mod framework, hooking the game's own events. If the
contract survives that, "game-agnostic" means something.
> Nothing here re-specifies the contract. [`../website/MODULE_API.md`](../website/MODULE_API.md) is > Nothing here re-specifies the contract. [`../website/MODULE_API.md`](../website/MODULE_API.md) is
> normative; this document only *uses* it. > normative; this document only *uses* it.
@@ -95,7 +97,7 @@ module.exports = function register(ctx, api) {
api.registerAnnounceLeg({ api.registerAnnounceLeg({
leg: 'rust.ingame', leg: 'rust.ingame',
label: 'In-game chat', label: 'In-game chat',
dispatch: (post) => rcon.say(`[NEWS] ${post.title}${ctx.site.baseUrl}/news/${post.slug}`), dispatch: (post) => link.command('chat.broadcast', { text: `[NEWS] ${post.title}${ctx.site.baseUrl}/news/${post.slug}` }),
classify: (result) => (result.ok ? { outcome: 'done' } : { outcome: 'retry', error: result.error }), classify: (result) => (result.ok ? { outcome: 'done' } : { outcome: 'retry', error: result.error }),
}) })
@@ -104,8 +106,8 @@ module.exports = function register(ctx, api) {
// word and the roster behind it. // word and the roster behind it.
api.registerTeamProvider(teamProvider) api.registerTeamProvider(teamProvider)
api.onBoot(async () => { await rcon.connectAll() }) api.onBoot(async () => { await link.connect() })
api.onShutdown(async () => { await rcon.closeAll() }) api.onShutdown(async () => { await link.close() })
} }
``` ```
@@ -115,9 +117,11 @@ are worth pointing at:
- **`rust.wipe` and `rust.raid` are namespaced**, with no grandfathering request. Module-uo's seven - **`rust.wipe` and `rust.raid` are namespaced**, with no grandfathering request. Module-uo's seven
bare stream ids are allowlisted because they were in `notification_subs` before the rule existed bare stream ids are allowlisted because they were in `notification_subs` before the rule existed
(§6.5); a new module gets the rule, and the rule is exactly right. (§6.5); a new module gets the rule, and the rule is exactly right.
- **The announce leg goes to in-game chat over RCON**, which is a one-shot delivery with retry — - **The announce leg goes to in-game chat**, by asking the sidecar to send a command down the socket
`registerAnnounceLeg`, not `registerPostHook`. The distinction §2.4 draws holds up on a game that the mod already holds — a one-shot delivery with retry, so `registerAnnounceLeg` and not
has nothing in common with the one it was drawn for. `registerPostHook`. The distinction §2.4 draws holds up on a game that has nothing in common with
the one it was drawn for. Note the direction: the module never speaks to a game server, and the
*sidecar* never dials one either — it answers on a connection the mod opened.
- **The Team provider is the one registration core calls back into**, and Rust makes two of its rules - **The Team provider is the one registration core calls back into**, and Rust makes two of its rules
bite harder than UO does. `externalId` must survive a rename, and a Rust team has no name at all — bite harder than UO does. `externalId` must survive a rename, and a Rust team has no name at all —
it is a numeric team id in the server's save, which is the right answer and the one a designer is it is a numeric team id in the server's save, which is the right answer and the one a designer is
@@ -125,7 +129,7 @@ are worth pointing at:
six servers has six team spaces, so a provider that can reach five of them must leave `complete` six servers has six team spaces, so a provider that can reach five of them must leave `complete`
off or core archives every Team on the sixth. Wipes make the same point once a month, on purpose — off or core archives every Team on the sixth. Wipes make the same point once a month, on purpose —
a wipe empties every team, and `{ ok: true, complete: true, teams: [] }` is then *true* and core a wipe empties every team, and `{ ok: true, complete: true, teams: [] }` is then *true* and core
archiving all of them is *correct*. Which is exactly why an unreachable RCON must answer archiving all of them is *correct*. Which is exactly why a sidecar with no mod connected must answer
`{ ok: false }` instead: the two states are one API call apart and only the module can tell them `{ ok: false }` instead: the two states are one API call apart and only the module can tell them
apart. apart.
@@ -135,7 +139,7 @@ are worth pointing at:
`rust_bans`, `rust_maps`. All `rust_`-prefixed, all in one idempotent `schema.sql` fragment. `rust_bans`, `rust_maps`. All `rust_`-prefixed, all in one idempotent `schema.sql` fragment.
**`rust_teams` stays this module's table, and core's `teams` stays core's.** They hold the same teams **`rust_teams` stays this module's table, and core's `teams` stays core's.** They hold the same teams
and neither reads the other: the module ingests from RCON into `rust_teams`, core reconciles by and neither reads the other: the module ingests from the sidecar into `rust_teams`, core reconciles by
*asking* the provider, and §2.6's prefix rule forbids the module touching core's table even though *asking* the provider, and §2.6's prefix rule forbids the module touching core's table even though
the module is what populates it. A module that wrote `team_members` directly would be racing core's the module is what populates it. A module that wrote `team_members` directly would be racing core's
reconciler for rows it does not own. reconciler for rows it does not own.
@@ -148,22 +152,60 @@ model gets wrong, and it is worth writing down for whoever builds this.
### Talking to the game ### Talking to the game
**A thin sidecar.** Rust ships RCON over WebSocket, so `rust-link` is small: it holds the RCON **A mod, a sidecar, and the same three-part shape UO has.** Rust's server is a binary, so there is no
connection to each server with the token an admin saved, and presents the website the same shape overlay to compile into it and no source to patch — but it loads **mods**, and a mod is C# with a hook
`uo-link` does — a bearer-authed HTTP + WebSocket API in front of a SQLite store. The module talks for everything this design needs. So `rust-link` is a real sidecar rather than a wrapper around an
only to it, never to a game server. admin channel, and it is fed the way `uo-link` is fed:
The store is the reason it exists even though the game is already remote-controllable. RCON is a ```
live channel with no memory: what it tells you while nobody is listening is gone. So the sidecar Rust server + rust-bridge mod (C#, hooks)
appends every kill, wipe and chat line, keeps the latest snapshot of each server's state, and │ loopback TCP, newline-delimited JSON, bidirectional
answers the website's reads from disk — a website that is down, restarting or mid-deploy loses │ the MOD dials out to the sidecar — the game opens no listening port
nothing, and a leaderboard renders the last thing the server said rather than an error. It also
keeps the RCON token, the reconnect loop and the per-server fan-out out of an Express process, where rust-link sidecar
a stalled socket is a stalled request handler. │ bearer-authed HTTP + WebSocket, versioned
module-rust, inside the website
```
**The mod is the interesting half, and it is where a UO-shaped model has the least to unlearn.** The
threading contract transfers whole — emit enqueues onto a bounded drop-oldest queue and returns, one
writer thread owns the socket, the world is read only on the game's own thread — because it is a
property of *game servers* and not of ServUO. What changes is that the hooks are handed to you rather
than found: the mod framework publishes them, so the plugin is small and the guesswork is in deciding
what to emit rather than in finding somewhere to hang it.
Two of them answer questions UO had to work for:
- **The wipe arrives as an event.** The mod is told a new save has begun; nothing has to detect a wipe
by noticing the world looks different.
- **Membership is real-time** — created, joined, left, disbanded, leader changed. UO has no
`guild.leave` at all and needed a 60-second sweep plus a set diff to synthesise one (protocol 4,
[`../link/v4.md`](../link/v4.md)); here every transition is delivered as it happens. So this
module's Team provider is **event-driven with a baseline on connect** rather than sweep-driven —
and the provider contract does not change by one line, because core asks the same three questions
and gets the same envelope. That is the result worth keeping: the contract never needed to know how
the data arrives.
**The sidecar still earns its place, and the store is why.** A hook fires once, and what it says while
nobody is listening is gone. So the sidecar appends every kill, wipe and chat line, keeps the latest
snapshot of each server's state, and answers the website's reads from disk — a website that is down,
restarting or mid-deploy loses nothing, and a leaderboard renders the last thing the server said
rather than an error. It also keeps the auth token, the reconnect loop and the per-server fan-out out
of an Express process, where a stalled socket is a stalled request handler.
**Several servers, one sidecar.** Each server runs the mod and each dials the same sidecar,
identifying itself on connect; the sidecar keys every board by server id. A community running six
servers deploys one thing per server and one sidecar, and the module sees a single API — which is
where the Team provider's per-server `complete` (§2) gets its meaning.
**`wipe_id` makes the durable copy load-bearing rather than a nicety.** A wipe is the moment the **`wipe_id` makes the durable copy load-bearing rather than a nicety.** A wipe is the moment the
game forgets; the sidecar is the only thing that remembers the shape of the map that just ended. game forgets; the sidecar is the only thing that remembers the shape of the map that just ended.
**Commands go back down the same socket.** The announce leg's in-game chat line is a command the
sidecar sends to the mod — the same direction UO's bridge already carries. The connection belongs to
the mod, and nothing outside the game ever dials into it.
> **Correction, 2026-08-12.** This section originally concluded **"No sidecar"** — the module dialling > **Correction, 2026-08-12.** This section originally concluded **"No sidecar"** — the module dialling
> RCON directly — and offered it as evidence that core has no opinion about how a module reaches its > RCON directly — and offered it as evidence that core has no opinion about how a module reaches its
> game. That was overruled by the org lead when Phase 5 (§2.11.1 d3/d4) settled the kit's stance, and > game. That was overruled by the org lead when Phase 5 (§2.11.1 d3/d4) settled the kit's stance, and
@@ -176,6 +218,19 @@ game forgets; the sidecar is the only thing that remembers the shape of the map
> to be restarted and the one facing the internet. The finding is left in view rather than edited out; > to be restarted and the one facing the internet. The finding is left in view rather than edited out;
> what a dry run concluded is worth more than a tidy document. > what a dry run concluded is worth more than a tidy document.
> **Correction, 2026-08-19 (org lead).** This document originally reached the game over **RCON**, and
> this section concluded "a thin sidecar" on that basis — no wire protocol to invent and no game-side
> plugin to write. Overruled: **the transport is a mod, exactly as it is for UO, and RCON is not
> used.** Hooks inside the mod expose the data and the mod dials the sidecar.
>
> Two things that costs the document, worth stating because both were used as evidence elsewhere.
> Rust is no longer an example of *"a game that already speaks a remote-control protocol, so its
> sidecar is thin"* — that example now has none in this project. And the reason Rust is a good second
> game is no longer that its protocol comes free. It is that its server is a **binary**, the opposite
> of ServUO: the way in is a published mod API rather than source you compile, and the
> shard-dials-out invariant has to survive that change of footing. It does, unchanged, which is a
> stronger result than the one this document originally claimed.
## 3. The client half ## 3. The client half
```js ```js
@@ -315,11 +370,16 @@ load-bearing with two.
## Verdict ## Verdict
**The contract generalises.** A second game, chosen for how little it shares with the first, is **The contract generalises.** A second game, chosen for how little it shares with the first, is
served by the same `module.json`, the same seven registration calls, the same schema-fragment rules, served by the same `module.json`, the same registration calls, the same schema-fragment rules,
the same client registry and the same UI kit — with one genuine gap (identity providers), one the same client registry and the same UI kit — with one genuine gap (identity providers), one
non-issue that looks like a gap (multiple servers), and two places where a rule written for one non-issue that looks like a gap (multiple servers), and two places where a rule written for one
reason turns out to cover another. reason turns out to cover another.
**And the three-part shape generalises with it**, which the 2026-08-19 correction is what actually
established: a game whose server is a binary, reached through a published mod API, still ends up with
a plugin that dials out, a sidecar that persists before it forwards, and a module that talks only to
the sidecar. Nothing about that arrangement was a property of ServUO being source you can compile.
The gap is worth having found before something was built on top of it, which is what a dry run is The gap is worth having found before something was built on top of it, which is what a dry run is
for. What it does **not** establish is that someone outside this org could build this module from the for. What it does **not** establish is that someone outside this org could build this module from the
documentation alone — that is the Integration Kit's acceptance test (§2.11), and it stays untested documentation alone — that is the Integration Kit's acceptance test (§2.11), and it stays untested