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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
243 lines
19 KiB
Markdown
243 lines
19 KiB
Markdown
# The player walk — proving the half of the read path a console cannot reach
|
|
|
|
Protocol 2's catalogue divides cleanly in two, and the line is not about importance: it is about
|
|
whether a hook can fire without somebody holding a mouse.
|
|
|
|
Everything in the first half was proven from a console and a REST client while phase 3 was built —
|
|
the boards, the wipe id, the envelope, bans, the server lifecycle. Everything below needs a **real
|
|
player on a real server**, because the hooks carry a `BasePlayer`, a `HitInfo` or a chat line, and
|
|
none of those three can be manufactured from a console without becoming a different test.
|
|
|
|
This document is the walk that closes it. It takes about ten minutes, it is the same on Oxide and on
|
|
Carbon, and it is written so that the answer is readable afterwards rather than watched live.
|
|
|
|
---
|
|
|
|
## Before you start
|
|
|
|
1. A rig running, with `RunicGateway.cs` loaded — `oxide.plugins` (or `c.plugins`) lists *Runic
|
|
Gateway*, and `rg.link` answers `connected=True`.
|
|
2. A sidecar the rig can reach, with its store **empty** — that is what makes the event list at the
|
|
end readable as a transcript of the walk and nothing else.
|
|
3. The sidecar's token to hand, for the reads at the bottom.
|
|
|
|
Run this once, before you join:
|
|
|
|
```
|
|
rg.hooks
|
|
```
|
|
|
|
Every player hook should read **silent**. That is the baseline: the point of the walk is to move
|
|
them, and starting from a run where some already fired proves less.
|
|
|
|
---
|
|
|
|
## The walk
|
|
|
|
Do these in order. The order matters only in two places, noted where it does.
|
|
|
|
| # | Do this | Fires | The frame should carry |
|
|
|---|---|---|---|
|
|
| 1 | **Join the server** | `CanUserLogin`, `OnUserApproved`, `OnPlayerConnected` | Three frames, in that order. The first two carry your **IP address** — check it is a real address and not the string `0`. `player.connected` carries your steam id and name |
|
|
| 2 | **Wake up / spawn in** (click Respawn if you are dead) | `OnPlayerRespawned` | `player.respawned`, steam id only. It does **not** fire if you simply wake from sleeping — that is the hook's own documented behaviour, so no frame here is a pass, not a failure |
|
|
| 3 | **Say something in chat**, then **say something in team chat** if you have a team | `OnPlayerChat` | Two `player.chat` frames, with `channel` reading `Global` and `Team`. The message must arrive whole — if it is truncated or the frame is missing, the flattener ate it |
|
|
| 4 | **Chop a tree for about twenty seconds**, then **mine a node** | `OnDispenserGather` | **Nothing immediately.** This is the aggregate: one `player.tally` frame within 60 seconds, carrying `gathered` with `wood` and `stones`, summed. Seeing a frame per swing would be the bug |
|
|
| 5 | **Kill an animal or a scientist** | `OnEntityDeath` | Again nothing immediately — `npcKills` on the next `player.tally`. No `player.death`: a chicken is not a killfeed entry |
|
|
| 6 | **Die to the environment** — fall damage is easiest | `OnPlayerDeath` | `player.death` with `attackerType: "environment"`, a `grid` like `H7`, and **no** `attackerId`. Check the grid against the map: a wrong sign in the row arithmetic mirrors the whole map, and only a human with the map open can see that |
|
|
| 7 | **Kill yourself** — `kill` in the F1 console | `OnPlayerDeath` | `attackerType: "self"`, no `attackerId` |
|
|
| 8 | **If a second player is available**: kill each other once | `OnPlayerDeath` | `attackerType: "player"`, with `attackerId`, `attackerName`, a `weapon` shortname and a `distance` in metres. This is the killfeed's whole shape, and it is the one row phase 4's page is built from |
|
|
| 9 | **Build a foundation, then destroy it yourself** | `OnEntityDeath` | `entity.destroyed` with `ownerId` (yours), `prefab`, `grid` and `attackerId`. Decay must **not** produce one of these — only a player breaking it |
|
|
| 10 | **Disconnect** | `OnPlayerDisconnected` | `player.disconnected` with a `reason` and a **`sessionSec`** roughly equal to how long you were on. It also flushes your tally first, so any gathering since the last minute arrives immediately before it |
|
|
|
|
Two ordering notes: step 4 must come before step 10 by at least a minute if you want to see the
|
|
cadence flush rather than the disconnect flush, and step 1's three frames are the only place the
|
|
order between hooks is itself part of the answer.
|
|
|
|
---
|
|
|
|
## Reading the result
|
|
|
|
From the machine running the sidecar:
|
|
|
|
```bash
|
|
TOKEN=… # [web].auth_token from sidecar.toml, or `--print-config`
|
|
BASE=http://127.0.0.1:8090
|
|
|
|
# The whole walk, oldest first, as a transcript.
|
|
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/feed?since=0&limit=500" \
|
|
| python -m json.tool
|
|
|
|
# Or one kind at a time.
|
|
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/events?kind=player.death&limit=20"
|
|
```
|
|
|
|
And from the game console:
|
|
|
|
```
|
|
rg.hooks
|
|
```
|
|
|
|
Every hook in the walk should now read **fired**, with a count. A hook still `silent` after the step
|
|
that should have fired it is the finding — and on Carbon it is the specific question
|
|
[`CARBON.md`](../modules/rust/CARBON.md) §6 asks, since Carbon's catalogue omits thirteen uMod names
|
|
and nobody has yet checked whether they are renames or holes.
|
|
|
|
---
|
|
|
|
## What counts as a pass
|
|
|
|
Not "frames arrived". Three things, and the third is the one worth slowing down for:
|
|
|
|
1. **Every hook in the table fired**, on both frameworks, from the same plugin file.
|
|
2. **Every frame carries the envelope** — `type`, `serverId` and `wipeId` on all of them
|
|
([`PROTOCOL.md`](PROTOCOL.md) §8.1). A player frame without a `wipeId` cannot be attributed to a
|
|
wipe and its rollup is lost.
|
|
3. **The aggregates are aggregates.** `player.tally` is a delta since the last flush, so two minutes
|
|
of chopping is two frames that sum to the total, not two frames each carrying the total. Getting
|
|
this backwards makes every leaderboard roughly double, and it looks correct until somebody counts.
|
|
|
|
Anything that disagrees with the table is a finding about the game or the framework rather than a
|
|
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.
|
|
|
|
---
|
|
|
|
## The identity walk (protocol 3, phase 6)
|
|
|
|
Added 2026-09-21, and here for the same reason as everything above: **a link code reaches a player
|
|
and nobody else**, so no console can read one. The site's own half was walked in a browser — the
|
|
refusals, the admin panel, staff unlink, the rate limit — and what needs a person in game is the
|
|
three steps below.
|
|
|
|
It takes two minutes, and it wants **two website accounts** — one you will link, one you will try to
|
|
link the same Steam account to.
|
|
|
|
| # | Do this | You should see |
|
|
|---|---|---|
|
|
| 1 | **In game, type `/link`** | A private reply with a six-character code and a five-minute deadline. Check it is private: a second player on the server must not see it. The code has **no O, 0, I or 1** in it — those glyphs are not in the alphabet, so one in your code is a finding |
|
|
| 2 | **Type `/link` again straight away** | *"Please wait a moment…"* — the thirty-second cooldown. The first code is now dead either way: a new request drops the old one, so only the newest ever works |
|
|
| 3 | **On the website, sign in and open `/player/rust`. Type the code** | The account appears, named as the game knows you, with the server it came from. Try the same code again: *"That code is unknown or has expired"* — it works once |
|
|
| 4 | **Sign in as the SECOND account and type a fresh code for the same Steam account** | Refused, naming the account that holds it: *"That Steam account is already linked to <name>. Run /unlink in game to release it."* The link must **not** move — it is what phase 7 grants permissions against |
|
|
| 5 | **In game, type `/unlink`** | The site's row disappears within one ingest tick (five seconds by default). Reload `/player/rust` to confirm — this is the frame arriving over the feed, not the page asking |
|
|
| 6 | **Type a code from a server whose sidecar you have just stopped** | *"One of the servers could not be reached… your code is still good — try again in a minute."* Distinct from step 3's refusal, and the distinction is the point: the code is fine and fetching another one would not help |
|
|
|
|
Step 6 needs a fleet of two, one of them down; on a single-server rig it reads *"The game servers are
|
|
unreachable right now"* instead, which is the same rule with nothing left to be unsure about.
|
|
|
|
**What counts as a pass here:** the code never appears anywhere but in front of the player who asked
|
|
for it (check the chat log and the sidecar's `/events?kind=account.link.requested` — the frame
|
|
carries the steam id, the name and a TTL, and **no code**), a Steam account belongs to one website
|
|
account at a time, and every refusal is a sentence that tells the player what to do next.
|
|
|
|
---
|
|
|
|
## The permission walk (protocol 4, phase 7)
|
|
|
|
Added 2026-09-21. The website half was walked end to end against a stand-in plugin — the authoring
|
|
screen, the report, drift and its two answers, and a restart that emptied the store and was fully
|
|
re-pushed. **What is left is the sentence the phase exists for: a grant made on the website gates a
|
|
third-party plugin in the game.**
|
|
|
|
It cannot be walked from a console, and it cannot be walked on the owner's account:
|
|
|
|
- **A console session bypasses every gate.** The standard idiom is
|
|
`return !player || permission.UserHasPermission(...)`, and an RCON command has no `BasePlayer` —
|
|
so the console is unconditionally allowed ([PLAN.md §12.5](../modules/rust/PLAN.md)).
|
|
- **An admin account bypasses most plugins' gates too**, and not uniformly: Popup Notifications
|
|
(`player.IsAdmin ||`) and Zone Manager (`authLevel > 0 ||`) are hard bypasses. Kits is the
|
|
exception — its `IsAdmin` is the `kits.admin` **permission** and `AdminIgnoreRestrictions`
|
|
defaults to `false` — so a kit's `RequiredPermission` does apply to a server owner.
|
|
|
|
So this walk wants a **second, non-admin Steam account** connected to the rig. Kits alone can be
|
|
walked on the owner's account; steps 4 and 5 cannot.
|
|
|
|
| # | Do this | You should see |
|
|
|---|---|---|
|
|
| 1 | **Link the second account** (the identity walk above), then on the website open Admin → Rust permissions and grant it a kit's `RequiredPermission` — pick the kit from `GetKitNames`, or read one out of `oxide/config/Kits.json` | The grant appears with the account beside it. Within a minute the server row reads **in sync** — or press *Sync now* and watch it happen |
|
|
| 2 | **In game on that account, open the kit menu** | The kit is no longer locked. Before the grant it shows as locked; that difference is the whole phase |
|
|
| 3 | **At the server console, `oxide.show user <steamid>`** | The permission is there, granted by this plugin rather than by hand |
|
|
| 4 | **At the console, `oxide.grant user <steamid> zonemanager.admin`** (a permission the site manages but did not grant) | Within seconds the website's screen shows it under *Changed in game*. **Revoke** it there, and it is gone from `oxide.show user` on the next sync. **Adopt** a different one instead and it stays, now listed as the site's own |
|
|
| 5 | **Put the second account in a group on the website, then wipe or restart the server** (a wipe script that clears `oxide/data/` is the interesting case) | After the server is back: the group exists again, the membership is back, and the grant is back — without anybody touching the website. This is R2's central promise and the one thing a stand-in cannot prove |
|
|
| 6 | **Grant a permission whose plugin you have just unloaded** | The site reports it **unresolved** against that server and keeps the grant. Load the plugin again: it lands on the next sync, with nothing typed |
|
|
| 7 | **Add a website account that has never connected to this server to a group** | The site reports the membership as *waiting on their first connection*. Have them connect: it lands. A **direct grant** to the same account, by contrast, is in `oxide.show user` immediately |
|
|
|
|
**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and
|
|
this phase has two specific things to confirm there rather than assume:
|
|
|
|
- **`GetPermissionUsers` / `GetUsersInGroup` entry format.** Both answer `id(name)`, and the spacing
|
|
differs between the calls and between the frameworks. The plugin takes everything before the first
|
|
bracket. If that parse is wrong, **every holder is reported as foreign** — which is visible
|
|
immediately: the drift list fills with grants the site itself made.
|
|
- **`GetGroupPermissions(name, false)`** is called with both arguments. If Carbon's signature has no
|
|
second parameter, the plugin does not compile there at all — the one place in protocol 4 where
|
|
R19's byte-identical-plugin claim is at risk.
|
|
|
|
**What counts as a pass:** a non-admin player's access in game changes because of something typed on
|
|
the website and nothing else; a hand edit is reported rather than undone; and a wipe costs the
|
|
operator nothing.
|
|
|
|
## The configuration walk (protocol 5, phase 7b)
|
|
|
|
Added 2026-09-22. The website half was walked end to end against a real sidecar and a stand-in
|
|
plugin over a real directory of real config files — the recursive walk, a form save, a rollback, a
|
|
refusal, a version conflict and the locked keys — and the plugin half **compiles and loads on the
|
|
live Oxide rig**, where `rg.config` answers
|
|
`protocol=5 framework=oxide root=/home/container/oxide/config`.
|
|
|
|
**What is left is the sentence the phase exists for: a setting changed on the website takes effect
|
|
in the running game.** It needs the sidecar and the game server on **one host**, because the game
|
|
link is loopback by design (D2).
|
|
|
|
**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.
|