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 { /// /// The Runic Gateway bridge. Dials out to a rust-link sidecar on loopback and speaks /// newline-delimited JSON over it: events out, commands in. /// /// /// Threading contract, which everything later in this plugin depends on: /// /// /// /// 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. /// /// /// One link thread owns the socket. It connects, drains the queue, and reconnects with /// backoff. A single writer keeps event ordering intact. /// /// /// A reader thread parses inbound lines and hands each to the main thread via /// Interface.Oxide.NextTick. 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. /// /// /// /// /// The outbound queue is bounded. On overflow the oldest record is dropped and counted, /// because telemetry is worth less than the server's memory. /// /// /// /// Loopback is the trust boundary. There is no token on this link: the plugin and /// the sidecar share a host, and the sidecar binds 127.0.0.1. Pointing Host at /// anything routable puts an unauthenticated command channel on the network. /// /// [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 { /// /// Wire-protocol version. Declared in three places that must agree: here, /// PROTOCOL_VERSION in the sidecar, and module.json in Module-Rust. The /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. /// private const int ProtocolVersion = 2; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost /// a few seconds of buffering, not half a minute. /// private const int MaxBackoffMs = 5000; /// The longest line accepted from the sidecar. Over-long lines are discarded. private const int MaxInboundLineBytes = 1024 * 1024; /// /// How long to wait for the TCP connect itself before giving up and backing off. /// /// /// It exists because TcpClient.Connect is blocking and has no timeout of its /// own, and the OS default is tens of seconds. A host that REFUSES answers instantly — /// which is every loopback test, and why this was invisible for two phases — but a host /// that drops does not answer at all: a firewall, a wrong address, a machine /// that is off. The link thread then sits in the connect, deaf to everything, and /// Unload waits out its whole join on the game's MAIN THREAD. /// /// /// /// Generous against any real network and short against a hang. Loopback, which is the /// supported deployment, connects in well under a millisecond. /// /// private const int ConnectTimeoutMs = 3000; /// /// How often the boards are re-sent (PROTOCOL.md §8.3) and the per-player tallies are /// flushed (§8.6). One cadence serves both because both answer the same question — /// "what has this server been doing lately" — and two timers would only be two things /// to get out of step. /// private const float BoardCadenceSeconds = 60f; /// /// The side of one map grid cell, in world units. Rust's own grid, as every community /// map and every server console uses it. /// private const float GridCellSize = 146.3f; private static readonly ConcurrentQueue Outbound = new ConcurrentQueue(); private static readonly AutoResetEvent Wake = new AutoResetEvent(false); /// /// Set once, by Unload, and never reset. It is what lets a thread blocked on a /// CONNECT give up immediately instead of at the end of its timeout. /// /// /// It cannot be : that handle also means "the queue has something in /// it", which Emit signals constantly, so waiting on it during a connect would /// abandon the attempt every time a hook fired. Two meanings, two handles. /// /// private readonly ManualResetEvent _stopping = new ManualResetEvent(false); private PluginConfig _config; private Thread _link; private volatile bool _running; private volatile bool _connected; /// Set when the peer goes away, so the writer stops trying. private volatile bool _dead; /// /// 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. /// private volatile bool _loggedConnectFailure; /// /// Incremented per connection attempt. A reader from a previous connection must not be /// able to mark a newer one dead — reader.Join can time out, and the stale /// thread's finally block would otherwise tear down the connection that replaced it. /// private int _epoch; private int _depth; private long _sent, _dropped, _received, _connects, _writeErrors; /// /// Identifies this run of the server process. 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. /// /// /// It must therefore be stable across a plugin reload, which a fresh /// Guid at Init is not: oxide.reload 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. /// /// private string _bootId; /// /// The current wipe, as w-yyyyMMddTHHmmssZ, derived from the save's creation /// time. Null until the server has saved at least once — a server with no save has no /// wipe, and PROTOCOL.md §8.2 says that is reported by the field being **absent** /// rather than by an empty string that will sort beside every other empty string for /// ever. /// /// /// Cached rather than read per frame, and re-read on OnNewSave, which is the /// only moment it can change. A wipe is a fact about the world; nothing about a /// reconnect, a plugin reload or a restart moves it. /// /// private string _wipeId; /// /// Per-player counters waiting to be flushed as a player.tally frame. Main /// thread only — every writer is a game hook and the flush runs on a timer, which is /// also the main thread, so this needs no lock and must never acquire one. /// private readonly Dictionary _tallies = new Dictionary(); /// /// When each connected player connected, in epoch milliseconds, so a disconnect can /// report a session length. Main thread only. /// /// /// A player already on the server when this plugin loaded has no entry, and /// sessionSec is then absent from their disconnect rather than zero: an /// unknown session is not a zero-length one, and summing the zeros would under-report /// playtime quietly and for ever. /// /// private readonly Dictionary _connectedAt = new Dictionary(); /// /// Every hook name this plugin declares, and whether it has fired at least once. /// /// /// Both frameworks bind hooks by name and arity, through reflection, with no /// compile-time check: a hook Facepunch renames on a Thursday simply stops being /// called, and a hook one framework's catalogue does not list may or may not exist. /// Neither failure logs anything anywhere. rg.hooks reads this dictionary, which /// makes the plugin its own answer to both questions on whichever framework it is /// actually running under — and a cheaper answer than either catalogue. /// /// private readonly Dictionary _hookCounts = new Dictionary(); /// /// The repeating boards/tally timer. /// /// /// Typed as the plugin-facing Oxide.Plugins.Timer, which is what /// timer.Every hands back — not Oxide.Core.Libraries.Timer.TimerInstance, /// which is the library's own type and does not convert. Both frameworks would destroy /// this timer for us when the plugin unloads; holding the handle is what lets /// Unload stop it before the link thread is joined rather than after. /// /// private Timer _cadence; // ---- lifecycle ---- /// /// Every hook this plugin declares. Seeded into _hookCounts at zero so that /// rg.hooks can report a hook that has never fired — which is the whole /// question, and which a dictionary that only learns names as they arrive can never /// answer. /// private static readonly string[] ExpectedHooks = { "OnServerInitialized", "OnServerShutdown", "OnNewSave", "OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned", "OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath", "OnPlayerReported", "OnUserBanned", "OnUserUnbanned", "CanUserLogin", "OnUserApproved" }; private void Init() { foreach (string hook in ExpectedHooks) _hookCounts[hook] = 0L; _bootId = ResolveBootId(); _wipeId = ResolveWipeId(); _running = true; _link = new Thread(LinkLoop) { 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 { /// Where the sidecar listens. Loopback is the trust boundary; see the class docs. public string Host = "127.0.0.1"; public int Port = 7799; /// /// 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. /// public int QueueCap = 5000; /// /// This server's stable identity across wipes and restarts, as the website knows /// it. It is not derived from the hostname: an operator renames a server for /// a season and the site must not lose its history for it. /// public string ServerId = "main"; } protected override void LoadDefaultConfig() { Config.WriteObject(new PluginConfig(), true); } private void LoadConfigValues() { _config = Config.ReadObject() ?? 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 ---- /// /// Main thread. Non-blocking. must already be a complete JSON /// object with no embedded newline; the newline is appended by the writer as the frame /// delimiter. /// 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 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; } /// /// Connects with a bound, and with a way out. /// /// /// The wait ends on whichever comes first: the connect completing, the plugin being /// unloaded, or . Without the middle one a reload against /// an unreachable sidecar costs the game's main thread the whole join timeout — /// observed as hook 'Unload' took longer than 100ms [2002ms] beside /// link thread did not stop cleanly, which is phase 1's stall arriving by a /// different road. /// /// private void Connect(TcpClient client, string host, int port) { IAsyncResult pending = client.BeginConnect(host, port, null, null); int signalled = WaitHandle.WaitAny( new WaitHandle[] { pending.AsyncWaitHandle, _stopping }, ConnectTimeoutMs); if (signalled != 0) { // Closing the client is what abandons the in-flight connect; the socket would // otherwise stay open behind an IAsyncResult nobody is going to end. try { client.Close(); } catch { } throw new System.IO.IOException( signalled == 1 ? "unloading" : $"connect to {host}:{port} timed out after {ConnectTimeoutMs}ms"); } // Throws the real socket error — a refusal, an unknown host — which is what the caller // logs. Only reached when the connect actually completed or failed on its own. client.EndConnect(pending); } private void WriteLoop(NetworkStream stream) { while (_running && !_dead) { 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 } } } /// /// Reader thread. Marshals to the main thread; nothing that touches the game happens /// here. /// 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 ---- /// Main thread. Every world read below is safe only because of that. private void HandleCommand(string line) { Dictionary cmd; try { cmd = JsonConvert.DeserializeObject>(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 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 ---- /// /// Builds the envelope every outbound frame shares (PROTOCOL.md §8.1). Nothing may /// emit a frame it did not get from here. /// /// /// is what the sidecar files on — event is appended to /// history, snapshot replaces a board, reply is routed by /// reqId, control is neither kept nor forwarded. It is the reason the /// sidecar needs no arm per kind and the reason the catalogue can grow for free. /// /// /// /// wipeId is omitted when the server has never saved. A server with no /// save has no wipe, and the difference between an absent field and an empty string is /// the difference between a fact and a row that sorts beside every other empty string /// for ever. /// /// private Dictionary Frame(string kind, string type) { var frame = new Dictionary { { "kind", kind }, { "type", type }, { "t", NowMs() }, { "serverId", _config != null ? _config.ServerId : "main" } }; // Resolved lazily while it is still unknown, which covers the case above on a // framework that never fires `OnServerInitialized` — and costs one static property // read per frame only until the answer exists. if (string.IsNullOrEmpty(_wipeId)) _wipeId = ResolveWipeId(); if (!string.IsNullOrEmpty(_wipeId)) frame["wipeId"] = _wipeId; return frame; } /// /// Main thread. Both boards, in the order a reader wants them: what the server is, /// then who is on it. /// /// /// Sent on every connect and every cadence tick. A board is current state with exactly /// one producer, re-sent rather than reconstructed — which is what makes "a restarted /// sidecar is fully populated within one connection" true by construction instead of /// by hoping nothing was in flight. /// /// private void SendBoards() { SendHello(); SendPlayersBoard(); } /// Main thread. private void SendHello() { Dictionary hello = Frame("server.hello", "snapshot"); Merge(hello, ServerBody()); hello["protocol"] = ProtocolVersion; hello["bootId"] = _bootId; hello["plugin"] = Version.ToString(); EmitFrame(hello); } /// /// Main thread. Who is connected right now, as the board the far side reconciles /// against — the connect and disconnect events carry the story in between. /// private void SendPlayersBoard() { var players = new List(); if (BasePlayer.activePlayerList != null) { foreach (BasePlayer player in BasePlayer.activePlayerList) { if (player == null) continue; var row = new Dictionary { { "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 frame = Frame("players.online", "snapshot"); frame["count"] = players.Count; frame["players"] = players; EmitFrame(frame); } /// /// Main thread. The shared body of server.hello and server.status — 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. /// private Dictionary ServerBody() { var status = new Dictionary { { "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; } /// /// Puts an IP address onto a frame, or leaves it off. /// /// /// The game does not always have one. A console banid against an offline id /// produces the literal string "0" 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. /// /// /// /// Absent, not zero. The same rule `saveCreatedAt` and `sessionSec` follow. /// /// private static void PutAddress(Dictionary frame, string ip) { if (string.IsNullOrEmpty(ip) || ip == "0" || ip == "0.0.0.0") return; frame["ip"] = ip; } private static void Merge(Dictionary into, Dictionary from) { foreach (KeyValuePair pair in from) into[pair.Key] = pair.Value; } /// /// Describes who or what killed somebody, onto . /// /// /// Four outcomes, and collapsing any two of them loses something a killfeed needs: /// player (a real kill, and the only one that belongs on a leaderboard), /// self, npc and environment — 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. /// /// private static void DescribeAttacker( Dictionary 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 ---- /// /// An entity's position as Rust's own map reference (H7), 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. /// 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); } /// A column index as Rust spells it: 0 is A, 25 is Z, 26 is AA. 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 ---- /// /// The current wipe, as w-yyyyMMddTHHmmssZ, or null if this server has never /// saved. /// /// /// 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. /// /// 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; } } /// /// Records that a hook fired. See _hookCounts for why this exists at all. /// 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 frame = Frame("server.initialized", "event"); frame["initial"] = initial; EmitFrame(frame); } private void OnServerShutdown() { MarkHook("OnServerShutdown"); FlushTallies(); EmitFrame(Frame("server.shutdown", "event")); } /// /// 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. /// 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 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 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 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 frame = Frame("player.respawned", "event"); frame["steamId"] = player.UserIDString; frame["name"] = player.displayName; EmitFrame(frame); } /// /// Void, and deliberately: uMod documents a non-null return here as overriding the /// death itself. See rule 1 at the top of this region. /// private void OnPlayerDeath(BasePlayer player, HitInfo info) { MarkHook("OnPlayerDeath"); if (player == null || player.IsNpc) return; Dictionary 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); } /// /// Void, and deliberately: a non-null return here overrides chat delivery — the bridge /// would be silently eating messages. /// private void OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel) { MarkHook("OnPlayerChat"); if (player == null || string.IsNullOrEmpty(message)) return; Dictionary 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); } /// /// Void, and deliberately: a non-null return here replaces what the player receives. /// This hook is a counter — see rule 2. /// 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); } /// /// 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. /// /// /// The type checks are ordered cheapest-first and return early, because this hook /// fires for every tree, barrel and animal on the map. /// /// 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 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 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 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 frame = Frame("player.unbanned", "event"); frame["steamId"] = id; frame["name"] = name; PutAddress(frame, ipAddress); EmitFrame(frame); } /// /// 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. /// /// /// It fires on every connection attempt, approved or not — so it is an attempt, not a /// denial. uMod publishes no OnUserRejected, 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 player.approved, decided by whoever reads the two /// frames later; PROTOCOL.md §8.8 records that as a correction to the trigger /// catalogue. /// /// private void CanUserLogin(string name, string id, string ipAddress) { MarkHook("CanUserLogin"); Dictionary 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 frame = Frame("player.approved", "event"); frame["steamId"] = id; frame["name"] = name; PutAddress(frame, ipAddress); EmitFrame(frame); } // ---- tallies ---- /// /// One player's counters between two flushes. Main thread only; no lock, ever. /// private class Tally { public readonly Dictionary Gathered = new Dictionary(); 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; } /// /// Emits one player.tally per player with anything to report, and clears what it /// emitted. /// /// /// A tally frame is a delta, not a running total: 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). /// /// 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(_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 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); } /// /// The one repeating job: re-send the boards and flush the tallies. /// /// /// 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. /// /// private void Cadence() { if (!_connected) return; FlushTallies(); SendBoards(); } /// /// Collapses every run of whitespace and control characters to one space, so an /// exception message stays one log entry. /// /// /// The control half is not defensive padding. Mono's SocketException.Message on /// Windows comes back from a fixed-size OS buffer and is NUL-padded — a couple /// of hundred \0 bytes sitting in the middle of the sentence. \0 is not /// whitespace, so Trim 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. /// /// 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(); } /// /// The server process's start instant, as boot-yyyyMMddTHHmmssZ. /// /// /// Process.StartTime 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. /// /// 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 ---- /// /// 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. /// [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")}"); } /// /// Which of this plugin's hooks have actually fired, and how often. /// /// /// 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. /// /// /// /// A zero is not automatically a fault: OnNewSave fires on wipe day and /// OnUserBanned only when somebody is banned. It is a fault when the thing it /// names has demonstrably happened. /// /// [ConsoleCommand("rg.hooks")] private void CmdHooks(ConsoleSystem.Arg arg) { var fired = new List(); var silent = new List(); 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}"); } } }