# The player walk — proving the half of the read path a console cannot reach Protocol 2's catalogue divides cleanly in two, and the line is not about importance: it is about whether a hook can fire without somebody holding a mouse. Everything in the first half was proven from a console and a REST client while phase 3 was built — the boards, the wipe id, the envelope, bans, the server lifecycle. Everything below needs a **real player on a real server**, because the hooks carry a `BasePlayer`, a `HitInfo` or a chat line, and none of those three can be manufactured from a console without becoming a different test. This document is the walk that closes it. It takes about ten minutes, it is the same on Oxide and on Carbon, and it is written so that the answer is readable afterwards rather than watched live. --- ## Before you start 1. A rig running, with `RunicGateway.cs` loaded — `oxide.plugins` (or `c.plugins`) lists *Runic Gateway*, and `rg.link` answers `connected=True`. 2. A sidecar the rig can reach, with its store **empty** — that is what makes the event list at the end readable as a transcript of the walk and nothing else. 3. The sidecar's token to hand, for the reads at the bottom. Run this once, before you join: ``` rg.hooks ``` Every player hook should read **silent**. That is the baseline: the point of the walk is to move them, and starting from a run where some already fired proves less. --- ## The walk Do these in order. The order matters only in two places, noted where it does. | # | Do this | Fires | The frame should carry | |---|---|---|---| | 1 | **Join the server** | `CanUserLogin`, `OnUserApproved`, `OnPlayerConnected` | Three frames, in that order. The first two carry your **IP address** — check it is a real address and not the string `0`. `player.connected` carries your steam id and name | | 2 | **Wake up / spawn in** (click Respawn if you are dead) | `OnPlayerRespawned` | `player.respawned`, steam id only. It does **not** fire if you simply wake from sleeping — that is the hook's own documented behaviour, so no frame here is a pass, not a failure | | 3 | **Say something in chat**, then **say something in team chat** if you have a team | `OnPlayerChat` | Two `player.chat` frames, with `channel` reading `Global` and `Team`. The message must arrive whole — if it is truncated or the frame is missing, the flattener ate it | | 4 | **Chop a tree for about twenty seconds**, then **mine a node** | `OnDispenserGather` | **Nothing immediately.** This is the aggregate: one `player.tally` frame within 60 seconds, carrying `gathered` with `wood` and `stones`, summed. Seeing a frame per swing would be the bug | | 5 | **Kill an animal or a scientist** | `OnEntityDeath` | Again nothing immediately — `npcKills` on the next `player.tally`. No `player.death`: a chicken is not a killfeed entry | | 6 | **Die to the environment** — fall damage is easiest | `OnPlayerDeath` | `player.death` with `attackerType: "environment"`, a `grid` like `H7`, and **no** `attackerId`. Check the grid against the map: a wrong sign in the row arithmetic mirrors the whole map, and only a human with the map open can see that | | 7 | **Kill yourself** — `kill` in the F1 console | `OnPlayerDeath` | `attackerType: "self"`, no `attackerId` | | 8 | **If a second player is available**: kill each other once | `OnPlayerDeath` | `attackerType: "player"`, with `attackerId`, `attackerName`, a `weapon` shortname and a `distance` in metres. This is the killfeed's whole shape, and it is the one row phase 4's page is built from | | 9 | **Build a foundation, then destroy it yourself** | `OnEntityDeath` | `entity.destroyed` with `ownerId` (yours), `prefab`, `grid` and `attackerId`. Decay must **not** produce one of these — only a player breaking it | | 10 | **Disconnect** | `OnPlayerDisconnected` | `player.disconnected` with a `reason` and a **`sessionSec`** roughly equal to how long you were on. It also flushes your tally first, so any gathering since the last minute arrives immediately before it | Two ordering notes: step 4 must come before step 10 by at least a minute if you want to see the cadence flush rather than the disconnect flush, and step 1's three frames are the only place the order between hooks is itself part of the answer. --- ## Reading the result From the machine running the sidecar: ```bash TOKEN=… # [web].auth_token from sidecar.toml, or `--print-config` BASE=http://127.0.0.1:8090 # The whole walk, oldest first, as a transcript. curl -s -H "Authorization: Bearer $TOKEN" "$BASE/feed?since=0&limit=500" \ | python -m json.tool # Or one kind at a time. curl -s -H "Authorization: Bearer $TOKEN" "$BASE/events?kind=player.death&limit=20" ``` And from the game console: ``` rg.hooks ``` Every hook in the walk should now read **fired**, with a count. A hook still `silent` after the step that should have fired it is the finding — and on Carbon it is the specific question [`CARBON.md`](../modules/rust/CARBON.md) §6 asks, since Carbon's catalogue omits thirteen uMod names and nobody has yet checked whether they are renames or holes. --- ## What counts as a pass Not "frames arrived". Three things, and the third is the one worth slowing down for: 1. **Every hook in the table fired**, on both frameworks, from the same plugin file. 2. **Every frame carries the envelope** — `type`, `serverId` and `wipeId` on all of them ([`PROTOCOL.md`](PROTOCOL.md) §8.1). A player frame without a `wipeId` cannot be attributed to a wipe and its rollup is lost. 3. **The aggregates are aggregates.** `player.tally` is a delta since the last flush, so two minutes of chopping is two frames that sum to the total, not two frames each carrying the total. Getting this backwards makes every leaderboard roughly double, and it looks correct until somebody counts. Anything that disagrees with the table is a finding about the game or the framework rather than a mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs. --- ## The identity walk (protocol 3, phase 6) Added 2026-09-21, and here for the same reason as everything above: **a link code reaches a player and nobody else**, so no console can read one. The site's own half was walked in a browser — the refusals, the admin panel, staff unlink, the rate limit — and what needs a person in game is the three steps below. It takes two minutes, and it wants **two website accounts** — one you will link, one you will try to link the same Steam account to. | # | Do this | You should see | |---|---|---| | 1 | **In game, type `/link`** | A private reply with a six-character code and a five-minute deadline. Check it is private: a second player on the server must not see it. The code has **no O, 0, I or 1** in it — those glyphs are not in the alphabet, so one in your code is a finding | | 2 | **Type `/link` again straight away** | *"Please wait a moment…"* — the thirty-second cooldown. The first code is now dead either way: a new request drops the old one, so only the newest ever works | | 3 | **On the website, sign in and open `/player/rust`. Type the code** | The account appears, named as the game knows you, with the server it came from. Try the same code again: *"That code is unknown or has expired"* — it works once | | 4 | **Sign in as the SECOND account and type a fresh code for the same Steam account** | Refused, naming the account that holds it: *"That Steam account is already linked to . 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 `** | The permission is there, granted by this plugin rather than by hand | | 4 | **At the console, `oxide.grant user 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. > **Run this walk on protocol 9 or later — before that, steps 1–3 could not pass on Oxide.** From > phase 7 until phase 13a the plugin granted with itself as the *owner*, and Oxide silently ignores a > grant whose owner did not register the permission — which is every permission this walk uses. The > site showed the server **in sync** and `oxide.show` showed **nothing**; the phase 13a walk > reproduced exactly that before fixing it ([PLAN.md §28.6](../modules/rust/PLAN.md), step 6). Since > protocol 9 the plugin reads every grant back, and one that did not land is listed on the > permissions screen as *sent and not found in the game's permission store* instead of being counted. > If you see that warning on this walk, it is a finding. ## 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//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. ## The events walk (phases 12 and 13a) Added 2026-09-24. Everything an event does to a server — the leases (protocol 8) and the world verbs (protocol 9) — was walked through real core on both rigs, and every effect was checked in the game with a console probe ([PLAN.md](../modules/rust/PLAN.md) §27.5 and §28.6). What a probe cannot do is **be a player**: stand in a zone, open a crate, get shot at, or see a kit unlock in the menu. That is this walk. It is §27.7's and §28.8's "not proven" lists, done by a person. **Before you start:** 1. **Kits installed on the rig** (`umod.org/plugins/Kits.cs`, into `oxide/plugins/` or `carbon/plugins/`) with at least one kit whose `RequiredPermission` is set — step 1 needs it, and neither rig has had Kits yet. 2. **`EventsEnabled` on** — Admin → Rust mod config → `RunicGateway.json`. It takes effect at once. 3. **The four event verbs switched on** in Admin → Events → Actions: `core.lease`, `rust.zone.open`, `rust.crate.place`, `rust.npc.place`. 4. **A non-admin Steam account for step 6** is preferable but not required. Kits applies its gate to an admin too, so step 1 works on the owner's account, and nothing in steps 2–6 depends on a permission. Each step is an event you author in Admin → Events, publish, and start by hand. A monument is picked from the dropdown (it lists this map's monuments by name and grid square); stand near it before you start the run. | # | Author this, start it, then | You should see | |---|---|---| | 1 | **A `core.lease` on `rust.group.permission`**: target `/default/`, value `true`, 10 minutes. Open the kit menu before and after starting it | The kit goes from **locked** to **available** when the step runs, and back to locked when the run ends or its ten minutes pass — whichever comes first. Redeem it once while it is open: the items stay in your inventory after it locks again, which is correct (the lease borrowed the *right*, not the loot) | | 2 | **`rust.zone.open`** at a monument you are standing next to, radius 30, 15 minutes, a name you will recognise. At the console run `rgp.inzone` with you **outside** the zone, then walk in and run it again | `zone_list` at the console lists the zone with your name (`rg--…`). `rgp.inzone` answers *nobody* for it while you are outside and **your name** once you are in — that is ZoneManager's own `IsPlayerInZone` saying a player is inside a zone the bridge made, which is §28.8's first item. (`rgp.inzone` is the rig-only probe, `RgProbe13`, on both rigs; ZoneManager itself has no command that says who is in a zone.) Nothing reaches the website yet: the bridge subscribes to `OnEnterZone`/`OnExitZone` in 13b, not 13a | | 3 | **Leave step 2's run going and restart the server.** Rejoin, walk back into the zone and run `rgp.inzone` | `zone_list` shows the zone **back** after the restart, at the same place and with the same name, re-created from the bridge's own record because ZoneManager forgets temporary zones. `rgp.inzone` names you again. On the site the run's zone row still reads `confirmed` | | 4 | **`rust.crate.place`**: `crate.hackable` ×1 and `crate.elite` ×3 at the same monument, spread 8. Loot one elite crate empty; start the hack on the locked crate. Then cancel the run | The crates are there and lootable, the hack timer runs. At teardown the untouched crates vanish, and the looted one is simply **gone** already — its row reads `reverted` all the same (§15.2: reverting something a player took is a success) | | 5 | **`rust.npc.place`**: `npc.scientist` ×3 at the monument. Kill one; let the others see you | They are hostile and fight. The one you killed is just gone; at teardown the survivors vanish mid-fight. Every row reads `reverted`. If an NPC spawned **inside a wall or fell through the ground**, note the monument — some offsets land off the navmesh, and that is a finding about where to put things, not a bug to hide | | 6 | **A zone for 3 minutes.** Stand inside it (`rgp.inzone` names you) and wait out the three minutes | The zone disappears around you **without anybody on the website doing anything**, because the game keeps the deadline (D96). `zone_list` no longer lists it, and the sidecar's `/events?kind=world.expired` shows the frame. Best done on the non-admin account, which is the view a real player has | | 7 | **If players can be arranged, a `core.lease` on `spawn.max_rate`** — these two scalars are left out of the catalogue today because they only matter with people online (§27.5) | With several players on, the population system's spawn rate moves (`spawn.report`) while the lease holds and returns after. If it does, the two `spawn.max_*` keys can be added to the allowlist; if nothing moves even with players, they stay out | **Run steps 1–5 on both frameworks.** One thing is specific to Carbon: ZoneManager was only installed on the Carbon rig on 2026-09-24, for the 13a walk — step 2 is the first time a *player* has stood in a zone there. **What counts as a pass:** a player sees what the event did and sees it stop — the kit locks again, the zone goes, the crates and NPCs vanish — with no website action beyond starting and cancelling the run; a restart in the middle keeps the zone; and nothing a player took or killed shows up as a failure on the run console.