All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 11s
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
213 lines
13 KiB
Markdown
213 lines
13 KiB
Markdown
# Rust-Plugins
|
||
|
||
The **in-game half** of the Runic Gateway bridge for [Rust](https://rust.facepunch.com/): one Oxide
|
||
plugin that dials out to a [rust-link](https://gitea.whitlocktech.com/RunicGateway/Rust-Link)
|
||
sidecar and speaks newline-delimited JSON over it.
|
||
|
||
It is the mirror of
|
||
[`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/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:
|
||
|
||
```json
|
||
{
|
||
"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](https://gitea.whitlocktech.com/RunicGateway/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`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/PROTOCOL.md).
|
||
|
||
## Licence
|
||
|
||
GPL-3.0-or-later. See [LICENSE.md](LICENSE.md).
|