Files
docs/rust-link/PLAYER_WALK.md
wtclaude 30e72adfcf docs(rust-link): protocol 2 — the read path, and phase 3 as built
The specification the other three repositories are held against, plus the phase
record.

`PROTOCOL.md` §8 is the new contract. Its centre is one field: every frame now
carries `type` — `event`, `snapshot`, `reply`, `control` — and the sidecar files
on that and nothing else. That is the dumb-forwarder property made structural
rather than intended: ten new event kinds are zero change in Rust-Link, and only
a version adding an indexed column touches it at all.

Also in §8: the fifteen-kind catalogue and what each frame carries; `wipeId`
derived by the plugin, which REVERSES §3.2's "deriving one is the website's job"
and says why; boards re-sent on connect and on a cadence; the aggregate rule (a
hook that can fire more than once a second per player is a counter, not an
event); the void rule that stops a read-path hook vetoing a death or a login; and
`GET /feed`, a cursor route separate from `/events` because one route with two
orderings serves the wrong one to every caller that forgets the parameter.

§8.5 is the part to read twice. The classification of a kind as public or staff
is NOT on the wire, deliberately: a boundary declared by the sender is one a
compromised or out-of-date game host can widen, so the module holds a
default-deny allowlist and this table is what its test holds it against.

§8.8 corrects a catalogue rather than a defect: PLAN.md §10 sources
`rust.login.denied` from `CanUserLogin`, and that hook fires on every attempt —
the only way to learn of a denial from it is to be the denier. A denial is the
absence of an approval, and protocol 2 emits both facts so phase 10 can pair them.

`PLAYER_WALK.md` is new, and it exists because half this catalogue cannot fire
without somebody holding a mouse. Ten steps, what each one should produce, and
what counts as a pass — written so the walk can be run without watching the
output live, and so the answer afterwards is readable as a transcript.

PLAN.md §16 is phase 3 as built: the four decisions, the two defects only a
server that BOOTED with the plugin could find (a wipe id that was null for every
real session, and a two-second main-thread stall on unload), what was proven and
how, and — stated plainly rather than implied — the three measurements still
queued on the org lead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 08:39:27 -05:00

5.9 KiB

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 yourselfkill 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:

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 §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 envelopetype, serverId and wipeId on all of them (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.