988 lines
33 KiB
C#
988 lines
33 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Runtime.InteropServices;
|
|
using Avalonia;
|
|
using Avalonia.Media.Imaging;
|
|
using Avalonia.Platform;
|
|
using Avalonia.Threading;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Automata.Memory;
|
|
|
|
namespace Automata.Ui.ViewModels;
|
|
|
|
public partial class MemoryNodeViewModel : ObservableObject
|
|
{
|
|
[ObservableProperty] private string _name;
|
|
[ObservableProperty] private string _value = "";
|
|
[ObservableProperty] private string _valueColor = "#484f58";
|
|
[ObservableProperty] private bool _isExpanded = true;
|
|
|
|
public ObservableCollection<MemoryNodeViewModel> Children { get; } = [];
|
|
|
|
public MemoryNodeViewModel(string name)
|
|
{
|
|
_name = name;
|
|
}
|
|
|
|
public void Set(string value, bool valid = true)
|
|
{
|
|
Value = value;
|
|
ValueColor = valid ? "#3fb950" : "#484f58";
|
|
}
|
|
}
|
|
|
|
public partial class MemoryViewModel : ObservableObject
|
|
{
|
|
private GameMemoryReader? _reader;
|
|
private CancellationTokenSource? _cts;
|
|
|
|
private const int ViewportRadius = 300; // grid pixels visible in each direction from player
|
|
|
|
[ObservableProperty] private bool _isEnabled;
|
|
[ObservableProperty] private string _statusText = "Not attached";
|
|
|
|
public ObservableCollection<MemoryNodeViewModel> RootNodes { get; } = [];
|
|
|
|
// Minimap
|
|
[ObservableProperty] private Bitmap? _terrainImage;
|
|
private byte[]? _terrainBasePixels;
|
|
private byte[]? _minimapBuffer; // reused each frame
|
|
private int _terrainImageWidth, _terrainImageHeight;
|
|
private uint _terrainImageAreaHash;
|
|
private WalkabilityGrid? _terrainGridRef;
|
|
|
|
// Raw explorer
|
|
[ObservableProperty] private string _rawAddress = "";
|
|
[ObservableProperty] private string _rawOffsets = "";
|
|
[ObservableProperty] private string _rawType = "pointer";
|
|
[ObservableProperty] private string _rawResult = "";
|
|
|
|
// Scan
|
|
[ObservableProperty] private string _scanAddress = "";
|
|
[ObservableProperty] private string _scanOffsets = "";
|
|
[ObservableProperty] private string _scanSize = "400";
|
|
[ObservableProperty] private string _scanResult = "";
|
|
|
|
// Component scan vitals
|
|
[ObservableProperty] private string _vitalHp = "";
|
|
[ObservableProperty] private string _vitalMana = "";
|
|
[ObservableProperty] private string _vitalEs = "";
|
|
|
|
public static string[] RawTypes { get; } = ["int32", "int64", "float", "double", "pointer", "bytes16", "string"];
|
|
|
|
// Tree node references
|
|
private MemoryNodeViewModel? _processStatus;
|
|
private MemoryNodeViewModel? _processPid;
|
|
private MemoryNodeViewModel? _processModule;
|
|
private MemoryNodeViewModel? _gsPattern;
|
|
private MemoryNodeViewModel? _gsBase;
|
|
private MemoryNodeViewModel? _gsController;
|
|
private MemoryNodeViewModel? _gsStates;
|
|
private MemoryNodeViewModel? _inGameState;
|
|
private MemoryNodeViewModel? _areaInstance;
|
|
private MemoryNodeViewModel? _areaLevel;
|
|
private MemoryNodeViewModel? _areaHash;
|
|
private MemoryNodeViewModel? _serverData;
|
|
private MemoryNodeViewModel? _localPlayer;
|
|
private MemoryNodeViewModel? _entityCount;
|
|
private MemoryNodeViewModel? _playerPos;
|
|
private MemoryNodeViewModel? _playerLife;
|
|
private MemoryNodeViewModel? _playerMana;
|
|
private MemoryNodeViewModel? _playerEs;
|
|
private MemoryNodeViewModel? _isLoadingNode;
|
|
private MemoryNodeViewModel? _escapeStateNode;
|
|
private MemoryNodeViewModel? _statesNode;
|
|
private MemoryNodeViewModel? _terrainCells;
|
|
private MemoryNodeViewModel? _terrainGrid;
|
|
private MemoryNodeViewModel? _terrainWalkable;
|
|
private MemoryNodeViewModel? _entitySummary;
|
|
private MemoryNodeViewModel? _entityTypesNode;
|
|
private MemoryNodeViewModel? _entityListNode;
|
|
|
|
partial void OnIsEnabledChanged(bool value)
|
|
{
|
|
if (value)
|
|
Enable();
|
|
else
|
|
Disable();
|
|
}
|
|
|
|
private void Enable()
|
|
{
|
|
_reader = new GameMemoryReader();
|
|
var attached = _reader.Attach();
|
|
if (attached)
|
|
{
|
|
var snap = _reader.ReadSnapshot();
|
|
StatusText = $"Attached (PID {snap.ProcessId})";
|
|
}
|
|
else
|
|
{
|
|
StatusText = "Process not found";
|
|
}
|
|
|
|
BuildTree();
|
|
_cts = new CancellationTokenSource();
|
|
_ = ReadLoop(_cts.Token);
|
|
}
|
|
|
|
private void Disable()
|
|
{
|
|
_cts?.Cancel();
|
|
_cts = null;
|
|
_reader?.Dispose();
|
|
_reader = null;
|
|
RootNodes.Clear();
|
|
StatusText = "Not attached";
|
|
|
|
_terrainBasePixels = null;
|
|
_terrainImageAreaHash = 0;
|
|
_terrainGridRef = null;
|
|
var old = TerrainImage;
|
|
TerrainImage = null;
|
|
old?.Dispose();
|
|
}
|
|
|
|
private void BuildTree()
|
|
{
|
|
RootNodes.Clear();
|
|
|
|
// Process
|
|
var process = new MemoryNodeViewModel("Process");
|
|
_processStatus = new MemoryNodeViewModel("Status:");
|
|
_processPid = new MemoryNodeViewModel("PID:");
|
|
_processModule = new MemoryNodeViewModel("Module:");
|
|
process.Children.Add(_processStatus);
|
|
process.Children.Add(_processPid);
|
|
process.Children.Add(_processModule);
|
|
|
|
// GameState
|
|
var gameState = new MemoryNodeViewModel("GameState");
|
|
_gsPattern = new MemoryNodeViewModel("Pattern:");
|
|
_gsBase = new MemoryNodeViewModel("Base:");
|
|
_gsController = new MemoryNodeViewModel("Controller:");
|
|
_gsStates = new MemoryNodeViewModel("States:");
|
|
_inGameState = new MemoryNodeViewModel("InGameState:");
|
|
_isLoadingNode = new MemoryNodeViewModel("Loading:");
|
|
_escapeStateNode = new MemoryNodeViewModel("Escape:");
|
|
_statesNode = new MemoryNodeViewModel("State Slots") { IsExpanded = true };
|
|
gameState.Children.Add(_gsPattern);
|
|
gameState.Children.Add(_gsBase);
|
|
gameState.Children.Add(_gsController);
|
|
gameState.Children.Add(_gsStates);
|
|
gameState.Children.Add(_inGameState);
|
|
gameState.Children.Add(_isLoadingNode);
|
|
gameState.Children.Add(_escapeStateNode);
|
|
gameState.Children.Add(_statesNode);
|
|
|
|
// InGameState children
|
|
var inGameStateGroup = new MemoryNodeViewModel("InGameState");
|
|
|
|
// AreaInstance (primary data source in POE2)
|
|
var areaInstanceGroup = new MemoryNodeViewModel("AreaInstance");
|
|
_areaInstance = new MemoryNodeViewModel("Ptr:");
|
|
_areaLevel = new MemoryNodeViewModel("AreaLevel:");
|
|
_areaHash = new MemoryNodeViewModel("AreaHash:");
|
|
_serverData = new MemoryNodeViewModel("ServerData:");
|
|
_localPlayer = new MemoryNodeViewModel("LocalPlayer:");
|
|
_entityCount = new MemoryNodeViewModel("Entities:");
|
|
areaInstanceGroup.Children.Add(_areaInstance);
|
|
areaInstanceGroup.Children.Add(_areaLevel);
|
|
areaInstanceGroup.Children.Add(_areaHash);
|
|
areaInstanceGroup.Children.Add(_serverData);
|
|
areaInstanceGroup.Children.Add(_localPlayer);
|
|
areaInstanceGroup.Children.Add(_entityCount);
|
|
|
|
// Player
|
|
var player = new MemoryNodeViewModel("Player");
|
|
_playerPos = new MemoryNodeViewModel("Position:") { Value = "?", ValueColor = "#484f58" };
|
|
_playerLife = new MemoryNodeViewModel("Life:") { Value = "?", ValueColor = "#484f58" };
|
|
_playerMana = new MemoryNodeViewModel("Mana:") { Value = "?", ValueColor = "#484f58" };
|
|
_playerEs = new MemoryNodeViewModel("ES:") { Value = "?", ValueColor = "#484f58" };
|
|
player.Children.Add(_playerPos);
|
|
player.Children.Add(_playerLife);
|
|
player.Children.Add(_playerMana);
|
|
player.Children.Add(_playerEs);
|
|
|
|
// Entities
|
|
var entitiesGroup = new MemoryNodeViewModel("Entities");
|
|
_entitySummary = new MemoryNodeViewModel("Summary:");
|
|
_entityTypesNode = new MemoryNodeViewModel("Types:") { IsExpanded = false };
|
|
_entityListNode = new MemoryNodeViewModel("List:") { IsExpanded = false };
|
|
entitiesGroup.Children.Add(_entitySummary);
|
|
entitiesGroup.Children.Add(_entityTypesNode);
|
|
entitiesGroup.Children.Add(_entityListNode);
|
|
|
|
// Terrain
|
|
var terrain = new MemoryNodeViewModel("Terrain");
|
|
_terrainCells = new MemoryNodeViewModel("Cells:");
|
|
_terrainGrid = new MemoryNodeViewModel("Grid:");
|
|
_terrainWalkable = new MemoryNodeViewModel("Walkable:");
|
|
terrain.Children.Add(_terrainCells);
|
|
terrain.Children.Add(_terrainGrid);
|
|
terrain.Children.Add(_terrainWalkable);
|
|
|
|
inGameStateGroup.Children.Add(areaInstanceGroup);
|
|
inGameStateGroup.Children.Add(player);
|
|
inGameStateGroup.Children.Add(entitiesGroup);
|
|
inGameStateGroup.Children.Add(terrain);
|
|
|
|
RootNodes.Add(process);
|
|
RootNodes.Add(gameState);
|
|
RootNodes.Add(inGameStateGroup);
|
|
}
|
|
|
|
private async Task ReadLoop(CancellationToken ct)
|
|
{
|
|
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(30));
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
if (!await timer.WaitForNextTickAsync(ct))
|
|
break;
|
|
}
|
|
catch (OperationCanceledException) { break; }
|
|
|
|
if (_reader is null) break;
|
|
|
|
var snap = _reader.ReadSnapshot();
|
|
Dispatcher.UIThread.Post(() => UpdateTree(snap));
|
|
}
|
|
}
|
|
|
|
private void UpdateTree(GameStateSnapshot snap)
|
|
{
|
|
if (_processStatus is null) return;
|
|
|
|
// Process
|
|
_processStatus.Set(snap.Attached ? "Attached" : "Not found", snap.Attached);
|
|
_processPid!.Set(snap.Attached ? snap.ProcessId.ToString() : "—", snap.Attached);
|
|
_processModule!.Set(
|
|
snap.Attached ? $"0x{snap.ModuleBase:X} ({snap.ModuleSize:N0} bytes)" : "—",
|
|
snap.Attached);
|
|
|
|
// GameState
|
|
_gsPattern!.Set(snap.OffsetsConfigured ? "configured" : "not configured", snap.OffsetsConfigured);
|
|
_gsBase!.Set(
|
|
snap.GameStateBase != 0 ? $"0x{snap.GameStateBase:X}" : "not resolved",
|
|
snap.GameStateBase != 0);
|
|
_gsController!.Set(
|
|
snap.ControllerPtr != 0 ? $"0x{snap.ControllerPtr:X}" : "—",
|
|
snap.ControllerPtr != 0);
|
|
_gsStates!.Set(
|
|
snap.StatesCount > 0 ? snap.StatesCount.ToString() : "—",
|
|
snap.StatesCount > 0);
|
|
_inGameState!.Set(
|
|
snap.InGameStatePtr != 0 ? $"0x{snap.InGameStatePtr:X}" : "not found",
|
|
snap.InGameStatePtr != 0);
|
|
_isLoadingNode!.Set(snap.IsLoading ? "Loading..." : "Ready", !snap.IsLoading);
|
|
_escapeStateNode!.Set(snap.IsEscapeOpen ? "Open" : "Closed", !snap.IsEscapeOpen);
|
|
|
|
// State Slots — show pointer + int32 at +0x08 for each state slot
|
|
if (_statesNode is not null && snap.StateSlots.Length > 0)
|
|
{
|
|
var slots = snap.StateSlots;
|
|
var needed = slots.Length;
|
|
|
|
while (_statesNode.Children.Count > needed)
|
|
_statesNode.Children.RemoveAt(_statesNode.Children.Count - 1);
|
|
|
|
for (var i = 0; i < needed; i++)
|
|
{
|
|
var ptr = slots[i];
|
|
var stateName = i < GameMemoryReader.StateNames.Length ? GameMemoryReader.StateNames[i] : $"State{i}";
|
|
var label = $"[{i}] {stateName}:";
|
|
string val;
|
|
string color;
|
|
|
|
if (ptr == 0)
|
|
{
|
|
val = "null";
|
|
color = "#484f58";
|
|
}
|
|
else
|
|
{
|
|
// Read int32 at state+0x08 (the value CE found)
|
|
var int32Val = snap.StateSlotValues?.Length > i ? snap.StateSlotValues[i] : 0;
|
|
val = $"0x{ptr:X} [+0x08]={int32Val}";
|
|
color = ptr == snap.InGameStatePtr ? "#3fb950" : "#8b949e";
|
|
}
|
|
|
|
if (i < _statesNode.Children.Count)
|
|
{
|
|
_statesNode.Children[i].Name = label;
|
|
_statesNode.Children[i].Set(val, true);
|
|
_statesNode.Children[i].ValueColor = color;
|
|
}
|
|
else
|
|
{
|
|
var node = new MemoryNodeViewModel(label);
|
|
node.Set(val, true);
|
|
node.ValueColor = color;
|
|
_statesNode.Children.Add(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Status text
|
|
if (snap.Attached)
|
|
StatusText = snap.InGameStatePtr != 0
|
|
? $"Attached (PID {snap.ProcessId}) — InGame"
|
|
: $"Attached (PID {snap.ProcessId})";
|
|
else if (snap.Error is not null)
|
|
StatusText = $"Error: {snap.Error}";
|
|
|
|
// AreaInstance
|
|
_areaInstance!.Set(
|
|
snap.AreaInstancePtr != 0 ? $"0x{snap.AreaInstancePtr:X}" : "—",
|
|
snap.AreaInstancePtr != 0);
|
|
_areaLevel!.Set(
|
|
snap.AreaLevel > 0 ? snap.AreaLevel.ToString() : "—",
|
|
snap.AreaLevel > 0);
|
|
_areaHash!.Set(
|
|
snap.AreaHash != 0 ? $"0x{snap.AreaHash:X8}" : "—",
|
|
snap.AreaHash != 0);
|
|
_serverData!.Set(
|
|
snap.ServerDataPtr != 0 ? $"0x{snap.ServerDataPtr:X}" : "—",
|
|
snap.ServerDataPtr != 0);
|
|
_localPlayer!.Set(
|
|
snap.LocalPlayerPtr != 0 ? $"0x{snap.LocalPlayerPtr:X}" : "—",
|
|
snap.LocalPlayerPtr != 0);
|
|
_entityCount!.Set(
|
|
snap.EntityCount > 0 ? snap.EntityCount.ToString() : "—",
|
|
snap.EntityCount > 0);
|
|
|
|
// Player position
|
|
if (snap.HasPosition)
|
|
_playerPos!.Set($"({snap.PlayerX:F1}, {snap.PlayerY:F1}, {snap.PlayerZ:F1})");
|
|
else
|
|
_playerPos!.Set("? (no Render component found)", false);
|
|
|
|
// Player vitals
|
|
if (snap.HasVitals)
|
|
{
|
|
_playerLife!.Set($"{snap.LifeCurrent} / {snap.LifeTotal}");
|
|
_playerMana!.Set($"{snap.ManaCurrent} / {snap.ManaTotal}");
|
|
_playerEs!.Set($"{snap.EsCurrent} / {snap.EsTotal}");
|
|
}
|
|
else
|
|
{
|
|
_playerLife!.Set("? (set LifeComponentIndex)", false);
|
|
_playerMana!.Set("? (set LifeComponentIndex)", false);
|
|
_playerEs!.Set("? (set LifeComponentIndex)", false);
|
|
}
|
|
|
|
// Entities
|
|
if (snap.Entities is { Count: > 0 })
|
|
{
|
|
var withPos = snap.Entities.Count(e => e.HasPosition);
|
|
var withComps = snap.Entities.Count(e => e.Components is not null);
|
|
var monsters = snap.Entities.Count(e => e.Type == Automata.Memory.EntityType.Monster);
|
|
var knownComps = _reader?.Registry["components"].Count ?? 0;
|
|
_entitySummary!.Set($"{snap.Entities.Count} total, {withComps} with comps, {knownComps} known, {monsters} monsters");
|
|
|
|
// Group by EntityType
|
|
var typeCounts = snap.Entities
|
|
.GroupBy(e => e.Type)
|
|
.OrderByDescending(g => g.Count())
|
|
.Take(20);
|
|
|
|
_entityTypesNode!.Children.Clear();
|
|
foreach (var group in typeCounts)
|
|
{
|
|
var node = new MemoryNodeViewModel($"{group.Key}:");
|
|
node.Set(group.Count().ToString());
|
|
_entityTypesNode.Children.Add(node);
|
|
}
|
|
|
|
// Entity list grouped by type
|
|
UpdateEntityList(snap.Entities);
|
|
}
|
|
else
|
|
{
|
|
_entitySummary!.Set("—", false);
|
|
_entityTypesNode!.Children.Clear();
|
|
_entityListNode!.Children.Clear();
|
|
}
|
|
|
|
// Terrain
|
|
if (snap.TerrainCols > 0 && snap.TerrainRows > 0)
|
|
{
|
|
_terrainCells!.Set($"{snap.TerrainCols}x{snap.TerrainRows}");
|
|
_terrainGrid!.Set($"{snap.TerrainWidth}x{snap.TerrainHeight}");
|
|
if (snap.Terrain != null)
|
|
_terrainWalkable!.Set($"{snap.TerrainWalkablePercent}%");
|
|
else
|
|
_terrainWalkable!.Set("no grid data", false);
|
|
}
|
|
else
|
|
{
|
|
_terrainCells!.Set("?", false);
|
|
_terrainGrid!.Set("?", false);
|
|
_terrainWalkable!.Set("?", false);
|
|
}
|
|
|
|
UpdateMinimap(snap);
|
|
}
|
|
|
|
private void UpdateMinimap(GameStateSnapshot snap)
|
|
{
|
|
// Skip rendering entirely during loading — terrain data is stale/invalid
|
|
if (snap.IsLoading)
|
|
{
|
|
_terrainBasePixels = null;
|
|
_terrainImageAreaHash = 0;
|
|
_terrainGridRef = null;
|
|
var oldImg = TerrainImage;
|
|
TerrainImage = null;
|
|
oldImg?.Dispose();
|
|
return;
|
|
}
|
|
|
|
// Invalidate cache when area changes or terrain grid object changes
|
|
var terrainChanged = snap.Terrain is not null && !ReferenceEquals(snap.Terrain, _terrainGridRef);
|
|
if (terrainChanged || (snap.AreaHash != 0 && snap.AreaHash != _terrainImageAreaHash))
|
|
{
|
|
_terrainBasePixels = null;
|
|
_terrainImageAreaHash = 0;
|
|
_terrainGridRef = null;
|
|
var old = TerrainImage;
|
|
TerrainImage = null;
|
|
old?.Dispose();
|
|
}
|
|
|
|
// Rebuild base pixels from new terrain data
|
|
if (snap.Terrain is { } grid && _terrainBasePixels is null)
|
|
{
|
|
var w = grid.Width;
|
|
var h = grid.Height;
|
|
var pixels = new byte[w * h * 4];
|
|
for (var y = 0; y < h; y++)
|
|
{
|
|
var srcY = h - 1 - y; // flip Y: game Y-up → bitmap Y-down
|
|
for (var x = 0; x < w; x++)
|
|
{
|
|
var i = (y * w + x) * 4;
|
|
if (grid.Data[srcY * w + x] == 0) // walkable — transparent
|
|
{
|
|
pixels[i] = 0x00;
|
|
pixels[i + 1] = 0x00;
|
|
pixels[i + 2] = 0x00;
|
|
pixels[i + 3] = 0x00;
|
|
}
|
|
else // blocked
|
|
{
|
|
pixels[i] = 0x50; // B
|
|
pixels[i + 1] = 0x50; // G
|
|
pixels[i + 2] = 0x50; // R
|
|
pixels[i + 3] = 0xFF; // A
|
|
}
|
|
}
|
|
}
|
|
|
|
_terrainBasePixels = pixels;
|
|
_terrainImageWidth = w;
|
|
_terrainImageHeight = h;
|
|
_terrainImageAreaHash = snap.AreaHash;
|
|
_terrainGridRef = grid;
|
|
}
|
|
|
|
var basePixels = _terrainBasePixels;
|
|
if (basePixels is null) return;
|
|
|
|
var tw = _terrainImageWidth;
|
|
var th = _terrainImageHeight;
|
|
if (tw < 2 || th < 2) return;
|
|
|
|
// World-to-grid conversion: Render component gives world coords,
|
|
// terrain bitmap is in grid/subtile coords (NumCols*23 x NumRows*23).
|
|
// Each tile = 250 world units = 23 subtiles, so grid = world * 23/250.
|
|
const float worldToGrid = 23.0f / 250.0f;
|
|
const float cos45 = 0.70710678f;
|
|
const float sin45 = 0.70710678f;
|
|
|
|
var viewSize = ViewportRadius * 2;
|
|
|
|
// Player grid position — center of the output
|
|
float pgx, pgy;
|
|
if (snap.HasPosition)
|
|
{
|
|
pgx = snap.PlayerX * worldToGrid;
|
|
pgy = th - 1 - snap.PlayerY * worldToGrid;
|
|
}
|
|
else
|
|
{
|
|
pgx = tw * 0.5f;
|
|
pgy = th * 0.5f;
|
|
}
|
|
|
|
var bufSize = viewSize * viewSize * 4;
|
|
if (_minimapBuffer is null || _minimapBuffer.Length != bufSize)
|
|
_minimapBuffer = new byte[bufSize];
|
|
var buf = _minimapBuffer;
|
|
Array.Clear(buf, 0, bufSize);
|
|
|
|
var outStride = viewSize * 4;
|
|
var cx = viewSize * 0.5f;
|
|
var cy = viewSize * 0.5f;
|
|
|
|
// Sample terrain with -45° rotation baked in (nearest-neighbor, unsafe).
|
|
unsafe
|
|
{
|
|
fixed (byte* srcPtr = basePixels, dstPtr = buf)
|
|
{
|
|
var srcInt = (int*)srcPtr;
|
|
var dstInt = (int*)dstPtr;
|
|
for (var ry = 0; ry < viewSize; ry++)
|
|
{
|
|
var dy = ry - cy;
|
|
var baseX = -dy * sin45 + pgx;
|
|
var baseY = dy * cos45 + pgy;
|
|
for (var rx = 0; rx < viewSize; rx++)
|
|
{
|
|
var dx = rx - cx;
|
|
var sx = (int)(dx * cos45 + baseX);
|
|
var sy = (int)(dx * sin45 + baseY);
|
|
if ((uint)sx >= (uint)tw || (uint)sy >= (uint)th) continue;
|
|
|
|
dstInt[ry * viewSize + rx] = srcInt[sy * tw + sx];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw entity dots — transform grid coords into rotated output space
|
|
if (snap.Entities is { Count: > 0 })
|
|
{
|
|
foreach (var e in snap.Entities)
|
|
{
|
|
if (!e.HasPosition) continue;
|
|
if (e.Type is not (EntityType.Player or EntityType.Monster or EntityType.Npc)) continue;
|
|
if (e.Address == snap.LocalPlayerPtr) continue;
|
|
if (e.Type == EntityType.Monster && e.IsDead) continue;
|
|
|
|
// Entity position relative to player in grid coords
|
|
var dx = e.X * worldToGrid - pgx;
|
|
var dy = (th - 1 - e.Y * worldToGrid) - pgy;
|
|
// Apply -45° rotation into output space
|
|
var ex = (int)(dx * cos45 + dy * sin45 + cx);
|
|
var ey = (int)(-dx * sin45 + dy * cos45 + cy);
|
|
|
|
if (ex < 0 || ex >= viewSize || ey < 0 || ey >= viewSize) continue;
|
|
|
|
byte b, g, r;
|
|
switch (e.Type)
|
|
{
|
|
case EntityType.Player: // other players — green #3FB950
|
|
b = 0x50; g = 0xB9; r = 0x3F; break;
|
|
case EntityType.Npc: // orange #FF8C00
|
|
b = 0x00; g = 0x8C; r = 0xFF; break;
|
|
case EntityType.Monster: // red #FF4444
|
|
b = 0x44; g = 0x44; r = 0xFF; break;
|
|
default: continue;
|
|
}
|
|
|
|
DrawDot(buf, outStride, viewSize, viewSize, ex, ey, 4, b, g, r);
|
|
}
|
|
}
|
|
|
|
// Draw player dot at center (white, on top)
|
|
if (snap.HasPosition)
|
|
DrawDot(buf, outStride, viewSize, viewSize, (int)cx, (int)cy, 5, 0xFF, 0xFF, 0xFF);
|
|
|
|
// Create WriteableBitmap
|
|
var bmp = new WriteableBitmap(
|
|
new PixelSize(viewSize, viewSize),
|
|
new Vector(96, 96),
|
|
Avalonia.Platform.PixelFormat.Bgra8888,
|
|
Avalonia.Platform.AlphaFormat.Premul);
|
|
|
|
using (var fb = bmp.Lock())
|
|
{
|
|
Marshal.Copy(buf, 0, fb.Address, buf.Length);
|
|
}
|
|
|
|
var old2 = TerrainImage;
|
|
TerrainImage = bmp;
|
|
old2?.Dispose();
|
|
}
|
|
|
|
private static void DrawDot(byte[] buf, int stride, int bw, int bh, int cx, int cy, int radius, byte b, byte g, byte r)
|
|
{
|
|
var outer = radius + 1;
|
|
for (var dy = -outer; dy <= outer; dy++)
|
|
{
|
|
for (var dx = -outer; dx <= outer; dx++)
|
|
{
|
|
var sx = cx + dx;
|
|
var sy = cy + dy;
|
|
if (sx < 0 || sx >= bw || sy < 0 || sy >= bh) continue;
|
|
|
|
var dist = MathF.Sqrt(dx * dx + dy * dy);
|
|
if (dist > radius + 0.5f) continue;
|
|
|
|
var alpha = Math.Clamp(radius + 0.5f - dist, 0f, 1f);
|
|
var i = sy * stride + sx * 4;
|
|
buf[i] = (byte)(b * alpha + buf[i] * (1 - alpha));
|
|
buf[i + 1] = (byte)(g * alpha + buf[i + 1] * (1 - alpha));
|
|
buf[i + 2] = (byte)(r * alpha + buf[i + 2] * (1 - alpha));
|
|
buf[i + 3] = (byte)(Math.Max(alpha, buf[i + 3] / 255f) * 255);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UpdateEntityList(List<Entity> entities)
|
|
{
|
|
if (_entityListNode is null) return;
|
|
|
|
// Group by type, sorted by type name
|
|
var groups = entities
|
|
.GroupBy(e => e.Type)
|
|
.OrderBy(g => g.Key.ToString());
|
|
|
|
// Build a lookup of existing type group nodes by name for reuse
|
|
var existingGroups = new Dictionary<string, MemoryNodeViewModel>();
|
|
foreach (var child in _entityListNode.Children)
|
|
existingGroups[child.Name] = child;
|
|
|
|
var usedGroups = new HashSet<string>();
|
|
|
|
foreach (var group in groups)
|
|
{
|
|
var groupName = $"{group.Key} ({group.Count()})";
|
|
usedGroups.Add(groupName);
|
|
|
|
if (!existingGroups.TryGetValue(groupName, out var groupNode))
|
|
{
|
|
groupNode = new MemoryNodeViewModel(groupName) { IsExpanded = false };
|
|
_entityListNode.Children.Add(groupNode);
|
|
existingGroups[groupName] = groupNode;
|
|
}
|
|
|
|
// Sort: players first, then by distance to 0,0 (or by id)
|
|
var sorted = group.OrderBy(e => e.Id).ToList();
|
|
|
|
// Rebuild children — reuse by index to reduce churn
|
|
while (groupNode.Children.Count > sorted.Count)
|
|
groupNode.Children.RemoveAt(groupNode.Children.Count - 1);
|
|
|
|
for (var i = 0; i < sorted.Count; i++)
|
|
{
|
|
var e = sorted[i];
|
|
var label = FormatEntityName(e);
|
|
var value = FormatEntityValue(e);
|
|
|
|
if (i < groupNode.Children.Count)
|
|
{
|
|
var existing = groupNode.Children[i];
|
|
existing.Name = label;
|
|
existing.Set(value, e.HasPosition);
|
|
UpdateEntityChildren(existing, e);
|
|
}
|
|
else
|
|
{
|
|
var node = new MemoryNodeViewModel(label) { IsExpanded = false };
|
|
node.Set(value, e.HasPosition);
|
|
UpdateEntityChildren(node, e);
|
|
groupNode.Children.Add(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove stale type groups
|
|
for (var i = _entityListNode.Children.Count - 1; i >= 0; i--)
|
|
{
|
|
if (!usedGroups.Contains(_entityListNode.Children[i].Name))
|
|
_entityListNode.Children.RemoveAt(i);
|
|
}
|
|
}
|
|
|
|
private static string FormatEntityName(Entity e)
|
|
{
|
|
// Short name: last path segment without @instance
|
|
if (e.Path is not null)
|
|
{
|
|
var lastSlash = e.Path.LastIndexOf('/');
|
|
var name = lastSlash >= 0 ? e.Path[(lastSlash + 1)..] : e.Path;
|
|
var at = name.IndexOf('@');
|
|
if (at > 0) name = name[..at];
|
|
return $"[{e.Id}] {name}";
|
|
}
|
|
return $"[{e.Id}] ?";
|
|
}
|
|
|
|
private static string FormatEntityValue(Entity e)
|
|
{
|
|
var parts = new List<string>();
|
|
|
|
if (e.HasVitals)
|
|
parts.Add(e.IsAlive ? "Alive" : "Dead");
|
|
|
|
if (e.HasPosition)
|
|
parts.Add($"({e.X:F0},{e.Y:F0})");
|
|
|
|
if (e.HasVitals)
|
|
parts.Add($"HP:{e.LifeCurrent}/{e.LifeTotal}");
|
|
|
|
if (e.Components is { Count: > 0 })
|
|
parts.Add($"{e.Components.Count} comps");
|
|
|
|
return parts.Count > 0 ? string.Join(" ", parts) : "—";
|
|
}
|
|
|
|
private static void UpdateEntityChildren(MemoryNodeViewModel node, Entity e)
|
|
{
|
|
// Build children: address, position, vitals, components
|
|
var needed = new List<(string name, string value, bool valid)>();
|
|
|
|
needed.Add(("Addr:", $"0x{e.Address:X}", true));
|
|
|
|
if (e.Path is not null)
|
|
needed.Add(("Path:", e.Path, true));
|
|
|
|
if (e.HasPosition)
|
|
needed.Add(("Pos:", $"({e.X:F1}, {e.Y:F1}, {e.Z:F1})", true));
|
|
|
|
if (e.HasVitals)
|
|
{
|
|
needed.Add(("Life:", $"{e.LifeCurrent} / {e.LifeTotal}", e.LifeCurrent > 0));
|
|
if (e.ManaTotal > 0)
|
|
needed.Add(("Mana:", $"{e.ManaCurrent} / {e.ManaTotal}", true));
|
|
if (e.EsTotal > 0)
|
|
needed.Add(("ES:", $"{e.EsCurrent} / {e.EsTotal}", true));
|
|
}
|
|
|
|
if (e.Components is { Count: > 0 })
|
|
{
|
|
var compList = string.Join(", ", e.Components.OrderBy(c => c));
|
|
needed.Add(("Components:", compList, true));
|
|
}
|
|
|
|
// Reuse existing children by index
|
|
while (node.Children.Count > needed.Count)
|
|
node.Children.RemoveAt(node.Children.Count - 1);
|
|
|
|
for (var i = 0; i < needed.Count; i++)
|
|
{
|
|
var (name, value, valid) = needed[i];
|
|
if (i < node.Children.Count)
|
|
{
|
|
node.Children[i].Name = name;
|
|
node.Children[i].Set(value, valid);
|
|
}
|
|
else
|
|
{
|
|
var child = new MemoryNodeViewModel(name);
|
|
child.Set(value, valid);
|
|
node.Children.Add(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ReadAddressExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
RawResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
RawResult = _reader.ReadAddress(RawAddress, RawOffsets, RawType);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ScanExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size))
|
|
size = 0x400;
|
|
|
|
ScanResult = _reader.ScanRegion(ScanAddress, ScanOffsets, size);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ScanStatesExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
ScanResult = _reader.ScanAllStates();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ProbeExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
ScanResult = _reader.ProbeInGameState();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ScanComponentsExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
int.TryParse(VitalHp, out var hp);
|
|
int.TryParse(VitalMana, out var mana);
|
|
int.TryParse(VitalEs, out var es);
|
|
|
|
ScanResult = _reader.ScanComponents(hp, mana, es);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void DeepScanExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
int.TryParse(VitalHp, out var hp);
|
|
int.TryParse(VitalMana, out var mana);
|
|
int.TryParse(VitalEs, out var es);
|
|
|
|
ScanResult = _reader.DeepScanVitals(hp, mana, es);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void DiagnoseVitalsExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
ScanResult = _reader.DiagnoseVitals();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ScanPositionExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
ScanResult = _reader.ScanPosition();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void DiagnoseEntityExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
ScanResult = _reader.DiagnoseEntity();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ScanEntitiesExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
ScanResult = _reader.ScanEntities();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ScanCompLookupExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
ScanResult = _reader.ScanComponentLookup();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ScanAreaLoadingExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
ScanResult = _reader.ScanAreaLoadingState();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ScanDiffExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
ScanResult = _reader.ScanMemoryDiff();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ScanActiveVecExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
ScanResult = _reader.ScanActiveStatesVector();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ScanTerrainExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
ScanResult = _reader.ScanTerrain();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ScanStructureExecute()
|
|
{
|
|
if (_reader is null || !_reader.IsAttached)
|
|
{
|
|
ScanResult = "Error: not attached";
|
|
return;
|
|
}
|
|
|
|
if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size))
|
|
size = 0x2000;
|
|
|
|
ScanResult = _reader.ScanStructure(ScanAddress, ScanOffsets, size);
|
|
}
|
|
}
|