Files
docs/modules/rust/PLAN_REDESIGNS.md
wtclaude 65c57cec36 docs(rust): stage 3 built — TruePVE in the zone step (PROTOCOL §20.6, PLAN_REDESIGNS §14.3.16)
- rust-link/PROTOCOL.md §20.6: hello's integrations.truePve, world.zone's
  truePve (exactly one of exclude and ruleset, all or nothing, the
  refusals), the unmapping on every erase path with truepve.json for
  while TruePVE is away, rg.world's two fields, and the website's two
  params. The §15 command table points at it.
- modules/rust/PLAN_REDESIGNS.md §14.3.16: the stage-3 record, ten points
  settled in code (all overrulable), TruePVE's one-second save, the tests
  and the rig compile. Pointers from §9's summary and §14.3.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 05:47:27 -05:00

241 KiB
Raw Blame History

module-rust — the redesigns, planned

Status: plan, written 2026-09-27. Its eleven questions were answered the same day: D188–D198 (§10). On 2026-09-28 the org lead added the first-class optional plugins (§11, D199–D202), and after the RaidableBases spikes, D203–D208 (§11.4). It is step 3 of PLAN_FIXES.md §6: the six changes the org lead decided during the first player walk, each "planned in detail before code". The org lead asked for all six in one plan (2026-09-27), so they can be read together. Built so far: §1, walked 2026-09-28 (§1.9); §5, built 2026-09-28 with its rig walk still to come (§5.8, D209); §3, built and walked on the rigs and the site 2026-09-29, its in-game rows still to come (§3.4); §4, built 2026-09-29 (§4.1). §6 was spiked on 2026-09-30, and the org lead chose neither route: Runic Gateway writes its own NPC plugin, RunicNPC, planned in ../../runicnpc/PLAN.md (D214–D314), and the rest of this plan waits for it. RunicNPC v1.0.0 shipped on 2026-10-09, and the plan resumed with §2 the same day (D331–D334); §2 was built on 2026-10-12, and §11's Economics spike the same day raised D335–D336 (§11.2.1). Economics was built on 2026-10-12 too (§11.2.4). The Backpacks spike followed and raised D337–D339 (§11.3.1), and Backpacks was built the same day (D340, D341, §11.3.4). The org lead then added permission rewards (§12, D342–D346), planned and built the same day. On 2026-10-14 RaidableBases was deferred until the current paid copy arrives (D347), with both editions to be supported (D348, §11.4.2). The same day the org lead added Rust's own world events to the base module (§13, D349) and six more first-class optional plugins (§14, D350–D352), to be tested together in one phase before their integration plan. Their questions were answered the same day (D353–D359, the end walk now waiting for RaidableBases), and Steam sign-in stays on the website's plan, linked as §15 (D360). The §14.2 test phase ran the same day on both rigs (§14.2.1): the org lead ruled D361–D362 during it and answered its Q19–Q25 the same day (D363–D369). Every item left is built, tested and PR'd first; every walk — theirs and the ones still owed by §3 and §5 — happens together at the end (D331, §9).

This is a companion to PLAN_FIXES.md and PLAN.md. Where they disagree, this document is later and wins. Its decisions continue PLAN_FIXES' numbering at D188.

§ Redesign PLAN_FIXES Where the work lands
1 The permission manager §4.1, D160–D163 Rust-Plugins, Module-Rust
2 The event step editor and the kit weekend §4.2, §4.3, D164, U-3–U-6, F11 core (website main), Module-Rust, Rust-Plugins
3 Zones: ZoneManager's options, and a dome §4.4, D166, D167 Rust-Plugins (bridge and helper), Module-Rust
4 The live map's marker types §4.5, D165 Module-Rust
5 Chat titles: twenty-three conditions §4.6, D172–D175 Rust-Plugins, Module-Rust
6 NPCs — now RunicNPC, its own plan (runicnpc/PLAN.md) §4.7 runicnpc-rust, Rust-Plugins, Rust-Link, Module-Rust, installer
11 First-class optional plugins: Economics, Backpacks, RaidableBases (Kits stays required) — (added 2026-09-28) Rust-Plugins, Module-Rust, installer, Android-app
12 Permissions as event rewards: until the event ends, or for good — (added 2026-10-12) core (website main, MODULE_API 1.14.0), Module-Rust
13 Rust's own world events (helicopter, Bradley, cargo, Chinook, airdrops, locked crates) in the base module — (added 2026-10-14) Rust-Plugins, Rust-Link, Module-Rust
14 Six more optional plugins: TruePVE, Dynamic PVP, Dangerous Treasures, Guarded Crate, ZLevels, Server Rewards — (added 2026-10-14) Rust-Plugins, Rust-Link, Module-Rust, Android-app
15 Steam sign-in: a link to the website's own plan, website/STEAM_SIGNIN.md §4.8, D171 core (website), Android-app, Module-Rust

Protocol. §1, §3, §4 and §5 joined protocol 13, which shipped on 2026-10-09 with RunicNPC v1.0.0 (bundle 2026.10.09). The wire changes still to come, §2's and §11's, open protocol 14 on edge and are released together once, after the end-of-plan walk (D332, §7).

Core. §2 is mostly core's editor, which is game-agnostic and serves Module-uo too. Its contract additions are MODULE_API 1.12.0 → 1.13.0, additions only (§8; 1.12.0 went to Events' progress(), website#210). The same proof website#209 ran for 1.11.0 applies: Module-uo's frozen-manifest job and its suites, run against the new core.


0. What was checked before writing

Each of these was a question PLAN_FIXES left open or a claim this plan depends on. All were checked on 2026-09-27 against the two rigs (rust-oxide, rust-carbon) or the code. The rig checks used a read-only probe plugin that wrote one data file. It was unloaded and deleted afterwards on both rigs.

# Question Answer Where it lands
0.1 Can the bridge learn which plugin registered a permission through public API? Yes, on both frameworks. permission.PermissionExists(name, owner) takes the owning plugin (Oxide: Plugin; Carbon: BaseHookable). Asking it of every loaded plugin named an owner for all 85 permissions on Oxide (RustCore owns oxide.*) and all but 30 on Carbon, in 3.3 ms and 1.8 ms. ZoneManager owns all 70 zonemanager.* names, zonemanager.ignoreflag.nokits included, which uMod PermissionsManager files under Kits. The 30 Carbon names without a plugin owner are adminmodule.*, which belong to Carbon's built-in AdminModule (a Carbon module, not a plugin). §1.2
0.2 Can the bridge list every user in the store? No. Neither framework has a public "all users" call (GetUsers and GetAllUsers do not exist on either). The complete holder list comes from GetPermissionUsers for each permission plus GetUsersInGroup for each group. That took 1.7 ms and 2.2 ms on the rigs' tiny stores, but each call walks every user, so on a real store the cost is permissions × users. §1.2 (sliced)
0.3 Do groups carry anything the site does not model? Parents. GetGroupParent answers on both. The site's rust_perm_groups has no parent column. §1.4
0.4 Is there a framework hook for a finished Rust mission (D173)? Yes: option 1 of D173's spike. Oxide's patched Assembly-CSharp.dll raises OnMissionSucceeded, OnMissionFailed, OnMissionStarted and OnMissionAssigned, and Carbon.Hooks.Oxide.dll patches the same four names onto BaseMission. Rust's own methods are BaseMission.MissionSuccess(MissionInstance, BasePlayer assignee) and MissionFailed(MissionInstance, BasePlayer, MissionFailReason, bool). The catalogues HOOKS.md and CARBON.md predate them. The exact hook arguments are confirmed when a person finishes a mission (§5.7). §5.7
0.5 Is ZoneDomes' API safe with a null player (PLAN_FIXES §4.4, §7)? No. AddNewDome and RemoveExistingDome end with player.ChatMessage(...) and no null check. With a null player the dome is made and saved, and then the call throws. On boot, ZoneDomes also drops every dome whose zone does not exist yet, and the bridge's temporary zones are re-created after that. §3.2
0.6 Can ZoneManager's flag list be read at run time? Only from inside. The list is ZoneManager.ZoneFlags.NameToIndex, a public static on a nested type. No hook or Call method returns it. The zone helper (RunicGatewayZones.cs) already references ZoneManager's types, and the bridge may not (R2, D168). Wrong, found at the build (D211): ZoneManager 3.1.14's API region has ZoneFieldListRaw(), which returns the zone field names followed by every NameToIndex key, and the bridge calls it by name. §3.1, §3.4
0.7 What does the map actually show? 85 markers on the rigs' map (world 3000, seed 1234): 31 substations in four prefab kinds, 7 caves in six, 7 train-tunnel entrances in four, and 5 water wells in three. The label (Substation) groups the variants. Rust also classifies monuments itself (MonumentInfo's MonumentType: Cave, WaterWell, Lake, Mountain, Radtown, Building, Town, Airport, Lighthouse). §4
0.8 Can the site see how much of a kit players have used? One player at a time. kits.list already returns each kit's max (MaximumUses) and cooldown, and the module ignores cooldown. Per-player use is read only during a credit settle (GetPlayerKitUses). §2.6
0.9 Where do the Ultima Online examples a Rust admin sees come from (U-3)? Core's own actions. core.lease gives uo.rate.skillgain as its example, and the announcement actions talk about Britain and Cove. §2.1
0.10 What free NPC plugins exist (§4.7)? HumanNPC (Remod/RFC1920) spawns NPCs with a name and a Kits kit (SpawnHumanNPC(position, rotation, name, clone), SetHumanNPCInfo), but it requires two more plugins, Pathfinding and Waypoints, and its NPCs run its own AI, not Rust's. NPC Loadouts (VisEntities) dresses Rust's own NPCs from configuration, for every NPC of a type rather than one spawn. The bridge's rust.npc.place already spawns Rust's own scientists, and a scientist is a BasePlayer, so it has an inventory and a displayName. §6

1. The permission manager (§4.1; D160–D163)

1.1 In one paragraph

Today the site pushes the set it authored, and the plugin reports "foreign" holders only for the names the site claims (D31). After this change, the plugin reads the whole store and the site keeps all of it. The first read of a server imports everything (D160). After that, every difference between the game and the site is either something the site changed, which it pushes as it does now, or something changed in the game. An in-game change is settled by the server's policy: auto-adopt, adopt, or revoke (D161). The screen is rebuilt on PermissionsManager's flow (D162), and every subject is shown with its linked account and in-game name (D163).

1.2 The inventory (plugin, protocol 13)

A new request/reply verb, perm.inventory, returns the whole store in the same shape on both frameworks:

  • permissions: every registered name with its owner, the plugin that registered it, found with PermissionExists(name, plugin) over plugins.GetAll() (§0.1). A name no plugin owns is sent with no owner. On Carbon those are the built-in modules' names, and the site groups them by prefix under "Carbon modules".
  • groups: name, title, rank, parent, and the permissions the group carries directly (GetGroupPermissions(name, false); inherited permissions are the parent's, not the group's).
  • users: for each Steam id holding anything, the permissions held directly, the groups (except default, which every connected player is in by framework rule and which the site treats as "everyone"), and the last name the store recorded. The name comes from the store's own id(name) entry.
  • leased: the group permissions an event lease is holding. The site excludes them from the in-game-change classification the way the foreign scan excludes them today (PLAN.md §27.3).

Cost. §0.2 means the read is permissions × users. It is built in slices on the main thread, one group of permissions per tick, the same drain the sync already uses (PermOpsPerStep). It is then sent in pages below 768 KiB, the way map.fetch stays under the sidecar's 1 MiB line cap. The site asks for page 0 and follows more. A snapshot is taken once and paged from memory, so a change between pages cannot tear it. The build walk measures a large store: the plan's ceiling is 20,000 users × 500 permissions under 2 s of drain, with no single tick over 20 ms.

What leaves the wire. The sync's managed list and the plugin's foreign scan (ScanPermission, ScanGroup) are replaced by the inventory. Protocol 13 is unreleased, so they are removed rather than kept beside it. perm.sync groups gain parent, applied with SetGroupParent on both frameworks.

1.3 Who can hold a permission on the site

Grants and memberships are keyed by website user today (D28), and adopt refuses a Steam id with no account ("there is nobody to author this against"). D160 makes the site the owner of every holder, including the many who never link. So the site gains Steam-keyed holders:

  • rust_perm_steam_grants (steam_id, permission, scope, source, …) and rust_perm_steam_members (group_id, steam_id, …), beside the existing user-keyed tables. buildDesired unions both, and they can overlap; the existing seenGrant de-duplication already handles that.
  • The import writes Steam-keyed rows, even for a linked player (D188). They are an exact copy of what the game holds. A user-keyed row would reach every Steam account the person has linked (D28), which would widen what the game held.
  • A toggle on the screen for a linked player writes a user-keyed row (it reaches all their accounts, as today). A toggle for an unlinked player writes a Steam-keyed row.
  • Linking or unlinking an account moves nothing. A Steam-keyed row stays about that game account, and the screen shows it under the person's name once they link.

1.4 Groups across servers

A group is one name fleet-wide today (rust_perm_groups.name is the primary key), with one scope, and everything it carries or contains is the same on every server in that scope. Real servers disagree: each has its own default and admin, and a vip on one server can carry different permissions from a vip on another.

D189: a group belongs to one server, unless an admin shares it. The same group on several servers, or on the whole fleet, is allowed, but it is never assumed:

  • A group is a row with its own id, rust_perm_groups (id, name, title, rank, parent, shared). What it carries (rust_perm_group_permissions) and who is in it (the member tables) belong to that row, so they are the same on every server the group is on.
  • Which servers it is on is rust_perm_group_servers (group_id, server_id), or shared = '*' for every server, including servers added later. A server never has two groups of one name; the model refuses it.
  • The import makes one group per server, even when two servers have a group of the same name with the same contents. Nothing is merged by guessing.
  • Sharing is an admin's act. "Use this group on other servers" picks servers, or all of them. When a chosen server already has its own group of that name, the screen shows the difference and the admin picks which version wins; the other is replaced. "Stop sharing" gives each server its own copy.
  • parent names a group on the same server. A shared group's parent must be on every server the group is on.
  • Built-in groups (default and admin on both frameworks, plus moderator on Carbon) are imported and editable, and never retired: the site does not send RetireGroup for them. default's membership is never modelled (§1.2).
  • Existing installs: a group scoped * today becomes a group shared with every server, and one scoped to a server becomes that server's group. What each server receives does not change.

1.5 The sync, rebuilt on three sets

rust_perm_pushed's own comment names the three sets. Until now the plugin could only compute the third one for the names the site claimed. The inventory gives the site all three:

