docs(modules): the Rust dry run reaches the game through an Oxide plugin, not RCON #170
@@ -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** — specifically an **Oxide** plugin, since Oxide is what modded Rust servers run —
|
||||||
|
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) => links.broadcast('chat.say', { 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 links.connectAll() })
|
||||||
api.onShutdown(async () => { await rcon.closeAll() })
|
api.onShutdown(async () => { await links.closeAll() })
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -115,9 +117,12 @@ 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 each server's sidecar to send a command down
|
||||||
`registerAnnounceLeg`, not `registerPostHook`. The distinction §2.4 draws holds up on a game that
|
the socket its plugin already holds — a one-shot delivery with retry, so `registerAnnounceLeg` and
|
||||||
has nothing in common with the one it was drawn for.
|
not `registerPostHook`. Note the plural: there is one sidecar per server, so a news post reaches
|
||||||
|
every configured server and the leg's `classify` answers for the set. 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 plugin 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,8 +130,8 @@ 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 plugin connected must
|
||||||
`{ ok: false }` instead: the two states are one API call apart and only the module can tell them
|
answer `{ ok: false }` instead: the two states are one API call apart and only the module can tell them
|
||||||
apart.
|
apart.
|
||||||
|
|
||||||
### Tables
|
### Tables
|
||||||
@@ -135,7 +140,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 +153,72 @@ 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
|
**An Oxide plugin, a sidecar, and the same three-part shape UO has.** Rust's server is a binary, so
|
||||||
connection to each server with the token an admin saved, and presents the website the same shape
|
there is no overlay to compile into it and no source to patch — but a modded server runs **Oxide**,
|
||||||
`uo-link` does — a bearer-authed HTTP + WebSocket API in front of a SQLite store. The module talks
|
and an Oxide plugin is C# with a hook for everything this design needs. So `rust-link` is a real
|
||||||
only to it, never to a game server.
|
sidecar rather than a wrapper around an 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
|
ONE Rust server + the rust-bridge Oxide plugin (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 PLUGIN 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
|
ONE rust-link sidecar, on that same host
|
||||||
a stalled socket is a stalled request handler.
|
│ bearer-authed HTTP + WebSocket, versioned
|
||||||
|
▼
|
||||||
|
module-rust, inside the website — one client per server it is configured with
|
||||||
|
```
|
||||||
|
|
||||||
|
**One server, one sidecar** (org lead, 2026-08-19). Not one sidecar fronting a community's several
|
||||||
|
servers, which is the arrangement a UO-shaped reading reaches for. Rust servers in practice sit on
|
||||||
|
**separate VMs**, so a shared sidecar would have to be reached across a network by plugins that are
|
||||||
|
supposed to talk to it over loopback — trading the invariant that makes this design safe for a saving
|
||||||
|
in process count. The pairing stays local: a server, its plugin, and its own sidecar on the same host.
|
||||||
|
|
||||||
|
The cost lands on the module, and it is the right place for it: `module-rust` holds one client per
|
||||||
|
configured server rather than one client to one aggregator, and every board it reads is that server's.
|
||||||
|
A community with six servers runs six pairs and the module knows about six endpoints. **That is a
|
||||||
|
reevaluable assumption rather than a principle** — if a deployment ever wants one sidecar for several
|
||||||
|
servers, nothing in the contract objects, because core is not in this conversation at all.
|
||||||
|
|
||||||
|
**The plugin 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: Oxide 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 plugin 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 its 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 and the reconnect loop out of an Express process,
|
||||||
|
where a stalled socket is a stalled request handler.
|
||||||
|
|
||||||
|
**A sidecar answers for exactly one server**, which is what makes the Team provider's `complete`
|
||||||
|
(§2) answerable at all: "every team there is" means every team on *this* server, and the module can
|
||||||
|
only claim it for the servers whose sidecar answered. Five of six reachable is `complete` left off,
|
||||||
|
and core then adds and updates without archiving anything.
|
||||||
|
|
||||||
**`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 its plugin — the same direction UO's bridge already carries. The connection belongs
|
||||||
|
to the plugin, 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 +231,21 @@ 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 an Oxide plugin, exactly as UO's is a ServUO
|
||||||
|
> overlay, and RCON is not used.** Hooks inside the plugin expose the data and the plugin dials the
|
||||||
|
> sidecar. **One server, one sidecar**, since Rust servers usually sit on separate VMs — a reevaluable
|
||||||
|
> assumption, recorded as one.
|
||||||
|
>
|
||||||
|
> 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
|
||||||
@@ -276,6 +346,10 @@ The contract is silent on this, and silence turns out to be right: multiplicity
|
|||||||
module's own tables and route parameters (`/rust/servers/:id`). Core's mount prefixes, capabilities
|
module's own tables and route parameters (`/rust/servers/:id`). Core's mount prefixes, capabilities
|
||||||
and state machine are per-**module**, and none of them wanted to be per-server.
|
and state machine are per-**module**, and none of them wanted to be per-server.
|
||||||
|
|
||||||
|
The one-server-one-sidecar decision above pushes that further and it still holds: the module holds
|
||||||
|
*several* sidecar clients, and core never learns there is more than one. What core has an opinion
|
||||||
|
about is the module; how many things the module talks to is the module's business.
|
||||||
|
|
||||||
Worth recording only because it looks like a problem until you try it — and because it is the shape
|
Worth recording only because it looks like a problem until you try it — and because it is the shape
|
||||||
that would have broken a contract designed around "the shard" as a singular noun. The phase-2
|
that would have broken a contract designed around "the shard" as a singular noun. The phase-2
|
||||||
inversions that removed core's opinions about game content (the push catalog, the announce legs,
|
inversions that removed core's opinions about game content (the push catalog, the announce legs,
|
||||||
@@ -315,11 +389,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
|
||||||
|
|||||||
Reference in New Issue
Block a user