Fifteen hooks: presence, deaths, chat, gathering, raided structures, bans,
reports, login attempts and the wipe. Every frame now carries an envelope —
`type`, `serverId` and `wipeId` — built in one place so nothing can emit a frame
without one.
Three rules the code enforces structurally rather than by intention:
• A read-path hook never vetoes. Four of these are documented as "returning a
non-null value overrides default behavior", so every hook is declared `void`
and cannot answer. `CanUserLogin` is in the wave for what it observes.
• A hook that can fire more than once a second per player is a counter.
`OnDispenserGather` fires on every swing at a tree; it accumulates into a
per-player tally flushed once a minute as one `player.tally` frame, as a
delta rather than a running total.
• `wipeId` is derived here, from the save's creation time, because this is the
only component that can read it. PROTOCOL.md §8.2 reverses protocol 1 on
that point deliberately.
`server.hello` becomes a board rather than a greeting, and `players.online`
joins it; both are re-sent on connect and every 60 seconds, which is what makes
a restarted sidecar repopulate itself without asking.
`rg.hooks` reports which hooks have actually fired. Hooks bind by name and arity
through reflection on both frameworks, so a rename by Facepunch and a name
Carbon's catalogue omits present identically — as silence. This is the standing
answer to both, and it outranks either catalogue because it is a measurement.
The repository had no `.gitea/workflows/` at all. `scripts/checkPlugin.js` asks
the three questions a compiler here cannot: every hook is in `ExpectedHooks`, so
`rg.hooks` can see it; every hook is `void`, unless answering is a decision
written down in `ANSWERS_DELIBERATELY`; and `ProtocolVersion` agrees with
`overlay.toml`, which is what stops a bundle that will not compose. The void
rule is inverted on purpose — a list of *vetoable* hooks would have to be
maintained against a catalogue in another repository, and the first one somebody
forgot to add is the one that would pass. Its own suite breaks it seven ways,
including the failure that would make the other six meaningless: a method parser
that silently matches nothing.
Proven on a live Oxide server: compiled, loaded, the envelope correct, both ban
hooks firing, and the boards repopulating a sidecar whose database had been
deleted 0.3 seconds earlier.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
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.