diff --git a/modules/rust-dryrun.md b/modules/rust-dryrun.md index f26a7b8..483ab7a 100644 --- a/modules/rust-dryrun.md +++ b/modules/rust-dryrun.md @@ -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: 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 -in. If the contract survives that, "game-agnostic" means something. +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. > Nothing here re-specifies the contract. [`../website/MODULE_API.md`](../website/MODULE_API.md) is > normative; this document only *uses* it. @@ -95,7 +97,7 @@ module.exports = function register(ctx, api) { api.registerAnnounceLeg({ leg: 'rust.ingame', 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 }), }) @@ -104,8 +106,8 @@ module.exports = function register(ctx, api) { // word and the roster behind it. api.registerTeamProvider(teamProvider) - api.onBoot(async () => { await rcon.connectAll() }) - api.onShutdown(async () => { await rcon.closeAll() }) + api.onBoot(async () => { await link.connect() }) + 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 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 over RCON**, which is a one-shot delivery with retry — - `registerAnnounceLeg`, not `registerPostHook`. The distinction §2.4 draws holds up on a game that - has nothing in common with the one it was drawn for. +- **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 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 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 @@ -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` 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 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 apart. @@ -135,7 +139,7 @@ are worth pointing at: `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 -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 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. @@ -148,22 +152,60 @@ model gets wrong, and it is worth writing down for whoever builds this. ### Talking to the game -**A thin sidecar.** Rust ships RCON over WebSocket, so `rust-link` is small: it holds the RCON -connection to each server with the token an admin saved, and presents the website the same shape -`uo-link` does — a bearer-authed HTTP + WebSocket API in front of a SQLite store. The module talks -only to it, never to a game server. +**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: -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 -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 RCON token, the reconnect loop and the per-server fan-out out of an Express process, where -a stalled socket is a stalled request handler. +``` +Rust server + rust-bridge mod (C#, hooks) + │ loopback TCP, newline-delimited JSON, bidirectional + │ the MOD dials out to the sidecar — the game opens no listening port + ▼ +rust-link sidecar + │ 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 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 > 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 @@ -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; > 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 ```js @@ -315,11 +370,16 @@ load-bearing with two. ## Verdict **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 non-issue that looks like a gap (multiple servers), and two places where a rule written for one 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 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