From 210c35f904a34e9e4b5f0051437ce2c1c7f66219 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 00:26:11 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(bridge):=20protocol=2011=20=E2=80=94?= =?UTF-8?q?=20the=20map=20(phase=2014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four verbs (PLAN.md §30.2), all outside EventsEnabled because none of them changes the world: - map.info: the map key, the picture's source (the Rust+ cache, a render kept on disk, or none), its size, SHA-256 and slice count, the game's own grid, and the monuments the in-game map shows. The cache is read by reflection by name only, never enumerated (it holds the Rust+ secret). The hash is computed on the thread pool. - map.fetch: one 512 KiB base64 slice, encoded on the thread pool, refused `stale` when the key or hash moved. - map.render: the admin fallback (D109). Answers `accepted` at once and renders on a later frame, keeping the result in data/RunicGateway/map.jpg beside its key so a restart within the map does not render again. - map.live: every layer at once (world events, event-owned zones/crates/NPCs, players and sleepers, TCs and player vending machines as positions only). The sets are kept by OnEntitySpawned/OnEntityKill and seeded by one sweep at OnServerInitialized. MapMaxSleepers/MapMaxBases bound the two layers. Also fixes Grid() (D119): it used a 146.3 m cell, but the game fits whole cells to the world (20 x 150 m on a 3000 map), so rows could be one off. It now asks MapHelper.PositionToString, the in-game map's own labeller. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 5 +- overlay/oxide/plugins/RunicGateway.cs | 1063 +++++++++++++++++++++++-- 2 files changed, 1017 insertions(+), 51 deletions(-) diff --git a/overlay.toml b/overlay.toml index 7b6c852..89d7c24 100644 --- a/overlay.toml +++ b/overlay.toml @@ -29,8 +29,9 @@ # Current: 10 — the transport, the read path, identity, the permission mirror, # configuration from the site, first-party clans, a raid frame that names who # lives there, the event leases, the world verbs events place and give back, and -# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16). -protocol = 10 +# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16), +# and the map: its picture and everything that moves on it (PROTOCOL.md §17). +protocol = 11 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index da708ad..cc4e961 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -57,7 +57,7 @@ namespace Oxide.Plugins /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. /// - private const int ProtocolVersion = 10; + private const int ProtocolVersion = 11; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -95,12 +95,6 @@ namespace Oxide.Plugins /// private const float BoardCadenceSeconds = 60f; - /// - /// The side of one map grid cell, in world units. Rust's own grid, as every community - /// map and every server console uses it. - /// - private const float GridCellSize = 146.3f; - private static readonly ConcurrentQueue Outbound = new ConcurrentQueue(); private static readonly AutoResetEvent Wake = new AutoResetEvent(false); @@ -239,7 +233,8 @@ namespace Oxide.Plugins "OnPluginLoaded", "OnPluginUnloaded", "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded", "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged", - "OnKitRedeemed" + "OnKitRedeemed", + "OnEntitySpawned", "OnEntityKill" }; private void Init() @@ -407,6 +402,15 @@ namespace Oxide.Plugins /// The longest line an event or a news post may say in chat, in characters. public int EventsMaxChat = ChatHardMax; + + /// + /// 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; } protected override void LoadDefaultConfig() @@ -443,9 +447,12 @@ namespace Oxide.Plugins _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.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["EventsMaxRecipients"] == null || Config["EventsMaxTallies"] == null || Config["EventsMaxChat"] == null || + Config["MapMaxSleepers"] == null || Config["MapMaxBases"] == null) Config.WriteObject(_config, true); } @@ -940,6 +947,23 @@ namespace Oxide.Plugins HandleChatSay(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; + default: Puts($"unknown command '{verb}'"); break; @@ -1206,7 +1230,7 @@ namespace Oxide.Plugins /// /// An entity's position as Rust's own map reference (H7), or null if it has no - /// position to read. A reference, never a coordinate: a grid cell is 146.3 units + /// 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. /// @@ -1218,51 +1242,31 @@ namespace Oxide.Plugins 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) { - float size = ConVar.Server.worldsize; - - if (size <= 0f) + if (ConVar.Server.worldsize <= 0) return null; - float half = size / 2f; - int columns = (int)Math.Ceiling(size / GridCellSize); - - int x = (int)((position.x + half) / GridCellSize); - - // Rows are numbered from the NORTH edge downwards, which is why this is a subtraction - // and not the same expression as the column. Getting it wrong mirrors every grid on - // the map and is invisible without a second opinion. - int z = (int)((half - position.z) / GridCellSize); - - x = Clamp(x, 0, columns - 1); - z = Clamp(z, 0, columns - 1); - - return Column(x) + z.ToString(System.Globalization.CultureInfo.InvariantCulture); - } - - private static int Clamp(int value, int low, int high) - { - if (high < low) - return low; - - return value < low ? low : (value > high ? high : value); - } - - /// A column index as Rust spells it: 0 is A, 25 is Z, 26 is AA. - private static string Column(int index) - { - string name = string.Empty; - int n = index + 1; - - while (n > 0) + try { - int remainder = (n - 1) % 26; - name = (char)('A' + remainder) + name; - n = (n - 1) / 26; + return MapHelper.PositionToString(position); + } + catch (Exception) + { + return null; } - - return name; } // ---- identity of the world ---- @@ -1355,6 +1359,9 @@ namespace Oxide.Plugins DropWorldFromOtherWipes(); RecreateZones("load"); + // The map's sets are seeded here for the same reason: a hotload hears no spawns. + SeedMapSets(); + // 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. @@ -8287,6 +8294,964 @@ namespace Oxide.Plugins arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray())); } + // ---- the map (protocol 11, PLAN.md §30) ---- + // + // The picture of this wipe's map and everything that moves on it, in four verbs. Five rules + // shape the section, and each came from the rig (§30.0): + // + // * **Nothing renders unasked.** The game's renderer holds the main thread for 8.5 s on a + // 3000 map and 21 s at full scale. The picture comes from Rust+'s cache, which the game + // fills at boot when `app.port` is set; only an admin's `map.render` ever draws one, and + // then on a later frame than the one that answered (D109). + // * **The picture is read, never copied, on the main thread.** Hashing it and cutting it + // into base64 slices happen on the thread pool. The bytes are never mutated once made, so + // handing the array to another thread is safe. + // * **No sweep per ask.** What moves is kept in sets by `OnEntitySpawned` / `OnEntityKill`, + // seeded by one sweep when the world is ready. An ask copies positions out of the sets. + // * **Filtering is the website's job** (§8.5). `map.live` answers every layer, and which of + // them a viewer may see is decided by the module, never here. + // * **Every map verb is outside `EventsEnabled`.** Nothing here changes the world. + + /// + /// One slice of the picture on the wire. Base64 grows it by a third, and the result must + /// fit the game link's 1 MiB line cap with its envelope (the asset bridge's phase 7 shape). + /// + private const int MapChunkBytes = 512 * 1024; + + /// + /// The render's geometry, which is the Rust+ cache's geometry exactly: half scale, and an + /// ocean margin of 500 PIXELS on each side, unscaled (§30.0). + /// + private const float MapRenderScale = 0.5f; + + private const int MapOceanMargin = 500; + + /// The default most sleepers, and the most bases, one live answer carries. + private const int MapDefaultMax = 2000; + + private const string MapSourceCompanion = "companion"; + private const string MapSourceRendered = "rendered"; + private const string MapSourceNone = "none"; + + /// What this plugin knows about the picture it can serve. Replaced whole, never edited. + private sealed class MapPicture + { + public string MapKey; + public string Source; + public byte[] Bytes; + public int Width; + public int Height; + public string Background; + + /// Null until the thread pool has hashed . + public volatile string Sha256; + } + + /// A rendered picture's companion file: which map it is a picture of. + private sealed class RenderedMapMeta + { + public string MapKey; + public string Background; + public long RenderedMs; + public long TookMs; + } + + /// The current picture, or null. Main thread writes; the pool only reads what it is handed. + private MapPicture _mapPicture; + + /// A picture this plugin rendered for the current map, read from disk at most once per map. + private MapPicture _mapRendered; + + private string _mapRenderedCheckedFor; + + /// Set between a `map.render` being accepted and the render finishing. + private bool _mapRendering; + + private readonly HashSet _mapWorld = new HashSet(); + private readonly HashSet _mapBases = new HashSet(); + + private string _lastMapSummary = "none yet"; + private long _mapLiveAsks, _mapLiveLastMs, _mapLiveMaxMs; + + private int MapMaxSleepers() + { + return _config != null ? _config.MapMaxSleepers : MapDefaultMax; + } + + private int MapMaxBases() + { + return _config != null ? _config.MapMaxBases : MapDefaultMax; + } + + /// + /// Which map this is. A procedural map is its size, seed and salt; a custom map is the + /// hash of the URL it was downloaded from. The key says WHICH map, and the picture's own + /// SHA-256 says which picture of it (§30.2). + /// + private static string MapKey() + { + if (World.Procedural) + { + return string.Format(System.Globalization.CultureInfo.InvariantCulture, + "{0}.{1}.{2}", World.Size, World.Seed, World.Salt); + } + + string url = !string.IsNullOrEmpty(World.Url) ? World.Url : ConVar.Server.levelurl; + return "custom." + Sha256Hex(Encoding.UTF8.GetBytes(url ?? string.Empty)); + } + + private static string Sha256Hex(byte[] bytes) + { + using (var sha = System.Security.Cryptography.SHA256.Create()) + { + byte[] hash = sha.ComputeHash(bytes); + var sb = new StringBuilder(hash.Length * 2); + + foreach (byte b in hash) + sb.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)); + + return sb.ToString(); + } + } + + /// + /// A JPEG's own width and height, read from its frame header. The picture says what size + /// it is; a private field that says so as well is a second opinion that can drift. + /// + private static bool JpegSize(byte[] bytes, out int width, out int height) + { + width = 0; + height = 0; + + if (bytes == null || bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8) + return false; + + int i = 2; + + while (i + 9 < bytes.Length) + { + if (bytes[i] != 0xFF) + return false; + + byte marker = bytes[i + 1]; + int length = (bytes[i + 2] << 8) | bytes[i + 3]; + + // SOF0..SOF15 carry the frame's size — except DHT (C4), JPG (C8) and DAC (CC), + // which share the range and do not. + if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC) + { + height = (bytes[i + 5] << 8) | bytes[i + 6]; + width = (bytes[i + 7] << 8) | bytes[i + 8]; + return width > 0 && height > 0; + } + + i += 2 + length; + } + + return false; + } + + private static System.Reflection.PropertyInfo _companionImage; + private static System.Reflection.FieldInfo _companionBackground; + private static bool _companionResolved; + + /// + /// Main thread. The Rust+ cache's picture, or null. Read by reflection, by NAME, and only + /// the two members this needs: CompanionServer types hold the server's Rust+ + /// secret, and a probe that enumerated one printed it into the log (§30.0). A Facepunch + /// rename makes this answer null, which is none — never an exception. + /// + private static byte[] CompanionImage(out string background) + { + background = null; + + try + { + if (!_companionResolved) + { + _companionResolved = true; + Type map = typeof(BasePlayer).Assembly.GetType("CompanionServer.Handlers.Map", false); + + if (map != null) + { + const System.Reflection.BindingFlags Static = + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Static; + + _companionImage = map.GetProperty("ImageData", Static); + _companionBackground = map.GetField("_background", Static); + } + } + + if (_companionImage == null) + return null; + + byte[] bytes = _companionImage.GetValue(null, null) as byte[]; + + if (bytes == null || bytes.Length == 0) + return null; + + if (_companionBackground != null) + background = _companionBackground.GetValue(null) as string; + + return bytes; + } + catch (Exception) + { + return null; + } + } + + private string MapDir() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name); + } + + /// + /// Main thread. A picture this plugin rendered earlier for THIS map, from disk. Read once + /// per map key, so a restart within the map does not ask for another render (§30.2). + /// + private MapPicture RenderedFromDisk(string key) + { + if (_mapRenderedCheckedFor == key) + return _mapRendered; + + _mapRenderedCheckedFor = key; + _mapRendered = null; + + try + { + string meta = Path.Combine(MapDir(), "map.json"); + string jpeg = Path.Combine(MapDir(), "map.jpg"); + + if (!File.Exists(meta) || !File.Exists(jpeg)) + return null; + + RenderedMapMeta m = JsonConvert.DeserializeObject(File.ReadAllText(meta)); + + if (m == null || m.MapKey != key) + return null; + + byte[] bytes = File.ReadAllBytes(jpeg); + int w, h; + + if (!JpegSize(bytes, out w, out h)) + return null; + + _mapRendered = new MapPicture + { + MapKey = key, + Source = MapSourceRendered, + Bytes = bytes, + Width = w, + Height = h, + Background = m.Background + }; + } + catch (Exception ex) + { + Puts($"map: could not read the rendered picture: {Flatten(ex.Message)}"); + } + + return _mapRendered; + } + + /// + /// Main thread. The picture to serve now: the Rust+ cache when it has one, else a render + /// of this map kept on disk, else null. Rebuilt only when the key or the cache's array + /// changes, and hashed on the pool the first time a picture is seen. + /// + private MapPicture CurrentPicture() + { + string key = MapKey(); + string background; + byte[] companion = CompanionImage(out background); + MapPicture current = _mapPicture; + + if (companion != null) + { + if (current != null && current.MapKey == key && ReferenceEquals(current.Bytes, companion)) + return current; + + int w, h; + + if (!JpegSize(companion, out w, out h)) + return null; + + current = new MapPicture + { + MapKey = key, + Source = MapSourceCompanion, + Bytes = companion, + Width = w, + Height = h, + Background = background + }; + } + else + { + current = RenderedFromDisk(key); + } + + _mapPicture = current; + HashLater(current); + return current; + } + + private static void HashLater(MapPicture picture) + { + if (picture == null || picture.Sha256 != null) + return; + + ThreadPool.QueueUserWorkItem(_ => + { + try + { + picture.Sha256 = Sha256Hex(picture.Bytes); + } + catch (Exception) + { + // Left null: `map.info` keeps answering `hashing`, and a later ask tries again. + } + }); + } + + /// + /// Main thread. The game's own grid, from its own function: how many cells a side has and + /// how wide one is. MapHelper is what the in-game map labels with, so the website + /// draws the game's grid by construction rather than from a constant somebody measured + /// once (§30.3). + /// + private static void GameGrid(out int cells, out float cellSize) + { + float size = ConVar.Server.worldsize; + float half = size / 2f; + + Vector2i last = MapHelper.PositionToGrid(new UnityEngine.Vector3(half - 0.5f, 0f, -half + 0.5f)); + cells = Math.Max(1, last.x + 1); + cellSize = size / cells; + } + + /// + /// Main thread. The monuments the in-game map shows: those with a display phrase, in the + /// same kind#n vocabulary as world.monuments, so a monument a step names and + /// a monument on the map are the same token. + /// + private static List MapMonuments() + { + var rows = new List(); + + foreach (KeyValuePair> kv in MonumentGroups()) + { + for (int i = 0; i < kv.Value.Count; i++) + { + MonumentInfo m = kv.Value[i]; + string phrase = m.displayPhrase != null ? m.displayPhrase.english : null; + + if (string.IsNullOrEmpty(phrase)) + continue; + + UnityEngine.Vector3 p = m.transform.position; + + rows.Add(new Dictionary + { + { "value", kv.Value.Count > 1 ? kv.Key + "#" + (i + 1) : kv.Key }, + { "kind", kv.Key }, + { "label", phrase }, + { "x", Math.Round(p.x, 1) }, + { "z", Math.Round(p.z, 1) }, + { "grid", MapHelper.PositionToString(p) } + }); + } + } + + return rows; + } + + /// + /// Stage one: what exists. Reads the cache, and hashes it on the pool once per map; no + /// other work. sha256 is absent while the hash is still being computed, and the + /// answer then says hashing so the caller asks again. + /// + private void HandleMapInfo(string reqId) + { + if (!MapReady(reqId)) + return; + + MapPicture picture = CurrentPicture(); + int cells; + float cellSize; + GameGrid(out cells, out cellSize); + + Dictionary reply = Frame("map.info", "reply"); + reply["mapKey"] = MapKey(); + reply["worldSize"] = ConVar.Server.worldsize; + reply["oceanMargin"] = MapOceanMargin; + reply["gridCells"] = cells; + reply["gridCellSize"] = Math.Round(cellSize, 3); + reply["monuments"] = MapMonuments(); + reply["rendering"] = _mapRendering; + + if (picture == null) + { + reply["source"] = MapSourceNone; + } + else + { + reply["source"] = picture.Source; + reply["bytes"] = picture.Bytes.Length; + reply["width"] = picture.Width; + reply["height"] = picture.Height; + reply["chunks"] = (picture.Bytes.Length + MapChunkBytes - 1) / MapChunkBytes; + reply["chunkBytes"] = MapChunkBytes; + + if (!string.IsNullOrEmpty(picture.Background)) + reply["background"] = picture.Background; + + string hash = picture.Sha256; + + if (hash != null) + reply["sha256"] = hash; + else + reply["hashing"] = true; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Stage two: one slice of the picture. Refused stale when the map or the picture + /// moved since the caller asked stage one, so a fetch that straddles a wipe cannot splice + /// two maps. The slice is base64'd and serialized on the pool: at 512 KiB that is work the + /// main thread has no business doing. + /// + private void HandleMapFetch(Dictionary cmd, string reqId) + { + if (!MapReady(reqId)) + return; + + if (_mapRendering) + { + EmitMapError(reqId, "busy", "the server is drawing its map; ask again when map.info says it is done"); + return; + } + + string key = StringField(cmd, "mapKey"); + string sha = StringField(cmd, "sha256"); + int n = IntField(cmd, "chunk"); + MapPicture picture = CurrentPicture(); + + if (picture == null) + { + EmitMapError(reqId, "no-picture", "this server has no picture of its map"); + return; + } + + string hash = picture.Sha256; + + if (key != picture.MapKey || hash == null || !string.Equals(sha, hash, StringComparison.OrdinalIgnoreCase)) + { + EmitMapError(reqId, "stale", "the map or its picture changed since it was described; ask map.info again"); + return; + } + + int chunks = (picture.Bytes.Length + MapChunkBytes - 1) / MapChunkBytes; + + if (n < 0 || n >= chunks) + { + EmitMapError(reqId, "malformed", $"this picture has {chunks} slices, numbered from 0"); + return; + } + + Dictionary reply = Frame("map.chunk", "reply"); + reply["mapKey"] = picture.MapKey; + reply["sha256"] = hash; + reply["chunk"] = n; + reply["chunks"] = chunks; + + if (reqId != null) + reply["reqId"] = reqId; + + byte[] bytes = picture.Bytes; + int offset = n * MapChunkBytes; + int length = Math.Min(MapChunkBytes, bytes.Length - offset); + + ThreadPool.QueueUserWorkItem(_ => + { + try + { + reply["data"] = Convert.ToBase64String(bytes, offset, length); + Emit(JsonConvert.SerializeObject(reply, Formatting.None)); + } + catch (Exception ex) + { + // Nothing is sent, and the caller's ask times out: a slice is re-asked, never trusted half-made. + Interface.Oxide.NextTick(() => Puts($"map: could not encode slice {n}: {Flatten(ex.Message)}")); + } + }); + } + + /// + /// The admin fallback (D109): draw the map with the game's own renderer, once. Refused + /// unless there is no picture at all, and while the world is loading. Answers + /// accepted AT ONCE and renders on a later frame, because the render outlasts the + /// sidecar's reply budget on any map larger than the rig's; the caller polls + /// map.info until its source is rendered. + /// + private void HandleMapRender(Dictionary cmd, string reqId) + { + if (!MapReady(reqId)) + return; + + if (_mapRendering) + { + EmitMapError(reqId, "busy", "the server is already drawing its map"); + return; + } + + MapPicture picture = CurrentPicture(); + + if (picture != null) + { + EmitMapError(reqId, "has-picture", + $"this server already has a picture of its map ({picture.Source}); nothing needs drawing"); + return; + } + + _mapRendering = true; + string actor = StringField(cmd, "actor"); + string key = MapKey(); + + Dictionary reply = Frame("map.render", "reply"); + reply["accepted"] = true; + reply["mapKey"] = key; + reply["worldSize"] = ConVar.Server.worldsize; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + + Puts($"map: drawing the map for {key} at an admin's request{(string.IsNullOrEmpty(actor) ? "" : " (" + actor + ")")}; " + + "the server will stall while it does"); + + // A timer rather than NextTick, so the reply above is on the wire before the stall. + timer.Once(0.5f, () => RenderMap(key)); + } + + /// Main thread, deliberately: the renderer reads the terrain, which is main-thread-only. + private void RenderMap(string key) + { + var clock = System.Diagnostics.Stopwatch.StartNew(); + + try + { + if (MapKey() != key) + { + _lastMapSummary = "a render was abandoned: the map changed before it started"; + return; + } + + System.Reflection.MethodInfo render = null; + + foreach (System.Reflection.MethodInfo m in typeof(MapImageRenderer).GetMethods( + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)) + { + if (m.Name == "Render" && m.ReturnType == typeof(byte[])) + { + render = m; + break; + } + } + + if (render == null) + { + _lastMapSummary = "a render failed: the game has no MapImageRenderer.Render"; + Puts("map: " + _lastMapSummary); + return; + } + + // Called by parameter NAME, as the probe did: a reordered signature must not swap + // the scale for the margin. + System.Reflection.ParameterInfo[] ps = render.GetParameters(); + object[] args = new object[ps.Length]; + + for (int i = 0; i < ps.Length; i++) + { + if (ps[i].IsOut) + continue; + + if (ps[i].Name == "scale") + args[i] = MapRenderScale; + else if (ps[i].Name == "oceanMargin") + args[i] = MapOceanMargin; + else if (ps[i].Name == "lossy") + args[i] = true; + else if (ps[i].Name == "transparent") + args[i] = false; + else if (ps[i].HasDefaultValue) + args[i] = ps[i].DefaultValue; + } + + byte[] bytes = render.Invoke(null, args) as byte[]; + clock.Stop(); + + int w, h; + + if (bytes == null || !JpegSize(bytes, out w, out h)) + { + _lastMapSummary = $"a render produced no picture after {clock.ElapsedMilliseconds} ms"; + Puts("map: " + _lastMapSummary); + return; + } + + string background = null; + + for (int i = 0; i < ps.Length; i++) + { + if (ps[i].IsOut && args[i] is UnityEngine.Color) + background = "#" + UnityEngine.ColorUtility.ToHtmlStringRGB((UnityEngine.Color)args[i]); + } + + Directory.CreateDirectory(MapDir()); + File.WriteAllBytes(Path.Combine(MapDir(), "map.jpg"), bytes); + File.WriteAllText(Path.Combine(MapDir(), "map.json"), JsonConvert.SerializeObject(new RenderedMapMeta + { + MapKey = key, + Background = background, + RenderedMs = NowMs(), + TookMs = clock.ElapsedMilliseconds + }, Formatting.Indented)); + + // Read back through the path a restart would take, so what is served now is exactly + // what a restart would serve. + _mapRenderedCheckedFor = null; + _mapPicture = null; + CurrentPicture(); + + _lastMapSummary = $"drew the map for {key}: {w}x{h}, {bytes.Length} bytes, {clock.ElapsedMilliseconds} ms of stall"; + Puts("map: " + _lastMapSummary); + } + catch (Exception ex) + { + _lastMapSummary = $"a render threw after {clock.ElapsedMilliseconds} ms: {Flatten((ex.InnerException ?? ex).Message)}"; + Puts("map: " + _lastMapSummary); + } + finally + { + _mapRendering = false; + } + } + + /// Refuses a map command until the world is loaded, as a map.error. + private bool MapReady(string reqId) + { + if (_worldReady) + return true; + + EmitMapError(reqId, "not-ready", "the server is still loading its world; ask again once it has started"); + return false; + } + + // ── What moves ──────────────────────────────────────────────────────────── + + /// The world-event types the map follows (D112). A cheap type test: this runs on every spawn. + private static bool IsMapWorldThing(BaseNetworkable entity) + { + return entity is CargoShip || entity is PatrolHelicopter || entity is CH47Helicopter || + entity is BradleyAPC || entity is SupplyDrop || entity is HackableLockedCrate; + } + + /// Tool cupboards, and a player's vending machine — never a monument's (NPC) one. + private static bool IsMapBase(BaseNetworkable entity) + { + return entity is BuildingPrivlidge || (entity is VendingMachine && !(entity is NPCVendingMachine)); + } + + /// + /// Runs on EVERY entity spawn on the server, tens of thousands at boot: two type tests and, + /// rarely, a set insert. Void, so it can never veto a spawn. + /// + private void OnEntitySpawned(BaseNetworkable entity) + { + MarkHook("OnEntitySpawned"); + + if (entity == null) + return; + + if (IsMapWorldThing(entity)) + _mapWorld.Add((BaseEntity)entity); + else if (IsMapBase(entity)) + _mapBases.Add((BaseEntity)entity); + } + + private void OnEntityKill(BaseNetworkable entity) + { + MarkHook("OnEntityKill"); + + var e = entity as BaseEntity; + + if (e == null) + return; + + if (!_mapWorld.Remove(e)) + _mapBases.Remove(e); + } + + /// + /// Main thread, once per load when the world is there: the one sweep that seeds the sets. + /// The spawn hook keeps them after that; a hotload has no spawns to hear, which is why this + /// runs at all. + /// + private void SeedMapSets() + { + var clock = System.Diagnostics.Stopwatch.StartNew(); + _mapWorld.Clear(); + _mapBases.Clear(); + + foreach (BaseNetworkable n in BaseNetworkable.serverEntities) + { + if (IsMapWorldThing(n)) + _mapWorld.Add((BaseEntity)n); + else if (IsMapBase(n)) + _mapBases.Add((BaseEntity)n); + } + + clock.Stop(); + Puts($"map: following {_mapWorld.Count} world things and {_mapBases.Count} bases ({clock.ElapsedMilliseconds} ms sweep)"); + + // Hashed now rather than at the first ask, so the first `map.info` usually has it. + CurrentPicture(); + } + + private static bool Gone(BaseEntity e) + { + return e == null || e.IsDestroyed || e.transform == null; + } + + private static Dictionary At(UnityEngine.Vector3 p) + { + return new Dictionary + { + { "x", Math.Round(p.x, 1) }, + { "z", Math.Round(p.z, 1) } + }; + } + + private static string WorldKind(BaseEntity e) + { + if (e is CargoShip) return "cargo"; + if (e is PatrolHelicopter) return "heli"; + if (e is CH47Helicopter) return "chinook"; + if (e is BradleyAPC) return "bradley"; + if (e is SupplyDrop) return "supply"; + return "crate"; + } + + /// + /// What moves, every layer at once. Positions only, and nothing a player typed about a + /// base: no owner, no authorised list, no shop name (§30.5). The two unbounded layers — + /// sleepers and bases — stop at their bound and say so with truncated; a read has + /// nobody to refuse. + /// + private void HandleMapLive(string reqId) + { + if (!MapReady(reqId)) + return; + + var clock = System.Diagnostics.Stopwatch.StartNew(); + + var world = new List(); + var dead = new List(); + + foreach (BaseEntity e in _mapWorld) + { + if (Gone(e)) + { + dead.Add(e); + continue; + } + + Dictionary row = At(e.transform.position); + row["kind"] = WorldKind(e); + + var crate = e as HackableLockedCrate; + + if (crate != null) + { + if (crate.IsFullyHacked()) + row["hacked"] = true; + else if (crate.IsBeingHacked()) + row["hackLeftSec"] = (int)Math.Max(0f, HackableLockedCrate.requiredHackSeconds - crate.hackSeconds); + } + + world.Add(row); + } + + foreach (BaseEntity e in dead) + _mapWorld.Remove(e); + + var events = new List(); + + foreach (WorldEntry w in _world.Values) + { + Dictionary row; + + if (w.Kind == WorldKindZone) + { + row = At(new UnityEngine.Vector3(w.X, 0f, w.Z)); + row["radius"] = Math.Round(w.Radius, 1); + + if (!string.IsNullOrEmpty(w.Name)) + row["name"] = w.Name; + } + else + { + // Where it is NOW: an NPC walks, and a crate can be knocked about. + var found = FindEntity(w.Id) as BaseEntity; + + if (Gone(found)) + continue; + + row = At(found.transform.position); + row["prefab"] = w.Prefab; + } + + row["kind"] = w.Kind; + row["runId"] = w.RunId; + events.Add(row); + } + + var players = new List(); + + foreach (BasePlayer p in BasePlayer.activePlayerList) + { + if (Gone(p) || p.IsDead()) + continue; + + Dictionary row = At(p.transform.position); + row["steamId"] = p.UserIDString; + row["name"] = p.displayName; + row["sleeping"] = p.IsSleeping(); + row["online"] = true; + players.Add(row); + } + + int maxSleepers = MapMaxSleepers(); + int sleepers = 0; + bool sleepersTruncated = false; + + foreach (BasePlayer p in BasePlayer.sleepingPlayerList) + { + if (Gone(p) || p.IsDead() || p.IsConnected) + continue; + + if (sleepers >= maxSleepers) + { + sleepersTruncated = true; + break; + } + + Dictionary row = At(p.transform.position); + row["steamId"] = p.UserIDString; + row["name"] = p.displayName; + row["sleeping"] = true; + row["online"] = false; + players.Add(row); + sleepers++; + } + + int maxBases = MapMaxBases(); + var bases = new List(); + bool basesTruncated = false; + dead.Clear(); + + foreach (BaseEntity e in _mapBases) + { + if (Gone(e)) + { + dead.Add(e); + continue; + } + + if (bases.Count >= maxBases) + { + basesTruncated = true; + continue; + } + + Dictionary row = At(e.transform.position); + row["kind"] = e is BuildingPrivlidge ? "tc" : "vending"; + bases.Add(row); + } + + foreach (BaseEntity e in dead) + _mapBases.Remove(e); + + clock.Stop(); + _mapLiveAsks++; + _mapLiveLastMs = clock.ElapsedMilliseconds; + _mapLiveMaxMs = Math.Max(_mapLiveMaxMs, _mapLiveLastMs); + + Dictionary reply = Frame("map.live", "reply"); + reply["mapKey"] = MapKey(); + reply["world"] = world; + reply["events"] = events; + reply["players"] = players; + reply["bases"] = bases; + reply["tookMs"] = clock.ElapsedMilliseconds; + + if (sleepersTruncated) + reply["playersTruncated"] = true; + + if (basesTruncated) + reply["basesTruncated"] = true; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// The map's one refusal frame. Every reason is a word the module can switch on. + private void EmitMapError(string reqId, string reason, string message) + { + Dictionary frame = Frame("map.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastMapSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// What the map can serve and what asking it costs, from the server console. + [ConsoleCommand("rg.map")] + private void CmdMap(ConsoleSystem.Arg arg) + { + MapPicture picture = _mapPicture; + int cells; + float cellSize; + GameGrid(out cells, out cellSize); + string hash = picture != null ? picture.Sha256 : null; + + arg.ReplyWith( + $"protocol={ProtocolVersion} mapKey={MapKey()} source={(picture != null ? picture.Source : MapSourceNone)} " + + $"bytes={(picture != null ? picture.Bytes.Length : 0)} size={(picture != null ? picture.Width + "x" + picture.Height : "-")} " + + $"sha256={(hash != null ? hash.Substring(0, 12) : "-")} rendering={_mapRendering} " + + $"grid={cells}x{cellSize:0.##}m" + Environment.NewLine + + $"following world={_mapWorld.Count} bases={_mapBases.Count} " + + $"live asks={_mapLiveAsks} last={_mapLiveLastMs}ms max={_mapLiveMaxMs}ms" + Environment.NewLine + + $"last: {_lastMapSummary}"); + } + // ---- reading a command's fields ---- // // `JsonConvert` hands back `object`, and every field below arrives as one. These are the From de5f0928bd2fd4b0b2f2d8f8e472aa4ed88abbd7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 01:05:51 -0500 Subject: [PATCH 2/3] fix(bridge): map.live names a player by userID, not UserIDString The phase 14 walk read `steamId: null` for every sleeper made on the server: UserIDString is a cached string the game fills for a player it has seen connect. An id the map cannot name is an own dot and a clan mate the site can never match, so the map reads the id from userID itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index cc4e961..be20606 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -9027,6 +9027,18 @@ namespace Oxide.Plugins CurrentPicture(); } + /// + /// A player's Steam id as text, from userID itself. UserIDString is a + /// cached string the game fills for a player it has seen connect, and the phase 14 walk + /// read it back empty from a sleeper made on the server: an id the map could not name + /// is an own dot and a clan mate the site can never find. + /// + private static string SteamIdOf(BasePlayer p) + { + ulong id = p.userID; + return id.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + private static bool Gone(BaseEntity e) { return e == null || e.IsDestroyed || e.transform == null; @@ -9133,7 +9145,7 @@ namespace Oxide.Plugins continue; Dictionary row = At(p.transform.position); - row["steamId"] = p.UserIDString; + row["steamId"] = SteamIdOf(p); row["name"] = p.displayName; row["sleeping"] = p.IsSleeping(); row["online"] = true; @@ -9156,7 +9168,7 @@ namespace Oxide.Plugins } Dictionary row = At(p.transform.position); - row["steamId"] = p.UserIDString; + row["steamId"] = SteamIdOf(p); row["name"] = p.displayName; row["sleeping"] = true; row["online"] = false; From ef78da8ce67660b997af41fab266491cca994640 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 06:31:42 -0500 Subject: [PATCH 3/3] fix(bridge): rg.map re-reads the picture rather than printing the last answer The walk emptied the Rust+ cache and rg.map still said `companion`: it printed the picture the last map.info had seen. It now re-reads while the world is ready. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index be20606..a55577e 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -9248,7 +9248,10 @@ namespace Oxide.Plugins [ConsoleCommand("rg.map")] private void CmdMap(ConsoleSystem.Arg arg) { - MapPicture picture = _mapPicture; + // Re-read rather than print the last answer: the cache can empty, or a render land, + // between asks, and a console that shows yesterday's source misleads the one person + // looking at it. + MapPicture picture = _worldReady ? CurrentPicture() : _mapPicture; int cells; float cellSize; GameGrid(out cells, out cellSize);