Files
Rust-Plugins/README.md
wtclaude e0d6c46850
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 11s
feat(bridge): RaidableBases — raid.* events, raid.spawn, the raids helper (PLAN_REDESIGNS §11.4)
PLAN_REDESIGNS §11.4 (D202-D208, D386-D390), protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.12.

- RunicGatewayRaids.cs, the raids helper (D168, D204-D207, D387), shipped
  beside the bridge and released with it. Harmony patches on RaidableBases'
  spawn queue Add, OpenEvent and each base's BypassUseOwners(): a run's base
  is tagged at birth, its PvP/NPCs/despawn/lock set on a member-by-member copy
  of its profile (Setup, NPC and DespawnOptions copied again, so the shared
  profile is never touched), PvP also set on the base after OpenEvent; a spot
  checked (own look, then the plugin's area check with its reason, rocks and
  cliffs a warning; then the ocean level, D388, D389) and pasted; one base
  despawned; profiles reloaded under watch. Both editions, every member found
  by name. rgr.status.
- Hello's integrations.raidableBases: edition, the paid spawn call (looked
  for by name, D348), CopyPaste, the plugin's own difficulties (D386), the
  helper, and with it the grid and the profiles.
- raid.started / raid.completed / raid.ended / raid.purchased / raid.locked
  from the lifecycle hooks; the unload trap's completions (no owner, no
  raiders) are neither sent nor counted. The tally's raidsCompleted (Warlord).
- map.live's raids layer, kept by the hooks and seeded from GetAllEvents().
- raid.spawn: with the helper the base is a world registry entry, so
  world.revert / world.owned / world.expired serve it (never-started after
  15 min, raidablebases-unloaded); without it the paid call or rbevent, not
  the run's. raid.check for the editor's advice (D208, D391).
- RaidableBases' profiles in the configuration editor (D205): a virtual
  @data/RaidableBases/Profiles/ path, all-profiles-or-none writes, reloaded by
  the helper and rolled back when a profile does not load.

Compiled on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262) and
driven on both rigs with the paid 3.2.2 and the tier-3 pack through a
throwaway plugin: the pick and spot routes with every setting, a repeat key,
revert, expiry, and a good and a broken profile edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 19:29:02 -05:00

13 KiB
Raw Blame History

Rust-Plugins

The in-game half of the Runic Gateway bridge for Rust: one Oxide plugin that dials out to a rust-link sidecar and speaks newline-delimited JSON over it.

It is the mirror of RunicGateway/servuo-plugins, which does the same job for Ultima Online — and it inherits that plugin's threading contract wholesale, because the reason for it is the same on both games.

The threading contract

Everything else in this repo depends on these three:

  • Emit is called from the main thread. It formats nothing, blocks on nothing, and touches no socket. It enqueues and returns. A slow, wedged, or absent sidecar cannot stall the game.
  • One link thread owns the socket. It connects, drains the queue, and reconnects with backoff. A single writer keeps event ordering intact.
  • A reader thread parses inbound lines and hands each to the main thread via Interface.Oxide.NextTick. The reader touches no Unity object, no BasePlayer and no ConVar — every one of those is main-thread-only, and reading one from the reader is the kind of bug that presents as a crash somewhere else entirely.

The outbound queue is bounded, drop-oldest. On overflow the oldest record goes and is counted, because telemetry is worth less than the server's memory.

Loopback is the trust boundary

There is no token on the game link. The plugin and the sidecar share a host, and the sidecar binds 127.0.0.1 — that is the authentication. Pointing Host at anything routable puts an unauthenticated command channel on the network.

Installing it

overlay/oxide/plugins/RunicGateway.cs       →  <server>/oxide/plugins/RunicGateway.cs
overlay/oxide/plugins/RunicGatewayZones.cs  →  <server>/oxide/plugins/RunicGatewayZones.cs
overlay/oxide/plugins/RunicGatewayDomes.cs  →  <server>/oxide/plugins/RunicGatewayDomes.cs
overlay/oxide/plugins/RunicGatewayRaids.cs  →  <server>/oxide/plugins/RunicGatewayRaids.cs

