feat(bridge): the Oxide plugin — protocol 1 #1

Merged
whitlocktech merged 1 commits from feat/phase-1-bridge into main 2026-09-16 01:31:04 +00:00
Member

First code in this repo. RunicGateway.cs dials out to a rust-link sidecar on loopback and speaks newline-delimited JSON over it.

Phase 1 of modules/rust/PLAN.md. Lands with Rust-Link#1, Module-Rust and docs.

The threading contract

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 (org-lead decision D3), 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 these could have been caught by a test. A hook binds by reflection and fails silently, so a running server is the only proof.

  1. 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. Generalisable: a log in a catch only covers the failures that throw, and an orderly peer shutdown is not one.
  2. Unload blocked the main thread for 1.9s, which Oxide reports out loud. 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.
  3. Mono's SocketException.Message is NUL-padded on Windows — around 200 NUL bytes in the middle of the sentence, from a fixed-size OS buffer. NUL is not whitespace, so Trim does not touch it and neither does a whitespace-only collapse. It took od -c on the log to see at all.
  4. 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. Verified by reloading twice and watching the id hold.

Diagnosing it

rg.link, from the console or over RCON, reports the link's own counters. That is what separates "the plugin is not loaded" from "the plugin cannot reach the sidecar" from "the website cannot reach the sidecar", which look identical from the site.

How it was verified

Compiled and loaded on the live rig on the first try; reconnect, disconnect logging, backoff, heartbeat and the correlated reply were each exercised there. A server.hello from it reached the public website API.


  • This contribution was AI-assisted (Claude Code).

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

First code in this repo. `RunicGateway.cs` dials out to a rust-link sidecar on loopback and speaks newline-delimited JSON over it. **Phase 1 of [`modules/rust/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust/PLAN.md).** Lands with [Rust-Link#1](https://gitea.whitlocktech.com/RunicGateway/Rust-Link/pulls/1), Module-Rust and docs. ## The threading contract 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 (org-lead decision D3), 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 these could have been caught by a test. A hook binds by reflection and fails silently, so a running server is the only proof. 1. **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. Generalisable: a log in a `catch` only covers the failures that throw, and an orderly peer shutdown is not one. 2. **`Unload` blocked the main thread for 1.9s**, which Oxide reports out loud. 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. 3. **Mono's `SocketException.Message` is NUL-padded on Windows** — around 200 NUL bytes in the middle of the sentence, from a fixed-size OS buffer. NUL is not whitespace, so `Trim` does not touch it and neither does a whitespace-only collapse. It took `od -c` on the log to see at all. 4. **`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. Verified by reloading twice and watching the id hold. ## Diagnosing it `rg.link`, from the console or over RCON, reports the link's own counters. That is what separates "the plugin is not loaded" from "the plugin cannot reach the sidecar" from "the website cannot reach the sidecar", which look identical from the site. ## How it was verified Compiled and loaded on the live rig on the first try; reconnect, disconnect logging, backoff, heartbeat and the correlated reply were each exercised there. A `server.hello` from it reached the public website API. --- - [x] This contribution was AI-assisted (Claude Code). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-16 00:57:44 +00:00
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
whitlocktech merged commit ad141368c8 into main 2026-09-16 01:31:04 +00:00
whitlocktech deleted branch feat/phase-1-bridge 2026-09-16 01:31:07 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Rust-Plugins#1
No description provided.