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(); @@ -105,19 +141,41 @@ namespace MosswartMassacre string objectClass = ClassifyObject(wo.ObjectClass); if (objectClass == null) continue; - // Get coordinates - var coords = Utils.GetWorldObjectCoordinates(wo); - if (coords == null || (coords.EW == 0 && coords.NS == 0)) continue; - - objects.Add(new + if (_inDungeon) { - id = wo.Id, - name = wo.Name, - object_class = objectClass, - ew = Math.Round(coords.EW, 7), - ns = Math.Round(coords.NS, 7), - z = Math.Round(coords.Z, 2) - }); + // In dungeons, use raw physics positions + var rawPos = Utils.GetWorldObjectPosition(wo.Id); + if (rawPos == Vector3.Zero) continue; + + objects.Add(new + { + id = wo.Id, + name = wo.Name, + object_class = objectClass, + ew = 0.0, + ns = 0.0, + z = 0.0, + raw_x = Math.Round(rawPos.X, 2), + raw_y = Math.Round(rawPos.Y, 2), + raw_z = Math.Round(rawPos.Z, 2) + }); + } + else + { + // On surface, use AC coordinates + var coords = Utils.GetWorldObjectCoordinates(wo); + if (coords == null || (coords.EW == 0 && coords.NS == 0)) continue; + + objects.Add(new + { + id = wo.Id, + name = wo.Name, + object_class = objectClass, + ew = Math.Round(coords.EW, 7), + ns = Math.Round(coords.NS, 7), + z = Math.Round(coords.Z, 2) + }); + } } catch { @@ -135,6 +193,11 @@ namespace MosswartMassacre player_ns = Math.Round(playerCoords.NS, 7), player_z = Math.Round(playerCoords.Z, 2), player_heading = Math.Round(playerHeading, 1), + is_dungeon = _inDungeon, + landblock = $"0x{landblock:X8}", + player_x = Math.Round(playerRawPos.X, 2), + player_y = Math.Round(playerRawPos.Y, 2), + player_raw_z = Math.Round(playerRawPos.Z, 2), objects }; } @@ -145,10 +208,6 @@ namespace MosswartMassacre } } - /// - /// Maps DECAL ObjectClass to a string label for the radar. - /// Returns null for object classes we don't want to track. - /// private static string ClassifyObject(ObjectClass oc) { switch (oc) diff --git a/MosswartMassacre/WebSocket.cs b/MosswartMassacre/WebSocket.cs index 4da2326..27c8387 100644 --- a/MosswartMassacre/WebSocket.cs +++ b/MosswartMassacre/WebSocket.cs @@ -354,6 +354,11 @@ namespace MosswartMassacre await SendEncodedAsync(json, CancellationToken.None); } + public static async Task SendDungeonMapAsync(string json) + { + await SendEncodedAsync(json, CancellationToken.None); + } + public static async Task SendQuestDataAsync(string questName, string countdown) { var envelope = new diff --git a/MosswartMassacre/bin/Release/MosswartMassacre.dll b/MosswartMassacre/bin/Release/MosswartMassacre.dll index 53cbf3a..ace5089 100644 Binary files a/MosswartMassacre/bin/Release/MosswartMassacre.dll and b/MosswartMassacre/bin/Release/MosswartMassacre.dll differ