Files
Rust-Plugins/overlay/oxide/plugins/RunicGateway.cs
wtclaude 0d9ec655f7 feat(bridge): protocol 2 — the read path, and the first CI this repo has had
Fifteen hooks: presence, deaths, chat, gathering, raided structures, bans,
reports, login attempts and the wipe. Every frame now carries an envelope —
`type`, `serverId` and `wipeId` — built in one place so nothing can emit a frame
without one.

Three rules the code enforces structurally rather than by intention:

  • A read-path hook never vetoes. Four of these are documented as "returning a
    non-null value overrides default behavior", so every hook is declared `void`
    and cannot answer. `CanUserLogin` is in the wave for what it observes.

  • A hook that can fire more than once a second per player is a counter.
    `OnDispenserGather` fires on every swing at a tree; it accumulates into a
    per-player tally flushed once a minute as one `player.tally` frame, as a
    delta rather than a running total.

  • `wipeId` is derived here, from the save's creation time, because this is the
    only component that can read it. PROTOCOL.md §8.2 reverses protocol 1 on
    that point deliberately.

`server.hello` becomes a board rather than a greeting, and `players.online`
joins it; both are re-sent on connect and every 60 seconds, which is what makes
a restarted sidecar repopulate itself without asking.

`rg.hooks` reports which hooks have actually fired. Hooks bind by name and arity
through reflection on both frameworks, so a rename by Facepunch and a name
Carbon's catalogue omits present identically — as silence. This is the standing
answer to both, and it outranks either catalogue because it is a measurement.

The repository had no `.gitea/workflows/` at all. `scripts/checkPlugin.js` asks
the three questions a compiler here cannot: every hook is in `ExpectedHooks`, so
`rg.hooks` can see it; every hook is `void`, unless answering is a decision
written down in `ANSWERS_DELIBERATELY`; and `ProtocolVersion` agrees with
`overlay.toml`, which is what stops a bundle that will not compose. The void
rule is inverted on purpose — a list of *vetoable* hooks would have to be
maintained against a catalogue in another repository, and the first one somebody
forgot to add is the one that would pass. Its own suite breaks it seven ways,
including the failure that would make the other six meaningless: a method parser
that silently matches nothing.

Proven on a live Oxide server: compiled, loaded, the envelope correct, both ban
hooks firing, and the boards repopulating a sidecar whose database had been
deleted 0.3 seconds earlier.

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

1613 lines
62 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 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);
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()
{
// 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();
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;
client.Connect(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;
}
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" }
};
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");
// 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}");
}
}
}