The three repos exist and are named: Module-Rust, Rust-Link, Rust-Plugins. Notes that a repository name is not a module id - Module-uo ships id `uo`, this ships `rust`, and 2.1 requires id to equal the directory core loads it from. R6 - the required base set is Kits 4.4.9, Clans 0.2.10 and PopupNotifications 0.2.1, all k1lly0u, all MIT, each with a direct .cs download, so phase 0's install step is three curls rather than manual retrieval. Clans is listed as a Universal plugin, written against Covalence, which is why its API takes IPlayer rather than BasePlayer. Kits is a good dependency: 23 HookMethods, including GiveKit for the reward action, GetKitNames/GetAllKits for the authoring form's OPTION SOURCE so an operator picks a kit from the live server instead of typing an identifier, and per-player uses and cooldown for eligibility. It also raises OnKitRedeemed, which the bridge can report as an ordinary event whoever triggered it. Two traps in GiveKit, both found by reading it rather than reasoning about it. It returns null on a failure path - `if (!player) return null` - and null is Oxide's idiom for "no opinion", so the reflex of treating null as fine reports a reward as delivered when there was no player to deliver it to. Success is the literal true and a refusal is a message string, which drops straight into chapter 5's `error`. And GiveKit takes a BasePlayer, so there is no offline grant: an event rewarding participants at 2am rewards only whoever is online. Phase 8 chooses between accepting online-only and keeping a persisted pending-grant queue, which is a second at-most-once store and is not free. R5 is settled far more firmly, and the other way round from how it was argued. For Teams the Clans plugin is WORSE than first-party, not richer: it publishes fifteen HookMethods and every one is a mutation, with no read API at all - no GetClan, no GetClanMembers, no GetAllClans - and it raises exactly three hooks, none of them a membership transition. It cannot answer any of core's three provider questions from its published surface, while first-party clans answer all three. So first-party is PERMANENT for the provider, not a first step. What the plugin actually adds is alliances and clan/alliance chat - richer in features, not in roster data - which is what phase 9 surfaces beside the provider rather than under it. Reading its own data files is recorded as deliberately not taken: that is another plugin's private storage, not an API. Two questions left open in 3, neither blocking: whether Clans being in the base set while the provider reads first-party is the intended reading (an interpretation, not something stated), and the offline-grant choice, deferred to phase 8 on purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
24 KiB
module-rust — the plan
Status: approved in outline 2026-09-15, not started. Six decisions of record settled; two questions open (§3).
The dry run designed this module on paper and deliberately did not build it. This is the document that builds it. Where the two disagree, this one is later and wins — but the dry run's four findings still stand, and one of them (identity) has moved from a footnote to the critical path. §2 says why.
Nothing here is normative. MODULE_API.md is the contract,
MODULE_SYSTEM.md the system, EVENTS.md
the event design of record, and the Integration Kit is the teaching text this plan follows
chapter by chapter. This document is a schedule and a set of decisions, not a specification.
1. The shape, and what is already settled
Three new repositories, mirroring the three the platform already has for Ultima Online, plus the optional fourth part that lives inside the module:
| Part | Repo | Mirrors | What it is |
|---|---|---|---|
| Website module | RunicGateway/Module-Rust |
Module-uo |
Routes, schema fragment, prebuilt client chunk, nav |
| Sidecar | RunicGateway/Rust-Link |
link |
Owns the game connection and the durable copy |
| Oxide bridge plugin | RunicGateway/Rust-Plugins |
servuo-plugins |
C# inside the game, dials out, never blocks |
| (event capability) | — | — | Declarations inside Module-Rust (kit ch. 5) |
All three were created empty on 2026-09-15. The module is built from
integration-kit/template/, which CI holds against a pinned core — currently
MODULE_API_VERSION 1.10.0.
The repository name is not the module id. Module-uo ships a module whose id is uo; this one
ships rust. §2.1 of the contract requires id to equal the directory core loads it from, which is
modules/rust/, and it is the prefix of every table and every mount — so the capitalisation in the
repository name reaches nothing inside the bundle.
Settled before this document and unchanged by it (dry run, org lead, 2026-08-19):
- One server, one sidecar, on that server's own host. A community with six servers runs six pairs; the module holds six clients and core never learns there is more than one.
- The plugin dials out. Rust's server is a binary, so the way in is Oxide's published hook API rather than source — and the shard-dials-out invariant survives that change of footing unchanged.
- No RCON. It was the original design and it was overruled.
wipe_idon every table that holds gameplay data. It is the whole shape of the game in one column, and it is the first thing a UO-shaped mental model gets wrong.
2. Decisions of record
R1 — identity is an in-game link code for v1
Decided 2026-09-15 (org lead). A player proves account ownership by typing a command in-game; the
plugin issues a one-time code; the website confirms it through the sidecar. Exactly the shape
module-uo uses.
This is the dry run's finding 1 answered for now rather than closed. There is still no
registerAuthProvider in the contract at 1.10.0 — verified against MODULE_API.md §2.4 on
2026-09-15 — so "Sign in with Steam", which every Rust community expects, is not reachable from a
module today. The link code is one screen worse and needs no core change, so it is what v1 ships.
What changed since the dry run is the stakes, not the options. The dry run rated this survivable because the module only read: a site that renders a leaderboard does not need to know which account owns a Steam ID. R2 makes the site the author of who may do what in the game, and R3 makes it the thing that hands out loot. Both are grants against a Steam ID. A weak identity link is now a privilege-escalation path, not a missing convenience — so the code must be single-use, short-lived, rate-limited, and issued in-game to the player who will own it.
Adding registerAuthProvider properly stays the first candidate for a future MODULE_API bump. It
participates in session creation, which is the one part of core a module must never be able to
weaken, and it must inherit core's existing policy: SSO is link-only, identities are never
auto-provisioned. Specified deliberately, not bolted on. It is out of scope here.
R2 — site-authored permissions are mirrored into Oxide's own permission store
Decided 2026-09-15 (org lead). The website is the author of record for groups and grants. The
bridge plugin applies them through Oxide's own API (permission.GrantUserPermission /
RevokeUserPermission), so Oxide is an enforcement cache and the site is the thing that
remembers.
The alternative — the plugin keeping a private table only our own features consult — was rejected because it cannot reach any third-party plugin, and reaching them is the point: a grant authored on the site has to gate Kits.
Three properties fall out, and they are the reason this shape is worth its cost:
- Every third-party plugin honours site-authored grants with no adapter, because they all already
call
permission.UserHasPermission. - A wipe stops being a data-loss event for permissions. The game forgets; the site does not, and re-pushes the whole set on the next connect.
- Hand edits are reported, not overwritten. Somebody typing
oxide.grantat the console is drift, and drift is surfaced to an operator — the same posture a lease'srestore()takes when it finds a value a human has moved (kit ch. 5).
This is a direction the Integration Kit has no chapter for, and that is a finding. Chapters 3 and 4 are the read path — data leaving the game. Chapter 5 is one-shot commands with a ledger and a teardown. This is neither: it is continuously reconciled state where the website is authoritative, and its nearest relative in the contract is the Team provider inverted — instead of core asking the module what the game knows, the module tells the game what the site knows. The mechanism it borrows is chapter 4's: every board's current state has exactly one producer, and it runs on connect, pointed the other way. Whether this deserves a sixth chapter is a question for phase 10, after it has been built once.
R3 — the Kits reward action registers always and refuses with a reason
Decided 2026-09-15 (org lead). Kits is required for event rewards, but "required" means the
action always exists and fails honestly where it cannot work — { ok: false, retry: false, error: 'Kits plugin not installed' } — rather than vanishing from the authoring form or refusing to boot
the module.
Two details from kit ch. 5 that this depends on and are easy to get wrong:
- The reason must be in
error. Core reads exactlyok,retryanderroroff a failure envelope; a message under any other name is dropped and the operator sees a bare"<action id> refused". retry: falsehas to be reachable. Core's dispatcher enforcesbudgetMsand classifies a budget timeout as retry unconditionally — so if the sidecar client's timeout is longer thanbudgetMs, our ownretry: falseis unreachable code. Derive one constant from the other and assert the inequality in a test. The first module this project shipped had exactly that pairing.
R4 — Rust reaches an operator through the existing installer, behind --game
Decided 2026-09-15 (org lead). Not a second binary and not a shared-core refactor: the shipped
installer grows a game dimension, --game servuo|rust, and keeps one
release stream, one doctor, one update, one uninstall.
The shape of the work is set by where the coupling already is. The reusable half is already
game-agnostic — src/service.rs and src/main.rs mention ServUO zero times, and net.rs,
diff.rs, paths.rs and ui.rs barely more. The UO-specific half is concentrated in four files
— install.rs, doctor.rs, overlay.rs, tier.rs — plus the bundle manifest, where
OverlayComponent and ServUoCompat name the game in the schema itself.
So the change is: make the bundle's game payload a variant rather than an overlay, and
ServUoCompat a per-game compat block. That is a schema change on the published bundles
branch, and it is the part to design before touching code.
The Rust payload is much simpler than the UO one, which is what makes this affordable: no source
tree to overlay, no patches/, no opt-in patch tier — a Rust install is a .cs file dropped into
oxide/plugins/, plus the sidecar and its service, which the installer already knows how to do. What
it gains instead is a prerequisite check: is Oxide installed, and is its build current enough.
That is doctor's shape, not a new concept.
The protocol pairing check generalises unchanged. servuo-plugins/overlay.toml declares the protocol
the overlay speaks and the installer refuses to pair a disagreeing sidecar; rust-plugins needs the
same declaration under whatever name the variant gives it, and the refusal is the same refusal.
R5 — Teams come from Rust's first-party clans; the uMod Clans plugin is the richer tier
Decided 2026-09-15 (org lead). Basic functionality is built on Rust's own clan system; the third-party uMod Clans plugin is an optional layer for a much richer experience, and lands in phase 9 rather than phase 6.
The distinction matters more than the names suggest, so it is worth stating precisely:
- Rust's first-party clans are seven hooks in
HOOKS.md—OnClanCreated,OnClanDisbanded,OnClanMemberAdded,OnClanMemberKicked,OnClanMemberLeft, plus colour and logo — each handing you aLocalClan. All seven are "no return behavior", which is exactly what a read-only bridge wants: there is nothing to abstain from, so kit ch. 4's return-null-from-every-veto rule has no work to do here. They need no plugin, and the rig is already running them (D:\rust\server\server1\clans.287.db). - Rust's first-party Teams are a different system — twelve hooks, mostly vetoable — and are the transient in-game squad, not the persistent organisation. They are not what core's Team provider should be fed. Clans is the right choice and this is why.
- The uMod Clans plugin's API is not in our mirror.
HOOKS.mdis the game's 477 hooks; a plugin's own API is its own documentation. It is reached through[PluginReference]and is null when absent, making it an R3-shaped dependency that must refuse with a reason.
Reading that plugin's source settled R5 far more firmly than the reasoning above did, and in a
direction worth stating plainly: for Teams, the plugin is worse than first-party, not richer.
Clans v0.2.10 (k1lly0u, MIT, 2,692 lines) publishes fifteen [HookMethod]s and every one of
them is a mutation — CreateClan, JoinClan, LeaveClan, KickPlayer, PromotePlayer,
DemotePlayer, DisbandClan, and the eight alliance verbs. There is no read API whatsoever: no
GetClan, no GetClanOf, no GetClanMembers, no GetAllClans. And it raises exactly three
hooks — OnClanCreate, OnClanChat, OnAllianceChat — none of which is a membership
transition.
So it cannot answer any of core's three provider questions from its published surface, while first-party clans answer all three. Feeding the Team provider from first-party clans is therefore permanent, not a first step.
What the plugin genuinely adds is alliances and clan/alliance chat — richer in features, not in roster data. That is what phase 9's adapter surfaces, and it sits beside the Team provider rather than under it.
One route is deliberately not taken: the plugin persists to its own files under oxide/data/, and a
determined integration could read those directly. That is reaching into another plugin's private
storage, not using an API — it breaks without warning on any upstream refactor and is not a
contract anybody owes us. If phase 9 wants roster data from the plugin, the honest path is an
upstream pull request adding a read method, not a file reader.
R6 — the required base set is Kits, Clans and PopupNotifications, all k1lly0u
Named 2026-09-15 (org lead). All three are MIT, all by the same author, all fetched at plan time:
| Plugin | Version | Released | Source |
|---|---|---|---|
| Kits | 4.4.9 | 2026-06-04 | https://umod.org/plugins/Kits.cs |
| Clans | 0.2.10 | 2026-05-06 | https://umod.org/plugins/Clans.cs |
| PopupNotifications | 0.2.1 | 2026-08-09 | https://umod.org/plugins/PopupNotifications.cs |
Every one has a direct .cs download, so phase 0's install step is three curls into
oxide/plugins/ and no manual retrieval. Pull them fresh rather than using the copies staged in
Downloads, which are an older vintage.
Clans is listed by uMod as a Universal plugin rather than a Rust one — it is written against
Covalence, and its API takes IPlayer rather than BasePlayer. That is the portable half of the
uMod surface, and it is why the same plugin serves several games.
Kits is the opposite of Clans and is a genuinely good dependency. Twenty-three [HookMethod]s,
including the three things the event work actually needs:
GiveKit(BasePlayer player, string name)— the reward action's call;GetKitNames(List<string>)/GetAllKits()— the option source for the authoring form, so an operator picks a kit from the live server instead of typing an identifier from memory;GetKitInfo/KitDescription/KitImage/KitMax/KitCooldown/GetPlayerKitUses/GetPlayerKitCooldown— form metadata and per-player eligibility.
It also raises OnKitRedeemed(BasePlayer player, string kitName), which the bridge can listen on
to report a redemption as an ordinary event, whoever triggered it.
Two traps in GiveKit that phase 8 must handle, both found by reading it rather than by reasoning
about it.
GiveKit returns null on a failure path, and null is Oxide's idiom for "no opinion". The
first line is if (!player) return null;. Everywhere else in this ecosystem a null return means
abstain, so the reflex — treat null as fine — reports a reward as delivered when there was no
player to deliver it to. The success value is the literal true; a refusal is a message string,
which drops straight into chapter 5's error field. So the mapping is
result is bool ok && ok for success, a string for a refusal reason, and null is a failure, not
a success.
GiveKit takes a BasePlayer, so the player must be connected. There is no offline grant in
this API. An event that rewards participants at two in the morning rewards only whoever is online at
that moment, silently. Phase 8 has to choose: accept online-only and say so in the action's
description, or keep our own persisted pending-grant queue in the bridge and redeem it on next
connect. The queue is the honest answer and it is not free — it is a second at-most-once store
with its own idempotency, which is exactly the machinery chapter 4 says to persist in the world save.
Decide it deliberately at phase 8 rather than discovering it from a complaint.
PopupNotifications is small and does exactly one thing: CreatePopupNotification(string message, BasePlayer player = null, float duration = 0f), where a null player makes it global. That is the
server-side notification surface, and it needs no more than that.
The one gap to design around: the seven first-party hooks carry created, disbanded, added,
kicked and left — but no promote or leader-changed event. Core's provider requires
getTeamLeaders, so leadership is read off LocalClan at snapshot time rather than tracked from
transitions. That makes phase 6 partly snapshot-driven where the dry run predicted it would be
fully event-driven — a small correction to that document, recorded here rather than silently.
3. Open questions
The base set question is closed by R6. Two smaller ones are open, neither blocking:
Clans is in the base set and the Team provider reads first-party clans. Those are not in conflict and this plan reads them as complementary: the plugin is installed because a community wants alliances and clan chat, while core's Teams are fed from the first-party system that actually publishes membership transitions (R5). Phase 9's adapter surfaces the plugin's extra features beside the provider rather than under it. Worth confirming — it is an interpretation, not something that was stated.
Offline reward grants. GiveKit requires a connected BasePlayer (R6). Whether phase 8 accepts
online-only or builds a persisted pending-grant queue is a real decision with real cost, and it is
deliberately deferred to that phase rather than guessed now.
D:\rust\oxide\plugins\ is empty, so the whole base set is a fresh install in phase 0.
4. The test rig
D:\rust on the org lead's workstation. It has been booted, it has a generated world
(procedural, seed 1234, size 4000, save v287) and Oxide 2.0.7585 matched to its build, and its
Oxide permission store already holds a default and an admin group with one admin user — which
means R2's mechanism can be exercised on day one.
Two traps recorded here because both cost time before they were understood:
D:\rust\start.batupdated the wrong directory — fixed 2026-09-15. It ransteamcmd +force_install_dir c:\rustserver\ +app_update 258550and then launchedD:\rust\RustDedicated.exe. The server that boots had never been updated by its own script, which is why a second, never-booted install exists atC:\rustserverand whyD:\rustis a wipe behind. Now reads+force_install_dir d:\rust\; the original is kept atD:\rust\start.bat.bak.D:\rust\steamapps\appmanifest_258550.acfwas already present at the same buildid, so the first corrected run is a delta to the current wipe rather than a 5.9 GB re-download.C:\oxide_filesis a 2025-04-23 Oxide and must not be copied anywhere. Oxide ships a patchedAssembly-CSharp.dll; that bundle's is 6,842,880 bytes against the live 9,780,224, so copying it over a real install is a hard downgrade.D:\rustis already correct and needs nothing from it.
Rust force-wipes on the first Thursday of the month and Oxide is rebuilt to match, so "is the rig current" is a recurring question, not a one-time setup step. Every phase that touches the plugin re-checks it.
5. The phases
Phases 0–3 produce a working read-only Rust site. 4–5 are the permissions product. 6 is Teams. 7–8 are events. 9 is the optional tier. 10 is how any of it reaches somebody who is not us. Each phase ends with its findings written down, as every workstream here does.
| # | Phase | Repos | Done when |
|---|---|---|---|
| 0 | The rig. Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set, prove a grant made at the console is visible to a plugin | docs | A current server boots with the base mods loaded and oxide.grant demonstrably gates something |
| 1 | Protocol 1 and three skeletons. Bounded drop-oldest queue, one writer thread, reconnect with a tagged epoch, dial-out; sidecar listener + SQLite + always-on token auth + version header; module from template/ |
all 3 + docs | One hello line travels game → sidecar → module, and killing the sidecar does not stall the game |
| 2 | The read path. First hook wave from HOOKS.md; events and snapshots kept distinct at the wire; wipe_id everywhere; every board re-emitted on connect |
all 3 + docs | A restarted sidecar is fully populated within one connection, with no negotiation |
| 3 | The first pages. Server list, per-server status, killfeed, leaderboard; capabilities; nav |
Module-rust | The site renders the last thing the game said while the game is off |
| 4 | Identity (R1) | 3 + docs | A player links an account in-game and the site names their Steam ID |
| 5 | Site-owned permissions (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe |
| 6 | Teams provider (R5). Rust's first-party clans; membership event-driven, leadership read off LocalClan at snapshot |
Module-rust + 2 | Core's reconciler is answered from live transitions, complete claimed only per reachable server |
| 7 | Events: one budget, one lease. kit ch. 5's own ordering — the lease before the action | Module-rust + 2 | The leased value is observed changing in the running game and restored, per key |
| 8 | Events: the Kits reward action (R3) | all 3 | A retried step grants loot once, and the ledger and the world agree |
| 9 | Optional mod integrations, the uMod Clans plugin first (R5). One adapter per plugin, each detecting via [PluginReference] and degrading to absent |
rust-plugins + docs | A server missing every optional mod still runs the module, Teams included |
| 10 | The installer (R4). `--game servuo | rust, the bundle payload as a variant, an Oxide prerequisite check in doctor`, the protocol pairing refusal carried over |
installer + docs |
| 11 | Docs, kit feedback, cutover | docs + Integration-kit | docs/ describes what shipped; R2's missing chapter answered either way |
Why the lease comes before the reward action
The stated priority is Kits rewards, and this plan still schedules a lease first. kit ch. 5 is
explicit about it and the reasoning survives restating: a lease is a value that already existed,
changed for a while, and put back, so reading it first gives you the baseline for nothing. An action
makes something that did not exist. The lease proves the whole command path — correlation, the
idempotency key, budgetMs against the client timeout, the deadline the game enforces on its own —
before anything hands out loot. It is a cheaper place to find all four of chapter 5's invisible
failures.
Reversible on request.
What phase 8 must declare honestly
A kit grant cannot be reversible: 'ledger'. That value is a promise that core may come back and
have the thing undone, on every terminal path including an abort — and there is no honest way to
un-grant loot a player has already spent. The correct declaration is reversible: 'none', and saying
so is the point: a capability that claims a reversal it cannot perform is the "capability that lies"
chapter 5 names, and neither core nor review can catch it.
cost() must count the kits actually granted, derived from the params, every time. Core prices
cost before dispatch and never reconciles it against what came back — it cannot, it does not know
what a kit is — so an action that reports one while granting twelve turns an operator's cap of 30
into a cap of 360 with nothing anywhere going red.
And the monthly wipe is the case revert's tolerance rule was written for: every ledgered resource
is invalidated at once, and "gone, and that is fine" is a success, not a failure.
6. Risks worth naming now
- Hooks bind by name and arity, by reflection, with no compile-time check. A misspelled hook is
never called, silently, with no warning at load — the single most common way a Rust plugin does
nothing. The plugin must log which of its expected hooks have fired at least once, so a hook
Facepunch renamed on a wipe is visible rather than mysterious. See
README.md§2. - A convar that applies cleanly and does nothing. Most game config is read once at boot and cached; applying it later succeeds, reads back correctly, and changes nothing. Every lease key gets verified live — apply, observe in the running game, restore — before it is advertised. The UO module surveyed 156 config reads and found roughly eight that were live.
- The wipe cadence is the schedule. A monthly force wipe moves the hook list, rebuilds Oxide, and invalidates every ledgered resource. Phases that end near one should expect to re-verify rather than assume.
start.bat's RCON password isletmeinin plaintext withrcon.web 1. Acceptable on a loopback dev rig, and it must never be the shape anything published copies.