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:
parent
5f20d395a6
commit
8402d7aa5b
5 changed files with 279 additions and 16 deletions
198
MosswartMassacre/DungeonMapReader.cs
Normal file
198
MosswartMassacre/DungeonMapReader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue