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
6.2 KiB
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:
Emitis 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, noBasePlayerand noConVar— 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
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.
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.
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.