diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index f3937c2..73537c2 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -93,6 +93,16 @@ Three properties fall out, and they are the reason this shape is worth its cost: drift, and drift is surfaced to an operator — the same posture a lease's `restore()` takes when it finds a value a human has moved ([kit][kit] ch. 5). +**Phase 0 exercised all three against the real store and they hold — but it found four rules the +push path has to obey (§12.2).** The one that would have cost the most: +**`permission.GrantUserPermission` silently no-ops when the permission is not registered.** It +returns `void`, throws nothing and logs nothing; the grant simply does not happen. A permission is +registered by the plugin that declares it, so **every grant naming an unloaded, renamed or +uninstalled plugin's permission disappears without a trace** — and since R2's whole recovery story is +"the site re-pushes the full set on connect", a re-push into a server missing one plugin is a silent +partial. The push must check `PermissionExists` (or register the name itself) and report the +difference as drift rather than assuming a write landed. + **This is a direction the Integration Kit has no chapter for, and that is a finding.** Chapters 3 and 4 are the read path — data leaving the game. Chapter 5 is one-shot commands with a ledger and a teardown. This is neither: it is *continuously reconciled state where the website is authoritative*, @@ -171,12 +181,30 @@ direction worth stating plainly: for Teams, the plugin is *worse* than first-par `Clans` v0.2.10 (k1lly0u, MIT, 2,692 lines) publishes **fifteen `[HookMethod]`s and every one of them is a mutation** — `CreateClan`, `JoinClan`, `LeaveClan`, `KickPlayer`, `PromotePlayer`, `DemotePlayer`, `DisbandClan`, and the eight alliance verbs. **There is no read API whatsoever**: no -`GetClan`, no `GetClanOf`, no `GetClanMembers`, no `GetAllClans`. And it raises exactly **three** -hooks — `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — **none of which is a membership -transition**. +`GetClan`, no `GetClanOf`, no `GetClanMembers`, no `GetAllClans`. -So it cannot answer any of core's three provider questions from its published surface, while -first-party clans answer all three. Feeding the Team provider from first-party clans is therefore +> **Corrected in phase 0 (§12.3).** This paragraph continued *"it raises exactly **three** hooks — +> `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — none of which is a membership transition"*, and +> concluded that the plugin cannot answer core's provider questions at all. **That was a grep +> artefact and it is wrong.** `Clans` 0.2.10 raises **nine** hooks. The missing six are invisible to +> a search for `CallHook("OnClan…` because the name is a `const` at the call site: +> +> ```csharp +> const string HOOK_NAME = "OnClanMemberJoined"; +> Interface.CallHook(HOOK_NAME, tag, ulong.Parse(joining), RustMemberList); +> ``` +> +> They are `OnClanMemberJoined(tag, joining, members)`, `OnClanMemberGone(tag, leaving, members)`, +> `OnClanDisbanded(tag, members)`, `OnClanAllianceCreated`, `OnClanAllianceDissolved`, and +> `OnClanUpdate(tag)`. The first three **carry the full member list**, so the plugin *can* answer +> "who is in this clan" without any read API, and `OnClanUpdate` fires on promote and demote — the +> exact transitions first-party lacks. + +**The decision does not change, but its reason does.** First-party remains the Team provider's source +because it is the system the *game* maintains and every server has it; the plugin is an optional +install that not every shard will run, and feeding a provider from something optional makes Teams +conditional on a mod. It is no longer true that the plugin *cannot* answer the questions — only that +it should not be the one asked. Feeding the Team provider from first-party clans is still **permanent, not a first step**. What the plugin genuinely adds is **alliances and clan/alliance chat** — richer in *features*, not in @@ -263,6 +291,12 @@ kicked and left — but **no promote or leader-changed event**. Core's provider transitions. That makes phase 9 *partly* snapshot-driven where the dry run predicted it would be fully event-driven — a small correction to that document, recorded here rather than silently. +Phase 0 verified the seven against `agent/hooks.tsv` and the gap is real. It also found that the +**Clans plugin closes it** — `OnClanUpdate(tag)` fires on both promote and demote (§12.3). That does +not move the provider off first-party, but it does mean phase 17's adapter can offer *event-driven +leadership* on servers that run the plugin, over a snapshot baseline on servers that do not. Design +phase 9's snapshot so phase 17 can sharpen it rather than replace it. + ### R7 — the notifications and engagement set ships in v1 **Decided 2026-09-15 (org lead).** `registerNotificationStreams`, `registerEventTriggers`, @@ -519,7 +553,7 @@ someone goes looking for one that is not there. | **`GetPlayerZoneIDsNoAlloc(player, List)`** | the allocation-free variant — **the one to use on any sweep** | | `CreateOrUpdateZone(zoneId, args, position)` | make a zone | | **`CreateOrUpdateTemporaryZone(..., Plugin owner)`** | make a zone *owned by our plugin* | -| **`EraseTemporaryZone(Plugin owner, zoneId)`** | remove one, **scoped to the owner** | +| **`EraseTemporaryZone(Plugin owner, zoneId)`** | remove one; owner-scoped **only against another plugin's zone**, not against an unowned one (§12.4) | | `GetZoneIDs` / `GetZoneName` / `GetZoneLocation` / `CheckZoneID` | the catalogue, for an option source | And nine hooks raised: `OnEnterZone` / `OnExitZone`, `OnEntityEnterZone` / `OnEntityExitZone`, @@ -543,10 +577,30 @@ expensive and less accurate. **`rust.zone.open` moves from the optional tier into the base catalogue, and it can be `reversible: 'ledger'` honestly.** This is the nicer half: `CreateOrUpdateTemporaryZone` takes a **`Plugin owner`** and `EraseTemporaryZone` is **scoped to that owner**, so ZoneManager already has a -first-class notion of a zone belonging to the plugin that made it. That is most of the persisted -ownership registry [kit][kit] ch. 4 demands — we still keep our own map from core's resource -reference to the zone id, but we are not inventing ownership, we are borrowing a concept the plugin -already has. And erasing a zone that is gone is a success, which is what `revert` needs. +first-class notion of a zone belonging to the plugin that made it. And erasing a zone that is gone is +a success, which is what `revert` needs. + +> **Narrowed in phase 0 (§12.4), and this one is a safety correction.** The scoping is real but it is +> *one-directional*: it stops us erasing a zone owned by **another plugin**, and does nothing at all +> for a zone owned by **nobody**. +> +> ```csharp +> // Only compare zone owner if the owner param is provided so users can remove temporary zones +> // without needing to unload the plugin that created them +> if (owner && zoneOwner && owner != zoneOwner) +> return false; +> ``` +> +> `zoneOwner` is null for every *permanent* zone — which is every zone an operator made by hand. So +> `EraseTemporaryZone(us, "")` **deletes it and returns `true`**, indistinguishable +> from erasing our own. Observed live: a zone created with `CreateOrUpdateZone` and no owner was +> erased by an `EraseTemporaryZone` call from an unrelated plugin. +> +> So this is **less** of ch. 4's persisted ownership registry than the paragraph above claims. We +> still keep our own map from core's resource reference to the zone id, and that map is now +> **load-bearing rather than convenient**: phase 12 must refuse to erase any zone id it did not +> record creating. ZoneManager will not refuse on our behalf, and the `true` it returns is not +> evidence the zone was ours. **One trap to design against, and it is chapter 4's rule meeting a chatty hook.** `OnEnterZone` and `OnExitZone` fire on the game thread and a large zone with a busy server produces a great many of @@ -635,22 +689,42 @@ online. The persisted pending-grant queue that question was weighing is not need ## 4. The test rig -`D:\rust` on the org lead's workstation. It has been booted, it has a generated world -(procedural, seed 1234, size 4000, save v287) and Oxide **2.0.7585** matched to its build, and its -Oxide permission store already holds a `default` and an `admin` group with one admin user — which -means R2's mechanism can be exercised on day one. +`D:\rust` on the org lead's workstation. **Brought current in phase 0 (see §12):** build +**25230300**, Oxide **2.0.7716**, a fresh procedural world (seed 1234, size 4000) generated for this +wipe, and all four base plugins loaded. Its Oxide permission store holds a `default` and an `admin` +group with one admin user, so R2's mechanism was exercised on day one and works. -Two traps recorded here because both cost time before they were understood: +A wipe keeps `server/server1/cfg/`. That directory holds `users.cfg`, and `users.cfg` holds the +`ownerid` line — delete the whole identity directory and you silently remove the operator's own +ownership along with the map. -- **`D:\rust\start.bat` updated the wrong directory — fixed 2026-09-15.** It ran - `steamcmd +force_install_dir c:\rustserver\ +app_update 258550` and then launched - `D:\rust\RustDedicated.exe`. The server that boots had never been updated by its own script, which - is why a second, never-booted install exists at `C:\rustserver` and why `D:\rust` is a wipe behind. - Now reads `+force_install_dir d:\rust\`; the original is kept at `D:\rust\start.bat.bak`. - `D:\rust\steamapps\appmanifest_258550.acf` was already present at the same buildid, so the first - corrected run is a delta to the current wipe rather than a 5.9 GB re-download. +Three traps recorded here because each cost time before it was understood: + +- **`start.bat` never updated anything — the 2026-09-15 diagnosis was wrong, corrected in phase 0.** + The script was read as *"updates `C:\rustserver`, runs `D:\rust`"*, and the fix changed the path to + `d:\rust\`. The path was never the problem. **steamcmd requires `+force_install_dir` before + `+login`**, and the script had it after: + + ``` + steamcmd.exe +login anonymous +force_install_dir d:\rust\ +app_update 258550 +quit + → Please use force_install_dir before logon! + → Error! App '258550' state is 0x486 after update job. + ``` + + So the flag was discarded, the update ran against steamcmd's own directory, and the job errored out + every single time. It updated **no** directory, ever — which is the actual reason `D:\rust` fell a + wipe behind, and why `C:\rustserver` sits at the *same* stale buildid rather than a newer one. + Now reads `+force_install_dir d:\rust\ +login anonymous +app_update 258550 +quit`; the pristine + original is kept at `start.bat.bak` and the path-only fix at `start.bat.broken-order-20260915`. + With the ordering right, the run is a delta and takes minutes. + +- **`app_update` silently un-Oxides the server.** Oxide ships a *patched* `Assembly-CSharp.dll`, and + a Steam update restores Facepunch's. It does not remove `Oxide.Core.dll` and friends, so the + install still *looks* Oxided — it simply loads no plugins and raises no hook. **Re-extract + `Oxide.Rust.zip` over the tree after every `app_update`, and check the size**: on build 25230300 + vanilla is 9,758,544 bytes and Oxide 2.0.7716's is 9,953,280. - **`C:\oxide_files` is a 2025-04-23 Oxide and must not be copied anywhere.** Oxide ships a patched - `Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,780,224, so copying it + `Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,953,280, so copying it over a real install is a hard downgrade. `D:\rust` is already correct and needs nothing from it. **Rust force-wipes on the first Thursday of the month and Oxide is rebuilt to match**, so "is the @@ -673,7 +747,7 @@ Each phase ends with its findings written down, as every workstream here does. | # | Phase | Repos | Done when | |---|---|---|---| -| 0 | **The rig.** Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set — Kits, Clans, PopupNotifications, ZoneManager (R6, R17) — prove a console grant reaches a plugin | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it | +| 0 | **The rig.** ✅ **Done 2026-09-15 — as built and findings in §12.** Updated to the current wipe (the script was fixed *again*, properly), Oxide re-laid, base set installed, the grant path proven end to end. One acceptance criterion is **open**: zone occupancy needs a connected player and cannot be closed headlessly (§12.5) | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it | | 1 | **Protocol 1, three skeletons, and every bundle seam at once.** Plugin: bounded drop-oldest queue, one writer thread, tagged reconnect epoch, dial-out. Sidecar: listener, SQLite, always-on token auth, version header, rpc correlation. Module: `id: rust`, `/rust` on all three tiers (R14), `schema.sql` **and `purge.sql`**, the **`extensions`** declaration (§11.3), per-server sidecar tokens through **`ctx.secretBox`** (§11.4), the vite aliases and shims, `checkExternals`, `checkImports`, the swagger fragment and its staleness check, explicit `onBoot`/`onShutdown`, `capabilities` | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton | | 2 | **Packaging and release.** `release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` | | 3 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, and a wipe does not erase a player's history | @@ -925,7 +999,7 @@ cleanly, reads back cleanly, and does nothing at all, and neither core nor revie | `rust.kit.entitle` | `change` | `ledger` | R16 — grants the kit's `RequiredPermission`; revert revokes | | `rust.prefab.place` | `change` | `ledger` | §H's verb; revert kills the entity, and needs the persisted ownership registry ch. 4 describes | | `rust.announce` | `notify` | `none` | via PopupNotifications (R6) — global or targeted | -| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17** — `CreateOrUpdateTemporaryZone` takes a `Plugin owner` and `EraseTemporaryZone` is scoped to it, so the undo is real | +| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17** — `CreateOrUpdateTemporaryZone` takes a `Plugin owner`, so the undo is real. Our own id map decides what may be erased, not ZoneManager's owner check (§12.4) | **Rewards are not a contract member.** `EVENTS.md` deleted a `registerEventRewards` registry because it carried four Ultima Online nouns inside a core signature. A reward here is an ordinary action — @@ -1096,4 +1170,146 @@ So R10's capability probe already has the behaviour the app wants: a Rust module makes the app render a site *without* those screens, rather than one advertising screens that `503`. The app needs no failure handling for this case because core does not expose the failure. +## 12. Phase 0 as built — the rig, 2026-09-15 + +The rig is current and the base set runs. Five things were learned that the plan had either wrong or +had never asked, and three of them change work in later phases. + +### 12.0 What the rig is now + +| | Before | After | +|---|---|---| +| Server build | `24613624` (2026-08-13) | **`25230300`** (2026-09-10) | +| Oxide | `2.0.7585` | **`2.0.7716`** (`OxideMod/Oxide.Rust`, 2026-09-11) | +| World | seed 1234 save v287, previous wipe | regenerated for this wipe; `cfg/` preserved | +| `oxide/plugins/` | empty | Kits 4.4.9 · Clans 0.2.10 · Popup Notifications 0.2.1 · Zone Manager 3.1.14 | + +All four compiled and loaded first time on the new build, at exactly the versions R6 and R17 name — +pulled fresh from `https://umod.org/plugins/.cs`, which still serves those versions and needs +no Cloudflare workaround. Server protocol `2633.288.1`. + +The Oxide permission store survived the update untouched (`oxide/` is not a Steam depot directory), +so `76561198038695917` is still in `default` and `admin`. + +Two instruments were built and are kept in the phase-0 scratchpad rather than committed: a +dependency-free **WebSocket RCON driver** (Node's global `WebSocket`, no `ws` package), and +**`RGProbe.cs`**, a throwaway Oxide plugin that exposes Oxide's permission API and ZoneManager's +by-name API as console commands. The probe is what made §12.2 and §12.4 observable; phase 1's plugin +skeleton can start from it. + +> **One thing the RCON driver had to learn.** Oxide tags its own `Puts()` output and its warnings +> with the **identifier of the command being run**, so a first-match-wins client reads a plugin's log +> line as if it were the reply and discards the real one. It cost two wrong readings before it was +> spotted. Collect every frame in a window; do not correlate one reply per identifier. + +### 12.1 The rig's own scripts were broken in a way the earlier diagnosis missed + +Recorded in §4. In short: `start.bat` put `+force_install_dir` **after** `+login`, steamcmd discarded +it, and every update run in the rig's history errored out without updating anything. The 2026-09-15 +"fix" changed the path and left the order, so it fixed nothing. Separately, `app_update` reverts +Oxide's patched `Assembly-CSharp.dll` while leaving Oxide's own assemblies in place, which produces a +server that looks Oxided and loads no plugins. + +Both matter beyond this workstation: **phase 18's installer will hit the second one.** An +operator who updates their Rust server has silently uninstalled Oxide, and therefore our plugin. +`doctor` should check the `Assembly-CSharp.dll` against the Oxide build, not merely that `oxide/` +exists. + +### 12.2 Four rules the R2 permission push must obey + +Verified live against the real store, granting and revoking through both the console command and the +API: + +1. **`permission.GrantUserPermission` silently no-ops for an unregistered permission.** `void`, no + throw, no log. The console `oxide.grant` at least answers `Permission 'x' doesn't exist`; the API + path R2 uses says nothing at all. This is the finding with teeth — see R2. +2. **A permission exists only because a loaded plugin registered it.** Kits registers `kits.admin` + and, dynamically, **every kit's `RequiredPermission`** (`Kits.cs:1225`, `:2895`) — which is what + makes R16's entitlement model real. Unload Kits and those names stop existing. +3. **`RegisterPermission` warns about a foreign prefix but registers anyway.** + `Missing plugin name prefix 'rgprobe' for permission 'someplugin.vip'` is a warning, not a + refusal — the permission was created and granted successfully. So the site *can* make a grant + stick for a plugin that is not currently loaded, at the cost of a console warning. Whether it + *should* is a phase 7 decision; the mechanism exists. +4. **A player who has never connected is in no group, but can hold direct grants.** A grant to an + unseen SteamID64 works and reads back immediately. Group membership does not exist for them yet, + so **anything the site expresses as group membership does not reach a player until their first + connection**, while a direct grant does. R16's offline entitlement is safe; a group-shaped + entitlement is not. + +Point 4 is the one to carry into phase 7's design: grants and groups have **different reach** for +offline players, and the site's model currently treats them as two spellings of the same thing. + +### 12.3 R5's claim about the Clans plugin was a grep artefact + +Corrected in R5. The plugin raises nine hooks, not three, and three of them carry full member lists; +the six that were missed are invisible to a literal search because the hook name is a `const` at the +call site. The decision stands on a different reason — first-party is what every server has, the +plugin is optional — and phase 17 gains event-driven leadership as a sharpening rather than a +replacement. + +Two smaller things from the same read, both worth having before phase 9 and 17: + +- **`Clans` raises the same hook name twice per transition**, once Rust-typed + (`string, ulong, List`) and once Universal-typed (`string, string, List`), plus two + deprecated arities. Oxide binds by name **and** arity, and both live forms are arity 3 — so a + loosely typed subscriber catches both and double-counts every join and leave. Type the parameters + precisely and pick one. +- **`Clans` calls `API_RegisterThirdPartyTitle` itself.** R15's BetterChat integration will be the + *second* title provider on any server running both, not the first. + +Also confirmed, since the plan rests on it: the first-party set is exactly the **seven** hooks in +`agent/hooks.tsv`, all "no return behavior", with no promote and no leader-changed. + +### 12.4 ZoneManager's owner scoping is narrower than R17 assumed + +Corrected in R17. `EraseTemporaryZone(owner, id)` refuses only when the zone has a *different* +owner; an **unowned** zone — every permanent zone, including every zone an operator made by hand — is +erased by anyone and returns `true`. Phase 12 must gate erasure on its own id map. + +Three more things the source and the live rig agreed on: + +- **ZoneManager's entire API is plain private methods**, no `[HookMethod]` anywhere in 3.1.14 — so + `Call()` by name is the only way in, and a typo is silence. Confirmed working live for + `CreateOrUpdateZone`, `CreateOrUpdateTemporaryZone`, `EraseTemporaryZone`, `GetZoneIDs` and + `GetPlayersInZone`. The three-conventions finding holds: Kits declares `[HookMethod]` (23 of them), + ZoneManager declares nothing, BetterChat will use `API_` prefixes. +- **`GetPlayersInZone` cannot distinguish an unknown zone from an empty one** — both return an empty + list, not null. The participation ledger R17 wants to feed therefore cannot use this call alone to + answer "is this zone still there", and must check `GetZoneIDs` separately. This is the same + absence-of-an-answer / answer-of-absence trap earlier phases of other workstreams hit. +- **NPCs never appear in a zone's player list.** `baseEntity is BasePlayer { IsNpc: false }` routes + them to the zone's *entity* list instead. Useful to know before designing a condition that counts + "players at the monument" on a server with scientists. + +### 12.5 The one criterion phase 0 could not close, and why + +> `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it + +The first half is **done** — proven for both an online-known and a never-seen player, in both +directions, through the same `UserHasPermission` call every third-party plugin makes. + +The second half is **open, and not by choice of method.** A plugin's own permission check reads: + +```csharp +private bool HasPermission(ConsoleSystem.Arg arg, string perm) +{ + BasePlayer player = arg.Player(); + return !player || permission.UserHasPermission(player.UserIDString, perm); +} +``` + +`!player ||` — a command from RCON has no `BasePlayer`, so **the server console is unconditionally +allowed**. This is the standard Oxide idiom, not a ZoneManager quirk, and it means no console session +can ever observe a gate closing. Zone occupancy has the same shape from the other end: NPCs are +excluded by construction, so nothing but a real connected player puts a name in that list. + +Everything up to the body was proven: a zone was created through the API, listed by both the probe +and ZoneManager's own `zone_list`, read for occupancy (a real empty list, so the call resolved), and +erased. What remains is one person, connected, standing inside it. + +**This is a standing property of the rig, not a phase-0 leftover.** Any phase whose acceptance +depends on a permission actually *refusing* somebody, or on presence, needs a client attached. Worth +knowing at phase 7 and phase 12 rather than discovering there. + [kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit