diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
index 6a6fd9c..35a62fa 100644
--- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
+++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
@@ -36,5 +36,16 @@ internal static class FeatureCatalog
session.AddConnectionListener(sharing);
session.AddShareHandler(sharing);
features.Add(new Sharing.VitalSharingOverlay(context, sharing));
+
+ // The radar the backend turns on and off.
+ var radar = new Radar.RadarStream(context, cells: null);
+ features.Add(radar);
+ session.RadarToggle = on =>
+ {
+ if (on)
+ radar.Start();
+ else
+ radar.Stop();
+ };
}
}
diff --git a/src/OpenAC.MosswartMassacre/Radar/DungeonMapBuilder.cs b/src/OpenAC.MosswartMassacre/Radar/DungeonMapBuilder.cs
new file mode 100644
index 0000000..0b31ba5
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Radar/DungeonMapBuilder.cs
@@ -0,0 +1,83 @@
+using System.Globalization;
+
+namespace OpenAC.MosswartMassacre.Radar;
+
+///
+/// One indoor cell of a landblock as the game data stores it: the low half of
+/// its environment id, its origin in the landblock (metres, as stored), and
+/// the first stored component of its orientation, which the original sent as
+/// the cell's rotation.
+///
+internal readonly record struct DungeonCellRecord(ushort EnvironmentId, float X, float Y, float Z, float RotationW);
+
+/// Reads a landblock's indoor cells from the game data.
+internal interface IDungeonCellSource
+{
+ ///
+ /// The landblock's indoor cells in cell order, or null when the host cannot
+ /// read them (the frame is then not sent).
+ ///
+ IReadOnlyList? ReadCells(uint landblock);
+}
+
+///
+/// Builds the dungeon_map frame exactly as the original did from the
+/// same cells: cells whose x is not a multiple of 10 dropped, coordinates
+/// rounded to whole metres, grouped into 6-metre floors.
+///
+internal static class DungeonMapBuilder
+{
+ public static object? Build(uint landblock, string characterName, IReadOnlyList records)
+ {
+ var cells = new List();
+ int minX = int.MaxValue, maxX = int.MinValue;
+ int minY = int.MaxValue, maxY = int.MinValue;
+ foreach (DungeonCellRecord record in records)
+ {
+ int x = (int)Math.Round(record.X);
+ int y = (int)Math.Round(record.Y);
+ int z = (int)Math.Round(record.Z);
+ if (x % 10 != 0)
+ continue;
+ cells.Add(new CellData
+ {
+ env_id = record.EnvironmentId,
+ x = x,
+ y = y,
+ z = z,
+ rotation = record.RotationW,
+ });
+ minX = Math.Min(minX, x);
+ maxX = Math.Max(maxX, x);
+ minY = Math.Min(minY, y);
+ maxY = Math.Max(maxY, y);
+ }
+ if (cells.Count == 0)
+ return null;
+
+ var zLevels = cells
+ .GroupBy(cell => (int)(Math.Floor((cell.z + 3.0) / 6.0) * 6.0))
+ .Select(group => new { z = group.Key, cells = group.ToList() })
+ .OrderBy(group => group.z)
+ .ToList();
+
+ return new
+ {
+ type = "dungeon_map",
+ character_name = characterName,
+ landblock = string.Create(CultureInfo.InvariantCulture, $"0x{landblock:X8}"),
+ bounds = new { min_x = minX, max_x = maxX, min_y = minY, max_y = maxY },
+ z_levels = zLevels,
+ };
+ }
+
+ // Public fields, in this order, are the wire shape the original sent.
+#pragma warning disable CA1051, IDE1006, SA1307
+ private sealed class CellData
+ {
+ public ushort env_id;
+ public int x, y, z;
+ public float rotation;
+ }
+#pragma warning restore CA1051, IDE1006, SA1307
+}
diff --git a/src/OpenAC.MosswartMassacre/Radar/RadarStream.cs b/src/OpenAC.MosswartMassacre/Radar/RadarStream.cs
new file mode 100644
index 0000000..dc4f00e
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Radar/RadarStream.cs
@@ -0,0 +1,200 @@
+using System.Globalization;
+using AcDream.Plugin.Abstractions;
+using OpenAC.MosswartMassacre.Backend;
+using OpenAC.MosswartMassacre.Infrastructure;
+
+namespace OpenAC.MosswartMassacre.Radar;
+
+///
+/// The radar the backend turns on with start_radar: every second a
+/// nearby_objects frame with the player and the monsters, players,
+/// NPCs, vendors, portals, corpses, containers and doors around, and on
+/// entering a dungeon landblock its dungeon_map, once per landblock
+/// per session.
+///
+internal sealed class RadarStream : IMmFeature
+{
+ public static readonly TimeSpan Interval = TimeSpan.FromSeconds(1);
+
+ private readonly MmContext _context;
+ private readonly IDungeonCellSource? _cells;
+ private readonly HashSet _mapsSent = [];
+ private IDisposable? _timer;
+ private uint _lastLandblock;
+ private bool _inDungeon;
+
+ public RadarStream(MmContext context, IDungeonCellSource? cells)
+ {
+ _context = context;
+ _cells = cells;
+ }
+
+ public bool IsActive => _timer is not null;
+
+ public void Start()
+ {
+ if (IsActive)
+ return;
+ _lastLandblock = 0;
+ _inDungeon = false;
+ _timer = _context.Scheduler.Every(Interval, OnTick);
+ _context.Verbose("[Radar] Started nearby objects tracker");
+ }
+
+ public void Stop()
+ {
+ if (!IsActive)
+ return;
+ _timer?.Dispose();
+ _timer = null;
+ _context.Verbose("[Radar] Stopped nearby objects tracker");
+ }
+
+ public void OnLogin()
+ {
+ }
+
+ public void OnLogoff() => Stop();
+
+ public void Dispose() => Stop();
+
+ internal void OnTick()
+ {
+ try
+ {
+ CheckDungeonTransition();
+ if (BuildNearbyObjects() is { } payload)
+ _context.Send(payload);
+ }
+ catch (Exception ex)
+ {
+ _context.Verbose($"[Radar] Tick error: {ex.Message}");
+ }
+ }
+
+ private void CheckDungeonTransition()
+ {
+ uint landcell = _context.Automation.Navigation.Snapshot.Position.CellId;
+ uint landblock = landcell & 0xFFFF0000;
+ if (landblock == _lastLandblock)
+ return;
+ _lastLandblock = landblock;
+ _inDungeon = IsDungeon(landcell);
+ if (!_inDungeon || _cells is null || !_mapsSent.Add(landblock))
+ return;
+
+ IReadOnlyList? records = _cells.ReadCells(landblock);
+ if (records is null)
+ return;
+ if (DungeonMapBuilder.Build(landblock, _context.CharacterName, records) is { } map)
+ {
+ string json = Wire.Serialize(map);
+ _context.Verbose($"[Radar] Sending dungeon_map ({json.Length} bytes) for 0x{landblock:X8}");
+ _context.SendRaw(json);
+ }
+ }
+
+ /// The original's test: an indoor cell (index 0x100 and up) that does not see outside.
+ private bool IsDungeon(uint landcell) =>
+ (landcell & 0x0000FFFF) >= 0x0100 && _context.Automation.DungeonMap.IsSealedDungeon(landcell);
+
+ internal object? BuildNearbyObjects()
+ {
+ if (!_context.IsLoggedIn)
+ return null;
+ uint playerId = _context.Automation.Character.ObjectId;
+ PluginNavigationPosition here = _context.Automation.Navigation.Snapshot.Position;
+ MmCoordinates player = MmCoordinates.From(here);
+ Local playerRaw = Local.Of(here);
+ uint landblock = here.CellId & 0xFFFF0000;
+
+ var objects = new List