feat(bridge): the Oxide plugin — protocol 1

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
This commit is contained in:
2026-09-15 19:53:33 -05:00
parent a6cfa3abfb
commit b3711e6778
3 changed files with 858 additions and 0 deletions

49
overlay.toml Normal file
View File

@@ -0,0 +1,49 @@
# Release metadata for the deployable overlay.
#
# Consumed by the release workflow, which folds these values into the
# manifest.json shipped inside the overlay tarball. The Runic Gateway installer
# reads that manifest to decide what it is deploying and whether it is compatible
# with the sidecar it is about to install.
#
# There is deliberately NO version key here. The release version is derived from
# git tags and conventional commits by the release workflow, so there is no bump
# commit to keep in sync and no way for this file to disagree with the tag.
# ── The loopback wire-protocol version this overlay speaks ───────────────────
#
# The plugin half of the compatibility contract. It MUST equal the sidecar's
# PROTOCOL_VERSION (Rust-Link's sidecar/src/main.rs) for a deployment to work:
# the sidecar rejects a mismatched WEBSITE with 409, and a mismatched PLUGIN is
# worse, because the game link has no such check — it would simply mis-parse.
#
# That asymmetry is why this file exists. The plugin announces its protocol in
# `server.hello`, which is only readable after the game server has booted with it
# loaded — far too late for an installer to refuse a bad pairing. This
# declaration is what lets the bundle CI check the pair BEFORE an operator
# installs either half.
#
# Keeping it honest is a manual duty: when the protocol changes, bump it here in
# the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant.
#
# Current: 1 — the transport (docs/rust-link/PROTOCOL.md).
protocol = 1
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
# The overlay only ADDS a file — one plugin into `oxide/plugins/` — and patches
# nothing, so it is expected to work on any reasonably current Oxide. This is the
# oldest build it is known good on.
#
# There is no `patches_verified_against` key, and there is no `patches/` tier:
# Rust's server is a binary and Oxide's hook API is the supported way in, so
# there is nothing to diff against. That is the whole reason the Rust payload is
# simpler than the ServUO one.
min_oxide_version = "2.0.7585"
# The `oxide/plugins/` files this overlay expects to find already installed. They
# are not shipped here — they are third-party plugins an operator installs from
# uMod — and the installer's `doctor` reports a missing one rather than
# installing it. Listing them is what turns "the site shows no clans" into a
# named prerequisite.
requires_plugins = ["Clans", "Kits", "PopupNotifications", "ZoneManager"]