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..ef401a0 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,6 +67,20 @@ namespace Oxide.Plugins
/// The longest line accepted from the sidecar. Over-long lines are discarded.
private const int MaxInboundLineBytes = 1024 * 1024;
+ ///
+ /// 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);
@@ -110,11 +124,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,6 +223,25 @@ namespace Oxide.Plugins
private void Unload()
{
+ // Best-effort, and it has to happen BEFORE `_running` goes false, because `Emit`
+ // refuses to enqueue once it does. A tally lost here is one interval of one player's
+ // gathering; flushing costs nothing and the ordering is the only reason it works at
+ // all.
+ try
+ {
+ FlushTallies();
+ }
+ catch (Exception ex)
+ {
+ Puts($"could not flush tallies on unload: {ex.Message}");
+ }
+
+ if (_cadence != null)
+ {
+ _cadence.Destroy();
+ _cadence = null;
+ }
+
_running = false;
Wake.Set();
@@ -138,6 +252,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.
@@ -283,11 +398,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))
@@ -527,17 +642,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 +668,64 @@ 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" }
+ };
+
+ 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 +733,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