poe2-bot/src/Roboto.Memory/MemoryDiagnostics.cs
2026-03-02 23:45:12 -05:00

3837 lines
166 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Drawing;
using System.Drawing.Imaging;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Diagnostic and scan methods extracted from GameMemoryReader.
/// All methods produce human-readable string output for the UI diagnostics panel.
/// </summary>
public sealed class MemoryDiagnostics
{
private readonly MemoryContext _ctx;
private readonly GameStateReader _stateReader;
private readonly ComponentReader _components;
private readonly EntityReader _entities;
private readonly MsvcStringReader _strings;
private readonly RttiResolver _rtti;
// Memory diff scan storage
private Dictionary<string, (nint Address, byte[] Data)>? _diffBaseline;
// Camera diff storage
private List<(string source, float sx, float sy, float dist, float[] floats)>? _cameraDiffBaseline;
// State object diff storage
private Dictionary<string, (nint Address, byte[] Data)>? _stateDiffBaseline;
public MemoryDiagnostics(
MemoryContext ctx,
GameStateReader stateReader,
ComponentReader components,
EntityReader entities,
MsvcStringReader strings,
RttiResolver rtti)
{
_ctx = ctx;
_stateReader = stateReader;
_components = components;
_entities = entities;
_strings = strings;
_rtti = rtti;
}
/// <summary>
/// Diagnostic: collects all state slot pointers, then scans the controller
/// and GameState global region for any qword matching ANY state.
/// Run in different game states to find the "active state" pointer.
/// </summary>
public string ScanAreaLoadingState()
{
if (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var controller = _ctx.Memory.ReadPointer(_ctx.GameStateBase);
if (controller == 0) return "Error: controller is null";
var sb = new StringBuilder();
sb.AppendLine($"GameState global: 0x{_ctx.GameStateBase:X}");
sb.AppendLine($"Controller: 0x{controller:X}");
// Collect all state slot pointers (both ptr1 and ptr2 per slot)
var stateSlots = new Dictionary<nint, string>();
for (var i = 0; i < 20; i++)
{
var slotOffset = _ctx.Offsets.StatesBeginOffset + i * _ctx.Offsets.StateStride + _ctx.Offsets.StatePointerOffset;
var ptr = _ctx.Memory.ReadPointer(controller + slotOffset);
if (ptr == 0) break;
stateSlots.TryAdd(ptr, $"State[{i}]");
// Also grab the second pointer in each 16-byte slot
if (_ctx.Offsets.StateStride >= 16)
{
var ptr2 = _ctx.Memory.ReadPointer(controller + slotOffset + 8);
if (ptr2 != 0)
stateSlots.TryAdd(ptr2, $"State[{i}]+8");
}
}
sb.AppendLine($"Known state values: {stateSlots.Count}");
foreach (var kv in stateSlots)
sb.AppendLine($" {kv.Value}: 0x{kv.Key:X}");
sb.AppendLine(new string('═', 70));
// ── Scan controller (0x1000 bytes) for any qword matching a state ──
sb.AppendLine($"\nController matches (outside state array):");
sb.AppendLine(new string('─', 70));
var stateArrayStart = _ctx.Offsets.StatesBeginOffset;
var stateArrayEnd = stateArrayStart + stateSlots.Count * _ctx.Offsets.StateStride;
var ctrlData = _ctx.Memory.ReadBytes(controller, 0x1000);
if (ctrlData is not null)
{
for (var offset = 0; offset + 8 <= ctrlData.Length; offset += 8)
{
// Skip the state array itself
if (offset >= stateArrayStart && offset < stateArrayEnd) continue;
var value = (nint)BitConverter.ToInt64(ctrlData, offset);
if (value == 0) continue;
if (stateSlots.TryGetValue(value, out var stateName))
sb.AppendLine($" ctrl+0x{offset:X3}: 0x{value:X} = {stateName}");
}
}
// ── Scan GameState global region ──
sb.AppendLine($"\nGameState global region matches:");
sb.AppendLine(new string('─', 70));
var globalScanStart = _ctx.GameStateBase - 0x100;
var globalData = _ctx.Memory.ReadBytes(globalScanStart, 0x300);
if (globalData is not null)
{
for (var offset = 0; offset + 8 <= globalData.Length; offset += 8)
{
var value = (nint)BitConverter.ToInt64(globalData, offset);
if (value == 0) continue;
var relToGlobal = offset - 0x100;
if (stateSlots.TryGetValue(value, out var stateName))
sb.AppendLine($" global{relToGlobal:+#;-#;+0} (0x{globalScanStart + offset:X}): 0x{value:X} = {stateName}");
else if (value == controller)
sb.AppendLine($" global{relToGlobal:+#;-#;+0} (0x{globalScanStart + offset:X}): 0x{value:X} = controller");
}
}
sb.AppendLine(new string('═', 70));
sb.AppendLine("Run in different states (in-game, loading, char select).");
sb.AppendLine("The offset where the state value CHANGES is the active state ptr.");
return sb.ToString();
}
/// <summary>
/// Memory diff scanner. First call captures baseline, second call shows all changed offsets.
/// Scans controller (0x1000 bytes) and InGameState (0x1000 bytes).
/// Usage: click once in state A, change game state (e.g. open/close Escape), click again.
/// </summary>
public string ScanMemoryDiff()
{
if (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var controller = _ctx.Memory.ReadPointer(_ctx.GameStateBase);
if (controller == 0) return "Error: controller is null";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
// Regions to scan
const int scanSize = 0x4000;
var regions = new Dictionary<string, nint>
{
["Controller"] = controller,
};
if (inGameState != 0)
regions["InGameState"] = inGameState;
// Follow pointer at controller+0x10 (changed during in-game→login transition)
var ctrlPtr10 = _ctx.Memory.ReadPointer(controller + 0x10);
if (ctrlPtr10 != 0 && !regions.ContainsValue(ctrlPtr10))
regions["Ctrl+0x10 target"] = ctrlPtr10;
// Read current data
var current = new Dictionary<string, (nint Address, byte[] Data)>();
foreach (var (name, addr) in regions)
{
var data = _ctx.Memory.ReadBytes(addr, scanSize);
if (data is not null)
current[name] = (addr, data);
}
// First call: save baseline
if (_diffBaseline is null)
{
_diffBaseline = current;
var sb = new StringBuilder();
sb.AppendLine("=== BASELINE CAPTURED ===");
foreach (var (rn, (ra, rd)) in current)
sb.AppendLine($" {rn}: 0x{ra:X} ({rd.Length} bytes)");
sb.AppendLine();
sb.AppendLine("Now change game state (login, char select, in-game, etc.) and click again.");
return sb.ToString();
}
// Second call: diff against baseline
var baseline = _diffBaseline;
_diffBaseline = null; // reset for next pair
var result = new StringBuilder();
result.AppendLine("=== MEMORY DIFF ===");
result.AppendLine($"Controller: 0x{controller:X}");
if (inGameState != 0)
result.AppendLine($"InGameState: 0x{inGameState:X}");
result.AppendLine();
// Resolve known pointers for annotation
var knownPtrs = new Dictionary<nint, string>();
if (inGameState != 0) knownPtrs[inGameState] = "IGS";
knownPtrs[controller] = "Controller";
for (var i = 0; i < 20; i++)
{
var slotOff = _ctx.Offsets.StatesBeginOffset + i * _ctx.Offsets.StateStride;
var ptr = _ctx.Memory.ReadPointer(controller + slotOff);
if (ptr == 0) break;
knownPtrs.TryAdd(ptr, $"State[{i}]");
}
foreach (var (name, (curAddr, curData)) in current)
{
if (!baseline.TryGetValue(name, out var baseEntry))
{
result.AppendLine($"── {name}: no baseline (new region) ──");
continue;
}
var (baseAddr, baseData) = baseEntry;
if (baseAddr != curAddr)
{
result.AppendLine($"── {name}: address changed 0x{baseAddr:X} → 0x{curAddr:X} ──");
continue;
}
var changes = new List<string>();
var len = Math.Min(baseData.Length, curData.Length);
for (var offset = 0; offset + 8 <= len; offset += 8)
{
var oldVal = BitConverter.ToInt64(baseData, offset);
var newVal = BitConverter.ToInt64(curData, offset);
if (oldVal == newVal) continue;
var line = $" +0x{offset:X3}: 0x{oldVal:X16} → 0x{newVal:X16}";
// Annotate known pointers
var oldPtr = (nint)oldVal;
var newPtr = (nint)newVal;
if (knownPtrs.TryGetValue(oldPtr, out var oldName))
line += $" (was {oldName})";
if (knownPtrs.TryGetValue(newPtr, out var newName))
line += $" (now {newName})";
// Also check int32 changes at this offset
var oldI1 = BitConverter.ToInt32(baseData, offset);
var oldI2 = BitConverter.ToInt32(baseData, offset + 4);
var newI1 = BitConverter.ToInt32(curData, offset);
var newI2 = BitConverter.ToInt32(curData, offset + 4);
if (oldI1 != newI1 && oldI2 == newI2)
line += $" [int32@+0x{offset:X}: {oldI1} → {newI1}]";
else if (oldI1 == newI1 && oldI2 != newI2)
line += $" [int32@+0x{offset + 4:X}: {oldI2} → {newI2}]";
changes.Add(line);
}
// Also check byte-level for small flags (scan each byte for 0↔1 or 0↔non-zero)
var byteChanges = new List<string>();
for (var offset = 0; offset < len; offset++)
{
if (baseData[offset] == curData[offset]) continue;
// Only report byte changes not already covered by qword changes (check alignment)
var qwordOffset = (offset / 8) * 8;
var qwordOld = BitConverter.ToInt64(baseData, qwordOffset);
var qwordNew = BitConverter.ToInt64(curData, qwordOffset);
if (qwordOld != qwordNew) continue; // already reported above
byteChanges.Add($" +0x{offset:X3}: byte {baseData[offset]:X2} → {curData[offset]:X2}");
}
result.AppendLine($"── {name} (0x{curAddr:X}, {len} bytes) ──");
if (changes.Count == 0 && byteChanges.Count == 0)
{
result.AppendLine(" (no changes)");
}
else
{
result.AppendLine($" {changes.Count} qword changes, {byteChanges.Count} byte-only changes:");
foreach (var c in changes) result.AppendLine(c);
foreach (var c in byteChanges) result.AppendLine(c);
}
}
result.AppendLine();
result.AppendLine("Click again to capture a new baseline.");
return result.ToString();
}
/// <summary>
/// Scans controller for any begin/end pointer pair whose buffer contains known state slot pointers.
/// Also follows each state slot's first pointer field and diffs those sub-objects.
/// </summary>
public string ScanActiveStatesVector()
{
if (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var controller = mem.ReadPointer(_ctx.GameStateBase);
if (controller == 0) return "Error: controller is null";
var stateNames = GameMemoryReader.StateNames;
// Collect state slot pointers
var stateSlotPtrs = new Dictionary<nint, int>();
for (var i = 0; i < offsets.StateCount; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
var ptr = mem.ReadPointer(controller + slotOffset);
if (ptr != 0)
stateSlotPtrs[ptr] = i;
}
var sb = new StringBuilder();
sb.AppendLine($"Controller: 0x{controller:X}");
sb.AppendLine($"StatesBeginOffset: 0x{offsets.StatesBeginOffset:X}");
sb.AppendLine($"Known state slots: {stateSlotPtrs.Count}");
sb.AppendLine();
// Dump all state slots with both .X and .Y values
sb.AppendLine("=== State Slots (StdTuple2D: .X=ptr, .Y=+0x08) ===");
for (var i = 0; i < offsets.StateCount; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
var ptrX = mem.ReadPointer(controller + slotOffset);
var ptrY = mem.ReadPointer(controller + slotOffset + 8);
var name = i < stateNames.Length ? stateNames[i] : $"State{i}";
if (ptrX == 0)
sb.AppendLine($" [{i:D2}] {name,-20} .X=0 (null)");
else
sb.AppendLine($" [{i:D2}] {name,-20} .X=0x{ptrX:X} .Y=0x{ptrY:X}");
}
sb.AppendLine();
// Dump every qword in pre-slots region with dereference analysis
var preSize = offsets.StatesBeginOffset;
sb.AppendLine($"=== Controller Pre-Slots (0x00 - 0x{preSize:X}) ===");
var ctrlData = mem.ReadBytes(controller, Math.Max(preSize + 0x100, 0x400));
if (ctrlData is null) return "Error: cannot read controller";
for (var off = 0; off < preSize && off + 8 <= ctrlData.Length; off += 8)
{
var val = (nint)BitConverter.ToInt64(ctrlData, off);
if (val == 0) { sb.AppendLine($" +0x{off:X2}: 0"); continue; }
var line = $" +0x{off:X2}: 0x{val:X}";
// Direct state slot match?
if (stateSlotPtrs.TryGetValue(val, out var slotIdx))
{
var name = slotIdx < stateNames.Length ? stateNames[slotIdx] : $"State{slotIdx}";
sb.AppendLine($"{line} ← DIRECT [{slotIdx}] {name}");
continue;
}
// Is it a pointer? Dereference and check first 8 qwords
var high = (ulong)val >> 32;
if (high > 0 && high < 0x7FFF)
{
var targetData = mem.ReadBytes(val, 64);
if (targetData is not null)
{
var innerMatches = new List<string>();
for (var qi = 0; qi + 8 <= targetData.Length; qi += 8)
{
var inner = (nint)BitConverter.ToInt64(targetData, qi);
if (inner != 0 && stateSlotPtrs.TryGetValue(inner, out var innerSlot))
{
var n = innerSlot < stateNames.Length ? stateNames[innerSlot] : $"State{innerSlot}";
innerMatches.Add($"*+0x{qi:X2}=[{innerSlot}]{n}");
}
}
if (innerMatches.Count > 0)
sb.AppendLine($"{line} ← DEREF: {string.Join(", ", innerMatches)}");
else
{
var first = (nint)BitConverter.ToInt64(targetData, 0);
sb.AppendLine($"{line} (ptr, *0=0x{first:X})");
}
}
else
sb.AppendLine($"{line} (unreadable)");
}
else
sb.AppendLine($"{line} (not a ptr)");
}
sb.AppendLine();
// Scan for StdVector {First, Last} containing state pointers — full controller
sb.AppendLine("=== Vector Scan (controller 0x000-0x400) ===");
var foundVec = false;
for (var off = 0; off + 16 <= ctrlData.Length; off += 8)
{
var p1 = (nint)BitConverter.ToInt64(ctrlData, off);
if (p1 == 0) continue;
var h1 = (ulong)p1 >> 32;
if (h1 is 0 or >= 0x7FFF) continue;
foreach (var endDelta in new[] { 8, 16 })
{
if (off + endDelta + 8 > ctrlData.Length) continue;
var p2 = (nint)BitConverter.ToInt64(ctrlData, off + endDelta);
if (p2 <= p1) continue;
var size = (int)(p2 - p1);
if (size is < 8 or > 0x400) continue;
var buf = mem.ReadBytes(p1, size);
if (buf is null) continue;
var entries = size / 8;
var matches = new List<string>();
for (var bi = 0; bi + 8 <= buf.Length; bi += 8)
{
var bval = (nint)BitConverter.ToInt64(buf, bi);
if (bval != 0 && stateSlotPtrs.TryGetValue(bval, out var si))
{
var n = si < stateNames.Length ? stateNames[si] : $"State{si}";
matches.Add($"[{bi / 8}]={n}");
}
}
if (matches.Count > 0)
{
foundVec = true;
var secondToLast = entries >= 2 ? (nint)BitConverter.ToInt64(buf, buf.Length - 16) : (nint)0;
var stlMatch = secondToLast != 0 && stateSlotPtrs.TryGetValue(secondToLast, out var stlSlot)
? (stlSlot < stateNames.Length ? stateNames[stlSlot] : $"State{stlSlot}")
: $"0x{secondToLast:X} (no match)";
sb.AppendLine($" ctrl+0x{off:X3} (end@+{endDelta}): First=0x{p1:X}, Last=0x{p2:X}, {entries} entries, {matches.Count} state matches");
sb.AppendLine($" Matches: {string.Join(", ", matches)}");
sb.AppendLine($" 2nd-to-last (GH2 method): {stlMatch}");
sb.Append(" Raw: ");
for (var bi = 0; bi + 8 <= buf.Length; bi += 8)
{
var bval = (nint)BitConverter.ToInt64(buf, bi);
var tag = stateSlotPtrs.ContainsKey(bval) ? "*" : "";
sb.Append($"{tag}0x{bval:X} ");
}
sb.AppendLine();
}
}
}
if (!foundVec)
sb.AppendLine(" (no vectors with state matches found)");
// === GameState objects from original pattern result ===
sb.AppendLine();
sb.AppendLine($"=== GameState Objects (original pattern result) ===");
sb.AppendLine($" GameStateBase: 0x{_ctx.GameStateBase:X}");
sb.AppendLine($" PatternResultAdjust: 0x{offsets.PatternResultAdjust:X}");
var originalPatternResult = _ctx.GameStateBase - offsets.PatternResultAdjust;
sb.AppendLine($" Original pattern result: 0x{originalPatternResult:X}");
// Read the static region
var gsRegion = mem.ReadBytes(originalPatternResult, 0x40);
if (gsRegion is not null)
{
sb.AppendLine();
sb.AppendLine(" Static region pointers:");
for (var gi = 0; gi + 8 <= gsRegion.Length; gi += 8)
{
var gv = (nint)BitConverter.ToInt64(gsRegion, gi);
var tag = "";
if (gv == controller) tag = " ← CONTROLLER";
else if (gv != 0 && stateSlotPtrs.ContainsKey(gv)) tag = " ← STATE SLOT";
sb.AppendLine($" patResult+0x{gi:X2}: 0x{gv:X}{tag}");
}
// For each non-controller pointer, follow it and scan for StdVectors with state matches
sb.AppendLine();
for (var gi = 0; gi + 8 <= Math.Min(gsRegion.Length, offsets.PatternResultAdjust); gi += 8)
{
var gv = (nint)BitConverter.ToInt64(gsRegion, gi);
if (gv == 0 || gv == controller) continue;
var gh = (ulong)gv >> 32;
if (gh is 0 or >= 0x7FFF) continue;
sb.AppendLine($" --- Object at *(patResult+0x{gi:X2}) = 0x{gv:X} ---");
// Read 0x200 bytes from this object
var objData = mem.ReadBytes(gv, 0x200);
if (objData is null) { sb.AppendLine(" (unreadable)"); continue; }
// Dump all qwords with annotations
for (var qi = 0; qi + 8 <= objData.Length; qi += 8)
{
var qv = (nint)BitConverter.ToInt64(objData, qi);
if (qv == 0) continue;
var qTag = "";
if (stateSlotPtrs.TryGetValue(qv, out var slotIdx2))
{
var sn = slotIdx2 < stateNames.Length ? stateNames[slotIdx2] : $"State{slotIdx2}";
qTag = $" ← STATE [{slotIdx2}] {sn}";
}
else if (qv == controller)
qTag = " ← CONTROLLER";
sb.AppendLine($" +0x{qi:X3}: 0x{qv:X}{qTag}");
}
// Try StdVector scan at every 8-byte offset in this object
sb.AppendLine();
sb.AppendLine($" Vector scan (state slot matches):");
var foundObjVec = false;
for (var voff = 0; voff + 24 <= objData.Length; voff += 8)
{
var vFirst = (nint)BitConverter.ToInt64(objData, voff);
if (vFirst == 0) continue;
var vhigh = (ulong)vFirst >> 32;
if (vhigh is 0 or >= 0x7FFF) continue;
var vLast = (nint)BitConverter.ToInt64(objData, voff + 8);
if (vLast <= vFirst) continue;
var vSize = (int)(vLast - vFirst);
if (vSize is < 8 or > 0x400) continue;
var vBuf = mem.ReadBytes(vFirst, vSize);
if (vBuf is null) continue;
var vEntries = vSize / 8;
var vMatches = new List<string>();
for (var bi = 0; bi + 8 <= vBuf.Length; bi += 8)
{
var bval = (nint)BitConverter.ToInt64(vBuf, bi);
if (bval != 0 && stateSlotPtrs.TryGetValue(bval, out var bsi))
{
var bn = bsi < stateNames.Length ? stateNames[bsi] : $"State{bsi}";
vMatches.Add($"[{bi / 8}]={bn}");
}
}
if (vMatches.Count > 0)
{
foundObjVec = true;
var secondToLast = vEntries >= 2 ? (nint)BitConverter.ToInt64(vBuf, vBuf.Length - 16) : (nint)0;
var lastEntry = vEntries >= 1 ? (nint)BitConverter.ToInt64(vBuf, vBuf.Length - 8) : (nint)0;
var stlMatch = "no match";
if (secondToLast != 0 && stateSlotPtrs.TryGetValue(secondToLast, out var stlSlot))
stlMatch = stlSlot < stateNames.Length ? stateNames[stlSlot] : $"State{stlSlot}";
var lastMatch = "no match";
if (lastEntry != 0 && stateSlotPtrs.TryGetValue(lastEntry, out var lastSlot))
lastMatch = lastSlot < stateNames.Length ? stateNames[lastSlot] : $"State{lastSlot}";
sb.AppendLine($" ★ obj+0x{voff:X3}: {vEntries} entries, {vMatches.Count} state matches");
sb.AppendLine($" Matches: {string.Join(", ", vMatches)}");
sb.AppendLine($" 2nd-to-last: 0x{secondToLast:X} → {stlMatch}");
sb.AppendLine($" Last: 0x{lastEntry:X} → {lastMatch}");
sb.Append(" All: ");
for (var bi = 0; bi + 8 <= vBuf.Length; bi += 8)
{
var bval = (nint)BitConverter.ToInt64(vBuf, bi);
var star = stateSlotPtrs.ContainsKey(bval) ? "*" : "";
sb.Append($"{star}0x{bval:X} ");
}
sb.AppendLine();
}
}
if (!foundObjVec)
sb.AppendLine(" (no vectors with state slot matches found in this object)");
}
}
sb.AppendLine();
// Compare state slot objects — find fields that differ (potential "is_active" flag)
sb.AppendLine("=== State Object Comparison (looking for active flag) ===");
var stateObjData = new Dictionary<int, byte[]>();
for (var i = 0; i < offsets.StateCount; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
var ptr = mem.ReadPointer(controller + slotOffset);
if (ptr == 0) continue;
var sData = mem.ReadBytes(ptr, 128);
if (sData is not null)
stateObjData[i] = sData;
}
if (stateObjData.Count >= 2)
{
// For each qword offset, show values that differ between states
for (var qi = 0; qi + 8 <= 128; qi += 8)
{
var values = new Dictionary<int, long>();
var allSame = true;
long? firstVal = null;
foreach (var (si, sData) in stateObjData)
{
var v = BitConverter.ToInt64(sData, qi);
values[si] = v;
if (firstVal == null) firstVal = v;
else if (v != firstVal) allSame = false;
}
if (allSame) continue;
sb.Append($" state+0x{qi:X2}: ");
foreach (var (si, v) in values)
{
var name = si < stateNames.Length ? stateNames[si] : $"S{si}";
var vStr = v is > -0x10000 and < 0x10000 ? $"{v}" : $"0x{v:X}";
sb.Append($"{name}={vStr} ");
}
sb.AppendLine();
}
}
sb.AppendLine();
// Check IsLoadingOffset value
sb.AppendLine($"=== IsLoadingOffset (0x{offsets.IsLoadingOffset:X}) ===");
if (offsets.IsLoadingOffset > 0)
{
var loadVal = mem.ReadPointer(controller + offsets.IsLoadingOffset);
var loadMatch = loadVal != 0 && stateSlotPtrs.TryGetValue(loadVal, out var loadSlot)
? $"[{loadSlot}] {(loadSlot < stateNames.Length ? stateNames[loadSlot] : $"State{loadSlot}")}"
: "no state match";
sb.AppendLine($" Value: 0x{loadVal:X} → {loadMatch}");
}
else
sb.AppendLine(" (disabled, offset=0)");
// Dump the vector at +0x08 with full dereferences
sb.AppendLine();
sb.AppendLine("=== Vector at +0x08 (deep dump) ===");
var vecFirst = (nint)BitConverter.ToInt64(ctrlData, 0x08);
var vecLast = (nint)BitConverter.ToInt64(ctrlData, 0x10);
var vecEnd = (nint)BitConverter.ToInt64(ctrlData, 0x18);
sb.AppendLine($" First=0x{vecFirst:X}, Last=0x{vecLast:X}, End=0x{vecEnd:X}");
if (vecFirst != 0 && vecLast > vecFirst)
{
var vecSize = (int)(vecLast - vecFirst);
var entryCount = vecSize / 8;
sb.AppendLine($" Size={vecSize} bytes, {entryCount} entries");
if (vecSize <= 0x1000)
{
var vecBuf = mem.ReadBytes(vecFirst, vecSize);
if (vecBuf is not null)
{
for (var vi = 0; vi + 8 <= vecBuf.Length; vi += 8)
{
var entry = (nint)BitConverter.ToInt64(vecBuf, vi);
if (entry == 0) { sb.AppendLine($" [{vi / 8:D2}] 0"); continue; }
// Direct state match?
if (stateSlotPtrs.TryGetValue(entry, out var esi))
{
var en = esi < stateNames.Length ? stateNames[esi] : $"State{esi}";
sb.AppendLine($" [{vi / 8:D2}] 0x{entry:X} ← STATE [{esi}] {en}");
continue;
}
// Dereference: read 256 bytes, check every qword for state matches
var eHigh = (ulong)entry >> 32;
if (eHigh > 0 && eHigh < 0x7FFF)
{
var deref = mem.ReadBytes(entry, 256);
if (deref is not null)
{
var innerMatches = new List<string>();
for (var qi = 0; qi + 8 <= deref.Length; qi += 8)
{
var iv = (nint)BitConverter.ToInt64(deref, qi);
if (iv != 0 && stateSlotPtrs.TryGetValue(iv, out var iSlot))
{
var n = iSlot < stateNames.Length ? stateNames[iSlot] : $"State{iSlot}";
innerMatches.Add($"*+0x{qi:X2}=[{iSlot}]{n}");
}
}
if (innerMatches.Count > 0)
sb.AppendLine($" [{vi / 8:D2}] 0x{entry:X} → {string.Join(", ", innerMatches)}");
else
{
// Show first few qwords for manual analysis
sb.Append($" [{vi / 8:D2}] 0x{entry:X} → no state refs in 256B. First qwords: ");
for (var qi = 0; qi < 48 && qi + 8 <= deref.Length; qi += 8)
{
var qv = (nint)BitConverter.ToInt64(deref, qi);
sb.Append($"0x{qv:X} ");
}
sb.AppendLine();
}
}
else
sb.AppendLine($" [{vi / 8:D2}] 0x{entry:X} (unreadable)");
}
else
sb.AppendLine($" [{vi / 8:D2}] 0x{entry:X} (not a ptr)");
}
}
}
else
sb.AppendLine($" (too large to dump: {vecSize} bytes)");
}
else
sb.AppendLine(" (invalid or empty vector)");
return sb.ToString();
}
/// <summary>
/// State object diff scanner. First call captures baseline of each state slot object (0x1000 bytes each)
/// and the active states vector at +0x08. Second call diffs everything.
/// Usage: click once while in-game, press Escape (or change state), click again.
/// </summary>
public string ScanStateDiff()
{
if (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var controller = mem.ReadPointer(_ctx.GameStateBase);
if (controller == 0) return "Error: controller is null";
var stateNames = GameMemoryReader.StateNames;
const int stateReadSize = 0x1000; // 4096 bytes per state object
// Collect current data: each state slot object + controller pre-slots + vector at +0x08
var current = new Dictionary<string, (nint Address, byte[] Data)>();
// Controller pre-slots region (contains the vector pointers)
var preData = mem.ReadBytes(controller, Math.Max(offsets.StatesBeginOffset, 0x48));
if (preData is not null)
current["Controller_PreSlots"] = (controller, preData);
// Each state slot object
for (var i = 0; i < offsets.StateCount; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
var ptr = mem.ReadPointer(controller + slotOffset);
if (ptr == 0) continue;
var name = i < stateNames.Length ? stateNames[i] : $"State{i}";
var data = mem.ReadBytes(ptr, stateReadSize);
if (data is not null)
current[$"[{i}]{name}"] = (ptr, data);
}
// Vector at +0x08 entries (the wrapper objects)
var vecFirst = mem.ReadPointer(controller + 0x08);
var vecLast = mem.ReadPointer(controller + 0x10);
if (vecFirst != 0 && vecLast > vecFirst)
{
var vecBuf = mem.ReadBytes(vecFirst, (int)(vecLast - vecFirst));
if (vecBuf is not null)
{
current["Vector_0x08"] = (vecFirst, vecBuf);
// Also capture each vector entry object (wrapper objects)
for (var vi = 0; vi + 8 <= vecBuf.Length; vi += 8)
{
var entry = (nint)BitConverter.ToInt64(vecBuf, vi);
if (entry == 0) continue;
var wData = mem.ReadBytes(entry, 0x200);
if (wData is not null)
current[$"VecEntry[{vi / 8}]"] = (entry, wData);
}
}
}
// First call: save baseline
if (_stateDiffBaseline is null)
{
_stateDiffBaseline = current;
var sb = new StringBuilder();
sb.AppendLine("=== STATE DIFF BASELINE CAPTURED ===");
foreach (var (rn, (ra, rd)) in current)
sb.AppendLine($" {rn}: 0x{ra:X} ({rd.Length} bytes)");
sb.AppendLine();
sb.AppendLine("Now change game state (press Escape, go to char select, etc.) and click again.");
return sb.ToString();
}
// Second call: diff against baseline
var baseline = _stateDiffBaseline;
_stateDiffBaseline = null; // reset for next pair
// Build state slot pointer lookup for annotations
var stateSlotPtrs = new Dictionary<nint, string>();
for (var i = 0; i < offsets.StateCount; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
var ptr = mem.ReadPointer(controller + slotOffset);
if (ptr != 0)
{
var n = i < stateNames.Length ? stateNames[i] : $"State{i}";
stateSlotPtrs[ptr] = $"[{i}]{n}";
}
}
stateSlotPtrs[controller] = "Controller";
var result = new StringBuilder();
result.AppendLine("=== STATE OBJECT DIFF ===");
result.AppendLine($"Controller: 0x{controller:X}");
result.AppendLine($"Baseline regions: {baseline.Count}, Current regions: {current.Count}");
result.AppendLine();
// Check for new/removed regions
foreach (var key in baseline.Keys.Except(current.Keys))
result.AppendLine($" REMOVED: {key}");
foreach (var key in current.Keys.Except(baseline.Keys))
result.AppendLine($" NEW: {key}");
var totalChanges = 0;
foreach (var (name, (curAddr, curData)) in current.OrderBy(kv => kv.Key))
{
if (!baseline.TryGetValue(name, out var baseEntry))
continue;
var (baseAddr, baseData) = baseEntry;
// Address itself changed?
if (baseAddr != curAddr)
{
result.AppendLine($"── {name}: ADDRESS CHANGED 0x{baseAddr:X} → 0x{curAddr:X} ──");
totalChanges++;
continue;
}
var len = Math.Min(baseData.Length, curData.Length);
var changes = new List<string>();
// Qword-level diff
for (var offset = 0; offset + 8 <= len; offset += 8)
{
var oldVal = BitConverter.ToInt64(baseData, offset);
var newVal = BitConverter.ToInt64(curData, offset);
if (oldVal == newVal) continue;
var line = $" +0x{offset:X3}: 0x{oldVal:X16} → 0x{newVal:X16}";
// Annotate pointer values
var oldPtr = (nint)oldVal;
var newPtr = (nint)newVal;
if (stateSlotPtrs.TryGetValue(oldPtr, out var oldName))
line += $" (was {oldName})";
if (stateSlotPtrs.TryGetValue(newPtr, out var newName))
line += $" (now {newName})";
// Check for small integer changes (flags, counters)
if (oldVal is > -0x100000 and < 0x100000 && newVal is > -0x100000 and < 0x100000)
line += $" [{oldVal} → {newVal}]";
else
{
// Check int32 halves
var oldI1 = BitConverter.ToInt32(baseData, offset);
var oldI2 = BitConverter.ToInt32(baseData, offset + 4);
var newI1 = BitConverter.ToInt32(curData, offset);
var newI2 = BitConverter.ToInt32(curData, offset + 4);
if (oldI1 != newI1 && oldI2 == newI2 && Math.Abs(newI1) < 0x100000)
line += $" [lo32: {oldI1} → {newI1}]";
else if (oldI1 == newI1 && oldI2 != newI2 && Math.Abs(newI2) < 0x100000)
line += $" [hi32: {oldI2} → {newI2}]";
}
changes.Add(line);
}
// Byte-level diff for flags not on qword boundaries
var byteChanges = new List<string>();
for (var offset = 0; offset < len; offset++)
{
if (baseData[offset] == curData[offset]) continue;
var qwordOffset = (offset / 8) * 8;
if (qwordOffset + 8 <= len)
{
var qOld = BitConverter.ToInt64(baseData, qwordOffset);
var qNew = BitConverter.ToInt64(curData, qwordOffset);
if (qOld != qNew) continue; // already in qword changes
}
byteChanges.Add($" +0x{offset:X3}: byte 0x{baseData[offset]:X2} → 0x{curData[offset]:X2} [{baseData[offset]} → {curData[offset]}]");
}
if (changes.Count == 0 && byteChanges.Count == 0)
continue; // skip unchanged regions
totalChanges += changes.Count + byteChanges.Count;
result.AppendLine($"── {name} (0x{curAddr:X}) ──");
result.AppendLine($" {changes.Count} qword + {byteChanges.Count} byte changes:");
foreach (var c in changes) result.AppendLine(c);
foreach (var c in byteChanges) result.AppendLine(c);
result.AppendLine();
}
if (totalChanges == 0)
result.AppendLine("(no changes detected — did you change game state between clicks?)");
result.AppendLine();
result.AppendLine("Click again to capture a new baseline.");
return result.ToString();
}
/// <summary>
/// Diagnostic: walks the entity std::map (red-black tree) from AreaInstance, reports RTTI types and positions.
/// </summary>
public string ScanEntities()
{
if (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return "Error: InGameState not resolved";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: AreaInstance not resolved";
var sb = new StringBuilder();
sb.AppendLine($"AreaInstance: 0x{ingameData:X}");
// Read sentinel (tree head node) pointer
var sentinel = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.EntityListOffset);
if (sentinel == 0)
{
sb.AppendLine("Entity tree sentinel is null");
return sb.ToString();
}
var entityCount = (int)_ctx.Memory.Read<long>(ingameData + _ctx.Offsets.EntityListOffset + _ctx.Offsets.EntityCountInternalOffset);
// Sentinel layout: _Left = min node, _Parent = root, _Right = max node
var root = _ctx.Memory.ReadPointer(sentinel + _ctx.Offsets.EntityNodeParentOffset);
sb.AppendLine($"Sentinel: 0x{sentinel:X}");
sb.AppendLine($"Root: 0x{root:X}");
sb.AppendLine($"Expected count: {entityCount}");
sb.AppendLine(new string('═', 90));
// In-order tree traversal
var entities = new List<Entity>();
var maxNodes = Math.Min(entityCount + 10, 500);
_entities.WalkTreeInOrder(sentinel, root, maxNodes, node =>
{
var entityPtr = _ctx.Memory.ReadPointer(node + _ctx.Offsets.EntityNodeValueOffset);
if (entityPtr == 0) return;
var high = (ulong)entityPtr >> 32;
if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
var entityId = _ctx.Memory.Read<uint>(entityPtr + _ctx.Offsets.EntityIdOffset);
var path = _entities.TryReadEntityPath(entityPtr);
var entity = new Entity(entityPtr, entityId, path);
if (_entities.TryReadEntityPosition(entityPtr, out var x, out var y, out var z))
{
entity.HasPosition = true;
entity.X = x;
entity.Y = y;
entity.Z = z;
}
entities.Add(entity);
if (entities.Count <= 50)
{
var posStr = entity.HasPosition ? $"({entity.X:F1}, {entity.Y:F1}, {entity.Z:F1})" : "no pos";
var displayPath = entity.Path ?? "?";
if (displayPath.Length > 60)
displayPath = "..." + displayPath[^57..];
sb.AppendLine($"[{entities.Count - 1,3}] ID={entity.Id,-10} {entity.Type,-20} {displayPath}");
sb.AppendLine($" {posStr}");
}
});
// Summary
sb.AppendLine(new string('─', 90));
sb.AppendLine($"Total entities walked: {entities.Count}");
sb.AppendLine($"With position: {entities.Count(e => e.HasPosition)}");
sb.AppendLine($"With path: {entities.Count(e => e.Path is not null)}");
sb.AppendLine();
sb.AppendLine("Type breakdown:");
foreach (var group in entities.GroupBy(e => e.Type).OrderByDescending(g => g.Count()))
sb.AppendLine($" {group.Key}: {group.Count()}");
sb.AppendLine();
sb.AppendLine("Category breakdown:");
foreach (var group in entities.GroupBy(e => e.PathCategory).OrderByDescending(g => g.Count()))
sb.AppendLine($" {group.Key}: {group.Count()}");
return sb.ToString();
}
/// <summary>
/// Scans all game states and returns their info.
/// Supports both inline (POE1-style) and vector modes.
/// </summary>
public string ScanAllStates()
{
if (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var controller = _ctx.Memory.ReadPointer(_ctx.GameStateBase);
if (controller == 0) return "Error: controller is null";
var sb = new StringBuilder();
sb.AppendLine($"Controller: 0x{controller:X}");
sb.AppendLine($"Mode: {(_ctx.Offsets.StatesInline ? "inline" : "vector")}");
int count;
if (_ctx.Offsets.StatesInline)
{
// Inline: count by scanning slots until null
count = 0;
for (var i = 0; i < 30; i++)
{
var slotOffset = _ctx.Offsets.StatesBeginOffset + i * _ctx.Offsets.StateStride + _ctx.Offsets.StatePointerOffset;
var ptr = _ctx.Memory.ReadPointer(controller + slotOffset);
if (ptr == 0) break;
count++;
}
sb.AppendLine($"States: {count} (inline at controller+0x{_ctx.Offsets.StatesBeginOffset:X})");
sb.AppendLine(new string('─', 70));
for (var i = 0; i < count; i++)
{
var slotOffset = _ctx.Offsets.StatesBeginOffset + i * _ctx.Offsets.StateStride + _ctx.Offsets.StatePointerOffset;
var statePtr = _ctx.Memory.ReadPointer(controller + slotOffset);
string? stateName = null;
if (statePtr != 0)
{
var stateVtable = _ctx.Memory.ReadPointer(statePtr);
if (stateVtable != 0 && _ctx.IsModuleAddress(stateVtable))
stateName = _rtti.ResolveRttiName(stateVtable);
}
var marker = i == _ctx.Offsets.InGameStateIndex ? " ◄◄◄" : "";
var tag = _ctx.IsModuleAddress(statePtr) ? " [module!]" : "";
sb.AppendLine($"[{i}] +0x{slotOffset:X}: 0x{statePtr:X} {stateName ?? "?"}{tag}{marker}");
}
return sb.ToString();
}
// Vector mode (fallback)
var statesBegin = _ctx.Memory.ReadPointer(controller + _ctx.Offsets.StatesBeginOffset);
if (statesBegin == 0) return "Error: states begin is null";
var statesEnd = _ctx.Memory.ReadPointer(controller + _ctx.Offsets.StatesBeginOffset + 8);
count = 0;
if (statesEnd > statesBegin && statesEnd - statesBegin < 0x1000 && _ctx.Offsets.StateStride > 0)
count = (int)((statesEnd - statesBegin) / _ctx.Offsets.StateStride);
else
{
for (var i = 0; i < 30; i++)
{
if (_ctx.Memory.ReadPointer(statesBegin + i * _ctx.Offsets.StateStride + _ctx.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 * _ctx.Offsets.StateStride;
var vtable = _ctx.Memory.ReadPointer(entryBase);
var statePtr = _ctx.Memory.ReadPointer(entryBase + _ctx.Offsets.StatePointerOffset);
var vtableName = vtable != 0 && _ctx.IsModuleAddress(vtable) ? _rtti.ResolveRttiName(vtable) : null;
string? stateName = null;
if (statePtr != 0)
{
var stateVtable = _ctx.Memory.ReadPointer(statePtr);
if (stateVtable != 0 && _ctx.IsModuleAddress(stateVtable))
stateName = _rtti.ResolveRttiName(stateVtable);
}
var marker = i == _ctx.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 (_ctx.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 = _ctx.Memory.FollowChain(addr, offsets);
if (addr == 0) return "Error: pointer chain broken (null)";
}
size = Math.Clamp(size, 0x10, 0x10000);
var data = _ctx.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 && _ctx.IsModuleAddress((nint)objVtable) ? _rtti.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 (_ctx.IsModuleAddress(value))
{
var name = _rtti.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 (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return "Error: InGameState not resolved";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: AreaInstance not resolved";
// Try direct LocalPlayer offset first, then ServerData chain
var localPlayer = nint.Zero;
if (_ctx.Offsets.LocalPlayerDirectOffset > 0)
localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
if (localPlayer == 0)
{
var serverData = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.ServerDataOffset);
if (serverData != 0)
localPlayer = _ctx.Memory.ReadPointer(serverData + _ctx.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) = _components.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 = _ctx.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 = _ctx.Memory.ReadPointer(compPtr);
string? rtti = null;
if (vtable != 0 && _ctx.IsModuleAddress(vtable))
rtti = _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 = _ctx.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 = _ctx.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 (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return "Error: InGameState not resolved";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: AreaInstance not resolved";
var localPlayer = nint.Zero;
if (_ctx.Offsets.LocalPlayerDirectOffset > 0)
localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
if (localPlayer == 0)
{
var serverData = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.ServerDataOffset);
if (serverData != 0)
localPlayer = _ctx.Memory.ReadPointer(serverData + _ctx.Offsets.LocalPlayerOffset);
}
if (localPlayer == 0) return "Error: LocalPlayer not resolved";
var sb = new StringBuilder();
sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
var (compBegin, vectorCount) = _components.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 = _ctx.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 = _ctx.Memory.ReadPointer(compPtr);
string? rtti = null;
if (vtable != 0 && _ctx.IsModuleAddress(vtable))
rtti = _rtti.ResolveRttiName(vtable);
// Read 8KB of component data
var compData = _ctx.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 (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return "Error: InGameState not resolved";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: IngameData not resolved";
// Resolve LocalPlayer
var localPlayer = nint.Zero;
if (_ctx.Offsets.LocalPlayerDirectOffset > 0)
localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
if (localPlayer == 0)
{
var serverData = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.ServerDataOffset);
if (serverData != 0)
localPlayer = _ctx.Memory.ReadPointer(serverData + _ctx.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: {_components.CachedLifeIndex}");
sb.AppendLine($"Last LocalPlayer: 0x{_components.LastLocalPlayer:X}");
sb.AppendLine();
if (localPlayer == 0)
{
sb.AppendLine("FAIL: LocalPlayer is null");
return sb.ToString();
}
// Read StdVector at entity+ComponentListOffset
var compFirst = _ctx.Memory.ReadPointer(localPlayer + _ctx.Offsets.ComponentListOffset);
var compLast = _ctx.Memory.ReadPointer(localPlayer + _ctx.Offsets.ComponentListOffset + 8);
var compEnd = _ctx.Memory.ReadPointer(localPlayer + _ctx.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{_ctx.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 = _ctx.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 = _ctx.Memory.ReadPointer(compPtr);
string? rtti = null;
if (vtable != 0 && _ctx.IsModuleAddress(vtable))
rtti = _rtti.ResolveRttiName(vtable);
// Check VitalStruct pattern
var hpTotal = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalTotalOffset);
var hpCurr = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalCurrentOffset);
var manaTotal = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalTotalOffset);
var manaCurr = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalCurrentOffset);
var esTotal = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeEsOffset + _ctx.Offsets.VitalTotalOffset);
var esCurr = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeEsOffset + _ctx.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 == _components.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 (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return "Error: InGameState not resolved";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: IngameData not resolved";
// Resolve LocalPlayer
var localPlayer = nint.Zero;
if (_ctx.Offsets.LocalPlayerDirectOffset > 0)
localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
if (localPlayer == 0)
{
var serverData = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.ServerDataOffset);
if (serverData != 0)
localPlayer = _ctx.Memory.ReadPointer(serverData + _ctx.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 = _ctx.Memory.ReadPointer(localPlayer);
string? entityRtti = null;
if (entityVtable != 0 && _ctx.IsModuleAddress(entityVtable))
entityRtti = _rtti.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 = _ctx.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 (_ctx.IsModuleAddress(val))
{
var name = _rtti.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{_ctx.Offsets.ComponentListOffset:X} ──");
var compFirst = _ctx.Memory.ReadPointer(localPlayer + _ctx.Offsets.ComponentListOffset);
var compLast = _ctx.Memory.ReadPointer(localPlayer + _ctx.Offsets.ComponentListOffset + 8);
var compEnd = _ctx.Memory.ReadPointer(localPlayer + _ctx.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 = _ctx.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 = _ctx.Memory.ReadPointer(compPtr);
string? compRtti = null;
if (compVtable != 0 && _ctx.IsModuleAddress(compVtable))
compRtti = _rtti.ResolveRttiName(compVtable);
// VitalStruct pattern check
var hpTotal = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalTotalOffset);
var hpCurr = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalCurrentOffset);
var manaTotal = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalTotalOffset);
var manaCurr = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalCurrentOffset);
var esTotal = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeEsOffset + _ctx.Offsets.VitalTotalOffset);
var esCurr = _ctx.Memory.Read<int>(compPtr + _ctx.Offsets.LifeEsOffset + _ctx.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 = _ctx.Memory.Read<float>(compPtr + _ctx.Offsets.PositionXOffset);
var py = _ctx.Memory.Read<float>(compPtr + _ctx.Offsets.PositionYOffset);
var pz = _ctx.Memory.Read<float>(compPtr + _ctx.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 = _ctx.Memory.ReadPointer(localPlayer);
sb.AppendLine($"Ptr: 0x{innerEntity:X}");
if (innerEntity != 0 && !_ctx.IsModuleAddress(innerEntity))
{
var innerHigh = (ulong)innerEntity >> 32;
if (innerHigh > 0 && innerHigh < 0x7FFF && (innerEntity & 0x3) == 0)
{
// Read inner entity vtable and RTTI
var innerVtable = _ctx.Memory.ReadPointer(innerEntity);
string? innerRtti = null;
if (innerVtable != 0 && _ctx.IsModuleAddress(innerVtable))
innerRtti = _rtti.ResolveRttiName(innerVtable);
sb.AppendLine($"Inner vtable: 0x{innerVtable:X} RTTI: {innerRtti ?? "?"}");
// Inner entity hex dump (first 0x80 bytes)
var innerData = _ctx.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 = _ctx.Memory.ReadPointer(innerEntity + _ctx.Offsets.ComponentListOffset);
var innerLast = _ctx.Memory.ReadPointer(innerEntity + _ctx.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{_ctx.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 = _ctx.Memory.ReadPointer(innerFirst + i * 8);
if (cp == 0) { sb.AppendLine($" [{i,2}] (null)"); continue; }
var cv = _ctx.Memory.ReadPointer(cp);
string? cr = null;
if (cv != 0 && _ctx.IsModuleAddress(cv)) cr = _rtti.ResolveRttiName(cv);
var ht = _ctx.Memory.Read<int>(cp + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalTotalOffset);
var hc = _ctx.Memory.Read<int>(cp + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalCurrentOffset);
var mt = _ctx.Memory.Read<int>(cp + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalTotalOffset);
var mc = _ctx.Memory.Read<int>(cp + _ctx.Offsets.LifeManaOffset + _ctx.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 = _ctx.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 = _ctx.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 = _ctx.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 (_ctx.IsModuleAddress(val))
{
var name = _rtti.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: {_components.CachedLifeIndex}");
sb.AppendLine($"Last LocalPlayer: 0x{_components.LastLocalPlayer:X}");
// FindComponentList result
var (bestFirst, bestCount) = _components.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 (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return "Error: InGameState not resolved";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.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 = _ctx.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 (_ctx.IsModuleAddress(ptr1)) continue;
if (!visited.Add(ptr1)) continue;
// Scan this target for vital values (first 0xC00 bytes)
var targetData = _ctx.Memory.ReadBytes(ptr1, 0xC00);
if (targetData is null) continue;
var hits = FindVitalHits(targetData, searchValues);
if (hits.Count > 0)
{
var vtable = _ctx.Memory.ReadPointer(ptr1);
var rtti = vtable != 0 && _ctx.IsModuleAddress(vtable) ? _rtti.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 (_ctx.IsModuleAddress(ptr2)) continue;
if (!visited.Add(ptr2)) continue;
var deepData = _ctx.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();
}
/// <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 (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
// Resolve InGameState
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return $"Error: InGameState not resolved (states: {snap.StatesCount})";
var sb = new StringBuilder();
sb.AppendLine($"InGameState: 0x{inGameState:X} (State[{_ctx.Offsets.InGameStateIndex}] of {snap.StatesCount})");
// Read InGameState vtable for identification
var igsVtable = _ctx.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 = _ctx.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 (_ctx.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 = _ctx.Memory.ReadPointer(ptr);
string? candidateRtti = null;
if (candidateVtable != 0 && _ctx.IsModuleAddress(candidateVtable))
candidateRtti = _rtti.ResolveRttiName(candidateVtable);
// Try AreaInstance-like probes
foreach (var (name, probeOff, probeType) in probeOffsets)
{
if (probeType == "ptr")
{
var val = _ctx.Memory.ReadPointer(ptr + probeOff);
if (val != 0 && !_ctx.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 = _ctx.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 = _ctx.Memory.Read<int>(ptr + probeOff);
if (val > 0 && val <= 100)
{
score += 3;
details.Add($" {name} = {val} ✓✓✓");
}
}
else if (probeType == "int_count")
{
var val = _ctx.Memory.Read<int>(ptr + probeOff);
if (val > 0 && val < 10000)
{
score += 2;
details.Add($" {name} = {val} ✓✓");
}
}
else if (probeType == "nonzero32")
{
var val = _ctx.Memory.Read<uint>(ptr + probeOff);
if (val != 0)
{
score++;
details.Add($" {name} = 0x{val:X8} ✓");
}
}
else if (probeType == "nonzero64")
{
var val = _ctx.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 = _rtti.ClassifyPointer(val);
string extra = "";
// If it's a heap pointer, try RTTI
if (tag == "heap ptr")
{
var vt = _ctx.Memory.ReadPointer(val);
if (vt != 0 && _ctx.IsModuleAddress(vt))
{
var rtti = _rtti.ResolveRttiName(vt);
if (rtti != null) extra = $" RTTI={rtti}";
}
// Quick check: does it look like an AreaInstance?
var lvl = _ctx.Memory.Read<byte>(val + 0xAC);
var lvl2 = _ctx.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();
}
/// <summary>
/// Diagnostic: scan the component lookup structure for an entity to figure out the POE2 layout.
/// Reads EntityDetails → ComponentLookupPtr and dumps memory, trying to find component name strings.
/// </summary>
public string ScanComponentLookup()
{
if (_ctx.Memory is null) return "Error: not attached";
var sb = new StringBuilder();
// Get local player
var inGameState = _stateReader.ResolveInGameState(new GameStateSnapshot());
if (inGameState == 0) return "Error: can't resolve InGameState";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: can't resolve AreaInstance";
var localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
if (localPlayer == 0) return "Error: can't resolve LocalPlayer";
sb.AppendLine($"LocalPlayer entity: 0x{localPlayer:X}");
// Also try on a few other entities for comparison
var entitiesToScan = new List<(string label, nint entity)> { ("LocalPlayer", localPlayer) };
// Try to grab a couple more entities from the tree
var sentinel = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.EntityListOffset);
if (sentinel != 0)
{
var root = _ctx.Memory.ReadPointer(sentinel + _ctx.Offsets.EntityNodeParentOffset);
var count = 0;
_entities.WalkTreeInOrder(sentinel, root, 20, node =>
{
var entityPtr = _ctx.Memory.ReadPointer(node + _ctx.Offsets.EntityNodeValueOffset);
if (entityPtr == 0 || entityPtr == localPlayer) return;
var high = (ulong)entityPtr >> 32;
if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
if (count >= 2) return;
var path = _entities.TryReadEntityPath(entityPtr);
if (path is not null && !path.Contains("Effects"))
{
entitiesToScan.Add(($"Entity({path?.Split('/').LastOrDefault() ?? "?"})", entityPtr));
count++;
}
});
}
foreach (var (label, entity) in entitiesToScan)
{
sb.AppendLine($"\n═══ {label}: 0x{entity:X} ═══");
ScanOneEntityComponentLookup(sb, entity);
}
return sb.ToString();
}
/// <summary>
/// Diagnostic: finds terrain struct layout by scanning AreaInstance memory for dimensions, vectors, and grid data.
/// </summary>
public string ScanTerrain()
{
if (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return "Error: InGameState not resolved";
var areaInstance = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (areaInstance == 0) return "Error: AreaInstance is null";
var sb = new StringBuilder();
sb.AppendLine($"AreaInstance: 0x{areaInstance:X}");
sb.AppendLine($"TerrainListOffset: 0x{_ctx.Offsets.TerrainListOffset:X}");
var terrainBase = areaInstance + _ctx.Offsets.TerrainListOffset;
sb.AppendLine($"TerrainStruct addr: 0x{terrainBase:X}");
sb.AppendLine();
// ── 1. Raw hex dump of first 0x200 bytes ──
const int dumpSize = 0x200;
var rawDump = _ctx.Memory.ReadBytes(terrainBase, dumpSize);
if (rawDump is null)
return sb.Append("Error: failed to read TerrainStruct region").ToString();
sb.AppendLine($"═══ Raw dump: TerrainStruct (0x{dumpSize:X} bytes) ═══");
for (var off = 0; off < rawDump.Length; off += 16)
{
sb.Append($"+0x{off:X3}: ");
var end = Math.Min(off + 16, rawDump.Length);
for (var i = off; i < end; i++)
sb.Append($"{rawDump[i]:X2} ");
sb.AppendLine();
}
sb.AppendLine();
// ── 2. Scan for dimension-like int32 pairs (10500) ──
sb.AppendLine("═══ Dimension candidates (int32 pairs, 10500) ═══");
for (var off = 0; off + 8 <= rawDump.Length; off += 4)
{
var a = BitConverter.ToInt32(rawDump, off);
var b = BitConverter.ToInt32(rawDump, off + 4);
if (a >= 10 && a <= 500 && b >= 10 && b <= 500)
sb.AppendLine($" +0x{off:X3}: {a} x {b} (int32 pair)");
}
// int64 pairs cast to int32
sb.AppendLine("═══ Dimension candidates (int64 pairs → int32, 10500) ═══");
for (var off = 0; off + 16 <= rawDump.Length; off += 8)
{
var a = (int)BitConverter.ToInt64(rawDump, off);
var b = (int)BitConverter.ToInt64(rawDump, off + 8);
if (a >= 10 && a <= 500 && b >= 10 && b <= 500)
sb.AppendLine($" +0x{off:X3}: {a} x {b} (int64 pair, low 32 bits)");
}
sb.AppendLine();
// ── 3. Scan for StdVector patterns (begin < end, end-begin reasonable, cap >= end) ──
sb.AppendLine("═══ StdVector candidates (begin/end/cap pointer triples) ═══");
var vectorCandidates = new List<(int Offset, nint Begin, nint End, long DataSize)>();
for (var off = 0; off + 24 <= rawDump.Length; off += 8)
{
var begin = (nint)BitConverter.ToInt64(rawDump, off);
var end = (nint)BitConverter.ToInt64(rawDump, off + 8);
var cap = (nint)BitConverter.ToInt64(rawDump, off + 16);
if (begin == 0 || end <= begin || cap < end) continue;
var dataSize = end - begin;
if (dataSize <= 0 || dataSize > 64 * 1024 * 1024) continue;
// Check high bits look like heap pointer
var highBegin = (ulong)begin >> 32;
if (highBegin == 0 || highBegin >= 0x7FFF) continue;
sb.AppendLine($" +0x{off:X3}: begin=0x{begin:X}, end=0x{end:X}, cap=0x{cap:X} size={dataSize:N0} (0x{dataSize:X})");
vectorCandidates.Add((off, begin, end, dataSize));
}
sb.AppendLine();
// ── 4. BytesPerRow candidates ──
// Expected: ceil(gridWidth / 2) where gridWidth = cols * 23
// For typical maps (cols 20-200): bytesPerRow range ~230 to ~2300
sb.AppendLine("═══ BytesPerRow candidates (int32, range 505000) ═══");
for (var off = 0; off + 4 <= rawDump.Length; off += 4)
{
var val = BitConverter.ToInt32(rawDump, off);
if (val >= 50 && val <= 5000)
sb.AppendLine($" +0x{off:X3}: {val} (0x{val:X})");
}
sb.AppendLine();
// ── 5. Try current offsets and report ──
sb.AppendLine("═══ Current offset readings ═══");
var dimOff = _ctx.Offsets.TerrainDimensionsOffset;
if (dimOff + 16 <= rawDump.Length)
{
var cols64 = BitConverter.ToInt64(rawDump, dimOff);
var rows64 = BitConverter.ToInt64(rawDump, dimOff + 8);
sb.AppendLine($" Dims @+0x{dimOff:X}: cols={cols64}, rows={rows64} (as int64)");
var cols32 = BitConverter.ToInt32(rawDump, dimOff);
var rows32 = BitConverter.ToInt32(rawDump, dimOff + 4);
sb.AppendLine($" Dims @+0x{dimOff:X}: cols={cols32}, rows={rows32} (as int32)");
}
var walkOff = _ctx.Offsets.TerrainWalkableGridOffset;
if (walkOff + 24 <= rawDump.Length)
{
var wBegin = (nint)BitConverter.ToInt64(rawDump, walkOff);
var wEnd = (nint)BitConverter.ToInt64(rawDump, walkOff + 8);
var wCap = (nint)BitConverter.ToInt64(rawDump, walkOff + 16);
sb.AppendLine($" WalkGrid @+0x{walkOff:X}: begin=0x{wBegin:X}, end=0x{wEnd:X}, cap=0x{wCap:X}, size={wEnd - wBegin:N0}");
}
var bprOff = _ctx.Offsets.TerrainBytesPerRowOffset;
if (bprOff + 4 <= rawDump.Length)
{
var bpr = BitConverter.ToInt32(rawDump, bprOff);
sb.AppendLine($" BytesPerRow @+0x{bprOff:X}: {bpr}");
}
sb.AppendLine();
// ── 6. If we can read the grid, dump sample + save image ──
var currentCols = (int)BitConverter.ToInt64(rawDump, Math.Min(dimOff, rawDump.Length - 8));
var currentRows = (dimOff + 8 < rawDump.Length) ? (int)BitConverter.ToInt64(rawDump, dimOff + 8) : 0;
if (currentCols > 0 && currentCols < 1000 && currentRows > 0 && currentRows < 1000)
{
var gridWidth = currentCols * _ctx.Offsets.SubTilesPerCell;
var gridHeight = currentRows * _ctx.Offsets.SubTilesPerCell;
// Try reading the walkable grid vector
if (walkOff + 16 <= rawDump.Length)
{
var gBegin = (nint)BitConverter.ToInt64(rawDump, walkOff);
var gEnd = (nint)BitConverter.ToInt64(rawDump, walkOff + 8);
var gSize = (int)(gEnd - gBegin);
if (gBegin != 0 && gSize > 0 && gSize < 16 * 1024 * 1024)
{
// Read sample (first 64 bytes)
var sample = _ctx.Memory.ReadBytes(gBegin, Math.Min(64, gSize));
if (sample != null)
{
sb.AppendLine($"═══ Grid sample (first {sample.Length} bytes from 0x{gBegin:X}) ═══");
sb.AppendLine($" Raw: {FormatBytes(sample)}");
// Nibble-unpacked view
sb.Append(" Nibbles: ");
for (var i = 0; i < Math.Min(32, sample.Length); i++)
{
var lo = sample[i] & 0x0F;
var hi = (sample[i] >> 4) & 0x0F;
sb.Append($"{lo},{hi} ");
}
sb.AppendLine();
}
var bytesPerRow = (bprOff + 4 <= rawDump.Length) ? BitConverter.ToInt32(rawDump, bprOff) : 0;
sb.AppendLine($" Expected grid: {gridWidth}x{gridHeight}, bytesPerRow={bytesPerRow}, expected raw size={bytesPerRow * gridHeight}");
sb.AppendLine($" Actual vector size: {gSize}");
// Try to read and save full grid image
if (bytesPerRow > 0 && bytesPerRow * gridHeight <= gSize + bytesPerRow)
{
var rawGrid = _ctx.Memory.ReadBytes(gBegin, Math.Min(gSize, bytesPerRow * gridHeight));
if (rawGrid != null)
{
// Unpack nibbles
var gridData = new byte[gridWidth * gridHeight];
for (var row = 0; row < gridHeight; row++)
{
var rowStart = row * bytesPerRow;
for (var col = 0; col < gridWidth; col++)
{
var byteIndex = rowStart + col / 2;
if (byteIndex >= rawGrid.Length) break;
gridData[row * gridWidth + col] = (col % 2 == 0)
? (byte)(rawGrid[byteIndex] & 0x0F)
: (byte)((rawGrid[byteIndex] >> 4) & 0x0F);
}
}
// Stats
var walkable = gridData.Count(b => b == 0);
var total = gridData.Length;
sb.AppendLine($" Walkable: {walkable:N0} / {total:N0} ({100.0 * walkable / total:F1}%)");
// Value distribution
var counts = new int[16];
foreach (var b in gridData) counts[b & 0x0F]++;
sb.Append(" Value distribution: ");
for (var v = 0; v < 16; v++)
if (counts[v] > 0) sb.Append($"[{v}]={counts[v]:N0} ");
sb.AppendLine();
// Save terrain image
try
{
SaveTerrainImage(gridData, gridWidth, gridHeight, "terrain.png");
sb.AppendLine($" Saved terrain.png ({gridWidth}x{gridHeight})");
}
catch (Exception ex)
{
sb.AppendLine($" Error saving image: {ex.Message}");
}
}
}
}
}
}
return sb.ToString();
}
/// <summary>
/// Raw explorer: parse hex address, follow offset chain, read as specified type.
/// </summary>
public string ReadAddress(string hexAddr, string offsetsCsv, string type)
{
if (_ctx.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 = _ctx.Memory.FollowChain(addr, offsets);
if (addr == 0)
return "Error: pointer chain broken (null)";
}
return type.ToLowerInvariant() switch
{
"int32" => _ctx.Memory.Read<int>(addr).ToString(),
"int64" => _ctx.Memory.Read<long>(addr).ToString(),
"float" => _ctx.Memory.Read<float>(addr).ToString("F4"),
"double" => _ctx.Memory.Read<double>(addr).ToString("F4"),
"pointer" => $"0x{_ctx.Memory.ReadPointer(addr):X}",
"bytes16" => FormatBytes(_ctx.Memory.ReadBytes(addr, 16)),
"string" => _strings.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 (_ctx.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 = _ctx.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 = _ctx.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 = _rtti.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();
}
// ════════════════════════════════════════════════════════════════
// Private helpers
// ════════════════════════════════════════════════════════════════
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 [];
}
private void ScanOneEntityComponentLookup(StringBuilder sb, nint entity)
{
// Step 1: Read EntityDetails — handle inner entity (ECS wrapper) pattern
var detailsPtr = _ctx.Memory.ReadPointer(entity + _ctx.Offsets.EntityHeaderOffset);
var high = (ulong)detailsPtr >> 32;
if (high == 0 || high >= 0x7FFF)
{
sb.AppendLine($" entity+0x{_ctx.Offsets.EntityHeaderOffset:X} = 0x{detailsPtr:X} (invalid — trying inner entity)");
// POE2 ECS wrapper: entity+0x00 → inner entity → +0x08 → EntityDetails
var innerEntity = _ctx.Memory.ReadPointer(entity);
if (innerEntity != 0 && innerEntity != entity && !_ctx.IsModuleAddress(innerEntity))
{
var innerHigh = (ulong)innerEntity >> 32;
if (innerHigh > 0 && innerHigh < 0x7FFF && (innerEntity & 0x7) == 0)
{
detailsPtr = _ctx.Memory.ReadPointer(innerEntity + _ctx.Offsets.EntityHeaderOffset);
sb.AppendLine($" Inner entity 0x{innerEntity:X} → details = 0x{detailsPtr:X}");
high = (ulong)detailsPtr >> 32;
}
}
if (high == 0 || high >= 0x7FFF)
{
sb.AppendLine($" ERROR: EntityDetails still invalid (0x{detailsPtr:X})");
return;
}
}
sb.AppendLine($" EntityDetails: 0x{detailsPtr:X}");
// Step 2: Component list for cross-reference
var (compFirst, compCount) = _components.FindComponentList(entity);
sb.AppendLine($" ComponentList: first=0x{compFirst:X}, count={compCount}");
// Step 3: Read ComponentLookup object at details+0x28
// Structure discovered: EntityDetails+0x28 → ComponentLookup object with:
// +0x00: VTablePtr
// +0x08: hash/data
// +0x10: Vec1 begin (component pointers, 8 bytes each)
// +0x18: Vec1 end
// +0x20: Vec1 capacity
// +0x28: Vec2 begin (name entries, ? bytes each)
// +0x30: Vec2 end
// +0x38: Vec2 capacity
var lookupObjPtr = _ctx.Memory.ReadPointer(detailsPtr + 0x28);
if (lookupObjPtr == 0 || !_ctx.IsValidHeapPtr(lookupObjPtr))
{
sb.AppendLine($" details+0x28 = 0x{lookupObjPtr:X} (not a valid pointer)");
return;
}
sb.AppendLine($" ComponentLookup object: 0x{lookupObjPtr:X}");
var objData = _ctx.Memory.ReadBytes(lookupObjPtr, 0x40);
if (objData is null) { sb.AppendLine(" ERROR: Can't read lookup object"); return; }
// Verify VTablePtr
var vtable = (nint)BitConverter.ToInt64(objData, 0);
sb.AppendLine($" VTable: 0x{vtable:X} ({(_ctx.IsModuleAddress(vtable) ? "module " : "NOT module")})");
// Read Vec1 (component pointer array)
var vec1Begin = (nint)BitConverter.ToInt64(objData, 0x10);
var vec1End = (nint)BitConverter.ToInt64(objData, 0x18);
var vec1Cap = (nint)BitConverter.ToInt64(objData, 0x20);
var vec1Size = (vec1Begin != 0 && vec1End > vec1Begin) ? vec1End - vec1Begin : 0;
sb.AppendLine($" Vec1: 0x{vec1Begin:X}..0x{vec1End:X} (0x{vec1Size:X} bytes = {vec1Size / 8} ptrs)");
// Read Vec2 (name lookup entries)
var vec2Begin = (nint)BitConverter.ToInt64(objData, 0x28);
var vec2End = (nint)BitConverter.ToInt64(objData, 0x30);
var vec2Cap = (nint)BitConverter.ToInt64(objData, 0x38);
var vec2Size = (vec2Begin != 0 && vec2End > vec2Begin) ? vec2End - vec2Begin : 0;
if (vec2Size <= 0)
{
sb.AppendLine($" Vec2: empty or invalid (begin=0x{vec2Begin:X}, end=0x{vec2End:X})");
return;
}
// Determine entry size from vec2 size and component count
var entrySize = compCount > 0 ? (int)(vec2Size / compCount) : 0;
var entryCount = entrySize > 0 ? (int)(vec2Size / entrySize) : 0;
sb.AppendLine($" Vec2: 0x{vec2Begin:X}..0x{vec2End:X} (0x{vec2Size:X} bytes)");
sb.AppendLine($" Entry size: {entrySize} bytes ({entryCount} entries for {compCount} components)");
if (entrySize <= 0 || entrySize > 128 || entryCount != compCount)
{
sb.AppendLine(" WARNING: entry size doesn't evenly divide by component count");
// Try common sizes
foreach (var trySize in new[] { 8, 16, 24, 32 })
{
if (vec2Size % trySize == 0)
sb.AppendLine($" Alt: {trySize} bytes → {vec2Size / trySize} entries");
}
}
// Step 4: Read and dump Vec2 entries
var readCount = Math.Min(entryCount > 0 ? entryCount : 30, 30);
var actualEntrySize = entrySize > 0 ? entrySize : 16; // default guess
var vec2Data = _ctx.Memory.ReadBytes(vec2Begin, readCount * actualEntrySize);
if (vec2Data is null) { sb.AppendLine(" ERROR: Can't read Vec2 data"); return; }
sb.AppendLine($"\n Vec2 entries (first {readCount}):");
for (var i = 0; i < readCount; i++)
{
var entryOff = i * actualEntrySize;
if (entryOff + actualEntrySize > vec2Data.Length) break;
var hex = BitConverter.ToString(vec2Data, entryOff, actualEntrySize).Replace('-', ' ');
sb.Append($" [{i,2}]: {hex}");
// Try each 8-byte field as string pointer
for (var f = 0; f < actualEntrySize && f + 8 <= actualEntrySize; f += 8)
{
var fieldPtr = (nint)BitConverter.ToInt64(vec2Data, entryOff + f);
if (fieldPtr == 0) continue;
if (!_ctx.IsValidHeapPtr(fieldPtr)) continue;
// Try as std::string (MSVC narrow)
var name = _strings.ReadMsvcString(fieldPtr);
if (name is not null && name.Length >= 2 && name.Length <= 50 &&
name.All(c => c >= 0x20 && c <= 0x7E))
{
sb.Append($" ← field+0x{f:X}: std::string \"{name}\"");
continue;
}
// Try as std::wstring
var wname = _strings.ReadMsvcWString(fieldPtr);
if (wname is not null && wname.Length >= 2 && wname.Length <= 50)
{
sb.Append($" ← field+0x{f:X}: wstring \"{wname}\"");
continue;
}
// Try as char* (null-terminated)
var rawBytes = _ctx.Memory.ReadBytes(fieldPtr, 64);
if (rawBytes is not null)
{
var end = Array.IndexOf(rawBytes, (byte)0);
if (end >= 2 && end <= 50)
{
var s = Encoding.ASCII.GetString(rawBytes, 0, end);
if (s.All(c => c >= 0x20 && c <= 0x7E))
{
sb.Append($" ← field+0x{f:X}: char* \"{s}\"");
continue;
}
}
}
// Try following pointer one more level (ptr → std::string)
var innerPtr = _ctx.Memory.ReadPointer(fieldPtr);
if (innerPtr != 0 && _ctx.IsValidHeapPtr(innerPtr))
{
var innerName = _strings.ReadMsvcString(innerPtr);
if (innerName is not null && innerName.Length >= 2 && innerName.Length <= 50 &&
innerName.All(c => c >= 0x20 && c <= 0x7E))
{
sb.Append($" ← field+0x{f:X} → → std::string \"{innerName}\"");
continue;
}
}
}
// Also try 4-byte fields as small ints (potential indices)
for (var f = 0; f + 4 <= actualEntrySize; f += 4)
{
var intVal = BitConverter.ToInt32(vec2Data, entryOff + f);
if (intVal >= 0 && intVal < compCount && f >= 8) // only after pointer fields
sb.Append($" [+0x{f:X}={intVal}?]");
}
sb.AppendLine();
}
// Also try reading Vec2 entries with inline std::string
// (entry = { std::string name (0x20 bytes), ... })
if (actualEntrySize >= 0x20)
{
sb.AppendLine($"\n Trying inline std::string in entries (0x20 bytes per string):");
for (var i = 0; i < Math.Min(readCount, 5); i++)
{
var entryAddr = vec2Begin + i * actualEntrySize;
for (var f = 0; f + 0x20 <= actualEntrySize; f += 8)
{
var name = _strings.ReadMsvcString(entryAddr + f);
if (name is not null && name.Length >= 2 && name.Length <= 50 &&
name.All(c => c >= 0x20 && c <= 0x7E))
{
sb.AppendLine($" [{i}]+0x{f:X}: inline \"{name}\"");
}
}
}
}
}
/// <summary>
/// Scans InGameState for the camera's view-projection Matrix4x4 by:
/// 1. Scanning InGameState itself for inline matrix candidates
/// 2. Following pointers and scanning for resolution pair (2560×1440) + nearby matrix
/// Validates by checking if WorldToScreen(playerPos) ≈ screen center.
/// </summary>
public string ScanCamera()
{
if (_ctx.Memory is null) return "Error: not attached";
var (candidates, header, error) = CollectCameraCandidates();
if (error is not null) return error;
var sb = new StringBuilder();
sb.AppendLine("═══ Camera Scan ═══");
sb.Append(header);
FormatCameraCandidates(sb, candidates);
return sb.ToString();
}
/// <summary>
/// Camera diff — click once to capture baseline, move/zoom camera, click again to see diff.
/// Only shows candidates whose float values changed between captures.
/// </summary>
public string CameraDiff()
{
if (_ctx.Memory is null) return "Error: not attached";
var (candidates, header, error) = CollectCameraCandidates();
if (error is not null) return error;
// First click: capture baseline
if (_cameraDiffBaseline is null)
{
_cameraDiffBaseline = candidates;
var sb = new StringBuilder();
sb.AppendLine("═══ Camera Diff — BASELINE CAPTURED ═══");
sb.Append(header);
sb.AppendLine($"Captured {candidates.Count} candidates.");
sb.AppendLine();
sb.AppendLine("Now move or zoom the camera, then click Camera Diff again.");
return sb.ToString();
}
// Second click: diff against baseline
var baseline = _cameraDiffBaseline;
_cameraDiffBaseline = null; // reset for next pair
// Index baseline by source key for matching
var baselineBySource = new Dictionary<string, float[]>();
foreach (var c in baseline)
baselineBySource[c.source] = c.floats;
var result = new StringBuilder();
result.AppendLine("═══ Camera Diff — RESULTS ═══");
result.Append(header);
result.AppendLine($"Baseline: {baseline.Count} candidates, Current: {candidates.Count} candidates");
result.AppendLine();
var changedCount = 0;
foreach (var c in candidates)
{
if (!baselineBySource.TryGetValue(c.source, out var oldFloats))
{
// New candidate not in baseline
result.AppendLine($"[NEW] {c.source} screen=({c.sx:F1},{c.sy:F1}) dist={c.dist:F0}px");
FormatFloatRow(result, c.floats);
result.AppendLine();
changedCount++;
continue;
}
// Compare floats — count how many changed
var diffs = 0;
var maxDelta = 0f;
for (var i = 0; i < 16; i++)
{
var delta = MathF.Abs(c.floats[i] - oldFloats[i]);
if (delta > 0.0001f)
{
diffs++;
if (delta > maxDelta) maxDelta = delta;
}
}
if (diffs == 0) continue; // unchanged — skip
changedCount++;
result.AppendLine($"[CHANGED {diffs}/16 floats, maxDelta={maxDelta:F6}] {c.source} screen=({c.sx:F1},{c.sy:F1}) dist={c.dist:F0}px");
result.Append(" OLD: ");
for (var i = 0; i < 16; i++)
{
result.Append($"{oldFloats[i]:F6}");
if (i < 15) result.Append(" | ");
}
result.AppendLine();
result.Append(" NEW: ");
for (var i = 0; i < 16; i++)
{
result.Append($"{c.floats[i]:F6}");
if (i < 15) result.Append(" | ");
}
result.AppendLine();
result.Append(" DIF: ");
for (var i = 0; i < 16; i++)
{
var delta = c.floats[i] - oldFloats[i];
if (MathF.Abs(delta) > 0.0001f)
result.Append($"{delta:+0.000000;-0.000000}");
else
result.Append(" .");
if (i < 15) result.Append(" | ");
}
result.AppendLine();
result.AppendLine();
}
// Check for candidates that disappeared
var currentSources = new HashSet<string>(candidates.Select(c => c.source));
foreach (var c in baseline)
{
if (!currentSources.Contains(c.source))
{
result.AppendLine($"[GONE] {c.source}");
changedCount++;
}
}
result.AppendLine();
result.AppendLine($"═══ {changedCount} changed out of {candidates.Count} candidates ═══");
if (changedCount == 0)
result.AppendLine("No matrices changed — did you move/zoom the camera?");
return result.ToString();
}
private (List<(string source, float sx, float sy, float dist, float[] floats)> candidates, string header, string? error) CollectCameraCandidates()
{
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0)
return ([], "", "Error: InGameState not resolved");
snap.InGameStatePtr = inGameState;
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData != 0)
{
snap.LocalPlayerPtr = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
if (snap.LocalPlayerPtr != 0)
{
_components.InvalidateCaches(snap.LocalPlayerPtr);
_components.ReadPlayerPosition(snap);
}
}
var headerSb = new StringBuilder();
headerSb.AppendLine($"InGameState: 0x{inGameState:X}");
headerSb.AppendLine($"Player pos: ({snap.PlayerX:F1}, {snap.PlayerY:F1}, {snap.PlayerZ:F1}) valid={snap.HasPosition}");
if (!snap.HasPosition)
return ([], headerSb.ToString(), headerSb + "ERROR: No player position — cannot validate matrices.");
headerSb.AppendLine();
const int screenW = 2560, screenH = 1440;
float halfW = screenW * 0.5f, halfH = screenH * 0.5f;
var playerWorld = new System.Numerics.Vector4(snap.PlayerX, snap.PlayerY, snap.PlayerZ, 1f);
var candidates = new List<(string source, float sx, float sy, float dist, float[] floats)>();
// Phase 1: Scan InGameState inline (camera struct is likely in first 0x800)
var igsData = _ctx.Memory.ReadBytes(inGameState, 0x2000);
if (igsData is not null)
{
for (var matOff = 0; matOff + 64 <= igsData.Length; matOff += 4)
{
if (!TryReadMatrix(igsData, matOff, out var matrix, out var floats)) continue;
if (!TryValidateMatrix(matrix, playerWorld, halfW, halfH, out var sx, out var sy, out var dist)) continue;
candidates.Add(($"IGS+0x{matOff:X} (inline)", sx, sy, dist, floats));
matOff += 60; // skip past this matrix (loop will add +4 = total +64)
}
}
// Phase 2: Follow pointers from InGameState (camera is likely a separate object)
// Only follow pointers in first 0x800 — camera ptr is a near field
if (igsData is not null)
{
var ptrScanLimit = Math.Min(igsData.Length, 0x800);
for (var off = 0; off + 8 <= ptrScanLimit; off += 8)
{
var ptr = (nint)BitConverter.ToInt64(igsData, off);
if (ptr == 0) continue;
var high = (ulong)ptr >> 32;
if (high == 0 || high >= 0x7FFF || (ptr & 0x3) != 0) continue;
// Camera object is small — scan 0x600 bytes max
var camData = _ctx.Memory.ReadBytes(ptr, 0x600);
if (camData is null) continue;
for (var matOff = 0; matOff + 64 <= camData.Length; matOff += 4)
{
if (!TryReadMatrix(camData, matOff, out var matrix, out var floats)) continue;
if (!TryValidateMatrix(matrix, playerWorld, halfW, halfH, out var sx, out var sy, out var dist)) continue;
candidates.Add(($"IGS+0x{off:X}→+0x{matOff:X} (ptr)", sx, sy, dist, floats));
matOff += 60; // skip past this matrix
}
}
}
// Deduplicate: multiple IGS pointers often resolve to the same underlying object.
// Group by float content, keep only the first (closest) source for each unique matrix.
var deduped = new List<(string source, float sx, float sy, float dist, float[] floats)>();
var seen = new HashSet<string>();
candidates.Sort((a, b) => a.dist.CompareTo(b.dist));
foreach (var c in candidates)
{
// Build a content key from the 16 floats (round to avoid floating point noise)
var key = string.Join(",", c.floats.Select(f => MathF.Round(f, 3)));
if (!seen.Add(key))
{
// Duplicate content — append source to existing entry
var idx = deduped.FindIndex(d => string.Join(",", d.floats.Select(f => MathF.Round(f, 3))) == key);
if (idx >= 0)
deduped[idx] = (deduped[idx].source + " | " + c.source, deduped[idx].sx, deduped[idx].sy, deduped[idx].dist, deduped[idx].floats);
continue;
}
deduped.Add(c);
}
return (deduped, headerSb.ToString(), null);
}
private static void FormatCameraCandidates(StringBuilder sb, List<(string source, float sx, float sy, float dist, float[] floats)> candidates)
{
var ptrCandidates = candidates.Where(c => c.source.Contains("ptr")).ToList();
var inlineCandidates = candidates.Where(c => c.source.Contains("inline")).ToList();
sb.AppendLine($"TOTAL: {candidates.Count} (inline={inlineCandidates.Count}, ptr={ptrCandidates.Count})");
sb.AppendLine();
if (ptrCandidates.Count > 0)
{
sb.AppendLine("═══ POINTER CANDIDATES ═══");
for (var i = 0; i < ptrCandidates.Count; i++)
{
var c = ptrCandidates[i];
sb.AppendLine($"[P{i}] {c.source} screen=({c.sx:F1},{c.sy:F1}) dist={c.dist:F0}px");
FormatFloatRow(sb, c.floats);
sb.AppendLine();
}
}
if (inlineCandidates.Count > 0)
{
sb.AppendLine("═══ INLINE CANDIDATES ═══");
for (var i = 0; i < inlineCandidates.Count; i++)
{
var c = inlineCandidates[i];
sb.AppendLine($"[I{i}] {c.source} screen=({c.sx:F1},{c.sy:F1}) dist={c.dist:F0}px");
FormatFloatRow(sb, c.floats);
sb.AppendLine();
}
}
}
private static void FormatFloatRow(StringBuilder sb, float[] floats)
{
sb.Append(" ");
for (var i = 0; i < 16; i++)
{
sb.Append($"{floats[i]:F6}");
if (i < 15) sb.Append(" | ");
}
sb.AppendLine();
}
private static bool TryReadMatrix(byte[] data, int offset, out System.Numerics.Matrix4x4 matrix, out float[] rawFloats)
{
matrix = default;
rawFloats = Array.Empty<float>();
if (offset + 64 > data.Length) return false;
var floats = new float[16];
for (var i = 0; i < 16; i++)
{
floats[i] = BitConverter.ToSingle(data, offset + i * 4);
if (float.IsNaN(floats[i]) || float.IsInfinity(floats[i])) return false;
}
// Need at least 8 non-zero values for a valid VP matrix
var nonZeroCount = 0;
for (var i = 0; i < 16; i++)
if (MathF.Abs(floats[i]) > 0.0001f) nonZeroCount++;
if (nonZeroCount < 8) return false;
rawFloats = floats;
matrix = new System.Numerics.Matrix4x4(
floats[0], floats[1], floats[2], floats[3],
floats[4], floats[5], floats[6], floats[7],
floats[8], floats[9], floats[10], floats[11],
floats[12], floats[13], floats[14], floats[15]);
return true;
}
private static bool TryValidateMatrix(
System.Numerics.Matrix4x4 matrix,
System.Numerics.Vector4 playerWorld,
float halfW, float halfH,
out float sx, out float sy, out float dist)
{
sx = sy = dist = 0;
var clip = System.Numerics.Vector4.Transform(playerWorld, matrix);
if (clip.W == 0 || float.IsNaN(clip.W) || float.IsInfinity(clip.W)) return false;
// W should be positive and in reasonable range for perspective divide
if (clip.W < 0.5f || clip.W > 10000f) return false;
var ndc = System.Numerics.Vector4.Divide(clip, clip.W);
// NDC should be in [-1, 1] for player at screen center
if (MathF.Abs(ndc.X) > 1.5f || MathF.Abs(ndc.Y) > 1.5f) return false;
if (float.IsNaN(ndc.Z) || float.IsInfinity(ndc.Z)) return false;
sx = (ndc.X + 1f) * halfW;
sy = (1f - ndc.Y) * halfH;
// Player should map to within 150px of screen center (real camera is very close)
dist = MathF.Sqrt((sx - halfW) * (sx - halfW) + (sy - halfH) * (sy - halfH));
return dist < 150;
}
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 static void SaveTerrainImage(byte[] gridData, int width, int height, string path)
{
using var bmp = new Bitmap(width, height, PixelFormat.Format8bppIndexed);
// Set palette: 0 = white (walkable), nonzero = black (blocked)
var palette = bmp.Palette;
palette.Entries[0] = Color.White;
for (var i = 1; i < 256; i++)
palette.Entries[i] = Color.Black;
bmp.Palette = palette;
// Lock bits and copy row by row (stride may differ from width)
var rect = new Rectangle(0, 0, width, height);
var bmpData = bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed);
try
{
for (var y = 0; y < height; y++)
{
Marshal.Copy(gridData, y * width, bmpData.Scan0 + y * bmpData.Stride, width);
}
}
finally
{
bmp.UnlockBits(bmpData);
}
bmp.Save(path, ImageFormat.Png);
}
// ── Actor diff storage ──
private Dictionary<string, (nint Address, byte[] Data)>? _actorDiffBaseline;
/// <summary>
/// Analyzes the Actor's granted-effects vector (shared_ptr pairs).
/// Reads ALL entries, performs field variance analysis to find distinguishing
/// fields (active skills vs support gems), and follows .dat pointers for names.
/// </summary>
public string ScanActorSkills()
{
if (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return "Error: InGameState not resolved";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: AreaInstance not resolved";
var localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
if (localPlayer == 0) return "Error: LocalPlayer not resolved";
var actorComp = _components.GetComponentAddress(localPlayer, "Actor");
if (actorComp == 0) return "Error: Actor component not found on LocalPlayer";
var sb = new StringBuilder();
sb.AppendLine("═══ Actor Skills Analysis ═══");
sb.AppendLine($"Actor: 0x{actorComp:X}");
const int scanSize = 0x1200;
var actorData = _ctx.Memory.ReadBytes(actorComp, scanSize);
if (actorData is null) return "Error: failed to read Actor data";
// ── Confirmed offsets from ExileCore2 ActorOffset ──
// 0x370: AnimationId (int)
// 0xB00: ActiveSkillsPtr (StdVector of shared_ptr)
// 0xB18: CooldownsPtr (StdVector)
// 0xC10: DeployedEntityArray (StdVector)
var animId = BitConverter.ToInt32(actorData, 0x370);
sb.AppendLine($"AnimationId (0x370): {animId}");
// Read Cooldowns vector at 0xB18
var cdFirst = (nint)BitConverter.ToInt64(actorData, 0xB18);
var cdLast = (nint)BitConverter.ToInt64(actorData, 0xB20);
var cdEnd = (nint)BitConverter.ToInt64(actorData, 0xB28);
if (cdFirst != 0 && cdLast > cdFirst && cdEnd >= cdLast)
{
var cdBytes = (int)(cdLast - cdFirst);
sb.AppendLine($"Cooldowns (0xB18): {cdBytes} bytes, First=0x{cdFirst:X}");
var cdData = _ctx.Memory.ReadBytes(cdFirst, Math.Min(cdBytes, 0x400));
if (cdData is not null)
{
// Try common entry sizes
var cdSizes = new List<string>();
foreach (var es in new[] { 0x04, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30 })
if (cdBytes % es == 0) cdSizes.Add($"0x{es:X}({cdBytes / es})");
sb.AppendLine($" Entry sizes: {string.Join(", ", cdSizes)}");
// Dump first 0x60 bytes
sb.AppendLine(" Cooldown data (first 0x60 bytes):");
DumpAnnotatedQwords(sb, cdData, Math.Min(cdData.Length, 0x60), " ");
}
}
else
{
sb.AppendLine("Cooldowns (0xB18): empty or invalid");
}
sb.AppendLine();
// ── Step 1: Read ActiveSkills vector at confirmed offset 0xB00 ──
var bestFirst = (nint)BitConverter.ToInt64(actorData, 0xB00);
var bestLast = (nint)BitConverter.ToInt64(actorData, 0xB08);
var bestEnd = (nint)BitConverter.ToInt64(actorData, 0xB10);
if (bestFirst == 0 || bestLast <= bestFirst || bestEnd < bestLast)
return sb.ToString() + "\nError: ActiveSkills vector at 0xB00 is empty/invalid";
var bestSize = (int)(bestLast - bestFirst);
var entryCount = bestSize / 0x10; // 2 ptrs per shared_ptr (16 bytes each)
sb.AppendLine($"ActiveSkills (0xB00): {entryCount} entries ({bestSize} bytes)");
var vecData = _ctx.Memory.ReadBytes(bestFirst, bestSize);
if (vecData is null) return "Error: failed to read vector data";
// ── Step 2: Read ALL entry targets (0x100 bytes each) ──
var entries = new List<(int idx, nint ptr1, nint ptr2, byte[] data)>();
for (var i = 0; i < entryCount; i++)
{
var ptr1 = (nint)BitConverter.ToInt64(vecData, i * 0x10);
var ptr2 = (nint)BitConverter.ToInt64(vecData, i * 0x10 + 8);
// ptr2 is the object base (vtable at +0x00), ptr1 points into the object
// Follow ptr2 for the actual GrantedEffect data
var target = ptr2;
if (!IsValidHeapPtrForScan(target)) target = ptr1;
if (!IsValidHeapPtrForScan(target)) continue;
var data = _ctx.Memory.ReadBytes(target, 0x100);
if (data is null) continue;
entries.Add((i, ptr1, ptr2, data));
}
sb.AppendLine($"Read {entries.Count} entries successfully\n");
// ── Step 3: Field variance analysis ──
// For each qword offset, count unique values across all entries
sb.AppendLine("═══ Field Variance (qword offsets) ═══");
sb.AppendLine("Offset Unique Category");
sb.AppendLine(new string('─', 60));
var interestingOffsets = new List<int>();
for (var off = 0; off < 0x100; off += 8)
{
var vals = new HashSet<long>();
foreach (var (_, _, _, data) in entries)
{
if (off + 8 <= data.Length)
vals.Add(BitConverter.ToInt64(data, off));
}
var count = vals.Count;
string tag;
if (count == 1)
tag = "[CONST]";
else if (count == entries.Count)
tag = "[ALL-UNIQUE]";
else if (count <= 8)
{
tag = $"[FEW={count}] <<<";
interestingOffsets.Add(off);
}
else
tag = $"[VARIED={count}]";
// Show the first value for CONST fields
var sample = entries.Count > 0 && off + 8 <= entries[0].data.Length
? BitConverter.ToInt64(entries[0].data, off) : 0;
var samplePtr = (nint)sample;
var typeHint = sample == 0 ? "zero" :
_ctx.IsModuleAddress(samplePtr) ? "MODULE" :
IsValidHeapPtrForScan(samplePtr) ? "HEAP" :
Math.Abs((int)sample) < 100000 && (sample >> 32) == 0 ? $"int={sample}" :
"";
sb.AppendLine($"+0x{off:X2} {count,3} {tag} {typeHint}");
}
// Also check int32 variance for offsets 0x00-0x30 (UseStage/CastType region)
sb.AppendLine("\n═══ Int32 Variance (0x00-0x30) ═══");
for (var off = 0; off < 0x30; off += 4)
{
var vals = new HashSet<int>();
foreach (var (_, _, _, data) in entries)
{
if (off + 4 <= data.Length)
vals.Add(BitConverter.ToInt32(data, off));
}
var count = vals.Count;
if (count > 1 && count <= 8)
{
sb.Append($" +0x{off:X2}: {count} values →");
foreach (var v in vals.OrderBy(v => v))
sb.Append($" {v}");
sb.AppendLine();
}
}
// ── Step 4: Detail on "FEW" offsets — show value→count mapping ──
if (interestingOffsets.Count > 0)
{
sb.AppendLine("\n═══ Interesting Fields Detail (few unique values) ═══");
foreach (var off in interestingOffsets)
{
var valueCounts = new Dictionary<long, int>();
foreach (var (_, _, _, data) in entries)
{
if (off + 8 > data.Length) continue;
var val = BitConverter.ToInt64(data, off);
valueCounts.TryGetValue(val, out var c);
valueCounts[val] = c + 1;
}
sb.AppendLine($"\n +0x{off:X2}:");
foreach (var (val, cnt) in valueCounts.OrderByDescending(kv => kv.Value))
{
var ptr = (nint)val;
var label = val == 0 ? "NULL" :
_ctx.IsModuleAddress(ptr) ? $"0x{val:X} [MODULE]" :
IsValidHeapPtrForScan(ptr) ? $"0x{val:X} [HEAP]" :
$"0x{val:X} (int={val})";
sb.Append($" {label} — {cnt} entries");
// Show which entry indices have this value
var indices = entries.Where(e => off + 8 <= e.data.Length &&
BitConverter.ToInt64(e.data, off) == val).Select(e => e.idx);
sb.AppendLine($" [{string.Join(",", indices)}]");
}
}
}
// ── Step 5: Follow .dat pointers for names ──
// Try MODULE ptrs at interesting offsets and ALL-UNIQUE offsets
sb.AppendLine("\n═══ Name Search (following .dat pointers) ═══");
var nameCache = new Dictionary<nint, string?>();
var entryNames = new Dictionary<int, string>(); // entryIdx → name
foreach (var (idx, _, _, data) in entries)
{
// Check multiple candidate offsets for .dat pointers
foreach (var off in new[] { 0x10, 0x18, 0x20, 0x28, 0x30, 0x38 })
{
if (off + 8 > data.Length) continue;
var datPtr = (nint)BitConverter.ToInt64(data, off);
if (datPtr == 0) continue;
if (!_ctx.IsModuleAddress(datPtr) && !IsValidHeapPtrForScan(datPtr)) continue;
if (!nameCache.TryGetValue(datPtr, out var name))
{
name = TryFindNameFromDatRow(datPtr);
nameCache[datPtr] = name;
}
if (name is not null)
{
entryNames.TryAdd(idx, $"[+0x{off:X2}]{name}");
break;
}
}
// Also try the heap ptr at +0x80 (adjusted from ptr2's +0x90)
if (!entryNames.ContainsKey(idx))
{
foreach (var off in new[] { 0x78, 0x80, 0x88, 0x90 })
{
if (off + 8 > data.Length) continue;
var heapPtr = (nint)BitConverter.ToInt64(data, off);
if (!IsValidHeapPtrForScan(heapPtr)) continue;
if (!nameCache.TryGetValue(heapPtr, out var name))
{
name = TryFindNameFromDatRow(heapPtr);
nameCache[heapPtr] = name;
}
if (name is not null)
{
entryNames.TryAdd(idx, $"[+0x{off:X2}]{name}");
break;
}
}
}
}
sb.AppendLine($"Names found: {entryNames.Count}/{entries.Count}");
foreach (var (idx, name) in entryNames.OrderBy(kv => kv.Key))
sb.AppendLine($" [{idx:D2}] {name}");
// ── Step 6: Compact summary table (from control block ptr2) ──
sb.AppendLine("\n═══ Entry Table (ctrl block) ═══");
sb.AppendLine("Idx RefCnt +0x28 Name");
sb.AppendLine(new string('─', 80));
foreach (var (idx, ptr1, ptr2, data) in entries)
{
var uses = BitConverter.ToInt32(data, 0x08);
var weaks = BitConverter.ToInt32(data, 0x0C);
var q28 = (nint)BitConverter.ToInt64(data, 0x28);
var q28s = q28 == 0 ? "NULL" : $"0x{q28:X}";
var name = entryNames.TryGetValue(idx, out var n) ? n : "?";
sb.AppendLine($"[{idx:D2}] {uses}/{weaks} {q28s,-18} {name}");
}
// ── Step 6b: ExileCore ActiveSkillDetails validation (from ptr1) ──
// ptr1 = ActiveSkillPtr, ptr2 = shared_ptr control block
sb.AppendLine("\n═══ ActiveSkillDetails via ptr1 (ExileCore offsets) ═══");
sb.AppendLine("Idx UseStage CastType GEPL_ptr ASDatPtr Uses CdMs Name");
sb.AppendLine(new string('─', 110));
foreach (var (idx, ptr1, ptr2, _) in entries)
{
if (!IsValidHeapPtrForScan(ptr1) && !IsDatPtr(ptr1)) continue;
var det = _ctx.Memory.ReadBytes(ptr1, 0xB0);
if (det is null) continue;
var useStage = BitConverter.ToInt32(det, 0x08);
var castType = BitConverter.ToInt32(det, 0x0C);
var geplRow = (nint)BitConverter.ToInt64(det, 0x18);
var asDatPtr = (nint)BitConverter.ToInt64(det, 0x20);
var totalUses = det.Length >= 0x9C ? BitConverter.ToInt32(det, 0x98) : -1;
var cooldownMs = det.Length >= 0xAC ? BitConverter.ToInt32(det, 0xA8) : -1;
var geplS = geplRow == 0 ? "NULL" : $"0x{geplRow:X}";
var asS = asDatPtr == 0 ? "NULL" : $"0x{asDatPtr:X}";
var name = entryNames.TryGetValue(idx, out var n) ? n : "";
sb.AppendLine($"[{idx:D2}] {useStage,5} {castType,5} {geplS,-18} {asS,-18} {totalUses,4} {cooldownMs,5} {name}");
}
// ── Step 6c: Cooldowns vector at Actor+0xB18 ──
var cdFirst2 = (nint)BitConverter.ToInt64(actorData, 0xB18);
var cdLast2 = (nint)BitConverter.ToInt64(actorData, 0xB20);
if (cdFirst2 != 0 && cdLast2 > cdFirst2)
{
var cdTotalBytes = (int)(cdLast2 - cdFirst2);
var cdCount = cdTotalBytes / 0x48;
sb.AppendLine($"\n═══ Cooldowns Vector (0xB18): {cdCount} entries ({cdTotalBytes} bytes) ═══");
for (var ci = 0; ci < cdCount && ci < 20; ci++)
{
var cdAddr = cdFirst2 + ci * 0x48;
var cdData = _ctx.Memory.ReadBytes(cdAddr, 0x48);
if (cdData is null) continue;
var asDatId = BitConverter.ToInt32(cdData, 0x08);
var cdListFirst = (nint)BitConverter.ToInt64(cdData, 0x10);
var cdListLast = (nint)BitConverter.ToInt64(cdData, 0x18);
var activeCds = cdListFirst != 0 && cdListLast > cdListFirst
? (int)(cdListLast - cdListFirst) / 0x10 : 0;
var maxUses = BitConverter.ToInt32(cdData, 0x30);
var totalCdMs = BitConverter.ToInt32(cdData, 0x34);
var equipId = BitConverter.ToUInt32(cdData, 0x3C);
var canUse = activeCds < maxUses ? "yes" : "NO";
sb.AppendLine($" [{ci}] DatId={asDatId} MaxUses={maxUses} ActiveCDs={activeCds} CdMs={totalCdMs} EquipId=0x{equipId:X} CanUse={canUse}");
}
}
else
{
sb.AppendLine("\nCooldowns (0xB18): empty or invalid (no skills on cooldown?)");
}
// ── Step 7: Deep analysis — follow GEPL FK chain from ptr1 (ActiveSkillPtr) ──
// Only show first 3 entries + all entries with non-zero UseStage for brevity
sb.AppendLine("\n═══ Active Skills Deep Analysis (ptr1 → GEPL FK) ═══");
foreach (var (idx, ptr1, ptr2, data) in entries)
{
if (!IsValidHeapPtrForScan(ptr1) && !IsDatPtr(ptr1)) continue;
var det = _ctx.Memory.ReadBytes(ptr1, 0xB0);
if (det is null) continue;
var useStage = BitConverter.ToInt32(det, 0x08);
var castType = BitConverter.ToInt32(det, 0x0C);
// Show deep analysis for entries with non-zero UseStage + first 3
if (idx >= 3 && useStage == 0) continue;
sb.AppendLine($"\n── Entry [{idx}] UseStage={useStage} CastType={castType} ──");
// Dump ptr1 hex
sb.AppendLine($" ptr1=0x{ptr1:X} (ActiveSkillDetails):");
DumpAnnotatedQwords(sb, det, Math.Min(det.Length, 0xB0), " ");
// Follow GEPL from ptr1+0x18
var geplPtr = (nint)BitConverter.ToInt64(det, 0x18);
if (IsDatPtr(geplPtr))
{
var geplRow = _ctx.Memory.ReadBytes(geplPtr, 0x80);
if (geplRow is not null)
{
sb.AppendLine($"\n GEPL row at ptr1+0x18 (0x{geplPtr:X}):");
// Follow FK at GEPL+0x00 → GE row → name
var geFk = (nint)BitConverter.ToInt64(geplRow, 0x00);
if (IsDatPtr(geFk))
{
var geRow = _ctx.Memory.ReadBytes(geFk, 0xB0);
if (geRow is not null)
{
// Path 1: GE+0x00 → .dat row → wchar* name
var namePtr = (nint)BitConverter.ToInt64(geRow, 0x00);
if (IsDatPtr(namePtr))
{
var wname = _strings.ReadNullTermWString(namePtr);
if (wname is not null)
sb.AppendLine($" GEPL→GE→+0x00 → wchar* \"{wname}\"");
}
// Path 2: GE+0xA8 → ptr → +0x00 → wchar* name
if (0xA8 + 8 <= geRow.Length)
{
var nameObj = (nint)BitConverter.ToInt64(geRow, 0xA8);
if (IsDatPtr(nameObj))
{
var innerPtr = _ctx.Memory.ReadPointer(nameObj);
if (innerPtr != 0)
{
var wn2 = _strings.ReadNullTermWString(innerPtr);
if (wn2 is not null)
sb.AppendLine($" GEPL→GE→+0xA8→+0x00 → wchar* \"{wn2}\"");
}
}
}
}
}
}
}
// Also try ActiveSkillsDatPtr at ptr1+0x20
var asDatPtr = (nint)BitConverter.ToInt64(det, 0x20);
if (IsDatPtr(asDatPtr))
{
var wn = _strings.ReadNullTermWString(asDatPtr);
if (wn is not null)
sb.AppendLine($" ptr1+0x20 (ActiveSkillsDatPtr) → wchar* \"{wn}\"");
else
{
// Try following as pointer
var inner = _ctx.Memory.ReadPointer(asDatPtr);
if (inner != 0)
{
var wn2 = _strings.ReadNullTermWString(inner);
if (wn2 is not null)
sb.AppendLine($" ptr1+0x20→+0x00 → wchar* \"{wn2}\"");
}
}
}
}
return sb.ToString();
}
/// <summary>
/// Follows a .dat row pointer and searches for name strings (2 levels deep).
/// </summary>
private string? TryFindNameFromDatRow(nint datPtr)
{
var datRow = _ctx.Memory.ReadBytes(datPtr, 0x80);
if (datRow is null) return null;
// Check each qword in the .dat row for string pointers
for (var off = 0; off + 8 <= 0x80; off += 8)
{
var sp = (nint)BitConverter.ToInt64(datRow, off);
if (sp == 0) continue;
// Try as direct string pointer (heap)
if (IsValidHeapPtrForScan(sp))
{
var name = _strings.ReadCharPtr(sp);
if (name is not null && name.Length >= 2 && !name.Contains('/') && !name.Contains('\\')
&& !name.Contains('.'))
return name;
var wname = _strings.ReadNullTermWString(sp);
if (wname is not null && wname.Length >= 2)
return wname;
}
// Try as MODULE ptr (nested .dat reference) — follow one more level
if (_ctx.IsModuleAddress(sp))
{
var sub = _ctx.Memory.ReadBytes(sp, 0x40);
if (sub is null) continue;
for (var soff = 0; soff + 8 <= 0x40; soff += 8)
{
var sp2 = (nint)BitConverter.ToInt64(sub, soff);
if (!IsValidHeapPtrForScan(sp2)) continue;
var name = _strings.ReadCharPtr(sp2);
if (name is not null && name.Length >= 2 && !name.Contains('/') && !name.Contains('\\')
&& !name.Contains('.'))
return name;
}
}
}
return null;
}
/// <summary>
/// Dumps byte array as annotated qwords (pointer/int/float detection).
/// </summary>
private void DumpAnnotatedQwords(StringBuilder sb, byte[] data, int len, string indent)
{
for (var off = 0; off + 8 <= len && off < data.Length; off += 8)
{
var val = BitConverter.ToInt64(data, off);
var ptr = (nint)val;
var lo32 = BitConverter.ToInt32(data, off);
var hi32 = BitConverter.ToInt32(data, off + 4);
sb.Append($"{indent}+0x{off:X2}: ");
if (val == 0)
{
sb.AppendLine("0 (zero)");
}
else if (IsValidHeapPtrForScan(ptr) && !_ctx.IsModuleAddress(ptr))
{
sb.Append($"0x{val:X16} [HEAP]");
var name = _strings.ReadCharPtr(ptr);
if (name is not null) sb.Append($" → \"{name}\"");
sb.AppendLine();
}
else if (ptr != 0 && _ctx.IsModuleAddress(ptr))
{
sb.Append($"0x{val:X16} [MODULE]");
var rtti = _rtti.ResolveRttiName(ptr);
if (rtti is not null) sb.Append($" → {rtti}");
sb.AppendLine();
}
else if (hi32 == 0 && lo32 != 0)
{
sb.AppendLine($"0x{val:X16} int={lo32}");
}
else if (Math.Abs(lo32) < 100000 && Math.Abs(hi32) < 100000 && (lo32 != 0 || hi32 != 0))
{
sb.AppendLine($"0x{val:X16} lo={lo32} hi={hi32}");
}
else
{
sb.AppendLine($"0x{val:X16}");
}
}
}
private static bool IsValidHeapPtrForScan(nint ptr)
{
if (ptr == 0) return false;
var h = (ulong)ptr >> 32;
return h > 0 && h < 0x7FFF && (ptr & 0x3) == 0;
}
/// <summary>
/// Relaxed pointer check for .dat row pointers — no alignment requirement.
/// .dat rows can be at any byte offset within the file's memory-mapped region.
/// </summary>
private static bool IsDatPtr(nint ptr)
{
if (ptr == 0) return false;
var h = (ulong)ptr >> 32;
return h > 0 && h < 0x7FFF;
}
/// <summary>
/// Two-call differential scan of the Actor struct to discover ActionId/AnimationId offsets.
/// First click captures baseline (player idle), second click shows what changed (player attacking/moving).
/// Also follows AnimationControllerPtr and diffs the first 0x100 bytes of that object.
/// </summary>
public string ScanActorDiff()
{
if (_ctx.Memory is null) return "Error: not attached";
if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
var snap = new GameStateSnapshot();
var inGameState = _stateReader.ResolveInGameState(snap);
if (inGameState == 0) return "Error: InGameState not resolved";
var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
if (ingameData == 0) return "Error: AreaInstance not resolved";
var localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
if (localPlayer == 0) return "Error: LocalPlayer not resolved";
var actorComp = _components.GetComponentAddress(localPlayer, "Actor");
if (actorComp == 0) return "Error: Actor component not found";
// Read Actor — use larger size since hardcoded 0x2E8 may be too small
const int actorSize = 0x600;
var actorData = _ctx.Memory.ReadBytes(actorComp, actorSize);
if (actorData is null) return "Error: failed to read Actor data";
// Scan for AnimationController — look for heap pointers that deref to a vtable
nint animCtrlPtr = 0;
var animCtrlOffset = -1;
for (var off = 0x08; off + 8 <= actorSize; off += 8)
{
var val = (nint)BitConverter.ToInt64(actorData, off);
if (val == 0) continue;
var h = (ulong)val >> 32;
if (h == 0 || h >= 0x7FFF || (val & 0x3) != 0) continue;
if (_ctx.IsModuleAddress(val)) continue;
// Check if target has a vtable (first qword is module ptr)
var targetVtable = _ctx.Memory.ReadPointer(val);
if (targetVtable != 0 && _ctx.IsModuleAddress(targetVtable))
{
var rtti = _rtti.ResolveRttiName(targetVtable);
if (rtti is not null && rtti.Contains("Animation", StringComparison.OrdinalIgnoreCase))
{
animCtrlPtr = val;
animCtrlOffset = off;
break;
}
}
}
// Build regions to scan
var current = new Dictionary<string, (nint Address, byte[] Data)>
{
["Actor"] = (actorComp, actorData),
};
if (animCtrlPtr != 0)
{
var animData = _ctx.Memory.ReadBytes(animCtrlPtr, 0x100);
if (animData is not null)
current["AnimController"] = (animCtrlPtr, animData);
}
// Also try to read a few nearby monsters' Actor components for comparison
_entities.ReadEntities(snap, ingameData);
if (snap.Entities is { Count: > 0 })
{
var monsterCount = 0;
foreach (var e in snap.Entities)
{
if (e.Type != EntityType.Monster || !e.IsAlive) continue;
var monActorComp = _components.GetComponentAddress(e.Address, "Actor");
if (monActorComp == 0) continue;
var monActorData = _ctx.Memory.ReadBytes(monActorComp, actorSize);
if (monActorData is null) continue;
current[$"Monster_{monsterCount}_Actor"] = (monActorComp, monActorData);
if (animCtrlOffset >= 0 && animCtrlOffset + 8 <= actorSize)
{
var monAnimPtr = (nint)BitConverter.ToInt64(monActorData, animCtrlOffset);
if (monAnimPtr != 0)
{
var monAnimData = _ctx.Memory.ReadBytes(monAnimPtr, 0x100);
if (monAnimData is not null)
current[$"Monster_{monsterCount}_AnimCtrl"] = (monAnimPtr, monAnimData);
}
}
monsterCount++;
if (monsterCount >= 3) break;
}
}
// First click: save baseline
if (_actorDiffBaseline is null)
{
_actorDiffBaseline = current;
var sb = new StringBuilder();
sb.AppendLine("=== ACTOR DIFF — BASELINE CAPTURED ===");
sb.AppendLine($"Player Actor: 0x{actorComp:X} (scanning 0x{actorSize:X} bytes)");
sb.AppendLine($"AnimController: 0x{animCtrlPtr:X}" + (animCtrlOffset >= 0 ? $" (Actor+0x{animCtrlOffset:X})" : " (not found)"));
foreach (var (name, (addr, data)) in current)
sb.AppendLine($" {name}: 0x{addr:X} ({data.Length} bytes)");
sb.AppendLine();
sb.AppendLine("Now attack/move and click Actor Diff again.");
return sb.ToString();
}
// Second click: diff
var baseline = _actorDiffBaseline;
_actorDiffBaseline = null;
var result = new StringBuilder();
result.AppendLine("=== ACTOR DIFF — RESULTS ===");
result.AppendLine($"Player Actor: 0x{actorComp:X}");
result.AppendLine($"AnimController: 0x{animCtrlPtr:X}");
result.AppendLine();
var totalChanges = 0;
foreach (var (name, (curAddr, curData)) in current.OrderBy(kv => kv.Key))
{
if (!baseline.TryGetValue(name, out var baseEntry)) continue;
var (baseAddr, baseData) = baseEntry;
if (baseAddr != curAddr)
{
result.AppendLine($"── {name}: ADDRESS CHANGED 0x{baseAddr:X} → 0x{curAddr:X} ──");
totalChanges++;
continue;
}
var len = Math.Min(baseData.Length, curData.Length);
var changes = new List<string>();
for (var offset = 0; offset + 8 <= len; offset += 8)
{
var oldVal = BitConverter.ToInt64(baseData, offset);
var newVal = BitConverter.ToInt64(curData, offset);
if (oldVal == newVal) continue;
var line = $" +0x{offset:X3}: 0x{oldVal:X16} → 0x{newVal:X16}";
// Annotate small integer changes
var oldI1 = BitConverter.ToInt32(baseData, offset);
var newI1 = BitConverter.ToInt32(curData, offset);
var oldI2 = BitConverter.ToInt32(baseData, offset + 4);
var newI2 = BitConverter.ToInt32(curData, offset + 4);
if (Math.Abs(oldI1) < 0x10000 && Math.Abs(newI1) < 0x10000)
line += $" [lo32: {oldI1} → {newI1}]";
if (Math.Abs(oldI2) < 0x10000 && Math.Abs(newI2) < 0x10000)
line += $" [hi32: {oldI2} → {newI2}]";
// Annotate floats
var oldF1 = BitConverter.ToSingle(baseData, offset);
var newF1 = BitConverter.ToSingle(curData, offset);
var oldF2 = BitConverter.ToSingle(baseData, offset + 4);
var newF2 = BitConverter.ToSingle(curData, offset + 4);
if (float.IsFinite(oldF1) && float.IsFinite(newF1) && MathF.Abs(oldF1) < 1e6f && MathF.Abs(newF1) < 1e6f)
line += $" [float1: {oldF1:F4} → {newF1:F4}]";
if (float.IsFinite(oldF2) && float.IsFinite(newF2) && MathF.Abs(oldF2) < 1e6f && MathF.Abs(newF2) < 1e6f)
line += $" [float2: {oldF2:F4} → {newF2:F4}]";
changes.Add(line);
}
if (changes.Count == 0) continue;
totalChanges += changes.Count;
result.AppendLine($"── {name} (0x{curAddr:X}) — {changes.Count} changes ──");
foreach (var c in changes) result.AppendLine(c);
result.AppendLine();
}
if (totalChanges == 0)
result.AppendLine("(no changes — did you attack/move between clicks?)");
result.AppendLine();
result.AppendLine("Key regions: Actor+0x000-0x1D8 (before AnimCtrlPtr), AnimController+0x000-0x100");
result.AppendLine("Look for short/int changes — ActionId is likely a short or int near the top of Actor.");
result.AppendLine("Click again to capture a new baseline.");
return result.ToString();
}
}