using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
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.
///
///
/// 14 (PROTOCOL.md §20, the module's PLAN_REDESIGNS.md §2): kits.uses , and the
/// one-time reward messages perm.sync carries as notices (D333); and
/// Economics (§20.3, PLAN_REDESIGNS.md §11.2): economics.pay ,
/// economics.balances , economics.adjust , the tally's balance and the
/// economics.wiped event; and Backpacks (§20.4, PLAN_REDESIGNS.md §11.3):
/// backpacks.read and hello's integrations.backpacks.sizes ; and Rust's own
/// world events (§20.5, PLAN_REDESIGNS.md §14.3.1): the world.event kind and its
/// 24 types, world.call , hello's console.plugins , and the tally's
/// cratesHacked and dropsLooted , where apcKills and
/// heliKills are no longer sent; and TruePVE (§20.6, PLAN_REDESIGNS.md §14.3.2):
/// world.zone 's truePve and the integrations.truePve it needs; and
/// Dynamic PVP (§20.7, PLAN_REDESIGNS.md §14.3.3): pvp.zone.created and
/// pvp.zone.deleted , map.live 's pvp layer and
/// integrations.dynamicPvp ; and Dangerous Treasures (§20.8, PLAN_REDESIGNS.md
/// §14.3.4): dt.started , dt.won and dt.ended , dt.start ,
/// map.live 's treasure rows, the tally's treasuresWon and
/// integrations.dangerousTreasures ; and Guarded Crate (§20.9, PLAN_REDESIGNS.md
/// §14.3.5): gc.started , gc.ended and gc.failed , gc.place ,
/// map.live 's guarded rows, the tally's guardedWon and
/// integrations.guardedCrate with NpcSpawn beside it.
///
///
private const int ProtocolVersion = 14;
///
/// 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;
///
/// How long a link must stay up before its drop counts as an outage rather than a
/// refusal. The backoff resets only past it: a sidecar that closes every connection at
/// once — one refusing a plugin that names another server (D155), or one crash-looping —
/// would otherwise be redialled twice a second for ever, rebuilding the boards on the
/// main thread and printing two console lines each time.
///
private const int StableLinkMs = 10000;
/// 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;
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",
"OnDispenserBonus", "OnCollectiblePickup", "OnGrowableGathered",
"OnPlayerReported", "OnUserBanned", "OnUserUnbanned",
"CanUserLogin", "OnUserApproved",
"OnUserPermissionGranted", "OnUserPermissionRevoked",
"OnUserGroupAdded", "OnUserGroupRemoved",
"OnGroupPermissionGranted", "OnGroupPermissionRevoked",
"OnPluginLoaded", "OnPluginUnloaded",
"OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded",
"OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged",
"OnKitRedeemed",
"OnEntitySpawned", "OnEntityKill",
"OnEntityTakeDamage", "OnItemCraftFinished", "OnEntityBuilt", "OnStructureRepair",
"OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched",
"OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded",
"OnEnterZone", "OnExitZone", "OnRgDomesReady",
"OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged",
"OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied",
"OnPlayerSleepEnded",
"OnEconomicsBalanceUpdated", "OnEconomicsDataWiped",
// Protocol 14, Rust's own world events (§20.5).
"OnPatrolHelicopterKill", "OnHelicopterRetire", "OnBradleyApcInitialize",
"OnCargoShipHarborApproach", "OnCargoShipHarborArrived", "OnCargoShipHarborLeave", "OnCargoShipEgress",
"OnHelicopterDropCrate", "OnCrateLanded", "CanHackCrate", "OnCrateHack", "OnCrateHackEnd", "OnLootEntity",
"OnCargoPlaneSignaled", "OnAirdrop", "OnSupplyDropDropped", "OnSupplyDropLanded",
"OnDeepSeaOpened", "OnDeepSeaClosed",
// Protocol 14, Dynamic PVP's zones (§20.7).
"OnCreatedDynamicPVP", "OnDeletedDynamicPVP",
// Protocol 14, Dangerous Treasures (§20.8).
"OnDangerousEventStarted", "OnDangerousEventWon", "OnDangerousEventEnded",
// Protocol 14, Guarded Crate (§20.9).
"OnGuardedCrateEventStart", "OnGuardedCrateEventEnded", "OnGuardedCrateEventFailed"
};
private void Init()
{
foreach (string hook in ExpectedHooks)
_hookCounts[hook] = 0L;
_bootId = ResolveBootId();
_wipeId = ResolveWipeId();
// Before the link thread, not in `Loaded`: both frameworks call `Init` first, and a
// thread started here with `_config` still null dials the 7799 fallback. With one
// server per host that is its own sidecar by coincidence; with two it is the OTHER
// server's, whose sidecar then files this server's history under that one's id
// (found by the phase 18 walk, D154).
LoadConfigValues();
_running = true;
// Expired link codes are dropped on a timer, not on lookup: an unconfirmed code is
// never looked up, so nothing else would ever remove one (protocol 3).
timer.Every(60f, PurgeExpiredCodes);
_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;
}
if (_clansBoardSoon != null)
{
_clansBoardSoon.Destroy();
_clansBoardSoon = null;
}
// A half-drained permission sync is abandoned rather than finished, and the site is
// told nothing — which is right: the report would describe a set that was only partly
// applied, and the next sync re-derives the whole answer from the store anyway. What
// must not happen is the timer outliving the plugin and writing during a reload.
_permJob = null;
StopPermTimer();
// The same for an inventory being built: the site's request times out and it asks
// again from page 0, which is the only way it ever reads one.
_inventoryJob = null;
_inventory = null;
StopInventoryTimer();
// A config write waiting on a reload dies here too, and its files stay as written.
// That is the honest outcome: the rollback exists to undo an edit that took a plugin
// down, and this plugin being unloaded is not evidence of either — it is usually THIS
// plugin being reloaded by somebody. No `config.outcome` follows; the website calls a
// write it never heard about lost once twice `ceilingMs` has passed, and re-reads.
StopConfigWatch();
_configJob = null;
// Holds are NOT given back on unload. The file keeps them, and the next load re-arms
// them: a plugin reload is not the end of an event (§27.3).
StopLeaseTimer();
// Zones are not erased here by us — ZoneManager erases them itself as this plugin
// unloads — and they are NOT forgotten: the next load re-creates them (D92).
StopWorldTimer();
// A tally is kept across a reload — it records who was there, and the next load
// carries on counting — so what it has counted since the last minute's save is written
// now rather than lost.
StopTallyTimer();
if (_talliesDirty)
SaveTallies();
_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";
///
/// Whether the website may borrow values on this server for events (protocol 8,
/// D76). Off until an operator turns it on. Letting staff moderate from a
/// screen they are looking at, and letting a schedule change the world at four in
/// the morning, are different consents, and one switch cannot honestly express
/// both. It gates taking a lease only: listing and giving back always work, so
/// turning it off never strands a value somebody already borrowed.
///
public bool EventsEnabled = false;
///
/// The most crates one event step may place (protocol 9, D95). An operator may
/// lower it; is the ceiling it cannot be raised
/// past. A step over the bound is refused, never trimmed to fit.
///
public int EventsMaxCrates = WorldHardMaxCrates;
/// The most NPCs one event step may place (D95). Lowerable, like the above.
public int EventsMaxNpcs = WorldHardMaxNpcs;
///
/// The most people one reward step may name (protocol 10, §29.5). The website reads
/// it and refuses a step over it; it is never trimmed to fit.
///
public int EventsMaxRecipients = RecipientsHardMax;
/// The most tallies that may count at once on this server (§29.5).
public int EventsMaxTallies = TallyHardMax;
/// The longest line an event or a news post may say in chat, in characters.
public int EventsMaxChat = ChatHardMax;
///
/// The most world events one run may call on this server (protocol 14, D354): patrol
/// helicopters, cargo ships, airdrops and the rest, each counted. Lowerable, like the
/// bounds above; is the ceiling.
///
public int EventsMaxWorldCalls = WorldCallsHardMax;
///
/// The most offline sleepers one map answer carries (protocol 11). Over it, the layer
/// answers the first this many and says it was cut short.
///
public int MapMaxSleepers = MapDefaultMax;
/// The most tool cupboards and vending machines one map answer carries.
public int MapMaxBases = MapDefaultMax;
}
///
/// Runs only when this plugin has no config file yet — so it is the one moment a server id
/// can arrive from outside the file (D150). A Pterodactyl egg cannot write a framework's
/// config directory (Carbon lets an operator move it), but it can set an environment
/// variable, and `RUSTLINK_SERVER_ID` is the one the sidecar already reads for its
/// cross-check. After this write the file is canonical and the website's lock applies,
/// so changing the variable later changes nothing — which is what keeps a server's
/// history attached to one id.
///
protected override void LoadDefaultConfig()
{
var config = new PluginConfig();
string fromEnv = Environment.GetEnvironmentVariable("RUSTLINK_SERVER_ID");
if (!string.IsNullOrEmpty(fromEnv))
{
if (IsValidServerId(fromEnv))
config.ServerId = fromEnv;
else
PrintWarning($"RUSTLINK_SERVER_ID '{fromEnv}' is not a valid server id " +
$"(lowercase letters, digits and '-', 1-64, not starting with '-'); " +
$"the new config keeps '{config.ServerId}'");
}
Config.WriteObject(config, true);
}
/// The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`, without a Regex.
private static bool IsValidServerId(string id)
{
if (string.IsNullOrEmpty(id) || id.Length > 64 || id[0] == '-')
return false;
foreach (char c in id)
{
bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
if (!ok)
return false;
}
return 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";
// A config written before protocol 8 has no `EventsEnabled`, and a key that is not in
// the file is a key the website's config editor cannot show an operator. Written back
// once, with its default, so the switch is visible before anybody needs it.
// The two bounds are clamped to their ceilings rather than refused: a config asking
// for MORE than the plugin will ever place is an operator's typo, and the ceiling is
// what the website mirrors (D95).
_config.EventsMaxCrates = Math.Max(0, Math.Min(_config.EventsMaxCrates, WorldHardMaxCrates));
_config.EventsMaxNpcs = Math.Max(0, Math.Min(_config.EventsMaxNpcs, WorldHardMaxNpcs));
_config.EventsMaxRecipients = Math.Max(0, Math.Min(_config.EventsMaxRecipients, RecipientsHardMax));
_config.EventsMaxTallies = Math.Max(0, Math.Min(_config.EventsMaxTallies, TallyHardMax));
_config.EventsMaxChat = Math.Max(0, Math.Min(_config.EventsMaxChat, ChatHardMax));
_config.EventsMaxWorldCalls = Math.Max(0, Math.Min(_config.EventsMaxWorldCalls, WorldCallsHardMax));
_config.MapMaxSleepers = Math.Max(0, _config.MapMaxSleepers);
_config.MapMaxBases = Math.Max(0, _config.MapMaxBases);
if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null ||
Config["EventsMaxRecipients"] == null || Config["EventsMaxTallies"] == null || Config["EventsMaxChat"] == null ||
Config["EventsMaxWorldCalls"] == null || Config["MapMaxSleepers"] == null || Config["MapMaxBases"] == null)
Config.WriteObject(_config, true);
}
private void Loaded()
{
Puts($"protocol {ProtocolVersion}, serverId '{_config.ServerId}', sidecar {_config.Host}:{_config.Port}");
// Here rather than at `OnServerInitialized`: the link thread starts in `Init`, so a
// lease command can arrive before the world has finished loading, and it must find
// the holds already in place rather than have them loaded over it afterwards.
LoadLeases();
LoadTruePvePending();
LoadWorld();
LoadRewards();
LoadWorldCalls();
LoadDtStarts();
LoadGcPlacements();
}
// ---- 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;
System.Diagnostics.Stopwatch upFor = 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
upFor = System.Diagnostics.Stopwatch.StartNew();
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;
// A link that held is an outage: start again from the shortest wait. One that was
// closed on arrival keeps doubling (see StableLinkMs).
if (upFor != null && upFor.ElapsedMilliseconds >= StableLinkMs)
backoffMs = 500;
// Nothing is listening yet, or the sidecar restarted. Both are normal.
//
// A handle, 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 `_stopping`, so waiting on it turns that stall into
// an immediate return.
//
// `_stopping`, not `Wake`, for the reason `_stopping` gives: `Emit` signals `Wake`
// on every frame, so a backoff waited on it lasted until the next hook fired. That
// was invisible while a failed connect was silent; against a sidecar that refuses
// (D155) the phase 18 walk measured some 250 redials a second, each printing two
// console lines and rebuilding the boards.
_stopping.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;
}
}
}
///
/// Reader thread. Splits the stream into lines as bytes and decodes each finished
/// line as UTF-8.
///
///
/// Bytes, not characters, because a character can straddle two reads: the first half of
/// an em dash at the end of one buffer and the rest at the start of the next. Splitting
/// on the byte is safe where splitting the text is not — UTF-8 never uses 0x0A
/// inside a multi-byte sequence, so a newline byte is always a newline — and decoding
/// the whole line at once is what keeps the halves together. The old loop cast each byte
/// to a char , which is Latin-1, and turned every non-ASCII character the website
/// sent into two or three wrong ones: announcements, titles, kit names, and the text of
/// every configuration file saved from the site (F15).
///
///
private void ReadLoop(NetworkStream stream, int epoch)
{
byte[] buffer = new byte[8192];
var line = new MemoryStream(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++)
{
byte b = buffer[i];
if (b == (byte)'\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(Encoding.UTF8.GetString(line.GetBuffer(), 0, (int)line.Length));
}
line.SetLength(0);
}
else if (b != (byte)'\r')
{
if (discarding)
continue;
line.WriteByte(b);
if (line.Length > MaxInboundLineBytes)
{
Puts("inbound line over the cap; discarding to the next newline");
line.SetLength(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;
case "link.confirm":
// The first command the WEBSITE originates (protocol 3). Everything before it
// was the game talking, or the sidecar asking the game to say something it
// already knew.
HandleLinkConfirm(cmd, reqId);
break;
case "perm.catalogue":
// Protocol 4. What this server's loaded plugins have registered — the option
// source the website's authoring form offers, so a grant can only be written
// against a name that will actually resolve.
HandlePermCatalogue(reqId);
break;
case "perm.sync":
// The first command that WRITES to the game (protocol 4, R2). Everything
// before it read, or spent a code the game itself had minted.
HandlePermSync(cmd, reqId);
break;
case "perm.inventory":
// Protocol 13 (D160). The whole store, owners included, in pages — what the
// site reads before every sync now that it owns all of it.
HandlePermInventory(cmd, reqId);
break;
case "config.list":
// Protocol 5 (R18). What settings files exist on this host, and which plugins
// are loaded to reload them. A description of the tree, never its contents.
HandleConfigList(reqId);
break;
case "config.read":
HandleConfigRead(cmd, reqId);
break;
case "config.write":
// The first command that writes to the game HOST's filesystem, which is the
// most powerful thing the website can do to a server.
HandleConfigWrite(cmd, reqId);
break;
case "lease.list":
// Protocol 8 (PLAN.md §27). What this server lends, what it holds now, and
// every hold in force — one read serving the website's `read()` and
// `inForce()` alike.
HandleLeaseList(cmd, reqId);
break;
case "lease.apply":
HandleLeaseApply(cmd, reqId);
break;
case "lease.release":
HandleLeaseRelease(cmd, reqId);
break;
case "world.monuments":
// Protocol 9 (PLAN.md §28). The monuments this wipe's map has, for the
// authoring form — and the same vocabulary a step's location is written in.
HandleWorldMonuments(reqId);
break;
case "world.zone":
HandleWorldZone(cmd, reqId);
break;
case "world.place":
HandleWorldPlace(cmd, reqId);
break;
case "world.revert":
HandleWorldRevert(cmd, reqId);
break;
case "world.owned":
HandleWorldOwned(cmd, reqId);
break;
case "world.call":
// Protocol 14 (PROTOCOL.md §20.5, D354). One of the game's own world events,
// now: kept, never reverted.
HandleWorldCall(cmd, reqId);
break;
case "dt.start":
// Protocol 14 (PROTOCOL.md §20.8, D365). One Dangerous Treasures event, opened
// through DT's own console command; DT picks the spot and despawns its own.
HandleDtStart(cmd, reqId);
break;
case "gc.place":
// Protocol 14 (PROTOCOL.md §20.9, D364, D373). One guarded crate, placed through
// Guarded Crate's own console command; kept when the run ends.
HandleGcPlace(cmd, reqId);
break;
case "tally.open":
// Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game.
HandleTallyOpen(cmd, reqId);
break;
case "tally.snapshot":
HandleTallySnapshot(cmd, reqId);
break;
case "tally.close":
HandleTallyClose(cmd, reqId);
break;
case "kits.list":
HandleKitsList(reqId);
break;
case "kits.uses":
// Protocol 14 (the module's PLAN_REDESIGNS.md §2.6). How many of the given
// players have used a kit up, for the warning beside a kit weekend.
HandleKitsUses(cmd, reqId);
break;
case "economics.pay":
// Protocol 14 (PROTOCOL.md §20.3, the module's PLAN_REDESIGNS.md §11.2). Coins
// from an event step, kept once paid (D335).
HandleEconomicsPay(cmd, reqId);
break;
case "economics.balances":
HandleEconomicsBalances(cmd, reqId);
break;
case "economics.adjust":
// Staff, from a player's admin page: deposit, withdraw or set.
HandleEconomicsAdjust(cmd, reqId);
break;
case "backpacks.read":
// Protocol 14 (PROTOCOL.md §20.4, the module's PLAN_REDESIGNS.md §11.3). One
// player's backpack, read and never written (R2).
HandleBackpacksRead(cmd, reqId);
break;
case "chat.say":
HandleChatSay(cmd, reqId);
break;
case "titles.set":
// Protocol 12. The chat titles players have earned, as one whole set that
// replaces the last (PLAN.md §33).
HandleTitlesSet(cmd, reqId);
break;
case "map.info":
// Protocol 11 (PLAN.md §30). What this map is and where its picture comes from.
HandleMapInfo(reqId);
break;
case "map.fetch":
HandleMapFetch(cmd, reqId);
break;
case "map.render":
HandleMapRender(cmd, reqId);
break;
case "map.live":
HandleMapLive(reqId);
break;
case "npc.profiles":
// Protocol 13, RunicNPC (docs/runicnpc/PLAN.md stage 4). The server's profiles,
// read before the site's first push so it can adopt them (D244).
HandleNpcProfiles(reqId);
break;
case "npc.profiles.set":
HandleNpcProfilesSet(cmd, reqId);
break;
case "npc.placements":
HandleNpcPlacements(reqId);
break;
case "npc.placement":
// One change to one placement, by `op` (D245, D246).
HandleNpcPlacement(cmd, reqId);
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();
// Last, and fenced: the clan board reads the game's clan database, which is the one
// board here built from something other than a list in memory. A failure there must
// cost the clans and nothing else — the two boards above are what every page renders.
try
{
SendClansBoard();
}
catch (Exception ex)
{
Puts($"could not build the clans board: {Flatten(ex.Message)}");
}
}
/// Main thread.
private void SendHello()
{
Dictionary hello = Frame("server.hello", "snapshot");
Merge(hello, ServerBody());
hello["protocol"] = ProtocolVersion;
hello["bootId"] = _bootId;
hello["plugin"] = Version.ToString();
// The website compares boot ids to decide when to reconcile the events ledger, and
// must not do it until the world it would be asking about exists (§28.6).
hello["worldReady"] = _worldReady;
// The ZoneManager helper (D182), only where there is a ZoneManager for it to help.
// `patched` is the whole fix; anything else means this plugin scores its zones by
// position and ZoneManager's flags miss somebody already inside a new zone.
if (ZoneManagerPlugin() != null)
{
var helper = new Dictionary { { "state", ZoneHelperState() } };
Plugin zones = ZoneHelperPlugin();
if (zones != null)
{
helper["version"] = zones.Version.ToString();
if (zones.Call("StateReason") is string reason)
helper["reason"] = reason;
}
hello["zoneHelper"] = helper;
// D211: ZoneManager's own flag list, for the site's zone presets (§3.1). In hello
// rather than every status frame, because it changes only when ZoneManager does,
// and a ZoneManager load sends hello again.
var zoneManager = new Dictionary { { "version", ZoneManagerPlugin().Version.ToString() } };
List flags = ZoneFlagNames(ZoneManagerPlugin());
if (flags != null)
zoneManager["flags"] = flags;
hello["zoneManager"] = zoneManager;
}
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", SteamIdOf(player) },
{ "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.
}
// Protocol 12: which of the optional mods this server has, so Admin → Rust can say why
// a title or a popup is not showing before anybody goes looking (§33.2).
status["integrations"] = IntegrationsBody();
// Protocol 14 (D363): whether another plugin's console command can be run from here.
status["console"] = ConsoleBody();
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 void DescribeAttacker(
Dictionary frame, BasePlayer victim, HitInfo info)
{
BaseEntity initiator = info != null ? info.Initiator : null;
BasePlayer attacker = info != null ? info.InitiatorPlayer : null;
string weapon = WeaponOf(info);
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"] = SteamIdOf(attacker);
frame["attackerName"] = attacker.displayName;
}
else
{
frame["attackerType"] = "npc";
frame["attackerName"] = initiator.ShortPrefabName;
// One of RunicNPC's: its own name, as the victim's death screen shows it, and its
// profile. Beside `attackerName`, not over it, so a reader that knows only prefab
// names still reads a scientist (runicnpc PLAN.md §1.4, stage 4).
string profile = RunicNpcProfileOf(initiator);
if (profile != null && attacker != null)
{
frame["attackerNpc"] = attacker.displayName;
frame["attackerProfile"] = profile;
}
}
float distance = DistanceOf(info, victim);
if (distance > 0f)
frame["distance"] = (float)Math.Round(distance, 1);
}
///
/// The killing blow's weapon as the killfeed names it: the held entity's short prefab
/// name (bow_hunting.entity ), or what was thrown or placed. Null when the hit
/// carries neither. The tally's weapon kills (§5.3) read this same name, so the lists
/// the site keeps are lists of what the killfeed already shows.
///
private static string WeaponOf(HitInfo info)
{
if (info == null)
return null;
if (info.WeaponPrefab != null)
return info.WeaponPrefab.ShortPrefabName;
return info.Weapon != null ? info.Weapon.ShortPrefabName : null;
}
///
/// How far the killing blow travelled, in metres: the projectile's own distance, or the
/// gap between the two when there was no projectile. 0 when neither can be read.
///
private static float DistanceOf(HitInfo info, BaseEntity victim)
{
if (info == null)
return 0f;
float distance = info.ProjectileDistance;
BaseEntity initiator = info.Initiator;
if (distance <= 0f && initiator != null && initiator.transform != null
&& victim != null && victim.transform != null)
distance = UnityEngine.Vector3.Distance(
initiator.transform.position, victim.transform.position);
return distance;
}
// ---- 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 about 150 m
/// 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);
}
///
/// The game's own label for a position, from MapHelper — the function the in-game
/// map draws its grid with (D119).
///
///
/// Phases 3 to 13 computed this here from a 146.3 m cell, the number community tools
/// quote. It is wrong: the game fits a whole number of cells to the world, so a 3000 map
/// is 20 cells of exactly 150 m, and the constant put every row below the first a row
/// too far south (the ferry terminal read O17 where the game says O16). Nobody could
/// see it without a second opinion, and the map's grid was the second opinion.
///
///
private static string Grid(UnityEngine.Vector3 position)
{
if (ConVar.Server.worldsize <= 0)
return null;
try
{
return MapHelper.PositionToString(position);
}
catch (Exception)
{
return null;
}
}
// ---- 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();
// Only now is the wipe known, so only now can what an earlier map owned be dropped;
// and this hook also fires on a hotload, which is exactly when ZoneManager has just
// erased every zone the previous load of this plugin owned (D92).
_worldReady = true;
SeedRegisteredPerms();
DropWorldFromOtherWipes();
RecreateZones("load");
// Mappings an erase left while TruePVE was away (§14.3.2). When TruePVE loads after
// this, its own OnPluginLoaded does it.
FlushTruePvePending("load");
// The map's sets are seeded here for the same reason: a hotload hears no spawns.
SeedMapSets();
SeedPvpZones("load");
SeedTreasures("load");
NoteDtSeen(DangerousTreasuresPlugin() != null);
SeedGuardedCrates("load");
// Protocol 14: the console probe (D363) and the world events' sweep. Before hello, which
// carries what the probe found.
StartWorldEvents();
// The titles this plugin last held, if they are this wipe's (D144), and then the getter
// handed to BetterChat, if it is here. When it loads later, or reloads and forgets us,
// OnPluginLoaded does this again (§33.1).
LoadTitles();
RegisterTitles("load");
// 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);
// Say the world is ready now rather than at the next tick: the website holds its
// reconcile until it hears this.
if (_connected)
SendHello();
Dictionary frame = Frame("server.initialized", "event");
frame["initial"] = initial;
EmitFrame(frame);
}
private void OnServerShutdown()
{
MarkHook("OnServerShutdown");
FlushTallies();
if (_talliesDirty)
SaveTallies();
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 one such piece of state
// this plugin holds.
_tallies.Clear();
_connectedAt.Clear();
ClearWorldEvents();
// Nor is anything an event placed: the old map, and everything on it, is gone.
DropWorldFromOtherWipes();
// A run's tally is NOT dropped: it records people, not the map, and a zone it counted
// in simply stops gaining anybody. Kit credits are kept too — unless Kits is about to
// clear its use counts, when the ones moved into them go with it (§29.3).
ResetCreditsForWipe();
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"] = SteamIdOf(player);
frame["name"] = player.displayName;
EmitFrame(frame);
// A balance carried over from last wipe is known at once, without waiting for it to
// move (D336). Nothing when Economics is not loaded.
NoteBalance(player);
}
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"] = SteamIdOf(player);
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"] = SteamIdOf(player);
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;
// A tally's player kill (D98): the last hit, never your own.
BasePlayer killer = info != null ? info.InitiatorPlayer : null;
if (killer != null && !killer.IsNpc && (ulong)killer.userID != (ulong)player.userID)
{
CreditKill(killer, true);
// The kill itself is `player.death`'s, counted by the site from this frame; the
// tally adds only what a title needs about it (§5.1).
TallyFor(killer).Killed(info, player, true);
}
Dictionary frame = Frame("player.death", "event");
frame["steamId"] = SteamIdOf(player);
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"] = SteamIdOf(player);
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);
}
// ---- the rest of what a player gathers (F1, D159) ----
//
// `OnDispenserGather` alone is the swings. The first walk had 825 wood in the inventory
// and 587 on the tally, and 20 cloth from hemp and none on it: the bonus on a tree's final
// hit is `OnDispenserBonus`, and anything picked up off the ground — hemp, mushrooms,
// stones, a wild pumpkin — is `OnCollectiblePickup`. A farmed plant is
// `OnGrowableGathered`. D159: "gathered" is everything a player harvests, so the
// leaderboard agrees with the inventory. All three are counters (rule 2) and void (rule 1).
/// The bonus on the final hit of a tree or a node. Void: an Item return replaces the bonus.
private void OnDispenserBonus(ResourceDispenser dispenser, BasePlayer player, Item item)
{
MarkHook("OnDispenserBonus");
if (player == null || player.IsNpc || item == null || item.info == null)
return;
TallyFor(player).Gather(item.info.shortname, item.amount);
}
///
/// A pick-up off the ground. Void: a non-null return cancels it. It fires BEFORE the
/// items move, so what is counted is the collectible's list — and a pick-up another
/// plugin vetoes is counted anyway, which is a rarity worth less than a second hook.
///
private void OnCollectiblePickup(CollectibleEntity collectible, BasePlayer player)
{
MarkHook("OnCollectiblePickup");
if (player == null || player.IsNpc || collectible == null || collectible.itemList == null)
return;
Tally tally = TallyFor(player);
foreach (ItemAmount amount in collectible.itemList)
{
if (amount == null || amount.itemDef == null)
continue;
tally.Gather(amount.itemDef.shortname, (int)amount.amount);
}
}
/// A farmed plant, harvested. No return behaviour on either framework.
private void OnGrowableGathered(GrowableEntity plant, Item item, BasePlayer player)
{
MarkHook("OnGrowableGathered");
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;
// Before the early return below: a patrol helicopter usually dies by crashing, and a
// crash's HitInfo names no player (§5.4). Protocol 14: the death is a world event that
// carries every candidate, and the site credits Rotorbane and Tankbreaker (D372).
if (IsVehicleKill(entity))
{
WorldVehicleDied(entity, attacker);
return;
}
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)
{
Tally tally = TallyFor(attacker);
tally.NpcKills++;
tally.Killed(info, entity, false);
string profile = RunicNpcProfileOf(victimPlayer);
if (profile != null)
tally.KilledProfile(profile);
CreditKill(attacker, false);
}
return;
}
// An animal is an NPC to a run's tally exactly as it is to the leaderboard (§29.5):
// two meanings of one word would be two answers to "how many NPCs did I kill".
// `animalKills` counts it AGAIN, separately (§5.2): the public NPC column keeps its
// meaning mid-wipe, and the site takes the human NPCs as the difference.
//
// Rust's own animals are now `Rust.Ai.Gen2.BaseNPC2` — stag, boar, wolf, bears, big cats,
// crocodile, farm animals, critters, sea life — and not `BaseNpc`, which is all this
// tested until the RunicNPC 9d walk found no animal kill had counted at all. `BaseNpc`
// stays for whatever still derives from it. `ScientistNPC2` is the one person among
// them, so it is a human NPC kill, not an animal.
if (entity is Rust.Ai.Gen2.ScientistNPC2)
{
Tally tally = TallyFor(attacker);
tally.NpcKills++;
tally.Killed(info, entity, false);
CreditKill(attacker, false);
return;
}
if (entity is BaseNpc || entity is Rust.Ai.Gen2.BaseNPC2)
{
Tally tally = TallyFor(attacker);
tally.NpcKills++;
tally.AnimalKills++;
tally.Killed(info, entity, false);
CreditKill(attacker, false);
return;
}
// Protocol 7 (PROTOCOL.md §13): a raid is the loss of anything a base is made of, not
// only a building block. A door is NOT a BuildingBlock — both derive from
// StabilityEntity as siblings — so until protocol 7 the most common raid there is, two
// charges on a door, reached nobody.
string structure = StructureKind(entity);
// `OwnerID == 0` is the world's own: a monument's doors and walls. Destroying those is
// not a raid on anybody, and emitting them would fill the staff feed with loot runs.
if (structure == null || entity.OwnerID == 0UL)
return;
// The tally's meaning is unchanged since protocol 2 — building blocks only — so a
// leaderboard column does not quietly change what it counts in the middle of a wipe.
//
// **But not your own** (F3). The first walk's org lead broke their own wall and the
// next tally carried `structures: 1`. Taking down a piece you placed, or one on a
// cupboard you are authorised on, is building, not raiding. The frame still goes: the
// site already skips the raid ALERT for an authorised attacker (D59), and a staff feed
// that loses a line because the player happened to own it is a feed with a hole.
if (structure == "block" && !OwnsOrAuthed(attacker, entity))
TallyFor(attacker).Structures++;
Dictionary frame = Frame("entity.destroyed", "event");
frame["ownerId"] = entity.OwnerID.ToString();
frame["prefab"] = entity.ShortPrefabName;
frame["structure"] = structure;
// From `userID`, not `UserIDString`. The string is filled in when a player connects,
// loads as a sleeper, or is an engine bot — and at no other time, so a player another
// plugin spawned carries none. The website skips the raid alert when the attacker is
// authorised on the cupboard (PLAN.md D59), and a null here would quietly defeat that.
frame["attackerId"] = ((ulong)attacker.userID).ToString();
frame["attackerName"] = attacker.displayName;
string grid = GridOf(entity);
if (grid != null)
frame["grid"] = grid;
AddAuthorization(frame, entity);
EmitFrame(frame);
}
// ---- what a chat title counts (PLAN_REDESIGNS §5, D172–D174) ----
//
// Every one of these is a counter (rule 2) and void (rule 1). Three of them can veto —
// `OnStructureRepair`, `OnHealingItemUse`, `OnPlayerRevive` — and `OnEntityTakeDamage`
// can cancel the damage outright, which is exactly why none of them returns anything.
/// The APC and the two helicopters: the three things a title credits a kill of (D174).
private static bool IsVehicleKill(BaseEntity entity)
{
return entity is BradleyAPC || entity is PatrolHelicopter || entity is CH47Helicopter;
}
///
/// Void: a non-null return cancels the damage. It fires for every hit on everything,
/// so the type test is the first thing it does; only a hit on one of the three
/// vehicles is remembered — who dealt it and how much — for the credit block its death
/// carries (protocol 14, D372). A helicopter that flies off the map is never killed;
/// the world events' sweep forgets its row.
///
private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
{
MarkHook("OnEntityTakeDamage");
if (entity == null || info == null || !IsVehicleKill(entity) || entity.net == null)
return;
BasePlayer player = info.InitiatorPlayer;
if (player == null || player.IsNpc)
return;
NoteVehicleHit(entity, player, info);
}
///
/// No return behaviour. A crafted shirt, rifle or anything else, by its item category.
/// Who crafted it is the crafter's owner: the task no longer carries one (read off the
/// rig, 2026-09-29).
///
private void OnItemCraftFinished(ItemCraftTask task, Item item, ItemCrafter crafter)
{
MarkHook("OnItemCraftFinished");
BasePlayer player = crafter != null ? crafter.owner : null;
if (player == null || player.IsNpc || item == null || item.info == null)
return;
TallyFor(player).Craft(item.info.category.ToString().ToLowerInvariant(), item.amount);
}
/// No return behaviour. Only a building block is a structure built; a deployable is not.
private void OnEntityBuilt(Planner planner, UnityEngine.GameObject go)
{
MarkHook("OnEntityBuilt");
BasePlayer player = planner != null ? planner.GetOwnerPlayer() : null;
if (player == null || player.IsNpc || go == null || go.GetComponent() == null)
return;
TallyFor(player).Built++;
}
///
/// Void: a non-null return refuses the repair. It fires on every hammer hit, BEFORE the
/// game decides whether there is anything to repair, the resources to pay for it, or a
/// recent attack that forbids it. So the health is read now and again next tick, and
/// only a hit that actually raised it is a repair.
///
private void OnStructureRepair(BaseCombatEntity entity, BasePlayer player)
{
MarkHook("OnStructureRepair");
if (entity == null || player == null || player.IsNpc)
return;
float before = entity.health;
ulong id = player.userID;
NextTick(() =>
{
if (entity == null || entity.IsDestroyed || entity.health <= before)
return;
BasePlayer still = BasePlayer.FindByID(id);
if (still != null)
TallyFor(still).Repaired++;
});
}
// A syringe on a downed player raises `OnHealingItemUse` and then, from inside the same
// `GiveEffectsTo`, `OnPlayerRevive`; one act of help is one heal. The last one counted is
// remembered for the frame it happened in, and the other hook skips the same pair.
private ulong _helpHealer;
private ulong _helpTarget;
private int _helpFrame = -1;
/// Counts one act of help unless it is yourself, an NPC, or the other hook already did this frame.
private void CountHelp(BasePlayer healer, BasePlayer player)
{
if (healer == null || healer.IsNpc || player == null || player.IsNpc
|| (ulong)healer.userID == (ulong)player.userID)
return;
int frame = UnityEngine.Time.frameCount;
if (_helpFrame == frame && _helpHealer == (ulong)healer.userID && _helpTarget == (ulong)player.userID)
return;
_helpHealer = healer.userID;
_helpTarget = player.userID;
_helpFrame = frame;
TallyFor(healer).Healed++;
}
///
/// Void: a non-null return refuses the heal. Healing yourself is not a heal (D174).
///
///
/// Rust's `MedicalTool.GiveEffectsTo(fromPlayer, toTarget)` raises it as
/// (tool, fromPlayer, toTarget) . Until the RunicNPC 9d walk this took two
/// parameters, so the second — the healer — arrived as the target, every heal looked like
/// healing yourself, and none counted. The target is object : `IMedicalToolTarget`
/// is not always a player, and only a player healed counts.
///
///
private void OnHealingItemUse(MedicalTool tool, BasePlayer fromPlayer, object target)
{
MarkHook("OnHealingItemUse");
CountHelp(fromPlayer ?? (tool != null ? tool.GetOwnerPlayer() : null), target as BasePlayer);
}
/// Void: a non-null return refuses the revive. Reviving yourself is not possible, and is not counted.
private void OnPlayerRevive(BasePlayer reviver, BasePlayer player)
{
MarkHook("OnPlayerRevive");
CountHelp(reviver, player);
}
/// No return behaviour. One rocket leaving a launcher.
private void OnRocketLaunched(BasePlayer player, BaseEntity entity)
{
MarkHook("OnRocketLaunched");
if (player == null || player.IsNpc)
return;
TallyFor(player).Rockets++;
}
/// No return behaviour. A grenade, beancan, satchel or C4 thrown.
private void OnExplosiveThrown(BasePlayer player, BaseEntity entity, ThrownWeapon item)
{
MarkHook("OnExplosiveThrown");
CountExplosive(player, entity);
}
/// No return behaviour. The same, dropped at your feet rather than thrown.
private void OnExplosiveDropped(BasePlayer player, BaseEntity entity, ThrownWeapon item)
{
MarkHook("OnExplosiveDropped");
CountExplosive(player, entity);
}
///
/// A supply signal and a smoke grenade are thrown through the same hooks, and neither
/// is an explosive. What is one is a timed explosive that does damage.
///
private void CountExplosive(BasePlayer player, BaseEntity entity)
{
if (player == null || player.IsNpc)
return;
TimedExplosive explosive = entity as TimedExplosive;
if (explosive == null || explosive is SupplySignal || explosive.damageTypes == null)
return;
foreach (Rust.DamageTypeEntry damage in explosive.damageTypes)
{
if (damage != null && damage.amount > 0f)
{
TallyFor(player).Explosives++;
return;
}
}
}
///
/// No return behaviour. A mission one of the game's NPCs handed out, finished (D173). A
/// failed or abandoned one raises a different hook, and is never counted.
///
private void OnMissionSucceeded(BaseMission mission, BaseMission.MissionInstance instance, BasePlayer assignee)
{
MarkHook("OnMissionSucceeded");
// The game hands the assignee over: `MissionSuccess(instance, assignee)`. The instance
// itself has no accessor for it (read off the rig, 2026-09-29).
if (assignee == null || assignee.IsNpc)
return;
TallyFor(assignee).Missions++;
}
///
/// The most people one entity.destroyed frame names as authorised. A cupboard
/// has no hard cap on vanilla, and an unbounded list is an unbounded line on the game
/// link; 64 is several full teams, and a frame cut short says so.
///
private const int MaxAuthorizedOnFrame = 64;
///
/// Which of the four things a base is made of this entity is, or null for anything else.
/// Ordered by how often each dies, which is also cheapest-first.
///
private static string StructureKind(BaseEntity entity)
{
if (entity is BuildingBlock)
return "block";
// External gates are Doors too, which is right: a gate is a door in a wall.
if (entity is Door)
return "door";
if (entity is SimpleBuildingBlock)
return "wall";
if (entity is BuildingPrivlidge)
return "cupboard";
return null;
}
///
/// Who lives here, by the game's own definition: everyone authorised on the building's
/// tool cupboard. Both fields are ABSENT when there is no cupboard, which is a different
/// answer from an empty list — "nobody is authorised" and "there is nothing to be
/// authorised on" — and the module alerts nobody in either case (PLAN.md D67).
///
///
/// authorizedPlayers is the set, and nothing else is. The cupboard also holds
/// recentGroupMembers , which LOOKS like residents and is not: it counts code-lock
/// users towards group upkeep and prunes itself on a timer.
///
///
///
/// The lookup resolves through the entity's building, which is cheap. An external wall
/// has no building and falls back to a sphere query on this thread — acceptable because
/// it runs only after every cheap filter above has passed, at the rate walls fall rather
/// than the rate this hook fires.
///
///
private static void AddAuthorization(Dictionary frame, BaseEntity entity)
{
// A cupboard being destroyed still holds its list: OnEntityDeath runs before the kill.
BuildingPrivlidge cupboard = entity as BuildingPrivlidge ?? entity.GetBuildingPrivilege();
if (cupboard == null || cupboard.net == null || cupboard.authorizedPlayers == null)
return;
HashSet online = new HashSet();
if (BasePlayer.activePlayerList != null)
{
foreach (BasePlayer player in BasePlayer.activePlayerList)
{
if (player != null)
online.Add(player.userID);
}
}
List authorized = new List();
foreach (ulong steamId in cupboard.authorizedPlayers)
{
if (authorized.Count >= MaxAuthorizedOnFrame)
{
frame["authorizedTruncated"] = true;
break;
}
authorized.Add(new Dictionary
{
{ "steamId", steamId.ToString() },
{ "online", online.Contains(steamId) },
});
}
frame["buildingId"] = cupboard.net.ID.Value.ToString();
frame["authorized"] = authorized;
}
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"] = SteamIdOf(reporter);
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);
}
// ---- clans (protocol 6) ----
//
// Rust's FIRST-PARTY clan system, which the website turns into core's Teams (R5,
// PROTOCOL.md §12). Not the uMod Clans plugin: that is a separate system that never
// touches `ClanManager` (D47), and this region reads nothing of it except whether it is
// loaded, so the website can warn that its clans are not Teams.
//
// One board and five events, the same split as presence. The events carry the story —
// founded, joined, left, removed, disbanded — and the board is the reconciliation point
// that corrects anything missed within a minute. Leadership travels ONLY on the board,
// because the game raises no hook when somebody is promoted or demoted (§12.3).
//
// Three facts about the game shape everything below, and all three were read from its
// assemblies rather than from a catalogue:
//
// 1. **There is no "list every clan" call.** The backend offers get-by-id and
// get-by-member; the only listing is the clan leaderboard, and the game clamps that
// to 100 clans by score. That clamp is the board's ceiling (D55), and a board at it
// says `truncated` rather than pretending to be the whole answer.
//
// 2. **A clan id is not an identity.** The database file's version number is
// hard-coded, so a game update that bumps it starts a fresh file whose ids restart at
// 1. Every frame therefore carries the clan's creation time beside its id, and the
// pair is what the website keys a Team on (D52).
//
// 3. **The local backend is synchronous behind an async signature.** Every `ValueTask`
// it returns has already completed, which is what makes reading it from a hook
// legal. A task that has NOT completed means some other backend, and it is refused
// rather than waited on — waiting would be the main thread blocking on a database.
///
/// The most clans one board can carry, and it is the GAME's number: the clan leaderboard
/// clamps its limit to 10..100. Asking for more returns 100.
///
private const int MaxClansOnBoard = 100;
///
/// How many bytes of clan rows one board may carry. Well inside the sidecar's 1 MiB line
/// cap, which discards an over-long line outright: a board that silently never arrived
/// would look like a server with no clans. 100 clans of 100 members each is about a
/// megabyte, so the cap is reachable, and a board that meets it says so.
///
private const int ClanBoardBudgetBytes = 768 * 1024;
///
/// How long after a membership change the clan board is re-sent. The event reaches the
/// sidecar at once; the roster that follows it waits for the game to refresh its own
/// cached clan, which `OnClanMemberAdded` fires before.
///
private const float ClanBoardSettleSeconds = 3f;
/// A pending early re-send of the clan board, so a burst of changes sends one.
private Timer _clansBoardSoon;
///
/// Main thread. Every clan on this server, with its roster and roles, as the board the
/// website reconciles Teams against.
///
///
/// The board is ALWAYS sent, including when there is nothing to read — with
/// supported: false and a reason. Saying nothing would leave the website holding
/// the last board it saw and no way to know it had stopped being true.
///
///
private void SendClansBoard()
{
Dictionary frame = Frame("clans", "snapshot");
var clans = new List();
frame["enabled"] = ConVar.Clan.enabled;
frame["umodClans"] = plugins.Find("Clans") != null;
frame["clans"] = clans;
frame["count"] = 0;
frame["truncated"] = false;
// Switched off by the operator. That is an authoritative answer — this server has no
// clans — and different from "could not read them", so it is supported and empty.
if (!ConVar.Clan.enabled)
{
frame["supported"] = true;
EmitFrame(frame);
return;
}
IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null;
if (backend == null)
{
frame["supported"] = false;
frame["reason"] = "the game's clan backend has not started yet";
EmitFrame(frame);
return;
}
frame["backend"] = backend.GetType().Name;
// A Nexus server keeps its clans on Facepunch's side of the Nexus, behind a backend
// whose calls do not complete on the spot. Refused with a reason rather than guessed
// at (D55): half-reading it would be a board that is wrong in a way nobody can see.
if (!(backend is LocalClanBackend))
{
frame["supported"] = false;
frame["reason"] = $"this server's clans are held by a {backend.GetType().Name}, which the bridge does not read";
EmitFrame(frame);
return;
}
System.Threading.Tasks.ValueTask>> listing = backend.GetLeaderboard(MaxClansOnBoard);
if (!listing.IsCompleted || !listing.Result.IsSuccess)
{
frame["supported"] = false;
frame["reason"] = listing.IsCompleted
? $"the game's clan list could not be read ({listing.Result.Result})"
: "the game's clan list did not answer at once";
EmitFrame(frame);
return;
}
List entries = listing.Result.Value;
// At the clamp there may be more clans than the game will list. It cannot be told
// apart from exactly 100, so it is reported as possibly more — the honest direction,
// since the website then only ADDS Teams and never removes one on this answer.
bool truncated = entries.Count >= MaxClansOnBoard;
int bytes = 0;
foreach (ClanLeaderboardEntry entry in entries)
{
IClan clan = ReadClan(backend, entry.ClanId);
if (clan == null)
continue;
Dictionary row = DescribeClan(clan);
int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None));
if (bytes + size > ClanBoardBudgetBytes)
{
truncated = true;
break;
}
bytes += size;
clans.Add(row);
}
frame["supported"] = true;
frame["truncated"] = truncated;
frame["count"] = clans.Count;
EmitFrame(frame);
}
///
/// Main thread. One clan by id, from the game's cache if it is there and from its
/// database if not, or null. Never waits: see fact 3 at the top of this region.
///
private static IClan ReadClan(IClanBackend backend, long clanId)
{
IClan clan;
if (backend.TryGet(clanId, out clan) && clan != null)
return clan;
System.Threading.Tasks.ValueTask> read = backend.Get(clanId);
if (!read.IsCompleted || !read.Result.IsSuccess)
return null;
return read.Result.Value;
}
///
/// Main thread. A clan as the board carries it.
///
///
/// A member's LastSeen is left off on purpose. It says when somebody was last on
/// the server, which is presence, and the website's rule is that nothing names who is on
/// by default (PLAN.md §23). Not sending it is simpler than trusting every reader to
/// withhold it.
///
///
private Dictionary DescribeClan(IClan clan)
{
var roles = new Dictionary();
if (clan.Roles != null)
{
foreach (ClanRole role in clan.Roles)
roles[role.RoleId] = role;
}
var members = new List();
if (clan.Members != null)
{
foreach (ClanMember member in clan.Members)
{
var row = new Dictionary
{
{ "steamId", member.SteamId.ToString() },
{ "joinedMs", member.Joined }
};
ClanRole role;
// A member whose role id matches no role is left without a rank rather than
// given one: "not known" must never read as "leads this clan", and rank 1 is
// exactly what a default of zero-plus-one would produce.
if (roles.TryGetValue(member.RoleId, out role))
{
row["rank"] = role.Rank;
row["role"] = role.Name;
}
string name = PlayerName(member.SteamId);
if (!string.IsNullOrEmpty(name))
row["name"] = name;
members.Add(row);
}
}
UnityEngine.Color32 color = clan.Color;
return new Dictionary
{
{ "clanId", clan.ClanId },
{ "createdMs", clan.Created },
{ "name", clan.Name },
{ "color", $"#{color.r:x2}{color.g:x2}{color.b:x2}" },
{ "score", clan.Score },
{ "maxMembers", clan.MaxMemberCount },
{ "members", members }
};
}
///
/// Main thread. A player's name, whether or not they are on, or null. The framework
/// remembers everybody who has ever joined, which is what lets an offline clan member
/// have a name on a roster.
///
private string PlayerName(ulong steamId)
{
BasePlayer player = BasePlayer.FindByID(steamId) ?? BasePlayer.FindSleeping(steamId);
if (player != null)
return player.displayName;
Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId.ToString());
return known != null ? known.Name : null;
}
///
/// Main thread. The envelope every clan event shares: which clan, identified the way
/// the website keys it (D52), and its name so a feed line can say something without a
/// lookup.
///
private Dictionary ClanFrame(string kind, IClan clan)
{
Dictionary frame = Frame(kind, "event");
frame["clanId"] = clan.ClanId;
frame["createdMs"] = clan.Created;
frame["clanName"] = clan.Name;
return frame;
}
///
/// Main thread. Puts a player onto a clan event as steamId and name — the
/// same two fields every player event carries, so the website's player table learns the
/// name the same way it does from a connect.
///
private void PutPlayer(Dictionary frame, string idKey, string nameKey, ulong steamId)
{
if (steamId == 0UL)
return;
frame[idKey] = steamId.ToString();
string name = PlayerName(steamId);
if (!string.IsNullOrEmpty(name))
frame[nameKey] = name;
}
///
/// Main thread. Re-sends the clan board shortly, once, however many changes arrive in
/// the meantime. The cadence would get there within a minute anyway; this is what makes
/// a roster follow the event that changed it.
///
private void ScheduleClansBoard()
{
if (_clansBoardSoon != null)
return;
_clansBoardSoon = timer.Once(ClanBoardSettleSeconds, () =>
{
_clansBoardSoon = null;
if (!_connected)
return;
try
{
SendClansBoard();
}
catch (Exception ex)
{
Puts($"could not build the clans board: {Flatten(ex.Message)}");
}
});
}
///
/// The founder's own membership raises no OnClanMemberAdded — the game adds them
/// inside the creation — so this event carries them, and the website counts them in.
///
private void OnClanCreated(LocalClan clan, ulong steamId)
{
MarkHook("OnClanCreated");
if (clan == null)
return;
Dictionary frame = ClanFrame("clan.created", clan);
PutPlayer(frame, "steamId", "name", steamId);
EmitFrame(frame);
ScheduleClansBoard();
}
///
/// Typed exactly, and that matters here more than anywhere: the uMod Clans plugin raises
/// a hook with this SAME NAME and arity — OnClanDisbanded(string, List<ulong>)
/// — about its own, unrelated clans. Both frameworks match a call to a method by its
/// argument types, so a loosely typed signature here would file the plugin's clans as
/// the game's.
///
private void OnClanDisbanded(LocalClan clan, ulong steamId)
{
MarkHook("OnClanDisbanded");
if (clan == null)
return;
Dictionary frame = ClanFrame("clan.disbanded", clan);
PutPlayer(frame, "steamId", "name", steamId);
// Protocol 7: who was in it. The website tells a disbanded clan's members, and by the
// time it reads this frame the next board may already have removed the roster it would
// otherwise ask — the board is re-sent seconds after this, and after an outage it is
// applied before the backlog. The game has deleted the clan from its database by now,
// and walked `Members` to drop each one's membership, but it never empties the list
// itself — so it is still whole here, bounded by the clan's own member limit.
if (clan.Members != null)
{
List members = new List();
foreach (ClanMember member in clan.Members)
members.Add(member.SteamId.ToString());
frame["members"] = members;
}
EmitFrame(frame);
ScheduleClansBoard();
}
///
/// The one clan hook that hands over a bare id rather than a clan: the game raises it
/// from inside its database layer, before the cached clan has been refreshed. The id is
/// enough to find the clan and its creation time; if even that fails, the frame goes
/// without createdMs and the website matches it on the id alone.
///
private void OnClanMemberAdded(long clanId, ulong steamId)
{
MarkHook("OnClanMemberAdded");
IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null;
IClan clan = backend != null ? ReadClan(backend, clanId) : null;
Dictionary frame;
if (clan != null)
{
frame = ClanFrame("clan.member.added", clan);
}
else
{
frame = Frame("clan.member.added", "event");
frame["clanId"] = clanId;
}
PutPlayer(frame, "steamId", "name", steamId);
EmitFrame(frame);
ScheduleClansBoard();
}
private void OnClanMemberLeft(LocalClan clan, ulong steamId)
{
MarkHook("OnClanMemberLeft");
if (clan == null)
return;
Dictionary frame = ClanFrame("clan.member.left", clan);
PutPlayer(frame, "steamId", "name", steamId);
EmitFrame(frame);
ScheduleClansBoard();
}
private void OnClanMemberKicked(LocalClan clan, ulong steamId, ulong bySteamId)
{
MarkHook("OnClanMemberKicked");
if (clan == null)
return;
Dictionary frame = ClanFrame("clan.member.kicked", clan);
PutPlayer(frame, "steamId", "name", steamId);
PutPlayer(frame, "bySteamId", "byName", bySteamId);
EmitFrame(frame);
ScheduleClansBoard();
}
///
/// Not an event: a colour is a property of the clan, so it travels on the board. The
/// hook only brings the next board forward.
///
private void OnClanColorChanged(LocalClan clan, UnityEngine.Color32 color, ulong steamId)
{
MarkHook("OnClanColorChanged");
ScheduleClansBoard();
}
// ---- 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;
// Protocol 13, the chat-title conditions (PLAN_REDESIGNS §5.1). Sums unless named a best.
public int AnimalKills;
public readonly Dictionary WeaponKills = new Dictionary();
public int Headshots;
/// The longest player kill this interval, in metres: a best, not a sum.
public float BestPvpM;
/// The longest NPC or animal kill this interval: a best, not a sum.
public float BestPveM;
// APC and helicopter kills left the tally at protocol 14: they are counted by the site
// from `world.event`'s credit block, by the server's own rule (D372).
/// Protocol 14 (D356): locked crates this player started hacking, once per crate (Safecracker).
public int CratesHacked;
/// Protocol 14 (D356): airdrops this player looted first (Scavenger).
public int DropsLooted;
/// Protocol 14 (D375): Dangerous Treasures chests this player emptied (Treasure Hunter).
public int TreasuresWon;
/// Protocol 14 (D375): guarded crates this player cleared, by killing the last guard (Warden).
public int GuardedWon;
public readonly Dictionary Crafted = new Dictionary();
/// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in .
public readonly Dictionary NpcProfileKills = new Dictionary();
public int Built;
public int Repaired;
public int Healed;
public int Rockets;
public int Explosives;
public int Missions;
///
/// Protocol 14 (§11.2.2): the player's Economics balance, a value and not a change,
/// like the bests. Sent only when : it moved, or was read at
/// connect, since the last flush.
///
public double Balance;
public bool HasBalance;
public void Gather(string shortname, int amount)
{
Add(Gathered, shortname, amount);
}
public void Craft(string category, int amount)
{
Add(Crafted, category, amount);
}
public void KilledProfile(string profile)
{
Add(NpcProfileKills, profile, 1);
}
///
/// What a credited kill adds for the titles: its weapon (any victim, D174), a
/// headshot, and its distance as this interval's best of its kind.
///
public void Killed(HitInfo info, BaseEntity victim, bool victimIsPlayer)
{
Add(WeaponKills, WeaponOf(info), 1);
if (info != null && info.isHeadshot)
Headshots++;
float distance = DistanceOf(info, victim);
if (victimIsPlayer)
BestPvpM = Math.Max(BestPvpM, distance);
else
BestPveM = Math.Max(BestPveM, distance);
}
private static void Add(Dictionary into, string key, int amount)
{
if (string.IsNullOrEmpty(key) || amount <= 0)
return;
int have;
into.TryGetValue(key, out have);
into[key] = have + amount;
}
public bool IsEmpty()
{
return Gathered.Count == 0 && NpcKills == 0 && Structures == 0
&& AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0
&& BestPvpM <= 0f && BestPveM <= 0f && CratesHacked == 0 && DropsLooted == 0 && TreasuresWon == 0 && GuardedWon == 0
&& Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0
&& Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0
&& !HasBalance;
}
}
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;
}
///
/// The tally of a player who need not be online — an Economics balance reaches an offline
/// player too. It carries no name, and the frame it flushes has none.
///
private Tally TallyForId(ulong userId)
{
Tally tally;
if (!_tallies.TryGetValue(userId, out tally))
{
tally = new Tally();
_tallies[userId] = tally;
}
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;
// The title conditions (§5.1), each absent when nothing moved, as the three above.
if (tally.AnimalKills > 0)
frame["animalKills"] = tally.AnimalKills;
if (tally.WeaponKills.Count > 0)
frame["weaponKills"] = tally.WeaponKills;
if (tally.Headshots > 0)
frame["headshots"] = tally.Headshots;
if (tally.BestPvpM > 0f)
frame["bestPvpM"] = (float)Math.Round(tally.BestPvpM, 1);
if (tally.BestPveM > 0f)
frame["bestPveM"] = (float)Math.Round(tally.BestPveM, 1);
if (tally.CratesHacked > 0)
frame["cratesHacked"] = tally.CratesHacked;
if (tally.DropsLooted > 0)
frame["dropsLooted"] = tally.DropsLooted;
if (tally.TreasuresWon > 0)
frame["treasuresWon"] = tally.TreasuresWon;
if (tally.GuardedWon > 0)
frame["guardedWon"] = tally.GuardedWon;
if (tally.Crafted.Count > 0)
frame["crafted"] = tally.Crafted;
if (tally.Built > 0)
frame["built"] = tally.Built;
if (tally.Repaired > 0)
frame["repaired"] = tally.Repaired;
if (tally.Healed > 0)
frame["healed"] = tally.Healed;
if (tally.Rockets > 0)
frame["rockets"] = tally.Rockets;
if (tally.Explosives > 0)
frame["explosives"] = tally.Explosives;
if (tally.Missions > 0)
frame["missions"] = tally.Missions;
if (tally.NpcProfileKills.Count > 0)
frame["npcProfileKills"] = tally.NpcProfileKills;
// Protocol 14: the current balance, a value the site stores rather than sums.
if (tally.HasBalance)
frame["balance"] = Math.Round(tally.Balance, 2);
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 ToUnixMs(DateTime.UtcNow);
}
// ---- account linking ----
///
/// The alphabet a link code is drawn from.
///
///
/// No O, 0, I or 1. A player reads this code off their screen and types it into
/// a browser, often on a phone, sometimes reading it aloud to somebody else. Those four
/// glyphs are the ones that get transcribed wrong, and a code that fails for a reason
/// the player cannot see is worse than a longer one.
///
///
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private const int CodeLength = 6;
///
/// How long a code is good for. Short on purpose: R1 made this an authorization path,
/// not a convenience — the site becomes the author of who may do what in game, so a
/// code that lingers is a privilege waiting to be picked up.
///
private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5);
/// How long a player must wait between /link requests.
private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30);
///
/// An unconfirmed code, and the Steam identity it will bind.
///
///
/// These live in memory and nowhere else , which is the same call the UO bridge
/// made and it is the right one for the same reason: the secret exists only on the
/// server that minted it, for five minutes, and nothing writes it to disk. A plugin
/// reload drops every pending code — and phase 7b's config editor reloads plugins
/// routinely — but the cost of that is a player typing /link again, which is
/// cheaper than a credential in a second process.
///
///
private sealed class PendingLink
{
public string SteamId;
public string Name;
public DateTime Expires;
}
/// code -> pending link. Main thread only, like every world read here.
private readonly Dictionary _codes =
new Dictionary(StringComparer.OrdinalIgnoreCase);
/// steam id -> when they last asked, so /link cannot be spammed.
private readonly Dictionary _lastLinkRequest =
new Dictionary(StringComparer.Ordinal);
private readonly System.Random _codeRandom = new System.Random();
///
/// /link — mint a one-time code for the caller and tell the sidecar about it.
///
///
/// Chat, not console, and the reply is private. Both frameworks consume a
/// / command rather than broadcasting it, and SendReply addresses one
/// player — so neither the request nor the code reaches anybody else's chat. That is
/// load-bearing rather than polish: a code read off a stream is a code somebody else
/// can spend.
///
///
///
/// The plugin does not record who is linked. The website is the author of
/// record for that (R2), and a second copy here would be a second thing to keep in
/// step across a wipe. So there is no "you are already linked" check on this path —
/// the site refuses a Steam id it already holds, where it can say which account holds
/// it.
///
///
[ChatCommand("link")]
private void CmdLinkChat(BasePlayer player, string command, string[] args)
{
if (player == null)
return;
string steamId = SteamIdOf(player);
if (string.IsNullOrEmpty(steamId))
return;
DateTime last;
if (_lastLinkRequest.TryGetValue(steamId, out last) && DateTime.UtcNow - last < RequestCooldown)
{
SendReply(player, "Please wait a moment before asking for another link code.");
return;
}
// One outstanding code per player: drop any earlier one so only the newest works.
// Without this a player who ran /link three times leaves three live codes behind.
DropCodesFor(steamId);
string code = MintCode();
_codes[code] = new PendingLink
{
SteamId = steamId,
Name = player.displayName,
Expires = DateTime.UtcNow + CodeTtl
};
_lastLinkRequest[steamId] = DateTime.UtcNow;
Dictionary frame = Frame("account.link.requested", "event");
frame["steamId"] = steamId;
frame["name"] = player.displayName;
frame["ttlSec"] = (long)CodeTtl.TotalSeconds;
// **The code is NOT in this frame, and that is the point.** The event exists so an
// operator can see that linking is being used and so the site can rate-limit a player
// who is fishing; it is not how the code travels. The code travels through the player,
// which is what makes typing it proof that they are the one who asked.
EmitFrame(frame);
SendReply(player, "Your link code is " + code + " .");
SendReply(
player,
"Enter it on the website within " + (int)CodeTtl.TotalMinutes +
" minutes to link this Steam account. It works once.");
}
///
/// /unlink — ask the website to sever this Steam id's link.
///
///
/// It emits rather than deletes , because the plugin holds no link to delete. The
/// site owns the record and applies this like any other event off the feed.
///
///
///
/// It exists because the site refuses to move a Steam id that another account
/// already holds. Without a way out, a player who linked the wrong account while logged
/// in as it would need staff; with this they walk to a computer they are already sitting
/// at. The authority is the Steam id itself — whoever is connected as it is who it is.
///
///
[ChatCommand("unlink")]
private void CmdUnlinkChat(BasePlayer player, string command, string[] args)
{
if (player == null)
return;
string steamId = SteamIdOf(player);
if (string.IsNullOrEmpty(steamId))
return;
DropCodesFor(steamId);
Dictionary frame = Frame("account.unlinked", "event");
frame["steamId"] = steamId;
frame["name"] = player.displayName;
frame["origin"] = "in-game";
EmitFrame(frame);
SendReply(
player,
"Asked the website to unlink this Steam account. If it was linked, it is not now.");
}
///
/// Inbound link.confirm — the website has been given a code and wants to know
/// whose it is.
///
///
/// This is the first command the website originates in this bridge; everything
/// before protocol 3 was the game talking. The reply carries the Steam id and nothing
/// the caller did not already prove — there is no session here, no account creation,
/// and no write to the game.
///
///
///
/// A code is consumed on the first lookup whether or not it turns out to be
/// expired, so a code cannot be probed twice. That is why the removal happens before
/// the expiry check rather than after it.
///
///
private void HandleLinkConfirm(Dictionary cmd, string reqId)
{
object codeObj;
string code = cmd.TryGetValue("code", out codeObj) && codeObj != null
? codeObj.ToString().Trim()
: null;
if (string.IsNullOrEmpty(code))
{
EmitLinkError(reqId, "malformed");
return;
}
PendingLink pending;
if (!_codes.TryGetValue(code, out pending))
{
EmitLinkError(reqId, "unknown");
return;
}
_codes.Remove(code);
if (DateTime.UtcNow > pending.Expires)
{
EmitLinkError(reqId, "expired");
return;
}
Dictionary ok = Frame("link.ok", "reply");
ok["steamId"] = pending.SteamId;
ok["name"] = pending.Name;
if (reqId != null)
ok["reqId"] = reqId;
EmitFrame(ok);
}
///
/// A refusal, with a reason the website can turn into a sentence.
///
///
/// unknown and expired are deliberately separate here and
/// deliberately the same to the player: an operator reading a log wants to know
/// whether codes are being guessed or merely going stale, and a stranger typing codes
/// must not learn which of the two they hit.
///
///
private void EmitLinkError(string reqId, string reason)
{
Dictionary frame = Frame("link.error", "reply");
frame["reason"] = reason;
if (reqId != null)
frame["reqId"] = reqId;
EmitFrame(frame);
}
/// Drops every outstanding code belonging to one Steam id.
private void DropCodesFor(string steamId)
{
List stale = null;
foreach (KeyValuePair entry in _codes)
{
if (entry.Value != null && entry.Value.SteamId == steamId)
{
if (stale == null)
stale = new List();
stale.Add(entry.Key);
}
}
if (stale == null)
return;
foreach (string key in stale)
_codes.Remove(key);
}
///
/// Drops expired codes and stale cooldowns.
///
///
/// Run on a timer rather than only on lookup, because an unconfirmed code is never
/// looked up — a player who runs /link and wanders off leaves an entry that
/// nothing else would ever remove.
///
///
private void PurgeExpiredCodes()
{
DateTime now = DateTime.UtcNow;
List dead = null;
foreach (KeyValuePair entry in _codes)
{
if (entry.Value == null || now > entry.Value.Expires)
{
if (dead == null)
dead = new List();
dead.Add(entry.Key);
}
}
if (dead != null)
{
foreach (string key in dead)
_codes.Remove(key);
}
List cold = null;
foreach (KeyValuePair entry in _lastLinkRequest)
{
if (now - entry.Value > RequestCooldown)
{
if (cold == null)
cold = new List();
cold.Add(entry.Key);
}
}
if (cold == null)
return;
foreach (string key in cold)
_lastLinkRequest.Remove(key);
}
///
/// A code from , retried until it does not collide.
///
///
/// System.Random rather than a cryptographic source, and the reason it is
/// defensible is the shape of the guess rather than the shape of the generator: a code
/// lives five minutes, is consumed on first lookup, and the website rate-limits the
/// confirm endpoint. An attacker who could predict the sequence would still have to
/// reach a code before the player does.
///
///
private string MintCode()
{
for (int attempt = 0; attempt < 16; attempt++)
{
var chars = new char[CodeLength];
for (int i = 0; i < CodeLength; i++)
chars[i] = CodeAlphabet[_codeRandom.Next(CodeAlphabet.Length)];
string candidate = new string(chars);
if (!_codes.ContainsKey(candidate))
return candidate;
}
// Sixteen collisions in a row against a table this small is not bit-space exhaustion,
// it is something being wrong. A code that cannot collide is better than a loop that
// might not end.
return Guid.NewGuid().ToString("N").Substring(0, CodeLength).ToUpperInvariant();
}
// ---- site-owned permissions (protocol 4) ----
//
// R2: the website is the author of record for groups and grants, and this framework's own
// permission store is an ENFORCEMENT CACHE. Every third-party plugin honours a site grant
// with no adapter, because they all already call `UserHasPermission`.
//
// ── One verb, and the plugin does the diffing (D32) ───────────────────────
//
// The site sends the whole set it authors for this server; this plugin compares it against
// the live store and writes only what differs. It is the same shape as a board, pointed the
// other way: one producer, re-sent on connect, reconciled by the receiver.
//
// The alternative — shipping this server's permission store to the website and diffing
// there — was rejected for two reasons. The store is the bigger of the two sets and would
// cross the wire constantly, and a website holding a copy of it has a second source of
// truth that is stale the moment it lands.
//
// ── The site must not be able to stall the game, either ───────────────────
//
// This is the first command whose work is UNBOUNDED: a community with two thousand linked
// players has thousands of grants in one frame, and applying them in the tick the frame
// arrives is a freeze the operator will blame on the game. So a sync is compiled into a
// list of single-store operations and drained a few hundred at a time on a timer, and the
// report goes back when the last one lands.
//
// That is the same rule the outbound queue lives under (§5), applied to the inbound half:
// **nothing the far side sends may cost the main thread an unbounded amount of work.**
//
// ── What it reports, and what it will not do ──────────────────────────────
//
// • A permission this server has not REGISTERED is reported `unresolved` and skipped.
// `GrantUserPermission` silently no-ops for an unregistered name — void, no throw, no
// log (PLAN.md §12.2 rule 1) — so without the pre-check a re-push into a server missing
// one plugin is a silent partial. Registering the name here instead would fabricate a
// permission the operator never installed (D33).
// • A holder the site does not author is never touched by a sync. Until protocol 13 the
// sync scanned the names the site claimed and reported such holders `foreign` (D31).
// Now the site owns the whole store (D160) and reads it with `perm.inventory`, so it
// finds a hand edit itself and answers it with the server's policy (D161): the sync
// only ever carries what the site decided.
// • A group member who has never connected is reported `pending`. The store has no user
// record to put in a group yet (§12.2 rule 4), and the membership lands on their first
// connection — a direct grant would have reached them immediately, and pretending the
// two are the same thing is how a site promises access it did not give.
///
/// How many store operations one timer step performs.
///
///
/// Each is a dictionary lookup and, at most, one write to an in-memory structure the
/// framework flushes on its own schedule — microseconds. Two hundred of them is well
/// under a frame at 60fps, and it drains ten thousand operations in about two and a
/// half seconds, which is inside the sidecar's reply timeout.
///
///
private const int PermOpsPerStep = 200;
/// How often the sync drain runs while a job is in flight.
private const float PermStepSeconds = 0.05f;
///
/// The ceiling on one sync, in operations.
///
///
/// A frame larger than this is refused whole rather than applied for a while and then
/// abandoned. The website has its own limit below this one, so reaching it means
/// something is wrong rather than something is big — and a half-applied permission set
/// is the state nobody can reason about.
///
///
private const int PermMaxOps = 20000;
/// The operations a compiled sync is made of. One store question each.
private enum PermOpKind
{
EnsureGroup,
EnsureGroupPermission,
EnsureMember,
EnsureGrant,
RetireGroupPermission,
RetireMember,
RetireGrant,
RetireGroup,
///
/// Protocol 13: a group's parent (PLAN_REDESIGNS §1.4). Compiled after every group,
/// so the parent it names already exists when it is applied.
///
EnsureGroupParent,
/// Protocol 12: a group's BetterChat style (D138).
EnsureChatStyle,
/// Protocol 12: a styled group's BetterChat group, gone from the site (D139).
RetireChatGroup
}
private struct PermOp
{
public PermOpKind Kind;
/// A Steam id, or a group name — whichever the op is about.
public string Subject;
/// A permission name, a group name, or null.
public string Object;
/// Only uses these.
public string Title;
public int Rank;
///
/// Whether the site sent a rank. A group whose frame has none keeps the rank it has,
/// rather than being set to 0 by an absent field.
///
public bool HasRank;
}
///
/// One sync in flight. There is at most one: a second arriving while this one drains is
/// answered `busy` rather than interleaved, because two writers reconciling the same
/// store against two different desired sets is a race with no correct outcome.
///
private sealed class PermSyncJob
{
public string ReqId;
public string SetId;
public List Ops = new List();
public int Next;
public int Grants;
public int Revokes;
public int GroupsCreated;
/// Protocol 13: an existing group whose title, rank or parent the sync changed.
public int GroupsUpdated;
public int GroupsRemoved;
public int GroupPermissions;
public int GroupPermissionsRemoved;
public int Members;
public int MembersRemoved;
public int AlreadyCorrect;
public int Absent;
public readonly List Unresolved = new List();
public readonly List Pending = new List();
///
/// Grants the store did not hold after being told to (subject:permission ).
/// Reported rather than counted, so the site can never again be told a grant
/// landed that did not (D85).
///
public readonly List NotLanded = new List();
///
/// Group permissions an event lease holds (protocol 8). Not applied; what the site
/// asked for is kept on the hold instead.
///
public readonly List Leased = new List();
public readonly HashSet LeasedSeen = new HashSet();
/// Permission names already reported unresolved, so each is reported once.
public readonly HashSet UnresolvedSeen = new HashSet();
///
/// The site's kit credits for this server (protocol 10, D103), or null when the sync
/// carried none — an older website, whose silence must not withdraw anybody's credit.
///
public List Credits;
public int CreditsApplied;
public int CreditsWithdrawn;
public bool CreditsWaiting;
public readonly List CreditsUnresolved = new List();
///
/// The site's untold reward messages for this server (protocol 14, D333), or null when
/// the sync carried none — an older website, whose silence must not cancel anything.
///
public List Notices;
/// The keys of already told, for the report.
public readonly List NoticesTold = new List();
///
/// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it
/// was not, no style op is compiled and the report says loaded: false and
/// nothing else: the permissions sync exactly as they did before.
///
public bool ChatLoaded;
public string ChatVersion;
/// The fields the site wants on each styled group.
public readonly Dictionary> ChatStyles =
new Dictionary>();
public int ChatApplied;
public int ChatSaved;
public int ChatSame;
public readonly List ChatDrift = new List();
public readonly List ChatRemoved = new List();
public readonly List ChatFailed = new List();
}
///
/// One BetterChat field the site wants on a group (protocol 12): the value, and the
/// value this site last pushed there.
///
private sealed class ChatFieldWant
{
public string Field;
public string Value;
///
/// What the site last pushed, or null for a field it has never pushed. A game value
/// that is neither this nor the one wanted is somebody's hand edit (D138).
///
public string Expect;
}
private PermSyncJob _permJob;
private Timer _permTimer;
///
/// True while this plugin is writing to the permission store.
///
///
/// The framework raises OnUserPermissionGranted and friends for every write,
/// including ours — so without this flag every sync would announce itself as drift and
/// the website would chase its own tail. It is set around each drain step rather than
/// for the whole job, so a write by somebody else BETWEEN steps is still seen.
///
///
private bool _applyingPermissions;
/// The last report, for rg.perms . Diagnostics only.
private string _lastPermSummary = "none yet";
///
/// Main thread. Everything this server knows about permissions and groups — the option
/// source the website's authoring form is built from (D33).
///
///
/// It is a live read rather than anything cached, because the answer changes when an
/// operator loads a plugin and the whole point of asking is to offer names that will
/// actually resolve.
///
///
private void HandlePermCatalogue(string reqId)
{
Dictionary reply = Frame("perm.catalogue", "reply");
var permissions = new List();
string[] registered = permission.GetPermissions();
if (registered != null)
{
foreach (string name in registered)
{
if (!string.IsNullOrEmpty(name))
permissions.Add(name);
}
}
var groups = new List();
string[] groupNames = permission.GetGroups();
if (groupNames != null)
{
foreach (string name in groupNames)
{
if (string.IsNullOrEmpty(name))
continue;
var row = new Dictionary
{
{ "name", name },
{ "title", permission.GetGroupTitle(name) },
{ "rank", permission.GetGroupRank(name) }
};
var granted = new List();
string[] groupPerms = permission.GetGroupPermissions(name, false);
if (groupPerms != null)
{
foreach (string perm in groupPerms)
{
if (!string.IsNullOrEmpty(perm))
granted.Add(perm);
}
}
row["permissions"] = granted;
groups.Add(row);
}
}
reply["permissions"] = permissions;
reply["groups"] = groups;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
// ---- protocol 13: the whole store (PLAN_REDESIGNS §1.2, D160) ----
//
// The site owns every permission and group on this server, so it has to be able to read
// all of them — not just the names it once authored, which is all the foreign scan ever
// looked at. One verb, `perm.inventory`, answers with:
//
// • every registered permission and the plugin that REGISTERED it. The owner comes from
// `PermissionExists(name, plugin)` asked of each loaded plugin — public API on both
// frameworks, proven on both rigs — never from the name's prefix, which is how
// `zonemanager.ignoreflag.nokits` ends up filed under Kits in other tools.
// • every group with its title, rank, parent and the permissions it carries itself.
// • every Steam id that holds anything: its own permissions and its groups, except
// `default`, which every connected player is in by the framework's own rule.
// • the group permissions an event lease is holding, which are the lease's, not a hand
// edit's.
//
// Neither framework can list its users, so the holders come from asking each permission
// and each group who has it, and each of those walks every user. On a real store that is
// permissions × users, so it is built a slice per tick, like the sync drain, and then
// sent in pages that each fit the game link's 1 MiB line cap. The snapshot is taken once
// and paged from memory, so a grant made between two pages cannot tear it.
/// How many permissions or groups one build step asks about.
private const int InventorySourcesPerStep = 25;
/// How many bytes of rows one page may carry, well inside the 1 MiB line cap.
private const int InventoryPageBudgetBytes = 700 * 1024;
///
/// How long a build may run before it is abandoned. The sidecar waits ten seconds for a
/// reply; a build past this has already lost that wait, and a store that large needs a
/// person to hear about it rather than a hitch every sync.
///
private const long InventoryMaxBuildMs = 8000;
/// How long a finished snapshot is kept for the pages after the first.
private const long InventoryKeepMs = 2 * 60 * 1000;
private sealed class InventoryUser
{
public string SteamId;
public string Name;
public readonly List Permissions = new List();
public readonly List Groups = new List();
}
/// A build in flight. There is at most one; a second request is answered `busy`.
private sealed class InventoryJob
{
public string ReqId;
public long StartedMs;
/// Started from the server console (rg.inventory ): logged, never sent.
public bool FromConsole;
/// Permissions first, then groups prefixed with a NUL, one store question each.
public readonly List Sources = new List();
public int Next;
public readonly Dictionary Users = new Dictionary();
public List Permissions;
public List Groups;
public List Leased;
}
/// A finished snapshot, paged from memory.
private sealed class Inventory
{
public string Id;
public long BuiltMs;
public long BuildMs;
public List Permissions;
public List Groups;
public List Leased;
public int UserCount;
public readonly List> Pages = new List>();
}
private InventoryJob _inventoryJob;
private Timer _inventoryTimer;
private Inventory _inventory;
/// The last inventory built, for rg.perms . Diagnostics only.
private string _lastInventorySummary = "none yet";
///
/// The same build from the server console, logged rather than sent: how a rig proves the
/// read — and times it on a large store — without the website or the sidecar's token.
///
[ConsoleCommand("rg.inventory")]
private void CmdInventory(ConsoleSystem.Arg arg)
{
if (arg.Connection != null)
return;
if (_inventoryJob != null)
{
arg.ReplyWith("an inventory is already being built");
return;
}
StartInventory(null, true);
arg.ReplyWith("inventory started; the summary is logged");
}
/// Main thread. Page 0 starts a build; any other page is read from the snapshot it names.
private void HandlePermInventory(Dictionary cmd, string reqId)
{
string snapshotId = StringField(cmd, "snapshotId");
int page = IntField(cmd, "page");
if (!string.IsNullOrEmpty(snapshotId))
{
Inventory held = _inventory;
if (held == null || held.Id != snapshotId || NowMs() - held.BuiltMs > InventoryKeepMs ||
page < 0 || page >= held.Pages.Count)
{
// The site starts again from page 0; a stale page is never a partial answer.
EmitPermError(reqId, "stale");
return;
}
EmitInventoryPage(held, page, reqId);
return;
}
if (_inventoryJob != null)
{
EmitPermError(reqId, "busy");
return;
}
StartInventory(reqId, false);
}
/// Main thread. Reads the small parts at once and starts the sliced holder walk.
private void StartInventory(string reqId, bool fromConsole)
{
var job = new InventoryJob { ReqId = reqId, StartedMs = NowMs(), FromConsole = fromConsole };
// Owners, groups and leases are small and read at once; the holder walk is sliced.
var owner = new Dictionary(StringComparer.OrdinalIgnoreCase);
string[] registered = permission.GetPermissions() ?? new string[0];
foreach (Plugin plugin in plugins.GetAll())
{
if (plugin == null)
continue;
foreach (string perm in registered)
{
if (!string.IsNullOrEmpty(perm) && !owner.ContainsKey(perm) && permission.PermissionExists(perm, plugin))
owner[perm] = plugin.Name;
}
}
job.Permissions = new List();
foreach (string perm in registered)
{
if (string.IsNullOrEmpty(perm))
continue;
string lowered = Lower(perm);
var row = new Dictionary { { "name", lowered } };
string by;
// A name no plugin owns — Carbon's built-in modules register theirs that way — is
// sent without one, and the site groups it by prefix.
if (owner.TryGetValue(perm, out by))
row["owner"] = by;
job.Permissions.Add(row);
job.Sources.Add(lowered);
}
job.Groups = new List();
foreach (string name in permission.GetGroups() ?? new string[0])
{
if (string.IsNullOrEmpty(name))
continue;
string lowered = Lower(name);
var carried = new List();
foreach (string perm in permission.GetGroupPermissions(name, false) ?? new string[0])
{
if (!string.IsNullOrEmpty(perm))
carried.Add(Lower(perm));
}
job.Groups.Add(new Dictionary
{
{ "name", lowered },
{ "title", permission.GetGroupTitle(name) ?? "" },
{ "rank", permission.GetGroupRank(name) },
{ "parent", Lower(permission.GetGroupParent(name)) ?? "" },
{ "permissions", carried }
});
// `default` holds every player who ever connected, by the framework's rule rather
// than anybody's decision, so its members are not the site's to own.
if (!string.Equals(lowered, "default", StringComparison.Ordinal))
job.Sources.Add("\0" + lowered);
}
job.Leased = new List();
foreach (LeaseHold hold in _leases.Values)
{
if (hold.Key != GroupPermissionKey || string.IsNullOrEmpty(hold.Target))
continue;
int slash = hold.Target.IndexOf('/');
if (slash <= 0)
continue;
job.Leased.Add(new Dictionary
{
{ "kind", "group-permission" },
{ "subject", Lower(hold.Target.Substring(0, slash)) },
{ "object", Lower(hold.Target.Substring(slash + 1)) }
});
}
_inventoryJob = job;
_inventoryTimer = timer.Every(PermStepSeconds, StepInventory);
// A small store — every rig, and most real servers — finishes inside this call.
StepInventory();
}
/// Main thread. Asks up to permissions or groups who holds them.
private void StepInventory()
{
InventoryJob job = _inventoryJob;
if (job == null)
{
StopInventoryTimer();
return;
}
if (NowMs() - job.StartedMs > InventoryMaxBuildMs)
{
_inventoryJob = null;
StopInventoryTimer();
Puts($"permission inventory abandoned after {InventoryMaxBuildMs} ms at {job.Next} of {job.Sources.Count} sources, {job.Users.Count} users");
if (!job.FromConsole)
EmitPermError(job.ReqId, "too-large");
return;
}
int budget = InventorySourcesPerStep;
while (budget-- > 0 && job.Next < job.Sources.Count)
{
string source = job.Sources[job.Next++];
try
{
bool isGroup = source.Length > 0 && source[0] == '\0';
string name = isGroup ? source.Substring(1) : source;
string[] entries = isGroup ? permission.GetUsersInGroup(name) : permission.GetPermissionUsers(name);
if (entries == null)
continue;
foreach (string entry in entries)
{
string id = UserId(entry);
if (string.IsNullOrEmpty(id))
continue;
InventoryUser user;
if (!job.Users.TryGetValue(id, out user))
{
user = new InventoryUser { SteamId = id, Name = StoredName(entry) };
job.Users[id] = user;
}
if (isGroup)
user.Groups.Add(name);
else
user.Permissions.Add(name);
}
}
catch (Exception ex)
{
// One odd name must not lose the whole read; the next sync reads it again.
Puts($"permission inventory: '{source.TrimStart('\0')}' threw: {Flatten(ex.Message)}");
}
}
if (job.Next < job.Sources.Count)
return;
_inventoryJob = null;
StopInventoryTimer();
Inventory done = Paginate(job);
_inventory = done;
_lastInventorySummary = $"{done.Id}: {done.Permissions.Count} permissions, {done.Groups.Count} groups, " +
$"{done.UserCount} users, {done.Pages.Count} pages, {done.BuildMs} ms at={DateTime.UtcNow:HH:mm:ss}Z";
if (job.FromConsole)
{
// To the log, where a rig walk can read it: the console's own reply never reaches
// Oxide's log file.
Puts($"inventory {_lastInventorySummary}");
var owners = new Dictionary();
foreach (Dictionary row in done.Permissions)
{
object by;
string key = row.TryGetValue("owner", out by) ? (string)by : "(none)";
int n;
owners.TryGetValue(key, out n);
owners[key] = n + 1;
}
var ownerText = new List();
foreach (KeyValuePair kv in owners)
ownerText.Add(kv.Key + "=" + kv.Value);
List sample = done.Pages[0].GetRange(0, Math.Min(10, done.Pages[0].Count));
Puts("inventory owners: " + string.Join(", ", ownerText.ToArray()));
Puts("inventory groups: " + JsonConvert.SerializeObject(done.Groups, Formatting.None));
Puts("inventory leased: " + JsonConvert.SerializeObject(done.Leased, Formatting.None));
Puts("inventory users: " + JsonConvert.SerializeObject(sample, Formatting.None));
return;
}
EmitInventoryPage(done, 0, job.ReqId);
}
private void StopInventoryTimer()
{
if (_inventoryTimer == null)
return;
_inventoryTimer.Destroy();
_inventoryTimer = null;
}
///
/// Splits the users into pages by their serialised size. Page 0 also carries the
/// permissions, groups and leases, so its budget is what they leave.
///
private Inventory Paginate(InventoryJob job)
{
var inv = new Inventory
{
Id = Guid.NewGuid().ToString("N").Substring(0, 12),
BuiltMs = NowMs(),
BuildMs = NowMs() - job.StartedMs,
Permissions = job.Permissions,
Groups = job.Groups,
Leased = job.Leased,
UserCount = job.Users.Count
};
int header = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(new Dictionary
{
{ "permissions", job.Permissions },
{ "groups", job.Groups },
{ "leased", job.Leased }
}, Formatting.None));
var ids = new List(job.Users.Keys);
ids.Sort(StringComparer.Ordinal);
var current = new List();
int bytes = header;
foreach (string id in ids)
{
InventoryUser u = job.Users[id];
var row = new Dictionary
{
{ "steamId", u.SteamId },
{ "permissions", u.Permissions },
{ "groups", u.Groups }
};
if (!string.IsNullOrEmpty(u.Name))
row["name"] = u.Name;
int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None)) + 1;
if (current.Count > 0 && bytes + size > InventoryPageBudgetBytes)
{
inv.Pages.Add(current);
current = new List();
bytes = 0;
}
current.Add(row);
bytes += size;
}
// Always at least one page, so an empty store still answers page 0.
inv.Pages.Add(current);
return inv;
}
private void EmitInventoryPage(Inventory inv, int page, string reqId)
{
Dictionary reply = Frame("perm.inventory", "reply");
reply["snapshotId"] = inv.Id;
reply["page"] = page;
reply["pages"] = inv.Pages.Count;
reply["more"] = page + 1 < inv.Pages.Count;
reply["users"] = inv.Pages[page];
if (page == 0)
{
reply["permissions"] = inv.Permissions;
reply["groups"] = inv.Groups;
reply["leased"] = inv.Leased;
reply["stats"] = new Dictionary
{
{ "permissions", inv.Permissions.Count },
{ "groups", inv.Groups.Count },
{ "users", inv.UserCount },
{ "buildMs", inv.BuildMs }
};
}
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
///
/// The name out of a store listing entry — 76561198…(Wanderer) or
/// 76561198… (Wanderer) — or null. Between the FIRST bracket and the LAST, so a
/// name with brackets of its own survives.
///
private static string StoredName(string entry)
{
if (string.IsNullOrEmpty(entry))
return null;
int open = entry.IndexOf('(');
int close = entry.LastIndexOf(')');
if (open < 0 || close <= open + 1)
return null;
string name = entry.Substring(open + 1, close - open - 1).Trim();
return name.Length > 0 && name != "Unnamed" ? name : null;
}
///
/// Main thread. Compiles a desired set into operations and starts draining it.
///
///
/// Nothing is written here. Compiling is pure bookkeeping over the frame, so a
/// malformed or enormous sync is refused before it has touched the store — there is no
/// state to unwind because none was made.
///
///
private void HandlePermSync(Dictionary cmd, string reqId)
{
if (_permJob != null)
{
EmitPermError(reqId, "busy");
return;
}
var job = new PermSyncJob
{
ReqId = reqId,
SetId = StringField(cmd, "setId")
};
Plugin betterChat = BetterChatPlugin();
job.ChatLoaded = betterChat != null;
job.ChatVersion = betterChat != null ? betterChat.Version.ToString() : null;
// ── The desired set ────────────────────────────────────────────────
//
// Order matters and it is not cosmetic: a group has to exist before anything can be
// put in it, and a permission has to be on the group before a member inherits it.
// Protocol 13: parents are applied after every group exists, because a group may name
// a parent that comes later in the frame.
var parentOps = new List();
foreach (Dictionary group in Rows(cmd, "groups"))
{
string name = Lower(StringField(group, "name"));
if (string.IsNullOrEmpty(name))
continue;
// Protocol 13: an absent title or rank means "leave it" — the site holds an
// in-game change to them while a person decides (D161's `adopt`) — and an
// empty title is a title, sent verbatim, not a reason to use the name.
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureGroup,
Subject = name,
Title = group.ContainsKey("title") ? (StringField(group, "title") ?? "") : null,
Rank = IntField(group, "rank"),
HasRank = group.ContainsKey("rank")
});
// Only a frame that says something about the parent changes it: "" clears it, and
// an absent key leaves whatever the group has.
if (group.ContainsKey("parent"))
{
parentOps.Add(new PermOp
{
Kind = PermOpKind.EnsureGroupParent,
Subject = name,
Object = Lower(StringField(group, "parent")) ?? ""
});
}
foreach (string perm in Strings(group, "permissions"))
{
string lowered = Lower(perm);
if (string.IsNullOrEmpty(lowered))
continue;
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureGroupPermission,
Subject = name,
Object = lowered
});
}
foreach (string member in Strings(group, "members"))
{
if (string.IsNullOrEmpty(member))
continue;
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureMember,
Subject = member,
Object = name
});
}
// Protocol 12: the group's BetterChat style. After the group's own ops, so the
// Oxide group BetterChat's members are read from exists first. With BetterChat
// absent nothing is compiled, and the report says why (§33.2).
if (job.ChatLoaded)
{
List style = ChatStyle(job, name, group);
if (style.Count > 0)
{
job.ChatStyles[name] = style;
job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureChatStyle, Subject = name });
}
}
}
foreach (Dictionary grant in Rows(cmd, "grants"))
{
string steamId = StringField(grant, "steamId");
if (string.IsNullOrEmpty(steamId))
continue;
foreach (string perm in Strings(grant, "permissions"))
{
string lowered = Lower(perm);
if (string.IsNullOrEmpty(lowered))
continue;
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureGrant,
Subject = steamId,
Object = lowered
});
}
}
// Parents after every group exists and before any retirement, so a group the site is
// about to remove is no longer anybody's parent when it goes.
job.Ops.AddRange(parentOps);
// ── What the site has withdrawn ────────────────────────────────────
//
// The site remembers what it put here, so a grant it has since removed arrives as an
// explicit retirement. It cannot be inferred from the desired set: a name that is not
// in it is either something the site withdrew or something a human granted, and those
// two have opposite correct answers (D31).
foreach (Dictionary row in Rows(cmd, "retire"))
{
string kind = StringField(row, "kind");
string subject = StringField(row, "subject");
string target = Lower(StringField(row, "object"));
if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(subject))
continue;
switch (kind)
{
case "grant":
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGrant, Subject = subject, Object = target });
break;
case "member":
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireMember, Subject = subject, Object = target });
break;
case "group-permission":
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroupPermission, Subject = Lower(subject), Object = target });
break;
case "group":
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroup, Subject = Lower(subject) });
break;
case "chat-group":
// Only with BetterChat here to ask. Otherwise the report says `loaded:
// false`, and the site keeps the retirement for a sync that can act on it.
if (job.ChatLoaded)
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireChatGroup, Subject = Lower(subject) });
break;
}
}
// Protocol 13 dropped the `managed` list and the foreign scan it drove: the site reads
// the whole store with `perm.inventory` and decides for itself (D160, D161).
// ── The kit credits (protocol 10) ──────────────────────────────────
//
// Not store operations, so not drained with them: they are settled once, after the
// grants have landed, and counted in the same report.
if (cmd.ContainsKey("credits"))
{
job.Credits = new List();
foreach (Dictionary row in Rows(cmd, "credits"))
{
string steamId = StringField(row, "steamId");
string kit = StringField(row, "kit");
int count = IntField(row, "count");
if (string.IsNullOrEmpty(steamId) || string.IsNullOrEmpty(kit) || count <= 0)
continue;
job.Credits.Add(new KitCredit { SteamId = steamId, Kit = kit, Earned = count });
}
}
// ── The reward messages (protocol 14, D333) ────────────────────────
//
// "You earned kit vip", said once to each player, now or when they next wake up. The
// site's WHOLE untold set, like the credits: a notice it stops sending is one it has
// been told about, or one whose reward was withdrawn before anybody heard of it.
if (cmd.ContainsKey("notices"))
{
job.Notices = new List();
foreach (Dictionary row in Rows(cmd, "notices"))
{
string key = StringField(row, "key");
string steamId = StringField(row, "steamId");
string message = Flatten(StringField(row, "message") ?? "");
ulong id;
if (string.IsNullOrEmpty(key) || key.Length > NoticeKeyMax || !ulong.TryParse(steamId, out id) || message.Length == 0)
continue;
if (job.Notices.Count >= NoticesMax)
break;
job.Notices.Add(new RewardNotice
{
Key = key,
SteamId = steamId,
Message = message.Length > MaxChat() ? message.Substring(0, MaxChat()) : message,
Delivery = StringField(row, "delivery") == DeliveryPopup ? DeliveryPopup : DeliveryChat
});
}
}
if (job.Ops.Count > PermMaxOps)
{
EmitPermError(reqId, "too-large");
Puts($"permission sync refused: {job.Ops.Count} operations exceeds {PermMaxOps}");
return;
}
_permJob = job;
_permTimer = timer.Every(PermStepSeconds, StepPermSync);
// Run the first slice now rather than waiting out a tick: a small sync — which is every
// ordinary one — then finishes inside the call the website is waiting on.
StepPermSync();
}
///
/// Main thread. Drains up to operations and, on the last
/// one, sends the report.
///
private void StepPermSync()
{
PermSyncJob job = _permJob;
if (job == null)
{
StopPermTimer();
return;
}
_applyingPermissions = true;
try
{
int budget = PermOpsPerStep;
while (budget-- > 0 && job.Next < job.Ops.Count)
{
PermOp op = job.Ops[job.Next++];
try
{
Apply(job, op);
}
catch (Exception ex)
{
// One bad operation must not abandon the set. The report still goes back,
// and the next sync retries what this one could not do.
Puts($"permission op {op.Kind} on '{op.Subject}' threw: {ex.Message}");
}
}
}
finally
{
_applyingPermissions = false;
}
if (job.Next < job.Ops.Count)
return;
_permJob = null;
StopPermTimer();
if (job.Credits != null)
{
try
{
ReconcileCredits(job);
}
catch (Exception ex)
{
// The grants have landed and must still be reported; a credit that could not be
// settled is settled by the next sync, or by the player's next redemption.
Puts($"could not settle kit credits: {Flatten(ex.Message)}");
}
}
if (job.Notices != null)
{
try
{
ApplyNotices(job);
}
catch (Exception ex)
{
// The grants have landed and must still be reported; an untold notice is told
// by the next sync, or when its player wakes.
Puts($"could not tell reward notices: {Flatten(ex.Message)}");
}
}
SendPermReport(job);
}
private void StopPermTimer()
{
if (_permTimer == null)
return;
_permTimer.Destroy();
_permTimer = null;
}
/// Main thread. One store question, and at most one store write.
private void Apply(PermSyncJob job, PermOp op)
{
switch (op.Kind)
{
case PermOpKind.EnsureGroup:
if (!permission.GroupExists(op.Subject))
{
permission.CreateGroup(op.Subject, op.Title ?? op.Subject, op.Rank);
job.GroupsCreated++;
break;
}
// Protocol 13: the site owns an existing group's title and rank too (D160), so
// a difference is written rather than left. Until now a group's title was only
// ever set when the sync created it.
bool updated = false;
if (op.Title != null && !string.Equals(permission.GetGroupTitle(op.Subject) ?? "", op.Title, StringComparison.Ordinal))
{
permission.SetGroupTitle(op.Subject, op.Title);
updated = true;
}
if (op.HasRank && permission.GetGroupRank(op.Subject) != op.Rank)
{
permission.SetGroupRank(op.Subject, op.Rank);
updated = true;
}
if (updated)
job.GroupsUpdated++;
else
job.AlreadyCorrect++;
break;
case PermOpKind.EnsureGroupParent:
string currentParent = Lower(permission.GetGroupParent(op.Subject)) ?? "";
if (string.Equals(currentParent, op.Object, StringComparison.Ordinal))
{
job.AlreadyCorrect++;
break;
}
// Both frameworks refuse a parent that does not exist or that would make a
// loop, and say so only by returning false. The read-back is what counts.
permission.SetGroupParent(op.Subject, string.IsNullOrEmpty(op.Object) ? null : op.Object);
if (string.Equals(Lower(permission.GetGroupParent(op.Subject)) ?? "", op.Object, StringComparison.Ordinal))
job.GroupsUpdated++;
else
job.NotLanded.Add(op.Subject + ":parent:" + op.Object);
break;
case PermOpKind.EnsureGroupPermission:
if (!Registered(job, op.Object))
break;
if (DeferToLease(job, op, true))
break;
if (permission.GroupHasPermission(op.Subject, op.Object))
{
job.AlreadyCorrect++;
break;
}
// **`null`, not `this`, as the owner (D85).** With an owner, Oxide's grant
// first asks `PermissionExists(perm, owner)` — whether THAT plugin registered
// the name — and returns without a word when it did not. Every permission the
// site grants belongs to some other plugin, so from phase 7 until phase 13 this
// call did nothing on Oxide and was counted as applied (PLAN.md §27.6). The
// read-back is what makes the count true: only a grant the store now holds
// is reported as one.
permission.GrantGroupPermission(op.Subject, op.Object, null);
if (permission.GroupHasPermission(op.Subject, op.Object))
job.GroupPermissions++;
else
job.NotLanded.Add(op.Subject + ":" + op.Object);
break;
case PermOpKind.EnsureMember:
if (permission.UserHasGroup(op.Subject, op.Object))
{
job.AlreadyCorrect++;
break;
}
permission.AddUserGroup(op.Subject, op.Object);
// Read back, because this is the call that can do nothing at all. A player the
// store has never seen has no user record to put in a group (§12.2 rule 4), and
// the API says so by returning void and changing nothing.
if (permission.UserHasGroup(op.Subject, op.Object))
job.Members++;
else
job.Pending.Add(op.Subject + ":" + op.Object);
break;
case PermOpKind.EnsureGrant:
if (!Registered(job, op.Object))
break;
if (HoldsDirectly(op.Subject, op.Object))
{
job.AlreadyCorrect++;
break;
}
// The same owner trap as the group grant above (D85), and the same read-back.
permission.GrantUserPermission(op.Subject, op.Object, null);
if (HoldsDirectly(op.Subject, op.Object))
job.Grants++;
else
job.NotLanded.Add(op.Subject + ":" + op.Object);
break;
case PermOpKind.RetireGrant:
if (HoldsDirectly(op.Subject, op.Object))
{
permission.RevokeUserPermission(op.Subject, op.Object);
job.Revokes++;
}
else
{
job.Absent++;
}
break;
case PermOpKind.RetireMember:
if (permission.UserHasGroup(op.Subject, op.Object))
{
permission.RemoveUserGroup(op.Subject, op.Object);
job.MembersRemoved++;
}
else
{
job.Absent++;
}
break;
case PermOpKind.RetireGroupPermission:
if (DeferToLease(job, op, false))
break;
if (permission.GroupHasPermission(op.Subject, op.Object))
{
permission.RevokeGroupPermission(op.Subject, op.Object);
job.GroupPermissionsRemoved++;
}
else
{
job.Absent++;
}
break;
case PermOpKind.RetireGroup:
if (permission.GroupExists(op.Subject))
{
permission.RemoveGroup(op.Subject);
job.GroupsRemoved++;
}
else
{
job.Absent++;
}
break;
case PermOpKind.EnsureChatStyle:
ApplyChatStyle(job, op.Subject);
break;
case PermOpKind.RetireChatGroup:
RemoveChatGroup(job, op.Subject);
break;
}
}
///
/// A sync op about a pair an event lease holds. The store is left to the lease; what
/// the site wants is recorded on the hold and becomes the value the lease gives back
/// (§27.3). Returns whether it deferred.
///
private bool DeferToLease(PermSyncJob job, PermOp op, bool wants)
{
LeaseHold hold = LeasedGroupPermission(op.Subject, op.Object);
if (hold == null)
return false;
if (hold.SiteWants != wants)
{
hold.SiteWants = wants;
SaveLeases();
}
NoteLeased(job, op.Subject, op.Object);
return true;
}
private static void NoteLeased(PermSyncJob job, string group, string perm)
{
string id = Lower(group) + "/" + Lower(perm);
if (!job.LeasedSeen.Add(id))
return;
job.Leased.Add(new Dictionary
{
{ "kind", "group-permission" },
{ "subject", Lower(group) },
{ "object", Lower(perm) }
});
}
///
/// Whether this server has a plugin that registered the name, recording the answer once
/// per sync when it has not.
///
///
/// This is the pre-check PLAN.md §12.2 rule 1 exists for. Without it the grant below
/// would return void, throw nothing, log nothing and do nothing, and the website would
/// record a privilege it never gave.
///
///
private bool Registered(PermSyncJob job, string perm)
{
if (string.IsNullOrEmpty(perm))
return false;
if (permission.PermissionExists(perm))
return true;
if (job.UnresolvedSeen.Add(perm))
job.Unresolved.Add(perm);
return false;
}
///
/// Whether a player holds a permission through a grant of their own, as opposed to
/// through a group.
///
///
/// UserHasPermission cannot answer this — it is true for a member of a group
/// that carries the permission, so using it would make every direct grant look already
/// applied the moment a group covered it, and a revoke look already done.
/// GetPermissionUsers reads the user's own set, which is the question.
///
///
private bool HoldsDirectly(string steamId, string perm)
{
string[] holders = permission.GetPermissionUsers(perm);
if (holders == null)
return false;
foreach (string entry in holders)
{
if (UserId(entry) == steamId)
return true;
}
return false;
}
///
/// The id out of a store listing entry.
///
///
/// Both GetPermissionUsers and GetUsersInGroup answer with the id and the
/// player's last known name stuck together — 76561198…(Wanderer) — and the
/// spacing differs between the two calls and between the frameworks. So the id is
/// everything before the first bracket, and a name containing one cannot confuse it
/// because only the first is looked for.
///
///
private static string UserId(string entry)
{
if (string.IsNullOrEmpty(entry))
return null;
int bracket = entry.IndexOf('(');
string id = bracket >= 0 ? entry.Substring(0, bracket) : entry;
return id.Trim();
}
private void SendPermReport(PermSyncJob job)
{
var applied = new Dictionary
{
{ "grants", job.Grants },
{ "revokes", job.Revokes },
{ "groupsCreated", job.GroupsCreated },
{ "groupsUpdated", job.GroupsUpdated },
{ "groupsRemoved", job.GroupsRemoved },
{ "groupPermissions", job.GroupPermissions },
{ "groupPermissionsRemoved", job.GroupPermissionsRemoved },
{ "members", job.Members },
{ "membersRemoved", job.MembersRemoved }
};
Dictionary report = Frame("perm.report", "reply");
report["applied"] = applied;
report["alreadyCorrect"] = job.AlreadyCorrect;
report["absent"] = job.Absent;
report["unresolved"] = job.Unresolved;
report["leased"] = job.Leased;
report["pending"] = job.Pending;
report["notLanded"] = job.NotLanded;
report["operations"] = job.Ops.Count;
if (job.Credits != null)
{
// Counted the way `notLanded` is counted for grants: what actually changed in Kits,
// read back, never what was asked for.
report["creditsApplied"] = job.CreditsApplied;
report["creditsWithdrawn"] = job.CreditsWithdrawn;
report["creditsUnresolved"] = job.CreditsUnresolved;
report["creditsWaiting"] = job.CreditsWaiting;
}
// Protocol 14 (D333). Every notice of this sync's set that its player has been told —
// at this sync or before it — so the site marks it told and stops sending it. Present
// only when the sync carried `notices`, like the credit counts.
if (job.Notices != null)
report["noticesTold"] = job.NoticesTold;
// Protocol 12. Always present, so a site can tell "BetterChat is absent" from "this
// plugin predates styles" — the second has no `chat` at all.
var chat = new Dictionary { { "loaded", job.ChatLoaded } };
if (job.ChatLoaded)
{
chat["version"] = job.ChatVersion;
chat["applied"] = job.ChatApplied;
chat["saved"] = job.ChatSaved;
chat["same"] = job.ChatSame;
chat["drift"] = job.ChatDrift;
chat["removed"] = job.ChatRemoved;
chat["failed"] = job.ChatFailed;
}
report["chat"] = chat;
if (!string.IsNullOrEmpty(job.SetId))
report["setId"] = job.SetId;
if (job.ReqId != null)
report["reqId"] = job.ReqId;
_lastPermSummary =
$"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " +
$"groups={job.GroupsCreated}+{job.GroupsUpdated} members={job.Members} same={job.AlreadyCorrect} " +
$"unresolved={job.Unresolved.Count} " +
$"pending={job.Pending.Count} notLanded={job.NotLanded.Count} " +
$"chat={(job.ChatLoaded ? $"applied {job.ChatApplied} saved {job.ChatSaved} drift {job.ChatDrift.Count} removed {job.ChatRemoved.Count} failed {job.ChatFailed.Count}" : "absent")} " +
$"at={DateTime.UtcNow:HH:mm:ss}Z";
EmitFrame(report);
}
/// A refusal of the whole sync, with a reason the website turns into a sentence.
private void EmitPermError(string reqId, string reason)
{
Dictionary frame = Frame("perm.error", "reply");
frame["reason"] = reason;
if (reqId != null)
frame["reqId"] = reqId;
EmitFrame(frame);
}
// ---- drift, as it happens ----
//
// Both frameworks raise a hook for every permission write, ours included. They are NOT the
// drift record — the inventory is (protocol 13), because only the site's own record can say
// whether a grant is a hand edit. What they are is a reason to reconcile SOON: an operator typing
// `oxide.grant` at the console shows up on the website in seconds instead of at the next
// scheduled audit, and the authoritative answer still comes from a sync.
//
// That division is what makes them safe to trust at exactly the weight they deserve. A hook
// that stops firing on a framework upgrade costs latency, not correctness.
private void OnUserPermissionGranted(string id, string permName)
{
MarkHook("OnUserPermissionGranted");
EmitPermDrift("granted", id, permName, null);
}
private void OnUserPermissionRevoked(string id, string permName)
{
MarkHook("OnUserPermissionRevoked");
EmitPermDrift("revoked", id, permName, null);
}
private void OnUserGroupAdded(string id, string groupName)
{
MarkHook("OnUserGroupAdded");
EmitPermDrift("group-added", id, null, groupName);
}
private void OnUserGroupRemoved(string id, string groupName)
{
MarkHook("OnUserGroupRemoved");
EmitPermDrift("group-removed", id, null, groupName);
}
private void OnGroupPermissionGranted(string name, string permName)
{
MarkHook("OnGroupPermissionGranted");
EmitPermDrift("group-permission-granted", null, permName, name);
}
private void OnGroupPermissionRevoked(string name, string permName)
{
MarkHook("OnGroupPermissionRevoked");
EmitPermDrift("group-permission-revoked", null, permName, name);
}
// ---- the reload, watched ----
//
// These two are why an automatic rollback is possible at all (R18): a reload either
// produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin
// that is DOWN because of an edit the website just made.
//
// Protocol 13 made them a feed as well, and a narrow one (F8, D184). A grant the site made
// for a plugin that was not loaded is `unresolved` until the plugin comes back — and the
// first walk watched it land thirteen minutes after the plugin did, on the fifteen-minute
// audit, under a page promising "it will land by itself". So a load or an unload is now
// announced with the permissions that plugin added or removed, and the site re-syncs when
// that list is not empty. Only once the world is ready: a boot loads every plugin, and a
// boot is the restart sync's job.
/// The registered permission list as the last load or unload left it. Main thread.
private HashSet _registeredPerms;
///
/// Main thread. The list as it is now; the first call is the baseline. From
/// OnServerInitialized , which is also the moment _worldReady turns true.
///
private void SeedRegisteredPerms()
{
string[] now = permission.GetPermissions();
_registeredPerms = new HashSet(now ?? new string[0], StringComparer.OrdinalIgnoreCase);
}
///
/// Main thread, one tick after the hook. The diff is taken then rather than inside the
/// hook because the order of "the framework drops an unloaded plugin's permissions" and
/// "the framework calls OnPluginUnloaded " is the framework's, and a tick later
/// both have happened on either. Two plugins loaded in the same tick share one diff and
/// the first name gets it — the site's answer (re-sync) is the same either way, and
/// §4.1's owner is taken from the inventory, not from this.
///
private void EmitPluginChange(string name, string version, bool loaded)
{
if (_registeredPerms == null)
return;
string[] now = permission.GetPermissions() ?? new string[0];
HashSet current = new HashSet(now, StringComparer.OrdinalIgnoreCase);
List changed = new List();
if (loaded)
{
foreach (string perm in current)
{
if (!_registeredPerms.Contains(perm))
changed.Add(perm);
}
}
else
{
foreach (string perm in _registeredPerms)
{
if (!current.Contains(perm))
changed.Add(perm);
}
}
_registeredPerms = current;
changed.Sort(StringComparer.Ordinal);
Dictionary frame = Frame(loaded ? "plugin.loaded" : "plugin.unloaded", "event");
frame["name"] = name;
if (!string.IsNullOrEmpty(version))
frame["version"] = version;
frame["permissions"] = changed;
EmitFrame(frame);
}
private bool IsZonePlugin(Plugin plugin)
{
if (plugin == null || plugin == this)
return false;
string name = plugin.Name;
return name == "ZoneManager" || name == "ZoneDomes" || name == "RunicGatewayZones" || name == "RunicGatewayDomes";
}
/// RunicNPC's presence and API are in hello, so its load and unload say it again (stage 4).
private bool IsRunicNpc(Plugin plugin)
{
return plugin != null && plugin != this && plugin.Name == "RunicNPC";
}
/// Main thread. Hello again, once the world is up and the link is.
private void ResendHello()
{
if (_worldReady && _connected)
SendHello();
}
/// Main thread. Queues for somebody else's plugin, once the world is up.
private void NotePluginChange(Plugin plugin, bool loaded)
{
if (plugin == null || plugin == this || !_worldReady)
return;
string name = plugin.Name;
string version = plugin.Version.ToString();
NextTick(() => EmitPluginChange(name, version, loaded));
}
private void OnPluginLoaded(Plugin plugin)
{
MarkHook("OnPluginLoaded");
NotePluginChange(plugin, true);
// ZoneManager keeps temporary zones in memory, so a reload of it loses every zone an
// event opened. They are put back from this plugin's own record (D92).
if (plugin != null && plugin != this && plugin.Name == "ZoneManager")
NextTick(() => RecreateZones("ZoneManager reloaded"));
// TruePVE has read its table by now (it does in Init), so what an erase left while it
// was away can go (§14.3.2).
if (IsTruePve(plugin))
NextTick(() => FlushTruePvePending("TruePVE loaded"));
// Hello carries ZoneManager's flag list and both helpers' states, so a change to any
// of the four is said again rather than waiting for the next connect (§3.1, §3.2).
// Economics too: hello carries its starting balance, which the richest column needs;
// and Backpacks, whose sizes are what a Bigger backpack step offers; and TruePVE, which a
// zone step's PvP rules are refused without (§14.3.2); and Dynamic PVP (§14.3.3).
// Guarded Crate and NpcSpawn too: a placement is refused without either (D364).
if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin))
NextTick(ResendHello);
// Dynamic PVP raises its creates as it opens its zones, which at a load are its
// monuments'; seeding once it has finished starting catches any it opened before us.
if (IsDynamicPvp(plugin))
NextTick(() => SeedPvpZones("Dynamic PVP loaded"));
// Its spawn grid is built from now, so a start that finds no spot is "not ready" for a
// while (D365); and any chest a reload kept is found again.
if (IsDangerousTreasures(plugin))
{
NoteDtSeen(true);
NextTick(() => SeedTreasures("Dangerous Treasures loaded"));
}
// Economics loaded after players connected: what they hold now is not otherwise known
// until it moves (D336).
if (IsEconomics(plugin))
NextTick(NoteOnlineBalances);
// BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has
// forgotten our getter. Registering again is the only way back (§33.1).
if (plugin != null && plugin != this && plugin.Name == "BetterChat")
NextTick(() => RegisterTitles("BetterChat loaded"));
ConfigWriteJob job = _configJob;
if (job == null || plugin == null)
return;
if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase))
ConfigReloadArrived();
}
private void OnPluginUnloaded(Plugin plugin)
{
MarkHook("OnPluginUnloaded");
NotePluginChange(plugin, false);
if (plugin != null && plugin.Name == "BetterChat")
_titlesRegistered = false;
if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin))
NextTick(ResendHello);
// Guarded Crate's Unload ended every event WITHOUT a hook.
if (plugin != null && plugin != this && plugin.Name == "GuardedCrate")
_guardedCrates.Clear();
// Its Unload deleted every zone, each with its hook; whatever is left was missed.
if (IsDynamicPvp(plugin))
_pvpZones.Clear();
// Its Unload ended every chest, each with its hook.
if (IsDangerousTreasures(plugin))
{
_treasures.Clear();
NoteDtSeen(false);
}
ConfigWriteJob job = _configJob;
if (job == null || plugin == null)
return;
// Only ever used to word the failure. "It unloaded and did not come back" is a
// compile error in the edited file; "it never reloaded" is a framework that did not
// act on the request at all, and those send an operator to different places.
if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase))
job.Unloaded = true;
}
///
/// Main thread. Says that the store changed under us, and by how much it is worth
/// caring.
///
///
/// Staff class (§8.5): it names a Steam id beside a privilege, which is a fact about a
/// person's standing rather than about what happened on the server.
///
///
private void EmitPermDrift(string action, string steamId, string perm, string group)
{
// Our own writes raise these hooks too. Suppressing them here rather than filtering on
// the website is the only version that works: the site cannot tell its own grant from
// a human's by looking at one, which is the entire reason drift needs a desired set.
if (_applyingPermissions)
return;
Dictionary frame = Frame("perm.drift", "event");
frame["action"] = action;
if (!string.IsNullOrEmpty(steamId))
frame["steamId"] = steamId;
if (!string.IsNullOrEmpty(perm))
frame["permission"] = perm;
if (!string.IsNullOrEmpty(group))
frame["group"] = group;
EmitFrame(frame);
}
// ---- protocol 5: plugin configuration ----
//
// R18. An admin edits a loaded plugin's settings on the website and the plugin reloads.
// Three request/reply verbs: `config.list` describes the tree, `config.read` returns one
// file, `config.write` replaces a set and reloads whatever owns it.
//
// Four properties live on this side because only the game host can hold them:
//
// * **The roots come from the framework, never from a literal.** `ConfigDirectory` is
// `oxide/config` on Oxide, `carbon/configs` on Carbon, and neither on a server whose
// operator moved it (`CARBON.md` §3). That amendment was proven the best way it could
// have been: this plugin's own config landed in both places, from this same file.
// * **`DataDirectory` is never walked.** It holds live state — kit cooldowns, zone
// definitions — and both frameworks' permission stores, which is R2's mirror one
// directory over. A settings editor that strayed there would be editing phase 7
// underneath itself.
// * **A reload is observable.** `OnPluginLoaded` is a real hook, so "did that work" has
// an answer rather than an assumption — and an answer is the whole reason an automatic
// rollback is possible at all.
// * **The site composes the bytes; this plugin writes them.** It receives whole file
// text, not a key and a value, because JavaScript cannot tell `1` from `1.0` and Oxide
// deserializes into typed C# classes (D35/D36). Everything this end does to a document
// is check that it parses.
/// How deep the walk goes before it stops trying to describe a tree.
private const int ConfigMaxDepth = 6;
/// How many files the walk will report.
private const int ConfigMaxFiles = 500;
///
/// The largest file this plugin will read or write.
///
///
/// A file past it is reported and marked un-editable with the reason , never
/// omitted: an operator who cannot find a file they know exists goes looking for a bug
/// in the bridge, and one who can see why it was refused does not.
///
///
private const int ConfigMaxFileBytes = 256 * 1024;
/// Files one write may touch. A plugin can own several; ten of them is not a tree.
private const int ConfigMaxWriteFiles = 10;
///
/// The longest a reload may take before this plugin stops waiting for it (D178).
///
///
/// A ceiling, not the test. Protocol 12 rolled back any reload that had not
/// announced itself within four seconds, because the caller held a socket for the whole
/// wait. That window lost to work whose length nothing here controls: the org lead's
/// first walk saw Oxide's compiler idle out, restart, and load Kits 2.85 s later — just
/// past the window — and the plugin put a valid edit back (F9). Protocol 13 answers the
/// save at once and sends the outcome as a frame, so the wait no longer has to fit
/// inside an RPC, and a reload fails on evidence : the framework's log saying the
/// plugin did not compile or initialise ( ). This number
/// only bounds a reload that says nothing at all.
///
///
///
/// Each phase gets it once — the edit's reload, and the restore's if there is one — and
/// the pending reply carries it as ceilingMs , so the website knows when a write it
/// never heard back about is lost rather than slow.
///
///
private const float ConfigReloadCeilingSeconds = 30f;
/// How often a pending reload reads the log for its failure.
private const float ConfigLogPollSeconds = 1f;
/// The most one poll reads from the log. A server spamming it cannot make a tick expensive.
private const int ConfigLogPollMaxBytes = 64 * 1024;
/// How much of the newest log file is read back when a reload fails.
private const int ConfigLogTailBytes = 8 * 1024;
///
/// What a failed load says in the log, beside the plugin's name.
///
///
/// Lower case, matched case-insensitively. Oxide's are its compiler's and its loader's
/// own words ("Error while compiling", "Failed to initialize plugin", and "Could not
/// initialize plugin 'Kits v4.4.9' (JsonReaderException: …)" when a plugin's own
/// LoadConfig throws, seen on the rig in configuration step 4). The last one
/// carries the exception, so it is the line an operator needs; Oxide follows it with
/// "Previous version of plugin failed to load", which says nothing about why. Carbon's
/// differ, and the rest of the list is there so that the same file works on both (R19);
/// the Carbon pass of the re-walk is what pins them. The first pinned is Carbon
/// 2.0.259's "[Kits] Failed ILoadConfig (Could not convert string to double: abc. …)" —
/// after which Carbon loads the plugin anyway, broken, and raises Loaded .
///
///
private static readonly string[] ConfigFailureWords =
{
"error while compiling",
"could not initialize",
"failed to initialize",
"failed initializing",
"failed to compile",
"failed compiling",
"failed to load",
"failed loading",
"failed iloadconfig",
"unable to load",
};
///
/// What the framework says when it gave up on a compile, beside the plugin's name.
///
///
/// Not a failure of the file: Oxide shuts its compiler down after a minute idle, and a
/// reload asked for in that moment is dropped with "Timed out waiting for plugin to be
/// compiled: ZoneManager" while the plugin carries on, loaded, on its old file (seen on
/// the rig, configuration step 2b). The compiler starts again on demand, so the reload
/// is asked for once more; only a second give-up settles the job.
///
///
private static readonly string[] ConfigStallWords =
{
"timed out waiting for plugin to be compiled",
};
/// Which reload a job is waiting on: the edit's, or the restore's after a rollback.
private enum ConfigPhase
{
Edit,
Restore
}
/// One file, resolved and checked, on its way to disk.
private sealed class StagedConfigWrite
{
public string RelPath;
public string FullPath;
public string Text;
/// What was there before, held for the rollback that may never be needed.
public string Original;
public bool Written;
}
///
/// A write that has landed and is waiting to hear whether the plugin came back.
///
///
/// At most one exists at a time. A second write arriving mid-window is refused rather
/// than queued: two overlapping rollbacks over intersecting file sets can restore a
/// file to a state that was never on disk.
///
///
private sealed class ConfigWriteJob
{
///
/// Ours, not the sidecar's reqId . The reply and the later
/// config.outcome both carry it, and the website files the outcome by it —
/// the sidecar's request ids restart with the sidecar, so they cannot name a write
/// across one.
///
public string WriteId;
public string Reload;
public List Staged;
/// The one-second poll that reads the log and enforces the ceiling.
public Timer Watch;
public ConfigPhase Phase;
/// When the current phase's reload was asked for. Each phase gets the whole ceiling.
public DateTime PhaseStarted;
/// When the write landed, for the outcome's elapsedMs .
public DateTime Started;
/// Whether the target was seen leaving in this phase. It separates "never started" from "did not come back".
public bool Unloaded;
/// Whether this phase's reload was already asked for again after the framework gave up compiling.
public bool Retried;
/// When OnPluginLoaded named the target in this phase, or null. The next log read settles it.
public DateTime? LoadedAt;
/// The log file being read, and how far into it this job has read.
public string LogPath;
public long LogOffset;
/// A line cut off at the end of the last read, kept until the rest arrives.
public string LogPartial = "";
/// Why the edit was rolled back, carried into the outcome once the restore settles.
public string RollbackReason;
/// The log lines that showed the failure, or the tail read when the ceiling passed.
public string RollbackLog;
}
private ConfigWriteJob _configJob;
private string _lastConfigSummary = "none yet";
/// Whether this is a Carbon host. Resolved once, by looking, never by compiling twice (R19).
private static bool? _carbon;
private void HandleConfigList(string reqId)
{
string root = ConfigRoot();
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
{
EmitConfigError(reqId, "no-root", "this framework reports no configuration directory");
return;
}
var files = new List();
bool truncated = false;
try
{
WalkConfig(root, root, 0, files, ref truncated);
}
catch (Exception ex)
{
EmitConfigError(reqId, "unreadable", ex.Message);
return;
}
Dictionary reply = Frame("config.catalogue", "reply");
reply["root"] = root;
reply["files"] = files;
reply["plugins"] = LoadedPlugins();
reply["truncated"] = truncated;
// Named rather than inferred, so the website's locked-key guard (D38) is written
// against what this plugin calls itself instead of against a filename somebody may
// rename. The file carrying the link is not a file the site may break from a form.
reply["self"] = Name;
reply["limits"] = new Dictionary
{
{ "depth", ConfigMaxDepth },
{ "files", ConfigMaxFiles },
{ "fileBytes", ConfigMaxFileBytes },
{ "writeFiles", ConfigMaxWriteFiles }
};
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
///
/// Main thread, and deliberately cheap: it reads directory entries, never file
/// contents. A tree of 500 files costs 500 stat calls here; hashing all of them would
/// cost up to 128 MB of reads in one frame, which is exactly the unbounded work §10.5
/// forbids. A file's version is therefore produced by config.read , on the one
/// file somebody actually opened.
///
private void WalkConfig(string root, string dir, int depth, List into, ref bool truncated)
{
if (depth > ConfigMaxDepth)
{
truncated = true;
return;
}
string[] entries;
try
{
entries = Directory.GetFiles(dir, "*.json");
}
catch (Exception)
{
truncated = true;
return;
}
Array.Sort(entries, StringComparer.OrdinalIgnoreCase);
foreach (string full in entries)
{
if (into.Count >= ConfigMaxFiles)
{
truncated = true;
return;
}
FileInfo info;
try
{
info = new FileInfo(full);
}
catch (Exception)
{
continue;
}
// A link is not followed even when it points somewhere legitimate. Resolving one
// is how a tree that looks bounded turns out not to be, and how a path that
// validates as "under the root" writes somewhere else.
if ((info.Attributes & FileAttributes.ReparsePoint) != 0)
continue;
string rel = RelativeConfigPath(root, full);
var row = new Dictionary
{
{ "path", rel },
{ "bytes", info.Length },
{ "modified", ToUnixMs(info.LastWriteTimeUtc) },
{ "plugin", PluginGuess(rel) }
};
if (info.Length > ConfigMaxFileBytes)
{
row["editable"] = false;
row["reason"] = "larger than this bridge will carry";
}
else
{
row["editable"] = true;
}
into.Add(row);
}
string[] dirs;
try
{
dirs = Directory.GetDirectories(dir);
}
catch (Exception)
{
truncated = true;
return;
}
Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
foreach (string sub in dirs)
{
if (into.Count >= ConfigMaxFiles)
{
truncated = true;
return;
}
try
{
if ((new DirectoryInfo(sub).Attributes & FileAttributes.ReparsePoint) != 0)
continue;
}
catch (Exception)
{
continue;
}
WalkConfig(root, sub, depth + 1, into, ref truncated);
}
}
private void HandleConfigRead(Dictionary cmd, string reqId)
{
string rel = StringField(cmd, "path");
string full;
string refusal;
if (!ResolveConfigPath(rel, out full, out refusal))
{
EmitConfigError(reqId, "path", refusal);
return;
}
if (!File.Exists(full))
{
EmitConfigError(reqId, "missing", "no such configuration file");
return;
}
string text;
long bytes;
DateTime modified;
try
{
var info = new FileInfo(full);
bytes = info.Length;
modified = info.LastWriteTimeUtc;
if (bytes > ConfigMaxFileBytes)
{
EmitConfigError(reqId, "too-large", "larger than this bridge will carry");
return;
}
text = File.ReadAllText(full, Encoding.UTF8);
}
catch (Exception ex)
{
EmitConfigError(reqId, "unreadable", ex.Message);
return;
}
Dictionary reply = Frame("config.file", "reply");
reply["path"] = rel;
reply["text"] = text;
reply["version"] = VersionOf(text);
reply["bytes"] = bytes;
reply["modified"] = ToUnixMs(modified);
reply["plugin"] = PluginGuess(rel);
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
///
/// Main thread. Replaces a set of files and reloads what owns them.
///
///
/// Everything is resolved, version-checked and parsed before the first byte is
/// written — the same posture as perm.sync , and for the same reason: a
/// refusal that has touched nothing has nothing to unwind. What cannot be checked in
/// advance is whether the plugin will come back, which is what the window and the
/// rollback are for.
///
///
private void HandleConfigWrite(Dictionary cmd, string reqId)
{
if (_configJob != null)
{
EmitConfigError(reqId, "busy", "another configuration write is still waiting for its reload");
return;
}
var rows = new List>(Rows(cmd, "files"));
if (rows.Count == 0)
{
EmitConfigError(reqId, "empty", "a write must name at least one file");
return;
}
if (rows.Count > ConfigMaxWriteFiles)
{
EmitConfigError(reqId, "too-many", $"a write may touch at most {ConfigMaxWriteFiles} files");
return;
}
string reload = StringField(cmd, "reload");
// The site may not reload the bridge. The reload would unload this plugin, close the
// link the answer travels on, and leave a rollback with nothing watching it — so the
// one failure this whole mechanism exists to prevent would be the one it could not
// report. Our own settings are still editable (D38); they take effect the next time
// somebody reloads this plugin deliberately.
if (!string.IsNullOrEmpty(reload) && reload.Equals(Name, StringComparison.OrdinalIgnoreCase))
{
EmitConfigError(reqId, "reload-self",
"this bridge will not reload itself: the reload would close the link carrying the answer");
return;
}
var staged = new List();
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (Dictionary row in rows)
{
string rel = StringField(row, "path");
string version = StringField(row, "version");
string text = StringField(row, "text");
string full;
string refusal;
if (!ResolveConfigPath(rel, out full, out refusal))
{
EmitConfigError(reqId, "path", refusal);
return;
}
if (!seen.Add(full))
{
EmitConfigError(reqId, "duplicate", $"'{rel}' is named twice in one write");
return;
}
if (text == null)
{
EmitConfigError(reqId, "empty", $"'{rel}' carries no text");
return;
}
if (Encoding.UTF8.GetByteCount(text) > ConfigMaxFileBytes)
{
EmitConfigError(reqId, "too-large", $"'{rel}' is larger than this bridge will carry");
return;
}
// A config file is edited, never created. A plugin writes its own defaults, and a
// file this walk has never seen is not a settings file somebody forgot — it is a
// path we have no reason to believe in.
if (!File.Exists(full))
{
EmitConfigError(reqId, "missing", $"'{rel}' does not exist");
return;
}
string current;
try
{
current = File.ReadAllText(full, Encoding.UTF8);
}
catch (Exception ex)
{
EmitConfigError(reqId, "unreadable", $"'{rel}': {ex.Message}");
return;
}
string actual = VersionOf(current);
if (version != actual)
{
Dictionary conflict = Frame("config.error", "reply");
conflict["reason"] = "conflict";
conflict["detail"] = $"'{rel}' changed on disk since it was read";
conflict["path"] = rel;
conflict["version"] = actual;
if (reqId != null)
conflict["reqId"] = reqId;
EmitFrame(conflict);
return;
}
try
{
Newtonsoft.Json.Linq.JToken.Parse(text);
}
catch (Exception ex)
{
EmitConfigError(reqId, "invalid", $"'{rel}' is not valid JSON: {ex.Message}");
return;
}
staged.Add(new StagedConfigWrite
{
RelPath = rel,
FullPath = full,
Text = text,
Original = current
});
}
foreach (StagedConfigWrite file in staged)
{
try
{
BackupConfig(file);
File.WriteAllText(file.FullPath, file.Text, Utf8NoBom);
file.Written = true;
}
catch (Exception ex)
{
// Half a set on disk is the state nothing can reason about. Put back whatever
// did land before answering.
RestoreConfig(staged);
EmitConfigError(reqId, "unwritable", $"'{file.RelPath}': {ex.Message}");
return;
}
}
// This plugin never reloads itself (§21.3), so a change to its own settings would
// otherwise wait for somebody's next manual reload — and `EventsEnabled` is the first
// of them an operator is MEANT to change from the website (protocol 8, D76). It is
// re-read here instead. Host, Port and ServerId are locked on the website's side and
// still apply on the next reload, because changing them live would mean tearing down
// the link this answer is about to travel on.
bool ownApplied = ApplyOwnSettings(staged);
if (string.IsNullOrEmpty(reload))
{
SendConfigReport(reqId, staged, false, false,
ownApplied
? "EventsEnabled took effect at once; the bridge's other settings apply on its next reload"
: "no reload was asked for",
null);
return;
}
if (plugins.Find(reload) == null)
{
// The file is written and the plugin is not loaded, so there is nothing to reload
// and nothing to roll back from: the change applies when it is next loaded. Said
// plainly, because "saved" and "in effect" are different claims.
SendConfigReport(reqId, staged, false, false, $"'{reload}' is not loaded", null);
return;
}
var job = new ConfigWriteJob
{
WriteId = Guid.NewGuid().ToString("N"),
Reload = reload,
Staged = staged,
Phase = ConfigPhase.Edit,
Started = DateTime.UtcNow,
PhaseStarted = DateTime.UtcNow
};
// Where the log is NOW, before the reload writes anything to it. Only lines after this
// point can be about this reload.
MarkLogPosition(job);
_configJob = job;
if (!ReloadPluginByName(reload))
{
_configJob = null;
RestoreConfig(staged);
EmitConfigError(reqId, "reload-failed", $"this framework refused to reload '{reload}'");
return;
}
// The reload was asked for and has not finished; it may be seconds away behind a cold
// compile. Answer now — the caller is holding a socket with a ten-second budget — and
// send the outcome as a frame when there is one (F9, D177).
job.Watch = timer.Every(ConfigLogPollSeconds, ConfigWatchTick);
SendConfigReport(reqId, staged, false, false, null, null, job);
}
///
/// Main thread. OnPluginLoaded named the target. It is noted, not settled: the
/// next log read settles it (W5).
///
///
/// "Loaded" is not "loaded well" on Carbon. A config its LoadConfig cannot read
/// is logged as a failure and the plugin is loaded anyway, broken, in the same instant
/// (seen on the rig, configuration step 4). Settling on the hook reported that edit as
/// applied; reading the log first rolls it back, for about a second's wait.
///
///
private void ConfigReloadArrived()
{
ConfigWriteJob job = _configJob;
if (job == null || job.LoadedAt.HasValue)
return;
job.LoadedAt = DateTime.UtcNow;
}
///
/// Main thread. A load that a full log read after it found no failure: in the edit
/// phase the write stands; in the restore phase the plugin is back on its old file.
///
private void ConfigLoadConfirmed(ConfigWriteJob job)
{
if (job.Phase == ConfigPhase.Edit)
SettleConfigJob(job, true, false, null, null, null);
else
SettleConfigJob(job, false, true, true, job.RollbackReason, job.RollbackLog);
}
///
/// Main thread, once a second while a reload is pending. Reads what the framework has
/// logged since the last tick, and enforces the ceiling.
///
///
/// A failure is a line, not a silence (D178). Both frameworks log the moment a
/// plugin fails to compile or to initialise, so a broken edit is rolled back about a
/// second after it breaks, and a slow compile — which logs nothing until it succeeds —
/// is simply waited for. The ceiling is for a reload that says nothing either way.
///
///
///
/// What the ceiling does depends on what is loaded, not on the clock (F10).
/// Protocol 12 restored the files whenever the window closed, even over a plugin that
/// was about to finish loading the edit, and then reported "did not come back" about a
/// plugin that did. Now: a target that is loaded again was reloaded and its announcement
/// missed, so the edit stands; one that is loaded and was never seen unloading was never
/// reloaded at all, so the file stands and applies at its next load; only a target that
/// is not loaded has its files put back.
///
///
private void ConfigWatchTick()
{
ConfigWriteJob job = _configJob;
if (job == null)
{
return;
}
string failure = ReadConfigFailure(job);
if (failure != null && IsCompileStallOf(failure, job.Reload))
{
ConfigCompileStalled(job, failure);
return;
}
if (failure != null)
{
if (job.Phase == ConfigPhase.Edit)
{
RollBackConfig(job, $"'{job.Reload}' failed to load: {ShortLine(failure)}", failure);
}
else
{
SettleConfigJob(job, false, true, false,
$"{job.RollbackReason}; the restored file did not load either: {ShortLine(failure)}",
job.RollbackLog + "\n" + failure);
}
return;
}
// A load is trusted once a read that began at least a poll after it found nothing:
// the failure it would have to beat is logged before the load, never after.
if (job.LoadedAt.HasValue &&
(DateTime.UtcNow - job.LoadedAt.Value).TotalSeconds >= ConfigLogPollSeconds)
{
ConfigLoadConfirmed(job);
return;
}
if ((DateTime.UtcNow - job.PhaseStarted).TotalSeconds < ConfigReloadCeilingSeconds)
return;
SettleByWhatIsLoaded(job, $"within {ConfigReloadCeilingSeconds:0}s");
}
///
/// Main thread. The framework gave up compiling the target (W1). The first time, the
/// reload is asked for again with a fresh ceiling; the second time, the job is settled
/// at once by what is loaded — the same judgement the ceiling makes, without waiting
/// for it, because the framework has already said it is not going to finish.
///
private void ConfigCompileStalled(ConfigWriteJob job, string line)
{
if (!job.Retried)
{
job.Retried = true;
job.PhaseStarted = DateTime.UtcNow;
job.Unloaded = false;
job.LoadedAt = null;
MarkLogPosition(job);
Puts($"the framework gave up compiling '{job.Reload}'; asking for the reload once more");
if (ReloadPluginByName(job.Reload))
return;
}
SettleByWhatIsLoaded(job, $"before the framework gave up compiling it ({ShortLine(line)})");
}
///
/// Main thread. Settles a reload that said nothing conclusive, by what is loaded — not
/// by the clock (F10). finishes the sentence: "within 30s", or
/// why it stopped sooner.
///
private void SettleByWhatIsLoaded(ConfigWriteJob job, string when)
{
bool loaded = IsPluginLoaded(job.Reload);
if (job.Phase == ConfigPhase.Restore)
{
SettleConfigJob(job, false, true, loaded,
loaded
? job.RollbackReason
: $"{job.RollbackReason}; '{job.Reload}' did not come back on the restored file " +
$"{when} either",
job.RollbackLog);
return;
}
if (loaded && job.Unloaded)
{
SettleConfigJob(job, true, false, null,
$"'{job.Reload}' is loaded again, but its load was not announced", null);
}
else if (loaded)
{
SettleConfigJob(job, false, false, null,
$"the framework did not reload '{job.Reload}' {when}; " +
"the file stands and applies at its next load",
null);
}
else
{
RollBackConfig(job,
job.Unloaded
? $"'{job.Reload}' unloaded and did not come back {when}"
: $"'{job.Reload}' did not load {when}",
LogTail());
}
}
///
/// Main thread. The edit took the plugin down: put every file back and reload again.
///
///
/// This is the feature's real content. Without it, R18 is a web form that takes a
/// required plugin off a production server one typo at a time. The outcome waits for the
/// restore's own reload, so it can say whether the plugin is back, rather than only that
/// the files are.
///
///
private void RollBackConfig(ConfigWriteJob job, string reason, string log)
{
RestoreConfig(job.Staged);
job.Phase = ConfigPhase.Restore;
job.PhaseStarted = DateTime.UtcNow;
job.Unloaded = false;
job.Retried = false;
job.LoadedAt = null;
job.RollbackReason = reason;
job.RollbackLog = log;
// The restore's failure, if it has one, is logged after this point.
MarkLogPosition(job);
if (!ReloadPluginByName(job.Reload))
{
SettleConfigJob(job, false, true, false,
$"{reason}; the files are back, but this framework refused to reload '{job.Reload}'", log);
}
}
/// Main thread. The job is over: stop watching and say how it ended.
private void SettleConfigJob(
ConfigWriteJob job,
bool reloaded,
bool rolledBack,
bool? restored,
string reason,
string log)
{
if (_configJob != job)
return;
StopConfigWatch();
_configJob = null;
EmitConfigOutcome(job, reloaded, rolledBack, restored, reason, log);
}
private void StopConfigWatch()
{
ConfigWriteJob job = _configJob;
if (job == null || job.Watch == null)
return;
job.Watch.Destroy();
job.Watch = null;
}
private bool IsPluginLoaded(string name)
{
Plugin found = plugins.Find(name);
return found != null;
}
/// Main thread. Points the job at the end of the newest log file.
private void MarkLogPosition(ConfigWriteJob job)
{
job.LogPath = NewestLogFile();
job.LogPartial = "";
try
{
job.LogOffset = job.LogPath != null ? new FileInfo(job.LogPath).Length : 0;
}
catch (Exception)
{
job.LogOffset = 0;
}
}
///
/// Main thread. The first line logged since the last read that says the target failed to
/// load, or null.
///
///
/// Bounded per call. A log that rotates mid-job (midnight) is followed to the new file
/// from its start; one that shrank was truncated and is read again from the top. Lines
/// this plugin wrote itself are skipped, so a message of ours that names the target
/// beside the word "failed" cannot roll back a reload that is going fine.
///
///
private string ReadConfigFailure(ConfigWriteJob job)
{
try
{
string newest = NewestLogFile();
if (newest == null)
return null;
if (!string.Equals(newest, job.LogPath, StringComparison.OrdinalIgnoreCase))
{
job.LogPath = newest;
job.LogOffset = 0;
job.LogPartial = "";
}
string text;
using (var stream = new FileStream(job.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
if (stream.Length < job.LogOffset)
{
job.LogOffset = 0;
job.LogPartial = "";
}
long available = stream.Length - job.LogOffset;
if (available <= 0)
return null;
int take = (int)Math.Min(available, ConfigLogPollMaxBytes);
stream.Seek(job.LogOffset, SeekOrigin.Begin);
var buffer = new byte[take];
int read = stream.Read(buffer, 0, take);
job.LogOffset += read;
text = job.LogPartial + Encoding.UTF8.GetString(buffer, 0, read);
}
int lastBreak = text.LastIndexOf('\n');
if (lastBreak < 0)
{
job.LogPartial = text.Length > ConfigLogPollMaxBytes ? "" : text;
return null;
}
job.LogPartial = text.Substring(lastBreak + 1);
if (job.LogPartial.Length > ConfigLogPollMaxBytes)
job.LogPartial = "";
string own = "[" + Title + "]";
foreach (string raw in text.Substring(0, lastBreak).Split('\n'))
{
string line = raw.TrimEnd('\r');
if (line.IndexOf(own, StringComparison.OrdinalIgnoreCase) >= 0)
continue;
if (IsLoadFailureOf(line, job.Reload) || IsCompileStallOf(line, job.Reload))
return line;
}
}
catch (Exception)
{
// A log that cannot be read costs the fast path and nothing else: the ceiling
// still settles the job.
}
return null;
}
/// Whether a log line says failed to compile or to initialise.
private static bool IsLoadFailureOf(string line, string plugin)
{
return SaysAbout(line, plugin, ConfigFailureWords);
}
/// Whether a log line says the framework gave up compiling (W1).
private static bool IsCompileStallOf(string line, string plugin)
{
return SaysAbout(line, plugin, ConfigStallWords);
}
private static bool SaysAbout(string line, string plugin, string[] words)
{
if (string.IsNullOrEmpty(line) || string.IsNullOrEmpty(plugin))
return false;
string lower = line.ToLowerInvariant();
bool said = false;
foreach (string word in words)
{
if (lower.IndexOf(word, StringComparison.Ordinal) >= 0)
{
said = true;
break;
}
}
return said && MentionsName(lower, plugin.ToLowerInvariant());
}
///
/// Whether appears as a whole name — so a failure of
/// KitsExtended is not read as one of Kits . A trailing .cs or a
/// version still counts: "Kits.cs", "'Kits v4.4.9'".
///
private static bool MentionsName(string lower, string name)
{
int from = 0;
while (true)
{
int at = lower.IndexOf(name, from, StringComparison.Ordinal);
if (at < 0)
return false;
int end = at + name.Length;
bool startOk = at == 0 || !char.IsLetterOrDigit(lower[at - 1]);
bool endOk = end == lower.Length || !char.IsLetterOrDigit(lower[end]);
if (startOk && endOk)
return true;
from = at + 1;
}
}
/// A log line short enough for a reason sentence.
private static string ShortLine(string line)
{
string trimmed = (line ?? "").Trim();
return trimmed.Length <= 240 ? trimmed : trimmed.Substring(0, 240) + "…";
}
///
/// Main thread. Each file as it is on disk now.
///
///
/// The versions are re-read rather than assumed, because both frameworks merge missing
/// defaults into a config on load and save it back — so the file after a successful
/// reload is regularly not the file we wrote, and a site holding our version would
/// conflict with itself on the next save.
///
///
private List ConfigFilesNow(List staged, bool rolledBack)
{
var files = new List();
foreach (StagedConfigWrite file in staged)
{
var row = new Dictionary { { "path", file.RelPath } };
try
{
string now = File.ReadAllText(file.FullPath, Encoding.UTF8);
row["version"] = VersionOf(now);
row["bytes"] = Encoding.UTF8.GetByteCount(now);
row["rewritten"] = !rolledBack && now != file.Text;
}
catch (Exception)
{
row["version"] = null;
}
files.Add(row);
}
return files;
}
///
/// Main thread. The reply to config.write .
///
///
/// With a job it says "written, reloading": pending ,
/// the writeId the outcome will carry, and ceilingMs . Without one it is the
/// whole answer — nothing was asked to reload, or the target was not loaded.
///
///
private void SendConfigReport(
string reqId,
List staged,
bool reloaded,
bool rolledBack,
string reason,
string log,
ConfigWriteJob pending = null)
{
Dictionary report = Frame("config.report", "reply");
report["ok"] = !rolledBack;
report["reloaded"] = reloaded;
report["rolledBack"] = rolledBack;
report["pending"] = pending != null;
report["files"] = ConfigFilesNow(staged, rolledBack);
if (pending != null)
{
report["writeId"] = pending.WriteId;
report["reload"] = pending.Reload;
report["ceilingMs"] = (long)(ConfigReloadCeilingSeconds * 1000);
}
if (!string.IsNullOrEmpty(reason))
report["reason"] = reason;
if (!string.IsNullOrEmpty(log))
report["log"] = log;
if (reqId != null)
report["reqId"] = reqId;
_lastConfigSummary = pending != null
? $"files={staged.Count} reloading={pending.Reload} at={DateTime.UtcNow:HH:mm:ss}Z"
: $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " +
$"at={DateTime.UtcNow:HH:mm:ss}Z" +
(string.IsNullOrEmpty(reason) ? "" : $" reason={reason}");
EmitFrame(report);
}
///
/// Main thread. How a pending reload ended, as an event (protocol 13, F9).
///
///
/// An event, not a reply, because nothing is waiting for it: the sidecar files it in its
/// feed and the website's ingest settles the write it names. Staff class on the site —
/// the log tail is an operator's console.
///
///
private void EmitConfigOutcome(
ConfigWriteJob job,
bool reloaded,
bool rolledBack,
bool? restored,
string reason,
string log)
{
Dictionary frame = Frame("config.outcome", "event");
frame["writeId"] = job.WriteId;
frame["reload"] = job.Reload;
frame["ok"] = !rolledBack;
frame["reloaded"] = reloaded;
frame["rolledBack"] = rolledBack;
frame["elapsedMs"] = (long)(DateTime.UtcNow - job.Started).TotalMilliseconds;
frame["files"] = ConfigFilesNow(job.Staged, rolledBack);
if (restored.HasValue)
frame["restored"] = restored.Value;
if (!string.IsNullOrEmpty(reason))
frame["reason"] = reason;
if (!string.IsNullOrEmpty(log))
frame["log"] = log;
_lastConfigSummary =
$"files={job.Staged.Count} reloaded={reloaded} rolledBack={rolledBack}" +
(restored.HasValue ? $" restored={restored.Value}" : "") +
$" at={DateTime.UtcNow:HH:mm:ss}Z" +
(string.IsNullOrEmpty(reason) ? "" : $" reason={reason}");
EmitFrame(frame);
}
private void EmitConfigError(string reqId, string reason, string detail)
{
Dictionary frame = Frame("config.error", "reply");
frame["reason"] = reason;
if (!string.IsNullOrEmpty(detail))
frame["detail"] = detail;
if (reqId != null)
frame["reqId"] = reqId;
EmitFrame(frame);
}
/// Main thread. Puts every file that landed back the way it was.
private void RestoreConfig(List staged)
{
foreach (StagedConfigWrite file in staged)
{
if (!file.Written)
continue;
try
{
File.WriteAllText(file.FullPath, file.Original, Utf8NoBom);
}
catch (Exception ex)
{
// Nothing else can be done from here, and it must be loud: the file on disk is
// now the edit that took a plugin down, and the operator is the only one who
// can fix it.
Puts($"could not restore '{file.RelPath}': {ex.Message}");
}
}
}
///
/// Main thread. If a write touched this plugin's own config, take the one setting that
/// is safe to change live — EventsEnabled — from what was written. Returns
/// whether it did.
///
private bool ApplyOwnSettings(List staged)
{
string own;
try
{
own = Path.GetFullPath(Path.Combine(Interface.Oxide.ConfigDirectory, Name + ".json"));
}
catch (Exception)
{
return false;
}
foreach (StagedConfigWrite file in staged)
{
string full;
try
{
full = Path.GetFullPath(file.FullPath);
}
catch (Exception)
{
continue;
}
if (!string.Equals(full, own, StringComparison.OrdinalIgnoreCase))
continue;
try
{
var root = Newtonsoft.Json.Linq.JObject.Parse(file.Text);
Newtonsoft.Json.Linq.JToken flag = root["EventsEnabled"];
if (flag == null || flag.Type != Newtonsoft.Json.Linq.JTokenType.Boolean || _config == null)
return false;
bool now = (bool)flag;
if (_config.EventsEnabled != now)
Puts($"EventsEnabled is now {now} (set from the website)");
_config.EventsEnabled = now;
return true;
}
catch (Exception ex)
{
Puts($"could not re-read own settings: {Flatten(ex.Message)}");
return false;
}
}
return false;
}
///
/// Keeps a copy of a file about to be overwritten, and the nine before it.
///
///
/// Under DataDirectory , which is this plugin writing its own state — not the
/// editor reaching into the tree it must never walk. The rollback itself does not
/// depend on these files; it holds the original in memory. They are for the operator
/// who wants last Tuesday's config back.
///
///
private void BackupConfig(StagedConfigWrite file)
{
string dir = Path.Combine(Interface.Oxide.DataDirectory, Name, "config-backups");
Directory.CreateDirectory(dir);
string flat = file.RelPath.Replace('/', '_');
string stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
File.WriteAllText(Path.Combine(dir, $"{flat}.{stamp}.bak"), file.Original, Utf8NoBom);
string[] kept;
try
{
kept = Directory.GetFiles(dir, $"{flat}.*.bak");
}
catch (Exception)
{
return;
}
if (kept.Length <= 10)
return;
Array.Sort(kept, StringComparer.Ordinal);
for (int i = 0; i < kept.Length - 10; i++)
{
try
{
File.Delete(kept[i]);
}
catch (Exception)
{
}
}
}
///
/// The framework's newest log file, or null. Oxide's own log is the newest *.txt
/// directly in LogDirectory ; plugins that log to files write into subdirectories
/// of it, which this does not look in.
///
///
/// Carbon's is Carbon.Core.log in carbon/logs , beside four other
/// *.log files and no *.txt at all (read on the rig, Carbon 2.0.259). Its
/// Harmony log is the one written most often, so "newest" would pick the wrong file;
/// the name is the only reliable way to choose.
///
///
private static string NewestLogFile()
{
try
{
string dir = Interface.Oxide.LogDirectory;
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
return null;
if (IsCarbon())
{
string core = Path.Combine(dir, "Carbon.Core.log");
return File.Exists(core) ? core : null;
}
string newest = null;
DateTime newestAt = DateTime.MinValue;
foreach (string candidate in Directory.GetFiles(dir, "*.txt"))
{
DateTime at = File.GetLastWriteTimeUtc(candidate);
if (at <= newestAt)
continue;
newest = candidate;
newestAt = at;
}
return newest;
}
catch (Exception)
{
return null;
}
}
///
/// The tail of the newest log file, read only when a reload failed.
///
///
/// A rollback that says "it did not come back" and nothing else sends the operator to a
/// console to find the line that says why. This carries the line instead. Bounded, and
/// paid for only on the failure path.
///
///
private string LogTail()
{
try
{
string newest = NewestLogFile();
if (newest == null)
return null;
using (var stream = new FileStream(newest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
int take = (int)Math.Min(stream.Length, ConfigLogTailBytes);
stream.Seek(-take, SeekOrigin.End);
var buffer = new byte[take];
int read = stream.Read(buffer, 0, take);
return Encoding.UTF8.GetString(buffer, 0, read);
}
}
catch (Exception)
{
return null;
}
}
///
/// Reloads a plugin through the framework rather than by composing a console command.
///
///
/// The console is the one place the two frameworks genuinely differ — Carbon's commands
/// are c. -prefixed and an alias for the Oxide names is opt-in — and a wrong
/// prefix on Carbon prints nothing at all, so it looks exactly like a command that
/// worked (`CARBON.md` §5). Reflection rather than #if CARBON , because one
/// byte-identical source file on both frameworks is the property R19 proved and is
/// worth keeping.
///
///
private bool ReloadPluginByName(string name)
{
// Carbon's Oxide-compatible ReloadPlugin is not a reload (W3, Carbon 2.0.259 on the
// rig): one call unloaded Kits and never compiled it again, the next did nothing at
// all. Its own console command, typed at the panel, reloads the same plugin in about a
// second — so on Carbon the reload IS that command, run on the server console.
if (IsCarbon())
return RunReloadCommand("c.reload", name);
try
{
System.Reflection.MethodInfo reload = Interface.Oxide.GetType()
.GetMethod("ReloadPlugin", new[] { typeof(string) });
if (reload != null)
{
reload.Invoke(Interface.Oxide, new object[] { name });
return true;
}
}
catch (Exception ex)
{
Puts($"framework reload of '{name}' threw: {ex.Message}");
}
return RunReloadCommand("oxide.reload", name);
}
/// Runs a reload command on the server console, as an operator at the panel would.
private bool RunReloadCommand(string command, string name)
{
try
{
ConsoleSystem.Run(ConsoleSystem.Option.Server, command, name);
return true;
}
catch (Exception ex)
{
Puts($"console {command} of '{name}' threw: {ex.Message}");
return false;
}
}
private static bool IsCarbon()
{
if (_carbon.HasValue)
return _carbon.Value;
bool found = false;
try
{
foreach (System.Reflection.Assembly loaded in AppDomain.CurrentDomain.GetAssemblies())
{
string assembly = loaded.GetName().Name;
if (assembly == null)
continue;
if (assembly == "Carbon" || assembly.StartsWith("Carbon.", StringComparison.Ordinal))
{
found = true;
break;
}
}
}
catch (Exception)
{
}
_carbon = found;
return found;
}
private static string ConfigRoot()
{
try
{
return Interface.Oxide.ConfigDirectory;
}
catch (Exception)
{
return null;
}
}
///
/// Turns a path from a web form into a path on this host, or refuses it with a reason.
///
///
/// Phase 7 addressed things by name; this phase addresses them by path , which is
/// exactly the change that introduces this bug class. Four refusals, and the last is the
/// one that matters: a resolved path must genuinely sit under the config root, checked
/// after canonicalisation rather than before.
///
///
private static bool ResolveConfigPath(string rel, out string full, out string refusal)
{
full = null;
refusal = null;
if (string.IsNullOrEmpty(rel))
{
refusal = "no path";
return false;
}
if (rel.IndexOf('\0') >= 0 || rel.IndexOf("..", StringComparison.Ordinal) >= 0)
{
refusal = "a path may not step outside the configuration directory";
return false;
}
if (rel.StartsWith("/", StringComparison.Ordinal)
|| rel.StartsWith("\\", StringComparison.Ordinal)
|| Path.IsPathRooted(rel)
|| rel.IndexOf(':') >= 0)
{
refusal = "a path must be relative to the configuration directory";
return false;
}
if (!rel.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
refusal = "only .json files are configuration";
return false;
}
string root = ConfigRoot();
if (string.IsNullOrEmpty(root))
{
refusal = "this framework reports no configuration directory";
return false;
}
string rooted;
try
{
rooted = Path.GetFullPath(root);
full = Path.GetFullPath(Path.Combine(rooted, rel.Replace('/', Path.DirectorySeparatorChar)));
}
catch (Exception ex)
{
refusal = ex.Message;
full = null;
return false;
}
string prefix = rooted.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? rooted
: rooted + Path.DirectorySeparatorChar;
if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
refusal = "that path is not inside the configuration directory";
full = null;
return false;
}
try
{
var info = new FileInfo(full);
if (info.Exists && (info.Attributes & FileAttributes.ReparsePoint) != 0)
{
refusal = "that file is a link, and a link is not followed";
full = null;
return false;
}
}
catch (Exception)
{
}
return true;
}
private static string RelativeConfigPath(string root, string full)
{
string prefix = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? root
: root + Path.DirectorySeparatorChar;
string rel = full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? full.Substring(prefix.Length)
: Path.GetFileName(full);
return rel.Replace(Path.DirectorySeparatorChar, '/').Replace('\\', '/');
}
///
/// Which plugin a file probably belongs to: the folder for a nested file, the file's own
/// name otherwise.
///
///
/// A guess, and it is labelled one all the way to the form. A folder name is
/// convention, not contract. Infer it silently and the failure is the nastiest available
/// here: we reload the wrong plugin, watch OnPluginLoaded fire for it , and
/// report success while the plugin that was actually edited never re-read a thing.
///
///
private static string PluginGuess(string rel)
{
int slash = rel.IndexOf('/');
return slash > 0
? rel.Substring(0, slash)
: Path.GetFileNameWithoutExtension(rel);
}
private List LoadedPlugins()
{
var rows = new List();
Plugin[] all;
try
{
all = plugins.GetAll();
}
catch (Exception)
{
return rows;
}
if (all == null)
return rows;
foreach (Plugin loaded in all)
{
if (loaded == null || string.IsNullOrEmpty(loaded.Name))
continue;
rows.Add(new Dictionary
{
{ "name", loaded.Name },
{ "title", loaded.Title },
{ "version", loaded.Version.ToString() },
{ "author", loaded.Author }
});
}
return rows;
}
///
/// A file's identity for optimistic concurrency: its length and an FNV-1a hash of its
/// text.
///
///
/// Deliberately not a cryptographic digest. Nothing here is a security claim — the site
/// never computes one of these, it only echoes back the one it was given — and
/// System.Security.Cryptography is a namespace whose availability under a plugin
/// compiler is one more thing that would have to be true on two frameworks.
///
///
private static string VersionOf(string text)
{
byte[] bytes = Encoding.UTF8.GetBytes(text ?? string.Empty);
ulong hash = 14695981039346656037UL;
foreach (byte b in bytes)
{
hash ^= b;
hash *= 1099511628211UL;
}
return $"{bytes.Length:x}-{hash:x16}";
}
/// UTF-8 with no byte-order mark, which is what both frameworks write.
private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false);
private static long ToUnixMs(DateTime utc)
{
return (long)(utc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds;
}
// ---- leases (protocol 8, PLAN.md §27) ----
//
// An event does not edit the world. It borrows a value and gives it back. The website
// decides WHEN (core's `core.lease` reads the baseline, reserves the target and schedules
// the restore); this plugin decides WHETHER, holds the deadline, and does the writing.
//
// Four rules, each the answer to a way the obvious version goes wrong:
//
// • **The deadline lives here.** A hold arms a deadline and the plugin restores the
// baseline when it passes, whether or not the website is ever heard from again. Core's
// restore is the normal path; this is the fail-safe that makes an unattended change at
// 04:00 defensible. `holdMs` is authoritative and `untilMs` is display only — an
// absolute time computed on the website and honoured here is a deadline measured
// against two clocks.
// • **Release is compare-and-set, never a blind write.** A value that is no longer what
// the lease applied was moved by somebody on purpose: answer `lease.drifted` with what
// is there now and leave it alone.
// • **Values cross as text and compare parsed.** `1` and `1.0` are one number and two
// strings; a drift check on formatted numbers blames an innocent operator.
// • **A hold longer than the ceiling is refused, never clamped.** A clamp gives the
// website a shorter lease than it believes it has, and the website is the half that
// schedules the restore.
//
// And one that is Rust's own (§27.1): **a convar hold is memory-only and a restart is a
// free restore, but a group permission is persisted by the framework.** So holds are
// written to a file under this plugin's data directory with the boot they were taken
// under, and on load a convar hold from an earlier boot is dropped (the restart already
// gave it back) while a permission hold is re-armed — or restored at once, if its
// deadline passed while the server was down.
///
/// The longest a lease may be held: seven days (D77). The website declares the same
/// ceiling; this one is independent of it and exists for the case where the website
/// is wrong.
///
private const long LeaseMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
/// How often held deadlines are checked. A lease ends within a second of its time.
private const float LeaseCheckSeconds = 1f;
/// The wire key of the one lease that is not a convar.
private const string GroupPermissionKey = "group.permission";
///
/// One convar the website may borrow, and the bounds this plugin holds it to. The
/// website's declaration carries the same bounds so a bad value is refused on a form;
/// these are what is true when the website is wrong.
///
private sealed class LeaseKey
{
public string Key;
public string Family;
public double Min;
public double Max;
public LeaseKey(string key, string family, double min, double max)
{
Key = key;
Family = family;
Min = min;
Max = max;
}
}
///
/// Every convar a lease may name (D74: the game's own convars, nothing Carbon-only).
///
///
/// An allowlist rather than "any convar", because the catalogue is a promise: a key
/// here has been set, observed changing something in a running game, and restored. A
/// value the server reads once at boot applies cleanly, reads back cleanly and does
/// nothing, and nothing but a live walk can tell the two apart (§9).
///
///
private static readonly LeaseKey[] LeaseKeys =
{
new LeaseKey("decay.scale", "decay", 0, 10),
// Every population is per square kilometre — vehicles too, whatever the game's own
// help text says ("active on the server"): on a 9 km² map a value of 3 is a target of
// 27. Each was walked: set, seen moving `spawn.report`'s target, and given back.
new LeaseKey("bear.population", "population", 0, 50),
new LeaseKey("polarbear.population", "population", 0, 50),
new LeaseKey("boar.population", "population", 0, 50),
new LeaseKey("chicken.population", "population", 0, 50),
new LeaseKey("stag.population", "population", 0, 50),
new LeaseKey("wolf2.population", "population", 0, 50),
new LeaseKey("ridablehorse.population", "population", 0, 50),
new LeaseKey("crocodile.population", "population", 0, 50),
new LeaseKey("panther.population", "population", 0, 50),
new LeaseKey("tiger.population", "population", 0, 50),
new LeaseKey("snakehazard.population", "population", 0, 50),
new LeaseKey("minicopter.population", "population", 0, 50),
new LeaseKey("scraptransporthelicopter.population", "population", 0, 50),
new LeaseKey("hotairballoon.population", "population", 0, 50),
new LeaseKey("modularcar.population", "population", 0, 50),
new LeaseKey("motorrowboat.population", "population", 0, 50),
new LeaseKey("rhib.rhibpopulation", "population", 0, 50),
new LeaseKey("traincar.population", "population", 0, 50),
// The population system's own scalars. Only the two MINIMUMS: the game scales between
// min and max by how full the server is, so on a rig with nobody on it the effective
// rate and density ARE the minimums, and `spawn.scalars` showed both moving. The two
// maximums only matter with players online, which no walk has had — so they are not
// lent until one does (§9: a key that cannot be seen doing something is not advertised).
new LeaseKey("spawn.min_rate", "spawn", 0, 10),
new LeaseKey("spawn.min_density", "spawn", 0, 10)
};
///
/// One value this plugin is holding. Persisted as it is, so every field is public and
/// plain.
///
private sealed class LeaseHold
{
public string Key;
/// Null for a convar; group/permission for a group permission.
public string Target;
public string Baseline;
public string Applied;
/// When the hold ends, on this host's clock: taken time plus holdMs .
public long DeadlineMs;
/// The website's absolute deadline, for display. Never compared against.
public long UntilMs;
public string RunId;
public string BootId;
public long TakenMs;
///
/// What the website's permission set said about this pair while the lease held it:
/// null for nothing, else whether the site wants it granted. A group permission
/// held by a lease belongs to the lease until it ends; a sync that arrives in the
/// meantime is recorded here and honoured at release instead of being applied
/// under the lease's feet (§27.3).
///
public bool? SiteWants;
}
/// Holds by . Main thread only.
private Dictionary _leases = new Dictionary();
/// Convars by lowered full name, resolved once. Null until first needed.
private Dictionary _convars;
private Timer _leaseTimer;
/// The last thing a lease did, for rg.leases . Diagnostics only.
private string _lastLeaseSummary = "none yet";
private static string LeaseSlot(string key, string target)
{
return string.IsNullOrEmpty(target) ? key : key + "#" + target;
}
private static LeaseKey FindLeaseKey(string key)
{
foreach (LeaseKey k in LeaseKeys)
{
if (string.Equals(k.Key, key, StringComparison.OrdinalIgnoreCase))
return k;
}
return null;
}
///
/// Main thread. The game's convar, or null when this build has no such convar or it
/// is one the plugin refuses to hold.
///
///
/// A Saved convar is refused (§27.1). The game writes those to
/// serverauto.cfg , so a hold on one would survive the restart that is
/// supposed to end it — and a lease that outlives its own fail-safe is the one thing
/// a lease exists to prevent. None of the allowlist is Saved today; this is
/// what keeps a Facepunch change to that from becoming our bug silently.
///
///
private ConsoleSystem.Command LeaseConvar(string key, out string refusal)
{
refusal = null;
if (_convars == null)
{
_convars = new Dictionary(StringComparer.OrdinalIgnoreCase);
ConsoleSystem.Command[] all = ConsoleSystem.Index.All;
if (all != null)
{
foreach (ConsoleSystem.Command c in all)
{
if (c != null && c.Variable && !string.IsNullOrEmpty(c.FullName))
_convars[c.FullName] = c;
}
}
}
ConsoleSystem.Command command;
if (!_convars.TryGetValue(key, out command) || command == null || command.SetOveride == null)
{
refusal = $"this server has no convar '{key}'";
return null;
}
if (command.Saved)
{
refusal = $"'{key}' is saved to serverauto.cfg, so a restart would not give it back";
return null;
}
return command;
}
private static bool TryParseNumber(string text, out double value)
{
return double.TryParse(
text, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out value);
}
private static bool TryParseFlag(string text, out bool value)
{
value = false;
if (text == null)
return false;
string t = text.Trim().ToLowerInvariant();
if (t == "true" || t == "1")
{
value = true;
return true;
}
if (t == "false" || t == "0")
return true;
return false;
}
///
/// Whether two values are the same value, parsed. A convar reads back through
/// float.ToString() , so the tolerance is a float's, not a double's.
///
private static bool SameLeaseValue(string key, string a, string b)
{
if (key == GroupPermissionKey)
{
bool x, y;
return TryParseFlag(a, out x) && TryParseFlag(b, out y) && x == y;
}
double p, q;
if (!TryParseNumber(a, out p) || !TryParseNumber(b, out q))
return string.Equals(a, b, StringComparison.Ordinal);
return Math.Abs(p - q) <= 1e-4 * Math.Max(1.0, Math.Max(Math.Abs(p), Math.Abs(q)));
}
///
/// Splits a group-permission target at its LAST slash. A permission name never
/// contains one; a group name is a free-form string an operator typed, so it is the
/// half allowed to.
///
private static bool SplitGroupTarget(string target, out string group, out string perm)
{
group = null;
perm = null;
if (string.IsNullOrEmpty(target))
return false;
int slash = target.LastIndexOf('/');
if (slash <= 0 || slash >= target.Length - 1)
return false;
group = Lower(target.Substring(0, slash));
perm = Lower(target.Substring(slash + 1));
return !string.IsNullOrEmpty(group) && !string.IsNullOrEmpty(perm);
}
///
/// Main thread. The value a key holds right now, as text — or null with a reason.
/// is set when the target itself no longer exists, which a
/// release treats as nothing left to give back rather than as a failure.
///
private string ReadLeaseValue(string key, string target, out string refusal, out bool gone)
{
gone = false;
if (key == GroupPermissionKey)
{
string group, perm;
if (!SplitGroupTarget(target, out group, out perm))
{
refusal = "a group permission is named as group/permission";
return null;
}
if (!permission.GroupExists(group))
{
gone = true;
refusal = $"there is no group '{group}' on this server";
return null;
}
refusal = null;
return permission.GroupHasPermission(group, perm) ? "true" : "false";
}
if (FindLeaseKey(key) == null)
{
refusal = $"'{key}' is not a value this server lends";
return null;
}
ConsoleSystem.Command command = LeaseConvar(key, out refusal);
return command == null ? null : command.String;
}
///
/// Main thread. Writes a value, then reads it back — the read-back is the answer, not
/// the call. Returns null on success, or why not.
///
private string WriteLeaseValue(string key, string target, string value)
{
if (key == GroupPermissionKey)
{
string group, perm;
if (!SplitGroupTarget(target, out group, out perm))
return "a group permission is named as group/permission";
bool grant;
if (!TryParseFlag(value, out grant))
return $"'{value}' is not true or false";
// Our own write raises OnGroupPermissionGranted/Revoked. Without this the site
// would be told a human had just edited the store, and it would reconcile against
// the lease's own change (§27.3).
_applyingPermissions = true;
try
{
// **`null`, not `this`, as the owner.** With an owner, Oxide's grant first asks
// `PermissionExists(perm, owner)` — whether THAT plugin registered the name —
// and returns without a word when it did not. Every permission a lease borrows
// belongs to some other plugin, so `this` made every grant a silent no-op. The
// read-back below is what caught it.
if (grant)
permission.GrantGroupPermission(group, perm, null);
else
permission.RevokeGroupPermission(group, perm);
}
finally
{
_applyingPermissions = false;
}
if (permission.GroupHasPermission(group, perm) != grant)
return $"the store did not {(grant ? "grant" : "revoke")} '{perm}' on '{group}'";
return null;
}
string refusal;
ConsoleSystem.Command command = LeaseConvar(key, out refusal);
if (command == null)
return refusal;
command.Set(value);
if (!SameLeaseValue(key, command.String, value))
return $"'{key}' reads back as {command.String} after being set to {value}";
return null;
}
///
/// Main thread. Protocol 8's catalogue: every key this server lends and what it holds
/// now, and every hold in force.
///
///
/// Narrowed by key (and target ) when asked, because a group permission
/// has no single current value — it is one per pair — and a caller asking about one
/// pair wants that pair's row, not the store.
///
///
private void HandleLeaseList(Dictionary cmd, string reqId)
{
string onlyKey = Lower(StringField(cmd, "key"));
string onlyTarget = StringField(cmd, "target");
var rows = new List();
foreach (LeaseKey k in LeaseKeys)
{
if (!string.IsNullOrEmpty(onlyKey) && onlyKey != k.Key)
continue;
rows.Add(LeaseRow(k.Key, k.Family, k.Min, k.Max, null));
}
if (onlyKey == GroupPermissionKey && !string.IsNullOrEmpty(onlyTarget))
rows.Add(LeaseRow(GroupPermissionKey, "group", 0, 1, onlyTarget));
var holds = new List();
foreach (LeaseHold h in _leases.Values)
holds.Add(HoldBody(h));
Dictionary reply = Frame("lease.list", "reply");
reply["eventsEnabled"] = _config != null && _config.EventsEnabled;
reply["maxHoldMs"] = LeaseMaxHoldMs;
reply["leases"] = rows;
reply["holds"] = holds;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
private Dictionary LeaseRow(string key, string family, double min, double max, string target)
{
var row = new Dictionary
{
{ "key", key },
{ "family", family }
};
if (key != GroupPermissionKey)
{
row["min"] = min;
row["max"] = max;
}
else
{
row["target"] = target;
}
string refusal;
bool gone;
string current = ReadLeaseValue(key, target, out refusal, out gone);
if (current != null)
row["current"] = current;
else
row["unreadable"] = refusal;
if (gone)
row["targetGone"] = true;
LeaseHold hold;
bool held = _leases.TryGetValue(LeaseSlot(key, target), out hold);
row["held"] = held;
if (held)
{
row["baseline"] = hold.Baseline;
row["applied"] = hold.Applied;
row["untilMs"] = hold.UntilMs;
row["runId"] = hold.RunId;
}
return row;
}
private static Dictionary HoldBody(LeaseHold h)
{
var body = new Dictionary
{
{ "key", h.Key },
{ "baseline", h.Baseline },
{ "applied", h.Applied },
{ "untilMs", h.UntilMs },
{ "remainingMs", Math.Max(0L, h.DeadlineMs - NowMs()) }
};
if (!string.IsNullOrEmpty(h.Target))
body["target"] = h.Target;
if (!string.IsNullOrEmpty(h.RunId))
body["runId"] = h.RunId;
return body;
}
///
/// Main thread. Take a value for a while.
///
///
/// A key this plugin already holds keeps its ORIGINAL baseline. Core reserves
/// a target before it applies, so a second holder is refused on the website's side;
/// a second apply arriving here means the first one's answer was lost, and core is
/// trying again. The value to give back at the end is still what was there before
/// anybody borrowed it, not the first attempt's value.
///
///
private void HandleLeaseApply(Dictionary cmd, string reqId)
{
string key = Lower(StringField(cmd, "key"));
string target = key == GroupPermissionKey ? StringField(cmd, "target") : null;
string family = Lower(StringField(cmd, "family"));
string value = StringField(cmd, "value");
long holdMs = LongField(cmd, "holdMs");
long untilMs = LongField(cmd, "untilMs");
if (_config == null || !_config.EventsEnabled)
{
EmitLeaseError(reqId, "events-disabled",
"events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
return;
}
if (string.IsNullOrEmpty(key) || value == null)
{
EmitLeaseError(reqId, "malformed", "a lease names a key and a value");
return;
}
if (holdMs <= 0)
{
EmitLeaseError(reqId, "malformed", "a lease is held for a positive number of milliseconds");
return;
}
if (holdMs > LeaseMaxHoldMs)
{
EmitLeaseError(reqId, "too-long",
$"this server holds a lease for at most {LeaseMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}");
return;
}
if (key == GroupPermissionKey)
{
bool flag;
if (!TryParseFlag(value, out flag))
{
EmitLeaseError(reqId, "out-of-range", $"'{value}' is not true or false");
return;
}
string group, perm;
if (!SplitGroupTarget(target, out group, out perm))
{
EmitLeaseError(reqId, "malformed", "a group permission is named as group/permission");
return;
}
// The same pre-check the permission mirror makes (§12.2 rule 1): a grant of a
// name nothing registered returns void and does nothing, and the lease would then
// record a hold on a value that never changed.
if (!permission.PermissionExists(perm))
{
EmitLeaseError(reqId, "unresolved", $"no loaded plugin on this server registers '{perm}'");
return;
}
target = group + "/" + perm;
value = flag ? "true" : "false";
}
else
{
LeaseKey k = FindLeaseKey(key);
if (k == null)
{
EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a value this server lends");
return;
}
if (!string.IsNullOrEmpty(family) && family != k.Family)
{
EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a {family} value");
return;
}
double number;
if (!TryParseNumber(value, out number) || number < k.Min || number > k.Max)
{
EmitLeaseError(reqId, "out-of-range", $"{key} takes {k.Min} to {k.Max}, not {value}");
return;
}
}
string slot = LeaseSlot(key, target);
LeaseHold existing;
_leases.TryGetValue(slot, out existing);
string refusal;
bool gone;
string before = ReadLeaseValue(key, target, out refusal, out gone);
if (before == null)
{
EmitLeaseError(reqId, gone ? "target-gone" : "unreadable", refusal);
return;
}
string failed = WriteLeaseValue(key, target, value);
if (failed != null)
{
// Put back what was there; a half-applied lease is not a state to leave behind.
WriteLeaseValue(key, target, before);
EmitLeaseError(reqId, "refused", failed);
return;
}
long now = NowMs();
var hold = new LeaseHold
{
Key = key,
Target = target,
Baseline = existing != null ? existing.Baseline : before,
Applied = value,
DeadlineMs = now + holdMs,
UntilMs = untilMs > 0 ? untilMs : now + holdMs,
RunId = StringField(cmd, "runId"),
BootId = _bootId,
TakenMs = now,
SiteWants = existing != null ? existing.SiteWants : null
};
_leases[slot] = hold;
SaveLeases();
EnsureLeaseTimer();
_lastLeaseSummary = $"applied {slot}={value} (baseline {hold.Baseline}) for {holdMs / 1000L}s";
Puts($"lease {_lastLeaseSummary}");
Dictionary reply = Frame("lease.ok", "reply");
reply["key"] = key;
if (!string.IsNullOrEmpty(target))
reply["target"] = target;
reply["baseline"] = hold.Baseline;
reply["applied"] = hold.Applied;
reply["untilMs"] = hold.UntilMs;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
///
/// Main thread. Give a value back — compare-and-set.
///
///
/// The comparison is against what the LEASE applied, from this plugin's own record
/// when it has one, else from the website's expected . The record is missing
/// when the deadline already fired, or when a restart dropped a convar hold; both of
/// those have already given the value back, and the compare then finds baseline there
/// and answers success without writing anything.
///
///
private void HandleLeaseRelease(Dictionary cmd, string reqId)
{
string key = Lower(StringField(cmd, "key"));
string target = StringField(cmd, "target");
if (key == GroupPermissionKey)
{
string group, perm;
if (SplitGroupTarget(target, out group, out perm))
target = group + "/" + perm;
}
else
{
target = null;
}
if (string.IsNullOrEmpty(key))
{
EmitLeaseError(reqId, "malformed", "a release names a key");
return;
}
LeaseHold hold;
_leases.TryGetValue(LeaseSlot(key, target), out hold);
string expected = hold != null ? hold.Applied : StringField(cmd, "expected");
string baseline = hold != null ? hold.Baseline : StringField(cmd, "baseline");
Dictionary outcome = Restore(key, target, expected, baseline, hold, "release");
if (reqId != null)
outcome["reqId"] = reqId;
EmitFrame(outcome);
}
///
/// Main thread. The one place a value is given back, by a release or a deadline. The
/// hold is ended either way — a drifted value is left alone, but the lease is over.
///
private Dictionary Restore(
string key, string target, string expected, string baseline, LeaseHold hold, string why)
{
string slot = LeaseSlot(key, target);
// A group permission the site asked for during the hold is given to the site, not
// back to the baseline: the lease borrowed the pair, the site owns what it becomes.
string restoreTo = baseline;
if (hold != null && hold.SiteWants.HasValue)
restoreTo = hold.SiteWants.Value ? "true" : "false";
string refusal;
bool gone;
string current = ReadLeaseValue(key, target, out refusal, out gone);
if (current == null)
{
if (gone)
{
// The group was deleted mid-hold: nothing is owed and nothing is out there.
_leases.Remove(slot);
SaveLeases();
_lastLeaseSummary = $"{why} {slot}: target gone";
Dictionary goneFrame = Frame("lease.ok", "reply");
goneFrame["key"] = key;
goneFrame["targetGone"] = true;
if (!string.IsNullOrEmpty(target))
goneFrame["target"] = target;
return goneFrame;
}
// The value cannot be read, so it cannot be compared. The hold stays: the next
// release, or the next deadline check, tries again.
Dictionary error = Frame("lease.error", "reply");
error["reason"] = "unreadable";
error["message"] = refusal;
return error;
}
if (restoreTo != null && SameLeaseValue(key, current, restoreTo))
{
// Already given back — by a deadline, a restart, or somebody who restored it by
// hand. Nothing to write, and it is a success.
_leases.Remove(slot);
SaveLeases();
_lastLeaseSummary = $"{why} {slot}: already {current}";
return RestoredFrame(key, target, current);
}
if (expected == null || !SameLeaseValue(key, current, expected))
{
_leases.Remove(slot);
SaveLeases();
_lastLeaseSummary = $"{why} {slot}: drifted to {current}, left alone";
Puts($"lease {_lastLeaseSummary}");
Dictionary drifted = Frame("lease.drifted", "reply");
drifted["key"] = key;
if (!string.IsNullOrEmpty(target))
drifted["target"] = target;
drifted["current"] = current;
drifted["expected"] = expected;
return drifted;
}
string failed = restoreTo == null ? "no baseline to restore" : WriteLeaseValue(key, target, restoreTo);
if (failed != null)
{
Dictionary error = Frame("lease.error", "reply");
error["reason"] = "refused";
error["message"] = failed;
return error;
}
_leases.Remove(slot);
SaveLeases();
_lastLeaseSummary = $"{why} {slot}: restored {restoreTo}";
Puts($"lease {_lastLeaseSummary}");
return RestoredFrame(key, target, restoreTo);
}
private Dictionary RestoredFrame(string key, string target, string value)
{
Dictionary ok = Frame("lease.ok", "reply");
ok["key"] = key;
if (!string.IsNullOrEmpty(target))
ok["target"] = target;
ok["restored"] = value;
return ok;
}
///
/// Main thread, every second while anything is held. The fail-safe: a deadline that
/// passes gives the value back without anybody asking, and says so.
///
private void CheckLeaseDeadlines()
{
if (_leases.Count == 0)
{
StopLeaseTimer();
return;
}
long now = NowMs();
List due = null;
foreach (LeaseHold h in _leases.Values)
{
if (h.DeadlineMs <= now)
{
if (due == null)
due = new List();
due.Add(h);
}
}
if (due == null)
return;
foreach (LeaseHold h in due)
Expire(h);
}
private void Expire(LeaseHold hold)
{
Dictionary outcome = Restore(
hold.Key, hold.Target, hold.Applied, hold.Baseline, hold, "expired");
string kind = StringField(outcome, "kind");
// An unreadable value keeps the hold; the next tick tries again rather than
// announcing an expiry that did not happen.
if (kind == "lease.error")
return;
Dictionary frame = Frame("lease.expired", "event");
frame["key"] = hold.Key;
if (!string.IsNullOrEmpty(hold.Target))
frame["target"] = hold.Target;
if (!string.IsNullOrEmpty(hold.RunId))
frame["runId"] = hold.RunId;
frame["drifted"] = kind == "lease.drifted";
object current;
if (outcome.TryGetValue("current", out current))
frame["current"] = current;
EmitFrame(frame);
}
private void EnsureLeaseTimer()
{
if (_leaseTimer == null && _leases.Count > 0)
_leaseTimer = timer.Every(LeaseCheckSeconds, CheckLeaseDeadlines);
}
private void StopLeaseTimer()
{
if (_leaseTimer != null)
{
_leaseTimer.Destroy();
_leaseTimer = null;
}
}
private void EmitLeaseError(string reqId, string reason, string message)
{
Dictionary frame = Frame("lease.error", "reply");
frame["reason"] = reason;
if (!string.IsNullOrEmpty(message))
frame["message"] = message;
if (reqId != null)
frame["reqId"] = reqId;
_lastLeaseSummary = $"refused ({reason}): {message}";
EmitFrame(frame);
}
private string LeasesPath()
{
return Path.Combine(Interface.Oxide.DataDirectory, Name, "leases.json");
}
///
/// Main thread. Written on every change, because the whole point of the file is the
/// crash nobody saw coming. It is a handful of rows.
///
private void SaveLeases()
{
try
{
string path = LeasesPath();
Directory.CreateDirectory(Path.GetDirectoryName(path));
var rows = new List(_leases.Values);
string tmp = path + ".tmp";
File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom);
if (File.Exists(path))
File.Delete(path);
File.Move(tmp, path);
}
catch (Exception ex)
{
Puts($"could not save leases: {Flatten(ex.Message)}");
}
}
///
/// Main thread, once per load, with the world available. Re-arms what is still owed.
///
///
/// A convar hold taken under an earlier BOOT is dropped: the restart put the game's
/// own value back, and re-applying it would resurrect a lease the restart ended. A
/// convar hold from THIS boot survives a plugin reload — the value is still in the
/// game's memory, and a config save from the website must not end an event. A group
/// permission hold is re-armed whatever the boot, because the framework kept it.
///
///
private void LoadLeases()
{
_leases = new Dictionary();
List rows = null;
try
{
string path = LeasesPath();
if (File.Exists(path))
rows = JsonConvert.DeserializeObject>(File.ReadAllText(path));
}
catch (Exception ex)
{
Puts($"could not read leases: {Flatten(ex.Message)}");
}
if (rows == null)
return;
int dropped = 0;
foreach (LeaseHold h in rows)
{
if (h == null || string.IsNullOrEmpty(h.Key))
continue;
if (h.Key != GroupPermissionKey && h.BootId != _bootId)
{
dropped++;
continue;
}
_leases[LeaseSlot(h.Key, h.Target)] = h;
}
if (dropped > 0)
SaveLeases();
Puts($"leases: {_leases.Count} held, {dropped} ended by a restart");
// Due now or later; the first tick restores anything whose deadline passed while the
// server was down.
EnsureLeaseTimer();
}
///
/// Whether a lease holds this group permission, for the permission mirror. A pair a
/// lease has borrowed is the lease's until it ends: a sync does not apply to it, and the
/// inventory lists it under leased so the site never takes it for a hand edit.
///
private LeaseHold LeasedGroupPermission(string group, string perm)
{
LeaseHold hold;
return _leases.TryGetValue(LeaseSlot(GroupPermissionKey, Lower(group) + "/" + Lower(perm)), out hold)
? hold
: null;
}
/// What every hold is doing, from the server console.
[ConsoleCommand("rg.leases")]
private void CmdLeases(ConsoleSystem.Arg arg)
{
var lines = new List
{
$"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " +
$"held={_leases.Count} bootId={_bootId}"
};
foreach (LeaseHold h in _leases.Values)
{
lines.Add(
$" {LeaseSlot(h.Key, h.Target)} applied={h.Applied} baseline={h.Baseline} " +
$"remaining={Math.Max(0L, h.DeadlineMs - NowMs()) / 1000L}s run={h.RunId ?? "-"}");
}
lines.Add($"last: {_lastLeaseSummary}");
arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
}
// ---- the world verbs (protocol 9, PLAN.md §28) ----
//
// Two things an event makes that did not exist — a zone, and a crate or NPC placed in the
// world — and one registry that remembers which run made what. Four rules shape all of it,
// and each came from the rig rather than from a document (§28.1):
//
// * **A restart is not proof a placed thing is gone.** Crates are saved by the game and
// come back with the same net id; NPCs are not saved and do not. So nothing here is
// inferred from a boot id. `world.owned` LOOKS, by net id, every time.
// * **A wipe is proof everything is gone.** The registry is dropped whole on a new save
// and at load when it names another wipe.
// * **ZoneManager forgets.** It holds temporary zones in memory and erases every zone a
// plugin owns when that plugin unloads — so an `oxide.reload` of this bridge deletes
// every event zone mid-run. The registry is the record, and zones are put back from it
// on every load and every ZoneManager reload (D92).
// * **There is no at-most-once store on this bridge.** The registry is keyed by the
// website's idempotency key instead: a repeat answers with the ids the first call made
// and places nothing, which is the whole of what the key is for.
//
// What may be erased is decided by the registry and never by ZoneManager, whose
// `EraseTemporaryZone` deletes an operator's own hand-made zone and returns true (§12.4).
/// The ceiling on crates per step. EventsMaxCrates may lower it, never raise it.
private const int WorldHardMaxCrates = 25;
/// The ceiling on NPCs per step (D95).
private const int WorldHardMaxNpcs = 20;
/// How far a group may be scattered around its point, in metres.
private const float WorldMaxSpread = 50f;
/// How far from a monument's centre a step may put its point, in metres.
private const float WorldMaxOffset = 150f;
private const float ZoneMinRadius = 5f;
private const float ZoneMaxRadius = 150f;
///
/// The longest a zone may stand: seven days, the lease ceiling (D96). A zone is priced
/// by its minutes, so it has a deadline, and the deadline is kept HERE — a zone never
/// outlives its event even when the website that opened it has gone.
///
private const long ZoneMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
private const string WorldKindZone = "zone";
private const string WorldKindCrate = "crate";
private const string WorldKindNpc = "npc";
/// One thing this plugin will place, by the key the website names it with.
private sealed class WorldPrefab
{
public readonly string Key;
public readonly string Kind;
public readonly string Path;
public readonly string Label;
public WorldPrefab(string key, string kind, string path, string label)
{
Key = key;
Kind = kind;
Path = path;
Label = label;
}
}
///
/// The allowlist (D88), and the only one: the sidecar forwards and the website offers
/// what this answers. Crates and NPCs, never vehicles — a player can take a vehicle, and
/// a revert would kill something a person may be sitting in. Every path here was
/// spawned on the rig and reported its type before it was listed (§28.1).
///
private static readonly WorldPrefab[] WorldPrefabs =
{
new WorldPrefab("crate.basic", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_basic.prefab", "Basic crate"),
new WorldPrefab("crate.normal", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal.prefab", "Military crate"),
new WorldPrefab("crate.normal2", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal_2.prefab", "Crate"),
new WorldPrefab("crate.elite", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_elite.prefab", "Elite crate"),
new WorldPrefab("crate.tools", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_tools.prefab", "Tool box"),
new WorldPrefab("crate.hackable", WorldKindCrate, "assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab", "Locked crate (hackable)"),
new WorldPrefab("supply.drop", WorldKindCrate, "assets/prefabs/misc/supply drop/supply_drop.prefab", "Supply drop"),
new WorldPrefab("barrel.loot", WorldKindCrate, "assets/bundled/prefabs/radtown/loot_barrel_1.prefab", "Loot barrel"),
new WorldPrefab("npc.scientist", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab", "Scientist"),
new WorldPrefab("npc.scientist.heavy", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab", "Heavy scientist"),
new WorldPrefab("npc.scientist.tethered", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roamtethered.prefab", "Scientist (stays put)"),
new WorldPrefab("npc.bandit.guard", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/banditguard/npc_bandit_guard.prefab", "Bandit guard")
};
/// One thing an event made. Persisted as it is, so every field is public and plain.
private sealed class WorldEntry
{
/// The net id for an entity, as text; the zone id for a zone.
public string Id;
public string Kind;
/// The allowlist key, for a crate or an NPC.
public string Prefab;
public string RunId;
/// The website's idempotency key for the step that made it.
public string Key;
public float X, Y, Z;
/// A zone's radius and name, for re-creating it.
public float Radius;
public string Name;
///
/// A zone's ZoneManager flags and settings (PLAN_REDESIGNS §3.1), kept so a re-created
/// zone gets them back. Null on a zone from before protocol 13's options.
///
public List Flags;
public Dictionary Settings;
///