poe2-bot/src/Automata.Memory/GameMemoryReader.cs
2026-03-01 13:26:47 -05:00

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();
}
}