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
This commit is contained in:
@@ -50,7 +50,8 @@ it is listening without one.
|
||||
|
||||
## 2. Versioning
|
||||
|
||||
The wire version is a single integer, declared in **four** places that must agree:
|
||||
The wire version is a single integer — **2** as of the read path (§8) — declared in **four** places
|
||||
that must agree:
|
||||
|
||||
| Where | Repo |
|
||||
|---|---|
|
||||
@@ -77,7 +78,11 @@ Bump all four in the same change as the emitters, together with this document.
|
||||
|
||||
## 3. Protocol 1 — the transport
|
||||
|
||||
Everything phase 1 defines, and deliberately nothing more.
|
||||
Everything phase 1 defines, and deliberately nothing more. It is still the floor every later version
|
||||
stands on — the framing, the greeting, the heartbeat and the one correlated round trip are unchanged
|
||||
— but **two things below were amended by protocol 2**: every frame now carries `type`, `serverId`
|
||||
and `wipeId` (§8.1), and `server.hello` is a *board* rather than a one-off greeting (§8.3). Read §8
|
||||
beside this section rather than after it.
|
||||
|
||||
### 3.1 Framing
|
||||
|
||||
@@ -128,7 +133,7 @@ independently of the game, so anything it needs up front has to be re-sent per c
|
||||
| `t` | epoch milliseconds, stamped when the world was read |
|
||||
| `serverId` | this server's stable identity across wipes and restarts, from the plugin's config. **Not derived from the hostname** — an operator renames a server for a season and the site must not lose its history for it |
|
||||
| `bootId` | see §3.2.1 |
|
||||
| `saveCreatedAt` | when the current save was created. **Raw material for a wipe id, not a wipe id** — deriving one is the website's job and is not yet specified, and emitting a guess now would bake a wrong one into stored rows |
|
||||
| `saveCreatedAt` | when the current save was created. Protocol 1 called this *raw material for a wipe id* and left deriving one to the website; **§8.2 reversed that** — the plugin derives `wipeId` from this value and stamps it on every frame |
|
||||
|
||||
Everything from `hostname` down is read from `ConVar.Server` and `BasePlayer.activePlayerList` on
|
||||
the game's main thread. A field the game cannot answer is **absent**, never zero.
|
||||
@@ -196,7 +201,8 @@ Every response carries `X-RustLink-Version`, including `/health` and including e
|
||||
|---|---|---|
|
||||
| `GET /health` | — | **Unauthenticated**, so monitoring can reach it |
|
||||
| `GET /server` | the store | The last `server.hello`. **`204` when the game has never connected** |
|
||||
| `GET /events?kind=&limit=` | the store | Newest first; `limit` clamped to 1–1000 |
|
||||
| `GET /events?kind=&wipe=&limit=` | the store | Newest first; `limit` clamped to 1–1000. For a human |
|
||||
| `GET /feed?since=&limit=` | the store | **Oldest first**, from a cursor. For a consumer that must not miss a row (§8.9) |
|
||||
| `GET /status` | the plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply |
|
||||
| `GET /ws` | broadcast | The live feed; sends `{"kind":"ws.hello","protocol":1}` on connect |
|
||||
|
||||
@@ -304,6 +310,7 @@ override the file.
|
||||
| `[web].bind` | `RUSTLINK_WEB_BIND` | `127.0.0.1:8090` |
|
||||
| `[web].auth_token` | `RUSTLINK_WEB_TOKEN` | *(generated on first start)* |
|
||||
| `[store].path` | `RUSTLINK_DB_PATH` | `rust-link.db` |
|
||||
| `[store].retain_days` | `RUSTLINK_RETAIN_DAYS` | `14` |
|
||||
|
||||
Two things about those are load-bearing:
|
||||
|
||||
@@ -323,15 +330,258 @@ writing the file and generating the token if they are missing — and prints it
|
||||
|
||||
## 7. What is deliberately not here yet
|
||||
|
||||
Protocol 1 is the transport. Every one of these arrives with the phase that needs it, and each is a
|
||||
version bump:
|
||||
Protocol 2 is the transport plus the read path. Every one of these arrives with the phase that needs
|
||||
it, and each is a version bump:
|
||||
|
||||
- the read path — player events, kills, clans, presence
|
||||
- identity and the in-game link code
|
||||
- the permission mirror
|
||||
- leases, budgets and the event actions
|
||||
- the map image over the asset-bridge shape
|
||||
- identity and the in-game link code (phase 6)
|
||||
- the permission mirror (phase 7), and plugin configuration edited from the site (phase 7b)
|
||||
- clans, for core's Team provider (phase 9)
|
||||
- leases, budgets and the event actions (phases 12-13)
|
||||
- the map image over the asset-bridge shape (phase 14)
|
||||
|
||||
The rule that governs all of them: **the sidecar is a dumb forwarder.** It defines no schema for a
|
||||
frame's contents, so a version that adds fields to an event needs no change there — only one that
|
||||
adds a new *indexed* column does.
|
||||
adds a new *indexed* column does. §8.1 is what turns that from an intention into a property of the
|
||||
code.
|
||||
|
||||
---
|
||||
|
||||
## 8. Protocol 2 — the read path
|
||||
|
||||
Protocol 1 proved a line could travel. Protocol 2 is what travels: presence, deaths, chat, gathering,
|
||||
moderation and the wipe, on both mod frameworks from one plugin file.
|
||||
|
||||
It is the first version with a *catalogue*, and a catalogue is the thing that grows fastest. So the
|
||||
shape below is chosen to make growth free everywhere except in the one place that must stay
|
||||
deliberate — what the public is allowed to see.
|
||||
|
||||
### 8.1 Every frame says what it **is**, not only what it is about
|
||||
|
||||
Protocol 1 routed on `kind`, in a `match` the sidecar had to learn a new arm for on every addition.
|
||||
Protocol 2 adds **`type`**, and the sidecar files by `type` alone:
|
||||
|
||||
| `type` | Persisted | Broadcast on `/ws` | Routed by `reqId` | Example |
|
||||
|---|---|---|---|---|
|
||||
| `event` | appended to the history | yes | no | `player.death` |
|
||||
| `snapshot` | **replaces** the board of that `kind` | yes | no | `players.online` |
|
||||
| `reply` | no | no | **yes** | `server.status` |
|
||||
| `control` | no | no | no | `pong` |
|
||||
|
||||
**This is the dumb-forwarder property made structural.** A protocol version that adds ten event
|
||||
kinds needs no change in the sidecar at all, because the sidecar never learns a kind — it learns
|
||||
four verbs, and they are the complete set of things that can be done with a frame. Only a version
|
||||
that adds a new *indexed column* touches it.
|
||||
|
||||
Every outbound frame therefore carries five fields before anything specific to it:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "player.death",
|
||||
"type": "event",
|
||||
"t": 1789510452152,
|
||||
"serverId": "main",
|
||||
"wipeId": "w-20260915T195817Z"
|
||||
}
|
||||
```
|
||||
|
||||
- **`type` is required.** A frame without one is **dropped and counted**, and the sidecar says so
|
||||
once per connection. It is not defaulted to `event`: guessing files a board as history, which is
|
||||
invisible until somebody wonders why the presence board has four thousand rows. The game link has
|
||||
no version handshake (§2), so this is the place a mismatched pair fails loudly instead of quietly.
|
||||
- **`serverId` is on every frame**, not only in the server body (R8). A frame is stored beside
|
||||
frames from five other servers and has to be able to say which one it came from on its own.
|
||||
- **`wipeId` is on every frame** — see §8.2.
|
||||
|
||||
### 8.2 `wipeId` is derived by the **plugin**, and this amends §3.2
|
||||
|
||||
§3.2 called `saveCreatedAt` *"raw material for a wipe id, not a wipe id — deriving one is the
|
||||
website's job"*. That is reversed here, deliberately, and the reason is that by protocol 2 there are
|
||||
**three** components storing rows that need it:
|
||||
|
||||
```
|
||||
w-yyyyMMddTHHmmssZ e.g. w-20260915T195817Z
|
||||
```
|
||||
|
||||
It is `SaveRestore.SaveCreatedTime` in UTC, to the second — the same instant `saveCreatedAt` already
|
||||
reports, in the id-shaped spelling `bootId` uses. The plugin stamps it because the plugin is the only
|
||||
component that can *read* it; every other component would be re-deriving a value it was already told,
|
||||
and two derivations of one fact eventually disagree about a boundary.
|
||||
|
||||
Three consequences worth stating rather than discovering:
|
||||
|
||||
- **A server that has never saved has no wipe**, so `wipeId` is **absent**, never `""` and never
|
||||
`w-unknown`. Absent is a fact; an empty string is a row that will sort beside every other empty
|
||||
string forever.
|
||||
- **The id changes on `OnNewSave` and at no other time.** It is not the boot id: a restart re-reads
|
||||
the same save and reports the same wipe, which is exactly what R12 needs to keep a player's
|
||||
history across a restart while splitting it across a wipe.
|
||||
- **A wipe boundary is a fact about the world, not about the bridge.** The plugin re-reads the value
|
||||
on `OnNewSave` and caches it otherwise; nothing about a reconnect can change it.
|
||||
|
||||
### 8.3 Boards — current state, one producer, re-sent on connect
|
||||
|
||||
A board is chapter 4's word: *current state with exactly one producer, re-sent on every connect*.
|
||||
Protocol 2 defines two.
|
||||
|
||||
| Board (`kind`) | Holds |
|
||||
|---|---|
|
||||
| `server.hello` | the server's own description — §3.2's body, now `type: "snapshot"` |
|
||||
| `players.online` | who is connected right now: `steamId`, `name`, `connectedAt`, `sleeping` |
|
||||
|
||||
**Boards are re-emitted on connect and on a 60-second cadence thereafter.** The events carry the
|
||||
story — `player.connected`, `player.disconnected` — and the board is the **reconciliation point**. A
|
||||
missed event is corrected within a minute rather than persisting until the next restart, and the
|
||||
acceptance criterion *"a restarted sidecar is fully populated within one connection"* is met by
|
||||
construction rather than by hoping no event was in flight.
|
||||
|
||||
The cadence is cheap on purpose: a full board for a 100-slot server is a few kilobytes, and a server
|
||||
with nobody on it emits an empty array, which is a different answer from having said nothing.
|
||||
|
||||
### 8.4 The catalogue
|
||||
|
||||
Every kind protocol 2 defines, and the hook behind it. **`class` is not a field on the wire** — see
|
||||
§8.5 — it is what this table binds the module's allowlist to.
|
||||
|
||||
| `kind` | Hook | `class` | Carries |
|
||||
|---|---|---|---|
|
||||
| `player.connected` | `OnPlayerConnected` | public | steamId, name |
|
||||
| `player.disconnected` | `OnPlayerDisconnected` | public | steamId, name, reason, sessionSec |
|
||||
| `player.respawned` | `OnPlayerRespawned` | public | steamId |
|
||||
| `player.death` | `OnPlayerDeath` | public | victim, attacker, attackerType, weapon, distance, grid |
|
||||
| `player.chat` | `OnPlayerChat` | public | steamId, name, channel, message |
|
||||
| `player.tally` | *aggregate* — see §8.6 | public | steamId, gathered{}, npcKills, structures |
|
||||
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
|
||||
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
|
||||
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
|
||||
| `player.login.attempt` | `CanUserLogin` *(observed, never answered)* | **staff** | id, name, **ip** |
|
||||
| `player.approved` | `OnUserApproved` | **staff** | id, name, **ip** |
|
||||
| `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced |
|
||||
| `server.initialized` | `OnServerInitialized` | public | — |
|
||||
| `server.shutdown` | `OnServerShutdown` | public | — |
|
||||
|
||||
`grid` is the Rust map reference (`H7`), not a coordinate. A death's grid is where a fight happened
|
||||
and every community site shows it; a **structure's** grid is where somebody lives, which is why
|
||||
`entity.destroyed` is staff-class here and why R9 makes the same distinction for map layers.
|
||||
|
||||
**Three hooks are deliberately not in this wave, and none of them is an oversight:** `OnEntityTakeDamage`
|
||||
and `OnFrame`/`OnTick` fire at a rate that makes a bridge a performance regression, and nothing in
|
||||
phases 3–19 needs per-hit or per-frame fidelity. R17's warning about chatty zone transitions is the
|
||||
same rule: **subscribe selectively; the cost of a hook is paid on the game's main thread.**
|
||||
|
||||
### 8.5 The class is enforced by the **module**, not declared on the wire
|
||||
|
||||
The wire carries no visibility field, and this is a security decision rather than an economy.
|
||||
|
||||
**A boundary must be enforced by the side that serves, never declared by the side that sends.** The
|
||||
website's own shard fan-out works this way — a public SSE stream with an allowlist of event kinds,
|
||||
and an admin stream that adds the rest — and the property that makes it trustworthy is that a
|
||||
compromised or merely out-of-date sender cannot widen it. A `"class":"public"` field on the frame
|
||||
would move the decision to the game host.
|
||||
|
||||
So: the table in §8.4 is the specification, `module-rust` holds the allowlist, and it is
|
||||
**default-deny** — a kind the allowlist has never heard of is not public. The module's own test holds
|
||||
its allowlist against this document, so adding a kind here without classifying it there fails a
|
||||
build rather than shipping an IP address to a public page.
|
||||
|
||||
`player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and
|
||||
`player.reported` carries the text of one player's complaint about another. They are stored because
|
||||
an operator chasing ban evasion needs them and because the sidecar persists what it is told; they
|
||||
reach no tier below admin, and the raw window that holds them is bounded (§8.9).
|
||||
|
||||
### 8.6 Two things are aggregated in the plugin, and that is the interesting part of this phase
|
||||
|
||||
`OnDispenserGather` fires on **every swing at a tree**. A single player chopping for a minute is
|
||||
hundreds of hooks; ten players gathering is a frame rate problem in the bridge rather than in the
|
||||
game. The same is true of animal and scientist kills, at a lower rate.
|
||||
|
||||
Neither is interesting per occurrence — nobody wants a killfeed of chickens — and both are wanted
|
||||
*in total*, for the leaderboard. So the plugin keeps a per-player tally on the main thread and flushes
|
||||
it as one `player.tally` frame:
|
||||
|
||||
- on a **60-second cadence**, for players with a non-zero tally;
|
||||
- on **disconnect**, so a session's last minute is not lost;
|
||||
- at `OnServerShutdown`, which is the flush that covers a restart.
|
||||
|
||||
A plugin *reload* is the one case that loses a tally, by choice: `Unload` runs on the game's main
|
||||
thread, and draining the outbound queue there means waiting on a socket from the main thread — the
|
||||
stall phase 1 removed. Under a minute of one player's gathering is the price, and a wedged peer
|
||||
would make the cure worse than the disease.
|
||||
|
||||
A tally frame is a **delta, not a running total** — it reports what happened since the last flush,
|
||||
so the consumer sums rather than diffs and a missed frame costs that interval instead of corrupting
|
||||
the series.
|
||||
|
||||
This is the general rule for every later wave: **if a hook can fire more than once a second per
|
||||
player, it is a counter, not an event.**
|
||||
|
||||
### 8.7 The read path never vetoes, and it is structural rather than disciplined
|
||||
|
||||
Four hooks in §8.4 are documented by uMod as *"returning a non-null value overrides default
|
||||
behavior"* — `OnPlayerDeath`, `OnDispenserGather` and `CanUserLogin` among them. A read-path bridge
|
||||
that returned something by accident would cancel a death, swallow a player's wood, or refuse a
|
||||
login, and it would do it on a production server at 3am.
|
||||
|
||||
**So every vetoable hook in the read path is declared `void`.** Both frameworks bind hooks by name
|
||||
and arity and take the method's return value; a `void` method returns nothing and therefore cannot
|
||||
override anything. The rule is enforced by the signature rather than by remembering to write
|
||||
`return null`, which is the only version of this rule that survives a year of edits.
|
||||
|
||||
`CanUserLogin` is in the wave for what it *observes*, never for what it answers.
|
||||
|
||||
### 8.8 A login denial is not a hook — and §10 of `PLAN.md` says it is
|
||||
|
||||
`PLAN.md` §10 sources the `rust.login.denied` trigger from `CanUserLogin`. Reading the hook says that
|
||||
cannot work: `CanUserLogin` is called on **every** connection attempt, and the only way to learn of a
|
||||
denial from it is to *be* the denier, which §8.7 forbids. uMod publishes no `OnUserRejected`.
|
||||
|
||||
What the game can actually tell us is two facts — an attempt, and an approval — so protocol 2 emits
|
||||
both and **a denial is the absence of an approval** for an attempt, decided by a deferred read rather
|
||||
than by a hook. Phase 10 owns that pairing; protocol 2 owes it the two frames and the `t` on each.
|
||||
|
||||
Recorded here because it is a correction to a catalogue, not a defect: the trigger survives, its
|
||||
source changes.
|
||||
|
||||
### 8.9 History, cursors and retention
|
||||
|
||||
Three changes on the sidecar's own side follow from a catalogue that actually produces volume.
|
||||
|
||||
**`events` gains `server_id` and `wipe_id` as indexed columns.** This is the one migration shape the
|
||||
store's own header predicted: *"only a version that adds a new indexed column ever needs a
|
||||
migration"*. It is applied as an `ALTER` guarded by a column check, never as an edit to the `CREATE`
|
||||
— the same rule the website's schema fragments live under, for the same reason.
|
||||
|
||||
**A new route, `GET /feed?since=&limit=`, is the ingest cursor**, and it is deliberately *not*
|
||||
`/events` with a flag:
|
||||
|
||||
| Route | Order | For |
|
||||
|---|---|---|
|
||||
| `GET /events?kind=&wipe=&limit=` | newest first | a human, an admin screen, a point-in-time look |
|
||||
| `GET /feed?since=&limit=` | **oldest first**, from a cursor | a consumer that must not miss a row |
|
||||
|
||||
One route with two orderings depending on a query parameter is a trap: every caller that forgets the
|
||||
parameter gets the other one silently, and for the ingesting caller that means it advances its cursor
|
||||
past rows it never read. Two routes, one ordering each.
|
||||
|
||||
`/feed` items are wrapped rather than bare, because a cursor needs the row's identity:
|
||||
|
||||
```json
|
||||
{ "items": [ { "id": 1041, "t": 1789…, "kind": "player.death", "frame": { … } } ],
|
||||
"lastId": 1041, "more": false }
|
||||
```
|
||||
|
||||
`more` is `true` when the page filled, so a consumer that has fallen an hour behind drains at its own
|
||||
pace instead of guessing from a count.
|
||||
|
||||
**Omitting `since` asks where the end is** — no rows, and the current `lastId`. `since=0` is the
|
||||
other question entirely: replay everything retained. That is deliberate, because the two intentions
|
||||
must not be separated by whether somebody typed a parameter: a module installed today against a
|
||||
month-old sidecar wants what happens next, not a fortnight of deaths it has no rollups for.
|
||||
|
||||
**The store prunes.** `[store].retain_days` (default 14) bounds the event history, swept hourly.
|
||||
Three things make that safe rather than lossy: the website holds the permanent per-wipe rollups
|
||||
(R12), boards are never pruned because they hold exactly one row per kind, and the sidecar's database
|
||||
lives inside a game container whose disk is the operator's (R20). A store that grows without bound on
|
||||
a game host is a wipe-day outage waiting for a busy month.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user