Compare commits
11 Commits
069e715b1b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 54b4059091 | |||
| dfdb0a3f63 | |||
| 2f24236993 | |||
| 788100e048 | |||
| a221d4409b | |||
| f80b9f95c8 | |||
| 30e72adfcf | |||
| ddf777fd8c | |||
| 5274d5a744 | |||
| 3c8b430fae | |||
| 22159ec78f |
@@ -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 |
|
||||
|
||||
122
android/PLAN.md
122
android/PLAN.md
@@ -1307,6 +1307,128 @@ push, and Play (M6–M8) 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
|
||||
|
||||
@@ -16,10 +16,10 @@ code has to care. Everything not listed here is identical by construction.
|
||||
> and wins any disagreement, exactly as uMod does for [`OXIDE_API.md`](OXIDE_API.md). Nothing here
|
||||
> is a Runic Gateway contract.
|
||||
>
|
||||
> **Not yet proven on a live Carbon server.** Every claim here is read off metadata or source. This
|
||||
> project's own record on that is poor — phases 0 and 1 each found source-read claims a running
|
||||
> server contradicted — so treat the whole document as *the hypothesis phase 3 tests*, not as
|
||||
> established fact.
|
||||
> **Verified on a live Carbon server on 2026-09-15** — Carbon **2.0.259.0** `[2026.09.03.0]` on
|
||||
> Linux, the `rust-carbon` rig (PLAN.md §14.5). Three of the four load-bearing claims held. **One was
|
||||
> wrong, and it was wrong about Oxide as well as Carbon** — see §4. Corrected in place; §10 is the
|
||||
> scorecard.
|
||||
|
||||
---
|
||||
|
||||
@@ -57,6 +57,8 @@ compiler simply evaluates the unknown symbol as false.
|
||||
| Symbol | Meaning |
|
||||
|---|---|
|
||||
| `CARBON` | The framework is Carbon |
|
||||
| `RUST` | The game is Rust |
|
||||
| `OXIDE_PUBLICIZED` | Compiled against publicised Oxide assemblies |
|
||||
| `WIN`, `UNIX` | Host operating system |
|
||||
| `STAGING`, `AUX01`, `AUX02` | Rust branch |
|
||||
| `RUST_ABV_<v>`, `RUST_BLW_<v>`, `RUST_IS_<v>` | Rust protocol above / below / exactly `<v>` |
|
||||
@@ -65,6 +67,11 @@ compiler simply evaluates the unknown symbol as false.
|
||||
This works because the bridge plugin ships as **source** and is compiled by whichever framework
|
||||
loaded it. It would not work for a precompiled DLL — a reason, among others, not to ship one.
|
||||
|
||||
**Confirmed on the live rig**: `carbon/config.json` reports
|
||||
`"ConditionalCompilationSymbols": ["CARBON", "RUST", "OXIDE_PUBLICIZED"]`, and the list is an
|
||||
operator-editable setting (`c.addconditional` adds to it), so treat the three above as the ones
|
||||
present by default rather than the ones guaranteed.
|
||||
|
||||
### At run time
|
||||
|
||||
`#if` is decided when the file is compiled, which is what we want for API differences. Where a
|
||||
@@ -116,7 +123,7 @@ the two roots are obtained.
|
||||
|
||||
---
|
||||
|
||||
## 4. Permissions — same API, different persistence
|
||||
## 4. Permissions — same API, same format, different directory
|
||||
|
||||
Every member R2 depends on exists with the same name and the same argument shape
|
||||
(`Carbon.Common/src/Oxide/Libraries/Permissions.cs`): `RegisterPermission`, `PermissionExists`,
|
||||
@@ -127,6 +134,9 @@ Every member R2 depends on exists with the same name and the same argument shape
|
||||
|
||||
Two differences, and they pull in opposite directions.
|
||||
|
||||
**All of the member names above were confirmed present on the live Carbon rig**, which loaded and ran
|
||||
our plugin against them unchanged.
|
||||
|
||||
**The return type differs, and the portable answer is the one we already chose.** Carbon's
|
||||
`GrantUserPermission` returns `bool`; Oxide's returns `void` — which is
|
||||
[§12.2](PLAN.md#122-four-rules-the-r2-permission-push-must-obey)'s finding, that a grant naming an
|
||||
@@ -138,14 +148,43 @@ is the framework that *would* have told us, and we still cannot listen.
|
||||
Carbon's signature also takes `BaseHookable` where Oxide's takes `Plugin`. Passing `this` is
|
||||
correct on both; a variable typed `Plugin` is not.
|
||||
|
||||
**The store is not a file we can read.** Oxide persists to JSON — `oxide/data/oxide.users.data` and
|
||||
`oxide.groups.data`. Carbon persists to **Protobuf or SQLite**, switchable at run time
|
||||
(`c.migrate_perms_proto`, `c.migrate_perms_sql`, with the SQLite path itself relocatable via
|
||||
`-carbon.sqlpermsdb`, default `server/identity/carbon.perms.db`); `Oxide Overrides/PermissionSql.cs`
|
||||
and `PermissionStoreless.cs` are the pluggable backends.
|
||||
### The store — **this section was wrong, and the truth is worse**
|
||||
|
||||
R2 never planned to read the store file, so this changes nothing — but it **closes the option
|
||||
permanently**, which is worth stating once. Drift detection reads the API, or it does not work.
|
||||
> **Corrected 2026-09-15 against both live rigs.** This document previously said *"Oxide persists to
|
||||
> JSON; Carbon persists to Protobuf or SQLite"*, and offered that difference as the reason not to read
|
||||
> the file. **Both halves were wrong.** The real shape is more dangerous than the one that was
|
||||
> imagined, which is the only reason it is worth the space.
|
||||
|
||||
Read off the two running servers, byte for byte:
|
||||
|
||||
| | Oxide rig | Carbon rig |
|
||||
|---|---|---|
|
||||
| Path | `oxide/data/oxide.users.data`, `oxide.groups.data` | `carbon/data/oxide.users.data`, `oxide.groups.data` |
|
||||
| First bytes | `0a 16 0a 07 64 65 66 61 75 6c 74 …` | `0a 17 0a 07 64 65 66 61 75 6c 74 …` |
|
||||
| Format | **Protobuf** | **Protobuf** |
|
||||
| Default groups | `default`, `admin` | `default`, `admin`, **`moderator`** |
|
||||
|
||||
**Neither framework writes JSON, and Carbon writes Carbon's data into files named after Oxide.** So
|
||||
the trap is not "two formats you must tell apart". It is:
|
||||
|
||||
1. **The filename is identical and tells you nothing**, so a reader keyed on `oxide.users.data`
|
||||
silently follows the wrong framework's file if it ever guesses the directory wrong.
|
||||
2. **The format is an undocumented binary**, not the JSON the name and the `.data` extension suggest.
|
||||
3. **Carbon can change it out from under you at run time** and Oxide cannot. `PermissionSerialization`
|
||||
in `carbon/config.json` defaults to `0` (the Protobuf above); `c.migrate_perms_sql` moves the whole
|
||||
store to SQLite at `server/identity/carbon.perms.db`, itself relocatable via `-carbon.sqlpermsdb`.
|
||||
`Oxide Overrides/PermissionSql.cs` and `PermissionStoreless.cs` are those backends.
|
||||
|
||||
**R2's conclusion is unchanged and the argument for it is now much stronger.** A file reader would
|
||||
have *worked* on both rigs today — same format, same names — and would break for the one operator
|
||||
who ran a migrate command, with no error and no version marker to notice. **Drift detection reads the
|
||||
API, or it does not work.**
|
||||
|
||||
**One more thing R2 has to accommodate: Carbon creates a third default group.** `carbon/config.json`
|
||||
names `PlayerDefaultGroup`, `AdminDefaultGroup` and `ModeratorDefaultGroup`, all auto-granted by auth
|
||||
level (`AutoGrantPlayerGroup` / `AutoGrantAdminGroup` / `AutoGrantModeratorGroup`, all `true`). A
|
||||
site that pushes its *full* group set on connect must not treat `moderator` as drift to be reported,
|
||||
nor delete it — the framework will simply recreate it, and the site will report drift for ever.
|
||||
|
||||
**Carbon does give R2 something Oxide's docs do not advertise: fourteen permission hooks**, a
|
||||
`Permissions` category of its own — `OnUserPermissionGranted`, `OnUserPermissionRevoked`,
|
||||
@@ -171,7 +210,17 @@ Carbon's 129 published commands are `c.`-prefixed. The ones with Oxide counterpa
|
||||
| `oxide.plugins` | `c.plugins` |
|
||||
|
||||
Carbon can be configured to alias the old prefix, so an operator's muscle memory survives — but an
|
||||
alias is opt-in and **we must never depend on one**.
|
||||
alias is opt-in and **we must never depend on one**. **Confirmed on the live rig:** `c.version`,
|
||||
`c.plugins`, `c.grant` and `c.group` all answered; **`oxide.plugins` produced no output at all**. Note
|
||||
the shape of that failure — Pterodactyl's `command` endpoint returns `204` either way, and Carbon
|
||||
prints nothing for an unknown command, so *a wrong prefix looks exactly like a command that worked.*
|
||||
|
||||
`c.plugins` is also worth knowing about for a reason unrelated to permissions: **it reports per-plugin
|
||||
`hook fires`, `hook time`, `hook memory`, `hook lag` and `hook exceptions`**, which is most of the
|
||||
"log which of its expected hooks have fired at least once" mechanism [`PLAN.md`](PLAN.md) §6 requires
|
||||
— for free, and only on Carbon. Useful when debugging *on* Carbon; **not a substitute for the
|
||||
plugin's own counter**, which has to work on both. Our plugin appears there as
|
||||
`Runic Gateway RunicGateway v0.1.0 … 2367ms [1077ms]`, under `Scripts`, with `failed plugins (0)`.
|
||||
|
||||
**Where this reaches us is narrow but real.** R2 and R18 both act through the plugin API, not the
|
||||
console, so neither cares. The two that do care are **documentation** — every operator-facing
|
||||
@@ -305,3 +354,25 @@ framework, and that is a deliberate decision rather than an oversight.
|
||||
| Everything else | Unchanged |
|
||||
|
||||
The honest summary: **Carbon costs three amendments and one extra rig, not a second codebase.**
|
||||
|
||||
---
|
||||
|
||||
## 10. Scorecard — what the live rig confirmed and what it corrected
|
||||
|
||||
Run 2026-09-15 against `rust-carbon` (Carbon **2.0.259.0** `[2026.09.03.0]` `21063e8`, Linux,
|
||||
`production_build`, Rust 103/2633.288.1), with the Oxide rig alongside for comparison.
|
||||
|
||||
| Claim | Verdict | Evidence |
|
||||
|---|---|---|
|
||||
| An `Oxide.Plugins` / `RustPlugin` source file loads unchanged | **CONFIRMED** | The byte-identical `RunicGateway.cs` that runs on the Oxide rig loaded as `Runic Gateway v0.1.0` in `2367ms`, printed the same startup line, and retried the absent sidecar the same way |
|
||||
| The framework root is `carbon/`, config dir is `configs` (plural) | **CONFIRMED** | `/carbon/{configs,data,lang,logs,plugins,extensions,modules,managed,native,modifiers,temp,tools}`; **no `/oxide` directory at all** |
|
||||
| `Interface.Oxide.ConfigDirectory` resolves there | **CONFIRMED, indirectly and decisively** | The plugin's own config was written to **`/carbon/configs/RunicGateway.json`** by the same code that writes `/oxide/config/RunicGateway.json` on the Oxide rig. A literal path in R18 would not have found it |
|
||||
| Console prefix is `c.`, `oxide.` is not aliased | **CONFIRMED** | `c.version` / `c.plugins` / `c.grant` / `c.group` answered; `oxide.plugins` produced nothing |
|
||||
| `#if CARBON` is defined | **CONFIRMED** | `carbon/config.json` → `ConditionalCompilationSymbols: ["CARBON", "RUST", "OXIDE_PUBLICIZED"]` — and two symbols this document had not known about |
|
||||
| Carbon self-updates | **CONFIRMED** | `SelfUpdating.Enabled: true`, plus the egg refetching `production_build` every boot |
|
||||
| *"Oxide stores JSON, Carbon stores Protobuf or SQLite"* | **WRONG — see §4** | **Both** store Protobuf, under **identical filenames**, differing only in directory. The refutation strengthens R2 rather than weakening it |
|
||||
| The 13 uMod hook names missing from Carbon's catalogue | **NOT YET TESTED** | None is in a phase; the plugin's own fired-hook log is the standing answer either way |
|
||||
|
||||
**Two things this document did not know to claim**, both found by looking rather than reading:
|
||||
Carbon ships a **third default group** (`moderator`) that R2's push must tolerate, and `c.plugins`
|
||||
exposes per-plugin hook telemetry Oxide has no equivalent for.
|
||||
|
||||
@@ -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 |
|
||||
@@ -1763,15 +1763,15 @@ including one finding that changes R20's shape.
|
||||
| | |
|
||||
|---|---|
|
||||
| Panel | `http://192.168.0.12` (no TLS — `https://` fails outright), node 1 `Main` |
|
||||
| Server | `rust-oxide`, id **17**, identifier **`e6758c06`** |
|
||||
| Egg | 18 `Rust Autowipe`, `ghcr.io/pterodactyl/games:rust` |
|
||||
| `FRAMEWORK` | `oxide` |
|
||||
| Servers | `rust-oxide` id **17** / **`e6758c06`**, and `rust-carbon` id **18** / **`87fb1f67`** (§14.5) |
|
||||
| Egg | 18 `Rust Autowipe`, `ghcr.io/pterodactyl/games:rust` — both rigs, one egg |
|
||||
| `FRAMEWORK` | `oxide` / `carbon` |
|
||||
| Limits | 8192 MB memory, 25600 MB disk — deliberately under half the node, so the Carbon rig fits beside it |
|
||||
| Allocations | 21000 game (default), 21001 query, 21002 RCON, 21003 Rust+, **21004 held for the sidecar's `[web].bind`** |
|
||||
| Allocations | oxide 21000-21004, carbon 21005-21009 — game, query, RCON, Rust+, **and one held for the sidecar's `[web].bind`** |
|
||||
| World | procedural, size 3000, seed 1234 |
|
||||
| SFTP | `192.168.0.12:2022` |
|
||||
|
||||
The RCON password is a generated 24-byte token rather than the old rig's `letmein`, kept out of this
|
||||
The RCON passwords are generated 24-byte tokens rather than the old rig's `letmein`, kept out of this
|
||||
document and out of the repo. §6 named that shape as the thing nothing published should copy; this is
|
||||
the first rig where it was not copied.
|
||||
|
||||
@@ -1943,6 +1943,794 @@ string is composed so that whatever the entrypoint prepends still lands on `Rust
|
||||
is more work and survives upstream changing its entrypoint; the second is free and depends on a line
|
||||
in somebody else's repository. Raised rather than settled.
|
||||
|
||||
|
||||
### 14.5 The Carbon rig, and R19 proven
|
||||
|
||||
`rust-carbon` — id **18**, identifier **`87fb1f67`**, same egg, same world (procedural, 3000, seed
|
||||
1234), same limits, allocations **21005-21009 with 21009 held for the sidecar**, `FRAMEWORK=carbon`.
|
||||
**A clean install, never a converted one**, per R21: install 122s, boot 543s, running.
|
||||
|
||||
**R19 is proven.** The byte-identical `RunicGateway.cs` that runs on the Oxide rig — no `#if CARBON`
|
||||
anywhere in it, nothing conditional at all — loaded on Carbon **2.0.259.0** and behaved the same:
|
||||
|
||||
```
|
||||
[INFO] Carbon 2.0.259.0 [2026.09.03.0] 21063e8 on Linux
|
||||
[INFO] [Runic Gateway] protocol 1, serverId 'main', sidecar 127.0.0.1:7799
|
||||
[INFO] Loaded plugin Runic Gateway v0.1.0 by RunicGateway [2367ms]
|
||||
[INFO] [Runic Gateway] cannot reach the sidecar: Connection refused - retrying quietly [RunicGateway Link|26]
|
||||
```
|
||||
|
||||
That last line is the no-stall contract holding on its **third** platform now — Windows/Mono,
|
||||
Linux/Oxide, Linux/Carbon — from one source file. [`CARBON.md`](CARBON.md) §10 is the full scorecard;
|
||||
the parts that change decisions are below.
|
||||
|
||||
**R18's amendment is confirmed the best way it could have been.** The plugin's own config, written by
|
||||
the same Oxide-compat API on both rigs, landed at `/oxide/config/RunicGateway.json` on one and
|
||||
**`/carbon/configs/RunicGateway.json`** on the other. D3 put the plugin's config inside R18's editor;
|
||||
had that editor used a literal `oxide/config/`, **it would not have found its own plugin's config on
|
||||
half of all installs.** No test would have caught it; only two rigs would.
|
||||
|
||||
**And one claim was refuted — the one with the sharpest consequence.** `CARBON.md` had said Oxide
|
||||
stores permissions as JSON and Carbon as Protobuf or SQLite, offering the difference as the reason
|
||||
not to read the file. Both rigs say otherwise:
|
||||
|
||||
| | Oxide rig | Carbon rig |
|
||||
|---|---|---|
|
||||
| Path | `oxide/data/oxide.users.data` | `carbon/data/`**`oxide.users.data`** |
|
||||
| First bytes | `0a 16 0a 07 64 65 66 61 75 6c 74` | `0a 17 0a 07 64 65 66 61 75 6c 74` |
|
||||
| Format | Protobuf | Protobuf |
|
||||
| Default groups | `default`, `admin` | `default`, `admin`, **`moderator`** |
|
||||
|
||||
**Same binary format, same filenames, different directory** — and Carbon writes *its* data into files
|
||||
named after Oxide. **This makes R2's API-only rule more important, not less.** A file reader would
|
||||
have worked on both rigs today and broken silently for the one operator who ran `c.migrate_perms_sql`
|
||||
— no error, no version marker, just a site reporting drift against a store nobody is writing any
|
||||
more. The rule survives; the reasoning behind it was wrong and is now right.
|
||||
|
||||
Two things nobody had thought to claim, found by looking:
|
||||
|
||||
- **Carbon auto-creates a third default group, `moderator`**, auto-granted by auth level alongside
|
||||
`default` and `admin`. R2 pushes its *full* set on connect, so it has to tolerate a group the
|
||||
framework will recreate the moment it is deleted — otherwise the site reports drift for ever.
|
||||
- **`c.plugins` reports per-plugin `hook fires`, `hook time`, `hook memory`, `hook lag` and
|
||||
`hook exceptions`** — most of §6's "log which expected hooks have fired" mechanism, free, and only
|
||||
on Carbon. Useful when debugging on Carbon; **not a substitute** for the plugin's own counter, which
|
||||
must work on both.
|
||||
|
||||
**A warning about how a wrong console command fails here.** Pterodactyl's `command` endpoint returns
|
||||
`204` whether or not anything happened, and Carbon prints nothing for an unknown command. So
|
||||
`oxide.plugins` on Carbon — which is simply not a command — is indistinguishable from success at the
|
||||
API. Anything driving the console has to read a log to know, which is the same conclusion §14.2c
|
||||
reached about `oxide.reload` and the same shape R18's rollback needs.
|
||||
|
||||
|
||||
## 15. Phase 2 as built — packaging and release, 2026-09-16
|
||||
|
||||
The first phase with no game server in it, and the one that turns a directory somebody copied into a
|
||||
thing an operator can install. [`Module-Rust#2`][mr] is the whole of it.
|
||||
|
||||
**The starting position was worse than the phase row implied.** Phase 1 built five guards and ran
|
||||
every one of them by hand: the repository had no `.gitea/workflows/` at all. So nothing gated the
|
||||
branch that gets released, and there was no way to release it. This phase adds both halves, and the
|
||||
org lead widened it to include the gate rather than only the release.
|
||||
|
||||
### 15.0 The three decisions this phase needed
|
||||
|
||||
- **D4 — the full CI suite, not only `release.yml`.** The phase row names the release; the repo had
|
||||
no gate at all. Both ship, and the frozen-manifest job ships with them rather than waiting for a
|
||||
later phase — see §15.2 for what it found on its first run.
|
||||
- **D5 — the bundle carries no `node_modules`, and the emptiness is asserted rather than assumed.**
|
||||
The shipped half declares no runtime dependencies: everything it needs arrives on `ctx`. So the
|
||||
release runs no `npm ci` and packs no dependency tree. The whole value of that decision is that
|
||||
*the day it stops being true is a loud day*, so `checkBundle.js` fails the PR that adds a
|
||||
`dependencies` entry without also teaching the release to install and pack it. Module-uo ships
|
||||
`ws` and does the opposite; this is a different answer to the same question, not a divergence
|
||||
from a rule.
|
||||
- **D6 — no SonarQube for the three Rust repos yet.** Phase 2 is packaging; scanning is quality
|
||||
tooling and phase 19 is already the sweep where `CLAUDE.md`'s project-key table gets updated.
|
||||
Three half-populated projects while the repos are skeletons buy nothing.
|
||||
|
||||
### 15.1 What is in a release, and what decides its number
|
||||
|
||||
A release is **not source**. It is the directory core's loader expects at `modules/rust/`, already
|
||||
assembled — the prebuilt client chunk, the schema fragment and the OpenAPI fragment, packed as they
|
||||
will be unpacked — because an operator never builds anything (`MODULE_SYSTEM.md` §1.14).
|
||||
|
||||
| Asset | What it is |
|
||||
|---|---|
|
||||
| `module-rust-<version>.tar.gz` | the assembled directory, one top-level entry, no `node_modules` |
|
||||
| `module-rust-<version>.json` | the install manifest: id, name, version, `coreApi`, url, size, **`sha256`** |
|
||||
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
|
||||
|
||||
The version is **derived**, using the engine `link`, `installer` and `Module-uo` already run:
|
||||
conventional-commit subjects since the newest `v*` tag decide major/minor/patch, nothing releasable
|
||||
cuts no release, and `module.json`'s version survives as a **floor** rather than as a record. The
|
||||
number that ships is the **tag**, and CI stamps it into the bundle's own `module.json` at assembly
|
||||
time — which is why the release also asserts that the assembled `module.json` carries the version
|
||||
being released, since a bundle that still declared the floor would install under a number that is
|
||||
not the one it came from.
|
||||
|
||||
Module-uo is the argument for deriving rather than declaring: it released only on a hand-edited
|
||||
version line, and between 2026-08-12 and 2026-08-19 that cost it *every* bundle, because nine phases
|
||||
of work landed without anyone touching that line. `workflow_dispatch` survives as the backdoor for
|
||||
the case the rules cannot reach — a widened `coreApi`, a new mount, a capability, with no releasable
|
||||
code behind it.
|
||||
|
||||
**Two failure modes are guarded before anything is built.** The credential check runs first, so a
|
||||
repository without `REGISTRY_TOKEN` fails with a named error instead of pushing a tag and then 401ing
|
||||
on the release API — the state `servuo-plugins` got stuck in on its own first release. And a tag that
|
||||
exists with **no release behind it** is deliberately not treated as "nothing to do": that is the
|
||||
signature of the same half-failure, and standing down on the tag alone would make it permanent.
|
||||
|
||||
### 15.2 The frozen manifest answers the question phase 1 had to take on trust
|
||||
|
||||
`§13`'s registration comment says `/rust` "collides with nothing on any of the three tiers, checked
|
||||
against core's mount tables rather than assumed" — and then names the limit of that check: **core
|
||||
answers several public routes mounted at the tier root rather than under a prefix**, `/status` and
|
||||
`/version` among them, which the loader's own collision probe cannot see.
|
||||
|
||||
The `frozen-manifest` job is the thing that can. It clones core at the sha pinned in
|
||||
`ci/core-ref.json`, generates core's route table **without** this module and then **with** it, and
|
||||
takes the difference. That difference is what the module serves, and it is checked three ways: it
|
||||
must match the committed `routes.manifest.json`, every route in it must have an operation in
|
||||
`swagger-fragment.json` *and* every operation must be a route, and — the half that matters most —
|
||||
**no core route may have been removed or changed**. A module whose mount displaced a core route
|
||||
cannot show up as an addition, because the URL is unchanged; only the diff sees it.
|
||||
|
||||
Run locally against the pinned ref before it was committed, and again in CI against a fresh clone,
|
||||
both agreeing:
|
||||
|
||||
```
|
||||
route manifest up to date (280 routes)
|
||||
wrote routes.manifest.json (282 public + 4 internal)
|
||||
routes.manifest.json is current — 6 routes, all documented
|
||||
```
|
||||
|
||||
Six routes, all documented, nothing of core's moved. **`/rust` is now free by proof rather than by
|
||||
reading**, and it stays that way on every pull request.
|
||||
|
||||
**The pin starts on `main`, unlike Module-uo's.** That repo spent the whole Event System window
|
||||
pinned to `edge`, because it depended on contract members that had not reached `main` yet. This
|
||||
module needs `MODULE_API` 1.10.0, which the Event System cutover already put there, so
|
||||
`ci/core-ref.json` names `efa9db7` on `main` and should stay on `main` until this module comes to
|
||||
depend on something unreleased.
|
||||
|
||||
### 15.3 One declaration, two readers — and it was verified by breaking it
|
||||
|
||||
`ci/bundle.json` is an **include** list, never an exclude list: an exclude list ships whatever it
|
||||
forgot, and the day somebody adds `server/tools/` with a scratch credential in it, an exclude list
|
||||
packs it and nobody finds out. The cost of that choice is the opposite failure — a new directory
|
||||
silently drops *out* of every release — which is exactly what happened to Module-uo between v0.3.0
|
||||
and v1.0.0, where `server/commands/` arrived in a cutover, the list did not learn about it, and the
|
||||
module installed cleanly and then died at the register stage on the operator's box.
|
||||
|
||||
Nothing caught it there because the PR checks copy the **whole repo** into core: they only ever
|
||||
exercised a tree that had the file. **The subset exists only in the release.** So the list has two
|
||||
readers — `release.yml`, which packs from it, and `server/scripts/checkBundle.js`, which asks on
|
||||
every PR whether it still covers everything `server/index.js` can reach.
|
||||
|
||||
It reaches requires written **inside `register()`**, which is not a detail: this module's entry point
|
||||
requires its routers inside the function on purpose, because `core.init(ctx)` has to run before
|
||||
anything under `router/` is required. A check that only saw file-scope requires would have missed
|
||||
every router the module has.
|
||||
|
||||
Verified the only way a check is worth anything — by breaking it. Dropping `"model"` from the list:
|
||||
|
||||
```
|
||||
ci/bundle.json does not ship everything server/index.js reaches.
|
||||
server/model (2 files reachable)
|
||||
Add "model" to ci/bundle.json's server[].
|
||||
```
|
||||
|
||||
It names the exact edit, in the units the list is written in.
|
||||
|
||||
### 15.4 The release, as published
|
||||
|
||||
`v0.1.0`, cut by the first push to `main`, with no tag before it — so the engine took the first-run
|
||||
branch and shipped what `module.json` declared. Three assets:
|
||||
|
||||
| Asset | Size |
|
||||
|---|---|
|
||||
| `module-rust-0.1.0.tar.gz` | 41,310 bytes |
|
||||
| `module-rust-0.1.0.json` | 355 bytes |
|
||||
| `SHA256SUMS` | 91 bytes |
|
||||
|
||||
Downloaded and hashed independently of CI, the artifact is
|
||||
`7296c76b988c6191840a1dc4ed1a77d96ad35e8c26f9b6b55e88fe4c10878b32` — byte-for-byte the `sha256` the
|
||||
manifest declares.
|
||||
|
||||
**`REGISTRY_TOKEN` was already configured on the repository**, which was the one prerequisite this
|
||||
work could not verify for itself: the bot identity can read neither repository nor organisation
|
||||
Actions secrets (`user should be the owner of the repo`). The credential check exists precisely
|
||||
because the answer was unknowable from here, and the first release ran clean through it.
|
||||
|
||||
### 15.5 The acceptance criterion, walked
|
||||
|
||||
> *An operator installs the empty module from Admin → Modules and it reaches `started`.*
|
||||
|
||||
**Met.** Walked as an operator would, against the local core on `edge`, with the phase-1 state
|
||||
deliberately torn down first — the hand-copied `modules/rust/` directory moved aside and its
|
||||
`installed_modules` row deleted, so this was a first install and not an upgrade. Before it,
|
||||
`GET /api/v1/public/rust/servers` answered **404**.
|
||||
|
||||
1. Admin → Modules, with no `rust` row and `gitea.whitlocktech.com` on the allowlist.
|
||||
2. Pasted the release's `module-rust-0.1.0.json` URL and pressed Install.
|
||||
→ *"Installed Rust v0.1.0. Restart to load it."*, and a row reading **Restart to start —
|
||||
installed, it mounts when the server next starts**, carrying the manifest URL and
|
||||
`sha256 7296c76b988c…`.
|
||||
3. Restarted.
|
||||
→ `registered module "rust" v0.1.0`, `schema ensured for module "rust" {"statements":2}`,
|
||||
`[rust:boot] booted {"refreshMs":30000}`, `module "rust" started`.
|
||||
|
||||
The row is now `state = started` with `started_at` set, the source URL and the released hash — where
|
||||
phase 1's row had `source` and `sha256` **null**, because a directory somebody copied has no
|
||||
provenance to record. The screen reads **Running — mounted and serving**.
|
||||
|
||||
**What the volume received is exactly the include list and nothing else: twenty files.** No tests, no
|
||||
`server/scripts/`, no `ci/`, no `client/src/`, no `node_modules`, and no dotfiles. `client/dist/entry.js`
|
||||
is the chunk CI built.
|
||||
|
||||
And the three checks that prove it is *serving* rather than merely loaded:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /api/v1/public/rust/servers` | **200**, answering with the rig's `main` server |
|
||||
| `GET /api/v1/player/rust/servers` · `GET /api/v1/admin/rust/servers` | **401** — the tier gates are on, not bypassed |
|
||||
| `/api/docs.json` | carries all **five** `/rust` paths: the fragment merged into core's own spec |
|
||||
|
||||
**Two things this walk is honest about.** The first is that the *restart* was done from the shell
|
||||
rather than with the screen's own **Restart the server** button: that button runs the same graceful
|
||||
shutdown a `SIGTERM` does and relies on a supervisor to bring the process back, which the shipped
|
||||
`docker-compose.yml` provides and a bare `npm start` does not. The button was not exercised, and it
|
||||
is core's, not this module's. The second is that the module's two tables were left in place when the
|
||||
row was deleted, so the rig's `main` server row survived the reinstall — the schema fragment replayed
|
||||
on boot (`statements: 2`) exactly as R12 requires of a fragment that runs every time.
|
||||
|
||||
**The one operational finding, and it is about the rig rather than the module.** Docker Desktop's
|
||||
Linux engine was found dead — its WSL distribution stopped, the `uomm-db` container exited, and
|
||||
`docker` answered every call with `500 Internal Server Error` rather than anything naming the cause.
|
||||
Restarting Docker Desktop and the container fixed it. Worth writing down because the failure presents
|
||||
as the *website* being broken (`ECONNREFUSED` to a database that is simply not there), and because
|
||||
`CLAUDE.md` points every smoketest at that one container.
|
||||
|
||||
## 16. Phase 3 as built — the read path, 2026-09-16
|
||||
|
||||
The first phase that had to be true on two mod frameworks, and the first with a
|
||||
catalogue rather than a message. Four repositories moved: the spec here, the plugin, the sidecar,
|
||||
and the module.
|
||||
|
||||
**Status: the bridge half is done and proven; two proofs are queued on the org lead.** What the
|
||||
plugin sends and what the sidecar does with it are built, tested and exercised against live Oxide
|
||||
and Carbon servers. The player-facing half of the catalogue — deaths, chat, gathering, sessions —
|
||||
cannot fire without somebody holding a mouse, and is written down as a walk to run rather than
|
||||
guessed at: [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md). §16.7 lists everything still open.
|
||||
|
||||
### 16.0 The four decisions this phase needed
|
||||
|
||||
- **D7 — the widest first hook wave.** The options ran from "presence, deaths and the wipe" to
|
||||
"everything read-only worth having", and the widest was chosen: fifteen hooks, including the
|
||||
moderation set that carries IP addresses and player reports. The consequence is real and is
|
||||
designed around rather than deferred — those frames arrive **nine phases before** the visibility
|
||||
framework phase 14 builds, so the classification and its default-deny allowlist ship now (§16.4).
|
||||
- **D8 — the plugin derives `wipeId`.** `PROTOCOL.md` §3.2 had reserved that for the website. By
|
||||
protocol 2 three components store rows that need it and only one of them can read the value, so
|
||||
the reversal is written into §8.2 rather than left as a contradiction.
|
||||
- **D9 — the live feed is a cursor, and D5 stands.** Core runs Node 20, where a global `WebSocket`
|
||||
is still behind a flag, so a socket means taking `ws` — against a release that asserts it ships no
|
||||
runtime dependencies. The deciding argument was the other one: **a socket needs a cursor anyway**
|
||||
for what it missed while the module was restarting, and the catch-up path is the one that must be
|
||||
right. One mechanism exercised every five seconds beats two where the second only runs after an
|
||||
outage nobody planned.
|
||||
- **D10 — rollups permanent, raw bounded.** The website keeps per-player-per-wipe totals for ever
|
||||
and a 30-day window of raw events; the sidecar keeps 14 days and prunes hourly. R12's "a wipe does
|
||||
not erase a player's history" is met by the totals, which is the row an operator actually reads.
|
||||
- **D11 — CI for both bridge repositories**, which had none at all. Phase 2 found that hole in
|
||||
Module-Rust; it was still open in the two repositories that ship the half running inside somebody
|
||||
else's game server.
|
||||
|
||||
### 16.1 `type` is the whole of protocol 2 in the sidecar
|
||||
|
||||
Protocol 1 routed on `kind`, in a `match` that needed a new arm per addition. Protocol 2 adds
|
||||
**`type`** — `event`, `snapshot`, `reply`, `control` — and the sidecar files on that and nothing
|
||||
else. Ten new event kinds are now zero change in Rust-Link, which is the property that matters when
|
||||
the thing growing fastest is the catalogue.
|
||||
|
||||
A frame whose `type` this build does not know is **dropped and counted**, never guessed at.
|
||||
Defaulting an absent one to `event` would file a *board* as history — the presence board appended a
|
||||
few thousand times, which nothing reports and nobody notices until they wonder why the database is
|
||||
large.
|
||||
|
||||
**It caught a real mismatch three seconds after it first ran**, which was not planned: a protocol 1
|
||||
plugin was still live on the retired workstation rig, dialled the new sidecar, and its `server.hello`
|
||||
went straight into the counter. The game link has no version handshake by design (§2), so
|
||||
`untyped_frames` on `/health` is the only place that failure is visible — and the symptom without it
|
||||
is a website showing nothing while the game is plainly up.
|
||||
|
||||
### 16.2 Two defects a live server found, and neither could have been found anywhere else
|
||||
|
||||
**The wipe id was null for every real session.** `Init` runs *before* the save is loaded, so
|
||||
`SaveRestore.SaveCreatedTime` is not yet meaningful there, and the id resolved at load time stayed
|
||||
null for the life of the process — every frame shipping without the field R12 splits history on.
|
||||
|
||||
It was invisible for the reason such things usually are: a **hot-reloaded** plugin reads an
|
||||
already-loaded world and gets the right answer every time. Every development iteration on the
|
||||
workstation rig was a hot reload. It took a server that *booted* with the plugin installed — which
|
||||
is every real one — to show `wipeId=none` beside a save sitting on disk. Now resolved again at
|
||||
`OnServerInitialized`, and lazily while still unknown.
|
||||
|
||||
**`Unload` blocked the game's main thread for two seconds.** Carbon reported it exactly:
|
||||
`hook 'Unload' took longer than 100ms [2002ms]`, next to `link thread did not stop cleanly`. That is
|
||||
phase 1's stall arriving by a different road — the link thread sits in a blocking
|
||||
`TcpClient.Connect`, which has no timeout of its own and cannot be woken, and `Unload` joins it from
|
||||
the main thread.
|
||||
|
||||
The reason two phases missed it is worth keeping: **a host that refuses answers instantly, and a
|
||||
host that drops does not answer at all.** Every loopback test is the first kind. A firewalled
|
||||
address, a typo, a machine that is off are all the second, and the deployment this phase was being
|
||||
tested through happened to be one. The connect is now bounded and waits on a stop handle of its own
|
||||
— it cannot share `Wake`, which also means "the queue has something in it" and is signalled by every
|
||||
hook that fires. After the fix the same reload logs no slow-hook warning and no stranded thread.
|
||||
|
||||
### 16.3 The aggregate, and the rule it generalises
|
||||
|
||||
`OnDispenserGather` fires on **every swing at a tree**. A frame per swing would make the bridge the
|
||||
most expensive thing on a busy server, and nobody wants a killfeed of chickens either, so gathering
|
||||
and NPC kills are counted in the plugin and flushed once a minute as one `player.tally` frame.
|
||||
|
||||
The rule: **if a hook can fire more than once a second per player, it is a counter, not an event.**
|
||||
R17's warning about chatty zone transitions is the same rule arriving early.
|
||||
|
||||
A tally is a **delta, not a running total** — what happened since the last flush — so the consumer
|
||||
sums rather than diffs, and a dropped frame costs one interval instead of corrupting the series. The
|
||||
outbound queue is drop-oldest by design, so frames are genuinely allowed to go missing; a running
|
||||
total over a lossy link is a number that is quietly wrong for ever.
|
||||
|
||||
One honest limitation, corrected in the code rather than in the comment that first claimed
|
||||
otherwise: **a plugin reload loses up to a minute of one player's tally.** `Unload` enqueues the
|
||||
flush, but the writer stops on the same flag and the queue is cleared after the join. Draining it
|
||||
first would mean waiting on a socket from the main thread — the stall §16.2 just removed — so the
|
||||
loss is taken deliberately. A real shutdown flushes at `OnServerShutdown`, and a player leaving
|
||||
flushes at their disconnect.
|
||||
|
||||
### 16.4 The boundary is enforced by the side that serves
|
||||
|
||||
The widest hook wave brings IP addresses (`CanUserLogin`, `OnUserApproved`, `OnUserBanned`), one
|
||||
player's report about another, and the grid reference of somebody's base — nine phases before the
|
||||
visibility framework §11.2 costed. So the classification ships with the catalogue.
|
||||
|
||||
**It is not a field on the wire.** The plugin could have stamped a class on every frame; it
|
||||
deliberately does not. A boundary declared by the *sender* is one a compromised — or merely
|
||||
out-of-date — game host can widen. The website's own shard fan-out works the same way: a public
|
||||
stream with an allowlist of kinds and an admin stream that adds the rest, and what makes it
|
||||
trustworthy is that the decision lives on the serving side.
|
||||
|
||||
So `module-rust/server/catalogue.js` holds it, **default-deny**: a kind this build has never heard
|
||||
of is not public. That is the shape of the mistake it prevents — the next protocol version adds a
|
||||
kind, the module stores it happily, and a deny-list filter would publish it the day it first
|
||||
arrived, before anybody decided whether it should be. A test holds the list against §8.4's table, so
|
||||
adding a kind to the protocol without classifying it fails a build.
|
||||
|
||||
### 16.5 A login denial is not a hook, and §10 says it is
|
||||
|
||||
`PLAN.md` §10 sources the `rust.login.denied` trigger from `CanUserLogin`. Reading the hook says that
|
||||
cannot work: it fires on **every** connection attempt, and the only way to learn of a denial from it
|
||||
is to *be* the denier — which §8.7 forbids, structurally, by declaring every read-path hook `void` so
|
||||
it cannot answer. uMod publishes no `OnUserRejected`.
|
||||
|
||||
What the game can tell us is two facts: an attempt, and an approval. Protocol 2 emits both, and a
|
||||
denial is **the absence of an approval** — a deferred read, phase 10's to make. The trigger survives;
|
||||
its source changes. (The same shape the engagement workstream hit at its own phase 10, which is
|
||||
either a coincidence or a property of login paths.)
|
||||
|
||||
### 16.6 What was proven, and how
|
||||
|
||||
| Claim | How | Result |
|
||||
|---|---|---|
|
||||
| The read path compiles and loads on **Oxide** | live server, protocol 2, 15 hooks bound | ✅ |
|
||||
| …and on **Carbon** 2.0.259.0, from the **byte-identical file** | the Pterodactyl rig, config read from `carbon/configs/` | ✅ |
|
||||
| Every frame carries `type`, `serverId`, `wipeId` | `server.hello` and `players.online` read back off the sidecar | ✅ |
|
||||
| A wipe id derived from the save, changing only with the save | `rg.link` reports `w-20260915T195817Z` against `saveCreatedAt 2026-09-15T19:58:17Z` | ✅ |
|
||||
| **A restarted sidecar is fully populated within one connection** | store deleted, process restarted: both boards present **0.3 s** after the listener bound, and **zero** events in history | ✅ |
|
||||
| Boards are not replayed as history | the same walk: `/events` returned 0 rows while `/boards` returned 2 | ✅ |
|
||||
| Moderation frames reach the sidecar whole | `banid` / `unban` over RCON | ✅ |
|
||||
| A protocol mismatch is counted, not mis-filed | a live protocol 1 plugin against the protocol 2 sidecar | ✅ |
|
||||
| `rg.hooks` answers on both frameworks | Oxide and Carbon, identical output shape | ✅ |
|
||||
| 44 sidecar tests, 95 module server tests, 20 client tests, every guard | locally, and now in CI on both repos | ✅ |
|
||||
| The module's route manifest against a **real core** at the pinned ref | 10 routes, all documented, none of core's moved | ✅ |
|
||||
| **The module ingests a live game** | the working tree installed into a running core, pointed at the workstation sidecar: `cursor started at the feed tail`, then a console `banid`/`unban` arrived as two `rust_events` rows with their wipe id, cursor advanced | ✅ |
|
||||
| **The allowlist holds against real rows** | with both ban events in the table, `GET /public/rust/servers/:id/events` answered `{"events":[]}` — **and answered the same when asked for `player.banned` by name** | ✅ |
|
||||
| A wipe row is created by being mentioned | `w-20260915T195817Z` appeared in `rust_wipes` from the first frame carrying it, with no "a wipe started" call anywhere | ✅ |
|
||||
|
||||
**One finding about the rigs rather than the code:** the panel rigs cannot reach a sidecar running on
|
||||
the workstation, because Windows Firewall holds two program-scoped **Block** rules for
|
||||
`rust-link-sidecar.exe` — created by a dismissed prompt at some point — and a program-level block
|
||||
beats any port-level allow. Removing them needs elevation. It is a rig problem only: the shipped
|
||||
design puts the sidecar on the game host's own loopback (D2, R20), where it is the deployment that
|
||||
never needs a rule at all.
|
||||
|
||||
The local workstation rig, which does reach its sidecar on loopback, is what proved everything in the
|
||||
table above that needs a live socket. **`D:\rust` is therefore not as retired as R21 assumed** — it
|
||||
survives as the fast loop (a saved file is a reloaded plugin in about ten seconds, against nine
|
||||
minutes of world generation on the panel), and the panel rigs are what answer "on both frameworks".
|
||||
|
||||
### 16.7 What is still open, and who it is waiting on
|
||||
|
||||
Three things, all of them measurements rather than decisions, and all of them the org lead's to run:
|
||||
|
||||
1. **The player walk** — [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md). Ten minutes on a rig
|
||||
with a mouse, and it closes `OnPlayerDeath`, `OnPlayerChat`, `OnDispenserGather`,
|
||||
`OnPlayerRespawned`, `OnEntityDeath`-by-a-player, and `sessionSec`. The document says what each
|
||||
step should produce, so it can be run without anybody watching the output live.
|
||||
2. **The wipe walk.** `OnNewSave` fires when a server starts with no save — the panel rigs wipe
|
||||
through the egg's own `REMOVE_FILES`, so this is the rig's own mechanism rather than a special
|
||||
test. What it proves is the second half of the acceptance criterion: that the old wipe's rows are
|
||||
still queryable by `?wipe=` afterwards.
|
||||
3. **The Carbon socket leg.** Everything up to the socket is proven on Carbon; what is not is frames
|
||||
actually arriving over a live link, which is one elevated firewall command away
|
||||
(`Remove-NetFirewallRule -DisplayName "rust-link-sidecar.exe"`, then an allow for the rig).
|
||||
|
||||
Until 1 and 2 are run, the honest statement of this phase is: **the transport, the envelope, the
|
||||
boards and the classification are proven on both frameworks; the player half of the catalogue is
|
||||
built, reviewed against the hook documentation, and unmeasured.** That is written here rather than
|
||||
in a commit message because it is the kind of thing a later phase will want to know it inherited.
|
||||
|
||||
### 16.7b The ingest walk, and the trap it walked into
|
||||
|
||||
The module half was exercised against the live rig rather than only against its own tests: the
|
||||
working tree installed into a running core, pointed at the workstation sidecar with a real Rust
|
||||
server behind it. It logged `cursor started at the feed tail {at: 1}` — the fresh-install path,
|
||||
starting at the end rather than replaying — and a console `banid`/`unban` pair then arrived as two
|
||||
`rust_events` rows carrying the wipe id, with the cursor advancing to 3 and `events_seen` at 2.
|
||||
|
||||
Then the boundary, on real rows rather than fixtures: with both ban events sitting in the table, the
|
||||
public events route answered `{"events":[]}`, **and answered the same when asked for
|
||||
`player.banned` by name**. That is the difference between a filter and a refusal — the kinds are, as
|
||||
far as a public caller is concerned, not there.
|
||||
|
||||
**The trap, and it is the same one the Pterodactyl work recorded.** The server-state row read as
|
||||
`online=1` with `hostname=NULL` and `protocol=NULL`, which is a shape no code path writes. The cause
|
||||
was two cores sharing one database: a second instance left running from earlier in the day still
|
||||
holds the **phase 2 release** in memory, speaks protocol 1, is refused `409` by a protocol 2 sidecar,
|
||||
and writes the row back as unreachable every thirty seconds. Nothing was wrong with either of them.
|
||||
|
||||
What made it *look* like a defect was the measurement: two `SELECT`s in two round trips, assuming
|
||||
the state did not move between them. A single atomic read caught the coherent row a moment later —
|
||||
`online: 1, protocol: 2, hostname: Test Server` — alternating with the other writer's.
|
||||
**A differential diagnosis across two calls is only valid if nothing else holds the controls**, and
|
||||
on a shared rig something usually does.
|
||||
|
||||
### 16.8 Smaller things worth keeping
|
||||
|
||||
- **`rg.hooks` collides with `RGProbe`**, the phase-0 rig plugin, which registered the same console
|
||||
command first. Oxide warns and the last loaded wins, which happens to be the bridge. Left alone:
|
||||
`RGProbe` is rig scaffolding that never ships, and renaming the shipping command to avoid a
|
||||
test tool would be the wrong way round.
|
||||
- **The IP a console ban reports is the literal string `"0"`**, not an address and not a null, when
|
||||
the banned id is offline. Observed, not guessed. The plugin omits the field instead of forwarding
|
||||
it — a column full of `"0"` survives every is-it-missing test a reader writes and then fails
|
||||
whatever parses it.
|
||||
- **`--print-config` reports the *effective* configuration and writes the *file* one.** Environment
|
||||
variables override the file (R22 depends on that), and the written file never contains them. Not a
|
||||
bug, but the two are not the same document and an installer reading one should not assume the
|
||||
other.
|
||||
- **A `cargo clippy` run does not produce a binary.** Two rig readings disagreed with the source
|
||||
because the sidecar under test was an older `cargo build`; `clippy` and `test` compile without
|
||||
writing one. Rebuild before believing a rig.
|
||||
|
||||
## 17. Phase 4 as built — the first pages, 2026-09-16
|
||||
|
||||
One repository, and the first phase whose whole deliverable is something a visitor looks at. It
|
||||
consumes exactly the routes phase 3 built and adds one of its own; nothing here talks to a game
|
||||
server, which is the point of the criterion it was written against: **the site renders the last
|
||||
thing each server said while every server is off.**
|
||||
|
||||
**Status: criterion met, and met the hard way.** The pages were walked in a browser against a live
|
||||
rig — a real Rust server behind the real sidecar for `main`, and a second, deliberately unreachable
|
||||
server for everything the record holds. That walk found four defects, two of them in code phase 3
|
||||
had already shipped, and all four are fixed here (§17.2).
|
||||
|
||||
### 17.0 The four decisions this phase needed
|
||||
|
||||
- **D12 — `/rust` is the server list.** Phase 1 registered it at `/rust/servers` and left the
|
||||
module's own namespace root answering core's CMS catch-all. R8 calls the list "the landing page",
|
||||
so it is registered with an **empty path** — core renders that as `/rust` — and the detail page
|
||||
hangs beneath it at `/rust/servers/:id`. One canonical address, and it is the module's name.
|
||||
- **D13 — one page with in-page tabs**, not four routes. `/rust/servers/:id` carries a header and
|
||||
four panels (feed, leaderboard, online, wipes). A tab strip is not in the shared UI kit, so the
|
||||
module bundles its own — which is the kit working as designed rather than a gap in it (§3.4 is a
|
||||
closed list of nine members, and everything above them is the module's).
|
||||
- **D14 — poll while the tab is visible.** The feed and the presence list re-fetch every twenty
|
||||
seconds, paused by the Page Visibility API and refreshed the instant a viewer comes back. The
|
||||
leaderboard and the wipe list load once: a table that re-sorts itself under the reader's cursor is
|
||||
worse than one four minutes old. Server-Sent Events were considered and are not this phase — core
|
||||
has a fan-out, but the module registers no stream, and a cursor-driven one is phase scope.
|
||||
- **D15 — the footer slot carries a live count**, not a static link: `2 servers · 42 online`, linking
|
||||
to `/rust`. The cost is stated rather than assumed — see §17.4.
|
||||
|
||||
### 17.1 What is on the pages
|
||||
|
||||
`/rust` is the list: name, map, size, when it was wiped, when it last reported, and the player count
|
||||
or `Offline`. The whole row is the link.
|
||||
|
||||
`/rust/servers/:id` is the server. The header is what a Rust player asks first — map, world size,
|
||||
seed, wipe date — with live status beside it and a wipe selector that applies to the whole page.
|
||||
Then four tabs:
|
||||
|
||||
| Tab | Reads | Refresh |
|
||||
|---|---|---|
|
||||
| Feed | `…/events`, with a filter that maps to the `kind` parameter | 20s, visibility-gated |
|
||||
| Leaderboard | `…/leaderboard`, sortable, per wipe or all-time | on mount |
|
||||
| Online | `…/online` — the presence **board**, not counted transitions | 20s, visibility-gated |
|
||||
| Wipes | `…/wipes`; picking one filters the feed | on mount |
|
||||
|
||||
**Everything selectable is in the URL** — tab, filter, wipe, sort. That costs a little ceremony in
|
||||
the page and buys the thing a community site is for: *"last wipe's leaderboard on Main"* is a link,
|
||||
the back button undoes a click rather than leaving the page, and a refresh lands where the reader
|
||||
was. `wipe=current` is a word rather than an id on purpose, so a shared link stays about now.
|
||||
|
||||
**The feed renders parts, not sentences.** `lib/feed.js` turns a stored frame into
|
||||
`{tone, actor, join, verb, subject, detail}`, which keeps the names emphasised without any HTML in a
|
||||
string, and makes the whole thing testable in a runner with no DOM. A death is four sentences, not
|
||||
one — `player`, `self`, `npc`, `environment` — because the plugin distinguishes them so a reader does
|
||||
not have to guess, and a fall reported as a kill by nobody is the failure that avoids. **A kind this
|
||||
build has never heard of renders as itself** rather than vanishing: the server's allowlist has
|
||||
already decided the row may be seen, so what is left here is presentation, and the honest
|
||||
presentation of a kind we have no words for is its own name.
|
||||
|
||||
**`player.tally` is public and deliberately not in the feed.** It is an aggregate the plugin flushes
|
||||
once a minute per active player (PROTOCOL.md §8.6), so a feed carrying it would be mostly wood
|
||||
counts. It is the leaderboard's input, and that is where it shows up.
|
||||
|
||||
### 17.2 Four defects the page walk found, and two of them were already shipped
|
||||
|
||||
The walk was the whole point of doing one. None of these is visible in a test that stubs a sidecar
|
||||
which answers.
|
||||
|
||||
1. **An unreachable refresh erased what the server last said.** Phase 3's refresh loop called
|
||||
`putState` — the whole-row write — with two fields when a sidecar did not answer, so `hostname`,
|
||||
`level`, `seed`, `world_size` and `wipe_id` all went to NULL the first time a game host rebooted.
|
||||
The list then read `Offline` with nothing beside it, which is not *"here is what we know about a
|
||||
server that is down"*, it is *"we have never heard of it"* — and it defeats this phase's
|
||||
criterion exactly. Fixed with `markUnreachable`, which moves three columns and mentions no
|
||||
others; `server/test/refresh.test.js` asserts against the SQL, because the defect is about which
|
||||
columns a statement names.
|
||||
2. **"Last reported" was reading the wrong timestamp.** `updated_at` is when this module last WROTE
|
||||
the row, which a failed poll does too — so an offline server claimed it had reported just now,
|
||||
every thirty seconds, for as long as it stayed down. They are two facts and both are wanted:
|
||||
`updated_at` decides staleness, and a new `last_seen_at` records when a `server.hello` last
|
||||
arrived. Only a successful refresh moves it.
|
||||
3. **Every feed row showed a bare time of day.** Correct for today's killfeed and wrong the moment
|
||||
the feed is filtered to a past wipe: three events from six weeks ago all rendered as `02:03 PM`.
|
||||
Rows from another calendar day now carry the date. The boundary is the calendar rather than a
|
||||
duration, because that is what a reader means by "what time was that".
|
||||
4. **A mistyped address was dressed as a fault.** The detail page rendered core's `ErrorState` under
|
||||
its own heading, so `/rust/servers/typo` read "No such server / Something went wrong" and sent a
|
||||
reader looking for an outage. A 404 is now its own answer and `ErrorState` is kept for a request
|
||||
that failed for a reason nobody can see.
|
||||
|
||||
A fifth, smaller: the Online tab listed three players under a header reading `Offline`. An
|
||||
unreachable sidecar does not clear the presence board — deliberately, the rows are still the best
|
||||
answer anybody has — but presented bare they read as *who is on right now*, which is the one thing
|
||||
an offline server cannot be saying. The panel now says which it is.
|
||||
|
||||
### 17.3 `useAsync` cannot poll, and that is not a defect in it
|
||||
|
||||
Core's fetch hook (§3.4) blanks `data` and sets `loading` on every dependency change. That is right
|
||||
for a page load and wrong for a poll: bumping a dependency every twenty seconds would clear the
|
||||
killfeed, render a spinner in its place and re-fill it, four times a minute, for ever.
|
||||
|
||||
So the module bundles `hooks/usePolled.js`: a refresh that is **invisible when it succeeds** and
|
||||
keeps the rows *and* reports the error when it fails — because a site whose premise is "it renders
|
||||
while the game is off" must not blank itself the first time a request does. `key` (the question)
|
||||
resets the data; the interval does not. `useAsync` is still the right hook for everything that loads
|
||||
once, and both are used here.
|
||||
|
||||
The live proof: with the tab hidden the log shows no requests at all, and the instant it became
|
||||
visible there was one refresh followed by one every 20.0 seconds.
|
||||
|
||||
### 17.4 The footer slot's real cost, stated
|
||||
|
||||
Core renders `SiteFooter` inside `PublicLayout`, and **every public page renders `PublicLayout`
|
||||
itself** (§3.3) — so a component in that slot mounts once per public page view, not once per
|
||||
session. D15's live count therefore puts one `/public/rust/servers` request on every public page of
|
||||
the site, including pages with nothing to do with Rust.
|
||||
|
||||
Two things keep that honest rather than merely cheap. It **renders nothing until it has an answer,
|
||||
and nothing at all if the request fails** — core's `<Slot wrap>` takes its separator with it, so a
|
||||
failure degrades to exactly the footer an instance with no module installed has. And it **never
|
||||
polls**: one request per page view is a cost; a timer in the footer of every page is a different
|
||||
kind of thing. If it ever shows up in an operator's logs, the fix is a short-lived cache in that one
|
||||
file and nothing else on the site changes.
|
||||
|
||||
### 17.5 Smaller things worth keeping
|
||||
|
||||
- **The registration fake was *nearly* core, which is worse than obviously not.** `client/test`'s
|
||||
fake registry prefixed routes as `` `${id}/${path}` ``; core strips the trailing separator too,
|
||||
which is exactly what lets a module register `path: ''` and own its namespace root. The day a
|
||||
module did, the fake produced `rust/` where a real core produces `rust`, and the suite failed the
|
||||
nav check for a link that works perfectly in a browser. The fake now copies core's line character
|
||||
for character.
|
||||
- **A SQL comment inside a JS template literal may not contain a backtick.** Obvious written down,
|
||||
invisible while writing prose about `markUnreachable` inside a query string; the file stops
|
||||
parsing several lines later and the error names an argument list.
|
||||
- **The detail route exists so that a page can 404.** Every other route under `/servers/:id` answers
|
||||
an empty list for an id nobody configured — an unknown server genuinely has no events, no
|
||||
leaderboard and nobody online, and each of those is a good answer to its own question. Only
|
||||
`GET …/servers/:id` can say the server is not there. A disabled server answers the same 404 as a
|
||||
missing one: an operator who switched a server off did not switch it into a 403.
|
||||
- **`capabilities` grew to what the pages serve** — `servers`, `killfeed`, `leaderboard`,
|
||||
`presence`, `wipes` — which is the list phase 5's Android leg feature-detects against.
|
||||
- **The rig had two cores again**, and this time the mechanism was visible rather than inferred: a
|
||||
leftover core holding an older module release spoke protocol 1, was refused `409`, and rewrote the
|
||||
state row as unreachable every thirty seconds — the phase-3 trap, and also how defect 1 above was
|
||||
found. One of the two was stopped with the org lead's say-so before the walk continued.
|
||||
|
||||
### 17.6 What was proven, and how
|
||||
|
||||
- **The criterion, directly.** A second server was configured pointing at a dead address and seeded
|
||||
with a fixture shaped exactly as the plugin emits (two wipes, 26 events, four players, a presence
|
||||
board). With that server unreachable and reporting `Offline`, its page still rendered its map,
|
||||
size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, last known board and wipe
|
||||
history. That is the phase criterion in one screenshot.
|
||||
- **The allowlist, on real rows.** The fixture includes a kind this build has never heard of. The
|
||||
public route answered `{"events":[]}` for it when asked **by name** — a refusal, not a filter.
|
||||
- **R12's arithmetic, on the page.** All-time equals the two wipes summed (41 + 18 = 59), and a
|
||||
player who only appears in the older wipe drops out of the current one rather than reading zero.
|
||||
- **The footer slot**, live in core's own footer on every public page, linking to `/rust`.
|
||||
- **The chunk's identity check**, which is the one failure only a browser can show: the console
|
||||
carried the module's own registration line and nothing else — no second React, no bare import.
|
||||
|
||||
What is **not** proven here and is deliberately left: the pages have not been read on a phone-width
|
||||
viewport, and the Android leg (phase 5) is where the same surface gets a second client anyway.
|
||||
|
||||
## 18. Phase 5 as built — Android leg A, 2026-09-17
|
||||
|
||||
The first leg of R10, and the first time this module's surface has had a second client. The app's
|
||||
own record of it is [`../../android/PLAN.md`](../../android/PLAN.md) **M14**; what follows is what
|
||||
the phase decided and what the walk found.
|
||||
|
||||
**Criterion met, and walked in an emulator rather than asserted:** *the app renders a Rust site it
|
||||
has never seen, and a UO site unchanged.* Both halves were shown on one device against two running
|
||||
cores.
|
||||
|
||||
### 18.0 Four org-lead decisions
|
||||
|
||||
- **D16 — the app gates on a new capability, `rust`.** This module declared five strings and every
|
||||
one names a **surface**: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`. Core flattens
|
||||
every started module's capabilities into one list, so a client gating a whole navigation group on
|
||||
`servers` would have those screens revealed by any future module that declared the same generic
|
||||
word. `module-uo` has exactly one string for this job — `shard` — and this module had none.
|
||||
|
||||
**Gating on the module `id` was considered and rejected.** It is the strongest fact available and
|
||||
it is already on the wire, but `id` is a **mount prefix** (§2.1 requires it to equal the directory
|
||||
core loads the module from) and `MODULE_API.md` §2.9 forbids a client inferring a route from a
|
||||
capability. Letting a client gate on `id` makes the two the same value in practice, and the day one
|
||||
builds `/<id>/servers` from it the separation that lets this module move its own pages is gone.
|
||||
|
||||
So `module.json` declares its own name as a sixth capability, and `server/test/entry.test.js`
|
||||
asserts it **against `manifest.id`** rather than against the literal `"rust"` — the day the id
|
||||
changes, the string a client gates on has to change with it. Module-Rust#5.
|
||||
|
||||
- **D17 — the app polls every 20 seconds while its screen is RESUMED.** D14's Page Visibility gate,
|
||||
translated. `repeatOnLifecycle(RESUMED)` gives the same three behaviours from one line: nothing at
|
||||
all while the app is away, an immediate refresh on return, and a pause behind a dialog. `STARTED`
|
||||
was rejected — it keeps polling behind a partially obscured screen, which is precisely the reader
|
||||
who is not reading.
|
||||
|
||||
- **D18 — the three Rust repositories move to `edge`** for the rest of the workstream, with releases
|
||||
cut at the cutover rather than per phase. `edge` branches were created from `main` in Module-Rust,
|
||||
Rust-Link and Rust-Plugins; `pr-checks.yml` in all three (and in Android-app) already triggers on
|
||||
`[main, edge]`, so this costs no CI. `release.yml` still fires only on a push to `main`, which is
|
||||
what makes the cutover the release.
|
||||
|
||||
- **D19 — the drawer row carries a live player count, and `NavPaths` learns `/rust`.** The second is
|
||||
small and load-bearing: without it an admin's nav override on the module's own `Servers` row, or an
|
||||
added link to `/rust`, hands off to a browser rather than opening the native screen.
|
||||
|
||||
### 18.1 D15 has no analogue on a phone, so it was translated
|
||||
|
||||
The footer slot works on the web because every public page renders the same footer (§17.4). The app
|
||||
has no footer and no slot. What it has is one drawer row per surface and, since engagement Phase 8, a
|
||||
precedent for a number beside one — the inbox's unread badge, in `NavigationDrawerItem`'s badge slot,
|
||||
with a `contentDescription` so a screen reader says *"42 players online"* and not *"42"*.
|
||||
|
||||
The count rides there and keeps all three of the website version's rules: **zero renders nothing** (an
|
||||
empty fleet is not a notification, and a badge reading `0` on a quiet evening is worse than none), a
|
||||
failed read keeps the last number rather than dropping to zero, and it **never polls**. It is asked
|
||||
for only where the module is installed, so a UO site makes no request at all.
|
||||
|
||||
**The number is players, not servers.** A badge is one integer, and of D15's two halves the live one
|
||||
is how many people are on — a server count changes when an operator edits configuration, which is not
|
||||
news, and is on the page the row opens anyway.
|
||||
|
||||
### 18.2 What the app had to grow: a refresh that is not a load
|
||||
|
||||
The app has had exactly one shape for a read since its first milestone — set `Loading`, ask, replace.
|
||||
That is right for opening a screen and wrong for a poll, and it is the *same* wall this module hit one
|
||||
tier along with core's `useAsync` (§17.3). A twenty-second refresh built on it would clear the
|
||||
killfeed, render a spinner in its place and re-fill it, three times a minute, for ever.
|
||||
|
||||
`ui/Polling.kt` is `usePolled`'s other half, and it keeps the same rule: **a refresh is invisible when
|
||||
it succeeds and keeps the rows when it fails.** Three cases, and the middle one is the whole point:
|
||||
|
||||
| Result | What the reader sees |
|
||||
| --- | --- |
|
||||
| It answered | New rows. Nothing else. |
|
||||
| It failed, and there are rows | The same rows, and one quiet line saying the refresh failed. |
|
||||
| It failed, and there is nothing yet | An ordinary error with a retry — a first load that failed. |
|
||||
|
||||
Only the **visible** live panel is polled. The website can afford to mount the one tab it is
|
||||
rendering; the app's four tabs are one screen, so the refresh asks what the reader is actually looking
|
||||
at. The leaderboard and the wipe list are never polled at all.
|
||||
|
||||
**Changing the question is not a poll.** Filter, sort and wipe blank their panel and load, because
|
||||
what is on screen is an answer to something the reader has stopped asking — leaving it up would show
|
||||
last wipe's killfeed under this wipe's heading.
|
||||
|
||||
### 18.3 What the walk proved, and how
|
||||
|
||||
The rig is phase 4's, unchanged: a core on `:3200` with this module installed, one live server
|
||||
(`main`, a real sidecar and a real game host) and the seeded `demo` fixture that has never reported.
|
||||
A second core on `:3100` serves `module-uo` and no Rust.
|
||||
|
||||
- **The criterion, first half.** With `demo` unreachable and reading *Offline*, the phone rendered its
|
||||
map, size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, its last known presence
|
||||
board and its wipe history. Nothing on the screen is a live call to a game host.
|
||||
- **The criterion, second half.** The same app, switched to the UO core, showed Shard / Rules / Atlas
|
||||
/ Leaderboards / Market and **no Rust row**.
|
||||
- **`refreshInto` against a genuinely dead backend.** The core was stopped with the list on screen. A
|
||||
poll tick later the rows were unchanged, under one line reading *"Could not refresh just now. This
|
||||
is the last thing the site heard."* — no spinner, no error page, nothing blanked.
|
||||
- **R12's arithmetic, on a phone.** All-time 59 = 41 + 18 across two wipes, and Drift — who appears
|
||||
only in the current wipe — **drops out** of the August board rather than reading zero.
|
||||
- **Every `describe` branch, from real rows**: a player kill with weapon, distance and grid; an NPC
|
||||
kill with the prefab read as words; a suicide; an environment death (the fall that must not read as
|
||||
a kill by nobody); chat with its colon in the join and a non-Global channel beside it; a disconnect
|
||||
with reason and session length; and *while sleeping*.
|
||||
- **The calendar-day rule.** Filtering to the August wipe produced three rows six weeks old, each
|
||||
carrying its date — the §17.2 defect, not re-introduced in Kotlin.
|
||||
- **The presence panel saying which it is.** The offline server's board rendered under *"The last
|
||||
board this server sent. It is offline, so this is who was on then — not who is on now."*
|
||||
- **The badge**, showing a live count on the drawer row.
|
||||
|
||||
### 18.4 The walk found three defects, and a green suite found none of them
|
||||
|
||||
1. **The drawer's live count resolved once per process.** It was keyed on the capability answer alone,
|
||||
so it was read when the app connected and never again — which is not what *live* means on a row
|
||||
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge and for
|
||||
the same reason: coming back to the app is exactly when a stale number would be noticed. *Visible
|
||||
only by backgrounding the app and returning to it.*
|
||||
2. **Every card's text sat flush against its edge.** The app's themed `ShardCard` is a `Card` and
|
||||
nothing more — it carries no padding, and each caller pads its own content. Four new call sites did
|
||||
not, and on a phone the first glyph of each line read as clipped.
|
||||
3. **A name touched its own kill count.** Five numeric columns beside an equal-weight name column left
|
||||
*Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes — and the
|
||||
**active sort moved to the header**, because the header is the control: tinting a column of numbers
|
||||
says *these are special* where tinting the header says *this is what the table is ordered by*.
|
||||
|
||||
### 18.5 The rig note worth keeping
|
||||
|
||||
The app's debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost`
|
||||
only** — not `10.0.2.2`. An emulator walk against a local core therefore needs
|
||||
`adb reverse tcp:<port> tcp:<port>` and the loopback address. Typed as `10.0.2.2`, every request fails
|
||||
with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, and the connect
|
||||
screen reports *"Couldn't reach that site"* — correct, and indistinguishable from a core that is not
|
||||
running.
|
||||
|
||||
Two smaller things: the first AVD tried had 95% of `/data` used and refused a 44 MB install with
|
||||
*"Requested internal only, but not enough space"* — `pm trim-caches` freed nothing, and the second AVD
|
||||
was the answer. And the app's own `pm clear` is the way to reach the first-run connect screen, because
|
||||
an `install -r` over an earlier install keeps the stored base URL.
|
||||
|
||||
### 18.6 What is not proven here
|
||||
|
||||
- **A phone-width read of the website's own pages**, which §17.6 deliberately left open. This phase
|
||||
gave the surface a second client rather than re-reading the first, and the pages have still not been
|
||||
looked at in a narrow browser.
|
||||
- **The badge's non-zero case on real traffic.** Nobody was playing on the rig, so the count was shown
|
||||
by seeding a player count on the unreachable server and reading the badge before the 30-second
|
||||
refresh zeroed it. The arithmetic and the rendering are proven; a fleet with people on it is not.
|
||||
|
||||
---
|
||||
|
||||
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
100
rust-link/PLAYER_WALK.md
Normal 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.
|
||||
@@ -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 1–1000 |
|
||||
| `GET /events?kind=&wipe=&limit=` | the store | Newest first; `limit` clamped to 1–1000. 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 3–19 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user