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