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
588 lines
29 KiB
Markdown
588 lines
29 KiB
Markdown
# rust-link — the wire protocol
|
||
|
||
**Canonical.** This document defines the two contracts that make up the Rust bridge. Code in three
|
||
repositories is held against it, and a change here is a change in all of them.
|
||
|
||
| Contract | Between | Transport |
|
||
|---|---|---|
|
||
| The **game link** | the Oxide bridge plugin ↔ the sidecar | loopback TCP, newline-delimited JSON |
|
||
| The **website API** | the sidecar ↔ `module-rust` | HTTP + WebSocket, bearer token |
|
||
|
||
Mirrors [`link/`](../link/PLAN.md), which is the same pair of contracts for Ultima Online. Where
|
||
this document is silent, that one is not a fallback: the two protocols are independent and share
|
||
only their shape.
|
||
|
||
---
|
||
|
||
## 1. Why the game does not listen
|
||
|
||
**The plugin is the TCP client; the sidecar owns the listener.** A Rust server therefore opens no
|
||
extra port, and the only component the website can reach is the sidecar. This is inherited unchanged
|
||
from the ServUO bridge — the footing changed (Oxide hooks instead of game source) and the invariant
|
||
did not.
|
||
|
||
```
|
||
Rust server + Oxide (Rust-Plugins, C#)
|
||
│ the plugin DIALS OUT · 127.0.0.1:7799 · newline-delimited JSON, bidirectional
|
||
▼
|
||
rust-link sidecar (Rust-Link) ← the only network-facing bridge component
|
||
│ WebSocket (live feed) + REST (point-in-time reads), bearer-token auth
|
||
▼
|
||
module-rust, inside a website core
|
||
```
|
||
|
||
**One game server, one sidecar, on that server's own host.** A community running six servers runs
|
||
six pairs; `module-rust` holds six clients and the website core never learns there is more than one.
|
||
Nothing in the sidecar is multiplexed and nothing in it should become multiplexed — the `serverId`
|
||
on every frame exists so the *module* can tell its clients apart, not so the sidecar can.
|
||
|
||
### 1.1 Loopback is the trust boundary on the game link
|
||
|
||
There is **no token on the game link**. The plugin and the sidecar share a host, and the sidecar
|
||
binds `127.0.0.1` — that is the authentication, exactly as on the ServUO bridge. Binding
|
||
`[game].bind` to a routable address puts an unauthenticated command channel on the network.
|
||
|
||
The website-facing surface is the opposite: authentication there is **always on** and cannot be
|
||
turned off. The sidecar generates and persists a token on first start, so there is no state in which
|
||
it is listening without one.
|
||
|
||
---
|
||
|
||
## 2. Versioning
|
||
|
||
The wire version is a single integer — **2** as of the read path (§8) — declared in **four** places
|
||
that must agree:
|
||
|
||
| Where | Repo |
|
||
|---|---|
|
||
| `PROTOCOL_VERSION` in `sidecar/src/main.rs` | Rust-Link |
|
||
| `ProtocolVersion` in `overlay/oxide/plugins/RunicGateway.cs` | Rust-Plugins |
|
||
| `protocol` in `overlay.toml` | Rust-Plugins |
|
||
| `PROTOCOL_VERSION` in `server/sidecarClient.js` | Module-Rust |
|
||
|
||
Bump all four in the same change as the emitters, together with this document.
|
||
|
||
**The two halves of the contract enforce it differently, and the asymmetry is the reason
|
||
`overlay.toml` exists at all:**
|
||
|
||
- On the **website API** the check is live. Every response carries `X-RustLink-Version`; a client
|
||
that declares a different one in its request header is refused `409` with both numbers in the
|
||
body, rather than served something it will mis-parse.
|
||
- On the **game link** there is no such check, and a mismatched plugin would simply mis-parse. The
|
||
plugin announces its protocol in `server.hello`, which is readable only after the game server has
|
||
booted with it loaded — far too late for an installer to refuse a bad pairing. So `overlay.toml`
|
||
declares it statically, and the installer refuses to pair an overlay and a sidecar whose numbers
|
||
disagree. A bump landing in one repo and not the others fails to compose rather than half-deploying.
|
||
|
||
---
|
||
|
||
## 3. Protocol 1 — the transport
|
||
|
||
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
|
||
|
||
Newline-delimited JSON over TCP, both directions, UTF-8. One complete JSON object per line, no
|
||
embedded newlines.
|
||
|
||
- **Outbound frames** (plugin → sidecar) carry `kind`.
|
||
- **Inbound frames** (sidecar → plugin) carry `cmd`.
|
||
|
||
Both ends cap an inbound line at **1 MiB**. An over-long line is **discarded, not buffered**, and
|
||
the connection stays up: a single malformed frame is not a reason to tear down a link that live
|
||
events are flowing over, and a dropped reply simply times out on the caller's side and is
|
||
re-requested.
|
||
|
||
The cap exists from protocol 1 rather than being added after the first large frame arrives. An
|
||
unbounded read facing a peer that will one day send a map image is a memory-exhaustion shape we
|
||
would be inventing ourselves.
|
||
|
||
### 3.2 `server.hello` — plugin → sidecar
|
||
|
||
Sent on **every successful connect**, not once at game-server start. The sidecar restarts
|
||
independently of the game, so anything it needs up front has to be re-sent per connection.
|
||
|
||
```json
|
||
{
|
||
"kind": "server.hello",
|
||
"t": 1789510452152,
|
||
"protocol": 1,
|
||
"serverId": "main",
|
||
"bootId": "boot-20260915T194502Z",
|
||
"plugin": "0.1.0",
|
||
"hostname": "Test Server",
|
||
"description": "No server description has been provided.",
|
||
"level": "Procedural Map",
|
||
"seed": 1234,
|
||
"worldSize": 4000,
|
||
"maxPlayers": 10,
|
||
"players": 0,
|
||
"joining": 0,
|
||
"queued": 0,
|
||
"uptimeSec": 8947,
|
||
"saveCreatedAt": "2026-09-15T19:58:17Z"
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
|---|---|
|
||
| `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. 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.
|
||
|
||
#### 3.2.1 `bootId` identifies the server PROCESS
|
||
|
||
It is the server process's start instant, formatted `boot-yyyyMMddTHHmmssZ`, and it must change
|
||
**when and only when the world started over**.
|
||
|
||
That makes three things it is deliberately not:
|
||
|
||
- **Not a fresh value per plugin load.** `oxide.reload RunicGateway` must not change it. The website
|
||
watches this value to tell a game restart — where everything an event put in the world is gone —
|
||
from a bridge reconnect, which loses nothing; a plugin reload is the second kind, and a boot id
|
||
regenerated at `Init` would ask the site to reconcile its whole ledger for no news.
|
||
- **Not the sidecar's identity.** The sidecar restarting is invisible to the world.
|
||
- **Not the wipe.** A wipe is `saveCreatedAt` changing; a restart is not a wipe.
|
||
|
||
The plugin reads it from `Process.StartTime`, which is exact and identical on every read.
|
||
|
||
### 3.3 `ping` / `pong` — the heartbeat
|
||
|
||
The sidecar sends `{"cmd":"ping"}` every 30 seconds while a plugin is connected; the plugin answers
|
||
`{"kind":"pong","t":…}`.
|
||
|
||
A `pong` is **never persisted**. It only moves the sidecar's `last_event`, which is the whole point:
|
||
a Rust server with nobody on it is very quiet, and without a heartbeat "the game has said nothing
|
||
for six hours" would be indistinguishable from "the link died six hours ago".
|
||
|
||
### 3.4 `server.status` — the request/reply verb
|
||
|
||
The one correlated round trip in protocol 1. It exists so the correlation path is exercised by
|
||
something before anything depends on it.
|
||
|
||
```
|
||
sidecar → plugin {"cmd":"server.status","reqId":"r-1"}
|
||
plugin → sidecar {"kind":"server.status","reqId":"r-1","t":…, …the §3.2 body…}
|
||
```
|
||
|
||
**Correlation is by `reqId`, a process-unique counter minted by the sidecar.** The plugin echoes it
|
||
verbatim and **only when one was supplied**: a reply that invented one would be routed to nobody,
|
||
and a reply that omitted one the caller sent would leave that caller waiting out its whole timeout.
|
||
|
||
`server.hello` and `server.status` share a body by construction, in one function in the plugin. They
|
||
differ in what wraps them, not in what they say about the server, and letting them drift is how a
|
||
site ends up showing two different player counts.
|
||
|
||
### 3.5 `link.down` — the sidecar's own observation
|
||
|
||
Not a frame the plugin sends. When a plugin connection ends the sidecar synthesises
|
||
`{"kind":"link.down"}` onto its broadcast channel, so the website sees the drop without polling. It
|
||
is **never persisted**: it is this process's observation, not something the game said.
|
||
|
||
---
|
||
|
||
## 4. The website API
|
||
|
||
Served by the sidecar. Everything except `/health` requires the token, which may arrive as
|
||
`Authorization: Bearer <t>`, `X-Api-Key: <t>`, or `?token=<t>` — the last so browser WebSocket
|
||
clients, which cannot set handshake headers, can still authenticate. The compare is constant-time.
|
||
|
||
Every response carries `X-RustLink-Version`, including `/health` and including error responses.
|
||
|
||
| Route | Backed by | Notes |
|
||
|---|---|---|
|
||
| `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=&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 |
|
||
|
||
### 4.1 The split between store-backed and live is deliberate
|
||
|
||
The store-backed reads answer **while the game server is off**, which is what lets the website render
|
||
a server list during a wipe or a restart. `/status` is the one route that fails when the game is
|
||
down, because "what is it doing right now" has no stale answer worth giving.
|
||
|
||
### 4.2 `204` is an answer
|
||
|
||
`GET /server` answers `204`, not `200` with a null, when the game has never connected. "We have
|
||
never heard from this server" and "this server reports nothing" are different answers, and a client
|
||
that cannot tell them apart renders a server that does not exist. `module-rust` maps the two onto
|
||
distinct stored states (`reachable` without `online`, versus neither).
|
||
|
||
### 4.3 Status codes carry the diagnosis
|
||
|
||
A wrong URL, a wrong token and a mismatched protocol all present to an operator as "the site says my
|
||
server is offline", and each has a different fix. The codes keep them apart:
|
||
|
||
| Code | Means | Where the fix is |
|
||
|---|---|---|
|
||
| `409` | protocol mismatch, both numbers in the body | upgrade one component |
|
||
| `401` | wrong or missing token | the admin form |
|
||
| `503` | no plugin connected | the game server |
|
||
| `504` | the plugin did not reply in time | the game server, differently |
|
||
| *(transport error)* | nothing is listening | the sidecar, or the URL |
|
||
|
||
### 4.4 The RPC timeout is a ceiling on every later command budget
|
||
|
||
The sidecar waits **10 seconds** for a correlated reply (`rpc::REPLY_TIMEOUT`). `module-rust`'s own
|
||
client waits **12 seconds** (`TIMEOUT_MS`).
|
||
|
||
Core's event dispatcher classifies a `budgetMs` overrun as retryable **unconditionally** — it cannot
|
||
ask the action, which is still awaiting a socket. So an action whose `budgetMs` does not exceed the
|
||
module's client timeout can never report `retry: false`, and that code is unreachable. The ordering
|
||
is:
|
||
|
||
```
|
||
sidecar RPC timeout (10s) < module client timeout (12s) < an action's budgetMs
|
||
```
|
||
|
||
Derive one from another rather than writing all three down independently.
|
||
|
||
---
|
||
|
||
## 5. What the plugin owes the game
|
||
|
||
Three rules, and each has a failure behind it. They are the ServUO bridge's, unchanged.
|
||
|
||
1. **`Emit` is called from the main thread. It formats nothing, blocks on nothing, and touches no
|
||
socket.** It enqueues and returns. A slow, wedged, or absent sidecar cannot stall the game.
|
||
2. **One link thread owns the socket.** A single writer keeps event ordering intact. It reconnects
|
||
with bounded backoff, and the backoff waits on a handle rather than sleeping — an uninterruptible
|
||
sleep there is a stall of up to the backoff on every plugin reload, on the main thread.
|
||
3. **A reader thread parses inbound lines and marshals each to the main thread** via
|
||
`Interface.Oxide.NextTick`. The reader touches no Unity object, no `BasePlayer` and no `ConVar`.
|
||
|
||
The outbound queue is **bounded, drop-oldest**: on overflow the oldest record goes and is counted,
|
||
because telemetry is worth less than the server's memory.
|
||
|
||
### 5.1 Diagnosing the link
|
||
|
||
```
|
||
rg.link
|
||
```
|
||
|
||
from the game server's console or over RCON:
|
||
|
||
```
|
||
protocol=1 serverId=main connected=True depth=0 sent=3 dropped=0 received=2
|
||
connects=1 writeErrors=0 bootId=boot-20260915T194502Z
|
||
```
|
||
|
||
This separates "the plugin is not loaded", "the plugin cannot reach the sidecar" and "the website
|
||
cannot reach the sidecar", which look identical from the site.
|
||
|
||
---
|
||
|
||
## 6. Configuration
|
||
|
||
### 6.1 The plugin — `oxide/config/RunicGateway.json`
|
||
|
||
Written by Oxide on first load; edited like any other plugin's config.
|
||
|
||
```json
|
||
{
|
||
"Host": "127.0.0.1",
|
||
"Port": 7799,
|
||
"QueueCap": 5000,
|
||
"ServerId": "main"
|
||
}
|
||
```
|
||
|
||
### 6.2 The sidecar — `sidecar.toml`
|
||
|
||
Resolved as `--config <PATH>`, else `$RUSTLINK_CONFIG`, else `./sidecar.toml`. Environment variables
|
||
override the file.
|
||
|
||
| Key | Env | Default |
|
||
|---|---|---|
|
||
| `[game].bind` | `RUSTLINK_GAME_BIND` | `127.0.0.1:7799` |
|
||
| `[game].server_id` | `RUSTLINK_SERVER_ID` | *(empty)* |
|
||
| `[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:
|
||
|
||
- **A relative `[store].path` resolves against the directory holding `sidecar.toml`**, not the
|
||
working directory. A service manager's working directory must not decide where the database lands
|
||
— on Windows that can be `%SystemRoot%\System32`, or a silently redirected VirtualStore copy.
|
||
- **`[game].server_id` is a cross-check, not a second source of truth.** The plugin announces its own
|
||
`serverId` and that is the authority; when both are set and they disagree, the sidecar logs the
|
||
disagreement loudly and keeps the plugin's. Two game servers pointed at one sidecar by a copied
|
||
config is the mistake this catches, and it is silent in every other design.
|
||
|
||
`rust-link-sidecar --print-config` resolves the configuration exactly as a normal start would —
|
||
writing the file and generating the token if they are missing — and prints it as JSON on stdout,
|
||
**including the token in clear text**. That is the supported way for an installer to read it back.
|
||
|
||
---
|
||
|
||
## 7. What is deliberately not here yet
|
||
|
||
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:
|
||
|
||
- 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. §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.
|
||
|
||
---
|
||
|