Stage 6 of the §14.3 integration plan. Spec: docs rust-link/PROTOCOL.md §20.9.
- gc.started, gc.ended (the clearer, or nobody) and gc.failed from Guarded
Crate's three hooks; the start's key joins exactly, inside the call.
- gc.place { key, event, location as a world call } runs `<command> position
<event> x,y,z`; refuses npcspawn-missing itself, since Guarded Crate's
`position` does not check and the crate would land unguarded (D364).
- The running set is checked against Guarded Crate's private list (by
reflection): the hotload seed, `stop all` (no hook), and which event ended.
- Hello's integrations.guardedCrate with npcSpawn and the events from its data
file; the tally's guardedWon (Warden); map.live's guarded rows; rg.world.
- Guarded Crate's cargo plane no longer says airdrop.called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Stage 5 of the §14.3 integration plan (docs rust-link/PROTOCOL.md §20.8).
- Three events from DT's own hooks: dt.started { x, z, key? },
dt.won { steamId, name, invaders (max 32), invaderCount, x, z } and
dt.ended { x, z }. None while the world is not live; a win still counts.
- The start hook fires inside the call that opens the chest, so the key a
dt.start is running joins its start exactly, not by a ten-second window.
- dt.start { key, level 0-2 } runs `dtevent <level>` through
ConsoleSystem.Run and answers dt.opened { key, level, repeat, x, z }. A key
already answered is answered again and opens nothing (kept seven days in
dt-starts.json). Refusals: malformed, bad-level, dt-missing,
console-unavailable (D363), dt-not-ready (nothing opened, DT's own "Manual
Event Failed" reply, under fifteen minutes since DT loaded, D365),
dt-no-spot (after), refused (anything else DT said).
- The tally gains treasuresWon (Treasure Hunter, D375); map.live's world gains
kind "treasure" for each running chest, seeded at load from DT's chests in
the world (DT has no API that lists them).
- Hello's integrations gain dangerousTreasures, sent again on DT's load and
unload. rg.world prints dangerousTreasures, treasures, dtStarts and
dtWarmingUp. README lists Dangerous Treasures.
checkPlugin: 81 hooks, every one void and listed; 18 tests. Compiled and
loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262);
rg.world read dangerousTreasures=False treasures=0, then both went back on
the protocol-13 bridge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Stage 4 of the §14.3 integration plan, specified in docs
rust-link/PROTOCOL.md §20.7 and §17.3.
- pvp.zone.created { zoneId, event, x, z, radius, shape, durationSec } from
OnCreatedDynamicPVP, and pvp.zone.deleted { zoneId, event } from
OnDeletedDynamicPVP. Neither is sent while the world is not live.
- Size and place come from ZoneManager: a sphere's Radius, or half a cube's
longer side (cargo's zone is a cube); the position is the zone's transform,
so cargo's moves with the ship.
- map.live gains a `pvp` layer of the live zones, kept by the two hooks and
seeded from Dynamic PVP's own AllDynamicPVPZones / GetEventName at world
ready and when it loads; a zone ZoneManager no longer holds is dropped.
- hello's integrations.dynamicPvp, sent again when it loads or unloads;
rg.world prints dynamicPvp= and pvpZones=; both hooks join rg.hooks.
- The bridge never makes, maps or erases one of Dynamic PVP's zones.
Compiled and loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon
2.0.262).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Stage 3 of the §14.3 integration plan (D362, D368), specified in docs
rust-link/PROTOCOL.md §20.6.
- world.zone's optional truePve: {"exclude": true} or {"ruleset": "name"},
exactly one, the name 1-64 characters. Malformed is `malformed`; TruePVE
not loaded is `truepve-missing`.
- All or nothing: the zone (and its dome) is made, then AddOrUpdateMapping.
A false erases both and answers `truepve-ruleset` with the name (or
`refused` for exclude).
- The registry entry remembers its mapping, and every path that erases one
of our zones calls RemoveMapping first: the deadline, world.revert, a zone
world.owned finds gone, a new wipe. While TruePVE is away the id waits in
data/RunicGateway/truepve.json, flushed at OnPluginLoaded(TruePVE) and at
the bridge's own load.
- hello's integrations.truePve { loaded, version }, sent again when TruePVE
loads or unloads. rg.world prints truePve= and truePveLeftover=.
Compiled and loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon
2.0.262, TruePVE 2.4.6).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Stage 1 of the §14.3 integration plan: the patrol helicopter, Bradley, the
cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the
satellite and the Deep Sea, with no plugin (D349).
- `world.event`, one frame kind whose `event` names which of 24 types
(PROTOCOL.md §20.5). The type is in `event`, never `type`: `type` is the
envelope's, and the rig's sidecar dropped every world event as untyped when
the plan's example put it there. Nothing is said before the world loads or
while the server quits. Positions are x/z; the site works out the grid.
- Credit (D372, D379): a damage table for the patrol helicopter, the Chinook
and Bradley; `heli.downed`, `chinook.downed` and `bradley.destroyed` carry
every candidate (blow, last, most, damagers) and the plugin credits nobody.
D174's `CreditVehicle` and the tally's apcKills/heliKills are gone.
- Locked crates told positively (oil rigs, cargo, ghost ship, a Chinook drop
joined at the crate's landing); anything else says nothing. A Bradley's
`where` comes from the game's spawners, not `RoadSpawned` (which defaults to
true). `airdrop.called` comes from the cargo plane's spawn: `supply.call`
raised neither OnAirdrop nor OnCargoPlaneSignaled.
- The tally gains cratesHacked and dropsLooted (Safecracker, Scavenger, D356).
- `world.call` (D354, D367, D378): one of a schedule's TriggeredEvents at
random, without Trigger (which would reset its clock); satellite.random for
the satellite. A position for the airdrop (InitDropPosition) and the patrol
helicopter (SetInitialDestination) only, both set before the spawn, as the
stage-1 spike measured (0-1 m and 10-54 m). Kept; the key is remembered seven
days in world-calls.json; EventsMaxWorldCalls (default and ceiling 10) per run.
- Hello/status `console.plugins` (D363): the bridge runs its own rg.probe
through ConsoleSystem.Run from a timer. Carbon 2.0.262 refuses plugin
commands only inside a console command (or a NextTick one queued), which is
how §14.2's spike tested; from a timer, and from the link reader's NextTick,
it finds them. Reads true on both rigs.
- checkPlugin.js: the probe must name a [ConsoleCommand] the plugin declares.
Compiled and loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon
2.0.262); heli.arrived, cargo.arrived and airdrop.called read back from each
rig's sidecar store. Rigs restored. 18 check tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
WhiteThunder's Backpacks as an optional integration (D199), as §11.3.2 designs it.
Read, never write (R2):
- hello/status `integrations.backpacks` adds `sizes`: the N of every registered
backpacks.size.N, ascending (dynamic-size profiles left out). Hello is resent
when Backpacks loads or unloads.
- `backpacks.read {steamId}`: API_ReadBackpackContentsAsJson, and only when that
answered a backpack, API_GetBackpackCapacityById — the backpack is loaded by
then, so the capacity follows a later grant (§11.3.1's trap). Each stack is
reduced to slot, shortname, display (the game's English name, D340), amount,
skin and name when set; a blueprint carries its target as `blueprint` (D341);
contents one level deep; `overflow` past the capacity. `pageSize` is
Backpacks' "Max size per page" from its config, cached by mtime.
- Refusals: backpacks-missing, bad-id, unreadable.
The bigger backpack an event lends is a backpacks.size.N permission pushed by
perm.sync (D337), so it needs no code here. Still protocol 14 (D332).
Compiled on both rigs (Rust changeset 167584; Oxide 2.0.7826, Carbon 2.0.262),
then restored to the bridge they ran before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Wulf's Economics as an optional integration (D199), as §11.2.2 designs it:
- hello/status `integrations.economics` adds startingBalance and wipesWithMap,
read from Economics.json (cached by mtime) because its API has neither.
- `economics.pay`: coins from an event step, kept once paid (D335). One main-
thread call for the list; each deposit measured as after - before, since
Deposit answers true while rounding and clamping (`at-limit` otherwise).
The key and its reply are kept in data/RunicGateway/payments.json for seven
days, so a retry answers `repeat: true` and pays nobody. The optional
message ({amount} = what landed) is told now if awake, else on wake.
- `economics.balances` (bounded by PermMaxOps) and staff `economics.adjust`
(deposit / withdraw / set; a withdrawal above the balance is refused
`insufficient` with the balance).
- `player.tally` gains `balance`, a value: read at connect (D336) and from
OnEconomicsBalanceUpdated, offline players included.
- `economics.wiped` from OnEconomicsDataWiped(IPlayer), with `newSave`.
Amounts are boxed as double and only Steam ids are sent, per the spike.
Compiled on both rigs (Oxide 2.0.7826, Carbon 2.0.262), then restored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
world.zone gains flags, settings, enterMessage, leaveMessage, delivery,
format and dome (PROTOCOL §19.11). Flags are checked against the live
ZoneManager's own list, read through its API call ZoneFieldListRaw() (D211),
and refused bad-option rather than dropped; settings are an allowlist of
five with bounds. Both are kept on the registry entry and re-applied when
a zone is re-created.
The bridge says a zone's enter and leave messages itself (D193), as chat
or a popup (chat when PopupNotifications is missing), and stays quiet for
5 s after re-creating a zone.
RunicGatewayDomes.cs, a new helper, patches ZoneDomes so its API can be
called without a player, and raises OnRgDomesReady after ZoneDomes' own
start; the bridge puts its domes back only after it, so none is drawn
twice. Domes are removed before their zone at expiry and revert.
hello carries zoneManager { version, flags }, integrations.zoneDomes the
ZoneDomes and helper state, and hello is sent again when ZoneManager,
ZoneDomes or either helper loads or unloads. The release ships the new
helper beside the zone helper.
Not compiled or walked on a rig: the panel's file API was down on both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
The bridge's half of PLAN_FIXES §6 step 2 (decisions D181-D185, docs#288).
- F13: `world.expired` carries the entity's kind as `what`. It used to write
it over the frame's own `kind`, so every expiry left filed as "zone".
- F1 (D159): `gathered` also counts OnDispenserBonus (the final-hit bonus),
OnCollectiblePickup (hemp, mushrooms, stones off the ground) and
OnGrowableGathered (farmed plants); all three are void and in ExpectedHooks.
- F3: taking down a building block you own, or one on a cupboard you are
authorised on, no longer counts toward `structures`. The frame still goes.
- F4: every remaining UserIDString is SteamIdOf(userID), so a player another
plugin spawned is never `steamId: null`.
- F8 (D184): `plugin.loaded` / `plugin.unloaded`, once the world is ready,
with the permissions that plugin added or removed (a diff of the registered
list, taken a tick after the hook).
- F12 (D181, D182): RunicGatewayZones.cs, a D168 helper shipped beside the
bridge, Harmony-postfixes ZoneManager's Zone.InitializeZone and, a tick
later, enters every connected player ZoneManager's own IsPositionInZone
places inside, through ZoneManager's own OnPlayerEnterZone. Without it (or
when it cannot find what it patches) the bridge scores its zones by
IsPositionInZone. Hello reports `zoneHelper: { state, version?, reason? }`.
- release.yml stages every helper beside the bridge and lists each .cs with
its sha256 in manifest.files; the installer and the egg place them all.
Not yet compiled: this is walked on both rigs before it merges.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Module-rust phase 18, step 3 of docs/modules/rust/PLAN.md §34.2.7.
The environment seam (D150): when the plugin writes its default config and
RUSTLINK_SERVER_ID holds a valid id (the site's ^[a-z0-9][a-z0-9-]{0,63}$),
the file's ServerId is that value. It is read nowhere else: after that write
the file is canonical and the site's lock applies. An invalid value is logged
and ignored. This is how the egg names a server; not a message change, so the
protocol stays at 12.
The release (D145, §34.2.1): release.yml reuses servuo-plugins' release engine
unchanged (tag-only, secrets preflighted, orphan tags recovered and swept, the
installer's bundle.yml dispatched at the end). The adapter re-runs
checkPlugin.js and packages runicgateway-rust-plugin-<version>.tar.gz with a
framework-neutral layout: RunicGateway.cs, its [Info] version stamped to the
release, and a manifest.json folding in overlay.toml.
overlay.toml gains min_carbon_version = "2.0.259", the build R19 was proven on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
The `clans` board carries every clan the game will list, with its roster
and roles, plus the facts the website needs to trust it: enabled,
backend, supported, truncated, and whether the optional uMod Clans
plugin is loaded. It is re-sent on connect and on the 60-second cadence,
and a few seconds after any membership change. The five events are
clan.created, clan.disbanded, clan.member.added, clan.member.left and
clan.member.kicked.
What the game's assemblies dictated:
- The game has no enumerate-all call. The clan leaderboard is the only
listing, and the game clamps it to 100 clans. A board at that ceiling
says `truncated`, as does one that would not fit in 768 KiB (the
sidecar drops lines over 1 MiB).
- The local backend is synchronous behind ValueTask. A task that has not
completed means another backend (Nexus), which is refused with a reason
rather than waited on from the main thread.
- A clan id is not an identity, so every frame carries createdMs.
- OnClanMemberAdded hands over a bare id before the game's cache
refreshes, so the clan is read back, with a fallback to the id alone.
- uMod Clans raises OnClanDisbanded(string, List<ulong>), which has the
same name and arity as the game's hook, so the bridge types it exactly.
A member's LastSeen is never sent: it is presence (PLAN.md §23).
overlay.toml moves to protocol 6 and drops Clans from requires_plugins
(D47): Teams come from the game's own clans.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
RunicGateway.cs dials out to a rust-link sidecar on loopback and speaks
newline-delimited JSON over it: server.hello on every connect, a pong to the
sidecar's heartbeat, and one correlated server.status.
The threading contract is the ServUO bridge's, unchanged, because the reason for
it is the same on both games:
* Emit is called from the main thread. It formats nothing, blocks on nothing and
touches no socket -- it enqueues and returns, so a wedged or absent sidecar
cannot stall the game. The queue is bounded, drop-oldest.
* One link thread owns the socket, which keeps event ordering intact.
* A reader thread marshals every inbound line to the main thread through
Interface.Oxide.NextTick, and touches no Unity object, BasePlayer or ConVar.
Settings come from Oxide's own config (oxide/config/RunicGateway.json), so an
operator retunes the bridge the way they retune any other plugin -- and so it
lands inside the site-side config editor a later phase adds.
Four things the live rig corrected, none of which a unit test could have:
* A disconnect was silent in the game console. The teardown log sat in the
catch, and a connection ending because the READER saw EOF leaves the writer to
exit cleanly -- nothing throws, so nothing was logged. A log in a catch only
covers the failures that throw, and an orderly peer shutdown is not one.
* Unload blocked the main thread for 1.9s (Oxide says so out loud), because the
reconnect backoff was Thread.Sleep and Unload joins the link thread. Waiting on
the AutoResetEvent that Unload already signals makes it immediate. The ServUO
plugin has the same sleep and gets away with it only because ServUO does not
hot-reload.
* Mono's SocketException.Message is NUL-padded on Windows -- around 200 \0 bytes
in the middle of the sentence, from a fixed-size OS buffer. \0 is not
whitespace, so Trim does not touch it and neither does a whitespace-only
collapse; the flattener has to treat control characters as separators. It took
od -c on the log to see at all.
* bootId regenerated on every PLUGIN load rather than every SERVER start. A
fresh Guid at Init meant oxide.reload announced a brand new boot, and the
website's reconcile design hangs off that value -- so every reload would have
asked core to sweep its whole resource ledger for a world that never moved. It
is now Process.StartTime: exact, identical on every read, and it changes when
and only when the thing it names changes.
rg.link reports the link's own counters from the console or over RCON, which is
what separates 'the plugin is not loaded' from 'the plugin cannot reach the
sidecar' from 'the website cannot reach the sidecar'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4