docs(modules): name Oxide, and pair one sidecar to one server
Two corrections from the org lead on the transport rewrite. It is an OXIDE plugin, not "a mod loaded by the server's mod framework". Oxide is what modded Rust servers run, and naming it is the difference between a design a reader can start from and one they have to go and pick a framework for. And the architecture is ONE SERVER, ONE SIDECAR - not one sidecar fronting a community's several servers, which is the arrangement a UO-shaped reading reaches for and which this document had. 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: it trades the invariant that makes the design safe for a saving in process count. The cost lands on the module, which is the right place for it - it holds one client per configured server rather than one client to an aggregator - and it makes the Team provider's `complete` answerable rather than vague, since "every team there is" now means every team on THIS server. Five of six sidecars reachable is `complete` left off, and core adds and updates without archiving. Recorded as a reevaluable assumption rather than a principle, because that is what it is. Nothing in the contract objects either way: core is not in this conversation at all, which finding 2 now says. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,8 +14,8 @@ Rust was chosen because it is unlike Ultima Online in the ways most likely to br
|
|||||||
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 is **not source you can compile** — ServUO's overlay is C# a shard owner
|
is **Steam**, and its server is **not source you can compile** — ServUO's overlay is C# a shard owner
|
||||||
builds into their own server, and a Rust server is a binary nobody outside Facepunch patches. The way
|
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
|
in is a **mod** — specifically an **Oxide** plugin, since Oxide is what modded Rust servers run —
|
||||||
contract survives that, "game-agnostic" means something.
|
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.
|
||||||
@@ -97,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) => link.command('chat.broadcast', { text: `[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 }),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -106,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 link.connect() })
|
api.onBoot(async () => { await links.connectAll() })
|
||||||
api.onShutdown(async () => { await link.close() })
|
api.onShutdown(async () => { await links.closeAll() })
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -117,11 +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**, by asking the sidecar to send a command down the socket
|
- **The announce leg goes to in-game chat**, by asking each server's sidecar to send a command down
|
||||||
the mod already holds — a one-shot delivery with retry, so `registerAnnounceLeg` and not
|
the socket its plugin already holds — a one-shot delivery with retry, so `registerAnnounceLeg` and
|
||||||
`registerPostHook`. The distinction §2.4 draws holds up on a game that has nothing in common with
|
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
|
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.
|
*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
|
||||||
@@ -129,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 a sidecar with no mod connected 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
|
||||||
@@ -152,33 +153,45 @@ model gets wrong, and it is worth writing down for whoever builds this.
|
|||||||
|
|
||||||
### Talking to the game
|
### Talking to the game
|
||||||
|
|
||||||
**A mod, a sidecar, and the same three-part shape UO has.** Rust's server is a binary, so there is no
|
**An Oxide plugin, a sidecar, and the same three-part shape UO has.** Rust's server is a binary, so
|
||||||
overlay to compile into it and no source to patch — but it loads **mods**, and a mod is C# with a hook
|
there is no overlay to compile into it and no source to patch — but a modded server runs **Oxide**,
|
||||||
for everything this design needs. So `rust-link` is a real sidecar rather than a wrapper around an
|
and an Oxide plugin is C# with a hook for everything this design needs. So `rust-link` is a real
|
||||||
admin channel, and it is fed the way `uo-link` is fed:
|
sidecar rather than a wrapper around an admin channel, and it is fed the way `uo-link` is fed:
|
||||||
|
|
||||||
```
|
```
|
||||||
Rust server + rust-bridge mod (C#, hooks)
|
ONE Rust server + the rust-bridge Oxide plugin (C#, hooks)
|
||||||
│ loopback TCP, newline-delimited JSON, bidirectional
|
│ loopback TCP, newline-delimited JSON, bidirectional
|
||||||
│ the MOD dials out to the sidecar — the game opens no listening port
|
│ the PLUGIN dials out to the sidecar — the game opens no listening port
|
||||||
▼
|
▼
|
||||||
rust-link sidecar
|
ONE rust-link sidecar, on that same host
|
||||||
│ bearer-authed HTTP + WebSocket, versioned
|
│ bearer-authed HTTP + WebSocket, versioned
|
||||||
▼
|
▼
|
||||||
module-rust, inside the website
|
module-rust, inside the website — one client per server it is configured with
|
||||||
```
|
```
|
||||||
|
|
||||||
**The mod is the interesting half, and it is where a UO-shaped model has the least to unlearn.** The
|
**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
|
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
|
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
|
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
|
than found: Oxide publishes them, so the plugin is small and the guesswork is in deciding what to
|
||||||
what to emit rather than in finding somewhere to hang it.
|
emit rather than in finding somewhere to hang it.
|
||||||
|
|
||||||
Two of them answer questions UO had to work for:
|
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
|
- **The wipe arrives as an event.** The plugin is told a new save has begun; nothing has to detect a
|
||||||
by noticing the world looks different.
|
wipe by noticing the world looks different.
|
||||||
- **Membership is real-time** — created, joined, left, disbanded, leader changed. UO has no
|
- **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,
|
`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
|
[`../link/v4.md`](../link/v4.md)); here every transition is delivered as it happens. So this
|
||||||
@@ -189,22 +202,22 @@ Two of them answer questions UO had to work for:
|
|||||||
|
|
||||||
**The sidecar still earns its place, and the store is why.** A hook fires once, and what it says while
|
**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
|
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,
|
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
|
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
|
rather than an error. It also keeps the auth token and the reconnect loop out of an Express process,
|
||||||
of an Express process, where a stalled socket is a stalled request handler.
|
where a stalled socket is a stalled request handler.
|
||||||
|
|
||||||
**Several servers, one sidecar.** Each server runs the mod and each dials the same sidecar,
|
**A sidecar answers for exactly one server**, which is what makes the Team provider's `complete`
|
||||||
identifying itself on connect; the sidecar keys every board by server id. A community running six
|
(§2) answerable at all: "every team there is" means every team on *this* server, and the module can
|
||||||
servers deploys one thing per server and one sidecar, and the module sees a single API — which is
|
only claim it for the servers whose sidecar answered. Five of six reachable is `complete` left off,
|
||||||
where the Team provider's per-server `complete` (§2) gets its meaning.
|
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
|
**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
|
sidecar sends to its plugin — the same direction UO's bridge already carries. The connection belongs
|
||||||
the mod, and nothing outside the game ever dials into it.
|
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
|
||||||
@@ -220,8 +233,10 @@ the mod, and nothing outside the game ever dials into it.
|
|||||||
|
|
||||||
> **Correction, 2026-08-19 (org lead).** This document originally reached the game over **RCON**, and
|
> **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
|
> 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
|
> plugin to write. Overruled: **the transport is an Oxide plugin, exactly as UO's is a ServUO
|
||||||
> used.** Hooks inside the mod expose the data and the mod dials the sidecar.
|
> 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.
|
> 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
|
> Rust is no longer an example of *"a game that already speaks a remote-control protocol, so its
|
||||||
@@ -331,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,
|
||||||
|
|||||||
Reference in New Issue
Block a user