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