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
252 lines
14 KiB
Markdown
252 lines
14 KiB
Markdown
# Rust — the modding-framework reference
|
||
|
||
Reference material for **`module-rust`**: a mirror of the uMod/Oxide documentation — the Rust game
|
||
API *and* the game-independent plugin framework around it — captured here so the module can be
|
||
designed and built against it without a round trip to umod.org on every question.
|
||
|
||
The mirrored material was **scraped verbatim from uMod on 2026-09-15**. One file,
|
||
[`CARBON.md`](CARBON.md), covers the *other* framework modded Rust servers run: PLAN.md **R19**
|
||
commits this module to supporting Oxide and Carbon both, and that file records only where the two
|
||
differ.
|
||
|
||
## The mirror
|
||
|
||
| Doc | What it holds |
|
||
|---|---|
|
||
| [`HOOKS.md`](HOOKS.md) | **What Rust will tell you.** All **477 hooks** in 20 categories — description, return contract, tags, every C# overload. The 34 *universal* hooks are marked. |
|
||
| [`OXIDE_API.md`](OXIDE_API.md) | **How a plugin is built.** The 19 developer pages — plugin structure, hooks, commands, `IPlayer`, permissions, config, data files, database, localization, timers, web requests, dependencies, CI, review. |
|
||
| [`DEFINITIONS.md`](DEFINITIONS.md) | **What things are called.** 678 items (short name, id, display name) and 2,590 workshop skin ids across 104 items. |
|
||
| [`OPERATING.md`](OPERATING.md) | **How it gets run.** The 6 operator pages — installing Oxide on a server, then installing, configuring and permissioning plugins. |
|
||
| [`agent/`](agent/README.md) | The same facts in **machine shape** — TSV and JSONL, ~46% of the tokens. Generated in the same pass, so it cannot drift. |
|
||
| [`CARBON.md`](CARBON.md) | **The other framework.** Where Carbon diverges from Oxide and nowhere else — file layout, the permission store, the `c.` commands, 30 Carbon-only hooks and 13 uMod names its catalogue omits. Sourced from Carbon's own metadata and source, and **proven on a live Carbon 2.0.259.0 server** — R19 at phase 0, and the whole read path at phase 3. |
|
||
|
||
**The one file here that is ours:** [`PLAN.md`](PLAN.md) — the schedule and the decisions of record
|
||
for actually building `module-rust`. Everything else in this directory is copied from uMod; that one
|
||
is written by this project and is where the phases, the settled decisions and the local test rig are
|
||
recorded.
|
||
|
||
> **This is a mirror, not a specification we own.** uMod is upstream and wins any disagreement; the
|
||
> point of copying it is availability and grep-ability, not authority. Nothing here may be cited as a
|
||
> Runic Gateway contract — our contracts are [`MODULE_API.md`](../../website/MODULE_API.md) and
|
||
> [`docs/link/`](../../link/).
|
||
|
||
---
|
||
|
||
## 1. Why this exists
|
||
|
||
The website core is game-agnostic; a **module** is what makes it a site for one particular game
|
||
([`MODULE_SYSTEM.md`](../../website/MODULE_SYSTEM.md)). `module-uo` was the first. A Rust module is
|
||
the second, and it was already designed once on paper —
|
||
[`../rust-dryrun.md`](../rust-dryrun.md) is that dry run, written deliberately *without*
|
||
implementing it, to find out whether the module contract generalises past Ultima Online.
|
||
|
||
The dry run's central structural fact is the thing this reference serves:
|
||
|
||
> A ServUO shard is C# **source** the operator compiles into their own server, so our bridge plugin
|
||
> can be anything we want. **A Rust server is a binary nobody outside Facepunch patches.** The only
|
||
> way in is a mod — hooking the game's own events through a modding framework.
|
||
|
||
The dry run named that framework as Oxide, and **R19 corrected it: there are two.** Carbon runs an
|
||
Oxide compatibility layer, so one plugin serves both and the ceiling below is the same ceiling —
|
||
but *which* framework an operator installed is their choice, not ours. [`CARBON.md`](CARBON.md) is
|
||
the difference list.
|
||
|
||
Two consequences, and they are the two halves of this directory:
|
||
|
||
1. **We can only emit what Oxide already hands us.** [`HOOKS.md`](HOOKS.md) is the hard ceiling on
|
||
what a Rust module can ever know about a live server. If a fact is not reachable from one of
|
||
those 477 hooks (or from a game type one of them hands you), the bridge cannot report it. That
|
||
makes it the input to the Rust sidecar's event catalogue — the analogue of
|
||
[`docs/link/PLAN.md`](../../link/PLAN.md) §5 on the UO side.
|
||
2. **We are a guest in someone else's plugin framework** — and we do not get to pick which one. Our
|
||
plugin is compiled, loaded, permissioned and configured by Oxide or by Carbon, on its terms. [`OXIDE_API.md`](OXIDE_API.md) is that rulebook, and
|
||
[`OPERATING.md`](OPERATING.md) is what the server owner has to do — which is the surface our
|
||
deployment story has to sit on, the way
|
||
[`installer/INSTALL.md`](../../installer/INSTALL.md) sits on top of ServUO.
|
||
|
||
---
|
||
|
||
## 2. How an Oxide hook actually binds
|
||
|
||
The one thing to understand before reading [`HOOKS.md`](HOOKS.md), because it is not in the table
|
||
itself:
|
||
|
||
**Oxide binds hooks by name and arity, by reflection, at runtime.** There is no interface to
|
||
implement and no compile-time check. A method whose name is misspelled, or whose parameter types do
|
||
not match, is simply **never called** — silently, with no warning at load. This is the single most
|
||
common way a Rust plugin "does nothing".
|
||
|
||
The consequence for us: **every hook name a module relies on is an untyped string constant against a
|
||
moving upstream.** Facepunch renames and removes hooks on wipes. A Rust sidecar should log which of
|
||
its expected hooks have actually fired at least once, so a hook that quietly stopped existing is
|
||
visible rather than mysterious.
|
||
|
||
### The return contract
|
||
|
||
[`HOOKS.md`](HOOKS.md) reproduces uMod's return line for every hook and it is the load-bearing part:
|
||
|
||
| uMod's wording | Code in [`agent/hooks.tsv`](agent/hooks.tsv) | What it means |
|
||
|---|---|---|
|
||
| *No return behavior* | `none` | A **notification**. Declare `void`. Nothing you return is read. |
|
||
| *Returning a non-null value overrides default behavior* | `nonnull` | A **veto with a payload**. Declare `object`; return `null` to let the game proceed, anything else to cancel it. |
|
||
| *Returning true or false overrides default behavior* | `bool` | A **veto**. Declare `object`; `null` abstains — returning `false` is *not* the same as abstaining. |
|
||
| *Returning a string will kick…* | `data` | The value is **consumed as data**, not merely as a veto. |
|
||
|
||
Two cautions that apply across the whole table:
|
||
|
||
- Never return non-`null` from a hook documented as *no return behavior*; on some hooks Oxide will
|
||
still read it as a veto.
|
||
- When several plugins hook the same veto, the **first non-`null` return wins** and the rest are not
|
||
consulted. Abstain with `null` unless you mean to decide. A bridge plugin should be a **reader**,
|
||
and must return `null` from every veto hook it listens on — see §5.
|
||
|
||
Three hooks (`OnEntityTakeDamage`, `OnExcavatorSuppliesRequested`, `OnPhoneCallStarted`) state no
|
||
return behaviour upstream at all; both the markdown and the TSV mark those *(not stated upstream)* /
|
||
`?` rather than guessing.
|
||
|
||
---
|
||
|
||
## 3. What is in the 477
|
||
|
||
| Category | Hooks | What it covers |
|
||
|---|---:|---|
|
||
| Server | 17 | Process lifecycle, tick/frame, save and restart, RCON and console commands |
|
||
| Player | 138 | Connect, chat, command, craft, loot, build, die, respawn |
|
||
| Entity | 140 | The generic `BaseEntity` graph — spawn, kill, damage, mount, loot, flags |
|
||
| Item | 43 | Item stacks and containers |
|
||
| Vehicle | 22 | Boats, cars, horses, helicopters, trains, submarines |
|
||
| Vending | 16 | Vending machines, the shop/trade flow, marketplace and drone deliveries |
|
||
| Weapon | 16 | Firing, reloading, throwing, melee, projectiles and traps |
|
||
| Structure | 15 | Placement, upgrade, demolish, stability, doors and locks |
|
||
| Resource | 12 | Gathering and node dispensers |
|
||
| Team | 12 | Rust's built-in team/party system |
|
||
| Phone | 12 | In-game telephones |
|
||
| Permission | 8 | uMod's own permission/group system (Covalence) |
|
||
| Clan | 7 | The first-party clan system, distinct from Team |
|
||
| Fishing · Plugin · TechTree | 4 each | |
|
||
| Sign | 3 | |
|
||
| Electronic | 2 | |
|
||
| Terrain · World | 1 each | |
|
||
|
||
**Player and Entity are 58% of the surface between them.** [`HOOKS.md`](HOOKS.md) opens with a full
|
||
alphabetical index (name → category → universal → return contract), which is the fastest way in when
|
||
you already know the hook's name.
|
||
|
||
### Universal vs. Rust-specific
|
||
|
||
**34 of the 477 are not Rust's.** They are uMod's own *universal* (Covalence) hooks — `Init`,
|
||
`Loaded`, `Unload`, `OnUserConnected`, the whole `OnGroup*`/`OnUserPermission*` family — which uMod
|
||
raises identically on every game it supports. They appear on the Rust page because they fire on Rust
|
||
too. Verified against <https://umod.org/documentation/games/universal> in the same capture: all 34
|
||
are in the Rust set, and the Rust page adds none of its own, so the overlap is exact.
|
||
|
||
The distinction is architectural, not trivia: **a universal hook is the portable part of the
|
||
surface.** Code written against one would carry to any other uMod-supported game; code written
|
||
against a Rust-specific hook would not. They are marked in `HOOKS.md` and flagged `1` in
|
||
[`agent/hooks.tsv`](agent/hooks.tsv).
|
||
|
||
Two notes on the data, both faithful to upstream:
|
||
|
||
- `OnShopCompleteTrade` appears **twice** — two separate records. Both are kept; the second carries
|
||
the anchor `#onshopcompletetrade-1`.
|
||
- Twelve hooks document **more than one overload** (e.g. `CanUpdateSign` takes either a `Signage` or
|
||
a `PhotoFrame`). Every code block is reproduced.
|
||
|
||
---
|
||
|
||
## 4. Reading the machine copy
|
||
|
||
[`agent/`](agent/README.md) holds the same facts as TSV and JSONL, at ~46% of the tokens — a real
|
||
3.2× saving on the hooks, 2.1× on the API prose, and only ~1.25× on the item and skin tables, which
|
||
were already dense. Reach for it when you want to *find* something; read the markdown when you want
|
||
the reasoning, which is deliberately not in there. [`agent/README.md`](agent/README.md) has the
|
||
column definitions and an honest account of where the saving is and is not.
|
||
|
||
---
|
||
|
||
## 5. Where this meets our architecture
|
||
|
||
The bridge invariants the platform already holds ([`ARCHITECTURE.md`](../../website/ARCHITECTURE.md),
|
||
[`docs/link/PLAN.md`](../../link/PLAN.md)) are game-independent, and a Rust module inherits all of
|
||
them. Restated against Oxide:
|
||
|
||
- **The game server is never network-reachable.** The Rust sidecar listens; the Oxide plugin dials
|
||
out to it, exactly as `BridgeLink.cs` does on the UO side.
|
||
- **A wedged or absent sidecar must never stall the server.** Oxide hooks run on Rust's main thread.
|
||
An emit must be an enqueue onto a bounded drop-oldest queue that returns immediately — never a
|
||
socket write, never a blocking call, and never anything expensive inside `OnTick`/`OnFrame`.
|
||
Oxide's own `timer` and `NextFrame` helpers ([`OXIDE_API.md` § Timers](OXIDE_API.md#timers)) are
|
||
the tools for deferring work off a hot hook.
|
||
- **The bridge plugin decides nothing.** It returns `null` from every veto hook it subscribes to.
|
||
Access control and audience scoping live on the website
|
||
([`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md)), not in the game plugin — the same
|
||
rule that makes the uo-link sidecar a dumb forwarder.
|
||
- **Sensitive events never reach the public stream.** Rust raises plenty that must not: `CanUserLogin`
|
||
and `OnUserApproved` carry IP addresses, `OnPlayerReported` carries player reports. These belong on
|
||
the admin channel only.
|
||
|
||
Reading order for someone picking the module up: [`../rust-dryrun.md`](../rust-dryrun.md) for the
|
||
design, [`MODULE_API.md`](../../website/MODULE_API.md) for the contract it must satisfy,
|
||
[`OXIDE_API.md`](OXIDE_API.md) for the framework we are a guest in, then [`HOOKS.md`](HOOKS.md) for
|
||
what the game can actually tell us.
|
||
|
||
---
|
||
|
||
## 6. Provenance and refreshing
|
||
|
||
| | |
|
||
|---|---|
|
||
| Captured | **2026-09-15** |
|
||
| Hooks | <https://umod.org/documentation/games/rust> · <https://umod.org/documentation/games/universal> |
|
||
| Definitions | <https://umod.org/documentation/games/rust/definitions> |
|
||
| Developer API | <https://umod.org/documentation/api/> — 19 pages |
|
||
| Operator docs | <https://umod.org/documentation/> — 6 pages |
|
||
| Content | 477 hooks (34 universal) · 678 items · 2,590 skins · 25 prose pages · 150 code examples |
|
||
|
||
**Rust wipes monthly and the hook list moves with it.** Re-capture before any significant work on
|
||
the module, and update the date in every file — they all carry it.
|
||
|
||
### How to capture it
|
||
|
||
Three different routes, because uMod serves these three ways. Cloudflare gates the HTML pages but
|
||
**not** the JSON endpoints:
|
||
|
||
**1. Hooks — a plain `curl`, no browser needed.** The page renders client-side from an endpoint that
|
||
is not gated. One request returns everything (`per_page` is 9999, so there is no paging):
|
||
|
||
```bash
|
||
curl -A "Mozilla/5.0" -H "Accept: application/json" \
|
||
"https://umod.org/documentation/hooks/rust.json" -o all_rust_hooks.json
|
||
curl -A "Mozilla/5.0" -H "Accept: application/json" \
|
||
"https://umod.org/documentation/hooks/universal.json" -o all_universal_hooks.json
|
||
```
|
||
|
||
Each record carries `name`, `subcategory`, `tags`, `description` (HTML `<ul>`) and `example` (a
|
||
markdown-fenced C# block). A `?subcategory=Server` query narrows it.
|
||
|
||
**2. Definitions — a real browser.** No JSON endpoint, and the HTML *is* gated. Load
|
||
`/documentation/games/rust/definitions`, then read the `Rust Items` table and the per-item
|
||
`Rust Skins` tables out of the rendered DOM.
|
||
|
||
**3. The prose pages — a real browser, throttled.** Also gated. Fetch each page same-origin from an
|
||
already-loaded uMod tab and take `document.querySelector('.documentation')`, dropping the
|
||
`.table-of-contents` child. **Pause ~1s between pages** — uMod returns `429 Too Many Requests` on a
|
||
tight loop, and a 429 is easy to mistake for a real page because it still returns 200-shaped HTML
|
||
with a title.
|
||
|
||
The pages mirrored here, in the order they appear:
|
||
|
||
- `api/` — overview, getting-started, hooks, commands, player, permissions, configuration,
|
||
data-files, database, localization, timers, web-requests, dependencies, integration,
|
||
preprocessor-directives, security, style-guide, continuous-integration, approval-guide
|
||
- operator — getting-started, plugins/getting-started, plugins/installation, plugins/configuration,
|
||
plugins/data-files, plugins/permissions
|
||
|
||
### Deliberately not mirrored
|
||
|
||
- **The other games** uMod supports (Hurtworld, 7 Days to Die, Reign of Kings, The Forest). Not our
|
||
ecosystem. `api/preprocessor-directives` lists their compile symbols if that ever changes.
|
||
- **Community pages** — contributing, community guidelines, reporting issues, getting help. Process,
|
||
not API.
|
||
- **The `/documentation/umod/*` paths**, which are aliases of the same pages.
|