diff --git a/modules/rust-dryrun.md b/modules/rust-dryrun.md index 483ab7a..9363cc7 100644 --- a/modules/rust-dryrun.md +++ b/modules/rust-dryrun.md @@ -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 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 -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. +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 > normative; this document only *uses* it. @@ -97,7 +97,7 @@ module.exports = function register(ctx, api) { api.registerAnnounceLeg({ leg: 'rust.ingame', 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 }), }) @@ -106,8 +106,8 @@ module.exports = function register(ctx, api) { // word and the roster behind it. api.registerTeamProvider(teamProvider) - api.onBoot(async () => { await link.connect() }) - api.onShutdown(async () => { await link.close() }) + api.onBoot(async () => { await links.connectAll() }) + 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 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. -- **The announce leg goes to in-game chat**, by asking the sidecar to send a command down the socket - the mod already holds — a one-shot delivery with retry, so `registerAnnounceLeg` and not - `registerPostHook`. The distinction §2.4 draws holds up on a game that has nothing in common with +- **The announce leg goes to in-game chat**, by asking each server's sidecar to send a command down + the socket its plugin already holds — a one-shot delivery with retry, so `registerAnnounceLeg` and + 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 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 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 @@ -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` 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 - 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 + archiving all of them is *correct*. Which is exactly why a sidecar with no plugin connected must + answer `{ ok: false }` instead: the two states are one API call apart and only the module can tell them apart. ### Tables @@ -152,33 +153,45 @@ model gets wrong, and it is worth writing down for whoever builds this. ### 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 -overlay to compile into it and no source to patch — but it loads **mods**, and a mod is C# with a hook -for everything this design needs. So `rust-link` is a real sidecar rather than a wrapper around an -admin channel, and it is fed the way `uo-link` is fed: +**An Oxide plugin, a sidecar, and the same three-part shape UO has.** Rust's server is a binary, so +there is no overlay to compile into it and no source to patch — but a modded server runs **Oxide**, +and an Oxide plugin is C# with a hook for everything this design needs. So `rust-link` is a real +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 - │ 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 ▼ -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 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. +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 mod is told a new save has begun; nothing has to detect a wipe - by noticing the world looks different. +- **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 @@ -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 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 -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. +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. -**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. +**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 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. +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 > 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 > 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. +> 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 @@ -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 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 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,