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