module-rust's surface gets its second client. `modules/rust/PLAN.md` §18 records the phase; `android/PLAN.md` M14 records the app's half, as every Android milestone does. The decision worth the most words is D16. This module declared five capability strings and every one named a SURFACE — `servers`, `killfeed`, `leaderboard`, `presence`, `wipes` — while a client gating a navigation group needs one that names the MODULE. Core flattens every started module's capabilities into a single list, so `servers` is a word another module could declare tomorrow and silently reveal these screens on a site that does not run Rust. Gating on the module `id` was considered and rejected in as many words: `id` is a mount prefix, §2.9 forbids inferring a route from a capability, and letting a client gate on `id` makes the two the same value in practice. Also recorded: D17 (poll while RESUMED — the phone's Page Visibility gate), D18 (the Rust repositories move to `edge`, releases at the cutover), D19 (the drawer badge, and NavPaths learning `/rust`), why D15's footer slot had to be translated rather than copied, and §18.4's three defects — none of which a green suite of 644 could see, because each is about what a screen looks like or when a number is re-read. §18.6 is deliberately short and honest: the website's own pages have still not been read at phone width, and the badge's non-zero case was shown with a seeded count rather than by people playing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2741 lines
184 KiB
Markdown
2741 lines
184 KiB
Markdown
# `module-rust` — the plan
|
||
|
||
**Status:** phases 0 and 1 done, 2026-09-15. **Twenty-two decisions of record, no open questions.** Audited against the whole contract, not just the game-facing chapters
|
||
(§7); the event and engagement catalogues are §9 and §10; §11 is a second pass over `MODULE_API.md`
|
||
itself. **R19–R22 (2026-09-15) added a second modding framework, a Pterodactyl egg, moved the rigs off
|
||
the workstation, and put the sidecar's configuration in the egg** — see [`CARBON.md`](CARBON.md) for
|
||
the framework reference and §14 for the rig as built.
|
||
|
||
The [dry run](../rust-dryrun.md) 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`](../../website/MODULE_API.md) is the contract,
|
||
[`MODULE_SYSTEM.md`](../../website/MODULE_SYSTEM.md) the system, [`EVENTS.md`](../../website/EVENTS.md)
|
||
the event design of record, and the [Integration Kit][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`](https://gitea.whitlocktech.com/RunicGateway/Module-Rust) | `Module-uo` | Routes, schema fragment, prebuilt client chunk, nav |
|
||
| Sidecar | [`RunicGateway/Rust-Link`](https://gitea.whitlocktech.com/RunicGateway/Rust-Link) | `link` | Owns the game connection and the durable copy |
|
||
| Oxide bridge plugin | [`RunicGateway/Rust-Plugins`](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins) | `servuo-plugins` | C# inside the game, dials out, never blocks |
|
||
| *(event capability)* | — | — | Declarations inside `Module-Rust` ([kit][kit] ch. 5) |
|
||
|
||
All three were created empty on **2026-09-15**. The module is built from
|
||
[`integration-kit/template/`][kit], 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_id` on 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.grant` at the console is
|
||
drift, and drift is surfaced to an operator — the same posture a lease's `restore()` takes when it
|
||
finds a value a human has moved ([kit][kit] ch. 5).
|
||
|
||
**Phase 0 exercised all three against the real store and they hold — but it found four rules the
|
||
push path has to obey (§12.2).** The one that would have cost the most:
|
||
**`permission.GrantUserPermission` silently no-ops when the permission is not registered.** It
|
||
returns `void`, throws nothing and logs nothing; the grant simply does not happen. A permission is
|
||
registered by the plugin that declares it, so **every grant naming an unloaded, renamed or
|
||
uninstalled plugin's permission disappears without a trace** — and since R2's whole recovery story is
|
||
"the site re-pushes the full set on connect", a re-push into a server missing one plugin is a silent
|
||
partial. The push must check `PermissionExists` (or register the name itself) and report the
|
||
difference as drift rather than assuming a write landed.
|
||
|
||
**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 19,
|
||
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][kit] ch. 5 that this depends on and are easy to get wrong:
|
||
|
||
- **The reason must be in `error`.** Core reads exactly `ok`, `retry` and `error` off a failure
|
||
envelope; a message under any other name is dropped and the operator sees a bare
|
||
`"<action id> refused"`.
|
||
- **`retry: false` has to be reachable.** Core's dispatcher enforces `budgetMs` and classifies a
|
||
budget timeout as retry *unconditionally* — so if the sidecar client's timeout is longer than
|
||
`budgetMs`, our own `retry: false` is 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`](../../installer/PLAN.md) 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 17 rather than phase 9.
|
||
|
||
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`](HOOKS.md) — `OnClanCreated`,
|
||
`OnClanDisbanded`, `OnClanMemberAdded`, `OnClanMemberKicked`, `OnClanMemberLeft`, plus colour and
|
||
logo — each handing you a `LocalClan`. **All seven are "no return behavior"**, which is exactly
|
||
what a read-only bridge wants: there is nothing to abstain from, so [kit][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.md`](HOOKS.md) is 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`.
|
||
|
||
> **Corrected in phase 0 (§12.3).** This paragraph continued *"it raises exactly **three** hooks —
|
||
> `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — none of which is a membership transition"*, and
|
||
> concluded that the plugin cannot answer core's provider questions at all. **That was a grep
|
||
> artefact and it is wrong.** `Clans` 0.2.10 raises **nine** hooks. The missing six are invisible to
|
||
> a search for `CallHook("OnClan…` because the name is a `const` at the call site:
|
||
>
|
||
> ```csharp
|
||
> const string HOOK_NAME = "OnClanMemberJoined";
|
||
> Interface.CallHook(HOOK_NAME, tag, ulong.Parse(joining), RustMemberList);
|
||
> ```
|
||
>
|
||
> They are `OnClanMemberJoined(tag, joining, members)`, `OnClanMemberGone(tag, leaving, members)`,
|
||
> `OnClanDisbanded(tag, members)`, `OnClanAllianceCreated`, `OnClanAllianceDissolved`, and
|
||
> `OnClanUpdate(tag)`. The first three **carry the full member list**, so the plugin *can* answer
|
||
> "who is in this clan" without any read API, and `OnClanUpdate` fires on promote and demote — the
|
||
> exact transitions first-party lacks.
|
||
|
||
**The decision does not change, but its reason does.** First-party remains the Team provider's source
|
||
because it is the system the *game* maintains and every server has it; the plugin is an optional
|
||
install that not every shard will run, and feeding a provider from something optional makes Teams
|
||
conditional on a mod. It is no longer true that the plugin *cannot* answer the questions — only that
|
||
it should not be the one asked. Feeding the Team provider from first-party clans is still
|
||
**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 17'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 17 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: Kits, Clans, PopupNotifications and ZoneManager
|
||
|
||
**Named 2026-09-15 (org lead), extended the same day by R17.** All four are MIT; three are k1lly0u's
|
||
and BetterChat's author differs only in the optional tier. All fetched at plan time:
|
||
|
||
| Plugin | Version | Released | Source |
|
||
|---|---|---|---|
|
||
| [Kits](https://umod.org/plugins/rust-kits) | 4.4.9 | 2026-06-04 | `https://umod.org/plugins/Kits.cs` |
|
||
| [Clans](https://umod.org/plugins/clans) | 0.2.10 | 2026-05-06 | `https://umod.org/plugins/Clans.cs` |
|
||
| [PopupNotifications](https://umod.org/plugins/popup-notifications) | 0.2.1 | 2026-08-09 | `https://umod.org/plugins/PopupNotifications.cs` |
|
||
| [ZoneManager](https://umod.org/plugins/zone-manager) (R17) | 3.1.14 | 2026-09-02 | `https://umod.org/plugins/ZoneManager.cs` |
|
||
|
||
Every one has a direct `.cs` download, so phase 0's install step is four `curl`s into
|
||
`oxide/plugins/` and no manual retrieval. **Pull them fresh rather than using the copies staged in
|
||
`Downloads`**, which are an older vintage.
|
||
|
||
**The four base plugins use three different conventions for their callable API**, which is worth
|
||
knowing before someone goes hunting for the wrong one: Kits uses `[HookMethod]`, BetterChat uses
|
||
`API_`-prefixed methods, and ZoneManager uses plain private methods resolved by name. All three are
|
||
reached the same way from our side — `[PluginReference]` plus `Call()` — but only the first is
|
||
greppable as a declared API.
|
||
|
||
`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`, both found by reading it rather than by reasoning about it.**
|
||
|
||
> **R16 moved these off the critical path.** The reward action no longer calls `GiveKit` — it grants
|
||
> the kit's `RequiredPermission` and the player redeems it themselves. Both traps are kept here
|
||
> because the second one is *why* R16 is the better design, and because anything that ever does call
|
||
> `GiveKit` directly — an admin "give this player a kit now" button, say — walks straight into them.
|
||
|
||
**`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. The choice this forced was: accept online-only and say so in the action's
|
||
description, or keep a persisted pending-grant queue in the bridge and redeem it on next connect —
|
||
a second at-most-once store with its own idempotency, which is not free.
|
||
|
||
**R16 took a third option and it is the right one: stop granting items.** Grant the entitlement
|
||
instead. An entitlement waits without a queue, because waiting is what an entitlement does. The
|
||
problem was not hard to solve — it was the wrong problem, produced by an action declared around the
|
||
wrong noun.
|
||
|
||
`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 9 *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.
|
||
|
||
Phase 0 verified the seven against `agent/hooks.tsv` and the gap is real. It also found that the
|
||
**Clans plugin closes it** — `OnClanUpdate(tag)` fires on both promote and demote (§12.3). That does
|
||
not move the provider off first-party, but it does mean phase 17's adapter can offer *event-driven
|
||
leadership* on servers that run the plugin, over a snapshot baseline on servers that do not. Design
|
||
phase 9's snapshot so phase 17 can sharpen it rather than replace it.
|
||
|
||
### R7 — the notifications and engagement set ships in v1
|
||
|
||
**Decided 2026-09-15 (org lead).** `registerNotificationStreams`, `registerEventTriggers`,
|
||
`registerAudiences` and `registerEngagementSeeds` are all in the first release, plus
|
||
`registerAnnounceLeg` and `registerPostHook`.
|
||
|
||
They are a **matched set**, which is why they are one decision and one phase: a trigger declares the
|
||
payload contract and the widest audience a rule on it may ever be given, an audience resolves *people*
|
||
over module data, and seeds ship the bodies and the rules that use them. Three rules from the kit
|
||
that this phase lives or dies on:
|
||
|
||
- **`ceiling` is required, has no default, and is not a ladder.** A `staff` ceiling does **not** permit
|
||
`owner`, because "one person" for a cheat-detection event is *the player it was detected on*. Fewer
|
||
people is not less exposure.
|
||
- **`subjectKey` must name one of your declared variables** — it is what the cooldown keys on. Core
|
||
refuses the module at boot if it names nothing, which is the good failure; the bad one it prevents
|
||
is every subject sharing `undefined`.
|
||
- **A rule group is offered once, per group key.** A rule appended to an existing group reaches
|
||
**fresh installs only** — so a rule that must reach existing deployments takes a new group key.
|
||
|
||
And the emit discipline: **emit on the transition, not on the poll.** Core's cooldown would hide a
|
||
module that emitted "the server is still up" as news.
|
||
|
||
### R8 — multi-server from the start
|
||
|
||
**Decided 2026-09-15 (org lead).** The server list is the landing page and everything else hangs
|
||
under `/rust/servers/:id`. Every gameplay row carries a server id as well as a `wipe_id`, and the
|
||
module holds one sidecar client per configured server.
|
||
|
||
This is the dry run's finding 2 taken at face value: *"one module, one game" is not the same as "one
|
||
module, one server"*. Retrofitting an `:id` segment through every route, table and page is the
|
||
expensive version, and a Rust community runs several servers by default.
|
||
|
||
### R9 — the live map, with every layer toggleable
|
||
|
||
**Decided 2026-09-15 (org lead).** The site offers a live map, and **each layer is an operator switch
|
||
set to public / players-only / admin-only** through the existing visibility framework
|
||
([`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md)).
|
||
|
||
**The map is two problems and they take different paths, which is the thing to get right before
|
||
building either:**
|
||
|
||
- **The map image is static content on the game host** — `proceduralmap.<size>.<seed>.<save>.map`
|
||
beside the world save, regenerated only on a wipe. That is exactly [kit][kit] ch. 3 §2b's case, and
|
||
it takes that shape: **request/reply, never events** (a sidecar that broadcast it would write
|
||
megabytes into its own store and fan them at every client), **one in flight with an explicit busy**,
|
||
**two stages — what exists, then what changed**, and a **derivation version** separate from the
|
||
protocol so improving how we read the file invalidates a cached image whose source hash did not
|
||
move. And **no import on boot**: a wipe is an event the operator knows about and the website does
|
||
not.
|
||
- **Everything moving on it is live state** down the ordinary read path — monuments, cargo ship,
|
||
patrol helicopter, airdrops, locked crates, and player positions.
|
||
|
||
**The layer switches are a security boundary, not a preference.** Public player positions in Rust are
|
||
a competitive-advantage leak — anyone, including people who do not play on the server, could locate
|
||
players and infer base positions. The default posture is monuments and world events public, player
|
||
and base layers admin-only, and an operator opening one up is a deliberate act with the consequence
|
||
stated on the switch.
|
||
|
||
**This is the only asset-bridge work in scope.** Item icons and the 2,590 workshop skin ids stay out
|
||
of v1; kill feeds and kit lists render as text.
|
||
|
||
### R10 — the Android app is in this workstream, capability-driven, trailing by one phase
|
||
|
||
**Decided 2026-09-15 (org lead).** Full Rust support in the app, not a degradation check. It decides
|
||
which screens to show from **`GET /api/v1/public/modules`** — each started module's `capabilities`
|
||
array — and each app leg lands **one phase after** the website surface it consumes is merged, so it
|
||
is always built against a real endpoint rather than a planned one.
|
||
|
||
Two endpoints that are *not* this and are easy to confuse with it, both checked on 2026-09-15:
|
||
`/api/v1/public/status` returns site mode plus a version block — the first-run probe and
|
||
version-mismatch guard, not a feature manifest — and `/api/health` on the internal app is a liveness
|
||
probe returning `{status:'ok'}`. Neither can say which pages exist.
|
||
|
||
§2.9's rule governs: **treat an unknown capability as absent, and never infer a URL from one.**
|
||
|
||
### R11 — a small read-only set of Discord slash commands
|
||
|
||
**Decided 2026-09-15 (org lead).** Questions answered from data the module already holds — server
|
||
status, wipe schedule, leaderboards, who is online. No write verbs, and the account link stays on the
|
||
two surfaces R1 names rather than acquiring a third.
|
||
|
||
The trap to carry in from the Teams work: **ephemerality is fixed at the deferral**, so a command
|
||
that might refuse must defer ephemeral or its refusal goes public in the channel. And the registries
|
||
have no removal path, which is an argument for adding a command late rather than early.
|
||
|
||
### R12 — per-wipe detail plus all-time rollups
|
||
|
||
**Decided 2026-09-15 (org lead).** Every gameplay row carries `wipe_id`; leaderboards default to the
|
||
current wipe; a separate rollup accumulates per player across wipes so a returning player's history
|
||
survives the monthly reset.
|
||
|
||
The truncation is a **runtime operation on a module route, never a schema one** — §2.6's leading-verb
|
||
allowlist forbids `DELETE` and `TRUNCATE` in a fragment precisely because the fragment replays at
|
||
every boot and would empty the table on each restart.
|
||
|
||
### R13 — two extension slots: `admin.users.detail` and `site.footer.status`
|
||
|
||
**Decided 2026-09-15 (org lead).** An operator looking at a user sees their linked Steam identity,
|
||
per-server stats and site-authored permission grants in core's own admin user page; the footer
|
||
carries a shard-status indicator on every page.
|
||
|
||
One module per slot, so claiming them also reserves them. And the naming rule matters for anyone
|
||
reading this later: **a slot is named for a PLACE, never for a meaning** — `site.footer.status` is
|
||
"the status-ish spot in the footer", not core knowing what a game server is.
|
||
|
||
### R14 — `/rust` on all three tiers
|
||
|
||
**Decided 2026-09-15 (org lead).** `public`, `admin` and `player` all mount `/rust`, exactly as
|
||
`module-uo` mounts `/uo`. Sub-surfaces are path segments: `/rust/servers/:id`, `/rust/map`,
|
||
`/rust/clans`.
|
||
|
||
Chosen deliberately because **prefixes share one namespace with core's own and the loader's collision
|
||
probe cannot see all of core's** — several core endpoints are mounted at the tier root rather than
|
||
under a prefix. A noun from our own domain that equals the module id cannot collide, where
|
||
`/servers` or `/map` very well might.
|
||
|
||
### R18 — plugin configuration is editable from the site, with a generated form and an auto-reload
|
||
|
||
**Decided 2026-09-15 (org lead).** An admin edits any loaded plugin's configuration from the website
|
||
and it reloads automatically. Two tiers:
|
||
|
||
- **Base:** the site walks `oxide/config/` **recursively** and **generates a form from the values
|
||
themselves** — a boolean becomes a toggle, a number a numeric field, a string a text box, an array
|
||
a list, a nested object a group. It is derived at read time, so **it works for whatever plugins
|
||
happen to be installed**, including ones added or removed after we shipped.
|
||
- **Advanced:** raw JSON editing for anything the generated form cannot express.
|
||
|
||
This is the same posture as R2 — the site as the authority over the game host — but a different
|
||
*shape*. R2 is continuously reconciled state pushed on every connect; this is **request/reply,
|
||
on demand** ([kit][kit] ch. 3 §2a). Do not build it on the permission mirror.
|
||
|
||
**The mechanics Oxide gives us, all verified 2026-09-15:** configs live at
|
||
`oxide/config/<PluginName>.json`; `oxide.reload <name>` rereads one; and **`OnPluginLoaded` /
|
||
`OnPluginUnloaded` are real hooks** in the Server category, so *whether a reload actually succeeded
|
||
is observable* rather than assumed.
|
||
|
||
#### Discovery is a recursive walk, and it stops at `oxide/config/`
|
||
|
||
**Amended 2026-09-15 (org lead).** Configuration is **not** one flat `oxide/config/<Plugin>.json` per
|
||
plugin. Plugins nest — `oxide/config/<Mod>/whatever.json`, and deeper — and one plugin may own
|
||
several files. So discovery is a **recursive walk** of the config tree, and the UI groups by plugin
|
||
rather than assuming one file each.
|
||
|
||
Four things follow, and the first is a boundary rather than a detail.
|
||
|
||
**`oxide/data/` is not the settings surface, and must not be walked into.** `DataFileSystem` writes
|
||
to `oxide/data/`, and that is **live state, not configuration**. The base set makes the point by
|
||
itself: Kits keeps `Kits/kits_data.json` and `Kits/player_data.json`, ZoneManager keeps
|
||
`ZoneManager/zone_data.json`, Clans keeps `clan_data.json` (and a legacy `clans_data.json` beside it,
|
||
which is also a reminder that these names are not stable). Editing those from a web form edits
|
||
**players' kit cooldowns and the live zone definitions**, a running plugin overwrites the change on
|
||
its next save, and `oxide.reload` does not make most plugins safely re-read them. It is a different
|
||
problem with a different answer and it is deliberately out of scope. If a plugin's *settings* genuinely
|
||
live under `data/`, that is a per-plugin exception someone opts into knowingly, never something the
|
||
walk discovers on its own.
|
||
|
||
**The reload target cannot be inferred from the path.** `oxide/config/Foo/bar.json` may belong to
|
||
plugin `Foo`, or to something else entirely — the folder name is convention, not contract. So the
|
||
reload target is an **explicit field with the folder name as its default guess**, confirmable by the
|
||
admin. Infer it silently and the failure is the nastiest kind available here: we reload the wrong
|
||
plugin, observe `OnPluginLoaded` for *it*, and report success while the plugin that was actually
|
||
edited never re-read anything.
|
||
|
||
**A relative path from a web form is now a path-traversal surface.** Canonicalise the resolved path,
|
||
assert it is genuinely under the config root, reject absolute paths, and reject symlinks that resolve
|
||
outside. Before this amendment the feature addressed files by plugin name; now it addresses them by
|
||
path, and that is exactly the change that introduces the bug class.
|
||
|
||
**Bound the walk and the file.** A depth limit, a file-count limit and a per-file size cap — a
|
||
pathological tree must not be enumerated and a multi-megabyte JSON must not be loaded into a form.
|
||
And because one plugin can own several files, **the backup and rollback operate on the whole set a
|
||
save touches**, not one file at a time.
|
||
|
||
#### The trap that would silently corrupt every float
|
||
|
||
**JavaScript cannot tell `1` from `1.0`, and Oxide configs deserialize into typed C# classes.**
|
||
`JSON.parse('{"Rate":1.0}')` yields the number `1`, and `JSON.stringify` writes it back as `1` — so
|
||
a naive read-modify-write of a config file **silently rewrites every whole-numbered float as an
|
||
integer**, on fields nobody touched. Newtonsoft may coerce it, or may throw, and a throw at load
|
||
means the plugin does not come back.
|
||
|
||
So: **never parse the whole document, mutate, and re-serialize.** Edit the file *textually* — a
|
||
surgical replacement of the edited key's value — or use a parser that preserves number literals. The
|
||
fields at risk are exactly the ones a Rust server tunes: gather rates, multipliers, scales.
|
||
|
||
#### Five more limits of inferring a schema from values
|
||
|
||
Worth stating because the feature's whole promise is that it works without knowing the plugin:
|
||
|
||
- **Empty arrays and `null` carry no type.** Nothing can be inferred; render them advanced-only.
|
||
- **Enum-like strings are indistinguishable from free text.** There is no allowed-value set to read,
|
||
so a string field is a text box and cannot be validated.
|
||
- **There are no descriptions, no minimums and no maximums.** The key name is the entire label —
|
||
which is survivable because Oxide convention favours readable keys (BetterChat really does ship
|
||
`"Maximal Titles"` and `"Reverse Title Order"`).
|
||
- **Nested objects and arrays of objects need recursion**, a depth limit, and a fall-back to raw JSON
|
||
past it.
|
||
- **The file after a reload may not be what we wrote.** Oxide merges missing defaults and saves, so
|
||
re-read after reloading rather than assuming our write is the current state.
|
||
|
||
#### Safety, and why it needs more than the usual
|
||
|
||
**A bad config does not fail the write — it fails the next load, and the plugin stays down.** Since
|
||
R6 and R17 make four plugins *required*, a broken ZoneManager config takes event participation with
|
||
it. So the write path is:
|
||
|
||
1. Read with a **version** (hash or mtime) and require it back on write — an operator editing on
|
||
disk at the same time gets a conflict rather than a silent overwrite.
|
||
2. Validate the edited document parses.
|
||
3. **Back up the current file**, write, reload.
|
||
4. **Watch for `OnPluginLoaded` within a window.** If it does not arrive, **restore the backup and
|
||
reload again, automatically**, and report the failure with whatever Oxide logged.
|
||
|
||
That rollback is the feature's real content. Without it this is a web form that can take the shard's
|
||
plugins down one typo at a time.
|
||
|
||
**Two more obligations.**
|
||
|
||
**Redact secrets.** Plugin configs routinely hold API keys and Discord webhooks. A config reader
|
||
hands those to anyone who can open the page. Mask values whose key matches the usual shapes — key,
|
||
token, secret, password, webhook — and treat them **write-only**, exactly as the platform already
|
||
treats the uo-link token. This is a real leak vector and the generated-form approach walks straight
|
||
into it.
|
||
|
||
**Gate it on its own site permission and audit every write** — who changed which key, from what to
|
||
what, and whether the reload succeeded. It is an admin writing to the game host's filesystem, which
|
||
is the most powerful thing the site can do to a server.
|
||
|
||
#### One distinction to keep
|
||
|
||
**Editing a plugin's config file is not a lease.** A lease (§9) borrows a *convar* for a while and
|
||
the game restores it on a deadline. This writes a *file* and is permanent until someone changes it
|
||
back. They look similar from a web form and they are not the same mechanism — an event should never
|
||
reach for this one.
|
||
|
||
### R17 — ZoneManager joins the required base set, because events need to know where people are
|
||
|
||
**Decided 2026-09-15 (org lead).** **[ZoneManager](https://umod.org/plugins/zone-manager)** (k1lly0u,
|
||
3.1.14 released 2026-09-02, MIT, Rust, ~218k downloads) is a fourth **required** plugin, not an
|
||
optional one. It is what makes an event able to answer *where a player is*.
|
||
|
||
Reading its source changed two things this plan had been vague about, and promoted one action out of
|
||
the optional tier.
|
||
|
||
**Its API is private methods reached through Oxide's reflection `Call()`** — no `[HookMethod]`, no
|
||
`API_` prefix, which is a third convention among the four base plugins and worth knowing before
|
||
someone goes looking for one that is not there.
|
||
|
||
| Surface | What it gives an event |
|
||
|---|---|
|
||
| `IsPlayerInZone(zoneId, player)` → `bool` | the direct question |
|
||
| `GetPlayerZoneIDs(player)` → `string[]` | every zone a player is in |
|
||
| **`GetPlayerZoneIDsNoAlloc(player, List<string>)`** | the allocation-free variant — **the one to use on any sweep** |
|
||
| `CreateOrUpdateZone(zoneId, args, position)` | make a zone |
|
||
| **`CreateOrUpdateTemporaryZone(..., Plugin owner)`** | make a zone *owned by our plugin* |
|
||
| **`EraseTemporaryZone(Plugin owner, zoneId)`** | remove one; owner-scoped **only against another plugin's zone**, not against an unowned one (§12.4) |
|
||
| `GetZoneIDs` / `GetZoneName` / `GetZoneLocation` / `CheckZoneID` | the catalogue, for an option source |
|
||
|
||
And nine hooks raised: `OnEnterZone` / `OnExitZone`, `OnEntityEnterZone` / `OnEntityExitZone`,
|
||
`OnZoneInitialize` / `OnZoneUpdated` / `OnZoneDestroyed` / `OnZoneErased`, plus `CanSpawnInZone`,
|
||
which is a veto our bridge abstains from like every other.
|
||
|
||
**Three things this settles.**
|
||
|
||
**Participation stops being the hard part.** [`EVENTS.md`](../../website/EVENTS.md) §H rates
|
||
participation *"the hard part"* for UO and *"substantially easier"* for Rust because hooks carry
|
||
attacker and victim. ZoneManager makes it exact rather than merely easier: `OnEnterZone` and
|
||
`OnExitZone` are **presence transitions delivered as events**, so the participation ledger is fed
|
||
from what happened rather than reconstructed from a sweep. An action's reply carries its
|
||
`participants` envelope member, and this is where that member gets its content.
|
||
|
||
**Advance conditions become expressible.** A phase that waits until *"ten players are at the
|
||
monument"* is a real gate rather than a wish — it reads zone membership. Without ZoneManager the
|
||
same condition is distance arithmetic against a point, recomputed on a timer, which is both more
|
||
expensive and less accurate.
|
||
|
||
**`rust.zone.open` moves from the optional tier into the base catalogue, and it can be
|
||
`reversible: 'ledger'` honestly.** This is the nicer half: `CreateOrUpdateTemporaryZone` takes a
|
||
**`Plugin owner`** and `EraseTemporaryZone` is **scoped to that owner**, so ZoneManager already has a
|
||
first-class notion of a zone belonging to the plugin that made it. And erasing a zone that is gone is
|
||
a success, which is what `revert` needs.
|
||
|
||
> **Narrowed in phase 0 (§12.4), and this one is a safety correction.** The scoping is real but it is
|
||
> *one-directional*: it stops us erasing a zone owned by **another plugin**, and does nothing at all
|
||
> for a zone owned by **nobody**.
|
||
>
|
||
> ```csharp
|
||
> // Only compare zone owner if the owner param is provided so users can remove temporary zones
|
||
> // without needing to unload the plugin that created them
|
||
> if (owner && zoneOwner && owner != zoneOwner)
|
||
> return false;
|
||
> ```
|
||
>
|
||
> `zoneOwner` is null for every *permanent* zone — which is every zone an operator made by hand. So
|
||
> `EraseTemporaryZone(us, "<operator's zone>")` **deletes it and returns `true`**, indistinguishable
|
||
> from erasing our own. Observed live: a zone created with `CreateOrUpdateZone` and no owner was
|
||
> erased by an `EraseTemporaryZone` call from an unrelated plugin.
|
||
>
|
||
> So this is **less** of ch. 4's persisted ownership registry than the paragraph above claims. We
|
||
> still keep our own map from core's resource reference to the zone id, and that map is now
|
||
> **load-bearing rather than convenient**: phase 12 must refuse to erase any zone id it did not
|
||
> record creating. ZoneManager will not refuse on our behalf, and the `true` it returns is not
|
||
> evidence the zone was ours.
|
||
|
||
**One trap to design against, and it is chapter 4's rule meeting a chatty hook.** `OnEnterZone` and
|
||
`OnExitZone` fire on the game thread and a large zone with a busy server produces a great many of
|
||
them. The emit path already enqueues and returns, so the game cannot stall — but **the bridge should
|
||
subscribe selectively rather than forwarding every transition in every zone**. A zone no event cares
|
||
about should cost nothing on the wire. Decide the filter with the hooks in front of you at phase 12,
|
||
and measure it, because a sweep over `GetPlayerZoneIDsNoAlloc` is cheap and a flood of wire traffic
|
||
is not.
|
||
|
||
### R15 — an optional-integration tier, with BetterChat as the first member
|
||
|
||
**Decided 2026-09-15 (org lead).** Beyond the required base set (R6) the module carries a tier of
|
||
**optional** integrations: each detects its plugin through `[PluginReference]`, degrades cleanly to
|
||
absent, and adds something the site already knows how to compute.
|
||
|
||
The first named member is **[BetterChat](https://umod.org/plugins/better-chat)** (LaserHydra, 5.2.15,
|
||
MIT, Universal/Covalence, ~200k downloads) — *"manage chat groups, customize colors, and add
|
||
titles"*. The motivating case is **titles earned from leaderboards**: top of the wipe's kill board
|
||
gets a tag in chat.
|
||
|
||
**Its integration point is a pull, not a push, and that is why it is a good first member.**
|
||
`API_RegisterThirdPartyTitle(Plugin plugin, Func<IPlayer, string> titleGetter)` registers a callback,
|
||
and BetterChat invokes it per player when it renders a chat line. So a leaderboard title is a *pure
|
||
function of state we already hold* — nothing is written into BetterChat, nothing can go stale, and
|
||
there is no drift to reconcile. Contrast R2, which is a push and needs a whole reconcile story.
|
||
|
||
**The one trap, and it is chapter 4's rule applied to somebody else's callback:** that getter runs
|
||
**synchronously on the chat path**. It must be a cheap in-memory lookup — never a socket call, never
|
||
a database query, never anything that can block. A title that costs a round trip is a chat message
|
||
that costs a round trip.
|
||
|
||
Its other two API methods, `API_AddGroup(group)` and `API_SetGroupField(group, field, value)`, pair
|
||
naturally with R2: the site already authors permission groups, so a site-authored group can carry a
|
||
chat colour and tag. That is a push and would need the same drift posture R2 has; it is a phase-17
|
||
decision, not a given.
|
||
|
||
**The tier is open-ended by design.** Other integrations get added as they prove useful, and the bar
|
||
for each is the one this plan applies everywhere: it must fulfil the contract — declare honestly,
|
||
degrade to absent, and never make the module's own surfaces depend on something that may not be
|
||
installed.
|
||
|
||
### R16 — the reward action grants the RIGHT to redeem, not the items
|
||
|
||
**Decided 2026-09-15 (org lead).** An event reward does **not** call `GiveKit`. It grants the
|
||
**permission that gates a kit**, and the player redeems it themselves in game, whenever they next
|
||
log in.
|
||
|
||
**Kits already has exactly this model built in**, which is what makes it cheap: every kit carries a
|
||
`RequiredPermission`, `GiveKit`'s own path checks it before handing anything over, and the in-game
|
||
kit menu renders a kit the player lacks the permission for as locked rather than hiding it.
|
||
`GetKitInfo` returns that permission under `["permission"]`, so the module can read which kits are
|
||
gated and which are open to everyone.
|
||
|
||
This is a better design than the one it replaces, and it is worth being explicit about how much it
|
||
removes:
|
||
|
||
- **The offline-grant problem disappears entirely.** `GiveKit` needed a connected `BasePlayer`, so an
|
||
event firing at two in the morning rewarded only whoever happened to be online. An entitlement
|
||
waits. **This closes the open question §3 carried** — no pending-grant queue, no second
|
||
at-most-once store, none of it.
|
||
- **It is the same machinery as R2, not a second mechanism.** A reward becomes a permission grant
|
||
authored by the site and mirrored into Oxide — the thing phase 7 already builds. One permission
|
||
authority, one drift story, one audit trail.
|
||
- **`reversible: 'ledger'` becomes honest**, where a direct grant could only ever be `'none'`. See
|
||
*"What phase 13 must declare honestly"* below.
|
||
- **The player gets agency.** They redeem when they want it, where they want it, with Kits' own
|
||
cooldown and use limits still applying — rather than having items appear in their inventory,
|
||
possibly while they are somewhere it is a liability.
|
||
|
||
**One design note the option source has to carry.** A kit with an **empty** `RequiredPermission` is
|
||
open to everybody, so granting a permission for it rewards nobody with anything. The authoring form's
|
||
kit dropdown must surface which kits are permission-gated and refuse — or at minimum warn loudly —
|
||
on one that is not. That is a real refusal with a real reason, and exactly what R3's envelope is for.
|
||
|
||
### R19 — the plugin is framework-agnostic: Oxide **and** Carbon, from now rather than later
|
||
|
||
**Decided 2026-09-15 (org lead).** Modded Rust runs on two frameworks, not one, and `module-rust`
|
||
supports both from the phase it first reads anything — not as a port after phase 18. The bridge
|
||
plugin stays **one `.cs` file in the `Oxide.Plugins` namespace deriving from `RustPlugin`**, which is
|
||
also Carbon's own documented first example, with `#if CARBON` used only where the APIs genuinely
|
||
differ.
|
||
|
||
**This is affordable because the divergence is concentrated, not spread.** Carbon is not a fork of
|
||
Oxide; it is a separate loader shipping an Oxide compatibility layer, and at the level a plugin sees
|
||
the two are the same API. [`CARBON.md`](CARBON.md) is the reference — where it came from, what was
|
||
read, and the honest note that **none of it has yet run on a live Carbon server.**
|
||
|
||
Three existing decisions take an amendment, and no decision is reversed:
|
||
|
||
- **R18 — paths come from the framework, never from a literal.** Carbon's config directory is
|
||
`carbon/configs` (plural) and its data directory `carbon/data`, *and every one of Carbon's
|
||
directories is relocatable from the command line* (`-carbon.configdir`, `-carbon.datadir`,
|
||
`-carbon.rootdir`, and nine more). So the recursive walk is rooted at
|
||
`Interface.Oxide.ConfigDirectory` and the directory it refuses to walk is
|
||
`Interface.Oxide.DataDirectory`. Carbon reimplements both accessors; a hardcoded `oxide/config/`
|
||
is wrong on Carbon *and* on an Oxide server whose operator moved things. The reasoning behind R18
|
||
is untouched — only how the two roots are obtained.
|
||
- **R2 — the store was never readable and now it is unreadable by construction.** Oxide persists
|
||
permissions as JSON (`oxide/data/oxide.users.data`); Carbon persists them as **Protobuf or
|
||
SQLite**, switchable at run time. R2 always planned to read the API, so nothing changes — but the
|
||
file-reading shortcut is now permanently closed, which is worth saying once. §12.2's
|
||
`PermissionExists` pre-check also survives intact: Carbon's `GrantUserPermission` returns `bool`
|
||
where Oxide's returns `void`, so **the framework that would have told us whether the write landed
|
||
is the one we cannot portably listen to.**
|
||
- **R4 — `doctor` asks *which* framework, not *whether Oxide*.** The payload drops into
|
||
`PluginDirectory` either way. One thing gets weaker: **Carbon's releases are rolling tags**
|
||
(`production_build`, `edge_build`), not an incrementing build number, so "current enough" is a
|
||
claim `doctor` can make about Oxide and can only approximate about Carbon.
|
||
|
||
**What this decision explicitly refuses.** Carbon publishes 30 hooks Oxide does not, including an
|
||
`OnCarbon*` family mirroring its admin module's every moderation action — a tempting staff-audit
|
||
feed, and precisely the thing that would quietly make Carbon required. **No Carbon-only hook and no
|
||
Carbon-only convar enters a catalogue** unless it has an Oxide answer first, or is advertised
|
||
conditionally on the connected server's framework as a deliberate decision. Likewise Carbon's native
|
||
`Carbon.Plugins` / `CarbonPlugin` shape is not used: it is the single choice that would make the
|
||
source Carbon-only.
|
||
|
||
**The one thing to hold loosely.** Thirteen hook names our uMod mirror carries are absent from
|
||
Carbon's published catalogue ([`CARBON.md`](CARBON.md) §6). At least two look like renames rather
|
||
than holes, and none is in a phase today. The protection is the one §6 already requires for a
|
||
different reason — *the plugin logs which of its expected hooks have fired at least once* — which
|
||
answers this on either framework without trusting either catalogue.
|
||
|
||
### R20 — a Pterodactyl egg is a Rust-Link deliverable, with the sidecar inside the game container
|
||
|
||
**Decided 2026-09-15 (org lead).** Most Rust servers are rented, and most rented Rust servers run on
|
||
a Pterodactyl panel. So alongside the installer (R4) and the hand install, **a published Pterodactyl
|
||
egg is the third supported way the shard side reaches an operator** — and it has to work *in the same
|
||
manner* as the other two, not as a degraded variant.
|
||
|
||
The egg is derived from the community **"Rust Autowipe"** egg, taken as the known-good base, and it
|
||
keeps everything that egg already gets right: the steamcmd install script, the wipe-day
|
||
`REGEN_SERVER` / `REMOVE_FILES` mechanism, the Rust+ `APP_PORT`, and — the reason it is the right
|
||
base — a **`FRAMEWORK` variable already offering `vanilla | carbon | oxide`**. The operator picks the
|
||
framework at deploy time, which is R19's justification restated as a deployment fact: we do not get
|
||
to choose.
|
||
|
||
**The sidecar runs inside the game's own container, and that is the load-bearing part.** A
|
||
Pterodactyl server gets its own network namespace, so `127.0.0.1` inside it is genuinely private —
|
||
which means **D2 survives untouched**: the game link stays loopback and stays unauthenticated,
|
||
because loopback *is* the authentication. The startup command becomes a small wrapper that launches
|
||
`rust-link-sidecar` and then `RustDedicated`.
|
||
|
||
The alternative — a second Pterodactyl server running the sidecar — was rejected for exactly that
|
||
reason. Two containers have no shared loopback, so it would force a token and a routable bind onto
|
||
the game link. That is the case argued at D2 and overruled; it is not reopened here.
|
||
|
||
Four things the egg must get right, each of which is a way to get it wrong:
|
||
|
||
- **A second allocation for `[web].bind`.** The sidecar's HTTP/WS side is the half the website
|
||
reaches, so it binds to the container's assigned address on an allocation the panel hands out —
|
||
not to loopback. The token is what guards it, exactly as on a hand install.
|
||
- **The sidecar's database must never appear in `REMOVE_FILES`.** That variable is the wipe
|
||
mechanism, and R12 keeps **all-time rollups across wipes**. A sidecar store swept on wipe day is
|
||
the one failure that looks like success: the server comes back, the site repopulates, and every
|
||
player's history is silently gone.
|
||
- **Stop means stop the game.** The egg's stop command is `quit`, addressed to RustDedicated. The
|
||
wrapper has to let the sidecar go down with it rather than outliving it or holding the container
|
||
open.
|
||
- **The plugin and the sidecar come from a release, never from a copy.** The install script fetches
|
||
the pinned pair the same way the installer resolves a bundle — which makes the egg the third
|
||
consumer of the protocol-pairing check, not an exception to it.
|
||
|
||
**It lands in phase 18, beside the installer**, because phase 18 is already "how the shard side
|
||
reaches an operator", and one story told twice is how two stories drift apart.
|
||
|
||
### R21 — both Rust rigs move to Pterodactyl, because one install cannot prove two frameworks
|
||
|
||
**Decided 2026-09-15 (org lead).** Oxide and Carbon **cannot coexist in one install** — Oxide ships
|
||
a patched `Assembly-CSharp.dll` and Carbon requires Facepunch's vanilla one. So R19 cannot be proven
|
||
on `D:\rust`, or on any single server, at all.
|
||
|
||
Both rigs move to the existing Pterodactyl panel at **192.168.0.12** (node `Main`): one server with
|
||
`FRAMEWORK=oxide`, one with `FRAMEWORK=carbon`, on the same egg. They are started and stopped as
|
||
needed rather than both left running.
|
||
|
||
This replaces `D:\rust` as the rig of record, and it buys more than parity:
|
||
|
||
- **The egg gets exercised by every phase**, not only by phase 18. R20's deliverable stops being a
|
||
thing written once at the end against a panel nobody has used.
|
||
- **It ends the wipe-day maintenance that dominated §4.** `start.bat`'s steamcmd argument ordering,
|
||
re-extracting Oxide after every `app_update`, checking `Assembly-CSharp.dll`'s byte size to tell a
|
||
half-done Oxide install from a working one — all of that becomes the panel's job, through
|
||
reinstall.
|
||
- **It is a Linux rig.** Every previous finding came from Windows and Mono; phase 1 spent real time
|
||
on a Mono-specific NUL-padded `SocketException.Message`. Production Rust servers are Linux, so the
|
||
rig moving there makes findings more representative, and makes any remaining Windows-only
|
||
behaviour something we notice rather than depend on.
|
||
|
||
**A Carbon rig must be a clean install, not a converted one.** Carbon migrates an Oxide install on
|
||
first boot — it copies config, data, lang and permission files across. A Carbon rig made by
|
||
converting the Oxide rig would start out holding the Oxide rig's state, and would prove less than a
|
||
fresh one.
|
||
|
||
**The access, and the two kinds of key it takes.** `RunicGateway/pterodactyl_claude_api_token` holds
|
||
both, one per line: an **application** key (`ptla_…`), which creates and configures servers,
|
||
allocations and users and reads eggs but **cannot touch files, power or console**; and a **client**
|
||
key (`ptlc_…`), which is where Pterodactyl puts exactly those. An application key is rejected
|
||
outright by `/api/client/**` and cannot be widened — they are two credentials, not two scopes of
|
||
one. So the deployment loop is three tiers, matched to what each is for:
|
||
|
||
| What | How | Why that one |
|
||
|---|---|---|
|
||
| **A release artefact** — the pinned plugin + sidecar pair | The egg's own install script, re-run by a panel **reinstall** | It is the path we ship. Exercising it on the rig is acceptance testing for free |
|
||
| **Working-tree iteration** — an uncommitted `.cs` under test | A **client** API key (`ptlc_…`): `files/write`, then `command` to reload | The Pterodactyl analogue of `servuo-plugins/deploy.ps1`, and it carries the same caveat: **if something only works when the push script copies it, it does not ship** |
|
||
| **Bulk or binary** — sidecar builds, world files | SFTP on the node, port 2022 | Where the client API's per-file write is the wrong shape |
|
||
|
||
**Both keys exist and both were exercised on 2026-09-15** — the token file holds them as
|
||
`application:` and `user:` lines, and §14 records the rig they built together. The push script itself
|
||
lives in **`Rust-Plugins`**, mirroring where `deploy.ps1` lives for ServUO.
|
||
|
||
### R22 — the sidecar is configured from the egg's variables, not from a file the operator edits
|
||
|
||
**Decided 2026-09-15 (org lead).** What normally lives in `sidecar.toml` moves into the Rust egg's
|
||
variables, so an operator on Pterodactyl configures the sidecar in the panel alongside the game's own
|
||
settings rather than opening a file manager to edit TOML. One configuration surface, in the place
|
||
they are already looking.
|
||
|
||
**This is nearly free, because the sidecar already does it.** `rust-link`'s `config.rs` documents its
|
||
precedence as *environment overrides file overrides defaults* and already reads all five keys from
|
||
the environment: `RUSTLINK_GAME_BIND`, `RUSTLINK_SERVER_ID`, `RUSTLINK_WEB_BIND`,
|
||
`RUSTLINK_WEB_TOKEN`, `RUSTLINK_DB_PATH` (plus `RUSTLINK_CONFIG` for the file's own path).
|
||
Pterodactyl exposes every egg variable to the container as an environment variable, so the mapping is
|
||
one-to-one and **no second configuration mechanism is introduced** — the file stays canonical, the
|
||
environment overrides it, the egg sets the environment, and the installer (R4) keeps writing the file
|
||
exactly as it does now.
|
||
|
||
Which gives the two halves of the shard side two different config surfaces, deliberately:
|
||
|
||
| | Configured from | Mechanism |
|
||
|---|---|---|
|
||
| The **plugin** | the website, Admin → the R18 config editor | D3: it reads `oxide/config/RunicGateway.json`, so it is inside R18 for free |
|
||
| The **sidecar** | the panel, as egg variables | R22: `RUSTLINK_*` in the container environment |
|
||
|
||
That split is right rather than merely convenient. The plugin is configured by the thing it talks to;
|
||
the sidecar is configured by the thing that starts it, and on a panel the operator has no shell.
|
||
|
||
**Three things the variable set has to get right**, each of which is a way to hand somebody a footgun:
|
||
|
||
- **`RUSTLINK_GAME_BIND` is not operator-editable.** D2 makes loopback the authentication on the game
|
||
link; a panel field that accepts `0.0.0.0:7799` is a web form that puts an unauthenticated command
|
||
channel on the network. It is set by the egg and marked neither viewable nor editable — the same
|
||
posture R18 takes toward the plugin's own `Host`/`Port`, for the same reason.
|
||
- **`RUSTLINK_WEB_BIND` is derived from an allocation, not typed.** It has to match the port the panel
|
||
actually handed out, exactly as the egg already derives `QUERY_PORT` and `RCON_PORT`. A free-text
|
||
bind is a bind that silently does not match the allocation, and the failure is the website never
|
||
connecting with nothing in any log to say why.
|
||
- **`RUSTLINK_DB_PATH` must point somewhere `REMOVE_FILES` never sweeps.** Already named in R20 and
|
||
restated here because this is the decision that makes the path an operator-visible field: the wipe
|
||
list and the database path become two settings on the same screen, and they must not be able to
|
||
agree.
|
||
|
||
**The token is the one place the ergonomics are not automatic.** Today the sidecar generates a token
|
||
when it finds none and persists it to its config file, which is what makes it secure out of the box;
|
||
`--print-config` is how an operator reads it back. A panel variable cannot be filled in by the
|
||
program that generates it, so the choices are: ship an empty default and let the sidecar generate and
|
||
persist as it does now, with the operator reading it out of the panel's file manager once; or make
|
||
the operator paste one in. The existing precedence already supports both — a set variable wins, an
|
||
empty one falls through to generation — so this is a default to choose when the egg is built, not a
|
||
mechanism to design. **Whichever is chosen, note that a Pterodactyl variable is visible to anyone
|
||
with panel access to that server and appears in the container environment**, which is a different
|
||
exposure from a `0600` file and should be stated in the operator guide rather than discovered.
|
||
|
||
**Lands in phase 18 with the rest of R20's egg.**
|
||
|
||
## 3. Open questions
|
||
|
||
**None.** Every question this section carried was closed on 2026-09-15, and so was the one open
|
||
*request*: the token file now holds both keys, and **both were exercised end to end on 2026-09-15**
|
||
(§14).
|
||
|
||
One correction belongs here rather than being quietly dropped, because the shape of the mistake is
|
||
the reusable part. This section briefly recorded that the client key "authenticates and then lists
|
||
zero servers", and built a diagnosis on top of it — including a claim that *includes are broken on
|
||
this panel*, because `/api/application/servers?include=user` returned an empty list where the same
|
||
route without the include had returned six.
|
||
|
||
**Both claims were wrong, and they were wrong the same way.** The servers were being deleted while
|
||
the probing was happening, so two reads minutes apart were reads of two different worlds. The empty
|
||
client list was correct. The empty include was correct. Nothing was broken.
|
||
|
||
The lesson is not "check twice"; it is that **a differential diagnosis across two API calls silently
|
||
assumes the state did not move between them**, and on a live panel somebody else is also holding the
|
||
controls. Once a server existed, every one of those calls answered correctly on the first try.
|
||
|
||
*Clans in the base set while the Team provider reads first-party* was confirmed as the intended
|
||
reading: complementary, not in conflict — the plugin is installed for alliances and clan chat, the
|
||
provider is fed from the first-party system that actually publishes membership transitions (R5).
|
||
|
||
*Offline reward grants* was dissolved rather than answered. **R16 changed the noun**: the reward
|
||
action grants an entitlement instead of items, and an entitlement does not need the player to be
|
||
online. The persisted pending-grant queue that question was weighing is not needed at all.
|
||
|
||
## 4. The test rig
|
||
|
||
**Two servers on the Pterodactyl panel at `192.168.0.12`, one per framework (R21).** They replace
|
||
`D:\rust`, which was the rig for phases 0 and 1 and whose findings are still recorded in §12 and §13.
|
||
|
||
| | Oxide rig | Carbon rig |
|
||
|---|---|---|
|
||
| Panel | node `Main` (192.168.0.12), nest 4 "Rust" | same |
|
||
| Egg | ours, derived from **"Rust Autowipe"** (panel egg id 18 is the unmodified base) | same egg |
|
||
| `FRAMEWORK` | `oxide` | `carbon` |
|
||
| Allocations | game, query, RCON, Rust+, **plus one for the sidecar's `[web].bind`** | same |
|
||
|
||
Started and stopped as needed rather than both left running; the other servers on the node are
|
||
shut down, which is what makes two ~20 GB Rust installs fit a 128 GB disk.
|
||
|
||
**The Carbon rig is a clean install, never a converted one.** Carbon migrates an Oxide install on
|
||
first boot — config, data, lang and permission files all come across — so a Carbon rig made by
|
||
switching `FRAMEWORK` on the Oxide rig would start out holding the Oxide rig's state and would prove
|
||
strictly less.
|
||
|
||
**What the panel changes about how work reaches a rig.** The token at
|
||
`RunicGateway/pterodactyl_claude_api_token` is an *application* key: it manages servers, allocations
|
||
and users, and it **cannot write a file, press a button or run a console command** — Pterodactyl puts
|
||
those on the client API. R21's table has the three tiers; the short version is *release artefacts
|
||
arrive by reinstall, iteration needs a client key, bulk goes over SFTP on port 2022.*
|
||
|
||
**Two facts about this panel that are easy to trip over:**
|
||
|
||
- `/api/client/**` returns **403 `AccessDeniedHttpException`** for the application key — a clear
|
||
error, but only if you are expecting it. It is not a permissions grant that can be widened.
|
||
- The application API has **no egg-write endpoint at all** (`/api/application/eggs` is a 404; eggs
|
||
are read through `/api/application/nests/{nest}/eggs`). Importing a new egg version is an admin-UI
|
||
or `php artisan` operation, so the egg's release artefact is a JSON file a human imports — which is
|
||
also exactly how an operator will consume it.
|
||
|
||
### What moving off the workstation retires
|
||
|
||
Everything below was true of `D:\rust` and is kept only because it explains findings in §12. **None
|
||
of it is maintenance any more** — the panel's reinstall does the same work correctly.
|
||
|
||
- **`start.bat` never updated anything.** steamcmd requires `+force_install_dir` **before** `+login`
|
||
and the script had it after, so the flag was discarded, the update ran against steamcmd's own
|
||
directory, and the job errored every single time (`Error! App '258550' state is 0x486`). That — not
|
||
the path, which was the first and wrong diagnosis — is why the rig fell a wipe behind.
|
||
- **Re-extract Oxide after every `app_update`.** Oxide ships a *patched* `Assembly-CSharp.dll` and a
|
||
Steam update restores Facepunch's, but the update does **not** remove `Oxide.Core.dll` and friends
|
||
— so a half-done install still *looks* Oxided while loading no plugins and raising no hook. The
|
||
tell was file size: on build 25230300, vanilla 9,758,544 bytes against Oxide 2.0.7716's 9,953,280.
|
||
- **`C:\oxide_files` is a 2025-04-23 Oxide and must not be copied anywhere** — its
|
||
`Assembly-CSharp.dll` is 6,842,880 bytes, a hard downgrade over a live install.
|
||
- **A wipe keeps `server/server1/cfg/`**, which holds `users.cfg` and therefore the `ownerid` line.
|
||
Delete the whole identity directory and you silently remove the operator's own ownership along with
|
||
the map. This one still applies — it is the game's shape, not the host's, and it is why the egg's
|
||
`REMOVE_FILES` list is worth reading carefully rather than trusting.
|
||
|
||
**Rust force-wipes on the first Thursday of the month, and both frameworks rebuild to match.** "Is
|
||
the rig current" stays a recurring question rather than a setup step; what changed is that the answer
|
||
is now a reinstall rather than a sequence of manual steps that can half-succeed.
|
||
|
||
## 5. The phases
|
||
|
||
**Twenty phases, roughly doubled from the first draft** — the contract audit in §7 is why, and the
|
||
honest reading is that the first list was a game-bridge plan with a website module bolted on, where
|
||
the kit treats the module as the bulk of the work.
|
||
|
||
0–4 produce a working read-only multi-server Rust site that an operator can actually install. 6–7b are
|
||
the permissions and remote-administration product. 9 is Teams. 10 is the notifications set, whose catalogue is **§10**. 12–13
|
||
are events, whose catalogue is **§9**. 14 is the map. 18 is how any of it reaches somebody who is
|
||
not us. The Android legs (5, 8, 11, 15) each trail the
|
||
website surface they consume by one phase, per R10.
|
||
|
||
**R19 and R21 do not add a phase — they change what "done" means for several.** Both rigs exist from
|
||
phase 3 onward, so from phase 3 a criterion is met when it is met **on both frameworks**, and a
|
||
finding that holds on only one is a finding either way. Phase 2's release artefacts and phase 18's
|
||
egg are the two places the second framework is visible in the deliverable rather than only in the
|
||
proving.
|
||
|
||
Each phase ends with its findings written down, as every workstream here does.
|
||
|
||
| # | Phase | Repos | Done when |
|
||
|---|---|---|---|
|
||
| 0 | **The rig.** ✅ **Done 2026-09-15 — as built and findings in §12.** Updated to the current wipe (the script was fixed *again*, properly), Oxide re-laid, base set installed, the grant path proven end to end and both zone transitions observed live with a player connected. **Both criteria met** | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it |
|
||
| 1 | **Protocol 1, three skeletons, and every bundle seam at once.** ✅ **Done 2026-09-15 — as built and findings in §13.** Plugin, sidecar and module all exist and all three were exercised against the live rig; three org-lead decisions (§13.0), five defects only a running server found (§13.3), and a correction to §11.3 (§13.2). **Both criteria met** | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton |
|
||
| 2 | **Packaging and release.** ✅ **Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
|
||
| 3 | **The read path, on both frameworks.** ✅ **Built and largely proven 2026-09-16 — as built and findings in §16.** Protocol 2: fifteen hooks, an envelope every frame carries, boards, a cursor feed and bounded history; four org-lead decisions (§16.0), two defects only a booted server could find (§16.2), and CI for the two bridge repositories that had none. **The player half of the catalogue is written down as a walk to run rather than measured** — see §16.7. First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
|
||
| 4 | **The first pages.** ✅ **Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off |
|
||
| 5 | **Android leg A** (R10). ✅ **Done 2026-09-17 — as built and findings in §18.** The server list and one server with four tabs, gated on a NEW capability the module had to declare (D16 — its five named surfaces, and a client needs one that names the module); a poll that keeps its rows when it fails, which the app had no shape for (D17); the drawer badge as D15 translated (D19). Four decisions (§18.0) and **three defects an emulator walk found that 644 green tests did not** (§18.4). **Both halves of the criterion walked on one device against two cores** | Android-app + Module-Rust + docs | The app renders a Rust site it has never seen, and a UO site unchanged |
|
||
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
||
| 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported. The `PermissionExists` pre-check stays the mechanism on **both** frameworks (R19); Carbon's 14 permission hooks are tested here as a possible live drift signal, and suppressed against our own pushes if they fire | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
|
||
| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
|
||
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
|
||
| 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
|
||
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |
|
||
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
|
||
| 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking |
|
||
| 13 | **Events: the actions** (§9, R3, R16). `rust.kit.entitle` first, then `rust.prefab.place` and `rust.announce`; `reversible: 'ledger'`; the kit option source flags kits with no permission gate, plus **`reconcile()` and the boot-id watch calling `ctx.events.reconcile()`** (§11.1) | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it; a wipe reconciles the ledger instead of stranding it |
|
||
| 14 | **The live map** (R9). The map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch built on **our own** visibility layer (§11.2 — `shardVisibility` is `module-uo`'s, not core's) | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it |
|
||
| 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates |
|
||
| 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel |
|
||
| 17 | **Optional mod integrations** (R15). **BetterChat** first — leaderboard titles through `API_RegisterThirdPartyTitle`, a pull with no drift — then the uMod **Clans** adapter (alliances and clan chat, beside the provider rather than under it, R5), then others as they prove useful | Rust-Plugins + Module-Rust + docs | A server missing every optional mod still runs the module, Teams included |
|
||
| 18 | **The installer** (R4) **and the Pterodactyl egg** (R20) — the two halves of "how the shard side reaches an operator", built together so one story is not told twice. `--game servuo\|rust`, the bundle payload as a variant, a **framework** prerequisite check in `doctor` (which one, not whether Oxide — R19), the protocol pairing refusal carried over; the egg derived from "Rust Autowipe" with the sidecar inside the game container, a second allocation for `[web].bind`, **the sidecar configured from egg variables** (R22), the sidecar store held out of `REMOVE_FILES`, and its install script fetching the same pinned pair the installer resolves | installer + Rust-Link + docs | An operator sets a Rust server up with the released binary and nothing hand-copied; **and** a second operator imports the egg, deploys, and reaches the same place — on either framework |
|
||
| 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change, and Pterodactyl is a hosting claim the site can now make); the Integration-kit question R2 raised; and whether the kit owes a reader anything about **supporting two mod frameworks at once** (R19) — a shape it has no chapter for either | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is 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 (12 before 13). [kit][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 13 must declare honestly
|
||
|
||
> **Rewritten 2026-09-15 by R16.** This section previously argued that a kit reward could only be
|
||
> `reversible: 'none'`, because there is no honest way to un-grant loot a player has already spent.
|
||
> That was correct *about a direct grant* and R16 stopped doing direct grants. The reasoning is kept
|
||
> below in its corrected form because the shape of the mistake is the reusable part: **the action was
|
||
> declared around the wrong noun.** What the event makes is not loot; it is an entitlement.
|
||
|
||
**The reward action grants an entitlement, and an entitlement is reversible.** `revert` revokes the
|
||
permission, removing one that is not there is a success, and it is idempotent by construction — so
|
||
**`reversible: 'ledger'` is the honest declaration**, and core's ledger sweep does real work on every
|
||
terminal path.
|
||
|
||
One consequence to state rather than discover: **a player who redeemed before the revert keeps the
|
||
items.** That is correct and not a hole. The ledgered resource is the *grant*, and reverting it
|
||
withdraws the entitlement rather than the consumption — the same way cancelling a coupon does not
|
||
un-eat the meal. An operator reading the run console should see that distinction in the wording.
|
||
|
||
**`cost()` counts permission grants, and unlike a kit count it is exactly knowable before dispatch.**
|
||
That removes the whole class of problem chapter 5 §4 warns about: there is no "declare the maximum
|
||
because you cannot know until the answer comes back". One recipient is one grant. Core prices `cost`
|
||
before dispatch and never reconciles it, so being able to count precisely is worth more than it
|
||
sounds.
|
||
|
||
**And the idempotency key largely stops mattering here**, which is the tidiest part of R16. Chapter 5
|
||
§2 draws the line itself: *"A key is for a write whose repetition would be a second EFFECT —
|
||
creating, granting, announcing. A write that SETS a value to X is idempotent by its own nature."* A
|
||
permission grant is a set. Pass the key through anyway — it costs nothing and it is what the
|
||
contract expects — but the failure mode it exists to prevent, a socket hiccup producing a second set
|
||
of everything, no longer has a way to happen.
|
||
|
||
The monthly wipe is still the case `revert`'s tolerance rule was written for: if a wipe or a rebuilt
|
||
host clears Oxide's permission store, every ledgered grant is invalidated at once and **"gone, and
|
||
that is fine" is a success**. Note this is also where R2 pays for itself twice — the site re-pushes
|
||
its whole permission set on the next connect, so an entitlement an event granted comes *back* rather
|
||
than being quietly lost.
|
||
|
||
## 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`](README.md) §2.
|
||
**R19 gives that mechanism a second job:** it is also the only trustworthy answer to "does this
|
||
hook exist on Carbon", since two published catalogues disagreeing is evidence about the catalogues
|
||
and not about the frameworks.
|
||
- **The cheapest way to make Carbon required is to do it by accident.** Carbon's extra 30 hooks, its
|
||
23 extra convars and its `bool`-returning permission API are each individually useful, individually
|
||
small, and collectively a framework lock-in nobody decided on. R19's refusal is written down
|
||
because it will be re-argued, once per convenience.
|
||
- **Two rigs is twice the state that can be quietly wrong.** A finding proven on the Oxide rig and
|
||
assumed on the Carbon one is exactly the failure this project keeps finding in source-read claims.
|
||
From phase 3, "done" means done on both, and a phase that could only check one says so.
|
||
- **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 both
|
||
frameworks, and invalidates every ledgered resource. Phases that end near one should expect to
|
||
re-verify rather than assume. **Carbon's self-updating and rolling release tags mean the Carbon rig
|
||
may move under us between two runs on the same day**, where an Oxide build number at least says so.
|
||
- **The old rig's RCON password was `letmein` in plaintext with `rcon.web 1`.** Acceptable on a
|
||
loopback dev rig behind a home firewall, and it must never be the shape anything published copies —
|
||
which now matters more, because the panel rigs are reachable on a LAN address and **the egg is a
|
||
published artefact that people will copy defaults out of.**
|
||
- **The panel is the rig and the deliverable at once.** Convenient, and a way to prove the wrong
|
||
thing: a rig hand-tuned through the panel UI stops testing the egg. Anything a rig needs belongs in
|
||
the egg or in the push script, never only in a server's saved configuration.
|
||
|
||
## 7. Contract coverage audit
|
||
|
||
Added 2026-09-15 after re-reading the whole kit rather than only chapters 3-5. **The first draft of
|
||
§5 was built from the game-facing chapters and under-planned the website module by a wide margin** —
|
||
the template makes eight of the ten non-event registrations and the plan covered three. Every element
|
||
is listed with the phase it now lands in; the column worth reading is *"was it in the first draft"*,
|
||
because that is the shape of the mistake.
|
||
|
||
### The server handshake — ten registrations plus two hooks (ch. 2)
|
||
|
||
| Call | In the first draft | Phase |
|
||
|---|---|---|
|
||
| `registerRoutes` | yes | 4 |
|
||
| `registerTeamProvider` | yes | 9 |
|
||
| `registerEventBudgets` / `OptionSources` / `Leases` / `Actions` | yes | 12-13 |
|
||
| `onBoot` / `onShutdown` | implicit only | 1 |
|
||
| `registerExtension` | **no** | 4 (`site.footer.status`), 6 (`admin.users.detail`) |
|
||
| `registerNotificationStreams` | **no** | 10 |
|
||
| `registerEventTriggers` | **no** | 10 |
|
||
| `registerAudiences` | **no** | 10 |
|
||
| `registerEngagementSeeds` | **no** | 10 |
|
||
| `registerAnnounceLeg` | **no** | 10 |
|
||
| `registerPostHook` | **no** | 10 |
|
||
| `registerSlashCommands` | **no** | 16 |
|
||
|
||
Triggers, audiences and seeds are one phase because they are a **matched set** in
|
||
`template/server/index.js`, not three independent gaps — see R7.
|
||
|
||
### The bundle's own parts
|
||
|
||
| Part | In the first draft | Phase |
|
||
|---|---|---|
|
||
| `module.json` `id` / `coreApi` / `capabilities` | yes | 1, 4 |
|
||
| `mounts` and prefix choice | **no** | 1 (R14) |
|
||
| `schema.sql` | yes | 1, 3 |
|
||
| `purge.sql` | **no** | 1 |
|
||
| `swagger-fragment.json` + generator + `check:swagger` | **no** | 1 |
|
||
| `vite.config.js` aliases / shims / `checkExternals` | **no** | 1 |
|
||
| `checkImports.js` | **no** | 1 |
|
||
| `release.yml`, install manifest, `sha256`, host allowlist | **no** | **2** — there was no packaging phase at all |
|
||
|
||
Phase 1 carries most of these on purpose. [kit][kit] ch. 1's whole argument is to get every seam
|
||
working at once with almost nothing in them, so that afterwards you break exactly one at a time.
|
||
|
||
### The client half
|
||
|
||
| Part | In the first draft | Phase |
|
||
|---|---|---|
|
||
| `registry.registerRoutes` / `registerNav` | yes | 4 |
|
||
| `declareModuleSlot` | **no** | 9 — the kit: *"you will need it the moment your game has anything like a guild"* |
|
||
| `registerFeatureProvider` | **no** | 4 |
|
||
| UI kit discipline (`PublicLayout` `shell`, `PageHeader` props) | **no** | 4 |
|
||
|
||
### Beyond the module
|
||
|
||
| Item | In the first draft | Phase |
|
||
|---|---|---|
|
||
| Sidecar rpc correlation | implicit | 1, stated |
|
||
| Asset bridge (ch. 3 §2b) | **no** | 14 — **map image only** (R9) |
|
||
| `.profile` landing page | **no** | 19 |
|
||
| `runicgateway.com` | **no** | 19 |
|
||
| Android app | **no** | 5, 8, 11, 15 (R10) |
|
||
| `docs/` | yes | every phase, plus 19 |
|
||
|
||
**What the audit cost the schedule: twelve phases.** That is the honest number, and it is worth
|
||
recording because the under-planning had one cause — reading the chapters that describe the game
|
||
bridge and treating the module as the thin part, when the kit says in its first paragraph that the
|
||
website module is most of the work.
|
||
|
||
## 8. Questions the audit raised — all answered
|
||
|
||
Every question §7 produced was put to the org lead on 2026-09-15 and answered the same day. They are
|
||
recorded as R7–R14 in §2 rather than repeated here:
|
||
|
||
| Question | Answer | Decision |
|
||
|---|---|---|
|
||
| Notifications and engagement in v1? | the full matched set | R7 |
|
||
| How many servers does the UI support? | multi-server from the start | R8 |
|
||
| The asset bridge? | **only** the live map — not item icons, not skins | R9 |
|
||
| Android in this workstream? | full app, capability-driven, trailing by one phase | R10 |
|
||
| Discord slash commands? | a small read-only set | R11 |
|
||
| What survives a wipe? | per-wipe detail plus all-time rollups | R12 |
|
||
| Extension slots? | `admin.users.detail` **and** `site.footer.status` | R13 |
|
||
| Which mount prefixes? | `/rust` on all three tiers | R14 |
|
||
|
||
§3 is empty; both were closed on the same day.
|
||
|
||
## 9. The event catalogue
|
||
|
||
Added 2026-09-15. **Phases 12–13 described the event *mechanism* and never the *catalogue*** — one
|
||
budget and one lease as a proof of life, which is a skeleton rather than a product. This section is
|
||
what the module actually declares.
|
||
|
||
[`EVENTS.md`](../../website/EVENTS.md) **§H is a Rust/Oxide compatibility section that already
|
||
sketched this**, and it should have been read before §5 was written. What follows takes its ids and
|
||
its reasoning as the starting point rather than inventing a parallel set.
|
||
|
||
> **§H's thesis, and it is the one to design around:** *"The lease is the primitive that travels, not
|
||
> the spawn. Double gather rate for the weekend is the canonical Rust community event, and it is
|
||
> exactly lease-with-expiry. Spawning creatures at a landmark is UO-shaped; holding a value for four
|
||
> hours is every game."* It also rates Rust the **easier** case than UO, because Oxide's convars are
|
||
> live by default where ServUO's are mostly cached at boot.
|
||
|
||
### Budgets — what core counts and bounds
|
||
|
||
| Dimension | Counts |
|
||
|---|---|
|
||
| `rust.prefabs` | objects placed into the world by a run |
|
||
| `rust.zone.minutes` | zone time held — real since R17 |
|
||
| `rust.grants` | entitlements granted (R16) |
|
||
| `rust.announcements` | in-game broadcasts |
|
||
|
||
**Caps are per run, and R8 makes that load-bearing.** §H: `run.scope` is part of a run's unique key,
|
||
so one definition fanning out to six servers is **six separate budgets, not one shared pool**. An
|
||
operator setting a cap of 30 prefabs is setting it per server. Say so on the field.
|
||
|
||
### Option sources — what fills a dropdown
|
||
|
||
| Source | Filled from |
|
||
|---|---|
|
||
| `rust.options.kits` | Kits `GetKitNames` / `GetAllKits`, **flagged by whether `RequiredPermission` is set** (R16) |
|
||
| `rust.options.groups` | Oxide permission groups (§H names this one) |
|
||
| `rust.options.permissions` | registered permissions |
|
||
| `rust.options.prefabs` | a plugin-declared constructible allowlist — the analogue of UO's spawn atlas |
|
||
| `rust.options.monuments` | monument names, shared with the map work (R9) |
|
||
| `rust.options.zones` | ZoneManager `GetZoneIDs` / `GetZoneName` (R17) |
|
||
|
||
Every one resolves from live data and returns `[]` on failure rather than defending with a hardcoded
|
||
list that will be wrong. A source that refuses degrades its field to free text with a warning and
|
||
never blocks the form.
|
||
|
||
### Leases — values borrowed with a deadline
|
||
|
||
The heart of it, and the thing to build first.
|
||
|
||
| Lease | Value |
|
||
|---|---|
|
||
| `rust.rate.gather` | gather rate multiplier |
|
||
| `rust.rate.craft` | craft speed |
|
||
| `rust.rate.smelt` | smelting speed |
|
||
| `rust.rate.decay` | decay scale |
|
||
| `rust.time.night` | night length |
|
||
| `rust.population.<kind>` | spawn population multipliers |
|
||
| `rust.group.membership` | **a time-limited permission group — weekend VIP** |
|
||
|
||
**`rust.group.membership` is the one §H names that R16 did not, and the pair is the whole design.**
|
||
R16 settled that a *permanent* earned entitlement is an **action** with `reversible: 'ledger'` — grant
|
||
the kit's permission, revert revokes it. §H settles that a *time-limited* group is genuinely
|
||
**`core.lease`** — held with a deadline the game enforces on its own, restored when it expires
|
||
without core having to come back. Same underlying permission mirror (R2), two different shapes,
|
||
and choosing the wrong one is the mistake: a weekend VIP implemented as a grant is a VIP who stays
|
||
one for ever if the website goes away.
|
||
|
||
**Every key gets verified live before it is advertised** — apply, observe in the running game,
|
||
restore, per key. §H's claim that Rust convars are live by default is an argument for *expecting*
|
||
them to work, never a substitute for checking. A value the server reads once at boot applies
|
||
cleanly, reads back cleanly, and does nothing at all, and neither core nor review can catch it.
|
||
|
||
### Actions — verbs a run performs
|
||
|
||
| Action | `risk` | `reversible` | Notes |
|
||
|---|---|---|---|
|
||
| `rust.kit.entitle` | `change` | `ledger` | R16 — grants the kit's `RequiredPermission`; revert revokes |
|
||
| `rust.prefab.place` | `change` | `ledger` | §H's verb; revert kills the entity, and needs the persisted ownership registry ch. 4 describes |
|
||
| `rust.announce` | `notify` | `none` | via PopupNotifications (R6) — global or targeted |
|
||
| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17** — `CreateOrUpdateTemporaryZone` takes a `Plugin owner`, so the undo is real. Our own id map decides what may be erased, not ZoneManager's owner check (§12.4) |
|
||
|
||
**Rewards are not a contract member.** `EVENTS.md` deleted a `registerEventRewards` registry because
|
||
it carried four Ultima Online nouns inside a core signature. A reward here is an ordinary action —
|
||
which is exactly why R16 could change what it grants without touching anything of core's.
|
||
|
||
## 10. The engagement catalogue — what Rust can expose
|
||
|
||
Added 2026-09-15, answering "check the default alerts Rust can expose". R7 settled that the set
|
||
ships; this is what goes in it.
|
||
|
||
**The ceiling lattice is containment, not size** — `self`, `owner`, `subscribers`, `staff`,
|
||
`members`, `authenticated`, `everyone`, and the flat reading is the trap. `staff` is **not** a
|
||
superset of `owner`: for a cheat-detection event, "one person" is *the player it was detected on*.
|
||
Every ceiling below is chosen against that, not against a ladder.
|
||
|
||
| Trigger | Source | `ceiling` | `subjectKey` |
|
||
|---|---|---|---|
|
||
| `rust.wipe.started` | `OnNewSave` | `everyone` | server |
|
||
| `rust.server.online` / `.offline` | link state transition | `everyone` | server |
|
||
| `rust.leaderboard.topped` | our own rollup (R12) | `everyone` | server |
|
||
| `rust.base.destroyed` | `OnEntityDeath` on owned building blocks | **`owner`** | player |
|
||
| `rust.kit.entitled` | R16's own grant | **`self`** | user |
|
||
| `rust.player.linked` | R1's link flow | **`self`** | user |
|
||
| `rust.clan.member.added` / `.left` / `.kicked` | first-party clan hooks (R5) | `members` | clan |
|
||
| `rust.clan.disbanded` | `OnClanDisbanded` | `members` | clan |
|
||
| `rust.player.reported` | `OnPlayerReported` | **`staff`** | player |
|
||
| `rust.login.denied` | `CanUserLogin` | **`staff`** | player |
|
||
| `rust.player.banned` / `.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **`staff`** | player |
|
||
|
||
**`rust.base.destroyed` is the one that matters most and the one most likely to be got wrong.** The
|
||
offline raid alert is the single most-wanted notification in Rust, and its ceiling is `owner` — the
|
||
player whose base it was. Ceilinged `staff` it would be useless to the person who needs it, and
|
||
ceilinged `everyone` it would broadcast base locations to the server. This is exactly the case the
|
||
lattice exists for.
|
||
|
||
**Three hooks carry data that must never widen.** `CanUserLogin` and `OnUserApproved` carry **IP
|
||
addresses**; `OnPlayerReported` carries player reports. [`README.md`](README.md) §5 already flags
|
||
these as admin-channel-only on the live feed, and the same judgement binds their triggers.
|
||
|
||
### Audiences
|
||
|
||
| Audience | Resolves to | `ceiling` |
|
||
|---|---|---|
|
||
| `rust.clan.members` | a clan's linked members | `members` |
|
||
| `rust.server.players` | linked accounts seen on a server this wipe | `authenticated` |
|
||
| `rust.wipe.participants` | everyone who played the current wipe | `authenticated` |
|
||
|
||
A resolver returns **user ids and nothing else** — never a template, a channel or an address — and
|
||
one that fails resolves to **nobody**, never to everybody and never to its last good answer. Its
|
||
params are constant, filled in when an operator saves the rule, so "the clan this event was about"
|
||
is not expressible; an event that needs that carries its own recipients.
|
||
|
||
### Seeds, and one thing to decide when building them
|
||
|
||
Bodies re-ensure every boot under a seed version; **rule groups are offered once per group key**, so
|
||
a rule appended to an existing group reaches fresh installs only. Wipe announcements, raid alerts and
|
||
clan transitions each take their own group key for that reason.
|
||
|
||
**One design note, flagged rather than decided.** `PopupNotifications` gives the module an *in-game*
|
||
alert surface, which is not one of core's channels — core resolves ids to email, in-app and push. So
|
||
an in-game popup is the module publishing to its own surface off its own trigger, not a fourth
|
||
channel core learns about. Worth settling deliberately at phase 10: a raid alert that reaches a
|
||
player's phone *and* pops on their screen next login is two mechanisms, and only one of them is
|
||
core's.
|
||
|
||
## 11. Second contract pass — `MODULE_API.md` read member by member
|
||
|
||
Added 2026-09-15, after docs#249 merged. §7 audited the plan against the **Integration Kit** and the
|
||
**template**; this pass reads [`MODULE_API.md`](../../website/MODULE_API.md) itself, enumerating every
|
||
member rather than grepping for registration names. It found one regression, one mispriced decision,
|
||
one missing declaration and a set of `ctx` members the plan had never mentioned.
|
||
|
||
### 11.1 The regression: `reconcile` was dropped
|
||
|
||
**`ctx.events.reconcile()` and an action's `reconcile()` appear nowhere in this document.** The
|
||
twelve-phase first draft had them — *"phase 6: reconcile, and the boot-id watch"* — and the rewrite to
|
||
twenty phases lost them. That is a regression in the plan, not a decision.
|
||
|
||
It matters **more** for Rust than for the game the contract was written against. [kit][kit] ch. 5
|
||
rates `reconcile` the one omission that is *"merely a lower standard rather than a broken promise"* —
|
||
but that judgement assumes a world that persists. **Rust wipes monthly, and a wipe invalidates every
|
||
ledgered resource for that server at once.** Core cannot tell a wedged sidecar from a game that
|
||
rebooted and lost everything an event made: it sees `{ ok: false, retry: true }` either way. It asks
|
||
once, at its own boot, and otherwise **waits to be told**.
|
||
|
||
`ctx.events.reconcile()` is being told, and the thing that triggers it is a **watch on the game's boot
|
||
id changing** — which is also the only way to tell a game restart from a sidecar reconnect. They are
|
||
not the same event and the second loses nothing. Two rules ride with it: anything that is not an
|
||
explicit `{ ok: true, inForce: [...] }` **leaves the ledger alone** — "I do not know" is never read as
|
||
"it is gone" — and a resource reported missing becomes `orphaned`, not `reverted`, because nobody
|
||
asked for it to go.
|
||
|
||
**Restored to phase 13**, after the actions exist, with the boot-id watch as its trigger.
|
||
|
||
### 11.2 R9 was mispriced: the visibility framework is `module-uo`'s, not core's
|
||
|
||
R9 says the map's per-layer switches work *"through the existing visibility framework
|
||
([`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md))"*, which reads as reuse. **It is not
|
||
reuse.** §6.3 records that `shardVisibility` is **module-owned**, and the tree confirms it — the util,
|
||
both models, the admin controller and its tests all live under `module-uo/server/`, and there is
|
||
**nothing** by that name left in `website/server`.
|
||
|
||
§2.7 forbids a module requiring anything outside its own directory, so `module-rust` cannot import a
|
||
line of it. **It builds its own**, informed by UO's design and its document but sharing no code.
|
||
|
||
That is a real cost R9 did not price. It is not a reason to change the decision — per-layer switches
|
||
are still right, and `SHARD_VISIBILITY.md` is still the design to learn from — but phase 14 carries
|
||
a visibility layer of its own rather than a configuration of somebody else's.
|
||
|
||
### 11.3 `extensions` is a declared field, not just a call
|
||
|
||
R13 claims two slots and never says where they are declared. **`module.json` has an `extensions`
|
||
array** (§2.1, optional), and the dry run's own manifest carried `"extensions": ["admin.users.detail"]`.
|
||
Like `mounts`, it is a statement of surface that the loader holds against reality — so
|
||
`admin.users.detail` and `site.footer.status` are declared there as well as registered. Phase 1 adds
|
||
it to the list of `module.json` fields that must be got right.
|
||
|
||
> **Corrected in phase 1 (§13.2), and it is half wrong.** The loader checks only that a named slot EXISTS (`loader.js:681` → `registries.hasSlot`); `checkDeclared` covers `mounts` **alone**, so a declaration with nothing behind it loads cleanly and means nothing. And only ONE of R13's two slots can be declared here at all — `admin.users.detail` is the only server slot core declares, while `site.footer.status` is a CLIENT slot registered from the chunk, and naming it in `extensions` fails the load outright.
|
||
|
||
### 11.4 The `ctx` members the plan had never named
|
||
|
||
`ctx` has **29 members** (§2.3). The plan named a handful. The ones that change work:
|
||
|
||
| Member | Where it lands | Why it matters |
|
||
|---|---|---|
|
||
| **`ctx.secretBox`** | 1 | Each configured server's sidecar token is a secret at rest. Core encrypts its own (AES-256-GCM, write-only in the API, never returned to any client) and hands a module the same facility — so R8's several tokens get the platform's existing posture rather than a new one |
|
||
| **`ctx.middleware.rateLimit`** | 6 | R1 requires the link code be rate-limited. This is the mechanism; `accountChangeLimiter` sits beside it for the account-facing half |
|
||
| **`ctx.uploads`** | 14 | Where R9's map image actually lands. The plan described fetching it over the bridge and never said where it goes |
|
||
| **`ctx.activity.log`** | 7, 7b | R2's permission changes and R18's config writes both owe an audit trail. Core has an activity log; neither needed inventing one |
|
||
| **`ctx.teams.publish`**, **`ctx.teams.activity.push`**, **`ctx.teams.reconcile`** | 9 | Teams is more than the provider. The plan named only `registerTeamProvider`, which answers core's questions — these are how a module *pushes* a change and asks for reconciliation |
|
||
| **`ctx.posts`** | 10 | The CMS surface behind `registerAnnounceLeg` and `registerPostHook` |
|
||
| `ctx.events.emit`, `ctx.inbox.push`, `ctx.push.publish` | 10 | The three send paths §10's catalogue implies and never named |
|
||
| `ctx.users.getById`, `ctx.settings.*`, `ctx.validator`, `ctx.db.query`, `ctx.paths.moduleRoot`, `ctx.log`, `ctx.express`, `ctx.auth.getUserFromRequest`, `ctx.site.baseUrl`, `ctx.moduleId` | throughout | Ordinary plumbing; listed so the narrowing is visible |
|
||
|
||
`ctx` is **a curated list, not core's internals** — `ctx.auth` is one function rather than core's whole
|
||
auth facade, because minting a session is core's job and a module needs to *read* one. Expect to want
|
||
something that is not there; that is a minor-version conversation, never a reason to reach around it.
|
||
|
||
### 11.5 §6.8 — a trigger, a rule and an audience outlive the module that declared them
|
||
|
||
A constraint on phase 10 and on purge that the plan did not carry.
|
||
|
||
`engagement_rules.trigger_id` is a plain `VARCHAR` — **no foreign key, no cascade** — deliberately, so
|
||
a module can be removed and reinstalled without destroying an operator's rules. The consequence:
|
||
|
||
- **A rule whose trigger is unregistered shows `dormant`** — never an error, never auto-deleted.
|
||
- **The same for an unregistered audience**: it resolves to the empty set and shows dormant, which is
|
||
**not the same answer as "resolved to nobody"** and must not be rendered as if it were.
|
||
|
||
The failure that prevents is exact: **an id that stops resolving must never silently become a send to
|
||
a different set of people.**
|
||
|
||
### 11.6 The two client lists, in full
|
||
|
||
Recorded because §7 said "UI kit discipline" without saying what is in it. Both are **closed and
|
||
curated** — adding a member is a minor version bump, changing an existing prop is a major one.
|
||
|
||
**`registry`** — `registerRoutes`, `registerNav`, `registerExtension`, `registerFeatureProvider`,
|
||
`declareModuleSlot` (1.6.0), plus the read side, `routesFor` and `featureProviders`.
|
||
|
||
**`ui`** — `PublicLayout`, `PageHeader`, `Loading`, `ErrorState`, `EmptyState`, `useAsync`, `useAuth`,
|
||
`useSite`, `Slot` (1.6.0). Anything else — tables, chips, tabs, editors — **your chunk carries it**.
|
||
|
||
### 11.7 One confirmation for R10
|
||
|
||
§2.9: `GET /api/v1/public/modules` returns **only `started` modules**, with four fields and no state,
|
||
no failure stage and no failure reason. A `disabled` or `startup_failed` module is simply **absent**.
|
||
|
||
So R10's capability probe already has the behaviour the app wants: a Rust module that failed to boot
|
||
makes the app render a site *without* those screens, rather than one advertising screens that `503`.
|
||
The app needs no failure handling for this case because core does not expose the failure.
|
||
|
||
## 12. Phase 0 as built — the rig, 2026-09-15
|
||
|
||
The rig is current, the base set runs, and **both acceptance criteria are met**. Six things were
|
||
learned that the plan had either wrong or had never asked, and four of them change work in later
|
||
phases.
|
||
|
||
### 12.0 What the rig is now
|
||
|
||
| | Before | After |
|
||
|---|---|---|
|
||
| Server build | `24613624` (2026-08-13) | **`25230300`** (2026-09-10) |
|
||
| Oxide | `2.0.7585` | **`2.0.7716`** (`OxideMod/Oxide.Rust`, 2026-09-11) |
|
||
| World | seed 1234 save v287, previous wipe | regenerated for this wipe; `cfg/` preserved |
|
||
| `oxide/plugins/` | empty | Kits 4.4.9 · Clans 0.2.10 · Popup Notifications 0.2.1 · Zone Manager 3.1.14 |
|
||
|
||
All four compiled and loaded first time on the new build, at exactly the versions R6 and R17 name —
|
||
pulled fresh from `https://umod.org/plugins/<Name>.cs`, which still serves those versions and needs
|
||
no Cloudflare workaround. Server protocol `2633.288.1`.
|
||
|
||
The Oxide permission store survived the update untouched (`oxide/` is not a Steam depot directory),
|
||
so `76561198038695917` is still in `default` and `admin`.
|
||
|
||
Two instruments were built and are kept in the phase-0 scratchpad rather than committed: a
|
||
dependency-free **WebSocket RCON driver** (Node's global `WebSocket`, no `ws` package), and
|
||
**`RGProbe.cs`**, a throwaway Oxide plugin that exposes Oxide's permission API and ZoneManager's
|
||
by-name API as console commands. The probe is what made §12.2 and §12.4 observable; phase 1's plugin
|
||
skeleton can start from it.
|
||
|
||
> **One thing the RCON driver had to learn.** Oxide tags its own `Puts()` output and its warnings
|
||
> with the **identifier of the command being run**, so a first-match-wins client reads a plugin's log
|
||
> line as if it were the reply and discards the real one. It cost two wrong readings before it was
|
||
> spotted. Collect every frame in a window; do not correlate one reply per identifier.
|
||
|
||
### 12.1 The rig's own script was broken in a way the earlier diagnosis missed
|
||
|
||
Recorded in §4. In short: `start.bat` put `+force_install_dir` **after** `+login`, steamcmd discarded
|
||
it, and every update run in the rig's history errored out without updating anything. The 2026-09-15
|
||
"fix" changed the path and left the order, so it fixed nothing.
|
||
|
||
The Oxide re-install in §4 is **not** a finding — pairing a server update with an Oxide re-install is
|
||
the routine every Rust host already follows, and saying otherwise would be this plan talking down to
|
||
its own audience. One narrow consequence is still worth carrying to **phase 18**: because
|
||
`app_update` leaves `Oxide.Core.dll` and the rest in place, a `doctor` check that tests for `oxide/`
|
||
or for Oxide's assemblies **passes on a server that is mid-routine**. Compare the
|
||
`Assembly-CSharp.dll` against the Oxide build instead, so `doctor` reports the real state rather than
|
||
a directory listing.
|
||
|
||
### 12.2 Four rules the R2 permission push must obey
|
||
|
||
Verified live against the real store, granting and revoking through both the console command and the
|
||
API:
|
||
|
||
1. **`permission.GrantUserPermission` silently no-ops for an unregistered permission.** `void`, no
|
||
throw, no log. The console `oxide.grant` at least answers `Permission 'x' doesn't exist`; the API
|
||
path R2 uses says nothing at all. This is the finding with teeth — see R2.
|
||
2. **A permission exists only because a loaded plugin registered it.** Kits registers `kits.admin`
|
||
and, dynamically, **every kit's `RequiredPermission`** (`Kits.cs:1225`, `:2895`) — which is what
|
||
makes R16's entitlement model real. Unload Kits and those names stop existing.
|
||
3. **`RegisterPermission` warns about a foreign prefix but registers anyway.**
|
||
`Missing plugin name prefix 'rgprobe' for permission 'someplugin.vip'` is a warning, not a
|
||
refusal — the permission was created and granted successfully. So the site *can* make a grant
|
||
stick for a plugin that is not currently loaded, at the cost of a console warning. Whether it
|
||
*should* is a phase 7 decision; the mechanism exists.
|
||
4. **A player who has never connected is in no group, but can hold direct grants.** A grant to an
|
||
unseen SteamID64 works and reads back immediately. Group membership does not exist for them yet,
|
||
so **anything the site expresses as group membership does not reach a player until their first
|
||
connection**, while a direct grant does. R16's offline entitlement is safe; a group-shaped
|
||
entitlement is not.
|
||
|
||
Point 4 is the one to carry into phase 7's design: grants and groups have **different reach** for
|
||
offline players, and the site's model currently treats them as two spellings of the same thing.
|
||
|
||
### 12.3 R5's claim about the Clans plugin was a grep artefact
|
||
|
||
Corrected in R5. The plugin raises nine hooks, not three, and three of them carry full member lists;
|
||
the six that were missed are invisible to a literal search because the hook name is a `const` at the
|
||
call site. The decision stands on a different reason — first-party is what every server has, the
|
||
plugin is optional — and phase 17 gains event-driven leadership as a sharpening rather than a
|
||
replacement.
|
||
|
||
Two smaller things from the same read, both worth having before phase 9 and 17:
|
||
|
||
- **`Clans` raises the same hook name twice per transition**, once Rust-typed
|
||
(`string, ulong, List<ulong>`) and once Universal-typed (`string, string, List<string>`), plus two
|
||
deprecated arities. Oxide binds by name **and** arity, and both live forms are arity 3 — so a
|
||
loosely typed subscriber catches both and double-counts every join and leave. Type the parameters
|
||
precisely and pick one.
|
||
- **`Clans` calls `API_RegisterThirdPartyTitle` itself.** R15's BetterChat integration will be the
|
||
*second* title provider on any server running both, not the first.
|
||
|
||
Also confirmed, since the plan rests on it: the first-party set is exactly the **seven** hooks in
|
||
`agent/hooks.tsv`, all "no return behavior", with no promote and no leader-changed.
|
||
|
||
### 12.4 ZoneManager's owner scoping is narrower than R17 assumed
|
||
|
||
Corrected in R17. `EraseTemporaryZone(owner, id)` refuses only when the zone has a *different*
|
||
owner; an **unowned** zone — every permanent zone, including every zone an operator made by hand — is
|
||
erased by anyone and returns `true`. Phase 12 must gate erasure on its own id map.
|
||
|
||
Three more things the source and the live rig agreed on:
|
||
|
||
- **ZoneManager's entire API is plain private methods**, no `[HookMethod]` anywhere in 3.1.14 — so
|
||
`Call()` by name is the only way in, and a typo is silence. Confirmed working live for
|
||
`CreateOrUpdateZone`, `CreateOrUpdateTemporaryZone`, `EraseTemporaryZone`, `GetZoneIDs` and
|
||
`GetPlayersInZone`. The three-conventions finding holds: Kits declares `[HookMethod]` (23 of them),
|
||
ZoneManager declares nothing, BetterChat will use `API_` prefixes.
|
||
- **`GetPlayersInZone` cannot distinguish an unknown zone from an empty one** — both return an empty
|
||
list, not null. The participation ledger R17 wants to feed therefore cannot use this call alone to
|
||
answer "is this zone still there", and must check `GetZoneIDs` separately. This is the same
|
||
absence-of-an-answer / answer-of-absence trap earlier phases of other workstreams hit.
|
||
- **NPCs never appear in a zone's player list.** `baseEntity is BasePlayer { IsNpc: false }` routes
|
||
them to the zone's *entity* list instead. Useful to know before designing a condition that counts
|
||
"players at the monument" on a server with scientists.
|
||
|
||
### 12.5 The criterion is closed, and it revealed two ceilings on the rig
|
||
|
||
> `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it
|
||
|
||
**Both halves done**, the second with the org lead connected. The zone was created on the player's
|
||
own position, and ZoneManager reported both transitions live:
|
||
|
||
```
|
||
[probe] ENTER zone=rgtest player=76561198038695917 (whitlocktech)
|
||
[probe] zone=rgtest occupancy=1 [76561198038695917:whitlocktech]
|
||
[probe] EXIT zone=rgtest player=76561198038695917 (whitlocktech)
|
||
```
|
||
|
||
The exit was produced by **moving the zone off the player** rather than walking them out —
|
||
`CreateOrUpdateZone` on an existing id relocates the trigger volume and fires `OnExitZone` as it
|
||
leaves. Useful for testing presence without choreographing a person.
|
||
|
||
So **R17's "presence transitions as events" is verified**, which is the claim the participation
|
||
ledger and the advance conditions both rest on.
|
||
|
||
Two ceilings surfaced on the way, and both constrain later phases:
|
||
|
||
**1. No console session can observe a gate.** The standard idiom is
|
||
`return !player || permission.UserHasPermission(...)` — a command from RCON has no `BasePlayer`, so
|
||
the console is unconditionally allowed. Anything whose acceptance needs a permission to actually
|
||
*refuse* somebody needs a client attached.
|
||
|
||
**2. An admin account cannot see a refusal either — from most plugins.** The bypass is not uniform,
|
||
and the difference decides which phases can be demonstrated on the org lead's own account:
|
||
|
||
| Plugin | Admin bypass | Demonstrable as owner? |
|
||
|---|---|---|
|
||
| Popup Notifications | `player.IsAdmin \|\|` — hard | **No** |
|
||
| Zone Manager | `authLevel > 0 \|\|` — hard | **No** |
|
||
| Kits (`RequiredPermission`) | `Configuration.AdminIgnoreRestrictions && IsAdmin(player)`, and Kits' own `IsAdmin` is the `kits.admin` **permission**, not auth level. The shipped default is **`false`** | **Yes** |
|
||
|
||
So **phase 13 is demonstrable on this rig as it stands** — R16's entitlement gate applies to a server
|
||
owner like anyone else. **Phase 7 is not**, if its acceptance is "a grant made on the website gates a
|
||
third-party plugin in-game" against Popup Notifications or Zone Manager: that needs a **second,
|
||
non-admin Steam account**. Worth arranging before phase 7 rather than discovering there.
|
||
|
||
### 12.6 R18's trees, as they actually look
|
||
|
||
The four base plugins wrote their configs on first boot, so R18's two trees can be compared against
|
||
something real rather than predicted:
|
||
|
||
```
|
||
oxide/config/ Clans.json Kits.json PopupNotifications.json ZoneManager.json
|
||
oxide/data/ clan_data.json Kits/kits_data.json Kits/player_data.json
|
||
ZoneManager/zone_data.json
|
||
oxide.users.data oxide.groups.data oxide.covalence.data oxide.lang.data
|
||
```
|
||
|
||
R18's inventory of `data/` was exactly right. One nuance worth correcting, though: R18 motivates the
|
||
**recursive** walk with *"plugins nest (`config/<Mod>/x.json` and deeper)"*, and on a fresh install of
|
||
the base set **`config/` is flat — it is `data/` that nests.** The recursive walk is still correct
|
||
(other plugins do nest configs), but the nesting the plan cites as its reason is currently visible
|
||
only in the tree it must never walk.
|
||
|
||
And a reason to hold that boundary harder than R18 states: **`oxide/data/` is where Oxide keeps its
|
||
own permission store** (`oxide.users.data`, `oxide.groups.data`). A config editor that strayed one
|
||
directory over would be editing R2's mirror underneath itself.
|
||
|
||
## 13. Phase 1 as built — the transport, 2026-09-15
|
||
|
||
**Both criteria met.** A `server.hello` produced by the live Rust rig travelled game → sidecar →
|
||
module → the public website API; killing the sidecar left the game untouched; all five guards are
|
||
green on the module skeleton. Three repositories have their first commits:
|
||
[`Rust-Link`][rl], [`Rust-Plugins`][rp], [`Module-Rust`][mr], plus
|
||
[`rust-link/PROTOCOL.md`](../../rust-link/PROTOCOL.md) and
|
||
[`INTEGRATION.md`](../../rust-link/INTEGRATION.md) here.
|
||
|
||
### 13.0 The three org-lead decisions this phase needed
|
||
|
||
None of them were settled by §2, and each would have been expensive to reverse later.
|
||
|
||
- **D1 — the Rust bridge's docs live at `docs/rust-link/`**, a new top-level directory mirroring
|
||
`docs/link/`, rather than under `modules/rust/`. It keeps the `uo`/`link` symmetry and keeps
|
||
*module* docs separate from *bridge* docs, which are different contracts with different audiences.
|
||
- **D2 — loopback is the only trust boundary on the game link**, exactly as on the ServUO bridge:
|
||
no token between plugin and sidecar. The alternative was argued on the grounds that Rust servers
|
||
are far more often on GSPs than ServUO shards are, so binding to something other than `127.0.0.1`
|
||
is a realistic operator need. **Overruled**, and the consequence is written into the plugin's
|
||
class docs and `PROTOCOL.md` §1.1: moving that bind puts an unauthenticated command channel on the
|
||
network, and it is documented as the mistake rather than defended against.
|
||
- **D3 — the plugin reads its settings from Oxide's own config file**
|
||
(`oxide/config/RunicGateway.json`) rather than a standalone `Bridge.cfg`-shaped file. It is
|
||
idiomatic for Oxide, and it lands inside R18's phase-7b config editor for free. The argument
|
||
against — that editing `Host`/`Port` from the website could cut the link carrying the edit — is
|
||
real and is now phase 7b's problem to guard rather than a reason for a second config mechanism.
|
||
|
||
### 13.1 What phase 1 deliberately did NOT register
|
||
|
||
The module registers **routes on three tiers and the two lifecycle hooks, and nothing else**. No
|
||
Team provider, no triggers, no audiences, no engagement seeds, no notification streams, no event
|
||
budgets/leases/actions/option sources, no extension slots.
|
||
|
||
That is asserted by a test (`nothing is registered that has nothing behind it yet`) so that removing
|
||
it is deliberate. The reasoning is worth keeping: **a declared trigger nothing emits and a declared
|
||
slot nothing fills are both surfaces an operator can configure and then wait on**, which is worse
|
||
than an absent one, because the absence is visible.
|
||
|
||
### 13.2 §11.3 was half wrong about `extensions`
|
||
|
||
§11.3 reads `module.json`'s `extensions` as *"declared, not just registered… like `mounts`, held
|
||
against reality by the loader"*. Reading the loader says otherwise, in two ways:
|
||
|
||
1. **The loader never checks that a declared slot was filled.** `loader.js:681` asks only
|
||
`registries.hasSlot(slot)` — does this slot *exist*. `checkDeclared` covers `mounts` **alone**,
|
||
in both directions. So a declaration with nothing behind it loads cleanly and means nothing.
|
||
2. **Only one of R13's two slots can be declared there at all.** `admin.users.detail` is the only
|
||
server slot core declares (`router/v1/admin/users.router.js:202` is the sole `declareSlot` call
|
||
outside the registry). `site.footer.status` is a **client** slot, registered from the chunk —
|
||
naming it in `extensions` fails the load with `unknown extension slot "site.footer.status"`.
|
||
|
||
So the field is written when phase 6 registers the server half, and never before. Phase 4's
|
||
`site.footer.status` work does not touch it.
|
||
|
||
### 13.3 Five defects the rig found that no test could
|
||
|
||
Each of these was written from source reading, shipped, and then corrected by a live Rust server.
|
||
The hit rate is the phase-0 lesson repeating.
|
||
|
||
1. **A disconnect was silent in the game console.** The link teardown log sat in `LinkLoop`'s
|
||
`catch`, and a connection that ends because the *reader* saw EOF leaves the writer to exit
|
||
cleanly — nothing throws, so nothing is logged. The fix captures `_connected` at the top of the
|
||
`finally`. Worth generalising: **a log in a catch block only covers the failures that throw**, and
|
||
an orderly shutdown of a peer is not one of them.
|
||
2. **`Unload` blocked the main thread for 1.9 seconds**, which Oxide reports as
|
||
`Calling 'Unload' on 'RunicGateway v0.1.0' took 1918ms`. The reconnect backoff was
|
||
`Thread.Sleep`, and `Unload` joins the link thread — so every plugin reload froze the server for
|
||
up to the backoff. Waiting on the same `AutoResetEvent` that `Unload` already signals makes it
|
||
immediate. **The ServUO plugin has the same `Thread.Sleep`**, and it is survivable there only
|
||
because ServUO does not hot-reload the way Oxide does.
|
||
3. **Mono's `SocketException.Message` is NUL-padded.** A connect refusal came back with ~200 `\0`
|
||
bytes in the middle of the sentence, from a fixed-size OS buffer. `\0` is not whitespace, so
|
||
`Trim()` does not touch it and neither does a whitespace-only collapse — the log line looks like
|
||
it contains a huge run of spaces and no amount of trimming removes it. The flattener has to treat
|
||
`char.IsControl` as a separator too. It took `od -c` on the log to see this at all.
|
||
4. **`bootId` regenerated on every PLUGIN load, not every SERVER start.** A fresh `Guid` at `Init`
|
||
meant `oxide.reload RunicGateway` announced a brand-new boot — and §11.1's whole reconcile design
|
||
hangs off that value, so every reload would have asked core to sweep its entire resource ledger
|
||
for a world that never moved. It is now `Process.StartTime`, which is exact, identical on every
|
||
read, and changes when and only when the thing it names changes. **Verified by reloading the
|
||
plugin twice and watching the id hold** (`boot-20260915T194502Z`, matching the process).
|
||
5. **A four-connection SQLite pool over `:memory:` hands out four empty databases.** An in-memory
|
||
database is per *connection*, so the schema created on the first pooled connection is invisible
|
||
to the second. It presents as `no such table` from a random subset of queries. The sidecar now
|
||
caps the pool at one connection for an in-memory path — which is the only coherent reading of
|
||
`:memory:` and is what makes it usable at all. The ServUO sidecar never hit this because it only
|
||
ever opens a file.
|
||
|
||
### 13.4 Two things the kit's own template got wrong for this module
|
||
|
||
Both are feedback for phase 19, and both are the kit being right about the general case and specific
|
||
about the wrong detail.
|
||
|
||
- **`registration.test.js` reads one page by NAME** (`src/routes/public/Clan.jsx`) to check that
|
||
every declared slot is rendered somewhere. A module that declares no slots — as phase 1 does — dies
|
||
on `ENOENT` before reaching the loop that would have been empty. Generalised here to scan every
|
||
file under `src/routes`.
|
||
- **`test/_fakes.js` supplies `validator: {}`.** The template's routers never use express-validator,
|
||
so `{}` is enough for them; an admin router that builds validation chains at file scope cannot be
|
||
*required* with it. The fake now holds the real library, for the same reason it holds a real
|
||
express Router: a fake of either would only ever test the fake.
|
||
|
||
The kit was also **right in a way worth recording**: `noGameConnection.test.js`'s header predicts, in
|
||
so many words, that a module adding a sidecar client will see this check go red and tells the reader
|
||
to narrow it rather than delete it — naming `sidecarClient.js` as the file to allow. That is exactly
|
||
what happened, on the first run, and the fix was the one line the header names.
|
||
|
||
### 13.5 The player tier is thin on purpose
|
||
|
||
R14 puts the module on all three tiers from the start, and the loader holds `mounts` against what is
|
||
registered in both directions — so the declaration and the registration land together or not at all.
|
||
|
||
What the player tier will carry is the signed-in view of a server: the viewer's linked Steam
|
||
identity, their presence, their entitlements. None of that exists before phase 6. So the one route
|
||
there answers the server list on the authenticated tier, delegating to the **same model** the public
|
||
tier uses, so the two cannot drift while they are meant to be the same.
|
||
|
||
That is a real route rather than a placeholder: it is the address the app and the SPA will call, and
|
||
it starts answering correctly now rather than moving later.
|
||
|
||
### 13.6 Three timeouts in a row, and the ordering is load-bearing
|
||
|
||
```
|
||
sidecar RPC reply timeout (10s) < module client timeout (12s) < an action's budgetMs
|
||
```
|
||
|
||
Core classifies a `budgetMs` overrun as retryable **unconditionally** — it cannot ask the action,
|
||
which is still awaiting a socket. So an action whose budget does not exceed the module's client
|
||
timeout can never report `retry: false`, and that branch is unreachable code. Phase 13's actions
|
||
must derive their budgets from `sidecarClient.TIMEOUT_MS` rather than writing a number beside it.
|
||
|
||
The first module this project shipped got this the wrong way round and retried a verb it had
|
||
explicitly refused, which is why all three values are now written down in one place
|
||
(`PROTOCOL.md` §4.4) instead of three.
|
||
|
||
### 13.7 The rig, as it stands after phase 1
|
||
|
||
The phase-0 rig plus the bridge. `RGProbe.cs` is still installed beside `RunicGateway.cs` and is
|
||
still useful — its `rg.checkperm` / `rg.zoneonme` commands are phase 7 and phase 12 instruments.
|
||
|
||
```
|
||
D:\rust\oxide\plugins\ Clans.cs Kits.cs PopupNotifications.cs ZoneManager.cs
|
||
RGProbe.cs RunicGateway.cs
|
||
rust-link-sidecar game 127.0.0.1:7799 · web 127.0.0.1:8090 · its own scratch config
|
||
website (edge) modules/rust/ installed as a directory; server row "main"
|
||
```
|
||
|
||
A dependency-free WebSocket RCON driver was rebuilt this phase (the phase-0 one lived in a scratchpad
|
||
that did not survive). `rcon.web 1`, port 28016 — the password is in `D:\rust\start.bat`. It is the
|
||
only way to reach `rg.link` without a console session, and phase 7 will need it again.
|
||
|
||
**The phase-0 ceiling still stands and still blocks phase 7:** no console session can observe a
|
||
permission gate, because every plugin's check short-circuits without a `BasePlayer`, and an admin
|
||
account bypasses most of them non-uniformly. A second, non-admin Steam account has to be arranged
|
||
before phase 7 — it is the one prerequisite this rig cannot satisfy on its own.
|
||
|
||
## 14. The Pterodactyl rig as built, 2026-09-15
|
||
|
||
R21's first server exists, made with the application key and driven with the client key. **Both
|
||
credentials work; neither can do the other's job.** What follows is what building it actually taught,
|
||
including one finding that changes R20's shape.
|
||
|
||
### 14.0 The rig
|
||
|
||
| | |
|
||
|---|---|
|
||
| Panel | `http://192.168.0.12` (no TLS — `https://` fails outright), node 1 `Main` |
|
||
| Servers | `rust-oxide` id **17** / **`e6758c06`**, and `rust-carbon` id **18** / **`87fb1f67`** (§14.5) |
|
||
| Egg | 18 `Rust Autowipe`, `ghcr.io/pterodactyl/games:rust` — both rigs, one egg |
|
||
| `FRAMEWORK` | `oxide` / `carbon` |
|
||
| Limits | 8192 MB memory, 25600 MB disk — deliberately under half the node, so the Carbon rig fits beside it |
|
||
| Allocations | oxide 21000-21004, carbon 21005-21009 — game, query, RCON, Rust+, **and one held for the sidecar's `[web].bind`** |
|
||
| World | procedural, size 3000, seed 1234 |
|
||
| SFTP | `192.168.0.12:2022` |
|
||
|
||
The RCON passwords are generated 24-byte tokens rather than the old rig's `letmein`, kept out of this
|
||
document and out of the repo. §6 named that shape as the thing nothing published should copy; this is
|
||
the first rig where it was not copied.
|
||
|
||
**A Rust server install is about 6 GB, not the ~20 GB this plan assumed** when it worried about node
|
||
capacity — measured at 5,894 MB with the game installed and the world generating. Two rigs are
|
||
comfortable on a 128 GB node, and the 25600 MB limit is generous rather than tight.
|
||
|
||
### 14.1 The two keys, and what each one is actually for
|
||
|
||
Confirmed by use rather than by reading:
|
||
|
||
| | Application (`ptla_`) | Client (`ptlc_`) |
|
||
|---|---|---|
|
||
| Create / configure a server, assign allocations | **yes** | no |
|
||
| List, read, power, console, **files** | no (`403`) | **yes** |
|
||
| Write or import an egg | **no** — `/api/application/eggs` 404s; eggs are an admin-UI or `php artisan` operation | no |
|
||
|
||
So the full loop needs both, and **a published egg is a JSON file a human imports** — which is also
|
||
exactly how an operator will consume ours, so it is a constraint worth designing into rather than
|
||
around.
|
||
|
||
**File operations are refused during install** with `409 ServerStateConflictException` —
|
||
*"this server has not yet completed its installation process"*. Anything that pushes files has to
|
||
wait for `is_installing: false`, not merely for the server to exist.
|
||
|
||
### 14.2 The correction: there was never a panel bug
|
||
|
||
An earlier pass through this section recorded that the client key "authenticates and then lists zero
|
||
servers", and reasoned from there to a second claim — that *includes are broken on this panel*,
|
||
because `/api/application/servers?include=user` returned an empty list where the same route without
|
||
the include had returned six.
|
||
|
||
**Both were wrong, and wrong the same way.** The servers were being deleted while the probing
|
||
happened, so two calls minutes apart read two different worlds. Once a server existed, every one of
|
||
those calls answered correctly on the first attempt — the client list, the single-server route, and
|
||
`include=user`.
|
||
|
||
The reusable part is not "check twice". It is that **a differential diagnosis across two API calls
|
||
silently assumes the state did not move between them**, and on a live panel somebody else is also
|
||
holding the controls.
|
||
|
||
### 14.2b The upload loop, proven with the real plugin
|
||
|
||
Not a hello-world: phase 1's actual `RunicGateway.cs` (27,642 bytes, 709 lines) was pushed straight
|
||
from the working tree with the client key, and it came back byte-identical on read.
|
||
|
||
Three things that worked and were not certain to:
|
||
|
||
- **`files/write` creates missing parents.** `/oxide/plugins/` did not exist — the framework is laid
|
||
down at boot (§14.3), and the server had never been started — and the write created the whole path.
|
||
- **A plugin placed before Oxide exists survives Oxide arriving.** The entrypoint's `unzip -o` over
|
||
`oxide/` left the file untouched, so the push does not have to wait for a first boot.
|
||
- **It compiled and loaded on Linux**, which no previous phase had ever established. Every prior
|
||
finding came from Windows and Mono:
|
||
|
||
```
|
||
02:19 [Info] RunicGateway was compiled successfully in 0ms
|
||
02:19 [Info] [Runic Gateway] protocol 1, serverId 'main', sidecar 127.0.0.1:7799
|
||
02:19 [Info] Loaded plugin Runic Gateway v0.1.0 by RunicGateway
|
||
02:19 [Info] [Runic Gateway] cannot reach the sidecar: Connection refused - retrying quietly
|
||
```
|
||
|
||
That last line is phase 1's no-stall contract holding on a second platform: no sidecar exists on
|
||
this host yet, the plugin says so once and keeps the game running.
|
||
|
||
**Read the console without a websocket.** Pterodactyl streams console over a websocket, which is
|
||
awkward to drive from a script — but `wrapper.js` also writes `latest.log`, and Oxide writes
|
||
`oxide/logs/oxide_<date>.txt`. Both are plain reads through `files/contents`, which is how every log
|
||
line quoted in this section was obtained. Worth knowing before anyone writes a websocket client.
|
||
|
||
### 14.2c The tier-2 loop, end to end
|
||
|
||
R21's middle tier is the one that has to be pleasant to use, so it was run rather than described.
|
||
One pass: patch the working-tree source so the change is visible in the game console, push, reload
|
||
through the client API's `command` endpoint, read Oxide's log back, then restore.
|
||
|
||
```
|
||
patched source: True
|
||
push -> HTTP 204
|
||
oxide.reload -> HTTP 204
|
||
02:27 [Info] RunicGateway was compiled successfully in 3392ms
|
||
02:27 [Info] Unloaded plugin Runic Gateway v0.1.0 by RunicGateway
|
||
02:27 [Info] [Runic Gateway] protocol 1 [PTERODACTYL-PUSH-PROOF], serverId 'main', sidecar 127.0.0.1:7799
|
||
02:27 [Info] Loaded plugin Runic Gateway v0.1.0 by RunicGateway
|
||
restored source and re-pushed -> 204
|
||
```
|
||
|
||
**Roughly ten seconds from a saved edit to a reloaded plugin**, against a running server with a
|
||
generated world, without touching the panel UI. That is the loop `deploy.ps1` gives us for ServUO,
|
||
and it is the thing that makes the panel a workable rig rather than only a deployment target.
|
||
|
||
Four details worth carrying into the push script:
|
||
|
||
- **Reload is `POST /command`, not a file operation**, and it answers `204` whether or not the plugin
|
||
actually came back. The proof has to be read out of `oxide/logs/` afterwards — the same shape R18's
|
||
auto-rollback needs, and an early rehearsal of it.
|
||
- **The unload/load pair straddles the plugin's own `Init` log line.** `Unloaded` is printed, then the
|
||
new instance's startup line, then `Loaded`. A script that waits for `Loaded` before reading has
|
||
already passed the line it wanted.
|
||
- **Oxide's compiler idles out and restarts.** The boot compile was `0ms`; the reload compile was
|
||
`3392ms` because `Shutting down compiler because idle shutdown` had happened in between. A timeout
|
||
tuned against a warm compiler will be wrong on the first reload after a quiet period.
|
||
- **Restore the working tree and re-push it.** A test that leaves a marker in the source is a test
|
||
that ships a marker. Both were put back and verified byte-identical against the server copy.
|
||
|
||
### 14.3 The image installs the framework on **every boot**, and neither version is pinnable
|
||
|
||
`ghcr.io/pterodactyl/games:rust`'s entrypoint is where `FRAMEWORK` is consumed — **not** the egg's
|
||
install script, which knows nothing about it. On every single start, before the game runs, it:
|
||
|
||
- runs `steamcmd +app_update 258550` unless `AUTO_UPDATE=0`;
|
||
- for `carbon`, downloads
|
||
`CarbonCommunity/Carbon.Core/releases/download/**production_build**/Carbon.Linux.Release.tar.gz`;
|
||
- for `oxide`, downloads `OxideMod/Oxide.Rust/releases/**latest**/Oxide.Rust-linux.zip`.
|
||
|
||
**Both are moving targets, fetched fresh at every restart.** CARBON.md §8 predicted this for Carbon
|
||
from its rolling release tags; the egg makes it true of *Oxide as well*, because `latest` is the same
|
||
kind of promise. The consequence is sharper than "the rig may drift":
|
||
|
||
> **A restart is a framework upgrade.** Two runs of the same test on the same server, minutes apart,
|
||
> are not guaranteed to be running the same framework build — and nothing in the panel says so.
|
||
|
||
That reaches three places. **R4's `doctor`**: the weaker "current enough" claim is not Carbon-specific
|
||
after all; under the egg neither framework has a pinned version to check. **§6's wipe-cadence risk**:
|
||
the re-verify step is per *restart*, not per wipe. And **R20 itself**: if the egg is our deliverable,
|
||
whether it should pin the framework at all is a decision, not an oversight — the upstream egg's
|
||
answer is "always newest", which is right for an operator on wipe day and wrong for a test rig
|
||
trying to reproduce a finding.
|
||
|
||
### 14.4 **The trap that changes R20: the startup string is not a safe place to launch the sidecar**
|
||
|
||
R20 says the startup command becomes "a small wrapper that launches `rust-link-sidecar` and then
|
||
`RustDedicated`". The mechanism allows it and the ordering makes it wrong.
|
||
|
||
`wrapper.js` runs the startup string through `child_process.exec`, which is `/bin/sh -c` — so
|
||
`./rust-link-sidecar & ./RustDedicated …` is syntactically fine. **But for Carbon the entrypoint
|
||
prepends to the whole string:**
|
||
|
||
```bash
|
||
MODIFIED_STARTUP="LD_PRELOAD=$(pwd)/libdoorstop.so ${MODIFIED_STARTUP}"
|
||
```
|
||
|
||
So a startup beginning with our sidecar becomes:
|
||
|
||
```bash
|
||
LD_PRELOAD=…/libdoorstop.so ./rust-link-sidecar & ./RustDedicated …
|
||
```
|
||
|
||
**The preload lands on the sidecar and not on the game.** Carbon loads through Doorstop rather than
|
||
through a patched `Assembly-CSharp.dll`, so the result is a server that starts cleanly, reports no
|
||
error, and **is not modded** — no plugins, no hooks, and a bridge that connects to a game it can
|
||
never hear from. It is the exact silent-success failure §6 keeps cataloguing, and it would only ever
|
||
appear on the Carbon half.
|
||
|
||
Two further consequences of the same handoff:
|
||
|
||
- **`quit` SIGTERMs the shell, not the sidecar.** `wrapper.js` kills `gameProcess`, which is the `sh`
|
||
running the startup string; a backgrounded sidecar is not its child in the way that reaches. R20
|
||
already required "stop means stop the game" — this is the mechanism by which it would fail, and it
|
||
leaves an orphan holding port 21004 against the next start.
|
||
- **Doorstop also confirms R21's clean-install rule from a second direction.** Switching `FRAMEWORK`
|
||
on an existing install does not undo the other framework: Oxide's patched DLL stays on disk while
|
||
Carbon preloads over it. The migration argument was the soft reason for a fresh Carbon rig; this is
|
||
the hard one.
|
||
|
||
**So R20 needs a decision it did not know it needed:** the sidecar is launched by something other
|
||
than the startup string — our own image or entrypoint layered on the upstream one — or the startup
|
||
string is composed so that whatever the entrypoint prepends still lands on `RustDedicated`. The first
|
||
is more work and survives upstream changing its entrypoint; the second is free and depends on a line
|
||
in somebody else's repository. Raised rather than settled.
|
||
|
||
|
||
### 14.5 The Carbon rig, and R19 proven
|
||
|
||
`rust-carbon` — id **18**, identifier **`87fb1f67`**, same egg, same world (procedural, 3000, seed
|
||
1234), same limits, allocations **21005-21009 with 21009 held for the sidecar**, `FRAMEWORK=carbon`.
|
||
**A clean install, never a converted one**, per R21: install 122s, boot 543s, running.
|
||
|
||
**R19 is proven.** The byte-identical `RunicGateway.cs` that runs on the Oxide rig — no `#if CARBON`
|
||
anywhere in it, nothing conditional at all — loaded on Carbon **2.0.259.0** and behaved the same:
|
||
|
||
```
|
||
[INFO] Carbon 2.0.259.0 [2026.09.03.0] 21063e8 on Linux
|
||
[INFO] [Runic Gateway] protocol 1, serverId 'main', sidecar 127.0.0.1:7799
|
||
[INFO] Loaded plugin Runic Gateway v0.1.0 by RunicGateway [2367ms]
|
||
[INFO] [Runic Gateway] cannot reach the sidecar: Connection refused - retrying quietly [RunicGateway Link|26]
|
||
```
|
||
|
||
That last line is the no-stall contract holding on its **third** platform now — Windows/Mono,
|
||
Linux/Oxide, Linux/Carbon — from one source file. [`CARBON.md`](CARBON.md) §10 is the full scorecard;
|
||
the parts that change decisions are below.
|
||
|
||
**R18's amendment is confirmed the best way it could have been.** The plugin's own config, written by
|
||
the same Oxide-compat API on both rigs, landed at `/oxide/config/RunicGateway.json` on one and
|
||
**`/carbon/configs/RunicGateway.json`** on the other. D3 put the plugin's config inside R18's editor;
|
||
had that editor used a literal `oxide/config/`, **it would not have found its own plugin's config on
|
||
half of all installs.** No test would have caught it; only two rigs would.
|
||
|
||
**And one claim was refuted — the one with the sharpest consequence.** `CARBON.md` had said Oxide
|
||
stores permissions as JSON and Carbon as Protobuf or SQLite, offering the difference as the reason
|
||
not to read the file. Both rigs say otherwise:
|
||
|
||
| | Oxide rig | Carbon rig |
|
||
|---|---|---|
|
||
| Path | `oxide/data/oxide.users.data` | `carbon/data/`**`oxide.users.data`** |
|
||
| First bytes | `0a 16 0a 07 64 65 66 61 75 6c 74` | `0a 17 0a 07 64 65 66 61 75 6c 74` |
|
||
| Format | Protobuf | Protobuf |
|
||
| Default groups | `default`, `admin` | `default`, `admin`, **`moderator`** |
|
||
|
||
**Same binary format, same filenames, different directory** — and Carbon writes *its* data into files
|
||
named after Oxide. **This makes R2's API-only rule more important, not less.** A file reader would
|
||
have worked on both rigs today and broken silently for the one operator who ran `c.migrate_perms_sql`
|
||
— no error, no version marker, just a site reporting drift against a store nobody is writing any
|
||
more. The rule survives; the reasoning behind it was wrong and is now right.
|
||
|
||
Two things nobody had thought to claim, found by looking:
|
||
|
||
- **Carbon auto-creates a third default group, `moderator`**, auto-granted by auth level alongside
|
||
`default` and `admin`. R2 pushes its *full* set on connect, so it has to tolerate a group the
|
||
framework will recreate the moment it is deleted — otherwise the site reports drift for ever.
|
||
- **`c.plugins` reports per-plugin `hook fires`, `hook time`, `hook memory`, `hook lag` and
|
||
`hook exceptions`** — most of §6's "log which expected hooks have fired" mechanism, free, and only
|
||
on Carbon. Useful when debugging on Carbon; **not a substitute** for the plugin's own counter, which
|
||
must work on both.
|
||
|
||
**A warning about how a wrong console command fails here.** Pterodactyl's `command` endpoint returns
|
||
`204` whether or not anything happened, and Carbon prints nothing for an unknown command. So
|
||
`oxide.plugins` on Carbon — which is simply not a command — is indistinguishable from success at the
|
||
API. Anything driving the console has to read a log to know, which is the same conclusion §14.2c
|
||
reached about `oxide.reload` and the same shape R18's rollback needs.
|
||
|
||
|
||
## 15. Phase 2 as built — packaging and release, 2026-09-16
|
||
|
||
The first phase with no game server in it, and the one that turns a directory somebody copied into a
|
||
thing an operator can install. [`Module-Rust#2`][mr] is the whole of it.
|
||
|
||
**The starting position was worse than the phase row implied.** Phase 1 built five guards and ran
|
||
every one of them by hand: the repository had no `.gitea/workflows/` at all. So nothing gated the
|
||
branch that gets released, and there was no way to release it. This phase adds both halves, and the
|
||
org lead widened it to include the gate rather than only the release.
|
||
|
||
### 15.0 The three decisions this phase needed
|
||
|
||
- **D4 — the full CI suite, not only `release.yml`.** The phase row names the release; the repo had
|
||
no gate at all. Both ship, and the frozen-manifest job ships with them rather than waiting for a
|
||
later phase — see §15.2 for what it found on its first run.
|
||
- **D5 — the bundle carries no `node_modules`, and the emptiness is asserted rather than assumed.**
|
||
The shipped half declares no runtime dependencies: everything it needs arrives on `ctx`. So the
|
||
release runs no `npm ci` and packs no dependency tree. The whole value of that decision is that
|
||
*the day it stops being true is a loud day*, so `checkBundle.js` fails the PR that adds a
|
||
`dependencies` entry without also teaching the release to install and pack it. Module-uo ships
|
||
`ws` and does the opposite; this is a different answer to the same question, not a divergence
|
||
from a rule.
|
||
- **D6 — no SonarQube for the three Rust repos yet.** Phase 2 is packaging; scanning is quality
|
||
tooling and phase 19 is already the sweep where `CLAUDE.md`'s project-key table gets updated.
|
||
Three half-populated projects while the repos are skeletons buy nothing.
|
||
|
||
### 15.1 What is in a release, and what decides its number
|
||
|
||
A release is **not source**. It is the directory core's loader expects at `modules/rust/`, already
|
||
assembled — the prebuilt client chunk, the schema fragment and the OpenAPI fragment, packed as they
|
||
will be unpacked — because an operator never builds anything (`MODULE_SYSTEM.md` §1.14).
|
||
|
||
| Asset | What it is |
|
||
|---|---|
|
||
| `module-rust-<version>.tar.gz` | the assembled directory, one top-level entry, no `node_modules` |
|
||
| `module-rust-<version>.json` | the install manifest: id, name, version, `coreApi`, url, size, **`sha256`** |
|
||
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
|
||
|
||
The version is **derived**, using the engine `link`, `installer` and `Module-uo` already run:
|
||
conventional-commit subjects since the newest `v*` tag decide major/minor/patch, nothing releasable
|
||
cuts no release, and `module.json`'s version survives as a **floor** rather than as a record. The
|
||
number that ships is the **tag**, and CI stamps it into the bundle's own `module.json` at assembly
|
||
time — which is why the release also asserts that the assembled `module.json` carries the version
|
||
being released, since a bundle that still declared the floor would install under a number that is
|
||
not the one it came from.
|
||
|
||
Module-uo is the argument for deriving rather than declaring: it released only on a hand-edited
|
||
version line, and between 2026-08-12 and 2026-08-19 that cost it *every* bundle, because nine phases
|
||
of work landed without anyone touching that line. `workflow_dispatch` survives as the backdoor for
|
||
the case the rules cannot reach — a widened `coreApi`, a new mount, a capability, with no releasable
|
||
code behind it.
|
||
|
||
**Two failure modes are guarded before anything is built.** The credential check runs first, so a
|
||
repository without `REGISTRY_TOKEN` fails with a named error instead of pushing a tag and then 401ing
|
||
on the release API — the state `servuo-plugins` got stuck in on its own first release. And a tag that
|
||
exists with **no release behind it** is deliberately not treated as "nothing to do": that is the
|
||
signature of the same half-failure, and standing down on the tag alone would make it permanent.
|
||
|
||
### 15.2 The frozen manifest answers the question phase 1 had to take on trust
|
||
|
||
`§13`'s registration comment says `/rust` "collides with nothing on any of the three tiers, checked
|
||
against core's mount tables rather than assumed" — and then names the limit of that check: **core
|
||
answers several public routes mounted at the tier root rather than under a prefix**, `/status` and
|
||
`/version` among them, which the loader's own collision probe cannot see.
|
||
|
||
The `frozen-manifest` job is the thing that can. It clones core at the sha pinned in
|
||
`ci/core-ref.json`, generates core's route table **without** this module and then **with** it, and
|
||
takes the difference. That difference is what the module serves, and it is checked three ways: it
|
||
must match the committed `routes.manifest.json`, every route in it must have an operation in
|
||
`swagger-fragment.json` *and* every operation must be a route, and — the half that matters most —
|
||
**no core route may have been removed or changed**. A module whose mount displaced a core route
|
||
cannot show up as an addition, because the URL is unchanged; only the diff sees it.
|
||
|
||
Run locally against the pinned ref before it was committed, and again in CI against a fresh clone,
|
||
both agreeing:
|
||
|
||
```
|
||
route manifest up to date (280 routes)
|
||
wrote routes.manifest.json (282 public + 4 internal)
|
||
routes.manifest.json is current — 6 routes, all documented
|
||
```
|
||
|
||
Six routes, all documented, nothing of core's moved. **`/rust` is now free by proof rather than by
|
||
reading**, and it stays that way on every pull request.
|
||
|
||
**The pin starts on `main`, unlike Module-uo's.** That repo spent the whole Event System window
|
||
pinned to `edge`, because it depended on contract members that had not reached `main` yet. This
|
||
module needs `MODULE_API` 1.10.0, which the Event System cutover already put there, so
|
||
`ci/core-ref.json` names `efa9db7` on `main` and should stay on `main` until this module comes to
|
||
depend on something unreleased.
|
||
|
||
### 15.3 One declaration, two readers — and it was verified by breaking it
|
||
|
||
`ci/bundle.json` is an **include** list, never an exclude list: an exclude list ships whatever it
|
||
forgot, and the day somebody adds `server/tools/` with a scratch credential in it, an exclude list
|
||
packs it and nobody finds out. The cost of that choice is the opposite failure — a new directory
|
||
silently drops *out* of every release — which is exactly what happened to Module-uo between v0.3.0
|
||
and v1.0.0, where `server/commands/` arrived in a cutover, the list did not learn about it, and the
|
||
module installed cleanly and then died at the register stage on the operator's box.
|
||
|
||
Nothing caught it there because the PR checks copy the **whole repo** into core: they only ever
|
||
exercised a tree that had the file. **The subset exists only in the release.** So the list has two
|
||
readers — `release.yml`, which packs from it, and `server/scripts/checkBundle.js`, which asks on
|
||
every PR whether it still covers everything `server/index.js` can reach.
|
||
|
||
It reaches requires written **inside `register()`**, which is not a detail: this module's entry point
|
||
requires its routers inside the function on purpose, because `core.init(ctx)` has to run before
|
||
anything under `router/` is required. A check that only saw file-scope requires would have missed
|
||
every router the module has.
|
||
|
||
Verified the only way a check is worth anything — by breaking it. Dropping `"model"` from the list:
|
||
|
||
```
|
||
ci/bundle.json does not ship everything server/index.js reaches.
|
||
server/model (2 files reachable)
|
||
Add "model" to ci/bundle.json's server[].
|
||
```
|
||
|
||
It names the exact edit, in the units the list is written in.
|
||
|
||
### 15.4 The release, as published
|
||
|
||
`v0.1.0`, cut by the first push to `main`, with no tag before it — so the engine took the first-run
|
||
branch and shipped what `module.json` declared. Three assets:
|
||
|
||
| Asset | Size |
|
||
|---|---|
|
||
| `module-rust-0.1.0.tar.gz` | 41,310 bytes |
|
||
| `module-rust-0.1.0.json` | 355 bytes |
|
||
| `SHA256SUMS` | 91 bytes |
|
||
|
||
Downloaded and hashed independently of CI, the artifact is
|
||
`7296c76b988c6191840a1dc4ed1a77d96ad35e8c26f9b6b55e88fe4c10878b32` — byte-for-byte the `sha256` the
|
||
manifest declares.
|
||
|
||
**`REGISTRY_TOKEN` was already configured on the repository**, which was the one prerequisite this
|
||
work could not verify for itself: the bot identity can read neither repository nor organisation
|
||
Actions secrets (`user should be the owner of the repo`). The credential check exists precisely
|
||
because the answer was unknowable from here, and the first release ran clean through it.
|
||
|
||
### 15.5 The acceptance criterion, walked
|
||
|
||
> *An operator installs the empty module from Admin → Modules and it reaches `started`.*
|
||
|
||
**Met.** Walked as an operator would, against the local core on `edge`, with the phase-1 state
|
||
deliberately torn down first — the hand-copied `modules/rust/` directory moved aside and its
|
||
`installed_modules` row deleted, so this was a first install and not an upgrade. Before it,
|
||
`GET /api/v1/public/rust/servers` answered **404**.
|
||
|
||
1. Admin → Modules, with no `rust` row and `gitea.whitlocktech.com` on the allowlist.
|
||
2. Pasted the release's `module-rust-0.1.0.json` URL and pressed Install.
|
||
→ *"Installed Rust v0.1.0. Restart to load it."*, and a row reading **Restart to start —
|
||
installed, it mounts when the server next starts**, carrying the manifest URL and
|
||
`sha256 7296c76b988c…`.
|
||
3. Restarted.
|
||
→ `registered module "rust" v0.1.0`, `schema ensured for module "rust" {"statements":2}`,
|
||
`[rust:boot] booted {"refreshMs":30000}`, `module "rust" started`.
|
||
|
||
The row is now `state = started` with `started_at` set, the source URL and the released hash — where
|
||
phase 1's row had `source` and `sha256` **null**, because a directory somebody copied has no
|
||
provenance to record. The screen reads **Running — mounted and serving**.
|
||
|
||
**What the volume received is exactly the include list and nothing else: twenty files.** No tests, no
|
||
`server/scripts/`, no `ci/`, no `client/src/`, no `node_modules`, and no dotfiles. `client/dist/entry.js`
|
||
is the chunk CI built.
|
||
|
||
And the three checks that prove it is *serving* rather than merely loaded:
|
||
|
||
| | |
|
||
|---|---|
|
||
| `GET /api/v1/public/rust/servers` | **200**, answering with the rig's `main` server |
|
||
| `GET /api/v1/player/rust/servers` · `GET /api/v1/admin/rust/servers` | **401** — the tier gates are on, not bypassed |
|
||
| `/api/docs.json` | carries all **five** `/rust` paths: the fragment merged into core's own spec |
|
||
|
||
**Two things this walk is honest about.** The first is that the *restart* was done from the shell
|
||
rather than with the screen's own **Restart the server** button: that button runs the same graceful
|
||
shutdown a `SIGTERM` does and relies on a supervisor to bring the process back, which the shipped
|
||
`docker-compose.yml` provides and a bare `npm start` does not. The button was not exercised, and it
|
||
is core's, not this module's. The second is that the module's two tables were left in place when the
|
||
row was deleted, so the rig's `main` server row survived the reinstall — the schema fragment replayed
|
||
on boot (`statements: 2`) exactly as R12 requires of a fragment that runs every time.
|
||
|
||
**The one operational finding, and it is about the rig rather than the module.** Docker Desktop's
|
||
Linux engine was found dead — its WSL distribution stopped, the `uomm-db` container exited, and
|
||
`docker` answered every call with `500 Internal Server Error` rather than anything naming the cause.
|
||
Restarting Docker Desktop and the container fixed it. Worth writing down because the failure presents
|
||
as the *website* being broken (`ECONNREFUSED` to a database that is simply not there), and because
|
||
`CLAUDE.md` points every smoketest at that one container.
|
||
|
||
## 16. Phase 3 as built — the read path, 2026-09-16
|
||
|
||
The first phase that had to be true on two mod frameworks, and the first with a
|
||
catalogue rather than a message. Four repositories moved: the spec here, the plugin, the sidecar,
|
||
and the module.
|
||
|
||
**Status: the bridge half is done and proven; two proofs are queued on the org lead.** What the
|
||
plugin sends and what the sidecar does with it are built, tested and exercised against live Oxide
|
||
and Carbon servers. The player-facing half of the catalogue — deaths, chat, gathering, sessions —
|
||
cannot fire without somebody holding a mouse, and is written down as a walk to run rather than
|
||
guessed at: [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md). §16.7 lists everything still open.
|
||
|
||
### 16.0 The four decisions this phase needed
|
||
|
||
- **D7 — the widest first hook wave.** The options ran from "presence, deaths and the wipe" to
|
||
"everything read-only worth having", and the widest was chosen: fifteen hooks, including the
|
||
moderation set that carries IP addresses and player reports. The consequence is real and is
|
||
designed around rather than deferred — those frames arrive **nine phases before** the visibility
|
||
framework phase 14 builds, so the classification and its default-deny allowlist ship now (§16.4).
|
||
- **D8 — the plugin derives `wipeId`.** `PROTOCOL.md` §3.2 had reserved that for the website. By
|
||
protocol 2 three components store rows that need it and only one of them can read the value, so
|
||
the reversal is written into §8.2 rather than left as a contradiction.
|
||
- **D9 — the live feed is a cursor, and D5 stands.** Core runs Node 20, where a global `WebSocket`
|
||
is still behind a flag, so a socket means taking `ws` — against a release that asserts it ships no
|
||
runtime dependencies. The deciding argument was the other one: **a socket needs a cursor anyway**
|
||
for what it missed while the module was restarting, and the catch-up path is the one that must be
|
||
right. One mechanism exercised every five seconds beats two where the second only runs after an
|
||
outage nobody planned.
|
||
- **D10 — rollups permanent, raw bounded.** The website keeps per-player-per-wipe totals for ever
|
||
and a 30-day window of raw events; the sidecar keeps 14 days and prunes hourly. R12's "a wipe does
|
||
not erase a player's history" is met by the totals, which is the row an operator actually reads.
|
||
- **D11 — CI for both bridge repositories**, which had none at all. Phase 2 found that hole in
|
||
Module-Rust; it was still open in the two repositories that ship the half running inside somebody
|
||
else's game server.
|
||
|
||
### 16.1 `type` is the whole of protocol 2 in the sidecar
|
||
|
||
Protocol 1 routed on `kind`, in a `match` that needed a new arm per addition. Protocol 2 adds
|
||
**`type`** — `event`, `snapshot`, `reply`, `control` — and the sidecar files on that and nothing
|
||
else. Ten new event kinds are now zero change in Rust-Link, which is the property that matters when
|
||
the thing growing fastest is the catalogue.
|
||
|
||
A frame whose `type` this build does not know is **dropped and counted**, never guessed at.
|
||
Defaulting an absent one to `event` would file a *board* as history — the presence board appended a
|
||
few thousand times, which nothing reports and nobody notices until they wonder why the database is
|
||
large.
|
||
|
||
**It caught a real mismatch three seconds after it first ran**, which was not planned: a protocol 1
|
||
plugin was still live on the retired workstation rig, dialled the new sidecar, and its `server.hello`
|
||
went straight into the counter. The game link has no version handshake by design (§2), so
|
||
`untyped_frames` on `/health` is the only place that failure is visible — and the symptom without it
|
||
is a website showing nothing while the game is plainly up.
|
||
|
||
### 16.2 Two defects a live server found, and neither could have been found anywhere else
|
||
|
||
**The wipe id was null for every real session.** `Init` runs *before* the save is loaded, so
|
||
`SaveRestore.SaveCreatedTime` is not yet meaningful there, and the id resolved at load time stayed
|
||
null for the life of the process — every frame shipping without the field R12 splits history on.
|
||
|
||
It was invisible for the reason such things usually are: a **hot-reloaded** plugin reads an
|
||
already-loaded world and gets the right answer every time. Every development iteration on the
|
||
workstation rig was a hot reload. It took a server that *booted* with the plugin installed — which
|
||
is every real one — to show `wipeId=none` beside a save sitting on disk. Now resolved again at
|
||
`OnServerInitialized`, and lazily while still unknown.
|
||
|
||
**`Unload` blocked the game's main thread for two seconds.** Carbon reported it exactly:
|
||
`hook 'Unload' took longer than 100ms [2002ms]`, next to `link thread did not stop cleanly`. That is
|
||
phase 1's stall arriving by a different road — the link thread sits in a blocking
|
||
`TcpClient.Connect`, which has no timeout of its own and cannot be woken, and `Unload` joins it from
|
||
the main thread.
|
||
|
||
The reason two phases missed it is worth keeping: **a host that refuses answers instantly, and a
|
||
host that drops does not answer at all.** Every loopback test is the first kind. A firewalled
|
||
address, a typo, a machine that is off are all the second, and the deployment this phase was being
|
||
tested through happened to be one. The connect is now bounded and waits on a stop handle of its own
|
||
— it cannot share `Wake`, which also means "the queue has something in it" and is signalled by every
|
||
hook that fires. After the fix the same reload logs no slow-hook warning and no stranded thread.
|
||
|
||
### 16.3 The aggregate, and the rule it generalises
|
||
|
||
`OnDispenserGather` fires on **every swing at a tree**. A frame per swing would make the bridge the
|
||
most expensive thing on a busy server, and nobody wants a killfeed of chickens either, so gathering
|
||
and NPC kills are counted in the plugin and flushed once a minute as one `player.tally` frame.
|
||
|
||
The rule: **if a hook can fire more than once a second per player, it is a counter, not an event.**
|
||
R17's warning about chatty zone transitions is the same rule arriving early.
|
||
|
||
A tally is a **delta, not a running total** — what happened since the last flush — so the consumer
|
||
sums rather than diffs, and a dropped frame costs one interval instead of corrupting the series. The
|
||
outbound queue is drop-oldest by design, so frames are genuinely allowed to go missing; a running
|
||
total over a lossy link is a number that is quietly wrong for ever.
|
||
|
||
One honest limitation, corrected in the code rather than in the comment that first claimed
|
||
otherwise: **a plugin reload loses up to a minute of one player's tally.** `Unload` enqueues the
|
||
flush, but the writer stops on the same flag and the queue is cleared after the join. Draining it
|
||
first would mean waiting on a socket from the main thread — the stall §16.2 just removed — so the
|
||
loss is taken deliberately. A real shutdown flushes at `OnServerShutdown`, and a player leaving
|
||
flushes at their disconnect.
|
||
|
||
### 16.4 The boundary is enforced by the side that serves
|
||
|
||
The widest hook wave brings IP addresses (`CanUserLogin`, `OnUserApproved`, `OnUserBanned`), one
|
||
player's report about another, and the grid reference of somebody's base — nine phases before the
|
||
visibility framework §11.2 costed. So the classification ships with the catalogue.
|
||
|
||
**It is not a field on the wire.** The plugin could have stamped a class on every frame; it
|
||
deliberately does not. A boundary declared by the *sender* is one a compromised — or merely
|
||
out-of-date — game host can widen. The website's own shard fan-out works the same way: a public
|
||
stream with an allowlist of kinds and an admin stream that adds the rest, and what makes it
|
||
trustworthy is that the decision lives on the serving side.
|
||
|
||
So `module-rust/server/catalogue.js` holds it, **default-deny**: a kind this build has never heard
|
||
of is not public. That is the shape of the mistake it prevents — the next protocol version adds a
|
||
kind, the module stores it happily, and a deny-list filter would publish it the day it first
|
||
arrived, before anybody decided whether it should be. A test holds the list against §8.4's table, so
|
||
adding a kind to the protocol without classifying it fails a build.
|
||
|
||
### 16.5 A login denial is not a hook, and §10 says it is
|
||
|
||
`PLAN.md` §10 sources the `rust.login.denied` trigger from `CanUserLogin`. Reading the hook says that
|
||
cannot work: it fires on **every** connection attempt, and the only way to learn of a denial from it
|
||
is to *be* the denier — which §8.7 forbids, structurally, by declaring every read-path hook `void` so
|
||
it cannot answer. uMod publishes no `OnUserRejected`.
|
||
|
||
What the game can tell us is two facts: an attempt, and an approval. Protocol 2 emits both, and a
|
||
denial is **the absence of an approval** — a deferred read, phase 10's to make. The trigger survives;
|
||
its source changes. (The same shape the engagement workstream hit at its own phase 10, which is
|
||
either a coincidence or a property of login paths.)
|
||
|
||
### 16.6 What was proven, and how
|
||
|
||
| Claim | How | Result |
|
||
|---|---|---|
|
||
| The read path compiles and loads on **Oxide** | live server, protocol 2, 15 hooks bound | ✅ |
|
||
| …and on **Carbon** 2.0.259.0, from the **byte-identical file** | the Pterodactyl rig, config read from `carbon/configs/` | ✅ |
|
||
| Every frame carries `type`, `serverId`, `wipeId` | `server.hello` and `players.online` read back off the sidecar | ✅ |
|
||
| A wipe id derived from the save, changing only with the save | `rg.link` reports `w-20260915T195817Z` against `saveCreatedAt 2026-09-15T19:58:17Z` | ✅ |
|
||
| **A restarted sidecar is fully populated within one connection** | store deleted, process restarted: both boards present **0.3 s** after the listener bound, and **zero** events in history | ✅ |
|
||
| Boards are not replayed as history | the same walk: `/events` returned 0 rows while `/boards` returned 2 | ✅ |
|
||
| Moderation frames reach the sidecar whole | `banid` / `unban` over RCON | ✅ |
|
||
| A protocol mismatch is counted, not mis-filed | a live protocol 1 plugin against the protocol 2 sidecar | ✅ |
|
||
| `rg.hooks` answers on both frameworks | Oxide and Carbon, identical output shape | ✅ |
|
||
| 44 sidecar tests, 95 module server tests, 20 client tests, every guard | locally, and now in CI on both repos | ✅ |
|
||
| The module's route manifest against a **real core** at the pinned ref | 10 routes, all documented, none of core's moved | ✅ |
|
||
| **The module ingests a live game** | the working tree installed into a running core, pointed at the workstation sidecar: `cursor started at the feed tail`, then a console `banid`/`unban` arrived as two `rust_events` rows with their wipe id, cursor advanced | ✅ |
|
||
| **The allowlist holds against real rows** | with both ban events in the table, `GET /public/rust/servers/:id/events` answered `{"events":[]}` — **and answered the same when asked for `player.banned` by name** | ✅ |
|
||
| A wipe row is created by being mentioned | `w-20260915T195817Z` appeared in `rust_wipes` from the first frame carrying it, with no "a wipe started" call anywhere | ✅ |
|
||
|
||
**One finding about the rigs rather than the code:** the panel rigs cannot reach a sidecar running on
|
||
the workstation, because Windows Firewall holds two program-scoped **Block** rules for
|
||
`rust-link-sidecar.exe` — created by a dismissed prompt at some point — and a program-level block
|
||
beats any port-level allow. Removing them needs elevation. It is a rig problem only: the shipped
|
||
design puts the sidecar on the game host's own loopback (D2, R20), where it is the deployment that
|
||
never needs a rule at all.
|
||
|
||
The local workstation rig, which does reach its sidecar on loopback, is what proved everything in the
|
||
table above that needs a live socket. **`D:\rust` is therefore not as retired as R21 assumed** — it
|
||
survives as the fast loop (a saved file is a reloaded plugin in about ten seconds, against nine
|
||
minutes of world generation on the panel), and the panel rigs are what answer "on both frameworks".
|
||
|
||
### 16.7 What is still open, and who it is waiting on
|
||
|
||
Three things, all of them measurements rather than decisions, and all of them the org lead's to run:
|
||
|
||
1. **The player walk** — [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md). Ten minutes on a rig
|
||
with a mouse, and it closes `OnPlayerDeath`, `OnPlayerChat`, `OnDispenserGather`,
|
||
`OnPlayerRespawned`, `OnEntityDeath`-by-a-player, and `sessionSec`. The document says what each
|
||
step should produce, so it can be run without anybody watching the output live.
|
||
2. **The wipe walk.** `OnNewSave` fires when a server starts with no save — the panel rigs wipe
|
||
through the egg's own `REMOVE_FILES`, so this is the rig's own mechanism rather than a special
|
||
test. What it proves is the second half of the acceptance criterion: that the old wipe's rows are
|
||
still queryable by `?wipe=` afterwards.
|
||
3. **The Carbon socket leg.** Everything up to the socket is proven on Carbon; what is not is frames
|
||
actually arriving over a live link, which is one elevated firewall command away
|
||
(`Remove-NetFirewallRule -DisplayName "rust-link-sidecar.exe"`, then an allow for the rig).
|
||
|
||
Until 1 and 2 are run, the honest statement of this phase is: **the transport, the envelope, the
|
||
boards and the classification are proven on both frameworks; the player half of the catalogue is
|
||
built, reviewed against the hook documentation, and unmeasured.** That is written here rather than
|
||
in a commit message because it is the kind of thing a later phase will want to know it inherited.
|
||
|
||
### 16.7b The ingest walk, and the trap it walked into
|
||
|
||
The module half was exercised against the live rig rather than only against its own tests: the
|
||
working tree installed into a running core, pointed at the workstation sidecar with a real Rust
|
||
server behind it. It logged `cursor started at the feed tail {at: 1}` — the fresh-install path,
|
||
starting at the end rather than replaying — and a console `banid`/`unban` pair then arrived as two
|
||
`rust_events` rows carrying the wipe id, with the cursor advancing to 3 and `events_seen` at 2.
|
||
|
||
Then the boundary, on real rows rather than fixtures: with both ban events sitting in the table, the
|
||
public events route answered `{"events":[]}`, **and answered the same when asked for
|
||
`player.banned` by name**. That is the difference between a filter and a refusal — the kinds are, as
|
||
far as a public caller is concerned, not there.
|
||
|
||
**The trap, and it is the same one the Pterodactyl work recorded.** The server-state row read as
|
||
`online=1` with `hostname=NULL` and `protocol=NULL`, which is a shape no code path writes. The cause
|
||
was two cores sharing one database: a second instance left running from earlier in the day still
|
||
holds the **phase 2 release** in memory, speaks protocol 1, is refused `409` by a protocol 2 sidecar,
|
||
and writes the row back as unreachable every thirty seconds. Nothing was wrong with either of them.
|
||
|
||
What made it *look* like a defect was the measurement: two `SELECT`s in two round trips, assuming
|
||
the state did not move between them. A single atomic read caught the coherent row a moment later —
|
||
`online: 1, protocol: 2, hostname: Test Server` — alternating with the other writer's.
|
||
**A differential diagnosis across two calls is only valid if nothing else holds the controls**, and
|
||
on a shared rig something usually does.
|
||
|
||
### 16.8 Smaller things worth keeping
|
||
|
||
- **`rg.hooks` collides with `RGProbe`**, the phase-0 rig plugin, which registered the same console
|
||
command first. Oxide warns and the last loaded wins, which happens to be the bridge. Left alone:
|
||
`RGProbe` is rig scaffolding that never ships, and renaming the shipping command to avoid a
|
||
test tool would be the wrong way round.
|
||
- **The IP a console ban reports is the literal string `"0"`**, not an address and not a null, when
|
||
the banned id is offline. Observed, not guessed. The plugin omits the field instead of forwarding
|
||
it — a column full of `"0"` survives every is-it-missing test a reader writes and then fails
|
||
whatever parses it.
|
||
- **`--print-config` reports the *effective* configuration and writes the *file* one.** Environment
|
||
variables override the file (R22 depends on that), and the written file never contains them. Not a
|
||
bug, but the two are not the same document and an installer reading one should not assume the
|
||
other.
|
||
- **A `cargo clippy` run does not produce a binary.** Two rig readings disagreed with the source
|
||
because the sidecar under test was an older `cargo build`; `clippy` and `test` compile without
|
||
writing one. Rebuild before believing a rig.
|
||
|
||
## 17. Phase 4 as built — the first pages, 2026-09-16
|
||
|
||
One repository, and the first phase whose whole deliverable is something a visitor looks at. It
|
||
consumes exactly the routes phase 3 built and adds one of its own; nothing here talks to a game
|
||
server, which is the point of the criterion it was written against: **the site renders the last
|
||
thing each server said while every server is off.**
|
||
|
||
**Status: criterion met, and met the hard way.** The pages were walked in a browser against a live
|
||
rig — a real Rust server behind the real sidecar for `main`, and a second, deliberately unreachable
|
||
server for everything the record holds. That walk found four defects, two of them in code phase 3
|
||
had already shipped, and all four are fixed here (§17.2).
|
||
|
||
### 17.0 The four decisions this phase needed
|
||
|
||
- **D12 — `/rust` is the server list.** Phase 1 registered it at `/rust/servers` and left the
|
||
module's own namespace root answering core's CMS catch-all. R8 calls the list "the landing page",
|
||
so it is registered with an **empty path** — core renders that as `/rust` — and the detail page
|
||
hangs beneath it at `/rust/servers/:id`. One canonical address, and it is the module's name.
|
||
- **D13 — one page with in-page tabs**, not four routes. `/rust/servers/:id` carries a header and
|
||
four panels (feed, leaderboard, online, wipes). A tab strip is not in the shared UI kit, so the
|
||
module bundles its own — which is the kit working as designed rather than a gap in it (§3.4 is a
|
||
closed list of nine members, and everything above them is the module's).
|
||
- **D14 — poll while the tab is visible.** The feed and the presence list re-fetch every twenty
|
||
seconds, paused by the Page Visibility API and refreshed the instant a viewer comes back. The
|
||
leaderboard and the wipe list load once: a table that re-sorts itself under the reader's cursor is
|
||
worse than one four minutes old. Server-Sent Events were considered and are not this phase — core
|
||
has a fan-out, but the module registers no stream, and a cursor-driven one is phase scope.
|
||
- **D15 — the footer slot carries a live count**, not a static link: `2 servers · 42 online`, linking
|
||
to `/rust`. The cost is stated rather than assumed — see §17.4.
|
||
|
||
### 17.1 What is on the pages
|
||
|
||
`/rust` is the list: name, map, size, when it was wiped, when it last reported, and the player count
|
||
or `Offline`. The whole row is the link.
|
||
|
||
`/rust/servers/:id` is the server. The header is what a Rust player asks first — map, world size,
|
||
seed, wipe date — with live status beside it and a wipe selector that applies to the whole page.
|
||
Then four tabs:
|
||
|
||
| Tab | Reads | Refresh |
|
||
|---|---|---|
|
||
| Feed | `…/events`, with a filter that maps to the `kind` parameter | 20s, visibility-gated |
|
||
| Leaderboard | `…/leaderboard`, sortable, per wipe or all-time | on mount |
|
||
| Online | `…/online` — the presence **board**, not counted transitions | 20s, visibility-gated |
|
||
| Wipes | `…/wipes`; picking one filters the feed | on mount |
|
||
|
||
**Everything selectable is in the URL** — tab, filter, wipe, sort. That costs a little ceremony in
|
||
the page and buys the thing a community site is for: *"last wipe's leaderboard on Main"* is a link,
|
||
the back button undoes a click rather than leaving the page, and a refresh lands where the reader
|
||
was. `wipe=current` is a word rather than an id on purpose, so a shared link stays about now.
|
||
|
||
**The feed renders parts, not sentences.** `lib/feed.js` turns a stored frame into
|
||
`{tone, actor, join, verb, subject, detail}`, which keeps the names emphasised without any HTML in a
|
||
string, and makes the whole thing testable in a runner with no DOM. A death is four sentences, not
|
||
one — `player`, `self`, `npc`, `environment` — because the plugin distinguishes them so a reader does
|
||
not have to guess, and a fall reported as a kill by nobody is the failure that avoids. **A kind this
|
||
build has never heard of renders as itself** rather than vanishing: the server's allowlist has
|
||
already decided the row may be seen, so what is left here is presentation, and the honest
|
||
presentation of a kind we have no words for is its own name.
|
||
|
||
**`player.tally` is public and deliberately not in the feed.** It is an aggregate the plugin flushes
|
||
once a minute per active player (PROTOCOL.md §8.6), so a feed carrying it would be mostly wood
|
||
counts. It is the leaderboard's input, and that is where it shows up.
|
||
|
||
### 17.2 Four defects the page walk found, and two of them were already shipped
|
||
|
||
The walk was the whole point of doing one. None of these is visible in a test that stubs a sidecar
|
||
which answers.
|
||
|
||
1. **An unreachable refresh erased what the server last said.** Phase 3's refresh loop called
|
||
`putState` — the whole-row write — with two fields when a sidecar did not answer, so `hostname`,
|
||
`level`, `seed`, `world_size` and `wipe_id` all went to NULL the first time a game host rebooted.
|
||
The list then read `Offline` with nothing beside it, which is not *"here is what we know about a
|
||
server that is down"*, it is *"we have never heard of it"* — and it defeats this phase's
|
||
criterion exactly. Fixed with `markUnreachable`, which moves three columns and mentions no
|
||
others; `server/test/refresh.test.js` asserts against the SQL, because the defect is about which
|
||
columns a statement names.
|
||
2. **"Last reported" was reading the wrong timestamp.** `updated_at` is when this module last WROTE
|
||
the row, which a failed poll does too — so an offline server claimed it had reported just now,
|
||
every thirty seconds, for as long as it stayed down. They are two facts and both are wanted:
|
||
`updated_at` decides staleness, and a new `last_seen_at` records when a `server.hello` last
|
||
arrived. Only a successful refresh moves it.
|
||
3. **Every feed row showed a bare time of day.** Correct for today's killfeed and wrong the moment
|
||
the feed is filtered to a past wipe: three events from six weeks ago all rendered as `02:03 PM`.
|
||
Rows from another calendar day now carry the date. The boundary is the calendar rather than a
|
||
duration, because that is what a reader means by "what time was that".
|
||
4. **A mistyped address was dressed as a fault.** The detail page rendered core's `ErrorState` under
|
||
its own heading, so `/rust/servers/typo` read "No such server / Something went wrong" and sent a
|
||
reader looking for an outage. A 404 is now its own answer and `ErrorState` is kept for a request
|
||
that failed for a reason nobody can see.
|
||
|
||
A fifth, smaller: the Online tab listed three players under a header reading `Offline`. An
|
||
unreachable sidecar does not clear the presence board — deliberately, the rows are still the best
|
||
answer anybody has — but presented bare they read as *who is on right now*, which is the one thing
|
||
an offline server cannot be saying. The panel now says which it is.
|
||
|
||
### 17.3 `useAsync` cannot poll, and that is not a defect in it
|
||
|
||
Core's fetch hook (§3.4) blanks `data` and sets `loading` on every dependency change. That is right
|
||
for a page load and wrong for a poll: bumping a dependency every twenty seconds would clear the
|
||
killfeed, render a spinner in its place and re-fill it, four times a minute, for ever.
|
||
|
||
So the module bundles `hooks/usePolled.js`: a refresh that is **invisible when it succeeds** and
|
||
keeps the rows *and* reports the error when it fails — because a site whose premise is "it renders
|
||
while the game is off" must not blank itself the first time a request does. `key` (the question)
|
||
resets the data; the interval does not. `useAsync` is still the right hook for everything that loads
|
||
once, and both are used here.
|
||
|
||
The live proof: with the tab hidden the log shows no requests at all, and the instant it became
|
||
visible there was one refresh followed by one every 20.0 seconds.
|
||
|
||
### 17.4 The footer slot's real cost, stated
|
||
|
||
Core renders `SiteFooter` inside `PublicLayout`, and **every public page renders `PublicLayout`
|
||
itself** (§3.3) — so a component in that slot mounts once per public page view, not once per
|
||
session. D15's live count therefore puts one `/public/rust/servers` request on every public page of
|
||
the site, including pages with nothing to do with Rust.
|
||
|
||
Two things keep that honest rather than merely cheap. It **renders nothing until it has an answer,
|
||
and nothing at all if the request fails** — core's `<Slot wrap>` takes its separator with it, so a
|
||
failure degrades to exactly the footer an instance with no module installed has. And it **never
|
||
polls**: one request per page view is a cost; a timer in the footer of every page is a different
|
||
kind of thing. If it ever shows up in an operator's logs, the fix is a short-lived cache in that one
|
||
file and nothing else on the site changes.
|
||
|
||
### 17.5 Smaller things worth keeping
|
||
|
||
- **The registration fake was *nearly* core, which is worse than obviously not.** `client/test`'s
|
||
fake registry prefixed routes as `` `${id}/${path}` ``; core strips the trailing separator too,
|
||
which is exactly what lets a module register `path: ''` and own its namespace root. The day a
|
||
module did, the fake produced `rust/` where a real core produces `rust`, and the suite failed the
|
||
nav check for a link that works perfectly in a browser. The fake now copies core's line character
|
||
for character.
|
||
- **A SQL comment inside a JS template literal may not contain a backtick.** Obvious written down,
|
||
invisible while writing prose about `markUnreachable` inside a query string; the file stops
|
||
parsing several lines later and the error names an argument list.
|
||
- **The detail route exists so that a page can 404.** Every other route under `/servers/:id` answers
|
||
an empty list for an id nobody configured — an unknown server genuinely has no events, no
|
||
leaderboard and nobody online, and each of those is a good answer to its own question. Only
|
||
`GET …/servers/:id` can say the server is not there. A disabled server answers the same 404 as a
|
||
missing one: an operator who switched a server off did not switch it into a 403.
|
||
- **`capabilities` grew to what the pages serve** — `servers`, `killfeed`, `leaderboard`,
|
||
`presence`, `wipes` — which is the list phase 5's Android leg feature-detects against.
|
||
- **The rig had two cores again**, and this time the mechanism was visible rather than inferred: a
|
||
leftover core holding an older module release spoke protocol 1, was refused `409`, and rewrote the
|
||
state row as unreachable every thirty seconds — the phase-3 trap, and also how defect 1 above was
|
||
found. One of the two was stopped with the org lead's say-so before the walk continued.
|
||
|
||
### 17.6 What was proven, and how
|
||
|
||
- **The criterion, directly.** A second server was configured pointing at a dead address and seeded
|
||
with a fixture shaped exactly as the plugin emits (two wipes, 26 events, four players, a presence
|
||
board). With that server unreachable and reporting `Offline`, its page still rendered its map,
|
||
size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, last known board and wipe
|
||
history. That is the phase criterion in one screenshot.
|
||
- **The allowlist, on real rows.** The fixture includes a kind this build has never heard of. The
|
||
public route answered `{"events":[]}` for it when asked **by name** — a refusal, not a filter.
|
||
- **R12's arithmetic, on the page.** All-time equals the two wipes summed (41 + 18 = 59), and a
|
||
player who only appears in the older wipe drops out of the current one rather than reading zero.
|
||
- **The footer slot**, live in core's own footer on every public page, linking to `/rust`.
|
||
- **The chunk's identity check**, which is the one failure only a browser can show: the console
|
||
carried the module's own registration line and nothing else — no second React, no bare import.
|
||
|
||
What is **not** proven here and is deliberately left: the pages have not been read on a phone-width
|
||
viewport, and the Android leg (phase 5) is where the same surface gets a second client anyway.
|
||
|
||
## 18. Phase 5 as built — Android leg A, 2026-09-17
|
||
|
||
The first leg of R10, and the first time this module's surface has had a second client. The app's
|
||
own record of it is [`../../android/PLAN.md`](../../android/PLAN.md) **M14**; what follows is what
|
||
the phase decided and what the walk found.
|
||
|
||
**Criterion met, and walked in an emulator rather than asserted:** *the app renders a Rust site it
|
||
has never seen, and a UO site unchanged.* Both halves were shown on one device against two running
|
||
cores.
|
||
|
||
### 18.0 Four org-lead decisions
|
||
|
||
- **D16 — the app gates on a new capability, `rust`.** This module declared five strings and every
|
||
one names a **surface**: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`. Core flattens
|
||
every started module's capabilities into one list, so a client gating a whole navigation group on
|
||
`servers` would have those screens revealed by any future module that declared the same generic
|
||
word. `module-uo` has exactly one string for this job — `shard` — and this module had none.
|
||
|
||
**Gating on the module `id` was considered and rejected.** It is the strongest fact available and
|
||
it is already on the wire, but `id` is a **mount prefix** (§2.1 requires it to equal the directory
|
||
core loads the module from) and `MODULE_API.md` §2.9 forbids a client inferring a route from a
|
||
capability. Letting a client gate on `id` makes the two the same value in practice, and the day one
|
||
builds `/<id>/servers` from it the separation that lets this module move its own pages is gone.
|
||
|
||
So `module.json` declares its own name as a sixth capability, and `server/test/entry.test.js`
|
||
asserts it **against `manifest.id`** rather than against the literal `"rust"` — the day the id
|
||
changes, the string a client gates on has to change with it. Module-Rust#5.
|
||
|
||
- **D17 — the app polls every 20 seconds while its screen is RESUMED.** D14's Page Visibility gate,
|
||
translated. `repeatOnLifecycle(RESUMED)` gives the same three behaviours from one line: nothing at
|
||
all while the app is away, an immediate refresh on return, and a pause behind a dialog. `STARTED`
|
||
was rejected — it keeps polling behind a partially obscured screen, which is precisely the reader
|
||
who is not reading.
|
||
|
||
- **D18 — the three Rust repositories move to `edge`** for the rest of the workstream, with releases
|
||
cut at the cutover rather than per phase. `edge` branches were created from `main` in Module-Rust,
|
||
Rust-Link and Rust-Plugins; `pr-checks.yml` in all three (and in Android-app) already triggers on
|
||
`[main, edge]`, so this costs no CI. `release.yml` still fires only on a push to `main`, which is
|
||
what makes the cutover the release.
|
||
|
||
- **D19 — the drawer row carries a live player count, and `NavPaths` learns `/rust`.** The second is
|
||
small and load-bearing: without it an admin's nav override on the module's own `Servers` row, or an
|
||
added link to `/rust`, hands off to a browser rather than opening the native screen.
|
||
|
||
### 18.1 D15 has no analogue on a phone, so it was translated
|
||
|
||
The footer slot works on the web because every public page renders the same footer (§17.4). The app
|
||
has no footer and no slot. What it has is one drawer row per surface and, since engagement Phase 8, a
|
||
precedent for a number beside one — the inbox's unread badge, in `NavigationDrawerItem`'s badge slot,
|
||
with a `contentDescription` so a screen reader says *"42 players online"* and not *"42"*.
|
||
|
||
The count rides there and keeps all three of the website version's rules: **zero renders nothing** (an
|
||
empty fleet is not a notification, and a badge reading `0` on a quiet evening is worse than none), a
|
||
failed read keeps the last number rather than dropping to zero, and it **never polls**. It is asked
|
||
for only where the module is installed, so a UO site makes no request at all.
|
||
|
||
**The number is players, not servers.** A badge is one integer, and of D15's two halves the live one
|
||
is how many people are on — a server count changes when an operator edits configuration, which is not
|
||
news, and is on the page the row opens anyway.
|
||
|
||
### 18.2 What the app had to grow: a refresh that is not a load
|
||
|
||
The app has had exactly one shape for a read since its first milestone — set `Loading`, ask, replace.
|
||
That is right for opening a screen and wrong for a poll, and it is the *same* wall this module hit one
|
||
tier along with core's `useAsync` (§17.3). A twenty-second refresh built on it would clear the
|
||
killfeed, render a spinner in its place and re-fill it, three times a minute, for ever.
|
||
|
||
`ui/Polling.kt` is `usePolled`'s other half, and it keeps the same rule: **a refresh is invisible when
|
||
it succeeds and keeps the rows when it fails.** Three cases, and the middle one is the whole point:
|
||
|
||
| Result | What the reader sees |
|
||
| --- | --- |
|
||
| It answered | New rows. Nothing else. |
|
||
| It failed, and there are rows | The same rows, and one quiet line saying the refresh failed. |
|
||
| It failed, and there is nothing yet | An ordinary error with a retry — a first load that failed. |
|
||
|
||
Only the **visible** live panel is polled. The website can afford to mount the one tab it is
|
||
rendering; the app's four tabs are one screen, so the refresh asks what the reader is actually looking
|
||
at. The leaderboard and the wipe list are never polled at all.
|
||
|
||
**Changing the question is not a poll.** Filter, sort and wipe blank their panel and load, because
|
||
what is on screen is an answer to something the reader has stopped asking — leaving it up would show
|
||
last wipe's killfeed under this wipe's heading.
|
||
|
||
### 18.3 What the walk proved, and how
|
||
|
||
The rig is phase 4's, unchanged: a core on `:3200` with this module installed, one live server
|
||
(`main`, a real sidecar and a real game host) and the seeded `demo` fixture that has never reported.
|
||
A second core on `:3100` serves `module-uo` and no Rust.
|
||
|
||
- **The criterion, first half.** With `demo` unreachable and reading *Offline*, the phone rendered its
|
||
map, size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, its last known presence
|
||
board and its wipe history. Nothing on the screen is a live call to a game host.
|
||
- **The criterion, second half.** The same app, switched to the UO core, showed Shard / Rules / Atlas
|
||
/ Leaderboards / Market and **no Rust row**.
|
||
- **`refreshInto` against a genuinely dead backend.** The core was stopped with the list on screen. A
|
||
poll tick later the rows were unchanged, under one line reading *"Could not refresh just now. This
|
||
is the last thing the site heard."* — no spinner, no error page, nothing blanked.
|
||
- **R12's arithmetic, on a phone.** All-time 59 = 41 + 18 across two wipes, and Drift — who appears
|
||
only in the current wipe — **drops out** of the August board rather than reading zero.
|
||
- **Every `describe` branch, from real rows**: a player kill with weapon, distance and grid; an NPC
|
||
kill with the prefab read as words; a suicide; an environment death (the fall that must not read as
|
||
a kill by nobody); chat with its colon in the join and a non-Global channel beside it; a disconnect
|
||
with reason and session length; and *while sleeping*.
|
||
- **The calendar-day rule.** Filtering to the August wipe produced three rows six weeks old, each
|
||
carrying its date — the §17.2 defect, not re-introduced in Kotlin.
|
||
- **The presence panel saying which it is.** The offline server's board rendered under *"The last
|
||
board this server sent. It is offline, so this is who was on then — not who is on now."*
|
||
- **The badge**, showing a live count on the drawer row.
|
||
|
||
### 18.4 The walk found three defects, and a green suite found none of them
|
||
|
||
1. **The drawer's live count resolved once per process.** It was keyed on the capability answer alone,
|
||
so it was read when the app connected and never again — which is not what *live* means on a row
|
||
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge and for
|
||
the same reason: coming back to the app is exactly when a stale number would be noticed. *Visible
|
||
only by backgrounding the app and returning to it.*
|
||
2. **Every card's text sat flush against its edge.** The app's themed `ShardCard` is a `Card` and
|
||
nothing more — it carries no padding, and each caller pads its own content. Four new call sites did
|
||
not, and on a phone the first glyph of each line read as clipped.
|
||
3. **A name touched its own kill count.** Five numeric columns beside an equal-weight name column left
|
||
*Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes — and the
|
||
**active sort moved to the header**, because the header is the control: tinting a column of numbers
|
||
says *these are special* where tinting the header says *this is what the table is ordered by*.
|
||
|
||
### 18.5 The rig note worth keeping
|
||
|
||
The app's debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost`
|
||
only** — not `10.0.2.2`. An emulator walk against a local core therefore needs
|
||
`adb reverse tcp:<port> tcp:<port>` and the loopback address. Typed as `10.0.2.2`, every request fails
|
||
with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, and the connect
|
||
screen reports *"Couldn't reach that site"* — correct, and indistinguishable from a core that is not
|
||
running.
|
||
|
||
Two smaller things: the first AVD tried had 95% of `/data` used and refused a 44 MB install with
|
||
*"Requested internal only, but not enough space"* — `pm trim-caches` freed nothing, and the second AVD
|
||
was the answer. And the app's own `pm clear` is the way to reach the first-run connect screen, because
|
||
an `install -r` over an earlier install keeps the stored base URL.
|
||
|
||
### 18.6 What is not proven here
|
||
|
||
- **A phone-width read of the website's own pages**, which §17.6 deliberately left open. This phase
|
||
gave the surface a second client rather than re-reading the first, and the pages have still not been
|
||
looked at in a narrow browser.
|
||
- **The badge's non-zero case on real traffic.** Nobody was playing on the rig, so the count was shown
|
||
by seeding a player count on the unreachable server and reading the badge before the 30-second
|
||
refresh zeroed it. The arithmetic and the rendering are proven; a fleet with people on it is not.
|
||
|
||
---
|
||
|
||
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
||
[rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins
|
||
[mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust
|
||
|
||
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit
|