diff --git a/MosswartMassacre/DungeonMapReader.cs b/MosswartMassacre/DungeonMapReader.cs
new file mode 100644
index 0000000..0c920aa
--- /dev/null
+++ b/MosswartMassacre/DungeonMapReader.cs
@@ -0,0 +1,198 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Decal.Adapter;
+using Decal.Filters;
+using Newtonsoft.Json;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Reads dungeon cell geometry from the game's dat files via DECAL FileService.
+ /// Detects whether the player is in a dungeon and extracts floor plan data
+ /// for streaming to the backend.
+ ///
+ /// Binary parsing based on UtilityBelt's Dungeon.cs / DungeonCell.cs / IsDungeon.cs.
+ /// Must be called from the UI thread (FileService is COM).
+ ///
+ public class DungeonMapReader
+ {
+ private readonly IPluginLogger _logger;
+ private readonly Dictionary _isDungeonCache = new Dictionary();
+ private readonly Dictionary _mapCache = new Dictionary();
+ private FileService _fileService;
+
+ public DungeonMapReader(IPluginLogger logger)
+ {
+ _logger = logger;
+ }
+
+ private FileService GetFileService()
+ {
+ if (_fileService == null)
+ _fileService = CoreManager.Current.Filter();
+ return _fileService;
+ }
+
+ ///
+ /// Check if the given landcell is inside a dungeon.
+ /// Uses indoor cell bit check + cell file byte 4 flag.
+ ///
+ public bool IsDungeon(uint landcell)
+ {
+ // Indoor cells have cell index >= 0x0100
+ if ((landcell & 0x0000FFFF) < 0x0100)
+ return false;
+
+ uint landblock = landcell & 0xFFFF0000;
+
+ if (_isDungeonCache.TryGetValue(landblock, out bool cached))
+ return cached;
+
+ try
+ {
+ var fs = GetFileService();
+ byte[] cellFile = fs.GetCellFile((int)landcell);
+
+ bool isDungeon;
+ if (cellFile == null || cellFile.Length < 5)
+ {
+ isDungeon = true; // Assume dungeon if can't read
+ }
+ else
+ {
+ // Byte 4 bit 0: 0 = dungeon, 1 = surface dwelling
+ isDungeon = (cellFile[4] & 0x01) == 0;
+ }
+
+ _isDungeonCache[landblock] = isDungeon;
+ return isDungeon;
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[DungeonMap] IsDungeon check failed: {ex.Message}");
+ _isDungeonCache[landblock] = false;
+ return false;
+ }
+ }
+
+ ///
+ /// Read all dungeon cells for the given landcell's landblock.
+ /// Returns a serializable payload or null if not a dungeon / already cached.
+ ///
+ public object ReadDungeonMap(uint landcell, string characterName)
+ {
+ uint landblock = landcell & 0xFFFF0000;
+
+ // Already read this dungeon
+ if (_mapCache.ContainsKey(landblock))
+ return null;
+
+ if (!IsDungeon(landcell))
+ return null;
+
+ try
+ {
+ var fs = GetFileService();
+
+ // Read master dungeon file
+ byte[] masterFile = fs.GetCellFile((int)(65534 + landblock));
+ if (masterFile == null || masterFile.Length < 8)
+ {
+ _logger?.Log($"[DungeonMap] No master file for landblock 0x{landblock:X8}");
+ _mapCache[landblock] = new object(); // Mark as attempted
+ return null;
+ }
+
+ int cellCount = BitConverter.ToInt32(masterFile, 4);
+ _logger?.Log($"[DungeonMap] Reading {cellCount} cells for landblock 0x{landblock:X8}");
+
+ var allCells = new List();
+ int minX = int.MaxValue, maxX = int.MinValue;
+ int minY = int.MaxValue, maxY = int.MinValue;
+
+ for (int i = 0; i < cellCount; i++)
+ {
+ try
+ {
+ int cellId = (int)(i + landblock + 256);
+ byte[] cellFile = fs.GetCellFile(cellId);
+ if (cellFile == null || cellFile.Length < 20)
+ continue;
+
+ int baseOffset = 16 + (int)cellFile[12] * 2;
+ if (baseOffset + 20 > cellFile.Length)
+ continue;
+
+ ushort envId = BitConverter.ToUInt16(cellFile, baseOffset);
+ int x = (int)Math.Round(BitConverter.ToSingle(cellFile, baseOffset + 4));
+ int y = (int)Math.Round(BitConverter.ToSingle(cellFile, baseOffset + 8));
+ int z = (int)Math.Round(BitConverter.ToSingle(cellFile, baseOffset + 12));
+ float rotation = BitConverter.ToSingle(cellFile, baseOffset + 16);
+
+ // Validation: X must be divisible by 10
+ if (x % 10 != 0) continue;
+
+ allCells.Add(new CellData
+ {
+ env_id = envId,
+ x = x,
+ y = y,
+ z = z,
+ rotation = rotation
+ });
+
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+ catch
+ {
+ // Skip individual cell failures
+ }
+ }
+
+ if (allCells.Count == 0)
+ {
+ _mapCache[landblock] = new object();
+ return null;
+ }
+
+ // Group by Z-level
+ var zLevels = allCells
+ .GroupBy(c => (int)(Math.Floor((c.z + 3.0) / 6.0) * 6.0))
+ .Select(g => new { z = g.Key, cells = g.ToList() })
+ .OrderBy(g => g.z)
+ .ToList();
+
+ var payload = new
+ {
+ type = "dungeon_map",
+ character_name = characterName,
+ landblock = $"0x{landblock:X8}",
+ bounds = new { min_x = minX, max_x = maxX, min_y = minY, max_y = maxY },
+ z_levels = zLevels
+ };
+
+ _mapCache[landblock] = payload;
+ _logger?.Log($"[DungeonMap] Read {allCells.Count} cells, {zLevels.Count} Z-levels for 0x{landblock:X8}");
+
+ return payload;
+ }
+ catch (Exception ex)
+ {
+ _logger?.Log($"[DungeonMap] ReadDungeonMap failed: {ex.Message}");
+ _mapCache[landblock] = new object();
+ return null;
+ }
+ }
+
+ private class CellData
+ {
+ public ushort env_id;
+ public int x, y, z;
+ public float rotation;
+ }
+ }
+}
diff --git a/MosswartMassacre/MosswartMassacre.csproj b/MosswartMassacre/MosswartMassacre.csproj
index 0304df2..b88c2a5 100644
--- a/MosswartMassacre/MosswartMassacre.csproj
+++ b/MosswartMassacre/MosswartMassacre.csproj
@@ -347,6 +347,7 @@
+
diff --git a/MosswartMassacre/NearbyObjectsTracker.cs b/MosswartMassacre/NearbyObjectsTracker.cs
index 1d2b8cd..daa0a4c 100644
--- a/MosswartMassacre/NearbyObjectsTracker.cs
+++ b/MosswartMassacre/NearbyObjectsTracker.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using System.Numerics;
using Decal.Adapter;
using Decal.Adapter.Wrappers;
using Newtonsoft.Json;
@@ -21,21 +22,27 @@ namespace MosswartMassacre
private const int PollIntervalMs = 1000;
private readonly IPluginLogger _logger;
+ private readonly DungeonMapReader _dungeonMapReader;
private System.Windows.Forms.Timer _timer;
private bool _active;
private bool _disposed;
+ private uint _lastLandblock;
+ private bool _inDungeon;
public bool IsActive => _active;
public NearbyObjectsTracker(IPluginLogger logger)
{
_logger = logger;
+ _dungeonMapReader = new DungeonMapReader(logger);
}
public void Start()
{
if (_active) return;
_active = true;
+ _lastLandblock = 0;
+ _inDungeon = false;
_timer = new System.Windows.Forms.Timer();
_timer.Interval = PollIntervalMs;
@@ -65,6 +72,9 @@ namespace MosswartMassacre
{
try
{
+ // Check for landblock change and send dungeon map if needed
+ await CheckDungeonTransition();
+
var payload = BuildNearbyObjectsPayload();
if (payload == null) return;
@@ -77,14 +87,40 @@ namespace MosswartMassacre
}
}
+ private async System.Threading.Tasks.Task CheckDungeonTransition()
+ {
+ uint landcell = Utils.GetPlayerLandcell();
+ uint landblock = landcell & 0xFFFF0000;
+
+ if (landblock == _lastLandblock) return;
+ _lastLandblock = landblock;
+
+ bool wasDungeon = _inDungeon;
+ _inDungeon = _dungeonMapReader.IsDungeon(landcell);
+
+ if (_inDungeon)
+ {
+ string characterName = CoreManager.Current.CharacterFilter.Name;
+ var dungeonMap = _dungeonMapReader.ReadDungeonMap(landcell, characterName);
+ if (dungeonMap != null)
+ {
+ var json = JsonConvert.SerializeObject(dungeonMap);
+ await WebSocket.SendDungeonMapAsync(json);
+ }
+ }
+ }
+
private object BuildNearbyObjectsPayload()
{
try
{
var playerCoords = Coordinates.Me;
+ var playerRawPos = Utils.GetPlayerPosition();
double playerHeading = CoreManager.Current.Actions.Heading;
string characterName = CoreManager.Current.CharacterFilter.Name;
int playerId = CoreManager.Current.CharacterFilter.Id;
+ uint landcell = Utils.GetPlayerLandcell();
+ uint landblock = landcell & 0xFFFF0000;
var objects = new List