RunicGatewayZones.cs is the ZoneManager helper (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager counts a player in a zone only when its trigger fires on entry, so a zone created, updated or re-created around somebody already standing there holds nobody. The helper patches that with Harmony. It is optional: without it the bridge still loads, scores its own zones by position, and reports the helper missing at hello. rgz.status in the server console says whether it patched.

RunicGatewayDomes.cs is the ZoneDomes helper (docs/modules/rust/PLAN_REDESIGNS.md §3.2, D194). ZoneDomes' API ends by telling the player who asked how it went, and throws when a plugin calls it with no player; and at its own start it drops the dome of every zone that does not exist yet, which is every zone the bridge re-creates. The helper patches the first with Harmony and tells the bridge when ZoneDomes has started, so the bridge can put its domes back without drawing any twice. It is optional: without it the site offers no dome, and says which piece is missing. rgd.status in the server console says whether it patched.

RunicGatewayRaids.cs is the RaidableBases helper (docs/modules/rust/PLAN_REDESIGNS.md §11.4, D204–D207, D387). RaidableBases' API spawns nothing and despawns nothing, so an event's base cannot be the event's from the outside. The helper reaches its internals by reflection and patches its spawn queue, OpenEvent and each base's BypassUseOwners() with Harmony: it tags the base a run asked for at birth, applies that spawn's own PvP, NPCs, despawn time and lock on a copy of its profile, places one at a spot after checking it, despawns that one base at the run's end, and reloads RaidableBases' profiles when the site edits them. It serves both editions, free and paid, and reads every difficulty, profile and base name from the installed plugin. It is optional: without it the bridge still reports raid bases on the feed and the map, and a step can still ask RaidableBases for a base where it picks, but that base despawns on its own timer, and the site says so. rgr.status in the server console says whether it patched, which edition's spawn call it found, and whether the plugin's spawn grid is built.

Oxide compiles and loads it on the write, and writes oxide/config/RunicGateway.json on first load:

{
  "Host": "127.0.0.1",
  "Port": 7799,
  "QueueCap": 5000,
  "ServerId": "main"
}

ServerId is this server's stable identity across wipes and restarts, as the website knows it. It is deliberately not derived from the hostname: an operator renames a server for a season, and the site must not lose its history for it.

When that first config is written, and only then, a valid RUSTLINK_SERVER_ID in the server's environment becomes its ServerId (an invalid one is logged and ignored). That is how the Pterodactyl egg names a server, since an egg can set a variable but cannot reliably write a framework's config directory. After that the file is canonical and the website locks the value, so changing the variable later changes nothing.

On Carbon the same file goes to carbon/plugins/ and its config to carbon/configs/.

That is the developer's loop. An operator uses the installer (--game rust) or the Pterodactyl egg, both of which deploy a released plugin and install the sidecar beside it.

Releases

Every merge to main that carries a feat, fix or perf commit cuts a release (.gitea/workflows/release.yml): runicgateway-rust-plugin-<version>.tar.gz and SHA256SUMS. The tarball holds one directory, runicgateway-rust-plugin/, with RunicGateway.cs (its [Info] version stamped to the release), the helpers beside it (each keeping its own version; every .cs is listed with its sha256 in the manifest's files, and the installer and the egg place them all) and a manifest.json folding in overlay.toml: the protocol, the Oxide and Carbon floors, and requires_plugins. The layout names no framework; the installer and the egg decide where the file goes. A release then asks the installer repo to recompose its Rust bundle.

Prerequisites

The bridge itself needs nothing but Oxide. The features that follow it read three third-party plugins an operator installs from uMod — Kits, PopupNotifications and ZoneManager. They are listed in overlay.toml so the installer's doctor can report a missing one by name rather than leaving the site quietly short of a feature.

Clans on the website come from Rust's own clan system, not from the uMod Clans plugin, which is optional. The two share no data: a server running the plugin has two unrelated clan systems, and only the game's own clans become Teams on the site.

Wulf's Economics is optional too, and not in overlay.toml. Where it is loaded the site can pay coins from an event, show balances and a richest column, and let staff adjust a balance; where it is not, each of those says "Economics is not installed on this server".

WhiteThunder's Backpacks is optional in the same way. Where it is loaded staff can open a player's backpack from the site, a player can see their own, and an event can lend a bigger one for its run; the bridge only ever reads a backpack, and the bigger size is one of Backpacks' own backpacks.size.N permissions, pushed like any other grant.

nivex's TruePVE is optional in the same way. Where it is loaded an event's zone can turn off PvP protection inside it, or use one of the operator's own TruePVE rulesets by name; where it is not, the zone step says "TruePVE is not installed on this server". The bridge only ever adds and removes the mappings of zones it opened, and removes each one with its zone, even when TruePVE was away at the time.

HunterZ's Dynamic PVP (which needs ZoneManager and TruePVE) is optional too. Where it is loaded, the PvP zones it opens around a downed helicopter, a Bradley, cargo and the rest reach the site's feed and its map. The bridge only reads them: it never makes, maps or erases one of Dynamic PVP's zones.

nivex's Dangerous Treasures is optional as well. Where it is loaded, its treasure events reach the site's feed, its map and the Treasure Hunter title, and an event can open one (dt.start) through DT's own dtevent console command; DT picks the spot and despawns its own chest. For about ten minutes after DT loads it is still finding its spots, and a start then is answered "not ready yet". A framework that cannot run another plugin's console command from code (rg.world says consolePlugins=False) cannot open one.

Bazz3l's Guarded Crate is optional too, and needs NpcSpawn (a free Codefling download, not shipped here) for its guards. Where both are loaded, a guarded crate landing, being cleared and timing out reach the site's feed, its map and the Warden title, and an event can place one (gc.place) at a monument or a point through Guarded Crate's own gcrate position console command. A placed crate is kept when the event ends: Guarded Crate has no way to end one event alone. Without NpcSpawn a placement is refused, because the crate would land unguarded; and, as for Dangerous Treasures, a framework with consolePlugins=False cannot place one.

nivex's RaidableBases is optional, free or paid, and needs CopyPaste to paste its bases. Where it is loaded, a base appearing, being raided, bought or locked to a player reach the site's feed, its map's own Raid bases layer and the Warlord title, and an event can spawn one (raid.spawn). With RunicGatewayRaids.cs (above) the base is the event's: at a spot or where RaidableBases picks, with its own settings, despawned when the event ends; and the site can edit RaidableBases' profiles from its configuration page, reloading them under watch. For up to ten minutes after RaidableBases loads it builds its spawn grid and refuses every spawn, and a spawn then is answered "not ready". Every difficulty, profile and base name is read from the installed plugin.

nivex's ZLevels Remastered is optional, and the bridge only says whether it is loaded. Its levels are never read or sent. Its XP boosts are its own permissions (zlevelsremastered.vip1 to vip3), so an event lends one as it lends any other permission, and it takes effect at once.

An event can also run console commands an admin typed (command.run): once, as a step, or once for each recipient of a reward who is online, with {steamid} and {name} filled in (the name as one quoted argument). That is how a plugin with no support of its own here is used from an event, for example RaidableBases' rbe. There is no allowlist: the site's switchboard is the switch, and EventsEnabled here is the server's. Each request runs at most once: its key is written to data/RunicGateway/commands-run.json before anything runs, and is kept for seven days. A step's teardown commands are kept with its key too, so the site can have them run even for a step that failed part-way or whose answer was lost (teardownOf). A command the server does not know stops the request there as unknown-command. On a framework with consolePlugins=False, that is every plugin's command.

Rust's own world events need no plugin at all. The patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the satellite and the Deep Sea reach the site's feed and its event triggers on every server, and an event can call one of them (world.call). What a call starts is kept when the event ends; EventsMaxWorldCalls caps how many one run may call.

Diagnosing it

rg.link

from the server console or over RCON. It reports the link's own counters:

protocol=1 serverId=main connected=True depth=0 sent=3 dropped=0 received=2
connects=1 writeErrors=0 bootId=boot-20260915T194502Z

This is the first thing to ask for when the website says a server is offline — it separates "the plugin is not loaded", "the plugin cannot reach the sidecar" and "the website cannot reach the sidecar", which look identical from the site.

bootId identifies the server PROCESS, not the plugin load. It is the process start time, so oxide.reload RunicGateway does not change it. That matters more than it looks: the website watches this value to tell a game restart (everything an event put in the world is gone) from a bridge reconnect (nothing is lost), and a plugin reload is the second kind.

The protocol is a contract

ProtocolVersion in the plugin and protocol in overlay.toml must agree with the sidecar's PROTOCOL_VERSION and the module's own constant. The installer refuses to pair an overlay and a sidecar that disagree, so a bump landing in one repo and not the others fails to compose rather than half-deploying.

The canonical spec is docs/rust-link/PROTOCOL.md.

Licence

GPL-3.0-or-later. See LICENSE.md.