feat: add dungeon map streaming for radar

New DungeonMapReader reads dungeon cell geometry from game dat files
via DECAL FileService. NearbyObjectsTracker detects dungeon entry
via landblock change, reads cells once, and streams dungeon_map event.
nearby_objects payload now includes is_dungeon flag, landblock ID,
and raw physics coordinates for accurate dungeon positioning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-08 13:16:45 +02:00
parent 5f20d395a6
commit 8402d7aa5b
5 changed files with 279 additions and 16 deletions

View file

@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Decal.Adapter;
using Decal.Filters;
using Newtonsoft.Json;
namespace MosswartMassacre
{
/// <summary>
/// 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).
/// </summary>
public class DungeonMapReader
{
private readonly IPluginLogger _logger;
private readonly Dictionary<uint, bool> _isDungeonCache = new Dictionary<uint, bool>();
private readonly Dictionary<uint, object> _mapCache = new Dictionary<uint, object>();
private FileService _fileService;
public DungeonMapReader(IPluginLogger logger)
{
_logger = logger;
}
private FileService GetFileService()
{
if (_fileService == null)
_fileService = CoreManager.Current.Filter<FileService>();
return _fileService;
}
/// <summary>
/// Check if the given landcell is inside a dungeon.
/// Uses indoor cell bit check + cell file byte 4 flag.
/// </summary>
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;
}
}
/// <summary>
/// Read all dungeon cells for the given landcell's landblock.
/// Returns a serializable payload or null if not a dungeon / already cached.
/// </summary>
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<CellData>();
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;
}
}
}

View file

@ -347,6 +347,7 @@
<Compile Include="Views\VVSBaseView.cs" />
<Compile Include="Views\VVSTabbedMainView.cs" />
<Compile Include="CharacterStats.cs" />
<Compile Include="DungeonMapReader.cs" />
<Compile Include="NearbyObjectsTracker.cs" />
<Compile Include="WebSocket.cs" />
</ItemGroup>

View file

@ -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<object>();
@ -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
}
}
/// <summary>
/// Maps DECAL ObjectClass to a string label for the radar.
/// Returns null for object classes we don't want to track.
/// </summary>
private static string ClassifyObject(ObjectClass oc)
{
switch (oc)

View file

@ -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