Compare commits
20 Commits
cbf322b4d6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 54b4059091 | |||
| dfdb0a3f63 | |||
| 2f24236993 | |||
| 788100e048 | |||
| a221d4409b | |||
| f80b9f95c8 | |||
| 30e72adfcf | |||
| ddf777fd8c | |||
| 5274d5a744 | |||
| 3c8b430fae | |||
| 22159ec78f | |||
| 069e715b1b | |||
| a29cdf0fae | |||
| 5890da633f | |||
| b4b05b4108 | |||
| bd83b34614 | |||
| 80c7a9dcd1 | |||
| b12c6dfde4 | |||
| 98bed8ff3d | |||
| 947e1c1c67 |
13
README.md
13
README.md
@@ -10,6 +10,7 @@ so they live in one place, independent of either codebase.
|
||||
website/ docs from the website core (Node/Express + MariaDB + React/Vite)
|
||||
modules/ docs for installable game modules — one directory per module id
|
||||
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
||||
rust-link/ docs from the Rust bridge (Oxide plugin + Rust sidecar)
|
||||
android/ docs from the native Android client (Kotlin + Jetpack Compose)
|
||||
installer/ docs for the installer that deploys a shard's bridge components
|
||||
ci/ cross-cutting CI/quality notes
|
||||
@@ -76,6 +77,18 @@ particular game; a module is what makes it a site *for* one.
|
||||
| [link-README.md](link/link-README.md) | Snapshot of the link repo's README |
|
||||
| [PROJECT_TREE.md](link/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
|
||||
|
||||
### `rust-link/`
|
||||
|
||||
The same pair of contracts as `link/`, for Rust rather than Ultima Online: an Oxide plugin that
|
||||
dials out to a sidecar, and a sidecar the website reads. The two bridges are **independent** — they
|
||||
share a shape and nothing else, so neither document is a fallback for the other.
|
||||
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [PROTOCOL.md](rust-link/PROTOCOL.md) | **Canonical** — the game link and the website API, the four declaration sites of the wire version, and what each protocol version defines: 1 the transport, 2 the read path |
|
||||
| [INTEGRATION.md](rust-link/INTEGRATION.md) | Standing the bridge up by hand, and which of the three components is wrong when it does not work |
|
||||
| [PLAYER_WALK.md](rust-link/PLAYER_WALK.md) | The half of the read path a console cannot reach: ten minutes on a rig with a player, step by step, with what each hook should produce |
|
||||
|
||||
### `android/`
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
|
||||
122
android/PLAN.md
122
android/PLAN.md
@@ -1307,6 +1307,128 @@ push, and Play (M6–M8) follow the designed app.
|
||||
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
|
||||
participation history self-scoped, proved by two accounts rather than asserted.
|
||||
|
||||
15. **M14 — the Rust module in the app** (post-v1; built 2026-09-17). The platform's **second game
|
||||
module** reached its first public pages in `module-rust` phase 4
|
||||
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §17), and this is phase 5 — the app's leg.
|
||||
R10 has each Android leg trail the website surface it consumes by exactly one phase, so every
|
||||
route here existed and answered before a line of Kotlin was written.
|
||||
|
||||
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §17 for the surface
|
||||
this mirrors and §18 for this phase as built. The contract is normative there; this entry records
|
||||
what the app does about it.
|
||||
|
||||
**No backend work beyond one word.** The five public routes were live. The one change is
|
||||
Module-Rust#5, which adds `rust` to the module's `capabilities` — see the gate below.
|
||||
|
||||
#### Why this is not the shard screens with a different name
|
||||
|
||||
The two games have genuinely different shapes, and collapsing them would have cost the app the
|
||||
thing that makes each legible. **UO is one shard: a place**, five drawer rows, a live SSE stream.
|
||||
**Rust is a fleet**: a list, and one page beneath it with four tabs. The app grows a second route
|
||||
tree rather than a second meaning for `shard/`, and both can be installed on one backend — in
|
||||
which case both trees exist at once and neither row appears on a site without its module.
|
||||
|
||||
| Screen | Route | Reads |
|
||||
| --- | --- | --- |
|
||||
| **Rust servers** (the list) | `rust` | `GET /public/rust/servers` |
|
||||
| **One server** (four tabs) | `rust/servers/{serverId}` | `…/:id`, `…/:id/events`, `…/leaderboard`, `…/online`, `…/wipes` |
|
||||
|
||||
#### Four decisions, taken by the org lead on 2026-09-16
|
||||
|
||||
- **D16 — the gate is a new capability, `rust`.** `module-uo`'s five shard rows all hang on one
|
||||
string, `shard`, because that is the only question a capability can answer: *is the module
|
||||
there*. `module-rust` declared five and every one named a **surface** — `servers`, `killfeed`,
|
||||
`leaderboard`, `presence`, `wipes`. Core flattens every started module's capabilities into a
|
||||
single list, so gating on `servers` would let another module declaring that word silently reveal
|
||||
these screens on a site that does not run Rust. Gating on the module **id** was considered and
|
||||
rejected: `id` is a mount prefix (§2.1 requires it to equal the directory core loads from), and
|
||||
`MODULE_API.md` §2.9 forbids a client inferring a route from a capability — making the two the
|
||||
same thing would quietly end that separation. So the module declares its own name as a sixth
|
||||
string, asserted in its suite against `module.json`'s own `id` so the two cannot drift.
|
||||
- **D17 — poll every 20s while the screen is RESUMED**, the phone's version of D14's Page
|
||||
Visibility gate. Immediate refresh on return to the foreground; nothing at all while away.
|
||||
- **D18 — the Rust repositories move to `edge`** for the rest of the workstream, with releases at
|
||||
the cutover rather than per phase. `pr-checks.yml` in all four repositories already triggers on
|
||||
`[main, edge]`, so this costs no CI — the trap that made all nine M12 phase PRs land unchecked
|
||||
was closed in engagement Phase 8.
|
||||
- **D19 — the drawer row carries a live player count**, and NavPaths learns `/rust`.
|
||||
|
||||
#### A refresh is not a load, and the app had only ever done loads
|
||||
|
||||
The app has had exactly one shape for a read since M1: set `Loading`, ask, replace. That is right
|
||||
for opening a screen and wrong for a poll — a twenty-second refresh built on it clears the
|
||||
killfeed, renders a spinner in its place and re-fills it, three times a minute, for ever. **The
|
||||
website hit the same wall one tier along**, which is why `module-rust` bundles its own `usePolled`
|
||||
instead of using core's `useAsync` (§17.3). `ui/Polling.kt` is that hook's other half:
|
||||
|
||||
- `refreshInto` — **a refresh is invisible when it succeeds and keeps the rows when it fails.** A
|
||||
failure with rows on screen keeps them and reports the failure beside them; a failure with
|
||||
nothing on screen is an ordinary error with a retry, because there is nothing to protect.
|
||||
- `PollWhileResumed` — `repeatOnLifecycle(RESUMED)`, which buys three behaviours from one line: no
|
||||
requests at all while backgrounded, an immediate refresh on return, and a pause behind a dialog
|
||||
or the recents switcher. `STARTED` would keep polling for a reader who is not reading.
|
||||
|
||||
Only the **visible** live panel is polled. The leaderboard and the wipe list never are: a
|
||||
leaderboard that re-sorted itself under a finger every twenty seconds would be worse than a stale
|
||||
one. Changing the filter, the sort or the wipe **is** a different question, so that panel blanks
|
||||
and loads — leaving the old rows up would show last wipe's killfeed under this wipe's heading.
|
||||
|
||||
#### The drawer badge is D15 translated, not D15 copied
|
||||
|
||||
D15 put a live count in core's `site.footer.status` slot, which works because every page of the
|
||||
website renders the same footer. The app has no footer and no slot. What it has is a drawer row
|
||||
per surface and, since engagement Phase 8, a precedent for a number beside one — the inbox's
|
||||
unread badge, in the `NavigationDrawerItem` badge slot, with a `contentDescription` so a screen
|
||||
reader says "42 players online" rather than "42". The count rides there, and keeps the website
|
||||
version's three rules: **zero renders nothing** (an empty fleet is not a notification), a failed
|
||||
read keeps the last number, and it never polls. It is asked for only where the module is
|
||||
installed, so a UO site makes no request at all.
|
||||
|
||||
#### Verified
|
||||
|
||||
The app suite (**644 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||
against the phase-4 rig — a core with the module installed, one live server and one seeded fixture
|
||||
that has never reported.
|
||||
|
||||
**Both halves of the phase criterion, directly.** With its server unreachable and reading
|
||||
*Offline*, the page still rendered its map, size, seed, wipe date, killfeed, per-wipe and all-time
|
||||
leaderboards, its last known presence board and its wipe history. The same app pointed at the UO
|
||||
core showed Shard / Rules / Atlas / Leaderboards / Market and **no Rust row**.
|
||||
|
||||
Also proven rather than asserted: `refreshInto` against a genuinely dead backend (the core was
|
||||
stopped with the list on screen; a poll tick later the rows were unchanged under one quiet line);
|
||||
R12's arithmetic on a phone (all-time 59 = 41 + 18, and a player who appears only in the older
|
||||
wipe **drops out** of it rather than reading zero); every `describe` branch from real rows,
|
||||
including the fall that must not read as a kill by nobody; the calendar-day rule, filtering to the
|
||||
August wipe and getting three rows six weeks old, each unmistakably dated; and the badge.
|
||||
|
||||
#### The walk found three defects, and 644 green tests found none of them
|
||||
|
||||
- **The drawer's live count resolved once per process.** It was keyed on the capability answer
|
||||
alone, so it was read at connect and never again — which is not what *live* means on a row
|
||||
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge.
|
||||
- **Every card's text sat flush against its edge.** `ShardCard` is the themed `Card` and carries
|
||||
no padding of its own; each caller pads its own content, and these four did not. On a phone the
|
||||
first glyph of each line read as clipped.
|
||||
- **A name touched its own kill count.** Five numeric columns beside an equal-weight name column
|
||||
left *Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes,
|
||||
and the **active sort is marked on the header** rather than by tinting a column of numbers — the
|
||||
header is the control, and tinting the values says *these are special* instead of *this is what
|
||||
the table is ordered by*.
|
||||
|
||||
#### The rig note worth keeping
|
||||
|
||||
The debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost` only**
|
||||
— not `10.0.2.2`. An emulator walk against a local core therefore needs
|
||||
`adb reverse tcp:<port> tcp:<port>` and the loopback address; typed as `10.0.2.2` every request
|
||||
fails with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, which the
|
||||
connect screen reports — correctly, and indistinguishably from a core that is not running.
|
||||
|
||||
- **Excluded**, in the same class as every earlier milestone's exclusions: the Rust **admin**
|
||||
surface. Server configuration, the sidecar token and the connection test are admin
|
||||
*configuration*, which the app consumes and does not edit. Identity and permissions are legs B
|
||||
and C (phases 8 and 11), and the map is leg D.
|
||||
|
||||
### Deferred (not a milestone)
|
||||
|
||||
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
||||
|
||||
378
modules/rust/CARBON.md
Normal file
378
modules/rust/CARBON.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# Carbon — the second modding framework, and where it differs from Oxide
|
||||
|
||||
**Carbon** is the other framework modded Rust servers run. It is not a fork of Oxide and it does not
|
||||
load Oxide; it is a separate loader that ships an **Oxide compatibility layer** — the `Oxide.Core`,
|
||||
`Oxide.Plugins` and `Oxide.Game.Rust` namespaces, reimplemented — so that a plugin written for Oxide
|
||||
compiles and runs unchanged.
|
||||
|
||||
This document exists because [`PLAN.md`](PLAN.md) **R19** commits `module-rust` to supporting both.
|
||||
It records the places the two frameworks are *not* the same, because those are the only places our
|
||||
code has to care. Everything not listed here is identical by construction.
|
||||
|
||||
> **Provenance.** Facts below were taken on **2026-09-15** from Carbon's own published metadata —
|
||||
> `api.carbonmod.gg/meta/carbon/{hooks,commands,convars,switches}.json` — and from the
|
||||
> [`CarbonCommunity/Carbon`](https://github.com/CarbonCommunity/Carbon) source at `main`, with the
|
||||
> narrative pages at [carbonmod.gg](https://carbonmod.gg) as the prose source. Carbon is upstream
|
||||
> and wins any disagreement, exactly as uMod does for [`OXIDE_API.md`](OXIDE_API.md). Nothing here
|
||||
> is a Runic Gateway contract.
|
||||
>
|
||||
> **Verified on a live Carbon server on 2026-09-15** — Carbon **2.0.259.0** `[2026.09.03.0]` on
|
||||
> Linux, the `rust-carbon` rig (PLAN.md §14.5). Three of the four load-bearing claims held. **One was
|
||||
> wrong, and it was wrong about Oxide as well as Carbon** — see §4. Corrected in place; §10 is the
|
||||
> scorecard.
|
||||
|
||||
---
|
||||
|
||||
## 1. The one-sentence version
|
||||
|
||||
**A plugin in the `Oxide.Plugins` namespace deriving from `RustPlugin` is Carbon's own documented
|
||||
first example**, so the bridge plugin is one `.cs` file that serves both frameworks. What diverges is
|
||||
not the plugin API but **the things around it**: where files live, how the permission store is
|
||||
persisted, what the console commands are called, and which extra hooks exist.
|
||||
|
||||
```csharp
|
||||
// Carbon's own "first plugin" page shows this, unchanged from Oxide:
|
||||
namespace Oxide.Plugins;
|
||||
|
||||
[Info("MyPlugin", "<author>", "1.0.0")]
|
||||
public class MyPlugin : RustPlugin
|
||||
{
|
||||
private void OnServerInitialized() => Puts("Hello world!");
|
||||
}
|
||||
```
|
||||
|
||||
Carbon also offers a native shape — `namespace Carbon.Plugins` / `CarbonPlugin` — which we do not
|
||||
use and should not: it is the one choice that would make the source Carbon-only.
|
||||
|
||||
---
|
||||
|
||||
## 2. Telling the two apart
|
||||
|
||||
### At compile time — `#if CARBON`
|
||||
|
||||
Carbon feeds the Roslyn compiler a set of conditional-compilation symbols. **Oxide defines no
|
||||
equivalent**, so `#if CARBON` / `#if !CARBON` is the portable framework branch, and an Oxide
|
||||
compiler simply evaluates the unknown symbol as false.
|
||||
|
||||
| Symbol | Meaning |
|
||||
|---|---|
|
||||
| `CARBON` | The framework is Carbon |
|
||||
| `RUST` | The game is Rust |
|
||||
| `OXIDE_PUBLICIZED` | Compiled against publicised Oxide assemblies |
|
||||
| `WIN`, `UNIX` | Host operating system |
|
||||
| `STAGING`, `AUX01`, `AUX02` | Rust branch |
|
||||
| `RUST_ABV_<v>`, `RUST_BLW_<v>`, `RUST_IS_<v>` | Rust protocol above / below / exactly `<v>` |
|
||||
| `CARBON_ABV_<YYYY_MM_DD>` | Carbon protocol above a date |
|
||||
|
||||
This works because the bridge plugin ships as **source** and is compiled by whichever framework
|
||||
loaded it. It would not work for a precompiled DLL — a reason, among others, not to ship one.
|
||||
|
||||
**Confirmed on the live rig**: `carbon/config.json` reports
|
||||
`"ConditionalCompilationSymbols": ["CARBON", "RUST", "OXIDE_PUBLICIZED"]`, and the list is an
|
||||
operator-editable setting (`c.addconditional` adds to it), so treat the three above as the ones
|
||||
present by default rather than the ones guaranteed.
|
||||
|
||||
### At run time
|
||||
|
||||
`#if` is decided when the file is compiled, which is what we want for API differences. Where a
|
||||
*runtime* answer is needed — reporting which framework a server runs, in a `server.hello` say — ask
|
||||
for the type rather than the file layout: `Carbon.Community` exists only under Carbon.
|
||||
|
||||
---
|
||||
|
||||
## 3. Where the files live — **the divergence that reaches the most decisions**
|
||||
|
||||
| Oxide | Carbon |
|
||||
|---|---|
|
||||
| `oxide/plugins/` | `carbon/plugins/` |
|
||||
| `oxide/config/` | `carbon/configs/` — **plural** |
|
||||
| `oxide/data/` | `carbon/data/` |
|
||||
| `oxide/lang/` | `carbon/lang/` |
|
||||
| `oxide/logs/` | `carbon/logs/` |
|
||||
| `oxide/extensions/`, plus `Oxide.Ext.*.dll` in `RustDedicated_Data/Managed` | `carbon/extensions/` only |
|
||||
| — | `carbon/modules/`, `carbon/harmony/`, `carbon/developer/` |
|
||||
|
||||
**And none of those paths is fixed.** Carbon takes a command-line override for every single
|
||||
directory — `-carbon.rootdir`, `-carbon.configdir`, `-carbon.datadir`, `-carbon.scriptdir`,
|
||||
`-carbon.langdir`, `-carbon.logdir`, `-carbon.extdir`, `-carbon.moduledir`, `-carbon.modifierdir`,
|
||||
`-carbon.profiledir`, `-carbon.carbonconfigdir`, `-carbon.sqlpermsdb`, `-harmonydir`. An operator
|
||||
who has moved one is not doing anything unsupported.
|
||||
|
||||
**So the rule is: never compose a config or data path.** Carbon reimplements Oxide's own directory
|
||||
accessors and populates them from its resolver:
|
||||
|
||||
```csharp
|
||||
Interface.Oxide.ConfigDirectory // oxide/config or carbon/configs or wherever -carbon.configdir points
|
||||
Interface.Oxide.DataDirectory
|
||||
Interface.Oxide.PluginDirectory
|
||||
Interface.Oxide.LangDirectory
|
||||
Interface.Oxide.LogDirectory
|
||||
Interface.Oxide.ExtensionDirectory
|
||||
Interface.Oxide.RootDirectory
|
||||
Interface.Oxide.InstanceDirectory
|
||||
```
|
||||
|
||||
(`Carbon.Common/src/Oxide/OxideMod.cs` assigns each from `Defines.Get*Folder()`; `Interface.cs`
|
||||
logs all eight at boot.) Asking the framework is both shorter and correct; hardcoding `oxide/config`
|
||||
is wrong on Carbon and wrong on an Oxide server whose operator moved things.
|
||||
|
||||
**This is a direct amendment to R18.** The config editor's recursive walk is rooted at
|
||||
`ConfigDirectory`, not at a literal `oxide/config/`; the directory it must refuse to walk is
|
||||
`DataDirectory`, not a literal `oxide/data/`. The reasoning behind R18 is untouched — only the way
|
||||
the two roots are obtained.
|
||||
|
||||
---
|
||||
|
||||
## 4. Permissions — same API, same format, different directory
|
||||
|
||||
Every member R2 depends on exists with the same name and the same argument shape
|
||||
(`Carbon.Common/src/Oxide/Libraries/Permissions.cs`): `RegisterPermission`, `PermissionExists`,
|
||||
`GrantUserPermission`, `RevokeUserPermission`, `GrantGroupPermission`, `RevokeGroupPermission`,
|
||||
`CreateGroup`, `RemoveGroup`, `AddUserGroup`, `RemoveUserGroup`, `UserHasPermission`,
|
||||
`GroupHasPermission`, `GetUserGroups`, `GetUserPermissions`, `GetGroupPermissions`,
|
||||
`GetPermissionUsers`, `GetPermissionGroups`, `GetGroups`, `GetUsersInGroup`, `SetGroupParent`.
|
||||
|
||||
Two differences, and they pull in opposite directions.
|
||||
|
||||
**All of the member names above were confirmed present on the live Carbon rig**, which loaded and ran
|
||||
our plugin against them unchanged.
|
||||
|
||||
**The return type differs, and the portable answer is the one we already chose.** Carbon's
|
||||
`GrantUserPermission` returns `bool`; Oxide's returns `void` — which is
|
||||
[§12.2](PLAN.md#122-four-rules-the-r2-permission-push-must-obey)'s finding, that a grant naming an
|
||||
unregistered permission silently does nothing. Calling it as a statement compiles on both, so the
|
||||
source stays single. But **the bool cannot be read portably**, so the `PermissionExists` pre-check
|
||||
stays the mechanism on both frameworks rather than being replaced by a return value on one. Carbon
|
||||
is the framework that *would* have told us, and we still cannot listen.
|
||||
|
||||
Carbon's signature also takes `BaseHookable` where Oxide's takes `Plugin`. Passing `this` is
|
||||
correct on both; a variable typed `Plugin` is not.
|
||||
|
||||
### The store — **this section was wrong, and the truth is worse**
|
||||
|
||||
> **Corrected 2026-09-15 against both live rigs.** This document previously said *"Oxide persists to
|
||||
> JSON; Carbon persists to Protobuf or SQLite"*, and offered that difference as the reason not to read
|
||||
> the file. **Both halves were wrong.** The real shape is more dangerous than the one that was
|
||||
> imagined, which is the only reason it is worth the space.
|
||||
|
||||
Read off the two running servers, byte for byte:
|
||||
|
||||
| | Oxide rig | Carbon rig |
|
||||
|---|---|---|
|
||||
| Path | `oxide/data/oxide.users.data`, `oxide.groups.data` | `carbon/data/oxide.users.data`, `oxide.groups.data` |
|
||||
| First bytes | `0a 16 0a 07 64 65 66 61 75 6c 74 …` | `0a 17 0a 07 64 65 66 61 75 6c 74 …` |
|
||||
| Format | **Protobuf** | **Protobuf** |
|
||||
| Default groups | `default`, `admin` | `default`, `admin`, **`moderator`** |
|
||||
|
||||
**Neither framework writes JSON, and Carbon writes Carbon's data into files named after Oxide.** So
|
||||
the trap is not "two formats you must tell apart". It is:
|
||||
|
||||
1. **The filename is identical and tells you nothing**, so a reader keyed on `oxide.users.data`
|
||||
silently follows the wrong framework's file if it ever guesses the directory wrong.
|
||||
2. **The format is an undocumented binary**, not the JSON the name and the `.data` extension suggest.
|
||||
3. **Carbon can change it out from under you at run time** and Oxide cannot. `PermissionSerialization`
|
||||
in `carbon/config.json` defaults to `0` (the Protobuf above); `c.migrate_perms_sql` moves the whole
|
||||
store to SQLite at `server/identity/carbon.perms.db`, itself relocatable via `-carbon.sqlpermsdb`.
|
||||
`Oxide Overrides/PermissionSql.cs` and `PermissionStoreless.cs` are those backends.
|
||||
|
||||
**R2's conclusion is unchanged and the argument for it is now much stronger.** A file reader would
|
||||
have *worked* on both rigs today — same format, same names — and would break for the one operator
|
||||
who ran a migrate command, with no error and no version marker to notice. **Drift detection reads the
|
||||
API, or it does not work.**
|
||||
|
||||
**One more thing R2 has to accommodate: Carbon creates a third default group.** `carbon/config.json`
|
||||
names `PlayerDefaultGroup`, `AdminDefaultGroup` and `ModeratorDefaultGroup`, all auto-granted by auth
|
||||
level (`AutoGrantPlayerGroup` / `AutoGrantAdminGroup` / `AutoGrantModeratorGroup`, all `true`). A
|
||||
site that pushes its *full* group set on connect must not treat `moderator` as drift to be reported,
|
||||
nor delete it — the framework will simply recreate it, and the site will report drift for ever.
|
||||
|
||||
**Carbon does give R2 something Oxide's docs do not advertise: fourteen permission hooks**, a
|
||||
`Permissions` category of its own — `OnUserPermissionGranted`, `OnUserPermissionRevoked`,
|
||||
`OnUserGroupAdded`, `OnUserGroupRemoved`, `OnGroupCreated`, `OnGroupDeleted`, `OnGroupParentSet`,
|
||||
`OnGroupRankSet`, `OnGroupTitleSet`, `OnGroupPermissionGranted`, `OnGroupPermissionRevoked`,
|
||||
`OnPermissionRegistered`, `OnPermissionsUnregistered`, `OnUserNameUpdated`. Our uMod mirror carries
|
||||
most of these as universal hooks too, so drift may be **observable as it happens** on both rather
|
||||
than only diffable on connect. Phase 7 should test that rather than assume it; a hook that fires on
|
||||
our *own* push is a feedback loop to suppress, not a bonus.
|
||||
|
||||
---
|
||||
|
||||
## 5. Console commands — `c.` not `oxide.`
|
||||
|
||||
Carbon's 129 published commands are `c.`-prefixed. The ones with Oxide counterparts:
|
||||
|
||||
| Oxide | Carbon |
|
||||
|---|---|
|
||||
| `oxide.grant` / `oxide.revoke` | `c.grant` / `c.revoke` |
|
||||
| `oxide.group` | `c.group` |
|
||||
| `oxide.usergroup` | `c.usergroup` |
|
||||
| `oxide.load` / `oxide.unload` / `oxide.reload` | `c.load` / `c.unload` / `c.reload` |
|
||||
| `oxide.plugins` | `c.plugins` |
|
||||
|
||||
Carbon can be configured to alias the old prefix, so an operator's muscle memory survives — but an
|
||||
alias is opt-in and **we must never depend on one**. **Confirmed on the live rig:** `c.version`,
|
||||
`c.plugins`, `c.grant` and `c.group` all answered; **`oxide.plugins` produced no output at all**. Note
|
||||
the shape of that failure — Pterodactyl's `command` endpoint returns `204` either way, and Carbon
|
||||
prints nothing for an unknown command, so *a wrong prefix looks exactly like a command that worked.*
|
||||
|
||||
`c.plugins` is also worth knowing about for a reason unrelated to permissions: **it reports per-plugin
|
||||
`hook fires`, `hook time`, `hook memory`, `hook lag` and `hook exceptions`**, which is most of the
|
||||
"log which of its expected hooks have fired at least once" mechanism [`PLAN.md`](PLAN.md) §6 requires
|
||||
— for free, and only on Carbon. Useful when debugging *on* Carbon; **not a substitute for the
|
||||
plugin's own counter**, which has to work on both. Our plugin appears there as
|
||||
`Runic Gateway RunicGateway v0.1.0 … 2367ms [1077ms]`, under `Scripts`, with `failed plugins (0)`.
|
||||
|
||||
**Where this reaches us is narrow but real.** R2 and R18 both act through the plugin API, not the
|
||||
console, so neither cares. The two that do care are **documentation** — every operator-facing
|
||||
instruction naming `oxide.grant` needs its Carbon line — and **any place we drive a reload by
|
||||
console string**, which R18's write path does. Resolve the reload through the framework rather than
|
||||
by composing a command, or branch it on `#if CARBON`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Hooks — Carbon is a superset, with thirteen names it does not list
|
||||
|
||||
Carbon publishes **894 hook entries, 774 unique names, in 42 categories**, against the **476** on
|
||||
uMod's Rust hooks page that [`HOOKS.md`](HOOKS.md) mirrors. The larger number is not more game
|
||||
coverage; Carbon documents patched methods our mirror's audience never sees.
|
||||
|
||||
Carbon flags every entry for compatibility. **30 are Carbon-only. Zero are marked Oxide-only.**
|
||||
|
||||
### The 30 Carbon-only hooks
|
||||
|
||||
| Hook | Category | What it is |
|
||||
|---|---|---|
|
||||
| `CanAcceptBackpackItem` | Global | Whether to accept a backpack item |
|
||||
| `CanPatrolHeliSeePlayer` | Global | Patrol-helicopter line of sight to a player |
|
||||
| `CanPickupAllFromRack` | Global | Taking every weapon from a rack |
|
||||
| `CanPickupFromRack` | Global | Taking one weapon from a rack |
|
||||
| `CanPlaceOnRack` | Global | Placing on a rack |
|
||||
| `OnPickupFromRack` | Global | Controls taking items from a rack |
|
||||
| `CanPlayerInheritNetworkGroup` | Global | Network-group inheritance |
|
||||
| `OnChairComfort` | Global | Chair comfort |
|
||||
| `OnChickenScared` | Global | A chicken is scared |
|
||||
| `OnGrowableUpdate` | Global | A growable updates |
|
||||
| `OnConsoleCommand` | Global | A console command is executed |
|
||||
| `OnNativeCommandHasPermission` | Global | Permission check on a native console command |
|
||||
| `OnEntitySpawn` | Global | An entity spawns — **not** Oxide's `OnEntitySpawned`, which exists on both |
|
||||
| `OnJackieChan` | Global | Undescribed upstream |
|
||||
| `OnCarbonBanPlayer`, `OnCarbonUnbanPlayer`, `OnCarbonKickPlayer`, `OnCarbonMutePlayer` | Player | Carbon admin-module moderation actions |
|
||||
| `OnCarbonBlinded`, `OnCarbonUnblinded`, `OnCarbonSpectateStart`, `OnCarbonSpectateEnd` | Player | Carbon admin-module spectate and blind actions |
|
||||
| `OnCarbonPrivateMessage`, `OnCarbonEmpowerPlayerStats`, `OnCarbonLockPlayerContainer` | Player | Carbon admin-module player actions |
|
||||
| `OnCompilationFail`, `OnConstructorFail` | Engine | Plugin compile / constructor failure |
|
||||
| `OnPluginCompileFailure`, `OnPluginOutdated` | Plugin | Plugin lifecycle |
|
||||
| `OnMarketplaceTerminalPurchase` | Vending | Marketplace terminal purchase |
|
||||
|
||||
**None of them is load-bearing for us and none should become so.** The `OnCarbon*` family is the
|
||||
Carbon admin module's own audit trail — tempting for a staff-actions feed, and exactly the kind of
|
||||
convenience that quietly makes Carbon the required framework. If we ever want that feed, it has to
|
||||
have an Oxide answer first.
|
||||
|
||||
### The 13 uMod names Carbon's catalogue does not carry
|
||||
|
||||
| Hook | Category | uMod's description |
|
||||
|---|---|---|
|
||||
| `CanNpcAttack` | Entity | An NPC attempts to attack another entity |
|
||||
| `CanPushBoat` | Player | Cancelling a boat push |
|
||||
| `CanUnlockTechTreeNode` | TechTree | Unlocking a blueprint in a tech tree |
|
||||
| `CanUnlockTechTreeNodePath` | TechTree | …after the path check |
|
||||
| `OnFrame` | Server | Each frame |
|
||||
| `OnHelicopterKilled` | Entity | A CH47 is going to be killed |
|
||||
| `OnNpcDestinationSet` | Entity | Cancelling an NPC destination change |
|
||||
| `OnNpcPlayerResume` | Entity | Cancelling `TryForceToNavmesh` |
|
||||
| `OnNpcStopMoving` | Entity | Denying an NPC move stop |
|
||||
| `OnPlayerCorpse` | Player | A non-null corpse has spawned |
|
||||
| `OnQuarryEnabled` | Resource | A mining quarry is turned on |
|
||||
| `OnTeamInvite` | Team | Cancelling a team invitation |
|
||||
| `OnTeamPromote` | Team | Cancelling a promotion |
|
||||
|
||||
**Absent from a catalogue is not the same as absent from the framework**, and two of these look like
|
||||
renames rather than holes: Carbon lists `OnTeamMemberInvite` and `OnTeamMemberPromote` in its `Team`
|
||||
category, which is `OnTeamInvite` and `OnTeamPromote` under different names. Carbon's `Team`
|
||||
category also carries visible duplicates and both tenses of the same event (`OnTeamCreate` *and*
|
||||
`OnTeamCreated`, `OnTeamUpdate` *and* `OnTeamUpdated`, `OnTeamMemberInvite` twice), which says the
|
||||
catalogue is generated rather than curated.
|
||||
|
||||
So this table is **a list of things to check on a live Carbon server**, not a list of losses. The
|
||||
practical protection is one we already committed to in [`PLAN.md`](PLAN.md) §6: *hooks bind by name
|
||||
and arity through reflection with no compile-time check*, so the plugin logs which of its expected
|
||||
hooks have fired at least once. That mechanism was written for Facepunch renaming a hook on wipe
|
||||
day; it answers this question too, on either framework, without us having to trust either catalogue.
|
||||
|
||||
**None of the 13 is currently in a phase.** R5 settled Teams on Rust's **first-party clans**, not
|
||||
first-party Teams, so `OnTeamInvite`/`OnTeamPromote` are outside the plan as written.
|
||||
|
||||
---
|
||||
|
||||
## 7. Convars — a Carbon-only set exists, and leases must not reach for it
|
||||
|
||||
Carbon publishes 23 convars of its own, several of them precisely the kind of live, gameplay-shaped
|
||||
value [`PLAN.md`](PLAN.md) §9 wants to lease — `c.recycletickmultiplier`,
|
||||
`c.safezonerecycletickmultiplier`, `c.researchdurationmultiplier` and so on, most flagged
|
||||
`ForceModded`.
|
||||
|
||||
**A lease over one of those would work on Carbon and be undeclarable on Oxide.** Lease keys are
|
||||
advertised to the event authoring form, and a key that silently does not exist on half of installs
|
||||
is the failure `EVENTS.md` §H's *verify every key live* rule exists to prevent. So: **the lease
|
||||
catalogue is drawn from the game's own convars, which both frameworks expose identically.** If a
|
||||
Carbon-only key is ever worth the cost, it is advertised conditionally on the connected server's
|
||||
framework, and that is a deliberate decision rather than an oversight.
|
||||
|
||||
---
|
||||
|
||||
## 8. Operating differences that reach deployment
|
||||
|
||||
- **They cannot coexist.** Oxide ships a patched `Assembly-CSharp.dll`; Carbon requires
|
||||
Facepunch's vanilla one and patches in memory through Harmony. One install runs one framework, so
|
||||
**one rig cannot prove both** — which is why [`PLAN.md`](PLAN.md) R21 moves the rigs to
|
||||
Pterodactyl and runs two.
|
||||
- **Carbon migrates an Oxide install on first boot** — it copies config, data, lang, user and group
|
||||
files across and relocates `Oxide.Ext.*.dll` out of `RustDedicated_Data/Managed`. Useful for an
|
||||
operator; a hazard for a test rig, because a Carbon rig built by converting an Oxide one starts
|
||||
with the Oxide one's state and proves less than a clean install.
|
||||
- **Carbon self-updates and its releases are rolling tags**, not versioned ones:
|
||||
`production_build` (v2.0.259 at the time of writing, 2026-09-06), plus `edge_build`,
|
||||
`experimental_build` and per-branch Rust builds. Oxide publishes an incrementing build number.
|
||||
**So "which Carbon is this" is not answerable the way "which Oxide is this" is**, and R4's
|
||||
`doctor` prerequisite check has to accept that — it can establish *that* Carbon is installed and
|
||||
report the build it reports, but "current enough" is a weaker claim on Carbon than on Oxide.
|
||||
- **Carbon patches hooks only when a plugin subscribes**, so an unsubscribed hook costs nothing.
|
||||
That rewards the selective subscription R17 already requires for ZoneManager's chatty zone
|
||||
transitions, on Carbon more than on Oxide.
|
||||
|
||||
---
|
||||
|
||||
## 9. What this costs us, in one table
|
||||
|
||||
| Decision | Change |
|
||||
|---|---|
|
||||
| **R2** permissions | None to the design. The store is API-only on Carbon *by construction* rather than by choice, and `PermissionExists` stays the check because the useful return value is Carbon-only |
|
||||
| **R4** installer | `doctor` detects *which* framework, not *whether Oxide*; the payload drops into `PluginDirectory`; "current enough" is weaker on Carbon (rolling tags) |
|
||||
| **R18** config editor | Roots come from `Interface.Oxide.ConfigDirectory` / `DataDirectory`, never literals; the reload is resolved through the framework, not by composing `oxide.reload` |
|
||||
| **R6/R17** base mods | Unchanged — Kits, Clans, PopupNotifications and ZoneManager are Oxide plugins and Oxide plugins run on Carbon |
|
||||
| **§9** event leases | Keys come from the game's convars; Carbon's own convars are out of the catalogue unless advertised conditionally |
|
||||
| Everything else | Unchanged |
|
||||
|
||||
The honest summary: **Carbon costs three amendments and one extra rig, not a second codebase.**
|
||||
|
||||
---
|
||||
|
||||
## 10. Scorecard — what the live rig confirmed and what it corrected
|
||||
|
||||
Run 2026-09-15 against `rust-carbon` (Carbon **2.0.259.0** `[2026.09.03.0]` `21063e8`, Linux,
|
||||
`production_build`, Rust 103/2633.288.1), with the Oxide rig alongside for comparison.
|
||||
|
||||
| Claim | Verdict | Evidence |
|
||||
|---|---|---|
|
||||
| An `Oxide.Plugins` / `RustPlugin` source file loads unchanged | **CONFIRMED** | The byte-identical `RunicGateway.cs` that runs on the Oxide rig loaded as `Runic Gateway v0.1.0` in `2367ms`, printed the same startup line, and retried the absent sidecar the same way |
|
||||
| The framework root is `carbon/`, config dir is `configs` (plural) | **CONFIRMED** | `/carbon/{configs,data,lang,logs,plugins,extensions,modules,managed,native,modifiers,temp,tools}`; **no `/oxide` directory at all** |
|
||||
| `Interface.Oxide.ConfigDirectory` resolves there | **CONFIRMED, indirectly and decisively** | The plugin's own config was written to **`/carbon/configs/RunicGateway.json`** by the same code that writes `/oxide/config/RunicGateway.json` on the Oxide rig. A literal path in R18 would not have found it |
|
||||
| Console prefix is `c.`, `oxide.` is not aliased | **CONFIRMED** | `c.version` / `c.plugins` / `c.grant` / `c.group` answered; `oxide.plugins` produced nothing |
|
||||
| `#if CARBON` is defined | **CONFIRMED** | `carbon/config.json` → `ConditionalCompilationSymbols: ["CARBON", "RUST", "OXIDE_PUBLICIZED"]` — and two symbols this document had not known about |
|
||||
| Carbon self-updates | **CONFIRMED** | `SelfUpdating.Enabled: true`, plus the egg refetching `production_build` every boot |
|
||||
| *"Oxide stores JSON, Carbon stores Protobuf or SQLite"* | **WRONG — see §4** | **Both** store Protobuf, under **identical filenames**, differing only in directory. The refutation strengthens R2 rather than weakening it |
|
||||
| The 13 uMod hook names missing from Carbon's catalogue | **NOT YET TESTED** | None is in a phase; the plugin's own fired-hook log is the standing answer either way |
|
||||
|
||||
**Two things this document did not know to claim**, both found by looking rather than reading:
|
||||
Carbon ships a **third default group** (`moderator`) that R2's push must tolerate, and `c.plugins`
|
||||
exposes per-plugin hook telemetry Oxide has no equivalent for.
|
||||
1737
modules/rust/PLAN.md
1737
modules/rust/PLAN.md
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,13 @@
|
||||
# Rust — the Oxide/uMod ecosystem reference
|
||||
# Rust — the modding-framework reference
|
||||
|
||||
Reference material for the **upcoming `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.
|
||||
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.
|
||||
|
||||
Everything below was **scraped verbatim from uMod on 2026-09-15**.
|
||||
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
|
||||
|
||||
@@ -15,6 +18,7 @@ Everything below was **scraped verbatim from uMod on 2026-09-15**.
|
||||
| [`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
|
||||
@@ -40,8 +44,12 @@ 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 — specifically an **Oxide plugin**, since Oxide/uMod is what modded Rust servers
|
||||
> run — hooking the game's own events.
|
||||
> 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:
|
||||
|
||||
@@ -50,8 +58,8 @@ Two consequences, and they are the two halves of this directory:
|
||||
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.** Our plugin is compiled, loaded, permissioned
|
||||
and configured by Oxide, on Oxide's terms. [`OXIDE_API.md`](OXIDE_API.md) is that rulebook, and
|
||||
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.
|
||||
|
||||
238
rust-link/INTEGRATION.md
Normal file
238
rust-link/INTEGRATION.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# rust-link — standing the bridge up
|
||||
|
||||
**Operator- and developer-facing.** How to get a Rust server, a sidecar and a website talking, and
|
||||
how to tell which of the three is wrong when they are not. The contract itself is
|
||||
[`PROTOCOL.md`](PROTOCOL.md).
|
||||
|
||||
There is no installer support for Rust yet — that is a later phase — so everything here is done by
|
||||
hand. When the installer gains `--game rust`, this page becomes the fallback path rather than the
|
||||
only one.
|
||||
|
||||
---
|
||||
|
||||
## 1. What you need
|
||||
|
||||
| Piece | Where it comes from |
|
||||
|---|---|
|
||||
| A Rust dedicated server with **Oxide** | umod.org |
|
||||
| `RunicGateway.cs` | [Rust-Plugins](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins), `overlay/oxide/plugins/` |
|
||||
| `rust-link-sidecar` | [Rust-Link](https://gitea.whitlocktech.com/RunicGateway/Rust-Link) |
|
||||
| A Runic Gateway website with `module-rust` installed | [Module-Rust](https://gitea.whitlocktech.com/RunicGateway/Module-Rust) |
|
||||
|
||||
**One game server, one sidecar, on that server's own host.** Six servers means six of the first two
|
||||
pairs and six rows in the website's admin panel.
|
||||
|
||||
The module also expects four third-party Oxide plugins to be present for the features that follow
|
||||
the bridge itself — `Clans`, `Kits`, `PopupNotifications` and `ZoneManager`, all from k1lly0u on
|
||||
umod.org. The bridge works without them; the features that read them do not.
|
||||
|
||||
---
|
||||
|
||||
## 2. The order that works
|
||||
|
||||
Sidecar first, then plugin, then website. Any order eventually converges — the plugin retries for
|
||||
ever and the website polls — but this one gives you a readable log at each step instead of three
|
||||
components all reporting that something else is missing.
|
||||
|
||||
### 2.1 The sidecar
|
||||
|
||||
```bash
|
||||
rust-link-sidecar --print-config
|
||||
```
|
||||
|
||||
This resolves the configuration exactly as a normal start would: it writes `sidecar.toml` if it is
|
||||
missing, generates and saves an auth token if there is none, and prints the whole thing as JSON —
|
||||
**including the token in clear text**, which is the point. Keep that token; the website needs it and
|
||||
there is no second way to read it back.
|
||||
|
||||
```json
|
||||
{
|
||||
"component": "rust-link-sidecar",
|
||||
"protocol": 1,
|
||||
"config_path": "/etc/runicgateway/rust-main.toml",
|
||||
"game": { "bind": "127.0.0.1:7799", "server_id": "" },
|
||||
"web": { "bind": "127.0.0.1:8090", "auth_token": "…", "ws_path": "/ws" },
|
||||
"store": { "path": "/var/lib/runicgateway/rust-link.db" }
|
||||
}
|
||||
```
|
||||
|
||||
Then start it. On a host running more than one game server, give each sidecar its own
|
||||
`--config`, its own ports and its own database file.
|
||||
|
||||
**`[game].bind` stays on loopback.** There is no token on the game link — the plugin and the sidecar
|
||||
share a host and `127.0.0.1` *is* the authentication. Moving that bind to a routable address puts an
|
||||
unauthenticated command channel on the network.
|
||||
|
||||
**`[web].bind` is the one you may need to move**, because the website is usually on another host.
|
||||
Behind TLS and a firewall: the token is the only thing guarding it.
|
||||
|
||||
Check it:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8090/health
|
||||
{"status":"degraded","protocol":1,"plugin_connected":false,"database":"ok","uptime":"0m","last_event":null}
|
||||
```
|
||||
|
||||
`degraded` with `plugin_connected: false` is exactly right at this point — nothing is connected yet.
|
||||
|
||||
### 2.2 The plugin
|
||||
|
||||
```bash
|
||||
cp RunicGateway.cs /path/to/rust/oxide/plugins/
|
||||
```
|
||||
|
||||
Oxide compiles and loads it on the write. Watch `oxide/logs/`:
|
||||
|
||||
```
|
||||
[Info] RunicGateway was compiled successfully in 2295ms
|
||||
[Info] [Runic Gateway] protocol 1, serverId 'main', sidecar 127.0.0.1:7799
|
||||
[Info] [Runic Gateway] connected to 127.0.0.1:7799
|
||||
```
|
||||
|
||||
The first load also writes `oxide/config/RunicGateway.json`. Set `ServerId` before you go further:
|
||||
|
||||
```json
|
||||
{ "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "main" }
|
||||
```
|
||||
|
||||
**`ServerId` is this server's identity as the website knows it, and it is permanent.** It is not
|
||||
derived from the hostname on purpose — an operator renames a server for a season, and the site must
|
||||
not lose its history for it. Changing it later orphans everything recorded under the old one.
|
||||
|
||||
Now `/health` should read:
|
||||
|
||||
```json
|
||||
{"status":"ok","protocol":1,"plugin_connected":true,"database":"ok","uptime":"2m","last_event":"…"}
|
||||
```
|
||||
|
||||
If it does not, ask the game server:
|
||||
|
||||
```
|
||||
rg.link
|
||||
protocol=1 serverId=main connected=True depth=0 sent=3 dropped=0 received=2
|
||||
connects=1 writeErrors=0 bootId=boot-20260915T194502Z
|
||||
```
|
||||
|
||||
### 2.3 The website
|
||||
|
||||
**Admin → Rust → add a server.** Four values:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Id | the slug every URL carries. Match `ServerId` in the plugin config |
|
||||
| Name | what visitors see |
|
||||
| Sidecar base URL | `http://<sidecar host>:8090` |
|
||||
| Sidecar token | the `auth_token` from `--print-config` |
|
||||
|
||||
**The token is write-only.** It is stored encrypted and never returned to any client; the panel
|
||||
reports only whether one is set. A save that leaves the field blank keeps the stored one — so
|
||||
renaming a server does not mean re-pasting a credential.
|
||||
|
||||
Then press **Test**, which probes the sidecar and reports what came back:
|
||||
|
||||
```json
|
||||
{ "ok": true, "status": "ok",
|
||||
"sidecar": { "status": "ok", "protocol": 1, "plugin_connected": true, … } }
|
||||
```
|
||||
|
||||
Within a poll interval the server appears at `/rust/servers`.
|
||||
|
||||
---
|
||||
|
||||
## 3. When it does not work
|
||||
|
||||
A wrong URL, a wrong token and a mismatched protocol version all present as *"the site says my
|
||||
server is offline"*. The **Test** button is what separates them, and its `status` is the whole
|
||||
diagnosis:
|
||||
|
||||
| `status` | What is wrong | Where to look |
|
||||
|---|---|---|
|
||||
| `ok` | nothing | — |
|
||||
| `no-token` | the admin form was saved without one | Admin → Rust |
|
||||
| `unauthorized` | the token does not match | `--print-config` on the sidecar host |
|
||||
| `protocol-mismatch` | the sidecar and the module speak different versions | upgrade one of them; the body names both numbers |
|
||||
| `timeout` | the sidecar answered too slowly, or not at all | the sidecar's own log |
|
||||
| `transport-error` | nothing is listening at that address | the base URL, the firewall, whether the sidecar is running |
|
||||
| `http-<code>` | something answered, and it was not a sidecar | usually a reverse proxy in front of the wrong thing |
|
||||
|
||||
Two failures that look alike and are not:
|
||||
|
||||
- **`plugin_connected: false` with an otherwise healthy sidecar** — the bridge is fine and the game
|
||||
is not talking to it. Check the plugin is loaded (`oxide.plugins`) and `rg.link` on the game
|
||||
server.
|
||||
- **The server is listed but reads `stale`** — something reported once and has not since. The row
|
||||
says what was true when it was written; nothing has written it since. Either the poll is failing
|
||||
(the website's log) or the sidecar stopped (its own).
|
||||
|
||||
### 3.0 `untyped_frames` on `/health` is not zero
|
||||
|
||||
**The plugin and the sidecar are on different protocol versions.** The game link has no handshake
|
||||
to catch that at connect time (`PROTOCOL.md` §2), so it shows up here instead: the sidecar files a
|
||||
frame by its `type`, a frame from the wrong version does not carry one it recognises, and it is
|
||||
dropped and counted rather than guessed at.
|
||||
|
||||
The symptom without this counter is the confusing one — a game server plainly up, a sidecar plainly
|
||||
healthy, and a website showing nothing. Check the plugin's `rg.link` (it prints its protocol) against
|
||||
the sidecar's `/health` (which prints its own) and upgrade whichever is behind.
|
||||
|
||||
### 3.1 The failures that are supposed to happen
|
||||
|
||||
Three things look like breakage and are the design:
|
||||
|
||||
- **Killing the sidecar does not disturb the game.** The plugin logs `sidecar link lost;
|
||||
reconnecting` and retries with backoff, buffering into a bounded queue that drops its oldest
|
||||
entries rather than growing. The game does not stall, and `Emit` never touches a socket.
|
||||
- **Starting the plugin before the sidecar logs one line and then goes quiet.** `cannot reach the
|
||||
sidecar: … — retrying quietly until it answers`, printed once per load rather than every few
|
||||
seconds. A wrong `Host` or `Port` looks exactly like this, which is why it is printed at all.
|
||||
- **The website renders with every game server off.** The server list, the player counts and the
|
||||
last-reported times all come from stored state. A page that 500s because a socket is closed would
|
||||
be a module that made the site's availability depend on the game's.
|
||||
|
||||
---
|
||||
|
||||
## 4. Running more than one server
|
||||
|
||||
Each pair is fully independent: its own ports, its own `sidecar.toml`, its own database file, its
|
||||
own token, its own row on the website.
|
||||
|
||||
Set `[game].server_id` in each `sidecar.toml` to match that server's plugin config. It is a
|
||||
**cross-check**, not a second source of truth — the plugin's announcement wins — and it exists to
|
||||
catch exactly one mistake: two game servers pointed at one sidecar by a copied config, which is
|
||||
silent in every other design and produces one server's history under another's name. When it fires
|
||||
you get a warning naming both ids.
|
||||
|
||||
---
|
||||
|
||||
## 4.1 What the bridge sends, and how much of it is kept
|
||||
|
||||
From protocol 2 the plugin sends the read path: connects and disconnects, deaths, chat, gathering,
|
||||
bans and reports, and the wipe. Two things about the volume are worth knowing before you size
|
||||
anything.
|
||||
|
||||
**Gathering and NPC kills are counted, not forwarded.** `OnDispenserGather` fires on every swing at
|
||||
a tree; sending one frame per swing would make the bridge the most expensive thing on the server. The
|
||||
plugin keeps a per-player tally and flushes it once a minute as a single `player.tally` frame. So the
|
||||
leaderboard is exact and the wire is quiet.
|
||||
|
||||
**The sidecar's history is bounded; the website's is not.** `[store].retain_days` (default 14) is
|
||||
how long the sidecar keeps raw events. The permanent record — per-wipe totals that survive a wipe —
|
||||
lives in the website's own tables, so shortening this loses recent detail and never loses a player's
|
||||
history. Set it to `0` to keep everything, if the host's disk is yours to spend.
|
||||
|
||||
**Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and
|
||||
stamps it on every frame, so a wipe splits the history rather than ending it. That is also why
|
||||
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
|
||||
`REMOVE_FILES`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Upgrading
|
||||
|
||||
The four declaration sites in [`PROTOCOL.md` §2](PROTOCOL.md#2-versioning) must agree. In practice
|
||||
that means upgrading the sidecar and the plugin **together**, because the game link has no version
|
||||
check of its own and a mismatched plugin mis-parses rather than refusing.
|
||||
|
||||
The website is the forgiving half: it sends its version on every request and a sidecar that
|
||||
disagrees answers `409` with both numbers, so a module ahead of or behind its sidecar reports a
|
||||
named fault rather than misbehaving.
|
||||
100
rust-link/PLAYER_WALK.md
Normal file
100
rust-link/PLAYER_WALK.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# The player walk — proving the half of the read path a console cannot reach
|
||||
|
||||
Protocol 2's catalogue divides cleanly in two, and the line is not about importance: it is about
|
||||
whether a hook can fire without somebody holding a mouse.
|
||||
|
||||
Everything in the first half was proven from a console and a REST client while phase 3 was built —
|
||||
the boards, the wipe id, the envelope, bans, the server lifecycle. Everything below needs a **real
|
||||
player on a real server**, because the hooks carry a `BasePlayer`, a `HitInfo` or a chat line, and
|
||||
none of those three can be manufactured from a console without becoming a different test.
|
||||
|
||||
This document is the walk that closes it. It takes about ten minutes, it is the same on Oxide and on
|
||||
Carbon, and it is written so that the answer is readable afterwards rather than watched live.
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
1. A rig running, with `RunicGateway.cs` loaded — `oxide.plugins` (or `c.plugins`) lists *Runic
|
||||
Gateway*, and `rg.link` answers `connected=True`.
|
||||
2. A sidecar the rig can reach, with its store **empty** — that is what makes the event list at the
|
||||
end readable as a transcript of the walk and nothing else.
|
||||
3. The sidecar's token to hand, for the reads at the bottom.
|
||||
|
||||
Run this once, before you join:
|
||||
|
||||
```
|
||||
rg.hooks
|
||||
```
|
||||
|
||||
Every player hook should read **silent**. That is the baseline: the point of the walk is to move
|
||||
them, and starting from a run where some already fired proves less.
|
||||
|
||||
---
|
||||
|
||||
## The walk
|
||||
|
||||
Do these in order. The order matters only in two places, noted where it does.
|
||||
|
||||
| # | Do this | Fires | The frame should carry |
|
||||
|---|---|---|---|
|
||||
| 1 | **Join the server** | `CanUserLogin`, `OnUserApproved`, `OnPlayerConnected` | Three frames, in that order. The first two carry your **IP address** — check it is a real address and not the string `0`. `player.connected` carries your steam id and name |
|
||||
| 2 | **Wake up / spawn in** (click Respawn if you are dead) | `OnPlayerRespawned` | `player.respawned`, steam id only. It does **not** fire if you simply wake from sleeping — that is the hook's own documented behaviour, so no frame here is a pass, not a failure |
|
||||
| 3 | **Say something in chat**, then **say something in team chat** if you have a team | `OnPlayerChat` | Two `player.chat` frames, with `channel` reading `Global` and `Team`. The message must arrive whole — if it is truncated or the frame is missing, the flattener ate it |
|
||||
| 4 | **Chop a tree for about twenty seconds**, then **mine a node** | `OnDispenserGather` | **Nothing immediately.** This is the aggregate: one `player.tally` frame within 60 seconds, carrying `gathered` with `wood` and `stones`, summed. Seeing a frame per swing would be the bug |
|
||||
| 5 | **Kill an animal or a scientist** | `OnEntityDeath` | Again nothing immediately — `npcKills` on the next `player.tally`. No `player.death`: a chicken is not a killfeed entry |
|
||||
| 6 | **Die to the environment** — fall damage is easiest | `OnPlayerDeath` | `player.death` with `attackerType: "environment"`, a `grid` like `H7`, and **no** `attackerId`. Check the grid against the map: a wrong sign in the row arithmetic mirrors the whole map, and only a human with the map open can see that |
|
||||
| 7 | **Kill yourself** — `kill` in the F1 console | `OnPlayerDeath` | `attackerType: "self"`, no `attackerId` |
|
||||
| 8 | **If a second player is available**: kill each other once | `OnPlayerDeath` | `attackerType: "player"`, with `attackerId`, `attackerName`, a `weapon` shortname and a `distance` in metres. This is the killfeed's whole shape, and it is the one row phase 4's page is built from |
|
||||
| 9 | **Build a foundation, then destroy it yourself** | `OnEntityDeath` | `entity.destroyed` with `ownerId` (yours), `prefab`, `grid` and `attackerId`. Decay must **not** produce one of these — only a player breaking it |
|
||||
| 10 | **Disconnect** | `OnPlayerDisconnected` | `player.disconnected` with a `reason` and a **`sessionSec`** roughly equal to how long you were on. It also flushes your tally first, so any gathering since the last minute arrives immediately before it |
|
||||
|
||||
Two ordering notes: step 4 must come before step 10 by at least a minute if you want to see the
|
||||
cadence flush rather than the disconnect flush, and step 1's three frames are the only place the
|
||||
order between hooks is itself part of the answer.
|
||||
|
||||
---
|
||||
|
||||
## Reading the result
|
||||
|
||||
From the machine running the sidecar:
|
||||
|
||||
```bash
|
||||
TOKEN=… # [web].auth_token from sidecar.toml, or `--print-config`
|
||||
BASE=http://127.0.0.1:8090
|
||||
|
||||
# The whole walk, oldest first, as a transcript.
|
||||
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/feed?since=0&limit=500" \
|
||||
| python -m json.tool
|
||||
|
||||
# Or one kind at a time.
|
||||
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/events?kind=player.death&limit=20"
|
||||
```
|
||||
|
||||
And from the game console:
|
||||
|
||||
```
|
||||
rg.hooks
|
||||
```
|
||||
|
||||
Every hook in the walk should now read **fired**, with a count. A hook still `silent` after the step
|
||||
that should have fired it is the finding — and on Carbon it is the specific question
|
||||
[`CARBON.md`](../modules/rust/CARBON.md) §6 asks, since Carbon's catalogue omits thirteen uMod names
|
||||
and nobody has yet checked whether they are renames or holes.
|
||||
|
||||
---
|
||||
|
||||
## What counts as a pass
|
||||
|
||||
Not "frames arrived". Three things, and the third is the one worth slowing down for:
|
||||
|
||||
1. **Every hook in the table fired**, on both frameworks, from the same plugin file.
|
||||
2. **Every frame carries the envelope** — `type`, `serverId` and `wipeId` on all of them
|
||||
([`PROTOCOL.md`](PROTOCOL.md) §8.1). A player frame without a `wipeId` cannot be attributed to a
|
||||
wipe and its rollup is lost.
|
||||
3. **The aggregates are aggregates.** `player.tally` is a delta since the last flush, so two minutes
|
||||
of chopping is two frames that sum to the total, not two frames each carrying the total. Getting
|
||||
this backwards makes every leaderboard roughly double, and it looks correct until somebody counts.
|
||||
|
||||
Anything that disagrees with the table is a finding about the game or the framework rather than a
|
||||
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.
|
||||
587
rust-link/PROTOCOL.md
Normal file
587
rust-link/PROTOCOL.md
Normal file
@@ -0,0 +1,587 @@
|
||||
# rust-link — the wire protocol
|
||||
|
||||
**Canonical.** This document defines the two contracts that make up the Rust bridge. Code in three
|
||||
repositories is held against it, and a change here is a change in all of them.
|
||||
|
||||
| Contract | Between | Transport |
|
||||
|---|---|---|
|
||||
| The **game link** | the Oxide bridge plugin ↔ the sidecar | loopback TCP, newline-delimited JSON |
|
||||
| The **website API** | the sidecar ↔ `module-rust` | HTTP + WebSocket, bearer token |
|
||||
|
||||
Mirrors [`link/`](../link/PLAN.md), which is the same pair of contracts for Ultima Online. Where
|
||||
this document is silent, that one is not a fallback: the two protocols are independent and share
|
||||
only their shape.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why the game does not listen
|
||||
|
||||
**The plugin is the TCP client; the sidecar owns the listener.** A Rust server therefore opens no
|
||||
extra port, and the only component the website can reach is the sidecar. This is inherited unchanged
|
||||
from the ServUO bridge — the footing changed (Oxide hooks instead of game source) and the invariant
|
||||
did not.
|
||||
|
||||
```
|
||||
Rust server + Oxide (Rust-Plugins, C#)
|
||||
│ the plugin DIALS OUT · 127.0.0.1:7799 · newline-delimited JSON, bidirectional
|
||||
▼
|
||||
rust-link sidecar (Rust-Link) ← the only network-facing bridge component
|
||||
│ WebSocket (live feed) + REST (point-in-time reads), bearer-token auth
|
||||
▼
|
||||
module-rust, inside a website core
|
||||
```
|
||||
|
||||
**One game server, one sidecar, on that server's own host.** A community running six servers runs
|
||||
six pairs; `module-rust` holds six clients and the website core never learns there is more than one.
|
||||
Nothing in the sidecar is multiplexed and nothing in it should become multiplexed — the `serverId`
|
||||
on every frame exists so the *module* can tell its clients apart, not so the sidecar can.
|
||||
|
||||
### 1.1 Loopback is the trust boundary on the game link
|
||||
|
||||
There is **no token on the game link**. The plugin and the sidecar share a host, and the sidecar
|
||||
binds `127.0.0.1` — that is the authentication, exactly as on the ServUO bridge. Binding
|
||||
`[game].bind` to a routable address puts an unauthenticated command channel on the network.
|
||||
|
||||
The website-facing surface is the opposite: authentication there is **always on** and cannot be
|
||||
turned off. The sidecar generates and persists a token on first start, so there is no state in which
|
||||
it is listening without one.
|
||||
|
||||
---
|
||||
|
||||
## 2. Versioning
|
||||
|
||||
The wire version is a single integer — **2** as of the read path (§8) — declared in **four** places
|
||||
that must agree:
|
||||
|
||||
| Where | Repo |
|
||||
|---|---|
|
||||
| `PROTOCOL_VERSION` in `sidecar/src/main.rs` | Rust-Link |
|
||||
| `ProtocolVersion` in `overlay/oxide/plugins/RunicGateway.cs` | Rust-Plugins |
|
||||
| `protocol` in `overlay.toml` | Rust-Plugins |
|
||||
| `PROTOCOL_VERSION` in `server/sidecarClient.js` | Module-Rust |
|
||||
|
||||
Bump all four in the same change as the emitters, together with this document.
|
||||
|
||||
**The two halves of the contract enforce it differently, and the asymmetry is the reason
|
||||
`overlay.toml` exists at all:**
|
||||
|
||||
- On the **website API** the check is live. Every response carries `X-RustLink-Version`; a client
|
||||
that declares a different one in its request header is refused `409` with both numbers in the
|
||||
body, rather than served something it will mis-parse.
|
||||
- On the **game link** there is no such check, and a mismatched plugin would simply mis-parse. The
|
||||
plugin announces its protocol in `server.hello`, which is readable only after the game server has
|
||||
booted with it loaded — far too late for an installer to refuse a bad pairing. So `overlay.toml`
|
||||
declares it statically, and the installer refuses to pair an overlay and a sidecar whose numbers
|
||||
disagree. A bump landing in one repo and not the others fails to compose rather than half-deploying.
|
||||
|
||||
---
|
||||
|
||||
## 3. Protocol 1 — the transport
|
||||
|
||||
Everything phase 1 defines, and deliberately nothing more. It is still the floor every later version
|
||||
stands on — the framing, the greeting, the heartbeat and the one correlated round trip are unchanged
|
||||
— but **two things below were amended by protocol 2**: every frame now carries `type`, `serverId`
|
||||
and `wipeId` (§8.1), and `server.hello` is a *board* rather than a one-off greeting (§8.3). Read §8
|
||||
beside this section rather than after it.
|
||||
|
||||
### 3.1 Framing
|
||||
|
||||
Newline-delimited JSON over TCP, both directions, UTF-8. One complete JSON object per line, no
|
||||
embedded newlines.
|
||||
|
||||
- **Outbound frames** (plugin → sidecar) carry `kind`.
|
||||
- **Inbound frames** (sidecar → plugin) carry `cmd`.
|
||||
|
||||
Both ends cap an inbound line at **1 MiB**. An over-long line is **discarded, not buffered**, and
|
||||
the connection stays up: a single malformed frame is not a reason to tear down a link that live
|
||||
events are flowing over, and a dropped reply simply times out on the caller's side and is
|
||||
re-requested.
|
||||
|
||||
The cap exists from protocol 1 rather than being added after the first large frame arrives. An
|
||||
unbounded read facing a peer that will one day send a map image is a memory-exhaustion shape we
|
||||
would be inventing ourselves.
|
||||
|
||||
### 3.2 `server.hello` — plugin → sidecar
|
||||
|
||||
Sent on **every successful connect**, not once at game-server start. The sidecar restarts
|
||||
independently of the game, so anything it needs up front has to be re-sent per connection.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "server.hello",
|
||||
"t": 1789510452152,
|
||||
"protocol": 1,
|
||||
"serverId": "main",
|
||||
"bootId": "boot-20260915T194502Z",
|
||||
"plugin": "0.1.0",
|
||||
"hostname": "Test Server",
|
||||
"description": "No server description has been provided.",
|
||||
"level": "Procedural Map",
|
||||
"seed": 1234,
|
||||
"worldSize": 4000,
|
||||
"maxPlayers": 10,
|
||||
"players": 0,
|
||||
"joining": 0,
|
||||
"queued": 0,
|
||||
"uptimeSec": 8947,
|
||||
"saveCreatedAt": "2026-09-15T19:58:17Z"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `t` | epoch milliseconds, stamped when the world was read |
|
||||
| `serverId` | this server's stable identity across wipes and restarts, from the plugin's config. **Not derived from the hostname** — an operator renames a server for a season and the site must not lose its history for it |
|
||||
| `bootId` | see §3.2.1 |
|
||||
| `saveCreatedAt` | when the current save was created. Protocol 1 called this *raw material for a wipe id* and left deriving one to the website; **§8.2 reversed that** — the plugin derives `wipeId` from this value and stamps it on every frame |
|
||||
|
||||
Everything from `hostname` down is read from `ConVar.Server` and `BasePlayer.activePlayerList` on
|
||||
the game's main thread. A field the game cannot answer is **absent**, never zero.
|
||||
|
||||
#### 3.2.1 `bootId` identifies the server PROCESS
|
||||
|
||||
It is the server process's start instant, formatted `boot-yyyyMMddTHHmmssZ`, and it must change
|
||||
**when and only when the world started over**.
|
||||
|
||||
That makes three things it is deliberately not:
|
||||
|
||||
- **Not a fresh value per plugin load.** `oxide.reload RunicGateway` must not change it. The website
|
||||
watches this value to tell a game restart — where everything an event put in the world is gone —
|
||||
from a bridge reconnect, which loses nothing; a plugin reload is the second kind, and a boot id
|
||||
regenerated at `Init` would ask the site to reconcile its whole ledger for no news.
|
||||
- **Not the sidecar's identity.** The sidecar restarting is invisible to the world.
|
||||
- **Not the wipe.** A wipe is `saveCreatedAt` changing; a restart is not a wipe.
|
||||
|
||||
The plugin reads it from `Process.StartTime`, which is exact and identical on every read.
|
||||
|
||||
### 3.3 `ping` / `pong` — the heartbeat
|
||||
|
||||
The sidecar sends `{"cmd":"ping"}` every 30 seconds while a plugin is connected; the plugin answers
|
||||
`{"kind":"pong","t":…}`.
|
||||
|
||||
A `pong` is **never persisted**. It only moves the sidecar's `last_event`, which is the whole point:
|
||||
a Rust server with nobody on it is very quiet, and without a heartbeat "the game has said nothing
|
||||
for six hours" would be indistinguishable from "the link died six hours ago".
|
||||
|
||||
### 3.4 `server.status` — the request/reply verb
|
||||
|
||||
The one correlated round trip in protocol 1. It exists so the correlation path is exercised by
|
||||
something before anything depends on it.
|
||||
|
||||
```
|
||||
sidecar → plugin {"cmd":"server.status","reqId":"r-1"}
|
||||
plugin → sidecar {"kind":"server.status","reqId":"r-1","t":…, …the §3.2 body…}
|
||||
```
|
||||
|
||||
**Correlation is by `reqId`, a process-unique counter minted by the sidecar.** The plugin echoes it
|
||||
verbatim and **only when one was supplied**: a reply that invented one would be routed to nobody,
|
||||
and a reply that omitted one the caller sent would leave that caller waiting out its whole timeout.
|
||||
|
||||
`server.hello` and `server.status` share a body by construction, in one function in the plugin. They
|
||||
differ in what wraps them, not in what they say about the server, and letting them drift is how a
|
||||
site ends up showing two different player counts.
|
||||
|
||||
### 3.5 `link.down` — the sidecar's own observation
|
||||
|
||||
Not a frame the plugin sends. When a plugin connection ends the sidecar synthesises
|
||||
`{"kind":"link.down"}` onto its broadcast channel, so the website sees the drop without polling. It
|
||||
is **never persisted**: it is this process's observation, not something the game said.
|
||||
|
||||
---
|
||||
|
||||
## 4. The website API
|
||||
|
||||
Served by the sidecar. Everything except `/health` requires the token, which may arrive as
|
||||
`Authorization: Bearer <t>`, `X-Api-Key: <t>`, or `?token=<t>` — the last so browser WebSocket
|
||||
clients, which cannot set handshake headers, can still authenticate. The compare is constant-time.
|
||||
|
||||
Every response carries `X-RustLink-Version`, including `/health` and including error responses.
|
||||
|
||||
| Route | Backed by | Notes |
|
||||
|---|---|---|
|
||||
| `GET /health` | — | **Unauthenticated**, so monitoring can reach it |
|
||||
| `GET /server` | the store | The last `server.hello`. **`204` when the game has never connected** |
|
||||
| `GET /events?kind=&wipe=&limit=` | the store | Newest first; `limit` clamped to 1–1000. For a human |
|
||||
| `GET /feed?since=&limit=` | the store | **Oldest first**, from a cursor. For a consumer that must not miss a row (§8.9) |
|
||||
| `GET /status` | the plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply |
|
||||
| `GET /ws` | broadcast | The live feed; sends `{"kind":"ws.hello","protocol":1}` on connect |
|
||||
|
||||
### 4.1 The split between store-backed and live is deliberate
|
||||
|
||||
The store-backed reads answer **while the game server is off**, which is what lets the website render
|
||||
a server list during a wipe or a restart. `/status` is the one route that fails when the game is
|
||||
down, because "what is it doing right now" has no stale answer worth giving.
|
||||
|
||||
### 4.2 `204` is an answer
|
||||
|
||||
`GET /server` answers `204`, not `200` with a null, when the game has never connected. "We have
|
||||
never heard from this server" and "this server reports nothing" are different answers, and a client
|
||||
that cannot tell them apart renders a server that does not exist. `module-rust` maps the two onto
|
||||
distinct stored states (`reachable` without `online`, versus neither).
|
||||
|
||||
### 4.3 Status codes carry the diagnosis
|
||||
|
||||
A wrong URL, a wrong token and a mismatched protocol all present to an operator as "the site says my
|
||||
server is offline", and each has a different fix. The codes keep them apart:
|
||||
|
||||
| Code | Means | Where the fix is |
|
||||
|---|---|---|
|
||||
| `409` | protocol mismatch, both numbers in the body | upgrade one component |
|
||||
| `401` | wrong or missing token | the admin form |
|
||||
| `503` | no plugin connected | the game server |
|
||||
| `504` | the plugin did not reply in time | the game server, differently |
|
||||
| *(transport error)* | nothing is listening | the sidecar, or the URL |
|
||||
|
||||
### 4.4 The RPC timeout is a ceiling on every later command budget
|
||||
|
||||
The sidecar waits **10 seconds** for a correlated reply (`rpc::REPLY_TIMEOUT`). `module-rust`'s own
|
||||
client waits **12 seconds** (`TIMEOUT_MS`).
|
||||
|
||||
Core's event dispatcher classifies a `budgetMs` overrun as retryable **unconditionally** — it cannot
|
||||
ask the action, which is still awaiting a socket. So an action whose `budgetMs` does not exceed the
|
||||
module's client timeout can never report `retry: false`, and that code is unreachable. The ordering
|
||||
is:
|
||||
|
||||
```
|
||||
sidecar RPC timeout (10s) < module client timeout (12s) < an action's budgetMs
|
||||
```
|
||||
|
||||
Derive one from another rather than writing all three down independently.
|
||||
|
||||
---
|
||||
|
||||
## 5. What the plugin owes the game
|
||||
|
||||
Three rules, and each has a failure behind it. They are the ServUO bridge's, unchanged.
|
||||
|
||||
1. **`Emit` is called from the main thread. It formats nothing, blocks on nothing, and touches no
|
||||
socket.** It enqueues and returns. A slow, wedged, or absent sidecar cannot stall the game.
|
||||
2. **One link thread owns the socket.** A single writer keeps event ordering intact. It reconnects
|
||||
with bounded backoff, and the backoff waits on a handle rather than sleeping — an uninterruptible
|
||||
sleep there is a stall of up to the backoff on every plugin reload, on the main thread.
|
||||
3. **A reader thread parses inbound lines and marshals each to the main thread** via
|
||||
`Interface.Oxide.NextTick`. The reader touches no Unity object, no `BasePlayer` and no `ConVar`.
|
||||
|
||||
The outbound queue is **bounded, drop-oldest**: on overflow the oldest record goes and is counted,
|
||||
because telemetry is worth less than the server's memory.
|
||||
|
||||
### 5.1 Diagnosing the link
|
||||
|
||||
```
|
||||
rg.link
|
||||
```
|
||||
|
||||
from the game server's console or over RCON:
|
||||
|
||||
```
|
||||
protocol=1 serverId=main connected=True depth=0 sent=3 dropped=0 received=2
|
||||
connects=1 writeErrors=0 bootId=boot-20260915T194502Z
|
||||
```
|
||||
|
||||
This separates "the plugin is not loaded", "the plugin cannot reach the sidecar" and "the website
|
||||
cannot reach the sidecar", which look identical from the site.
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration
|
||||
|
||||
### 6.1 The plugin — `oxide/config/RunicGateway.json`
|
||||
|
||||
Written by Oxide on first load; edited like any other plugin's config.
|
||||
|
||||
```json
|
||||
{
|
||||
"Host": "127.0.0.1",
|
||||
"Port": 7799,
|
||||
"QueueCap": 5000,
|
||||
"ServerId": "main"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 The sidecar — `sidecar.toml`
|
||||
|
||||
Resolved as `--config <PATH>`, else `$RUSTLINK_CONFIG`, else `./sidecar.toml`. Environment variables
|
||||
override the file.
|
||||
|
||||
| Key | Env | Default |
|
||||
|---|---|---|
|
||||
| `[game].bind` | `RUSTLINK_GAME_BIND` | `127.0.0.1:7799` |
|
||||
| `[game].server_id` | `RUSTLINK_SERVER_ID` | *(empty)* |
|
||||
| `[web].bind` | `RUSTLINK_WEB_BIND` | `127.0.0.1:8090` |
|
||||
| `[web].auth_token` | `RUSTLINK_WEB_TOKEN` | *(generated on first start)* |
|
||||
| `[store].path` | `RUSTLINK_DB_PATH` | `rust-link.db` |
|
||||
| `[store].retain_days` | `RUSTLINK_RETAIN_DAYS` | `14` |
|
||||
|
||||
Two things about those are load-bearing:
|
||||
|
||||
- **A relative `[store].path` resolves against the directory holding `sidecar.toml`**, not the
|
||||
working directory. A service manager's working directory must not decide where the database lands
|
||||
— on Windows that can be `%SystemRoot%\System32`, or a silently redirected VirtualStore copy.
|
||||
- **`[game].server_id` is a cross-check, not a second source of truth.** The plugin announces its own
|
||||
`serverId` and that is the authority; when both are set and they disagree, the sidecar logs the
|
||||
disagreement loudly and keeps the plugin's. Two game servers pointed at one sidecar by a copied
|
||||
config is the mistake this catches, and it is silent in every other design.
|
||||
|
||||
`rust-link-sidecar --print-config` resolves the configuration exactly as a normal start would —
|
||||
writing the file and generating the token if they are missing — and prints it as JSON on stdout,
|
||||
**including the token in clear text**. That is the supported way for an installer to read it back.
|
||||
|
||||
---
|
||||
|
||||
## 7. What is deliberately not here yet
|
||||
|
||||
Protocol 2 is the transport plus the read path. Every one of these arrives with the phase that needs
|
||||
it, and each is a version bump:
|
||||
|
||||
- identity and the in-game link code (phase 6)
|
||||
- the permission mirror (phase 7), and plugin configuration edited from the site (phase 7b)
|
||||
- clans, for core's Team provider (phase 9)
|
||||
- leases, budgets and the event actions (phases 12-13)
|
||||
- the map image over the asset-bridge shape (phase 14)
|
||||
|
||||
The rule that governs all of them: **the sidecar is a dumb forwarder.** It defines no schema for a
|
||||
frame's contents, so a version that adds fields to an event needs no change there — only one that
|
||||
adds a new *indexed* column does. §8.1 is what turns that from an intention into a property of the
|
||||
code.
|
||||
|
||||
---
|
||||
|
||||
## 8. Protocol 2 — the read path
|
||||
|
||||
Protocol 1 proved a line could travel. Protocol 2 is what travels: presence, deaths, chat, gathering,
|
||||
moderation and the wipe, on both mod frameworks from one plugin file.
|
||||
|
||||
It is the first version with a *catalogue*, and a catalogue is the thing that grows fastest. So the
|
||||
shape below is chosen to make growth free everywhere except in the one place that must stay
|
||||
deliberate — what the public is allowed to see.
|
||||
|
||||
### 8.1 Every frame says what it **is**, not only what it is about
|
||||
|
||||
Protocol 1 routed on `kind`, in a `match` the sidecar had to learn a new arm for on every addition.
|
||||
Protocol 2 adds **`type`**, and the sidecar files by `type` alone:
|
||||
|
||||
| `type` | Persisted | Broadcast on `/ws` | Routed by `reqId` | Example |
|
||||
|---|---|---|---|---|
|
||||
| `event` | appended to the history | yes | no | `player.death` |
|
||||
| `snapshot` | **replaces** the board of that `kind` | yes | no | `players.online` |
|
||||
| `reply` | no | no | **yes** | `server.status` |
|
||||
| `control` | no | no | no | `pong` |
|
||||
|
||||
**This is the dumb-forwarder property made structural.** A protocol version that adds ten event
|
||||
kinds needs no change in the sidecar at all, because the sidecar never learns a kind — it learns
|
||||
four verbs, and they are the complete set of things that can be done with a frame. Only a version
|
||||
that adds a new *indexed column* touches it.
|
||||
|
||||
Every outbound frame therefore carries five fields before anything specific to it:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "player.death",
|
||||
"type": "event",
|
||||
"t": 1789510452152,
|
||||
"serverId": "main",
|
||||
"wipeId": "w-20260915T195817Z"
|
||||
}
|
||||
```
|
||||
|
||||
- **`type` is required.** A frame without one is **dropped and counted**, and the sidecar says so
|
||||
once per connection. It is not defaulted to `event`: guessing files a board as history, which is
|
||||
invisible until somebody wonders why the presence board has four thousand rows. The game link has
|
||||
no version handshake (§2), so this is the place a mismatched pair fails loudly instead of quietly.
|
||||
- **`serverId` is on every frame**, not only in the server body (R8). A frame is stored beside
|
||||
frames from five other servers and has to be able to say which one it came from on its own.
|
||||
- **`wipeId` is on every frame** — see §8.2.
|
||||
|
||||
### 8.2 `wipeId` is derived by the **plugin**, and this amends §3.2
|
||||
|
||||
§3.2 called `saveCreatedAt` *"raw material for a wipe id, not a wipe id — deriving one is the
|
||||
website's job"*. That is reversed here, deliberately, and the reason is that by protocol 2 there are
|
||||
**three** components storing rows that need it:
|
||||
|
||||
```
|
||||
w-yyyyMMddTHHmmssZ e.g. w-20260915T195817Z
|
||||
```
|
||||
|
||||
It is `SaveRestore.SaveCreatedTime` in UTC, to the second — the same instant `saveCreatedAt` already
|
||||
reports, in the id-shaped spelling `bootId` uses. The plugin stamps it because the plugin is the only
|
||||
component that can *read* it; every other component would be re-deriving a value it was already told,
|
||||
and two derivations of one fact eventually disagree about a boundary.
|
||||
|
||||
Three consequences worth stating rather than discovering:
|
||||
|
||||
- **A server that has never saved has no wipe**, so `wipeId` is **absent**, never `""` and never
|
||||
`w-unknown`. Absent is a fact; an empty string is a row that will sort beside every other empty
|
||||
string forever.
|
||||
- **The id changes on `OnNewSave` and at no other time.** It is not the boot id: a restart re-reads
|
||||
the same save and reports the same wipe, which is exactly what R12 needs to keep a player's
|
||||
history across a restart while splitting it across a wipe.
|
||||
- **A wipe boundary is a fact about the world, not about the bridge.** The plugin re-reads the value
|
||||
on `OnNewSave` and caches it otherwise; nothing about a reconnect can change it.
|
||||
|
||||
### 8.3 Boards — current state, one producer, re-sent on connect
|
||||
|
||||
A board is chapter 4's word: *current state with exactly one producer, re-sent on every connect*.
|
||||
Protocol 2 defines two.
|
||||
|
||||
| Board (`kind`) | Holds |
|
||||
|---|---|
|
||||
| `server.hello` | the server's own description — §3.2's body, now `type: "snapshot"` |
|
||||
| `players.online` | who is connected right now: `steamId`, `name`, `connectedAt`, `sleeping` |
|
||||
|
||||
**Boards are re-emitted on connect and on a 60-second cadence thereafter.** The events carry the
|
||||
story — `player.connected`, `player.disconnected` — and the board is the **reconciliation point**. A
|
||||
missed event is corrected within a minute rather than persisting until the next restart, and the
|
||||
acceptance criterion *"a restarted sidecar is fully populated within one connection"* is met by
|
||||
construction rather than by hoping no event was in flight.
|
||||
|
||||
The cadence is cheap on purpose: a full board for a 100-slot server is a few kilobytes, and a server
|
||||
with nobody on it emits an empty array, which is a different answer from having said nothing.
|
||||
|
||||
### 8.4 The catalogue
|
||||
|
||||
Every kind protocol 2 defines, and the hook behind it. **`class` is not a field on the wire** — see
|
||||
§8.5 — it is what this table binds the module's allowlist to.
|
||||
|
||||
| `kind` | Hook | `class` | Carries |
|
||||
|---|---|---|---|
|
||||
| `player.connected` | `OnPlayerConnected` | public | steamId, name |
|
||||
| `player.disconnected` | `OnPlayerDisconnected` | public | steamId, name, reason, sessionSec |
|
||||
| `player.respawned` | `OnPlayerRespawned` | public | steamId |
|
||||
| `player.death` | `OnPlayerDeath` | public | victim, attacker, attackerType, weapon, distance, grid |
|
||||
| `player.chat` | `OnPlayerChat` | public | steamId, name, channel, message |
|
||||
| `player.tally` | *aggregate* — see §8.6 | public | steamId, gathered{}, npcKills, structures |
|
||||
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
|
||||
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
|
||||
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
|
||||
| `player.login.attempt` | `CanUserLogin` *(observed, never answered)* | **staff** | id, name, **ip** |
|
||||
| `player.approved` | `OnUserApproved` | **staff** | id, name, **ip** |
|
||||
| `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced |
|
||||
| `server.initialized` | `OnServerInitialized` | public | — |
|
||||
| `server.shutdown` | `OnServerShutdown` | public | — |
|
||||
|
||||
`grid` is the Rust map reference (`H7`), not a coordinate. A death's grid is where a fight happened
|
||||
and every community site shows it; a **structure's** grid is where somebody lives, which is why
|
||||
`entity.destroyed` is staff-class here and why R9 makes the same distinction for map layers.
|
||||
|
||||
**Three hooks are deliberately not in this wave, and none of them is an oversight:** `OnEntityTakeDamage`
|
||||
and `OnFrame`/`OnTick` fire at a rate that makes a bridge a performance regression, and nothing in
|
||||
phases 3–19 needs per-hit or per-frame fidelity. R17's warning about chatty zone transitions is the
|
||||
same rule: **subscribe selectively; the cost of a hook is paid on the game's main thread.**
|
||||
|
||||
### 8.5 The class is enforced by the **module**, not declared on the wire
|
||||
|
||||
The wire carries no visibility field, and this is a security decision rather than an economy.
|
||||
|
||||
**A boundary must be enforced by the side that serves, never declared by the side that sends.** The
|
||||
website's own shard fan-out works this way — a public SSE stream with an allowlist of event kinds,
|
||||
and an admin stream that adds the rest — and the property that makes it trustworthy is that a
|
||||
compromised or merely out-of-date sender cannot widen it. A `"class":"public"` field on the frame
|
||||
would move the decision to the game host.
|
||||
|
||||
So: the table in §8.4 is the specification, `module-rust` holds the allowlist, and it is
|
||||
**default-deny** — a kind the allowlist has never heard of is not public. The module's own test holds
|
||||
its allowlist against this document, so adding a kind here without classifying it there fails a
|
||||
build rather than shipping an IP address to a public page.
|
||||
|
||||
`player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and
|
||||
`player.reported` carries the text of one player's complaint about another. They are stored because
|
||||
an operator chasing ban evasion needs them and because the sidecar persists what it is told; they
|
||||
reach no tier below admin, and the raw window that holds them is bounded (§8.9).
|
||||
|
||||
### 8.6 Two things are aggregated in the plugin, and that is the interesting part of this phase
|
||||
|
||||
`OnDispenserGather` fires on **every swing at a tree**. A single player chopping for a minute is
|
||||
hundreds of hooks; ten players gathering is a frame rate problem in the bridge rather than in the
|
||||
game. The same is true of animal and scientist kills, at a lower rate.
|
||||
|
||||
Neither is interesting per occurrence — nobody wants a killfeed of chickens — and both are wanted
|
||||
*in total*, for the leaderboard. So the plugin keeps a per-player tally on the main thread and flushes
|
||||
it as one `player.tally` frame:
|
||||
|
||||
- on a **60-second cadence**, for players with a non-zero tally;
|
||||
- on **disconnect**, so a session's last minute is not lost;
|
||||
- at `OnServerShutdown`, which is the flush that covers a restart.
|
||||
|
||||
A plugin *reload* is the one case that loses a tally, by choice: `Unload` runs on the game's main
|
||||
thread, and draining the outbound queue there means waiting on a socket from the main thread — the
|
||||
stall phase 1 removed. Under a minute of one player's gathering is the price, and a wedged peer
|
||||
would make the cure worse than the disease.
|
||||
|
||||
A tally frame is a **delta, not a running total** — it reports what happened since the last flush,
|
||||
so the consumer sums rather than diffs and a missed frame costs that interval instead of corrupting
|
||||
the series.
|
||||
|
||||
This is the general rule for every later wave: **if a hook can fire more than once a second per
|
||||
player, it is a counter, not an event.**
|
||||
|
||||
### 8.7 The read path never vetoes, and it is structural rather than disciplined
|
||||
|
||||
Four hooks in §8.4 are documented by uMod as *"returning a non-null value overrides default
|
||||
behavior"* — `OnPlayerDeath`, `OnDispenserGather` and `CanUserLogin` among them. A read-path bridge
|
||||
that returned something by accident would cancel a death, swallow a player's wood, or refuse a
|
||||
login, and it would do it on a production server at 3am.
|
||||
|
||||
**So every vetoable hook in the read path is declared `void`.** Both frameworks bind hooks by name
|
||||
and arity and take the method's return value; a `void` method returns nothing and therefore cannot
|
||||
override anything. The rule is enforced by the signature rather than by remembering to write
|
||||
`return null`, which is the only version of this rule that survives a year of edits.
|
||||
|
||||
`CanUserLogin` is in the wave for what it *observes*, never for what it answers.
|
||||
|
||||
### 8.8 A login denial is not a hook — and §10 of `PLAN.md` says it is
|
||||
|
||||
`PLAN.md` §10 sources the `rust.login.denied` trigger from `CanUserLogin`. Reading the hook says that
|
||||
cannot work: `CanUserLogin` is called on **every** connection attempt, and the only way to learn of a
|
||||
denial from it is to *be* the denier, which §8.7 forbids. uMod publishes no `OnUserRejected`.
|
||||
|
||||
What the game can actually tell us is two facts — an attempt, and an approval — so protocol 2 emits
|
||||
both and **a denial is the absence of an approval** for an attempt, decided by a deferred read rather
|
||||
than by a hook. Phase 10 owns that pairing; protocol 2 owes it the two frames and the `t` on each.
|
||||
|
||||
Recorded here because it is a correction to a catalogue, not a defect: the trigger survives, its
|
||||
source changes.
|
||||
|
||||
### 8.9 History, cursors and retention
|
||||
|
||||
Three changes on the sidecar's own side follow from a catalogue that actually produces volume.
|
||||
|
||||
**`events` gains `server_id` and `wipe_id` as indexed columns.** This is the one migration shape the
|
||||
store's own header predicted: *"only a version that adds a new indexed column ever needs a
|
||||
migration"*. It is applied as an `ALTER` guarded by a column check, never as an edit to the `CREATE`
|
||||
— the same rule the website's schema fragments live under, for the same reason.
|
||||
|
||||
**A new route, `GET /feed?since=&limit=`, is the ingest cursor**, and it is deliberately *not*
|
||||
`/events` with a flag:
|
||||
|
||||
| Route | Order | For |
|
||||
|---|---|---|
|
||||
| `GET /events?kind=&wipe=&limit=` | newest first | a human, an admin screen, a point-in-time look |
|
||||
| `GET /feed?since=&limit=` | **oldest first**, from a cursor | a consumer that must not miss a row |
|
||||
|
||||
One route with two orderings depending on a query parameter is a trap: every caller that forgets the
|
||||
parameter gets the other one silently, and for the ingesting caller that means it advances its cursor
|
||||
past rows it never read. Two routes, one ordering each.
|
||||
|
||||
`/feed` items are wrapped rather than bare, because a cursor needs the row's identity:
|
||||
|
||||
```json
|
||||
{ "items": [ { "id": 1041, "t": 1789…, "kind": "player.death", "frame": { … } } ],
|
||||
"lastId": 1041, "more": false }
|
||||
```
|
||||
|
||||
`more` is `true` when the page filled, so a consumer that has fallen an hour behind drains at its own
|
||||
pace instead of guessing from a count.
|
||||
|
||||
**Omitting `since` asks where the end is** — no rows, and the current `lastId`. `since=0` is the
|
||||
other question entirely: replay everything retained. That is deliberate, because the two intentions
|
||||
must not be separated by whether somebody typed a parameter: a module installed today against a
|
||||
month-old sidecar wants what happens next, not a fortnight of deaths it has no rollups for.
|
||||
|
||||
**The store prunes.** `[store].retain_days` (default 14) bounds the event history, swept hourly.
|
||||
Three things make that safe rather than lossy: the website holds the permanent per-wipe rollups
|
||||
(R12), boards are never pruned because they hold exactly one row per kind, and the sidecar's database
|
||||
lives inside a game container whose disk is the operator's (R20). A store that grows without bound on
|
||||
a game host is a wipe-day outage waiting for a busy month.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user