9 Commits

Author SHA1 Message Date
54b4059091 Merge pull request 'docs(modules): phase 5 as built — Android leg A, and the capability a client had to be given' (#257) from docs/rust-phase-5 into main
Reviewed-on: #257
2026-09-17 09:21:44 +00:00
dfdb0a3f63 docs(modules): phase 5 as built — Android leg A, and the capability a client had to be given
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
2026-09-17 03:45:01 -05:00
2f24236993 Merge pull request 'docs(modules): phase 4 as built — the first pages, and the four defects a browser walk found' (#256) from docs/rust-phase-4 into main
Reviewed-on: #256
2026-09-17 02:42:51 +00:00
788100e048 docs(modules): phase 4 as built — the first pages, and the four defects a browser walk found
Records PLAN.md section 17 and marks the phase row done. Four decisions (D12-D15),
what is on the pages, and the four defects only a live walk could find — two of
them in code phase 3 had already shipped: an unreachable refresh that erased the
server's description, and a "last reported" line reading the timestamp of our own
poll rather than of the server's last frame.

Also written down: why core's useAsync cannot poll, the real per-page cost of a
live count in the footer slot, and the test fake that was *nearly* core.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 21:40:39 -05:00
a221d4409b Merge pull request 'docs(rust-link): protocol 2 — the read path, and phase 3 as built' (#255) from docs/rust-protocol-2 into main
Reviewed-on: #255
2026-09-16 16:36:42 +00:00
f80b9f95c8 docs(modules): the ingest walk, and the two-writer trap it walked into
The module half exercised against the live rig rather than only its own tests:
the working tree installed into a running core, pointed at the workstation
sidecar with a real Rust server behind it. The fresh-install path logged
`cursor started at the feed tail`, a console banid/unban arrived as two
rust_events rows carrying the wipe id, and the wipe row was created by being
mentioned rather than by any "a wipe started" call.

Then the boundary on real rows: with both ban events in the table, the public
events route answered {"events":[]} — and answered the same when asked for
player.banned BY NAME.

The trap is worth more than the proof. The state row read as online=1 with
hostname=NULL, a shape no code path writes. Two cores shared one database: a
second instance still held the phase 2 release in memory, spoke protocol 1, was
refused 409, and rewrote the row every thirty seconds. Nothing was wrong with
either. What made it look like a defect was two SELECTs in two round trips
assuming the state did not move between them — the same mistake the Pterodactyl
work recorded, and a single atomic read caught the coherent row alternating with
the other writer's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 08:52:48 -05:00
30e72adfcf docs(rust-link): protocol 2 — the read path, and phase 3 as built
The specification the other three repositories are held against, plus the phase
record.

`PROTOCOL.md` §8 is the new contract. Its centre is one field: every frame now
carries `type` — `event`, `snapshot`, `reply`, `control` — and the sidecar files
on that and nothing else. That is the dumb-forwarder property made structural
rather than intended: ten new event kinds are zero change in Rust-Link, and only
a version adding an indexed column touches it at all.

Also in §8: the fifteen-kind catalogue and what each frame carries; `wipeId`
derived by the plugin, which REVERSES §3.2's "deriving one is the website's job"
and says why; boards re-sent on connect and on a cadence; the aggregate rule (a
hook that can fire more than once a second per player is a counter, not an
event); the void rule that stops a read-path hook vetoing a death or a login; and
`GET /feed`, a cursor route separate from `/events` because one route with two
orderings serves the wrong one to every caller that forgets the parameter.

§8.5 is the part to read twice. The classification of a kind as public or staff
is NOT on the wire, deliberately: a boundary declared by the sender is one a
compromised or out-of-date game host can widen, so the module holds a
default-deny allowlist and this table is what its test holds it against.

§8.8 corrects a catalogue rather than a defect: PLAN.md §10 sources
`rust.login.denied` from `CanUserLogin`, and that hook fires on every attempt —
the only way to learn of a denial from it is to be the denier. A denial is the
absence of an approval, and protocol 2 emits both facts so phase 10 can pair them.

`PLAYER_WALK.md` is new, and it exists because half this catalogue cannot fire
without somebody holding a mouse. Ten steps, what each one should produce, and
what counts as a pass — written so the walk can be run without watching the
output live, and so the answer afterwards is readable as a transcript.

PLAN.md §16 is phase 3 as built: the four decisions, the two defects only a
server that BOOTED with the plugin could find (a wipe id that was null for every
real session, and a two-second main-thread stall on unload), what was proven and
how, and — stated plainly rather than implied — the three measurements still
queued on the org lead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 08:39:27 -05:00
ddf777fd8c Merge pull request 'docs(modules): phase 2 as built — packaging, release and the frozen manifest' (#254) from docs/rust-phase-2 into main
Reviewed-on: #254
2026-09-16 10:51:01 +00:00
5274d5a744 docs(modules): phase 2 as built — packaging, release and the frozen manifest
PLAN.md gains §15 and the phase-2 row is marked done.

What the phase found worth recording:

- The starting position was worse than the row implied: phase 1 built five
  guards and ran every one by hand, because the repo had no .gitea/workflows at
  all. So the org lead widened the phase to include the gate, not only the
  release (D4), and the frozen-manifest job shipped with it rather than waiting.
- That job answers a question §13 had to take on trust. Core mounts /status and
  /version at a tier root where the loader's collision probe cannot see them, so
  phase 1 could only check /rust by reading. Locally and in CI, against the same
  pinned core: 280 core routes, 6 added, all documented, none of core's moved.
- The bundle carries no node_modules (D5), and checkBundle.js asserts the
  emptiness rather than assuming it — the day a dependency appears is meant to
  be a loud day. Verified by breaking the include list.
- No Sonar for the three Rust repos until phase 19 (D6).
- The criterion is met. v0.1.0 published, downloaded, hashed independently
  (7296c76b… matches the manifest), and installed from its manifest URL through
  Admin → Modules into a core with the phase-1 directory and row torn down
  first. The volume received exactly the twenty files the include list names;
  the row reads started with a source and a sha where phase 1's had nulls; the
  public route answers 200, the player and admin tiers 401, and all five /rust
  paths merged into core's own spec.
- REGISTRY_TOKEN was already configured — the one prerequisite this work could
  not check for itself, since the bot can read no Actions secrets.
- One rig finding: Docker Desktop's Linux engine was found dead and answered
  every call with a bare 500, which presents as the website being broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 05:42:36 -05:00
7 changed files with 1252 additions and 18 deletions

View File

@@ -85,8 +85,9 @@ share a shape and nothing else, so neither document is a fallback for the other.
| Doc | What it covers |
|---|---|
| [PROTOCOL.md](rust-link/PROTOCOL.md) | **Canonical** — the game link and the website API, the four declaration sites of the wire version, and what protocol 1 defines |
| [PROTOCOL.md](rust-link/PROTOCOL.md) | **Canonical** — the game link and the website API, the four declaration sites of the wire version, and what each protocol version defines: 1 the transport, 2 the read path |
| [INTEGRATION.md](rust-link/INTEGRATION.md) | Standing the bridge up by hand, and which of the three components is wrong when it does not work |
| [PLAYER_WALK.md](rust-link/PLAYER_WALK.md) | The half of the read path a console cannot reach: ten minutes on a rig with a player, step by step, with what each hook should produce |
### `android/`
| Doc | What it covers |

View File

@@ -1307,6 +1307,128 @@ push, and Play (M6M8) follow the designed app.
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
participation history self-scoped, proved by two accounts rather than asserted.
15. **M14 — the Rust module in the app** (post-v1; built 2026-09-17). The platform's **second game
module** reached its first public pages in `module-rust` phase 4
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §17), and this is phase 5 — the app's leg.
R10 has each Android leg trail the website surface it consumes by exactly one phase, so every
route here existed and answered before a line of Kotlin was written.
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §17 for the surface
this mirrors and §18 for this phase as built. The contract is normative there; this entry records
what the app does about it.
**No backend work beyond one word.** The five public routes were live. The one change is
Module-Rust#5, which adds `rust` to the module's `capabilities` — see the gate below.
#### Why this is not the shard screens with a different name
The two games have genuinely different shapes, and collapsing them would have cost the app the
thing that makes each legible. **UO is one shard: a place**, five drawer rows, a live SSE stream.
**Rust is a fleet**: a list, and one page beneath it with four tabs. The app grows a second route
tree rather than a second meaning for `shard/`, and both can be installed on one backend — in
which case both trees exist at once and neither row appears on a site without its module.
| Screen | Route | Reads |
| --- | --- | --- |
| **Rust servers** (the list) | `rust` | `GET /public/rust/servers` |
| **One server** (four tabs) | `rust/servers/{serverId}` | `…/:id`, `…/:id/events`, `…/leaderboard`, `…/online`, `…/wipes` |
#### Four decisions, taken by the org lead on 2026-09-16
- **D16 — the gate is a new capability, `rust`.** `module-uo`'s five shard rows all hang on one
string, `shard`, because that is the only question a capability can answer: *is the module
there*. `module-rust` declared five and every one named a **surface** — `servers`, `killfeed`,
`leaderboard`, `presence`, `wipes`. Core flattens every started module's capabilities into a
single list, so gating on `servers` would let another module declaring that word silently reveal
these screens on a site that does not run Rust. Gating on the module **id** was considered and
rejected: `id` is a mount prefix (§2.1 requires it to equal the directory core loads from), and
`MODULE_API.md` §2.9 forbids a client inferring a route from a capability — making the two the
same thing would quietly end that separation. So the module declares its own name as a sixth
string, asserted in its suite against `module.json`'s own `id` so the two cannot drift.
- **D17 — poll every 20s while the screen is RESUMED**, the phone's version of D14's Page
Visibility gate. Immediate refresh on return to the foreground; nothing at all while away.
- **D18 — the Rust repositories move to `edge`** for the rest of the workstream, with releases at
the cutover rather than per phase. `pr-checks.yml` in all four repositories already triggers on
`[main, edge]`, so this costs no CI — the trap that made all nine M12 phase PRs land unchecked
was closed in engagement Phase 8.
- **D19 — the drawer row carries a live player count**, and NavPaths learns `/rust`.
#### A refresh is not a load, and the app had only ever done loads
The app has had exactly one shape for a read since M1: set `Loading`, ask, replace. That is right
for opening a screen and wrong for a poll — a twenty-second refresh built on it clears the
killfeed, renders a spinner in its place and re-fills it, three times a minute, for ever. **The
website hit the same wall one tier along**, which is why `module-rust` bundles its own `usePolled`
instead of using core's `useAsync` (§17.3). `ui/Polling.kt` is that hook's other half:
- `refreshInto` — **a refresh is invisible when it succeeds and keeps the rows when it fails.** A
failure with rows on screen keeps them and reports the failure beside them; a failure with
nothing on screen is an ordinary error with a retry, because there is nothing to protect.
- `PollWhileResumed` — `repeatOnLifecycle(RESUMED)`, which buys three behaviours from one line: no
requests at all while backgrounded, an immediate refresh on return, and a pause behind a dialog
or the recents switcher. `STARTED` would keep polling for a reader who is not reading.
Only the **visible** live panel is polled. The leaderboard and the wipe list never are: a
leaderboard that re-sorted itself under a finger every twenty seconds would be worse than a stale
one. Changing the filter, the sort or the wipe **is** a different question, so that panel blanks
and loads — leaving the old rows up would show last wipe's killfeed under this wipe's heading.
#### The drawer badge is D15 translated, not D15 copied
D15 put a live count in core's `site.footer.status` slot, which works because every page of the
website renders the same footer. The app has no footer and no slot. What it has is a drawer row
per surface and, since engagement Phase 8, a precedent for a number beside one — the inbox's
unread badge, in the `NavigationDrawerItem` badge slot, with a `contentDescription` so a screen
reader says "42 players online" rather than "42". The count rides there, and keeps the website
version's three rules: **zero renders nothing** (an empty fleet is not a notification), a failed
read keeps the last number, and it never polls. It is asked for only where the module is
installed, so a UO site makes no request at all.
#### Verified
The app suite (**644 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
against the phase-4 rig — a core with the module installed, one live server and one seeded fixture
that has never reported.
**Both halves of the phase criterion, directly.** With its server unreachable and reading
*Offline*, the page still rendered its map, size, seed, wipe date, killfeed, per-wipe and all-time
leaderboards, its last known presence board and its wipe history. The same app pointed at the UO
core showed Shard / Rules / Atlas / Leaderboards / Market and **no Rust row**.
Also proven rather than asserted: `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 quiet line);
R12's arithmetic on a phone (all-time 59 = 41 + 18, and a player who appears only in the older
wipe **drops out** of it rather than reading zero); every `describe` branch from real rows,
including the fall that must not read as a kill by nobody; the calendar-day rule, filtering to the
August wipe and getting three rows six weeks old, each unmistakably dated; and the badge.
#### The walk found three defects, and 644 green tests found none of them
- **The drawer's live count resolved once per process.** It was keyed on the capability answer
alone, so it was read at connect 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.
- **Every card's text sat flush against its edge.** `ShardCard` is the themed `Card` and carries
no padding of its own; each caller pads its own content, and these four did not. On a phone the
first glyph of each line read as clipped.
- **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 is marked on the header** rather than by tinting a column of numbers — the
header is the control, and tinting the values says *these are special* instead of *this is what
the table is ordered by*.
#### The rig note worth keeping
The 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`, which the
connect screen reports — correctly, and indistinguishably from a core that is not running.
- **Excluded**, in the same class as every earlier milestone's exclusions: the Rust **admin**
surface. Server configuration, the sidecar token and the connection test are admin
*configuration*, which the app consumes and does not edit. Identity and permissions are legs B
and C (phases 8 and 11), and the map is leg D.
### Deferred (not a milestone)
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is

View File

@@ -979,10 +979,10 @@ Each phase ends with its findings written down, as every workstream here does.
|---|---|---|---|
| 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.** `release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
| 3 | **The read path, on both frameworks.** 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.** Server list as the landing page, `/rust/servers/:id` beneath it, killfeed, leaderboard; nav rows; the UI kit (`PublicLayout` `shell`, `PageHeader` props); `capabilities`; the `site.footer.status` slot (R13) | Module-Rust | The site renders the last thing each server said while every server is off |
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
| 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 |
@@ -2004,6 +2004,733 @@ API. Anything driving the console has to read a log to know, which is the same c
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

View File

@@ -18,7 +18,7 @@ differ.
| [`DEFINITIONS.md`](DEFINITIONS.md) | **What things are called.** 678 items (short name, id, display name) and 2,590 workshop skin ids across 104 items. |
| [`OPERATING.md`](OPERATING.md) | **How it gets run.** The 6 operator pages — installing Oxide on a server, then installing, configuring and permissioning plugins. |
| [`agent/`](agent/README.md) | The same facts in **machine shape** — TSV and JSONL, ~46% of the tokens. Generated in the same pass, so it cannot drift. |
| [`CARBON.md`](CARBON.md) | **The other framework.** Where Carbon diverges from Oxide and nowhere else — file layout, the permission store, the `c.` commands, 30 Carbon-only hooks and 13 uMod names its catalogue omits. Sourced from Carbon's own metadata and source, **not yet proven on a live Carbon server.** |
| [`CARBON.md`](CARBON.md) | **The other framework.** Where Carbon diverges from Oxide and nowhere else — file layout, the permission store, the `c.` commands, 30 Carbon-only hooks and 13 uMod names its catalogue omits. Sourced from Carbon's own metadata and source, and **proven on a live Carbon 2.0.259.0 server** — R19 at phase 0, and the whole read path at phase 3. |
**The one file here that is ours:** [`PLAN.md`](PLAN.md) — the schedule and the decisions of record
for actually building `module-rust`. Everything else in this directory is copied from uMod; that one

View File

@@ -164,6 +164,17 @@ Two failures that look alike and are not:
says what was true when it was written; nothing has written it since. Either the poll is failing
(the website's log) or the sidecar stopped (its own).
### 3.0 `untyped_frames` on `/health` is not zero
**The plugin and the sidecar are on different protocol versions.** The game link has no handshake
to catch that at connect time (`PROTOCOL.md` §2), so it shows up here instead: the sidecar files a
frame by its `type`, a frame from the wrong version does not carry one it recognises, and it is
dropped and counted rather than guessed at.
The symptom without this counter is the confusing one — a game server plainly up, a sidecar plainly
healthy, and a website showing nothing. Check the plugin's `rg.link` (it prints its protocol) against
the sidecar's `/health` (which prints its own) and upgrade whichever is behind.
### 3.1 The failures that are supposed to happen
Three things look like breakage and are the design:
@@ -193,6 +204,29 @@ you get a warning naming both ids.
---
## 4.1 What the bridge sends, and how much of it is kept
From protocol 2 the plugin sends the read path: connects and disconnects, deaths, chat, gathering,
bans and reports, and the wipe. Two things about the volume are worth knowing before you size
anything.
**Gathering and NPC kills are counted, not forwarded.** `OnDispenserGather` fires on every swing at
a tree; sending one frame per swing would make the bridge the most expensive thing on the server. The
plugin keeps a per-player tally and flushes it once a minute as a single `player.tally` frame. So the
leaderboard is exact and the wire is quiet.
**The sidecar's history is bounded; the website's is not.** `[store].retain_days` (default 14) is
how long the sidecar keeps raw events. The permanent record — per-wipe totals that survive a wipe —
lives in the website's own tables, so shortening this loses recent detail and never loses a player's
history. Set it to `0` to keep everything, if the host's disk is yours to spend.
**Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and
stamps it on every frame, so a wipe splits the history rather than ending it. That is also why
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
`REMOVE_FILES`.
---
## 5. Upgrading
The four declaration sites in [`PROTOCOL.md` §2](PROTOCOL.md#2-versioning) must agree. In practice

100
rust-link/PLAYER_WALK.md Normal file
View File

@@ -0,0 +1,100 @@
# The player walk — proving the half of the read path a console cannot reach
Protocol 2's catalogue divides cleanly in two, and the line is not about importance: it is about
whether a hook can fire without somebody holding a mouse.
Everything in the first half was proven from a console and a REST client while phase 3 was built —
the boards, the wipe id, the envelope, bans, the server lifecycle. Everything below needs a **real
player on a real server**, because the hooks carry a `BasePlayer`, a `HitInfo` or a chat line, and
none of those three can be manufactured from a console without becoming a different test.
This document is the walk that closes it. It takes about ten minutes, it is the same on Oxide and on
Carbon, and it is written so that the answer is readable afterwards rather than watched live.
---
## Before you start
1. A rig running, with `RunicGateway.cs` loaded — `oxide.plugins` (or `c.plugins`) lists *Runic
Gateway*, and `rg.link` answers `connected=True`.
2. A sidecar the rig can reach, with its store **empty** — that is what makes the event list at the
end readable as a transcript of the walk and nothing else.
3. The sidecar's token to hand, for the reads at the bottom.
Run this once, before you join:
```
rg.hooks
```
Every player hook should read **silent**. That is the baseline: the point of the walk is to move
them, and starting from a run where some already fired proves less.
---
## The walk
Do these in order. The order matters only in two places, noted where it does.
| # | Do this | Fires | The frame should carry |
|---|---|---|---|
| 1 | **Join the server** | `CanUserLogin`, `OnUserApproved`, `OnPlayerConnected` | Three frames, in that order. The first two carry your **IP address** — check it is a real address and not the string `0`. `player.connected` carries your steam id and name |
| 2 | **Wake up / spawn in** (click Respawn if you are dead) | `OnPlayerRespawned` | `player.respawned`, steam id only. It does **not** fire if you simply wake from sleeping — that is the hook's own documented behaviour, so no frame here is a pass, not a failure |
| 3 | **Say something in chat**, then **say something in team chat** if you have a team | `OnPlayerChat` | Two `player.chat` frames, with `channel` reading `Global` and `Team`. The message must arrive whole — if it is truncated or the frame is missing, the flattener ate it |
| 4 | **Chop a tree for about twenty seconds**, then **mine a node** | `OnDispenserGather` | **Nothing immediately.** This is the aggregate: one `player.tally` frame within 60 seconds, carrying `gathered` with `wood` and `stones`, summed. Seeing a frame per swing would be the bug |
| 5 | **Kill an animal or a scientist** | `OnEntityDeath` | Again nothing immediately — `npcKills` on the next `player.tally`. No `player.death`: a chicken is not a killfeed entry |
| 6 | **Die to the environment** — fall damage is easiest | `OnPlayerDeath` | `player.death` with `attackerType: "environment"`, a `grid` like `H7`, and **no** `attackerId`. Check the grid against the map: a wrong sign in the row arithmetic mirrors the whole map, and only a human with the map open can see that |
| 7 | **Kill yourself**`kill` in the F1 console | `OnPlayerDeath` | `attackerType: "self"`, no `attackerId` |
| 8 | **If a second player is available**: kill each other once | `OnPlayerDeath` | `attackerType: "player"`, with `attackerId`, `attackerName`, a `weapon` shortname and a `distance` in metres. This is the killfeed's whole shape, and it is the one row phase 4's page is built from |
| 9 | **Build a foundation, then destroy it yourself** | `OnEntityDeath` | `entity.destroyed` with `ownerId` (yours), `prefab`, `grid` and `attackerId`. Decay must **not** produce one of these — only a player breaking it |
| 10 | **Disconnect** | `OnPlayerDisconnected` | `player.disconnected` with a `reason` and a **`sessionSec`** roughly equal to how long you were on. It also flushes your tally first, so any gathering since the last minute arrives immediately before it |
Two ordering notes: step 4 must come before step 10 by at least a minute if you want to see the
cadence flush rather than the disconnect flush, and step 1's three frames are the only place the
order between hooks is itself part of the answer.
---
## Reading the result
From the machine running the sidecar:
```bash
TOKEN=# [web].auth_token from sidecar.toml, or `--print-config`
BASE=http://127.0.0.1:8090
# The whole walk, oldest first, as a transcript.
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/feed?since=0&limit=500" \
| python -m json.tool
# Or one kind at a time.
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/events?kind=player.death&limit=20"
```
And from the game console:
```
rg.hooks
```
Every hook in the walk should now read **fired**, with a count. A hook still `silent` after the step
that should have fired it is the finding — and on Carbon it is the specific question
[`CARBON.md`](../modules/rust/CARBON.md) §6 asks, since Carbon's catalogue omits thirteen uMod names
and nobody has yet checked whether they are renames or holes.
---
## What counts as a pass
Not "frames arrived". Three things, and the third is the one worth slowing down for:
1. **Every hook in the table fired**, on both frameworks, from the same plugin file.
2. **Every frame carries the envelope**`type`, `serverId` and `wipeId` on all of them
([`PROTOCOL.md`](PROTOCOL.md) §8.1). A player frame without a `wipeId` cannot be attributed to a
wipe and its rollup is lost.
3. **The aggregates are aggregates.** `player.tally` is a delta since the last flush, so two minutes
of chopping is two frames that sum to the total, not two frames each carrying the total. Getting
this backwards makes every leaderboard roughly double, and it looks correct until somebody counts.
Anything that disagrees with the table is a finding about the game or the framework rather than a
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.

View File

@@ -50,7 +50,8 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer, declared in **four** places that must agree:
The wire version is a single integer**2** as of the read path (§8) — declared in **four** places
that must agree:
| Where | Repo |
|---|---|
@@ -77,7 +78,11 @@ Bump all four in the same change as the emitters, together with this document.
## 3. Protocol 1 — the transport
Everything phase 1 defines, and deliberately nothing more.
Everything phase 1 defines, and deliberately nothing more. It is still the floor every later version
stands on — the framing, the greeting, the heartbeat and the one correlated round trip are unchanged
— but **two things below were amended by protocol 2**: every frame now carries `type`, `serverId`
and `wipeId` (§8.1), and `server.hello` is a *board* rather than a one-off greeting (§8.3). Read §8
beside this section rather than after it.
### 3.1 Framing
@@ -128,7 +133,7 @@ independently of the game, so anything it needs up front has to be re-sent per c
| `t` | epoch milliseconds, stamped when the world was read |
| `serverId` | this server's stable identity across wipes and restarts, from the plugin's config. **Not derived from the hostname** — an operator renames a server for a season and the site must not lose its history for it |
| `bootId` | see §3.2.1 |
| `saveCreatedAt` | when the current save was created. **Raw material for a wipe id, not a wipe id** deriving one is the website's job and is not yet specified, and emitting a guess now would bake a wrong one into stored rows |
| `saveCreatedAt` | when the current save was created. Protocol 1 called this *raw material for a wipe id* and left deriving one to the website; **§8.2 reversed that** — the plugin derives `wipeId` from this value and stamps it on every frame |
Everything from `hostname` down is read from `ConVar.Server` and `BasePlayer.activePlayerList` on
the game's main thread. A field the game cannot answer is **absent**, never zero.
@@ -196,7 +201,8 @@ Every response carries `X-RustLink-Version`, including `/health` and including e
|---|---|---|
| `GET /health` | — | **Unauthenticated**, so monitoring can reach it |
| `GET /server` | the store | The last `server.hello`. **`204` when the game has never connected** |
| `GET /events?kind=&limit=` | the store | Newest first; `limit` clamped to 11000 |
| `GET /events?kind=&wipe=&limit=` | the store | Newest first; `limit` clamped to 11000. For a human |
| `GET /feed?since=&limit=` | the store | **Oldest first**, from a cursor. For a consumer that must not miss a row (§8.9) |
| `GET /status` | the plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply |
| `GET /ws` | broadcast | The live feed; sends `{"kind":"ws.hello","protocol":1}` on connect |
@@ -304,6 +310,7 @@ override the file.
| `[web].bind` | `RUSTLINK_WEB_BIND` | `127.0.0.1:8090` |
| `[web].auth_token` | `RUSTLINK_WEB_TOKEN` | *(generated on first start)* |
| `[store].path` | `RUSTLINK_DB_PATH` | `rust-link.db` |
| `[store].retain_days` | `RUSTLINK_RETAIN_DAYS` | `14` |
Two things about those are load-bearing:
@@ -323,15 +330,258 @@ writing the file and generating the token if they are missing — and prints it
## 7. What is deliberately not here yet
Protocol 1 is the transport. Every one of these arrives with the phase that needs it, and each is a
version bump:
Protocol 2 is the transport plus the read path. Every one of these arrives with the phase that needs
it, and each is a version bump:
- the read path — player events, kills, clans, presence
- identity and the in-game link code
- the permission mirror
- leases, budgets and the event actions
- the map image over the asset-bridge shape
- identity and the in-game link code (phase 6)
- the permission mirror (phase 7), and plugin configuration edited from the site (phase 7b)
- clans, for core's Team provider (phase 9)
- leases, budgets and the event actions (phases 12-13)
- the map image over the asset-bridge shape (phase 14)
The rule that governs all of them: **the sidecar is a dumb forwarder.** It defines no schema for a
frame's contents, so a version that adds fields to an event needs no change there — only one that
adds a new *indexed* column does.
adds a new *indexed* column does. §8.1 is what turns that from an intention into a property of the
code.
---
## 8. Protocol 2 — the read path
Protocol 1 proved a line could travel. Protocol 2 is what travels: presence, deaths, chat, gathering,
moderation and the wipe, on both mod frameworks from one plugin file.
It is the first version with a *catalogue*, and a catalogue is the thing that grows fastest. So the
shape below is chosen to make growth free everywhere except in the one place that must stay
deliberate — what the public is allowed to see.
### 8.1 Every frame says what it **is**, not only what it is about
Protocol 1 routed on `kind`, in a `match` the sidecar had to learn a new arm for on every addition.
Protocol 2 adds **`type`**, and the sidecar files by `type` alone:
| `type` | Persisted | Broadcast on `/ws` | Routed by `reqId` | Example |
|---|---|---|---|---|
| `event` | appended to the history | yes | no | `player.death` |
| `snapshot` | **replaces** the board of that `kind` | yes | no | `players.online` |
| `reply` | no | no | **yes** | `server.status` |
| `control` | no | no | no | `pong` |
**This is the dumb-forwarder property made structural.** A protocol version that adds ten event
kinds needs no change in the sidecar at all, because the sidecar never learns a kind — it learns
four verbs, and they are the complete set of things that can be done with a frame. Only a version
that adds a new *indexed column* touches it.
Every outbound frame therefore carries five fields before anything specific to it:
```json
{
"kind": "player.death",
"type": "event",
"t": 1789510452152,
"serverId": "main",
"wipeId": "w-20260915T195817Z"
}
```
- **`type` is required.** A frame without one is **dropped and counted**, and the sidecar says so
once per connection. It is not defaulted to `event`: guessing files a board as history, which is
invisible until somebody wonders why the presence board has four thousand rows. The game link has
no version handshake (§2), so this is the place a mismatched pair fails loudly instead of quietly.
- **`serverId` is on every frame**, not only in the server body (R8). A frame is stored beside
frames from five other servers and has to be able to say which one it came from on its own.
- **`wipeId` is on every frame** — see §8.2.
### 8.2 `wipeId` is derived by the **plugin**, and this amends §3.2
§3.2 called `saveCreatedAt` *"raw material for a wipe id, not a wipe id — deriving one is the
website's job"*. That is reversed here, deliberately, and the reason is that by protocol 2 there are
**three** components storing rows that need it:
```
w-yyyyMMddTHHmmssZ e.g. w-20260915T195817Z
```
It is `SaveRestore.SaveCreatedTime` in UTC, to the second — the same instant `saveCreatedAt` already
reports, in the id-shaped spelling `bootId` uses. The plugin stamps it because the plugin is the only
component that can *read* it; every other component would be re-deriving a value it was already told,
and two derivations of one fact eventually disagree about a boundary.
Three consequences worth stating rather than discovering:
- **A server that has never saved has no wipe**, so `wipeId` is **absent**, never `""` and never
`w-unknown`. Absent is a fact; an empty string is a row that will sort beside every other empty
string forever.
- **The id changes on `OnNewSave` and at no other time.** It is not the boot id: a restart re-reads
the same save and reports the same wipe, which is exactly what R12 needs to keep a player's
history across a restart while splitting it across a wipe.
- **A wipe boundary is a fact about the world, not about the bridge.** The plugin re-reads the value
on `OnNewSave` and caches it otherwise; nothing about a reconnect can change it.
### 8.3 Boards — current state, one producer, re-sent on connect
A board is chapter 4's word: *current state with exactly one producer, re-sent on every connect*.
Protocol 2 defines two.
| Board (`kind`) | Holds |
|---|---|
| `server.hello` | the server's own description — §3.2's body, now `type: "snapshot"` |
| `players.online` | who is connected right now: `steamId`, `name`, `connectedAt`, `sleeping` |
**Boards are re-emitted on connect and on a 60-second cadence thereafter.** The events carry the
story — `player.connected`, `player.disconnected` — and the board is the **reconciliation point**. A
missed event is corrected within a minute rather than persisting until the next restart, and the
acceptance criterion *"a restarted sidecar is fully populated within one connection"* is met by
construction rather than by hoping no event was in flight.
The cadence is cheap on purpose: a full board for a 100-slot server is a few kilobytes, and a server
with nobody on it emits an empty array, which is a different answer from having said nothing.
### 8.4 The catalogue
Every kind protocol 2 defines, and the hook behind it. **`class` is not a field on the wire** — see
§8.5 — it is what this table binds the module's allowlist to.
| `kind` | Hook | `class` | Carries |
|---|---|---|---|
| `player.connected` | `OnPlayerConnected` | public | steamId, name |
| `player.disconnected` | `OnPlayerDisconnected` | public | steamId, name, reason, sessionSec |
| `player.respawned` | `OnPlayerRespawned` | public | steamId |
| `player.death` | `OnPlayerDeath` | public | victim, attacker, attackerType, weapon, distance, grid |
| `player.chat` | `OnPlayerChat` | public | steamId, name, channel, message |
| `player.tally` | *aggregate* — see §8.6 | public | steamId, gathered{}, npcKills, structures |
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
| `player.login.attempt` | `CanUserLogin` *(observed, never answered)* | **staff** | id, name, **ip** |
| `player.approved` | `OnUserApproved` | **staff** | id, name, **ip** |
| `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced |
| `server.initialized` | `OnServerInitialized` | public | — |
| `server.shutdown` | `OnServerShutdown` | public | — |
`grid` is the Rust map reference (`H7`), not a coordinate. A death's grid is where a fight happened
and every community site shows it; a **structure's** grid is where somebody lives, which is why
`entity.destroyed` is staff-class here and why R9 makes the same distinction for map layers.
**Three hooks are deliberately not in this wave, and none of them is an oversight:** `OnEntityTakeDamage`
and `OnFrame`/`OnTick` fire at a rate that makes a bridge a performance regression, and nothing in
phases 319 needs per-hit or per-frame fidelity. R17's warning about chatty zone transitions is the
same rule: **subscribe selectively; the cost of a hook is paid on the game's main thread.**
### 8.5 The class is enforced by the **module**, not declared on the wire
The wire carries no visibility field, and this is a security decision rather than an economy.
**A boundary must be enforced by the side that serves, never declared by the side that sends.** The
website's own shard fan-out works this way — a public SSE stream with an allowlist of event kinds,
and an admin stream that adds the rest — and the property that makes it trustworthy is that a
compromised or merely out-of-date sender cannot widen it. A `"class":"public"` field on the frame
would move the decision to the game host.
So: the table in §8.4 is the specification, `module-rust` holds the allowlist, and it is
**default-deny** — a kind the allowlist has never heard of is not public. The module's own test holds
its allowlist against this document, so adding a kind here without classifying it there fails a
build rather than shipping an IP address to a public page.
`player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and
`player.reported` carries the text of one player's complaint about another. They are stored because
an operator chasing ban evasion needs them and because the sidecar persists what it is told; they
reach no tier below admin, and the raw window that holds them is bounded (§8.9).
### 8.6 Two things are aggregated in the plugin, and that is the interesting part of this phase
`OnDispenserGather` fires on **every swing at a tree**. A single player chopping for a minute is
hundreds of hooks; ten players gathering is a frame rate problem in the bridge rather than in the
game. The same is true of animal and scientist kills, at a lower rate.
Neither is interesting per occurrence — nobody wants a killfeed of chickens — and both are wanted
*in total*, for the leaderboard. So the plugin keeps a per-player tally on the main thread and flushes
it as one `player.tally` frame:
- on a **60-second cadence**, for players with a non-zero tally;
- on **disconnect**, so a session's last minute is not lost;
- at `OnServerShutdown`, which is the flush that covers a restart.
A plugin *reload* is the one case that loses a tally, by choice: `Unload` runs on the game's main
thread, and draining the outbound queue there means waiting on a socket from the main thread — the
stall phase 1 removed. Under a minute of one player's gathering is the price, and a wedged peer
would make the cure worse than the disease.
A tally frame is a **delta, not a running total** — it reports what happened since the last flush,
so the consumer sums rather than diffs and a missed frame costs that interval instead of corrupting
the series.
This is the general rule for every later wave: **if a hook can fire more than once a second per
player, it is a counter, not an event.**
### 8.7 The read path never vetoes, and it is structural rather than disciplined
Four hooks in §8.4 are documented by uMod as *"returning a non-null value overrides default
behavior"* — `OnPlayerDeath`, `OnDispenserGather` and `CanUserLogin` among them. A read-path bridge
that returned something by accident would cancel a death, swallow a player's wood, or refuse a
login, and it would do it on a production server at 3am.
**So every vetoable hook in the read path is declared `void`.** Both frameworks bind hooks by name
and arity and take the method's return value; a `void` method returns nothing and therefore cannot
override anything. The rule is enforced by the signature rather than by remembering to write
`return null`, which is the only version of this rule that survives a year of edits.
`CanUserLogin` is in the wave for what it *observes*, never for what it answers.
### 8.8 A login denial is not a hook — and §10 of `PLAN.md` says it is
`PLAN.md` §10 sources the `rust.login.denied` trigger from `CanUserLogin`. Reading the hook says that
cannot work: `CanUserLogin` is called on **every** connection attempt, and the only way to learn of a
denial from it is to *be* the denier, which §8.7 forbids. uMod publishes no `OnUserRejected`.
What the game can actually tell us is two facts — an attempt, and an approval — so protocol 2 emits
both and **a denial is the absence of an approval** for an attempt, decided by a deferred read rather
than by a hook. Phase 10 owns that pairing; protocol 2 owes it the two frames and the `t` on each.
Recorded here because it is a correction to a catalogue, not a defect: the trigger survives, its
source changes.
### 8.9 History, cursors and retention
Three changes on the sidecar's own side follow from a catalogue that actually produces volume.
**`events` gains `server_id` and `wipe_id` as indexed columns.** This is the one migration shape the
store's own header predicted: *"only a version that adds a new indexed column ever needs a
migration"*. It is applied as an `ALTER` guarded by a column check, never as an edit to the `CREATE`
— the same rule the website's schema fragments live under, for the same reason.
**A new route, `GET /feed?since=&limit=`, is the ingest cursor**, and it is deliberately *not*
`/events` with a flag:
| Route | Order | For |
|---|---|---|
| `GET /events?kind=&wipe=&limit=` | newest first | a human, an admin screen, a point-in-time look |
| `GET /feed?since=&limit=` | **oldest first**, from a cursor | a consumer that must not miss a row |
One route with two orderings depending on a query parameter is a trap: every caller that forgets the
parameter gets the other one silently, and for the ingesting caller that means it advances its cursor
past rows it never read. Two routes, one ordering each.
`/feed` items are wrapped rather than bare, because a cursor needs the row's identity:
```json
{ "items": [ { "id": 1041, "t": 1789, "kind": "player.death", "frame": { } } ],
"lastId": 1041, "more": false }
```
`more` is `true` when the page filled, so a consumer that has fallen an hour behind drains at its own
pace instead of guessing from a count.
**Omitting `since` asks where the end is** — no rows, and the current `lastId`. `since=0` is the
other question entirely: replay everything retained. That is deliberate, because the two intentions
must not be separated by whether somebody typed a parameter: a module installed today against a
month-old sidecar wants what happens next, not a fortnight of deaths it has no rollups for.
**The store prunes.** `[store].retain_days` (default 14) bounds the event history, swept hourly.
Three things make that safe rather than lossy: the website holds the permanent per-wipe rollups
(R12), boards are never pruned because they hold exactly one row per kind, and the sidecar's database
lives inside a game container whose disk is the operator's (R20). A store that grows without bound on
a game host is a wipe-day outage waiting for a busy month.
---