From 11b8965aa7ba2bae2f15239793f0025617cf3eb5 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 22 Sep 2026 20:35:46 -0500 Subject: [PATCH] docs(modules): phase 8 as built, and the rig that finally put the sidecar where the design says it lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three documents: * `modules/rust/PLAN.md` §22 — the phase as built. Three org-lead decisions (D39-D41), what a player is told and what they are not, the refusals on a phone, and an honest limit the rig found: a rank can be live while every permission it carries resolves nowhere. * `rust-link/INSTALL_RIG.md` — new. The sidecar runs INSIDE the game container now, which is the shape R20 says the egg ships and which retires the firewall wall phases 6, 7 and 7b each stopped at. 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. Three things in the launcher are load-bearing and each is written down with the failure it prevents. * `rust-link/PLAYER_WALK.md` — the account walk on a phone, and a correction: 7b's "it needs a firewall rule on a development machine" is no longer true. `android/PLAN.md` gains M15. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- android/PLAN.md | 82 ++++++++++++++++++++++++ modules/rust/PLAN.md | 103 ++++++++++++++++++++++++++++- rust-link/INSTALL_RIG.md | 135 +++++++++++++++++++++++++++++++++++++++ rust-link/PLAYER_WALK.md | 29 ++++++++- 4 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 rust-link/INSTALL_RIG.md diff --git a/android/PLAN.md b/android/PLAN.md index cf3f9de..139de23 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1429,6 +1429,88 @@ push, and Play (M6–M8) follow the designed app. *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. + ### Deferred (not a milestone) - **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index be90ad9..5c92dc1 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -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 | | 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 `/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 | | 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 | @@ -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 `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 diff --git a/rust-link/INSTALL_RIG.md b/rust-link/INSTALL_RIG.md new file mode 100644 index 0000000..222efdf --- /dev/null +++ b/rust-link/INSTALL_RIG.md @@ -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: RUSTLINK_SERVER_ID= \ +RUSTLINK_WEB_TOKEN= ./rust-link/with-sidecar.sh +``` + +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://:` 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. diff --git a/rust-link/PLAYER_WALK.md b/rust-link/PLAYER_WALK.md index 08aa2c5..0553cb8 100644 --- a/rust-link/PLAYER_WALK.md +++ b/rust-link/PLAYER_WALK.md @@ -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 in the running game.** It needs the sidecar and the game server on **one host**, because the game -link is loopback by design (D2) — on the Pterodactyl rigs that is phase 18's egg, and on a -development machine it is a firewall rule for the port the plugin dials. +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 | |---|---|---| @@ -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 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. -- 2.49.1