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 — notOxide.Core.Libraries.Timer.TimerInstance,
/// which is the library's own type and does not convert. Both frameworks would destroy
/// this timer for us when the plugin unloads; holding the handle is what lets
/// Unload stop it before the link thread is joined rather than after.
///
///
private Timer _cadence;
// ---- lifecycle ----
///
/// Every hook this plugin declares. Seeded into _hookCounts at zero so that
/// rg.hooks can report a hook that has never fired — which is the whole
/// question, and which a dictionary that only learns names as they arrive can never
/// answer.
///
private static readonly string[] ExpectedHooks =
{
"OnServerInitialized", "OnServerShutdown", "OnNewSave",
"OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned",
"OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath",
"OnPlayerReported", "OnUserBanned", "OnUserUnbanned",
"CanUserLogin", "OnUserApproved"
};
private void Init()
{
foreach (string hook in ExpectedHooks)
_hookCounts[hook] = 0L;
_bootId = ResolveBootId();
_wipeId = ResolveWipeId();
_running = true;
_link = new Thread(LinkLoop)
{
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