Compare commits
18 Commits
069e715b1b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 15d64b28fe | |||
| 7f008fd1f3 | |||
| 82145d3b4a | |||
| 132205f0f4 | |||
| bba2ab04e0 | |||
| 4fe8864939 | |||
| e35880e713 | |||
| 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.
|
||||
|
||||
1287
modules/rust/PLAN.md
1287
modules/rust/PLAN.md
File diff suppressed because it is too large
Load Diff
@@ -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,92 @@ 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.
|
||||
|
||||
**From protocol 3 your players can link their Steam account.** In game they type `/link` and the
|
||||
server answers them privately with a six-character code; on the website they type that code in
|
||||
within five minutes and the two are joined. Nothing about the link is stored on the game host — the
|
||||
website owns the record, and `/unlink` in game asks it to let go.
|
||||
|
||||
Two things an operator should know about it:
|
||||
|
||||
- **The code is never in a frame.** It reaches the player and nobody else, which is what makes typing
|
||||
it into a signed-in browser proof that they are the one who asked. What crosses the bridge is
|
||||
`account.link.requested`, a staff-visible note that somebody asked.
|
||||
- **A Steam account can belong to one website account at a time, across your whole fleet.** A code
|
||||
from any of your servers links for all of them. If somebody links the wrong account the site
|
||||
refuses to move it — the player runs `/unlink` in game, or staff release it from the user's page in
|
||||
the admin panel.
|
||||
|
||||
**From protocol 4 the website owns your permissions.** Groups and grants are written in
|
||||
Admin → Rust permissions and pushed into this server's own Oxide/Carbon permission store, so every
|
||||
plugin you already run honours them — Kits, ZoneManager, anything that calls `UserHasPermission`.
|
||||
Nothing is required of those plugins and nothing is configured twice.
|
||||
|
||||
Four things an operator should know about it, because each looks like something else from the game
|
||||
side:
|
||||
|
||||
- **A wipe does not lose them.** The site re-pushes the whole set when the server comes back. If your
|
||||
wipe script clears `oxide/data/`, the permissions the site authored are back within a minute of the
|
||||
server being up; ones granted at the console are not, because nothing remembers those.
|
||||
- **Granting at the console still works, and the website notices.** A hand edit is reported as
|
||||
drift on that screen and is **never** undone on its own — an operator is offered two answers to
|
||||
it: adopt it, so the site maintains it from then on, or revoke it. That is deliberate: a console
|
||||
grant during an incident must survive the next sync.
|
||||
- **A permission no loaded plugin has registered cannot be granted.** Oxide's own API silently does
|
||||
nothing for an unknown name, so the site checks first and reports the name as unresolved instead
|
||||
of claiming a privilege nobody has. Load the plugin and the grant lands by itself.
|
||||
- **A player who has never connected to that server can hold a grant but cannot be in a group.**
|
||||
The store has no record of them to put in a group yet; the site says which memberships are waiting
|
||||
and they land on that player's first connection.
|
||||
|
||||
`rg.perms` at the server console prints what the last sync did, which is the fastest way to tell
|
||||
"that permission does not exist here" from "that player has never been seen here".
|
||||
|
||||
**From protocol 5 you can edit your plugins' settings from the website**, in Admin → Rust mod
|
||||
config. It reads the configuration directory your framework actually uses — `oxide/config` or
|
||||
`carbon/configs`, or wherever you moved it — and generates a form from the values it finds, so it
|
||||
works for whatever you have installed. Four things worth knowing before you use it:
|
||||
|
||||
- **A save reloads the plugin and watches the reload.** If the plugin does not come back within four
|
||||
seconds, the old file is **restored automatically** and the site shows you the log line that says
|
||||
why. A typo costs you a few seconds, not a plugin.
|
||||
- **Your data directory is not listed, deliberately.** `oxide/data` (or `carbon/data`) holds live
|
||||
state — kit cooldowns, zone definitions, the permission store itself — not settings. Editing it
|
||||
from a web form edits your players' cooldowns, and a running plugin overwrites the change on its
|
||||
next save anyway.
|
||||
- **Which plugin gets reloaded is your choice, with a guess filled in.** A folder name is
|
||||
convention, not contract, so the site suggests one and lets you change it. The suggestion is right
|
||||
nearly always and wrong silently when it is wrong, which is why it is a field rather than an
|
||||
assumption.
|
||||
- **This bridge's own `Host`, `Port` and `ServerId` are read-only there.** Changing them from the
|
||||
website would cut the link carrying the change, or strand every row the site holds for this
|
||||
server. Edit them on the host; everything else in that file is editable from the site.
|
||||
|
||||
`rg.config` at the server console prints which directory the site is reading and what the last write
|
||||
from it did.
|
||||
|
||||
**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
|
||||
|
||||
217
rust-link/PLAYER_WALK.md
Normal file
217
rust-link/PLAYER_WALK.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## The identity walk (protocol 3, phase 6)
|
||||
|
||||
Added 2026-09-21, and here for the same reason as everything above: **a link code reaches a player
|
||||
and nobody else**, so no console can read one. The site's own half was walked in a browser — the
|
||||
refusals, the admin panel, staff unlink, the rate limit — and what needs a person in game is the
|
||||
three steps below.
|
||||
|
||||
It takes two minutes, and it wants **two website accounts** — one you will link, one you will try to
|
||||
link the same Steam account to.
|
||||
|
||||
| # | Do this | You should see |
|
||||
|---|---|---|
|
||||
| 1 | **In game, type `/link`** | A private reply with a six-character code and a five-minute deadline. Check it is private: a second player on the server must not see it. The code has **no O, 0, I or 1** in it — those glyphs are not in the alphabet, so one in your code is a finding |
|
||||
| 2 | **Type `/link` again straight away** | *"Please wait a moment…"* — the thirty-second cooldown. The first code is now dead either way: a new request drops the old one, so only the newest ever works |
|
||||
| 3 | **On the website, sign in and open `/player/rust`. Type the code** | The account appears, named as the game knows you, with the server it came from. Try the same code again: *"That code is unknown or has expired"* — it works once |
|
||||
| 4 | **Sign in as the SECOND account and type a fresh code for the same Steam account** | Refused, naming the account that holds it: *"That Steam account is already linked to <name>. Run /unlink in game to release it."* The link must **not** move — it is what phase 7 grants permissions against |
|
||||
| 5 | **In game, type `/unlink`** | The site's row disappears within one ingest tick (five seconds by default). Reload `/player/rust` to confirm — this is the frame arriving over the feed, not the page asking |
|
||||
| 6 | **Type a code from a server whose sidecar you have just stopped** | *"One of the servers could not be reached… your code is still good — try again in a minute."* Distinct from step 3's refusal, and the distinction is the point: the code is fine and fetching another one would not help |
|
||||
|
||||
Step 6 needs a fleet of two, one of them down; on a single-server rig it reads *"The game servers are
|
||||
unreachable right now"* instead, which is the same rule with nothing left to be unsure about.
|
||||
|
||||
**What counts as a pass here:** the code never appears anywhere but in front of the player who asked
|
||||
for it (check the chat log and the sidecar's `/events?kind=account.link.requested` — the frame
|
||||
carries the steam id, the name and a TTL, and **no code**), a Steam account belongs to one website
|
||||
account at a time, and every refusal is a sentence that tells the player what to do next.
|
||||
|
||||
---
|
||||
|
||||
## The permission walk (protocol 4, phase 7)
|
||||
|
||||
Added 2026-09-21. The website half was walked end to end against a stand-in plugin — the authoring
|
||||
screen, the report, drift and its two answers, and a restart that emptied the store and was fully
|
||||
re-pushed. **What is left is the sentence the phase exists for: a grant made on the website gates a
|
||||
third-party plugin in the game.**
|
||||
|
||||
It cannot be walked from a console, and it cannot be walked on the owner's account:
|
||||
|
||||
- **A console session bypasses every gate.** The standard idiom is
|
||||
`return !player || permission.UserHasPermission(...)`, and an RCON command has no `BasePlayer` —
|
||||
so the console is unconditionally allowed ([PLAN.md §12.5](../modules/rust/PLAN.md)).
|
||||
- **An admin account bypasses most plugins' gates too**, and not uniformly: Popup Notifications
|
||||
(`player.IsAdmin ||`) and Zone Manager (`authLevel > 0 ||`) are hard bypasses. Kits is the
|
||||
exception — its `IsAdmin` is the `kits.admin` **permission** and `AdminIgnoreRestrictions`
|
||||
defaults to `false` — so a kit's `RequiredPermission` does apply to a server owner.
|
||||
|
||||
So this walk wants a **second, non-admin Steam account** connected to the rig. Kits alone can be
|
||||
walked on the owner's account; steps 4 and 5 cannot.
|
||||
|
||||
| # | Do this | You should see |
|
||||
|---|---|---|
|
||||
| 1 | **Link the second account** (the identity walk above), then on the website open Admin → Rust permissions and grant it a kit's `RequiredPermission` — pick the kit from `GetKitNames`, or read one out of `oxide/config/Kits.json` | The grant appears with the account beside it. Within a minute the server row reads **in sync** — or press *Sync now* and watch it happen |
|
||||
| 2 | **In game on that account, open the kit menu** | The kit is no longer locked. Before the grant it shows as locked; that difference is the whole phase |
|
||||
| 3 | **At the server console, `oxide.show user <steamid>`** | The permission is there, granted by this plugin rather than by hand |
|
||||
| 4 | **At the console, `oxide.grant user <steamid> zonemanager.admin`** (a permission the site manages but did not grant) | Within seconds the website's screen shows it under *Changed in game*. **Revoke** it there, and it is gone from `oxide.show user` on the next sync. **Adopt** a different one instead and it stays, now listed as the site's own |
|
||||
| 5 | **Put the second account in a group on the website, then wipe or restart the server** (a wipe script that clears `oxide/data/` is the interesting case) | After the server is back: the group exists again, the membership is back, and the grant is back — without anybody touching the website. This is R2's central promise and the one thing a stand-in cannot prove |
|
||||
| 6 | **Grant a permission whose plugin you have just unloaded** | The site reports it **unresolved** against that server and keeps the grant. Load the plugin again: it lands on the next sync, with nothing typed |
|
||||
| 7 | **Add a website account that has never connected to this server to a group** | The site reports the membership as *waiting on their first connection*. Have them connect: it lands. A **direct grant** to the same account, by contrast, is in `oxide.show user` immediately |
|
||||
|
||||
**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and
|
||||
this phase has two specific things to confirm there rather than assume:
|
||||
|
||||
- **`GetPermissionUsers` / `GetUsersInGroup` entry format.** Both answer `id(name)`, and the spacing
|
||||
differs between the calls and between the frameworks. The plugin takes everything before the first
|
||||
bracket. If that parse is wrong, **every holder is reported as foreign** — which is visible
|
||||
immediately: the drift list fills with grants the site itself made.
|
||||
- **`GetGroupPermissions(name, false)`** is called with both arguments. If Carbon's signature has no
|
||||
second parameter, the plugin does not compile there at all — the one place in protocol 4 where
|
||||
R19's byte-identical-plugin claim is at risk.
|
||||
|
||||
**What counts as a pass:** a non-admin player's access in game changes because of something typed on
|
||||
the website and nothing else; a hand edit is reported rather than undone; and a wipe costs the
|
||||
operator nothing.
|
||||
|
||||
## The configuration walk (protocol 5, phase 7b)
|
||||
|
||||
Added 2026-09-22. The website half was walked end to end against a real sidecar and a stand-in
|
||||
plugin over a real directory of real config files — the recursive walk, a form save, a rollback, a
|
||||
refusal, a version conflict and the locked keys — and the plugin half **compiles and loads on the
|
||||
live Oxide rig**, where `rg.config` answers
|
||||
`protocol=5 framework=oxide root=/home/container/oxide/config`.
|
||||
|
||||
**What is left is the sentence the phase exists for: a setting changed on the website takes effect
|
||||
in the running game.** It needs the sidecar and the game server on **one host**, because the game
|
||||
link is loopback by design (D2) — on the Pterodactyl rigs that is phase 18's egg, and on a
|
||||
development machine it is a firewall rule for the port the plugin dials.
|
||||
|
||||
| # | Do this | You should see |
|
||||
|---|---|---|
|
||||
| 1 | **Open Admin → Rust mod config** and pick the server | The tree the framework actually uses — `oxide/config` on Oxide, `carbon/configs` on Carbon — grouped by plugin, with every loaded plugin's version beside it |
|
||||
| 2 | **Open `ZoneManager.json`, change a setting, leave the reload target on its guess, and save** | "Saved, and the plugin reloaded." At the console, `oxide.show`/`c.show` is irrelevant — the proof is the plugin behaving differently, so pick a setting you can see: `Auto Show Zones`, or an entry message |
|
||||
| 3 | **Check a float nobody touched**, e.g. a rate ending `.0`, in the file on the host | It is still `1.0`, not `1`. This is the trap the whole editor exists for, and a server whose configs are full of whole-numbered floats is where it bites |
|
||||
| 4 | **Break a config on purpose** — in Raw JSON, give a numeric field a string, or anything the plugin's own class cannot deserialize — and save with that plugin as the reload target | Within about four seconds: *"The plugin did not come back, so the old file was put back automatically"*, the compiler's own line underneath it, and the file on the host back as it was. `oxide.plugins` shows the plugin **loaded** — because the restore was reloaded too |
|
||||
| 5 | **Save a nested file** (`Kits/kits.json`, or any `config/<Mod>/x.json`) **and confirm the reload target** | The right plugin reloads. Reloading the wrong one is the failure this field exists to prevent, and it reports success — so check `oxide.plugins`' timestamps, not the website's word |
|
||||
| 6 | **Open the bridge's own config** | `Host`, `Port` and `ServerId` are read-only with the reason; `QueueCap` saves; the reload dropdown does not offer this plugin. The save says it was written and **not** reloaded, which is the honest answer — our settings apply on the next deliberate reload |
|
||||
| 7 | **Edit a file on the host over SSH while the website has it open, then save from the website** | A conflict, with the current file offered — never an overwrite |
|
||||
| 8 | **Ask for a file outside the tree** (`../data/oxide.users.data`, an absolute path) with `curl` against the sidecar, with a valid token | Refused by the **plugin**, with a reason. The sidecar forwards paths and judges none of them; the guard is where the directory is |
|
||||
|
||||
**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and
|
||||
this phase has two specific things to confirm rather than assume:
|
||||
|
||||
- **The reload path.** The plugin asks `Interface.Oxide` for `ReloadPlugin` by reflection and falls
|
||||
back to a console command — `c.reload` on Carbon, `oxide.reload` on Oxide, chosen by looking for a
|
||||
Carbon assembly at runtime. A wrong prefix on Carbon prints **nothing at all**, which looks
|
||||
exactly like a command that worked (`CARBON.md` §5), so the proof is `OnPluginLoaded` arriving,
|
||||
not the command being accepted.
|
||||
- **`OnPluginLoaded` / `OnPluginUnloaded` firing at all.** They are the rollback's only evidence. If
|
||||
either does not fire on a framework, every save there rolls itself back four seconds later and
|
||||
reports a plugin that is in fact perfectly fine. `rg.hooks` at the console is the standing answer:
|
||||
both names are in `ExpectedHooks`, so a framework that never raises one shows a zero.
|
||||
|
||||
**What counts as a pass:** a setting typed on the website changes what the running game does; a
|
||||
deliberately broken config leaves the plugin loaded and the operator holding the reason; and no file
|
||||
the save did not touch differs by a single byte.
|
||||
@@ -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 — **5** as of configuration from the site (§11) — 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,760 @@ 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 4 is the transport, the read path, identity and the permission mirror. 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)~~ — **protocol 3, §9**
|
||||
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
|
||||
- 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 | — |
|
||||
| `account.link.requested` | `/link` chat command *(protocol 3)* | **staff** | steamId, name, ttlSec — **never the code** |
|
||||
| `account.unlinked` | `/unlink` chat command *(protocol 3)* | **staff** | steamId, name, origin |
|
||||
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## 9. Protocol 3 — identity
|
||||
|
||||
R1's identity link, and the first message in this bridge that the **website** originates. Everything
|
||||
in protocol 2 was the game talking, or the sidecar asking the game to repeat something it already
|
||||
knew.
|
||||
|
||||
The shape is the one the UO bridge proved: the player asks in game, the plugin mints a one-time code
|
||||
and hands it to them privately, and the website redeems it through the sidecar.
|
||||
|
||||
```
|
||||
player plugin sidecar website
|
||||
│ /link │ │ │
|
||||
├────────────────────►│ mint code, hold it │ │
|
||||
│◄────── code ────────┤ in memory, 5 min │ │
|
||||
│ ├─ account.link.requested ►│ ───── feed ───────►│
|
||||
│ │
|
||||
│ ………… the player types the code into the website ……………………………►│
|
||||
│ │ │◄ POST /link/confirm ┤
|
||||
│ │◄──── link.confirm ───────┤ │
|
||||
│ ├───── link.ok ───────────►│ ── steamId, name ──►│
|
||||
│ │ (code spent) │ │
|
||||
```
|
||||
|
||||
**Nothing about the link is stored in the game.** The site is the author of record, which is not a
|
||||
preference: there is no per-account store in Rust that survives a wipe, and phase 7 makes the site
|
||||
authoritative anyway — it pushes permissions *into* the game keyed by Steam id. A copy on the game
|
||||
host would be a second thing to reconcile every wipe, answering no question better.
|
||||
|
||||
### 9.1 `/link` and `/unlink` are CHAT commands, and the reply is private
|
||||
|
||||
`[ChatCommand("link")]`. Both frameworks consume a `/` command rather than broadcasting it, and
|
||||
`SendReply` addresses one player — so neither the request nor the code reaches anybody else's chat.
|
||||
That is load-bearing rather than polish: **a code read off a stream is a code somebody else can
|
||||
spend.**
|
||||
|
||||
`/unlink` emits rather than deletes, because the plugin holds no link to delete. It exists because
|
||||
the website **refuses** to move a Steam id another account already holds (D23): without a way out, a
|
||||
player who linked the wrong account while signed in as it would need staff. The authority on that
|
||||
path is the Steam account itself — whoever is connected to the game as it is who it is.
|
||||
|
||||
### 9.2 The code is **not** on the wire
|
||||
|
||||
`account.link.requested` carries the Steam id, the name and the TTL, and **never the code**. The
|
||||
event exists so an operator can see linking being used and so the site can see a player fishing; it
|
||||
is not how the code travels. The code travels **through the player**, which is what makes typing it
|
||||
into a signed-in browser proof that they are the one who asked.
|
||||
|
||||
Both account frames are **staff** class (§8.5). Neither carries a secret, but both name a Steam id
|
||||
beside a website account's activity, and that join — *this player is that person* — is a fact about
|
||||
somebody's identity rather than about what happened on the server.
|
||||
|
||||
### 9.3 `link.confirm` — website → plugin
|
||||
|
||||
The first inbound command that is not a request to repeat something.
|
||||
|
||||
```json
|
||||
{ "cmd": "link.confirm", "reqId": "r-42", "code": "K7M2PQ" }
|
||||
```
|
||||
|
||||
Answered with `link.ok` carrying `steamId` and `name`, or `link.error` carrying a `reason` of
|
||||
`unknown`, `expired` or `malformed`. Both are replies, correlated by `reqId` like `server.status`.
|
||||
|
||||
**A code is consumed on the FIRST lookup, whether or not it turns out to be expired.** The removal
|
||||
happens before the expiry check rather than after it, so a code cannot be probed twice.
|
||||
|
||||
**`unknown` and `expired` are separate here and identical to the player.** An operator reading a log
|
||||
wants to know whether codes are being guessed or merely going stale; a stranger typing codes must not
|
||||
learn which of the two they hit, because that is the difference between "keep guessing" and "guess
|
||||
faster".
|
||||
|
||||
### 9.4 The code itself
|
||||
|
||||
Six characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` — **no O, 0, I or 1**, because a player reads
|
||||
this off their screen and types it into a browser, often on a phone. A five-minute TTL, a
|
||||
thirty-second cooldown per player, **one outstanding code each** (a new `/link` drops the old one),
|
||||
and a purge timer, because an unconfirmed code is never looked up and nothing else would ever remove
|
||||
it.
|
||||
|
||||
They live in plugin memory and nowhere else. A plugin reload drops every pending code — and phase
|
||||
7b's config editor will reload plugins routinely — but the cost of that is a player typing `/link`
|
||||
again, which is cheaper than an unconfirmed credential living in a second process.
|
||||
|
||||
### 9.5 `POST /link/confirm` — the first route on this sidecar that is not a GET
|
||||
|
||||
```
|
||||
POST /link/confirm { "code": "K7M2PQ" } → 200 { "kind": "link.ok", "steamId": "765…" }
|
||||
→ 200 { "kind": "link.error", "reason": "unknown" }
|
||||
→ 503 the game is not connected
|
||||
→ 504 the game is up and did not answer
|
||||
```
|
||||
|
||||
**A refused code is a `200`.** `link.ok` and `link.error` are both answers; the sidecar reserves its
|
||||
own status codes for the transport, because the website has to tell *"that code is wrong"* from
|
||||
*"the game never replied"* to say the right thing to a player (§4.3).
|
||||
|
||||
The sidecar validates nothing but the shape — it trims the code, bounds its length, and forwards it.
|
||||
Only the game holds the pending codes, and putting the table here instead would give the sidecar a
|
||||
credential and an opinion, which D2 and the bridge principles say it has neither of.
|
||||
|
||||
### 9.6 The website asks EVERY server (D24)
|
||||
|
||||
A code is minted by one server, and the player types six characters into a browser. Nothing in the
|
||||
code says which server it came from, so the module asks each configured server in turn and the first
|
||||
`link.ok` wins; the others answer `unknown` and nothing happens there, because a code is only spent
|
||||
at the server that holds it.
|
||||
|
||||
Asking the player to pick was rejected: a wrong pick comes back indistinguishable from a wrong code.
|
||||
|
||||
The consequence for this protocol is worth stating, because it is the shape of every later
|
||||
fleet-wide command: **"every reachable server refused" is not the same answer as "a server could not
|
||||
be reached"**, and a module that collapses them tells the player whose server is down that their code
|
||||
is wrong — so they fetch another code from the same server and hear it again.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 10. Protocol 4 — the permission mirror
|
||||
|
||||
R2, and the first command on this bridge that **changes the game**. Protocol 3's
|
||||
`link.confirm` was the website originating a message, but it spent a code the game
|
||||
itself had minted; this writes to a store the game enforces.
|
||||
|
||||
```
|
||||
website sidecar plugin
|
||||
│ │ │
|
||||
├── POST /permissions/sync ►│ ──── perm.sync ─────────►│ diff against the
|
||||
│ the whole desired set │ (the same object) │ live store, apply
|
||||
│ │ │ the difference in
|
||||
│◄──── the report ──────────│◄──── perm.report ────────┤ bounded steps
|
||||
│ │
|
||||
│◄──── perm.drift (event) ──────────────────────────────┤ somebody else wrote
|
||||
```
|
||||
|
||||
**The website is the author of record and the framework's store is an enforcement
|
||||
cache.** Every third-party plugin honours a site grant with no adapter, because
|
||||
they all already call `permission.UserHasPermission` — reaching them is the point,
|
||||
and it is why the site does not keep a private table of its own.
|
||||
|
||||
### 10.1 One verb, and the PLUGIN does the diffing
|
||||
|
||||
`perm.sync` carries the whole set the site authors **for that server**. The plugin
|
||||
compares it against the live store and writes only what differs.
|
||||
|
||||
The alternative — the plugin reporting its store and the website computing the
|
||||
difference — was rejected for two reasons. The store is the bigger of the two sets
|
||||
and would cross the wire constantly, and a website holding a copy of it has a
|
||||
second source of truth that is stale the moment it lands.
|
||||
|
||||
```json
|
||||
{
|
||||
"cmd": "perm.sync",
|
||||
"reqId": "r-42",
|
||||
"setId": "69dfc769…",
|
||||
"groups": [
|
||||
{ "name": "vip", "title": "VIP", "rank": 10,
|
||||
"permissions": ["kits.vip"],
|
||||
"members": ["76561198000000001", "76561198000000002"] }
|
||||
],
|
||||
"grants": [
|
||||
{ "steamId": "76561198000000001", "permissions": ["kits.gold"] }
|
||||
],
|
||||
"managed": ["kits.vip", "kits.gold"],
|
||||
"retire": [
|
||||
{ "kind": "grant", "subject": "76561198000000003", "object": "kits.silver" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Means |
|
||||
|---|---|
|
||||
| `setId` | the site's digest of the set, echoed in the report. It is how the site knows a report describes the set it sent rather than an earlier one |
|
||||
| `groups` | group definitions, what each carries, and who is in it. **Three separate facts**, because the game can fail at each independently |
|
||||
| `grants` | permissions held by one account without a group |
|
||||
| `managed` | the permission namespace the site claims. Foreign holders are only looked for within it — which also bounds the scan by the site's own set rather than by the size of the store |
|
||||
| `retire` | what the site put there and has since withdrawn (§10.3) |
|
||||
|
||||
### 10.2 `perm.report` — what actually happened
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "perm.report", "type": "reply", "reqId": "r-42", "setId": "69dfc769…",
|
||||
"applied": { "grants": 1, "revokes": 0, "groupsCreated": 1, "groupPermissions": 1,
|
||||
"members": 2, "membersRemoved": 0, "groupsRemoved": 0,
|
||||
"groupPermissionsRemoved": 0 },
|
||||
"alreadyCorrect": 14,
|
||||
"absent": 0,
|
||||
"unresolved": ["kits.gold"],
|
||||
"pending": ["76561198000000003:vip"],
|
||||
"foreign": [{ "kind": "grant", "subject": "76561198000000009", "object": "kits.admin" }],
|
||||
"operations": 4
|
||||
}
|
||||
```
|
||||
|
||||
**`unresolved` and `pending` are the two ways a push looks like it worked and did
|
||||
not**, and both are load-bearing:
|
||||
|
||||
- **`unresolved`** — no loaded plugin on that server has registered the name.
|
||||
`permission.GrantUserPermission` returns void, throws nothing and logs nothing
|
||||
for an unregistered name ([PLAN.md §12.2](../modules/rust/PLAN.md) rule 1), so
|
||||
without the `PermissionExists` pre-check the grant vanishes without a trace. The
|
||||
plugin does **not** register the name itself: that fabricates a permission the
|
||||
operator never installed.
|
||||
- **`pending`** — the store has never seen that player, so there is no user record
|
||||
to put in a group (§12.2 rule 4). A **direct grant** to the same account works
|
||||
immediately, and the asymmetry is exactly why groups are not the only shape the
|
||||
site can express. The membership lands on their first connection.
|
||||
|
||||
Neither is recorded by the website as pushed. A site that recorded them would
|
||||
believe it had given a privilege it had not — and would later "retire" it from a
|
||||
server that never had it, which is a no-op that reads as a success in every log.
|
||||
|
||||
**A refusal of the whole sync is `perm.error`**, with a reason of `busy` (an
|
||||
earlier sync is still draining) or `too-large`. Like `link.error` it is a `200`
|
||||
from the sidecar: the transport worked and the game answered.
|
||||
|
||||
### 10.3 Retirement is the one thing the game cannot work out
|
||||
|
||||
A name in the store that is not in the desired set is **either** something the site
|
||||
authored and has since withdrawn **or** something a human granted at a console —
|
||||
and those two have opposite correct answers. The store records who granted a
|
||||
permission nowhere, so only the website can tell them apart, from its own memory of
|
||||
what it pushed.
|
||||
|
||||
So the site sends `retire` explicitly, and everything else it did not ask for comes
|
||||
back as `foreign`. **Nothing in `foreign` is ever removed by a sync** (D31): a
|
||||
console `oxide.grant` during an incident is drift, not an error, and an operator is
|
||||
offered two answers to it on the website — adopt it, or revoke it.
|
||||
|
||||
### 10.4 `perm.drift` — a reason to reconcile, not the reconciliation
|
||||
|
||||
Both frameworks raise a hook for every permission write. The plugin subscribes to
|
||||
six of them and emits `perm.drift` for writes **it did not make itself**, staff
|
||||
class (§8.5): it names a Steam id beside a privilege, which is a fact about a
|
||||
person's standing rather than about what happened on the server.
|
||||
|
||||
```json
|
||||
{ "kind": "perm.drift", "type": "event", "action": "granted",
|
||||
"steamId": "76561198000000009", "permission": "kits.admin" }
|
||||
```
|
||||
|
||||
`action` is one of `granted`, `revoked`, `group-added`, `group-removed`,
|
||||
`group-permission-granted`, `group-permission-revoked`.
|
||||
|
||||
**It cannot say whether the change is foreign** — only the desired set can, and
|
||||
that comparison happens in a sync. So the website treats the frame as a reason to
|
||||
reconcile *soon*: a hand edit shows up in seconds instead of at the next audit, and
|
||||
the authoritative answer still arrives as a report. That division is what makes the
|
||||
hooks safe to trust at this weight: one that stops firing on a framework upgrade
|
||||
costs latency, not correctness.
|
||||
|
||||
The plugin suppresses them while it is applying a sync, because they fire for its
|
||||
own writes too — and the site cannot tell its own grant from a human's by looking
|
||||
at one.
|
||||
|
||||
### 10.5 Nothing the far side sends may cost the main thread unbounded work
|
||||
|
||||
This is the first command whose work is **not** bounded by its own shape. A
|
||||
community with two thousand linked players sends thousands of store operations in
|
||||
one frame, and applying them in the tick the frame arrives is a freeze an operator
|
||||
will blame on the game.
|
||||
|
||||
So a sync is compiled into a list of single-store operations and drained a few
|
||||
hundred at a time on a timer; the report goes back when the last one lands.
|
||||
Compiling touches nothing, so an oversized or malformed sync is refused before any
|
||||
state exists to unwind. That is §5's rule — the one that keeps a wedged sidecar
|
||||
from stalling the game — pointed at the inbound half.
|
||||
|
||||
Three bounds, each on the side that can say something useful when it is hit:
|
||||
|
||||
| Bound | Where | Why there |
|
||||
|---|---|---|
|
||||
| ~15,000 rows | the website | it can name the server and reach an operator |
|
||||
| 1 MiB | the sidecar | it is the game link's own line cap (§3.1); forwarded, the line is discarded silently and presents as a `504` |
|
||||
| 20,000 operations | the plugin | past it, a half-applied permission set is the state nobody can reason about |
|
||||
|
||||
### 10.6 `GET /permissions/catalogue`
|
||||
|
||||
A live round trip to the plugin: every permission the loaded plugins have
|
||||
registered, and the groups the store holds. It is the option source behind the
|
||||
website's authoring form — a grant can only be written against a name that will
|
||||
actually resolve — and, like `/status`, it fails when the game is down, because
|
||||
"what exists right now" has no stale answer worth giving.
|
||||
|
||||
### 10.7 What the sidecar does NOT do
|
||||
|
||||
It defines no schema for either body. Protocol 4 adds the largest command on this
|
||||
bridge and touches neither the store nor the feed, which is §8.1's dumb-forwarder
|
||||
property paying for itself a second time.
|
||||
|
||||
What it does own is the envelope: `cmd` and `reqId` are written over whatever the
|
||||
caller sent, so no request can arrive claiming to be a different command or aimed
|
||||
at a correlation id somebody else is waiting on.
|
||||
|
||||
---
|
||||
|
||||
## 11. Protocol 5 — configuration from the site
|
||||
|
||||
R18, and the first command on this bridge that writes to the game host's
|
||||
**filesystem**. Protocol 4 wrote to a store the game owns through an API the game
|
||||
owns; this replaces bytes in a file and then asks the framework to read them.
|
||||
|
||||
```
|
||||
website sidecar plugin
|
||||
│ │ │
|
||||
├── GET /config/files ─────►│ ──── config.list ───────►│ walk ConfigDirectory
|
||||
│◄──── the tree ────────────│◄──── config.catalogue ───┤ (never DataDirectory)
|
||||
│ │ │
|
||||
├── GET /config/file ──────►│ ──── config.read ───────►│ one file + a version
|
||||
│ │ │
|
||||
├── POST /config/write ────►│ ──── config.write ──────►│ back up, write,
|
||||
│ whole file TEXT │ │ reload, WATCH
|
||||
│◄──── the report ──────────│◄──── config.report ──────┤ …or restore it all
|
||||
```
|
||||
|
||||
**The website composes the bytes and the plugin writes them.** That split is the
|
||||
one design decision everything else here follows from, and §11.5 is why.
|
||||
|
||||
### 11.1 The roots come from the framework, and one of them is forbidden
|
||||
|
||||
The walk is rooted at `Interface.Oxide.ConfigDirectory` — `oxide/config` on
|
||||
Oxide, `carbon/configs` on Carbon, and neither on a server whose operator moved
|
||||
it with `-carbon.configdir` ([`CARBON.md`](../modules/rust/CARBON.md) §3). It is
|
||||
never composed from a literal, and that amendment was proven the best way it
|
||||
could have been: this bridge's own config landed in **both** places, written by
|
||||
the same source file.
|
||||
|
||||
`DataDirectory` is **never walked**. It holds live state — kit cooldowns, zone
|
||||
definitions — and both frameworks' own permission stores (`oxide.users.data`,
|
||||
`oxide.groups.data`), which is protocol 4's mirror one directory over. A
|
||||
settings editor that strayed there would be editing §10 underneath itself.
|
||||
|
||||
### 11.2 `config.list` — a description of the tree, never its contents
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "config.catalogue", "type": "reply", "reqId": "r-7",
|
||||
"root": "/home/container/oxide/config",
|
||||
"self": "RunicGateway",
|
||||
"files": [
|
||||
{ "path": "ZoneManager.json", "bytes": 4210, "modified": 1758500000000,
|
||||
"plugin": "ZoneManager", "editable": true },
|
||||
{ "path": "Kits/kits.json", "bytes": 980, "modified": 1758400000000,
|
||||
"plugin": "Kits", "editable": true },
|
||||
{ "path": "Huge.json", "bytes": 9400000, "editable": false,
|
||||
"reason": "larger than this bridge will carry" }
|
||||
],
|
||||
"plugins": [ { "name": "ZoneManager", "title": "Zone Manager", "version": "3.1.14" } ],
|
||||
"truncated": false,
|
||||
"limits": { "depth": 6, "files": 500, "fileBytes": 262144, "writeFiles": 10 }
|
||||
}
|
||||
```
|
||||
|
||||
Four things about that shape are load-bearing.
|
||||
|
||||
**No file is hashed here.** A version is produced by `config.read`, on the one
|
||||
file somebody actually opened. Hashing 500 files would be up to 128 MB of reads
|
||||
in a single frame, which is the unbounded main-thread work §10.5 forbids — so
|
||||
this walk reads directory entries and nothing else.
|
||||
|
||||
**`plugin` is a GUESS and is labelled one all the way to the form.** It is the
|
||||
folder for a nested file and the filename otherwise, and a folder name is
|
||||
convention rather than contract. Infer it silently and the failure is the
|
||||
nastiest available here: the wrong plugin is reloaded, `OnPluginLoaded` fires for
|
||||
*it*, and the write is reported as a success while the plugin that was actually
|
||||
edited never re-read anything.
|
||||
|
||||
**A file past a limit is listed and marked, never hidden.** An operator who
|
||||
cannot find a file they know exists goes looking for a bug in the bridge; one who
|
||||
can see why it was refused does not.
|
||||
|
||||
**`self` is the plugin naming itself**, so the website can lock the three keys in
|
||||
*our* config that would cut this link (§11.6) without matching on a filename
|
||||
somebody may rename.
|
||||
|
||||
### 11.3 `config.read` — one file, and the version a write must present back
|
||||
|
||||
```json
|
||||
{ "kind": "config.file", "type": "reply", "reqId": "r-8",
|
||||
"path": "ZoneManager.json", "text": "{\n \"Auto Show\": true\n}",
|
||||
"version": "1a4-3f2c8a91b0de4471", "bytes": 420, "modified": 1758500000000 }
|
||||
```
|
||||
|
||||
`version` is the file's length and an FNV-1a hash of its text. It is deliberately
|
||||
**not** a cryptographic digest: nothing here is a security claim — the website
|
||||
never computes one, it only echoes back the one it was given — and
|
||||
`System.Security.Cryptography` is one more thing that would have to be available
|
||||
under two plugin compilers.
|
||||
|
||||
### 11.4 `config.write` — the set, the reload, and the undo
|
||||
|
||||
```json
|
||||
{ "cmd": "config.write", "reqId": "r-9",
|
||||
"files": [ { "path": "ZoneManager.json", "version": "1a4-3f2c…", "text": "{…}" } ],
|
||||
"reload": "ZoneManager" }
|
||||
```
|
||||
|
||||
The plugin, in order:
|
||||
|
||||
1. resolves and guards every path (§11.6), checks every version, and checks that
|
||||
every document parses — **before the first byte is written**. Same posture as
|
||||
`perm.sync`: a refusal that has touched nothing has nothing to unwind;
|
||||
2. backs each file up under `DataDirectory/RunicGateway/config-backups/`, keeping
|
||||
the last ten per file, and holds the original in memory for the rollback;
|
||||
3. writes the set;
|
||||
4. reloads the named plugin **through the framework**, not by composing a console
|
||||
string — Carbon's commands are `c.`-prefixed, an alias for the Oxide names is
|
||||
opt-in, and a wrong prefix on Carbon prints *nothing*, so it looks exactly
|
||||
like a command that worked;
|
||||
5. waits up to **four seconds** for `OnPluginLoaded` naming that plugin;
|
||||
6. if it arrives, re-reads each file and reports the new versions. If it does
|
||||
not, **restores every file, reloads again, and reports the failure with the
|
||||
tail of the server's newest log file.**
|
||||
|
||||
```json
|
||||
{ "kind": "config.report", "type": "reply", "reqId": "r-9",
|
||||
"ok": false, "reloaded": false, "rolledBack": true,
|
||||
"reason": "'ZoneManager' did not reload within 4s",
|
||||
"log": "…Error while compiling ZoneManager…",
|
||||
"files": [ { "path": "ZoneManager.json", "version": "1a4-…", "rewritten": false } ] }
|
||||
```
|
||||
|
||||
**That rollback is the feature.** Without it this is a web form that takes a
|
||||
required plugin off a production server one typo at a time — and four plugins are
|
||||
required (R6/R17), so a broken `ZoneManager` config is also event participation
|
||||
gone.
|
||||
|
||||
Three consequences worth naming:
|
||||
|
||||
- **The window is arithmetic, not taste.** The worst path is two windows — wait,
|
||||
give up, restore, wait again — and the caller holds a socket throughout. It
|
||||
must fit inside the sidecar's `REPLY_TIMEOUT` (§4.4, 10s), or the rollback
|
||||
report arrives after the only thing waiting for it has gone. The sidecar
|
||||
mirrors the number as `web::CONFIG_RELOAD_WINDOW` and a test asserts the
|
||||
inequality rather than trusting it.
|
||||
- **`rewritten` is normal.** Both frameworks merge missing defaults into a config
|
||||
on load and save it back, so the file after a successful reload is regularly
|
||||
not the file that was sent. The report says so; a website that assumed
|
||||
otherwise would conflict with itself on the next save.
|
||||
- **The bridge will not reload itself.** The reload would unload this plugin and
|
||||
close the link carrying the answer, leaving a rollback with nothing watching
|
||||
it — the one failure the mechanism exists to report would be the one it could
|
||||
not. `reload-self` is refused, and our own settings apply on the next
|
||||
deliberate reload instead.
|
||||
|
||||
### 11.5 JavaScript cannot tell `1` from `1.0`, so it never writes the number
|
||||
|
||||
`JSON.parse('{"Rate":1.0}')` yields `1` and `JSON.stringify` writes `1`. Both
|
||||
frameworks deserialize a config into typed C# classes, so a naive
|
||||
read-modify-write **silently rewrites every whole-numbered float as an integer,
|
||||
on fields nobody touched** — and Newtonsoft may coerce that or may throw. A throw
|
||||
at load is a plugin that does not come back.
|
||||
|
||||
So the website never parses, mutates and re-serialises. Its editor records the
|
||||
**source span** of every value and splices new literals into them, which is why
|
||||
`config.write` carries whole file text: the bytes on the wire are the bytes that
|
||||
will be on disk, and the fields nobody edited are byte-identical. A number's new
|
||||
value travels as the literal an admin typed, and never becomes a JavaScript
|
||||
number anywhere in the path.
|
||||
|
||||
The plugin's contribution to that is deliberately nothing beyond checking that
|
||||
the document parses. Giving this end an opinion about content would put the
|
||||
decision in two places, and only one of them can be tested against a real
|
||||
Newtonsoft.
|
||||
|
||||
### 11.6 Addressing by path is a new bug class, and it is guarded here
|
||||
|
||||
Protocol 4 addressed things by name. This addresses them by path, which is
|
||||
exactly the change that introduces traversal — so the plugin refuses a path that
|
||||
is absolute, carries a drive letter, contains `..`, does not end in `.json`, or
|
||||
does not resolve **under the canonicalised config root**. Links are not followed:
|
||||
any file or directory carrying a reparse point is skipped by the walk and refused
|
||||
by the resolver, because resolving one is how a tree that looks bounded turns out
|
||||
not to be.
|
||||
|
||||
The sidecar forwards the path verbatim and judges nothing, as it forwards a link
|
||||
code and a permission set. That is not laziness: only the process holding the
|
||||
directory can decide whether a path resolves inside it, and a guard in the middle
|
||||
would be a weaker second opinion in a place with no way to check it.
|
||||
|
||||
The website checks the *shape* before spending a round trip, and the bridge's own
|
||||
three keys — `Host`, `Port`, `ServerId` — are refused there rather than here,
|
||||
because "which file is ours" is a question about the website's configuration, not
|
||||
about the game's.
|
||||
|
||||
### 11.7 What the sidecar does NOT do
|
||||
|
||||
It stores nothing. Nothing from protocol 5 reaches the store or the feed: a
|
||||
config this sidecar cached would be an edit an operator made over SSH that the
|
||||
website then silently overwrote. All three routes fail when the game is down,
|
||||
like `/status`, because "what is on that host's disk" has no stale answer worth
|
||||
giving.
|
||||
|
||||
The one thing it adds is a better `504`. A timeout on `/config/write` is the only
|
||||
timeout on this bridge with a knowable answer, because the plugin writes a whole
|
||||
set or restores a whole set and never half of either — so the body says to
|
||||
re-read rather than to guess, and names the reload window that is probably still
|
||||
running.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user