Files
Rust-Plugins/overlay/oxide/plugins/RunicGateway.cs
wtclaude 551359c08e
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
fix(bridge): resolve the wipe at OnServerInitialized, and bound the connect
Two defects the Carbon rig found, neither visible on a development loopback.

**The wipe id was null for every real session.** `Init` runs before the save is
loaded, so `SaveRestore.SaveCreatedTime` is not yet meaningful there and the id
resolved at load time stayed null for the life of the process — every frame
shipped without the field R12 splits history on. It was invisible because a
hot-reloaded plugin reads an already-loaded world and gets the right answer every
time; it took a server that BOOTED with the plugin installed, which is every real
one, to show `wipeId=none` beside a save sitting on disk. Resolved again at
`OnServerInitialized`, and lazily while still unknown.

**Unload blocked the main thread for two seconds against an unreachable sidecar.**
Observed as `hook 'Unload' took longer than 100ms [2002ms]` next to `link thread
did not stop cleanly` — phase 1's stall arriving by a different road. The link
thread sits in a blocking `TcpClient.Connect`, which has no timeout of its own
and cannot be woken; a host that REFUSES answers instantly, which is every
loopback test, and a host that DROPS does not answer at all. The connect is now
bounded, and waits on a stop handle of its own so a reload abandons it at once.
It cannot share `Wake`, which also means 'the queue has something in it' and is
signalled by every hook.

After: the same reload logs no slow-hook warning and no stranded thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 08:29:24 -05:00

