docs(modules): phase 8 as built, and the rig that put the sidecar where the design says it lives #261

Merged
whitlocktech merged 1 commits from docs/rust-phase-8 into main 2026-09-23 01:51:54 +00:00
4 changed files with 346 additions and 3 deletions

View File

@@ -1429,6 +1429,88 @@ push, and Play (M6M8) follow the designed app.
*configuration*, which the app consumes and does not edit. Identity and permissions are legs B *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. 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.
### 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

View File

@@ -986,7 +986,7 @@ Each phase ends with its findings written down, as every workstream here does.
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page | | 6 | **Identity** (R1), and the `admin.users.detail` slot (R13). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
| 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one | | 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
| 7b | **Mod configuration from the site** (R18). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin | | 7b | **Mod configuration from the site** (R18). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app | | 8 | **Android leg B** (R10), and the half of R2 a player may see. ✅ **Built 2026-09-22 — as built and findings in §22.** One drawer row under the player group shaped like `module-uo`'s own (the code card first, then what the code got them), gated on `rust` and `PLAYER`; the four refusals kept four pieces of advice; and **one new website route**, `GET /player/rust/permissions`, because phase 7 gave an operator every view of in-game privilege and a player none. Three org-lead decisions (§22.0), the scope arithmetic answered on the server so `inScope` exists once (§22.1), and **the rig moved the sidecar inside the game container** ([`INSTALL_RIG.md`](../../rust-link/INSTALL_RIG.md)), which retired the firewall wall phases 6, 7 and 7b each stopped at | Android-app + Module-Rust + docs | A player links from the app |
| 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly | | 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else | | 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there | | 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
@@ -3206,6 +3206,107 @@ down in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
- **Still no module-declared site permission** at MODULE_API 1.10.0, exactly as §20 predicted, so - **Still no module-declared site permission** at MODULE_API 1.10.0, exactly as §20 predicted, so
`requireRole('admin')` is again the whole vocabulary. Two phases have now wanted the same member. `requireRole('admin')` is again the whole vocabulary. Two phases have now wanted the same member.
## 22. Phase 8 as built — the player's own half, on two screens, 2026-09-22
**Walked end to end against a live rig, and the rig is why this phase reads differently from the
three before it.** The sidecar now runs inside the game container, so for the first time since phase
5 the website talked to a real plugin on a real Oxide server rather than to a stand-in — including
the permission push, which landed a group membership in Oxide's own store and reported
`unresolved` for a name nothing had registered.
Two repositories and this document: [`Module-Rust#10`][mr] (the website half, into `edge`) and
[`Android-app#48`][aa] (M15, into `edge`). Core needed no change — the third Rust phase running.
### 22.0 The three decisions this phase needed
| | Decision |
|---|---|
| **D39** | **The app's permission half is player-facing and read-only, and the website grows the route for it.** Phase 7's whole surface is `requireRole('admin')`, and the app has no admin user-detail screen to port it into; porting the authoring page to a phone was considered and rejected (it writes into a running game from a device that is easy to mis-tap, and M14 already excluded the Rust admin surface as configuration the app consumes rather than edits). So the phase adds one self-scoped read instead — and renders it on the **website** as well, so the app never has a surface the website lacks. |
| **D40** | **It mirrors `module-uo`'s player surface, not a shape of its own.** One drawer row under the player group, with the code card at the top of the screen it leads to — exactly `CharactersScreen`. A tab under one server was rejected because a link is fleet-wide, and a section inside core's own Account screen was rejected because the app has no slot mechanism and the module's data would be hard-wired into a core screen. |
| **D41** | **The row hangs on `rust`, matching `module-uo`'s single `shard`.** The module declares a surface word per feature and `identity` is one of them, but D16's rule stands: a capability answers *is the module there*, and core flattens every module's capabilities into one list. Requiring `rust` **and** `identity` was considered — it would refuse the screen on a build predating phase 6 — and rejected as a second meaning for a word that has one. |
### 22.1 What the player is told, and what they are not
`GET /player/rust/permissions` is a different shape from the admin read rather than a filtered one,
and the three differences are the phase:
1. **The scope is resolved on the server.** A client handed `scope: "*"` would have to know what the
fleet is to say anything, and then `inScope` exists twice. Each entry arrives carrying the
servers it reaches, each already marked.
2. **`live` is the pushed ledger, never the authored row.** Phase 7 is careful never to record a
push that silently did nothing, so *waiting* here means waiting — and the alternative is the site
claiming to have given something it has not.
3. **Nothing says why it is waiting.** An offline server, a permission no loaded plugin registered
and a store that has never seen the account are one state on this screen. Telling them apart is
an operator's diagnosis and an inventory of what is installed on a host.
**An entitlement that reaches nobody still lists**, and both surfaces say so: authored against the
website account, it exists before a Steam id does. Hiding it until one turns up is the defect the
admin user page shipped in phase 7 (§20.5) — the same mistake, one tier along, caught before it
shipped this time because the rule was already written down.
**The honest limit, named rather than designed around:** a *rank* can be live while every permission
it carries resolves nowhere. The rig proved it — `vip` was created in Oxide's store and the
membership landed, while `kits.vip` came back `unresolved` because Kits is not installed there. The
player is told the rank reached the game, which is true and is what the site gave them; whether a
server's plugins understand the names inside it is on the admin screen, where the warning already
is.
### 22.2 The refusals, on a phone
The app's convention since M1 is that a refusal is chosen by **status** and rendered from a string
resource — the website's sentence is never displayed, because the app is localized and the website
is not. That convention holds here and it costs something worth writing down: the module
distinguishes *three* 503s (a server it could not reach, a fleet that is all down, a site with no
servers configured at all) by sentence, and the app has one string for the status. It is written to
be true of all three, and it does **not** say "get a new code" — a player told that would go back to
the same unreachable server for another one.
The four that do differ by status keep four different pieces of advice: 400 a spent code, 409 a
Steam account another website account holds, 429 the limiter, 503 a server that could not be
reached.
### 22.3 What the walk proved, and on what
- **The website half** in a browser, against the live rig: the section rendering with zero linked
accounts, a rank marked *has it* and a grant marked *waiting* on the same screen, the note that
explains why, and the refusal path.
- **The app half** on an emulator against the same core: the row absent when signed out and absent
on a UO site, present for a signed-in player; both reads; a refused code rendering **beside the
button**; the marks; and the release.
- **The push itself against a real Oxide store**, which is new. `perm.sync` created the group,
applied the membership, and reported `unresolved: ["kits.vip", "zonemanager.admin"]` — the second
of those a genuine finding about the rig rather than the code: ZoneManager registers
`zonemanager.ignoreflag.*` and friends, and **not** `zonemanager.admin`. A permission name that
looks obvious is still a name some plugin has to have registered.
**The acceptance line needs a person in game.** Everything above used a link row written directly
into the rig's database, because a `/link` code reaches a player and nobody else. The three minutes
that close it are in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
### 22.4 Smaller things worth keeping
- **`MenuAccess.PLAYER` is `isPlayer || isStaff`**, and that is right here: `/player/rust/*` is
`requireAuth` with no role above it, and staff play the game too. The app's own gating test now
asks *which* module a row on a module path declares, not merely whether it declares one — a
second game under `player/` is a new way for that test to have been passing for the wrong reason.
- **The website's pill carries the word, not only the dot.** A filled circle beside a hollow one is
the whole difference between "you have this in game" and "you do not yet". Found by looking at it.
- **The app's row now says when and where an account was linked**, which the website's always did.
Which server minted the code is not part of the identity — a link is fleet-wide — but it is where
a support conversation starts.
- **`/player/rust` has no app deep link**, deliberately: `module-uo`'s player screens have none
either, and the app's web-path table is the *public* nav's. Phase 10 is where it will matter, when
a notification about an entitlement wants somewhere to land.
- **A rig timestamp written by a different connection is five hours out.** The `DATETIME` columns
here are naive, and they round-trip correctly only through the connection that wrote them —
inserting a stand-in row with `docker exec mariadb` made the site read it as the future. Not a
product defect; a rig recipe.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
--- ---
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link [rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link

135
rust-link/INSTALL_RIG.md Normal file
View 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.

View File

@@ -185,8 +185,13 @@ live Oxide rig**, where `rg.config` answers
**What is left is the sentence the phase exists for: a setting changed on the website takes effect **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 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 link is loopback by design (D2).
development machine it is a firewall rule for the port the plugin dials.
**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 | | # | Do this | You should see |
|---|---|---| |---|---|---|
@@ -215,3 +220,23 @@ this phase has two specific things to confirm rather than assume:
**What counts as a pass:** a setting typed on the website changes what the running game does; a **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 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 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.