Files
Rust-Plugins/README.md
wtclaude b52b8a8f80 feat(bridge): console commands as event steps — command.run (PLAN_REDESIGNS §16)
A `command.run` frame (protocol 14, PROTOCOL.md §20.11) runs the console
commands a step's author typed: once, or once per online recipient with
{steamid} and {name} filled in (the name quoted the way the game's own
BuildCommand quotes an argument). D382-D384: no allowlist; offline
recipients are skipped and named.

- At most once per key: the key is written to commands-run.json before
  anything runs; a repeat answers from the record, a repeat while running
  joins the running job, a record a reload interrupted answers
  `interrupted`. Kept 7 days, at most 500 records.
- Spread over frames: whole recipients per frame, up to 30 commands or 8 ms.
- unknown-command: ConsoleSystem.Run returns null both for an unknown command
  and for one that replies nothing, so the run listens to Unity's log for the
  game's own "Command '...' not found" line while each command runs (spiked
  on Oxide 2.0.7830 and Carbon 2.0.262, Rust 25902425). The same capture is
  the reply when Run returns nothing.
- rg.world shows commandKeys and commandsRunning.

Compiled on both rigs and driven through a throwaway plugin (repeat key,
unknown command, offline recipients, malformed requests); rigs restored to
the protocol-13 bridge.

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

190 lines
11 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
```
`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.
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 `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 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).