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