- PLAN.md §28.5–28.8: two more org-lead decisions (D96 required zone minutes held by the game; D97 one placing verb per kind, because core infers cap boxes from examples), the seven-step walk on both rigs, the defect a mid-run restart found (the reconcile asked a world that had not loaded and the plugin pruned live crates) and its fix, and what is not proven. - PROTOCOL.md §15: protocol 9 — the five world commands, the registry keyed by the website's key, what a restart and a wipe do, `worldReady`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
1539 lines
82 KiB
Markdown
1539 lines
82 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 — **9** as of the world verbs (§15) — 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 |
|
||
| `GET /lease`, `POST /lease`, `POST /lease/release` | the plugin (RPC) | Protocol 8, the leases (§14) |
|
||
| `GET /world/monuments`, `GET /world/owned?runId=`, `POST /world/zone`, `POST /world/place`, `POST /world/revert` | the plugin (RPC) | Protocol 9, the world verbs (§15) |
|
||
|
||
### 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.
|
||
|
||
**Lease calls are the exception, and a deliberate one** (§14.7): `core.lease` spends one default
|
||
budget on two calls, so the module's lease timeout is *shorter* than the sidecar's.
|
||
|
||
---
|
||
|
||
## 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 4 is the transport, the read path, identity and the permission mirror. 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)~~ — **protocol 3, §9**
|
||
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
|
||
- ~~plugin configuration edited from the site (phase 7b)~~ — **protocol 5, §11**
|
||
- ~~clans, for core's Team provider (phase 9)~~ — **protocol 6, §12**
|
||
- ~~who lives in a raided base, for the raid alert (phase 10)~~ — **protocol 7, §13**
|
||
- 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` | **presence** | steamId, name |
|
||
| `player.disconnected` | `OnPlayerDisconnected` | **presence** | steamId, name, reason, sessionSec |
|
||
| `player.respawned` | `OnPlayerRespawned` | **presence** | steamId |
|
||
| `player.death` | `OnPlayerDeath` | **presence** | victim, attacker, attackerType, weapon, distance, grid |
|
||
| `player.chat` | `OnPlayerChat` | **presence** | steamId, name, channel, message |
|
||
| `player.tally` | *aggregate* — see §8.6 | **presence** | steamId, gathered{}, npcKills, structures |
|
||
| `entity.destroyed` | `OnEntityDeath` on owned building blocks — **and doors, external walls and the cupboard from protocol 7 (§13)** | **staff** | ownerId, prefab, grid, attacker; from protocol 7 also `structure`, `buildingId`, `authorized` |
|
||
| `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 | — |
|
||
| `account.link.requested` | `/link` chat command *(protocol 3)* | **staff** | steamId, name, ttlSec — **never the code** |
|
||
| `account.unlinked` | `/unlink` chat command *(protocol 3)* | **staff** | steamId, name, origin |
|
||
|
||
`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.
|
||
|
||
**`presence` is `public` with an audience an operator chooses** (added 2026-09-22, [`PLAN.md`](../modules/rust/PLAN.md) §23).
|
||
The six kinds marked so each say that a *named* player was on the server at a given moment, and the
|
||
org lead's rule is that nothing names who is online by default: `module-rust` serves them only to
|
||
viewers inside an operator-chosen audience — staff unless widened, fleet-wide with a per-server
|
||
override. Below it the public feed carries only what names nobody (a wipe, a start, a shutdown).
|
||
Nothing on the wire changed: the class is still the module's to enforce, which is why the rule could
|
||
be added without a protocol bump.
|
||
|
||
`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.
|
||
|
||
---
|
||
|
||
## 9. Protocol 3 — identity
|
||
|
||
R1's identity link, and the first message in this bridge that the **website** originates. Everything
|
||
in protocol 2 was the game talking, or the sidecar asking the game to repeat something it already
|
||
knew.
|
||
|
||
The shape is the one the UO bridge proved: the player asks in game, the plugin mints a one-time code
|
||
and hands it to them privately, and the website redeems it through the sidecar.
|
||
|
||
```
|
||
player plugin sidecar website
|
||
│ /link │ │ │
|
||
├────────────────────►│ mint code, hold it │ │
|
||
│◄────── code ────────┤ in memory, 5 min │ │
|
||
│ ├─ account.link.requested ►│ ───── feed ───────►│
|
||
│ │
|
||
│ ………… the player types the code into the website ……………………………►│
|
||
│ │ │◄ POST /link/confirm ┤
|
||
│ │◄──── link.confirm ───────┤ │
|
||
│ ├───── link.ok ───────────►│ ── steamId, name ──►│
|
||
│ │ (code spent) │ │
|
||
```
|
||
|
||
**Nothing about the link is stored in the game.** The site is the author of record, which is not a
|
||
preference: there is no per-account store in Rust that survives a wipe, and phase 7 makes the site
|
||
authoritative anyway — it pushes permissions *into* the game keyed by Steam id. A copy on the game
|
||
host would be a second thing to reconcile every wipe, answering no question better.
|
||
|
||
### 9.1 `/link` and `/unlink` are CHAT commands, and the reply is private
|
||
|
||
`[ChatCommand("link")]`. Both frameworks consume a `/` command rather than broadcasting it, and
|
||
`SendReply` addresses one player — so neither the request nor the code reaches anybody else's chat.
|
||
That is load-bearing rather than polish: **a code read off a stream is a code somebody else can
|
||
spend.**
|
||
|
||
`/unlink` emits rather than deletes, because the plugin holds no link to delete. It exists because
|
||
the website **refuses** to move a Steam id another account already holds (D23): without a way out, a
|
||
player who linked the wrong account while signed in as it would need staff. The authority on that
|
||
path is the Steam account itself — whoever is connected to the game as it is who it is.
|
||
|
||
### 9.2 The code is **not** on the wire
|
||
|
||
`account.link.requested` carries the Steam id, the name and the TTL, and **never the code**. The
|
||
event exists so an operator can see linking being used and so the site can see a player fishing; it
|
||
is not how the code travels. The code travels **through the player**, which is what makes typing it
|
||
into a signed-in browser proof that they are the one who asked.
|
||
|
||
Both account frames are **staff** class (§8.5). Neither carries a secret, but both name a Steam id
|
||
beside a website account's activity, and that join — *this player is that person* — is a fact about
|
||
somebody's identity rather than about what happened on the server.
|
||
|
||
### 9.3 `link.confirm` — website → plugin
|
||
|
||
The first inbound command that is not a request to repeat something.
|
||
|
||
```json
|
||
{ "cmd": "link.confirm", "reqId": "r-42", "code": "K7M2PQ" }
|
||
```
|
||
|
||
Answered with `link.ok` carrying `steamId` and `name`, or `link.error` carrying a `reason` of
|
||
`unknown`, `expired` or `malformed`. Both are replies, correlated by `reqId` like `server.status`.
|
||
|
||
**A code is consumed on the FIRST lookup, whether or not it turns out to be expired.** The removal
|
||
happens before the expiry check rather than after it, so a code cannot be probed twice.
|
||
|
||
**`unknown` and `expired` are separate here and identical to the player.** An operator reading a log
|
||
wants to know whether codes are being guessed or merely going stale; a stranger typing codes must not
|
||
learn which of the two they hit, because that is the difference between "keep guessing" and "guess
|
||
faster".
|
||
|
||
### 9.4 The code itself
|
||
|
||
Six characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` — **no O, 0, I or 1**, because a player reads
|
||
this off their screen and types it into a browser, often on a phone. A five-minute TTL, a
|
||
thirty-second cooldown per player, **one outstanding code each** (a new `/link` drops the old one),
|
||
and a purge timer, because an unconfirmed code is never looked up and nothing else would ever remove
|
||
it.
|
||
|
||
They live in plugin memory and nowhere else. A plugin reload drops every pending code — and phase
|
||
7b's config editor will reload plugins routinely — but the cost of that is a player typing `/link`
|
||
again, which is cheaper than an unconfirmed credential living in a second process.
|
||
|
||
### 9.5 `POST /link/confirm` — the first route on this sidecar that is not a GET
|
||
|
||
```
|
||
POST /link/confirm { "code": "K7M2PQ" } → 200 { "kind": "link.ok", "steamId": "765…" }
|
||
→ 200 { "kind": "link.error", "reason": "unknown" }
|
||
→ 503 the game is not connected
|
||
→ 504 the game is up and did not answer
|
||
```
|
||
|
||
**A refused code is a `200`.** `link.ok` and `link.error` are both answers; the sidecar reserves its
|
||
own status codes for the transport, because the website has to tell *"that code is wrong"* from
|
||
*"the game never replied"* to say the right thing to a player (§4.3).
|
||
|
||
The sidecar validates nothing but the shape — it trims the code, bounds its length, and forwards it.
|
||
Only the game holds the pending codes, and putting the table here instead would give the sidecar a
|
||
credential and an opinion, which D2 and the bridge principles say it has neither of.
|
||
|
||
### 9.6 The website asks EVERY server (D24)
|
||
|
||
A code is minted by one server, and the player types six characters into a browser. Nothing in the
|
||
code says which server it came from, so the module asks each configured server in turn and the first
|
||
`link.ok` wins; the others answer `unknown` and nothing happens there, because a code is only spent
|
||
at the server that holds it.
|
||
|
||
Asking the player to pick was rejected: a wrong pick comes back indistinguishable from a wrong code.
|
||
|
||
The consequence for this protocol is worth stating, because it is the shape of every later
|
||
fleet-wide command: **"every reachable server refused" is not the same answer as "a server could not
|
||
be reached"**, and a module that collapses them tells the player whose server is down that their code
|
||
is wrong — so they fetch another code from the same server and hear it again.
|
||
|
||
---
|
||
|
||
|
||
## 10. Protocol 4 — the permission mirror
|
||
|
||
R2, and the first command on this bridge that **changes the game**. Protocol 3's
|
||
`link.confirm` was the website originating a message, but it spent a code the game
|
||
itself had minted; this writes to a store the game enforces.
|
||
|
||
```
|
||
website sidecar plugin
|
||
│ │ │
|
||
├── POST /permissions/sync ►│ ──── perm.sync ─────────►│ diff against the
|
||
│ the whole desired set │ (the same object) │ live store, apply
|
||
│ │ │ the difference in
|
||
│◄──── the report ──────────│◄──── perm.report ────────┤ bounded steps
|
||
│ │
|
||
│◄──── perm.drift (event) ──────────────────────────────┤ somebody else wrote
|
||
```
|
||
|
||
**The website is the author of record and the framework's store is an enforcement
|
||
cache.** Every third-party plugin honours a site grant with no adapter, because
|
||
they all already call `permission.UserHasPermission` — reaching them is the point,
|
||
and it is why the site does not keep a private table of its own.
|
||
|
||
### 10.1 One verb, and the PLUGIN does the diffing
|
||
|
||
`perm.sync` carries the whole set the site authors **for that server**. The plugin
|
||
compares it against the live store and writes only what differs.
|
||
|
||
The alternative — the plugin reporting its store and the website computing the
|
||
difference — was rejected for two reasons. The store is the bigger of the two sets
|
||
and would cross the wire constantly, and a website holding a copy of it has a
|
||
second source of truth that is stale the moment it lands.
|
||
|
||
```json
|
||
{
|
||
"cmd": "perm.sync",
|
||
"reqId": "r-42",
|
||
"setId": "69dfc769…",
|
||
"groups": [
|
||
{ "name": "vip", "title": "VIP", "rank": 10,
|
||
"permissions": ["kits.vip"],
|
||
"members": ["76561198000000001", "76561198000000002"] }
|
||
],
|
||
"grants": [
|
||
{ "steamId": "76561198000000001", "permissions": ["kits.gold"] }
|
||
],
|
||
"managed": ["kits.vip", "kits.gold"],
|
||
"retire": [
|
||
{ "kind": "grant", "subject": "76561198000000003", "object": "kits.silver" }
|
||
]
|
||
}
|
||
```
|
||
|
||
| Field | Means |
|
||
|---|---|
|
||
| `setId` | the site's digest of the set, echoed in the report. It is how the site knows a report describes the set it sent rather than an earlier one |
|
||
| `groups` | group definitions, what each carries, and who is in it. **Three separate facts**, because the game can fail at each independently |
|
||
| `grants` | permissions held by one account without a group |
|
||
| `managed` | the permission namespace the site claims. Foreign holders are only looked for within it — which also bounds the scan by the site's own set rather than by the size of the store |
|
||
| `retire` | what the site put there and has since withdrawn (§10.3) |
|
||
|
||
### 10.2 `perm.report` — what actually happened
|
||
|
||
```json
|
||
{
|
||
"kind": "perm.report", "type": "reply", "reqId": "r-42", "setId": "69dfc769…",
|
||
"applied": { "grants": 1, "revokes": 0, "groupsCreated": 1, "groupPermissions": 1,
|
||
"members": 2, "membersRemoved": 0, "groupsRemoved": 0,
|
||
"groupPermissionsRemoved": 0 },
|
||
"alreadyCorrect": 14,
|
||
"absent": 0,
|
||
"unresolved": ["kits.gold"],
|
||
"pending": ["76561198000000003:vip"],
|
||
"foreign": [{ "kind": "grant", "subject": "76561198000000009", "object": "kits.admin" }],
|
||
"operations": 4
|
||
}
|
||
```
|
||
|
||
**`unresolved` and `pending` are the two ways a push looks like it worked and did
|
||
not**, and both are load-bearing:
|
||
|
||
- **`unresolved`** — no loaded plugin on that server has registered the name.
|
||
`permission.GrantUserPermission` returns void, throws nothing and logs nothing
|
||
for an unregistered name ([PLAN.md §12.2](../modules/rust/PLAN.md) rule 1), so
|
||
without the `PermissionExists` pre-check the grant vanishes without a trace. The
|
||
plugin does **not** register the name itself: that fabricates a permission the
|
||
operator never installed.
|
||
- **`pending`** — the store has never seen that player, so there is no user record
|
||
to put in a group (§12.2 rule 4). A **direct grant** to the same account works
|
||
immediately, and the asymmetry is exactly why groups are not the only shape the
|
||
site can express. The membership lands on their first connection.
|
||
|
||
Neither is recorded by the website as pushed. A site that recorded them would
|
||
believe it had given a privilege it had not — and would later "retire" it from a
|
||
server that never had it, which is a no-op that reads as a success in every log.
|
||
|
||
**A refusal of the whole sync is `perm.error`**, with a reason of `busy` (an
|
||
earlier sync is still draining) or `too-large`. Like `link.error` it is a `200`
|
||
from the sidecar: the transport worked and the game answered.
|
||
|
||
### 10.3 Retirement is the one thing the game cannot work out
|
||
|
||
A name in the store that is not in the desired set is **either** something the site
|
||
authored and has since withdrawn **or** something a human granted at a console —
|
||
and those two have opposite correct answers. The store records who granted a
|
||
permission nowhere, so only the website can tell them apart, from its own memory of
|
||
what it pushed.
|
||
|
||
So the site sends `retire` explicitly, and everything else it did not ask for comes
|
||
back as `foreign`. **Nothing in `foreign` is ever removed by a sync** (D31): a
|
||
console `oxide.grant` during an incident is drift, not an error, and an operator is
|
||
offered two answers to it on the website — adopt it, or revoke it.
|
||
|
||
### 10.4 `perm.drift` — a reason to reconcile, not the reconciliation
|
||
|
||
Both frameworks raise a hook for every permission write. The plugin subscribes to
|
||
six of them and emits `perm.drift` for writes **it did not make itself**, staff
|
||
class (§8.5): it names a Steam id beside a privilege, which is a fact about a
|
||
person's standing rather than about what happened on the server.
|
||
|
||
```json
|
||
{ "kind": "perm.drift", "type": "event", "action": "granted",
|
||
"steamId": "76561198000000009", "permission": "kits.admin" }
|
||
```
|
||
|
||
`action` is one of `granted`, `revoked`, `group-added`, `group-removed`,
|
||
`group-permission-granted`, `group-permission-revoked`.
|
||
|
||
**It cannot say whether the change is foreign** — only the desired set can, and
|
||
that comparison happens in a sync. So the website treats the frame as a reason to
|
||
reconcile *soon*: a hand edit shows up in seconds instead of at the next audit, and
|
||
the authoritative answer still arrives as a report. That division is what makes the
|
||
hooks safe to trust at this weight: one that stops firing on a framework upgrade
|
||
costs latency, not correctness.
|
||
|
||
The plugin suppresses them while it is applying a sync, because they fire for its
|
||
own writes too — and the site cannot tell its own grant from a human's by looking
|
||
at one.
|
||
|
||
### 10.5 Nothing the far side sends may cost the main thread unbounded work
|
||
|
||
This is the first command whose work is **not** bounded by its own shape. A
|
||
community with two thousand linked players sends thousands of store operations in
|
||
one frame, and applying them in the tick the frame arrives is a freeze an operator
|
||
will blame on the game.
|
||
|
||
So a sync is compiled into a list of single-store operations and drained a few
|
||
hundred at a time on a timer; the report goes back when the last one lands.
|
||
Compiling touches nothing, so an oversized or malformed sync is refused before any
|
||
state exists to unwind. That is §5's rule — the one that keeps a wedged sidecar
|
||
from stalling the game — pointed at the inbound half.
|
||
|
||
Three bounds, each on the side that can say something useful when it is hit:
|
||
|
||
| Bound | Where | Why there |
|
||
|---|---|---|
|
||
| ~15,000 rows | the website | it can name the server and reach an operator |
|
||
| 1 MiB | the sidecar | it is the game link's own line cap (§3.1); forwarded, the line is discarded silently and presents as a `504` |
|
||
| 20,000 operations | the plugin | past it, a half-applied permission set is the state nobody can reason about |
|
||
|
||
### 10.6 `GET /permissions/catalogue`
|
||
|
||
A live round trip to the plugin: every permission the loaded plugins have
|
||
registered, and the groups the store holds. It is the option source behind the
|
||
website's authoring form — a grant can only be written against a name that will
|
||
actually resolve — and, like `/status`, it fails when the game is down, because
|
||
"what exists right now" has no stale answer worth giving.
|
||
|
||
### 10.7 What the sidecar does NOT do
|
||
|
||
It defines no schema for either body. Protocol 4 adds the largest command on this
|
||
bridge and touches neither the store nor the feed, which is §8.1's dumb-forwarder
|
||
property paying for itself a second time.
|
||
|
||
What it does own is the envelope: `cmd` and `reqId` are written over whatever the
|
||
caller sent, so no request can arrive claiming to be a different command or aimed
|
||
at a correlation id somebody else is waiting on.
|
||
|
||
---
|
||
|
||
## 11. Protocol 5 — configuration from the site
|
||
|
||
R18, and the first command on this bridge that writes to the game host's
|
||
**filesystem**. Protocol 4 wrote to a store the game owns through an API the game
|
||
owns; this replaces bytes in a file and then asks the framework to read them.
|
||
|
||
```
|
||
website sidecar plugin
|
||
│ │ │
|
||
├── GET /config/files ─────►│ ──── config.list ───────►│ walk ConfigDirectory
|
||
│◄──── the tree ────────────│◄──── config.catalogue ───┤ (never DataDirectory)
|
||
│ │ │
|
||
├── GET /config/file ──────►│ ──── config.read ───────►│ one file + a version
|
||
│ │ │
|
||
├── POST /config/write ────►│ ──── config.write ──────►│ back up, write,
|
||
│ whole file TEXT │ │ reload, WATCH
|
||
│◄──── the report ──────────│◄──── config.report ──────┤ …or restore it all
|
||
```
|
||
|
||
**The website composes the bytes and the plugin writes them.** That split is the
|
||
one design decision everything else here follows from, and §11.5 is why.
|
||
|
||
### 11.1 The roots come from the framework, and one of them is forbidden
|
||
|
||
The walk is rooted at `Interface.Oxide.ConfigDirectory` — `oxide/config` on
|
||
Oxide, `carbon/configs` on Carbon, and neither on a server whose operator moved
|
||
it with `-carbon.configdir` ([`CARBON.md`](../modules/rust/CARBON.md) §3). It is
|
||
never composed from a literal, and that amendment was proven the best way it
|
||
could have been: this bridge's own config landed in **both** places, written by
|
||
the same source file.
|
||
|
||
`DataDirectory` is **never walked**. It holds live state — kit cooldowns, zone
|
||
definitions — and both frameworks' own permission stores (`oxide.users.data`,
|
||
`oxide.groups.data`), which is protocol 4's mirror one directory over. A
|
||
settings editor that strayed there would be editing §10 underneath itself.
|
||
|
||
### 11.2 `config.list` — a description of the tree, never its contents
|
||
|
||
```json
|
||
{
|
||
"kind": "config.catalogue", "type": "reply", "reqId": "r-7",
|
||
"root": "/home/container/oxide/config",
|
||
"self": "RunicGateway",
|
||
"files": [
|
||
{ "path": "ZoneManager.json", "bytes": 4210, "modified": 1758500000000,
|
||
"plugin": "ZoneManager", "editable": true },
|
||
{ "path": "Kits/kits.json", "bytes": 980, "modified": 1758400000000,
|
||
"plugin": "Kits", "editable": true },
|
||
{ "path": "Huge.json", "bytes": 9400000, "editable": false,
|
||
"reason": "larger than this bridge will carry" }
|
||
],
|
||
"plugins": [ { "name": "ZoneManager", "title": "Zone Manager", "version": "3.1.14" } ],
|
||
"truncated": false,
|
||
"limits": { "depth": 6, "files": 500, "fileBytes": 262144, "writeFiles": 10 }
|
||
}
|
||
```
|
||
|
||
Four things about that shape are load-bearing.
|
||
|
||
**No file is hashed here.** A version is produced by `config.read`, on the one
|
||
file somebody actually opened. Hashing 500 files would be up to 128 MB of reads
|
||
in a single frame, which is the unbounded main-thread work §10.5 forbids — so
|
||
this walk reads directory entries and nothing else.
|
||
|
||
**`plugin` is a GUESS and is labelled one all the way to the form.** It is the
|
||
folder for a nested file and the filename otherwise, and a folder name is
|
||
convention rather than contract. Infer it silently and the failure is the
|
||
nastiest available here: the wrong plugin is reloaded, `OnPluginLoaded` fires for
|
||
*it*, and the write is reported as a success while the plugin that was actually
|
||
edited never re-read anything.
|
||
|
||
**A file past a limit is listed and marked, never hidden.** An operator who
|
||
cannot find a file they know exists goes looking for a bug in the bridge; one who
|
||
can see why it was refused does not.
|
||
|
||
**`self` is the plugin naming itself**, so the website can lock the three keys in
|
||
*our* config that would cut this link (§11.6) without matching on a filename
|
||
somebody may rename.
|
||
|
||
### 11.3 `config.read` — one file, and the version a write must present back
|
||
|
||
```json
|
||
{ "kind": "config.file", "type": "reply", "reqId": "r-8",
|
||
"path": "ZoneManager.json", "text": "{\n \"Auto Show\": true\n}",
|
||
"version": "1a4-3f2c8a91b0de4471", "bytes": 420, "modified": 1758500000000 }
|
||
```
|
||
|
||
`version` is the file's length and an FNV-1a hash of its text. It is deliberately
|
||
**not** a cryptographic digest: nothing here is a security claim — the website
|
||
never computes one, it only echoes back the one it was given — and
|
||
`System.Security.Cryptography` is one more thing that would have to be available
|
||
under two plugin compilers.
|
||
|
||
### 11.4 `config.write` — the set, the reload, and the undo
|
||
|
||
```json
|
||
{ "cmd": "config.write", "reqId": "r-9",
|
||
"files": [ { "path": "ZoneManager.json", "version": "1a4-3f2c…", "text": "{…}" } ],
|
||
"reload": "ZoneManager" }
|
||
```
|
||
|
||
The plugin, in order:
|
||
|
||
1. resolves and guards every path (§11.6), checks every version, and checks that
|
||
every document parses — **before the first byte is written**. Same posture as
|
||
`perm.sync`: a refusal that has touched nothing has nothing to unwind;
|
||
2. backs each file up under `DataDirectory/RunicGateway/config-backups/`, keeping
|
||
the last ten per file, and holds the original in memory for the rollback;
|
||
3. writes the set;
|
||
4. reloads the named plugin **through the framework**, not by composing a console
|
||
string — Carbon's commands are `c.`-prefixed, an alias for the Oxide names is
|
||
opt-in, and a wrong prefix on Carbon prints *nothing*, so it looks exactly
|
||
like a command that worked;
|
||
5. waits up to **four seconds** for `OnPluginLoaded` naming that plugin;
|
||
6. if it arrives, re-reads each file and reports the new versions. If it does
|
||
not, **restores every file, reloads again, and reports the failure with the
|
||
tail of the server's newest log file.**
|
||
|
||
```json
|
||
{ "kind": "config.report", "type": "reply", "reqId": "r-9",
|
||
"ok": false, "reloaded": false, "rolledBack": true,
|
||
"reason": "'ZoneManager' did not reload within 4s",
|
||
"log": "…Error while compiling ZoneManager…",
|
||
"files": [ { "path": "ZoneManager.json", "version": "1a4-…", "rewritten": false } ] }
|
||
```
|
||
|
||
**That rollback is the feature.** Without it this is a web form that takes a
|
||
required plugin off a production server one typo at a time — and four plugins are
|
||
required (R6/R17), so a broken `ZoneManager` config is also event participation
|
||
gone.
|
||
|
||
Three consequences worth naming:
|
||
|
||
- **The window is arithmetic, not taste.** The worst path is two windows — wait,
|
||
give up, restore, wait again — and the caller holds a socket throughout. It
|
||
must fit inside the sidecar's `REPLY_TIMEOUT` (§4.4, 10s), or the rollback
|
||
report arrives after the only thing waiting for it has gone. The sidecar
|
||
mirrors the number as `web::CONFIG_RELOAD_WINDOW` and a test asserts the
|
||
inequality rather than trusting it.
|
||
- **`rewritten` is normal.** Both frameworks merge missing defaults into a config
|
||
on load and save it back, so the file after a successful reload is regularly
|
||
not the file that was sent. The report says so; a website that assumed
|
||
otherwise would conflict with itself on the next save.
|
||
- **The bridge will not reload itself.** The reload would unload this plugin and
|
||
close the link carrying the answer, leaving a rollback with nothing watching
|
||
it — the one failure the mechanism exists to report would be the one it could
|
||
not. `reload-self` is refused, and our own settings apply on the next
|
||
deliberate reload instead.
|
||
|
||
### 11.5 JavaScript cannot tell `1` from `1.0`, so it never writes the number
|
||
|
||
`JSON.parse('{"Rate":1.0}')` yields `1` and `JSON.stringify` writes `1`. Both
|
||
frameworks deserialize a config into typed C# classes, so a naive
|
||
read-modify-write **silently rewrites every whole-numbered float as an integer,
|
||
on fields nobody touched** — and Newtonsoft may coerce that or may throw. A throw
|
||
at load is a plugin that does not come back.
|
||
|
||
So the website never parses, mutates and re-serialises. Its editor records the
|
||
**source span** of every value and splices new literals into them, which is why
|
||
`config.write` carries whole file text: the bytes on the wire are the bytes that
|
||
will be on disk, and the fields nobody edited are byte-identical. A number's new
|
||
value travels as the literal an admin typed, and never becomes a JavaScript
|
||
number anywhere in the path.
|
||
|
||
The plugin's contribution to that is deliberately nothing beyond checking that
|
||
the document parses. Giving this end an opinion about content would put the
|
||
decision in two places, and only one of them can be tested against a real
|
||
Newtonsoft.
|
||
|
||
### 11.6 Addressing by path is a new bug class, and it is guarded here
|
||
|
||
Protocol 4 addressed things by name. This addresses them by path, which is
|
||
exactly the change that introduces traversal — so the plugin refuses a path that
|
||
is absolute, carries a drive letter, contains `..`, does not end in `.json`, or
|
||
does not resolve **under the canonicalised config root**. Links are not followed:
|
||
any file or directory carrying a reparse point is skipped by the walk and refused
|
||
by the resolver, because resolving one is how a tree that looks bounded turns out
|
||
not to be.
|
||
|
||
The sidecar forwards the path verbatim and judges nothing, as it forwards a link
|
||
code and a permission set. That is not laziness: only the process holding the
|
||
directory can decide whether a path resolves inside it, and a guard in the middle
|
||
would be a weaker second opinion in a place with no way to check it.
|
||
|
||
The website checks the *shape* before spending a round trip, and the bridge's own
|
||
three keys — `Host`, `Port`, `ServerId` — are refused there rather than here,
|
||
because "which file is ours" is a question about the website's configuration, not
|
||
about the game's.
|
||
|
||
### 11.7 What the sidecar does NOT do
|
||
|
||
It stores nothing. Nothing from protocol 5 reaches the store or the feed: a
|
||
config this sidecar cached would be an edit an operator made over SSH that the
|
||
website then silently overwrote. All three routes fail when the game is down,
|
||
like `/status`, because "what is on that host's disk" has no stale answer worth
|
||
giving.
|
||
|
||
The one thing it adds is a better `504`. A timeout on `/config/write` is the only
|
||
timeout on this bridge with a knowable answer, because the plugin writes a whole
|
||
set or restores a whole set and never half of either — so the body says to
|
||
re-read rather than to guess, and names the reload window that is probably still
|
||
running.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 12. Protocol 6 — first-party clans
|
||
|
||
Phase 9, R5. Rust's **own** clan system becomes core's Teams: the plugin reports it, the module
|
||
answers core's Team provider from it, and the website owns the clan page. Design of record:
|
||
[`PLAN.md`](../modules/rust/PLAN.md) §24 (D47–D58).
|
||
|
||
It is not the uMod **Clans** plugin. That is a separate system that never touches the game's
|
||
`ClanManager` (D47), so a server running it has two unrelated clan systems, and only the game's
|
||
becomes Teams. The plugin reads nothing of it except whether it is loaded.
|
||
|
||
**The sidecar changed nothing but its version.** One board and five events, filed by `type` (§8.1).
|
||
|
||
### 12.1 The `clans` board
|
||
|
||
A snapshot, re-sent on connect, on the 60-second cadence, and about three seconds after any clan
|
||
hook fires, so a roster follows the change that caused it.
|
||
|
||
```json
|
||
{
|
||
"kind": "clans", "type": "snapshot", "t": 1790158748054, "serverId": "rust-oxide",
|
||
"enabled": true, "backend": "LocalClanBackend", "supported": true, "truncated": false,
|
||
"umodClans": false, "count": 1,
|
||
"clans": [{
|
||
"clanId": 1, "createdMs": 1790158729260, "name": "Northwatch", "color": "#3fa9f5",
|
||
"score": 0, "maxMembers": 100,
|
||
"members": [{ "steamId": "76561190000000001", "rank": 1, "role": "Leader", "joinedMs": 1790158729264, "name": "…" }]
|
||
}]
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
|---|---|
|
||
| `enabled` | The game's `clan.enabled` convar. `false` is an authoritative answer — no clans — and is `supported` |
|
||
| `supported` / `reason` | Could the plugin read the clans at all. `false` when the backend has not started, or is not the local one (a **Nexus** server keeps its clans elsewhere) — refused with a reason rather than guessed at |
|
||
| `truncated` | There may be clans the board does not list. See §12.3 |
|
||
| `umodClans` | The uMod Clans plugin is loaded. The website warns that its clans are not Teams |
|
||
| `rank` | The member's role rank. **Rank 1 is leader**, and several members may hold it. Absent when the member's role id matched no role — never defaulted |
|
||
| `name` | Present when the framework knows the player. Absent, not empty, otherwise |
|
||
|
||
A member's `LastSeen` is **not sent**. It is presence, and nothing names who is online by default
|
||
(PLAN.md §23).
|
||
|
||
### 12.2 The five events
|
||
|
||
| `kind` | Hook | Carries |
|
||
|---|---|---|
|
||
| `clan.created` | `OnClanCreated(LocalClan, ulong)` | clan, founder (`steamId`, `name`) |
|
||
| `clan.disbanded` | `OnClanDisbanded(LocalClan, ulong)` | clan, who disbanded it |
|
||
| `clan.member.added` | `OnClanMemberAdded(long, ulong)` | clan, the new member |
|
||
| `clan.member.left` | `OnClanMemberLeft(LocalClan, ulong)` | clan, the member |
|
||
| `clan.member.kicked` | `OnClanMemberKicked(LocalClan, ulong, ulong)` | clan, the member, and who kicked them (`bySteamId`, `byName`) |
|
||
|
||
"Clan" is always `clanId`, `createdMs` and `clanName`. Every one is **`staff` class** in §8.5's
|
||
terms: clan membership is members-only (D49), so the public feed never carries it. It reaches a
|
||
clan's members through core's Team feed, where core decides who is a member.
|
||
|
||
`OnClanColorChanged` is hooked too, but produces no event: a colour is a property of the clan, so it
|
||
travels on the board, and the hook only brings the next board forward.
|
||
|
||
Three facts the hook sites impose, all read from the game's assemblies:
|
||
|
||
- **The founder's membership fires no `OnClanMemberAdded`.** The game adds them inside the
|
||
creation, so `clan.created` implies it.
|
||
- **`OnClanMemberAdded` hands over a bare id**, fired from inside the database layer before the
|
||
game's cached clan is refreshed. The plugin reads the clan back for `createdMs` and `clanName`; if
|
||
even that fails the frame carries `clanId` alone and the website matches on it.
|
||
- **The game raises no promote or demote hook.** Leadership travels on the board only, and the
|
||
website diffs one board against the next (D54).
|
||
|
||
### 12.3 Identity, and the ceiling
|
||
|
||
**A clan's identity is `clanId` AND `createdMs`.** The game keeps clans in `clans.<version>.db` with
|
||
the version hard-coded, so a game update that bumps it starts a fresh file whose ids restart at 1.
|
||
The website keys a Team on `<serverId>:<clanId>:<createdMs>` (D52), and every clan frame carries
|
||
both halves for that reason.
|
||
|
||
**The game has no "list every clan" call.** Its backend offers get-by-id and get-by-member; the only
|
||
listing is the clan leaderboard, which runs `SELECT … ORDER BY score DESC LIMIT ?` with the limit
|
||
**clamped to 100**. So the board lists at most the top 100 clans by score. A board at that ceiling
|
||
cannot be told apart from one with exactly 100 clans, and says `truncated: true` either way. It also
|
||
says so if its rows would pass **768 KiB**, well inside the sidecar's 1 MiB line cap, which drops a
|
||
longer line outright — a board that never arrived would read as a server with no clans.
|
||
|
||
The org lead accepted the ceiling (D55) over reading the game's private SQLite schema directly. A
|
||
truncated board is answered to core as partial, so core adds and updates Teams there and never
|
||
removes one on its word.
|
||
|
||
### 12.4 A hook name another plugin also raises
|
||
|
||
The uMod Clans plugin raises `OnClanDisbanded(string tag, List<ulong> members)`, and a Universal
|
||
form with `List<string>`. Both have **the same name and arity** as the game's
|
||
`OnClanDisbanded(LocalClan, ulong)`. The bridge declares the game's types exactly, and the framework
|
||
matches a call to a method by its argument types, so neither call reaches it.
|
||
|
||
**Walked on the Oxide rig (2026-09-23):** both uMod-shaped calls were raised from a rig plugin, and
|
||
the bridge's own `rg.hooks` count for `OnClanDisbanded` stayed at the one real disband, with nothing
|
||
logged. A loosely typed signature (`object, object`) would have filed the plugin's clans as the
|
||
game's.
|
||
|
||
## 13. Protocol 7 — the raid frame names who lives there
|
||
|
||
Added in phase 10 ([`PLAN.md`](../modules/rust/PLAN.md) §25). The raid alert goes to the people whose
|
||
base it was (D59), and protocol 2's `entity.destroyed` could not say who that is: it named the
|
||
block's **placer** (`ownerId`), which is not the base's owner in any sense a Rust player recognises,
|
||
and it fired only for `BuildingBlock` — which a door is not. **No new kind and no new route;** one
|
||
frame widens, and `clan.disbanded` gains its roster.
|
||
|
||
### 13.1 `entity.destroyed`, widened
|
||
|
||
It now fires, still only when a real (non-NPC) player did it and `OwnerID` is non-zero, for four
|
||
kinds of entity. The kind travels as `structure`:
|
||
|
||
| `structure` | Game type | Notes |
|
||
|---|---|---|
|
||
| `block` | `BuildingBlock` | as before; the only kind the `structures` tally counts, so that column keeps its meaning |
|
||
| `door` | `Door` (an `AnimatedBuildingBlock`, a *sibling* of `BuildingBlock`) | external gates are doors too |
|
||
| `wall` | `SimpleBuildingBlock` | external walls |
|
||
| `cupboard` | `BuildingPrivlidge` | the tool cupboard itself; `OnEntityDeath` runs before the kill, so it still reports its own list |
|
||
|
||
Two fields are added when the entity resolves to a cupboard (`DecayEntity.GetBuildingPrivilege()`,
|
||
which goes through the building, or the cupboard itself):
|
||
|
||
| Field | Meaning |
|
||
|---|---|
|
||
| `buildingId` | the cupboard's network id, as a string — the base's identity, and the raid alert's cooldown subject |
|
||
| `authorized` | `[{ steamId, online }]` from the cupboard's `authorizedPlayers`, **bounded at 64**; `authorizedTruncated: true` when cut |
|
||
|
||
**Both are ABSENT when there is no cupboard**, which is a different answer from an empty list, and
|
||
the website alerts nobody in that case (D67). `recentGroupMembers` — which also sits on the cupboard
|
||
— is **not** authorisation: it counts code-lock users toward group upkeep, and it is not sent.
|
||
|
||
`attackerId` is now derived from the player's `userID` rather than `UserIDString`, which the game
|
||
fills in only for a connected player, a loaded sleeper or an engine bot. The website skips the alert
|
||
when the attacker is on the cupboard (a self-demolish, a teammate), and a null would defeat that.
|
||
|
||
The class is unchanged: **staff**. A structure's grid is where somebody lives, and the frame now also
|
||
names who. It reaches a player only through the raid alert, which is ceilinged `owner` and sent one
|
||
person at a time.
|
||
|
||
### 13.2 `clan.disbanded` carries `members`
|
||
|
||
The Steam ids of the clan it ended. The website tells a disbanded clan's members, and by the time it
|
||
reads the frame the next `clans` board may already have removed the roster from its store — the board
|
||
is re-sent seconds after the event, and after an outage it is applied before the backlog. The game
|
||
deletes the clan and walks `Members` to drop each membership, but never empties the list, so it is
|
||
whole when the hook fires. Bounded by the clan's own member limit.
|
||
|
||
### 13.3 The sidecar
|
||
|
||
`PROTOCOL_VERSION` becomes 7 and nothing else changes: both frames are `event`s, stored and served as
|
||
they arrive (§8.1). The bump exists because a website that alerts on `authorized` must not pair with
|
||
a protocol-6 plugin that never sends it — against one it would read every raid as a base with no
|
||
cupboard and alert nobody while looking healthy.
|
||
|
||
## 14. Protocol 8 — the leases
|
||
|
||
Added in phase 12 ([`PLAN.md`](../modules/rust/PLAN.md) §27). An event borrows a value on a server
|
||
and gives it back. **Three commands, one event and one plugin config key.** The plugin holds the
|
||
allowlist, the bounds, the seven-day ceiling and the deadline. The website holds the ledger (core's
|
||
`core.lease`). This process forwards three routes and learns nothing about either side.
|
||
|
||
The shape is UO's lease plane (`link/v6.md` §8), and its three rules carry over unchanged:
|
||
|
||
- **`holdMs` is authoritative and `untilMs` is display.** An absolute deadline computed on the
|
||
website and honoured on the game host is measured against two clocks.
|
||
- **Values cross as text and compare parsed.**
|
||
- **A hold over the ceiling is refused, never clamped.**
|
||
|
||
What differs from UO is what Rust's convars and permission store are like (§14.4).
|
||
|
||
### 14.1 The commands
|
||
|
||
```json
|
||
{"cmd":"lease.apply","reqId":"r-7","key":"decay.scale","family":"decay","value":"0",
|
||
"holdMs":3600000,"untilMs":1790000000000}
|
||
```
|
||
|
||
| Command | Answers | |
|
||
|---|---|---|
|
||
| `lease.list` | `lease.list` | Every allowlisted key with `family`, `min`/`max`, `current` (or `unreadable` with a reason), `held`, and while held `baseline`/`applied`/`untilMs`/`runId`. Plus `holds` (every hold in force), `eventsEnabled` and `maxHoldMs`. **Narrowed by `key`, and by `target` for a group permission**, which has one value per pair rather than one per key |
|
||
| `lease.apply` | `lease.ok` or `lease.error` | `lease.ok` carries `baseline`, `applied` and `untilMs` |
|
||
| `lease.release` | `lease.ok`, `lease.drifted` or `lease.error` | Compare-and-set. `lease.ok` carries `restored`, or `targetGone: true` for a group deleted mid-hold |
|
||
|
||
**The allowlist is the plugin's.** It holds `decay.scale` (family `decay`, 0–10), eighteen animal
|
||
and vehicle `*.population` convars (family `population`, 0–50, **all per square kilometre**, vehicles
|
||
included, whatever the game's help text says), and `spawn.min_rate` and `spawn.min_density` (family
|
||
`spawn`, 0–10). Every key was walked live: set, seen changing the game's own computation, and given
|
||
back. The two `spawn.max_*` scalars only matter with players online, and no walk has had any, so
|
||
they are not lent (PLAN.md §27.5). A `family` sent with an apply must match, so a website that confused two
|
||
leases is refused rather than obeyed. The one key that is not a convar is `group.permission`, whose
|
||
`target` is `group/permission` (split at the **last** slash, because a group name is free text and
|
||
a permission name never contains one) and whose value is `true` or `false`. **The plugin grants with
|
||
a `null` owner.** Given an owner, Oxide's `GrantGroupPermission` first checks that *that* plugin
|
||
registered the name, and returns silently when it did not. Every permission a lease borrows
|
||
belongs to another plugin, so the call has to name none.
|
||
|
||
**`lease.error` reasons**, each with a `message` meant for an operator:
|
||
|
||
| `reason` | Means | Worth retrying |
|
||
|---|---|---|
|
||
| `events-disabled` | `EventsEnabled` is off on this server (§14.3) | no |
|
||
| `unknown-key` | not a value this server lends, or not in the family named | no |
|
||
| `out-of-range` | outside the plugin's own bounds for the key | no |
|
||
| `too-long` | `holdMs` over seven days | no |
|
||
| `unresolved` | a group permission naming a permission no loaded plugin registered | no |
|
||
| `target-gone` | the group does not exist | no |
|
||
| `malformed` | a field is missing or unparseable | no |
|
||
| `unreadable` | the current value could not be read this moment | yes |
|
||
| `refused` | the game did not take the value: it read back as something else, and the old value was put back | yes |
|
||
|
||
### 14.2 The two mechanisms
|
||
|
||
**The deadline lives on the game.** A hold is checked every second. When its deadline passes, the
|
||
plugin restores the baseline (compare-and-set, as a release would) and emits `lease.expired`, whether
|
||
or not the website is ever heard from again:
|
||
|
||
```json
|
||
{"kind":"lease.expired","type":"event","key":"decay.scale","runId":"77","drifted":false}
|
||
```
|
||
|
||
The website maps it to nothing. Core learns what happened through `restore` and `inForce`, just as
|
||
UO's website does.
|
||
|
||
**Release is compare-and-set.** The comparison is against what the lease applied, taken from the
|
||
plugin's own record of the hold when it has one, else from the website's `expected`. A current value
|
||
that is neither what was applied nor what would be restored was moved by somebody on purpose. The
|
||
answer is `lease.drifted` with that value, the world is left alone, and the hold is over. A current
|
||
value that already equals the baseline is a success and nothing is written, which is what a release
|
||
finds after a deadline or a restart has already given the value back. **A drifted release is a
|
||
`200`**: the plugin did what it was asked.
|
||
|
||
**An apply of a key already held keeps the original baseline.** Core reserves the target before it
|
||
applies, so a second holder is refused on the website's side. A second apply arriving here therefore
|
||
means the first one's answer was lost and core is trying again. The value to give back is still what
|
||
was there before anybody borrowed it.
|
||
|
||
### 14.3 `EventsEnabled`
|
||
|
||
A new key in the plugin's config, **`false` by default** (D76), written into an existing config the
|
||
first time protocol 8 loads so that the site's config editor can show it. It gates **`lease.apply`
|
||
only**. Listing and releasing always work, so switching events off never strands a value somebody
|
||
already borrowed.
|
||
|
||
It is its own switch for UO's reason: a scheduled change to the world at four in the morning is a
|
||
different consent from a permission sync or a moderation action.
|
||
|
||
### 14.4 What a restart gives back, and what it does not
|
||
|
||
**No allowlisted convar is `Saved`.** The game writes the `Saved` set to `serverauto.cfg`, and none of
|
||
these is in it. So a convar hold is memory-only, and **a restart is a free restore**. The plugin
|
||
checks rather than trusts: at load it refuses any allowlisted convar whose `Command.Saved` is true,
|
||
with a reason in `unreadable`.
|
||
|
||
**A group permission is persisted by both frameworks**, so a hold on one survives a crash, a restart
|
||
and a plugin reload. The plugin therefore keeps its own record, `leases.json` under its data
|
||
directory (which R18's editor never walks), with the `bootId` each hold was taken under:
|
||
|
||
| On load | A convar hold | A group-permission hold |
|
||
|---|---|---|
|
||
| **Same boot** (a plugin reload) | re-armed: the value is still in the game's memory, and a config save must not end an event | re-armed |
|
||
| **New boot** | dropped: the restart restored it | re-armed, and restored at once if its deadline passed while the server was down |
|
||
|
||
Holds are **not** given back on unload. The file keeps them.
|
||
|
||
### 14.5 The permission mirror defers to a lease
|
||
|
||
A `(group, permission)` pair held by a lease belongs to the lease until the hold ends. `perm.sync`
|
||
neither grants nor revokes it, and the scan never reports it `foreign`. It is listed in the report's
|
||
new **`leased`** array. What the site asked for in the meantime is recorded on the hold, and at
|
||
release the pair is set to **that** rather than to the baseline: the lease borrowed the pair, and the
|
||
site owns what it becomes afterwards. The plugin's own lease writes raise no `perm.drift`.
|
||
|
||
### 14.6 The sidecar
|
||
|
||
`PROTOCOL_VERSION` becomes 8. Three routes, each a correlated round trip that fails when the game is
|
||
down:
|
||
|
||
| Route | Command | |
|
||
|---|---|---|
|
||
| `GET /lease?key=&target=` | `lease.list` | Both query fields optional and forwarded as they are |
|
||
| `POST /lease` | `lease.apply` | Opaque object; `cmd` and `reqId` written over the caller's |
|
||
| `POST /lease/release` | `lease.release` | The same |
|
||
|
||
`lease.expired` is an `event`, filed and served like every other (§8.1).
|
||
|
||
### 14.7 The website's timeout, again
|
||
|
||
`core.lease` declares no `budgetMs`, so it runs under the dispatcher's default of **10 s**, and it
|
||
makes **two** calls into the module inside that (`read`, then `apply`). `module-rust` therefore gives
|
||
lease calls their own client timeout of **4.5 s** (`LEASE_TIMEOUT_MS`), so that two fit inside the
|
||
budget. A test asserts the sum.
|
||
|
||
That is below the sidecar's 10 s reply timeout, so the module can give up on an apply the game is
|
||
still going to take. It follows a timed-out apply with a release of the same value down the same
|
||
link. The plugin handles the two in order: if the apply landed, the hold's own baseline goes back,
|
||
and if it never did, the compare finds nothing to do.
|
||
|
||
## 15. Protocol 9 — the world verbs
|
||
|
||
Added in phase 13a ([`PLAN.md`](../modules/rust/PLAN.md) §28). An event **makes** something that was
|
||
not there, a zone or crates or NPCs, and gives it back at teardown. **Five commands, one event, two
|
||
plugin config keys and one `server.hello` field.** As in §14, the plugin holds everything that
|
||
decides what is allowed: the allowlist, the bounds, the monument vocabulary and the registry of what
|
||
each run owns. The website holds the ledger (core's `event_run_resources`), and the sidecar forwards
|
||
five routes.
|
||
|
||
### 15.1 The commands
|
||
|
||
```json
|
||
{"cmd":"world.place","reqId":"r-9","runId":"13","key":"6803fe68…","prefab":"crate.tools",
|
||
"count":4,"spread":5,"monument":"powerplant_1","offsetX":-30,"offsetZ":0}
|
||
```
|
||
|
||
| Command | Answers | |
|
||
|---|---|---|
|
||
| `world.monuments` | `world.monuments` | This map's monuments in one stable order (grouped by prefab short name, then by position). Each has `value` (`kind`, or `kind#n` when the kind repeats), `kind`, `instance`, `of`, `label` (the game's display phrase), `x`, `z` and `grid`. Also `worldSize`, the placeable `prefabs` (`key`, `kind`, `label`), `eventsEnabled`, `maxCrates`, `maxNpcs` and `zoneManager` |
|
||
| `world.zone` | `world.ok` or `world.error` | Opens a ZoneManager temporary zone owned by the bridge. Needs `runId`, `key`, a location, `radius` (5–150 m) and `holdMs` (1 minute to 7 days); `name` is optional |
|
||
| `world.place` | `world.ok` or `world.error` | Places `count` of one allowlisted `prefab` at a location, scattered within `spread` m (0–50, 10 by default for a group). All or nothing: if the game refuses one, the ones already made are killed |
|
||
| `world.revert` | `world.ok` | Gives back what a run owns: the named `ids`, or else everything under `key`, or else everything the run owns. The answer lists `removed`, `gone` and `refused` |
|
||
| `world.owned` | `world.owned` | What the world still holds of what events made, **looked for** by net id or zone id, narrowed by `runId`. Anything gone is pruned from the registry as the walk passes it |
|
||
|
||
**A location is a monument or coordinates, exactly one.** A monument location is `monument` (a `value`
|
||
from `world.monuments`) plus an optional `offsetX`/`offsetZ` of up to 150 m in total. A bare kind
|
||
that repeats means its first instance. A coordinate location is `x` and `z`, which must lie on the
|
||
map, and an optional `y`. When no height is given, the ground height is used.
|
||
|
||
`world.ok` for a zone or a placement carries `placed`, one row per thing (`id`, `kind`, `runId`, `x`,
|
||
`y`, `z`, and `prefab`, or for a zone `radius`, `name` and `remainingMs`), and **`repeat: true` when
|
||
the `key` had already been used** (§15.2).
|
||
|
||
**`world.error` reasons**:
|
||
|
||
| `reason` | Means | Worth retrying |
|
||
|---|---|---|
|
||
| `events-disabled` | `EventsEnabled` is off (§15.4) | no |
|
||
| `malformed` | both kinds of location or neither, a missing field, or no `runId` | no |
|
||
| `unknown-prefab` | not in the plugin's allowlist | no |
|
||
| `out-of-range` | a count, radius, spread, offset or duration outside the plugin's bounds | no |
|
||
| `no-monument` | this map has no such monument, or not that many of it | no |
|
||
| `off-map` | coordinates outside the map | no |
|
||
| `zonemanager-missing` | ZoneManager is not loaded | no |
|
||
| `not-ready` | **the world has not finished loading** (§15.5) | yes |
|
||
| `refused` | the game or ZoneManager did not create what was asked | yes |
|
||
|
||
**The allowlist** (PLAN.md D88) is crates and NPCs only, never vehicles. The crates are
|
||
`crate.basic`, `crate.normal` (military), `crate.normal2`, `crate.elite`, `crate.tools`,
|
||
`crate.hackable`, `supply.drop` and `barrel.loot`. The NPCs are `npc.scientist`,
|
||
`npc.scientist.heavy`, `npc.scientist.tethered` and `npc.bandit.guard`. Each one was spawned on the rig
|
||
and reported its type before it was listed.
|
||
|
||
### 15.2 The registry, and why it is keyed by the website's key
|
||
|
||
`world.json`, under the framework's data directory beside `leases.json`, records everything events
|
||
have made on this map. Each entry holds the id, the kind, the prefab, the run, **the website's
|
||
idempotency key**, the position, a zone's radius, name and deadline, and the `bootId` and `wipeId` it
|
||
was made under.
|
||
|
||
**This bridge has no at-most-once store**, unlike UO's shard. The registry is keyed by the website's
|
||
key instead. A `world.zone` or `world.place` whose key the registry already holds is a retry whose
|
||
first answer was lost, so it is answered with **the first call's ids and `repeat: true`**, and nothing
|
||
is placed. The same record answers a `world.revert` that carries only a key: *everything placed under
|
||
it*.
|
||
|
||
**What may be erased is decided by the registry and never by the world.** `world.revert` of an id
|
||
the registry holds for another run is `refused`. An id the registry does not hold is `gone` when
|
||
nothing is there (a wipe or a prune already took it), and `refused` when something **is** there. The
|
||
bridge never erases a thing it cannot prove it made, and ZoneManager's `EraseTemporaryZone` returns
|
||
`true` for an operator's own hand-made zone (PLAN.md §12.4).
|
||
|
||
### 15.3 What a restart and a wipe do
|
||
|
||
Found on the rig, not taken from a document (PLAN.md §28.1):
|
||
|
||
| | A crate, the hackable crate, the supply drop, a barrel | An NPC | A zone |
|
||
|---|---|---|---|
|
||
| **Plugin reload** | there | there | **erased by ZoneManager** as the bridge unloads, and re-created from the registry on load |
|
||
| **ZoneManager reload** | there | there | erased, and re-created on `OnPluginLoaded(ZoneManager)` |
|
||
| **Server restart** | **there, same net id** (the game saves it) | gone (the game does not save NPCs) | re-created at `OnServerInitialized` |
|
||
| **Wipe** | gone | gone | dropped, not re-created |
|
||
|
||
So **a restart is not proof that a placed thing is gone**, which is the opposite of UO's town crier.
|
||
`world.owned` always looks. A new save (`OnNewSave`), or a load that finds entries recorded against
|
||
another wipe, drops those entries whole.
|
||
|
||
**A zone has a deadline, and the game keeps it** (PLAN.md D96). The plugin checks every second and
|
||
erases a zone whose `holdMs` has run out, whether or not the website is heard from again, and emits:
|
||
|
||
```json
|
||
{"kind":"world.expired","type":"event","id":"rg-13-35875416-1","runId":"13"}
|
||
```
|
||
|
||
The website maps it to nothing. Core learns about it through `reconcile` and `revert`, as with
|
||
`lease.expired`.
|
||
|
||
### 15.4 `EventsEnabled`, and the two bounds
|
||
|
||
`EventsEnabled` (§14.3) now gates **every world write** as well as `lease.apply` (PLAN.md D94).
|
||
`world.revert`, `world.owned` and `world.monuments` are never behind it, so switching events off never
|
||
strands anything.
|
||
|
||
Two new config keys, written into an existing config the first time protocol 9 loads:
|
||
**`EventsMaxCrates`** (25) and **`EventsMaxNpcs`** (20), per step. An operator may lower them. A value
|
||
above the ceiling is clamped down to it, because the website mirrors the ceiling. A step over the
|
||
bound is refused, never trimmed.
|
||
|
||
### 15.5 `worldReady`, and why the world is not there yet
|
||
|
||
**The link starts in `Init`, before the save loads**, so for the first minute or two of a real start
|
||
the plugin is connected, its `bootId` is new, and the world is empty. Asked `world.owned` then, the
|
||
phase 13a walk's plugin found no entity behind any net id and pruned three live crates from its own
|
||
registry.
|
||
|
||
So every world command answers **`not-ready`** until `OnServerInitialized`, and `server.hello` carries
|
||
**`worldReady`**. The field is `false` from connect until the world has loaded, then `true`. A hello is
|
||
also sent at that moment, rather than at the next board tick. A website that reconciles on a changed
|
||
`bootId` or `wipeId` waits for `worldReady: true` before it asks. On the rig the gap was about
|
||
95 seconds.
|
||
|
||
### 15.6 The sidecar
|
||
|
||
`PROTOCOL_VERSION` becomes 9. There are five routes, each a correlated round trip that fails when the
|
||
game is down:
|
||
|
||
| Route | Command | |
|
||
|---|---|---|
|
||
| `GET /world/monuments` | `world.monuments` | |
|
||
| `GET /world/owned?runId=` | `world.owned` | `runId` optional; forwarded as it is |
|
||
| `POST /world/zone` | `world.zone` | Opaque object; `cmd` and `reqId` written over the caller's |
|
||
| `POST /world/place` | `world.place` | The same |
|
||
| `POST /world/revert` | `world.revert` | The same |
|
||
|
||
`world.expired` is an `event`, filed and served like every other (§8.1).
|