1713 lines
68 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
namespace Oxide.Plugins
{
/// <summary>
/// The Runic Gateway bridge. Dials out to a rust-link sidecar on loopback and speaks
/// newline-delimited JSON over it: events out, commands in.
///
/// <para>
/// Threading contract, which everything later in this plugin depends on:
/// </para>
/// <list type="bullet">
/// <item><description>
/// <see cref="Emit"/> is called from the server's main thread. It formats nothing,
/// blocks on nothing, and touches no socket. It enqueues and returns. A slow, wedged,
/// or absent sidecar cannot stall the game.
/// </description></item>
/// <item><description>
/// One link thread owns the socket. It connects, drains the queue, and reconnects with
/// backoff. A single writer keeps event ordering intact.
/// </description></item>
/// <item><description>
/// A reader thread parses inbound lines and hands each to the main thread via
/// <c>Interface.Oxide.NextTick</c>. The reader touches no Unity object, no BasePlayer
/// and no ConVar — every one of those is main-thread-only, and reading them from here
/// is the kind of bug that presents as a crash somewhere else entirely.
/// </description></item>
/// </list>
///
/// <para>
/// The outbound queue is bounded. On overflow the oldest record is dropped and counted,
/// because telemetry is worth less than the server's memory.
/// </para>
///
/// <para>
/// <b>Loopback is the trust boundary.</b> There is no token on this link: the plugin and
/// the sidecar share a host, and the sidecar binds 127.0.0.1. Pointing <c>Host</c> at
/// anything routable puts an unauthenticated command channel on the network.
/// </para>
/// </summary>
[Info("Runic Gateway", "RunicGateway", "0.1.0")]
[Description("Bridges this Rust server to a Runic Gateway website through a rust-link sidecar.")]
internal class RunicGateway : RustPlugin
{
/// <summary>
/// Wire-protocol version. Declared in three places that must agree: here,
/// <c>PROTOCOL_VERSION</c> in the sidecar, and <c>module.json</c> in Module-Rust. The
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
/// </summary>
private const int ProtocolVersion = 2;
/// <summary>
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
/// a few seconds of buffering, not half a minute.
/// </summary>
private const int MaxBackoffMs = 5000;
/// <summary>The longest line accepted from the sidecar. Over-long lines are discarded.</summary>
private const int MaxInboundLineBytes = 1024 * 1024;
/// <summary>
/// How long to wait for the TCP connect itself before giving up and backing off.
///
/// <para>
/// It exists because <c>TcpClient.Connect</c> 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 <b>drops</b> 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
/// <c>Unload</c> waits out its whole join on the game's MAIN THREAD.
/// </para>
///
/// <para>
/// Generous against any real network and short against a hang. Loopback, which is the
/// supported deployment, connects in well under a millisecond.
/// </para>
/// </summary>
private const int ConnectTimeoutMs = 3000;
/// <summary>
/// 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.
/// </summary>
private const float BoardCadenceSeconds = 60f;
/// <summary>
/// The side of one map grid cell, in world units. Rust's own grid, as every community
/// map and every server console uses it.
/// </summary>
private const float GridCellSize = 146.3f;
private static readonly ConcurrentQueue<string> Outbound = new ConcurrentQueue<string>();
private static readonly AutoResetEvent Wake = new AutoResetEvent(false);
/// <summary>
/// Set once, by <c>Unload</c>, and never reset. It is what lets a thread blocked on a
/// CONNECT give up immediately instead of at the end of its timeout.
///
/// <para>
/// It cannot be <see cref="Wake"/>: that handle also means "the queue has something in
/// it", which <c>Emit</c> signals constantly, so waiting on it during a connect would
/// abandon the attempt every time a hook fired. Two meanings, two handles.
/// </para>
/// </summary>
private readonly ManualResetEvent _stopping = new ManualResetEvent(false);
private PluginConfig _config;
private Thread _link;
private volatile bool _running;
private volatile bool _connected;
/// <summary>Set when the peer goes away, so the writer stops trying.</summary>
private volatile bool _dead;
/// <summary>
/// Whether the "cannot reach the sidecar" line has been printed for this load. It is
/// printed once, not per attempt: the retry runs every few seconds for ever.
/// </summary>
private volatile bool _loggedConnectFailure;
/// <summary>
/// Incremented per connection attempt. A reader from a previous connection must not be
/// able to mark a newer one dead — <c>reader.Join</c> can time out, and the stale
/// thread's finally block would otherwise tear down the connection that replaced it.
/// </summary>
private int _epoch;
private int _depth;
private long _sent, _dropped, _received, _connects, _writeErrors;
/// <summary>
/// Identifies this run of the <b>server process</b>. It is how the website tells a game
/// restart apart from a sidecar reconnect — the same link comes back either way, and
/// only one of the two means the world it is describing started over.
///
/// <para>
/// It must therefore be stable across a <b>plugin</b> reload, which a fresh
/// <c>Guid</c> at <c>Init</c> is not: <c>oxide.reload</c> would announce a brand new
/// boot to a website that is about to reconcile its whole ledger on the strength of it,
/// while the world it describes never moved. The process's own start time is the only
/// value here that changes exactly when the thing it names changes.
/// </para>
/// </summary>
private string _bootId;
/// <summary>
/// The current wipe, as <c>w-yyyyMMddTHHmmssZ</c>, 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.
///
/// <para>
/// Cached rather than read per frame, and re-read on <c>OnNewSave</c>, 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.
/// </para>
/// </summary>
private string _wipeId;
/// <summary>
/// Per-player counters waiting to be flushed as a <c>player.tally</c> 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.
/// </summary>
private readonly Dictionary<ulong, Tally> _tallies = new Dictionary<ulong, Tally>();
/// <summary>
/// When each connected player connected, in epoch milliseconds, so a disconnect can
/// report a session length. Main thread only.
///
/// <para>
/// A player already on the server when this plugin loaded has no entry, and
/// <c>sessionSec</c> is then <b>absent</b> 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.
/// </para>
/// </summary>
private readonly Dictionary<ulong, long> _connectedAt = new Dictionary<ulong, long>();
/// <summary>
/// Every hook name this plugin declares, and whether it has fired at least once.
///
/// <para>
/// Both frameworks bind hooks by <b>name and arity, through reflection, with no
/// compile-time check</b>: 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. <c>rg.hooks</c> 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.
/// </para>
/// </summary>
private readonly Dictionary<string, long> _hookCounts = new Dictionary<string, long>();
/// <summary>
/// The repeating boards/tally timer.
///
/// <para>
/// Typed as the plugin-facing <c>Oxide.Plugins.Timer</c>, which is what
/// <c>timer.Every</c> hands back — <b>not</b> <c>Oxide.Core.Libraries.Timer.TimerInstance</c>,
/// 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
/// <c>Unload</c> stop it before the link thread is joined rather than after.
/// </para>
/// </summary>
private Timer _cadence;
// ---- lifecycle ----
/// <summary>
/// Every hook this plugin declares. Seeded into <c>_hookCounts</c> at zero so that
/// <c>rg.hooks</c> can report a hook that has <b>never</b> fired — which is the whole
/// question, and which a dictionary that only learns names as they arrive can never
/// answer.
/// </summary>
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)
{
Name = "RunicGateway Link",
IsBackground = true
};
_link.Start();
}
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;
if (t != null && !t.Join(TimeSpan.FromSeconds(2.0)))
Puts("link thread did not stop cleanly");
_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.
string discard;
while (Outbound.TryDequeue(out discard))
{
}
Interlocked.Exchange(ref _depth, 0);
}
// ---- configuration ----
private class PluginConfig
{
/// <summary>Where the sidecar listens. Loopback is the trust boundary; see the class docs.</summary>
public string Host = "127.0.0.1";
public int Port = 7799;
/// <summary>
/// How many outbound lines may wait for a wedged sidecar before the oldest is
/// dropped. Sized in lines rather than bytes because that is what is cheap to
/// count; the inbound cap is what bounds a single line's size.
/// </summary>
public int QueueCap = 5000;
/// <summary>
/// This server's stable identity across wipes and restarts, as the website knows
/// it. It is <b>not</b> derived from the hostname: an operator renames a server for
/// a season and the site must not lose its history for it.
/// </summary>
public string ServerId = "main";
}
protected override void LoadDefaultConfig()
{
Config.WriteObject(new PluginConfig(), true);
}
private void LoadConfigValues()
{
_config = Config.ReadObject<PluginConfig>() ?? new PluginConfig();
if (string.IsNullOrEmpty(_config.Host))
_config.Host = "127.0.0.1";
if (_config.Port <= 0 || _config.Port > 65535)
_config.Port = 7799;
// A cap of zero would drop every line on the way in and report a healthy link doing
// nothing, which is the worst failure available here.
if (_config.QueueCap < 16)
_config.QueueCap = 16;
if (string.IsNullOrEmpty(_config.ServerId))
_config.ServerId = "main";
}
private void Loaded()
{
LoadConfigValues();
Puts($"protocol {ProtocolVersion}, serverId '{_config.ServerId}', sidecar {_config.Host}:{_config.Port}");
}
// ---- the outbound queue ----
/// <summary>
/// Main thread. Non-blocking. <paramref name="line"/> must already be a complete JSON
/// object with no embedded newline; the newline is appended by the writer as the frame
/// delimiter.
/// </summary>
private void Emit(string line)
{
if (line == null || !_running)
return;
int cap = _config != null ? _config.QueueCap : 5000;
// Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over the
// cap but never grows without limit.
while (Volatile.Read(ref _depth) >= cap)
{
string discard;
if (!Outbound.TryDequeue(out discard))
break;
Interlocked.Decrement(ref _depth);
Interlocked.Increment(ref _dropped);
}
Outbound.Enqueue(line);
Interlocked.Increment(ref _depth);
Wake.Set();
}
private void EmitFrame(Dictionary<string, object> frame)
{
try
{
Emit(JsonConvert.SerializeObject(frame, Formatting.None));
}
catch (Exception ex)
{
// A frame that cannot be serialized must not take the caller down with it — the
// caller is usually a game hook.
Puts($"could not serialize frame: {ex.Message}");
}
}
// ---- the link ----
private void LinkLoop()
{
int backoffMs = 500;
while (_running)
{
TcpClient client = null;
Thread reader = null;
int epoch = Interlocked.Increment(ref _epoch);
try
{
string host = _config != null ? _config.Host : "127.0.0.1";
int port = _config != null ? _config.Port : 7799;
client = new TcpClient();
client.NoDelay = true;
Connect(client, host, port);
NetworkStream stream = client.GetStream();
stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang
_dead = false;
_connected = true;
_loggedConnectFailure = false; // so a LATER outage is reported again
backoffMs = 500;
Interlocked.Increment(ref _connects);
Puts($"connected to {host}:{port}");
// 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))
{
Name = "RunicGateway Reader",
IsBackground = true
};
reader.Start();
WriteLoop(stream);
}
catch (Exception ex)
{
// Socket exception messages carry EMBEDDED newlines on Windows — the refusal
// text and the endpoint arrive on separate lines — so trimming the ends is not
// enough to keep the Oxide log one entry per line.
string reason = Flatten(ex.Message);
if (_connected)
{
Puts($"link error: {reason}");
}
else if (!_loggedConnectFailure)
{
// The FIRST failure to connect, and only the first: a sidecar that is not
// running yet is normal at boot and would otherwise spam the console every
// few seconds, but a wrong Host or Port is otherwise silent for ever.
_loggedConnectFailure = true;
Puts($"cannot reach the sidecar: {reason} — retrying quietly until it answers");
}
}
finally
{
// Captured before it is cleared: a connection that ended because the READER saw
// EOF leaves the writer to exit cleanly, so nothing throws and the catch above
// never runs. Without this the link going down is invisible in the game console,
// which is the one place an operator looks first.
bool wasConnected = _connected;
_connected = false;
_dead = true;
try
{
if (client != null)
client.Close();
}
catch
{
// Closing a socket that is already gone is not news.
}
if (reader != null)
reader.Join(TimeSpan.FromSeconds(1.0));
if (wasConnected)
Puts("sidecar link lost; reconnecting");
}
if (!_running)
break;
// Nothing is listening yet, or the sidecar restarted. Both are normal.
//
// `Wake`, not `Thread.Sleep`: Unload runs on the MAIN thread and joins this one, so
// an uninterruptible sleep here is a stall of up to MaxBackoffMs on every reload —
// Oxide reports it as `Calling 'Unload' took 1918ms`, and the server is frozen for
// that whole time. Unload sets `_running` false and signals `Wake`, so waiting on
// the handle turns that stall into an immediate return.
Wake.WaitOne(backoffMs);
backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs);
}
_connected = false;
}
/// <summary>
/// Connects with a bound, and with a way out.
///
/// <para>
/// The wait ends on whichever comes first: the connect completing, the plugin being
/// unloaded, or <see cref="ConnectTimeoutMs"/>. Without the middle one a reload against
/// an unreachable sidecar costs the game's main thread the whole join timeout —
/// observed as <c>hook 'Unload' took longer than 100ms [2002ms]</c> beside
/// <c>link thread did not stop cleanly</c>, which is phase 1's stall arriving by a
/// different road.
/// </para>
/// </summary>
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)
{
string line;
if (!Outbound.TryDequeue(out line))
{
Wake.WaitOne(250);
continue;
}
Interlocked.Decrement(ref _depth);
try
{
byte[] bytes = Encoding.UTF8.GetBytes(line + "\n");
stream.Write(bytes, 0, bytes.Length);
Interlocked.Increment(ref _sent);
}
catch (Exception)
{
// The record is already off the queue. Count it and let the outer loop
// reconnect; re-queueing risks an unbounded retry storm against a dead peer.
Interlocked.Increment(ref _writeErrors);
_dead = true;
throw;
}
}
}
private void ReadLoop(NetworkStream stream, int epoch)
{
byte[] buffer = new byte[8192];
StringBuilder line = new StringBuilder(512);
bool discarding = false;
try
{
while (_running && !_dead)
{
int read = stream.Read(buffer, 0, buffer.Length);
if (read <= 0)
break; // clean EOF: the sidecar closed. Normal.
for (int i = 0; i < read; i++)
{
char c = (char)buffer[i];
if (c == '\n')
{
if (discarding)
{
// The tail of a line already given up on. Swallowing it here is
// what stops the remainder being parsed as a line of its own.
discarding = false;
}
else
{
Dispatch(line.ToString());
}
line.Length = 0;
}
else if (c != '\r')
{
if (discarding)
continue;
line.Append(c);
if (line.Length > MaxInboundLineBytes)
{
Puts("inbound line over the cap; discarding to the next newline");
line.Length = 0;
discarding = true;
}
}
}
}
}
catch (System.IO.IOException)
{
// Expected when the peer vanishes mid-read.
}
catch (ObjectDisposedException)
{
// Expected when Unload closes the socket under us.
}
catch (Exception ex)
{
Puts($"reader error: {ex.Message}");
}
finally
{
// Only tear down the connection this reader actually owned.
if (Volatile.Read(ref _epoch) == epoch)
{
_dead = true;
Wake.Set(); // let the writer notice and fall through to reconnect
}
}
}
/// <summary>
/// Reader thread. Marshals to the main thread; nothing that touches the game happens
/// here.
/// </summary>
private void Dispatch(string line)
{
if (string.IsNullOrEmpty(line))
return;
Interlocked.Increment(ref _received);
Interface.Oxide.NextTick(() =>
{
try
{
HandleCommand(line);
}
catch (Exception ex)
{
// A malformed command must never escape into a game code path.
Puts($"inbound handler threw: {ex}");
}
});
}
// ---- commands ----
/// <summary>Main thread. Every world read below is safe only because of that.</summary>
private void HandleCommand(string line)
{
Dictionary<string, object> cmd;
try
{
cmd = JsonConvert.DeserializeObject<Dictionary<string, object>>(line);
}
catch (Exception ex)
{
Puts($"unparseable command: {ex.Message}");
return;
}
if (cmd == null)
return;
object verbObj;
if (!cmd.TryGetValue("cmd", out verbObj) || verbObj == null)
return;
string verb = verbObj.ToString();
object reqIdObj;
string reqId = cmd.TryGetValue("reqId", out reqIdObj) && reqIdObj != null
? reqIdObj.ToString()
: null;
switch (verb)
{
case "ping":
// `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<string, object> 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
// caller sent would leave that caller waiting out its whole timeout.
if (reqId != null)
status["reqId"] = reqId;
EmitFrame(status);
break;
default:
Puts($"unknown command '{verb}'");
break;
}
}
// ---- frames ----
/// <summary>
/// Builds the envelope every outbound frame shares (PROTOCOL.md §8.1). Nothing may
/// emit a frame it did not get from here.
///
/// <para>
/// <paramref name="type"/> is what the sidecar files on — <c>event</c> is appended to
/// history, <c>snapshot</c> replaces a board, <c>reply</c> is routed by
/// <c>reqId</c>, <c>control</c> 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.
/// </para>
///
/// <para>
/// <c>wipeId</c> is <b>omitted</b> 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.
/// </para>
/// </summary>
private Dictionary<string, object> Frame(string kind, string type)
{
var frame = new Dictionary<string, object>
{
{ "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;
}
/// <summary>
/// Main thread. Both boards, in the order a reader wants them: what the server is,
/// then who is on it.
///
/// <para>
/// 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.
/// </para>
/// </summary>
private void SendBoards()
{
SendHello();
SendPlayersBoard();
}
/// <summary>Main thread.</summary>
private void SendHello()
{
Dictionary<string, object> hello = Frame("server.hello", "snapshot");
Merge(hello, ServerBody());
hello["protocol"] = ProtocolVersion;
hello["bootId"] = _bootId;
hello["plugin"] = Version.ToString();
EmitFrame(hello);
}
/// <summary>
/// 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.
/// </summary>
private void SendPlayersBoard()
{
var players = new List<object>();
if (BasePlayer.activePlayerList != null)
{
foreach (BasePlayer player in BasePlayer.activePlayerList)
{
if (player == null)
continue;
var row = new Dictionary<string, object>
{
{ "steamId", player.UserIDString },
{ "name", player.displayName },
{ "sleeping", player.IsSleeping() }
};
long since;
if (_connectedAt.TryGetValue(player.userID, out since))
row["connectedAt"] = since;
players.Add(row);
}
}
Dictionary<string, object> frame = Frame("players.online", "snapshot");
frame["count"] = players.Count;
frame["players"] = players;
EmitFrame(frame);
}
/// <summary>
/// Main thread. The shared body of <c>server.hello</c> and <c>server.status</c> — the
/// two frames differ in what wraps them, not in what they say about the server, and
/// letting them drift is how a site ends up showing two different player counts.
/// </summary>
private Dictionary<string, object> ServerBody()
{
var status = new Dictionary<string, object>
{
{ "hostname", ConVar.Server.hostname },
{ "description", ConVar.Server.description },
{ "level", ConVar.Server.level },
{ "seed", ConVar.Server.seed },
{ "worldSize", ConVar.Server.worldsize },
{ "maxPlayers", ConVar.Server.maxplayers },
{ "players", BasePlayer.activePlayerList != null ? BasePlayer.activePlayerList.Count : 0 },
{ "joining", ServerMgr.Instance != null ? ServerMgr.Instance.connectionQueue.Joining : 0 },
{ "queued", ServerMgr.Instance != null ? ServerMgr.Instance.connectionQueue.Queued : 0 },
{ "uptimeSec", (int)UnityEngine.Time.realtimeSinceStartup }
};
// The instant `wipeId` is derived from, kept in its own right: the id is for grouping
// rows and this is for showing a date, and deriving one back from the other is the
// kind of parsing nobody should have to do twice.
try
{
status["saveCreatedAt"] = SaveRestore.SaveCreatedTime.ToUniversalTime()
.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture);
}
catch (Exception)
{
// A server that has never saved has no created time. Absent, not zero.
}
return status;
}
/// <summary>
/// Puts an IP address onto a frame, or leaves it off.
///
/// <para>
/// The game does not always have one. A console <c>banid</c> against an offline id
/// produces the literal string <b><c>"0"</c></b> rather than an address or a null —
/// observed on a live server, not guessed — and a database column full of "0" is worse
/// than one full of nulls, because "0" survives every is-it-missing test a reader
/// writes and then fails whatever parses it.
/// </para>
///
/// <para>
/// Absent, not zero. The same rule `saveCreatedAt` and `sessionSec` follow.
/// </para>
/// </summary>
private static void PutAddress(Dictionary<string, object> frame, string ip)
{
if (string.IsNullOrEmpty(ip) || ip == "0" || ip == "0.0.0.0")
return;
frame["ip"] = ip;
}
private static void Merge(Dictionary<string, object> into, Dictionary<string, object> from)
{
foreach (KeyValuePair<string, object> pair in from)
into[pair.Key] = pair.Value;
}
/// <summary>
/// Describes who or what killed somebody, onto <paramref name="frame"/>.
///
/// <para>
/// Four outcomes, and collapsing any two of them loses something a killfeed needs:
/// <c>player</c> (a real kill, and the only one that belongs on a leaderboard),
/// <c>self</c>, <c>npc</c> and <c>environment</c> — falling, drowning, a helicopter's
/// fire, or the world simply deciding. `HitInfo` may legitimately be null, which is
/// the environment case and not an error.
/// </para>
/// </summary>
private static void DescribeAttacker(
Dictionary<string, object> frame, BasePlayer victim, HitInfo info)
{
BaseEntity initiator = info != null ? info.Initiator : null;
BasePlayer attacker = info != null ? info.InitiatorPlayer : null;
string weapon = null;
if (info != null)
{
if (info.WeaponPrefab != null)
weapon = info.WeaponPrefab.ShortPrefabName;
else if (info.Weapon != null)
weapon = info.Weapon.ShortPrefabName;
}
if (weapon != null)
frame["weapon"] = weapon;
if (initiator == null)
{
frame["attackerType"] = "environment";
return;
}
if (attacker != null && attacker.userID == victim.userID)
{
frame["attackerType"] = "self";
return;
}
if (attacker != null && !attacker.IsNpc)
{
frame["attackerType"] = "player";
frame["attackerId"] = attacker.UserIDString;
frame["attackerName"] = attacker.displayName;
}
else
{
frame["attackerType"] = "npc";
frame["attackerName"] = initiator.ShortPrefabName;
}
float distance = info != null ? info.ProjectileDistance : 0f;
if (distance <= 0f && initiator.transform != null && victim.transform != null)
distance = UnityEngine.Vector3.Distance(
initiator.transform.position, victim.transform.position);
if (distance > 0f)
frame["distance"] = (float)Math.Round(distance, 1);
}
// ---- the map grid ----
/// <summary>
/// An entity's position as Rust's own map reference (<c>H7</c>), or null if it has no
/// position to read. A reference, never a coordinate: a grid cell is 146.3 units
/// across, which is precise enough to say where a fight happened and far too coarse to
/// walk to somebody's door with.
/// </summary>
private static string GridOf(BaseEntity entity)
{
if (entity == null || entity.transform == null)
return null;
return Grid(entity.transform.position);
}
private static string Grid(UnityEngine.Vector3 position)
{
float size = ConVar.Server.worldsize;
if (size <= 0f)
return null;
float half = size / 2f;
int columns = (int)Math.Ceiling(size / GridCellSize);
int x = (int)((position.x + half) / GridCellSize);
// Rows are numbered from the NORTH edge downwards, which is why this is a subtraction
// and not the same expression as the column. Getting it wrong mirrors every grid on
// the map and is invisible without a second opinion.
int z = (int)((half - position.z) / GridCellSize);
x = Clamp(x, 0, columns - 1);
z = Clamp(z, 0, columns - 1);
return Column(x) + z.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
private static int Clamp(int value, int low, int high)
{
if (high < low)
return low;
return value < low ? low : (value > high ? high : value);
}
/// <summary>A column index as Rust spells it: 0 is A, 25 is Z, 26 is AA.</summary>
private static string Column(int index)
{
string name = string.Empty;
int n = index + 1;
while (n > 0)
{
int remainder = (n - 1) % 26;
name = (char)('A' + remainder) + name;
n = (n - 1) / 26;
}
return name;
}
// ---- identity of the world ----
/// <summary>
/// The current wipe, as <c>w-yyyyMMddTHHmmssZ</c>, or null if this server has never
/// saved.
///
/// <para>
/// Protocol 1 left this to the website. Protocol 2 derives it here because by now
/// three components store rows that need it, and the plugin is the only one of the
/// three that can read the value — the other two would be re-deriving something they
/// were already told, and two derivations of one fact eventually disagree about a
/// boundary.
/// </para>
/// </summary>
private static string ResolveWipeId()
{
try
{
DateTime created = SaveRestore.SaveCreatedTime.ToUniversalTime();
// A server that has never saved reports the bottom of the calendar. That is not a
// wipe in 0001, it is the absence of one.
if (created.Year < 2000)
return null;
return "w-" + created.ToString(
"yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Records that a hook fired. See <c>_hookCounts</c> for why this exists at all.
/// </summary>
private void MarkHook(string name)
{
long count;
_hookCounts.TryGetValue(name, out count);
_hookCounts[name] = count + 1;
}
// ---- the read path ----
//
// Every hook below obeys three rules, and each of them has a failure behind it.
//
// 1. **A read-path hook never vetoes.** Four of these are documented by uMod as
// "returning a non-null value overrides default behavior" — a bridge that returned
// something by accident would cancel a death, swallow a player's wood or refuse a
// login, on somebody's production server at 3am. So they are declared `void`: both
// frameworks take the method's return value, and a void method has none. The rule is
// enforced by the signature rather than by remembering to write `return null`, which
// is the only version of it that survives a year of edits.
//
// 2. **A hook that can fire more than once a second per player is a counter, not an
// event.** `OnDispenserGather` fires on every swing at a tree. It accumulates into
// `_tallies` and leaves as one `player.tally` frame a minute (PROTOCOL.md §8.6).
//
// 3. **Every hook marks itself.** `MarkHook` is what makes `rg.hooks` able to say which
// of these actually fire on the framework this server is running — the standing
// answer both to Facepunch renaming a hook and to a framework catalogue that does not
// list one.
//
// All of it is the main thread, which is what makes reading `BasePlayer`, `ConVar` and a
// transform legal here and illegal in the reader thread.
private void OnServerInitialized(bool initial)
{
MarkHook("OnServerInitialized");
// Re-read the wipe, and this is not belt-and-braces: at a real server
// start `Init` runs BEFORE the save is loaded, so `SaveCreatedTime` is not yet
// meaningful and the id resolved there is null for the entire session. This hook is
// the first moment the world is actually there.
//
// It was invisible on a development rig for the reason such things usually are: a
// hot-reloaded plugin reads an already-loaded world and gets the right answer every
// time. It took a server that BOOTED with the plugin installed — which is every real
// one — to show `wipeId=none` on a server with a save sitting on disk.
_wipeId = ResolveWipeId();
// Started here rather than in Init: this hook fires both at the end of server startup
// and immediately on a hotload, so it is the one place that runs exactly once per
// load with the world available either way.
if (_cadence == null)
_cadence = timer.Every(BoardCadenceSeconds, Cadence);
Dictionary<string, object> frame = Frame("server.initialized", "event");
frame["initial"] = initial;
EmitFrame(frame);
}
private void OnServerShutdown()
{
MarkHook("OnServerShutdown");
FlushTallies();
EmitFrame(Frame("server.shutdown", "event"));
}
/// <summary>
/// A new save is the only moment a wipe id can change, so it is the only place that
/// re-reads it. The frame carries both ids because "which wipe did this replace" is
/// the question a site asks when it draws the boundary, and it is unanswerable
/// afterwards.
/// </summary>
private void OnNewSave(string filename)
{
MarkHook("OnNewSave");
string previous = _wipeId;
_wipeId = ResolveWipeId();
// Built after the re-read, so `wipeId` on the envelope is the NEW wipe: this frame
// belongs to the world it opens, not to the one it closes.
Dictionary<string, object> frame = Frame("server.wipe", "event");
if (!string.IsNullOrEmpty(previous))
frame["previousWipeId"] = previous;
EmitFrame(frame);
// Nothing from the old world is still true. The tallies are the only such state this
// plugin holds; everything else lives on the far side of the link.
_tallies.Clear();
_connectedAt.Clear();
Puts($"new save; wipe is now {(_wipeId ?? "unknown")}");
}
private void OnPlayerConnected(BasePlayer player)
{
MarkHook("OnPlayerConnected");
if (player == null)
return;
_connectedAt[player.userID] = NowMs();
Dictionary<string, object> frame = Frame("player.connected", "event");
frame["steamId"] = player.UserIDString;
frame["name"] = player.displayName;
EmitFrame(frame);
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
MarkHook("OnPlayerDisconnected");
if (player == null)
return;
// Before the frame: a session's last minute of gathering is worth more than the order
// of two lines, and after this point there is nothing to attribute it to.
FlushTally(player.userID);
Dictionary<string, object> frame = Frame("player.disconnected", "event");
frame["steamId"] = player.UserIDString;
frame["name"] = player.displayName;
if (!string.IsNullOrEmpty(reason))
frame["reason"] = Flatten(reason);
long since;
if (_connectedAt.TryGetValue(player.userID, out since))
{
frame["sessionSec"] = (int)((NowMs() - since) / 1000L);
_connectedAt.Remove(player.userID);
}
// A player who was already on when this plugin loaded has no recorded connect, so
// `sessionSec` is ABSENT rather than zero: a session of unknown length is not a
// session of no length, and summing zeros silently under-reports playtime for ever.
EmitFrame(frame);
}
private void OnPlayerRespawned(BasePlayer player)
{
MarkHook("OnPlayerRespawned");
if (player == null)
return;
Dictionary<string, object> frame = Frame("player.respawned", "event");
frame["steamId"] = player.UserIDString;
frame["name"] = player.displayName;
EmitFrame(frame);
}
/// <summary>
/// Void, and deliberately: uMod documents a non-null return here as overriding the
/// death itself. See rule 1 at the top of this region.
/// </summary>
private void OnPlayerDeath(BasePlayer player, HitInfo info)
{
MarkHook("OnPlayerDeath");
if (player == null || player.IsNpc)
return;
Dictionary<string, object> frame = Frame("player.death", "event");
frame["steamId"] = player.UserIDString;
frame["name"] = player.displayName;
frame["sleeping"] = player.IsSleeping();
string grid = GridOf(player);
if (grid != null)
frame["grid"] = grid;
DescribeAttacker(frame, player, info);
EmitFrame(frame);
}
/// <summary>
/// Void, and deliberately: a non-null return here overrides chat delivery — the bridge
/// would be silently eating messages.
/// </summary>
private void OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel)
{
MarkHook("OnPlayerChat");
if (player == null || string.IsNullOrEmpty(message))
return;
Dictionary<string, object> frame = Frame("player.chat", "event");
frame["steamId"] = player.UserIDString;
frame["name"] = player.displayName;
frame["channel"] = channel.ToString();
// Flattened for the same reason every other string on this wire is: one frame is one
// line, and a chat message is the one field a player controls the bytes of.
frame["message"] = Flatten(message);
EmitFrame(frame);
}
/// <summary>
/// Void, and deliberately: a non-null return here replaces what the player receives.
/// This hook is a counter — see rule 2.
/// </summary>
private void OnDispenserGather(ResourceDispenser dispenser, BasePlayer player, Item item)
{
MarkHook("OnDispenserGather");
if (player == null || player.IsNpc || item == null || item.info == null)
return;
TallyFor(player).Gather(item.info.shortname, item.amount);
}
/// <summary>
/// Everything that dies which is not a player death: NPC kills, which are a counter,
/// and raided structures, which are an event with a `staff` class because a
/// structure's grid is where somebody lives.
///
/// <para>
/// The type checks are ordered cheapest-first and return early, because this hook
/// fires for every tree, barrel and animal on the map.
/// </para>
/// </summary>
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
{
MarkHook("OnEntityDeath");
if (entity == null)
return;
BasePlayer attacker = info != null ? info.InitiatorPlayer : null;
bool byPlayer = attacker != null && !attacker.IsNpc;
// Nothing below is interesting unless a real player did it. That single test discards
// decay, despawns and the world killing itself, which is the bulk of this hook.
if (!byPlayer)
return;
BasePlayer victimPlayer = entity as BasePlayer;
if (victimPlayer != null)
{
// A real player's death is OnPlayerDeath's, not ours — emitting here too would
// double every kill in the feed. Only the NPC kind is ours to count.
if (victimPlayer.IsNpc)
TallyFor(attacker).NpcKills++;
return;
}
if (entity is BaseNpc)
{
TallyFor(attacker).NpcKills++;
return;
}
BuildingBlock block = entity as BuildingBlock;
if (block == null || block.OwnerID == 0UL)
return;
TallyFor(attacker).Structures++;
Dictionary<string, object> frame = Frame("entity.destroyed", "event");
frame["ownerId"] = block.OwnerID.ToString();
frame["prefab"] = block.ShortPrefabName;
frame["attackerId"] = attacker.UserIDString;
frame["attackerName"] = attacker.displayName;
string grid = GridOf(block);
if (grid != null)
frame["grid"] = grid;
EmitFrame(frame);
}
private void OnPlayerReported(
BasePlayer reporter, string targetName, string targetId,
string subject, string message, string type)
{
MarkHook("OnPlayerReported");
Dictionary<string, object> frame = Frame("player.reported", "event");
if (reporter != null)
{
frame["reporterId"] = reporter.UserIDString;
frame["reporterName"] = reporter.displayName;
}
frame["targetId"] = targetId;
frame["targetName"] = targetName;
frame["subject"] = Flatten(subject);
frame["message"] = Flatten(message);
frame["reportType"] = type;
EmitFrame(frame);
}
private void OnUserBanned(string name, string id, string ipAddress, string reason)
{
MarkHook("OnUserBanned");
Dictionary<string, object> frame = Frame("player.banned", "event");
frame["steamId"] = id;
frame["name"] = name;
frame["reason"] = Flatten(reason);
PutAddress(frame, ipAddress);
EmitFrame(frame);
}
private void OnUserUnbanned(string name, string id, string ipAddress)
{
MarkHook("OnUserUnbanned");
Dictionary<string, object> frame = Frame("player.unbanned", "event");
frame["steamId"] = id;
frame["name"] = name;
PutAddress(frame, ipAddress);
EmitFrame(frame);
}
/// <summary>
/// Void, and this is the most important void in the file: a non-null return here
/// **kicks the player**, with the returned string as the reason. The bridge is here to
/// watch logins, never to decide one.
///
/// <para>
/// It fires on every connection attempt, approved or not — so it is an attempt, not a
/// denial. uMod publishes no <c>OnUserRejected</c>, and the only way to observe a
/// refusal from this hook is to be the thing refusing. A denial is therefore the
/// ABSENCE of a matching <c>player.approved</c>, decided by whoever reads the two
/// frames later; PROTOCOL.md §8.8 records that as a correction to the trigger
/// catalogue.
/// </para>
/// </summary>
private void CanUserLogin(string name, string id, string ipAddress)
{
MarkHook("CanUserLogin");
Dictionary<string, object> frame = Frame("player.login.attempt", "event");
frame["steamId"] = id;
frame["name"] = name;
PutAddress(frame, ipAddress);
EmitFrame(frame);
}
private void OnUserApproved(string name, string id, string ipAddress)
{
MarkHook("OnUserApproved");
Dictionary<string, object> frame = Frame("player.approved", "event");
frame["steamId"] = id;
frame["name"] = name;
PutAddress(frame, ipAddress);
EmitFrame(frame);
}
// ---- tallies ----
/// <summary>
/// One player's counters between two flushes. Main thread only; no lock, ever.
/// </summary>
private class Tally
{
public readonly Dictionary<string, int> Gathered = new Dictionary<string, int>();
public int NpcKills;
public int Structures;
public string Name;
public void Gather(string shortname, int amount)
{
if (string.IsNullOrEmpty(shortname) || amount <= 0)
return;
int have;
Gathered.TryGetValue(shortname, out have);
Gathered[shortname] = have + amount;
}
public bool IsEmpty()
{
return Gathered.Count == 0 && NpcKills == 0 && Structures == 0;
}
}
private Tally TallyFor(BasePlayer player)
{
Tally tally;
if (!_tallies.TryGetValue(player.userID, out tally))
{
tally = new Tally();
_tallies[player.userID] = tally;
}
tally.Name = player.displayName;
return tally;
}
/// <summary>
/// Emits one <c>player.tally</c> per player with anything to report, and clears what it
/// emitted.
///
/// <para>
/// A tally frame is a <b>delta, not a running total</b>: it says what happened since
/// the last flush, so the far side sums rather than diffs. A missed frame then costs
/// one interval instead of corrupting the whole series, which is the failure mode a
/// running total has over a link that is allowed to drop frames (and this one is —
/// the outbound queue is drop-oldest by design).
/// </para>
/// </summary>
private void FlushTallies()
{
if (_tallies.Count == 0)
return;
// Materialised, because emitting mutates nothing here but clearing does, and iterating
// a dictionary while clearing it is a different bug on every runtime.
var ids = new List<ulong>(_tallies.Keys);
foreach (ulong id in ids)
FlushTally(id);
}
private void FlushTally(ulong userId)
{
Tally tally;
if (!_tallies.TryGetValue(userId, out tally))
return;
_tallies.Remove(userId);
if (tally.IsEmpty())
return;
Dictionary<string, object> frame = Frame("player.tally", "event");
frame["steamId"] = userId.ToString();
if (!string.IsNullOrEmpty(tally.Name))
frame["name"] = tally.Name;
if (tally.Gathered.Count > 0)
frame["gathered"] = tally.Gathered;
if (tally.NpcKills > 0)
frame["npcKills"] = tally.NpcKills;
if (tally.Structures > 0)
frame["structures"] = tally.Structures;
EmitFrame(frame);
}
/// <summary>
/// The one repeating job: re-send the boards and flush the tallies.
///
/// <para>
/// It does nothing at all while the link is down. That is not an optimisation — the
/// outbound queue is drop-oldest, so a disconnected server would spend its whole
/// outage pushing minute-old boards over the top of the events that actually matter.
/// A reconnect re-sends the boards anyway, which is the whole point of a board.
/// </para>
/// </summary>
private void Cadence()
{
if (!_connected)
return;
FlushTallies();
SendBoards();
}
/// <summary>
/// Collapses every run of whitespace <b>and control characters</b> to one space, so an
/// exception message stays one log entry.
///
/// <para>
/// The control half is not defensive padding. Mono's <c>SocketException.Message</c> on
/// Windows comes back from a fixed-size OS buffer and is <b>NUL-padded</b> — a couple
/// of hundred <c>\0</c> bytes sitting in the middle of the sentence. <c>\0</c> is not
/// whitespace, so <c>Trim</c> does not touch it and neither does a whitespace-only
/// collapse; the line looks like it contains a huge run of spaces and no amount of
/// trimming removes it.
/// </para>
/// </summary>
private static string Flatten(string text)
{
if (string.IsNullOrEmpty(text))
return string.Empty;
var sb = new StringBuilder(text.Length);
bool lastWasSpace = false;
foreach (char c in text)
{
if (char.IsWhiteSpace(c) || char.IsControl(c))
{
if (!lastWasSpace && sb.Length > 0)
sb.Append(' ');
lastWasSpace = true;
}
else
{
sb.Append(c);
lastWasSpace = false;
}
}
return sb.ToString().TrimEnd();
}
/// <summary>
/// The server process's start instant, as <c>boot-yyyyMMddTHHmmssZ</c>.
///
/// <para>
/// <c>Process.StartTime</c> is exact and identical on every read, which is what makes
/// the id survive a plugin reload. The fallback derives the same instant from Unity's
/// monotonic clock, and is only reached if the platform refuses the process query; it
/// is rounded to the second deliberately, because the subtraction jitters in the last
/// decimal and an id that changes on rounding is worse than one a second off.
/// </para>
/// </summary>
private static string ResolveBootId()
{
DateTime startUtc;
try
{
startUtc = System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime();
}
catch (Exception)
{
double up = UnityEngine.Time.realtimeSinceStartup;
startUtc = DateTime.UtcNow.AddSeconds(-Math.Round(up));
}
startUtc = new DateTime(
startUtc.Year, startUtc.Month, startUtc.Day,
startUtc.Hour, startUtc.Minute, startUtc.Second,
DateTimeKind.Utc);
return "boot-" + startUtc.ToString(
"yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
}
private static long NowMs()
{
return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc))
.TotalMilliseconds;
}
// ---- diagnostics ----
/// <summary>
/// The link's own counters, from the server console. This is how an operator answers
/// "is the bridge working" without reading a log, and it is the first thing to ask for
/// when the website says a server is offline.
/// </summary>
[ConsoleCommand("rg.link")]
private void CmdLink(ConsoleSystem.Arg arg)
{
arg.ReplyWith(
$"protocol={ProtocolVersion} serverId={(_config != null ? _config.ServerId : "?")} " +
$"connected={_connected} depth={Volatile.Read(ref _depth)} " +
$"sent={Interlocked.Read(ref _sent)} dropped={Interlocked.Read(ref _dropped)} " +
$"received={Interlocked.Read(ref _received)} connects={Interlocked.Read(ref _connects)} " +
$"writeErrors={Interlocked.Read(ref _writeErrors)} bootId={_bootId} " +
$"wipeId={(_wipeId ?? "none")}");
}
/// <summary>
/// Which of this plugin's hooks have actually fired, and how often.
///
/// <para>
/// Both frameworks bind hooks by name and arity through reflection, with no
/// compile-time check and no warning when a name matches nothing. So a hook Facepunch
/// renames on a Thursday, and a hook one framework's catalogue does not list, both
/// present identically: silence. This command is the standing answer to both, on
/// whichever framework the server is actually running — and it outranks either
/// catalogue, because it is a measurement rather than a document.
/// </para>
///
/// <para>
/// A zero is not automatically a fault: <c>OnNewSave</c> fires on wipe day and
/// <c>OnUserBanned</c> only when somebody is banned. It is a fault when the thing it
/// names has demonstrably happened.
/// </para>
/// </summary>
[ConsoleCommand("rg.hooks")]
private void CmdHooks(ConsoleSystem.Arg arg)
{
var fired = new List<string>();
var silent = new List<string>();
foreach (string hook in ExpectedHooks)
{
long count;
_hookCounts.TryGetValue(hook, out count);
if (count > 0L)
fired.Add($"{hook}={count}");
else
silent.Add(hook);
}
string firedText = fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)";
string silentText = silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)";
arg.ReplyWith(
$"protocol={ProtocolVersion} hooks={ExpectedHooks.Length} " +
$"fired={fired.Count} silent={silent.Count}" + Environment.NewLine +
$"fired: {firedText}" + Environment.NewLine +
$"silent: {silentText}");
}
}
}