1932 lines
78 KiB
C#
1932 lines
78 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using Serilog;
|
|
|
|
namespace Automata.Memory;
|
|
|
|
public class GameStateSnapshot
|
|
{
|
|
// Process
|
|
public bool Attached;
|
|
public int ProcessId;
|
|
public nint ModuleBase;
|
|
public int ModuleSize;
|
|
public string? Error;
|
|
|
|
// GameState
|
|
public nint GameStateBase;
|
|
public bool OffsetsConfigured;
|
|
public int StatesCount;
|
|
|
|
// Pointers
|
|
public nint ControllerPtr;
|
|
public nint InGameStatePtr;
|
|
public nint AreaInstancePtr;
|
|
public nint ServerDataPtr;
|
|
public nint LocalPlayerPtr;
|
|
|
|
// Area
|
|
public int AreaLevel;
|
|
public uint AreaHash;
|
|
|
|
// Player position (Render component)
|
|
public bool HasPosition;
|
|
public float PlayerX, PlayerY, PlayerZ;
|
|
|
|
// Player vitals (Life component)
|
|
public bool HasVitals;
|
|
public int LifeCurrent, LifeTotal;
|
|
public int ManaCurrent, ManaTotal;
|
|
public int EsCurrent, EsTotal;
|
|
|
|
// Entities
|
|
public int EntityCount;
|
|
|
|
// Terrain
|
|
public int TerrainWidth, TerrainHeight;
|
|
public int TerrainCols, TerrainRows;
|
|
}
|
|
|
|
public class GameMemoryReader : IDisposable
|
|
{
|
|
private ProcessMemory? _memory;
|
|
private PatternScanner? _scanner;
|
|
private readonly TerrainOffsets _offsets;
|
|
private nint _moduleBase;
|
|
private int _moduleSize;
|
|
private nint _gameStateBase;
|
|
private bool _disposed;
|
|
private int _cachedLifeIndex = -1;
|
|
private int _cachedRenderIndex = -1;
|
|
private nint _lastLocalPlayer;
|
|
|
|
public GameMemoryReader()
|
|
{
|
|
_offsets = TerrainOffsets.Load("offsets.json");
|
|
}
|
|
|
|
public bool IsAttached => _memory != null;
|
|
|
|
public bool Attach()
|
|
{
|
|
Detach();
|
|
_memory = ProcessMemory.Attach(_offsets.ProcessName);
|
|
if (_memory is null)
|
|
return false;
|
|
|
|
var module = _memory.GetMainModule();
|
|
if (module is not null)
|
|
(_moduleBase, _moduleSize) = module.Value;
|
|
|
|
// Try pattern scan first
|
|
if (!string.IsNullOrWhiteSpace(_offsets.GameStatePattern))
|
|
{
|
|
_scanner = new PatternScanner(_memory);
|
|
_gameStateBase = _scanner.FindPatternRip(_offsets.GameStatePattern);
|
|
if (_gameStateBase != 0)
|
|
{
|
|
_gameStateBase += _offsets.PatternResultAdjust;
|
|
Log.Information("GameState base (pattern+adjust): 0x{Address:X}", _gameStateBase);
|
|
}
|
|
}
|
|
|
|
// Fallback: manual offset from module base
|
|
if (_gameStateBase == 0 && _offsets.GameStateGlobalOffset > 0)
|
|
{
|
|
_gameStateBase = _moduleBase + _offsets.GameStateGlobalOffset;
|
|
Log.Information("GameState base (manual): 0x{Address:X}", _gameStateBase);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public void Detach()
|
|
{
|
|
_memory?.Dispose();
|
|
_memory = null;
|
|
_scanner = null;
|
|
_gameStateBase = 0;
|
|
_moduleBase = 0;
|
|
_moduleSize = 0;
|
|
}
|
|
|
|
public GameStateSnapshot ReadSnapshot()
|
|
{
|
|
var snap = new GameStateSnapshot();
|
|
|
|
if (_memory is null)
|
|
{
|
|
snap.Error = "Not attached";
|
|
return snap;
|
|
}
|
|
|
|
snap.Attached = true;
|
|
snap.ProcessId = _memory.ProcessId;
|
|
snap.ModuleBase = _moduleBase;
|
|
snap.ModuleSize = _moduleSize;
|
|
snap.OffsetsConfigured = _gameStateBase != 0;
|
|
snap.GameStateBase = _gameStateBase;
|
|
|
|
if (_gameStateBase == 0)
|
|
return snap;
|
|
|
|
// Static area level — direct module offset, always reliable (CE: exe+3E84B78)
|
|
if (_offsets.AreaLevelStaticOffset > 0 && _moduleBase != 0)
|
|
{
|
|
var level = _memory.Read<int>(_moduleBase + _offsets.AreaLevelStaticOffset);
|
|
if (level > 0 && level < 200)
|
|
snap.AreaLevel = level;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Resolve InGameState from controller
|
|
var inGameState = ResolveInGameState(snap);
|
|
if (inGameState == 0)
|
|
return snap;
|
|
snap.InGameStatePtr = inGameState;
|
|
|
|
// InGameState → AreaInstance (dump: InGameStateOffset.AreaInstanceData at +0x298)
|
|
var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
|
|
snap.AreaInstancePtr = ingameData;
|
|
|
|
if (ingameData != 0)
|
|
{
|
|
// Area level (dump: byte at +0xAC)
|
|
if (_offsets.AreaLevelIsByte)
|
|
{
|
|
var level = _memory.Read<byte>(ingameData + _offsets.AreaLevelOffset);
|
|
if (level > 0 && level < 200)
|
|
snap.AreaLevel = level;
|
|
}
|
|
else
|
|
{
|
|
var level = _memory.Read<int>(ingameData + _offsets.AreaLevelOffset);
|
|
if (level > 0 && level < 200)
|
|
snap.AreaLevel = level;
|
|
}
|
|
|
|
// Area hash (dump: 0xEC)
|
|
snap.AreaHash = _memory.Read<uint>(ingameData + _offsets.AreaHashOffset);
|
|
|
|
// ServerData pointer (dump: via LocalPlayerStruct at +0x9F0)
|
|
var serverData = _memory.ReadPointer(ingameData + _offsets.ServerDataOffset);
|
|
snap.ServerDataPtr = serverData;
|
|
|
|
// LocalPlayer — try direct offset first (dump: +0xA10), fallback to ServerData chain
|
|
if (_offsets.LocalPlayerDirectOffset > 0)
|
|
snap.LocalPlayerPtr = _memory.ReadPointer(ingameData + _offsets.LocalPlayerDirectOffset);
|
|
|
|
if (snap.LocalPlayerPtr == 0 && serverData != 0)
|
|
snap.LocalPlayerPtr = _memory.ReadPointer(serverData + _offsets.LocalPlayerOffset);
|
|
|
|
// Entity count — dump: EntityListStruct contains StdMap, count at StdMap+0x08 (_Mysize)
|
|
var entityCount = (int)_memory.Read<long>(ingameData + _offsets.EntityListOffset + _offsets.EntityCountInternalOffset);
|
|
if (entityCount > 0 && entityCount < 50000)
|
|
snap.EntityCount = entityCount;
|
|
|
|
// Player vitals & position — ECS: LocalPlayer → ComponentList → Life/Render components
|
|
if (snap.LocalPlayerPtr != 0)
|
|
{
|
|
// Invalidate caches if LocalPlayer entity changed (zone change, new character)
|
|
if (snap.LocalPlayerPtr != _lastLocalPlayer)
|
|
{
|
|
_cachedLifeIndex = -1;
|
|
_cachedRenderIndex = -1;
|
|
_lastLocalPlayer = snap.LocalPlayerPtr;
|
|
}
|
|
|
|
ReadVitalsEcs(snap);
|
|
ReadPlayerPosition(snap);
|
|
}
|
|
|
|
// Terrain
|
|
ReadTerrain(snap, ingameData);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Debug(ex, "Error reading snapshot");
|
|
}
|
|
|
|
return snap;
|
|
}
|
|
|
|
private nint ResolveInGameState(GameStateSnapshot snap)
|
|
{
|
|
// Global points to the GameStateMachine controller object
|
|
var controller = _memory!.ReadPointer(_gameStateBase);
|
|
if (controller == 0) return 0;
|
|
snap.ControllerPtr = controller;
|
|
|
|
// Direct offset mode: read InGameState straight from controller (CE confirmed: +0x210)
|
|
if (_offsets.InGameStateDirectOffset > 0)
|
|
{
|
|
var igs = _memory.ReadPointer(controller + _offsets.InGameStateDirectOffset);
|
|
if (igs != 0)
|
|
{
|
|
// Count states for display (scan inline slots)
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
var slotOffset = _offsets.StatesBeginOffset + i * _offsets.StateStride + _offsets.StatePointerOffset;
|
|
var ptr = _memory.ReadPointer(controller + slotOffset);
|
|
if (ptr == 0) break;
|
|
snap.StatesCount++;
|
|
}
|
|
return igs;
|
|
}
|
|
}
|
|
|
|
if (_offsets.StatesInline)
|
|
{
|
|
// POE1-style: states stored INLINE at fixed offsets in the controller struct
|
|
// InGameState = controller + StatesBeginOffset + InGameStateIndex * StateStride + StatePointerOffset
|
|
// POE1: controller + 0x48 + 4*0x10 + 0 = controller + 0x88
|
|
var inlineOffset = _offsets.StatesBeginOffset
|
|
+ _offsets.InGameStateIndex * _offsets.StateStride
|
|
+ _offsets.StatePointerOffset;
|
|
|
|
// Count states by scanning inline slots
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
var slotOffset = _offsets.StatesBeginOffset + i * _offsets.StateStride + _offsets.StatePointerOffset;
|
|
var ptr = _memory.ReadPointer(controller + slotOffset);
|
|
if (ptr == 0) break;
|
|
snap.StatesCount++;
|
|
}
|
|
|
|
return _memory.ReadPointer(controller + inlineOffset);
|
|
}
|
|
else
|
|
{
|
|
// Vector-style: StatesBeginOffset points to a std::vector of state entries
|
|
var statesBegin = _memory.ReadPointer(controller + _offsets.StatesBeginOffset);
|
|
if (statesBegin == 0) return 0;
|
|
|
|
var statesEnd = _memory.ReadPointer(controller + _offsets.StatesBeginOffset + 8);
|
|
if (statesEnd > statesBegin && statesEnd - statesBegin < 0x1000 && _offsets.StateStride > 0)
|
|
{
|
|
snap.StatesCount = (int)((statesEnd - statesBegin) / _offsets.StateStride);
|
|
}
|
|
else
|
|
{
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
if (_memory.ReadPointer(statesBegin + i * _offsets.StateStride + _offsets.StatePointerOffset) == 0) break;
|
|
snap.StatesCount++;
|
|
}
|
|
}
|
|
|
|
if (_offsets.InGameStateIndex < 0 || _offsets.InGameStateIndex >= snap.StatesCount)
|
|
return 0;
|
|
|
|
return _memory.ReadPointer(statesBegin + _offsets.InGameStateIndex * _offsets.StateStride + _offsets.StatePointerOffset);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads vitals via Entity Component System: LocalPlayer → ComponentList StdVector → Life component.
|
|
/// Auto-discovers the Life component index by scanning for VitalStruct pattern, caches the index.
|
|
/// Stable across zone changes because the ECS pointers are maintained by the game engine.
|
|
/// </summary>
|
|
private void ReadVitalsEcs(GameStateSnapshot snap)
|
|
{
|
|
var entity = snap.LocalPlayerPtr;
|
|
if (entity == 0) return;
|
|
|
|
// Find component array — try multiple strategies
|
|
var (compFirst, count) = FindComponentList(entity);
|
|
if (count <= 0) return;
|
|
|
|
// Try cached index first
|
|
if (_cachedLifeIndex >= 0 && _cachedLifeIndex < count)
|
|
{
|
|
var lifeComp = _memory!.ReadPointer(compFirst + _cachedLifeIndex * 8);
|
|
if (lifeComp != 0 && TryReadVitals(snap, lifeComp))
|
|
return;
|
|
// Cache miss — index shifted, re-scan
|
|
_cachedLifeIndex = -1;
|
|
}
|
|
|
|
// Scan all component pointers for VitalStruct pattern (HP + Mana at known offsets)
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
var compPtr = _memory!.ReadPointer(compFirst + i * 8);
|
|
if (compPtr == 0) continue;
|
|
|
|
// Quick heap pointer validation
|
|
var high = (ulong)compPtr >> 32;
|
|
if (high == 0 || high >= 0x7FFF) continue;
|
|
if ((compPtr & 0x3) != 0) continue;
|
|
|
|
// Check VitalStruct pattern: HP.Total must be reasonable player HP (not 1/1 false positives)
|
|
var hpTotal = _memory.Read<int>(compPtr + _offsets.LifeHealthOffset + _offsets.VitalTotalOffset);
|
|
if (hpTotal < 20 || hpTotal > 200000) continue;
|
|
|
|
var hpCurrent = _memory.Read<int>(compPtr + _offsets.LifeHealthOffset + _offsets.VitalCurrentOffset);
|
|
if (hpCurrent < 0 || hpCurrent > hpTotal + 1000) continue;
|
|
|
|
var manaTotal = _memory.Read<int>(compPtr + _offsets.LifeManaOffset + _offsets.VitalTotalOffset);
|
|
if (manaTotal < 0 || manaTotal > 200000) continue;
|
|
|
|
var manaCurrent = _memory.Read<int>(compPtr + _offsets.LifeManaOffset + _offsets.VitalCurrentOffset);
|
|
if (manaCurrent < 0 || manaCurrent > manaTotal + 1000) continue;
|
|
|
|
// Require at least some mana or ES (all player characters have base mana)
|
|
var esTotal = _memory.Read<int>(compPtr + _offsets.LifeEsOffset + _offsets.VitalTotalOffset);
|
|
if (manaTotal == 0 && esTotal == 0) continue;
|
|
|
|
// Found Life component — cache index and read vitals
|
|
_cachedLifeIndex = i;
|
|
Log.Information("ECS: Life component at index {Index} (0x{Addr:X}) — HP: {Hp}/{HpMax}, Mana: {Mana}/{ManaMax}",
|
|
i, compPtr, hpCurrent, hpTotal, manaCurrent, manaTotal);
|
|
TryReadVitals(snap, compPtr);
|
|
return;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the best component list from the entity, trying multiple strategies:
|
|
/// 1. StdVector at entity+ComponentListOffset (ExileCore standard)
|
|
/// 2. Inner entity: entity+0x000 → deref → StdVector at +ComponentListOffset (POE2 wrapper)
|
|
/// 3. Scan entity memory for any StdVector with many pointer-sized elements
|
|
/// </summary>
|
|
private (nint First, int Count) FindComponentList(nint entity)
|
|
{
|
|
// Strategy 1: direct StdVector at entity+ComponentListOffset
|
|
var compFirst = _memory!.ReadPointer(entity + _offsets.ComponentListOffset);
|
|
var compLast = _memory.ReadPointer(entity + _offsets.ComponentListOffset + 8);
|
|
var count = 0;
|
|
if (compFirst != 0 && compLast > compFirst && (compLast - compFirst) < 0x2000)
|
|
count = (int)((compLast - compFirst) / 8);
|
|
|
|
if (count > 1) return (compFirst, count);
|
|
|
|
// Strategy 2: POE2 may wrap entities — entity+0x000 is a pointer to the real entity
|
|
var innerEntity = _memory.ReadPointer(entity);
|
|
if (innerEntity != 0 && innerEntity != entity && !IsModuleAddress(innerEntity))
|
|
{
|
|
var high = (ulong)innerEntity >> 32;
|
|
if (high > 0 && high < 0x7FFF && (innerEntity & 0x3) == 0)
|
|
{
|
|
var innerFirst = _memory.ReadPointer(innerEntity + _offsets.ComponentListOffset);
|
|
var innerLast = _memory.ReadPointer(innerEntity + _offsets.ComponentListOffset + 8);
|
|
if (innerFirst != 0 && innerLast > innerFirst && (innerLast - innerFirst) < 0x2000)
|
|
{
|
|
var innerCount = (int)((innerLast - innerFirst) / 8);
|
|
if (innerCount > count)
|
|
{
|
|
Log.Debug("ECS: Using inner entity 0x{Addr:X} component list ({Count} entries)",
|
|
innerEntity, innerCount);
|
|
return (innerFirst, innerCount);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 3: scan entity memory for StdVector patterns with ≥3 pointer-sized elements
|
|
var entityData = _memory.ReadBytes(entity, 0x300);
|
|
if (entityData is not null)
|
|
{
|
|
for (var off = 0; off + 24 <= entityData.Length; off += 8)
|
|
{
|
|
var f = (nint)BitConverter.ToInt64(entityData, off);
|
|
var l = (nint)BitConverter.ToInt64(entityData, off + 8);
|
|
if (f == 0 || l <= f) continue;
|
|
var sz = l - f;
|
|
if (sz < 24 || sz > 0x2000 || sz % 8 != 0) continue;
|
|
var n = (int)(sz / 8);
|
|
if (n <= count) continue;
|
|
|
|
// Verify: first element should be a valid heap pointer
|
|
var firstEl = _memory.ReadPointer(f);
|
|
var h = (ulong)firstEl >> 32;
|
|
if (h == 0 || h >= 0x7FFF || (firstEl & 0x3) != 0) continue;
|
|
|
|
Log.Debug("ECS: Found StdVector at entity+0x{Off:X} with {Count} elements", off, n);
|
|
if (n > count) { compFirst = f; count = n; }
|
|
}
|
|
}
|
|
|
|
return (compFirst, count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to read all vitals from a Life component pointer. Returns true if values pass sanity checks.
|
|
/// </summary>
|
|
private bool TryReadVitals(GameStateSnapshot snap, nint lifeComp)
|
|
{
|
|
var hp = _memory!.Read<int>(lifeComp + _offsets.LifeHealthOffset + _offsets.VitalCurrentOffset);
|
|
var hpMax = _memory.Read<int>(lifeComp + _offsets.LifeHealthOffset + _offsets.VitalTotalOffset);
|
|
var mana = _memory.Read<int>(lifeComp + _offsets.LifeManaOffset + _offsets.VitalCurrentOffset);
|
|
var manaMax = _memory.Read<int>(lifeComp + _offsets.LifeManaOffset + _offsets.VitalTotalOffset);
|
|
var es = _memory.Read<int>(lifeComp + _offsets.LifeEsOffset + _offsets.VitalCurrentOffset);
|
|
var esMax = _memory.Read<int>(lifeComp + _offsets.LifeEsOffset + _offsets.VitalTotalOffset);
|
|
|
|
// Sanity check: values must be non-negative and within reasonable range
|
|
if (hpMax <= 0 || hpMax > 200000 || hp < 0 || hp > hpMax + 1000) return false;
|
|
if (manaMax < 0 || manaMax > 200000 || mana < 0) return false;
|
|
|
|
snap.HasVitals = true;
|
|
snap.LifeCurrent = hp;
|
|
snap.LifeTotal = hpMax;
|
|
snap.ManaCurrent = mana;
|
|
snap.ManaTotal = manaMax;
|
|
snap.EsCurrent = es;
|
|
snap.EsTotal = esMax;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads player position from the Render component via ECS.
|
|
/// Auto-discovers the Render component index by scanning for world-coordinate float triplets, caches the index.
|
|
/// </summary>
|
|
private void ReadPlayerPosition(GameStateSnapshot snap)
|
|
{
|
|
var entity = snap.LocalPlayerPtr;
|
|
if (entity == 0) return;
|
|
|
|
var (compFirst, count) = FindComponentList(entity);
|
|
if (count <= 0) return;
|
|
|
|
// Try configured index first
|
|
if (_offsets.RenderComponentIndex >= 0)
|
|
{
|
|
var idx = _offsets.RenderComponentIndex;
|
|
if (idx < count)
|
|
{
|
|
var renderComp = _memory!.ReadPointer(compFirst + idx * 8);
|
|
if (renderComp != 0 && TryReadPosition(snap, renderComp))
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Try cached index
|
|
if (_cachedRenderIndex >= 0 && _cachedRenderIndex < count)
|
|
{
|
|
var renderComp = _memory!.ReadPointer(compFirst + _cachedRenderIndex * 8);
|
|
if (renderComp != 0 && TryReadPosition(snap, renderComp))
|
|
return;
|
|
_cachedRenderIndex = -1;
|
|
}
|
|
|
|
// Auto-discover: scan components for float triplet that looks like world coordinates
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
if (i == _cachedLifeIndex) continue;
|
|
|
|
var compPtr = _memory!.ReadPointer(compFirst + i * 8);
|
|
if (compPtr == 0) continue;
|
|
|
|
var high = (ulong)compPtr >> 32;
|
|
if (high == 0 || high >= 0x7FFF) continue;
|
|
if ((compPtr & 0x3) != 0) continue;
|
|
|
|
if (TryReadPosition(snap, compPtr))
|
|
{
|
|
_cachedRenderIndex = i;
|
|
Log.Information("ECS: Render component at index {Index} (0x{Addr:X}) — Pos: ({X:F1}, {Y:F1}, {Z:F1})",
|
|
i, compPtr, snap.PlayerX, snap.PlayerY, snap.PlayerZ);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to read position from a Render component pointer. Returns true if values look like valid world coordinates.
|
|
/// </summary>
|
|
private bool TryReadPosition(GameStateSnapshot snap, nint renderComp)
|
|
{
|
|
var x = _memory!.Read<float>(renderComp + _offsets.PositionXOffset);
|
|
var y = _memory.Read<float>(renderComp + _offsets.PositionYOffset);
|
|
var z = _memory.Read<float>(renderComp + _offsets.PositionZOffset);
|
|
|
|
if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) return false;
|
|
if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) return false;
|
|
// POE2 world coordinates: typically 50-50000 range for X/Y
|
|
if (x < 50 || x > 50000 || y < 50 || y > 50000) return false;
|
|
if (MathF.Abs(z) > 5000) return false;
|
|
|
|
snap.HasPosition = true;
|
|
snap.PlayerX = x;
|
|
snap.PlayerY = y;
|
|
snap.PlayerZ = z;
|
|
return true;
|
|
}
|
|
|
|
private void ReadTerrain(GameStateSnapshot snap, nint areaInstance)
|
|
{
|
|
if (_offsets.TerrainInline)
|
|
{
|
|
// Dump: TerrainStruct is inline at AreaInstance + 0xCC0
|
|
// TotalTiles (StdTuple2D<long>) at TerrainStruct + 0x18
|
|
var terrainBase = areaInstance + _offsets.TerrainListOffset;
|
|
var cols = (int)_memory!.Read<long>(terrainBase + _offsets.TerrainDimensionsOffset);
|
|
var rows = (int)_memory.Read<long>(terrainBase + _offsets.TerrainDimensionsOffset + 8);
|
|
|
|
if (cols > 0 && cols < 1000 && rows > 0 && rows < 1000)
|
|
{
|
|
snap.TerrainCols = cols;
|
|
snap.TerrainRows = rows;
|
|
snap.TerrainWidth = cols * _offsets.SubTilesPerCell;
|
|
snap.TerrainHeight = rows * _offsets.SubTilesPerCell;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Pointer-based: AreaInstance → TerrainList → first terrain → dimensions
|
|
var terrainListPtr = _memory!.ReadPointer(areaInstance + _offsets.TerrainListOffset);
|
|
if (terrainListPtr == 0) return;
|
|
|
|
var terrainPtr = _memory.ReadPointer(terrainListPtr);
|
|
if (terrainPtr == 0) return;
|
|
|
|
var dimsPtr = _memory.ReadPointer(terrainPtr + _offsets.TerrainDimensionsOffset);
|
|
if (dimsPtr == 0) return;
|
|
|
|
snap.TerrainCols = _memory.Read<int>(dimsPtr);
|
|
snap.TerrainRows = _memory.Read<int>(dimsPtr + 4);
|
|
if (snap.TerrainCols > 0 && snap.TerrainCols < 1000 &&
|
|
snap.TerrainRows > 0 && snap.TerrainRows < 1000)
|
|
{
|
|
snap.TerrainWidth = snap.TerrainCols * _offsets.SubTilesPerCell;
|
|
snap.TerrainHeight = snap.TerrainRows * _offsets.SubTilesPerCell;
|
|
}
|
|
else
|
|
{
|
|
snap.TerrainCols = 0;
|
|
snap.TerrainRows = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raw explorer: parse hex address, follow offset chain, read as specified type.
|
|
/// </summary>
|
|
public string ReadAddress(string hexAddr, string offsetsCsv, string type)
|
|
{
|
|
if (_memory is null)
|
|
return "Error: not attached";
|
|
|
|
if (!nint.TryParse(hexAddr, NumberStyles.HexNumber, null, out var addr))
|
|
return $"Error: invalid address '{hexAddr}'";
|
|
|
|
// Parse offsets
|
|
var offsets = Array.Empty<int>();
|
|
if (!string.IsNullOrWhiteSpace(offsetsCsv))
|
|
{
|
|
var parts = offsetsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
offsets = new int[parts.Length];
|
|
for (var i = 0; i < parts.Length; i++)
|
|
{
|
|
if (!int.TryParse(parts[i], NumberStyles.HexNumber, null, out offsets[i]))
|
|
return $"Error: invalid offset '{parts[i]}'";
|
|
}
|
|
}
|
|
|
|
// Follow chain if offsets provided
|
|
if (offsets.Length > 0)
|
|
{
|
|
addr = _memory.FollowChain(addr, offsets);
|
|
if (addr == 0)
|
|
return "Error: pointer chain broken (null)";
|
|
}
|
|
|
|
return type.ToLowerInvariant() switch
|
|
{
|
|
"int32" => _memory.Read<int>(addr).ToString(),
|
|
"int64" => _memory.Read<long>(addr).ToString(),
|
|
"float" => _memory.Read<float>(addr).ToString("F4"),
|
|
"double" => _memory.Read<double>(addr).ToString("F4"),
|
|
"pointer" => $"0x{_memory.ReadPointer(addr):X}",
|
|
"bytes16" => FormatBytes(_memory.ReadBytes(addr, 16)),
|
|
"string" => ReadNullTermString(addr),
|
|
_ => $"Error: unknown type '{type}'"
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scans a memory region and returns all pointer-like values with their offsets.
|
|
/// </summary>
|
|
public string ScanRegion(string hexAddr, string offsetsCsv, int size)
|
|
{
|
|
if (_memory is null)
|
|
return "Error: not attached";
|
|
|
|
if (!nint.TryParse(hexAddr, NumberStyles.HexNumber, null, out var addr))
|
|
return $"Error: invalid address '{hexAddr}'";
|
|
|
|
// Parse and follow offset chain
|
|
if (!string.IsNullOrWhiteSpace(offsetsCsv))
|
|
{
|
|
var parts = offsetsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
var offsets = new int[parts.Length];
|
|
for (var i = 0; i < parts.Length; i++)
|
|
{
|
|
if (!int.TryParse(parts[i], NumberStyles.HexNumber, null, out offsets[i]))
|
|
return $"Error: invalid offset '{parts[i]}'";
|
|
}
|
|
addr = _memory.FollowChain(addr, offsets);
|
|
if (addr == 0)
|
|
return "Error: pointer chain broken (null)";
|
|
}
|
|
|
|
// Read the full block
|
|
size = Math.Clamp(size, 0x10, 0x10000);
|
|
var data = _memory.ReadBytes(addr, size);
|
|
if (data is null)
|
|
return "Error: read failed";
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"Scan: 0x{addr:X} size: 0x{size:X}");
|
|
sb.AppendLine(new string('─', 60));
|
|
|
|
for (var offset = 0; offset + 8 <= data.Length; offset += 8)
|
|
{
|
|
var value = BitConverter.ToUInt64(data, offset);
|
|
if (value == 0) continue;
|
|
|
|
var nVal = (nint)(long)value;
|
|
var tag = ClassifyPointer(nVal);
|
|
if (tag is null)
|
|
{
|
|
// Show non-zero non-pointer values only if they look like small ints or floats
|
|
if (value <= 0xFFFF)
|
|
{
|
|
sb.AppendLine($"+0x{offset:X3}: {value,-20} [int: {value}]");
|
|
}
|
|
else
|
|
{
|
|
var f1 = BitConverter.ToSingle(data, offset);
|
|
var f2 = BitConverter.ToSingle(data, offset + 4);
|
|
if (IsReasonableFloat(f1) || IsReasonableFloat(f2))
|
|
{
|
|
sb.AppendLine($"+0x{offset:X3}: {f1,12:F2} {f2,12:F2} [float pair]");
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
sb.AppendLine($"+0x{offset:X3}: 0x{value:X} [{tag}]");
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a vtable address to its RTTI class name using MSVC x64 RTTI layout.
|
|
/// vtable[-1] → RTTICompleteObjectLocator → TypeDescriptor → mangled name
|
|
/// </summary>
|
|
public string? ResolveRttiName(nint vtableAddr)
|
|
{
|
|
if (_memory is null || _moduleBase == 0) return null;
|
|
|
|
try
|
|
{
|
|
// vtable[-1] = pointer to RTTICompleteObjectLocator
|
|
var colPtr = _memory.ReadPointer(vtableAddr - 8);
|
|
if (colPtr == 0) return null;
|
|
|
|
// COL signature check: x64 = 1
|
|
var signature = _memory.Read<int>(colPtr);
|
|
if (signature != 1) return null;
|
|
|
|
// COL+0x0C = typeDescriptorOffset (image-relative, 4 bytes)
|
|
var typeDescOffset = _memory.Read<int>(colPtr + 0x0C);
|
|
if (typeDescOffset <= 0) return null;
|
|
|
|
// TypeDescriptor = moduleBase + typeDescOffset
|
|
var typeDesc = _moduleBase + typeDescOffset;
|
|
|
|
// TypeDescriptor+0x10 = mangled name string (e.g. ".?AVInGameState@@")
|
|
var nameBytes = _memory.ReadBytes(typeDesc + 0x10, 128);
|
|
if (nameBytes is null) return null;
|
|
|
|
var end = Array.IndexOf(nameBytes, (byte)0);
|
|
if (end <= 0) return null;
|
|
|
|
var mangled = Encoding.ASCII.GetString(nameBytes, 0, end);
|
|
|
|
// Demangle: ".?AVClassName@@" → "ClassName"
|
|
if (mangled.StartsWith(".?AV") && mangled.EndsWith("@@"))
|
|
return mangled[4..^2];
|
|
if (mangled.StartsWith(".?AU") && mangled.EndsWith("@@"))
|
|
return mangled[4..^2];
|
|
|
|
return mangled;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private bool IsModuleAddress(nint value)
|
|
{
|
|
return _moduleBase != 0 && _moduleSize > 0 &&
|
|
value >= _moduleBase && value < _moduleBase + _moduleSize;
|
|
}
|
|
|
|
private string? ClassifyPointer(nint value)
|
|
{
|
|
if (value == 0) return null;
|
|
|
|
// Module range — try RTTI resolution
|
|
if (IsModuleAddress(value))
|
|
{
|
|
var name = ResolveRttiName(value);
|
|
return name ?? "module (vtable?)";
|
|
}
|
|
|
|
// Heap heuristic: user-mode addresses in typical 64-bit range
|
|
if (value > 0x10000 && value < (nint)0x7FFFFFFFFFFF && (value & 0x3) == 0)
|
|
{
|
|
var high = (ulong)value >> 32;
|
|
if (high > 0 && high < 0x7FFF)
|
|
return "heap ptr";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scans all game states and returns their info.
|
|
/// Supports both inline (POE1-style) and vector modes.
|
|
/// </summary>
|
|
public string ScanAllStates()
|
|
{
|
|
if (_memory is null) return "Error: not attached";
|
|
if (_gameStateBase == 0) return "Error: GameState base not resolved";
|
|
|
|
var controller = _memory.ReadPointer(_gameStateBase);
|
|
if (controller == 0) return "Error: controller is null";
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"Controller: 0x{controller:X}");
|
|
sb.AppendLine($"Mode: {(_offsets.StatesInline ? "inline" : "vector")}");
|
|
|
|
int count;
|
|
if (_offsets.StatesInline)
|
|
{
|
|
// Inline: count by scanning slots until null
|
|
count = 0;
|
|
for (var i = 0; i < 30; i++)
|
|
{
|
|
var slotOffset = _offsets.StatesBeginOffset + i * _offsets.StateStride + _offsets.StatePointerOffset;
|
|
var ptr = _memory.ReadPointer(controller + slotOffset);
|
|
if (ptr == 0) break;
|
|
count++;
|
|
}
|
|
|
|
sb.AppendLine($"States: {count} (inline at controller+0x{_offsets.StatesBeginOffset:X})");
|
|
sb.AppendLine(new string('─', 70));
|
|
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
var slotOffset = _offsets.StatesBeginOffset + i * _offsets.StateStride + _offsets.StatePointerOffset;
|
|
var statePtr = _memory.ReadPointer(controller + slotOffset);
|
|
|
|
string? stateName = null;
|
|
if (statePtr != 0)
|
|
{
|
|
var stateVtable = _memory.ReadPointer(statePtr);
|
|
if (stateVtable != 0 && IsModuleAddress(stateVtable))
|
|
stateName = ResolveRttiName(stateVtable);
|
|
}
|
|
|
|
var marker = i == _offsets.InGameStateIndex ? " ◄◄◄" : "";
|
|
var tag = IsModuleAddress(statePtr) ? " [module!]" : "";
|
|
sb.AppendLine($"[{i}] +0x{slotOffset:X}: 0x{statePtr:X} {stateName ?? "?"}{tag}{marker}");
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
// Vector mode (fallback)
|
|
var statesBegin = _memory.ReadPointer(controller + _offsets.StatesBeginOffset);
|
|
if (statesBegin == 0) return "Error: states begin is null";
|
|
|
|
var statesEnd = _memory.ReadPointer(controller + _offsets.StatesBeginOffset + 8);
|
|
count = 0;
|
|
if (statesEnd > statesBegin && statesEnd - statesBegin < 0x1000 && _offsets.StateStride > 0)
|
|
count = (int)((statesEnd - statesBegin) / _offsets.StateStride);
|
|
else
|
|
{
|
|
for (var i = 0; i < 30; i++)
|
|
{
|
|
if (_memory.ReadPointer(statesBegin + i * _offsets.StateStride + _offsets.StatePointerOffset) == 0) break;
|
|
count++;
|
|
}
|
|
}
|
|
|
|
sb.AppendLine($"States: {count} (vector begin: 0x{statesBegin:X})");
|
|
sb.AppendLine(new string('─', 70));
|
|
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
var entryBase = statesBegin + i * _offsets.StateStride;
|
|
var vtable = _memory.ReadPointer(entryBase);
|
|
var statePtr = _memory.ReadPointer(entryBase + _offsets.StatePointerOffset);
|
|
|
|
var vtableName = vtable != 0 && IsModuleAddress(vtable) ? ResolveRttiName(vtable) : null;
|
|
|
|
string? stateName = null;
|
|
if (statePtr != 0)
|
|
{
|
|
var stateVtable = _memory.ReadPointer(statePtr);
|
|
if (stateVtable != 0 && IsModuleAddress(stateVtable))
|
|
stateName = ResolveRttiName(stateVtable);
|
|
}
|
|
|
|
var marker = i == _offsets.InGameStateIndex ? " ◄◄◄" : "";
|
|
sb.AppendLine($"[{i}] 0x{statePtr:X} {stateName ?? "?"}{marker}");
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scans an object's memory and identifies all RTTI-typed sub-elements.
|
|
/// Groups by vtable to show the structure with class names.
|
|
/// </summary>
|
|
public string ScanStructure(string hexAddr, string offsetsCsv, int size)
|
|
{
|
|
if (_memory is null) return "Error: not attached";
|
|
|
|
if (!nint.TryParse(hexAddr, NumberStyles.HexNumber, null, out var addr))
|
|
return $"Error: invalid address '{hexAddr}'";
|
|
|
|
if (!string.IsNullOrWhiteSpace(offsetsCsv))
|
|
{
|
|
var parts = offsetsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
var offsets = new int[parts.Length];
|
|
for (var i = 0; i < parts.Length; i++)
|
|
{
|
|
if (!int.TryParse(parts[i], NumberStyles.HexNumber, null, out offsets[i]))
|
|
return $"Error: invalid offset '{parts[i]}'";
|
|
}
|
|
addr = _memory.FollowChain(addr, offsets);
|
|
if (addr == 0) return "Error: pointer chain broken (null)";
|
|
}
|
|
|
|
size = Math.Clamp(size, 0x10, 0x10000);
|
|
var data = _memory.ReadBytes(addr, size);
|
|
if (data is null) return "Error: read failed";
|
|
|
|
// First pass: get RTTI name for the object itself
|
|
var objVtable = BitConverter.ToInt64(data, 0);
|
|
var objName = objVtable != 0 && IsModuleAddress((nint)objVtable) ? ResolveRttiName((nint)objVtable) : null;
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"Structure: 0x{addr:X} size: 0x{size:X} type: {objName ?? "?"}");
|
|
sb.AppendLine(new string('─', 80));
|
|
|
|
// Scan for all vtable pointers and resolve their RTTI names
|
|
for (var offset = 0; offset + 8 <= data.Length; offset += 8)
|
|
{
|
|
var value = (nint)BitConverter.ToInt64(data, offset);
|
|
if (value == 0) continue;
|
|
|
|
if (IsModuleAddress(value))
|
|
{
|
|
var name = ResolveRttiName(value);
|
|
if (name is not null)
|
|
{
|
|
// Check if next qword is a heap pointer (data for this element)
|
|
nint dataPtr = 0;
|
|
if (offset + 16 <= data.Length)
|
|
dataPtr = (nint)BitConverter.ToInt64(data, offset + 8);
|
|
|
|
sb.AppendLine($"+0x{offset:X4}: [{name}] data: 0x{dataPtr:X}");
|
|
}
|
|
else
|
|
{
|
|
sb.AppendLine($"+0x{offset:X4}: 0x{value:X} [module ?]");
|
|
}
|
|
}
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scans the LocalPlayer's component list, resolves RTTI names, and searches each component
|
|
/// for known vital values (HP, Mana, ES) to determine component indices and offsets.
|
|
/// </summary>
|
|
public string ScanComponents(int hpValue, int manaValue, int esValue)
|
|
{
|
|
if (_memory is null) return "Error: not attached";
|
|
if (_gameStateBase == 0) return "Error: GameState base not resolved";
|
|
|
|
var snap = new GameStateSnapshot();
|
|
var inGameState = ResolveInGameState(snap);
|
|
if (inGameState == 0) return "Error: InGameState not resolved";
|
|
|
|
var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
|
|
if (ingameData == 0) return "Error: AreaInstance not resolved";
|
|
|
|
// Try direct LocalPlayer offset first, then ServerData chain
|
|
var localPlayer = nint.Zero;
|
|
if (_offsets.LocalPlayerDirectOffset > 0)
|
|
localPlayer = _memory.ReadPointer(ingameData + _offsets.LocalPlayerDirectOffset);
|
|
if (localPlayer == 0)
|
|
{
|
|
var serverData = _memory.ReadPointer(ingameData + _offsets.ServerDataOffset);
|
|
if (serverData != 0)
|
|
localPlayer = _memory.ReadPointer(serverData + _offsets.LocalPlayerOffset);
|
|
}
|
|
if (localPlayer == 0) return "Error: LocalPlayer not resolved";
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
|
|
|
|
// Find component list (tries direct, inner entity, and StdVector scan)
|
|
var (compBegin, vectorCount) = FindComponentList(localPlayer);
|
|
if (compBegin == 0) return "Error: ComponentList not found (tried direct, inner entity, and scan)";
|
|
|
|
sb.AppendLine($"ComponentList: 0x{compBegin:X} count: {vectorCount}");
|
|
sb.AppendLine(new string('═', 90));
|
|
|
|
var searchValues = new (string name, int value)[]
|
|
{
|
|
("HP", hpValue),
|
|
("Mana", manaValue),
|
|
("ES", esValue)
|
|
};
|
|
|
|
// Determine scan limit: use vector bounds if valid, otherwise scan up to 64 slots
|
|
var maxSlots = vectorCount > 0 ? vectorCount : 64;
|
|
var realCount = 0;
|
|
|
|
for (var i = 0; i < maxSlots; i++)
|
|
{
|
|
var compPtr = _memory.ReadPointer(compBegin + i * 8);
|
|
if (compPtr == 0) continue; // Null slot
|
|
|
|
// Filter bogus pointers (pointing back into entity/list region)
|
|
var distFromEntity = Math.Abs((long)(compPtr - localPlayer));
|
|
if (distFromEntity < 0x200)
|
|
{
|
|
sb.AppendLine($"[{i,2}] 0x{compPtr:X} (skip: within entity struct)");
|
|
continue;
|
|
}
|
|
|
|
var high = (ulong)compPtr >> 32;
|
|
if (high == 0 || high >= 0x7FFF)
|
|
{
|
|
if (vectorCount == 0) break; // No vector bounds, stop on invalid
|
|
continue;
|
|
}
|
|
|
|
realCount++;
|
|
|
|
// Get RTTI name
|
|
var vtable = _memory.ReadPointer(compPtr);
|
|
string? rtti = null;
|
|
if (vtable != 0 && IsModuleAddress(vtable))
|
|
rtti = ResolveRttiName(vtable);
|
|
|
|
var line = $"[{i,2}] 0x{compPtr:X} {rtti ?? "?"}";
|
|
|
|
// Scan this component's memory for vital values (deeper: 0x2000 = 8KB)
|
|
var compSize = 0x2000;
|
|
var compData = _memory.ReadBytes(compPtr, compSize);
|
|
if (compData is not null)
|
|
{
|
|
var hits = new List<string>();
|
|
foreach (var (vName, vValue) in searchValues)
|
|
{
|
|
if (vValue == 0) continue;
|
|
for (var off = 0; off + 4 <= compData.Length; off += 4)
|
|
{
|
|
var val = BitConverter.ToInt32(compData, off);
|
|
if (val == vValue)
|
|
hits.Add($"{vName}={vValue}@+0x{off:X}");
|
|
}
|
|
}
|
|
if (hits.Count > 0)
|
|
line += $"\n ◄ {string.Join(", ", hits)}";
|
|
}
|
|
|
|
sb.AppendLine(line);
|
|
}
|
|
|
|
sb.AppendLine($"\nReal components: {realCount}");
|
|
|
|
// Also scan the entity struct itself (first 0x200 bytes) for vitals
|
|
sb.AppendLine($"\n── Entity scan (0x{localPlayer:X}, 0x200 bytes) ──");
|
|
var entityData = _memory.ReadBytes(localPlayer, 0x200);
|
|
if (entityData is not null)
|
|
{
|
|
foreach (var (vName, vValue) in searchValues)
|
|
{
|
|
if (vValue == 0) continue;
|
|
for (var off = 0; off + 4 <= entityData.Length; off += 4)
|
|
{
|
|
var val = BitConverter.ToInt32(entityData, off);
|
|
if (val == vValue)
|
|
sb.AppendLine($" Entity+0x{off:X}: {vName}={vValue}");
|
|
}
|
|
}
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scans all components for float triplets (X,Y,Z) that look like world coordinates.
|
|
/// Finds the Render component and the correct position offsets within it.
|
|
/// </summary>
|
|
public string ScanPosition()
|
|
{
|
|
if (_memory is null) return "Error: not attached";
|
|
if (_gameStateBase == 0) return "Error: GameState base not resolved";
|
|
|
|
var snap = new GameStateSnapshot();
|
|
var inGameState = ResolveInGameState(snap);
|
|
if (inGameState == 0) return "Error: InGameState not resolved";
|
|
|
|
var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
|
|
if (ingameData == 0) return "Error: AreaInstance not resolved";
|
|
|
|
var localPlayer = nint.Zero;
|
|
if (_offsets.LocalPlayerDirectOffset > 0)
|
|
localPlayer = _memory.ReadPointer(ingameData + _offsets.LocalPlayerDirectOffset);
|
|
if (localPlayer == 0)
|
|
{
|
|
var serverData = _memory.ReadPointer(ingameData + _offsets.ServerDataOffset);
|
|
if (serverData != 0)
|
|
localPlayer = _memory.ReadPointer(serverData + _offsets.LocalPlayerOffset);
|
|
}
|
|
if (localPlayer == 0) return "Error: LocalPlayer not resolved";
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
|
|
|
|
var (compBegin, vectorCount) = FindComponentList(localPlayer);
|
|
if (compBegin == 0) return "Error: ComponentList not found";
|
|
|
|
sb.AppendLine($"ComponentList: 0x{compBegin:X} count: {vectorCount}");
|
|
sb.AppendLine(new string('═', 90));
|
|
sb.AppendLine("Scanning each component (8KB) for float triplets that look like world coordinates...");
|
|
sb.AppendLine("(X,Y in 50-50000, |Z| < 5000, consecutive floats at 4-byte alignment)");
|
|
sb.AppendLine();
|
|
|
|
var maxSlots = vectorCount > 0 ? vectorCount : 64;
|
|
|
|
for (var i = 0; i < maxSlots; i++)
|
|
{
|
|
var compPtr = _memory.ReadPointer(compBegin + i * 8);
|
|
if (compPtr == 0) continue;
|
|
|
|
var high = (ulong)compPtr >> 32;
|
|
if (high == 0 || high >= 0x7FFF) continue;
|
|
if ((compPtr & 0x3) != 0) continue;
|
|
|
|
// Skip pointers that are too close to entity struct
|
|
var dist = Math.Abs((long)(compPtr - localPlayer));
|
|
if (dist < 0x200) continue;
|
|
|
|
// RTTI
|
|
var vtable = _memory.ReadPointer(compPtr);
|
|
string? rtti = null;
|
|
if (vtable != 0 && IsModuleAddress(vtable))
|
|
rtti = ResolveRttiName(vtable);
|
|
|
|
// Read 8KB of component data
|
|
var compData = _memory.ReadBytes(compPtr, 0x2000);
|
|
if (compData is null) continue;
|
|
|
|
var hits = new List<string>();
|
|
for (var off = 0; off + 12 <= compData.Length; off += 4)
|
|
{
|
|
var x = BitConverter.ToSingle(compData, off);
|
|
var y = BitConverter.ToSingle(compData, off + 4);
|
|
var z = BitConverter.ToSingle(compData, off + 8);
|
|
|
|
if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) continue;
|
|
if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) continue;
|
|
if (x < 50 || x > 50000 || y < 50 || y > 50000) continue;
|
|
if (MathF.Abs(z) > 5000) continue;
|
|
|
|
hits.Add($"+0x{off:X}: ({x:F1}, {y:F1}, {z:F1})");
|
|
}
|
|
|
|
if (hits.Count > 0)
|
|
{
|
|
sb.AppendLine($"[{i,2}] 0x{compPtr:X} {rtti ?? "?"}");
|
|
foreach (var hit in hits.Take(10))
|
|
sb.AppendLine($" ◄ {hit}");
|
|
if (hits.Count > 10)
|
|
sb.AppendLine($" ... and {hits.Count - 10} more");
|
|
sb.AppendLine();
|
|
}
|
|
}
|
|
|
|
sb.AppendLine("Done.");
|
|
sb.AppendLine();
|
|
sb.AppendLine("If a component shows position hits, update offsets.json:");
|
|
sb.AppendLine(" PositionXOffset = the offset shown (e.g. 0xB0)");
|
|
sb.AppendLine(" PositionYOffset = PositionXOffset + 4");
|
|
sb.AppendLine(" PositionZOffset = PositionXOffset + 8");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Diagnostic: shows the ECS vitals reading state — LocalPlayer, component list, cached Life index, current values.
|
|
/// </summary>
|
|
public string DiagnoseVitals()
|
|
{
|
|
if (_memory is null) return "Error: not attached";
|
|
if (_gameStateBase == 0) return "Error: GameState base not resolved";
|
|
|
|
var snap = new GameStateSnapshot();
|
|
var inGameState = ResolveInGameState(snap);
|
|
if (inGameState == 0) return "Error: InGameState not resolved";
|
|
|
|
var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
|
|
if (ingameData == 0) return "Error: IngameData not resolved";
|
|
|
|
// Resolve LocalPlayer
|
|
var localPlayer = nint.Zero;
|
|
if (_offsets.LocalPlayerDirectOffset > 0)
|
|
localPlayer = _memory.ReadPointer(ingameData + _offsets.LocalPlayerDirectOffset);
|
|
if (localPlayer == 0)
|
|
{
|
|
var serverData = _memory.ReadPointer(ingameData + _offsets.ServerDataOffset);
|
|
if (serverData != 0)
|
|
localPlayer = _memory.ReadPointer(serverData + _offsets.LocalPlayerOffset);
|
|
}
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("── ECS Vitals Diagnostics ──");
|
|
sb.AppendLine($"IngameData: 0x{ingameData:X}");
|
|
sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
|
|
sb.AppendLine($"Cached Life index: {_cachedLifeIndex}");
|
|
sb.AppendLine($"Last LocalPlayer: 0x{_lastLocalPlayer:X}");
|
|
sb.AppendLine();
|
|
|
|
if (localPlayer == 0)
|
|
{
|
|
sb.AppendLine("FAIL: LocalPlayer is null");
|
|
return sb.ToString();
|
|
}
|
|
|
|
// Read StdVector at entity+ComponentListOffset
|
|
var compFirst = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset);
|
|
var compLast = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset + 8);
|
|
var compEnd = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset + 16);
|
|
|
|
var count = 0;
|
|
if (compFirst != 0 && compLast > compFirst && (compLast - compFirst) < 0x2000)
|
|
count = (int)((compLast - compFirst) / 8);
|
|
|
|
sb.AppendLine($"ComponentList StdVector (entity+0x{_offsets.ComponentListOffset:X}):");
|
|
sb.AppendLine($" First: 0x{compFirst:X}");
|
|
sb.AppendLine($" Last: 0x{compLast:X}");
|
|
sb.AppendLine($" End: 0x{compEnd:X}");
|
|
sb.AppendLine($" Count: {count}");
|
|
sb.AppendLine();
|
|
|
|
if (count <= 0)
|
|
{
|
|
sb.AppendLine("FAIL: Component list empty or invalid");
|
|
return sb.ToString();
|
|
}
|
|
|
|
// Scan components for Life pattern
|
|
sb.AppendLine($"── Component scan ({count} entries) ──");
|
|
for (var i = 0; i < count && i < 40; i++)
|
|
{
|
|
var compPtr = _memory.ReadPointer(compFirst + i * 8);
|
|
if (compPtr == 0)
|
|
{
|
|
sb.AppendLine($"[{i,2}] (null)");
|
|
continue;
|
|
}
|
|
|
|
var high = (ulong)compPtr >> 32;
|
|
if (high == 0 || high >= 0x7FFF)
|
|
{
|
|
sb.AppendLine($"[{i,2}] 0x{compPtr:X} (invalid pointer)");
|
|
continue;
|
|
}
|
|
|
|
// RTTI
|
|
var vtable = _memory.ReadPointer(compPtr);
|
|
string? rtti = null;
|
|
if (vtable != 0 && IsModuleAddress(vtable))
|
|
rtti = ResolveRttiName(vtable);
|
|
|
|
// Check VitalStruct pattern
|
|
var hpTotal = _memory.Read<int>(compPtr + _offsets.LifeHealthOffset + _offsets.VitalTotalOffset);
|
|
var hpCurr = _memory.Read<int>(compPtr + _offsets.LifeHealthOffset + _offsets.VitalCurrentOffset);
|
|
var manaTotal = _memory.Read<int>(compPtr + _offsets.LifeManaOffset + _offsets.VitalTotalOffset);
|
|
var manaCurr = _memory.Read<int>(compPtr + _offsets.LifeManaOffset + _offsets.VitalCurrentOffset);
|
|
var esTotal = _memory.Read<int>(compPtr + _offsets.LifeEsOffset + _offsets.VitalTotalOffset);
|
|
var esCurr = _memory.Read<int>(compPtr + _offsets.LifeEsOffset + _offsets.VitalCurrentOffset);
|
|
|
|
var hpOk = hpTotal > 0 && hpTotal < 200000 && hpCurr >= 0 && hpCurr <= hpTotal + 1000;
|
|
var manaOk = manaTotal >= 0 && manaTotal < 200000 && manaCurr >= 0 && manaCurr <= manaTotal + 1000;
|
|
|
|
var lifeTag = (hpOk && manaOk) ? $" ◄ LIFE HP={hpCurr}/{hpTotal} Mana={manaCurr}/{manaTotal} ES={esCurr}/{esTotal}" : "";
|
|
var cached = (i == _cachedLifeIndex) ? " [CACHED]" : "";
|
|
sb.AppendLine($"[{i,2}] 0x{compPtr:X} {rtti ?? "?"}{lifeTag}{cached}");
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Diagnostic: dumps LocalPlayer entity structure, component list with RTTI names,
|
|
/// VitalStruct pattern matches, and ObjectHeader alternative path.
|
|
/// </summary>
|
|
public string DiagnoseEntity()
|
|
{
|
|
if (_memory is null) return "Error: not attached";
|
|
if (_gameStateBase == 0) return "Error: GameState base not resolved";
|
|
|
|
var snap = new GameStateSnapshot();
|
|
var inGameState = ResolveInGameState(snap);
|
|
if (inGameState == 0) return "Error: InGameState not resolved";
|
|
|
|
var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
|
|
if (ingameData == 0) return "Error: IngameData not resolved";
|
|
|
|
// Resolve LocalPlayer
|
|
var localPlayer = nint.Zero;
|
|
if (_offsets.LocalPlayerDirectOffset > 0)
|
|
localPlayer = _memory.ReadPointer(ingameData + _offsets.LocalPlayerDirectOffset);
|
|
if (localPlayer == 0)
|
|
{
|
|
var serverData = _memory.ReadPointer(ingameData + _offsets.ServerDataOffset);
|
|
if (serverData != 0)
|
|
localPlayer = _memory.ReadPointer(serverData + _offsets.LocalPlayerOffset);
|
|
}
|
|
if (localPlayer == 0) return "Error: LocalPlayer not resolved";
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
|
|
|
|
// RTTI of entity
|
|
var entityVtable = _memory.ReadPointer(localPlayer);
|
|
string? entityRtti = null;
|
|
if (entityVtable != 0 && IsModuleAddress(entityVtable))
|
|
entityRtti = ResolveRttiName(entityVtable);
|
|
sb.AppendLine($"Entity RTTI: {entityRtti ?? "?"} (vtable: 0x{entityVtable:X})");
|
|
sb.AppendLine();
|
|
|
|
// Hex dump of first 0x100 bytes with pointer classification
|
|
sb.AppendLine("── Entity hex dump (first 0x100 bytes) ──");
|
|
var entityData = _memory.ReadBytes(localPlayer, 0x100);
|
|
if (entityData is not null)
|
|
{
|
|
for (var row = 0; row < entityData.Length; row += 16)
|
|
{
|
|
sb.Append($"+0x{row:X3}: ");
|
|
for (var col = 0; col < 16 && row + col < entityData.Length; col++)
|
|
{
|
|
sb.Append($"{entityData[row + col]:X2} ");
|
|
if (col == 7) sb.Append(' ');
|
|
}
|
|
|
|
// Pointer interpretation for each 8-byte slot
|
|
sb.Append(" | ");
|
|
for (var slot = 0; slot < 16 && row + slot + 8 <= entityData.Length; slot += 8)
|
|
{
|
|
var val = (nint)BitConverter.ToInt64(entityData, row + slot);
|
|
if (val == 0) continue;
|
|
if (IsModuleAddress(val))
|
|
{
|
|
var name = ResolveRttiName(val);
|
|
sb.Append(name is not null ? $"[{name}] " : "[module] ");
|
|
}
|
|
else
|
|
{
|
|
var h = (ulong)val >> 32;
|
|
if (h > 0 && h < 0x7FFF && (val & 0x3) == 0)
|
|
sb.Append("[heap] ");
|
|
}
|
|
}
|
|
sb.AppendLine();
|
|
}
|
|
}
|
|
sb.AppendLine();
|
|
|
|
// StdVector at entity+ComponentListOffset
|
|
sb.AppendLine($"── StdVector ComponentList at entity+0x{_offsets.ComponentListOffset:X} ──");
|
|
var compFirst = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset);
|
|
var compLast = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset + 8);
|
|
var compEnd = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset + 16);
|
|
|
|
sb.AppendLine($"First: 0x{compFirst:X}");
|
|
sb.AppendLine($"Last: 0x{compLast:X}");
|
|
sb.AppendLine($"End: 0x{compEnd:X}");
|
|
|
|
var vectorCount = 0;
|
|
if (compFirst != 0 && compLast > compFirst && (compLast - compFirst) < 0x2000)
|
|
vectorCount = (int)((compLast - compFirst) / 8);
|
|
sb.AppendLine($"Element count: {vectorCount}");
|
|
sb.AppendLine();
|
|
|
|
// List components with RTTI names and VitalStruct pattern check
|
|
if (vectorCount > 0)
|
|
{
|
|
var maxShow = Math.Min(vectorCount, 30);
|
|
sb.AppendLine($"── Components ({maxShow} of {vectorCount}) ──");
|
|
for (var i = 0; i < maxShow; i++)
|
|
{
|
|
var compPtr = _memory.ReadPointer(compFirst + i * 8);
|
|
if (compPtr == 0)
|
|
{
|
|
sb.AppendLine($"[{i,2}] (null)");
|
|
continue;
|
|
}
|
|
|
|
var high = (ulong)compPtr >> 32;
|
|
if (high == 0 || high >= 0x7FFF)
|
|
{
|
|
sb.AppendLine($"[{i,2}] 0x{compPtr:X} (bad pointer)");
|
|
continue;
|
|
}
|
|
|
|
// RTTI
|
|
var compVtable = _memory.ReadPointer(compPtr);
|
|
string? compRtti = null;
|
|
if (compVtable != 0 && IsModuleAddress(compVtable))
|
|
compRtti = ResolveRttiName(compVtable);
|
|
|
|
// VitalStruct pattern check
|
|
var hpTotal = _memory.Read<int>(compPtr + _offsets.LifeHealthOffset + _offsets.VitalTotalOffset);
|
|
var hpCurr = _memory.Read<int>(compPtr + _offsets.LifeHealthOffset + _offsets.VitalCurrentOffset);
|
|
var manaTotal = _memory.Read<int>(compPtr + _offsets.LifeManaOffset + _offsets.VitalTotalOffset);
|
|
var manaCurr = _memory.Read<int>(compPtr + _offsets.LifeManaOffset + _offsets.VitalCurrentOffset);
|
|
var esTotal = _memory.Read<int>(compPtr + _offsets.LifeEsOffset + _offsets.VitalTotalOffset);
|
|
var esCurr = _memory.Read<int>(compPtr + _offsets.LifeEsOffset + _offsets.VitalCurrentOffset);
|
|
|
|
var hpOk = hpTotal > 0 && hpTotal < 200000 && hpCurr >= 0 && hpCurr <= hpTotal + 1000;
|
|
var manaOk = manaTotal >= 0 && manaTotal < 200000 && manaCurr >= 0 && manaCurr <= manaTotal + 1000;
|
|
|
|
var lifeTag = (hpOk && manaOk)
|
|
? $"\n ◄ LIFE: HP={hpCurr}/{hpTotal}, Mana={manaCurr}/{manaTotal}, ES={esCurr}/{esTotal}"
|
|
: "";
|
|
|
|
// Position float triplet check
|
|
var px = _memory.Read<float>(compPtr + _offsets.PositionXOffset);
|
|
var py = _memory.Read<float>(compPtr + _offsets.PositionYOffset);
|
|
var pz = _memory.Read<float>(compPtr + _offsets.PositionZOffset);
|
|
var posTag = (!float.IsNaN(px) && !float.IsNaN(py) && !float.IsNaN(pz) &&
|
|
px > 50 && px < 50000 && py > 50 && py < 50000 && MathF.Abs(pz) < 5000)
|
|
? $"\n ◄ RENDER: Pos=({px:F1}, {py:F1}, {pz:F1})"
|
|
: "";
|
|
|
|
sb.AppendLine($"[{i,2}] 0x{compPtr:X} {compRtti ?? "?"}{lifeTag}{posTag}");
|
|
}
|
|
}
|
|
sb.AppendLine();
|
|
|
|
// Follow entity+0x000 as potential "inner entity" (POE2 wrapper pattern)
|
|
sb.AppendLine($"── Inner Entity (entity+0x000 deref) ──");
|
|
var innerEntity = _memory.ReadPointer(localPlayer);
|
|
sb.AppendLine($"Ptr: 0x{innerEntity:X}");
|
|
if (innerEntity != 0 && !IsModuleAddress(innerEntity))
|
|
{
|
|
var innerHigh = (ulong)innerEntity >> 32;
|
|
if (innerHigh > 0 && innerHigh < 0x7FFF && (innerEntity & 0x3) == 0)
|
|
{
|
|
// Read inner entity vtable and RTTI
|
|
var innerVtable = _memory.ReadPointer(innerEntity);
|
|
string? innerRtti = null;
|
|
if (innerVtable != 0 && IsModuleAddress(innerVtable))
|
|
innerRtti = ResolveRttiName(innerVtable);
|
|
sb.AppendLine($"Inner vtable: 0x{innerVtable:X} RTTI: {innerRtti ?? "?"}");
|
|
|
|
// Inner entity hex dump (first 0x80 bytes)
|
|
var innerData = _memory.ReadBytes(innerEntity, 0x80);
|
|
if (innerData is not null)
|
|
{
|
|
for (var row = 0; row < innerData.Length; row += 16)
|
|
{
|
|
sb.Append($" +0x{row:X3}: ");
|
|
for (var col = 0; col < 16 && row + col < innerData.Length; col++)
|
|
{
|
|
sb.Append($"{innerData[row + col]:X2} ");
|
|
if (col == 7) sb.Append(' ');
|
|
}
|
|
sb.AppendLine();
|
|
}
|
|
}
|
|
|
|
// Check StdVector at inner+ComponentListOffset
|
|
var innerFirst = _memory.ReadPointer(innerEntity + _offsets.ComponentListOffset);
|
|
var innerLast = _memory.ReadPointer(innerEntity + _offsets.ComponentListOffset + 8);
|
|
var innerCount = 0;
|
|
if (innerFirst != 0 && innerLast > innerFirst && (innerLast - innerFirst) < 0x2000)
|
|
innerCount = (int)((innerLast - innerFirst) / 8);
|
|
sb.AppendLine($"Inner ComponentList (inner+0x{_offsets.ComponentListOffset:X}): First=0x{innerFirst:X}, count={innerCount}");
|
|
|
|
// List inner components
|
|
if (innerCount > 1)
|
|
{
|
|
var maxInner = Math.Min(innerCount, 20);
|
|
for (var i = 0; i < maxInner; i++)
|
|
{
|
|
var cp = _memory.ReadPointer(innerFirst + i * 8);
|
|
if (cp == 0) { sb.AppendLine($" [{i,2}] (null)"); continue; }
|
|
var cv = _memory.ReadPointer(cp);
|
|
string? cr = null;
|
|
if (cv != 0 && IsModuleAddress(cv)) cr = ResolveRttiName(cv);
|
|
|
|
var ht = _memory.Read<int>(cp + _offsets.LifeHealthOffset + _offsets.VitalTotalOffset);
|
|
var hc = _memory.Read<int>(cp + _offsets.LifeHealthOffset + _offsets.VitalCurrentOffset);
|
|
var mt = _memory.Read<int>(cp + _offsets.LifeManaOffset + _offsets.VitalTotalOffset);
|
|
var mc = _memory.Read<int>(cp + _offsets.LifeManaOffset + _offsets.VitalCurrentOffset);
|
|
var hpOk2 = ht > 0 && ht < 200000 && hc >= 0 && hc <= ht + 1000;
|
|
var mOk2 = mt >= 0 && mt < 200000 && mc >= 0 && mc <= mt + 1000;
|
|
var lt = (hpOk2 && mOk2) ? $" ◄ LIFE HP={hc}/{ht} Mana={mc}/{mt}" : "";
|
|
sb.AppendLine($" [{i,2}] 0x{cp:X} {cr ?? "?"}{lt}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sb.AppendLine();
|
|
|
|
// Scan entity memory for all StdVector-like patterns with >1 pointer-sized elements
|
|
sb.AppendLine("── StdVector scan (entity 0x000-0x300) ──");
|
|
var scanData = _memory.ReadBytes(localPlayer, 0x300);
|
|
if (scanData is not null)
|
|
{
|
|
for (var off = 0; off + 24 <= scanData.Length; off += 8)
|
|
{
|
|
var f = (nint)BitConverter.ToInt64(scanData, off);
|
|
var l = (nint)BitConverter.ToInt64(scanData, off + 8);
|
|
if (f == 0 || l <= f) continue;
|
|
var sz = l - f;
|
|
if (sz < 16 || sz > 0x2000 || sz % 8 != 0) continue;
|
|
var n = (int)(sz / 8);
|
|
|
|
// Quick validate: first element should be non-zero
|
|
var firstEl = _memory.ReadPointer(f);
|
|
if (firstEl == 0) continue;
|
|
|
|
var tag = "";
|
|
var elHigh = (ulong)firstEl >> 32;
|
|
if (elHigh > 0 && elHigh < 0x7FFF && (firstEl & 0x3) == 0)
|
|
tag = " [ptr elements]";
|
|
else if (firstEl < 0x100000)
|
|
tag = " [small int elements]";
|
|
|
|
sb.AppendLine($"entity+0x{off:X3}: StdVector count={n}{tag} First=0x{f:X}");
|
|
}
|
|
}
|
|
sb.AppendLine();
|
|
|
|
// Extended entity dump (+0x100 to +0x200) for finding component-related offsets
|
|
sb.AppendLine("── Entity extended dump (+0x100 to +0x200) ──");
|
|
var extData = _memory.ReadBytes(localPlayer + 0x100, 0x100);
|
|
if (extData is not null)
|
|
{
|
|
for (var row = 0; row < extData.Length; row += 16)
|
|
{
|
|
var absOff = row + 0x100;
|
|
sb.Append($"+0x{absOff:X3}: ");
|
|
for (var col = 0; col < 16 && row + col < extData.Length; col++)
|
|
{
|
|
sb.Append($"{extData[row + col]:X2} ");
|
|
if (col == 7) sb.Append(' ');
|
|
}
|
|
sb.Append(" | ");
|
|
for (var slot = 0; slot < 16 && row + slot + 8 <= extData.Length; slot += 8)
|
|
{
|
|
var val = (nint)BitConverter.ToInt64(extData, row + slot);
|
|
if (val == 0) continue;
|
|
if (IsModuleAddress(val))
|
|
{
|
|
var name = ResolveRttiName(val);
|
|
sb.Append(name is not null ? $"[{name}] " : "[module] ");
|
|
}
|
|
else
|
|
{
|
|
var h = (ulong)val >> 32;
|
|
if (h > 0 && h < 0x7FFF && (val & 0x3) == 0)
|
|
sb.Append("[heap] ");
|
|
}
|
|
}
|
|
sb.AppendLine();
|
|
}
|
|
}
|
|
|
|
// ECS cache state
|
|
sb.AppendLine();
|
|
sb.AppendLine($"── ECS Cache ──");
|
|
sb.AppendLine($"Cached Life index: {_cachedLifeIndex}");
|
|
sb.AppendLine($"Last LocalPlayer: 0x{_lastLocalPlayer:X}");
|
|
|
|
// FindComponentList result
|
|
var (bestFirst, bestCount) = FindComponentList(localPlayer);
|
|
sb.AppendLine($"FindComponentList result: First=0x{bestFirst:X}, count={bestCount}");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deep scan: follows pointers from AreaInstance and LocalPlayer, scanning each target for vital values.
|
|
/// Uses two-level search to find the correct pointer chain to vitals.
|
|
/// </summary>
|
|
public string DeepScanVitals(int hpValue, int manaValue, int esValue)
|
|
{
|
|
if (_memory is null) return "Error: not attached";
|
|
if (_gameStateBase == 0) return "Error: GameState base not resolved";
|
|
|
|
var snap = new GameStateSnapshot();
|
|
var inGameState = ResolveInGameState(snap);
|
|
if (inGameState == 0) return "Error: InGameState not resolved";
|
|
|
|
var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
|
|
if (ingameData == 0) return "Error: AreaInstance not resolved";
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"Deep vital scan — HP={hpValue}, Mana={manaValue}, ES={esValue}");
|
|
sb.AppendLine($"AreaInstance: 0x{ingameData:X}");
|
|
|
|
var searchValues = new List<(string name, int value)>();
|
|
if (hpValue > 0) searchValues.Add(("HP", hpValue));
|
|
if (manaValue > 0) searchValues.Add(("Mana", manaValue));
|
|
if (esValue > 0) searchValues.Add(("ES", esValue));
|
|
|
|
if (searchValues.Count == 0) return "Error: enter at least one vital value";
|
|
|
|
// Level 1: scan AreaInstance pointers (first 0x1200 bytes covers all known offsets)
|
|
var areaData = _memory.ReadBytes(ingameData, 0x1200);
|
|
if (areaData is null) return "Error: failed to read AreaInstance";
|
|
|
|
var visited = new HashSet<nint>();
|
|
var results = new List<string>();
|
|
|
|
for (var off1 = 0; off1 + 8 <= areaData.Length; off1 += 8)
|
|
{
|
|
var ptr1 = (nint)BitConverter.ToInt64(areaData, off1);
|
|
if (ptr1 == 0 || ptr1 == ingameData) continue;
|
|
var h = (ulong)ptr1 >> 32;
|
|
if (h == 0 || h >= 0x7FFF) continue;
|
|
if ((ptr1 & 0x3) != 0) continue;
|
|
if (IsModuleAddress(ptr1)) continue;
|
|
if (!visited.Add(ptr1)) continue;
|
|
|
|
// Scan this target for vital values (first 0xC00 bytes)
|
|
var targetData = _memory.ReadBytes(ptr1, 0xC00);
|
|
if (targetData is null) continue;
|
|
|
|
var hits = FindVitalHits(targetData, searchValues);
|
|
if (hits.Count > 0)
|
|
{
|
|
var vtable = _memory.ReadPointer(ptr1);
|
|
var rtti = vtable != 0 && IsModuleAddress(vtable) ? ResolveRttiName(vtable) : null;
|
|
results.Add($"Area+0x{off1:X} → 0x{ptr1:X} [{rtti ?? "?"}]\n {string.Join(", ", hits)}");
|
|
}
|
|
|
|
// Level 2: follow pointers FROM this target (first 0x200 bytes)
|
|
for (var off2 = 0; off2 + 8 <= Math.Min(targetData.Length, 0x200); off2 += 8)
|
|
{
|
|
var ptr2 = (nint)BitConverter.ToInt64(targetData, off2);
|
|
if (ptr2 == 0 || ptr2 == ptr1 || ptr2 == ingameData) continue;
|
|
var h2 = (ulong)ptr2 >> 32;
|
|
if (h2 == 0 || h2 >= 0x7FFF) continue;
|
|
if ((ptr2 & 0x3) != 0) continue;
|
|
if (IsModuleAddress(ptr2)) continue;
|
|
if (!visited.Add(ptr2)) continue;
|
|
|
|
var deepData = _memory.ReadBytes(ptr2, 0xC00);
|
|
if (deepData is null) continue;
|
|
|
|
var deepHits = FindVitalHits(deepData, searchValues);
|
|
if (deepHits.Count > 0)
|
|
{
|
|
results.Add($"Area+0x{off1:X} → +0x{off2:X} → 0x{ptr2:X}\n {string.Join(", ", deepHits)}");
|
|
}
|
|
}
|
|
}
|
|
|
|
sb.AppendLine($"\nFound {results.Count} objects with vital matches:");
|
|
sb.AppendLine(new string('─', 80));
|
|
foreach (var r in results)
|
|
sb.AppendLine(r);
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static List<string> FindVitalHits(byte[] data, List<(string name, int value)> searchValues)
|
|
{
|
|
// Find hits and check for CLUSTERS (multiple vitals within 0x40 bytes of each other)
|
|
var allHits = new List<(string name, int value, int offset)>();
|
|
foreach (var (vName, vValue) in searchValues)
|
|
{
|
|
for (var off = 0; off + 4 <= data.Length; off += 4)
|
|
{
|
|
if (BitConverter.ToInt32(data, off) == vValue)
|
|
allHits.Add((vName, vValue, off));
|
|
}
|
|
}
|
|
|
|
// Only return if we have at least 2 different vitals, or a single vital at a reasonable offset
|
|
var distinctVitals = allHits.Select(h => h.name).Distinct().Count();
|
|
if (distinctVitals >= 2)
|
|
{
|
|
// Find clusters where 2+ vitals are within 0x80 bytes
|
|
var clusters = new List<string>();
|
|
foreach (var h1 in allHits)
|
|
{
|
|
foreach (var h2 in allHits)
|
|
{
|
|
if (h1.name == h2.name) continue;
|
|
if (Math.Abs(h1.offset - h2.offset) <= 0x80)
|
|
{
|
|
clusters.Add($"{h1.name}@+0x{h1.offset:X}, {h2.name}@+0x{h2.offset:X}");
|
|
}
|
|
}
|
|
}
|
|
if (clusters.Count > 0)
|
|
return clusters.Distinct().Take(10).ToList();
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Probes InGameState to find sub-structure offsets by looking for recognizable data patterns.
|
|
/// Scans the object for heap pointers and checks each for area level, entity counts, terrain, etc.
|
|
/// </summary>
|
|
public string ProbeInGameState()
|
|
{
|
|
if (_memory is null) return "Error: not attached";
|
|
if (_gameStateBase == 0) return "Error: GameState base not resolved";
|
|
|
|
// Resolve InGameState
|
|
var snap = new GameStateSnapshot();
|
|
var inGameState = ResolveInGameState(snap);
|
|
if (inGameState == 0) return $"Error: InGameState not resolved (states: {snap.StatesCount})";
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"InGameState: 0x{inGameState:X} (State[{_offsets.InGameStateIndex}] of {snap.StatesCount})");
|
|
|
|
// Read InGameState vtable for identification
|
|
var igsVtable = _memory.ReadPointer(inGameState);
|
|
sb.AppendLine($"Vtable: 0x{igsVtable:X}");
|
|
sb.AppendLine(new string('═', 80));
|
|
|
|
// Scan InGameState for all heap pointers (potential sub-object data pointers)
|
|
var igsSize = 0x8000;
|
|
var igsData = _memory.ReadBytes(inGameState, igsSize);
|
|
if (igsData is null) return "Error: failed to read InGameState";
|
|
|
|
// Collect unique data pointers to probe
|
|
var candidates = new List<(int offset, nint ptr)>();
|
|
for (var off = 0; off + 8 <= igsData.Length; off += 8)
|
|
{
|
|
var val = (nint)BitConverter.ToInt64(igsData, off);
|
|
if (val == 0) continue;
|
|
// Only probe heap pointers (not module addresses, not self-references)
|
|
if (IsModuleAddress(val)) continue;
|
|
if (val == inGameState) continue;
|
|
var high = (ulong)val >> 32;
|
|
if (high == 0 || high >= 0x7FFF) continue;
|
|
if ((val & 0x3) != 0) continue;
|
|
// Skip self-referencing pointers (InGameState + small offset)
|
|
if (val >= inGameState && val < inGameState + igsSize) continue;
|
|
candidates.Add((off, val));
|
|
}
|
|
|
|
sb.AppendLine($"Found {candidates.Count} heap pointer candidates");
|
|
sb.AppendLine();
|
|
|
|
// Probe patterns for AreaInstance identification
|
|
// Dump offsets + POE1 reference + nearby values for version drift
|
|
var probeOffsets = new (string name, int offset, string type)[]
|
|
{
|
|
// Area level — byte at dump 0xAC, strong signal (value 1-100)
|
|
("AreaLevel byte (0xAC)", 0xAC, "byte_level"), // Dump
|
|
("AreaLevel byte (0xBC)", 0xBC, "byte_level"), // Near dump (version drift)
|
|
("AreaLevel int (0xAC)", 0xAC, "int_level"), // Dump as int
|
|
("AreaLevel int (0xBC)", 0xBC, "int_level"), // Near dump as int
|
|
("AreaLevel int (0xD4)", 0xD4, "int_level"), // POE1
|
|
// Area hash (dump: 0xEC)
|
|
("AreaHash (0xEC)", 0xEC, "nonzero32"), // Dump
|
|
// Server data pointer (dump: 0x9F0 via LocalPlayerStruct)
|
|
("ServerData (0x9F0)", 0x9F0, "ptr"), // Dump
|
|
("ServerData (0xA00)", 0xA00, "ptr"), // Near dump
|
|
// Local player pointer (dump: 0xA10)
|
|
("LocalPlayer (0xA10)", 0xA10, "ptr"), // Dump
|
|
("LocalPlayer (0xA20)", 0xA20, "ptr"), // Near dump
|
|
// Entity list — StdMap at dump 0xAF8. StdMap.head at +0, .size at +8
|
|
("Entities.head (0xAF8)", 0xAF8, "ptr"), // Dump
|
|
("Entities.size (0xB00)", 0xB00, "int_count"), // Dump StdMap._Mysize
|
|
("Entities.head (0xB58)", 0xB58, "ptr"), // Near dump
|
|
("Entities.size (0xB60)", 0xB60, "int_count"), // Near dump
|
|
// Terrain inline (dump: 0xCC0)
|
|
("Terrain (0xCC0)", 0xCC0, "nonzero64"), // Dump (inline, check for data)
|
|
};
|
|
|
|
// Track best matches
|
|
var matches = new List<(int igsOffset, nint ptr, string desc, int score)>();
|
|
|
|
foreach (var (off, ptr) in candidates)
|
|
{
|
|
var score = 0;
|
|
var details = new List<string>();
|
|
|
|
// Also try RTTI on the candidate object itself
|
|
var candidateVtable = _memory.ReadPointer(ptr);
|
|
string? candidateRtti = null;
|
|
if (candidateVtable != 0 && IsModuleAddress(candidateVtable))
|
|
candidateRtti = ResolveRttiName(candidateVtable);
|
|
|
|
// Try AreaInstance-like probes
|
|
foreach (var (name, probeOff, probeType) in probeOffsets)
|
|
{
|
|
if (probeType == "ptr")
|
|
{
|
|
var val = _memory.ReadPointer(ptr + probeOff);
|
|
if (val != 0 && !IsModuleAddress(val))
|
|
{
|
|
var h = (ulong)val >> 32;
|
|
if (h > 0 && h < 0x7FFF)
|
|
{
|
|
score++;
|
|
details.Add($" {name} = 0x{val:X} ✓");
|
|
}
|
|
}
|
|
}
|
|
else if (probeType == "byte_level")
|
|
{
|
|
var val = _memory.Read<byte>(ptr + probeOff);
|
|
if (val > 0 && val <= 100)
|
|
{
|
|
score += 3; // Strong signal
|
|
details.Add($" {name} = {val} ✓✓✓");
|
|
}
|
|
}
|
|
else if (probeType == "int_level")
|
|
{
|
|
var val = _memory.Read<int>(ptr + probeOff);
|
|
if (val > 0 && val <= 100)
|
|
{
|
|
score += 3;
|
|
details.Add($" {name} = {val} ✓✓✓");
|
|
}
|
|
}
|
|
else if (probeType == "int_count")
|
|
{
|
|
var val = _memory.Read<int>(ptr + probeOff);
|
|
if (val > 0 && val < 10000)
|
|
{
|
|
score += 2;
|
|
details.Add($" {name} = {val} ✓✓");
|
|
}
|
|
}
|
|
else if (probeType == "nonzero32")
|
|
{
|
|
var val = _memory.Read<uint>(ptr + probeOff);
|
|
if (val != 0)
|
|
{
|
|
score++;
|
|
details.Add($" {name} = 0x{val:X8} ✓");
|
|
}
|
|
}
|
|
else if (probeType == "nonzero64")
|
|
{
|
|
var val = _memory.Read<long>(ptr + probeOff);
|
|
if (val != 0)
|
|
{
|
|
score++;
|
|
details.Add($" {name} = 0x{val:X} ✓");
|
|
}
|
|
}
|
|
}
|
|
|
|
// RTTI bonus: if object has a known class name, boost score significantly
|
|
if (candidateRtti is not null)
|
|
{
|
|
details.Insert(0, $" RTTI: {candidateRtti}");
|
|
if (candidateRtti.Contains("AreaInstance") || candidateRtti.Contains("IngameData")
|
|
|| candidateRtti.Contains("WorldInstance"))
|
|
score += 10; // Very strong signal
|
|
}
|
|
|
|
if (score >= 3)
|
|
{
|
|
matches.Add((off, ptr, string.Join("\n", details), score));
|
|
}
|
|
}
|
|
|
|
// Sort by score descending
|
|
matches.Sort((a, b) => b.score.CompareTo(a.score));
|
|
|
|
if (matches.Count == 0)
|
|
{
|
|
sb.AppendLine("No matches found with known offset patterns.");
|
|
sb.AppendLine("Try scanning InGameState with the raw Scan tool.");
|
|
}
|
|
else
|
|
{
|
|
sb.AppendLine($"── Top matches (by score) ──");
|
|
sb.AppendLine();
|
|
foreach (var (off, ptr, desc, score) in matches.Take(15))
|
|
{
|
|
sb.AppendLine($"IGS+0x{off:X3} → 0x{ptr:X} (score: {score})");
|
|
sb.AppendLine(desc);
|
|
sb.AppendLine();
|
|
}
|
|
}
|
|
|
|
// Check InGameState at dump-predicted offsets directly
|
|
sb.AppendLine("── Dump-predicted InGameState fields ──");
|
|
var dumpFields = new (string name, int offset)[]
|
|
{
|
|
("AreaInstanceData", 0x290),
|
|
("WorldData", 0x2F8),
|
|
("UiRootPtr", 0x648),
|
|
("IngameUi", 0xC40),
|
|
};
|
|
foreach (var (fname, foff) in dumpFields)
|
|
{
|
|
if (foff + 8 <= igsSize)
|
|
{
|
|
var val = (nint)BitConverter.ToInt64(igsData, foff);
|
|
if (val != 0)
|
|
{
|
|
var tag = ClassifyPointer(val);
|
|
string extra = "";
|
|
// If it's a heap pointer, try RTTI
|
|
if (tag == "heap ptr")
|
|
{
|
|
var vt = _memory.ReadPointer(val);
|
|
if (vt != 0 && IsModuleAddress(vt))
|
|
{
|
|
var rtti = ResolveRttiName(vt);
|
|
if (rtti != null) extra = $" RTTI={rtti}";
|
|
}
|
|
// Quick check: does it look like an AreaInstance?
|
|
var lvl = _memory.Read<byte>(val + 0xAC);
|
|
var lvl2 = _memory.Read<byte>(val + 0xBC);
|
|
if (lvl > 0 && lvl <= 100) extra += $" lvl@0xAC={lvl}";
|
|
if (lvl2 > 0 && lvl2 <= 100) extra += $" lvl@0xBC={lvl2}";
|
|
}
|
|
sb.AppendLine($" IGS+0x{foff:X}: {fname} = 0x{val:X} [{tag ?? "?"}]{extra}");
|
|
}
|
|
else
|
|
{
|
|
sb.AppendLine($" IGS+0x{foff:X}: {fname} = (null)");
|
|
}
|
|
}
|
|
}
|
|
sb.AppendLine();
|
|
|
|
// Also scan InGameState directly for position-like floats
|
|
sb.AppendLine("── Position-like floats in InGameState ──");
|
|
for (var off = 0; off + 12 <= igsData.Length; off += 4)
|
|
{
|
|
var x = BitConverter.ToSingle(igsData, off);
|
|
var y = BitConverter.ToSingle(igsData, off + 4);
|
|
var z = BitConverter.ToSingle(igsData, off + 8);
|
|
// Look for reasonable world coordinates (POE2: typically 100-10000 range)
|
|
if (x > 100 && x < 50000 && y > 100 && y < 50000 &&
|
|
!float.IsNaN(x) && !float.IsNaN(y) && !float.IsNaN(z) &&
|
|
MathF.Abs(z) < 5000)
|
|
{
|
|
sb.AppendLine($" IGS+0x{off:X4}: ({x:F1}, {y:F1}, {z:F1})");
|
|
}
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static bool IsReasonableFloat(float f)
|
|
{
|
|
if (float.IsNaN(f) || float.IsInfinity(f)) return false;
|
|
var abs = MathF.Abs(f);
|
|
return abs > 0.001f && abs < 100000f;
|
|
}
|
|
|
|
private static string FormatBytes(byte[]? data)
|
|
{
|
|
if (data is null) return "Error: read failed";
|
|
return BitConverter.ToString(data).Replace('-', ' ');
|
|
}
|
|
|
|
private string ReadNullTermString(nint addr)
|
|
{
|
|
var data = _memory!.ReadBytes(addr, 256);
|
|
if (data is null) return "Error: read failed";
|
|
var end = Array.IndexOf(data, (byte)0);
|
|
if (end < 0) end = data.Length;
|
|
return Encoding.UTF8.GetString(data, 0, end);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
Detach();
|
|
}
|
|
}
|