diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml
new file mode 100644
index 0000000..1fbfcda
--- /dev/null
+++ b/.gitea/workflows/pr-checks.yml
@@ -0,0 +1,61 @@
+# Gate every pull request into `main`.
+#
+# This repository had no workflows at all — the same hole phase 2 found in
+# Module-Rust, in the repo that ships the half running inside somebody's game
+# server. It is the one component here that cannot be compiled by CI: the plugin
+# is deployed as SOURCE and built by Oxide or Carbon against game assemblies that
+# exist only on a Rust server, so a build job is not available at any price.
+#
+# What is available is a reader, and the mistakes worth reading for are the ones
+# both frameworks make silent. Hooks bind by name and arity through reflection,
+# with no compile-time check and no warning when a name matches nothing, so:
+#
+# • a hook that is not in `ExpectedHooks` is invisible to `rg.hooks`, which is
+# the instrument this project relies on to answer "does this hook fire on
+# this framework" (CARBON.md §6);
+# • a hook that RETURNS something can cancel a death, swallow a player's
+# gathered wood, or refuse a login (PROTOCOL.md §8.7);
+# • a `ProtocolVersion` that disagrees with `overlay.toml` produces a bundle
+# that will not compose, and the game link has no handshake to catch it.
+#
+# `scripts/checkPlugin.js` asks all three, dependency-free, and its own test
+# suite breaks it seven ways — including the failure that would make every other
+# case meaningless, a method parser that silently matches nothing.
+#
+# Enforcement (one-time, in the Gitea UI):
+# Repository Settings → Branches → Branch Protection (rule for `main`)
+# • Enable Status Check
+# • Status check patterns: PR Checks / *
+# Gitea only lists a context after it has reported once; the glob matches
+# without the dropdown and keeps matching as jobs are added.
+
+name: PR Checks
+
+on:
+ pull_request:
+ branches: [main, edge]
+
+concurrency:
+ group: pr-checks-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ plugin-checks:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ # No install step: the checks are dependency-free on purpose, which is also
+ # how a contributor runs them.
+ - name: Check the plugin's hooks, void rule and protocol declaration
+ run: node scripts/checkPlugin.js
+
+ # Named individually rather than `node --test scripts/`: directory mode is
+ # not portable across the Node versions this project runs on.
+ - name: Test the checker itself
+ run: node --test scripts/checkPlugin.test.js
diff --git a/overlay.toml b/overlay.toml
index 3e12f77..d3a3ca7 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -26,8 +26,8 @@
# 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
+# Current: 2 — the transport plus the read path (docs/rust-link/PROTOCOL.md §8).
+protocol = 2
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index fec886d..dd1fb84 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -56,7 +56,7 @@ namespace Oxide.Plugins
/// overlay's overlay.toml carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
///
- private const int ProtocolVersion = 1;
+ private const int ProtocolVersion = 2;
///
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -67,9 +67,54 @@ namespace Oxide.Plugins
/// The longest line accepted from the sidecar. Over-long lines are discarded.
private const int MaxInboundLineBytes = 1024 * 1024;
+ ///
+ /// How long to wait for the TCP connect itself before giving up and backing off.
+ ///
+ ///
+ /// It exists because TcpClient.Connect is blocking and has no timeout of its
+ /// own, and the OS default is tens of seconds. A host that REFUSES answers instantly —
+ /// which is every loopback test, and why this was invisible for two phases — but a host
+ /// that drops does not answer at all: a firewall, a wrong address, a machine
+ /// that is off. The link thread then sits in the connect, deaf to everything, and
+ /// Unload waits out its whole join on the game's MAIN THREAD.
+ ///
+ ///
+ ///
+ /// Generous against any real network and short against a hang. Loopback, which is the
+ /// supported deployment, connects in well under a millisecond.
+ ///
+ ///
+ private const int ConnectTimeoutMs = 3000;
+
+ ///
+ /// How often the boards are re-sent (PROTOCOL.md §8.3) and the per-player tallies are
+ /// flushed (§8.6). One cadence serves both because both answer the same question —
+ /// "what has this server been doing lately" — and two timers would only be two things
+ /// to get out of step.
+ ///
+ private const float BoardCadenceSeconds = 60f;
+
+ ///
+ /// The side of one map grid cell, in world units. Rust's own grid, as every community
+ /// map and every server console uses it.
+ ///
+ private const float GridCellSize = 146.3f;
+
private static readonly ConcurrentQueue Outbound = new ConcurrentQueue();
private static readonly AutoResetEvent Wake = new AutoResetEvent(false);
+ ///
+ /// Set once, by Unload, and never reset. It is what lets a thread blocked on a
+ /// CONNECT give up immediately instead of at the end of its timeout.
+ ///
+ ///
+ /// It cannot be : that handle also means "the queue has something in
+ /// it", which Emit signals constantly, so waiting on it during a connect would
+ /// abandon the attempt every time a hook fired. Two meanings, two handles.
+ ///
+ ///
+ private readonly ManualResetEvent _stopping = new ManualResetEvent(false);
+
private PluginConfig _config;
private Thread _link;
@@ -110,11 +155,92 @@ namespace Oxide.Plugins
///
private string _bootId;
+ ///
+ /// The current wipe, as w-yyyyMMddTHHmmssZ, derived from the save's creation
+ /// time. Null until the server has saved at least once — a server with no save has no
+ /// wipe, and PROTOCOL.md §8.2 says that is reported by the field being **absent**
+ /// rather than by an empty string that will sort beside every other empty string for
+ /// ever.
+ ///
+ ///
+ /// Cached rather than read per frame, and re-read on OnNewSave, which is the
+ /// only moment it can change. A wipe is a fact about the world; nothing about a
+ /// reconnect, a plugin reload or a restart moves it.
+ ///
+ ///
+ private string _wipeId;
+
+ ///
+ /// Per-player counters waiting to be flushed as a player.tally frame. Main
+ /// thread only — every writer is a game hook and the flush runs on a timer, which is
+ /// also the main thread, so this needs no lock and must never acquire one.
+ ///
+ private readonly Dictionary _tallies = new Dictionary();
+
+ ///
+ /// When each connected player connected, in epoch milliseconds, so a disconnect can
+ /// report a session length. Main thread only.
+ ///
+ ///
+ /// A player already on the server when this plugin loaded has no entry, and
+ /// sessionSec is then absent from their disconnect rather than zero: an
+ /// unknown session is not a zero-length one, and summing the zeros would under-report
+ /// playtime quietly and for ever.
+ ///
+ ///
+ private readonly Dictionary _connectedAt = new Dictionary();
+
+ ///
+ /// Every hook name this plugin declares, and whether it has fired at least once.
+ ///
+ ///
+ /// Both frameworks bind hooks by name and arity, through reflection, with no
+ /// compile-time check: a hook Facepunch renames on a Thursday simply stops being
+ /// called, and a hook one framework's catalogue does not list may or may not exist.
+ /// Neither failure logs anything anywhere. rg.hooks reads this dictionary, which
+ /// makes the plugin its own answer to both questions on whichever framework it is
+ /// actually running under — and a cheaper answer than either catalogue.
+ ///
+ ///
+ private readonly Dictionary _hookCounts = new Dictionary();
+
+ ///
+ /// The repeating boards/tally timer.
+ ///
+ ///
+ /// Typed as the plugin-facing Oxide.Plugins.Timer, which is what
+ /// timer.Every hands back — notOxide.Core.Libraries.Timer.TimerInstance,
+ /// which is the library's own type and does not convert. Both frameworks would destroy
+ /// this timer for us when the plugin unloads; holding the handle is what lets
+ /// Unload stop it before the link thread is joined rather than after.
+ ///
+ ///
+ private Timer _cadence;
+
// ---- lifecycle ----
+ ///
+ /// Every hook this plugin declares. Seeded into _hookCounts at zero so that
+ /// rg.hooks can report a hook that has never fired — which is the whole
+ /// question, and which a dictionary that only learns names as they arrive can never
+ /// answer.
+ ///
+ private static readonly string[] ExpectedHooks =
+ {
+ "OnServerInitialized", "OnServerShutdown", "OnNewSave",
+ "OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned",
+ "OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath",
+ "OnPlayerReported", "OnUserBanned", "OnUserUnbanned",
+ "CanUserLogin", "OnUserApproved"
+ };
+
private void Init()
{
+ foreach (string hook in ExpectedHooks)
+ _hookCounts[hook] = 0L;
+
_bootId = ResolveBootId();
+ _wipeId = ResolveWipeId();
_running = true;
_link = new Thread(LinkLoop)
@@ -128,7 +254,36 @@ namespace Oxide.Plugins
private void Unload()
{
+ // Attempted before `_running` goes false, because `Emit` refuses to enqueue once it
+ // does — but be honest about what this achieves: the writer stops on the same flag a
+ // few lines below and the queue is cleared after the join, so a frame enqueued here
+ // only survives if the writer happens to be mid-drain. It usually is not.
+ //
+ // That is left as it is rather than fixed with a drain-before-exit, because draining
+ // means waiting on a socket from `Unload`, which runs on the MAIN THREAD — the exact
+ // stall phase 1 removed when a 1.9-second reload showed up as
+ // `Calling 'Unload' took 1918ms`. A wedged peer would make it worse than the bug.
+ //
+ // What is actually lost is under a minute of one player's gathering, and only on a
+ // manual plugin reload: a real shutdown flushes at `OnServerShutdown`, and a player
+ // leaving flushes at their disconnect.
+ try
+ {
+ FlushTallies();
+ }
+ catch (Exception ex)
+ {
+ Puts($"could not flush tallies on unload: {ex.Message}");
+ }
+
+ if (_cadence != null)
+ {
+ _cadence.Destroy();
+ _cadence = null;
+ }
+
_running = false;
+ _stopping.Set();
Wake.Set();
Thread t = _link;
@@ -138,6 +293,7 @@ namespace Oxide.Plugins
_link = null;
_connected = false;
+ _tallies.Clear();
// A reload leaves the static queue holding lines addressed to a connection that no
// longer exists. Clearing it is the difference between a reload and a slow leak.
@@ -270,7 +426,7 @@ namespace Oxide.Plugins
client = new TcpClient();
client.NoDelay = true;
- client.Connect(host, port);
+ Connect(client, host, port);
NetworkStream stream = client.GetStream();
stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang
@@ -283,11 +439,11 @@ namespace Oxide.Plugins
Interlocked.Increment(ref _connects);
Puts($"connected to {host}:{port}");
- // Building the greeting reads the world, so it must happen on the main thread.
- // It is sent on every connect rather than once at startup: the sidecar restarts
- // independently of the game, and anything it needs up front has to be re-sent
- // per connection.
- Interface.Oxide.NextTick(SendHello);
+ // Building the boards reads the world, so it must happen on the main thread.
+ // They are sent on every connect rather than once at startup: the sidecar
+ // restarts independently of the game, and anything it needs up front has to be
+ // re-sent per connection.
+ Interface.Oxide.NextTick(SendBoards);
NetworkStream localStream = stream;
reader = new Thread(() => ReadLoop(localStream, epoch))
@@ -364,6 +520,48 @@ namespace Oxide.Plugins
_connected = false;
}
+ ///
+ /// Connects with a bound, and with a way out.
+ ///
+ ///
+ /// The wait ends on whichever comes first: the connect completing, the plugin being
+ /// unloaded, or . Without the middle one a reload against
+ /// an unreachable sidecar costs the game's main thread the whole join timeout —
+ /// observed as hook 'Unload' took longer than 100ms [2002ms] beside
+ /// link thread did not stop cleanly, which is phase 1's stall arriving by a
+ /// different road.
+ ///
+ ///
+ private void Connect(TcpClient client, string host, int port)
+ {
+ IAsyncResult pending = client.BeginConnect(host, port, null, null);
+
+ int signalled = WaitHandle.WaitAny(
+ new WaitHandle[] { pending.AsyncWaitHandle, _stopping }, ConnectTimeoutMs);
+
+ if (signalled != 0)
+ {
+ // Closing the client is what abandons the in-flight connect; the socket would
+ // otherwise stay open behind an IAsyncResult nobody is going to end.
+ try
+ {
+ client.Close();
+ }
+ catch
+ {
+ }
+
+ throw new System.IO.IOException(
+ signalled == 1
+ ? "unloading"
+ : $"connect to {host}:{port} timed out after {ConnectTimeoutMs}ms");
+ }
+
+ // Throws the real socket error — a refusal, an unknown host — which is what the caller
+ // logs. Only reached when the connect actually completed or failed on its own.
+ client.EndConnect(pending);
+ }
+
private void WriteLoop(NetworkStream stream)
{
while (_running && !_dead)
@@ -527,17 +725,14 @@ namespace Oxide.Plugins
switch (verb)
{
case "ping":
- EmitFrame(new Dictionary
- {
- { "kind", "pong" },
- { "t", NowMs() }
- });
+ // `control`: neither kept nor forwarded. It exists to move the sidecar's
+ // last-seen clock on a server nobody is playing on.
+ EmitFrame(Frame("pong", "control"));
break;
case "server.status":
- Dictionary status = ServerStatus();
- status["kind"] = "server.status";
- status["t"] = NowMs();
+ Dictionary status = Frame("server.status", "reply");
+ Merge(status, ServerBody());
// The correlation id is echoed verbatim, and only when one was supplied. A
// reply that invents one would be routed to nobody; a reply that omits one the
@@ -556,13 +751,70 @@ namespace Oxide.Plugins
// ---- frames ----
+ ///
+ /// Builds the envelope every outbound frame shares (PROTOCOL.md §8.1). Nothing may
+ /// emit a frame it did not get from here.
+ ///
+ ///
+ /// is what the sidecar files on — event is appended to
+ /// history, snapshot replaces a board, reply is routed by
+ /// reqId, control is neither kept nor forwarded. It is the reason the
+ /// sidecar needs no arm per kind and the reason the catalogue can grow for free.
+ ///
+ ///
+ ///
+ /// wipeId is omitted when the server has never saved. A server with no
+ /// save has no wipe, and the difference between an absent field and an empty string is
+ /// the difference between a fact and a row that sorts beside every other empty string
+ /// for ever.
+ ///
+ ///
+ private Dictionary Frame(string kind, string type)
+ {
+ var frame = new Dictionary
+ {
+ { "kind", kind },
+ { "type", type },
+ { "t", NowMs() },
+ { "serverId", _config != null ? _config.ServerId : "main" }
+ };
+
+ // Resolved lazily while it is still unknown, which covers the case above on a
+ // framework that never fires `OnServerInitialized` — and costs one static property
+ // read per frame only until the answer exists.
+ if (string.IsNullOrEmpty(_wipeId))
+ _wipeId = ResolveWipeId();
+
+ if (!string.IsNullOrEmpty(_wipeId))
+ frame["wipeId"] = _wipeId;
+
+ return frame;
+ }
+
+ ///
+ /// Main thread. Both boards, in the order a reader wants them: what the server is,
+ /// then who is on it.
+ ///
+ ///
+ /// Sent on every connect and every cadence tick. A board is current state with exactly
+ /// one producer, re-sent rather than reconstructed — which is what makes "a restarted
+ /// sidecar is fully populated within one connection" true by construction instead of
+ /// by hoping nothing was in flight.
+ ///
+ ///
+ private void SendBoards()
+ {
+ SendHello();
+ SendPlayersBoard();
+ }
+
/// Main thread.
private void SendHello()
{
- Dictionary hello = ServerStatus();
+ Dictionary hello = Frame("server.hello", "snapshot");
+
+ Merge(hello, ServerBody());
- hello["kind"] = "server.hello";
- hello["t"] = NowMs();
hello["protocol"] = ProtocolVersion;
hello["bootId"] = _bootId;
hello["plugin"] = Version.ToString();
@@ -570,16 +822,52 @@ namespace Oxide.Plugins
EmitFrame(hello);
}
+ ///
+ /// Main thread. Who is connected right now, as the board the far side reconciles
+ /// against — the connect and disconnect events carry the story in between.
+ ///
+ private void SendPlayersBoard()
+ {
+ var players = new List