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
4.1 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
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.
That is the developer's loop. An operator uses the installer, which syncs the released overlay tarball and installs the sidecar alongside it.
Prerequisites
The bridge itself needs nothing but Oxide. The features that follow it read four third-party plugins
an operator installs from uMod — Clans, 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.
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.