21 Commits

Author SHA1 Message Date
15d64b28fe Merge pull request 'docs(modules): phase 7b as built — configuration from the site, and the undo that makes it safe' (#260) from docs/rust-phase-7b into main
Reviewed-on: #260
2026-09-22 15:01:26 +00:00
7f008fd1f3 docs(modules): the Carbon rig answers too, and its root is a different directory
R19's byte-identical plugin now carries protocol 5 on both frameworks:
`framework=oxide root=/home/container/oxide/config` on one rig and
`framework=carbon root=/home/container/carbon/configs` on the other, from the
same file. That is R18's amendment proven rather than argued — a literal
`oxide/config/` in this editor would have missed every config on half of all
installs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 08:58:21 -05:00
82145d3b4a docs(modules): phase 7b as built — configuration from the site, and the undo that makes it safe
PROTOCOL.md §11 specifies protocol 5: the walk rooted at the framework's own
config directory, the version a write must present back, the set-shaped write,
the watched reload and the automatic restore. PLAN.md §21 is the phase as built
— four org-lead decisions, the float trap and what avoiding it cost, and four
defects a browser found that 179 green tests did not.

PLAYER_WALK.md gains the configuration walk, because the acceptance line needs
the sidecar and the game on one host and the rig cannot reach one yet.
INTEGRATION.md gains the four things an operator should know before they edit a
plugin's settings from a web page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 08:55:39 -05:00
132205f0f4 Merge pull request 'docs(modules): phase 7 as built — the permission mirror, and the set arithmetic behind it' (#259) from docs/rust-phase-7 into main
Reviewed-on: #259
2026-09-22 06:53:00 +00:00
bba2ab04e0 docs(modules): phase 7 as built — the permission mirror, and the set arithmetic behind it
Protocol 4 (`PROTOCOL.md` §10), phase 7 as built (`PLAN.md` §20), what an operator
needs to know about it (`INTEGRATION.md`), and the in-game leg as a walk to run
(`PLAYER_WALK.md`).

**The spec.** One verb carrying the whole desired set, diffed by the plugin
against the live store; a report whose two interesting fields are the ways a push
looks like it worked and did not (`unresolved`, `pending`); drift as a report
rather than an action; and the permission hooks as a live SIGNAL rather than the
record — a hook that stops firing costs latency, not correctness.

**The finding the design turns on, written where it belongs.** A name in the store
that is not in the desired set is either something the site retired or something a
human granted, and those have opposite correct answers. The store records who
granted a permission nowhere, so only the website can tell them apart — which is
why it keeps a ledger of what it pushed, and why revoking a hand edit needed a
table of its own.

**§10.5 is a rule generalising.** "A wedged sidecar must never stall the game"
becomes "nothing the far side sends may cost the main thread unbounded work",
because `perm.sync` is the first command whose work is not bounded by its own
shape. Three bounds, each on the side that can say something useful when it is hit.

**§20.7 says plainly what is not proven**: the acceptance line needs a second,
non-admin Steam account on the rig, and nothing in the plugin has been compiled.
The walk doc carries the seven steps, including the two things to confirm on
Carbon rather than assume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 22:59:24 -05:00
4fe8864939 Merge pull request 'docs(modules): phase 6 as built — identity, and the sentence a player could not see' (#258) from docs/rust-phase-6 into main
Reviewed-on: #258
2026-09-21 22:24:42 +00:00
e35880e713 docs(modules): phase 6 as built — identity, and the sentence a player could not see
Protocol 3 in PROTOCOL.md §9, the identity walk in PLAYER_WALK.md, what an
operator needs in INTEGRATION.md, and PLAN.md §19.

Seven org-lead decisions (§19.0): /link is chat and its reply is private, codes
live in plugin memory as the UO bridge does, an alphabet with no O/0/I/1, a
Steam id another account holds is refused rather than moved, the website asks
EVERY server because a code does not say which one minted it, staff can sever a
link, and the activity-row overflow belongs to core.

§19.3 is the finding worth reading: a slot router is not registered under a tier,
so this repo own OpenAPI generator described two routes fewer than the module
serves — internally consistent, and wrong. The frozen-manifest job catches it,
which was verified by deleting the two paths and watching it fail.

§19.4 is what a browser found and 122 green tests did not. Core request
primitive reads data.message; this module has answered { error } since phase 1,
so every refusal this phase exists to write rendered as Service Unavailable.

The code-from-the-game half is written down rather than claimed: a code reaches
a player and nobody else, so no console can read one (D27).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 09:06:51 -05:00
54b4059091 Merge pull request 'docs(modules): phase 5 as built — Android leg A, and the capability a client had to be given' (#257) from docs/rust-phase-5 into main
Reviewed-on: #257
2026-09-17 09:21:44 +00:00
dfdb0a3f63 docs(modules): phase 5 as built — Android leg A, and the capability a client had to be given
module-rust's surface gets its second client. `modules/rust/PLAN.md` §18 records
the phase; `android/PLAN.md` M14 records the app's half, as every Android
milestone does.

The decision worth the most words is D16. This module declared five capability
strings and every one named a SURFACE — `servers`, `killfeed`, `leaderboard`,
`presence`, `wipes` — while a client gating a navigation group needs one that
names the MODULE. Core flattens every started module's capabilities into a single
list, so `servers` is a word another module could declare tomorrow and silently
reveal these screens on a site that does not run Rust. Gating on the module `id`
was considered and rejected in as many words: `id` is a mount prefix, §2.9
forbids inferring a route from a capability, and letting a client gate on `id`
makes the two the same value in practice.

Also recorded: D17 (poll while RESUMED — the phone's Page Visibility gate), D18
(the Rust repositories move to `edge`, releases at the cutover), D19 (the drawer
badge, and NavPaths learning `/rust`), why D15's footer slot had to be translated
rather than copied, and §18.4's three defects — none of which a green suite of
644 could see, because each is about what a screen looks like or when a number is
re-read.

§18.6 is deliberately short and honest: the website's own pages have still not
been read at phone width, and the badge's non-zero case was shown with a seeded
count rather than by people playing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 03:45:01 -05:00
2f24236993 Merge pull request 'docs(modules): phase 4 as built — the first pages, and the four defects a browser walk found' (#256) from docs/rust-phase-4 into main
Reviewed-on: #256
2026-09-17 02:42:51 +00:00
788100e048 docs(modules): phase 4 as built — the first pages, and the four defects a browser walk found
Records PLAN.md section 17 and marks the phase row done. Four decisions (D12-D15),
what is on the pages, and the four defects only a live walk could find — two of
them in code phase 3 had already shipped: an unreachable refresh that erased the
server's description, and a "last reported" line reading the timestamp of our own
poll rather than of the server's last frame.

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

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

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

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

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

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

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

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

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

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

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

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

What the phase found worth recording:

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 05:42:36 -05:00
3c8b430fae Merge pull request 'docs(modules): module-rust supports Carbon too, ships a Pterodactyl egg, and the rigs move to the panel' (#253) from docs/rust-carbon-and-pterodactyl into main
Reviewed-on: #253
2026-09-16 02:54:23 +00:00
22159ec78f docs(modules): the Carbon rig, R19 proven, and one refuted claim
rust-carbon (id 18, 87fb1f67) is built on the same egg with FRAMEWORK=carbon,
a clean install rather than a converted one. Install 122s, boot 543s.

R19 is proven. The byte-identical RunicGateway.cs that runs on the Oxide rig,
with no conditional compilation in it at all, loaded on Carbon 2.0.259.0 and
behaved identically - same startup line, same no-stall retry against an absent
sidecar. That contract now holds on three platforms from one source file:
Windows/Mono, Linux/Oxide, Linux/Carbon.

R18's amendment is confirmed the best way it could have been. The plugin's own
config, written by the same Oxide-compat API on both rigs, landed at
oxide/config/RunicGateway.json on one and carbon/configs/RunicGateway.json on
the other. Had the R18 editor used a literal path it would not have found its
own plugin's config on half of all installs, and no test would have caught it.

One claim is REFUTED, and it was wrong about Oxide as well as Carbon. CARBON.md
said Oxide stores permissions as JSON and Carbon as Protobuf or SQLite. Both
rigs say otherwise: both store Protobuf, under IDENTICAL filenames
(oxide.users.data, oxide.groups.data), differing only in directory - Carbon
writes its own data into files named after Oxide. This strengthens R2's
API-only rule rather than weakening it: a file reader would have worked on both
rigs today and broken silently for the one operator who ran c.migrate_perms_sql.

Two things nobody had thought to claim, found by looking. Carbon auto-creates a
third default group, moderator, which R2's full-set push must tolerate or
report drift for ever. And c.plugins exposes per-plugin hook telemetry that is
most of section 6's fired-hook mechanism, free, and only on Carbon.

Also: a wrong console command is indistinguishable from success at the API.
Pterodactyl's command endpoint returns 204 either way and Carbon prints nothing
for an unknown command, so oxide.plugins on Carbon looks exactly like it
worked. Anything driving the console must read a log to know.

CARBON.md gains a scorecard (section 10) and drops its unproven banner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 21:51:11 -05:00
069e715b1b docs(modules): the Pterodactyl rig as built, R22, and a correction
Section 14 records R21's first rig, made with the application key and driven
with the client key. Both work; neither can do the other's job, and there is
no egg-write endpoint at all, so a published egg is a JSON file a human
imports.

The tier-2 loop was run rather than described: phase 1's real RunicGateway.cs
pushed from the working tree, oxide.reload through the client API, the edit
observed in Oxide's own log, source restored. About ten seconds end to end.
Three things worked that were not certain to - files/write creates missing
parents, a plugin placed before Oxide exists survives Oxide arriving, and the
plugin compiles and loads on Linux, which no previous phase had established.

Two findings that change decisions. The image installs the framework on EVERY
boot, Carbon from the rolling production_build tag and Oxide from
releases/latest, so a restart is a framework upgrade and neither is pinnable
through the egg. And R20's "wrapper launches the sidecar then RustDedicated"
does not survive Carbon: the entrypoint prepends LD_PRELOAD to the whole
startup string, so the preload would land on the sidecar and the server would
start cleanly, report nothing, and be silently unmodded.

R22 (org lead): the sidecar's configuration moves into the egg's variables.
Nearly free - rust-link already reads all five keys from the environment with
env-over-file-over-defaults precedence - but the game bind must not be
operator-editable, the web bind must derive from an allocation, and the db
path must not be able to agree with REMOVE_FILES.

Also corrects an earlier claim in this branch. The client key listing zero
servers and includes returning empty were both CORRECT; the servers were
being deleted between reads. A differential diagnosis across two API calls
assumes the state did not move between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 21:30:06 -05:00
a29cdf0fae docs(modules): record the Pterodactyl client key and its empty-server-list problem
The token file now carries both keys. The client key authenticates as the
admin owner and then lists zero servers, on every listing type and for both
the short identifier and the full UUID, so tier 2 of R21's deployment loop is
not usable yet. Reinstall and SFTP are unaffected.

Records the diagnosis rather than only the symptom, including one trap worth
not repeating: includes are independently broken on this panel, so
servers?include=user returns an empty list where the same route without the
include returns six. Nothing built on an include is evidence about ownership.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 21:08:15 -05:00
5890da633f docs(modules): module-rust supports Carbon too, and ships a Pterodactyl egg
Three new decisions of record, and a new reference for the second modding
framework.

R19 - the bridge plugin is framework-agnostic from now, not ported later.
Carbon is not a fork of Oxide but a separate loader shipping an Oxide
compatibility layer, so one .cs in the Oxide.Plugins namespace serves both,
with #if CARBON only where the APIs genuinely differ. Three existing
decisions take an amendment and none is reversed: R18's config walk roots at
Interface.Oxide.ConfigDirectory rather than a literal oxide/config (Carbon
uses carbon/configs AND lets an operator relocate every directory from the
command line); R2's permission store is Protobuf or SQLite on Carbon, which
permanently closes the file-reading shortcut it never planned to take, while
the PermissionExists pre-check survives because Carbon's bool return is the
one thing we cannot read portably; R4's doctor asks which framework rather
than whether Oxide, and gets a weaker "current enough" claim because Carbon
ships rolling release tags.

R20 - a Pterodactyl egg is a third supported deployment path beside the
installer and the hand install, derived from the community "Rust Autowipe"
egg, which already carries a FRAMEWORK variable offering vanilla/carbon/oxide.
The sidecar runs inside the game's container, which is what lets D2 stand
unchanged: a container's 127.0.0.1 is genuinely private, so the game link
stays loopback and stays unauthenticated. Lands in phase 18 beside the
installer.

R21 - both rigs move to the Pterodactyl panel, because Oxide and Carbon
cannot coexist in one install and so a single server cannot prove R19. Also
retires the wipe-day maintenance that dominated section 4, and makes the rig
Linux where every prior finding came from Windows and Mono.

New: modules/rust/CARBON.md, the difference list - file layout, the
permission store, the c. commands, 30 Carbon-only hooks, and 13 uMod names
Carbon's catalogue omits (at least two of which look like renames). Sourced
from Carbon's own published metadata and source at main, and labelled
throughout as not yet proven on a live Carbon server.

One outstanding request, recorded in section 3: the panel token on disk is an
application key and Pterodactyl puts files, power and console on the client
API, so iteration needs a ptlc_ key only the account holder can mint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 20:59:23 -05:00
8 changed files with 3351 additions and 75 deletions

View File

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

View File

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

378
modules/rust/CARBON.md Normal file
View File

@@ -0,0 +1,378 @@
# Carbon — the second modding framework, and where it differs from Oxide
**Carbon** is the other framework modded Rust servers run. It is not a fork of Oxide and it does not
load Oxide; it is a separate loader that ships an **Oxide compatibility layer** — the `Oxide.Core`,
`Oxide.Plugins` and `Oxide.Game.Rust` namespaces, reimplemented — so that a plugin written for Oxide
compiles and runs unchanged.
This document exists because [`PLAN.md`](PLAN.md) **R19** commits `module-rust` to supporting both.
It records the places the two frameworks are *not* the same, because those are the only places our
code has to care. Everything not listed here is identical by construction.
> **Provenance.** Facts below were taken on **2026-09-15** from Carbon's own published metadata —
> `api.carbonmod.gg/meta/carbon/{hooks,commands,convars,switches}.json` — and from the
> [`CarbonCommunity/Carbon`](https://github.com/CarbonCommunity/Carbon) source at `main`, with the
> narrative pages at [carbonmod.gg](https://carbonmod.gg) as the prose source. Carbon is upstream
> and wins any disagreement, exactly as uMod does for [`OXIDE_API.md`](OXIDE_API.md). Nothing here
> is a Runic Gateway contract.
>
> **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.
---
## 1. The one-sentence version
**A plugin in the `Oxide.Plugins` namespace deriving from `RustPlugin` is Carbon's own documented
first example**, so the bridge plugin is one `.cs` file that serves both frameworks. What diverges is
not the plugin API but **the things around it**: where files live, how the permission store is
persisted, what the console commands are called, and which extra hooks exist.
```csharp
// Carbon's own "first plugin" page shows this, unchanged from Oxide:
namespace Oxide.Plugins;
[Info("MyPlugin", "<author>", "1.0.0")]
public class MyPlugin : RustPlugin
{
private void OnServerInitialized() => Puts("Hello world!");
}
```
Carbon also offers a native shape — `namespace Carbon.Plugins` / `CarbonPlugin` — which we do not
use and should not: it is the one choice that would make the source Carbon-only.
---
## 2. Telling the two apart
### At compile time — `#if CARBON`
Carbon feeds the Roslyn compiler a set of conditional-compilation symbols. **Oxide defines no
equivalent**, so `#if CARBON` / `#if !CARBON` is the portable framework branch, and an Oxide
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>` |
| `CARBON_ABV_<YYYY_MM_DD>` | Carbon protocol above a date |
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
*runtime* answer is needed — reporting which framework a server runs, in a `server.hello` say — ask
for the type rather than the file layout: `Carbon.Community` exists only under Carbon.
---
## 3. Where the files live — **the divergence that reaches the most decisions**
| Oxide | Carbon |
|---|---|
| `oxide/plugins/` | `carbon/plugins/` |
| `oxide/config/` | `carbon/configs/`**plural** |
| `oxide/data/` | `carbon/data/` |
| `oxide/lang/` | `carbon/lang/` |
| `oxide/logs/` | `carbon/logs/` |
| `oxide/extensions/`, plus `Oxide.Ext.*.dll` in `RustDedicated_Data/Managed` | `carbon/extensions/` only |
| — | `carbon/modules/`, `carbon/harmony/`, `carbon/developer/` |
**And none of those paths is fixed.** Carbon takes a command-line override for every single
directory — `-carbon.rootdir`, `-carbon.configdir`, `-carbon.datadir`, `-carbon.scriptdir`,
`-carbon.langdir`, `-carbon.logdir`, `-carbon.extdir`, `-carbon.moduledir`, `-carbon.modifierdir`,
`-carbon.profiledir`, `-carbon.carbonconfigdir`, `-carbon.sqlpermsdb`, `-harmonydir`. An operator
who has moved one is not doing anything unsupported.
**So the rule is: never compose a config or data path.** Carbon reimplements Oxide's own directory
accessors and populates them from its resolver:
```csharp
Interface.Oxide.ConfigDirectory // oxide/config or carbon/configs or wherever -carbon.configdir points
Interface.Oxide.DataDirectory
Interface.Oxide.PluginDirectory
Interface.Oxide.LangDirectory
Interface.Oxide.LogDirectory
Interface.Oxide.ExtensionDirectory
Interface.Oxide.RootDirectory
Interface.Oxide.InstanceDirectory
```
(`Carbon.Common/src/Oxide/OxideMod.cs` assigns each from `Defines.Get*Folder()`; `Interface.cs`
logs all eight at boot.) Asking the framework is both shorter and correct; hardcoding `oxide/config`
is wrong on Carbon and wrong on an Oxide server whose operator moved things.
**This is a direct amendment to R18.** The config editor's recursive walk is rooted at
`ConfigDirectory`, not at a literal `oxide/config/`; the directory it must refuse to walk is
`DataDirectory`, not a literal `oxide/data/`. The reasoning behind R18 is untouched — only the way
the two roots are obtained.
---
## 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`,
`GrantUserPermission`, `RevokeUserPermission`, `GrantGroupPermission`, `RevokeGroupPermission`,
`CreateGroup`, `RemoveGroup`, `AddUserGroup`, `RemoveUserGroup`, `UserHasPermission`,
`GroupHasPermission`, `GetUserGroups`, `GetUserPermissions`, `GetGroupPermissions`,
`GetPermissionUsers`, `GetPermissionGroups`, `GetGroups`, `GetUsersInGroup`, `SetGroupParent`.
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
unregistered permission silently does nothing. Calling it as a statement compiles on both, so the
source stays single. But **the bool cannot be read portably**, so the `PermissionExists` pre-check
stays the mechanism on both frameworks rather than being replaced by a return value on one. Carbon
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 — **this section was wrong, and the truth is worse**
> **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`,
`OnUserGroupAdded`, `OnUserGroupRemoved`, `OnGroupCreated`, `OnGroupDeleted`, `OnGroupParentSet`,
`OnGroupRankSet`, `OnGroupTitleSet`, `OnGroupPermissionGranted`, `OnGroupPermissionRevoked`,
`OnPermissionRegistered`, `OnPermissionsUnregistered`, `OnUserNameUpdated`. Our uMod mirror carries
most of these as universal hooks too, so drift may be **observable as it happens** on both rather
than only diffable on connect. Phase 7 should test that rather than assume it; a hook that fires on
our *own* push is a feedback loop to suppress, not a bonus.
---
## 5. Console commands — `c.` not `oxide.`
Carbon's 129 published commands are `c.`-prefixed. The ones with Oxide counterparts:
| Oxide | Carbon |
|---|---|
| `oxide.grant` / `oxide.revoke` | `c.grant` / `c.revoke` |
| `oxide.group` | `c.group` |
| `oxide.usergroup` | `c.usergroup` |
| `oxide.load` / `oxide.unload` / `oxide.reload` | `c.load` / `c.unload` / `c.reload` |
| `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**. **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
instruction naming `oxide.grant` needs its Carbon line — and **any place we drive a reload by
console string**, which R18's write path does. Resolve the reload through the framework rather than
by composing a command, or branch it on `#if CARBON`.
---
## 6. Hooks — Carbon is a superset, with thirteen names it does not list
Carbon publishes **894 hook entries, 774 unique names, in 42 categories**, against the **476** on
uMod's Rust hooks page that [`HOOKS.md`](HOOKS.md) mirrors. The larger number is not more game
coverage; Carbon documents patched methods our mirror's audience never sees.
Carbon flags every entry for compatibility. **30 are Carbon-only. Zero are marked Oxide-only.**
### The 30 Carbon-only hooks
| Hook | Category | What it is |
|---|---|---|
| `CanAcceptBackpackItem` | Global | Whether to accept a backpack item |
| `CanPatrolHeliSeePlayer` | Global | Patrol-helicopter line of sight to a player |
| `CanPickupAllFromRack` | Global | Taking every weapon from a rack |
| `CanPickupFromRack` | Global | Taking one weapon from a rack |
| `CanPlaceOnRack` | Global | Placing on a rack |
| `OnPickupFromRack` | Global | Controls taking items from a rack |
| `CanPlayerInheritNetworkGroup` | Global | Network-group inheritance |
| `OnChairComfort` | Global | Chair comfort |
| `OnChickenScared` | Global | A chicken is scared |
| `OnGrowableUpdate` | Global | A growable updates |
| `OnConsoleCommand` | Global | A console command is executed |
| `OnNativeCommandHasPermission` | Global | Permission check on a native console command |
| `OnEntitySpawn` | Global | An entity spawns — **not** Oxide's `OnEntitySpawned`, which exists on both |
| `OnJackieChan` | Global | Undescribed upstream |
| `OnCarbonBanPlayer`, `OnCarbonUnbanPlayer`, `OnCarbonKickPlayer`, `OnCarbonMutePlayer` | Player | Carbon admin-module moderation actions |
| `OnCarbonBlinded`, `OnCarbonUnblinded`, `OnCarbonSpectateStart`, `OnCarbonSpectateEnd` | Player | Carbon admin-module spectate and blind actions |
| `OnCarbonPrivateMessage`, `OnCarbonEmpowerPlayerStats`, `OnCarbonLockPlayerContainer` | Player | Carbon admin-module player actions |
| `OnCompilationFail`, `OnConstructorFail` | Engine | Plugin compile / constructor failure |
| `OnPluginCompileFailure`, `OnPluginOutdated` | Plugin | Plugin lifecycle |
| `OnMarketplaceTerminalPurchase` | Vending | Marketplace terminal purchase |
**None of them is load-bearing for us and none should become so.** The `OnCarbon*` family is the
Carbon admin module's own audit trail — tempting for a staff-actions feed, and exactly the kind of
convenience that quietly makes Carbon the required framework. If we ever want that feed, it has to
have an Oxide answer first.
### The 13 uMod names Carbon's catalogue does not carry
| Hook | Category | uMod's description |
|---|---|---|
| `CanNpcAttack` | Entity | An NPC attempts to attack another entity |
| `CanPushBoat` | Player | Cancelling a boat push |
| `CanUnlockTechTreeNode` | TechTree | Unlocking a blueprint in a tech tree |
| `CanUnlockTechTreeNodePath` | TechTree | …after the path check |
| `OnFrame` | Server | Each frame |
| `OnHelicopterKilled` | Entity | A CH47 is going to be killed |
| `OnNpcDestinationSet` | Entity | Cancelling an NPC destination change |
| `OnNpcPlayerResume` | Entity | Cancelling `TryForceToNavmesh` |
| `OnNpcStopMoving` | Entity | Denying an NPC move stop |
| `OnPlayerCorpse` | Player | A non-null corpse has spawned |
| `OnQuarryEnabled` | Resource | A mining quarry is turned on |
| `OnTeamInvite` | Team | Cancelling a team invitation |
| `OnTeamPromote` | Team | Cancelling a promotion |
**Absent from a catalogue is not the same as absent from the framework**, and two of these look like
renames rather than holes: Carbon lists `OnTeamMemberInvite` and `OnTeamMemberPromote` in its `Team`
category, which is `OnTeamInvite` and `OnTeamPromote` under different names. Carbon's `Team`
category also carries visible duplicates and both tenses of the same event (`OnTeamCreate` *and*
`OnTeamCreated`, `OnTeamUpdate` *and* `OnTeamUpdated`, `OnTeamMemberInvite` twice), which says the
catalogue is generated rather than curated.
So this table is **a list of things to check on a live Carbon server**, not a list of losses. The
practical protection is one we already committed to in [`PLAN.md`](PLAN.md) §6: *hooks bind by name
and arity through reflection with no compile-time check*, so the plugin logs which of its expected
hooks have fired at least once. That mechanism was written for Facepunch renaming a hook on wipe
day; it answers this question too, on either framework, without us having to trust either catalogue.
**None of the 13 is currently in a phase.** R5 settled Teams on Rust's **first-party clans**, not
first-party Teams, so `OnTeamInvite`/`OnTeamPromote` are outside the plan as written.
---
## 7. Convars — a Carbon-only set exists, and leases must not reach for it
Carbon publishes 23 convars of its own, several of them precisely the kind of live, gameplay-shaped
value [`PLAN.md`](PLAN.md) §9 wants to lease — `c.recycletickmultiplier`,
`c.safezonerecycletickmultiplier`, `c.researchdurationmultiplier` and so on, most flagged
`ForceModded`.
**A lease over one of those would work on Carbon and be undeclarable on Oxide.** Lease keys are
advertised to the event authoring form, and a key that silently does not exist on half of installs
is the failure `EVENTS.md` §H's *verify every key live* rule exists to prevent. So: **the lease
catalogue is drawn from the game's own convars, which both frameworks expose identically.** If a
Carbon-only key is ever worth the cost, it is advertised conditionally on the connected server's
framework, and that is a deliberate decision rather than an oversight.
---
## 8. Operating differences that reach deployment
- **They cannot coexist.** Oxide ships a patched `Assembly-CSharp.dll`; Carbon requires
Facepunch's vanilla one and patches in memory through Harmony. One install runs one framework, so
**one rig cannot prove both** — which is why [`PLAN.md`](PLAN.md) R21 moves the rigs to
Pterodactyl and runs two.
- **Carbon migrates an Oxide install on first boot** — it copies config, data, lang, user and group
files across and relocates `Oxide.Ext.*.dll` out of `RustDedicated_Data/Managed`. Useful for an
operator; a hazard for a test rig, because a Carbon rig built by converting an Oxide one starts
with the Oxide one's state and proves less than a clean install.
- **Carbon self-updates and its releases are rolling tags**, not versioned ones:
`production_build` (v2.0.259 at the time of writing, 2026-09-06), plus `edge_build`,
`experimental_build` and per-branch Rust builds. Oxide publishes an incrementing build number.
**So "which Carbon is this" is not answerable the way "which Oxide is this" is**, and R4's
`doctor` prerequisite check has to accept that — it can establish *that* Carbon is installed and
report the build it reports, but "current enough" is a weaker claim on Carbon than on Oxide.
- **Carbon patches hooks only when a plugin subscribes**, so an unsubscribed hook costs nothing.
That rewards the selective subscription R17 already requires for ZoneManager's chatty zone
transitions, on Carbon more than on Oxide.
---
## 9. What this costs us, in one table
| Decision | Change |
|---|---|
| **R2** permissions | None to the design. The store is API-only on Carbon *by construction* rather than by choice, and `PermissionExists` stays the check because the useful return value is Carbon-only |
| **R4** installer | `doctor` detects *which* framework, not *whether Oxide*; the payload drops into `PluginDirectory`; "current enough" is weaker on Carbon (rolling tags) |
| **R18** config editor | Roots come from `Interface.Oxide.ConfigDirectory` / `DataDirectory`, never literals; the reload is resolved through the framework, not by composing `oxide.reload` |
| **R6/R17** base mods | Unchanged — Kits, Clans, PopupNotifications and ZoneManager are Oxide plugins and Oxide plugins run on Carbon |
| **§9** event leases | Keys come from the game's convars; Carbon's own convars are out of the catalogue unless advertised conditionally |
| 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.

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,13 @@
# Rust — the Oxide/uMod ecosystem reference
# Rust — the modding-framework reference
Reference material for the **upcoming `module-rust`**: a mirror of the uMod/Oxide documentation —
the Rust game API *and* the game-independent plugin framework around it — captured here so the
module can be designed and built against it without a round trip to umod.org on every question.
Reference material for **`module-rust`**: a mirror of the uMod/Oxide documentation — the Rust game
API *and* the game-independent plugin framework around it — captured here so the module can be
designed and built against it without a round trip to umod.org on every question.
Everything below was **scraped verbatim from uMod on 2026-09-15**.
The mirrored material was **scraped verbatim from uMod on 2026-09-15**. One file,
[`CARBON.md`](CARBON.md), covers the *other* framework modded Rust servers run: PLAN.md **R19**
commits this module to supporting Oxide and Carbon both, and that file records only where the two
differ.
## The mirror
@@ -15,6 +18,7 @@ Everything below was **scraped verbatim from uMod on 2026-09-15**.
| [`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, 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
@@ -40,8 +44,12 @@ The dry run's central structural fact is the thing this reference serves:
> A ServUO shard is C# **source** the operator compiles into their own server, so our bridge plugin
> can be anything we want. **A Rust server is a binary nobody outside Facepunch patches.** The only
> way in is a mod — specifically an **Oxide plugin**, since Oxide/uMod is what modded Rust servers
> run — hooking the game's own events.
> way in is a mod — hooking the game's own events through a modding framework.
The dry run named that framework as Oxide, and **R19 corrected it: there are two.** Carbon runs an
Oxide compatibility layer, so one plugin serves both and the ceiling below is the same ceiling —
but *which* framework an operator installed is their choice, not ours. [`CARBON.md`](CARBON.md) is
the difference list.
Two consequences, and they are the two halves of this directory:
@@ -50,8 +58,8 @@ Two consequences, and they are the two halves of this directory:
those 477 hooks (or from a game type one of them hands you), the bridge cannot report it. That
makes it the input to the Rust sidecar's event catalogue — the analogue of
[`docs/link/PLAN.md`](../../link/PLAN.md) §5 on the UO side.
2. **We are a guest in someone else's plugin framework.** Our plugin is compiled, loaded, permissioned
and configured by Oxide, on Oxide's terms. [`OXIDE_API.md`](OXIDE_API.md) is that rulebook, and
2. **We are a guest in someone else's plugin framework** — and we do not get to pick which one. Our
plugin is compiled, loaded, permissioned and configured by Oxide or by Carbon, on its terms. [`OXIDE_API.md`](OXIDE_API.md) is that rulebook, and
[`OPERATING.md`](OPERATING.md) is what the server owner has to do — which is the surface our
deployment story has to sit on, the way
[`installer/INSTALL.md`](../../installer/INSTALL.md) sits on top of ServUO.

View File

@@ -164,6 +164,17 @@ Two failures that look alike and are not:
says what was true when it was written; nothing has written it since. Either the poll is failing
(the website's log) or the sidecar stopped (its own).
### 3.0 `untyped_frames` on `/health` is not zero
**The plugin and the sidecar are on different protocol versions.** The game link has no handshake
to catch that at connect time (`PROTOCOL.md` §2), so it shows up here instead: the sidecar files a
frame by its `type`, a frame from the wrong version does not carry one it recognises, and it is
dropped and counted rather than guessed at.
The symptom without this counter is the confusing one — a game server plainly up, a sidecar plainly
healthy, and a website showing nothing. Check the plugin's `rg.link` (it prints its protocol) against
the sidecar's `/health` (which prints its own) and upgrade whichever is behind.
### 3.1 The failures that are supposed to happen
Three things look like breakage and are the design:
@@ -193,6 +204,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
View 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.

View File

@@ -50,7 +50,8 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer, declared in **four** places that must agree:
The wire version is a single integer**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 11000 |
| `GET /events?kind=&wipe=&limit=` | the store | Newest first; `limit` clamped to 11000. For a human |
| `GET /feed?since=&limit=` | the store | **Oldest first**, from a cursor. For a consumer that must not miss a row (§8.9) |
| `GET /status` | the plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply |
| `GET /ws` | broadcast | The live feed; sends `{"kind":"ws.hello","protocol":1}` on connect |
@@ -304,6 +310,7 @@ override the file.
| `[web].bind` | `RUSTLINK_WEB_BIND` | `127.0.0.1:8090` |
| `[web].auth_token` | `RUSTLINK_WEB_TOKEN` | *(generated on first start)* |
| `[store].path` | `RUSTLINK_DB_PATH` | `rust-link.db` |
| `[store].retain_days` | `RUSTLINK_RETAIN_DAYS` | `14` |
Two things about those are load-bearing:
@@ -323,15 +330,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 319 needs per-hit or per-frame fidelity. R17's warning about chatty zone transitions is the
same rule: **subscribe selectively; the cost of a hook is paid on the game's main thread.**
### 8.5 The class is enforced by the **module**, not declared on the wire
The wire carries no visibility field, and this is a security decision rather than an economy.
**A boundary must be enforced by the side that serves, never declared by the side that sends.** The
website's own shard fan-out works this way — a public SSE stream with an allowlist of event kinds,
and an admin stream that adds the rest — and the property that makes it trustworthy is that a
compromised or merely out-of-date sender cannot widen it. A `"class":"public"` field on the frame
would move the decision to the game host.
So: the table in §8.4 is the specification, `module-rust` holds the allowlist, and it is
**default-deny** — a kind the allowlist has never heard of is not public. The module's own test holds
its allowlist against this document, so adding a kind here without classifying it there fails a
build rather than shipping an IP address to a public page.
`player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and
`player.reported` carries the text of one player's complaint about another. They are stored because
an operator chasing ban evasion needs them and because the sidecar persists what it is told; they
reach no tier below admin, and the raw window that holds them is bounded (§8.9).
### 8.6 Two things are aggregated in the plugin, and that is the interesting part of this phase
`OnDispenserGather` fires on **every swing at a tree**. A single player chopping for a minute is
hundreds of hooks; ten players gathering is a frame rate problem in the bridge rather than in the
game. The same is true of animal and scientist kills, at a lower rate.
Neither is interesting per occurrence — nobody wants a killfeed of chickens — and both are wanted
*in total*, for the leaderboard. So the plugin keeps a per-player tally on the main thread and flushes
it as one `player.tally` frame:
- on a **60-second cadence**, for players with a non-zero tally;
- on **disconnect**, so a session's last minute is not lost;
- at `OnServerShutdown`, which is the flush that covers a restart.
A plugin *reload* is the one case that loses a tally, by choice: `Unload` runs on the game's main
thread, and draining the outbound queue there means waiting on a socket from the main thread — the
stall phase 1 removed. Under a minute of one player's gathering is the price, and a wedged peer
would make the cure worse than the disease.
A tally frame is a **delta, not a running total** — it reports what happened since the last flush,
so the consumer sums rather than diffs and a missed frame costs that interval instead of corrupting
the series.
This is the general rule for every later wave: **if a hook can fire more than once a second per
player, it is a counter, not an event.**
### 8.7 The read path never vetoes, and it is structural rather than disciplined
Four hooks in §8.4 are documented by uMod as *"returning a non-null value overrides default
behavior"* — `OnPlayerDeath`, `OnDispenserGather` and `CanUserLogin` among them. A read-path bridge
that returned something by accident would cancel a death, swallow a player's wood, or refuse a
login, and it would do it on a production server at 3am.
**So every vetoable hook in the read path is declared `void`.** Both frameworks bind hooks by name
and arity and take the method's return value; a `void` method returns nothing and therefore cannot
override anything. The rule is enforced by the signature rather than by remembering to write
`return null`, which is the only version of this rule that survives a year of edits.
`CanUserLogin` is in the wave for what it *observes*, never for what it answers.
### 8.8 A login denial is not a hook — and §10 of `PLAN.md` says it is
`PLAN.md` §10 sources the `rust.login.denied` trigger from `CanUserLogin`. Reading the hook says that
cannot work: `CanUserLogin` is called on **every** connection attempt, and the only way to learn of a
denial from it is to *be* the denier, which §8.7 forbids. uMod publishes no `OnUserRejected`.
What the game can actually tell us is two facts — an attempt, and an approval — so protocol 2 emits
both and **a denial is the absence of an approval** for an attempt, decided by a deferred read rather
than by a hook. Phase 10 owns that pairing; protocol 2 owes it the two frames and the `t` on each.
Recorded here because it is a correction to a catalogue, not a defect: the trigger survives, its
source changes.
### 8.9 History, cursors and retention
Three changes on the sidecar's own side follow from a catalogue that actually produces volume.
**`events` gains `server_id` and `wipe_id` as indexed columns.** This is the one migration shape the
store's own header predicted: *"only a version that adds a new indexed column ever needs a
migration"*. It is applied as an `ALTER` guarded by a column check, never as an edit to the `CREATE`
— the same rule the website's schema fragments live under, for the same reason.
**A new route, `GET /feed?since=&limit=`, is the ingest cursor**, and it is deliberately *not*
`/events` with a flag:
| Route | Order | For |
|---|---|---|
| `GET /events?kind=&wipe=&limit=` | newest first | a human, an admin screen, a point-in-time look |
| `GET /feed?since=&limit=` | **oldest first**, from a cursor | a consumer that must not miss a row |
One route with two orderings depending on a query parameter is a trap: every caller that forgets the
parameter gets the other one silently, and for the ingesting caller that means it advances its cursor
past rows it never read. Two routes, one ordering each.
`/feed` items are wrapped rather than bare, because a cursor needs the row's identity:
```json
{ "items": [ { "id": 1041, "t": 1789, "kind": "player.death", "frame": { } } ],
"lastId": 1041, "more": false }
```
`more` is `true` when the page filled, so a consumer that has fallen an hour behind drains at its own
pace instead of guessing from a count.
**Omitting `since` asks where the end is** — no rows, and the current `lastId`. `since=0` is the
other question entirely: replay everything retained. That is deliberate, because the two intentions
must not be separated by whether somebody typed a parameter: a module installed today against a
month-old sidecar wants what happens next, not a fortnight of deaths it has no rollups for.
**The store prunes.** `[store].retain_days` (default 14) bounds the event history, swept hourly.
Three things make that safe rather than lossy: the website holds the permanent per-wipe rollups
(R12), boards are never pruned because they hold exactly one row per kind, and the sidecar's database
lives inside a game container whose disk is the operator's (R20). A store that grows without bound on
a game host is a wipe-day outage waiting for a busy month.
---
## 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.
---