using System.Drawing;
using System.Drawing.Imaging;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using Serilog;
namespace Roboto.Memory;
///
/// Diagnostic and scan methods extracted from GameMemoryReader.
/// All methods produce human-readable string output for the UI diagnostics panel.
///
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? _diffBaseline;
// Camera diff storage
private List<(string source, float sx, float sy, float dist, float[] floats)>? _cameraDiffBaseline;
// State object diff storage
private Dictionary? _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;
}
///
/// 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.
///
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();
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();
}
///
/// 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.
///
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
{
["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();
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();
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();
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();
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();
}
///
/// 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.
///
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();
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();
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();
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();
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();
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();
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();
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();
}
///
/// 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.
///
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();
// 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();
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();
// 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();
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();
}
///
/// Diagnostic: walks the entity std::map (red-black tree) from AreaInstance, reports RTTI types and positions.
///
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(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();
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(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();
}
///
/// Scans all game states and returns their info.
/// Supports both inline (POE1-style) and vector modes.
///
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();
}
///
/// Scans an object's memory and identifies all RTTI-typed sub-elements.
/// Groups by vtable to show the structure with class names.
///
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();
}
///
/// 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.
///
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();
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();
}
///
/// 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.
///
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();
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();
}
///
/// Diagnostic: shows the ECS vitals reading state — LocalPlayer, component list, cached Life index, current values.
///
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(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalTotalOffset);
var hpCurr = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalCurrentOffset);
var manaTotal = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalTotalOffset);
var manaCurr = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalCurrentOffset);
var esTotal = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeEsOffset + _ctx.Offsets.VitalTotalOffset);
var esCurr = _ctx.Memory.Read(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();
}
///
/// Diagnostic: dumps LocalPlayer entity structure, component list with RTTI names,
/// VitalStruct pattern matches, and ObjectHeader alternative path.
///
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(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalTotalOffset);
var hpCurr = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalCurrentOffset);
var manaTotal = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalTotalOffset);
var manaCurr = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalCurrentOffset);
var esTotal = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeEsOffset + _ctx.Offsets.VitalTotalOffset);
var esCurr = _ctx.Memory.Read(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(compPtr + _ctx.Offsets.PositionXOffset);
var py = _ctx.Memory.Read(compPtr + _ctx.Offsets.PositionYOffset);
var pz = _ctx.Memory.Read(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(cp + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalTotalOffset);
var hc = _ctx.Memory.Read(cp + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalCurrentOffset);
var mt = _ctx.Memory.Read(cp + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalTotalOffset);
var mc = _ctx.Memory.Read(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();
}
///
/// 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.
///
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();
var results = new List();
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();
}
///
/// 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.
///
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();
// 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(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(ptr + probeOff);
if (val > 0 && val <= 100)
{
score += 3;
details.Add($" {name} = {val} ✓✓✓");
}
}
else if (probeType == "int_count")
{
var val = _ctx.Memory.Read(ptr + probeOff);
if (val > 0 && val < 10000)
{
score += 2;
details.Add($" {name} = {val} ✓✓");
}
}
else if (probeType == "nonzero32")
{
var val = _ctx.Memory.Read(ptr + probeOff);
if (val != 0)
{
score++;
details.Add($" {name} = 0x{val:X8} ✓");
}
}
else if (probeType == "nonzero64")
{
var val = _ctx.Memory.Read(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(val + 0xAC);
var lvl2 = _ctx.Memory.Read(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();
}
///
/// 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.
///
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();
}
///
/// Diagnostic: finds terrain struct layout by scanning AreaInstance memory for dimensions, vectors, and grid data.
///
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 (10–500) ──
sb.AppendLine("═══ Dimension candidates (int32 pairs, 10–500) ═══");
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, 10–500) ═══");
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 50–5000) ═══");
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();
}
///
/// Raw explorer: parse hex address, follow offset chain, read as specified type.
///
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();
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(addr).ToString(),
"int64" => _ctx.Memory.Read(addr).ToString(),
"float" => _ctx.Memory.Read(addr).ToString("F4"),
"double" => _ctx.Memory.Read(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}'"
};
}
///
/// Scans a memory region and returns all pointer-like values with their offsets.
///
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 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();
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}\"");
}
}
}
}
}
///
/// 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.
///
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();
}
///
/// Camera diff — click once to capture baseline, move/zoom camera, click again to see diff.
/// Only shows candidates whose float values changed between captures.
///
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();
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(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();
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();
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? _actorDiffBaseline;
///
/// 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.
///
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();
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();
for (var off = 0; off < 0x100; off += 8)
{
var vals = new HashSet();
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();
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();
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();
var entryNames = new Dictionary(); // 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();
}
///
/// Follows a .dat row pointer and searches for name strings (2 levels deep).
///
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;
}
///
/// Dumps byte array as annotated qwords (pointer/int/float detection).
///
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;
}
///
/// 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.
///
private static bool IsDatPtr(nint ptr)
{
if (ptr == 0) return false;
var h = (ulong)ptr >> 32;
return h > 0 && h < 0x7FFF;
}
///
/// 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.
///
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
{
["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();
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();
}
}