Compare commits
16 Commits
2f24236993
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| edff0e3695 | |||
| eea4f958b2 | |||
| de25ed3276 | |||
| a4c4476c3e | |||
| 3b7b9cca4f | |||
| eb4a8bcafc | |||
| 11b8965aa7 | |||
| 15d64b28fe | |||
| 7f008fd1f3 | |||
| 82145d3b4a | |||
| 132205f0f4 | |||
| bba2ab04e0 | |||
| 4fe8864939 | |||
| e35880e713 | |||
| 54b4059091 | |||
| dfdb0a3f63 |
221
android/PLAN.md
221
android/PLAN.md
@@ -1307,6 +1307,227 @@ push, and Play (M6–M8) follow the designed app.
|
|||||||
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
|
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.
|
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.
|
||||||
|
|
||||||
|
16. **M15 — the player's own Rust account** (post-v1; built 2026-09-22). `module-rust` phase 8, and
|
||||||
|
R10's leg B: identity (phase 6) and the half of site-owned permissions (phase 7) a player is
|
||||||
|
allowed to see. Every route existed and answered before a line of Kotlin was written except one,
|
||||||
|
`GET /player/rust/permissions`, which this phase added on the website for exactly this screen.
|
||||||
|
|
||||||
|
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §22 for the phase as
|
||||||
|
built and its three decisions. The contract is normative there; this entry records what the app
|
||||||
|
does about it.
|
||||||
|
|
||||||
|
#### It is `CharactersScreen` for a different game, deliberately
|
||||||
|
|
||||||
|
The org lead's instruction was to mirror `module-uo`, and the mirror is exact: **one drawer row
|
||||||
|
under the player group**, with the code card at the top of the screen it opens — the same shape
|
||||||
|
UO has had since M4, where the `[link` card sits above the character rosters. Two alternatives
|
||||||
|
were rejected for reasons the mirror makes obvious: a tab under one Rust server (a link is
|
||||||
|
**fleet-wide** — one Steam account is one person on every server, while stats are per server and
|
||||||
|
per wipe), and a section inside core's own Account screen (the app has no slot mechanism, so the
|
||||||
|
module's data would be hard-wired into a core screen).
|
||||||
|
|
||||||
|
| Screen | Route | Reads |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **My Rust account** | `player/rust` | `GET /player/rust/links`, `POST /player/rust/link`, `DELETE …/links/{steamId}`, `GET /player/rust/permissions` |
|
||||||
|
|
||||||
|
#### The gate is `rust`, and `PLAYER` means staff too
|
||||||
|
|
||||||
|
`module-uo`'s five shard rows all hang on `shard`, and this hangs on `rust` for the same reason
|
||||||
|
D16 gave: a capability answers *is the module there*, and core flattens every started module's
|
||||||
|
capabilities into one list, so a surface word like `identity` is not something a row may hang
|
||||||
|
on. `MenuAccess.PLAYER` is `isPlayer || isStaff`, which is right here — `/player/rust/*` is
|
||||||
|
`requireAuth` with no role above it, and staff play the game as well.
|
||||||
|
|
||||||
|
#### Two reads, and neither blocks the other
|
||||||
|
|
||||||
|
The accounts and the entitlements load separately and fail separately. That is not tidiness: an
|
||||||
|
entitlement is authored against the **website** account, so it exists before a Steam id does, and
|
||||||
|
the person who has just been given something and has not linked yet is exactly the one who needs
|
||||||
|
to see both halves at once. The screen says so in as many words when nothing is linked.
|
||||||
|
|
||||||
|
**The app does no scope arithmetic.** `*` never reaches a screen: each entry arrives carrying the
|
||||||
|
servers its scope reaches, each already marked *has it* or *waiting*, because a second
|
||||||
|
implementation of that rule is a second thing to keep true.
|
||||||
|
|
||||||
|
#### The refusals stay four pieces of advice
|
||||||
|
|
||||||
|
A refusal is chosen by **status** and rendered from a string resource, the convention every
|
||||||
|
earlier milestone follows (the app is localized; the website's sentence is not). 400 is a spent
|
||||||
|
code, 409 is a Steam account another website account holds — released with `/unlink` in game,
|
||||||
|
never moved silently — 429 is the server's limiter, and 503 is a server that could not be
|
||||||
|
reached. The last of those may **not** say "get a new code": the code is still good, and a player
|
||||||
|
told otherwise goes back to the same unreachable server for another one.
|
||||||
|
|
||||||
|
The known cost, written down rather than discovered later: the website distinguishes three 503s
|
||||||
|
by sentence (one server unreachable, the whole fleet down, no servers configured at all) and the
|
||||||
|
app has one string for the status, written to be true of all three.
|
||||||
|
|
||||||
|
#### Verified
|
||||||
|
|
||||||
|
The app suite (**657 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||||
|
against a core with the module installed and a **live Oxide rig** behind it — the first Rust leg
|
||||||
|
where the backend was talking to a real game server rather than a stand-in.
|
||||||
|
|
||||||
|
Walked directly: the row absent signed-out and absent on a UO site, present for a signed-in
|
||||||
|
player; both reads; a code the live plugin genuinely refused, with its advice rendering **beside
|
||||||
|
the button** rather than at the top of a long form; a rank marked *has it* and a grant marked
|
||||||
|
*waiting* on the same screen, which is what the pushed ledger actually said; and the release.
|
||||||
|
|
||||||
|
#### The walk found one thing the suite did not
|
||||||
|
|
||||||
|
**The row said who, and not when or where.** The website's row has always read "linked just now
|
||||||
|
on rust-oxide"; the app's carried the name and the Steam id and stopped. Neither fact is part of
|
||||||
|
the identity — a link is fleet-wide — but which server minted the code is where a support
|
||||||
|
conversation starts, and the module's own schema says so in a comment.
|
||||||
|
|
||||||
|
- **Excluded**, in the same class as M14's exclusions: the Rust **admin** permission surface.
|
||||||
|
Authoring grants, groups and drift is `requireRole('admin')` on the website, the app has no
|
||||||
|
admin user-detail screen to put it in, and it writes into a running game — the phone is where
|
||||||
|
you read what you hold, not where you decide what somebody else holds.
|
||||||
|
- **No deep link yet.** `/player/rust` is not in the app's web-path table, deliberately:
|
||||||
|
`module-uo`'s player screens are not either, and that table is built from the *public* nav.
|
||||||
|
Phase 10 is when it will matter, because a notification about an entitlement will want
|
||||||
|
somewhere to land.
|
||||||
|
|
||||||
|
#### Amended 2026-09-22 — M14's Online tab and feed stop naming players by default
|
||||||
|
|
||||||
|
Not a milestone of its own: a correction to what M14 shipped, made on the website first
|
||||||
|
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §23). **Nothing names who is online by
|
||||||
|
default** — the module now withholds the Online list, and every feed item that says a named
|
||||||
|
player was on, from anyone below an operator-chosen audience (staff unless widened), while the
|
||||||
|
player **count** stays public.
|
||||||
|
|
||||||
|
The app's part is to never read that as *nobody is on*. `RustOnlineDto` carries `hidden`,
|
||||||
|
`count` and `audience`, and `RustEventListDto` carries `presenceHidden` and `presenceAudience`;
|
||||||
|
the repository and view model keep the whole answer rather than its rows. The Online tab says
|
||||||
|
"2 players online" and who can see the names; the feed says once, above the rows, that joins,
|
||||||
|
deaths and chat are not shown. An older module without the flags decodes as visible, as before.
|
||||||
|
Walked on an emulator: the withheld panel at the default, and the names arriving on the next poll
|
||||||
|
once the fleet was widened to signed-in — the app's bearer session reaching the module's viewer
|
||||||
|
check. Branch `fix/rust-presence-visibility`.
|
||||||
|
|
||||||
### Deferred (not a milestone)
|
### Deferred (not a milestone)
|
||||||
|
|
||||||
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
||||||
|
|||||||
1037
modules/rust/PLAN.md
1037
modules/rust/PLAN.md
File diff suppressed because it is too large
Load Diff
135
rust-link/INSTALL_RIG.md
Normal file
135
rust-link/INSTALL_RIG.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# The sidecar inside the game container — the Pterodactyl rig recipe
|
||||||
|
|
||||||
|
**Added 2026-09-22, during phase 8.** It is written here rather than in a phase section because it
|
||||||
|
is not a phase: it is how the rigs are wired from now on, and it is the shape
|
||||||
|
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) R20 says the **egg** ships in phase 18.
|
||||||
|
|
||||||
|
Until now the rigs ran the sidecar on a development machine and the game on the Pterodactyl node,
|
||||||
|
which meant the plugin had to dial *out across a LAN* to reach it. That contradicts D2 — the game
|
||||||
|
link is loopback and carries no token, precisely because it is not supposed to leave the host — and
|
||||||
|
it is why the acceptance line of phases 6, 7 and 7b each ended at a firewall rule.
|
||||||
|
|
||||||
|
**The fix is not a firewall rule. It is putting the sidecar where the design always said it lives.**
|
||||||
|
A container's `127.0.0.1` is genuinely private, so a stock plugin config and a stock sidecar find
|
||||||
|
each other with nothing configured at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What goes on the volume
|
||||||
|
|
||||||
|
Two files under `/home/container/rust-link/`, plus whatever the sidecar writes beside them:
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `rust-link-sidecar` | A **statically linked** Linux binary (`x86_64-unknown-linux-musl`), `chmod 755`. Static because the game image is not ours and its glibc is not a contract |
|
||||||
|
| `with-sidecar.sh` | The launcher below, `chmod 755` |
|
||||||
|
| `sidecar.toml` | Written by the sidecar itself on first run, with a generated token. Env overrides it |
|
||||||
|
| `rust-link.db` | The store. **It must never appear in the egg's `REMOVE_FILES`** — R12 keeps all-time rollups across wipes, and a swept store is the failure that looks like success |
|
||||||
|
|
||||||
|
Building the binary needs no Rust toolchain on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$PWD/rust-link/sidecar:/src" -v "$PWD/out:/out" rust:1-slim-bookworm bash -c '
|
||||||
|
apt-get update -qq && apt-get install -y -qq musl-tools >/dev/null
|
||||||
|
rustup target add x86_64-unknown-linux-musl
|
||||||
|
cd /src && CARGO_TARGET_DIR=/build cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
cp /build/x86_64-unknown-linux-musl/release/rust-link-sidecar /out/'
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload both files with the panel's **client** API (`POST /api/client/servers/{id}/files/write`,
|
||||||
|
raw body — it creates missing parent directories), then `files/chmod` them. **Chmod one file per
|
||||||
|
call:** a two-entry `files` array applied only the first, silently, on this panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The launcher
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
RL=/home/container/rust-link
|
||||||
|
mkdir -p "$RL"
|
||||||
|
|
||||||
|
export RUSTLINK_CONFIG="$RL/sidecar.toml"
|
||||||
|
: "${RUSTLINK_DB_PATH:=$RL/rust-link.db}"
|
||||||
|
export RUSTLINK_DB_PATH
|
||||||
|
|
||||||
|
for v in RUSTLINK_GAME_BIND RUSTLINK_WEB_BIND RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS; do
|
||||||
|
eval "val=\${$v-}"
|
||||||
|
if [ -n "$val" ]; then export "$v"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
env -u LD_PRELOAD "$RL/rust-link-sidecar" >> "$RL/sidecar.log" 2>&1 &
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things in it are load-bearing, and each is a thing that went wrong first:
|
||||||
|
|
||||||
|
- **`env -u LD_PRELOAD` for the sidecar.** Carbon's entrypoint prepends
|
||||||
|
`LD_PRELOAD=$(pwd)/libdoorstop.so` to the **whole** startup string, so without this the Mono
|
||||||
|
preloader is injected into a static Rust binary that has never heard of it. RustDedicated still
|
||||||
|
inherits it from this script's environment and still boots modded — which is the composition R20
|
||||||
|
left unsettled, and this is the answer.
|
||||||
|
- **`exec "$@"` for the game.** The game *becomes* this process, so the panel console keeps its
|
||||||
|
stdin and stdout and **stop still stops the server** — which then takes the sidecar down with the
|
||||||
|
container. A `wait` here instead would leave the panel talking to a shell.
|
||||||
|
- **An unset variable is never exported.** The sidecar's precedence is env > file > default, and
|
||||||
|
exporting `RUSTLINK_WEB_TOKEN=""` would override a perfectly good token in `sidecar.toml` with
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
## The startup command
|
||||||
|
|
||||||
|
The launcher is a **prefix** on the egg's own startup, with the sidecar's settings in front of it
|
||||||
|
the way egg variables will supply them in phase 18 (R22):
|
||||||
|
|
||||||
|
```
|
||||||
|
RUSTLINK_WEB_BIND=0.0.0.0:<sidecar allocation> RUSTLINK_SERVER_ID=<server id> \
|
||||||
|
RUSTLINK_WEB_TOKEN=<token> ./rust-link/with-sidecar.sh <the egg's unchanged startup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Set it with the **application** API (`PATCH /api/application/servers/{id}/startup`, sending the
|
||||||
|
server's existing `environment`, `egg` and `image` back unchanged with `skip_scripts: true`).
|
||||||
|
|
||||||
|
**Shell operators cannot be used here.** The image's entrypoint runs the startup string through
|
||||||
|
`eval echo` before handing it to `node /wrapper.js`, so an `&` in it would background the *eval*
|
||||||
|
and a quoted sub-shell would lose its quotes. A wrapper program that `exec`s the rest is the shape
|
||||||
|
that survives that, which is why the launcher takes the game command as arguments rather than
|
||||||
|
containing it.
|
||||||
|
|
||||||
|
## Wiring the website to it
|
||||||
|
|
||||||
|
The sidecar's web API is on the **second allocation**, so from the site it is
|
||||||
|
`http://<node ip>:<sidecar allocation>` with the token above — the ordinary Admin → Rust server row,
|
||||||
|
no tunnel and no rule. `POST /admin/rust/servers/{id}/test` should answer with
|
||||||
|
`plugin_connected: true` and the protocol version.
|
||||||
|
|
||||||
|
The plugin needs **no configuration**: `oxide/config/RunicGateway.json`'s defaults
|
||||||
|
(`127.0.0.1:7799`) are already right, which is the clearest statement of why the sidecar belongs in
|
||||||
|
the container.
|
||||||
|
|
||||||
|
## What it proved, first time
|
||||||
|
|
||||||
|
On `rust-oxide` (egg 18, `ghcr.io/pterodactyl/games:rust`), from a cold start:
|
||||||
|
|
||||||
|
```
|
||||||
|
web server listening addr=0.0.0.0:21004
|
||||||
|
plugin connected peer=127.0.0.1:51148
|
||||||
|
{"kind":"server.hello","protocol":5,"serverId":"rust-oxide",...}
|
||||||
|
```
|
||||||
|
|
||||||
|
— the sidecar bound its allocation **29 seconds** before the world had finished generating, and the
|
||||||
|
plugin found it on loopback as soon as Oxide loaded. `/health` from another machine on the LAN
|
||||||
|
answered `plugin_connected: true`.
|
||||||
|
|
||||||
|
## Still open for phase 18
|
||||||
|
|
||||||
|
- **The egg's own variables.** `RUSTLINK_*` are not egg variables yet, so they live in the startup
|
||||||
|
string on the rigs. Pterodactyl rejects environment keys an egg does not declare, which is exactly
|
||||||
|
what R22's variable block is for.
|
||||||
|
- **Carbon.** The launcher is written for it and the reasoning above is specific about why, but at
|
||||||
|
the time of writing it has run on the Oxide rig only. The two rigs cannot be up at once on this
|
||||||
|
node, so this is a walk to run, not a claim to repeat.
|
||||||
|
- **The framework is reinstalled on every boot** (Carbon from `production_build`, Oxide from
|
||||||
|
`releases/latest`), so a restart is a framework upgrade and neither is pinnable. Unchanged by any
|
||||||
|
of this, and still the reason a rig can differ from itself between two runs.
|
||||||
@@ -220,6 +220,69 @@ how long the sidecar keeps raw events. The permanent record — per-wipe totals
|
|||||||
lives in the website's own tables, so shortening this loses recent detail and never loses a player's
|
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.
|
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
|
**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
|
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
|
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
|
||||||
|
|||||||
@@ -98,3 +98,145 @@ Not "frames arrived". Three things, and the third is the one worth slowing down
|
|||||||
|
|
||||||
Anything that disagrees with the table is a finding about the game or the framework rather than a
|
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.
|
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).
|
||||||
|
|
||||||
|
**Since 2026-09-22 the rigs have that**, and it is no longer a firewall rule on anybody's
|
||||||
|
development machine: the sidecar runs **inside the game container** on the Pterodactyl rigs, which
|
||||||
|
is the shape phase 18's egg ships. The recipe is in [`INSTALL_RIG.md`](INSTALL_RIG.md); a stock
|
||||||
|
plugin config (`127.0.0.1:7799`) and a stock sidecar need no configuration at all to find each
|
||||||
|
other, which is the whole point of putting them in one container.
|
||||||
|
|
||||||
|
| # | 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.
|
||||||
|
|
||||||
|
## The account walk on a phone (phase 8, Android leg B)
|
||||||
|
|
||||||
|
Added 2026-09-22. The app's half was walked on an emulator against a core with the module installed
|
||||||
|
and a **live** rig behind it — the drawer row appearing only for a signed-in player on a site that
|
||||||
|
runs the module, both reads, a refused code rendering beside the button, the entitlement list with
|
||||||
|
its per-server marks, and a release. What no emulator can produce is the code itself, so this is the
|
||||||
|
same three minutes as the identity walk above, done on the phone instead of in a browser.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **In game, type `/link`.** On the phone, open the drawer → *My Rust account*, type the code and press *Link account* | The account appears with the name the game knows you by, when it was linked and which server minted the code |
|
||||||
|
| 2 | **Press it again with the same code** | *"That code is unknown or has expired."* Beside the button, not at the top of the screen |
|
||||||
|
| 3 | **Turn the site off and try a fresh code** | *"A server could not be reached… your code is still good — try again in a minute."* It must NOT tell you to get a new code: you would get it from the same unreachable server |
|
||||||
|
| 4 | **Have an operator grant you something on the website, then pull down / reopen the screen** | It appears under *What you can do in game*, marked **waiting** until a sync lands it and **has it** afterwards. The two states are a word as well as a colour |
|
||||||
|
| 5 | **Press *Unlink*** | The row goes, and every entitlement returns to *waiting* on the next read — the site still holds them, and they now reach nobody |
|
||||||
|
| 6 | **Sign out** | The row is gone from the drawer. On a site with no Rust module it is never there at all, whoever is signed in |
|
||||||
|
|
||||||
|
**What counts as a pass:** a player links an account from the phone without touching a browser, and
|
||||||
|
the screen never claims an entitlement is in the game when the site has not confirmed it there.
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ it is listening without one.
|
|||||||
|
|
||||||
## 2. Versioning
|
## 2. Versioning
|
||||||
|
|
||||||
The wire version is a single integer — **2** as of the read path (§8) — declared in **four** places
|
The wire version is a single integer — **6** as of first-party clans (§12) — declared in
|
||||||
that must agree:
|
**four** places that must agree:
|
||||||
|
|
||||||
| Where | Repo |
|
| Where | Repo |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -330,12 +330,13 @@ writing the file and generating the token if they are missing — and prints it
|
|||||||
|
|
||||||
## 7. What is deliberately not here yet
|
## 7. What is deliberately not here yet
|
||||||
|
|
||||||
Protocol 2 is the transport plus the read path. Every one of these arrives with the phase that needs
|
Protocol 4 is the transport, the read path, identity and the permission mirror. Every one of these
|
||||||
it, and each is a version bump:
|
arrives with the phase that needs it, and each is a version bump:
|
||||||
|
|
||||||
- identity and the in-game link code (phase 6)
|
- ~~identity and the in-game link code (phase 6)~~ — **protocol 3, §9**
|
||||||
- the permission mirror (phase 7), and plugin configuration edited from the site (phase 7b)
|
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
|
||||||
- clans, for core's Team provider (phase 9)
|
- ~~plugin configuration edited from the site (phase 7b)~~ — **protocol 5, §11**
|
||||||
|
- ~~clans, for core's Team provider (phase 9)~~ — **protocol 6, §12**
|
||||||
- leases, budgets and the event actions (phases 12-13)
|
- leases, budgets and the event actions (phases 12-13)
|
||||||
- the map image over the asset-bridge shape (phase 14)
|
- the map image over the asset-bridge shape (phase 14)
|
||||||
|
|
||||||
@@ -444,12 +445,12 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
|
|||||||
|
|
||||||
| `kind` | Hook | `class` | Carries |
|
| `kind` | Hook | `class` | Carries |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `player.connected` | `OnPlayerConnected` | public | steamId, name |
|
| `player.connected` | `OnPlayerConnected` | **presence** | steamId, name |
|
||||||
| `player.disconnected` | `OnPlayerDisconnected` | public | steamId, name, reason, sessionSec |
|
| `player.disconnected` | `OnPlayerDisconnected` | **presence** | steamId, name, reason, sessionSec |
|
||||||
| `player.respawned` | `OnPlayerRespawned` | public | steamId |
|
| `player.respawned` | `OnPlayerRespawned` | **presence** | steamId |
|
||||||
| `player.death` | `OnPlayerDeath` | public | victim, attacker, attackerType, weapon, distance, grid |
|
| `player.death` | `OnPlayerDeath` | **presence** | victim, attacker, attackerType, weapon, distance, grid |
|
||||||
| `player.chat` | `OnPlayerChat` | public | steamId, name, channel, message |
|
| `player.chat` | `OnPlayerChat` | **presence** | steamId, name, channel, message |
|
||||||
| `player.tally` | *aggregate* — see §8.6 | public | steamId, gathered{}, npcKills, structures |
|
| `player.tally` | *aggregate* — see §8.6 | **presence** | steamId, gathered{}, npcKills, structures |
|
||||||
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
|
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
|
||||||
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
|
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
|
||||||
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
|
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
|
||||||
@@ -458,6 +459,8 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
|
|||||||
| `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced |
|
| `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced |
|
||||||
| `server.initialized` | `OnServerInitialized` | public | — |
|
| `server.initialized` | `OnServerInitialized` | public | — |
|
||||||
| `server.shutdown` | `OnServerShutdown` | 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
|
`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
|
and every community site shows it; a **structure's** grid is where somebody lives, which is why
|
||||||
@@ -483,6 +486,14 @@ So: the table in §8.4 is the specification, `module-rust` holds the allowlist,
|
|||||||
its allowlist against this document, so adding a kind here without classifying it there fails a
|
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.
|
build rather than shipping an IP address to a public page.
|
||||||
|
|
||||||
|
**`presence` is `public` with an audience an operator chooses** (added 2026-09-22, [`PLAN.md`](../modules/rust/PLAN.md) §23).
|
||||||
|
The six kinds marked so each say that a *named* player was on the server at a given moment, and the
|
||||||
|
org lead's rule is that nothing names who is online by default: `module-rust` serves them only to
|
||||||
|
viewers inside an operator-chosen audience — staff unless widened, fleet-wide with a per-server
|
||||||
|
override. Below it the public feed carries only what names nobody (a wipe, a start, a shutdown).
|
||||||
|
Nothing on the wire changed: the class is still the module's to enforce, which is why the rule could
|
||||||
|
be added without a protocol bump.
|
||||||
|
|
||||||
`player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and
|
`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
|
`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
|
an operator chasing ban evasion needs them and because the sidecar persists what it is told; they
|
||||||
@@ -585,3 +596,603 @@ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Protocol 6 — first-party clans
|
||||||
|
|
||||||
|
Phase 9, R5. Rust's **own** clan system becomes core's Teams: the plugin reports it, the module
|
||||||
|
answers core's Team provider from it, and the website owns the clan page. Design of record:
|
||||||
|
[`PLAN.md`](../modules/rust/PLAN.md) §24 (D47–D58).
|
||||||
|
|
||||||
|
It is not the uMod **Clans** plugin. That is a separate system that never touches the game's
|
||||||
|
`ClanManager` (D47), so a server running it has two unrelated clan systems, and only the game's
|
||||||
|
becomes Teams. The plugin reads nothing of it except whether it is loaded.
|
||||||
|
|
||||||
|
**The sidecar changed nothing but its version.** One board and five events, filed by `type` (§8.1).
|
||||||
|
|
||||||
|
### 12.1 The `clans` board
|
||||||
|
|
||||||
|
A snapshot, re-sent on connect, on the 60-second cadence, and about three seconds after any clan
|
||||||
|
hook fires, so a roster follows the change that caused it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "clans", "type": "snapshot", "t": 1790158748054, "serverId": "rust-oxide",
|
||||||
|
"enabled": true, "backend": "LocalClanBackend", "supported": true, "truncated": false,
|
||||||
|
"umodClans": false, "count": 1,
|
||||||
|
"clans": [{
|
||||||
|
"clanId": 1, "createdMs": 1790158729260, "name": "Northwatch", "color": "#3fa9f5",
|
||||||
|
"score": 0, "maxMembers": 100,
|
||||||
|
"members": [{ "steamId": "76561190000000001", "rank": 1, "role": "Leader", "joinedMs": 1790158729264, "name": "…" }]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `enabled` | The game's `clan.enabled` convar. `false` is an authoritative answer — no clans — and is `supported` |
|
||||||
|
| `supported` / `reason` | Could the plugin read the clans at all. `false` when the backend has not started, or is not the local one (a **Nexus** server keeps its clans elsewhere) — refused with a reason rather than guessed at |
|
||||||
|
| `truncated` | There may be clans the board does not list. See §12.3 |
|
||||||
|
| `umodClans` | The uMod Clans plugin is loaded. The website warns that its clans are not Teams |
|
||||||
|
| `rank` | The member's role rank. **Rank 1 is leader**, and several members may hold it. Absent when the member's role id matched no role — never defaulted |
|
||||||
|
| `name` | Present when the framework knows the player. Absent, not empty, otherwise |
|
||||||
|
|
||||||
|
A member's `LastSeen` is **not sent**. It is presence, and nothing names who is online by default
|
||||||
|
(PLAN.md §23).
|
||||||
|
|
||||||
|
### 12.2 The five events
|
||||||
|
|
||||||
|
| `kind` | Hook | Carries |
|
||||||
|
|---|---|---|
|
||||||
|
| `clan.created` | `OnClanCreated(LocalClan, ulong)` | clan, founder (`steamId`, `name`) |
|
||||||
|
| `clan.disbanded` | `OnClanDisbanded(LocalClan, ulong)` | clan, who disbanded it |
|
||||||
|
| `clan.member.added` | `OnClanMemberAdded(long, ulong)` | clan, the new member |
|
||||||
|
| `clan.member.left` | `OnClanMemberLeft(LocalClan, ulong)` | clan, the member |
|
||||||
|
| `clan.member.kicked` | `OnClanMemberKicked(LocalClan, ulong, ulong)` | clan, the member, and who kicked them (`bySteamId`, `byName`) |
|
||||||
|
|
||||||
|
"Clan" is always `clanId`, `createdMs` and `clanName`. Every one is **`staff` class** in §8.5's
|
||||||
|
terms: clan membership is members-only (D49), so the public feed never carries it. It reaches a
|
||||||
|
clan's members through core's Team feed, where core decides who is a member.
|
||||||
|
|
||||||
|
`OnClanColorChanged` is hooked too, but produces no event: a colour is a property of the clan, so it
|
||||||
|
travels on the board, and the hook only brings the next board forward.
|
||||||
|
|
||||||
|
Three facts the hook sites impose, all read from the game's assemblies:
|
||||||
|
|
||||||
|
- **The founder's membership fires no `OnClanMemberAdded`.** The game adds them inside the
|
||||||
|
creation, so `clan.created` implies it.
|
||||||
|
- **`OnClanMemberAdded` hands over a bare id**, fired from inside the database layer before the
|
||||||
|
game's cached clan is refreshed. The plugin reads the clan back for `createdMs` and `clanName`; if
|
||||||
|
even that fails the frame carries `clanId` alone and the website matches on it.
|
||||||
|
- **The game raises no promote or demote hook.** Leadership travels on the board only, and the
|
||||||
|
website diffs one board against the next (D54).
|
||||||
|
|
||||||
|
### 12.3 Identity, and the ceiling
|
||||||
|
|
||||||
|
**A clan's identity is `clanId` AND `createdMs`.** The game keeps clans in `clans.<version>.db` with
|
||||||
|
the version hard-coded, so a game update that bumps it starts a fresh file whose ids restart at 1.
|
||||||
|
The website keys a Team on `<serverId>:<clanId>:<createdMs>` (D52), and every clan frame carries
|
||||||
|
both halves for that reason.
|
||||||
|
|
||||||
|
**The game has no "list every clan" call.** Its backend offers get-by-id and get-by-member; the only
|
||||||
|
listing is the clan leaderboard, which runs `SELECT … ORDER BY score DESC LIMIT ?` with the limit
|
||||||
|
**clamped to 100**. So the board lists at most the top 100 clans by score. A board at that ceiling
|
||||||
|
cannot be told apart from one with exactly 100 clans, and says `truncated: true` either way. It also
|
||||||
|
says so if its rows would pass **768 KiB**, well inside the sidecar's 1 MiB line cap, which drops a
|
||||||
|
longer line outright — a board that never arrived would read as a server with no clans.
|
||||||
|
|
||||||
|
The org lead accepted the ceiling (D55) over reading the game's private SQLite schema directly. A
|
||||||
|
truncated board is answered to core as partial, so core adds and updates Teams there and never
|
||||||
|
removes one on its word.
|
||||||
|
|
||||||
|
### 12.4 A hook name another plugin also raises
|
||||||
|
|
||||||
|
The uMod Clans plugin raises `OnClanDisbanded(string tag, List<ulong> members)`, and a Universal
|
||||||
|
form with `List<string>`. Both have **the same name and arity** as the game's
|
||||||
|
`OnClanDisbanded(LocalClan, ulong)`. The bridge declares the game's types exactly, and the framework
|
||||||
|
matches a call to a method by its argument types, so neither call reaches it.
|
||||||
|
|
||||||
|
**Walked on the Oxide rig (2026-09-23):** both uMod-shaped calls were raised from a rig plugin, and
|
||||||
|
the bridge's own `rg.hooks` count for `OnClanDisbanded` stayed at the one real disband, with nothing
|
||||||
|
logged. A loosely typed signature (`object, object`) would have filed the plugin's clans as the
|
||||||
|
game's.
|
||||||
|
|||||||
Reference in New Issue
Block a user