In the game Pushed (the site put it there) Desired (the site authors it) Means
yes no no added in the game
no yes yes removed in the game
no no yes to apply (today's path)
— yes no to retire (today's path)
yes no yes landed by someone else; recorded as pushed

One sync becomes: inventory, then classify, then apply the policy (§1.6), then push the desired set and the retirements with the existing perm.sync. D32's shape is unchanged: whole set, one verb, the plugin reconciles. Triggers are unchanged too. The drift hooks still mark the server dirty, so an in-game change is settled within one tick (30 s) plus the read.

The first import (D160). A server with no completed inventory yet (new column rust_perm_sync.imported_at) takes everything present as site-owned, whatever its policy, and records it as pushed. Policies apply only to changes after that. This includes existing installs. The first inventory after the upgrade imports every current hand edit, including the drift rows now waiting for an answer, and clears those rows (D198, confirming D160 as written).

1.6 The policy (D161)

rust_servers.perm_policy, one of auto-adopt (the default), adopt, or revoke, set on the permission screen's server header.

Policy Added in the game Removed in the game
auto-adopt written as the site's own, for that server only: a Steam-keyed grant scoped to it, or a change to that server's group, with source: 'adopted', and recorded as pushed the site stops giving it on that server only (below)
adopt a row on the "needs a person" list, with Adopt and Revoke (today's drift answers) a row on the list, with "Accept the removal" and "Put it back"
revoke queued as a revocation and removed at this sync pushed back at this sync (nothing new: the desired set re-applies it)

D190: an in-game change touches that server and nothing else, even when the site's row reaches more servers. Auto-adopt acts on it without asking:

  • A grant that reaches several servers (scoped *, or a user grant) and is removed on one gains an exception for that server: rust_perm_exceptions (kind, subject, object, server_id). It keeps reaching every other server, including servers added later. buildDesired skips an excepted row on that server. The screen shows it as "all servers except rust-carbon", and removing the exception gives it back.
  • A group shared across servers that changes on one server (a permission or a member added or removed in the game) is split: that server gets its own copy of the group with the change applied, and the shared group stops covering that server. The other servers see no change. The "needs a person" list records the split, so an admin who wanted the change everywhere can apply it to the shared group and share it back.
  • A change to a row that is that server's alone is adopted directly: a removal deletes the row, and an addition writes one.

1.7 The screen (D162, D163, U-1)

Following PermissionsManager 2.1.2, the reference screenshots in the workspace's perms-screenshots/:

  • A server picker first. PermissionsManager works on one server, and the site holds several. Everything below is for the chosen server. A grant that reaches more servers shows where it reaches ("all servers", "all except rust-carbon"), and a shared group says which servers share it. Changing either asks whether to change it everywhere or on this server alone. "This server alone" makes an exception or splits the group, as D190 does for an in-game change.
  • Players ⇄ Groups, as two tabs. The players list shows the server's holders from the inventory first, then a search over every player seen on that server and every linked account. Each row shows the in-game name, the linked website account if there is one, and the Steam id when the server has no cached name (D163).
  • A subject, then its plugins. The plugin buttons come from the inventory's owner (§0.1), never from the name's prefix. A button shows a count of what the subject holds from that plugin.
  • A plugin's permissions, with Granted / Revoked, plus Grant all and Revoke all. Each toggle carries its own state on this server (U-1): granted; through group vip (read-only, with a link to the group); waiting for their first connection (a member the store cannot place yet); not registered here (the plugin is not loaded, formerly "unresolved"); or did not land (D85).
  • A player's groups (add, remove, Remove all). A group's players (Remove all), its title, rank and parent, and the servers it is shared with (§1.4).
  • The server header: sync state, the policy, and the "needs a person" list (§1.6).
  • zonemanager.ignoreflag.* (the question PLAN_FIXES §4.1 left open): D191, listed like any other permission. They are ZoneManager's 69 exemptions, under ZoneManager's button with their own names, the same as its seventieth, zonemanager.zone.

The existing account-first view (what one website account holds across the fleet) stays, on the account's own page, because that is where the events' grants (D84) and the chat styles (phase 17) are read.

1.8 The walk

On both rigs: install on a server with hand-made state (a vip group with a parent, two direct grants, one to an unlinked Steam id), and check that the import shows it exactly, as two separate vip groups when both rigs have one. Then an oxide.grant / c.grant under each of the three policies. Then, under auto-adopt, an in-game revoke of a server-only grant (deleted), of a grant reaching all servers (an exception, still on the other rig), and a change to a shared group (split off, the other rig unchanged). Then share a group, stop sharing it, and Grant all / Revoke all. The plugin buttons must be by owner: nokits is under ZoneManager. The large-store measurement is in §1.2.

1.9 As built, and as walked (2026-09-28)

Rust-Plugins feat/perm-inventory, Rust-Link feat/perm-inventory, Module-Rust feat/perm-manager. The wire is PROTOCOL.md §19.9, and the admin API is the module's swagger fragment (/admin/rust/permissions…).

What the build settled that the plan left open:

  • The data model. Groups are rows with ids: rust_permgroups, plus _servers (with included = 0 to take one server out of an all-servers group), _permissions, _members, _steam_members and _chat. The chat style belongs to a group row now, because one server's vip may be styled differently. Steam-account grants are rust_perm_steam_grants, and D190's exceptions are rust_perm_exceptions. rust_servers.perm_policy, rust_perm_sync.imported_at, rust_perm_catalogue.owner and rust_perm_drift.direction are new columns. The old group tables are copied once at boot (a rust_settings marker makes it once) and are then left unread.
  • Every sync is read → reconcile → push. The reconcile step runs under one fleet-wide lock, so two servers never split one shared group at once. Each desired row remembers the authored rows that produced it (its sources). That is how a removal knows whether to delete a row, add an exception, or split a group.
  • Two things are never judged. A permission the server does not register right now (an unloaded plugin is not a revocation), and a pair an event lease holds.
  • A removal at the first import is pushed back, not deleted. D198 imports what is present; a snapshot taken the moment the site first looked is not taken as a revocation.
  • An event's grant removed in the game is pushed back under auto-adopt, with a notice. The event owns it, and its revert withdraws it.
  • Under adopt, a removal or a changed group is held: it is not pushed back, retired or recorded until a person answers. "Put back" works by forgetting the ledger row, and the next sync pushes it.
  • A group the site already authored keeps the site's title at import. walkvoice, made by the site before the rebuild, overwrote the game's empty title with "Walk voice" on Oxide.
  • The announcement voice names a group by id. A setting saved as a name before the rebuild still resolves, to the first styled group of that name.

Walked on rust-oxide and rust-carbon, against the walk core on rustp16 (backed up first):

Case Result
Migration of the old groups 2 copied (site → Oxide only; walkvoice → every server)
First import (D198) on an existing install Oxide: default, admin made its own, 2 hand grants → Steam grants, the site's own grants recorded as landed; Carbon: default, admin, moderator with titles kept byte for byte
Auto-adopt: oxide.grant + oxide.revoke adopted within one 30 s tick; the revoked Steam grant deleted
Fleet grant (*) revoked in the game on Oxide (D190) an exception for rust-oxide; still pushed and landed on Carbon
oxide.unload Kits nothing deleted; the report lists both kits.* as unresolved
c.grant group walkvoice kits.admin on Carbon (D190) split: Carbon got its own walkvoice with the change and the style; the shared one excludes Carbon; a notice waits
Policy adopt one addition and one removal waited; the removal was held, not pushed back; adopt and accept answered them
Policy revoke an in-game grant undone at the next sync (revokes: 1)
Server view plugins by owner (nokits under ZoneManager), groups with where they are, players named, the exception listed

Not walked yet: a group with a parent; sharing and unsharing from the screen, and its conflict answer; Grant all / Revoke all; the screen itself in a browser (every endpoint behind it was called); and the large-store measurement.


2. The event step editor and the kit weekend (§4.2, §4.3; U-3–U-6, F11; D164)

Most of this is core's editor (EventEditor.jsx), so it lands on website main, stays game-agnostic, and must leave Module-uo exactly as it is.

2.1 A step becomes the thing it does (U-3, U-4, F11)

  • No type tags. ParamField stops printing · {type} and the bare *. A required field says so in words only when it is empty at save time.
  • Each type gets its control: for boolean, a toggle when the param is required and three buttons side by side, [Not set] [Yes] [No], when it is optional, because "not set" is a real third state there — the action's own default — and a two-state toggle would save No for every field nobody touched (D334); a number field with the declared range beside it for int and float, a date picker for datetime, and a text box only for string without a source.
  • core.lease renders the chosen lease's own form. The lease is picked by its label. The step then shows the value as the lease's type (a toggle for a boolean lease, a number with the lease's min–max beside it, or a dropdown when the lease declares values), the target as a picker over the lease's own target.source (F11, never built: today the editor reads only param.source), and the minutes with the lease's maxDurationMs shown beside them.
  • Examples. Core's own examples become game-neutral (§0.9). A lease may declare an optional example for its value (MODULE_API 1.13.0, §8), and target.example already exists, so the examples on a lease step are that lease's.

2.2 A phase that holds a timed step (U-6)

A phase with no advance rule ends when its steps finish, and teardown gives a lease back at once. So the "10 minutes" of a lease lasted one second. The fix is to make the duration visible:

  • A param may declare duration: 'minutes' (MODULE_API 1.13.0). core.lease's minutes is one, and so are rust.zone.open's minutes and the other modules' equivalents.
  • When a timed step is added to a phase that has no advance rule, the editor gives the phase advance: { after: '<the longest duration in the phase>m' }, visibly, and the author may change it.
  • A phase whose advance rule ends before its longest timed step says so under the phase: "This phase ends after 5 minutes; the lease is given back then, not after 10."

2.3 Start with an unpublished edit (U-5)

When the definition has a saved draft newer than its published version, Start asks: "Publish and start", or "Start version N (the published one)". Core client only. runs.create already pins the published version.

2.4 Popups (§4.3)

rust.announce's delivery already has two values from an option source. With two or three choices the editor draws them as a segmented control rather than a dropdown.

D193: a zone's enter and leave messages and a player's "reward earned" message get the same Chat / Popup choice.

  • Zones. rust.zone.open gains enterMessage, leaveMessage and delivery. The bridge no longer passes the messages to ZoneManager for its own zones. It subscribes to ZoneManager's OnEnterZone and OnExitZone and sends them itself, as chat or as a popup, only for the zones in its registry. This also gives F12's "row 5 cannot show an enter message" (PLAN_FIXES, the step-2 walk) a message to show. Built with §3 (§3.4), so §2 leaves it alone.
  • Rewards (D333). There was no in-game reward message to give a delivery to: a reward's only notice was core's engagement notification (rust.kit.entitled). So the message is new. rust.kit.entitle gains an optional message and delivery. Alice comes second and is rewarded kit vip: if she is online she is told at once, in chat or a popup — "You earned kit vip — type /kit vip" — and if she is offline she is told when she next wakes up on that server, since the grant already waits for her.
    • A blank message sends nothing, so a step saved before §2 behaves exactly as it did. {kit} in the text is the kit's name. Only linked recipients have a grant, so only they are told.
    • The notice rides perm.sync like the credit it belongs to (PROTOCOL §16.3): a new notices field, the site's whole set of untold notices for that server, each {key, steamId, message, delivery}, keyed by run and step. The plugin tells each player once, keeps the keys it has told, and reports them back (noticesTold), so the site marks the grant row told and stops sending it.
    • A grant withdrawn before its player came back takes its notice with it: nobody is told about a reward that is no longer theirs.
    • Protocol 14 (D332). An older plugin ignores notices; an older site sends none.
  • Without PopupNotifications a popup falls back to chat. The step editor says so beside the choice when the server's hello reports PopupNotifications absent, as rust.options.delivery already does.

2.5 The kit weekend as a template (D164)

There is no template concept anywhere today. D192: core gains api.registerEventTemplates (MODULE_API 1.13.0), which any module can use: a module declares a named skeleton (phases, steps, params filled in, and a short list of the inputs the author supplies). The editor's "New event" screen offers the skeletons, asks for their inputs, and opens an ordinary draft. Nothing about a template survives into the definition, so it is purely an authoring shortcut and the runner never sees it.

Module-Rust registers "Kit weekend". Its inputs are a server, a kit and a duration, and it makes one phase with advance: { after } equal to the duration and two steps:

  1. core.lease → rust.group.permission, target <server>/default/<the kit's permission>, value true, for the duration. The kit becomes visible and redeemable for everyone.
  2. rust.kit.entitle → that kit, every linked player on the server, one use each (D103: withdrawn on revert). This recipient mode does not exist yet. Today's modes are all about a run's scoring ("everyone who scored", top N, and so on), so Module-Rust adds linked.

2.6 The kit warning (D164)

"N linked players have already used this kit" needs a bulk read the plugin does not have (§0.8). So there is a new verb, kits.uses: {kit, steamIds[]} in, and {max, cooldown, exhausted, onCooldown} out (counts, not a list). It is main thread, capped at the site's linked-player count for that server (the same bound as the sync's). It is protocol 14 (D332): against a protocol-13 server the note is simply left off.

The warning reaches the core editor through an optional note on an option row (MODULE_API 1.13.0). When the chosen option carries one, the editor shows it under the field. rust.options.grouppermissions attaches the note to a kits.* target: "Kit vip: 2 uses, 24 h cooldown — 9 of 41 linked players have used it up. The weekend's extra use (step 2) is what lets them take it again."

2.7 The walk

Deferred to the end-of-plan walk (D331, §9). The rows, for that walk, plus D333's reward message (online and on next wake, chat and popup, and a withdrawn grant that tells nobody):

Author a kit weekend from the template on each rig; a lease step with the phase's advance removed (the warning shows); a draft edited after publishing (Start asks); a boolean lease (a toggle); and a Module-uo event opened in the same editor, which must be unchanged apart from the controls.

2.8 As built: the core half (2026-10-12)

website feat/event-step-editor, MODULE_API 1.13.0 (§8; website/MODULE_API.md §1.1, website/EVENTS.md §I). Everything in §2.1–§2.3 and §2.5's core half, with three things settled while building:

  • The phase guard counts the RUN, not the phase. Teardown gives a held thing back when the run ends, so a 10-minute lease in phase 1 is safe if phases 2 and 3 take ten minutes between them. The floor is the sum of every later phase's after; a phase with no rule counts as no time, and one that waits on the game makes the floor unknown, so it is not warned about. Only a phase with no rule is given one, and only when a change can create or lengthen a hold.
  • "Is the draft ahead?" is answered by the server (draftAhead): the working spec against the published one, as data with keys sorted. A timestamp cannot answer it, because publishing writes the row too.
  • A template's inputs are params, checked by the same function, so they get the same controls, sources and duration unit as a step's fields. The draft comes back unsaved, from POST /admin/events/catalog/templates/:templateId.

The Module-uo proof, against this branch: the frozen-manifest job (76 routes, all documented; uo registers its 12 actions), the server suite 723/723 and the client suite 45/45 — the same numbers as 1.11.0's. The Module-Rust half (the kit weekend, linked, kits.uses, the reward message) follows on edge.

2.9 As built: the Rust half, protocol 14 (2026-10-12)

Branches feat/protocol-14-step-editor in Rust-Plugins, Rust-Link and Module-Rust, into edge; the wire is rust-link/PROTOCOL.md §20.

  • The kit weekend is rust.template.kit_weekend: inputs kit, minutes (a duration), message and delivery. It reads the kit from its server when it is filled in, so the lease names the kit's real permission. A kit with no permission is open to everyone already and gets step 2 alone; a kit with no use limit has no "one more use" and gets step 1 alone. The phase is the weekend: advance: { after } equals the minutes. Run through the real 1.13.0 core (website#212), it registers, and the plan it builds validates as a spec.
  • linked is "every linked account that has played on this server" — a stats row there — because a link is fleet-wide and the server is what narrows it. It reads no tally, and past the server's recipient bound it is refused with the bound's name (EventsMaxRecipients), never trimmed.
  • The reward message is checked with the kit's name in place of {kit}, against the chat bound, so a long kit name cannot push past what the game will say. It is stored on each grant row (rust_perm_run_grants.notice, notice_delivery, notice_told_at).
  • The kit warning is the note on rust.options.grouppermissions rows. That source is searchable, so the editor may not hold the chosen row; core asks for that one value when it needs its note (website#212).
  • Timed params: rust.zone.open and rust.participation.open declare minutes a duration, and the group-permission lease gives true as its value example.
  • Compiled on both rigs, 2026-10-12. Carbon 2.0.262 on Rust 25823813 (the latest of both): the bridge compiled and loaded in 3.8 s, protocol 14, its told-notices store read at load. Oxide 2.0.7820 on the same Rust build (the rig was behind on both, and was updated first: its first restart's steamcmd update did not finish, the second did): compiled in 5.3 s and loaded. Both rigs were put back on their protocol-13 bridge afterwards, to match their sidecars until the end-of-plan walk sets up protocol 14 (D331).

3. Zones: ZoneManager's options, and a dome (§4.4; D166, D167)

3.1 The options (D166)

  • Read from the installed ZoneManager, by the helper. §0.6 means only code that references ZoneManager's types can read the flag list, and D168 puts that in the helper, not the bridge. RunicGatewayZones.cs gains a hook, RgZoneOptions(), returning ZoneManager's version and ZoneFlags.NameToIndex's names. The bridge calls it and adds zoneManager: { version, flags[] } to hello. Without the helper the site offers only a name, radius and duration, and says why, the same way D182 degrades.
  • Settings offered: radiation, comfort, temperature, safe zone and permission. The enter and leave messages are the bridge's own (D193, §2.4). The bridge keeps control of the id, location, radius and size, and the rest (rotation, parent, eject spawns) stays out, because each one changes what the bridge believes the zone is.
  • Wire: world.zone gains flags (names) and settings (key → value). The plugin checks every flag against the live list and every setting against the allowlist, and refuses an unknown one with a new reason, bad-option. It passes them to CreateOrUpdateTemporaryZone as ZoneManager's own key/value arguments, stores them on the zone's registry entry, and re-applies them in RecreateZones after a restart or a ZoneManager reload.
  • The form groups the flags from one module file (Vehicles, Combat, Building, Loot, NPCs, Communication, Survival). A flag the file does not know (a newer ZoneManager) goes under Other, so it is never hidden.
  • Presets are the admins' own (D195). None ship with the module, because what an "arena" is differs by server. An admin saves a set of flags and settings under a name, for one server, several servers, or the whole fleet: rust_zone_presets (id, name, flags, settings, shared) plus rust_zone_preset_servers, the same shape as a shared group (§1.4). The zone step offers the presets that cover its server, and Save as preset turns the flags on a step into one. A preset fills the form once, when it is picked; the step keeps its own copy, so editing a preset later does not change a published event.
  • Exemptions are zonemanager.ignoreflag.<flag> permissions, managed on the permission screen (§1.7). The zone form links there.
  • Flying has no ZoneManager flag (PLAN_FIXES §4.4) and is not in this step.

3.2 The dome (D167, D194)

D194: ZoneDomes stays the optional dependency D167 chose, because it already has the logic. What it lacks is filled in by a helper (D168). §0.5 found two gaps:

  • Its API throws without a player. A second helper, RunicGatewayDomes.cs, fixes that for ZoneDomes and nothing else (a helper does one job). It patches ZoneDomes' message method with Harmony so a call with no player sends no message, and so the call returns normally instead of throwing after the dome is made. It ships in the overlay beside the zone helper and is released, installed and checked the way D182's helper is. Hello reports it, like the zone helper's patched / missing / unsupported.
  • Its boot drops the domes of temporary zones. The bridge owns the fix, because it owns the order: after RecreateZones puts a zone back (at boot or after a ZoneManager reload), it calls AddNewDome again for each zone that had one, and it does the same when ZoneDomes itself reloads. At expiry and at teardown it calls RemoveExistingDome, so a dome never outlives its zone in ZoneDomes' data file.

The zone step gains "Show a dome", with ZoneDomes' own type and stack. It is offered only when the server reports ZoneDomes loaded and the domes helper patched. Otherwise the step says which one is missing.

The default type and stack (PLAN_FIXES §9 question 3) are chosen by looking at each on the rig, in daylight and at night, during the build walk.

3.3 The walk

On each rig, a zone made from an admin's preset: the flags hold for a player without the exemption and not for one with it; the enter and leave messages arrive as chat and then as popups; a restart, a ZoneManager reload and a ZoneDomes reload all keep the flags and the dome; expiry and teardown remove the dome from the world and from ZoneDomes' data file; and with the domes helper removed, the step stops offering a dome.

3.4 As built (2026-09-29; not yet walked on a rig)

Built on edge as Rust-Plugins feat/zones-domes, Module-Rust feat/zones-domes and installer feat/rust-domes-helper; the wire is PROTOCOL §19.11. The build changed §3.1 and §3.2 in four places.

  • Where the flags are ticked (D210). Core's step editor holds one value per field: a text box, a number, a yes/no, a date, or one dropdown. It has no checkbox list and no slot for a module's own button, and MODULE_API 1.13.0 (§8) adds neither. So the checkboxes are on a new page, Admin → Rust zone presets, and the step's Options field is one line of text (NoBuild, NoPlayerLoot, radiation=10). The field's dropdown lists the presets, and each row's value is the preset's line, so picking one writes that line into the field. Core already writes a dropdown's pick into the free-text field beside it, so this needed no core change. It keeps §3.1's promise: the step holds its own copy, and editing a preset changes no published event. There is no Save as preset button in the step; a preset is made on its page. Presets are rust_zone_presets (name, flags, settings, all_servers) and rust_zone_preset_servers. As with a group (D189), the same name may be used on two servers but not twice on one.
  • Who reads the flag list (D211). The bridge does, with ZoneFieldListRaw() (§0.6 corrected). The zone helper gained nothing, and a server without it still offers every flag, so there is no "name, radius and duration only" mode. The list is in hello, and the bridge sends hello again when ZoneManager, ZoneDomes or either helper loads or unloads.
  • The dome must wait for ZoneDomes' own start. ZoneDomes' InitializeDomes also re-draws every dome in its data file whose zone exists. A dome the bridge added before that is drawn twice, and the first set of spheres is left in the world with nothing able to remove it. The domes helper therefore postfixes InitializeDomes and raises OnRgDomesReady, and the bridge puts domes back only after it. The helper's patch goes in at load, before ZoneDomes' OnServerInitialized.
  • A popup falls back to chat at the moment of speaking, as §2.4 says, and the zone is not refused for it: a zone's messages are said long after the step ran. (rust.announce still refuses a popup without PopupNotifications, because its line is said at once.)

Chosen in the build, within the plan:

  • Settings bounds: radiation 0–500, comfort 0–1, temperature −100–100 °C.
  • Permission: up to 32 of a–z0–9_.
  • Dome stack: 1–10. ZoneDomes itself has no limit, and each sphere is an entity.
  • The 5 s quiet: after re-creating a zone, the bridge stays silent to anyone already inside it.
  • The flag groups: in server/model/zones/zoneOptions.js. NoTp and ZoneManager's Custom1–5 are under Other.

The default dome (D212). ZoneDomes 2.0.2 has five types, and only Standard (type 0, Rust's shaded sphere.prefab) is a whole dome: a dark, see-through sphere that each stacked copy makes darker. Red, Blue, Green and Purple are the Twitch battle-royale spheres, which ZoneDomes itself says show only where they meet terrain or an object, so they read as a ring at the zone's edge. The org lead chose the full dome as the default: the step lists Standard first as "Full dome (shaded)", labels each colour for what it shows, and a dome sent without a type is Standard. The default stack is 3 (D326, runicnpc/PLAN.md): the org lead chose it by eye at noon in RunicNPC's 9d player session, and the module sends it when the step's stack is blank. Steps saved with a blank stack draw 3 from their next run. The wire is unchanged: a dome sent without a stack still gets 1 from the bridge, and the module always sends one.

Compiled on both rigs, and the rig rows walked, 2026-09-29 (no player; the "file API outage" that morning was a Git Bash path-rewriting bug in the walk's own helper, not the rigs):

  • Both rigs: the bridge and both helpers compiled and loaded (Oxide; Carbon with 0 failed plugins). Both helpers reported patched, and the domes helper ready.
  • Hello (Oxide, read from the sidecar's board):
    • zoneManager carried all 69 flags of ZoneManager 3.1.14 (64 named and Custom1–5), with no zone helper involved (D211).
    • integrations.zoneDomes was { loaded, patched, ready }.
    • It was re-sent within a second each time ZoneDomes or a helper loaded or unloaded.
  • Refusals: an unknown flag (NoFlying), a setting outside the allowlist (rotation) and radiation 900 were each refused bad-option, with the reason.
  • A full zone: nobuild, PvpGod, NoPlayerLoot, radiation 5, safezone off, a popup enter/leave message and a stacked dome.
    • ZoneManager held exactly those flags and settings (read through a throwaway probe calling ZoneFieldList).
    • ZoneManager held no enter message of its own: the bridge says it (D193).
    • The registry stored the flags as ZoneManager spells them.
    • ZoneDomes' data file held the dome.
  • ZoneManager reload: the zone came back ("1 re-created") with all its flags. The dome's data was untouched.
  • ZoneDomes reload: the helper re-patched and was ready again. The dome was redrawn from ZoneDomes' own data.
  • Server restart:
    • The zone was re-created with its flags.
    • On this boot the bridge's zones existed before ZoneDomes started, so ZoneDomes drew the dome itself, and the bridge's restore added nothing.
    • Exactly 2 sphere entities for a stack of 2, so the dome was not drawn twice.
  • The other order, forced:
    • The bridge was unloaded (ZoneManager erased the zone), then ZoneDomes reloaded and dropped the dome's data.
    • The bridge was loaded again: "domes after load: 1 put back", and again exactly 2 spheres. §0.5's boot fault is handled whichever plugin starts first.
  • Teardown (world.revert): the zone was erased, 0 spheres were left, and ZoneDomes' data was empty.
  • Expiry: a 1-minute domed zone was erased at its deadline, and its dome went with it (0 spheres, data empty).
  • Without the domes helper: a dome was refused dome-unavailable, "RunicGatewayDomes.cs is not installed beside the bridge…". Hello said missing, then patched/ready once the helper was loaded again.

The site half, walked 2026-09-29 on a local core (website main f0e7d2a) with this branch's module, against rust-carbon. rust-oxide's fresh egg installed the last released sidecar, which speaks protocol 12, so the site refuses it until protocol 13 ships:

  • The page, in the browser as an admin:
    • Each server's line: Carbon's ZoneManager 3.1.14 with 69 flags, and "domes available".
    • The edit form: the flags in their groups with the preset's ticked, the servers, and the settings with their bounds.
    • A save with comfort 3 was refused under the form ("comfort is 0 to 1, not "3""). Corrected, it saved, and the preset's line updated.
    • The sidebar row. No console errors.
  • The API: a flag Carbon does not have was refused, naming the server. So were an out-of-range setting, a preset for no server, and a second "arena" on the same server (409).
  • The step:
    • The Options dropdown's row value is the preset's line (D210).
    • After the preset was edited, the published step still held its own old line. Picking the preset in the editor wrote the new one.
    • The dome dropdown carries D212's labels.
    • A draft with NoFlying failed its dry run with that reason.
  • A run through the whole path:
    • The site opened the zone on Carbon: the preset's flags and settings, the popup messages, and ZoneDomes' Standard dome at stack 2.
    • The run's cleanup removed it, leaving ZoneDomes' data empty.

Still to walk:

  • The in-game rows, deferred by the org lead (2026-09-29) to the later in-game walk, with §5.7's:
    • The flags hold for a player without the exemption and not for one with it.
    • The enter and leave messages arrive as chat and as a popup.
    • The default stack is chosen by eye, in daylight and at night. Chosen at noon in RunicNPC's 9d player session: 3 (D326).

4. The live map's marker types (§4.5; D165)

  • The site filters (D196). The map meta already carries each monument's kind and label. The toggle is by label, because that is what staff read and it groups the variants (31 substations, four prefabs, one "Substation"). No wire change is needed.
  • The default is a module list of the minor labels, off unless turned on: Substation, Underground Cave, Train Tunnel, Water Well, Wild Swamp, Jungle Swamp, Ice Lake, Jungle Ruin, Fishing Village (not the large one), Large Barn and Abandoned Supermarket, as the rig's map labels them, plus the gas station, mining outpost and ranch under whatever labels the game gives them (read at build time; they are not on the rig's map). Every label not on the list is on, so a monument Facepunch adds next month appears rather than disappears.
  • Where: Visibility.jsx's map section, under the Monuments & world events layer: a list of the labels on that server's current map with a switch each, a fleet default, and a per-server override. That is the same machinery the four layers already use (D114). The filter is applied where the map meta is served, so a hidden marker is not sent at all.
  • Not chosen: the game's own MonumentType (§0.7), sent by the plugin. It is authoritative, but it is a wire change, and nothing yet shows that substations are a type of their own.

4.1 As built (2026-09-29; Module-Rust only, no wire change)

  • Storage. One row per label, on or off: the fleet's in rust_settings as map.marker.<key>, a server's in rust_map_overrides under the same setting name. No schema change. The key is the label trimmed, single-spaced and lower case, because the game is not consistent: the rig's map says jungle swamp for ue_jungle_swamp_a. Both columns are VARCHAR(64), so a label longer than 53 characters cannot carry a switch and the write says so. A stored word that is neither on nor off follows the default above it. Unlike a layer audience, this does not narrow, because a label switch is clutter control and not a security boundary. The world layer's audience still decides whether a viewer gets monuments at all.
  • Precedence. A server's switch wins over the fleet's, and the fleet's over the built-in list, label by label. Setting the fleet back to what the built-in list says deletes the row instead of storing a copy, so a later change to the list reaches that label.
  • The built-in list (MINOR_LABELS, off by default): Substation, Underground Cave, Train Tunnel, Water Well, Wild Swamp, Jungle Swamp, Ice Lake, Jungle Ruin, Fishing Village, Large Barn, Abandoned Supermarket, Oxum's Gas Station, Mining Outpost, Ranch, and (D213) Canyon B, Canyon C, Lake A, Oasis A, Oasis C, Mountain, Train Tunnel Link, Abandoned Cabins. All twenty-two were read from a rig: the Carbon rig regenerated at world 6000, seed 981448696 (2026-09-29), whose map has 144 markers under 51 labels, including every one on the list. Mining Outpost is the warehouse prefab and Ranch is stables_a. The game spells one label two ways, "Jungle ruin" on that map and "Jungle Ruin" on the 3000 map, which the lower-case key absorbs. Large Fishing Village is a separate label and stays on. A 6000 world needs more than 8 GB: the rig was killed at 8 GB after "Initialized … occlusion sub-chunks", ran at 8.5 GB once up, and now has a 12 GB limit.
  • Where it is applied. GET /public/rust/servers/:id/map leaves out every monument whose label is hidden on that server. The website and the Android app read the same answer, so both lose the markers with no client change. map.access() carries the merged switches. Event steps that name a monument (world.monuments) are untouched: an event can still be placed at a substation that the map hides.
  • Admin → Rust visibility, the live map card. Under the fleet layer switches, Monuments on the map is a collapsed list with a checkbox for each label found on any server's current map plus the built-in list (a minor label no map has yet is still listed, so it can be decided before a wipe brings it). Each server row has its own collapsed Monuments list of the labels on its current map, each set to Default (drawn/hidden), Drawn or Hidden. The page's one PUT carries map.fleet.markers and map.servers.<id>.markers, each { key: true | false | null }. null clears the switch.
  • Tests: 7 server (map.test.js, one of them the rig map's 51 real labels) and 8 client (mapMarkers.test.js, the page's pure half) — 462 and 66 in all.
  • Walked 2026-09-29 on the local walk core against the Carbon rig's 6000 map. Signed in as a test admin: the fleet list read 51 labels, 22 hidden. Carbon read 51/22 and the Oxide rig's 3000 map 33/11. Ticking Substation on the fleet dropped both server counts by one before saving. Carbon was then set to Hidden and the page saved. The rows were map.marker.substation = on (fleet) and off (rust-carbon), with one activity row naming both. The public answer then gave Oxide 55 markers (its 31 substations back) and Carbon 31 (none). Undoing both through the page deleted both rows, logged as default and inherit. The public map tab on Carbon drew its 31 monuments over the Rust+ picture with no console errors (144 before the filter). The walk found one cosmetic flaw, now fixed: the fleet list printed the game's lower-case "jungle swamp". It now prefers a capitalised spelling. The page remounts after a save, which collapses the open lists, as it does for the rest of the page.

5. Chat titles: twenty-three conditions (§4.6; D172–D175)

5.1 What is counted, and where

Everything rides on player.tally (rule 2: aggregates, never a frame per event). The gathering conditions need no plugin change: rust_gather_totals already keeps every resource by shortname from all three sources (D159), so the site sums named lists.

Condition (title) Counted from Tally field (new unless noted) Site
Animal kills (Beastbane) OnEntityDeath, victim BaseNpc or Rust.Ai.Gen2.BaseNPC2 (not ScientistNPC2; RunicNPC D320) animalKills column
NPC kills (Exterminator) OnEntityDeath, victim a human NPC npcKills (exists, and keeps meaning all NPC kills) npc_kills − animal_kills
Players killed (Reaper) player.death (exists) — kills
Bow / Melee / Blade / Revolver kills (Fletcher, Brawler, Duelist, Outlaw) every credited kill's weapon shortname, same source as the killfeed weaponKills: { shortname: n } new table, classified by lists (§5.3)
Headshots (Crownbreaker) the killing blow's HitInfo.isHeadshot, any victim headshots column
PvP / PvE kill distance (Longshot, Marksman) the killfeed's distance bestPvpM, bestPveM (this interval's maximum) best columns, GREATEST at ingest
APC / helicopter kills (Tankbreaker, Rotorbane) OnEntityDeath, victim BradleyAPC, PatrolHelicopter, CH47Helicopter apcKills, heliKills columns (§5.4)
Wood / ore / plants (Timberwright, Prospector, Greenhand) exists (D159) — sums of named resource lists
Clothes / weapons crafted (Seamster, Armorer) OnItemCraftFinished, item.info.category crafted: { category: n } columns for attire and weapon
Structures built (Mason) OnEntityBuilt, a BuildingBlock built column (not structures, which is raids)
Structures repaired (Tinkerer) OnStructureRepair repaired column
Players healed (Mender) OnHealingItemUse on another player, OnPlayerRevive healed column
Rockets fired (Artillerist) OnRocketLaunched rockets column
Explosives thrown (Demolitionist) OnExplosiveThrown, OnExplosiveDropped explosives column
Quests completed (Wayfarer) OnMissionSucceeded (§0.4) missions column

Three of these hooks can veto (OnStructureRepair, OnHealingItemUse, OnPlayerRevive), so they are declared void (PROTOCOL.md §8.7). All nine new hooks join ExpectedHooks, so a framework that stops raising one is visible on the servers page.

5.2 NPC kills, without changing a public number

Splitting npcKills in two would change what the public leaderboard's NPC kills column means, and it would change it mid-wipe. So npcKills keeps counting every NPC and animal. animalKills counts the animals again separately, and the human-NPC count is the difference. Nothing already on a page moves.

5.3 The weapon lists live on the site

D174 wants the weapon lists "in one place, so a Rust update adds a weapon by editing a list". The plugin sends every credited kill by weapon shortname, and the site keeps rust_weapon_kills (server, wipe, steam, weapon, n), shaped like rust_gather_totals. The bow, melee, blade and revolver lists are one module file, applied when the standings are read. Editing a list corrects the whole wipe, and adding a Rust weapon needs no plugin release. Melee is a list too, because the site cannot ask BaseMelee.

5.4 Who killed the helicopter

D174: the killing blow. A patrol helicopter usually dies by crashing, and its death HitInfo may carry no player. The build walk shoots one down on the rig. If the killing blow has no player, the credit goes to the last player who damaged it within 60 s, tracked only for those three vehicle types. That is a stated default, and the walk decides whether it is needed.

5.5 Titles an admin can rename (D175)

  • rust_title_categories (stat PK, text, updated_by, updated_at) holds the admin's title for a category. The shipped defaults (§4.6's table) stay in the module.
  • rust_title_rules.text may be empty, meaning "use the category's title". That is what makes a rename reach existing rules. D175 also says "new rules open with the category's current title filled in". If it were filled in as the rule's own text, the rename would never reach that rule. So the form shows the current category title as a placeholder and saves nothing unless the admin types.
  • stat takes the 23 category ids (they fit VARCHAR(16)). The standings query for a best stat is a MAX.
  • The titles screen gains a "Category titles" section: 23 rows, each with the default, the admin's text, and Reset.
  • The new stats feed titles only. The public leaderboard keeps its columns in this step. Adding any of them there is a later, separate choice.

5.6 Missions (D173)

OnMissionSucceeded increments missions for the assignee. A failed or abandoned mission is never counted. The hook's exact arguments are confirmed the first time a person finishes a mission on the rig, which is also D173's pass condition: a mission taken from an NPC and finished moves Wayfarer by exactly one, and an abandoned one does not move it. Should the hook disappear on a framework, D173's option 2 is known to work: the player's acceptedMissions carries each instance's status (Completed, Failed, …, §0.4).

5.7 The walk

Every condition earned once on each rig (PLAN_FIXES §8): kill an animal and a scientist with a bow, a machete and a revolver, one of them by headshot; shoot a player from range (this needs the second player); craft a shirt and a weapon; build and repair a wall; bandage and revive another player; fire a rocket; throw a grenade; destroy an APC; shoot down a helicopter; finish a mission and abandon one. Each count is checked in the tally frame and on the titles screen.

5.8 As built (2026-09-28/29; compiled on both rigs, not yet walked)

Rust-Plugins feat/title-conditions, Module-Rust feat/title-conditions. The wire is PROTOCOL.md §19.10, and the admin API is the module's swagger fragment (/admin/rust/title-categories…).

One question the plan left open, answered (D209): a rule's npckills now ranks human NPCs only, every NPC kill less the animals. Titles had never shipped, so no live rule changes meaning; the rigs' walk database held one such rule.

What the build settled:

  • The categories are ids a rule stores, in model/titles/titles.js: kills, npckills, animalkills, bowkills, meleekills, bladekills, revolverkills, headshots, pvpdistance, pvedistance, apckills, helikills, wood, ore, plants, attire, weapons, built, repaired, healed, rockets, explosives, missions, plus playtime, which predates them. Playtime has no default title, so it cannot be renamed and a playtime rule always carries its own text.
  • Each category names where it is read from: a summed column, a column's MAX (the two distances), a difference (npc_kills − animal_kills, in signed arithmetic, since a row counted before animal_kills existed would otherwise wrap), or a named list over rust_weapon_kills or rust_gather_totals. Every standing is ordered by value, then by who was seen last, the leaderboard's own tie-break.
  • The lists are one file, model/titles/lists.js: bow, melee, blade, revolver, wood, ore and plants. Every blade is also melee. Plants include cloth, which is what hemp picks up as, and leave out seeds.
  • A rule's text may be empty, meaning its category's title. A text typed as only markup is still refused (§33.4 reading 3): brackets are a mistake, not a request for the default. The form shows the category's current title as a placeholder and never saves it into the rule.
  • The category titles are GET /admin/rust/title-categories and PUT /admin/rust/title-categories/:stat (an empty text is the Reset). A change forgets every server's cached answer, so the next push carries the new name. The servers list also returns them, so each server's summary line names what its rules show.
  • The plugin's choices, each to be checked by the walk: a repair counts only when the entity's health rose by the next tick (the hook fires before the game decides there is anything to repair); a syringe revive raises both the revive and the heal hook and counts once; an explosive is a TimedExplosive that deals damage, which leaves out supply signals and smoke; the helicopter's last-hitter credit (§5.4) is built rather than waiting for the walk, because proving it unnecessary needs a helicopter shot down first.

Read off the rig, 2026-09-29. A read-only probe walked the live server's ItemManager.itemList and named each held entity's prefab and class. The lists in lists.js are that output, not memory: the revolvers are pistol_revolver, python and hc_revolver (there is no revolver.entity); the bows include the legacy bow, the mini crossbow and the bowless crossbow, and leave out the speargun, a crossbow underneath; melee is every BaseMelee except the pies (Food is a BaseMelee too). C4, the satchel and the beancan are all TimedExplosive subclasses, so §5.1's explosive test holds. The same probe corrected three members the plan had assumed, each of which would not have compiled or would never have been called:

  • ItemCraftTask has no owner; the crafter is ItemCrafter.owner.
  • A mission's assignee is the hook's third argument (MissionSuccess(instance, assignee)); the instance has no accessor for it.
  • MedicalTool.GiveEffectsTo now takes an IMedicalToolTarget, so OnHealingItemUse takes its target as object and counts only a player.

Corrected by the RunicNPC 9d walk (2026-10-07, D320–D322). Two of the plugin's choices above were wrong on a live server, and a third gap was ZoneManager's: Rust's animals are Rust.Ai.Gen2.BaseNPC2, not BaseNpc, so no animal kill had counted (D320); OnHealingItemUse is (tool, fromPlayer, toTarget), so the two-parameter handler read the healer as the target and no heal counted (D321); and an event's zone did not enforce its flags until RunicGatewayZones 0.2.0 has ZoneManager refresh its hooks (D322, §3). See ../../runicnpc/PLAN.md stage 9d.

Compiled on both rigs, 2026-09-29. rust-oxide (a fresh install on the published egg) and rust-carbon: all 45 hooks bound, and OnEntityTakeDamage fires on world damage with nobody on (53 and 109 in the first half-minute), which is the cost the helicopter credit was expected to have. The other nine new hooks need a player: that is §5.7's walk.


6. NPCs (§4.7)

Superseded 2026-09-30. The spike below was run on the Oxide rig. Route A kept its kit and fought with it but cannot be named; HumanNPC counted its NPCs as players in the killfeed and tallies. The org lead then chose neither route: Runic Gateway writes its own NPC plugin, RunicNPC, in its own repository (D214–D220). The spike's results, two corrections to the text below, and the staged plan are in ../../runicnpc/PLAN.md. The text below is kept as what was planned.

D197: the spike tries both routes on both rigs, then the org lead picks one. The goal is the one the org lead named: NPCs with different kits and names. NPC Loadouts is out, because it cannot dress one spawn differently from the next (§0.10). The spike reports on each route before any site work.

Route A: extend rust.npc.place. The bridge already spawns Rust's own scientists, and they are players underneath:

  • kit: a Kits kit. After spawning, the bridge empties the NPC's inventory and gives it the kit through Kits' API (GiveKit). Kits is already a required plugin (PLAN.md R6).
  • name: the NPC's displayName, which already flows into the killfeed (attackerName) and on to F2's labels.
  • The spike answers: does a scientist given a kit fight with the kit's weapon, or fall back to its prefab loadout? Does the name reach the killfeed? Does Rust re-dress it on a later tick?

Route B: HumanNPC. Its API (SpawnHumanNPC, SetHumanNPCInfo, RemoveHumanNPC) gives a name and a kit directly. The gotchas the spike checks, each of which could rule it out:

  • Two more dependencies. It requires Pathfinding and Waypoints. All three become optional dependencies that operators have to install, and the egg and the installer do not ship them.
  • Carbon. Nothing says it runs on Carbon. The spike loads all three on rust-carbon.
  • Its own persistence. HumanNPC saves its NPCs to its data file and respawns them on boot, which is the ZoneDomes problem again (§0.5). An event's NPC must be removed at teardown and must not come back after a restart that happened mid-event. The bridge would track them in its registry by HumanNPC's id, not by an entity net id as world.place does today.
  • Its own AI. Its NPCs do not use Rust's scientist brain. Hostility, the weapon they use and whether they chase are its settings. The spike checks that a hostile one actually fights.
  • Being counted as a player. A HumanNPC is a BasePlayer with a generated userID. The bridge's death and kill paths (OnPlayerDeath, the tally, the killfeed, a player-kill title) must count it as an NPC, not as a player. The spike kills one and reads the frames.
  • Upkeep. Its last release date and open issues are recorded, because an abandoned dependency is a cost.

If route A works on both frameworks it needs nothing new installed, and that is weighed against what B offers beyond it.


7. Protocols 13 and 14, what this adds

The rows for §1, §3 and §5 were additions to protocol 13, which shipped on 2026-10-09; PROTOCOL.md §19 has a section for each. §2's two rows open protocol 14 (D332), which §11 joins, and PROTOCOL.md §20 gains them. §6's row was never built: RunicNPC replaced route A.

Change Direction §
perm.inventory / its paged reply (owners, groups with parents, users, leased) site → game 1.2
perm.sync: groups carry parent; managed and the foreign report removed site → game 1.2
kits.uses / its reply — protocol 14 site → game 2.6
hello: zoneManager: { version, flags[] } (from the zone helper); zoneDomes: { loaded, version } and the domes helper's state game → site 3.1, 3.2
world.zone: flags, settings, enterMessage, leaveMessage, delivery, dome: { type, stack }; reason bad-option site → game 2.4, 3.1, 3.2
perm.sync: notices (the reward message, D333), and the report's noticesTold — protocol 14 site ⇄ game 2.4
world.place for an NPC — not built; RunicNPC (D214) — 6
player.tally: animalKills, weaponKills, headshots, bestPvpM, bestPveM, apcKills, heliKills, crafted, built, repaired, healed, rockets, explosives, missions game → site 5.1
hello: integrations.economics { loaded, version, startingBalance, wipesWithMap } — protocol 14 game → site 11.2.2
economics.pay, economics.balances, economics.adjust and their replies — protocol 14 site → game 11.2.2
player.tally: balance (a value); economics.wiped — protocol 14 game → site 11.2.2

8. MODULE_API 1.13.0 (core, website main)

Planned as 1.12.0; that number went to Events' progress() (website#210) while this plan waited for RunicNPC, so these are 1.13.0. Additions only, so Module-uo, declared ^1.10.0, is unaffected, and that is proven the way 1.11.0 was.

  • registerEventTemplates (§2.5).
  • A param's optional duration: 'minutes' (§2.2).
  • A lease's optional value example (§2.1).
  • An option row's optional note (§2.6).

9. Order of building

Each item is built on edge and PR'd with its spec, like steps 1 and 2. Items 1–4 were also walked as they were built; from item 6 on, nothing is walked until every item is coded (D331). Code that runs in the game is still compiled on both rigs before its PR, and a rig spike that a design depends on (§11's source pulls) is research, not a walk, so it still comes first:

  1. The permission manager (§1). It is the largest, and §3's exemptions point at it.
  2. Chat titles (§5), including the mission hook.
  3. Zones and the dome (§3), with the zone half of D193's popups and the domes helper.
  4. The map's marker types (§4).
  5. NPCs (§6): spiked 2026-09-30; now RunicNPC, built and released through its own stages 0–9 (runicnpc/PLAN.md §9) before items 6 and 7 (D220).
  6. The step editor and the kit weekend (§2). It is core's, so it can be built alongside any of the above. It goes out as its own website PR with the Module-uo proof.
  7. The first-class optional plugins (§11): Economics, then Backpacks, then RaidableBases, each starting with its source pull and rig spike. Economics and Backpacks were built on 2026-10-12. RaidableBases waits for the current paid copy (D347) and is built for both editions (D348).
  8. Permissions as event rewards (§12), built on 2026-10-12.
  9. Rust's own world events and six more optional plugins (§13, §14). First comes one test phase covering all of them on both rigs (§14.2, D352); it ran on 2026-10-14 (§14.2.1). Then one integration plan, answered before code: written on 2026-10-14 as §14.3, from D361–D369, and answered as D370–D376. Then the build, in §14.3.11's nine stages (two of them core), §13 first. Stage 1, §13, was built on 2026-10-15 (§14.3.14), with D378 and D379. Stage 2, core's retryAfterMs (MODULE_API 1.15.0), was built on 2026-10-15 (§14.3.15). Stage 3, TruePVE in the zone step, was built on 2026-10-15 (§14.3.16).

Then one walk, on Oxide and Carbon, of everything still owed (D331): §2.7; §11's rows; §12.9; §13's and §14's rows; the in-game rows of §5.7 and §3.4; PLAN_FIXES §8's re-walk, including the Carbon pass of the first player walk. What it finds is fixed, protocol 14 is released, and the cutover follows. The walk waits for RaidableBases (D359), so it runs only once §11.4 is built from the current paid copy (D347).

10. Decisions of record

The plan asked eleven questions. The org lead answered them on 2026-09-27, the day it was written.

# Decision Rejected §
D188 The import records a holding by Steam account, exactly as the game has it, even when that account is linked. A toggle made on the site for a linked player is a grant to their website account and reaches every Steam account they link (D28). Recording imported holdings against the website account, which would widen them to the person's other Steam accounts. 1.3
D189 A group belongs to one server unless an admin shares it with chosen servers or the whole fleet. The import makes one group per server, even for identical names. The same group across servers is allowed but never assumed. One group name fleet-wide with per-server contents (the plan's first proposal). 1.4
D190 An in-game change affects that server only, and auto-adopt applies it without asking. A grant that reaches more servers gains an exception for that server; a shared group is split, and that server gets its own copy. Holding a change to a multi-server row for a person under every policy. 1.6
D191 ZoneManager's ignoreflag permissions are listed like any other permission. One collapsed "exempt from zone flags" section. 1.7
D192 Core gains event templates (registerEventTemplates, MODULE_API 1.13.0 — planned as 1.12.0, §8), and the kit weekend is the first. A Rust-only kit-weekend button. 2.5
D193 A zone's enter and leave messages and a player's reward message get the Chat / Popup choice, as announcements have. The bridge sends zone messages itself for its own zones. Popups for announcements only in this step. 2.4
D194 ZoneDomes stays the optional dependency (D167 stands). A domes helper makes its API safe without a player; the bridge re-adds domes after re-creating zones and removes them at expiry and teardown. The bridge drawing the dome itself (the plan's first proposal). 3.2
D195 Zone presets are the admins' own, saved for one server, several, or the fleet. None ship. Shipped presets (Arena, Safe zone, No building). 3.1
D196 The site filters map markers by label, with a default-off list of minor labels; every other label shows. The plugin sending Rust's MonumentType. 4
D197 The NPC spike tries both routes, extending rust.npc.place and HumanNPC, and reports HumanNPC's gotchas before the org lead picks. Deciding the route before the spike. 6
D198 The first inventory on an existing install adopts everything present, including hand edits waiting for an answer today (D160 as written). Leaving existing drift rows on the "needs a person" list. 1.5

The org lead added §11 on 2026-09-28 and answered its questions the same day:

# Decision Rejected §
D199 RaidableBases, Economics and Backpacks become first-class optional plugins, and Kits stays required. Optional means what it means for PopupNotifications and Clans: the bridge detects each at hello, the steps that need one refuse with a reason when it is absent, and the installer does not require it. Making Kits optional as well. 11
D200 Economics: all four uses. Coins as an event reward (withdrawn on revert); balances shown to the player (site and app) and to staff; staff deposit, withdraw and set a balance, with an activity row; and a richest leaderboard column and chat-title condition. — 11.2
D201 Backpacks: staff view any player's backpack, a player views their own (site and app), and an event can grant extra capacity for a time, taken back at the end. Showing backpack sizes as named tiers on the permission screen. 11.3
D202 RaidableBases: all four uses, and the paid edition too. Active bases on the live map and in the feed; an event step that spawns a base at a difficulty and despawns it at the end; bases raided per player on the leaderboard and as a title condition; and the paid edition's extras (five difficulties, lockouts) where it is installed, falling back to what both editions share. Supporting only what the free edition has. 11.4
D203 RaidableBases picks where an event's base goes; the site learns the spot from OnRaidableBaseStarted and shows it. The paid edition's placement, if it has one, is offered where installed. A helper that places a base at a chosen monument. 11.4.3
D204 At an event's end, a helper (RunicGatewayRaids.cs, D168) despawns only that event's base. Without the helper the base despawns on its own timer, and the run records it as expiring on its own. Leaving every base to its timer; RaidableBases' "despawn all". 11.4.3
D205 An event picks a RaidableBases profile and a difficulty, may override a bounded set of settings for that spawn (through the helper), and the site edits RaidableBases' profiles — a named exception to the editor never walking a data directory. Profile and difficulty only; overrides without profile editing. 11.4.3
D206 Lock-to-first-attacker is set per event. RaidableBases keeps it only in its main config, so the raids helper patches the base's BypassUseOwners() to answer from the run's own setting for the event's base, and leaves every other base to the config. One server-wide switch the config page edits; no lock control at all. 11.4.5
D207 A step places its base either where RaidableBases picks (the default, D203) or at an admin-given spot: x and z, plus either a height adjustment or an exact height. The spot needs the raids helper; without it the step offers only the plugin's pick. Only an admin-given spot; keeping D203 alone. 11.4.5
D208 A blocked spot is refused, with the reason. The step does not spawn when the plugin's area check fails or the helper's water and terrain test fails, and the run log says why ("player building", "raid base", "in water"). The editor runs the same check when the step is saved. Falling back to the plugin's pick; spawning anyway. 11.4.5

The §5 build raised one more, answered on 2026-09-28:

# Decision Rejected §
D209 A title rule's "NPC kills" ranks human NPCs only (scientists and bandits): every NPC kill less the animals, which Beastbane ranks. The public leaderboard's NPC column still counts both (§5.2). Keeping the old all-NPC count as a 25th category beside Exterminator. 5.8

The §3 build raised two more, answered on 2026-09-29:

# Decision Rejected §
D210 A zone's flags and settings are ticked on an Admin → Rust zone presets page, and the zone step's Options field copies a preset's line when it is picked. Core is unchanged. A list param type in core (MODULE_API) drawn as grouped checkboxes; a module-supplied step editor. 3.4
D211 The bridge reads ZoneManager's flag list through its API call ZoneFieldListRaw() and sends it in hello. A server without the zone helper still offers every flag. Keeping the read in the zone helper, with a name/radius/minutes-only mode without it. 3.4
D212 A zone's dome defaults to ZoneDomes' Standard type, the full shaded dome. The coloured types show only where they meet the ground or a building, and the step says so. A coloured default. 3.4

The §4 build raised one more, answered on 2026-09-29:

# Decision Rejected §
D213 Eight labels the 6000 map showed also start hidden: Canyon B, Canyon C, Lake A, Oasis A, Oasis C, Mountain (terrain, not places to loot), Train Tunnel Link (like Train Tunnel), and Abandoned Cabins (the third swamp, like Wild Swamp). Leaving them drawn under "every other label is on"; hiding the terrain and tunnel but drawing the cabins for their loot. 4.1
D214 Runic Gateway writes its own NPC plugin, RunicNPC, API-style like NpcSpawn, in its own repository runicnpc-rust; behaviours may be borrowed, code may not. Route A alone; HumanNPC; depending on NpcSpawn. 6, RunicNPC §0
D215 RunicNPC is not only for events: admins place NPCs in game for normal play, through chat commands. Event steps only. 6, RunicNPC §5
D216 No visual editor: profiles are authored on the website; in game, commands only. NpcSpawn's in-game GUI. 6, RunicNPC §0
D217 Kits is how an NPC is equipped, and Kits is required. Item lists in the profile. 6, RunicNPC §2
D218 Loot tables are RunicNPC's own for now; links to other loot plugins later. A loot plugin as a dependency. 6, RunicNPC §7
D219 Rust's navmesh is used; a custom navigation mesh is a later stage, only if needed. NpcSpawn-style point meshes from the start. 6, RunicNPC §8
D220 RunicNPC is built and tested in stages before this plan resumes, then module-rust requires it. Building it alongside items 6 and 7. 9, RunicNPC §9

The plan resumed on 2026-10-09, after RunicNPC v1.0.0, with four more:

# Decision Rejected §
D331 Every item left (§2, §11) is coded before anything is walked; then one walk covers it all, with the walks §3 and §5 still owe and PLAN_FIXES §8's re-walk, and what it finds is fixed before the release. Rig spikes a design depends on, and compiling on both rigs, still happen as each item is built. Walking each item as it is built, as items 1–4 were. 9
D332 §2 and §11 open protocol 14 on edge, released together once after the end walk. Protocol 13 shipped on 2026-10-09. Protocol 14 for §2 alone and 15 for §11; dropping kits.uses to keep §2 off the wire. 7, 11.5
D333 A kit reward can tell its player in game. rust.kit.entitle gains an optional message and delivery; the notice rides perm.sync and is told once, at once or on the player's next wake; a blank message tells nobody. The engagement notification as the only notice. 2.4
D334 An optional boolean param is three buttons, [Not set] [Yes] [No]; a required one is a toggle. A two-state toggle everywhere, which saves No for a field nobody touched. 2.1

The Economics spike raised two more, answered on 2026-10-12:

# Decision Rejected §
D335 Coins paid by an event are kept. rust.coins.pay is reversible: 'none': a run that finishes, is cancelled or is aborted after paying leaves the coins where they are, and staff take coins back by hand from the player's admin page. Kept on a finish but taken back on a cancel or abort (core would have to tell revert() how the run ended, MODULE_API 1.14.0); a per-step choice; always taken back at the end, like a kit weekend's credits. 11.2.2
D336 The richest column and Magnate rank only balances above the server's starting balance, read when a player connects and whenever it changes. A fresh wipe's untouched players are not ranked; a balance carried over from the last wipe is, at once. Ranking a player only once their balance moves this wipe; ranking everyone, ties at the starting balance included. 11.2.2

The Backpacks spike raised three more, answered on 2026-10-12:

# Decision Rejected §
D337 Extra backpack space is a size permission leased for the run. rust.backpack.size grants one of the server's backpacks.size.N to each recipient through rust_perm_run_grants, as a kit reward is granted, and the run's end revokes it. It reaches offline players, needs no experimental setting, and needs no plugin code. The highest size a player holds wins. Backpacks' API_AddBackpackCapacity (+N slots), which throws unless the owner turns on Dynamic Size (EXPERIMENTAL), is clamped to a size profile, and needs the player online; offering both; dropping the reward. 11.3.2
D338 Taking the space back is Backpacks' own behaviour: nothing moves at the revoke, and on the owner's next open the stacks past their size go to their inventory, or drop at their feet when it is full. Tested on both rigs at the org lead's request; the step's default message warns about it. Holding the space until the extra slots are empty; never taking it back. 11.3.1, 11.3.2
D339 The Carbon permission leak found by the spike stays out of §11.3: filed as Rust-Plugins#32, for later. Fixing it in §11.3; only noting it. 11.3.1

The build raised two more, answered on 2026-10-12 before any code: §11.3.2's grid named an "item catalogue" the module does not have, and R9 keeps item icons out of the asset bridge.

# Decision Rejected §
D340 A backpack slot shows the item's name, not an icon. The plugin sends each stack's English name from the game (display), so "Wood ×1000" and "Assault Rifle + Weapon Lasersight" need no item catalogue and nothing loads from outside the site. R9 stands. Icons from a third-party host keyed by shortname; shortnames alone. 11.3.4
D341 A blueprint names what it makes. Backpacks stores a blueprint as blueprintbase with a target; the stack carries blueprint (the target's shortname), its display is the target's name, and the slot reads "Assault Rifle blueprint". Every blueprint shown as the same anonymous "Blueprint". 11.3.4

Permission rewards, added on 2026-10-12, were decided as D342–D346; they are tabled with their section, in §12.8.

Reading the paid edition (§11.4.2) raised two more, answered on 2026-10-14:

# Decision Rejected §
D347 RaidableBases waits for the current paid copy. No §11.4 code is written until the org lead supplies it and its spike (§11.4.2) has run. The old 3.0.5 copy was read for drift only. Building §11.4 on the free edition now and fitting the paid edition in later; spiking the old 3.0.5 copy on the rigs. 11.4.2
D348 Runic Gateway and all its systems support either edition, free or paid, as far as each allows. Every part detects what the installed copy offers and uses the best route it has, and says so where there is none (§11.4.2). This widens D202: both editions must get each part, not only the paid edition's extras. Building on the free edition's API and treating the paid edition as extras on top. 11.4.2

The plugin survey the same day led to four more, decided by the org lead on 2026-10-14:

# Decision Rejected §
D349 Rust's own world events are part of the base module: the patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops and locked crates reach the feed and the event system on every server, with no plugin. Leaving them on the map only; supporting them through a plugin such as Inbound or Automated Events. 13
D350 TruePVE, Dynamic PVP, Dangerous Treasures, Guarded Crate, ZLevels Remastered and Server Rewards become first-class optional plugins, as D199 defines them. Duelist is left out entirely. Duelist; SmoothRestarter and PlayerRanks (not wanted); Event Manager, Quests and Bounty (no result hooks). 14
D351 As few helper plugins as possible, and nothing that runs afoul of a licence. Runic Gateway never copies, modifies, bundles or redistributes a third-party plugin; it uses only their public hooks, methods and console commands, and operators install them from uMod. A helper per plugin by default; shipping plugins with the installer or egg. 14
D352 One test phase covers §13 and all six plugins together, on both rigs; then one integration plan, answered before code. A spike and a plan per plugin, as §11 did. 14.2, 14.3

The org lead answered §13's and §14's questions the same day, and added Steam sign-in:

# Decision Rejected §
D353 The public feed shows a world event with its grid and the player's name; the staff feed adds the Steam id (Q12). Staff only. 13.4
D354 An event can call a vanilla event (patrol helicopter, cargo ship, Chinook, airdrop at a grid or position) through a new step whose result is kept (reversible: 'none'): nothing is removed when the run ends. A new verb, not an exception to D88 (Q13). No such step; removing the called event at teardown. 13.4
D355 Every world event in §13.2 is an event-system trigger (Q14). Feed only. 13.4
D356 Two new counts with titles: locked crates hacked (Safecracker) and airdrops looted first (Scavenger) (Q15). Barrels broken, K/D ratio and time played are not added. All of PlayerRanks' extra counts. 13.4
D357 §13 and §14 join protocol 14 (D332), released with the rest after the end walk (Q16). A protocol of their own. 13.4
D358 BotReSpawn stays out (Q17). Feed and kill-count support for its bots. 14.4
D359 The end walk waits for RaidableBases (Q18). D331's one walk runs once every item is coded, §11.4 included, and §11.4 waits for the current paid copy (D347). Walking the rest without it and giving RaidableBases its own walk later. 9, 14.4
D360 Steam sign-in is supported in the same manner as the other providers: a built-in provider beside Google and Discord, link-only, through the one session service, configured on Admin → Auth, on the website and in the Android app. It stays on the website's plan (D171), now written as website/STEAM_SIGNIN.md and on the roadmap as the website milestone Steam sign-in. §15 links it. Planning it inside this document. 15

During the §14.2 test phase (2026-10-14) the org lead ruled on two points it raised:

# Decision Rejected §
D361 Guarded Crate may depend on NpcSpawn. NpcSpawn (Codefling) is downloaded and used as is, on the rigs and by operators; Runic Gateway never ships or modifies it (D351). Treating NpcSpawn as a licence problem that rules Guarded Crate out. 14.2.1
D362 Our teardown removes our TruePVE mappings. TruePVE never prunes a mapping whose zone id is not numeric, and ours are rg-…, so the run that maps a zone unmaps it. Relying on TruePVE to clean up. 14.2.1

The org lead answered the phase's Q19–Q25 on 2026-10-14:

# Decision Rejected §
D363 Carbon's console route (Q19): watch Carbon's updates and keep an issue open in Rust-Plugins so it is not forgotten (Rust-Plugins#34). No workaround is built for now; each new Carbon production build is re-tested. Running commands through Carbon's CommandManager by reflection now. 14.2.1
D364 A missing NpcSpawn is reported and refused (Q20): the hello reports NpcSpawn beside Guarded Crate, the site refuses a guarded-crate step without it and says why, and the operator docs say what NpcSpawn does on load. Documenting it only. 14.2.1
D365 Dangerous Treasures' warm-up (Q21): a step that fails because DT is not ready says so ("Dangerous Treasures is not ready yet"), retries once on its own after ten minutes, and offers staff a manual retry on the run. Failing with DT's own text; the bridge pre-warming DT at boot. 14.2.1
D366 One Bradley event, two places (Q22): bradley.destroyed carries `where: launch_site road`, and both count toward Tankbreaker. Two separate events; Launch Site only.
D367 The newer events join §13 (Q23): the satellite crash, the Deep Sea opening and closing, and the ghost ship's crate (a fourth crate location) are feed events and triggers. The F-15 flyover is not an event; it can be added to an event as decoration, a step that calls the flyover (D354). The F-15 as a feed event or trigger. 14.2.1, 13
D368 TruePVE in the zone step (Q24): a "no PvP protection in this zone" checkbox (exclude), plus an optional ruleset name typed by staff, which the bridge validates by AddOrUpdateMapping's own false. Checkbox only; reading TruePVE's config file. 14.2.1, 3
D369 Wipes mirror the plugin (Q25): the site shows what ZLevels and Server Rewards hold and does nothing at a wipe; ZLevels columns are current level, not this wipe. Per-wipe snapshots of our own; a site-side wipe reset. 14.2.1

The org lead answered §14.3's Q26–Q32 on 2026-10-14:

# Decision Rejected §
D370 A trigger can start a run (Q26). Core gains a definition's starts (a trigger, conditions and a cooldown), one run at a time per definition, event.* triggers excluded; core work on the website's own plan, its own PR with the Module-uo proof. Only phase gates; an event that waits for the trigger in its first phase. 14.3.12
D371 A failed step may name its retry delay (Q27): retryAfterMs, 1–30 min, counted as one attempt, a MODULE_API minor bump. DT's warm-up retries ten minutes later through it. The bridge holding the request and starting DT itself later. 14.3.4, 14.3.13
D372 The operator chooses who is credited with a helicopter or a Bradley (Q28): killing blow (the default; for the helicopter, the crippling hit, else the last hit), last hit, most damage, or everyone who hurt it. The plugin sends every candidate; the site applies the server's rule to the feed line and to Rotorbane / Tankbreaker alike. This amends D174. The crippling hit as the one fixed rule; D174's 60 s last-hitter. 14.3.1
D373 A guarded crate outlives its run (Q29): nothing is removed at teardown; the step says so and the run log notes a crate left standing. Ending every guarded crate on the server with gcrate stop all. 14.3.5
D374 The leaderboard is not changed for ZLevels (Q30): no column and no title, because most of its skills already line up with counts of ours. ZLevels is reported at hello, and its XP boosts are lent as permissions (§12). One total column; six columns; one column with a skill picker. 14.3.6
D375 Three new titles (Q31): Treasure Hunter (DT treasures won), Warden (guarded crates cleared), Hoarder (the most RP, above 0), renamable, each with a column on servers that report the plugin. No titles for the plugins' counts. 14.3.4, 14.3.5, 14.3.7
D376 A public line names a player only for the presence audience (Q32): the event and its grid are public ("Patrol helicopter downed at G8"), and the name shows only to the audience the operator chose for presence. The name always public (D353 as written); the whole line hidden below the presence audience. 14.3.1
D377 The leaderboard changes only for something new and substantial. A new title may add its column (Safecracker, Scavenger, Treasure Hunter, Warden, Hoarder do), but a small plugin's own numbers, such as ZLevels' levels, never become columns (org lead, 2026-10-14, confirming D374 beside D375). Adding a column for every number a plugin reports. 14.3.1, 14.3.6
D378 The Chinook is called with no position. It drops only at the map's own drop zones (seven on the rigs' map, all at monuments), and the one route that sends it to a chosen zone borrows the Airfield call terminal's shared state, so a player at the real terminal and a step calling at once would race for one Chinook (stage 1's spike, org lead 2026-10-15). The airdrop and the patrol helicopter take a position. A position snapped to the nearest drop zone through the terminal's state, refused while a real Airfield call is active. 14.3.1, 14.3.14
D379 chinook.downed is a 24th world event, with the credit block and the operator's rule, and counts toward Rotorbane: D174 counted Chinook kills there, and the plan's first draft would have dropped them (org lead, 2026-10-15). Counting Chinook kills through the old tally, with no line; patrol helicopters only. 14.3.1, 14.3.14

11. First-class optional plugins (D199–D202)

The org lead named four plugins the module supports as first-class integrations. Kits already is one. The other three are new, and none of them is mentioned anywhere in PLAN.md or PLAN_FIXES.md. They are optional (D199): the pattern PopupNotifications set (D141) and Clans set (D47) before them.

  • Detected at hello. integrations gains economics, backpacks and raidableBases, each as { loaded, version }, and RaidableBases also reports which edition it is.
  • A step or screen that needs one refuses with a reason when it is absent ("Economics is not installed on this server"). It never disappears and never fails silently, as R3 settled for Kits.
  • Not required by the installer or the egg. overlay.toml's requires_plugins keeps Kits and ZoneManager only.
  • Public API only (R2). Where a plugin lacks what we need, a D168 helper does the rest.

11.1 What was found, and what must be pulled before any build

From the research on 2026-09-28. Every exact signature below is confirmed again from the plugin's current umod.org/plugins/<Name>.cs at the start of its build, the way Kits' was (PLAN.md, "pulled 2026-09-24"). Personal GitHub mirrors are not a source: k1lly0u's mirror of Kits is two major versions behind.

Plugin Author · version · price API we would use Hooks it raises Risk
Economics Wulf · 3.9.2 · free Balance(id) → double; Deposit, Withdraw, SetBalance(id, amount) → bool; Transfer OnEconomicsDeposit, OnEconomicsWithdrawl (sic), OnEconomicsTransfer, OnEconomicsBalanceUpdated, OnEconomicsDataWiped Last released about two years ago; fine as optional, never load-bearing
Backpacks WhiteThunder · 3.17.7 (3.17.8 at the spike) · free API_ReadBackpackContentsAsJson(ownerId); the capacity family (API_GetBackpackCapacity, API_AddBackpackCapacity, API_SetBackpackCapacity) OnBackpackOpened, OnBackpackClosed, OnBackpackDropped, CanOpenBackpack Newer builds add EncryptedValue<ulong> overloads; one community report of backpack data lost across a Carbon restart — a rig test before shipping (not reproduced, §11.3.1)
RaidableBases nivex · free 3.2.0 on uMod, paid on Codefling EventTerritory(Vector3), SpawnRandomBase(type); listing and despawn not yet confirmed OnRaidableBaseStarted (two overloads), OnRaidableBaseEnded, OnRaidableBasePrivilegeDestroyed, OnRaidableBaseDespawn — signatures not yet confirmed from source Two products with different features and release lines; data path differs by framework; needs a PvE-damage plugin (TruePVE) for players to raid

11.2 Economics (D200)

This is the plan as the org lead approved it on 2026-09-28. The spike (§11.2.1) changed two points of it, and §11.2.2 is the design to build:

  • Coins as a reward. A step rust.coins.pay gives each recipient N coins (the same recipient modes as rust.kit.entitle, plus §2.5's linked). It is ledgered per recipient like D103's kit credits, and revert withdraws what the step paid. Superseded by D335: the coins are kept. Core reverts a ledgered step at the end of every run, a normal finish included, so a ledgered payment would take back a prize.
  • Balances. A new read verb, economics.balances {steamIds[]} (bounded like kits.uses), and the OnEconomicsBalanceUpdated hook folded into the tally (rule 2: aggregates only), so the site keeps each player's last balance per server. The player's account page and the app show it; staff see anyone's.
  • Staff adjust. Deposit, withdraw and set from the player's admin page, through the plugin's API. Each is an activity row with the amount before and after.
  • Richest. A leaderboard column (the last known balance) and a title condition, Magnate by default, admin-renamable like every other category (D175). It is a best column in §5.5's sense, the current value rather than a sum. It ranks only balances above Economics' starting balance (D336).

11.2.1 The spike (2026-10-12)

The source is Economics 3.9.2, Wulf, pulled from umod.org/plugins/Economics.cs (845 lines) on 2026-10-12. A throwaway plugin called its API and logged every hook on both rigs, on Rust 25877464 with Oxide 2.0.7826 and Carbon 2.0.262. Both frameworks answered identically, line for line. The spike and Economics were then removed from both rigs, with their data and config.

The API is five private methods, each with a string and an object overload (the object one takes a ulong). Both overloads bind through Call on both frameworks.

Call Returns Found
Balance(id) double An account Economics has never seen reads as the starting balance (1000 by default). Nothing is stored for it, and at load Economics deletes every account that sits exactly at the starting balance ("Remove unused accounts", on by default). So "1000" cannot tell a new player from one who earned and spent back to it
Deposit(id, amount) bool Refuses an amount of 0 or less. Returns true while adding less than asked: the balance is rounded to two places and silently clamped to the balance limit, so Deposit(id, 0.004) answers true and changes nothing. What was paid is the balance after less the balance before, never the amount asked
Withdraw(id, amount) bool All or nothing. It refuses an amount above the balance (unless the config allows negative balances); there is no partial withdrawal
SetBalance(id, amount) bool Refuses a negative amount unless the config allows one; rounds and clamps like Deposit
Transfer(from, to, amount) bool A withdrawal and a deposit. Not needed
  • The amount must be a double. Call("Deposit", id, 10), with an int, matches no overload on either framework: Call returns null and nothing happens. The bridge boxes every amount as double.
  • A string id is not checked. Deposit("notanid", 1.0) stored an account named notanid. The bridge sends only ids it has parsed as Steam ids.
  • Cost: 500 Balance calls took 2.6 ms (Carbon) and 2.8 ms (Oxide) on the main thread.
  • Balances survive a reload on both frameworks (data/Economics.json, written at unload and at every server save).
  • What the API does not give: the starting balance, the balance limit, and whether balances wipe with the map. They are in Economics.json in the framework's config directory, which the bridge already reads for Kits (PROTOCOL.md §16.3). Economics' own chat and console commands find a player through covalence, so they cannot reach a Steam id the server has never seen; the API can.

The hooks, in the order one deposit raises them:

Hook Arguments When
OnEconomicsBalanceUpdated (string playerId, double balance) Every change, including the ones below, first. The new balance, not the change
OnEconomicsDeposit (string playerId, double amount) After a deposit; the amount asked, even when less landed
OnEconomicsWithdrawl (sic) (string playerId, double amount) After a withdrawal
OnEconomicsTransfer (string from, string to, double amount) After a transfer's withdrawal and deposit
OnEconomicsDataWiped none, or (IPlayer player) None at a new save when the config wipes balances; the IPlayer after ecowipe. One method taking an IPlayer binds both shapes on both frameworks, with null for the first

Two traps on the rigs, neither Economics' fault: a Rust update needed two panel restarts on each rig, because the first boot ran the old build with the download unfinished (StateFlags 1158 and 1062). And Carbon kept Economics loaded after its file was deleted, until c.unload; Oxide unloaded it.

11.2.2 The design to build (D335, D336)

Hello. integrations.economics is { loaded, version, startingBalance, wipesWithMap }. The last two come from Economics.json. When the file cannot be read, they are absent, the site assumes Economics' default of 1000, and the servers page says so.

Paying coins: economics.pay, site → game. A one-shot command, because the coins are kept (D335):

{"cmd":"economics.pay","reqId":"r-31","key":"7:4","amount":500,"steamIds":["76561190000000001"],
 "message":"You earned {amount} coins","delivery":"popup"}
{"kind":"economics.paid","type":"reply","reqId":"r-31","key":"7:4","repeat":false,
 "paid":[{"steamId":"76561190000000001","before":1000,"after":1500}],"failed":[]}
  • Main thread, one call for the whole list. For each id it reads the balance, deposits, and reads it again. paid holds the before and after values; a deposit that moved nothing, at the balance limit, is listed in failed with at-limit.
  • The key is remembered with its reply, on disk (data/RunicGateway/payments.json) for seven days, as tallies are. A retried step is answered from the first reply with repeat: true, and never pays twice.
  • The message is told once to each player paid: at once if they are awake, otherwise when they next wake on this server, as reward notices are (PROTOCOL.md §20.2). The untold ids are kept in payments.json with the key. {amount} is the amount the player received. A blank message tells nobody.
  • Bounds: at most EventsMaxRecipients ids, and an amount from 0.01 to 1,000,000. Refusals are economics.error: economics-missing, events-disabled, bad-amount, too-large.

Reading balances: economics.balances {steamIds[]}, answered with {balances: [{steamId, balance}], startingBalance}, and bounded by the sync's own bound (PermMaxOps), like kits.uses. A balance at the starting value may belong to an account Economics has never seen (§11.2.1).

Staff: economics.adjust {key, steamId, op, amount}, where op is deposit, withdraw or set. It answers {before, after}, and a remembered key is answered with repeat: true like a payment. A withdrawal above the balance is refused with insufficient and the balance, because Economics will not take part of it. Staff then see "has only 340".

The tally. player.tally gains balance: the current balance, a value and not a change, like the best* fields. It is sent when it moved since the last flush, and it is read:

  • when a player connects, so a balance carried over from last wipe is known without waiting for it to move (D336); and
  • from OnEconomicsBalanceUpdated, for an id that parses as a Steam id. The player need not be online: a payment or a staff adjustment reaches an offline player, and that frame carries no name.

economics.wiped, an event from OnEconomicsDataWiped in either shape. The site clears the server's current-wipe balances, since everyone is back at the start.

The sidecar adds POST /economics/pay, POST /economics/balances and POST /economics/adjust, thin forwards like /kits/uses. It keeps nothing.

The website (Module-Rust).

  • rust.coins.pay, "Pay coins": risk: 'irreversible', reversible: 'none' (D335), as Module-uo's uo.item.grant is. Its params are the server, the amount, the recipients and count (the modes of rust.kit.entitle, including linked), and the message and delivery. One payment goes to each website user: of two linked accounts that both took part, the higher-scoring one is paid, as for kits (D103). The run log lists what each person received, anyone missed for having no linked account, and anyone at the limit. A run cancelled or aborted after the payment keeps it; staff take coins back by hand.
  • Balances are stored in rust_player_wipe_stats.balance, written as a value, never summed. They are shown on the player's own Rust page, per linked account and server, with a new GET /player/rust/economics that reads live and falls back to the last known balance, and on a user's admin page beside their links.
  • Staff adjust through POST /admin/users/:userId/rust/economics, { serverId, steamId, op, amount }. Each adjustment is a core activity row: "Deposited 500 coins to name on server: 1,000 → 1,500".
  • Richest. A leaderboard column on servers that report Economics, and a title category richest, Magnate, whose standing is the current balance, counted only above that server's starting balance (D336). A fresh wipe's 40 untouched players at 1,000 are not on it; a player carrying 5,000 over is.
  • Every surface that needs Economics refuses with a reason where it is absent (D199): the step's verify, the adjust form, and the account page ("Economics is not installed on this server").

The app (Android-app). The Rust account screen shows the player's balances from the same route, capability-gated like the rest of that screen (R10).

Not touched: the installer and the egg. requires_plugins stays Kits and ZoneManager (D199).

11.2.3 The walk (deferred to the end, D331)

On both rigs, with Economics installed:

  • pay three players from a tally, one of them offline, and see the popup on wake;
  • retry the step, and see repeat and no second payment;
  • pay at the balance limit, and see at-limit;
  • deposit, withdraw (including more than the balance) and set from the admin page, and read the activity rows;
  • watch the richest column and Magnate on a fresh wipe, then with a carried-over balance;
  • run ecowipe, and see the column clear;
  • read a balance on the account page and in the app.

11.2.4 Built (2026-10-12)

Built to §11.2.2 on protocol 14, and specified in PROTOCOL.md §20.3. The bridge compiled on both rigs (Rust 25877464; Oxide 2.0.7826, Carbon 2.0.262), which were then given back their protocol 13 bridge. Six points the design left open were settled in the code, and are named here so they can be overruled:

  • economics.wiped carries newSave. After ecowipe the site clears the wipe's balances; at a new save it clears nothing, because the new map's balances arrive as players connect, and the last wipe's stay as the record of how it ended.
  • A balance frame with no name is not play. The plugin names only a connected player, and the site writes such a frame's balance without moving anybody's last-seen.
  • The Coins column is per wipe. A balance is a value, so there is no all-time one: the column and sort=balance exist only when one wipe of a server that reports Economics is shown.
  • A payment's failed reasons are at-limit, refused, unmeasured (deposited, but not measured — remembered with the key so a retry pays nothing more) and bad-id.
  • Payments and adjustments keep their keys apart, so a step's key can never be answered with a staff adjustment's reply. The admin form sends a fresh key with each submission.
  • The step spends a new rust.coins budget, priced like the kit reward: the amount times the count, or times the recipient bound for a mode that is not a count.

11.3 Backpacks (D201)

This is the plan as the org lead approved it on 2026-09-28. The spike (§11.3.1) changed how the extra space is given, and §11.3.2 is the design to build:

  • Read, never write (R2). A new verb, backpacks.read {steamId}, returns API_ReadBackpackContentsAsJson reduced to shortname, amount, skin and slot. It never uses the write counterpart, so the site cannot change a backpack.
  • Staff open any player's backpack from the player's admin page. A player sees their own on the account page and in the app, only for accounts they have linked (the player tier's usual scoping).
  • Extra space as a reward. A step rust.backpack.capacity adds N slots for the event's duration through API_AddBackpackCapacity, and takes them back at teardown, ledgered like a lease. If the player is offline at either end, it waits for their next connection: the capacity calls take a BasePlayer. Superseded by D337: the step leases one of Backpacks' size permissions for the run instead. The capacity calls work only on a server whose owner has turned on an experimental setting (§11.3.1).
  • The spike first: the Carbon restart report. Fill a backpack on rust-carbon, restart, and check it. Done; the loss did not reproduce (§11.3.1).

11.3.1 The spike (2026-10-12)

The source is Backpacks 3.17.8 (one patch past the 3.17.7 that §11.1 names), WhiteThunder, pulled from umod.org/plugins/Backpacks.cs (9,782 lines) on 2026-10-12. A throwaway plugin made stand-in players with Steam-shaped ids (the RunicNPC 9d pattern), called the API and logged every hook, on both rigs, on Rust 25877464 with Oxide 2.0.7826 and Carbon 2.0.262. Both frameworks answered identically everywhere the plugin was concerned. The spike and Backpacks were then removed from both rigs, with their data, config and lang files, and the permissions the stand-ins held.

The restart report did not reproduce. A backpack changed after the last save (the save interval raised to an hour first, so no timed save could run) was intact after a panel restart on both frameworks, for an online owner and an offline one alike. A plugin reload kept it too. Backpacks saves at every server save and again at unload. The one way the spike lost a backpack was its own: the first run killed its stand-ins at unload, Backpacks took each kill for a death, and Drop on death, on by default, dropped the contents on the ground. On Carbon, Backpacks was still loaded when the spike unloaded during shutdown; on Oxide it was not, so only Carbon showed it. That is Backpacks working as configured: any plugin that kills a player entity drops that player's backpack. The bridge kills no players.

Reading.

Call Found
API_ReadBackpackContentsAsJson(ulong) A JSON string, or null for a player who has never had a backpack. It loads an offline player's backpack from disk. 0.2–7 ms once loaded; 55–117 ms the first time after a boot (Oxide logs its own "took 116ms" warning), on the main thread
API_CountBackpackItems(ulong, null) The number of stacks
API_GetBackpackCapacityById(ulong, string) The capacity an id would have. Caches it (see the trap below)

The JSON is a list of Backpacks' own ItemData, not of Rust items:

[{"ID":-151838493,"Position":0,"Amount":1000},
 {"ID":1545779598,"Position":2,"Amount":1,"Contents":[{"ID":567235583,"Position":0,"Amount":1}]},
 {"ID":1751045826,"Skin":10129,"Position":3,"Amount":1},
 {"ID":1414245162,"Name":"spike-a","Position":4,"Amount":1}]
  • ID is the item id, not the shortname; the bridge maps it with ItemManager.FindItemDefinition.
  • Position runs across pages: page two of a 96-slot backpack starts at 48 ("Max size per page").
  • Contents holds a weapon's attachments or a container's items, nested. Name is a renamed item's name. Condition, ammo, fuel and the rest appear when they differ from the default.

Capacity. A player's size comes from permissions: backpacks.use gives the default (6), and the highest backpacks.size.N they hold sets it, for each N in the config's Permission sizes (6, 12, 18, 24, 30, 36, 42, 48, 96 and 144 by default). Backpacks registers each one, so the bridge can list them.

  • API_AddBackpackCapacity and API_SetBackpackCapacity throw (InvalidOperationException, logged with a stack trace on both frameworks; Call returns null) unless the config's "Dynamic Size (EXPERIMENTAL)" is on, and it is off by default. With it on, each player is clamped to the initial and maximum size of a size profile they hold (backpacks.size.profile.6-48 …); a player with no profile gains nothing. They take a BasePlayer, so an offline player cannot be given space. This is why D337 leases a permission instead.
  • A size permission takes effect at once, both ways, for a player whose backpack is loaded (an online player who has opened it, or whose backpack anything has read): granted backpacks.size.48, 6 became 48; revoked, 48 became 6. An offline player's grant is simply there when they connect.
  • The trap: a capacity cached without its backpack. Backpacks clears its capacity cache on a permission change only for a backpack it has loaded. If anything reads an online player's capacity while their backpack is not loaded (API_GetBackpackCapacity*), the value is pinned: a size granted afterwards did not show, even after the owner opened the backpack, until they disconnected. Same on both frameworks. Backpacks itself reads capacity only through a loaded backpack, so only another plugin's call can cause it. The bridge never reads a capacity except through a backpack it has just loaded with a read.

Taking space back (the org lead's test, 2026-10-12). A stand-in held backpacks.size.96, six stacks on page one and five notes on page two (slots 48–52); the permission was revoked.

State What happened, on both frameworks
After the revoke, before the owner opens it Nothing moves. Capacity reads 6, and the read still returns all eleven stacks: the five past slot 6 wait in the backpack
The owner opens it, inventory has room The five notes moved into the owner's inventory (main 5/24), and the backpack read six stacks. Backpacks tells the owner in chat: "Your backpack was over capacity. Overflowing items were added to your inventory or dropped."
The owner opens it, inventory full (30/30) The five notes dropped on the ground at the owner's feet as loose items, and the backpack read six stacks

That is Backpacks' default, the behaviour a VIP page that lapses already has, and it stands (D338). A staff member looking at someone else's backpack sees the overflow too; only the owner's own open moves it.

Hooks. OnBackpackOpened(BasePlayer looter, ulong ownerId, ItemContainer) and OnBackpackClosed fired for the owner's open and close; OnBackpackDropped(ulong ownerId, List<DroppedItemContainer>) for each death drop. None is needed by the design.

Two quirks the bridge never meets, because it never writes: API_TryDepositBackpackItem took only the first item into a backpack nobody had opened yet, and API_GetBackpackContainer returns null for a backpack that does not exist yet. A page that has never been opened is sized to what it holds until its owner opens it.

Found on the way, outside Backpacks: on Carbon, a permission granted to a Steam id the store has no user record for leaks onto every such id. Filed as Rust-Plugins#32 and kept out of §11.3 (D339). The size lease grants through perm.sync like a kit reward, so it inherits whatever that issue decides.

11.3.2 The design to build (D337, D338)

Hello. integrations.backpacks is { loaded, version, sizes }. sizes lists the N of every registered backpacks.size.N permission, ascending ([6, 12, 18, 24, 30, 36, 42, 48, 96, 144] by default), read from the permission store, so an operator's own Permission sizes are what the site offers.

Reading a backpack: backpacks.read {steamId}, site → game.

{"cmd":"backpacks.read","reqId":"r-40","steamId":"76561190000000001"}
{"kind":"backpacks.contents","type":"reply","reqId":"r-40","steamId":"76561190000000001","exists":true,
 "capacity":12,"pageSize":48,
 "items":[{"slot":0,"shortname":"wood","amount":1000},
          {"slot":2,"shortname":"rifle.ak","amount":1,
           "contents":[{"slot":0,"shortname":"weapon.mod.small.scope","amount":1}]},
          {"slot":3,"shortname":"hoodie","amount":1,"skin":10129},
          {"slot":4,"shortname":"note","amount":1,"name":"spike-a"},
          {"slot":14,"shortname":"note","amount":1,"overflow":true}]}
  • One id per call, on the main thread. It calls API_ReadBackpackContentsAsJson, and only when that answered a backpack does it read API_GetBackpackCapacityById: that backpack is now loaded, so the capacity follows later grants (§11.3.1's trap). A player who has never had one is exists: false, with no capacity.
  • Each stack is reduced to slot, shortname, amount, and skin and name when set; nested contents keep the same shape, one level deep. An id no item definition knows is sent as shortname: null with its itemId.
  • overflow: true marks a stack at or past the capacity: it is waiting for its owner's next open to be moved (§11.3.1).
  • Refusals are backpacks.error: backpacks-missing, bad-id.

Extra space: a size lease (D337). A new step, rust.backpack.size, "Bigger backpack", grants one of integrations.backpacks.sizes for the run, to the recipients a mode picks.

  • It is a kit reward with a different permission. It writes one rust_perm_run_grants row per recipient, with permission = backpacks.size.N and no kit. The permission mirror pushes it in the next perm.sync, so a recipient who is offline has it when they connect, and a wipe cannot take it away. Its revert deletes the step's rows, and the next sync revokes them. So the space lasts until the run ends, a normal finish included (core reverts every ledgered step at the end of a run, §11.2).
  • No plugin code. The grant, the revoke and their read-backs are perm.sync's.
  • Params: the server; the size (a select of the server's sizes above the smallest, refused when it is not one of them); the recipients and count (rust.kit.entitle's modes, linked included); and a message and delivery, told once like a kit reward's notice (D333). The default message warns about the end: "Your backpack holds {size} until the event ends. Anything past your normal size then moves to your inventory, or drops at your feet if it is full."
  • The highest size wins. A recipient who already holds a size at least as big gains nothing, and nothing is taken from them at the end. The step's description says so.
  • The take-back is Backpacks' own (D338): nothing moves at the revoke, and the overflow goes to the owner's inventory, or the ground, the next time they open the backpack.
  • risk and reversible are rust.kit.entitle's (change, ledger). It spends a new rust.backpacks budget, Backpack sizes: one per recipient, or the recipient bound for a mode that is not a count, as kit rewards are priced.
  • It refuses where Backpacks is absent (D199): "Backpacks is not installed on this server".

The sidecar adds POST /backpacks/read, a thin forward like /kits/uses. It keeps nothing.

The website (Module-Rust).

  • Staff see a player's backpack on the user's admin page, per linked account and server, through GET /admin/users/:userId/rust/backpacks?serverId=&steamId=. A player sees their own on their Rust page through GET /player/rust/backpacks?serverId=&steamId=, for their own linked accounts only.
  • Live, never stored. Each view is one backpacks.read; nothing is written to the database. A server that is down or lacks Backpacks says so on the panel. Opening the panel reads once; there is no polling.
  • The grid draws the slots as pages of pageSize, with each stack's icon from the item catalogue, its amount, and an overflow mark.

The app (Android-app). The Rust account screen shows the player's own backpack from the player route, capability-gated like the rest of that screen (R10).

Not touched: the installer and the egg. requires_plugins stays Kits and ZoneManager (D199). The bridge uses none of Backpacks' hooks.

11.3.3 The walk (deferred to the end, D331)

On both rigs, with Backpacks installed and a real player:

  • open a staff view of a full backpack, a two-page one, and one that has never been used;
  • read your own on the account page and in the app, and see another player's refused;
  • run a Bigger backpack step, online and offline at the grant, and see the size on the next open;
  • end the run with items on the extra page, open the backpack, and see them move to the inventory; then again with a full inventory, and see them on the ground;
  • remove Backpacks, and see the step and the panels refuse with a reason.

The walk's players must have real Steam ids: the bridge reads only a 17-digit id at or above 76561197960265728, so the spike's 7656119000000000N stand-ins are refused bad-id (§11.3.4).

11.3.4 Built (2026-10-12)

Built to §11.3.2 on protocol 14, and specified in PROTOCOL.md §20.4. The bridge compiled on both rigs (Rust changeset 167584; Oxide 2.0.7826, Carbon 2.0.262), which were then given back the bridge they ran before. Before any code the grid's "item catalogue" turned out not to exist; D340 and D341 answered it. These points the design left open were settled in the code, and are named here so they can be overruled:

  • Each stack carries display, the game's English name (D340); for a blueprint, the name of what it makes (D341). The grids on the site and in the app show it, then the shortname, then item <id> for an item the game no longer knows.
  • A third refusal, unreadable, for contents the bridge cannot parse. Backpacks writes the JSON itself, so it should never happen; it is a sentence rather than an exception if it does.
  • The step's message is never silent. Left empty, it is the default take-back warning, because a player nobody warned is the one surprised by D338. An author can reword it, with {size}.
  • The size picker offers each server's sizes above its smallest, grouped by server, from the last hello. The step checks the pair it is given, and names the sizes the server does offer when it refuses one.
  • The panels offer every enabled server and learn that one lacks Backpacks when the read answers 409, so nothing about which mods a server runs reaches the public server list.
  • The player route is rate-limited to 20 reads a minute, because a read runs on the game's main thread.
  • A skin id is text on the site's API: workshop ids are labels, and some pass 2^53.
  • The app gates the section on the Coins read's 404: Backpacks and Economics ship in the same protocol, so an older module has neither.

11.4 RaidableBases (D202)

  • Live map and feed. OnRaidableBaseStarted / Ended / Despawn / PrivilegeDestroyed become events (raid.started, raid.ended, …) carrying the base's position, difficulty and owner. The map draws active bases as a layer with its own audience switch (D114), and the feed says who raided what.
  • An event step, rust.raidbase.spawn, spawns a base at a difficulty near a monument or position, owned by the run. The run's teardown despawns it, and D170's expiry pattern marks it expired if the plugin ends it first.
  • Stats and titles. Bases completed per player per wipe (the player credited with the tool cupboard, as the plugin credits it), a leaderboard column, and a title condition, Warlord by default.
  • The paid edition (D202). Hello reports the edition. Where it is the paid one, the spawn step offers its five difficulties and the feed carries lockouts. On the free edition the step offers the one difficulty it has. Testing the paid edition needs a licence for the rigs, which is the org lead's to buy or supply. D348 (2026-10-14) widens this: every part works on either edition, by the best route each has (§11.4.2).
  • The spike first: pull the free source, confirm every hook and API signature above, and find the listing and despawn calls. Then load it on both rigs, with TruePVE, and raid one base.

11.4.1 The free edition, as the spike found it (2026-09-28)

The source is RaidableBases 3.2.0 (free, umod.org/plugins/RaidableBases.cs), and it was loaded on both rigs with CopyPaste 4.3.0. A throwaway spike plugin made one base file from the walk hut and logged every lifecycle hook. It was deleted afterwards, and so were the three plugins on both rigs.

What it needs. CopyPaste is a hard dependency. A base is a CopyPaste building file in data/copypaste/, and none ship with the free edition: the default profile RaidBases names RaidBase1–RaidBase5, and none of them exist. A base with no storage box is thrown away as soon as it is pasted ("No usable boxes found"). A PvE plugin (TruePVE, SimplePVE, NextGenPVE, Imperium or AegisPVE) is optional.

What it costs at boot. OnServerInitialized took 6.7 s (Oxide) and 7.9 s (Carbon) on the main thread. The spawn grid then builds for about a minute: 52 s for 1,172 points on the rigs' 3000 map. During that minute a spawn is refused ("grid is loading").

Its public API ([HookMethod]):

Call Returns Use to us
IsPremium() false on the free edition hello's edition field
GetAllDifficulties() [("Normal", 512)] on the free edition the step's difficulty list, read from the installed plugin rather than hard-coded
GetAllEventsCount(), GetActiveEventCount() ints the servers page
GetAllEvents() / GetAllEventsNonAlloc(list) an 18-field tuple per base: position, mode, level, PvP, owner, raiders, intruders, entities, base name, spawn and despawn time, radius, loot left the live map layer. The plugin warns that GetAllEvents changes shape in the next update.
EventTerritory(pos), …Any, …All, GetPlayersFrom, GetOwnerFrom, HasPVPDelay not needed

Nothing in the API spawns, despawns, or lists profiles.

The hooks it raises. OnRaidableBaseStarted, Ended, Completed, Despawn, Despawned and PrivilegeDestroyed all carry the same 17 arguments:

(Vector3 location, int level, bool allowPVP, string id, float, float, float loadTime, ulong ownerId,
 BasePlayer owner, List<BasePlayer> raiders, List<BasePlayer> intruders, List<BaseEntity> entities,
 string baseName, DateTime spawn, DateTime despawn, float protectionRadius, int lootRemaining)

A plugin method with exactly that signature binds on both frameworks. Started also has a one-argument overload carrying the plugin's own class, which the bridge cannot name and does not need. Other hooks: OnPlayerEnteredRaidableBase / Exited (14 arguments, the player first), OnRaidableBaseUnlocked, OnRaidableAwardGiven / Owner, the PvP-delay hooks and loot hooks.

How another plugin spawns a base: only through the server console.

  • rbevent [baseName] queues a base, and RaidableBases chooses where. On the rigs, three spawns landed at Q6, C13 and C4, and none of them was named by the command. There is no position, no monument and no difficulty argument, and nothing comes back to the caller.
  • rbe pastes where a player is looking, so it needs a player in the game.
  • Every base's id is "0" on the free edition. The only way to know which base the event made is the next Started for that base name after the command. That is safe while events make one base at a time.

How another plugin despawns a base: all or nothing. rbevent despawnall removes every base on the server, including ones the event did not make, and rbevent despawn_inactive removes every inactive one. Despawning one base needs a player standing at it. Left alone, a base despawns on its profile's own timer: 45 minutes in the default profile, as despawn in Started said.

Its options. Each profile has well over a hundred settings. Among them: NPCs, turrets, loot rules, despawn timers, the plugins to block, where bases may spawn (beaches, roads, monuments), what players may do, and backpacks. The global config has its own schedulers (Maintained, Scheduled and Manual events), map markers, ZoneManager zones, a ranked ladder and messages. Profiles live in the plugin's data directory, which the site's configuration editor deliberately never walks. The global config is in the config directory, where the editor already reaches it.

What that means for D202's event step. "Spawn a base at a difficulty near a monument, and despawn it at the end" is not possible on the free edition through anything RaidableBases offers another plugin. What it does offer is: spawn a named base, somewhere, now; learn where and when it will despawn from the hook; and remove every base at once. §11.4.3 asks which way to go.

11.4.2 The paid edition: an old copy read (2026-10-14), the current copy still to come

RaidableBases is deferred until the org lead supplies the current paid copy (D347). Runic Gateway supports both editions as far as each allows (D348).

The only paid copy at hand on 2026-10-14 was 3.0.5, from 2025-05-03 (IsPremium() returns true). By then uMod's free edition was 3.2.2; the spikes above ran on 3.2.0. The org lead asked for the free source to be compared with that old paid copy before anything else, and the comparison was a reading only. Nothing was loaded on a rig. Because 3.0.5 is older than 3.2.2, most of what differs is probably old against new rather than paid against free. The current paid copy settles which.

The same in both, so §11.4.3–§11.4.5 stand on them:

  • OnRaidableBaseStarted, Ended, Completed, Despawn, Despawned and PrivilegeDestroyed carry the same 17 arguments in the same order (§11.4.1). Only level differs: the free edition always passes 512, and the paid one passes the base's tier, 0 (Easy) to 4 (Nightmare). OnPlayerEnteredRaidableBase / Exited keep their 14 arguments, with the same split on level.
  • rbevent [baseName], rbevent despawnall / despawn_inactive and rb.reloadprofiles all work as before.
  • Every base's id is "0" in both.
  • Everything §11.4.4's helper reached has the same name and shape:
    • the plugin's list of live bases, OpenEvent(RandomBase) (which sets raid.Options = rb.options), and each base's own Despawn();
    • the shallow BuildingOptions.Clone() trap, PasteBuilding(RandomBase), and RandomBase itself (the paid one adds a payments field);
    • the profile settings the overrides change: AllowPVP, SpawnAmountMurderers / SpawnAmountScientists, DespawnMinutes / DespawnMinutesInactive, OverrideConfig, and Setup.EnableForcedHeight / ForcedHeight / PasteHeightAdjustment. Their JSON names match too. Only the NPC counts' defaults differ: -9 free, 1 paid.
    • the "Initialized Grid" log line that ends the boot-time spawn refusal.

Where paid 3.0.5 differs from free 3.2.2:

Area Free 3.2.2 Paid 3.0.5 What it touches
Difficulties GetAllDifficulties() → [("Normal", 512)] No GetAllDifficulties. GetRaidableModes() returns names: Easy, Medium, Hard, Expert, Nightmare the step's difficulty list (§11.4.3, D205)
Live bases GetAllEvents() and GetAllEventsNonAlloc: an 18-field tuple, with the level as an int. Plus GetAllEventsCount and GetActiveEventCount GetAllEvents(Vector3, float): a 17-field tuple with the mode as a string and no level int; the position is ignored. No count or non-alloc calls the map layer and the servers page (§11.4.1's API table)
Spawning rbevent [base] only rbevent also takes a difficulty name and a count. A private SpawnRandomBase(string baseName, Vector3 position, int mode, int type, bool free) that another plugin can Call returns "API_INVALID_PROFILE" or a result, and queues the base at that position when one is given D203 and D207: a position and a difficulty without the helper's reflection
Lock to first attacker Each base has BypassUseOwners(), and the config has a Manual pair and a Management pair No BypassUseOwners() method and no Manual pair. The Management pair is checked inline in three places D206's patch target is missing
Water No seabed spawns Profiles can place bases on the seabed (Water.Seabed, with minimum and maximum depths) D208's "in water" refusal would block a profile built for the seabed
The area check SpawnsController.IsAreaSafe(…, RaidableType type) One more optional parameter at the end (BuildingOptionsDifficultySpawns) the helper has to bind it by name, not by an exact parameter list
Paid-only hooks — OnRaidableBasePurchased (10 arguments: buyer id, position, grid, level, PvP, …, base name, spawn and despawn times), OnPurchaseBase, OnPurchaseTakePayments, OnRaidableResetPayLock, OnRaidableBaseHint the feed. No hook announces a lockout, so §11.4's "the feed carries lockouts" has nothing to listen to in 3.0.5
Free-only hooks OnRaidableBaseUnlocked, and a 17-argument OnRaidableAwardOwner Neither. A 5-argument OnRaidableBaseCompleted overload instead the feed. The bridge binds only the 17-argument hooks, so it does not hear the overload
Ranked ladder A player's Raids and TotalRaids Adds Points, TotalPoints and a count per difficulty none: Warlord counts from the bridge's own Completed hook
loadTime Time.realtimeSinceStartup Time.time nothing we read

When the current paid copy arrives, the same spike runs against it on both rigs, and this section records:

  • its version, and a fresh read of the free edition's at the same time, since each may have moved;
  • IsPremium(), and whether it has GetAllDifficulties(), GetRaidableModes() or both;
  • whether 3.0.5's callable SpawnRandomBase is still there and takes the same parameters, and whether it now adds a despawn of one base or real base ids;
  • whether the 17-argument hooks and GetAllEvents have the same shape as free 3.2.2's (the free edition warns that the latter is changing), and what level carries per difficulty;
  • whether BypassUseOwners() and the Manual lock pair exist, which decides how D206 is built;
  • lockouts, buyable events and the ranked ladder: the hooks they raise and what the feed could say;
  • the seabed option, and how D208's water test should treat it;
  • everything §11.4.4's helper touches, rerun on both rigs (Oxide and Carbon), as the helper spike was on the free edition.

What D348 asks of the build. Every part of §11.4 asks the installed copy what it has, rather than assuming an edition:

  • Detection. Hello reports the edition (IsPremium()) and the version. The bridge and the helper check for each call and member they use, by name, and report what they found. "Paid" or "free" is never taken to mean that a call exists.
  • Every edition gets each part, by the best route it has. Difficulties come from whichever list call exists. The live map is built from the lifecycle hooks, which both editions raise alike, and needs neither GetAllEvents shape. A spawn uses the plugin's own position-and-difficulty call where there is one, and the helper's route (§11.4.4) where there isn't.
  • Where a part has no route on the installed copy, the site says so on the step, the page or the feed, as D199 does for a missing plugin. It never offers something that fails at run time. The open cases on 3.0.5 are the lock (D206) and lockouts in the feed.
  • The checks D348 adds are part of the build's tests: each mechanism is exercised against both editions' shapes, and the end walk (D331) covers a rig with each edition.

The design in §11.4.3–§11.4.5 stays, and D348 governs how it is built. Which route each part takes on the current paid edition is recorded here after its spike, before any §11.4 code.

11.4.3 Decided (2026-09-28)

The org lead answered the spike's three questions the same day:

  • D203: where a base goes — RaidableBases picks. The step spawns the chosen base with rbevent, and RaidableBases places it wherever its grid allows. The bridge learns the spot from the next OnRaidableBaseStarted for that base name. The site then shows it on the live map and in the run log, and records it on the run's ledger. If the paid edition offers placement, the step offers it there too (§11.4.2).
  • D204: at the end — a helper removes only the event's base. Teardown, a revert, and an operator's stop each despawn exactly the base the run made, never another. The free edition cannot do that from outside (§11.4.1), so a D168 helper, RunicGatewayRaids.cs, finds the run's base by the position and spawn time Started reported, and calls RaidableBases' own despawn on that one base. Like the zone helper (D181, D182), it is optional and default-installed, and it reports at hello. Without it, the step still spawns, and the base despawns on its own timer. The site says so on the step, and the run records the base as expiring on its own, like D170's zones. A base the plugin despawns first is marked expired.
  • D205: what an event can choose — a profile, a difficulty, and per-event overrides; and the site edits profiles.
    • Profile and difficulty. The step picks a RaidableBases profile, and a difficulty from the installed plugin's GetAllDifficulties(). The free edition offers "Normal"; the paid edition offers its five.
    • Per-event overrides. The step may override a bounded set of a profile's settings for this spawn only. The first set is PvP on/off, despawn minutes, NPCs on/off and count, and the lock-to-first-attacker rule; the build's spike settles which of them the plugin lets a helper change after Started. The free API has no way to do this, so the helper applies them. Without the helper there are no overrides, and the step says so.
    • The site edits profiles. The configuration page gains RaidableBases' profile files, data/RaidableBases/Profiles/*.json. This is a named exception to the rule that the editor never walks a plugin's data directory, which exists because that directory holds live state and the permission stores. The exception is that one folder, for that one plugin. It gets the editor's usual guarantees: the write is recorded, a reload is watched, and a failed load rolls back (D178, D179). The reload is rb.reloadprofiles, which the spike ran on both rigs, not a plugin reload.

Still to settle in the build's own spike: the same helper questions on Carbon and on the paid edition. Also the one-at-a-time rule: while a first base of the same name is still waiting to start on a server, the step refuses to spawn a second one, because the free edition's bases have no ids to tell them apart. §11.4.4 may retire that rule.

11.4.4 The helper spike (2026-09-28)

The org lead asked for two tests of a D168 helper before the build: can it despawn one base, and which settings can it override? A third question came with them: can an admin give the spot, meaning x, z and a height adjustment? The spike ran on the rust-oxide rig against the free edition, 3.2.0. Carbon and the paid edition are still to run (§11.4.2). Every finding below reaches RaidableBases' internals by reflection and a Harmony patch. None of it is public API, so the helper has to check the plugin's version at hello, as the zone helper does.

  • Despawning one base works. The plugin keeps its live bases in a list on the plugin, and each base has its own Despawn(). Calling it on one base removed that base and no other. It fired the usual OnRaidableBaseDespawn and OnRaidableBaseDespawned hooks, so the bridge's feed sees nothing new.

  • The helper can know exactly which base is the event's. A Harmony postfix on the plugin's OpenEvent receives the new base object itself. The helper can tag it for the run at birth, instead of matching the position and spawn time Started reports afterwards (D204). The one-at-a-time rule above may then go, if the build confirms it on Carbon and the paid edition.

  • Overrides go in at OpenEvent, not after Started. At OpenEvent the base holds a reference to its profile's settings object, which every base of that profile shares. The helper swaps in a deep copy (a JSON round trip), changed for this spawn. The shared profile was checked afterwards and was untouched. Proven on the rig:

    • PvP on/off;
    • despawn minutes (inactive and total, via the profile's own "override config" switch);
    • NPCs on/off, and the scientist and murderer counts.
  • Lock-to-first-attacker is not a profile setting. It lives in RaidableBases' main config, in two pairs of switches (PvP and PvE): one pair for "Manual" bases, which is what rbevent and the helper spawn, and one pair for scheduled bases. A per-event lock would need a second Harmony patch, on the base's BypassUseOwners(). Not built; a question for the org lead.

  • An admin-given spot works. The helper builds the same spawn request the plugin's own "spawn where I look" command builds, from numbers instead of a player's view. It then calls the plugin's paste. Bases landed at the given x and z every time.

  • Height has to go through a copy of the profile. The plugin recomputes y at paste time from the ground (or water), plus the building's own height, plus the profile's paste height adjustment. It ignores any y it is handed, unless the profile forces a height. So the helper copies the profile:

    • an adjustment is added to the copy's paste height adjustment;
    • an absolute y turns on the copy's forced height.

    Results, measured as the height of the base's building blocks:

    Request Blocks at Ground there
    no adjustment 1–4 −0.2
    +5 6–9 −1.1
    absolute y = 30 31–34 0.5
    a plain rbevent spawned right after 1–4 −0.2

    Trap: the plugin's own BuildingOptions.Clone() is a shallow MemberwiseClone. The first run changed the height through that clone, and the change leaked into the shared profile: the next plain rbevent base spawned 35 m up. The height settings sit in a nested object, so the helper must copy that too, or use the same deep copy as the overrides. Nothing reached the file on disk, and rb.reloadprofiles reset the leak.

  • The plugin's area check catches buildings, but not water. Its safety check, run at an admin's spot, refused a player's building and refused an existing raid base. At 25 m away from the building it passed. A spot on the seabed 50 m under water also passed: the plugin tests water in its own location picker, not in this check. So an admin-given spot needs the helper's own water and terrain test as well as the plugin's check. The spike reported the check but did not enforce it; whether to enforce is a question for the org lead.

  • A RaidableBases reload drops the helper's Harmony patches, because the patched methods belong to the old assembly. The helper must re-patch in OnPluginLoaded, as the zone helper does for ZoneManager.

  • After a server boot the plugin refuses every spawn for about 50 s, until its log says "Grid initialization completed". This includes the helper's spawns. The step has to wait for that, not fail on it.

11.4.5 Decided after the helper spike (2026-09-28)

The org lead answered §11.4.4's three questions the same day:

  • D206: lock-to-first-attacker is per event. The helper adds a second Harmony patch, on each base's BypassUseOwners(). For a base the helper tagged for a run, it answers from that run's setting. Every other base, including one an admin spawns by hand, still follows RaidableBases' config. Without the helper the lock follows the config, and the step says so.
  • D207: a step places its base one of two ways. The default is D203, where RaidableBases picks. The other is an admin-given spot: x and z, plus either a height adjustment (added to the profile's own) or an exact height (the profile's forced height). Both go through the helper's deep copy of the profile (§11.4.4). The spot needs the helper; without it the step offers only the plugin's pick.
  • D208: a blocked spot is refused, and the run log says why. At spawn time the helper runs RaidableBases' own area check and its own water and terrain test. If either fails, the step does not spawn, and the run records the reason ("player building", "raid base", "in water"). The editor runs the same check through the bridge when the step is saved. That is advice only, since the world can change before the event runs.

11.5 Protocol

These are new verbs, events and hello fields. They join protocol 14 (D332), which §2 opens: protocol 13 shipped on 2026-10-09, before §9 reached item 7.


12. Permissions as event rewards (D342–D346)

The org lead asked for this on 2026-10-12, during the Backpacks build: an event reward that grants any permission the server's plugins registered, so a perk nobody planned for (night vision, a chat colour, a /remove command) is a reward without code written for it. The picker sorts by mod, then by permission inside the mod. It is its own item, after Backpacks, and planned first. Five decisions, D342–D346, were answered the same day (§12.8).

Two steps come out of it. Permissions until the event ends lends them for the run, as a kit reward does. Permissions for good gives them for keeps, as a staff member's grant in the permission manager does. Both take several permissions in one step, which needs one small addition to core's step editor (§12.3).

12.1 What it builds on

Nothing below is new machinery; both steps are rows in tables the permission manager already pushes.

  • The lease is a kit reward's (PLAN.md §29, D84). rust.kit.entitle and rust.backpack.size (D337) write one rust_perm_run_grants row per recipient, the permission mirror pushes it in the next perm.sync, and the step's revert deletes the rows so the next sync revokes them. The push reads the union of these rows and the standing grants, so a player who already held the permission keeps it after the revert.
  • A standing grant is the permission manager's own (§1): a rust_perm_grants row for a website user, scoped to a server, reaching every account the user has linked (D28). It is UNIQUE on (user, permission, scope), so granting one twice leaves the first.
  • The catalogue is rust_perm_catalogue: per server, every registered permission and the plugin that registered it (owner, the plugin's Name). A permission no plugin owns, as Carbon's built-in modules register theirs, has no owner, and the permission manager groups it by its prefix (§1).
  • The message is D333's notice, which rides perm.sync and is told once, at once or on the player's next wake.
  • So there is no plugin code, no sidecar code and no protocol change. Protocol 14 is untouched.

12.2 The picker: rust.options.permissions (D342)

  • Every permission in the catalogue of every enabled server, once each. Its group is the plugin that registered it. A permission with no owner is grouped by its prefix, labelled "<prefix> (not registered by a plugin)", the same rule the permission manager draws.
  • Sorted by group, then by permission inside the group, both case-insensitively. The editor keeps a source's order.
  • A row says where it is missing. A permission that is not in every enabled server's catalogue carries a row note (MODULE_API 1.13.0): "Only on Main, PvP".
  • Not searchable. A server registers a few hundred permissions; the multi-select's own filter box (§12.3) narrows them in the browser. If a deployment ever passes the 2,000-row bound, the source says so, as every bounded source does.
  • No blocklist and no allowlist (D343). The bridge's own runicgateway.* permissions are in the list like any other.
  • The step checks the server it is given. Every picked permission must be in that server's catalogue; one that is not is refused by name: "PvP has no permission nightvision.allowed — its plugins did not register it". The check runs in the dry run too.

12.3 The core addition: a param that holds several values (D344, MODULE_API 1.14.0)

The step editor has six param types, all single values. A step that takes several permissions needs a list, so core gains one declaration flag. It is an addition only: a module that does not use it is unchanged, and Module-uo's coreApi: "^1.10.0" still resolves.

  • multiple: true on an action param of type string or int, with an optional max (1 to 100, 20 if left out). It is refused on any other type, on a duration param, and on a template's inputs, which this version does not extend.
  • The value is a JSON array: each element checked as the type's literal, duplicates dropped, order kept, at most max. required means at least one. A bare value is read as a list of one, so a spec written by hand or over the API does not trip on it. The module's perform() and cost() receive the array.
  • The catalog serves multiple and max beside the rest of the declaration (GET /admin/events/catalog).
  • The editor draws a multi-select.
    • With a source: the chosen values as chips above a grouped checklist, with a filter box over it. Ticking past max is refused with the number.
    • With a searchable source: the typeahead adds a chip per pick.
    • With no source: a tag box, a value per Enter.
  • The definition view and the run console show a list as its values joined by commas.
  • Docs: website/MODULE_API.md gains 1.14.0's entry and the flag in the declaration reference; website/EVENTS.md §F names the control.

12.4 Permissions until the event ends: rust.perm.lend (D345, D346)

"Lends the permissions you pick to the people a mode picks from this run's tally — or every linked player of the server — until the run ends. Waits for them if they are offline. Someone who already held one keeps it when the event ends."

  • Params:
    • the server;
    • the permissions (multiple, max 10, from §12.2);
    • the recipients and count (rust.kit.entitle's modes, linked included);
    • a message and its delivery;
    • confirm, a required boolean: "I am sure I want to give these permissions as a reward" (D343).
  • One row per recipient per permission in rust_perm_run_grants, with no kit and no credit; the notice goes on one row per recipient, so each is told once.
  • Unticked, it refuses in the dry run and at run time, naming what it would have given: "Tick "I am sure" to make nightvision.allowed, betterchat.color a reward". Being a required toggle (D334), it cannot be left unset.
  • risk: 'change', reversible: 'ledger', as a kit reward is. Its revert and reconcile are rust.kit.entitle's, and core reverts it at the end of every run, a normal finish included.
  • The budget is new: rust.permissions, Permissions lent. One per recipient per permission; a mode that is not a count is priced at the recipient bound times the permissions, as kit rewards are.

12.5 Permissions for good: rust.perm.give (D345, D346)

"Gives the permissions you pick to the people a mode picks, for good. Ending or cancelling the event does not take them back; staff remove them in the permission manager."

  • The same params as §12.4, the confirm included.
  • A standing grant per website user per permission, scoped to the step's server, with source event and the note "event run <run>, step <step>". A grant already there is left alone and counted as already held.
  • risk: 'irreversible', reversible: 'none', as rust.coins.pay is (D335). Like every step that changes the world it arrives off until an admin turns it on under Actions; unlike §12.4's, its failure aborts the run by default instead of pausing it, and Actions lists it as irreversible, so an admin enables the two separately.
  • The budget is new too: rust.permissions.kept, Permissions given for good, priced as §12.4's.
  • Taking one back is the permission manager's. The grant shows there with its source and note, and staff delete it like any other.
  • The message rides a notice-only row in rust_perm_run_grants: no permission, no kit, no credit. The push grants nothing for it and tells the notice once. Nothing reverts the step, so the row stays; it is a sentence that has been said.

12.6 Settled in this plan, and overrulable

  • A blank message tells nobody, as a kit reward's does (D333), rather than a default sentence. A permission's name (nightvision.allowed) means nothing to a player, so only the author can say what it does: "You can use /nv until the event ends". {permissions} puts the names in.
  • At most 10 permissions in one step: enough for a perk bundle, and the price stays readable.
  • A permission the server's catalogue lacks is refused, not skipped. The plugin may be unloaded for the wipe, and a reward that silently gives less than it says is the worse failure.
  • The for-good grant is scoped to the step's server, not every server. An author who wants it everywhere adds a step per server.

12.7 Where the work lands, and in what order

  1. Website (core), on main: §12.3, MODULE_API 1.13.0 → 1.14.0, with its tests (registries, spec, the editor's multi-select) and Module-uo's frozen-manifest proof against the new core, as 1.11.0's and 1.13.0's were run.
  2. Module-Rust, on edge, after the website PR merges: the option source, the two actions, the two budgets; coreApi ^1.14.0; ci/core-ref.json re-pinned to the merge; routes.manifest regenerated.
  3. docs: this section's Built notes; website/MODULE_API.md; website/EVENTS.md.

Not touched: Rust-Plugins, Rust-Link, the protocol, the installer, Android-app (the app does not author events) and Module-uo. The Integration Kit's pin is at 1.12.0 and moves only when someone re-reads its chapters for a newer core; it skipped 1.13.0 the same way.

12.8 Decisions of record

Answered by the org lead on 2026-10-12.

# Decision Rejected §
D342 An event reward may be any permission the server's plugins registered, picked from the catalogue grouped by the plugin that registered it and sorted by plugin, then permission. rust.backpack.size stays beside it, with its sizes and its take-back warning. A fixed list of perks; folding the backpack step into the generic one. 12.2
D343 No blocklist and no allowlist. The safety is a required "I am sure" toggle in the step; unticked, the dry run and the run refuse, naming the permissions. A pop-up when the step is saved (a core change); a blocklist of dangerous permissions. 12.2, 12.4
D344 One step gives several permissions, through a multi-select picker: a multiple flag on an action param, added to core as MODULE_API 1.14.0. One permission per step; five fixed picker slots; a comma-separated text box. 12.3
D345 A permission reward lasts until the run ends, or for good, the author's choice. Until the run ends only. 12.4, 12.5
D346 The two durations are two steps, rust.perm.lend (change) and rust.perm.give (irreversible), each with its own budget, because core gives an action one risk class. One step with a "keep it" switch, rated irreversible throughout. 12.4, 12.5

12.9 The walk (deferred to the end, D331)

On both rigs, with a plugin that registers a perk (NightVision or similar):

  • open the picker and see it grouped by plugin, sorted inside each, a permission missing from a server noted, and the unowned ones grouped by prefix;
  • run a Permissions until the event ends step with two permissions, unticked (refused) and ticked; see the perks work online and offline at the grant, and gone after the run ends, except for a player who already held one;
  • run a Permissions for good step; see it stay after the run ends, show in the permission manager with its note, and go when staff delete it;
  • unload the plugin and see the step refuse the permission by name.

12.10 Built (2026-10-12)

Built to §12.2–§12.5 in two PRs, core first: website#213 (MODULE_API 1.14.0, the multiple flag and the editor's multi-select, with Module-uo's frozen-manifest proof in its body) and Module-Rust#42 (the source, the two steps and the two budgets, in a new server/eventPermRewards.js, with ci/core-ref.json pinned to #213 until it merges). A core at #213 with the module installed registered it, and its own spec path validated and priced both steps. These points the design left open were settled in the code, and are named here so they can be overruled:

  • The two steps share one param list (§12.5's "the same params"), each with its own message example: "You can use /nv until the event ends" and "…from now on". confirm is the last param, and a new step starts with it off, so it must be ticked on purpose.
  • Only a real true ticks it. A step written by hand with "confirm": "true" is refused like an unticked one. A string is not the author's tick.
  • A server that has reported no catalogue yet is a wait, not a refusal. It has not had a permission sync since it was added, so the step is retried rather than refused for every permission by name.
  • The picker uses the first owner any server reports for a permission. Two servers that disagree about which plugin registered a name would be a renamed plugin, and the name is one row either way.
  • A permission is matched without case and stored lower case, which is how Oxide and Carbon store one. The picker shows the catalogue's own spelling.
  • Give tells a retry from a staff member's grant by its note. A grant already there that carries this step's note is counted as given, not already held, so a retry after a lost answer reports what the first attempt did. Lend finds its rows by step, as a kit reward does.
  • Give's message goes to every recipient, including one who already held every permission: the sentence is still true for them.
  • The permission manager marks a for-good grant with a chip, given by event run N, step M, from the grant's note. It is turned off like any staff grant. A lent permission keeps its from an event chip and its locked toggle, because the run's teardown takes it back.

13. Rust's own world events (D349)

On 2026-10-14 the org lead asked for a survey of further plugins worth supporting, "especially considering events". The survey found that Rust's own world events already reach the site, but only as dots on the map. The org lead decided that they become part of the base module (D349): no plugin, nothing optional, on every server. They are the patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops and locked crates.

13.1 What already exists

What Where What it does
The live map's world layer PROTOCOL §17.3 Draws cargo, heli, chinook, bradley, supply and crate (a locked crate adds hackLeftSec or hacked), kept by OnEntitySpawned / OnEntityKill
Kill credit for the APC and both helicopters the bridge's CreditVehicle (D174) Credits the killing blow's player, or the last player to hurt the vehicle within 60 s when a helicopter crashes with nobody named. It counts apcKills / heliKills in player.tally for Tankbreaker and Rotorbane (§5.1)
Places the step verbs may use WorldPrefabs (D88) Crates and NPCs only. Vehicles are kept out because a player can take one, and a revert would kill something a person may be sitting in

Nothing about these events reaches the feed, the event system's triggers, the run log or a leaderboard, apart from the two kill counts.

13.2 The events, as proposed

The test phase (§14.2) confirms each one on the current Rust, Oxide and Carbon builds before the integration plan names a hook. The column says what each should carry.

Event Carries
Patrol helicopter arrives; leaves without dying grid, position
Patrol helicopter downed who (the D174 credit), grid, position
Bradley spawns at Launch Site; Bradley destroyed who, grid
Cargo ship arrives; docks at a harbour; leaves grid, harbour
Chinook arrives; drops its locked crate the monument it dropped at
Airdrop called (supply signal or plane); lands; first looted who called it, grid, who looted it
Locked crate: hack started; unlocked; looted who, where (small or large oil rig, cargo, Chinook drop, other), grid
Oil rig: heavy scientists called which rig
Travelling Vendor arrives; leaves grid
Anything newer in the current build found by scanning Assembly-CSharp for event prefabs, and Oxide and Carbon for hooks, as §5's mission hook was found

On the wire they would be one new frame kind with a type field. That keeps the public feed's allowlist one entry wide, and it is filtered per type. They are rare, a few an hour, so one frame per event is fine. Kill credit reuses D174's rule, so the feed and Rotorbane never disagree about who downed a helicopter.

13.3 What the site would do with them

  • The feed. Each event is a feed line, e.g. "Patrol helicopter downed by Alice at G7". Who may see which line is Q12.
  • Triggers. Each event is an event-system trigger, like rust.boss.killed today. Q14.
  • Steps that call a vanilla event. For example a patrol helicopter, a cargo ship, a Chinook, or an airdrop at a grid or position, offered as a step. Q13.
  • Titles and columns. New counts from these events. Q15.
  • The map already shows them, and nothing changes there.

13.4 Questions for the org lead

Answered on 2026-10-14: every proposal below was accepted (D353–D357).

  • Q12 — Who sees each line in the feed? For example, "Patrol helicopter downed by Alice at G7" and "Bob started hacking the locked crate on Large Oil Rig". Everyone, or staff only? Proposed: the public feed shows the event, the grid and the player's name; the staff feed adds the Steam id. The map already shows these things publicly (D114 switches apply).
  • Q13 — Can an event call one? For example, a Saturday Air Raid event at 20:00 calls a patrol helicopter and three airdrops onto Launch Site. Proposed: yes, as a step whose result is kept (reversible: 'none'). When the run ends, nothing is removed, because a cargo ship or an airdrop may already carry players or their loot. This is why D88 kept vehicles out of the place verbs. It is a new verb, not an exception to D88.
  • Q14 — Are they triggers? For example, "when anyone destroys Bradley, start the Launch Site Lockdown event", or "when the cargo ship docks, announce it on Discord". Proposed: every event in §13.2 is a trigger.
  • Q15 — Which new counts? For example, Safecracker for 10 locked crates hacked this wipe, or Scavenger for 10 airdrops looted first. The PlayerRanks plugin the org lead set aside also counts barrels broken, K/D ratio and time played, which §5 does not. Proposed: locked crates hacked and airdrops looted first. The others only if the org lead wants them.
  • Q16 — Which protocol? Proposed: protocol 14, which is unreleased (D332), released with the rest after the end walk.

14. More first-class optional plugins (D350–D352)

The same survey read the org lead's two old plugin folders and uMod's Rust catalogue (446 plugins, by downloads and 57 searches). The org lead chose six free plugins as first-class optional integrations (D350): TruePVE, Dynamic PVP, Dangerous Treasures, Guarded Crate, ZLevels Remastered and Server Rewards. Optional means D199's pattern: each is detected at hello, a step or screen that needs one refuses with a reason when it is absent, and the installer never requires it.

Two rules from the org lead govern every one of them (D351):

  • As few helper plugins as possible. The source reading below finds a public route for every proposed use, so the target is no new helper. If the test phase finds one is needed, the integration plan says why and asks first.
  • Nothing that runs afoul of a licence. Runic Gateway never copies, modifies, bundles or redistributes a third-party plugin. The installer and the egg do not ship one, and operators install them from uMod. The bridge uses only their public hooks, their callable methods and their console commands (R2). Paid copies (RaidableBases' paid edition, D347) are never committed to any repository or scratch area that is shared.

And one way to test them (D352): a single test phase covering all six plugins and §13's native events together, on both rigs. Then one integration plan, written from what it found, before any code. This replaces §11's pattern of a spike and a plan per plugin.

14.1 The six, as read from their source (2026-10-14)

Each line was read from the plugin's current umod.org/plugins/<Name>.cs. The test phase confirms each one on the rigs.

Plugin Version, release, licence What integrating it would give The route the source shows
TruePVE (nivex) 2.4.6, 2026-10-02, none listed on uMod §3's zone step gains "PvP rules in this zone": a ruleset for the run's zone, removed at teardown AddOrUpdateMapping(zoneId, ruleset) / RemoveMapping(zoneId); it raises OnUpdatedMappings / OnRemovedMappings
Dynamic PVP (HunterZ) 5.2.0, 2026-08-31, MIT Its temporary PvP zones around a downed helicopter, Bradley, a locked crate, a supply drop or cargo appear on the live map and in the feed OnCreatedDynamicPVP / OnDeletedDynamicPVP (zone id, event name), OnPlayerEnterPVP / ExitPVP. It requires ZoneManager and TruePVE
Dangerous Treasures (nivex) 3.0.0, 2026-10-07, GPL-3 Feed and map for a treasure event, a treasures won column and title, and a step that starts one Hooks OnDangerousEventStarted(pos), Won(looter, invaders), Ended(pos), OnPlayerEntered/ExitedDangerousEvent. Console dtevent [difficulty] [count]; it picks the spot
Guarded Crate (Bazz3l) 2.0.17, 2026-08-09, MIT Feed and map, and a step that places a guarded crate at a spot Hooks OnGuardedCrateEventStart(pos), Ended(player, instance), Failed(pos). Console gcrate start <event> and gcrate position <event> x y z. gcrate stop clears every one, and the handler checks arg.IsRcon
ZLevels Remastered (nivex) 3.3.1, 2026-09-10, MIT Skill levels as leaderboard columns and title conditions. XP boosts need no code: its VIP XP multipliers are keyed by permission name, so §12's rust.perm.lend already lends one GetLevel(userId, skill), api_GetPlayerInfo(userId). No level-up hook, so the level is read at tally time. Its only write, api_SetPlayerInfo, replaces the whole record and is not used
Server Rewards (k1lly0u) 2.0.9, 2026-09-27, MIT The second common currency (RP). It mirrors §11.2: a pay step, balances on the site and app, staff adjust, and a column AddPoints / TakePoints / CheckPoints (by Steam id), and OnPointsUpdated(userId, balance)

Not included:

  • Duelist: left out entirely by the org lead. Its name also clashes with §5's blade-kill title.
  • SmoothRestarter: not wanted.
  • PlayerRanks: not wanted, because the site's leaderboard and titles already do its job. Its stat list fed Q15.
  • BotReSpawn: paid ($40 on Codefling), with its own NPC AI overlapping RunicNPC (Q17).
  • Event Manager and its modes, Quests, Bounty: they raise no result hooks, so the site could only poll their data files, and most of Event Manager's modes were last released in 2021–2024.

14.2 The single test phase

One phase covers everything in §13 and §14, on both rigs (Oxide and Carbon). It is research, so it runs now, not with the deferred walks (D331).

Setup.

  • Bring Rust, Oxide and Carbon up to date on both rigs, and record the builds.
  • Back up each rig's plugin, config and data directories.
  • Install the six from uMod at their current versions, recording each version and checksum.
  • Load one throwaway spike plugin that subscribes to every hook in §13.2 and §14.1, logs each call with its arguments, and calls each route in §14.1. It and the six are removed afterwards, and the rigs are restored from the backups.

What it records, per plugin:

Plugin Questions the phase answers
Native events For each row of §13.2, forced from the rig console: the hook that fires on each framework, its arguments, and who it names. Does a downed helicopter's credit agree with D174?
TruePVE Do mapping a zone made by §3's zone step, and removing the mapping, change damage inside it as expected? Is the mapping saved across a TruePVE reload and a server restart? Can the ruleset names be read for the step's picker without the bridge reading the config file?
Dynamic PVP For each vanilla event it reacts to: the zone id and name it creates, the radius, and how long the zone lasts. Does §1/§3's zone handling see its zones as foreign ones, and leave them alone? Do its TruePVE mappings and ours coexist?
Dangerous Treasures dtevent per difficulty: where the event lands, every hook's arguments, the despawn timing, its map marker, and its own TruePVE and ZoneManager options. Boot cost
Guarded Crate Does gcrate position work from the bridge's server-side console run, given the IsRcon check? Are the hook arguments as read? Is there any way to end one instance? How do RunicNPC's factions (D254, D255) treat its guards, which are Rust's scientists?
ZLevels What does a level read cost for one player, and for a full server at tally time? Does a lent VIP permission change the XP rate at once? What happens at wipe, and with the no-wipe permission?
Server Rewards Do Add, Take and Check work for an offline player? OnPointsUpdated's arguments. Its wipe behaviour
All together All six plus RunicNPC, ZoneDomes, Economics and Backpacks loaded at once: boot time, any errors, and hook overhead during a busy minute

The phase ends with a findings section per item, as §11.4.1 and §11.4.4 did, plus the questions it raises. It ships as one docs PR.

14.2.1 What the test phase found (2026-10-14)

Builds. Rust 25902425 (protocol 2634.289.1, public since 2026-10-13), Oxide 2.0.7830, Carbon 2.0.262 (the current production build, released before that Rust update). Both rigs were brought up to date first; the first restart skipped the Rust update again, as on 2026-10-09, and a second start took it. The six plugins came from uMod at the versions in §14.1; Guarded Crate's dependency NpcSpawn 3.4.8 came from Codefling (D361); Economics 3.9.2 and Backpacks 3.17.8 were added for the all-together run. A throwaway spike plugin logged every hook named below with its arguments, forced the world events through the game's own schedule, and used stand-in players with Steam-shaped ids (the RunicNPC 9d pattern) to down, hack, loot and throw. It, the plugins and every file they wrote were removed afterwards and both rigs restored from backups taken first. Timers were shortened for the test (crate hack 900 → 20 s, helicopter lifetime 30 → 2 min, vendor 1800 → 120 s) and put back.

Oxide and Carbon raised the same hooks with the same arguments everywhere, with one exception that matters: on Carbon a plugin's console command cannot be run from another plugin (below, and Q19).

Rust's own world events (§13)

The schedule. Rust keeps one EventSchedule per scheduled event. This build has seven, and each runs the way the game runs it by calling RunEvent on its TriggeredEvent components. That is the route for D354's step, with no console command and no helper:

Schedule (Key) Spawns Default gap (game hours)
patrol_heli patrolhelicopter 36–54
cargo cargoshiptest 45–78
chinook ch47scientists.entity 27–54
airdrop cargo_plane 9–18
travelling_vendor TravellingVendor (with four NPC turrets) 45–78
bradley_road (RoadBradleys) 12 Bradleys on the roads at once 7.5–18
f15 one F15 jet flying over at 150 m 15–30

Two systems run outside the schedule: the satellite (players launch or steer it; satellite.random crashes one at a random spot) and the Deep Sea (deepsea.open / deepsea.close, also on its own timer). Launch Site's Bradley comes from BradleySpawner (bradley.quickrespawn).

What each §13.2 row raised:

Event Hook(s), as seen Who and where
Patrol helicopter arrives OnEntitySpawned(PatrolHelicopter) Spawns at the map edge
Helicopter downed OnPatrolHelicopterKill(heli, info) fires twice: at the crippling hit, naming the player, and again at the crash, naming nobody. OnEntityDeath fires at the crash, naming nobody The crippled helicopter flew 4.5 s (Oxide) and 34.7 s (Carbon) before crashing. D174's 60 s last-hitter rule credited the right player both times, but the first OnPatrolHelicopterKill names them outright, with no window
Helicopter leaves without dying OnHelicopterRetire(PatrolHelicopterAI), then OnEntityKill far off the map with full health (IsDead() false) —
Launch Site Bradley destroyed OnEntityDeath(BradleyAPC, info) with the killer; OnCrateSpawned(bradley, crate) ×3 Killer named directly
Road Bradleys OnBradleyApcInitialize + OnEntitySpawned per Bradley; death as above The same BradleyAPC class: a road Bradley is told from Launch Site's only by where it is
Cargo ship arrives OnEntitySpawned(CargoShip), then OnCargoShipSpawnCrate + OnCrateDropped per crate —
Cargo docks; leaves OnCargoShipHarborApproach(ship, CargoNotifier) (43 min after spawn on the rig), OnCargoShipHarborArrived (5 min later, at Harbor, with a fresh crate), OnCargoShipHarborLeave (8 min docked). OnCargoShipEgress (leaving the map) did not come within the window The harbour is the nearest harbor_* monument
Chinook arrives; drops its crate OnEntitySpawned(CH47HelicopterAIController); OnHelicopterDropCrate(ch47) then OnCrateDropped + OnCrateLanded (17 min after the call, at Water Treatment) Monument = nearest to the crate
Airdrop called A supply signal: OnCargoPlaneSignaled(plane, signal), whose signal.creatorEntity is the thrower. The schedule: OnAirdrop(plane, dropPosition) only —
Airdrop lands; first looted OnSupplyDropDropped(drop, plane) and OnSupplyDropLanded(drop); the drop's owner is 0, so "who called it" means remembering plane → thrower. OnSupplyDropLanded fired twice for one drop. OnLootEntity(player, drop) for the loot —
Locked crate hack started; unlocked; looted CanHackCrate(player, crate) names who; OnCrateHack(crate) names nobody; OnCrateHackEnd(crate); OnLootEntity(player, crate) Where it is: prefab codelockedhackablecrate_oilrig = an oil rig (nearest oilrig_1 large / oilrig_2 small); parent CargoShip = cargo; codelockedhackablecrate_ghostship, parent GhostShip = the Deep Sea; otherwise a Chinook drop. wasDropped is true for cargo's crates too, so it does not mean "Chinook"
Oil rig scientists called Nothing of its own. Starting a hack on an oil-rig crate spawns a reinforcement CH47HelicopterAIController at once, which drops 11 scientistnpc_heavy within 2 s The crate's prefab says which rig
Travelling Vendor arrives; leaves OnEntitySpawned(TravellingVendor); OnEntityKill when it goes. Carbon's catalogue lists OnVendingShopOpen/Opened for its shop (not exercised) —
Satellite crash (newer) OnEntitySpawned(SatelliteCrash) as it is launched (about 5 min before impact), then SatelliteCrashRemains + five satellite_crate_N at impact. No hook of its own Crates burn, unlootable, for satellite.crate_fire_duration (300 s)
Deep Sea (newer) OnDeepSeaOpen → OnDeepSeaOpened (2.5 min of staged spawning: a floating city, 6 islands, 4 ghost ships crewed by Gen2 ScientistNPC2) and OnDeepSeaClose → OnDeepSeaClosed (35 s). OnDeepSeaTeleport for crossings All of it is off the main map (x −4,800 to −7,400 on a 6000 map), so a grid reference there means nothing
F-15 flyover (newer) OnEntitySpawned(F15) Cosmetic; nothing to report (Q23)

Hooks to avoid. OnHelicopterOutOfCrates fires every tick for a Chinook that carries no crate (a reinforcement Chinook raised it 668 times in 20 s). OnPatrolHelicopterTakeDamage fires per hit.

Plugin crates are vanilla crates. Guarded Crate drops an ordinary HackableLockedCrate, so every crate hook above fires for it too. §13 must not count a plugin's crate as Rust's own (the integration plan says how).

TruePVE 2.4.6
  • The zone step works as planned, with no helper. Two stand-ins in a zone made like §3's (rg-spike-1) could not hurt each other under the default ruleset. After AddOrUpdateMapping("rg-spike-1", "exclude") returned true, a 10-damage hit landed. Stand-ins outside the zone stayed protected, and RemoveMapping returned true and protection came back at once. Same on both frameworks.
  • Persistence. The mapping is written to data/TruePVE.json about 1 s after the call. It survived a TruePVE reload and a full server restart on both rigs. TruePVE prunes a mapping whose zone is gone only when the zone id is numeric. Ours are rg-…, so a leftover is never pruned, and our teardown removes our mappings (D362).
  • OnUpdatedMappings(Dictionary<string,string>) and OnRemovedMappings fire about 1 s after each change, carrying the whole table, Dynamic PVP's entries included.
  • Ruleset names cannot be read. There is no API and no console command that lists them (tpve with no argument does nothing). AddOrUpdateMapping returns false for a name that does not exist, and the special name exclude means "TruePVE does not apply here". The default config has one ruleset, default, with "players cannot hurt players" (Q24).
  • Memory: TruePVE held 123 MB after a reload, the largest of the six.
Dynamic PVP 5.2.0
  • Every event is off by default, and when switched on, Bradley, the helicopter and the others refuse to make a zone ("both radius and size are less than or equal to zero"): their default radius is 0. Only cargo (sized to the ship) works out of the box. With a radius set, every general event made its zone.
  • Its zones. OnCreatedDynamicPVP(zoneId, eventName, position, duration). The id is the entity's net id for parented events (cargo, a crate, a supply drop) or a generated number (Bradley, helicopter, satellite), always numeric. Cargo's lives as long as the ship (duration -1); the others last the configured time, then OnDeleteDynamicPVP / OnDeletedDynamicPVP and their mapping goes. Each zone is mapped in TruePVE, to exclude by default.
  • Ours and theirs coexist. Our mapping stayed in TruePVE's table through Dynamic PVP's creates and deletes, and neither the bridge nor RunicGatewayZones touched its zones. RunicGatewayZones' startup sweep enters connected players into every zone, theirs included, which is what Dynamic PVP needs anyway.
  • OnPlayerEnterPVP(player, zoneId) fired as stand-ins arrived. OnPlayerExitPVP fired twice per player when a zone was deleted.
  • A guarded crate's zone was made where its crate spawned, 500 m up, before it fell. A satellite crash made one too.
Dangerous Treasures 3.0.0
  • dtevent <level> works from the bridge's console route on Oxide, and DT picks the spot. The levels are 0 Easy, 1 Medium, 2 Hard. All three started; a success replies with an empty string.
  • The first request after every load fails for about ten minutes. DT builds its spawn grid lazily, on the first request, at 1 ms per frame, about 2,800 positions a minute. A 6000 map has 31,188, and every request until the grid is done fails with "Unable to obtain a valid position", the same text it gives when there really is no spot (Q21).
  • Hooks, as read: OnDangerousEventStarted(Vector3), OnPlayerEnteredDangerousEvent(player, pos, pvp), OnDangerousEventWon(looter, List<ulong> invaders) when the chest is emptied, and OnDangerousEventEnded(pos). The chest unlocks after 300–480 s and despawns 900 s after that if nobody loots it. The map marker is a vending marker. It allows PvP at its events through TruePVE. It can pay Economics or Server Rewards itself (off by default).
  • Its NPCs are HumanoidNPC : ScientistNPC, so RunicNPC treats them as the built-in scientists faction.
  • Boot cost: its OnServerInitialized stalls the server for 8.8 s (Oxide) / 9.4 s (Carbon), at every load.
  • It treats ZoneManager zones, ours included, as places not to spawn.
Guarded Crate 2.0.17 (with NpcSpawn 3.4.8)
  • It needs NpcSpawn, a Codefling extension that §14.1 did not list, and it unloads itself if NpcSpawn is missing when an event starts. NpcSpawn in turn needs its image files in data/Images/, or it unloads too, and on load it calls a version server at a bare IP address (37.153.157.216:5000). Q20.
  • gcrate position <event> x,y,z works from the bridge's console route on Oxide. The handler checks arg.IsAdmin, not IsRcon as §14.1 said, and the server console passes. A success replies with an empty string. gcrate start <event> picks a spot itself and replies "Event starting." The events are Easy (8 guards), Medium, Hard and Elite; the default duration is 1,800 s.
  • The event completes when the last guard dies, crediting that guard's killer: OnGuardedCrateEventEnded(player, instance). It also fires with player = null when the guards vanish some other way (seen when NpcSpawn reloaded), which means "no winner". The crate is never locked. Looting it does not end the event. OnGuardedCrateEventFailed(pos) is the timeout.
  • There is no way to end one instance. gcrate stop with no second argument silently shows help; gcrate stop all ends every event and raises no hook at all.
  • Its guards are NpcSpawn's CustomScientistNpc : ScientistNPC, so RunicNPC's factions treat them as scientists (D254, D255): a profile hostile to scientists hunts them, an allied one leaves them alone.
ZLevels Remastered 3.3.1
  • GetLevel(ulong, skill) costs about 3 µs per call on both rigs, so a full server's six skills at tally time is trivial. api_GetPlayerInfo costs 60–80 µs and creates a record for a Steam id it has never seen. The bridge reads with GetLevel only.
  • Lent XP boosts take effect at once. ZLevels reads the permission on every gain (getXpMulti). The default config has zlevelsremastered.vip1–vip3 (×2, ×3, ×4) as permissions and zlevelsvip1–3 (×5–×7) as group names, so §12's rust.perm.lend already lends one.
  • Levels survive a wipe by default (Wipe Data OnNewSave: false); the zlevelsremastered.nowipes permission keeps them when wiping is on. There is still no level-up hook.
Server Rewards 2.0.9
  • AddPoints, TakePoints and CheckPoints work for an offline player, through both the ulong and string overloads. TakePoints refuses (false) more than the balance. OnPointsUpdated(ulong, int balance) follows every change.
  • The trap: a negative amount through the id overloads is not refused. TakePoints(id, -10) adds 10 and AddPoints(id, -10) takes 10, with no floor. Only the BasePlayer and IPlayer overloads check the sign. The bridge must refuse a non-positive amount itself.
  • It has no wipe handling at all. Balances carry across wipes until an operator clears them.
All together
  • Everything loaded at once on both rigs (the six, NpcSpawn, Economics, Backpacks, RunicNPC, ZoneDomes, ZoneManager, Kits and our three plugins): no errors on either. A full restart with all of them took about 8 min 50 s to "Server startup complete" on Oxide and about 11 min on Carbon, against about 8 min 15 s and 10 min for the same rigs restored (each start includes the egg's steamcmd check). That is under a minute of difference: noise against steamcmd, so no boot cost worth designing around. The slow starters were DT (8.8 s once), NpcSpawn 2.3 s, ZLevels 1.4 s and RunicNPC 0.26 s.
  • A busy minute (two helicopters, three Bradleys, an airdrop, two hits, a DT and a guarded crate, a hack; 108 logged hooks) cost, in hook time: NpcSpawn 0.19 s, TruePVE 0.08 s, RunicNPC 0.06 s, Guarded Crate 0.05 s, the bridge 0.04 s, Dynamic PVP 0.03 s, DT and ZoneManager 0.02 s each, the rest under 0.01 s.
Found on the way, outside this plan
  • Carbon cannot run another plugin's console command, or its own, through ConsoleSystem.Run on this Rust build. Every plugin command (gcrate, dtevent, tpve, zl.info) and Carbon's own (c.version, c.reload) answered "Command '…' not found", while the same text typed at the console worked. Rust's own commands still run, with their replies. On Oxide everything ran. The same Rust update changed ConsoleSystem (arg.Args is now StringView[]; the spike had to stop using ?? new string[0] on it), and Carbon 2.0.262 predates it, so this is most likely Carbon not yet updated. It also breaks the bridge's own Carbon reload (D180), which runs c.reload exactly this way: c.reload PopupNotifications run like the bridge runs it answered "Command 'c.reload' not found" and reloaded nothing. Carbon's own CommandManager (Find, Execute) is reachable by reflection. Q19.
  • On Carbon, a folder the panel's file API creates is not writable by the game (the known rig trap): NpcSpawn failed until its data/NpcSpawn folder was deleted and left for it to make.
  • RunicNPC's IsScientist is ScientistNPC only, so the Deep Sea's Gen2 ScientistNPC2 crews are not scientists to its factions. Noted for RunicNPC, not this plan.
Questions the phase raises

All seven were answered on 2026-10-14 (D363–D369, §10). Q19, Q21 and Q23 differ from the proposals below; D363, D365 and D367 say how.

Q19 — Carbon's console route. On Carbon today the bridge cannot start a DT event or a guarded crate by console command, and its own c.reload (D180) silently does nothing. For example, a Treasure Hunt event's "start Dangerous Treasures" step would work on the Oxide rig and do nothing on the Carbon rig. Proposed: (a) report it to Carbon and re-test on their next production build; (b) meanwhile, on Carbon only, run commands through Carbon's CommandManager found by reflection (as D180 already reflects), keeping ConsoleSystem.Run on Oxide; (c) file the reload breakage against Rust-Plugins now, since it affects what is released.

Q20 — Saying NpcSpawn is missing. D361 accepts the dependency; what is left is how an operator finds out. An operator who installs Guarded Crate from uMod must also install NpcSpawn from Codefling (a free download, no licence stated), which contacts its author's server at load. For example, the site would say "Guarded Crate is loaded but NpcSpawn is missing: its events cannot start." Proposed: the hello reports NpcSpawn beside Guarded Crate; the site refuses a guarded-crate step when NpcSpawn is absent, with that reason; the operator docs say what NpcSpawn does on load.

Q21 — Dangerous Treasures' warm-up. After every restart, the first DT request starts a ten-minute grid build and fails. For example, a Treasure Hunt event at 20:00 after a 19:55 restart would fail its step. Proposed: the step's failure is reported as "Dangerous Treasures is not ready yet" (the bridge can tell, from DT's text plus a retry), and the run retries the step once after ten minutes. Not proposed: the bridge pre-warming DT at boot, which would need it to reach into DT.

Q22 — Two kinds of Bradley. A road Bradley and Launch Site's are one class. For example, "Bradley destroyed by Alice at Launch Site" against "Road Bradley destroyed by Alice at G7". Proposed: one event, bradley.destroyed, carrying where: launch_site | road, and one Tankbreaker count for both.

Q23 — The newer events. Proposed: the satellite crash, the Deep Sea opening and closing, and a ghost ship's crate join §13 as events and triggers. The F-15 flyover does not (cosmetic). For example, "A satellite crashed at M30" and "The Deep Sea is open" in the feed.

Q24 — TruePVE ruleset names. They cannot be listed without reading TruePVE's config file. For example, a zone step offering "PvP rules: default / pvp-arena / exclude" needs the names. Proposed: the step offers "no PvP protection here" (exclude) as a checkbox, plus an optional ruleset name typed by staff, which the bridge validates by the call's own false.

Q25 — Wipes. ZLevels keeps levels across wipes and Server Rewards keeps points across wipes, by default. Proposed: the site shows what the plugin holds and does nothing at a wipe; ZLevels columns are "current level", not "this wipe".

14.3 The integration plan

Written on 2026-10-14 from §14.2.1 and D353–D369, against the code on edge (bridge, rust-link and Module-Rust at protocol 14 with §2, §11.2, §11.3 and §12 in), and answered by the org lead before any code. For §13 and each of the six it says what hello reports, what goes on the wire, what the site does, which helper (none), the tests and the walk rows. §14.3.9 gathers the points the plan settles itself, so they can be overruled, and §14.3.10 asks Q26–Q32, answered as D370–D376; §14.3.12 and §14.3.13 are the two pieces of core work they add.

Everything here is protocol 14 (D357), specified in PROTOCOL.md §20 as each piece lands, and released with the rest after the end walk. No helper plugin is needed (D351): every route below is a public hook, a callable method or a console command that §14.2 ran. The installer and the egg are not touched: requires_plugins stays Kits, ZoneManager and RunicNPC, and none of the six is shipped (D351).

14.3.1 Rust's own world events (§13; D349, D353–D356, D366, D367)

Built as stage 1 (§14.3.14), with seven changes the build found. The one that matters to a reader of the examples below: a world event's type is in event, not type, which is the envelope's. There are 24 types with chinook.downed (D379); the Chinook takes no position (D378); airdrop.called comes from the plane's spawn. PROTOCOL.md §20.5 is the wire as built.

Hello. Nothing new: the events need no plugin. Hello gains one fact the steps below need, console: { plugins: true | false }, found at load by running one of the bridge's own read-only commands through ConsoleSystem.Run, the route a plugin command takes. On Carbon 2.0.262 it is false (D363), and the site refuses a step that needs another plugin's console command, with the reason and Rust-Plugins#34. When Carbon fixes it, the next load says true and the steps work, with no new code.

One new event kind, world.event, with a type. It is rare (a few an hour), so it is one frame per event. Positions are x, z; the site computes the grid, as the map already does, so the grid has one source. A player is steamId and name.

type Raised from (§14.2.1) Carries
heli.arrived OnEntitySpawned(PatrolHelicopter) x, z
heli.left OnHelicopterRetire x, z (last seen)
heli.downed the crash's OnEntityDeath, with the first OnPatrolHelicopterKill's player kept as the killing blow every credit candidate (D372, below), x, z
bradley.spawned OnBradleyApcInitialize. Road Bradleys come twelve at once, so their spawns in one second are one frame with count where: launch_site | road, count
bradley.destroyed OnEntityDeath(BradleyAPC) (D366) every credit candidate (D372), where, x, z
cargo.arrived / cargo.docking / cargo.docked / cargo.undocked OnEntitySpawned(CargoShip), OnCargoShipHarborApproach, …HarborArrived, …HarborLeave x, z; the three harbour types add harbour (the nearest harbor_*)
cargo.left OnCargoShipEgress, which did not fire within §14.2's window; else the ship's OnEntityKill. Stage 1 confirms which x, z
chinook.arrived OnEntitySpawned(CH47HelicopterAIController), except the oil-rig reinforcement, which is told by the crate hack it answers x, z
chinook.dropped OnHelicopterDropCrate, then the crate's OnCrateLanded monument (nearest), x, z
airdrop.called OnCargoPlaneSignaled (who = the signal's thrower) or OnAirdrop (the schedule: nobody) who if any, x, z (the drop point)
airdrop.landed OnSupplyDropLanded, once per drop (it fired twice) x, z, the caller remembered from plane to drop
airdrop.looted the first OnLootEntity on a drop who, x, z
crate.hack.started CanHackCrate (who) joined to the OnCrateHack that follows for the same crate who, where, x, z
crate.unlocked OnCrateHackEnd where, x, z, the hacker
crate.looted the first OnLootEntity on an unlocked crate who, where
vendor.arrived / vendor.left OnEntitySpawned / OnEntityKill (TravellingVendor) x, z
satellite.crashed the SatelliteCrashRemains spawn at impact (no hook of its own) x, z
deepsea.opened / deepsea.closed OnDeepSeaOpened / OnDeepSeaClosed No position: the Deep Sea is off the map
  • where for a locked crate is oilrig_large, oilrig_small, cargo, ghostship or chinook, each told positively as §14.2.1 found (the _oilrig prefab and nearest rig, parent CargoShip, the _ghostship prefab, a crate landed after OnHelicopterDropCrate). Anything else is other and raises no frame, so a plugin's crate (§14.2.1: Guarded Crate's is an ordinary HackableLockedCrate) is never reported as Rust's own. A crate one of our steps placed is in the world ledger and is skipped as well.
  • "Oil rig scientists called" is not a separate type. Starting a hack at a rig is what calls them, at once, so crate.hack.started with where: oilrig_* says it.
  • Hooks not subscribed: OnHelicopterOutOfCrates (every tick), OnPatrolHelicopterTakeDamage (every hit; D174's hit list already comes from OnEntityTakeDamage), OnDeepSeaTeleport, the F-15 (D367).
  • Bounded state. The joins above (plane → thrower, crate → hacker, looted-once sets) are keyed by net id, dropped at the entity's kill, and swept every ten minutes; nothing is written to disk.

The tally gains cratesHacked and dropsLooted (D356): a hack counts for its starter at crate.hack.started, once per crate, from any location except other; a drop counts for its first looter. New columns crates_hacked, drops_looted in rust_player_wipe_stats; title categories crateshacked (Safecracker) and dropslooted (Scavenger), sum sources, renamable (D175).

Who gets the credit for a helicopter or a Bradley is the operator's choice (D372). The plugin no longer decides. For each patrol helicopter and Bradley it keeps a small damage table from OnEntityTakeDamage, the hook that already feeds D174's last-hitter, after the type test it already does, and for those two classes only. heli.downed and bradley.destroyed carry every candidate:

{"kind":"world.event","type":"heli.downed","x":812.0,"z":-340.5,
 "credit":{"blow":"76561190000000001","last":"76561190000000002","most":"76561190000000002",
           "damagers":[{"steamId":"76561190000000002","damage":6120.0},{"steamId":"76561190000000001","damage":3880.0}]},
 "names":{"76561190000000001":"Alice","76561190000000002":"Bob"}}
  • blow is the killing blow's player: for the helicopter, the player the first OnPatrolHelicopterKill names at the crippling hit; for a Bradley, its death's InitiatorPlayer. last is the last player to hurt it, with no 60 s window. most is the player who dealt the most damage. Each is absent when nobody qualifies.

  • The site applies the server's rule, a setting beside the server's other Rust settings:

    Rule Credits For example (Bob did 61 %, Alice the crippling hit)
    Killing blow (default) blow, else last Alice
    Last hit last whoever hit it last: Alice or Bob
    Most damage most Bob
    Everyone who hurt it every damagers entry, one each Alice and Bob, one each
  • The same rule writes the feed line and the count, so the line and Rotorbane / Tankbreaker never disagree. Under protocol 14 the site counts heli_kills and apc_kills from these frames, and the tally's heliKills / apcKills are no longer sent. Under Everyone, the line reads "Patrol helicopter downed by Alice, Bob and 2 others at G7".

  • Changing the rule applies from then on; past counts are not recounted.

The step that calls one: world.call, site → game (D354, D367).

{"cmd":"world.call","reqId":"r-40","key":"9:2","event":"airdrop","count":3,"x":-412.0,"z":1180.5}
{"kind":"world.called","type":"reply","reqId":"r-40","key":"9:2","repeat":false,"called":3}
  • event is one of the schedule keys §14.2.1 found (patrol_heli, cargo, chinook, airdrop, travelling_vendor, bradley_road, f15) or satellite. It runs the game's own TriggeredEvent.RunEvent (satellite.random for the satellite), so it works the same on Carbon: no plugin console command is used.
  • A position is offered only where the game has a route to one. §14.2 ran the events where the game puts them; placing them was not tested. The first build stage starts with a short spike (research, D331) on the airdrop's drop point, the patrol helicopter's first destination and the Chinook's drop target. An event whose route does not hold is offered without a position, and the step's form says the game decides where.
  • Kept (reversible: 'none'): nothing is removed at teardown, because a cargo ship or a drop may already hold players or their loot (D354). risk: 'change'.
  • Bounds: count 1–5 (1 for cargo, the vendor and road Bradleys, which the game runs one at a time), at most EventsMaxWorldCalls per run on the plugin. A new budget, rust.world.calls, counts each call. Refusals are world.error: events-disabled, bad-event, bad-count, not-placeable, too-many.
  • The sidecar adds POST /world/call, a thin forward.

The website (Module-Rust).

  • The feed. world.event joins the catalogue, filtered per type. Each line is written from the type, e.g. "Patrol helicopter downed by Alice at G7", "Bob started hacking the locked crate on Large Oil Rig", "12 road Bradleys are out", "The Deep Sea is open". The event and its grid are public; the player's name shows only to the operator's presence audience (D376), so with presence left at staff a signed-out visitor reads "Patrol helicopter downed at G8". Staff see the name and the Steam id (D353). A line with nobody in it (the cargo docking, the Deep Sea) is the same for everyone.
  • Triggers (D355). One engagement trigger per type, rust.world.<type> (rust.world.heli.downed, rust.world.crate.hack.started …), kind: 'event', ceiling everyone, cooldown subject the server and type (the crate types add where), with typed variables (server, grid, player, where, harbour, monument, count). Because a phase gate's { on: … } reads the same catalogue, "advance when Bradley is destroyed" works with no new code. Starting a run from one is new core work (D370, §14.3.12).
  • The step rust.world.call, "Call a world event": server, event, count, and the location params of the place verbs where the event allows a position (above).
  • Titles and columns: Safecracker and Scavenger (above); the leaderboard gains the two columns.
  • The map already draws these entities; nothing changes there.

Tests. Bridge: checkPlugin.js (hook names, the console probe). Module-Rust: the catalogue holds every type against PROTOCOL.md's table (default deny for an unknown type); each feed line; the trigger declarations and their examples; the world.call verify and bounds; the two title categories and columns; the ingest of the two tally fields; each of D372's four rules against one credit block (one with no blow, one with nobody at all), for the line and the count alike; a presence-gated line with and without the name (D376).

Walk rows (D331). On both rigs, with timers shortened as §14.2 did: force each type and read its feed line, staff line and trigger, signed out and as staff; down a helicopter with a crash long after the hit, and a Bradley with two players, under each of the four credit rules; hack a crate at each location and see Safecracker count; loot a drop first and see Scavenger; call an airdrop at a position and a patrol helicopter from a run, then cancel the run and see both stay; a Guarded Crate crate raising no Rust frame.

14.3.2 TruePVE in the zone step (D362, D368)

Built on 2026-10-15 (§14.3.16), specified in PROTOCOL.md §20.6.

Hello. integrations.truePve: { loaded, version }.

The wire. world.zone gains an optional truePve: { "exclude": true } or { "ruleset": "pvp-arena" } (exactly one). The plugin makes the zone, then calls AddOrUpdateMapping(zoneId, "exclude" | ruleset).

  • All or nothing. A false from TruePVE (no such ruleset) erases the zone just made and answers world.error truepve-ruleset with the name. TruePVE absent answers truepve-missing.
  • The unmapping lives with the zone, on the plugin (D362). Every path that erases one of our zones (the zone's own deadline, which runs with the site down; teardown; the boot sweep of PROTOCOL.md §15.3) calls RemoveMapping first. When TruePVE is not loaded at that moment, the id is kept in data/RunicGateway/truepve.json and removed at the next OnPluginLoaded(TruePVE). The registry entry remembers that the zone was mapped, so nothing else of TruePVE's table is ever touched.
  • OnUpdatedMappings / OnRemovedMappings are not subscribed: the call's own return says what happened.

The website. rust.zone.open gains noPvpProtection (D334's three-button boolean, "No PvP protection in this zone") and pvpRuleset (a string, "or this TruePVE ruleset"). Setting both is refused at verify. Either one refuses at verify where TruePVE is absent ("TruePVE is not installed on this server"). A name TruePVE does not know fails the step at perform, with the name, because only the call can tell (D368).

Tests. The two params' verify (both set, absent TruePVE, blank); the wire shape; a truepve-ruleset reply classified as a non-retryable failure.

Walk rows. Map exclude and a typed ruleset on both rigs and hurt a stand-in inside; type a wrong name; let a zone expire on its deadline with the site stopped and see the mapping go; unload TruePVE, end a run, load it and see the leftover removed.

14.3.3 Dynamic PVP

Hello. integrations.dynamicPvp: { loaded, version }.

The wire. Two events, pvp.zone.created { zoneId, event, x, z, radius, durationSec } (−1 for "as long as the ship") and pvp.zone.deleted { zoneId }, from OnCreatedDynamicPVP and OnDeletedDynamicPVP. The radius is read from ZoneManager, since the hook does not carry it. map.live gains a pvp layer of the live ones, kept by the same two hooks and seeded at load from ZoneManager's zones whose ids TruePVE maps for Dynamic PVP. OnPlayerEnterPVP / ExitPVP are not subscribed: they would say who stands where, and exit fired twice.

The website. The map draws the pvp layer as a red ring with its event's name. The feed's public line is "A PvP zone opened around the downed patrol helicopter at G7", which names nobody. Its deletion is a staff line only. No trigger and no step: Dynamic PVP's zones are its own to make.

Tests. The layer's ingest and the viewer filter; the feed lines; the default deny until the kinds are classified.

Walk rows. With a radius set for the helicopter and Bradley: down each, see the ring and the line, and see both go when the zone lasts out; cargo's ring for the ship's life; one of our zones beside theirs, neither touched.

14.3.4 Dangerous Treasures (D365)

Hello. integrations.dangerousTreasures: { loaded, version }.

The wire.

  • Events dt.started { x, z, key? }, dt.won { steamId, name, invaders: [steamId…], x, z }, dt.ended { x, z }, from OnDangerousEventStarted, …Won and …Ended. key is set when the start answers one of our steps: the first OnDangerousEventStarted within ten seconds of our dtevent.
  • Command dt.start { key, level } (0 Easy, 1 Medium, 2 Hard) runs dtevent <level> through ConsoleSystem.Run. An empty reply is success. "Unable to obtain a valid position" within the first fifteen minutes after DT loaded is dt-not-ready; after that, dt-no-spot. console-unavailable when hello's console.plugins is false (D363). The key is remembered like a payment's, so a retry never starts a second.
  • map.live's world gains kind: "treasure" for each running event, from the same hooks.
  • The tally gains treasuresWon for the looter of dt.won.

The website.

  • The step rust.dt.start, "Start a Dangerous Treasures event": server and difficulty; DT picks the spot. reversible: 'none' (DT despawns its own), risk: 'change', a new budget rust.dt.events. It refuses at verify where DT is absent or console.plugins is false, naming Rust-Plugins#34 for the second.
  • D365. dt-not-ready is reported as "Dangerous Treasures is not ready yet", and the step answers { ok: false, retry: true, retryAfterMs: 600000 }: core's new per-failure retry delay (D371, §14.3.13), so the run tries once more ten minutes later. dt-no-spot retries on core's ordinary 60 s. The manual retry is core's own: the step's on_failure pauses the run, and staff press Retry on the paused step (EVENTS.md §I), which re-sends the same key.
  • Feed and triggers. "A Dangerous Treasures event started at K12"; "Alice won the treasure at K12"; ended is staff-only. rust.dt.started and rust.dt.won are triggers (§14.3.9).
  • A column and a title, treasureswon, default Treasure Hunter (D375).
  • The map draws a treasure marker.

Tests. The step's verify (absent, console unavailable), each error's wording, the key join on dt.started, the column and category.

Walk rows. On Oxide: start each difficulty from a run; start one in the first minutes after a restart and see "not ready yet", the automatic retry and the manual retry; win one and see the line, the column and the title. On Carbon: the step refused with Rust-Plugins#34 (until D363 clears).

14.3.5 Guarded Crate, with NpcSpawn (D361, D364)

Hello. integrations.guardedCrate: { loaded, version, npcSpawn: { loaded, version }, events: […] }. events are the event names in Guarded Crate's config file (Easy, Medium, Hard and Elite by default), read as the bridge already reads Kits' and Economics' (PROTOCOL.md §16.3), and absent when the file cannot be read.

The wire.

  • Events gc.started { x, z, key? }, gc.ended { steamId?, name?, x, z } (no player = no winner), gc.failed { x, z }, from the three hooks. key as for DT.
  • Command gc.place { key, event, x, y, z } runs gcrate position <event> x,y,z (the handler checks IsAdmin, which the server console passes). It refuses with npcspawn-missing, bad-event, console-unavailable or gc-missing.
  • map.live's world gains kind: "guarded" while one runs.
  • The tally gains guardedWon for the player gc.ended names: a column and a title, guardedwon, default Warden (D375).

The website.

  • The step rust.gc.place, "Place a guarded crate": server, event (the names from hello), and the place verbs' location params (monument, grid or point). Kept: nothing is removed at teardown (D373). There is no way to end one instance, and gcrate stop all would end other people's and raise no hook. The step's description says so, and when a run ends with one still standing, the run log notes "a guarded crate at M4 was left standing; it ends when cleared or after its own time". A new budget rust.gc.crates.
  • D364: it refuses at verify where NpcSpawn is absent: "Guarded Crate is loaded but NpcSpawn is missing: its events cannot start." The servers page shows NpcSpawn beside Guarded Crate.
  • Feed and triggers. "A guarded crate landed at M4"; "Alice cleared the guarded crate at M4"; failed is a staff line. rust.gc.started and rust.gc.ended are triggers (§14.3.9).
  • RunicNPC needs nothing: its guards are scientists to factions (§14.2.1).
  • The operator docs (OPERATING.md) gain a section on Guarded Crate: install NpcSpawn from Codefling, its data/Images/ files, and that it contacts its author's server at load (D364).

Tests. Verify (NpcSpawn, a name not in events, console unavailable), the null-winner frame, the column.

Walk rows. On Oxide: place one at a monument and at a point from a run, clear it and see the line, the column and the trigger; let one time out; cancel the run and see the crate stay; unload NpcSpawn and see the step refused with the reason. On Carbon: refused (D363).

14.3.6 ZLevels Remastered (D369, D374)

Hello. integrations.zLevels: { loaded, version }.

The leaderboard does not change (D374). It gains no ZLevels column and no ZLevels title. Most of its six skills already line up with counts of ours: woodcutting with Timberwright, mining with Prospector, crafting with Seamster and Armorer, acquire (plants) with Greenhand, and skinning sits beside the animal kills (Beastbane). Fishing has no count of ours, and stays that way. So no levels go on the wire and nothing is stored: the levels tally field and the rust_player_levels table this plan first proposed are dropped. D369 still holds in what remains: nothing is done at a wipe.

What the integration is:

  • Hello reports it, and the servers page shows it beside the other optional plugins.
  • XP boosts need no code (§14.2.1): rust.perm.lend already lends zlevelsremastered.vip1–vip3, and its picker lists them once ZLevels registers them (§12.2). A Double XP weekend is a lend step.

Tests. The hello field and the servers page.

Walk rows. Lend vip2 from a run and see the XP rate change at once, and back at the run's end.

14.3.7 Server Rewards (D369)

It mirrors §11.2.2 (Economics) wherever Server Rewards allows, and the code shares Economics' path where the two are the same.

Hello. integrations.serverRewards: { loaded, version }. Server Rewards has no starting balance, so a new player is at 0.

The wire.

  • points.pay { key, amount, steamIds[], message, delivery }, answered by points.paid with paid (before and after) and failed, the key remembered seven days, the message told once, exactly as economics.pay. Whole numbers only, 1 to 1,000,000. The bridge refuses anything else itself, because the id overloads accept a negative amount and invert it (§14.2.1).
  • points.balances { steamIds[] } → { balances: [{ steamId, points }] }, bounded like economics.balances.
  • points.adjust { key, steamId, op: add | take, amount } → { before, after }. There is no set, because Server Rewards has no call for it. take above the balance is refused with insufficient, as Server Rewards refuses it.
  • The tally gains points, a value, read at connect and from OnPointsUpdated(ulong, int).
  • The sidecar adds POST /points/pay, /points/balances, /points/adjust.

The website.

  • The step rust.points.pay, "Pay RP": kept like coins (D335's reasoning), the same recipient modes, a new budget rust.points.
  • Balances in a new table, rust_player_points (server_id, steam_id, points), per server and not per wipe (D369). The player's Rust page, the user's admin page and the app (R10) show it beside the coins.
  • Staff add and take from the user's admin page, each an activity row, like Economics.
  • A column and a title, points, default Hoarder, a value like the richest (D375): ranked only above 0, on servers that report Server Rewards, and shown for every wipe view because the points do not belong to one (D369).

Tests. The same set as Economics': the amount bounds (0, negative, fractional), a repeat key, insufficient, the value ingest, the admin route and its activity row, the app's screen.

Walk rows. Pay three players, one offline, and see the popup on wake; retry the step and see no second payment; add and take from the admin page, including more than the balance; read the balance on the site and in the app; wipe and see the points stay.

14.3.8 The wire, all together

Kind New in protocol 14
Hello console.plugins; integrations.truePve, dynamicPvp, dangerousTreasures, guardedCrate (with npcSpawn, events), zLevels, serverRewards
Events world.event (24 types with chinook.downed, D379; the type in event, §14.3.14); pvp.zone.created / deleted; dt.started / won / ended; gc.started / ended / failed
Commands world.call; dt.start; gc.place; points.pay / balances / adjust; world.zone's truePve
Tally cratesHacked, dropsLooted, treasuresWon, guardedWon; points (a value). heliKills and apcKills leave the tally: the site counts them from world.event's credit (D372)
map.live world kinds treasure and guarded; the pvp layer
Sidecar POST /world/call, /dt/start, /gc/place, /points/pay, /points/balances, /points/adjust, thin forwards that keep nothing

PROTOCOL_VERSION stays 14 (D332); overlay.toml already says 14.

14.3.9 Settled in this plan, and overrulable

  • One world.event kind with a type, filtered per type (§13.2's shape), rather than twenty-three kinds.
  • The site computes the grid from x, z; the Deep Sea's events carry no position.
  • Road Bradleys' spawn is one line with a count; Launch Site's is its own line.
  • Oil-rig reinforcements are crate.hack.started at a rig, not a type of their own.
  • A crate is Rust's own only when its location is told positively; anything else raises no frame.
  • A hack counts for Safecracker when it starts, for the player CanHackCrate named, once per crate.
  • Dangerous Treasures' and Guarded Crate's starts and wins are triggers too, as world events are (D355). Dynamic PVP's zones are feed and map only.
  • A run's DT or guarded crate is told from the plugin's own by the first start within ten seconds of our command, which is the only join the hooks allow.
  • The DT and GC steps refuse on a server whose console.plugins is false, rather than being hidden.
  • Server Rewards' wire is points.*, beside economics.*, not one verb with a currency field: protocol 14 is unreleased, but Economics' verbs are built and specified, and two names keep each plugin's refusals its own.

14.3.10 Questions for the org lead

All seven were answered on 2026-10-14 (D370–D376, §10), and D377 states the leaderboard rule behind Q30 and Q31. Q26, Q27, Q29, Q31 and Q32 as proposed (Q26 is written as §14.3.12, Q27 as §14.3.13). Q28 differs: the operator chooses the credit rule, from four options (D372). Q30 differs: the leaderboard is not changed for ZLevels at all (D374).

  • Q26 — Can a world event start a run? D355 makes every world event a trigger, and §13.4's example was "when anyone destroys Bradley, start the Launch Site Lockdown event". Today a trigger can advance a phase of a run already going ({ on: 'rust.world.bradley.destroyed' }) and can send a notice, but core cannot start a run from a trigger: a run starts by its schedule or by hand. Proposed: add it in core as a definition's "start when" (a trigger, conditions and a cooldown, so Bradley dying twice in a minute starts one run), on the website's own plan, in its own PR with the Module-uo proof, as §2 was. Or leave it out for now, and an event that needs it opens with a phase that waits for the trigger.
  • Q27 — DT's ten-minute retry (D365). Core retries a failed step every 60 s, three times, so a step cannot say "try me again in ten minutes". For example, a Treasure Hunt at 20:00 after a 19:55 restart fails at 20:00, 20:01 and 20:02, then pauses the run for staff. Proposed: a small core addition, retryAfterMs on a step's failure (bounded to 30 min, counted as one attempt), a MODULE_API minor bump. The other way is the bridge holding the request and starting DT itself ten minutes later, which means a step that "succeeded" before anything started.
  • Q28 — Who downed the helicopter. D174 credits the last player to hurt it within 60 s of the crash. The test found the game names the player at the crippling hit, and a crippled helicopter flew 34.7 s on Carbon before crashing. One that glides 70 s would credit nobody today. Proposed: credit the player named at the crippling hit, falling back to D174's rule, for the feed line and Rotorbane alike (amending D174).
  • Q29 — A guarded crate outlives its run. There is no way to end one guarded crate: gcrate stop all ends every one on the server, and raises no hook. For example, a run cancelled five minutes after placing one leaves it, with its eight guards, for up to 30 minutes, until it is cleared or times out. Proposed: accept that. The step says so, and the run log notes the crate left standing.
  • Q30 — ZLevels on the leaderboard. Six skills. For example: (a) one Level column, the total of the six, and one title Grandmaster for the highest total; (b) six columns (Woodcutting, Mining …) and six titles; (c) one Level column whose skill the viewer picks, with six title categories an admin may use or not. Proposed: (c).
  • Q31 — Titles for the plugins' counts. For example: Treasure Hunter for most DT treasures won, Warden (?) for most guarded crates cleared, Hoarder (?) for the most RP. Proposed: all three as title categories with those default names, renamable (D175), and a leaderboard column for each on servers that report the plugin. RP ranks only above 0.
  • Q32 — A public line that names a player, and the presence rule. The rule of 2026-09-22 says nothing tells who is online by default, and the public feed already hides npc.boss.died (it names the killer) below the operator's presence audience. D353 says the public line names the player. For example, with presence left at staff, does a signed-out visitor see "Patrol helicopter downed by Alice at G7", "Patrol helicopter downed at G7", or nothing? Proposed: the middle one. The event is public, and the name shows only to the presence audience, so a line with nobody in it (the cargo docking, the Deep Sea) is always public.

14.3.11 Order of building

Each stage is built on edge, compiled on both rigs, and PR'd with its PROTOCOL.md section; nothing is walked until the end (D331, D359). The org lead is asked before each stage starts.

  1. §13, the world events, starting with the placement spike for world.call. It has no plugin dependency and carries the console probe the later stages use, and D372's credit rule.
  2. Core: a step's retry delay (§14.3.13, D371), a website PR with the Module-uo proof. It is small and comes before stage 5, which needs it.
  3. TruePVE in the zone step.
  4. Dynamic PVP (it needs TruePVE).
  5. Dangerous Treasures, on stage 2's retryAfterMs.
  6. Guarded Crate.
  7. ZLevels (hello only, D374).
  8. Server Rewards.
  9. Core: starting a run from a trigger (§14.3.12, D370), a website PR of its own with the Module-uo proof, as §2 was. It can be built alongside any stage after 1; it is last here only because nothing else waits on it. Module-Rust needs no code for it, because the world events are already triggers.

14.3.12 Core: starting a run from a trigger (D370)

What it gives. "When anyone destroys Bradley on Main, start Launch Site Lockdown"; "when the Deep Sea opens, start Sea Raiders"; on UO, "when a champion's boss appears, start Champion's Call". Today a trigger can only advance a phase of a run already going, or send a notice (§14.3.1). This is core work on the website's own plan (EVENTS.md gains it when it is built). It is written here because this plan asked for it, and no module contract changes: the triggers it starts from are the ones modules already register.

The design, to be settled again in EVENTS.md at build time:

  • A definition's version gains starts: a list of { on: <triggerId>, where: <conditions>, cooldown }, beside its schedule (a definition may have either, or both). on and where are a phase gate's own (EVENTS.md §E), the same catalogue, operators and form, so "where: { server: 'main', where: 'launch_site' }" is already a thing the editor can draw.
  • The emit path starts it. When a trigger fires, core finds the ready definitions whose published version starts on it and whose conditions match, and creates a run of that version now, with scheduled_for = the firing's time, so the existing UNIQUE (definition_id, scope, scheduled_for) keeps one firing from starting two runs. started_by is trigger:<triggerId>, and the firing's variables are stored on the run, so its page says "Started because Bradley was destroyed at G8", and the audit says who and what.
  • One run at a time, and a cooldown. A firing while a run of that definition is going, or within its cooldown (default 30 minutes, 0 to 7 days) of the last trigger-started run, starts nothing. It is counted, and the definition's page shows "2 firings ignored while running". This is what stops twelve road Bradleys dying in a minute from starting twelve runs.
  • No loops. Core's own event.* triggers cannot start a run, so a run starting can never start another. A run whose own steps cause its start trigger (an event that calls a patrol helicopter and starts on one being downed) is held by one run at a time and the cooldown.
  • Everything else is a run like any other: caps and budgets, verify before publish, the same roles that may schedule one (admin and editor), the same announcements and cleanup. A definition that starts from a trigger whose module is not loaded is dormant, as a phase gate on one is.

Tests (website). A matching firing starts one run; a second firing during it, and one in the cooldown, start nothing and are counted; two instances emitting the same firing make one run; an event.* trigger is refused in starts; a dormant trigger; the run's origin on its page and in the audit. Module-uo proof: one definition starting on uo.champ.boss_up.

Walk rows. On a Rust rig: "start when Bradley is destroyed" with a short cooldown. Destroy two in a minute and see one run and one ignored firing; destroy one after the cooldown and see a second run.

14.3.13 Core: a step's retry delay (D371)

What it gives. A failed step may say when to try again: { ok: false, retry: true, retryAfterMs }. Today every retry waits the flat EVENT_STEP_RETRY_MS (60 s). DT's warm-up (D365) is the first user: "not ready yet; try in ten minutes".

  • Bounded 60 000 to 1 800 000 ms (one to thirty minutes). Outside that it is clamped, because this is the module's hint, not an author's value (D95 is about the second). Absent, the flat delay as today.
  • It counts as one attempt of EVENT_STEP_MAX_ATTEMPTS, and the run goes degraded on it as on any retry (EVENTS.md §L). The run console shows "retrying at 20:10".
  • A run that is paused or cancelled while it waits does not retry; the existing staff Retry on a paused step still works at any time.
  • MODULE_API minor bump (additions only). STEAM_SIGNIN.md has claimed 1.15.0; whichever lands first takes it, the other the next minor. Module-uo needs no change. The Integration Kit's coreApi equality check turns red on the bump, as it is meant to, and its pin moves when the chapters are re-read.

Tests (website). The delay honoured; clamped at both ends; counted as an attempt; no retry for a run paused or cancelled in the wait; MODULE_API.md and the classify rules updated.

Built on 2026-10-15 as MODULE_API 1.15.0 (§14.3.15).

14.3.14 Stage 1, built: Rust's own world events (2026-10-15)

Built on edge in Rust-Plugins, Rust-Link and Module-Rust, specified in PROTOCOL.md §20.5, on Rust 25902425, Oxide 2.0.7830 and Carbon 2.0.262 (the latest of each that day). Walked at the end with everything else (D331).

The placement spike (research, D331). A throwaway plugin on both rigs called each event at a chosen point by the routes the game itself has, and measured where it went:

Event Route Result
Airdrop CreateEntity(cargo_plane), InitDropPosition(point), then Spawn() Released 0–1 m above the point on both rigs; lands there. The plane crosses a 6000 map in about 480 s and drops at half way
Patrol helicopter PatrolHelicopterAI.SetInitialDestination(point) before Spawn(), so ServerInit skips its random start Came within 10–54 m of the point after 65–165 s, then patrolled as usual
Chinook It drops only at the map's CH47DropZones (seven, all at monuments). Its brain's interest point: it orbited the zone 110 s and left without dropping (a drop needs it five minutes old). The Airfield terminal's statics, set 5 s after spawn: dropped 5 m from the zone. Set before spawn: it dropped at its offshore spawn and vanished No position (D378): the working route is the real terminal's shared state
Cargo leaving cargoship.egress OnCargoShipEgress fires on both frameworks (a natural one was seen on Oxide); the kill follows after egress_duration_minutes. So cargo.left is the egress

What changed from §14.3.1 as planned, each found by the build:

  1. The type is event, not type. §14.3.1's example wrote "type":"heli.downed", but type is the envelope's and the sidecar files on it: the rig's sidecar dropped every world event as untyped, the same collision protocol 13's world.expired had (§19). Every frame says "type":"event","event":"heli.downed".
  2. airdrop.called comes from the cargo plane's spawn. The console's supply.call raised neither OnAirdrop nor OnCargoPlaneSignaled, so a line from those hooks would miss it. They now only say who and where; the drop point is otherwise read from the plane.
  3. A Bradley's where is told by the game's spawners, not by RoadSpawned, which defaults to true (a plugin's Bradley would read as a road one): Launch Site's when BradleySpawner.singleton holds it, road when the road event's list does. A Bradley neither holds is other; its spawn says nothing, but its death is sent and counts toward Tankbreaker, as it did under D174.
  4. chinook.downed (D379). D174 counted Chinook kills toward Rotorbane; they keep counting, by the rule.
  5. The Chinook's drop is joined at the crate's landing. The crate a Chinook drops has no net id when OnCrateDropped fires (it is made just before its spawn), so the drop is joined to the crate that lands under it within 40 m and 120 s.
  6. world.call runs one of the schedule's events at random, as EventSchedule.Trigger does, but not Trigger, which would also reset the schedule's clock: calling an airdrop must not delay the next natural one. The schedule is found whether or not it is enabled.
  7. Carbon's console, corrected. §14.2.1 found Carbon 2.0.262's ConsoleSystem.Run answers "not found" to every plugin command. Stage 1 found that holds only for a call inside a console command, or in a NextTick a console command queued, which is how §14.2's spike ran every test (rgs.cmd …). From a timer, and from a NextTick queued by a background thread — the bridge's own path for every website command — Carbon finds plugin commands, c.version included. So the console probe (D363) runs from a timer, and reads true on both rigs. It also suggests D180's Carbon reload works from the website after all, and that Rust-Plugins#34 describes the spike's context rather than the bridge's; the end walk confirms it.

Settled in the code, and overrulable:

  • A position never leaves the module. The feed sends a grid; x and z reach staff only, because the map has its own audience for where things are (D111).
  • EventsMaxWorldCalls defaults to its ceiling, 10 per run per server.
  • The credit rule is on the server's settings (Admin → Rust → the server, World events), and an unknown stored word reads as the default, the killing blow.
  • damage is the damage each hit reported, summed, and a credit block names at most 32 damagers.
  • The feed gains a World events filter, and the two leaderboard columns sort.
  • A trigger's player, for a downed one, is the credited names as a phrase ("Alice, Bob and 2 others"), by the server's rule; credited is how many.
  • A step's answer says how many the game started (called), and a note when it started fewer than asked.

Tests. Bridge: checkPlugin.js (76 hooks, every one void and listed) and a new check that the console probe names a command the plugin declares, 18 tests. Compiled and loaded on both rigs; heli.arrived, cargo.arrived and airdrop.called read back from each rig's sidecar store, and hello's console.plugins on both. Sidecar: 71 tests, fmt and clippy clean. Module-Rust: 585 server tests (22 new in test/worldEvents.test.js: the catalogue against the protocol's 24 types, D372's four rules against one block and against one with nobody, the line and the count agreeing under every rule, the per-viewer reading, the grid off the map, ingest, the triggers and their variables, the step's bounds and refusals), 84 client tests, every check, and a boot against the pinned core (43 triggers, 12 actions, 10 budgets, 20 option sources; the 89 routes unchanged).

Walk rows added to §14.3.1's: the Travelling Vendor (the spike's schedule run raised no spawn on the rig); the road Bradleys' line (twelve were already out, so none came); the console probe after a c.reload typed in Carbon's console; and a configuration save's reload from the website on Carbon (D180), which settles item 7.

Not changed: the Android app. It reads the API leniently, so it shows neither world events nor the two columns until it learns them.

14.3.15 Stage 2, built: a step's retry delay (2026-10-15)

Built in website as MODULE_API 1.15.0, specified in MODULE_API.md §1.1 and EVENTS.md §F, as §14.3.13 planned it. It took 1.15.0 ahead of Steam sign-in, whose plan now names 1.16.0. No module changes: Module-uo (^1.10.0) answers none of it, and Module-Rust stays on ^1.14.0 until stage 5, when Dangerous Treasures answers it and its pin moves.

Settled in the code, and overrulable:

  1. A retryAfterMs that is not a number is ignored, not clamped. "600000", NaN and Infinity are dropped and logged, and the flat delay applies; the failure is unchanged. Zero and negatives are numbers and clamp to the one-minute floor.
  2. Rounded to whole milliseconds.
  3. Only on the retry shape. Beside retry: false it is not carried, because a terminal failure is never tried again. A missing retry still means retry, so the hint is read there too.
  4. Every retry line now says when: step.retry carries retryAt whether or not the module hinted, and retryAfterMs only when it did. The console reads "failed, attempt 1 of 3, retrying at 20:10: not ready yet". The step table's existing "due 20:10" was already there.
  5. revert() and reconcile() are unchanged. Cleanup has its own retry counter (MAX_REVERT_ATTEMPTS), and nothing in this plan asks for a delay there.
  6. Paused and cancelled need no new code: the runner's findDue reads only running runs, so a step waiting on its delay is simply not picked up. The tests prove it rather than adding a guard.

Tests. Server: six new in test/eventRunner.test.js (the hinted delay honoured and the flat delay not; an unhinted line's retryAt; three attempts at the thirty-minute ceiling fail on schedule; paused in the wait; cancelled in the wait; classify()'s clamps and refusals), 67 in the file. Found on the way: the file's stubs never covered finishedRunsWithNothingLeft (MODULE_API 1.11.0), so a tick that reached the cleanup sweep with a terminal run waited on the dead pool; the cancelled-run test was the first to do it, and it is stubbed now. Client: the retry line, an older line, an unreadable time. Full suites: server 2102 pass; the only failures are routeManifest.test.js's two and they fail identically on an untouched main here, because they read this machine's installed modules/; client 422/422 and the build.

No walk rows of its own. Stage 5's Dangerous Treasures rows exercise it: a step answering "not ready" shows "retrying at …" and runs then.

14.3.16 Stage 3, built: TruePVE in the zone step (2026-10-15)

Built in Rust-Plugins and Module-Rust as §14.3.2 planned it, specified in PROTOCOL.md §20.6. No Rust-Link change: the sidecar forwards world.zone whole. PROTOCOL_VERSION stays 14. Builds: Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262, TruePVE 2.4.6 on both rigs.

Settled in the code, and overrulable:

  1. A ruleset name is trimmed and 1–64 characters, checked by the site and the plugin alike; past that it is passed as written, because TruePVE compares names case-sensitively (r.name == ruleset in 2.4.6).
  2. A server whose hello has not said whether it has TruePVE is left to the plugin (a plugin before protocol 14, or never reached), as a dome is, and the plugin answers truepve-missing. Only a hello that says loaded: false is refused at verify.
  3. exclude refused by TruePVE answers refused, not truepve-ruleset. It happens only when TruePVE has not read its config or data yet; the zone is erased all the same.
  4. The mapping comes after the dome, so a refusal erases both, as a failed dome erases its zone.
  5. Re-creation maps nothing again. TruePVE keeps the mapping in its own data file, keyed by the zone id, which re-creation keeps.
  6. Two erase paths beyond the plan's three are covered: a zone world.owned finds ZoneManager no longer holds, and a new wipe dropping the old map's entries. TruePVE's table outlives a wipe.
  7. Leftovers are also flushed at the bridge's own load, not only at OnPluginLoaded(TruePVE), so a bridge reloaded after TruePVE came back does not wait for TruePVE's next reload. truepve.json is deleted when empty.
  8. Hello is sent again when TruePVE loads or unloads, so the site's verify reads the current answer.
  9. truepve-missing and truepve-ruleset are permanent failures, as no-zone is.
  10. Server settings' plugin line names TruePVE: "TruePVE 2.4.6 is loaded — a zone step may turn off PvP protection or name a ruleset."

One thing TruePVE does that we cannot change: it saves its data file one second after a change, on its own timer. A restart inside that second loses the change. For a removal that leaves a mapping for an rg-… id no zone will have again, which changes nothing in game; D362's cleanup is otherwise complete.

Tests. Module-Rust: five new in test/zones.test.js (exclude, "true" and a trimmed ruleset on the wire; unset, off and blank send nothing; both, a long name and no TruePVE refused for good at verify and perform; a hello that has not said is left to the plugin; truepve-ruleset and truepve-missing fail for good with the plugin's words). Full suites: server 603/603 and every check (imports, bundle, swagger, engagement); client 84/84, the build and externals. The plugin compiled and loaded on both rigs, and rg.world read truePve=True truePveLeftover=0 on each; both rigs were put back on the protocol-13 bridge after.

Walk rows are §14.3.2's, at the end walk (D331, D359).

14.4 Questions for the org lead

Answered on 2026-10-14: Q17 as proposed, out (D358). Q18 against the proposal: the end walk waits for RaidableBases (D359).

  • Q17 — Is BotReSpawn in or out? It is paid ($40) and runs its own NPC AI, profiles and monument spawns, which RunicNPC already does. It cannot drive RunicNPC. The most it could add is its bots named in the feed and kill counts. Proposed: out. It can be added later under D348's rules, with a licensed copy for the rigs.
  • Q18 — Does the end walk wait for RaidableBases? D331 holds one walk until every item is coded, and RaidableBases now waits for a copy with no date (D347). For example, if the paid copy arrives in a month, do §2, §11.2, §11.3, §12, §13 and §14 wait a month for their walk? Proposed: no. The walk runs when §13 and §14 are coded, and RaidableBases gets its own walk after its build.

15. Steam sign-in (D360): on the website's plan

The org lead asked on 2026-10-14 for Steam sign-in in the same manner as the other providers, and for it to stay on the website's plan (D171). It is planned in website/STEAM_SIGNIN.md and is on the roadmap as the website milestone Steam sign-in. It is core work, not an item of this plan, and it does not hold the end walk (D359).

What this module takes from it:

  • A verified Steam id for a signed-in account, read through the module contract (MODULE_API 1.16.0 there; 1.15.0 went to §14.3.13).
  • A Steam identity as a linked Rust player, beside the in-game /link code (R1). Whether it counts as a link is that plan's Q1, and what unlinking removes is its Q2.
  • When it lands, Module-Rust's account screens and the link flow are updated in their own PR. Rust's grants already follow the website user to every linked Steam id (D28), so nothing else changes.