diff --git a/Automata.sln b/Automata.sln
index 43fad29..a7d1e4f 100644
--- a/Automata.sln
+++ b/Automata.sln
@@ -45,6 +45,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Data", "lib\Sideki
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sidekick.Data.Builder", "lib\Sidekick\src\Sidekick.Data.Builder\Sidekick.Data.Builder.csproj", "{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "roboto", "roboto", "{D1A2B3C4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Core", "src\Roboto.Core\Roboto.Core.csproj", "{A31E6F94-A702-4B58-8317-83658E556B5C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Input", "src\Roboto.Input\Roboto.Input.csproj", "{E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Systems", "src\Roboto.Systems\Roboto.Systems.csproj", "{95AC4C34-26A0-4D7F-A712-375EB28B54B8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Engine", "src\Roboto.Engine\Roboto.Engine.csproj", "{C2E97306-20E4-4A69-A7AB-541A72614C76}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Navigation", "src\Roboto.Navigation\Roboto.Navigation.csproj", "{F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Roboto.Data", "src\Roboto.Data\Roboto.Data.csproj", "{1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -130,6 +144,30 @@ Global
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A31E6F94-A702-4B58-8317-83658E556B5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A31E6F94-A702-4B58-8317-83658E556B5C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A31E6F94-A702-4B58-8317-83658E556B5C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A31E6F94-A702-4B58-8317-83658E556B5C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {95AC4C34-26A0-4D7F-A712-375EB28B54B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {95AC4C34-26A0-4D7F-A712-375EB28B54B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {95AC4C34-26A0-4D7F-A712-375EB28B54B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {95AC4C34-26A0-4D7F-A712-375EB28B54B8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C2E97306-20E4-4A69-A7AB-541A72614C76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C2E97306-20E4-4A69-A7AB-541A72614C76}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C2E97306-20E4-4A69-A7AB-541A72614C76}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C2E97306-20E4-4A69-A7AB-541A72614C76}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{6432F6A5-11A0-4960-AFFC-E810D4325C35} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
@@ -151,5 +189,12 @@ Global
{8CEE036C-A229-4F22-BD0E-D7CDAE13E54F} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{9428D5D4-4061-467A-BD26-C1FEED95E8E6} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
{E5C26A34-5EDF-488B-93C7-F8738F2CEB97} = {652F700E-4F84-4E66-BD62-717D3A8D6FBC}
+ {D1A2B3C4-E5F6-7890-ABCD-EF1234567890} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}
+ {A31E6F94-A702-4B58-8317-83658E556B5C} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
+ {E61E96C5-3DE7-4B31-A7AD-CCA99BEF8E4A} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
+ {95AC4C34-26A0-4D7F-A712-375EB28B54B8} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
+ {C2E97306-20E4-4A69-A7AB-541A72614C76} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
+ {F4B5C6D7-E8F9-0A1B-2C3D-4E5F6A7B8C9D} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
+ {1A2B3C4D-5E6F-7A8B-9C0D-E1F2A3B4C5D6} = {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}
EndGlobalSection
EndGlobal
diff --git a/components.json b/components.json
index 92a83dd..e62d2d5 100644
--- a/components.json
+++ b/components.json
@@ -3,11 +3,14 @@
"Animated",
"AreaTransition",
"BaseEvents",
+ "Brackets",
"Buffs",
"Chest",
"ControlZone",
"CritterAI",
+ "DiesAfterTime",
"Functions",
+ "GlobalAudioParamEvents",
"HideoutDoodad",
"InteractionAction",
"Inventories",
diff --git a/entities.json b/entities.json
index bda2902..cc306b9 100644
--- a/entities.json
+++ b/entities.json
@@ -8,8 +8,13 @@
"Metadata/Characters/Str/StrFourb",
"Metadata/Characters/StrDex/StrDexFourb",
"Metadata/Characters/StrInt/StrIntFourb",
+ "Metadata/Chests/EzomyteChest_02",
"Metadata/Chests/EzomyteChest_05",
"Metadata/Chests/EzomyteChest_06",
+ "Metadata/Chests/LeagueIncursion/EncounterChest",
+ "Metadata/Chests/MossyChest11",
+ "Metadata/Chests/MossyChest20",
+ "Metadata/Chests/MossyChest21",
"Metadata/Chests/MossyChest26",
"Metadata/Critters/Chicken/Chicken_kingsmarch",
"Metadata/Critters/Hedgehog/HedgehogSlow",
@@ -19,13 +24,16 @@
"Metadata/Effects/Microtransactions/foot_prints/harvest02/footprints_harvest",
"Metadata/Effects/PermanentEffect",
"Metadata/Effects/ServerEffect",
+ "Metadata/Effects/Spells/monsters_effects/Act1_FOUR/CarrionCrone/IceSpike",
"Metadata/MiscellaneousObjects/AreaTransitionBlockage",
"Metadata/MiscellaneousObjects/AreaTransitionDoodad",
"Metadata/MiscellaneousObjects/AreaTransition_Animate",
"Metadata/MiscellaneousObjects/Checkpoint",
"Metadata/MiscellaneousObjects/Doodad",
+ "Metadata/MiscellaneousObjects/DoodadInvisible",
"Metadata/MiscellaneousObjects/DoodadNoBlocking",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_20_1",
+ "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_4.75_1",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_6_4",
"Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_4.75_1",
"Metadata/MiscellaneousObjects/GuildStash",
@@ -42,16 +50,22 @@
"Metadata/MiscellaneousObjects/Stash",
"Metadata/MiscellaneousObjects/Waypoint",
"Metadata/MiscellaneousObjects/WorldItem",
+ "Metadata/Monsters/Hags/Objects/BossRoomMinimapIcon",
"Metadata/Monsters/Hags/UrchinHag1",
+ "Metadata/Monsters/Hags/UrchinHagBoss",
+ "Metadata/Monsters/InvisibleFire/MDCarrionCroneWave",
"Metadata/Monsters/Urchins/MeleeUrchin1",
"Metadata/Monsters/Urchins/SlingUrchin1",
"Metadata/Monsters/Wolves/RottenWolf1_",
"Metadata/Monsters/Wolves/RottenWolfDead",
+ "Metadata/Monsters/Wolves/RottenWolfHagSummonedDead",
"Metadata/Monsters/Zombies/CourtGuardZombieUnarmed",
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxe",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxePhysics__",
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmed",
"Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmedPhysics",
"Metadata/NPC/Four_Act1/ClearfellPosting1",
+ "Metadata/NPC/Four_Act1/ClearfellPosting3",
"Metadata/NPC/Four_Act1/DogTrader_Entrance",
"Metadata/NPC/Four_Act1/ExecutionerFemaleNPCTown",
"Metadata/NPC/Four_Act1/EzomyteCivilianFemale01",
@@ -70,17 +84,31 @@
"Metadata/NPC/Four_Act1/UnaAfterIronCount",
"Metadata/NPC/Four_Act1/UnaHoodedOneInjured",
"Metadata/NPC/League/Incursion/AlvaIncursionWild",
+ "Metadata/Pet/BabyBossesHumans/BabyBrutus/BabyBrutus",
+ "Metadata/Pet/BabyChimera/BabyChimera",
+ "Metadata/Pet/BetaKiwis/BaronKiwi",
"Metadata/Pet/BetaKiwis/FaridunKiwi",
+ "Metadata/Pet/BookAndQuillPet/BookAndQuillPet_Abyss",
"Metadata/Pet/FledglingBellcrow/FledglingBellcrow",
+ "Metadata/Pet/LandSharkPet/LandSharkPet",
+ "Metadata/Pet/OctopusParasite/OctopusParasiteCelestial",
"Metadata/Pet/OrigamiPet/OrigamiPetBase",
+ "Metadata/Pet/Phoenix/PhoenixPetBlue",
"Metadata/Pet/Phoenix/PhoenixPetGreen",
"Metadata/Pet/Phoenix/PhoenixPetRed",
+ "Metadata/Pet/QuadrillaPet/QuadrillaArmoured",
+ "Metadata/Pet/ScavengerBat/ScavengerBat",
+ "Metadata/Projectiles/CarrionCroneIceSpear",
+ "Metadata/Projectiles/HagBossIceShard",
+ "Metadata/Projectiles/IceSpear",
"Metadata/Projectiles/SlingUrchinProjectile",
"Metadata/Projectiles/Twister",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull1_CountKilled",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull2",
"Metadata/Terrain/Doodads/Gallows/ClearfellBull2_CountKilled",
+ "Metadata/Terrain/Gallows/Act1/1_2/Objects/CampsiteChest",
+ "Metadata/Terrain/Gallows/Act1/1_2/Objects/CampsiteController",
"Metadata/Terrain/Gallows/Act1/1_2/Objects/RuleSet",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/Act1_finished_LightController",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBenchEzomyte",
@@ -88,6 +116,9 @@
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBench_EnableRendering",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_DisableRendering",
"Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/VisitedAct2_EnableRendering",
+ "Metadata/Terrain/Tools/AudioTools/G1_2/BurrowEntrance",
+ "Metadata/Terrain/Tools/AudioTools/G1_2/ForestEntrance",
+ "Metadata/Terrain/Tools/AudioTools/G1_2/HagArena",
"Metadata/Terrain/Tools/AudioTools/G1_2/RiverRapidsMedium",
"Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio",
"Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio"
diff --git a/offsets.json b/offsets.json
index 49ac354..4d6677b 100644
--- a/offsets.json
+++ b/offsets.json
@@ -51,18 +51,13 @@
"PositionXOffset": 312,
"PositionYOffset": 316,
"PositionZOffset": 320,
+ "CameraOffset": 776,
+ "CameraMatrixOffset": 416,
"TerrainListOffset": 3264,
"TerrainInline": true,
"TerrainDimensionsOffset": 144,
"TerrainWalkableGridOffset": 328,
"TerrainBytesPerRowOffset": 424,
"TerrainGridPtrOffset": 8,
- "SubTilesPerCell": 23,
- "InGameStateOffset": 0,
- "IngameDataOffset": 0,
- "TerrainDataOffset": 0,
- "NumColsOffset": 0,
- "NumRowsOffset": 0,
- "LayerMeleeOffset": 0,
- "BytesPerRowOffset": 0
+ "SubTilesPerCell": 23
}
\ No newline at end of file
diff --git a/src/Automata.Memory/ComponentReader.cs b/src/Automata.Memory/ComponentReader.cs
new file mode 100644
index 0000000..38c899d
--- /dev/null
+++ b/src/Automata.Memory/ComponentReader.cs
@@ -0,0 +1,382 @@
+using System.Text;
+using Serilog;
+
+namespace Automata.Memory;
+
+///
+/// Reads entity components via ECS: component list discovery, vitals, position, component lookup.
+///
+public sealed class ComponentReader
+{
+ private readonly MemoryContext _ctx;
+ private readonly MsvcStringReader _strings;
+
+ // Cached component indices — invalidated when LocalPlayer changes
+ private int _cachedLifeIndex = -1;
+ private int _cachedRenderIndex = -1;
+ private nint _lastLocalPlayer;
+
+ public ComponentReader(MemoryContext ctx, MsvcStringReader strings)
+ {
+ _ctx = ctx;
+ _strings = strings;
+ }
+
+ ///
+ /// Invalidates cached component indices when LocalPlayer entity changes (zone change, new character).
+ ///
+ public void InvalidateCaches(nint newLocalPlayer)
+ {
+ if (newLocalPlayer != _lastLocalPlayer)
+ {
+ _cachedLifeIndex = -1;
+ _cachedRenderIndex = -1;
+ _lastLocalPlayer = newLocalPlayer;
+ }
+ }
+
+ public int CachedLifeIndex => _cachedLifeIndex;
+ public nint LastLocalPlayer => _lastLocalPlayer;
+
+ ///
+ /// Finds the best component list from the entity, trying multiple strategies:
+ /// 1. StdVector at entity+ComponentListOffset (ExileCore standard)
+ /// 2. Inner entity: entity+0x000 → deref → StdVector at +ComponentListOffset (POE2 wrapper)
+ /// 3. Scan entity memory for any StdVector with many pointer-sized elements
+ ///
+ public (nint First, int Count) FindComponentList(nint entity)
+ {
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+
+ // Strategy 1: direct StdVector at entity+ComponentListOffset
+ var compFirst = mem.ReadPointer(entity + offsets.ComponentListOffset);
+ var compLast = mem.ReadPointer(entity + offsets.ComponentListOffset + 8);
+ var count = 0;
+ if (compFirst != 0 && compLast > compFirst && (compLast - compFirst) < 0x2000)
+ count = (int)((compLast - compFirst) / 8);
+
+ if (count > 1) return (compFirst, count);
+
+ // Strategy 2: POE2 may wrap entities — entity+0x000 is a pointer to the real entity
+ var innerEntity = mem.ReadPointer(entity);
+ if (innerEntity != 0 && innerEntity != entity && !_ctx.IsModuleAddress(innerEntity))
+ {
+ var high = (ulong)innerEntity >> 32;
+ if (high > 0 && high < 0x7FFF && (innerEntity & 0x3) == 0)
+ {
+ var innerFirst = mem.ReadPointer(innerEntity + offsets.ComponentListOffset);
+ var innerLast = mem.ReadPointer(innerEntity + offsets.ComponentListOffset + 8);
+ if (innerFirst != 0 && innerLast > innerFirst && (innerLast - innerFirst) < 0x2000)
+ {
+ var innerCount = (int)((innerLast - innerFirst) / 8);
+ if (innerCount > count)
+ {
+ Log.Debug("ECS: Using inner entity 0x{Addr:X} component list ({Count} entries)",
+ innerEntity, innerCount);
+ return (innerFirst, innerCount);
+ }
+ }
+ }
+ }
+
+ // Strategy 3: scan entity memory for StdVector patterns with ≥3 pointer-sized elements
+ var entityData = mem.ReadBytes(entity, 0x300);
+ if (entityData is not null)
+ {
+ for (var off = 0; off + 24 <= entityData.Length; off += 8)
+ {
+ var f = (nint)BitConverter.ToInt64(entityData, off);
+ var l = (nint)BitConverter.ToInt64(entityData, off + 8);
+ if (f == 0 || l <= f) continue;
+ var sz = l - f;
+ if (sz < 24 || sz > 0x2000 || sz % 8 != 0) continue;
+ var n = (int)(sz / 8);
+ if (n <= count) continue;
+
+ var firstEl = mem.ReadPointer(f);
+ var h = (ulong)firstEl >> 32;
+ if (h == 0 || h >= 0x7FFF || (firstEl & 0x3) != 0) continue;
+
+ Log.Debug("ECS: Found StdVector at entity+0x{Off:X} with {Count} elements", off, n);
+ if (n > count) { compFirst = f; count = n; }
+ }
+ }
+
+ return (compFirst, count);
+ }
+
+ ///
+ /// Reads vitals via ECS: LocalPlayer → ComponentList → Life component.
+ /// Auto-discovers the Life component index, caches it.
+ ///
+ public void ReadPlayerVitals(GameStateSnapshot snap)
+ {
+ var entity = snap.LocalPlayerPtr;
+ if (entity == 0) return;
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+
+ var (compFirst, count) = FindComponentList(entity);
+ if (count <= 0) return;
+
+ // Try cached index first
+ if (_cachedLifeIndex >= 0 && _cachedLifeIndex < count)
+ {
+ var lifeComp = mem.ReadPointer(compFirst + _cachedLifeIndex * 8);
+ if (lifeComp != 0 && TryReadVitals(snap, lifeComp))
+ return;
+ _cachedLifeIndex = -1;
+ }
+
+ // Scan all component pointers for VitalStruct pattern
+ for (var i = 0; i < count; i++)
+ {
+ var compPtr = mem.ReadPointer(compFirst + i * 8);
+ if (compPtr == 0) continue;
+
+ var high = (ulong)compPtr >> 32;
+ if (high == 0 || high >= 0x7FFF) continue;
+ if ((compPtr & 0x3) != 0) continue;
+
+ var hpTotal = mem.Read(compPtr + offsets.LifeHealthOffset + offsets.VitalTotalOffset);
+ if (hpTotal < 20 || hpTotal > 200000) continue;
+
+ var hpCurrent = mem.Read(compPtr + offsets.LifeHealthOffset + offsets.VitalCurrentOffset);
+ if (hpCurrent < 0 || hpCurrent > hpTotal + 1000) continue;
+
+ var manaTotal = mem.Read(compPtr + offsets.LifeManaOffset + offsets.VitalTotalOffset);
+ if (manaTotal < 0 || manaTotal > 200000) continue;
+
+ var manaCurrent = mem.Read(compPtr + offsets.LifeManaOffset + offsets.VitalCurrentOffset);
+ if (manaCurrent < 0 || manaCurrent > manaTotal + 1000) continue;
+
+ var esTotal = mem.Read(compPtr + offsets.LifeEsOffset + offsets.VitalTotalOffset);
+ if (manaTotal == 0 && esTotal == 0) continue;
+
+ _cachedLifeIndex = i;
+ Log.Information("ECS: Life component at index {Index} (0x{Addr:X}) — HP: {Hp}/{HpMax}, Mana: {Mana}/{ManaMax}",
+ i, compPtr, hpCurrent, hpTotal, manaCurrent, manaTotal);
+ TryReadVitals(snap, compPtr);
+ return;
+ }
+ }
+
+ ///
+ /// Attempts to read all vitals from a Life component pointer.
+ ///
+ public bool TryReadVitals(GameStateSnapshot snap, nint lifeComp)
+ {
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+
+ var hp = mem.Read(lifeComp + offsets.LifeHealthOffset + offsets.VitalCurrentOffset);
+ var hpMax = mem.Read(lifeComp + offsets.LifeHealthOffset + offsets.VitalTotalOffset);
+ var mana = mem.Read(lifeComp + offsets.LifeManaOffset + offsets.VitalCurrentOffset);
+ var manaMax = mem.Read(lifeComp + offsets.LifeManaOffset + offsets.VitalTotalOffset);
+ var es = mem.Read(lifeComp + offsets.LifeEsOffset + offsets.VitalCurrentOffset);
+ var esMax = mem.Read(lifeComp + offsets.LifeEsOffset + offsets.VitalTotalOffset);
+
+ if (hpMax <= 0 || hpMax > 200000 || hp < 0 || hp > hpMax + 1000) return false;
+ if (manaMax < 0 || manaMax > 200000 || mana < 0) return false;
+
+ snap.HasVitals = true;
+ snap.LifeCurrent = hp;
+ snap.LifeTotal = hpMax;
+ snap.ManaCurrent = mana;
+ snap.ManaTotal = manaMax;
+ snap.EsCurrent = es;
+ snap.EsTotal = esMax;
+ return true;
+ }
+
+ ///
+ /// Reads player position from the Render component via ECS.
+ /// Auto-discovers the Render component index, caches it.
+ ///
+ public void ReadPlayerPosition(GameStateSnapshot snap)
+ {
+ var entity = snap.LocalPlayerPtr;
+ if (entity == 0) return;
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+
+ var (compFirst, count) = FindComponentList(entity);
+ if (count <= 0) return;
+
+ // Try configured index first
+ if (offsets.RenderComponentIndex >= 0 && offsets.RenderComponentIndex < count)
+ {
+ var renderComp = mem.ReadPointer(compFirst + offsets.RenderComponentIndex * 8);
+ if (renderComp != 0 && TryReadPosition(snap, renderComp))
+ return;
+ }
+
+ // Try cached index
+ if (_cachedRenderIndex >= 0 && _cachedRenderIndex < count)
+ {
+ var renderComp = mem.ReadPointer(compFirst + _cachedRenderIndex * 8);
+ if (renderComp != 0 && TryReadPosition(snap, renderComp))
+ return;
+ _cachedRenderIndex = -1;
+ }
+
+ // Auto-discover: scan for float triplet that looks like world coordinates
+ for (var i = 0; i < count; i++)
+ {
+ if (i == _cachedLifeIndex) continue;
+
+ var compPtr = mem.ReadPointer(compFirst + i * 8);
+ if (compPtr == 0) continue;
+ var high = (ulong)compPtr >> 32;
+ if (high == 0 || high >= 0x7FFF) continue;
+ if ((compPtr & 0x3) != 0) continue;
+
+ if (TryReadPosition(snap, compPtr))
+ {
+ _cachedRenderIndex = i;
+ Log.Information("ECS: Render component at index {Index} (0x{Addr:X}) — Pos: ({X:F1}, {Y:F1}, {Z:F1})",
+ i, compPtr, snap.PlayerX, snap.PlayerY, snap.PlayerZ);
+ return;
+ }
+ }
+ }
+
+ ///
+ /// Attempts to read position from a Render component pointer.
+ ///
+ public bool TryReadPosition(GameStateSnapshot snap, nint renderComp)
+ {
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+
+ var x = mem.Read(renderComp + offsets.PositionXOffset);
+ var y = mem.Read(renderComp + offsets.PositionYOffset);
+ var z = mem.Read(renderComp + offsets.PositionZOffset);
+
+ if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) return false;
+ if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) return false;
+ if (x < 50 || x > 50000 || y < 50 || y > 50000) return false;
+ if (MathF.Abs(z) > 5000) return false;
+
+ snap.HasPosition = true;
+ snap.PlayerX = x;
+ snap.PlayerY = y;
+ snap.PlayerZ = z;
+ return true;
+ }
+
+ ///
+ /// Reads position floats and validates as world coordinates (for entity position reading).
+ ///
+ public bool TryReadPositionRaw(nint comp, out float x, out float y, out float z)
+ {
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+
+ x = mem.Read(comp + offsets.PositionXOffset);
+ y = mem.Read(comp + offsets.PositionYOffset);
+ z = mem.Read(comp + offsets.PositionZOffset);
+
+ if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) return false;
+ if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) return false;
+ if (x < 50 || x > 50000 || y < 50 || y > 50000) return false;
+ if (MathF.Abs(z) > 5000) return false;
+ return true;
+ }
+
+ ///
+ /// Resolves EntityDetails pointer for an entity, handling ECS inner entity wrapper.
+ ///
+ public nint ResolveEntityDetails(nint entity)
+ {
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+
+ var detailsPtr = mem.ReadPointer(entity + offsets.EntityHeaderOffset);
+ if (_ctx.IsValidHeapPtr(detailsPtr))
+ return detailsPtr;
+
+ var innerEntity = mem.ReadPointer(entity);
+ if (innerEntity == 0 || innerEntity == entity || _ctx.IsModuleAddress(innerEntity))
+ return 0;
+ if (!_ctx.IsValidHeapPtr(innerEntity))
+ return 0;
+
+ detailsPtr = mem.ReadPointer(innerEntity + offsets.EntityHeaderOffset);
+ return _ctx.IsValidHeapPtr(detailsPtr) ? detailsPtr : 0;
+ }
+
+ ///
+ /// Reads the component name→index mapping for an entity.
+ /// Chain: entity → EntityDetails(+0x28) → ComponentLookup obj(+0x28/+0x30) → Vec2 entries.
+ ///
+ public Dictionary? ReadComponentLookup(nint entity)
+ {
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+ if (offsets.ComponentLookupEntrySize == 0) return null;
+
+ var detailsPtr = ResolveEntityDetails(entity);
+ if (detailsPtr == 0) return null;
+
+ var lookupObj = mem.ReadPointer(detailsPtr + offsets.ComponentLookupOffset);
+ if (!_ctx.IsValidHeapPtr(lookupObj)) return null;
+
+ var vec2Begin = mem.ReadPointer(lookupObj + offsets.ComponentLookupVec2Offset);
+ var vec2End = mem.ReadPointer(lookupObj + offsets.ComponentLookupVec2Offset + 8);
+ if (vec2Begin == 0 || vec2End <= vec2Begin) return null;
+
+ var size = vec2End - vec2Begin;
+ var entrySize = offsets.ComponentLookupEntrySize;
+ if (size % entrySize != 0 || size > 0x10000) return null;
+
+ var entryCount = (int)(size / entrySize);
+ var allData = mem.ReadBytes(vec2Begin, (int)size);
+ if (allData is null) return null;
+
+ var result = new Dictionary(entryCount);
+ for (var i = 0; i < entryCount; i++)
+ {
+ var entryOff = i * entrySize;
+ var namePtr = (nint)BitConverter.ToInt64(allData, entryOff + offsets.ComponentLookupNameOffset);
+ if (namePtr == 0) continue;
+
+ var name = _strings.ReadCharPtr(namePtr);
+ if (name is null) continue;
+
+ var index = BitConverter.ToInt32(allData, entryOff + offsets.ComponentLookupIndexOffset);
+ if (index < 0 || index > 200) continue;
+
+ result[name] = index;
+ }
+
+ return result.Count > 0 ? result : null;
+ }
+
+ ///
+ /// Checks if an entity has a component by name.
+ ///
+ public bool HasComponent(nint entity, string componentName)
+ {
+ var lookup = ReadComponentLookup(entity);
+ return lookup?.ContainsKey(componentName) == true;
+ }
+
+ ///
+ /// Gets the component pointer by name from an entity's component list.
+ ///
+ public nint GetComponentAddress(nint entity, string componentName)
+ {
+ var lookup = ReadComponentLookup(entity);
+ if (lookup is null || !lookup.TryGetValue(componentName, out var index))
+ return 0;
+
+ var (compFirst, count) = FindComponentList(entity);
+ if (index < 0 || index >= count) return 0;
+
+ return _ctx.Memory.ReadPointer(compFirst + index * 8);
+ }
+}
diff --git a/src/Automata.Memory/EntityReader.cs b/src/Automata.Memory/EntityReader.cs
new file mode 100644
index 0000000..b2bd490
--- /dev/null
+++ b/src/Automata.Memory/EntityReader.cs
@@ -0,0 +1,200 @@
+using Serilog;
+
+namespace Automata.Memory;
+
+///
+/// Reads entity list from AreaInstance's std::map red-black tree.
+///
+public sealed class EntityReader
+{
+ private readonly MemoryContext _ctx;
+ private readonly ComponentReader _components;
+ private readonly MsvcStringReader _strings;
+
+ public EntityReader(MemoryContext ctx, ComponentReader components, MsvcStringReader strings)
+ {
+ _ctx = ctx;
+ _components = components;
+ _strings = strings;
+ }
+
+ ///
+ /// Reads entity list into the snapshot for continuous display.
+ ///
+ public void ReadEntities(GameStateSnapshot snap, nint areaInstance)
+ {
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+ var registry = _ctx.Registry;
+
+ var sentinel = mem.ReadPointer(areaInstance + offsets.EntityListOffset);
+ if (sentinel == 0) return;
+
+ var root = mem.ReadPointer(sentinel + offsets.EntityNodeParentOffset);
+ var entities = new List();
+ var maxNodes = Math.Min(snap.EntityCount + 10, 500);
+ var hasComponentLookup = offsets.ComponentLookupEntrySize > 0;
+ var dirty = false;
+
+ WalkTreeInOrder(sentinel, root, maxNodes, node =>
+ {
+ var entityPtr = mem.ReadPointer(node + offsets.EntityNodeValueOffset);
+ if (entityPtr == 0) return;
+
+ var high = (ulong)entityPtr >> 32;
+ if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
+
+ var entityId = mem.Read(entityPtr + offsets.EntityIdOffset);
+ var path = TryReadEntityPath(entityPtr);
+
+ var entity = new Entity(entityPtr, entityId, path);
+
+ if (registry["entities"].Register(entity.Metadata))
+ dirty = true;
+
+ if (TryReadEntityPosition(entityPtr, out var x, out var y, out var z))
+ {
+ entity.HasPosition = true;
+ entity.X = x;
+ entity.Y = y;
+ entity.Z = z;
+ }
+
+ // Read component names for non-trivial entities
+ if (hasComponentLookup &&
+ entity.Type != EntityType.Effect &&
+ entity.Type != EntityType.Terrain &&
+ entity.Type != EntityType.Critter)
+ {
+ var lookup = _components.ReadComponentLookup(entityPtr);
+ if (lookup is not null)
+ {
+ entity.Components = new HashSet(lookup.Keys);
+ entity.ReclassifyFromComponents();
+
+ if (registry["components"].Register(lookup.Keys))
+ dirty = true;
+
+ // Read HP for monsters to determine alive/dead
+ if (entity.Type == EntityType.Monster && lookup.TryGetValue("Life", out var lifeIdx))
+ {
+ var (compFirst, compCount) = _components.FindComponentList(entityPtr);
+ if (lifeIdx >= 0 && lifeIdx < compCount)
+ {
+ var lifeComp = mem.ReadPointer(compFirst + lifeIdx * 8);
+ if (lifeComp != 0)
+ {
+ var hp = mem.Read(lifeComp + offsets.LifeHealthOffset + offsets.VitalCurrentOffset);
+ var hpMax = mem.Read(lifeComp + offsets.LifeHealthOffset + offsets.VitalTotalOffset);
+ if (hpMax > 0 && hpMax < 200000 && hp >= 0 && hp <= hpMax + 1000)
+ {
+ entity.HasVitals = true;
+ entity.LifeCurrent = hp;
+ entity.LifeTotal = hpMax;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ entities.Add(entity);
+ });
+
+ if (dirty)
+ registry.Flush();
+
+ snap.Entities = entities;
+ }
+
+ ///
+ /// Iterative in-order traversal of an MSVC std::map red-black tree.
+ ///
+ public void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action visitor)
+ {
+ if (root == 0 || root == sentinel) return;
+
+ var offsets = _ctx.Offsets;
+ var mem = _ctx.Memory;
+ var stack = new Stack();
+ var current = root;
+ var count = 0;
+ var visited = new HashSet { sentinel };
+
+ while ((current != sentinel && current != 0) || stack.Count > 0)
+ {
+ while (current != sentinel && current != 0)
+ {
+ if (!visited.Add(current))
+ {
+ current = sentinel;
+ break;
+ }
+ stack.Push(current);
+ current = mem.ReadPointer(current + offsets.EntityNodeLeftOffset);
+ }
+
+ if (stack.Count == 0) break;
+
+ current = stack.Pop();
+ visitor(current);
+ count++;
+ if (count >= maxNodes) break;
+
+ current = mem.ReadPointer(current + offsets.EntityNodeRightOffset);
+ }
+ }
+
+ ///
+ /// Reads entity path string via EntityDetailsPtr → std::wstring.
+ ///
+ public string? TryReadEntityPath(nint entity)
+ {
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+
+ var detailsPtr = mem.ReadPointer(entity + offsets.EntityDetailsOffset);
+ if (detailsPtr == 0) return null;
+
+ var high = (ulong)detailsPtr >> 32;
+ if (high == 0 || high >= 0x7FFF) return null;
+
+ return _strings.ReadMsvcWString(detailsPtr + offsets.EntityPathStringOffset);
+ }
+
+ ///
+ /// Tries to read position from an entity by scanning its component list for the Render component.
+ ///
+ public bool TryReadEntityPosition(nint entity, out float x, out float y, out float z)
+ {
+ x = y = z = 0;
+ var offsets = _ctx.Offsets;
+
+ var (compFirst, count) = _components.FindComponentList(entity);
+ if (count <= 0) return false;
+
+ // If we know the Render component index, try it directly
+ if (offsets.RenderComponentIndex >= 0 && offsets.RenderComponentIndex < count)
+ {
+ var renderComp = _ctx.Memory.ReadPointer(compFirst + offsets.RenderComponentIndex * 8);
+ if (renderComp != 0 && _components.TryReadPositionRaw(renderComp, out x, out y, out z))
+ return true;
+ }
+
+ // Scan components (limit to avoid performance issues with many entities)
+ var scanLimit = Math.Min(count, 20);
+ for (var i = 0; i < scanLimit; i++)
+ {
+ var compPtr = _ctx.Memory.ReadPointer(compFirst + i * 8);
+ if (compPtr == 0) continue;
+ var high = (ulong)compPtr >> 32;
+ if (high == 0 || high >= 0x7FFF) continue;
+ if ((compPtr & 0x3) != 0) continue;
+
+ if (_components.TryReadPositionRaw(compPtr, out x, out y, out z))
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/Automata.Memory/GameMemoryReader.cs b/src/Automata.Memory/GameMemoryReader.cs
index e5757cd..fc9e0ab 100644
--- a/src/Automata.Memory/GameMemoryReader.cs
+++ b/src/Automata.Memory/GameMemoryReader.cs
@@ -1,74 +1,8 @@
-using System.Drawing;
-using System.Drawing.Imaging;
-using System.Globalization;
-using System.Runtime.InteropServices;
-using System.Text;
+using System.Numerics;
using Serilog;
namespace Automata.Memory;
-public class GameStateSnapshot
-{
- // Process
- public bool Attached;
- public int ProcessId;
- public nint ModuleBase;
- public int ModuleSize;
- public string? Error;
-
- // GameState
- public nint GameStateBase;
- public bool OffsetsConfigured;
- public int StatesCount;
-
- // Pointers
- public nint ControllerPtr;
- public nint InGameStatePtr;
- public nint AreaInstancePtr;
- public nint ServerDataPtr;
- public nint LocalPlayerPtr;
-
- // Area
- public int AreaLevel;
- public uint AreaHash;
-
- // Player position (Render component)
- public bool HasPosition;
- public float PlayerX, PlayerY, PlayerZ;
-
- // Player vitals (Life component)
- public bool HasVitals;
- public int LifeCurrent, LifeTotal;
- public int ManaCurrent, ManaTotal;
- public int EsCurrent, EsTotal;
-
- // Entities
- public int EntityCount;
- public List? Entities;
-
- // Loading state
- public bool IsLoading;
- public bool IsEscapeOpen;
-
- // Live state flags — individual bytes from InGameState+0x200 region
- public byte[]? StateFlagBytes; // raw bytes from InGameState+0x200, length 0x30
- public int StateFlagBaseOffset = 0x200;
-
- // Active game states (ExileCore state machine)
- public nint[] StateSlots = Array.Empty(); // State[0]..State[N] pointer values (all 12 slots)
- public int[]? StateSlotValues; // int32 at state+0x08 for each slot
- public HashSet ActiveStates = new(); // which state pointers are in the active list
- public nint ActiveStatesBegin, ActiveStatesEnd; // debug: raw vector pointers
- public nint[] ActiveStatesRaw = Array.Empty(); // debug: all pointers in the vector
- public (int Offset, nint Value)[] WatchOffsets = []; // candidate controller offsets
-
- // Terrain
- public int TerrainWidth, TerrainHeight;
- public int TerrainCols, TerrainRows;
- public WalkabilityGrid? Terrain;
- public int TerrainWalkablePercent;
-}
-
public class GameMemoryReader : IDisposable
{
// ExileCore state slot names (index → name)
@@ -88,100 +22,117 @@ public class GameMemoryReader : IDisposable
"Loading", // 11
];
- private ProcessMemory? _memory;
- private PatternScanner? _scanner;
- private readonly TerrainOffsets _offsets;
+ private readonly GameOffsets _offsets;
private readonly ObjectRegistry _registry;
- private nint _moduleBase;
- private int _moduleSize;
- private nint _gameStateBase;
private bool _disposed;
- private int _cachedLifeIndex = -1;
- private int _cachedRenderIndex = -1;
- private nint _lastLocalPlayer;
- private uint _cachedTerrainAreaHash;
- private WalkabilityGrid? _cachedTerrain;
- private bool _wasLoading;
- // Memory diff scan storage
- private Dictionary? _diffBaseline;
+
+ // Sub-readers (created on Attach)
+ private MemoryContext? _ctx;
+ private GameStateReader? _stateReader;
+ private ComponentReader? _components;
+ private EntityReader? _entities;
+ private TerrainReader? _terrain;
+ private MsvcStringReader? _strings;
+ private RttiResolver? _rtti;
public ObjectRegistry Registry => _registry;
+ public MemoryDiagnostics? Diagnostics { get; private set; }
public GameMemoryReader()
{
- _offsets = TerrainOffsets.Load("offsets.json");
+ _offsets = GameOffsets.Load("offsets.json");
_registry = new ObjectRegistry();
}
- public bool IsAttached => _memory != null;
+ public bool IsAttached => _ctx != null;
public bool Attach()
{
Detach();
- _memory = ProcessMemory.Attach(_offsets.ProcessName);
- if (_memory is null)
+ var memory = ProcessMemory.Attach(_offsets.ProcessName);
+ if (memory is null)
return false;
- var module = _memory.GetMainModule();
+ _ctx = new MemoryContext(memory, _offsets, _registry);
+
+ var module = memory.GetMainModule();
if (module is not null)
- (_moduleBase, _moduleSize) = module.Value;
+ {
+ _ctx.ModuleBase = module.Value.Base;
+ _ctx.ModuleSize = module.Value.Size;
+ }
// Try pattern scan first
if (!string.IsNullOrWhiteSpace(_offsets.GameStatePattern))
{
- _scanner = new PatternScanner(_memory);
- _gameStateBase = _scanner.FindPatternRip(_offsets.GameStatePattern);
- if (_gameStateBase != 0)
+ var scanner = new PatternScanner(memory);
+ _ctx.GameStateBase = scanner.FindPatternRip(_offsets.GameStatePattern);
+ if (_ctx.GameStateBase != 0)
{
- _gameStateBase += _offsets.PatternResultAdjust;
- Log.Information("GameState base (pattern+adjust): 0x{Address:X}", _gameStateBase);
+ _ctx.GameStateBase += _offsets.PatternResultAdjust;
+ Log.Information("GameState base (pattern+adjust): 0x{Address:X}", _ctx.GameStateBase);
}
}
// Fallback: manual offset from module base
- if (_gameStateBase == 0 && _offsets.GameStateGlobalOffset > 0)
+ if (_ctx.GameStateBase == 0 && _offsets.GameStateGlobalOffset > 0)
{
- _gameStateBase = _moduleBase + _offsets.GameStateGlobalOffset;
- Log.Information("GameState base (manual): 0x{Address:X}", _gameStateBase);
+ _ctx.GameStateBase = _ctx.ModuleBase + _offsets.GameStateGlobalOffset;
+ Log.Information("GameState base (manual): 0x{Address:X}", _ctx.GameStateBase);
}
+ // Create sub-readers
+ _strings = new MsvcStringReader(_ctx);
+ _rtti = new RttiResolver(_ctx);
+ _stateReader = new GameStateReader(_ctx);
+ _components = new ComponentReader(_ctx, _strings);
+ _entities = new EntityReader(_ctx, _components, _strings);
+ _terrain = new TerrainReader(_ctx);
+ Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
+
return true;
}
public void Detach()
{
- _memory?.Dispose();
- _memory = null;
- _scanner = null;
- _gameStateBase = 0;
- _moduleBase = 0;
- _moduleSize = 0;
+ _ctx?.Memory.Dispose();
+ _ctx = null;
+ _stateReader = null;
+ _components = null;
+ _entities = null;
+ _terrain = null;
+ _strings = null;
+ _rtti = null;
+ Diagnostics = null;
}
public GameStateSnapshot ReadSnapshot()
{
var snap = new GameStateSnapshot();
- if (_memory is null)
+ if (_ctx is null)
{
snap.Error = "Not attached";
return snap;
}
- snap.Attached = true;
- snap.ProcessId = _memory.ProcessId;
- snap.ModuleBase = _moduleBase;
- snap.ModuleSize = _moduleSize;
- snap.OffsetsConfigured = _gameStateBase != 0;
- snap.GameStateBase = _gameStateBase;
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
- if (_gameStateBase == 0)
+ snap.Attached = true;
+ snap.ProcessId = mem.ProcessId;
+ snap.ModuleBase = _ctx.ModuleBase;
+ snap.ModuleSize = _ctx.ModuleSize;
+ snap.OffsetsConfigured = _ctx.GameStateBase != 0;
+ snap.GameStateBase = _ctx.GameStateBase;
+
+ if (_ctx.GameStateBase == 0)
return snap;
- // Static area level — direct module offset, always reliable (CE: exe+3E84B78)
- if (_offsets.AreaLevelStaticOffset > 0 && _moduleBase != 0)
+ // Static area level — direct module offset, always reliable
+ if (offsets.AreaLevelStaticOffset > 0 && _ctx.ModuleBase != 0)
{
- var level = _memory.Read(_moduleBase + _offsets.AreaLevelStaticOffset);
+ var level = mem.Read(_ctx.ModuleBase + offsets.AreaLevelStaticOffset);
if (level > 0 && level < 200)
snap.AreaLevel = level;
}
@@ -189,83 +140,79 @@ public class GameMemoryReader : IDisposable
try
{
// Resolve InGameState from controller
- var inGameState = ResolveInGameState(snap);
+ var inGameState = _stateReader!.ResolveInGameState(snap);
if (inGameState == 0)
return snap;
snap.InGameStatePtr = inGameState;
- // Read all state slot pointers + candidate offsets for live state tracking
- ReadStateSlots(snap);
+ // Read all state slot pointers
+ _stateReader.ReadStateSlots(snap);
- // InGameState → AreaInstance (dump: InGameStateOffset.AreaInstanceData at +0x298)
- var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
+ // InGameState → AreaInstance
+ var ingameData = mem.ReadPointer(inGameState + offsets.IngameDataFromStateOffset);
snap.AreaInstancePtr = ingameData;
if (ingameData != 0)
{
- // Area level (dump: byte at +0xAC)
- if (_offsets.AreaLevelIsByte)
+ // Area level
+ if (offsets.AreaLevelIsByte)
{
- var level = _memory.Read(ingameData + _offsets.AreaLevelOffset);
+ var level = mem.Read(ingameData + offsets.AreaLevelOffset);
if (level > 0 && level < 200)
snap.AreaLevel = level;
}
else
{
- var level = _memory.Read(ingameData + _offsets.AreaLevelOffset);
+ var level = mem.Read(ingameData + offsets.AreaLevelOffset);
if (level > 0 && level < 200)
snap.AreaLevel = level;
}
- // Area hash (dump: 0xEC)
- snap.AreaHash = _memory.Read(ingameData + _offsets.AreaHashOffset);
+ // Area hash
+ snap.AreaHash = mem.Read(ingameData + offsets.AreaHashOffset);
- // ServerData pointer (dump: via LocalPlayerStruct at +0x9F0)
- var serverData = _memory.ReadPointer(ingameData + _offsets.ServerDataOffset);
+ // ServerData pointer
+ var serverData = mem.ReadPointer(ingameData + offsets.ServerDataOffset);
snap.ServerDataPtr = serverData;
- // LocalPlayer — try direct offset first (dump: +0xA10), fallback to ServerData chain
- if (_offsets.LocalPlayerDirectOffset > 0)
- snap.LocalPlayerPtr = _memory.ReadPointer(ingameData + _offsets.LocalPlayerDirectOffset);
-
+ // LocalPlayer — try direct offset first, fallback to ServerData chain
+ if (offsets.LocalPlayerDirectOffset > 0)
+ snap.LocalPlayerPtr = mem.ReadPointer(ingameData + offsets.LocalPlayerDirectOffset);
if (snap.LocalPlayerPtr == 0 && serverData != 0)
- snap.LocalPlayerPtr = _memory.ReadPointer(serverData + _offsets.LocalPlayerOffset);
+ snap.LocalPlayerPtr = mem.ReadPointer(serverData + offsets.LocalPlayerOffset);
- // Entity count — dump: EntityListStruct contains StdMap, count at StdMap+0x08 (_Mysize)
- var entityCount = (int)_memory.Read(ingameData + _offsets.EntityListOffset + _offsets.EntityCountInternalOffset);
+ // Entity count and list
+ var entityCount = (int)mem.Read(ingameData + offsets.EntityListOffset + offsets.EntityCountInternalOffset);
if (entityCount > 0 && entityCount < 50000)
{
snap.EntityCount = entityCount;
- ReadEntities(snap, ingameData);
+ _entities!.ReadEntities(snap, ingameData);
}
- // Player vitals & position — ECS: LocalPlayer → ComponentList → Life/Render components
+ // Player vitals & position — ECS
if (snap.LocalPlayerPtr != 0)
{
- // Invalidate caches if LocalPlayer entity changed (zone change, new character)
- if (snap.LocalPlayerPtr != _lastLocalPlayer)
- {
- _cachedLifeIndex = -1;
- _cachedRenderIndex = -1;
- _cachedTerrain = null;
- _cachedTerrainAreaHash = 0;
- _lastLocalPlayer = snap.LocalPlayerPtr;
- }
-
- ReadVitalsEcs(snap);
- ReadPlayerPosition(snap);
+ // Invalidate caches if LocalPlayer entity changed (zone change)
+ if (snap.LocalPlayerPtr != _components!.LastLocalPlayer)
+ _terrain!.InvalidateCache();
+ _components.InvalidateCaches(snap.LocalPlayerPtr);
+ _components.ReadPlayerVitals(snap);
+ _components.ReadPlayerPosition(snap);
}
- // AreaLoadingState
- ReadIsLoading(snap);
- ReadEscapeState(snap);
+ // Camera matrix
+ ReadCameraMatrix(snap, inGameState);
- // Read state flag bytes from InGameState for live display
+ // Loading and escape state
+ _stateReader.ReadIsLoading(snap);
+ _stateReader.ReadEscapeState(snap);
+
+ // Read state flag bytes
if (snap.InGameStatePtr != 0)
- snap.StateFlagBytes = _memory!.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30);
+ snap.StateFlagBytes = mem.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30);
// Terrain
- ReadTerrain(snap, ingameData);
+ _terrain!.ReadTerrain(snap, ingameData);
}
}
catch (Exception ex)
@@ -273,3300 +220,47 @@ public class GameMemoryReader : IDisposable
Log.Debug(ex, "Error reading snapshot");
}
- // Update edge detection for next tick (after ReadTerrain has used _wasLoading)
- _wasLoading = snap.IsLoading;
+ // Update edge detection for next tick
+ _terrain!.UpdateLoadingEdge(snap.IsLoading);
return snap;
}
- private nint ResolveInGameState(GameStateSnapshot snap)
+ private void ReadCameraMatrix(GameStateSnapshot snap, nint inGameState)
{
- // Global points to the GameStateMachine controller object
- var controller = _memory!.ReadPointer(_gameStateBase);
- if (controller == 0) return 0;
- snap.ControllerPtr = controller;
+ var mem = _ctx!.Memory;
+ var offsets = _ctx.Offsets;
- // Direct offset mode: read InGameState straight from controller (CE confirmed: +0x210)
- if (_offsets.InGameStateDirectOffset > 0)
+ if (offsets.CameraMatrixOffset <= 0) return;
+
+ // If CameraOffset > 0: follow pointer from InGameState, then read matrix
+ // If CameraOffset == 0: matrix is inline in InGameState at CameraMatrixOffset
+ nint matrixAddr;
+ if (offsets.CameraOffset > 0)
{
- var igs = _memory.ReadPointer(controller + _offsets.InGameStateDirectOffset);
- if (igs != 0)
- {
- // Count states for display (scan inline slots)
- for (var i = 0; i < 20; i++)
- {
- var slotOffset = _offsets.StatesBeginOffset + i * _offsets.StateStride + _offsets.StatePointerOffset;
- var ptr = _memory.ReadPointer(controller + slotOffset);
- if (ptr == 0) break;
- snap.StatesCount++;
- }
- return igs;
- }
- }
-
- if (_offsets.StatesInline)
- {
- // POE1-style: states stored INLINE at fixed offsets in the controller struct
- // InGameState = controller + StatesBeginOffset + InGameStateIndex * StateStride + StatePointerOffset
- // POE1: controller + 0x48 + 4*0x10 + 0 = controller + 0x88
- var inlineOffset = _offsets.StatesBeginOffset
- + _offsets.InGameStateIndex * _offsets.StateStride
- + _offsets.StatePointerOffset;
-
- // Count states by scanning inline slots
- for (var i = 0; i < 20; i++)
- {
- var slotOffset = _offsets.StatesBeginOffset + i * _offsets.StateStride + _offsets.StatePointerOffset;
- var ptr = _memory.ReadPointer(controller + slotOffset);
- if (ptr == 0) break;
- snap.StatesCount++;
- }
-
- return _memory.ReadPointer(controller + inlineOffset);
+ var cam = mem.ReadPointer(inGameState + offsets.CameraOffset);
+ if (cam == 0) return;
+ matrixAddr = cam + offsets.CameraMatrixOffset;
}
else
{
- // Vector-style: StatesBeginOffset points to a std::vector of state entries
- var statesBegin = _memory.ReadPointer(controller + _offsets.StatesBeginOffset);
- if (statesBegin == 0) return 0;
-
- var statesEnd = _memory.ReadPointer(controller + _offsets.StatesBeginOffset + 8);
- if (statesEnd > statesBegin && statesEnd - statesBegin < 0x1000 && _offsets.StateStride > 0)
- {
- snap.StatesCount = (int)((statesEnd - statesBegin) / _offsets.StateStride);
- }
- else
- {
- for (var i = 0; i < 20; i++)
- {
- if (_memory.ReadPointer(statesBegin + i * _offsets.StateStride + _offsets.StatePointerOffset) == 0) break;
- snap.StatesCount++;
- }
- }
-
- if (_offsets.InGameStateIndex < 0 || _offsets.InGameStateIndex >= snap.StatesCount)
- return 0;
-
- return _memory.ReadPointer(statesBegin + _offsets.InGameStateIndex * _offsets.StateStride + _offsets.StatePointerOffset);
- }
- }
-
- ///
- /// Reads vitals via Entity Component System: LocalPlayer → ComponentList StdVector → Life component.
- /// Auto-discovers the Life component index by scanning for VitalStruct pattern, caches the index.
- /// Stable across zone changes because the ECS pointers are maintained by the game engine.
- ///
- private void ReadVitalsEcs(GameStateSnapshot snap)
- {
- var entity = snap.LocalPlayerPtr;
- if (entity == 0) return;
-
- // Find component array — try multiple strategies
- var (compFirst, count) = FindComponentList(entity);
- if (count <= 0) return;
-
- // Try cached index first
- if (_cachedLifeIndex >= 0 && _cachedLifeIndex < count)
- {
- var lifeComp = _memory!.ReadPointer(compFirst + _cachedLifeIndex * 8);
- if (lifeComp != 0 && TryReadVitals(snap, lifeComp))
- return;
- // Cache miss — index shifted, re-scan
- _cachedLifeIndex = -1;
- }
-
- // Scan all component pointers for VitalStruct pattern (HP + Mana at known offsets)
- for (var i = 0; i < count; i++)
- {
- var compPtr = _memory!.ReadPointer(compFirst + i * 8);
- if (compPtr == 0) continue;
-
- // Quick heap pointer validation
- var high = (ulong)compPtr >> 32;
- if (high == 0 || high >= 0x7FFF) continue;
- if ((compPtr & 0x3) != 0) continue;
-
- // Check VitalStruct pattern: HP.Total must be reasonable player HP (not 1/1 false positives)
- var hpTotal = _memory.Read(compPtr + _offsets.LifeHealthOffset + _offsets.VitalTotalOffset);
- if (hpTotal < 20 || hpTotal > 200000) continue;
-
- var hpCurrent = _memory.Read(compPtr + _offsets.LifeHealthOffset + _offsets.VitalCurrentOffset);
- if (hpCurrent < 0 || hpCurrent > hpTotal + 1000) continue;
-
- var manaTotal = _memory.Read(compPtr + _offsets.LifeManaOffset + _offsets.VitalTotalOffset);
- if (manaTotal < 0 || manaTotal > 200000) continue;
-
- var manaCurrent = _memory.Read(compPtr + _offsets.LifeManaOffset + _offsets.VitalCurrentOffset);
- if (manaCurrent < 0 || manaCurrent > manaTotal + 1000) continue;
-
- // Require at least some mana or ES (all player characters have base mana)
- var esTotal = _memory.Read(compPtr + _offsets.LifeEsOffset + _offsets.VitalTotalOffset);
- if (manaTotal == 0 && esTotal == 0) continue;
-
- // Found Life component — cache index and read vitals
- _cachedLifeIndex = i;
- Log.Information("ECS: Life component at index {Index} (0x{Addr:X}) — HP: {Hp}/{HpMax}, Mana: {Mana}/{ManaMax}",
- i, compPtr, hpCurrent, hpTotal, manaCurrent, manaTotal);
- TryReadVitals(snap, compPtr);
- return;
- }
- }
-
- ///
- /// Finds the best component list from the entity, trying multiple strategies:
- /// 1. StdVector at entity+ComponentListOffset (ExileCore standard)
- /// 2. Inner entity: entity+0x000 → deref → StdVector at +ComponentListOffset (POE2 wrapper)
- /// 3. Scan entity memory for any StdVector with many pointer-sized elements
- ///
- private (nint First, int Count) FindComponentList(nint entity)
- {
- // Strategy 1: direct StdVector at entity+ComponentListOffset
- var compFirst = _memory!.ReadPointer(entity + _offsets.ComponentListOffset);
- var compLast = _memory.ReadPointer(entity + _offsets.ComponentListOffset + 8);
- var count = 0;
- if (compFirst != 0 && compLast > compFirst && (compLast - compFirst) < 0x2000)
- count = (int)((compLast - compFirst) / 8);
-
- if (count > 1) return (compFirst, count);
-
- // Strategy 2: POE2 may wrap entities — entity+0x000 is a pointer to the real entity
- var innerEntity = _memory.ReadPointer(entity);
- if (innerEntity != 0 && innerEntity != entity && !IsModuleAddress(innerEntity))
- {
- var high = (ulong)innerEntity >> 32;
- if (high > 0 && high < 0x7FFF && (innerEntity & 0x3) == 0)
- {
- var innerFirst = _memory.ReadPointer(innerEntity + _offsets.ComponentListOffset);
- var innerLast = _memory.ReadPointer(innerEntity + _offsets.ComponentListOffset + 8);
- if (innerFirst != 0 && innerLast > innerFirst && (innerLast - innerFirst) < 0x2000)
- {
- var innerCount = (int)((innerLast - innerFirst) / 8);
- if (innerCount > count)
- {
- Log.Debug("ECS: Using inner entity 0x{Addr:X} component list ({Count} entries)",
- innerEntity, innerCount);
- return (innerFirst, innerCount);
- }
- }
- }
- }
-
- // Strategy 3: scan entity memory for StdVector patterns with ≥3 pointer-sized elements
- var entityData = _memory.ReadBytes(entity, 0x300);
- if (entityData is not null)
- {
- for (var off = 0; off + 24 <= entityData.Length; off += 8)
- {
- var f = (nint)BitConverter.ToInt64(entityData, off);
- var l = (nint)BitConverter.ToInt64(entityData, off + 8);
- if (f == 0 || l <= f) continue;
- var sz = l - f;
- if (sz < 24 || sz > 0x2000 || sz % 8 != 0) continue;
- var n = (int)(sz / 8);
- if (n <= count) continue;
-
- // Verify: first element should be a valid heap pointer
- var firstEl = _memory.ReadPointer(f);
- var h = (ulong)firstEl >> 32;
- if (h == 0 || h >= 0x7FFF || (firstEl & 0x3) != 0) continue;
-
- Log.Debug("ECS: Found StdVector at entity+0x{Off:X} with {Count} elements", off, n);
- if (n > count) { compFirst = f; count = n; }
- }
- }
-
- return (compFirst, count);
- }
-
- ///
- /// Attempts to read all vitals from a Life component pointer. Returns true if values pass sanity checks.
- ///
- private bool TryReadVitals(GameStateSnapshot snap, nint lifeComp)
- {
- var hp = _memory!.Read(lifeComp + _offsets.LifeHealthOffset + _offsets.VitalCurrentOffset);
- var hpMax = _memory.Read(lifeComp + _offsets.LifeHealthOffset + _offsets.VitalTotalOffset);
- var mana = _memory.Read(lifeComp + _offsets.LifeManaOffset + _offsets.VitalCurrentOffset);
- var manaMax = _memory.Read(lifeComp + _offsets.LifeManaOffset + _offsets.VitalTotalOffset);
- var es = _memory.Read(lifeComp + _offsets.LifeEsOffset + _offsets.VitalCurrentOffset);
- var esMax = _memory.Read(lifeComp + _offsets.LifeEsOffset + _offsets.VitalTotalOffset);
-
- // Sanity check: values must be non-negative and within reasonable range
- if (hpMax <= 0 || hpMax > 200000 || hp < 0 || hp > hpMax + 1000) return false;
- if (manaMax < 0 || manaMax > 200000 || mana < 0) return false;
-
- snap.HasVitals = true;
- snap.LifeCurrent = hp;
- snap.LifeTotal = hpMax;
- snap.ManaCurrent = mana;
- snap.ManaTotal = manaMax;
- snap.EsCurrent = es;
- snap.EsTotal = esMax;
- return true;
- }
-
- ///
- /// Reads player position from the Render component via ECS.
- /// Auto-discovers the Render component index by scanning for world-coordinate float triplets, caches the index.
- ///
- private void ReadPlayerPosition(GameStateSnapshot snap)
- {
- var entity = snap.LocalPlayerPtr;
- if (entity == 0) return;
-
- var (compFirst, count) = FindComponentList(entity);
- if (count <= 0) return;
-
- // Try configured index first
- if (_offsets.RenderComponentIndex >= 0)
- {
- var idx = _offsets.RenderComponentIndex;
- if (idx < count)
- {
- var renderComp = _memory!.ReadPointer(compFirst + idx * 8);
- if (renderComp != 0 && TryReadPosition(snap, renderComp))
- return;
- }
- }
-
- // Try cached index
- if (_cachedRenderIndex >= 0 && _cachedRenderIndex < count)
- {
- var renderComp = _memory!.ReadPointer(compFirst + _cachedRenderIndex * 8);
- if (renderComp != 0 && TryReadPosition(snap, renderComp))
- return;
- _cachedRenderIndex = -1;
- }
-
- // Auto-discover: scan components for float triplet that looks like world coordinates
- for (var i = 0; i < count; i++)
- {
- if (i == _cachedLifeIndex) continue;
-
- var compPtr = _memory!.ReadPointer(compFirst + i * 8);
- if (compPtr == 0) continue;
-
- var high = (ulong)compPtr >> 32;
- if (high == 0 || high >= 0x7FFF) continue;
- if ((compPtr & 0x3) != 0) continue;
-
- if (TryReadPosition(snap, compPtr))
- {
- _cachedRenderIndex = i;
- Log.Information("ECS: Render component at index {Index} (0x{Addr:X}) — Pos: ({X:F1}, {Y:F1}, {Z:F1})",
- i, compPtr, snap.PlayerX, snap.PlayerY, snap.PlayerZ);
- return;
- }
- }
- }
-
- ///
- /// Attempts to read position from a Render component pointer. Returns true if values look like valid world coordinates.
- ///
- private bool TryReadPosition(GameStateSnapshot snap, nint renderComp)
- {
- var x = _memory!.Read(renderComp + _offsets.PositionXOffset);
- var y = _memory.Read(renderComp + _offsets.PositionYOffset);
- var z = _memory.Read(renderComp + _offsets.PositionZOffset);
-
- if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) return false;
- if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) return false;
- // POE2 world coordinates: typically 50-50000 range for X/Y
- if (x < 50 || x > 50000 || y < 50 || y > 50000) return false;
- if (MathF.Abs(z) > 5000) return false;
-
- snap.HasPosition = true;
- snap.PlayerX = x;
- snap.PlayerY = y;
- snap.PlayerZ = z;
- return true;
- }
-
- private void ReadTerrain(GameStateSnapshot snap, nint areaInstance)
- {
- if (!_offsets.TerrainInline)
- {
- // Pointer-based: AreaInstance → TerrainList → first terrain → dimensions
- var terrainListPtr = _memory!.ReadPointer(areaInstance + _offsets.TerrainListOffset);
- if (terrainListPtr == 0) return;
-
- var terrainPtr = _memory.ReadPointer(terrainListPtr);
- if (terrainPtr == 0) return;
-
- var dimsPtr = _memory.ReadPointer(terrainPtr + _offsets.TerrainDimensionsOffset);
- if (dimsPtr == 0) return;
-
- snap.TerrainCols = _memory.Read(dimsPtr);
- snap.TerrainRows = _memory.Read(dimsPtr + 4);
- if (snap.TerrainCols > 0 && snap.TerrainCols < 1000 &&
- snap.TerrainRows > 0 && snap.TerrainRows < 1000)
- {
- snap.TerrainWidth = snap.TerrainCols * _offsets.SubTilesPerCell;
- snap.TerrainHeight = snap.TerrainRows * _offsets.SubTilesPerCell;
- }
- else
- {
- snap.TerrainCols = 0;
- snap.TerrainRows = 0;
- }
- return;
- }
-
- // Inline mode: TerrainStruct is inline at AreaInstance + TerrainListOffset
- // TotalTiles (StdTuple2D) at TerrainStruct + TerrainDimensionsOffset
- var terrainBase = areaInstance + _offsets.TerrainListOffset;
- var cols = (int)_memory!.Read(terrainBase + _offsets.TerrainDimensionsOffset);
- var rows = (int)_memory.Read(terrainBase + _offsets.TerrainDimensionsOffset + 8);
-
- if (cols <= 0 || cols >= 1000 || rows <= 0 || rows >= 1000)
- return;
-
- snap.TerrainCols = cols;
- snap.TerrainRows = rows;
- snap.TerrainWidth = cols * _offsets.SubTilesPerCell;
- snap.TerrainHeight = rows * _offsets.SubTilesPerCell;
-
- // While loading, clear cached terrain and don't read (data is stale/invalid)
- if (snap.IsLoading)
- {
- _cachedTerrain = null;
- _cachedTerrainAreaHash = 0;
- return;
- }
-
- // Loading just finished — clear cache to force a fresh read
- if (_wasLoading)
- {
- _cachedTerrain = null;
- _cachedTerrainAreaHash = 0;
- }
-
- // Return cached grid if same area
- if (_cachedTerrain != null && _cachedTerrainAreaHash == snap.AreaHash)
- {
- snap.Terrain = _cachedTerrain;
- snap.TerrainWalkablePercent = CalcWalkablePercent(_cachedTerrain);
- return;
- }
-
- // Read GridWalkableData StdVector (begin/end/cap pointers)
- var gridVecOffset = _offsets.TerrainWalkableGridOffset;
- var gridBegin = _memory.ReadPointer(terrainBase + gridVecOffset);
- var gridEnd = _memory.ReadPointer(terrainBase + gridVecOffset + 8);
- if (gridBegin == 0 || gridEnd <= gridBegin)
- return;
-
- var gridDataSize = (int)(gridEnd - gridBegin);
- if (gridDataSize <= 0 || gridDataSize > 16 * 1024 * 1024)
- return;
-
- // Read BytesPerRow
- var bytesPerRow = _memory.Read(terrainBase + _offsets.TerrainBytesPerRowOffset);
- if (bytesPerRow <= 0 || bytesPerRow > 0x10000)
- return;
-
- var gridWidth = cols * _offsets.SubTilesPerCell;
- var gridHeight = rows * _offsets.SubTilesPerCell;
-
- // Read raw grid data
- var rawData = _memory.ReadBytes(gridBegin, gridDataSize);
- if (rawData is null)
- return;
-
- // Unpack 4-bit nibbles: each byte → 2 cells (low nibble = even col, high nibble = odd col)
- var data = 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 >= rawData.Length) break;
-
- data[row * gridWidth + col] = (col % 2 == 0)
- ? (byte)(rawData[byteIndex] & 0x0F)
- : (byte)((rawData[byteIndex] >> 4) & 0x0F);
- }
- }
-
- var grid = new WalkabilityGrid(gridWidth, gridHeight, data);
- snap.Terrain = grid;
- snap.TerrainWalkablePercent = CalcWalkablePercent(grid);
-
- _cachedTerrain = grid;
- _cachedTerrainAreaHash = snap.AreaHash;
-
- Log.Information("Terrain grid read: {W}x{H} ({Cols}x{Rows} cells), {Pct}% walkable",
- gridWidth, gridHeight, cols, rows, snap.TerrainWalkablePercent);
- }
-
- private static int CalcWalkablePercent(WalkabilityGrid grid)
- {
- var walkable = 0;
- for (var i = 0; i < grid.Data.Length; i++)
- if (grid.Data[i] == 0) walkable++;
- return grid.Data.Length > 0 ? (int)(100L * walkable / grid.Data.Length) : 0;
- }
-
- ///
- /// Reads all state slot pointers and all non-null heap pointers from the controller
- /// (outside the state array) for live display. Allows spotting which pointer changes on zone.
- ///
- private void ReadStateSlots(GameStateSnapshot snap)
- {
- var controller = snap.ControllerPtr;
- if (controller == 0) return;
-
- // Read all state slot pointers (fixed count, allow nulls)
- var count = _offsets.StateCount;
- var slots = new nint[count];
- for (var i = 0; i < count; i++)
- {
- var slotOffset = _offsets.StatesBeginOffset + i * _offsets.StateStride + _offsets.StatePointerOffset;
- slots[i] = _memory!.ReadPointer(controller + slotOffset);
- }
- snap.StateSlots = slots;
-
- // Read int32 at +0x08 within each state object
- var values = new int[count];
- for (var i = 0; i < count; i++)
- {
- if (slots[i] != 0)
- values[i] = _memory!.Read(slots[i] + 0x08);
- }
- snap.StateSlotValues = values;
-
- // Read active states vector: begin at controller+ActiveStatesOffset, end at +ActiveStatesOffset+16
- // ExileCore: begin = Read(controller+0x20), end = Read(controller+0x30)
- if (_offsets.ActiveStatesOffset > 0)
- {
- var beginPtr = _memory!.ReadPointer(controller + _offsets.ActiveStatesOffset);
- var endPtr = _memory!.ReadPointer(controller + _offsets.ActiveStatesOffset + 16);
- snap.ActiveStatesBegin = beginPtr;
- snap.ActiveStatesEnd = endPtr;
-
- if (beginPtr != 0 && endPtr > beginPtr)
- {
- var size = (int)(endPtr - beginPtr);
- if (size is > 0 and < 0x1000)
- {
- var data = _memory.ReadBytes(beginPtr, size);
- if (data is not null)
- {
- var rawList = new List();
- for (var i = 0; i + 8 <= data.Length; i += _offsets.StateStride)
- {
- var ptr = (nint)BitConverter.ToInt64(data, i);
- rawList.Add(ptr);
- if (ptr != 0)
- snap.ActiveStates.Add(ptr);
- }
- snap.ActiveStatesRaw = rawList.ToArray();
- }
- }
- }
- }
-
- // Read all non-null pointer-like qwords from controller (outside state array)
- var stateArrayStart = _offsets.StatesBeginOffset;
- var stateArrayEnd = stateArrayStart + count * _offsets.StateStride;
- var watches = new List<(int, nint)>();
-
- var ctrlData = _memory!.ReadBytes(controller, 0x350);
- if (ctrlData is not null)
- {
- for (var offset = 0; offset + 8 <= ctrlData.Length; offset += 8)
- {
- if (offset >= stateArrayStart && offset < stateArrayEnd) continue;
- var value = (nint)BitConverter.ToInt64(ctrlData, offset);
- if (value == 0) continue;
- var high = (ulong)value >> 32;
- if (high > 0 && high < 0x7FFF && (value & 0x3) == 0)
- watches.Add((offset, value));
- }
- }
- snap.WatchOffsets = watches.ToArray();
- }
-
- ///
- /// Detects loading by comparing the active state pointer to InGameStatePtr.
- /// If ActiveStateOffset is configured, reads the "current state" from the controller.
- /// When it doesn't match InGameState → we're loading (or in another state).
- ///
- private void ReadIsLoading(GameStateSnapshot snap)
- {
- var controller = snap.ControllerPtr;
- if (controller == 0 || _offsets.IsLoadingOffset <= 0)
- return;
-
- // Read pointer at controller + IsLoadingOffset.
- // Two modes:
- // "active state" style: value == InGameState when not loading, differs when loading
- // "loading ptr" style: value == 0 when not loading, non-zero when loading (0x340)
- var value = _memory!.ReadPointer(controller + _offsets.IsLoadingOffset);
-
- // Detect which mode: if value matches InGameState, use active-state comparison
- // Otherwise use presence check (non-zero = loading)
- if (value == snap.InGameStatePtr && snap.InGameStatePtr != 0)
- {
- snap.IsLoading = false; // active state = InGameState → not loading
- }
- else if (value == 0)
- {
- // Could be: active-state mode where null = loading, OR loading-ptr mode where null = not loading
- // Use heuristic: if InGameState is resolved, 0 means not-in-game → loading
- // But for 0x340 pattern (0 = not loading), we need: 0 = not loading
- snap.IsLoading = false;
- }
- else
- {
- snap.IsLoading = true; // non-zero and not InGameState → loading
- }
- }
-
- private void ReadEscapeState(GameStateSnapshot snap)
- {
- // Primary: use active states vector (State[3] = EscapeState in ExileCore)
- if (snap.ActiveStates.Count > 0 && snap.StateSlots.Length > 3 && snap.StateSlots[3] != 0)
- {
- snap.IsEscapeOpen = snap.ActiveStates.Contains(snap.StateSlots[3]);
- return;
- }
-
- // Fallback: direct int32 flag from InGameState
- if (snap.InGameStatePtr == 0 || _offsets.EscapeStateOffset <= 0)
- return;
-
- var value = _memory!.Read(snap.InGameStatePtr + _offsets.EscapeStateOffset);
- snap.IsEscapeOpen = value != 0;
- }
-
- ///
- /// Diagnostic: collects all state slot pointers, then scans the controller
- /// and GameState global region for any qword matching ANY state.
- /// Run in different game states to find the "active state" pointer.
- ///
- public string ScanAreaLoadingState()
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- var controller = _memory.ReadPointer(_gameStateBase);
- if (controller == 0) return "Error: controller is null";
-
- var sb = new StringBuilder();
- sb.AppendLine($"GameState global: 0x{_gameStateBase:X}");
- sb.AppendLine($"Controller: 0x{controller:X}");
-
- // Collect all state slot pointers (both ptr1 and ptr2 per slot)
- var stateSlots = new Dictionary();
- for (var i = 0; i < 20; i++)
- {
- var slotOffset = _offsets.StatesBeginOffset + i * _offsets.StateStride + _offsets.StatePointerOffset;
- var ptr = _memory.ReadPointer(controller + slotOffset);
- if (ptr == 0) break;
- stateSlots.TryAdd(ptr, $"State[{i}]");
-
- // Also grab the second pointer in each 16-byte slot
- if (_offsets.StateStride >= 16)
- {
- var ptr2 = _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 = _offsets.StatesBeginOffset;
- var stateArrayEnd = stateArrayStart + stateSlots.Count * _offsets.StateStride;
- var ctrlData = _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 = _gameStateBase - 0x100;
- var globalData = _memory.ReadBytes(globalScanStart, 0x300);
- if (globalData is not null)
- {
- for (var offset = 0; offset + 8 <= globalData.Length; offset += 8)
- {
- var value = (nint)BitConverter.ToInt64(globalData, offset);
- if (value == 0) continue;
-
- var relToGlobal = offset - 0x100;
- if (stateSlots.TryGetValue(value, out var stateName))
- sb.AppendLine($" global{relToGlobal:+#;-#;+0} (0x{globalScanStart + offset:X}): 0x{value:X} = {stateName}");
- else if (value == controller)
- sb.AppendLine($" global{relToGlobal:+#;-#;+0} (0x{globalScanStart + offset:X}): 0x{value:X} = controller");
- }
- }
-
- sb.AppendLine(new string('═', 70));
- sb.AppendLine("Run in different states (in-game, loading, char select).");
- sb.AppendLine("The offset where the state value CHANGES is the active state ptr.");
-
- return sb.ToString();
- }
-
- ///
- /// Memory diff scanner. First call captures baseline, second call shows all changed offsets.
- /// Scans controller (0x1000 bytes) and InGameState (0x1000 bytes).
- /// Usage: click once in state A, change game state (e.g. open/close Escape), click again.
- ///
- public string ScanMemoryDiff()
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- var controller = _memory.ReadPointer(_gameStateBase);
- if (controller == 0) return "Error: controller is null";
-
- var snap = new GameStateSnapshot();
- var inGameState = ResolveInGameState(snap);
-
- // Regions to scan
- const int scanSize = 0x4000;
- var regions = new Dictionary
- {
- ["Controller"] = controller,
- };
- if (inGameState != 0)
- regions["InGameState"] = inGameState;
-
- // Follow pointer at controller+0x10 (changed during in-game→login transition)
- var ctrlPtr10 = _memory.ReadPointer(controller + 0x10);
- if (ctrlPtr10 != 0 && !regions.ContainsValue(ctrlPtr10))
- regions["Ctrl+0x10 target"] = ctrlPtr10;
-
- // Read current data
- var current = new Dictionary();
- foreach (var (name, addr) in regions)
- {
- var data = _memory.ReadBytes(addr, scanSize);
- if (data is not null)
- current[name] = (addr, data);
- }
-
- // First call: save baseline
- if (_diffBaseline is null)
- {
- _diffBaseline = current;
- var sb = new StringBuilder();
- sb.AppendLine("=== BASELINE CAPTURED ===");
- foreach (var (rn, (ra, rd)) in current)
- sb.AppendLine($" {rn}: 0x{ra:X} ({rd.Length} bytes)");
- sb.AppendLine();
- sb.AppendLine("Now change game state (login, char select, in-game, etc.) and click again.");
- return sb.ToString();
- }
-
- // Second call: diff against baseline
- var baseline = _diffBaseline;
- _diffBaseline = null; // reset for next pair
-
- var result = new StringBuilder();
- result.AppendLine("=== MEMORY DIFF ===");
- result.AppendLine($"Controller: 0x{controller:X}");
- if (inGameState != 0)
- result.AppendLine($"InGameState: 0x{inGameState:X}");
- result.AppendLine();
-
- // Resolve known pointers for annotation
- var knownPtrs = new Dictionary();
- if (inGameState != 0) knownPtrs[inGameState] = "IGS";
- knownPtrs[controller] = "Controller";
- for (var i = 0; i < 20; i++)
- {
- var slotOff = _offsets.StatesBeginOffset + i * _offsets.StateStride;
- var ptr = _memory.ReadPointer(controller + slotOff);
- if (ptr == 0) break;
- knownPtrs.TryAdd(ptr, $"State[{i}]");
- }
-
- foreach (var (name, (curAddr, curData)) in current)
- {
- if (!baseline.TryGetValue(name, out var baseEntry))
- {
- result.AppendLine($"── {name}: no baseline (new region) ──");
- continue;
- }
-
- var (baseAddr, baseData) = baseEntry;
- if (baseAddr != curAddr)
- {
- result.AppendLine($"── {name}: address changed 0x{baseAddr:X} → 0x{curAddr:X} ──");
- continue;
- }
-
- var changes = new List();
- var len = Math.Min(baseData.Length, curData.Length);
-
- for (var offset = 0; offset + 8 <= len; offset += 8)
- {
- var oldVal = BitConverter.ToInt64(baseData, offset);
- var newVal = BitConverter.ToInt64(curData, offset);
- if (oldVal == newVal) continue;
-
- var line = $" +0x{offset:X3}: 0x{oldVal:X16} → 0x{newVal:X16}";
-
- // Annotate known pointers
- var oldPtr = (nint)oldVal;
- var newPtr = (nint)newVal;
- if (knownPtrs.TryGetValue(oldPtr, out var oldName))
- line += $" (was {oldName})";
- if (knownPtrs.TryGetValue(newPtr, out var newName))
- line += $" (now {newName})";
-
- // Also check int32 changes at this offset
- var oldI1 = BitConverter.ToInt32(baseData, offset);
- var oldI2 = BitConverter.ToInt32(baseData, offset + 4);
- var newI1 = BitConverter.ToInt32(curData, offset);
- var newI2 = BitConverter.ToInt32(curData, offset + 4);
- if (oldI1 != newI1 && oldI2 == newI2)
- line += $" [int32@+0x{offset:X}: {oldI1} → {newI1}]";
- else if (oldI1 == newI1 && oldI2 != newI2)
- line += $" [int32@+0x{offset + 4:X}: {oldI2} → {newI2}]";
-
- changes.Add(line);
- }
-
- // Also check byte-level for small flags (scan each byte for 0↔1 or 0↔non-zero)
- var byteChanges = new List();
- for (var offset = 0; offset < len; offset++)
- {
- if (baseData[offset] == curData[offset]) continue;
- // Only report byte changes not already covered by qword changes (check alignment)
- var qwordOffset = (offset / 8) * 8;
- var qwordOld = BitConverter.ToInt64(baseData, qwordOffset);
- var qwordNew = BitConverter.ToInt64(curData, qwordOffset);
- if (qwordOld != qwordNew) continue; // already reported above
-
- byteChanges.Add($" +0x{offset:X3}: byte {baseData[offset]:X2} → {curData[offset]:X2}");
- }
-
- result.AppendLine($"── {name} (0x{curAddr:X}, {len} bytes) ──");
- if (changes.Count == 0 && byteChanges.Count == 0)
- {
- result.AppendLine(" (no changes)");
- }
- else
- {
- result.AppendLine($" {changes.Count} qword changes, {byteChanges.Count} byte-only changes:");
- foreach (var c in changes) result.AppendLine(c);
- foreach (var c in byteChanges) result.AppendLine(c);
- }
- }
-
- result.AppendLine();
- result.AppendLine("Click again to capture a new baseline.");
- return result.ToString();
- }
-
- ///
- /// Scans controller for any begin/end pointer pair whose buffer contains known state slot pointers.
- /// Also follows each state slot's first pointer field and diffs those sub-objects.
- ///
- public string ScanActiveStatesVector()
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- var controller = _memory.ReadPointer(_gameStateBase);
- if (controller == 0) return "Error: controller is null";
-
- // Collect state slot pointers
- var stateSlotPtrs = new Dictionary();
- for (var i = 0; i < _offsets.StateCount; i++)
- {
- var slotOffset = _offsets.StatesBeginOffset + i * _offsets.StateStride + _offsets.StatePointerOffset;
- var ptr = _memory.ReadPointer(controller + slotOffset);
- if (ptr != 0)
- stateSlotPtrs[ptr] = i;
- }
-
- var sb = new StringBuilder();
- sb.AppendLine($"Controller: 0x{controller:X}");
- sb.AppendLine($"Known state slots: {stateSlotPtrs.Count}");
- sb.AppendLine();
-
- // Scan controller for vector-like pairs (begin, ?, end) where buffer contains state pointers
- sb.AppendLine("=== Scanning controller for vectors containing state pointers ===");
- var ctrlData = _memory.ReadBytes(controller, 0x400);
- if (ctrlData is null) return "Error: cannot read controller";
-
- var found = false;
- for (var off = 0; off + 24 <= ctrlData.Length; off += 8)
- {
- var p1 = (nint)BitConverter.ToInt64(ctrlData, off);
- if (p1 == 0) continue;
- var high1 = (ulong)p1 >> 32;
- if (high1 == 0 || high1 >= 0x7FFF) continue;
-
- // Try end at +8 and +16 (std::vector layouts vary)
- 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 = _memory.ReadBytes(p1, size);
- if (buf is null) continue;
-
- var matches = new List();
- for (var bi = 0; bi + 8 <= buf.Length; bi += 8)
- {
- var val = (nint)BitConverter.ToInt64(buf, bi);
- if (stateSlotPtrs.TryGetValue(val, out var slotIdx))
- matches.Add($"State[{slotIdx}]@buf+0x{bi:X}");
- }
-
- if (matches.Count > 0)
- {
- found = true;
- sb.AppendLine($" ctrl+0x{off:X3} (end@+{endDelta}): buf 0x{p1:X}, size={size}, {matches.Count} matches:");
- foreach (var m in matches)
- sb.AppendLine($" {m}");
-
- // Dump the entire buffer
- sb.Append(" buf: ");
- for (var bi = 0; bi + 8 <= buf.Length; bi += 8)
- {
- var val = (nint)BitConverter.ToInt64(buf, bi);
- sb.Append($"0x{val:X} ");
- }
- sb.AppendLine();
- }
- }
- }
-
- if (!found)
- sb.AppendLine(" (none found in controller 0x000-0x400)");
-
- // Also check InGameState for vectors containing state pointers
- var snap = new GameStateSnapshot();
- var igs = ResolveInGameState(snap);
- if (igs != 0)
- {
- sb.AppendLine();
- sb.AppendLine("=== Scanning InGameState for vectors containing state pointers ===");
- var igsData = _memory.ReadBytes(igs, 0x400);
- if (igsData is not null)
- {
- var foundIgs = false;
- for (var off = 0; off + 24 <= igsData.Length; off += 8)
- {
- var p1 = (nint)BitConverter.ToInt64(igsData, off);
- if (p1 == 0) continue;
- var high1 = (ulong)p1 >> 32;
- if (high1 == 0 || high1 >= 0x7FFF) continue;
-
- foreach (var endDelta in new[] { 8, 16 })
- {
- if (off + endDelta + 8 > igsData.Length) continue;
- var p2 = (nint)BitConverter.ToInt64(igsData, off + endDelta);
- if (p2 <= p1) continue;
- var size = (int)(p2 - p1);
- if (size is < 8 or > 0x400) continue;
-
- var buf = _memory.ReadBytes(p1, size);
- if (buf is null) continue;
-
- var matches = new List();
- for (var bi = 0; bi + 8 <= buf.Length; bi += 8)
- {
- var val = (nint)BitConverter.ToInt64(buf, bi);
- if (stateSlotPtrs.TryGetValue(val, out var slotIdx))
- matches.Add($"State[{slotIdx}]@buf+0x{bi:X}");
- }
-
- if (matches.Count > 0)
- {
- foundIgs = true;
- sb.AppendLine($" igs+0x{off:X3} (end@+{endDelta}): buf 0x{p1:X}, size={size}, {matches.Count} matches:");
- foreach (var m in matches)
- sb.AppendLine($" {m}");
- }
- }
- }
- if (!foundIgs)
- sb.AppendLine(" (none found in InGameState 0x000-0x400)");
- }
- }
-
- return sb.ToString();
- }
-
- ///
- /// Diagnostic: walks the entity std::map (red-black tree) from AreaInstance, reports RTTI types and positions.
- ///
- public string ScanEntities()
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- var snap = new GameStateSnapshot();
- var inGameState = ResolveInGameState(snap);
- if (inGameState == 0) return "Error: InGameState not resolved";
-
- var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
- if (ingameData == 0) return "Error: AreaInstance not resolved";
-
- var sb = new StringBuilder();
- sb.AppendLine($"AreaInstance: 0x{ingameData:X}");
-
- // Read sentinel (tree head node) pointer
- var sentinel = _memory.ReadPointer(ingameData + _offsets.EntityListOffset);
- if (sentinel == 0)
- {
- sb.AppendLine("Entity tree sentinel is null");
- return sb.ToString();
- }
-
- var entityCount = (int)_memory.Read(ingameData + _offsets.EntityListOffset + _offsets.EntityCountInternalOffset);
-
- // Sentinel layout: _Left = min node, _Parent = root, _Right = max node
- var root = _memory.ReadPointer(sentinel + _offsets.EntityNodeParentOffset);
- sb.AppendLine($"Sentinel: 0x{sentinel:X}");
- sb.AppendLine($"Root: 0x{root:X}");
- sb.AppendLine($"Expected count: {entityCount}");
- sb.AppendLine(new string('═', 90));
-
- // In-order tree traversal
- var entities = new List();
- var maxNodes = Math.Min(entityCount + 10, 500);
-
- WalkTreeInOrder(sentinel, root, maxNodes, node =>
- {
- var entityPtr = _memory.ReadPointer(node + _offsets.EntityNodeValueOffset);
- if (entityPtr == 0) return;
-
- var high = (ulong)entityPtr >> 32;
- if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
-
- var entityId = _memory.Read(entityPtr + _offsets.EntityIdOffset);
- var path = TryReadEntityPath(entityPtr);
- var entity = new Entity(entityPtr, entityId, path);
-
- if (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.Category).OrderByDescending(g => g.Count()))
- sb.AppendLine($" {group.Key}: {group.Count()}");
-
- return sb.ToString();
- }
-
- ///
- /// Reads entity list into the snapshot for continuous display.
- /// Called from ReadSnapshot() when entity count > 0.
- ///
- private void ReadEntities(GameStateSnapshot snap, nint areaInstance)
- {
- var sentinel = _memory!.ReadPointer(areaInstance + _offsets.EntityListOffset);
- if (sentinel == 0) return;
-
- var root = _memory.ReadPointer(sentinel + _offsets.EntityNodeParentOffset);
- var entities = new List();
- var maxNodes = Math.Min(snap.EntityCount + 10, 500);
- var hasComponentLookup = _offsets.ComponentLookupEntrySize > 0;
- var dirty = false;
-
- WalkTreeInOrder(sentinel, root, maxNodes, node =>
- {
- var entityPtr = _memory.ReadPointer(node + _offsets.EntityNodeValueOffset);
- if (entityPtr == 0) return;
-
- var high = (ulong)entityPtr >> 32;
- if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
-
- var entityId = _memory.Read(entityPtr + _offsets.EntityIdOffset);
- var path = TryReadEntityPath(entityPtr);
-
- var entity = new Entity(entityPtr, entityId, path);
-
- if (_registry["entities"].Register(entity.Metadata))
- dirty = true;
-
- if (TryReadEntityPosition(entityPtr, out var x, out var y, out var z))
- {
- entity.HasPosition = true;
- entity.X = x;
- entity.Y = y;
- entity.Z = z;
- }
-
- // Read component names for non-trivial entities (skip effects/terrain/critters for perf)
- if (hasComponentLookup &&
- entity.Type != EntityType.Effect &&
- entity.Type != EntityType.Terrain &&
- entity.Type != EntityType.Critter)
- {
- var lookup = ReadComponentLookup(entityPtr);
- if (lookup is not null)
- {
- entity.Components = new HashSet(lookup.Keys);
- entity.ReclassifyFromComponents();
-
- if (_registry["components"].Register(lookup.Keys))
- dirty = true;
-
- // Read HP for monsters to determine alive/dead
- if (entity.Type == EntityType.Monster && lookup.TryGetValue("Life", out var lifeIdx))
- {
- var (compFirst, compCount) = FindComponentList(entityPtr);
- if (lifeIdx >= 0 && lifeIdx < compCount)
- {
- var lifeComp = _memory!.ReadPointer(compFirst + lifeIdx * 8);
- if (lifeComp != 0)
- {
- var hp = _memory.Read(lifeComp + _offsets.LifeHealthOffset + _offsets.VitalCurrentOffset);
- var hpMax = _memory.Read(lifeComp + _offsets.LifeHealthOffset + _offsets.VitalTotalOffset);
- if (hpMax > 0 && hpMax < 200000 && hp >= 0 && hp <= hpMax + 1000)
- {
- entity.HasVitals = true;
- entity.LifeCurrent = hp;
- entity.LifeTotal = hpMax;
- }
- }
- }
- }
- }
- }
-
- entities.Add(entity);
- });
-
- if (dirty)
- _registry.Flush();
-
- snap.Entities = entities;
- }
-
- ///
- /// Iterative in-order traversal of an MSVC std::map red-black tree.
- /// Sentinel node is the "nil" node — left/right children equal to sentinel mean "no child".
- ///
- private void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action visitor)
- {
- if (root == 0 || root == sentinel) return;
-
- var stack = new Stack();
- var current = root;
- var count = 0;
- var visited = new HashSet { sentinel };
-
- while ((current != sentinel && current != 0) || stack.Count > 0)
- {
- // Go left as far as possible
- while (current != sentinel && current != 0)
- {
- if (!visited.Add(current))
- {
- current = sentinel; // break out of cycle
- break;
- }
- stack.Push(current);
- current = _memory!.ReadPointer(current + _offsets.EntityNodeLeftOffset);
- }
-
- if (stack.Count == 0) break;
-
- current = stack.Pop();
- visitor(current);
- count++;
- if (count >= maxNodes) break;
-
- // Move to right subtree
- current = _memory!.ReadPointer(current + _offsets.EntityNodeRightOffset);
- }
- }
-
- ///
- /// Tries to read position from an entity by scanning its component list for the Render component.
- ///
- private bool TryReadEntityPosition(nint entity, out float x, out float y, out float z)
- {
- x = y = z = 0;
- var (compFirst, count) = FindComponentList(entity);
- if (count <= 0) return false;
-
- // If we know the Render component index, try it directly
- if (_offsets.RenderComponentIndex >= 0 && _offsets.RenderComponentIndex < count)
- {
- var renderComp = _memory!.ReadPointer(compFirst + _offsets.RenderComponentIndex * 8);
- if (renderComp != 0 && TryReadPositionRaw(renderComp, out x, out y, out z))
- return true;
- }
-
- // Scan components (limit to avoid performance issues with many entities)
- var scanLimit = Math.Min(count, 20);
- for (var i = 0; i < scanLimit; i++)
- {
- var compPtr = _memory!.ReadPointer(compFirst + i * 8);
- if (compPtr == 0) continue;
- var high = (ulong)compPtr >> 32;
- if (high == 0 || high >= 0x7FFF) continue;
- if ((compPtr & 0x3) != 0) continue;
-
- if (TryReadPositionRaw(compPtr, out x, out y, out z))
- return true;
- }
-
- return false;
- }
-
- ///
- /// Reads position floats from a component pointer and validates them as world coordinates.
- ///
- private bool TryReadPositionRaw(nint comp, out float x, out float y, out float z)
- {
- x = _memory!.Read(comp + _offsets.PositionXOffset);
- y = _memory.Read(comp + _offsets.PositionYOffset);
- z = _memory.Read(comp + _offsets.PositionZOffset);
-
- if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) return false;
- if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) return false;
- if (x < 50 || x > 50000 || y < 50 || y > 50000) return false;
- if (MathF.Abs(z) > 5000) return false;
- return true;
- }
-
- ///
- /// Reads entity path string via EntityDetailsPtr → std::string.
- /// Path looks like "Metadata/Monsters/...", "Metadata/Effects/...", etc.
- ///
- private string? TryReadEntityPath(nint entity)
- {
- var detailsPtr = _memory!.ReadPointer(entity + _offsets.EntityDetailsOffset);
- if (detailsPtr == 0) return null;
-
- var high = (ulong)detailsPtr >> 32;
- if (high == 0 || high >= 0x7FFF) return null;
-
- return ReadMsvcWString(detailsPtr + _offsets.EntityPathStringOffset);
- }
-
- ///
- /// Reads an MSVC std::wstring (UTF-16) from the given address.
- /// Layout: _Bx (16 bytes: SSO buffer or heap ptr), _Mysize (8), _Myres (8).
- /// wchar_t is 2 bytes on Windows. SSO threshold: capacity <= 7 (16 bytes / 2 = 8 wchars, minus null).
- ///
- private string? ReadMsvcWString(nint stringAddr)
- {
- var size = _memory!.Read(stringAddr + 0x10); // count of wchar_t
- var capacity = _memory.Read(stringAddr + 0x18); // capacity in wchar_t
-
- if (size <= 0 || size > 512 || capacity < size) return null;
-
- nint dataAddr;
- if (capacity <= 7)
- {
- // SSO: data is inline in the 16-byte _Bx buffer
- dataAddr = stringAddr;
- }
- else
- {
- // Heap-allocated: _Bx contains pointer to data
- dataAddr = _memory.ReadPointer(stringAddr);
- if (dataAddr == 0) return null;
- }
-
- var bytes = _memory.ReadBytes(dataAddr, (int)size * 2);
- if (bytes is null) return null;
-
- var str = Encoding.Unicode.GetString(bytes);
-
- // Sanity check: should be printable ASCII-range characters
- if (str.Length > 0 && str[0] >= 0x20 && str[0] <= 0x7E)
- return str;
-
- return null;
- }
-
- ///
- /// Raw explorer: parse hex address, follow offset chain, read as specified type.
- ///
- public string ReadAddress(string hexAddr, string offsetsCsv, string type)
- {
- if (_memory is null)
- return "Error: not attached";
-
- if (!nint.TryParse(hexAddr, NumberStyles.HexNumber, null, out var addr))
- return $"Error: invalid address '{hexAddr}'";
-
- // Parse offsets
- var offsets = Array.Empty();
- if (!string.IsNullOrWhiteSpace(offsetsCsv))
- {
- var parts = offsetsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
- offsets = new int[parts.Length];
- for (var i = 0; i < parts.Length; i++)
- {
- if (!int.TryParse(parts[i], NumberStyles.HexNumber, null, out offsets[i]))
- return $"Error: invalid offset '{parts[i]}'";
- }
- }
-
- // Follow chain if offsets provided
- if (offsets.Length > 0)
- {
- addr = _memory.FollowChain(addr, offsets);
- if (addr == 0)
- return "Error: pointer chain broken (null)";
- }
-
- return type.ToLowerInvariant() switch
- {
- "int32" => _memory.Read(addr).ToString(),
- "int64" => _memory.Read(addr).ToString(),
- "float" => _memory.Read(addr).ToString("F4"),
- "double" => _memory.Read(addr).ToString("F4"),
- "pointer" => $"0x{_memory.ReadPointer(addr):X}",
- "bytes16" => FormatBytes(_memory.ReadBytes(addr, 16)),
- "string" => ReadNullTermString(addr),
- _ => $"Error: unknown type '{type}'"
- };
- }
-
- ///
- /// Scans a memory region and returns all pointer-like values with their offsets.
- ///
- public string ScanRegion(string hexAddr, string offsetsCsv, int size)
- {
- if (_memory is null)
- return "Error: not attached";
-
- if (!nint.TryParse(hexAddr, NumberStyles.HexNumber, null, out var addr))
- return $"Error: invalid address '{hexAddr}'";
-
- // Parse and follow offset chain
- if (!string.IsNullOrWhiteSpace(offsetsCsv))
- {
- var parts = offsetsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
- var offsets = new int[parts.Length];
- for (var i = 0; i < parts.Length; i++)
- {
- if (!int.TryParse(parts[i], NumberStyles.HexNumber, null, out offsets[i]))
- return $"Error: invalid offset '{parts[i]}'";
- }
- addr = _memory.FollowChain(addr, offsets);
- if (addr == 0)
- return "Error: pointer chain broken (null)";
- }
-
- // Read the full block
- size = Math.Clamp(size, 0x10, 0x10000);
- var data = _memory.ReadBytes(addr, size);
- if (data is null)
- return "Error: read failed";
-
- var sb = new StringBuilder();
- sb.AppendLine($"Scan: 0x{addr:X} size: 0x{size:X}");
- sb.AppendLine(new string('─', 60));
-
- for (var offset = 0; offset + 8 <= data.Length; offset += 8)
- {
- var value = BitConverter.ToUInt64(data, offset);
- if (value == 0) continue;
-
- var nVal = (nint)(long)value;
- var tag = ClassifyPointer(nVal);
- if (tag is null)
- {
- // Show non-zero non-pointer values only if they look like small ints or floats
- if (value <= 0xFFFF)
- {
- sb.AppendLine($"+0x{offset:X3}: {value,-20} [int: {value}]");
- }
- else
- {
- var f1 = BitConverter.ToSingle(data, offset);
- var f2 = BitConverter.ToSingle(data, offset + 4);
- if (IsReasonableFloat(f1) || IsReasonableFloat(f2))
- {
- sb.AppendLine($"+0x{offset:X3}: {f1,12:F2} {f2,12:F2} [float pair]");
- }
- }
- continue;
- }
-
- sb.AppendLine($"+0x{offset:X3}: 0x{value:X} [{tag}]");
- }
-
- return sb.ToString();
- }
-
- ///
- /// Resolves a vtable address to its RTTI class name using MSVC x64 RTTI layout.
- /// vtable[-1] → RTTICompleteObjectLocator → TypeDescriptor → mangled name
- ///
- public string? ResolveRttiName(nint vtableAddr)
- {
- if (_memory is null || _moduleBase == 0) return null;
-
- try
- {
- // vtable[-1] = pointer to RTTICompleteObjectLocator
- var colPtr = _memory.ReadPointer(vtableAddr - 8);
- if (colPtr == 0) return null;
-
- // COL signature check: x64 = 1
- var signature = _memory.Read(colPtr);
- if (signature != 1) return null;
-
- // COL+0x0C = typeDescriptorOffset (image-relative, 4 bytes)
- var typeDescOffset = _memory.Read(colPtr + 0x0C);
- if (typeDescOffset <= 0) return null;
-
- // TypeDescriptor = moduleBase + typeDescOffset
- var typeDesc = _moduleBase + typeDescOffset;
-
- // TypeDescriptor+0x10 = mangled name string (e.g. ".?AVInGameState@@")
- var nameBytes = _memory.ReadBytes(typeDesc + 0x10, 128);
- if (nameBytes is null) return null;
-
- var end = Array.IndexOf(nameBytes, (byte)0);
- if (end <= 0) return null;
-
- var mangled = Encoding.ASCII.GetString(nameBytes, 0, end);
-
- // Demangle: ".?AVClassName@@" → "ClassName"
- if (mangled.StartsWith(".?AV") && mangled.EndsWith("@@"))
- return mangled[4..^2];
- if (mangled.StartsWith(".?AU") && mangled.EndsWith("@@"))
- return mangled[4..^2];
-
- return mangled;
- }
- catch
- {
- return null;
- }
- }
-
- private bool IsModuleAddress(nint value)
- {
- return _moduleBase != 0 && _moduleSize > 0 &&
- value >= _moduleBase && value < _moduleBase + _moduleSize;
- }
-
- private string? ClassifyPointer(nint value)
- {
- if (value == 0) return null;
-
- // Module range — try RTTI resolution
- if (IsModuleAddress(value))
- {
- var name = ResolveRttiName(value);
- return name ?? "module (vtable?)";
- }
-
- // Heap heuristic: user-mode addresses in typical 64-bit range
- if (value > 0x10000 && value < (nint)0x7FFFFFFFFFFF && (value & 0x3) == 0)
- {
- var high = (ulong)value >> 32;
- if (high > 0 && high < 0x7FFF)
- return "heap ptr";
- }
-
- return null;
- }
-
- ///
- /// Scans all game states and returns their info.
- /// Supports both inline (POE1-style) and vector modes.
- ///
- public string ScanAllStates()
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- var controller = _memory.ReadPointer(_gameStateBase);
- if (controller == 0) return "Error: controller is null";
-
- var sb = new StringBuilder();
- sb.AppendLine($"Controller: 0x{controller:X}");
- sb.AppendLine($"Mode: {(_offsets.StatesInline ? "inline" : "vector")}");
-
- int count;
- if (_offsets.StatesInline)
- {
- // Inline: count by scanning slots until null
- count = 0;
- for (var i = 0; i < 30; i++)
- {
- var slotOffset = _offsets.StatesBeginOffset + i * _offsets.StateStride + _offsets.StatePointerOffset;
- var ptr = _memory.ReadPointer(controller + slotOffset);
- if (ptr == 0) break;
- count++;
- }
-
- sb.AppendLine($"States: {count} (inline at controller+0x{_offsets.StatesBeginOffset:X})");
- sb.AppendLine(new string('─', 70));
-
- for (var i = 0; i < count; i++)
- {
- var slotOffset = _offsets.StatesBeginOffset + i * _offsets.StateStride + _offsets.StatePointerOffset;
- var statePtr = _memory.ReadPointer(controller + slotOffset);
-
- string? stateName = null;
- if (statePtr != 0)
- {
- var stateVtable = _memory.ReadPointer(statePtr);
- if (stateVtable != 0 && IsModuleAddress(stateVtable))
- stateName = ResolveRttiName(stateVtable);
- }
-
- var marker = i == _offsets.InGameStateIndex ? " ◄◄◄" : "";
- var tag = IsModuleAddress(statePtr) ? " [module!]" : "";
- sb.AppendLine($"[{i}] +0x{slotOffset:X}: 0x{statePtr:X} {stateName ?? "?"}{tag}{marker}");
- }
-
- return sb.ToString();
- }
-
- // Vector mode (fallback)
- var statesBegin = _memory.ReadPointer(controller + _offsets.StatesBeginOffset);
- if (statesBegin == 0) return "Error: states begin is null";
-
- var statesEnd = _memory.ReadPointer(controller + _offsets.StatesBeginOffset + 8);
- count = 0;
- if (statesEnd > statesBegin && statesEnd - statesBegin < 0x1000 && _offsets.StateStride > 0)
- count = (int)((statesEnd - statesBegin) / _offsets.StateStride);
- else
- {
- for (var i = 0; i < 30; i++)
- {
- if (_memory.ReadPointer(statesBegin + i * _offsets.StateStride + _offsets.StatePointerOffset) == 0) break;
- count++;
- }
- }
-
- sb.AppendLine($"States: {count} (vector begin: 0x{statesBegin:X})");
- sb.AppendLine(new string('─', 70));
-
- for (var i = 0; i < count; i++)
- {
- var entryBase = statesBegin + i * _offsets.StateStride;
- var vtable = _memory.ReadPointer(entryBase);
- var statePtr = _memory.ReadPointer(entryBase + _offsets.StatePointerOffset);
-
- var vtableName = vtable != 0 && IsModuleAddress(vtable) ? ResolveRttiName(vtable) : null;
-
- string? stateName = null;
- if (statePtr != 0)
- {
- var stateVtable = _memory.ReadPointer(statePtr);
- if (stateVtable != 0 && IsModuleAddress(stateVtable))
- stateName = ResolveRttiName(stateVtable);
- }
-
- var marker = i == _offsets.InGameStateIndex ? " ◄◄◄" : "";
- sb.AppendLine($"[{i}] 0x{statePtr:X} {stateName ?? "?"}{marker}");
- }
-
- return sb.ToString();
- }
-
- ///
- /// Scans an object's memory and identifies all RTTI-typed sub-elements.
- /// Groups by vtable to show the structure with class names.
- ///
- public string ScanStructure(string hexAddr, string offsetsCsv, int size)
- {
- if (_memory is null) return "Error: not attached";
-
- if (!nint.TryParse(hexAddr, NumberStyles.HexNumber, null, out var addr))
- return $"Error: invalid address '{hexAddr}'";
-
- if (!string.IsNullOrWhiteSpace(offsetsCsv))
- {
- var parts = offsetsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
- var offsets = new int[parts.Length];
- for (var i = 0; i < parts.Length; i++)
- {
- if (!int.TryParse(parts[i], NumberStyles.HexNumber, null, out offsets[i]))
- return $"Error: invalid offset '{parts[i]}'";
- }
- addr = _memory.FollowChain(addr, offsets);
- if (addr == 0) return "Error: pointer chain broken (null)";
- }
-
- size = Math.Clamp(size, 0x10, 0x10000);
- var data = _memory.ReadBytes(addr, size);
- if (data is null) return "Error: read failed";
-
- // First pass: get RTTI name for the object itself
- var objVtable = BitConverter.ToInt64(data, 0);
- var objName = objVtable != 0 && IsModuleAddress((nint)objVtable) ? ResolveRttiName((nint)objVtable) : null;
-
- var sb = new StringBuilder();
- sb.AppendLine($"Structure: 0x{addr:X} size: 0x{size:X} type: {objName ?? "?"}");
- sb.AppendLine(new string('─', 80));
-
- // Scan for all vtable pointers and resolve their RTTI names
- for (var offset = 0; offset + 8 <= data.Length; offset += 8)
- {
- var value = (nint)BitConverter.ToInt64(data, offset);
- if (value == 0) continue;
-
- if (IsModuleAddress(value))
- {
- var name = ResolveRttiName(value);
- if (name is not null)
- {
- // Check if next qword is a heap pointer (data for this element)
- nint dataPtr = 0;
- if (offset + 16 <= data.Length)
- dataPtr = (nint)BitConverter.ToInt64(data, offset + 8);
-
- sb.AppendLine($"+0x{offset:X4}: [{name}] data: 0x{dataPtr:X}");
- }
- else
- {
- sb.AppendLine($"+0x{offset:X4}: 0x{value:X} [module ?]");
- }
- }
- }
-
- return sb.ToString();
- }
-
- ///
- /// Scans the LocalPlayer's component list, resolves RTTI names, and searches each component
- /// for known vital values (HP, Mana, ES) to determine component indices and offsets.
- ///
- public string ScanComponents(int hpValue, int manaValue, int esValue)
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- var snap = new GameStateSnapshot();
- var inGameState = ResolveInGameState(snap);
- if (inGameState == 0) return "Error: InGameState not resolved";
-
- var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
- if (ingameData == 0) return "Error: AreaInstance not resolved";
-
- // Try direct LocalPlayer offset first, then ServerData chain
- var localPlayer = nint.Zero;
- if (_offsets.LocalPlayerDirectOffset > 0)
- localPlayer = _memory.ReadPointer(ingameData + _offsets.LocalPlayerDirectOffset);
- if (localPlayer == 0)
- {
- var serverData = _memory.ReadPointer(ingameData + _offsets.ServerDataOffset);
- if (serverData != 0)
- localPlayer = _memory.ReadPointer(serverData + _offsets.LocalPlayerOffset);
- }
- if (localPlayer == 0) return "Error: LocalPlayer not resolved";
-
- var sb = new StringBuilder();
- sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
-
- // Find component list (tries direct, inner entity, and StdVector scan)
- var (compBegin, vectorCount) = FindComponentList(localPlayer);
- if (compBegin == 0) return "Error: ComponentList not found (tried direct, inner entity, and scan)";
-
- sb.AppendLine($"ComponentList: 0x{compBegin:X} count: {vectorCount}");
- sb.AppendLine(new string('═', 90));
-
- var searchValues = new (string name, int value)[]
- {
- ("HP", hpValue),
- ("Mana", manaValue),
- ("ES", esValue)
- };
-
- // Determine scan limit: use vector bounds if valid, otherwise scan up to 64 slots
- var maxSlots = vectorCount > 0 ? vectorCount : 64;
- var realCount = 0;
-
- for (var i = 0; i < maxSlots; i++)
- {
- var compPtr = _memory.ReadPointer(compBegin + i * 8);
- if (compPtr == 0) continue; // Null slot
-
- // Filter bogus pointers (pointing back into entity/list region)
- var distFromEntity = Math.Abs((long)(compPtr - localPlayer));
- if (distFromEntity < 0x200)
- {
- sb.AppendLine($"[{i,2}] 0x{compPtr:X} (skip: within entity struct)");
- continue;
- }
-
- var high = (ulong)compPtr >> 32;
- if (high == 0 || high >= 0x7FFF)
- {
- if (vectorCount == 0) break; // No vector bounds, stop on invalid
- continue;
- }
-
- realCount++;
-
- // Get RTTI name
- var vtable = _memory.ReadPointer(compPtr);
- string? rtti = null;
- if (vtable != 0 && IsModuleAddress(vtable))
- rtti = ResolveRttiName(vtable);
-
- var line = $"[{i,2}] 0x{compPtr:X} {rtti ?? "?"}";
-
- // Scan this component's memory for vital values (deeper: 0x2000 = 8KB)
- var compSize = 0x2000;
- var compData = _memory.ReadBytes(compPtr, compSize);
- if (compData is not null)
- {
- var hits = new List();
- foreach (var (vName, vValue) in searchValues)
- {
- if (vValue == 0) continue;
- for (var off = 0; off + 4 <= compData.Length; off += 4)
- {
- var val = BitConverter.ToInt32(compData, off);
- if (val == vValue)
- hits.Add($"{vName}={vValue}@+0x{off:X}");
- }
- }
- if (hits.Count > 0)
- line += $"\n ◄ {string.Join(", ", hits)}";
- }
-
- sb.AppendLine(line);
- }
-
- sb.AppendLine($"\nReal components: {realCount}");
-
- // Also scan the entity struct itself (first 0x200 bytes) for vitals
- sb.AppendLine($"\n── Entity scan (0x{localPlayer:X}, 0x200 bytes) ──");
- var entityData = _memory.ReadBytes(localPlayer, 0x200);
- if (entityData is not null)
- {
- foreach (var (vName, vValue) in searchValues)
- {
- if (vValue == 0) continue;
- for (var off = 0; off + 4 <= entityData.Length; off += 4)
- {
- var val = BitConverter.ToInt32(entityData, off);
- if (val == vValue)
- sb.AppendLine($" Entity+0x{off:X}: {vName}={vValue}");
- }
- }
- }
-
- return sb.ToString();
- }
-
- ///
- /// Scans all components for float triplets (X,Y,Z) that look like world coordinates.
- /// Finds the Render component and the correct position offsets within it.
- ///
- public string ScanPosition()
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- var snap = new GameStateSnapshot();
- var inGameState = ResolveInGameState(snap);
- if (inGameState == 0) return "Error: InGameState not resolved";
-
- var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
- if (ingameData == 0) return "Error: AreaInstance not resolved";
-
- var localPlayer = nint.Zero;
- if (_offsets.LocalPlayerDirectOffset > 0)
- localPlayer = _memory.ReadPointer(ingameData + _offsets.LocalPlayerDirectOffset);
- if (localPlayer == 0)
- {
- var serverData = _memory.ReadPointer(ingameData + _offsets.ServerDataOffset);
- if (serverData != 0)
- localPlayer = _memory.ReadPointer(serverData + _offsets.LocalPlayerOffset);
- }
- if (localPlayer == 0) return "Error: LocalPlayer not resolved";
-
- var sb = new StringBuilder();
- sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
-
- var (compBegin, vectorCount) = FindComponentList(localPlayer);
- if (compBegin == 0) return "Error: ComponentList not found";
-
- sb.AppendLine($"ComponentList: 0x{compBegin:X} count: {vectorCount}");
- sb.AppendLine(new string('═', 90));
- sb.AppendLine("Scanning each component (8KB) for float triplets that look like world coordinates...");
- sb.AppendLine("(X,Y in 50-50000, |Z| < 5000, consecutive floats at 4-byte alignment)");
- sb.AppendLine();
-
- var maxSlots = vectorCount > 0 ? vectorCount : 64;
-
- for (var i = 0; i < maxSlots; i++)
- {
- var compPtr = _memory.ReadPointer(compBegin + i * 8);
- if (compPtr == 0) continue;
-
- var high = (ulong)compPtr >> 32;
- if (high == 0 || high >= 0x7FFF) continue;
- if ((compPtr & 0x3) != 0) continue;
-
- // Skip pointers that are too close to entity struct
- var dist = Math.Abs((long)(compPtr - localPlayer));
- if (dist < 0x200) continue;
-
- // RTTI
- var vtable = _memory.ReadPointer(compPtr);
- string? rtti = null;
- if (vtable != 0 && IsModuleAddress(vtable))
- rtti = ResolveRttiName(vtable);
-
- // Read 8KB of component data
- var compData = _memory.ReadBytes(compPtr, 0x2000);
- if (compData is null) continue;
-
- var hits = new List();
- for (var off = 0; off + 12 <= compData.Length; off += 4)
- {
- var x = BitConverter.ToSingle(compData, off);
- var y = BitConverter.ToSingle(compData, off + 4);
- var z = BitConverter.ToSingle(compData, off + 8);
-
- if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) continue;
- if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) continue;
- if (x < 50 || x > 50000 || y < 50 || y > 50000) continue;
- if (MathF.Abs(z) > 5000) continue;
-
- hits.Add($"+0x{off:X}: ({x:F1}, {y:F1}, {z:F1})");
- }
-
- if (hits.Count > 0)
- {
- sb.AppendLine($"[{i,2}] 0x{compPtr:X} {rtti ?? "?"}");
- foreach (var hit in hits.Take(10))
- sb.AppendLine($" ◄ {hit}");
- if (hits.Count > 10)
- sb.AppendLine($" ... and {hits.Count - 10} more");
- sb.AppendLine();
- }
- }
-
- sb.AppendLine("Done.");
- sb.AppendLine();
- sb.AppendLine("If a component shows position hits, update offsets.json:");
- sb.AppendLine(" PositionXOffset = the offset shown (e.g. 0xB0)");
- sb.AppendLine(" PositionYOffset = PositionXOffset + 4");
- sb.AppendLine(" PositionZOffset = PositionXOffset + 8");
-
- return sb.ToString();
- }
-
- ///
- /// Diagnostic: shows the ECS vitals reading state — LocalPlayer, component list, cached Life index, current values.
- ///
- public string DiagnoseVitals()
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- var snap = new GameStateSnapshot();
- var inGameState = ResolveInGameState(snap);
- if (inGameState == 0) return "Error: InGameState not resolved";
-
- var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
- if (ingameData == 0) return "Error: IngameData not resolved";
-
- // Resolve LocalPlayer
- var localPlayer = nint.Zero;
- if (_offsets.LocalPlayerDirectOffset > 0)
- localPlayer = _memory.ReadPointer(ingameData + _offsets.LocalPlayerDirectOffset);
- if (localPlayer == 0)
- {
- var serverData = _memory.ReadPointer(ingameData + _offsets.ServerDataOffset);
- if (serverData != 0)
- localPlayer = _memory.ReadPointer(serverData + _offsets.LocalPlayerOffset);
- }
-
- var sb = new StringBuilder();
- sb.AppendLine("── ECS Vitals Diagnostics ──");
- sb.AppendLine($"IngameData: 0x{ingameData:X}");
- sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
- sb.AppendLine($"Cached Life index: {_cachedLifeIndex}");
- sb.AppendLine($"Last LocalPlayer: 0x{_lastLocalPlayer:X}");
- sb.AppendLine();
-
- if (localPlayer == 0)
- {
- sb.AppendLine("FAIL: LocalPlayer is null");
- return sb.ToString();
- }
-
- // Read StdVector at entity+ComponentListOffset
- var compFirst = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset);
- var compLast = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset + 8);
- var compEnd = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset + 16);
-
- var count = 0;
- if (compFirst != 0 && compLast > compFirst && (compLast - compFirst) < 0x2000)
- count = (int)((compLast - compFirst) / 8);
-
- sb.AppendLine($"ComponentList StdVector (entity+0x{_offsets.ComponentListOffset:X}):");
- sb.AppendLine($" First: 0x{compFirst:X}");
- sb.AppendLine($" Last: 0x{compLast:X}");
- sb.AppendLine($" End: 0x{compEnd:X}");
- sb.AppendLine($" Count: {count}");
- sb.AppendLine();
-
- if (count <= 0)
- {
- sb.AppendLine("FAIL: Component list empty or invalid");
- return sb.ToString();
- }
-
- // Scan components for Life pattern
- sb.AppendLine($"── Component scan ({count} entries) ──");
- for (var i = 0; i < count && i < 40; i++)
- {
- var compPtr = _memory.ReadPointer(compFirst + i * 8);
- if (compPtr == 0)
- {
- sb.AppendLine($"[{i,2}] (null)");
- continue;
- }
-
- var high = (ulong)compPtr >> 32;
- if (high == 0 || high >= 0x7FFF)
- {
- sb.AppendLine($"[{i,2}] 0x{compPtr:X} (invalid pointer)");
- continue;
- }
-
- // RTTI
- var vtable = _memory.ReadPointer(compPtr);
- string? rtti = null;
- if (vtable != 0 && IsModuleAddress(vtable))
- rtti = ResolveRttiName(vtable);
-
- // Check VitalStruct pattern
- var hpTotal = _memory.Read(compPtr + _offsets.LifeHealthOffset + _offsets.VitalTotalOffset);
- var hpCurr = _memory.Read(compPtr + _offsets.LifeHealthOffset + _offsets.VitalCurrentOffset);
- var manaTotal = _memory.Read(compPtr + _offsets.LifeManaOffset + _offsets.VitalTotalOffset);
- var manaCurr = _memory.Read(compPtr + _offsets.LifeManaOffset + _offsets.VitalCurrentOffset);
- var esTotal = _memory.Read(compPtr + _offsets.LifeEsOffset + _offsets.VitalTotalOffset);
- var esCurr = _memory.Read(compPtr + _offsets.LifeEsOffset + _offsets.VitalCurrentOffset);
-
- var hpOk = hpTotal > 0 && hpTotal < 200000 && hpCurr >= 0 && hpCurr <= hpTotal + 1000;
- var manaOk = manaTotal >= 0 && manaTotal < 200000 && manaCurr >= 0 && manaCurr <= manaTotal + 1000;
-
- var lifeTag = (hpOk && manaOk) ? $" ◄ LIFE HP={hpCurr}/{hpTotal} Mana={manaCurr}/{manaTotal} ES={esCurr}/{esTotal}" : "";
- var cached = (i == _cachedLifeIndex) ? " [CACHED]" : "";
- sb.AppendLine($"[{i,2}] 0x{compPtr:X} {rtti ?? "?"}{lifeTag}{cached}");
- }
-
- return sb.ToString();
- }
-
- ///
- /// Diagnostic: dumps LocalPlayer entity structure, component list with RTTI names,
- /// VitalStruct pattern matches, and ObjectHeader alternative path.
- ///
- public string DiagnoseEntity()
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- var snap = new GameStateSnapshot();
- var inGameState = ResolveInGameState(snap);
- if (inGameState == 0) return "Error: InGameState not resolved";
-
- var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
- if (ingameData == 0) return "Error: IngameData not resolved";
-
- // Resolve LocalPlayer
- var localPlayer = nint.Zero;
- if (_offsets.LocalPlayerDirectOffset > 0)
- localPlayer = _memory.ReadPointer(ingameData + _offsets.LocalPlayerDirectOffset);
- if (localPlayer == 0)
- {
- var serverData = _memory.ReadPointer(ingameData + _offsets.ServerDataOffset);
- if (serverData != 0)
- localPlayer = _memory.ReadPointer(serverData + _offsets.LocalPlayerOffset);
- }
- if (localPlayer == 0) return "Error: LocalPlayer not resolved";
-
- var sb = new StringBuilder();
- sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
-
- // RTTI of entity
- var entityVtable = _memory.ReadPointer(localPlayer);
- string? entityRtti = null;
- if (entityVtable != 0 && IsModuleAddress(entityVtable))
- entityRtti = ResolveRttiName(entityVtable);
- sb.AppendLine($"Entity RTTI: {entityRtti ?? "?"} (vtable: 0x{entityVtable:X})");
- sb.AppendLine();
-
- // Hex dump of first 0x100 bytes with pointer classification
- sb.AppendLine("── Entity hex dump (first 0x100 bytes) ──");
- var entityData = _memory.ReadBytes(localPlayer, 0x100);
- if (entityData is not null)
- {
- for (var row = 0; row < entityData.Length; row += 16)
- {
- sb.Append($"+0x{row:X3}: ");
- for (var col = 0; col < 16 && row + col < entityData.Length; col++)
- {
- sb.Append($"{entityData[row + col]:X2} ");
- if (col == 7) sb.Append(' ');
- }
-
- // Pointer interpretation for each 8-byte slot
- sb.Append(" | ");
- for (var slot = 0; slot < 16 && row + slot + 8 <= entityData.Length; slot += 8)
- {
- var val = (nint)BitConverter.ToInt64(entityData, row + slot);
- if (val == 0) continue;
- if (IsModuleAddress(val))
- {
- var name = ResolveRttiName(val);
- sb.Append(name is not null ? $"[{name}] " : "[module] ");
- }
- else
- {
- var h = (ulong)val >> 32;
- if (h > 0 && h < 0x7FFF && (val & 0x3) == 0)
- sb.Append("[heap] ");
- }
- }
- sb.AppendLine();
- }
- }
- sb.AppendLine();
-
- // StdVector at entity+ComponentListOffset
- sb.AppendLine($"── StdVector ComponentList at entity+0x{_offsets.ComponentListOffset:X} ──");
- var compFirst = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset);
- var compLast = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset + 8);
- var compEnd = _memory.ReadPointer(localPlayer + _offsets.ComponentListOffset + 16);
-
- sb.AppendLine($"First: 0x{compFirst:X}");
- sb.AppendLine($"Last: 0x{compLast:X}");
- sb.AppendLine($"End: 0x{compEnd:X}");
-
- var vectorCount = 0;
- if (compFirst != 0 && compLast > compFirst && (compLast - compFirst) < 0x2000)
- vectorCount = (int)((compLast - compFirst) / 8);
- sb.AppendLine($"Element count: {vectorCount}");
- sb.AppendLine();
-
- // List components with RTTI names and VitalStruct pattern check
- if (vectorCount > 0)
- {
- var maxShow = Math.Min(vectorCount, 30);
- sb.AppendLine($"── Components ({maxShow} of {vectorCount}) ──");
- for (var i = 0; i < maxShow; i++)
- {
- var compPtr = _memory.ReadPointer(compFirst + i * 8);
- if (compPtr == 0)
- {
- sb.AppendLine($"[{i,2}] (null)");
- continue;
- }
-
- var high = (ulong)compPtr >> 32;
- if (high == 0 || high >= 0x7FFF)
- {
- sb.AppendLine($"[{i,2}] 0x{compPtr:X} (bad pointer)");
- continue;
- }
-
- // RTTI
- var compVtable = _memory.ReadPointer(compPtr);
- string? compRtti = null;
- if (compVtable != 0 && IsModuleAddress(compVtable))
- compRtti = ResolveRttiName(compVtable);
-
- // VitalStruct pattern check
- var hpTotal = _memory.Read(compPtr + _offsets.LifeHealthOffset + _offsets.VitalTotalOffset);
- var hpCurr = _memory.Read(compPtr + _offsets.LifeHealthOffset + _offsets.VitalCurrentOffset);
- var manaTotal = _memory.Read(compPtr + _offsets.LifeManaOffset + _offsets.VitalTotalOffset);
- var manaCurr = _memory.Read(compPtr + _offsets.LifeManaOffset + _offsets.VitalCurrentOffset);
- var esTotal = _memory.Read(compPtr + _offsets.LifeEsOffset + _offsets.VitalTotalOffset);
- var esCurr = _memory.Read(compPtr + _offsets.LifeEsOffset + _offsets.VitalCurrentOffset);
-
- var hpOk = hpTotal > 0 && hpTotal < 200000 && hpCurr >= 0 && hpCurr <= hpTotal + 1000;
- var manaOk = manaTotal >= 0 && manaTotal < 200000 && manaCurr >= 0 && manaCurr <= manaTotal + 1000;
-
- var lifeTag = (hpOk && manaOk)
- ? $"\n ◄ LIFE: HP={hpCurr}/{hpTotal}, Mana={manaCurr}/{manaTotal}, ES={esCurr}/{esTotal}"
- : "";
-
- // Position float triplet check
- var px = _memory.Read(compPtr + _offsets.PositionXOffset);
- var py = _memory.Read(compPtr + _offsets.PositionYOffset);
- var pz = _memory.Read(compPtr + _offsets.PositionZOffset);
- var posTag = (!float.IsNaN(px) && !float.IsNaN(py) && !float.IsNaN(pz) &&
- px > 50 && px < 50000 && py > 50 && py < 50000 && MathF.Abs(pz) < 5000)
- ? $"\n ◄ RENDER: Pos=({px:F1}, {py:F1}, {pz:F1})"
- : "";
-
- sb.AppendLine($"[{i,2}] 0x{compPtr:X} {compRtti ?? "?"}{lifeTag}{posTag}");
- }
- }
- sb.AppendLine();
-
- // Follow entity+0x000 as potential "inner entity" (POE2 wrapper pattern)
- sb.AppendLine($"── Inner Entity (entity+0x000 deref) ──");
- var innerEntity = _memory.ReadPointer(localPlayer);
- sb.AppendLine($"Ptr: 0x{innerEntity:X}");
- if (innerEntity != 0 && !IsModuleAddress(innerEntity))
- {
- var innerHigh = (ulong)innerEntity >> 32;
- if (innerHigh > 0 && innerHigh < 0x7FFF && (innerEntity & 0x3) == 0)
- {
- // Read inner entity vtable and RTTI
- var innerVtable = _memory.ReadPointer(innerEntity);
- string? innerRtti = null;
- if (innerVtable != 0 && IsModuleAddress(innerVtable))
- innerRtti = ResolveRttiName(innerVtable);
- sb.AppendLine($"Inner vtable: 0x{innerVtable:X} RTTI: {innerRtti ?? "?"}");
-
- // Inner entity hex dump (first 0x80 bytes)
- var innerData = _memory.ReadBytes(innerEntity, 0x80);
- if (innerData is not null)
- {
- for (var row = 0; row < innerData.Length; row += 16)
- {
- sb.Append($" +0x{row:X3}: ");
- for (var col = 0; col < 16 && row + col < innerData.Length; col++)
- {
- sb.Append($"{innerData[row + col]:X2} ");
- if (col == 7) sb.Append(' ');
- }
- sb.AppendLine();
- }
- }
-
- // Check StdVector at inner+ComponentListOffset
- var innerFirst = _memory.ReadPointer(innerEntity + _offsets.ComponentListOffset);
- var innerLast = _memory.ReadPointer(innerEntity + _offsets.ComponentListOffset + 8);
- var innerCount = 0;
- if (innerFirst != 0 && innerLast > innerFirst && (innerLast - innerFirst) < 0x2000)
- innerCount = (int)((innerLast - innerFirst) / 8);
- sb.AppendLine($"Inner ComponentList (inner+0x{_offsets.ComponentListOffset:X}): First=0x{innerFirst:X}, count={innerCount}");
-
- // List inner components
- if (innerCount > 1)
- {
- var maxInner = Math.Min(innerCount, 20);
- for (var i = 0; i < maxInner; i++)
- {
- var cp = _memory.ReadPointer(innerFirst + i * 8);
- if (cp == 0) { sb.AppendLine($" [{i,2}] (null)"); continue; }
- var cv = _memory.ReadPointer(cp);
- string? cr = null;
- if (cv != 0 && IsModuleAddress(cv)) cr = ResolveRttiName(cv);
-
- var ht = _memory.Read(cp + _offsets.LifeHealthOffset + _offsets.VitalTotalOffset);
- var hc = _memory.Read(cp + _offsets.LifeHealthOffset + _offsets.VitalCurrentOffset);
- var mt = _memory.Read(cp + _offsets.LifeManaOffset + _offsets.VitalTotalOffset);
- var mc = _memory.Read(cp + _offsets.LifeManaOffset + _offsets.VitalCurrentOffset);
- var hpOk2 = ht > 0 && ht < 200000 && hc >= 0 && hc <= ht + 1000;
- var mOk2 = mt >= 0 && mt < 200000 && mc >= 0 && mc <= mt + 1000;
- var lt = (hpOk2 && mOk2) ? $" ◄ LIFE HP={hc}/{ht} Mana={mc}/{mt}" : "";
- sb.AppendLine($" [{i,2}] 0x{cp:X} {cr ?? "?"}{lt}");
- }
- }
- }
- }
- sb.AppendLine();
-
- // Scan entity memory for all StdVector-like patterns with >1 pointer-sized elements
- sb.AppendLine("── StdVector scan (entity 0x000-0x300) ──");
- var scanData = _memory.ReadBytes(localPlayer, 0x300);
- if (scanData is not null)
- {
- for (var off = 0; off + 24 <= scanData.Length; off += 8)
- {
- var f = (nint)BitConverter.ToInt64(scanData, off);
- var l = (nint)BitConverter.ToInt64(scanData, off + 8);
- if (f == 0 || l <= f) continue;
- var sz = l - f;
- if (sz < 16 || sz > 0x2000 || sz % 8 != 0) continue;
- var n = (int)(sz / 8);
-
- // Quick validate: first element should be non-zero
- var firstEl = _memory.ReadPointer(f);
- if (firstEl == 0) continue;
-
- var tag = "";
- var elHigh = (ulong)firstEl >> 32;
- if (elHigh > 0 && elHigh < 0x7FFF && (firstEl & 0x3) == 0)
- tag = " [ptr elements]";
- else if (firstEl < 0x100000)
- tag = " [small int elements]";
-
- sb.AppendLine($"entity+0x{off:X3}: StdVector count={n}{tag} First=0x{f:X}");
- }
- }
- sb.AppendLine();
-
- // Extended entity dump (+0x100 to +0x200) for finding component-related offsets
- sb.AppendLine("── Entity extended dump (+0x100 to +0x200) ──");
- var extData = _memory.ReadBytes(localPlayer + 0x100, 0x100);
- if (extData is not null)
- {
- for (var row = 0; row < extData.Length; row += 16)
- {
- var absOff = row + 0x100;
- sb.Append($"+0x{absOff:X3}: ");
- for (var col = 0; col < 16 && row + col < extData.Length; col++)
- {
- sb.Append($"{extData[row + col]:X2} ");
- if (col == 7) sb.Append(' ');
- }
- sb.Append(" | ");
- for (var slot = 0; slot < 16 && row + slot + 8 <= extData.Length; slot += 8)
- {
- var val = (nint)BitConverter.ToInt64(extData, row + slot);
- if (val == 0) continue;
- if (IsModuleAddress(val))
- {
- var name = ResolveRttiName(val);
- sb.Append(name is not null ? $"[{name}] " : "[module] ");
- }
- else
- {
- var h = (ulong)val >> 32;
- if (h > 0 && h < 0x7FFF && (val & 0x3) == 0)
- sb.Append("[heap] ");
- }
- }
- sb.AppendLine();
- }
- }
-
- // ECS cache state
- sb.AppendLine();
- sb.AppendLine($"── ECS Cache ──");
- sb.AppendLine($"Cached Life index: {_cachedLifeIndex}");
- sb.AppendLine($"Last LocalPlayer: 0x{_lastLocalPlayer:X}");
-
- // FindComponentList result
- var (bestFirst, bestCount) = FindComponentList(localPlayer);
- sb.AppendLine($"FindComponentList result: First=0x{bestFirst:X}, count={bestCount}");
-
- return sb.ToString();
- }
-
- ///
- /// Deep scan: follows pointers from AreaInstance and LocalPlayer, scanning each target for vital values.
- /// Uses two-level search to find the correct pointer chain to vitals.
- ///
- public string DeepScanVitals(int hpValue, int manaValue, int esValue)
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- var snap = new GameStateSnapshot();
- var inGameState = ResolveInGameState(snap);
- if (inGameState == 0) return "Error: InGameState not resolved";
-
- var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
- if (ingameData == 0) return "Error: AreaInstance not resolved";
-
- var sb = new StringBuilder();
- sb.AppendLine($"Deep vital scan — HP={hpValue}, Mana={manaValue}, ES={esValue}");
- sb.AppendLine($"AreaInstance: 0x{ingameData:X}");
-
- var searchValues = new List<(string name, int value)>();
- if (hpValue > 0) searchValues.Add(("HP", hpValue));
- if (manaValue > 0) searchValues.Add(("Mana", manaValue));
- if (esValue > 0) searchValues.Add(("ES", esValue));
-
- if (searchValues.Count == 0) return "Error: enter at least one vital value";
-
- // Level 1: scan AreaInstance pointers (first 0x1200 bytes covers all known offsets)
- var areaData = _memory.ReadBytes(ingameData, 0x1200);
- if (areaData is null) return "Error: failed to read AreaInstance";
-
- var visited = new HashSet();
- var results = new List();
-
- for (var off1 = 0; off1 + 8 <= areaData.Length; off1 += 8)
- {
- var ptr1 = (nint)BitConverter.ToInt64(areaData, off1);
- if (ptr1 == 0 || ptr1 == ingameData) continue;
- var h = (ulong)ptr1 >> 32;
- if (h == 0 || h >= 0x7FFF) continue;
- if ((ptr1 & 0x3) != 0) continue;
- if (IsModuleAddress(ptr1)) continue;
- if (!visited.Add(ptr1)) continue;
-
- // Scan this target for vital values (first 0xC00 bytes)
- var targetData = _memory.ReadBytes(ptr1, 0xC00);
- if (targetData is null) continue;
-
- var hits = FindVitalHits(targetData, searchValues);
- if (hits.Count > 0)
- {
- var vtable = _memory.ReadPointer(ptr1);
- var rtti = vtable != 0 && IsModuleAddress(vtable) ? ResolveRttiName(vtable) : null;
- results.Add($"Area+0x{off1:X} → 0x{ptr1:X} [{rtti ?? "?"}]\n {string.Join(", ", hits)}");
- }
-
- // Level 2: follow pointers FROM this target (first 0x200 bytes)
- for (var off2 = 0; off2 + 8 <= Math.Min(targetData.Length, 0x200); off2 += 8)
- {
- var ptr2 = (nint)BitConverter.ToInt64(targetData, off2);
- if (ptr2 == 0 || ptr2 == ptr1 || ptr2 == ingameData) continue;
- var h2 = (ulong)ptr2 >> 32;
- if (h2 == 0 || h2 >= 0x7FFF) continue;
- if ((ptr2 & 0x3) != 0) continue;
- if (IsModuleAddress(ptr2)) continue;
- if (!visited.Add(ptr2)) continue;
-
- var deepData = _memory.ReadBytes(ptr2, 0xC00);
- if (deepData is null) continue;
-
- var deepHits = FindVitalHits(deepData, searchValues);
- if (deepHits.Count > 0)
- {
- results.Add($"Area+0x{off1:X} → +0x{off2:X} → 0x{ptr2:X}\n {string.Join(", ", deepHits)}");
- }
- }
- }
-
- sb.AppendLine($"\nFound {results.Count} objects with vital matches:");
- sb.AppendLine(new string('─', 80));
- foreach (var r in results)
- sb.AppendLine(r);
-
- return sb.ToString();
- }
-
- private static List FindVitalHits(byte[] data, List<(string name, int value)> searchValues)
- {
- // Find hits and check for CLUSTERS (multiple vitals within 0x40 bytes of each other)
- var allHits = new List<(string name, int value, int offset)>();
- foreach (var (vName, vValue) in searchValues)
- {
- for (var off = 0; off + 4 <= data.Length; off += 4)
- {
- if (BitConverter.ToInt32(data, off) == vValue)
- allHits.Add((vName, vValue, off));
- }
- }
-
- // Only return if we have at least 2 different vitals, or a single vital at a reasonable offset
- var distinctVitals = allHits.Select(h => h.name).Distinct().Count();
- if (distinctVitals >= 2)
- {
- // Find clusters where 2+ vitals are within 0x80 bytes
- var clusters = new List();
- foreach (var h1 in allHits)
- {
- foreach (var h2 in allHits)
- {
- if (h1.name == h2.name) continue;
- if (Math.Abs(h1.offset - h2.offset) <= 0x80)
- {
- clusters.Add($"{h1.name}@+0x{h1.offset:X}, {h2.name}@+0x{h2.offset:X}");
- }
- }
- }
- if (clusters.Count > 0)
- return clusters.Distinct().Take(10).ToList();
- }
-
- return [];
- }
-
- ///
- /// Probes InGameState to find sub-structure offsets by looking for recognizable data patterns.
- /// Scans the object for heap pointers and checks each for area level, entity counts, terrain, etc.
- ///
- public string ProbeInGameState()
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- // Resolve InGameState
- var snap = new GameStateSnapshot();
- var inGameState = ResolveInGameState(snap);
- if (inGameState == 0) return $"Error: InGameState not resolved (states: {snap.StatesCount})";
-
- var sb = new StringBuilder();
- sb.AppendLine($"InGameState: 0x{inGameState:X} (State[{_offsets.InGameStateIndex}] of {snap.StatesCount})");
-
- // Read InGameState vtable for identification
- var igsVtable = _memory.ReadPointer(inGameState);
- sb.AppendLine($"Vtable: 0x{igsVtable:X}");
- sb.AppendLine(new string('═', 80));
-
- // Scan InGameState for all heap pointers (potential sub-object data pointers)
- var igsSize = 0x8000;
- var igsData = _memory.ReadBytes(inGameState, igsSize);
- if (igsData is null) return "Error: failed to read InGameState";
-
- // Collect unique data pointers to probe
- var candidates = new List<(int offset, nint ptr)>();
- for (var off = 0; off + 8 <= igsData.Length; off += 8)
- {
- var val = (nint)BitConverter.ToInt64(igsData, off);
- if (val == 0) continue;
- // Only probe heap pointers (not module addresses, not self-references)
- if (IsModuleAddress(val)) continue;
- if (val == inGameState) continue;
- var high = (ulong)val >> 32;
- if (high == 0 || high >= 0x7FFF) continue;
- if ((val & 0x3) != 0) continue;
- // Skip self-referencing pointers (InGameState + small offset)
- if (val >= inGameState && val < inGameState + igsSize) continue;
- candidates.Add((off, val));
- }
-
- sb.AppendLine($"Found {candidates.Count} heap pointer candidates");
- sb.AppendLine();
-
- // Probe patterns for AreaInstance identification
- // Dump offsets + POE1 reference + nearby values for version drift
- var probeOffsets = new (string name, int offset, string type)[]
- {
- // Area level — byte at dump 0xAC, strong signal (value 1-100)
- ("AreaLevel byte (0xAC)", 0xAC, "byte_level"), // Dump
- ("AreaLevel byte (0xBC)", 0xBC, "byte_level"), // Near dump (version drift)
- ("AreaLevel int (0xAC)", 0xAC, "int_level"), // Dump as int
- ("AreaLevel int (0xBC)", 0xBC, "int_level"), // Near dump as int
- ("AreaLevel int (0xD4)", 0xD4, "int_level"), // POE1
- // Area hash (dump: 0xEC)
- ("AreaHash (0xEC)", 0xEC, "nonzero32"), // Dump
- // Server data pointer (dump: 0x9F0 via LocalPlayerStruct)
- ("ServerData (0x9F0)", 0x9F0, "ptr"), // Dump
- ("ServerData (0xA00)", 0xA00, "ptr"), // Near dump
- // Local player pointer (dump: 0xA10)
- ("LocalPlayer (0xA10)", 0xA10, "ptr"), // Dump
- ("LocalPlayer (0xA20)", 0xA20, "ptr"), // Near dump
- // Entity list — StdMap at dump 0xAF8. StdMap.head at +0, .size at +8
- ("Entities.head (0xAF8)", 0xAF8, "ptr"), // Dump
- ("Entities.size (0xB00)", 0xB00, "int_count"), // Dump StdMap._Mysize
- ("Entities.head (0xB58)", 0xB58, "ptr"), // Near dump
- ("Entities.size (0xB60)", 0xB60, "int_count"), // Near dump
- // Terrain inline (dump: 0xCC0)
- ("Terrain (0xCC0)", 0xCC0, "nonzero64"), // Dump (inline, check for data)
- };
-
- // Track best matches
- var matches = new List<(int igsOffset, nint ptr, string desc, int score)>();
-
- foreach (var (off, ptr) in candidates)
- {
- var score = 0;
- var details = new List();
-
- // Also try RTTI on the candidate object itself
- var candidateVtable = _memory.ReadPointer(ptr);
- string? candidateRtti = null;
- if (candidateVtable != 0 && IsModuleAddress(candidateVtable))
- candidateRtti = ResolveRttiName(candidateVtable);
-
- // Try AreaInstance-like probes
- foreach (var (name, probeOff, probeType) in probeOffsets)
- {
- if (probeType == "ptr")
- {
- var val = _memory.ReadPointer(ptr + probeOff);
- if (val != 0 && !IsModuleAddress(val))
- {
- var h = (ulong)val >> 32;
- if (h > 0 && h < 0x7FFF)
- {
- score++;
- details.Add($" {name} = 0x{val:X} ✓");
- }
- }
- }
- else if (probeType == "byte_level")
- {
- var val = _memory.Read(ptr + probeOff);
- if (val > 0 && val <= 100)
- {
- score += 3; // Strong signal
- details.Add($" {name} = {val} ✓✓✓");
- }
- }
- else if (probeType == "int_level")
- {
- var val = _memory.Read(ptr + probeOff);
- if (val > 0 && val <= 100)
- {
- score += 3;
- details.Add($" {name} = {val} ✓✓✓");
- }
- }
- else if (probeType == "int_count")
- {
- var val = _memory.Read(ptr + probeOff);
- if (val > 0 && val < 10000)
- {
- score += 2;
- details.Add($" {name} = {val} ✓✓");
- }
- }
- else if (probeType == "nonzero32")
- {
- var val = _memory.Read(ptr + probeOff);
- if (val != 0)
- {
- score++;
- details.Add($" {name} = 0x{val:X8} ✓");
- }
- }
- else if (probeType == "nonzero64")
- {
- var val = _memory.Read(ptr + probeOff);
- if (val != 0)
- {
- score++;
- details.Add($" {name} = 0x{val:X} ✓");
- }
- }
- }
-
- // RTTI bonus: if object has a known class name, boost score significantly
- if (candidateRtti is not null)
- {
- details.Insert(0, $" RTTI: {candidateRtti}");
- if (candidateRtti.Contains("AreaInstance") || candidateRtti.Contains("IngameData")
- || candidateRtti.Contains("WorldInstance"))
- score += 10; // Very strong signal
- }
-
- if (score >= 3)
- {
- matches.Add((off, ptr, string.Join("\n", details), score));
- }
- }
-
- // Sort by score descending
- matches.Sort((a, b) => b.score.CompareTo(a.score));
-
- if (matches.Count == 0)
- {
- sb.AppendLine("No matches found with known offset patterns.");
- sb.AppendLine("Try scanning InGameState with the raw Scan tool.");
+ matrixAddr = inGameState + offsets.CameraMatrixOffset;
}
- else
- {
- sb.AppendLine($"── Top matches (by score) ──");
- sb.AppendLine();
- foreach (var (off, ptr, desc, score) in matches.Take(15))
- {
- sb.AppendLine($"IGS+0x{off:X3} → 0x{ptr:X} (score: {score})");
- sb.AppendLine(desc);
- sb.AppendLine();
- }
- }
-
- // Check InGameState at dump-predicted offsets directly
- sb.AppendLine("── Dump-predicted InGameState fields ──");
- var dumpFields = new (string name, int offset)[]
- {
- ("AreaInstanceData", 0x290),
- ("WorldData", 0x2F8),
- ("UiRootPtr", 0x648),
- ("IngameUi", 0xC40),
- };
- foreach (var (fname, foff) in dumpFields)
- {
- if (foff + 8 <= igsSize)
- {
- var val = (nint)BitConverter.ToInt64(igsData, foff);
- if (val != 0)
- {
- var tag = ClassifyPointer(val);
- string extra = "";
- // If it's a heap pointer, try RTTI
- if (tag == "heap ptr")
- {
- var vt = _memory.ReadPointer(val);
- if (vt != 0 && IsModuleAddress(vt))
- {
- var rtti = ResolveRttiName(vt);
- if (rtti != null) extra = $" RTTI={rtti}";
- }
- // Quick check: does it look like an AreaInstance?
- var lvl = _memory.Read(val + 0xAC);
- var lvl2 = _memory.Read(val + 0xBC);
- if (lvl > 0 && lvl <= 100) extra += $" lvl@0xAC={lvl}";
- if (lvl2 > 0 && lvl2 <= 100) extra += $" lvl@0xBC={lvl2}";
- }
- sb.AppendLine($" IGS+0x{foff:X}: {fname} = 0x{val:X} [{tag ?? "?"}]{extra}");
- }
- else
- {
- sb.AppendLine($" IGS+0x{foff:X}: {fname} = (null)");
- }
- }
- }
- sb.AppendLine();
-
- // Also scan InGameState directly for position-like floats
- sb.AppendLine("── Position-like floats in InGameState ──");
- for (var off = 0; off + 12 <= igsData.Length; off += 4)
- {
- var x = BitConverter.ToSingle(igsData, off);
- var y = BitConverter.ToSingle(igsData, off + 4);
- var z = BitConverter.ToSingle(igsData, off + 8);
- // Look for reasonable world coordinates (POE2: typically 100-10000 range)
- if (x > 100 && x < 50000 && y > 100 && y < 50000 &&
- !float.IsNaN(x) && !float.IsNaN(y) && !float.IsNaN(z) &&
- MathF.Abs(z) < 5000)
- {
- sb.AppendLine($" IGS+0x{off:X4}: ({x:F1}, {y:F1}, {z:F1})");
- }
- }
-
- return sb.ToString();
- }
-
- private static bool IsReasonableFloat(float f)
- {
- if (float.IsNaN(f) || float.IsInfinity(f)) return false;
- var abs = MathF.Abs(f);
- return abs > 0.001f && abs < 100000f;
- }
-
- private static string FormatBytes(byte[]? data)
- {
- if (data is null) return "Error: read failed";
- return BitConverter.ToString(data).Replace('-', ' ');
- }
-
- private string ReadNullTermString(nint addr)
- {
- var data = _memory!.ReadBytes(addr, 256);
- if (data is null) return "Error: read failed";
- var end = Array.IndexOf(data, (byte)0);
- if (end < 0) end = data.Length;
- return Encoding.UTF8.GetString(data, 0, end);
- }
-
- // ════════════════════════════════════════════════════════════════
- // Component Lookup — name→index hash table on EntityDetails
- // ════════════════════════════════════════════════════════════════
-
- ///
- /// Reads an MSVC std::string (narrow, UTF-8/ASCII) from the given address.
- /// Layout: _Bx (16 bytes: SSO buffer or heap ptr), _Mysize (8), _Myres (8).
- /// SSO threshold: capacity <= 15 (16 bytes buffer).
- ///
- private string? ReadMsvcString(nint stringAddr)
- {
- var size = _memory!.Read(stringAddr + 0x10);
- var capacity = _memory.Read(stringAddr + 0x18);
-
- if (size <= 0 || size > 512 || capacity < size) return null;
-
- nint dataAddr;
- if (capacity <= 15)
- {
- // SSO: data is inline in the 16-byte _Bx buffer
- dataAddr = stringAddr;
- }
- else
- {
- // Heap-allocated
- dataAddr = _memory.ReadPointer(stringAddr);
- if (dataAddr == 0) return null;
- }
-
- var bytes = _memory.ReadBytes(dataAddr, (int)size);
- if (bytes is null) return null;
-
- var str = Encoding.UTF8.GetString(bytes);
- if (str.Length > 0 && str[0] >= 0x20 && str[0] <= 0x7E)
- return str;
-
- return null;
- }
-
- ///
- /// Diagnostic: scan the component lookup structure for an entity to figure out the POE2 layout.
- /// Reads EntityDetails → ComponentLookupPtr and dumps memory, trying to find component name strings.
- ///
- public string ScanComponentLookup()
- {
- if (_memory is null) return "Error: not attached";
-
- var sb = new StringBuilder();
-
- // Get local player
- var inGameState = ResolveInGameState(new GameStateSnapshot());
- if (inGameState == 0) return "Error: can't resolve InGameState";
-
- var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
- if (ingameData == 0) return "Error: can't resolve AreaInstance";
-
- var localPlayer = _memory.ReadPointer(ingameData + _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 = _memory.ReadPointer(ingameData + _offsets.EntityListOffset);
- if (sentinel != 0)
- {
- var root = _memory.ReadPointer(sentinel + _offsets.EntityNodeParentOffset);
- var count = 0;
- WalkTreeInOrder(sentinel, root, 20, node =>
- {
- var entityPtr = _memory.ReadPointer(node + _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 = 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();
- }
-
- private void ScanOneEntityComponentLookup(StringBuilder sb, nint entity)
- {
- // Step 1: Read EntityDetails — handle inner entity (ECS wrapper) pattern
- var detailsPtr = _memory!.ReadPointer(entity + _offsets.EntityHeaderOffset);
- var high = (ulong)detailsPtr >> 32;
-
- if (high == 0 || high >= 0x7FFF)
- {
- sb.AppendLine($" entity+0x{_offsets.EntityHeaderOffset:X} = 0x{detailsPtr:X} (invalid — trying inner entity)");
-
- // POE2 ECS wrapper: entity+0x00 → inner entity → +0x08 → EntityDetails
- var innerEntity = _memory.ReadPointer(entity);
- if (innerEntity != 0 && innerEntity != entity && !IsModuleAddress(innerEntity))
- {
- var innerHigh = (ulong)innerEntity >> 32;
- if (innerHigh > 0 && innerHigh < 0x7FFF && (innerEntity & 0x7) == 0)
- {
- detailsPtr = _memory.ReadPointer(innerEntity + _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) = 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 = _memory.ReadPointer(detailsPtr + 0x28);
- if (lookupObjPtr == 0 || !IsValidHeapPtr(lookupObjPtr))
- {
- sb.AppendLine($" details+0x28 = 0x{lookupObjPtr:X} (not a valid pointer)");
- return;
- }
-
- sb.AppendLine($" ComponentLookup object: 0x{lookupObjPtr:X}");
-
- var objData = _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} ({(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 = _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 (!IsValidHeapPtr(fieldPtr)) continue;
-
- // Try as std::string (MSVC narrow)
- var name = 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 = 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 = _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 = _memory.ReadPointer(fieldPtr);
- if (innerPtr != 0 && IsValidHeapPtr(innerPtr))
- {
- var innerName = 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 = 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}\"");
- }
- }
- }
- }
- }
-
- private bool IsValidHeapPtr(nint ptr)
- {
- if (ptr == 0) return false;
- var high = (ulong)ptr >> 32;
- return high > 0 && high < 0x7FFF && (ptr & 0x3) == 0;
- }
- ///
- /// Resolves EntityDetails pointer for an entity, handling ECS inner entity wrapper.
- /// POE2 local player (and possibly others) wraps the real entity: entity+0x00 → inner entity → +0x08 → details.
- ///
- private nint ResolveEntityDetails(nint entity)
- {
- if (_memory is null) return 0;
+ // Read 64-byte Matrix4x4 as 16 floats
+ var bytes = mem.ReadBytes(matrixAddr, 64);
+ if (bytes is null || bytes.Length < 64) return;
- var detailsPtr = _memory.ReadPointer(entity + _offsets.EntityHeaderOffset);
- if (IsValidHeapPtr(detailsPtr))
- return detailsPtr;
+ var m = new Matrix4x4(
+ BitConverter.ToSingle(bytes, 0), BitConverter.ToSingle(bytes, 4), BitConverter.ToSingle(bytes, 8), BitConverter.ToSingle(bytes, 12),
+ BitConverter.ToSingle(bytes, 16), BitConverter.ToSingle(bytes, 20), BitConverter.ToSingle(bytes, 24), BitConverter.ToSingle(bytes, 28),
+ BitConverter.ToSingle(bytes, 32), BitConverter.ToSingle(bytes, 36), BitConverter.ToSingle(bytes, 40), BitConverter.ToSingle(bytes, 44),
+ BitConverter.ToSingle(bytes, 48), BitConverter.ToSingle(bytes, 52), BitConverter.ToSingle(bytes, 56), BitConverter.ToSingle(bytes, 60));
- // Inner entity fallback: entity+0x00 → inner → inner+0x08 → details
- var innerEntity = _memory.ReadPointer(entity);
- if (innerEntity == 0 || innerEntity == entity || IsModuleAddress(innerEntity))
- return 0;
- if (!IsValidHeapPtr(innerEntity))
- return 0;
-
- detailsPtr = _memory.ReadPointer(innerEntity + _offsets.EntityHeaderOffset);
- return IsValidHeapPtr(detailsPtr) ? detailsPtr : 0;
- }
-
- ///
- /// Reads a null-terminated char* string from a module-range or heap address.
- /// Component names are char* literals in .rdata, e.g. "Life", "Render", "Monster".
- ///
- private string? ReadCharPtr(nint ptr)
- {
- if (ptr == 0) return null;
- var data = _memory!.ReadBytes(ptr, 64);
- if (data is null) return null;
- var end = Array.IndexOf(data, (byte)0);
- if (end < 1 || end > 50) return null;
- var str = Encoding.ASCII.GetString(data, 0, end);
- // Sanity: must be printable ASCII
- if (str.Length > 0 && str.All(c => c >= 0x20 && c <= 0x7E))
- return str;
- return null;
- }
-
- ///
- /// Reads the component name→index mapping for an entity.
- /// Chain: entity → EntityDetails(+0x28) → ComponentLookup obj(+0x28/+0x30) → Vec2 entries.
- /// Each 16-byte entry: { char* name (8), int32 index (4), int32 flags (4) }.
- ///
- public Dictionary? ReadComponentLookup(nint entity)
- {
- if (_memory is null) return null;
- if (_offsets.ComponentLookupEntrySize == 0) return null;
-
- var detailsPtr = ResolveEntityDetails(entity);
- if (detailsPtr == 0) return null;
-
- // EntityDetails + ComponentLookupOffset → ComponentLookup object
- var lookupObj = _memory.ReadPointer(detailsPtr + _offsets.ComponentLookupOffset);
- if (!IsValidHeapPtr(lookupObj)) return null;
-
- // ComponentLookup object + Vec2Offset → Vec2 begin/end
- var vec2Begin = _memory.ReadPointer(lookupObj + _offsets.ComponentLookupVec2Offset);
- var vec2End = _memory.ReadPointer(lookupObj + _offsets.ComponentLookupVec2Offset + 8);
- if (vec2Begin == 0 || vec2End <= vec2Begin) return null;
-
- var size = vec2End - vec2Begin;
- var entrySize = _offsets.ComponentLookupEntrySize;
- if (size % entrySize != 0 || size > 0x10000) return null;
-
- var entryCount = (int)(size / entrySize);
-
- // Batch read all entries at once for performance
- var allData = _memory.ReadBytes(vec2Begin, (int)size);
- if (allData is null) return null;
-
- var result = new Dictionary(entryCount);
-
- for (var i = 0; i < entryCount; i++)
- {
- var entryOff = i * entrySize;
-
- // Read char* name pointer
- var namePtr = (nint)BitConverter.ToInt64(allData, entryOff + _offsets.ComponentLookupNameOffset);
- if (namePtr == 0) continue;
-
- var name = ReadCharPtr(namePtr);
- if (name is null) continue;
-
- // Read int32 index
- var index = BitConverter.ToInt32(allData, entryOff + _offsets.ComponentLookupIndexOffset);
- if (index < 0 || index > 200) continue;
-
- result[name] = index;
- }
-
- return result.Count > 0 ? result : null;
- }
-
- ///
- /// Checks if an entity has a component by name.
- ///
- public bool HasComponent(nint entity, string componentName)
- {
- var lookup = ReadComponentLookup(entity);
- return lookup?.ContainsKey(componentName) == true;
- }
-
- ///
- /// Gets the component pointer by name from an entity's component list.
- ///
- public nint GetComponentAddress(nint entity, string componentName)
- {
- var lookup = ReadComponentLookup(entity);
- if (lookup is null || !lookup.TryGetValue(componentName, out var index))
- return 0;
-
- var (compFirst, count) = FindComponentList(entity);
- if (index < 0 || index >= count) return 0;
-
- return _memory!.ReadPointer(compFirst + index * 8);
- }
-
- // ════════════════════════════════════════════════════════════════
- // ScanTerrain — diagnostic for finding terrain struct layout
- // ════════════════════════════════════════════════════════════════
-
- public string ScanTerrain()
- {
- if (_memory is null) return "Error: not attached";
- if (_gameStateBase == 0) return "Error: GameState base not resolved";
-
- var snap = new GameStateSnapshot();
- var inGameState = ResolveInGameState(snap);
- if (inGameState == 0) return "Error: InGameState not resolved";
-
- var areaInstance = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
- if (areaInstance == 0) return "Error: AreaInstance is null";
-
- var sb = new StringBuilder();
- sb.AppendLine($"AreaInstance: 0x{areaInstance:X}");
- sb.AppendLine($"TerrainListOffset: 0x{_offsets.TerrainListOffset:X}");
-
- var terrainBase = areaInstance + _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 = _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 = _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 = _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 = _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 * _offsets.SubTilesPerCell;
- var gridHeight = currentRows * _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 = _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 = _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();
- }
-
- 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);
- }
+ // Quick sanity check
+ if (float.IsNaN(m.M11) || float.IsInfinity(m.M11)) return;
- bmp.Save(path, ImageFormat.Png);
+ snap.CameraMatrix = m;
}
public void Dispose()
diff --git a/src/Automata.Memory/TerrainOffsets.cs b/src/Automata.Memory/GameOffsets.cs
similarity index 73%
rename from src/Automata.Memory/TerrainOffsets.cs
rename to src/Automata.Memory/GameOffsets.cs
index 99a3f24..ca988a2 100644
--- a/src/Automata.Memory/TerrainOffsets.cs
+++ b/src/Automata.Memory/GameOffsets.cs
@@ -4,7 +4,7 @@ using Serilog;
namespace Automata.Memory;
-public sealed class TerrainOffsets
+public sealed class GameOffsets
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -24,8 +24,6 @@ public sealed class TerrainOffsets
public int PatternResultAdjust { get; set; } = 0x18;
// ── GameState → States ──
- // Dump: GameStateOffset { [0x08] StdVector CurrentStatePtr, [0x48] GameStateBuffer States }
- // GameStateBuffer = StdTuple2D (begin/end pair). Each entry is an IntPtr (8 bytes).
/// Offset to States begin/end pair in GameState (dump: 0x48).
public int StatesBeginOffset { get; set; } = 0x48;
/// Bytes per state entry (16 for inline slots, 8 for vector of pointers).
@@ -38,7 +36,7 @@ public sealed class TerrainOffsets
public int InGameStateIndex { get; set; } = 4;
/// Offset from controller to active states vector begin/end pair (ExileCore: 0x20).
public int ActiveStatesOffset { get; set; } = 0x20;
- /// Offset from controller to the active state pointer. When it != InGameState, we're loading. 0 = disabled, use ScanAreaLoadingState to find.
+ /// Offset from controller to the active state pointer. When it != InGameState, we're loading. 0 = disabled.
public int IsLoadingOffset { get; set; } = 0;
/// If true, states are inline in the controller struct. If false, StatesBeginOffset points to a begin/end vector pair.
public bool StatesInline { get; set; } = true;
@@ -46,7 +44,6 @@ public sealed class TerrainOffsets
public int InGameStateDirectOffset { get; set; } = 0x210;
// ── InGameState → sub-structures ──
- // Dump: InGameStateOffset { [0x208] EscapeState flags, [0x298] AreaInstanceData, [0x2F8] WorldData, [0x648] UiRootPtr, [0xC40] IngameUi }
/// InGameState → EscapeState int32 flag (0=closed, 1=open). Diff-scan confirmed: 0x20C. 0 = disabled.
public int EscapeStateOffset { get; set; } = 0x20C;
/// InGameState → AreaInstance (IngameData) pointer (dump: 0x298, CE confirmed: 0x290).
@@ -55,14 +52,6 @@ public sealed class TerrainOffsets
public int WorldDataFromStateOffset { get; set; } = 0x2F8;
// ── AreaInstance (IngameData) → sub-structures ──
- // Dump: AreaInstanceOffsets {
- // [0xAC] byte CurrentAreaLevel,
- // [0xEC] uint CurrentAreaHash,
- // [0x948] StdVector Environments,
- // [0x9F0] LocalPlayerStruct PlayerInfo, ← contains ServerDataPtr at +0x0, LocalPlayerPtr at +0x20
- // [0xAF8] EntityListStruct Entities, ← StdMap AwakeEntities + StdMap SleepingEntities
- // [0xCC0] TerrainStruct TerrainMetadata ← inline, not behind pointer
- // }
/// AreaInstance → CurrentAreaLevel (dump: byte at 0xAC, CE confirmed: byte at 0xC4).
public int AreaLevelOffset { get; set; } = 0xC4;
/// If true, AreaLevel is a byte. If false, read as int.
@@ -75,14 +64,12 @@ public sealed class TerrainOffsets
public int ServerDataOffset { get; set; } = 0x9F0;
/// AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr).
public int LocalPlayerDirectOffset { get; set; } = 0xA10;
- /// AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50). Contains StdMap AwakeEntities then SleepingEntities.
+ /// AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50).
public int EntityListOffset { get; set; } = 0xB50;
/// Offset within StdMap to _Mysize (entity count). MSVC std::map: head(8) + size(8).
public int EntityCountInternalOffset { get; set; } = 0x08;
// ── Entity list node layout (MSVC std::map red-black tree) ──
- // Node: _Left(+0x00), _Parent(+0x08), _Right(+0x10), _Color(+0x18), _Myval(+0x20)
- // _Myval = pair
/// Tree node → left child pointer.
public int EntityNodeLeftOffset { get; set; } = 0x00;
/// Tree node → parent pointer.
@@ -97,7 +84,7 @@ public sealed class TerrainOffsets
public int EntityFlagsOffset { get; set; } = 0x84;
/// Entity → EntityDetailsPtr (Head/MainObject pointer, +0x08).
public int EntityDetailsOffset { get; set; } = 0x08;
- /// EntityDetails → std::string path (MSVC layout: ptr/SSO at +0, size at +0x10, capacity at +0x18). Offset within EntityDetails struct.
+ /// EntityDetails → std::string path (MSVC layout). Offset within EntityDetails struct.
public int EntityPathStringOffset { get; set; } = 0x08;
// ServerData → fields
@@ -105,14 +92,12 @@ public sealed class TerrainOffsets
public int LocalPlayerOffset { get; set; } = 0x20;
// ── Entity / Component ──
- // Dump: ItemStruct { [0x0] VTablePtr, [0x8] EntityDetailsPtr, [0x10] StdVector ComponentListPtr }
- // Dump: EntityOffsets { [0x0] ItemStruct, [0x80] uint Id, [0x84] byte IsValid }
public int ComponentListOffset { get; set; } = 0x10;
/// Entity → ObjectHeader pointer (for alternative component lookup via name→index map). ExileCore: 0x08.
public int EntityHeaderOffset { get; set; } = 0x08;
- /// EntityDetails → ComponentLookup object pointer. Confirmed: 0x28 (right after wstring path).
+ /// EntityDetails → ComponentLookup object pointer. Confirmed: 0x28.
public int ComponentLookupOffset { get; set; } = 0x28;
- /// ComponentLookup object → Vec2 begin/end (name entry array). Object layout: +0x10=Vec1(ptrs), +0x28=Vec2(names).
+ /// ComponentLookup object → Vec2 begin/end (name entry array).
public int ComponentLookupVec2Offset { get; set; } = 0x28;
/// Size of each entry in Vec2 (bytes). Confirmed: 16 = { char* name (8), int32 index (4), int32 flags (4) }.
public int ComponentLookupEntrySize { get; set; } = 16;
@@ -125,17 +110,11 @@ public sealed class TerrainOffsets
/// Index of Render/Position component in entity's component list. -1 = unknown.
public int RenderComponentIndex { get; set; } = -1;
- // ── Life component (via direct chain from AreaInstance) ──
- // Deep scan confirmed: AreaInstance+0x420 → ptr+0x98 → Life component
- // VitalStruct gaps match dump: HP→Mana = 0x50, Mana→ES = 0x38
- // HP.Current@+0x188, Mana.Current@+0x1D8, ES.Current@+0x210
+ // ── Life component ──
/// First offset from AreaInstance to reach Life component (AreaInstance → ptr). 0 = use entity component list instead.
public int LifeComponentOffset1 { get; set; } = 0x420;
/// Second offset from intermediate pointer to Life component (ptr → Life).
public int LifeComponentOffset2 { get; set; } = 0x98;
- // VitalStruct offsets within Life component (VitalStruct base, add VitalCurrentOffset/VitalTotalOffset)
- // ECS inner entity path: HP@+0x1D8 = 0x1A8+0x30, Mana@+0x228 = 0x1F8+0x30, ES@+0x260 = 0x230+0x30
- // (shifted +0x50 from old direct chain due to inner entity component header)
public int LifeHealthOffset { get; set; } = 0x1A8;
public int LifeManaOffset { get; set; } = 0x1F8;
public int LifeEsOffset { get; set; } = 0x230;
@@ -143,22 +122,17 @@ public sealed class TerrainOffsets
public int VitalTotalOffset { get; set; } = 0x2C;
// ── Render/Position component ──
- // Scan confirmed: position float triplet at +0x138 in component [10] (with real Z height)
- // Dump reference (older): RenderOffsets { [0xB0] CurrentWorldPosition } — shifted to 0x138 in current build
public int PositionXOffset { get; set; } = 0x138;
public int PositionYOffset { get; set; } = 0x13C;
public int PositionZOffset { get; set; } = 0x140;
+ // ── Camera (for WorldToScreen projection) ──
+ /// Offset from InGameState to Camera pointer. 0 = disabled (use ScanCamera to discover).
+ public int CameraOffset { get; set; } = 0x308;
+ /// Offset within Camera struct to the Matrix4x4 (64 bytes). 0 = disabled.
+ public int CameraMatrixOffset { get; set; } = 0x1A0;
+
// ── Terrain (inline in AreaInstance) ──
- // Scan-confirmed TerrainStruct (at AreaInstance + 0xCC0):
- // [0x90] StdTuple2D TotalTiles (cols, rows as int64)
- // [0xA0] StdVector TileDetailsPtr (56 bytes/tile)
- // [0x100] int32 cols, int32 rows (redundant compact dims)
- // [0x148] StdVector GridWalkableData (size = bytesPerRow * gridHeight)
- // [0x160] StdVector GridLandscapeData
- // [0x178] StdVector Grid3
- // [0x190] StdVector Grid4
- // [0x1A8] int BytesPerRow = ceil(cols * 23 / 2)
/// Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).
public int TerrainListOffset { get; set; } = 0xCC0;
/// If true, terrain is inline in AreaInstance (no pointer dereference). If false, follow pointer.
@@ -173,21 +147,12 @@ public sealed class TerrainOffsets
public int TerrainGridPtrOffset { get; set; } = 0x08;
public int SubTilesPerCell { get; set; } = 23;
- // Legacy terrain offsets (used by TerrainReader)
- public int InGameStateOffset { get; set; }
- public int IngameDataOffset { get; set; }
- public int TerrainDataOffset { get; set; }
- public int NumColsOffset { get; set; }
- public int NumRowsOffset { get; set; }
- public int LayerMeleeOffset { get; set; }
- public int BytesPerRowOffset { get; set; }
-
- public static TerrainOffsets Load(string path)
+ public static GameOffsets Load(string path)
{
if (!File.Exists(path))
{
Log.Information("Offsets file not found at '{Path}', using defaults", path);
- var defaults = new TerrainOffsets();
+ var defaults = new GameOffsets();
defaults.Save(path);
return defaults;
}
@@ -195,11 +160,11 @@ public sealed class TerrainOffsets
try
{
var json = File.ReadAllText(path);
- var offsets = JsonSerializer.Deserialize(json, JsonOptions);
+ var offsets = JsonSerializer.Deserialize(json, JsonOptions);
if (offsets is null)
{
Log.Warning("Failed to deserialize '{Path}', using defaults", path);
- return new TerrainOffsets();
+ return new GameOffsets();
}
Log.Information("Loaded offsets from '{Path}'", path);
return offsets;
@@ -207,7 +172,7 @@ public sealed class TerrainOffsets
catch (Exception ex)
{
Log.Error(ex, "Error loading offsets from '{Path}'", path);
- return new TerrainOffsets();
+ return new GameOffsets();
}
}
diff --git a/src/Automata.Memory/GameStateReader.cs b/src/Automata.Memory/GameStateReader.cs
new file mode 100644
index 0000000..a24547c
--- /dev/null
+++ b/src/Automata.Memory/GameStateReader.cs
@@ -0,0 +1,203 @@
+using Serilog;
+
+namespace Automata.Memory;
+
+///
+/// Resolves GameState → Controller → InGameState, reads state slots, loading/escape state.
+///
+public sealed class GameStateReader
+{
+ private readonly MemoryContext _ctx;
+
+ public GameStateReader(MemoryContext ctx)
+ {
+ _ctx = ctx;
+ }
+
+ ///
+ /// Resolves InGameState pointer from the GameState controller.
+ ///
+ public nint ResolveInGameState(GameStateSnapshot snap)
+ {
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+
+ var controller = mem.ReadPointer(_ctx.GameStateBase);
+ if (controller == 0) return 0;
+ snap.ControllerPtr = controller;
+
+ // Direct offset mode: read InGameState straight from controller
+ if (offsets.InGameStateDirectOffset > 0)
+ {
+ var igs = mem.ReadPointer(controller + offsets.InGameStateDirectOffset);
+ if (igs != 0)
+ {
+ for (var i = 0; i < 20; i++)
+ {
+ var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
+ var ptr = mem.ReadPointer(controller + slotOffset);
+ if (ptr == 0) break;
+ snap.StatesCount++;
+ }
+ return igs;
+ }
+ }
+
+ if (offsets.StatesInline)
+ {
+ var inlineOffset = offsets.StatesBeginOffset
+ + offsets.InGameStateIndex * offsets.StateStride
+ + offsets.StatePointerOffset;
+
+ for (var i = 0; i < 20; i++)
+ {
+ var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
+ var ptr = mem.ReadPointer(controller + slotOffset);
+ if (ptr == 0) break;
+ snap.StatesCount++;
+ }
+
+ return mem.ReadPointer(controller + inlineOffset);
+ }
+ else
+ {
+ var statesBegin = mem.ReadPointer(controller + offsets.StatesBeginOffset);
+ if (statesBegin == 0) return 0;
+
+ var statesEnd = mem.ReadPointer(controller + offsets.StatesBeginOffset + 8);
+ if (statesEnd > statesBegin && statesEnd - statesBegin < 0x1000 && offsets.StateStride > 0)
+ {
+ snap.StatesCount = (int)((statesEnd - statesBegin) / offsets.StateStride);
+ }
+ else
+ {
+ for (var i = 0; i < 20; i++)
+ {
+ if (mem.ReadPointer(statesBegin + i * offsets.StateStride + offsets.StatePointerOffset) == 0) break;
+ snap.StatesCount++;
+ }
+ }
+
+ if (offsets.InGameStateIndex < 0 || offsets.InGameStateIndex >= snap.StatesCount)
+ return 0;
+
+ return mem.ReadPointer(statesBegin + offsets.InGameStateIndex * offsets.StateStride + offsets.StatePointerOffset);
+ }
+ }
+
+ ///
+ /// Reads all state slot pointers and active states vector from the controller.
+ ///
+ public void ReadStateSlots(GameStateSnapshot snap)
+ {
+ var controller = snap.ControllerPtr;
+ if (controller == 0) return;
+
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
+
+ var count = offsets.StateCount;
+ var slots = new nint[count];
+ for (var i = 0; i < count; i++)
+ {
+ var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
+ slots[i] = mem.ReadPointer(controller + slotOffset);
+ }
+ snap.StateSlots = slots;
+
+ var values = new int[count];
+ for (var i = 0; i < count; i++)
+ {
+ if (slots[i] != 0)
+ values[i] = mem.Read(slots[i] + 0x08);
+ }
+ snap.StateSlotValues = values;
+
+ // Read active states vector
+ if (offsets.ActiveStatesOffset > 0)
+ {
+ var beginPtr = mem.ReadPointer(controller + offsets.ActiveStatesOffset);
+ var endPtr = mem.ReadPointer(controller + offsets.ActiveStatesOffset + 16);
+ snap.ActiveStatesBegin = beginPtr;
+ snap.ActiveStatesEnd = endPtr;
+
+ if (beginPtr != 0 && endPtr > beginPtr)
+ {
+ var size = (int)(endPtr - beginPtr);
+ if (size is > 0 and < 0x1000)
+ {
+ var data = mem.ReadBytes(beginPtr, size);
+ if (data is not null)
+ {
+ var rawList = new List();
+ for (var i = 0; i + 8 <= data.Length; i += offsets.StateStride)
+ {
+ var ptr = (nint)BitConverter.ToInt64(data, i);
+ rawList.Add(ptr);
+ if (ptr != 0)
+ snap.ActiveStates.Add(ptr);
+ }
+ snap.ActiveStatesRaw = rawList.ToArray();
+ }
+ }
+ }
+ }
+
+ // Read all non-null pointer-like qwords from controller (outside state array)
+ var stateArrayStart = offsets.StatesBeginOffset;
+ var stateArrayEnd = stateArrayStart + count * offsets.StateStride;
+ var watches = new List<(int, nint)>();
+
+ var ctrlData = mem.ReadBytes(controller, 0x350);
+ if (ctrlData is not null)
+ {
+ for (var offset = 0; offset + 8 <= ctrlData.Length; offset += 8)
+ {
+ if (offset >= stateArrayStart && offset < stateArrayEnd) continue;
+ var value = (nint)BitConverter.ToInt64(ctrlData, offset);
+ if (value == 0) continue;
+ var high = (ulong)value >> 32;
+ if (high > 0 && high < 0x7FFF && (value & 0x3) == 0)
+ watches.Add((offset, value));
+ }
+ }
+ snap.WatchOffsets = watches.ToArray();
+ }
+
+ ///
+ /// Detects loading by comparing the active state pointer to InGameStatePtr.
+ ///
+ public void ReadIsLoading(GameStateSnapshot snap)
+ {
+ var controller = snap.ControllerPtr;
+ if (controller == 0 || _ctx.Offsets.IsLoadingOffset <= 0)
+ return;
+
+ var value = _ctx.Memory.ReadPointer(controller + _ctx.Offsets.IsLoadingOffset);
+
+ if (value == snap.InGameStatePtr && snap.InGameStatePtr != 0)
+ snap.IsLoading = false;
+ else if (value == 0)
+ snap.IsLoading = false;
+ else
+ snap.IsLoading = true;
+ }
+
+ ///
+ /// Reads escape menu state from active states vector or InGameState flag.
+ ///
+ public void ReadEscapeState(GameStateSnapshot snap)
+ {
+ if (snap.ActiveStates.Count > 0 && snap.StateSlots.Length > 3 && snap.StateSlots[3] != 0)
+ {
+ snap.IsEscapeOpen = snap.ActiveStates.Contains(snap.StateSlots[3]);
+ return;
+ }
+
+ if (snap.InGameStatePtr == 0 || _ctx.Offsets.EscapeStateOffset <= 0)
+ return;
+
+ var value = _ctx.Memory.Read(snap.InGameStatePtr + _ctx.Offsets.EscapeStateOffset);
+ snap.IsEscapeOpen = value != 0;
+ }
+}
diff --git a/src/Automata.Memory/GameStateSnapshot.cs b/src/Automata.Memory/GameStateSnapshot.cs
new file mode 100644
index 0000000..3155fbc
--- /dev/null
+++ b/src/Automata.Memory/GameStateSnapshot.cs
@@ -0,0 +1,68 @@
+using System.Numerics;
+
+namespace Automata.Memory;
+
+public class GameStateSnapshot
+{
+ // Process
+ public bool Attached;
+ public int ProcessId;
+ public nint ModuleBase;
+ public int ModuleSize;
+ public string? Error;
+
+ // GameState
+ public nint GameStateBase;
+ public bool OffsetsConfigured;
+ public int StatesCount;
+
+ // Pointers
+ public nint ControllerPtr;
+ public nint InGameStatePtr;
+ public nint AreaInstancePtr;
+ public nint ServerDataPtr;
+ public nint LocalPlayerPtr;
+
+ // Area
+ public int AreaLevel;
+ public uint AreaHash;
+
+ // Player position (Render component)
+ public bool HasPosition;
+ public float PlayerX, PlayerY, PlayerZ;
+
+ // Player vitals (Life component)
+ public bool HasVitals;
+ public int LifeCurrent, LifeTotal;
+ public int ManaCurrent, ManaTotal;
+ public int EsCurrent, EsTotal;
+
+ // Entities
+ public int EntityCount;
+ public List? Entities;
+
+ // Loading state
+ public bool IsLoading;
+ public bool IsEscapeOpen;
+
+ // Live state flags — individual bytes from InGameState+0x200 region
+ public byte[]? StateFlagBytes; // raw bytes from InGameState+0x200, length 0x30
+ public int StateFlagBaseOffset = 0x200;
+
+ // Active game states (ExileCore state machine)
+ public nint[] StateSlots = Array.Empty(); // State[0]..State[N] pointer values (all 12 slots)
+ public int[]? StateSlotValues; // int32 at state+0x08 for each slot
+ public HashSet ActiveStates = new(); // which state pointers are in the active list
+ public nint ActiveStatesBegin, ActiveStatesEnd; // debug: raw vector pointers
+ public nint[] ActiveStatesRaw = Array.Empty(); // debug: all pointers in the vector
+ public (int Offset, nint Value)[] WatchOffsets = []; // candidate controller offsets
+
+ // Camera
+ public Matrix4x4? CameraMatrix;
+
+ // Terrain
+ public int TerrainWidth, TerrainHeight;
+ public int TerrainCols, TerrainRows;
+ public WalkabilityGrid? Terrain;
+ public int TerrainWalkablePercent;
+}
diff --git a/src/Automata.Memory/MemoryContext.cs b/src/Automata.Memory/MemoryContext.cs
new file mode 100644
index 0000000..a99205f
--- /dev/null
+++ b/src/Automata.Memory/MemoryContext.cs
@@ -0,0 +1,35 @@
+namespace Automata.Memory;
+
+///
+/// Shared state for all memory reader classes. Holds the process handle, offsets, registry,
+/// and resolved module/GameState base addresses.
+///
+public sealed class MemoryContext
+{
+ public ProcessMemory Memory { get; }
+ public GameOffsets Offsets { get; }
+ public ObjectRegistry Registry { get; }
+ public nint ModuleBase { get; set; }
+ public int ModuleSize { get; set; }
+ public nint GameStateBase { get; set; }
+
+ public MemoryContext(ProcessMemory memory, GameOffsets offsets, ObjectRegistry registry)
+ {
+ Memory = memory;
+ Offsets = offsets;
+ Registry = registry;
+ }
+
+ public bool IsModuleAddress(nint value)
+ {
+ return ModuleBase != 0 && ModuleSize > 0 &&
+ value >= ModuleBase && value < ModuleBase + ModuleSize;
+ }
+
+ public bool IsValidHeapPtr(nint ptr)
+ {
+ if (ptr == 0) return false;
+ var high = (ulong)ptr >> 32;
+ return high > 0 && high < 0x7FFF && (ptr & 0x3) == 0;
+ }
+}
diff --git a/src/Automata.Memory/MemoryDiagnostics.cs b/src/Automata.Memory/MemoryDiagnostics.cs
new file mode 100644
index 0000000..3d2f4bc
--- /dev/null
+++ b/src/Automata.Memory/MemoryDiagnostics.cs
@@ -0,0 +1,2625 @@
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Text;
+using Serilog;
+
+namespace Automata.Memory;
+
+///
+/// Diagnostic and scan methods extracted from GameMemoryReader.
+/// All methods produce human-readable string output for the UI diagnostics panel.
+///
+public sealed class MemoryDiagnostics
+{
+ private readonly MemoryContext _ctx;
+ private readonly GameStateReader _stateReader;
+ private readonly ComponentReader _components;
+ private readonly EntityReader _entities;
+ private readonly MsvcStringReader _strings;
+ private readonly RttiResolver _rtti;
+
+ // Memory diff scan storage
+ private Dictionary? _diffBaseline;
+
+ // Camera diff storage
+ private List<(string source, float sx, float sy, float dist, float[] floats)>? _cameraDiffBaseline;
+
+ public MemoryDiagnostics(
+ MemoryContext ctx,
+ GameStateReader stateReader,
+ ComponentReader components,
+ EntityReader entities,
+ MsvcStringReader strings,
+ RttiResolver rtti)
+ {
+ _ctx = ctx;
+ _stateReader = stateReader;
+ _components = components;
+ _entities = entities;
+ _strings = strings;
+ _rtti = rtti;
+ }
+
+ ///
+ /// Diagnostic: collects all state slot pointers, then scans the controller
+ /// and GameState global region for any qword matching ANY state.
+ /// Run in different game states to find the "active state" pointer.
+ ///
+ public string ScanAreaLoadingState()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var controller = _ctx.Memory.ReadPointer(_ctx.GameStateBase);
+ if (controller == 0) return "Error: controller is null";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"GameState global: 0x{_ctx.GameStateBase:X}");
+ sb.AppendLine($"Controller: 0x{controller:X}");
+
+ // Collect all state slot pointers (both ptr1 and ptr2 per slot)
+ var stateSlots = new Dictionary();
+ for (var i = 0; i < 20; i++)
+ {
+ var slotOffset = _ctx.Offsets.StatesBeginOffset + i * _ctx.Offsets.StateStride + _ctx.Offsets.StatePointerOffset;
+ var ptr = _ctx.Memory.ReadPointer(controller + slotOffset);
+ if (ptr == 0) break;
+ stateSlots.TryAdd(ptr, $"State[{i}]");
+
+ // Also grab the second pointer in each 16-byte slot
+ if (_ctx.Offsets.StateStride >= 16)
+ {
+ var ptr2 = _ctx.Memory.ReadPointer(controller + slotOffset + 8);
+ if (ptr2 != 0)
+ stateSlots.TryAdd(ptr2, $"State[{i}]+8");
+ }
+ }
+
+ sb.AppendLine($"Known state values: {stateSlots.Count}");
+ foreach (var kv in stateSlots)
+ sb.AppendLine($" {kv.Value}: 0x{kv.Key:X}");
+ sb.AppendLine(new string('═', 70));
+
+ // ── Scan controller (0x1000 bytes) for any qword matching a state ──
+ sb.AppendLine($"\nController matches (outside state array):");
+ sb.AppendLine(new string('─', 70));
+
+ var stateArrayStart = _ctx.Offsets.StatesBeginOffset;
+ var stateArrayEnd = stateArrayStart + stateSlots.Count * _ctx.Offsets.StateStride;
+ var ctrlData = _ctx.Memory.ReadBytes(controller, 0x1000);
+ if (ctrlData is not null)
+ {
+ for (var offset = 0; offset + 8 <= ctrlData.Length; offset += 8)
+ {
+ // Skip the state array itself
+ if (offset >= stateArrayStart && offset < stateArrayEnd) continue;
+
+ var value = (nint)BitConverter.ToInt64(ctrlData, offset);
+ if (value == 0) continue;
+
+ if (stateSlots.TryGetValue(value, out var stateName))
+ sb.AppendLine($" ctrl+0x{offset:X3}: 0x{value:X} = {stateName}");
+ }
+ }
+
+ // ── Scan GameState global region ──
+ sb.AppendLine($"\nGameState global region matches:");
+ sb.AppendLine(new string('─', 70));
+
+ var globalScanStart = _ctx.GameStateBase - 0x100;
+ var globalData = _ctx.Memory.ReadBytes(globalScanStart, 0x300);
+ if (globalData is not null)
+ {
+ for (var offset = 0; offset + 8 <= globalData.Length; offset += 8)
+ {
+ var value = (nint)BitConverter.ToInt64(globalData, offset);
+ if (value == 0) continue;
+
+ var relToGlobal = offset - 0x100;
+ if (stateSlots.TryGetValue(value, out var stateName))
+ sb.AppendLine($" global{relToGlobal:+#;-#;+0} (0x{globalScanStart + offset:X}): 0x{value:X} = {stateName}");
+ else if (value == controller)
+ sb.AppendLine($" global{relToGlobal:+#;-#;+0} (0x{globalScanStart + offset:X}): 0x{value:X} = controller");
+ }
+ }
+
+ sb.AppendLine(new string('═', 70));
+ sb.AppendLine("Run in different states (in-game, loading, char select).");
+ sb.AppendLine("The offset where the state value CHANGES is the active state ptr.");
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Memory diff scanner. First call captures baseline, second call shows all changed offsets.
+ /// Scans controller (0x1000 bytes) and InGameState (0x1000 bytes).
+ /// Usage: click once in state A, change game state (e.g. open/close Escape), click again.
+ ///
+ public string ScanMemoryDiff()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var controller = _ctx.Memory.ReadPointer(_ctx.GameStateBase);
+ if (controller == 0) return "Error: controller is null";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+
+ // Regions to scan
+ const int scanSize = 0x4000;
+ var regions = new Dictionary
+ {
+ ["Controller"] = controller,
+ };
+ if (inGameState != 0)
+ regions["InGameState"] = inGameState;
+
+ // Follow pointer at controller+0x10 (changed during in-game→login transition)
+ var ctrlPtr10 = _ctx.Memory.ReadPointer(controller + 0x10);
+ if (ctrlPtr10 != 0 && !regions.ContainsValue(ctrlPtr10))
+ regions["Ctrl+0x10 target"] = ctrlPtr10;
+
+ // Read current data
+ var current = new Dictionary();
+ foreach (var (name, addr) in regions)
+ {
+ var data = _ctx.Memory.ReadBytes(addr, scanSize);
+ if (data is not null)
+ current[name] = (addr, data);
+ }
+
+ // First call: save baseline
+ if (_diffBaseline is null)
+ {
+ _diffBaseline = current;
+ var sb = new StringBuilder();
+ sb.AppendLine("=== BASELINE CAPTURED ===");
+ foreach (var (rn, (ra, rd)) in current)
+ sb.AppendLine($" {rn}: 0x{ra:X} ({rd.Length} bytes)");
+ sb.AppendLine();
+ sb.AppendLine("Now change game state (login, char select, in-game, etc.) and click again.");
+ return sb.ToString();
+ }
+
+ // Second call: diff against baseline
+ var baseline = _diffBaseline;
+ _diffBaseline = null; // reset for next pair
+
+ var result = new StringBuilder();
+ result.AppendLine("=== MEMORY DIFF ===");
+ result.AppendLine($"Controller: 0x{controller:X}");
+ if (inGameState != 0)
+ result.AppendLine($"InGameState: 0x{inGameState:X}");
+ result.AppendLine();
+
+ // Resolve known pointers for annotation
+ var knownPtrs = new Dictionary();
+ if (inGameState != 0) knownPtrs[inGameState] = "IGS";
+ knownPtrs[controller] = "Controller";
+ for (var i = 0; i < 20; i++)
+ {
+ var slotOff = _ctx.Offsets.StatesBeginOffset + i * _ctx.Offsets.StateStride;
+ var ptr = _ctx.Memory.ReadPointer(controller + slotOff);
+ if (ptr == 0) break;
+ knownPtrs.TryAdd(ptr, $"State[{i}]");
+ }
+
+ foreach (var (name, (curAddr, curData)) in current)
+ {
+ if (!baseline.TryGetValue(name, out var baseEntry))
+ {
+ result.AppendLine($"── {name}: no baseline (new region) ──");
+ continue;
+ }
+
+ var (baseAddr, baseData) = baseEntry;
+ if (baseAddr != curAddr)
+ {
+ result.AppendLine($"── {name}: address changed 0x{baseAddr:X} → 0x{curAddr:X} ──");
+ continue;
+ }
+
+ var changes = new List();
+ var len = Math.Min(baseData.Length, curData.Length);
+
+ for (var offset = 0; offset + 8 <= len; offset += 8)
+ {
+ var oldVal = BitConverter.ToInt64(baseData, offset);
+ var newVal = BitConverter.ToInt64(curData, offset);
+ if (oldVal == newVal) continue;
+
+ var line = $" +0x{offset:X3}: 0x{oldVal:X16} → 0x{newVal:X16}";
+
+ // Annotate known pointers
+ var oldPtr = (nint)oldVal;
+ var newPtr = (nint)newVal;
+ if (knownPtrs.TryGetValue(oldPtr, out var oldName))
+ line += $" (was {oldName})";
+ if (knownPtrs.TryGetValue(newPtr, out var newName))
+ line += $" (now {newName})";
+
+ // Also check int32 changes at this offset
+ var oldI1 = BitConverter.ToInt32(baseData, offset);
+ var oldI2 = BitConverter.ToInt32(baseData, offset + 4);
+ var newI1 = BitConverter.ToInt32(curData, offset);
+ var newI2 = BitConverter.ToInt32(curData, offset + 4);
+ if (oldI1 != newI1 && oldI2 == newI2)
+ line += $" [int32@+0x{offset:X}: {oldI1} → {newI1}]";
+ else if (oldI1 == newI1 && oldI2 != newI2)
+ line += $" [int32@+0x{offset + 4:X}: {oldI2} → {newI2}]";
+
+ changes.Add(line);
+ }
+
+ // Also check byte-level for small flags (scan each byte for 0↔1 or 0↔non-zero)
+ var byteChanges = new List();
+ for (var offset = 0; offset < len; offset++)
+ {
+ if (baseData[offset] == curData[offset]) continue;
+ // Only report byte changes not already covered by qword changes (check alignment)
+ var qwordOffset = (offset / 8) * 8;
+ var qwordOld = BitConverter.ToInt64(baseData, qwordOffset);
+ var qwordNew = BitConverter.ToInt64(curData, qwordOffset);
+ if (qwordOld != qwordNew) continue; // already reported above
+
+ byteChanges.Add($" +0x{offset:X3}: byte {baseData[offset]:X2} → {curData[offset]:X2}");
+ }
+
+ result.AppendLine($"── {name} (0x{curAddr:X}, {len} bytes) ──");
+ if (changes.Count == 0 && byteChanges.Count == 0)
+ {
+ result.AppendLine(" (no changes)");
+ }
+ else
+ {
+ result.AppendLine($" {changes.Count} qword changes, {byteChanges.Count} byte-only changes:");
+ foreach (var c in changes) result.AppendLine(c);
+ foreach (var c in byteChanges) result.AppendLine(c);
+ }
+ }
+
+ result.AppendLine();
+ result.AppendLine("Click again to capture a new baseline.");
+ return result.ToString();
+ }
+
+ ///
+ /// Scans controller for any begin/end pointer pair whose buffer contains known state slot pointers.
+ /// Also follows each state slot's first pointer field and diffs those sub-objects.
+ ///
+ public string ScanActiveStatesVector()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var controller = _ctx.Memory.ReadPointer(_ctx.GameStateBase);
+ if (controller == 0) return "Error: controller is null";
+
+ // Collect state slot pointers
+ var stateSlotPtrs = new Dictionary();
+ for (var i = 0; i < _ctx.Offsets.StateCount; i++)
+ {
+ var slotOffset = _ctx.Offsets.StatesBeginOffset + i * _ctx.Offsets.StateStride + _ctx.Offsets.StatePointerOffset;
+ var ptr = _ctx.Memory.ReadPointer(controller + slotOffset);
+ if (ptr != 0)
+ stateSlotPtrs[ptr] = i;
+ }
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"Controller: 0x{controller:X}");
+ sb.AppendLine($"Known state slots: {stateSlotPtrs.Count}");
+ sb.AppendLine();
+
+ // Scan controller for vector-like pairs (begin, ?, end) where buffer contains state pointers
+ sb.AppendLine("=== Scanning controller for vectors containing state pointers ===");
+ var ctrlData = _ctx.Memory.ReadBytes(controller, 0x400);
+ if (ctrlData is null) return "Error: cannot read controller";
+
+ var found = false;
+ for (var off = 0; off + 24 <= ctrlData.Length; off += 8)
+ {
+ var p1 = (nint)BitConverter.ToInt64(ctrlData, off);
+ if (p1 == 0) continue;
+ var high1 = (ulong)p1 >> 32;
+ if (high1 == 0 || high1 >= 0x7FFF) continue;
+
+ // Try end at +8 and +16 (std::vector layouts vary)
+ 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 = _ctx.Memory.ReadBytes(p1, size);
+ if (buf is null) continue;
+
+ var matches = new List();
+ for (var bi = 0; bi + 8 <= buf.Length; bi += 8)
+ {
+ var val = (nint)BitConverter.ToInt64(buf, bi);
+ if (stateSlotPtrs.TryGetValue(val, out var slotIdx))
+ matches.Add($"State[{slotIdx}]@buf+0x{bi:X}");
+ }
+
+ if (matches.Count > 0)
+ {
+ found = true;
+ sb.AppendLine($" ctrl+0x{off:X3} (end@+{endDelta}): buf 0x{p1:X}, size={size}, {matches.Count} matches:");
+ foreach (var m in matches)
+ sb.AppendLine($" {m}");
+
+ // Dump the entire buffer
+ sb.Append(" buf: ");
+ for (var bi = 0; bi + 8 <= buf.Length; bi += 8)
+ {
+ var val = (nint)BitConverter.ToInt64(buf, bi);
+ sb.Append($"0x{val:X} ");
+ }
+ sb.AppendLine();
+ }
+ }
+ }
+
+ if (!found)
+ sb.AppendLine(" (none found in controller 0x000-0x400)");
+
+ // Also check InGameState for vectors containing state pointers
+ var snap = new GameStateSnapshot();
+ var igs = _stateReader.ResolveInGameState(snap);
+ if (igs != 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine("=== Scanning InGameState for vectors containing state pointers ===");
+ var igsData = _ctx.Memory.ReadBytes(igs, 0x400);
+ if (igsData is not null)
+ {
+ var foundIgs = false;
+ for (var off = 0; off + 24 <= igsData.Length; off += 8)
+ {
+ var p1 = (nint)BitConverter.ToInt64(igsData, off);
+ if (p1 == 0) continue;
+ var high1 = (ulong)p1 >> 32;
+ if (high1 == 0 || high1 >= 0x7FFF) continue;
+
+ foreach (var endDelta in new[] { 8, 16 })
+ {
+ if (off + endDelta + 8 > igsData.Length) continue;
+ var p2 = (nint)BitConverter.ToInt64(igsData, off + endDelta);
+ if (p2 <= p1) continue;
+ var size = (int)(p2 - p1);
+ if (size is < 8 or > 0x400) continue;
+
+ var buf = _ctx.Memory.ReadBytes(p1, size);
+ if (buf is null) continue;
+
+ var matches = new List();
+ for (var bi = 0; bi + 8 <= buf.Length; bi += 8)
+ {
+ var val = (nint)BitConverter.ToInt64(buf, bi);
+ if (stateSlotPtrs.TryGetValue(val, out var slotIdx))
+ matches.Add($"State[{slotIdx}]@buf+0x{bi:X}");
+ }
+
+ if (matches.Count > 0)
+ {
+ foundIgs = true;
+ sb.AppendLine($" igs+0x{off:X3} (end@+{endDelta}): buf 0x{p1:X}, size={size}, {matches.Count} matches:");
+ foreach (var m in matches)
+ sb.AppendLine($" {m}");
+ }
+ }
+ }
+ if (!foundIgs)
+ sb.AppendLine(" (none found in InGameState 0x000-0x400)");
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Diagnostic: walks the entity std::map (red-black tree) from AreaInstance, reports RTTI types and positions.
+ ///
+ public string ScanEntities()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (ingameData == 0) return "Error: AreaInstance not resolved";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"AreaInstance: 0x{ingameData:X}");
+
+ // Read sentinel (tree head node) pointer
+ var sentinel = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.EntityListOffset);
+ if (sentinel == 0)
+ {
+ sb.AppendLine("Entity tree sentinel is null");
+ return sb.ToString();
+ }
+
+ var entityCount = (int)_ctx.Memory.Read(ingameData + _ctx.Offsets.EntityListOffset + _ctx.Offsets.EntityCountInternalOffset);
+
+ // Sentinel layout: _Left = min node, _Parent = root, _Right = max node
+ var root = _ctx.Memory.ReadPointer(sentinel + _ctx.Offsets.EntityNodeParentOffset);
+ sb.AppendLine($"Sentinel: 0x{sentinel:X}");
+ sb.AppendLine($"Root: 0x{root:X}");
+ sb.AppendLine($"Expected count: {entityCount}");
+ sb.AppendLine(new string('═', 90));
+
+ // In-order tree traversal
+ var entities = new List();
+ var maxNodes = Math.Min(entityCount + 10, 500);
+
+ _entities.WalkTreeInOrder(sentinel, root, maxNodes, node =>
+ {
+ var entityPtr = _ctx.Memory.ReadPointer(node + _ctx.Offsets.EntityNodeValueOffset);
+ if (entityPtr == 0) return;
+
+ var high = (ulong)entityPtr >> 32;
+ if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
+
+ var entityId = _ctx.Memory.Read(entityPtr + _ctx.Offsets.EntityIdOffset);
+ var path = _entities.TryReadEntityPath(entityPtr);
+ var entity = new Entity(entityPtr, entityId, path);
+
+ if (_entities.TryReadEntityPosition(entityPtr, out var x, out var y, out var z))
+ {
+ entity.HasPosition = true;
+ entity.X = x;
+ entity.Y = y;
+ entity.Z = z;
+ }
+
+ entities.Add(entity);
+
+ if (entities.Count <= 50)
+ {
+ var posStr = entity.HasPosition ? $"({entity.X:F1}, {entity.Y:F1}, {entity.Z:F1})" : "no pos";
+ var displayPath = entity.Path ?? "?";
+ if (displayPath.Length > 60)
+ displayPath = "..." + displayPath[^57..];
+ sb.AppendLine($"[{entities.Count - 1,3}] ID={entity.Id,-10} {entity.Type,-20} {displayPath}");
+ sb.AppendLine($" {posStr}");
+ }
+ });
+
+ // Summary
+ sb.AppendLine(new string('─', 90));
+ sb.AppendLine($"Total entities walked: {entities.Count}");
+ sb.AppendLine($"With position: {entities.Count(e => e.HasPosition)}");
+ sb.AppendLine($"With path: {entities.Count(e => e.Path is not null)}");
+ sb.AppendLine();
+ sb.AppendLine("Type breakdown:");
+ foreach (var group in entities.GroupBy(e => e.Type).OrderByDescending(g => g.Count()))
+ sb.AppendLine($" {group.Key}: {group.Count()}");
+ sb.AppendLine();
+ sb.AppendLine("Category breakdown:");
+ foreach (var group in entities.GroupBy(e => e.Category).OrderByDescending(g => g.Count()))
+ sb.AppendLine($" {group.Key}: {group.Count()}");
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Scans all game states and returns their info.
+ /// Supports both inline (POE1-style) and vector modes.
+ ///
+ public string ScanAllStates()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var controller = _ctx.Memory.ReadPointer(_ctx.GameStateBase);
+ if (controller == 0) return "Error: controller is null";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"Controller: 0x{controller:X}");
+ sb.AppendLine($"Mode: {(_ctx.Offsets.StatesInline ? "inline" : "vector")}");
+
+ int count;
+ if (_ctx.Offsets.StatesInline)
+ {
+ // Inline: count by scanning slots until null
+ count = 0;
+ for (var i = 0; i < 30; i++)
+ {
+ var slotOffset = _ctx.Offsets.StatesBeginOffset + i * _ctx.Offsets.StateStride + _ctx.Offsets.StatePointerOffset;
+ var ptr = _ctx.Memory.ReadPointer(controller + slotOffset);
+ if (ptr == 0) break;
+ count++;
+ }
+
+ sb.AppendLine($"States: {count} (inline at controller+0x{_ctx.Offsets.StatesBeginOffset:X})");
+ sb.AppendLine(new string('─', 70));
+
+ for (var i = 0; i < count; i++)
+ {
+ var slotOffset = _ctx.Offsets.StatesBeginOffset + i * _ctx.Offsets.StateStride + _ctx.Offsets.StatePointerOffset;
+ var statePtr = _ctx.Memory.ReadPointer(controller + slotOffset);
+
+ string? stateName = null;
+ if (statePtr != 0)
+ {
+ var stateVtable = _ctx.Memory.ReadPointer(statePtr);
+ if (stateVtable != 0 && _ctx.IsModuleAddress(stateVtable))
+ stateName = _rtti.ResolveRttiName(stateVtable);
+ }
+
+ var marker = i == _ctx.Offsets.InGameStateIndex ? " ◄◄◄" : "";
+ var tag = _ctx.IsModuleAddress(statePtr) ? " [module!]" : "";
+ sb.AppendLine($"[{i}] +0x{slotOffset:X}: 0x{statePtr:X} {stateName ?? "?"}{tag}{marker}");
+ }
+
+ return sb.ToString();
+ }
+
+ // Vector mode (fallback)
+ var statesBegin = _ctx.Memory.ReadPointer(controller + _ctx.Offsets.StatesBeginOffset);
+ if (statesBegin == 0) return "Error: states begin is null";
+
+ var statesEnd = _ctx.Memory.ReadPointer(controller + _ctx.Offsets.StatesBeginOffset + 8);
+ count = 0;
+ if (statesEnd > statesBegin && statesEnd - statesBegin < 0x1000 && _ctx.Offsets.StateStride > 0)
+ count = (int)((statesEnd - statesBegin) / _ctx.Offsets.StateStride);
+ else
+ {
+ for (var i = 0; i < 30; i++)
+ {
+ if (_ctx.Memory.ReadPointer(statesBegin + i * _ctx.Offsets.StateStride + _ctx.Offsets.StatePointerOffset) == 0) break;
+ count++;
+ }
+ }
+
+ sb.AppendLine($"States: {count} (vector begin: 0x{statesBegin:X})");
+ sb.AppendLine(new string('─', 70));
+
+ for (var i = 0; i < count; i++)
+ {
+ var entryBase = statesBegin + i * _ctx.Offsets.StateStride;
+ var vtable = _ctx.Memory.ReadPointer(entryBase);
+ var statePtr = _ctx.Memory.ReadPointer(entryBase + _ctx.Offsets.StatePointerOffset);
+
+ var vtableName = vtable != 0 && _ctx.IsModuleAddress(vtable) ? _rtti.ResolveRttiName(vtable) : null;
+
+ string? stateName = null;
+ if (statePtr != 0)
+ {
+ var stateVtable = _ctx.Memory.ReadPointer(statePtr);
+ if (stateVtable != 0 && _ctx.IsModuleAddress(stateVtable))
+ stateName = _rtti.ResolveRttiName(stateVtable);
+ }
+
+ var marker = i == _ctx.Offsets.InGameStateIndex ? " ◄◄◄" : "";
+ sb.AppendLine($"[{i}] 0x{statePtr:X} {stateName ?? "?"}{marker}");
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Scans an object's memory and identifies all RTTI-typed sub-elements.
+ /// Groups by vtable to show the structure with class names.
+ ///
+ public string ScanStructure(string hexAddr, string offsetsCsv, int size)
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+
+ if (!nint.TryParse(hexAddr, NumberStyles.HexNumber, null, out var addr))
+ return $"Error: invalid address '{hexAddr}'";
+
+ if (!string.IsNullOrWhiteSpace(offsetsCsv))
+ {
+ var parts = offsetsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var offsets = new int[parts.Length];
+ for (var i = 0; i < parts.Length; i++)
+ {
+ if (!int.TryParse(parts[i], NumberStyles.HexNumber, null, out offsets[i]))
+ return $"Error: invalid offset '{parts[i]}'";
+ }
+ addr = _ctx.Memory.FollowChain(addr, offsets);
+ if (addr == 0) return "Error: pointer chain broken (null)";
+ }
+
+ size = Math.Clamp(size, 0x10, 0x10000);
+ var data = _ctx.Memory.ReadBytes(addr, size);
+ if (data is null) return "Error: read failed";
+
+ // First pass: get RTTI name for the object itself
+ var objVtable = BitConverter.ToInt64(data, 0);
+ var objName = objVtable != 0 && _ctx.IsModuleAddress((nint)objVtable) ? _rtti.ResolveRttiName((nint)objVtable) : null;
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"Structure: 0x{addr:X} size: 0x{size:X} type: {objName ?? "?"}");
+ sb.AppendLine(new string('─', 80));
+
+ // Scan for all vtable pointers and resolve their RTTI names
+ for (var offset = 0; offset + 8 <= data.Length; offset += 8)
+ {
+ var value = (nint)BitConverter.ToInt64(data, offset);
+ if (value == 0) continue;
+
+ if (_ctx.IsModuleAddress(value))
+ {
+ var name = _rtti.ResolveRttiName(value);
+ if (name is not null)
+ {
+ // Check if next qword is a heap pointer (data for this element)
+ nint dataPtr = 0;
+ if (offset + 16 <= data.Length)
+ dataPtr = (nint)BitConverter.ToInt64(data, offset + 8);
+
+ sb.AppendLine($"+0x{offset:X4}: [{name}] data: 0x{dataPtr:X}");
+ }
+ else
+ {
+ sb.AppendLine($"+0x{offset:X4}: 0x{value:X} [module ?]");
+ }
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Scans the LocalPlayer's component list, resolves RTTI names, and searches each component
+ /// for known vital values (HP, Mana, ES) to determine component indices and offsets.
+ ///
+ public string ScanComponents(int hpValue, int manaValue, int esValue)
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (ingameData == 0) return "Error: AreaInstance not resolved";
+
+ // Try direct LocalPlayer offset first, then ServerData chain
+ var localPlayer = nint.Zero;
+ if (_ctx.Offsets.LocalPlayerDirectOffset > 0)
+ localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
+ if (localPlayer == 0)
+ {
+ var serverData = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.ServerDataOffset);
+ if (serverData != 0)
+ localPlayer = _ctx.Memory.ReadPointer(serverData + _ctx.Offsets.LocalPlayerOffset);
+ }
+ if (localPlayer == 0) return "Error: LocalPlayer not resolved";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
+
+ // Find component list (tries direct, inner entity, and StdVector scan)
+ var (compBegin, vectorCount) = _components.FindComponentList(localPlayer);
+ if (compBegin == 0) return "Error: ComponentList not found (tried direct, inner entity, and scan)";
+
+ sb.AppendLine($"ComponentList: 0x{compBegin:X} count: {vectorCount}");
+ sb.AppendLine(new string('═', 90));
+
+ var searchValues = new (string name, int value)[]
+ {
+ ("HP", hpValue),
+ ("Mana", manaValue),
+ ("ES", esValue)
+ };
+
+ // Determine scan limit: use vector bounds if valid, otherwise scan up to 64 slots
+ var maxSlots = vectorCount > 0 ? vectorCount : 64;
+ var realCount = 0;
+
+ for (var i = 0; i < maxSlots; i++)
+ {
+ var compPtr = _ctx.Memory.ReadPointer(compBegin + i * 8);
+ if (compPtr == 0) continue; // Null slot
+
+ // Filter bogus pointers (pointing back into entity/list region)
+ var distFromEntity = Math.Abs((long)(compPtr - localPlayer));
+ if (distFromEntity < 0x200)
+ {
+ sb.AppendLine($"[{i,2}] 0x{compPtr:X} (skip: within entity struct)");
+ continue;
+ }
+
+ var high = (ulong)compPtr >> 32;
+ if (high == 0 || high >= 0x7FFF)
+ {
+ if (vectorCount == 0) break; // No vector bounds, stop on invalid
+ continue;
+ }
+
+ realCount++;
+
+ // Get RTTI name
+ var vtable = _ctx.Memory.ReadPointer(compPtr);
+ string? rtti = null;
+ if (vtable != 0 && _ctx.IsModuleAddress(vtable))
+ rtti = _rtti.ResolveRttiName(vtable);
+
+ var line = $"[{i,2}] 0x{compPtr:X} {rtti ?? "?"}";
+
+ // Scan this component's memory for vital values (deeper: 0x2000 = 8KB)
+ var compSize = 0x2000;
+ var compData = _ctx.Memory.ReadBytes(compPtr, compSize);
+ if (compData is not null)
+ {
+ var hits = new List();
+ foreach (var (vName, vValue) in searchValues)
+ {
+ if (vValue == 0) continue;
+ for (var off = 0; off + 4 <= compData.Length; off += 4)
+ {
+ var val = BitConverter.ToInt32(compData, off);
+ if (val == vValue)
+ hits.Add($"{vName}={vValue}@+0x{off:X}");
+ }
+ }
+ if (hits.Count > 0)
+ line += $"\n ◄ {string.Join(", ", hits)}";
+ }
+
+ sb.AppendLine(line);
+ }
+
+ sb.AppendLine($"\nReal components: {realCount}");
+
+ // Also scan the entity struct itself (first 0x200 bytes) for vitals
+ sb.AppendLine($"\n── Entity scan (0x{localPlayer:X}, 0x200 bytes) ──");
+ var entityData = _ctx.Memory.ReadBytes(localPlayer, 0x200);
+ if (entityData is not null)
+ {
+ foreach (var (vName, vValue) in searchValues)
+ {
+ if (vValue == 0) continue;
+ for (var off = 0; off + 4 <= entityData.Length; off += 4)
+ {
+ var val = BitConverter.ToInt32(entityData, off);
+ if (val == vValue)
+ sb.AppendLine($" Entity+0x{off:X}: {vName}={vValue}");
+ }
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Scans all components for float triplets (X,Y,Z) that look like world coordinates.
+ /// Finds the Render component and the correct position offsets within it.
+ ///
+ public string ScanPosition()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (ingameData == 0) return "Error: AreaInstance not resolved";
+
+ var localPlayer = nint.Zero;
+ if (_ctx.Offsets.LocalPlayerDirectOffset > 0)
+ localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
+ if (localPlayer == 0)
+ {
+ var serverData = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.ServerDataOffset);
+ if (serverData != 0)
+ localPlayer = _ctx.Memory.ReadPointer(serverData + _ctx.Offsets.LocalPlayerOffset);
+ }
+ if (localPlayer == 0) return "Error: LocalPlayer not resolved";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
+
+ var (compBegin, vectorCount) = _components.FindComponentList(localPlayer);
+ if (compBegin == 0) return "Error: ComponentList not found";
+
+ sb.AppendLine($"ComponentList: 0x{compBegin:X} count: {vectorCount}");
+ sb.AppendLine(new string('═', 90));
+ sb.AppendLine("Scanning each component (8KB) for float triplets that look like world coordinates...");
+ sb.AppendLine("(X,Y in 50-50000, |Z| < 5000, consecutive floats at 4-byte alignment)");
+ sb.AppendLine();
+
+ var maxSlots = vectorCount > 0 ? vectorCount : 64;
+
+ for (var i = 0; i < maxSlots; i++)
+ {
+ var compPtr = _ctx.Memory.ReadPointer(compBegin + i * 8);
+ if (compPtr == 0) continue;
+
+ var high = (ulong)compPtr >> 32;
+ if (high == 0 || high >= 0x7FFF) continue;
+ if ((compPtr & 0x3) != 0) continue;
+
+ // Skip pointers that are too close to entity struct
+ var dist = Math.Abs((long)(compPtr - localPlayer));
+ if (dist < 0x200) continue;
+
+ // RTTI
+ var vtable = _ctx.Memory.ReadPointer(compPtr);
+ string? rtti = null;
+ if (vtable != 0 && _ctx.IsModuleAddress(vtable))
+ rtti = _rtti.ResolveRttiName(vtable);
+
+ // Read 8KB of component data
+ var compData = _ctx.Memory.ReadBytes(compPtr, 0x2000);
+ if (compData is null) continue;
+
+ var hits = new List();
+ for (var off = 0; off + 12 <= compData.Length; off += 4)
+ {
+ var x = BitConverter.ToSingle(compData, off);
+ var y = BitConverter.ToSingle(compData, off + 4);
+ var z = BitConverter.ToSingle(compData, off + 8);
+
+ if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) continue;
+ if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) continue;
+ if (x < 50 || x > 50000 || y < 50 || y > 50000) continue;
+ if (MathF.Abs(z) > 5000) continue;
+
+ hits.Add($"+0x{off:X}: ({x:F1}, {y:F1}, {z:F1})");
+ }
+
+ if (hits.Count > 0)
+ {
+ sb.AppendLine($"[{i,2}] 0x{compPtr:X} {rtti ?? "?"}");
+ foreach (var hit in hits.Take(10))
+ sb.AppendLine($" ◄ {hit}");
+ if (hits.Count > 10)
+ sb.AppendLine($" ... and {hits.Count - 10} more");
+ sb.AppendLine();
+ }
+ }
+
+ sb.AppendLine("Done.");
+ sb.AppendLine();
+ sb.AppendLine("If a component shows position hits, update offsets.json:");
+ sb.AppendLine(" PositionXOffset = the offset shown (e.g. 0xB0)");
+ sb.AppendLine(" PositionYOffset = PositionXOffset + 4");
+ sb.AppendLine(" PositionZOffset = PositionXOffset + 8");
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Diagnostic: shows the ECS vitals reading state — LocalPlayer, component list, cached Life index, current values.
+ ///
+ public string DiagnoseVitals()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (ingameData == 0) return "Error: IngameData not resolved";
+
+ // Resolve LocalPlayer
+ var localPlayer = nint.Zero;
+ if (_ctx.Offsets.LocalPlayerDirectOffset > 0)
+ localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
+ if (localPlayer == 0)
+ {
+ var serverData = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.ServerDataOffset);
+ if (serverData != 0)
+ localPlayer = _ctx.Memory.ReadPointer(serverData + _ctx.Offsets.LocalPlayerOffset);
+ }
+
+ var sb = new StringBuilder();
+ sb.AppendLine("── ECS Vitals Diagnostics ──");
+ sb.AppendLine($"IngameData: 0x{ingameData:X}");
+ sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
+ sb.AppendLine($"Cached Life index: {_components.CachedLifeIndex}");
+ sb.AppendLine($"Last LocalPlayer: 0x{_components.LastLocalPlayer:X}");
+ sb.AppendLine();
+
+ if (localPlayer == 0)
+ {
+ sb.AppendLine("FAIL: LocalPlayer is null");
+ return sb.ToString();
+ }
+
+ // Read StdVector at entity+ComponentListOffset
+ var compFirst = _ctx.Memory.ReadPointer(localPlayer + _ctx.Offsets.ComponentListOffset);
+ var compLast = _ctx.Memory.ReadPointer(localPlayer + _ctx.Offsets.ComponentListOffset + 8);
+ var compEnd = _ctx.Memory.ReadPointer(localPlayer + _ctx.Offsets.ComponentListOffset + 16);
+
+ var count = 0;
+ if (compFirst != 0 && compLast > compFirst && (compLast - compFirst) < 0x2000)
+ count = (int)((compLast - compFirst) / 8);
+
+ sb.AppendLine($"ComponentList StdVector (entity+0x{_ctx.Offsets.ComponentListOffset:X}):");
+ sb.AppendLine($" First: 0x{compFirst:X}");
+ sb.AppendLine($" Last: 0x{compLast:X}");
+ sb.AppendLine($" End: 0x{compEnd:X}");
+ sb.AppendLine($" Count: {count}");
+ sb.AppendLine();
+
+ if (count <= 0)
+ {
+ sb.AppendLine("FAIL: Component list empty or invalid");
+ return sb.ToString();
+ }
+
+ // Scan components for Life pattern
+ sb.AppendLine($"── Component scan ({count} entries) ──");
+ for (var i = 0; i < count && i < 40; i++)
+ {
+ var compPtr = _ctx.Memory.ReadPointer(compFirst + i * 8);
+ if (compPtr == 0)
+ {
+ sb.AppendLine($"[{i,2}] (null)");
+ continue;
+ }
+
+ var high = (ulong)compPtr >> 32;
+ if (high == 0 || high >= 0x7FFF)
+ {
+ sb.AppendLine($"[{i,2}] 0x{compPtr:X} (invalid pointer)");
+ continue;
+ }
+
+ // RTTI
+ var vtable = _ctx.Memory.ReadPointer(compPtr);
+ string? rtti = null;
+ if (vtable != 0 && _ctx.IsModuleAddress(vtable))
+ rtti = _rtti.ResolveRttiName(vtable);
+
+ // Check VitalStruct pattern
+ var hpTotal = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalTotalOffset);
+ var hpCurr = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalCurrentOffset);
+ var manaTotal = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalTotalOffset);
+ var manaCurr = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalCurrentOffset);
+ var esTotal = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeEsOffset + _ctx.Offsets.VitalTotalOffset);
+ var esCurr = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeEsOffset + _ctx.Offsets.VitalCurrentOffset);
+
+ var hpOk = hpTotal > 0 && hpTotal < 200000 && hpCurr >= 0 && hpCurr <= hpTotal + 1000;
+ var manaOk = manaTotal >= 0 && manaTotal < 200000 && manaCurr >= 0 && manaCurr <= manaTotal + 1000;
+
+ var lifeTag = (hpOk && manaOk) ? $" ◄ LIFE HP={hpCurr}/{hpTotal} Mana={manaCurr}/{manaTotal} ES={esCurr}/{esTotal}" : "";
+ var cached = (i == _components.CachedLifeIndex) ? " [CACHED]" : "";
+ sb.AppendLine($"[{i,2}] 0x{compPtr:X} {rtti ?? "?"}{lifeTag}{cached}");
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Diagnostic: dumps LocalPlayer entity structure, component list with RTTI names,
+ /// VitalStruct pattern matches, and ObjectHeader alternative path.
+ ///
+ public string DiagnoseEntity()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (ingameData == 0) return "Error: IngameData not resolved";
+
+ // Resolve LocalPlayer
+ var localPlayer = nint.Zero;
+ if (_ctx.Offsets.LocalPlayerDirectOffset > 0)
+ localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
+ if (localPlayer == 0)
+ {
+ var serverData = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.ServerDataOffset);
+ if (serverData != 0)
+ localPlayer = _ctx.Memory.ReadPointer(serverData + _ctx.Offsets.LocalPlayerOffset);
+ }
+ if (localPlayer == 0) return "Error: LocalPlayer not resolved";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"LocalPlayer: 0x{localPlayer:X}");
+
+ // RTTI of entity
+ var entityVtable = _ctx.Memory.ReadPointer(localPlayer);
+ string? entityRtti = null;
+ if (entityVtable != 0 && _ctx.IsModuleAddress(entityVtable))
+ entityRtti = _rtti.ResolveRttiName(entityVtable);
+ sb.AppendLine($"Entity RTTI: {entityRtti ?? "?"} (vtable: 0x{entityVtable:X})");
+ sb.AppendLine();
+
+ // Hex dump of first 0x100 bytes with pointer classification
+ sb.AppendLine("── Entity hex dump (first 0x100 bytes) ──");
+ var entityData = _ctx.Memory.ReadBytes(localPlayer, 0x100);
+ if (entityData is not null)
+ {
+ for (var row = 0; row < entityData.Length; row += 16)
+ {
+ sb.Append($"+0x{row:X3}: ");
+ for (var col = 0; col < 16 && row + col < entityData.Length; col++)
+ {
+ sb.Append($"{entityData[row + col]:X2} ");
+ if (col == 7) sb.Append(' ');
+ }
+
+ // Pointer interpretation for each 8-byte slot
+ sb.Append(" | ");
+ for (var slot = 0; slot < 16 && row + slot + 8 <= entityData.Length; slot += 8)
+ {
+ var val = (nint)BitConverter.ToInt64(entityData, row + slot);
+ if (val == 0) continue;
+ if (_ctx.IsModuleAddress(val))
+ {
+ var name = _rtti.ResolveRttiName(val);
+ sb.Append(name is not null ? $"[{name}] " : "[module] ");
+ }
+ else
+ {
+ var h = (ulong)val >> 32;
+ if (h > 0 && h < 0x7FFF && (val & 0x3) == 0)
+ sb.Append("[heap] ");
+ }
+ }
+ sb.AppendLine();
+ }
+ }
+ sb.AppendLine();
+
+ // StdVector at entity+ComponentListOffset
+ sb.AppendLine($"── StdVector ComponentList at entity+0x{_ctx.Offsets.ComponentListOffset:X} ──");
+ var compFirst = _ctx.Memory.ReadPointer(localPlayer + _ctx.Offsets.ComponentListOffset);
+ var compLast = _ctx.Memory.ReadPointer(localPlayer + _ctx.Offsets.ComponentListOffset + 8);
+ var compEnd = _ctx.Memory.ReadPointer(localPlayer + _ctx.Offsets.ComponentListOffset + 16);
+
+ sb.AppendLine($"First: 0x{compFirst:X}");
+ sb.AppendLine($"Last: 0x{compLast:X}");
+ sb.AppendLine($"End: 0x{compEnd:X}");
+
+ var vectorCount = 0;
+ if (compFirst != 0 && compLast > compFirst && (compLast - compFirst) < 0x2000)
+ vectorCount = (int)((compLast - compFirst) / 8);
+ sb.AppendLine($"Element count: {vectorCount}");
+ sb.AppendLine();
+
+ // List components with RTTI names and VitalStruct pattern check
+ if (vectorCount > 0)
+ {
+ var maxShow = Math.Min(vectorCount, 30);
+ sb.AppendLine($"── Components ({maxShow} of {vectorCount}) ──");
+ for (var i = 0; i < maxShow; i++)
+ {
+ var compPtr = _ctx.Memory.ReadPointer(compFirst + i * 8);
+ if (compPtr == 0)
+ {
+ sb.AppendLine($"[{i,2}] (null)");
+ continue;
+ }
+
+ var high = (ulong)compPtr >> 32;
+ if (high == 0 || high >= 0x7FFF)
+ {
+ sb.AppendLine($"[{i,2}] 0x{compPtr:X} (bad pointer)");
+ continue;
+ }
+
+ // RTTI
+ var compVtable = _ctx.Memory.ReadPointer(compPtr);
+ string? compRtti = null;
+ if (compVtable != 0 && _ctx.IsModuleAddress(compVtable))
+ compRtti = _rtti.ResolveRttiName(compVtable);
+
+ // VitalStruct pattern check
+ var hpTotal = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalTotalOffset);
+ var hpCurr = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalCurrentOffset);
+ var manaTotal = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalTotalOffset);
+ var manaCurr = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalCurrentOffset);
+ var esTotal = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeEsOffset + _ctx.Offsets.VitalTotalOffset);
+ var esCurr = _ctx.Memory.Read(compPtr + _ctx.Offsets.LifeEsOffset + _ctx.Offsets.VitalCurrentOffset);
+
+ var hpOk = hpTotal > 0 && hpTotal < 200000 && hpCurr >= 0 && hpCurr <= hpTotal + 1000;
+ var manaOk = manaTotal >= 0 && manaTotal < 200000 && manaCurr >= 0 && manaCurr <= manaTotal + 1000;
+
+ var lifeTag = (hpOk && manaOk)
+ ? $"\n ◄ LIFE: HP={hpCurr}/{hpTotal}, Mana={manaCurr}/{manaTotal}, ES={esCurr}/{esTotal}"
+ : "";
+
+ // Position float triplet check
+ var px = _ctx.Memory.Read(compPtr + _ctx.Offsets.PositionXOffset);
+ var py = _ctx.Memory.Read(compPtr + _ctx.Offsets.PositionYOffset);
+ var pz = _ctx.Memory.Read(compPtr + _ctx.Offsets.PositionZOffset);
+ var posTag = (!float.IsNaN(px) && !float.IsNaN(py) && !float.IsNaN(pz) &&
+ px > 50 && px < 50000 && py > 50 && py < 50000 && MathF.Abs(pz) < 5000)
+ ? $"\n ◄ RENDER: Pos=({px:F1}, {py:F1}, {pz:F1})"
+ : "";
+
+ sb.AppendLine($"[{i,2}] 0x{compPtr:X} {compRtti ?? "?"}{lifeTag}{posTag}");
+ }
+ }
+ sb.AppendLine();
+
+ // Follow entity+0x000 as potential "inner entity" (POE2 wrapper pattern)
+ sb.AppendLine($"── Inner Entity (entity+0x000 deref) ──");
+ var innerEntity = _ctx.Memory.ReadPointer(localPlayer);
+ sb.AppendLine($"Ptr: 0x{innerEntity:X}");
+ if (innerEntity != 0 && !_ctx.IsModuleAddress(innerEntity))
+ {
+ var innerHigh = (ulong)innerEntity >> 32;
+ if (innerHigh > 0 && innerHigh < 0x7FFF && (innerEntity & 0x3) == 0)
+ {
+ // Read inner entity vtable and RTTI
+ var innerVtable = _ctx.Memory.ReadPointer(innerEntity);
+ string? innerRtti = null;
+ if (innerVtable != 0 && _ctx.IsModuleAddress(innerVtable))
+ innerRtti = _rtti.ResolveRttiName(innerVtable);
+ sb.AppendLine($"Inner vtable: 0x{innerVtable:X} RTTI: {innerRtti ?? "?"}");
+
+ // Inner entity hex dump (first 0x80 bytes)
+ var innerData = _ctx.Memory.ReadBytes(innerEntity, 0x80);
+ if (innerData is not null)
+ {
+ for (var row = 0; row < innerData.Length; row += 16)
+ {
+ sb.Append($" +0x{row:X3}: ");
+ for (var col = 0; col < 16 && row + col < innerData.Length; col++)
+ {
+ sb.Append($"{innerData[row + col]:X2} ");
+ if (col == 7) sb.Append(' ');
+ }
+ sb.AppendLine();
+ }
+ }
+
+ // Check StdVector at inner+ComponentListOffset
+ var innerFirst = _ctx.Memory.ReadPointer(innerEntity + _ctx.Offsets.ComponentListOffset);
+ var innerLast = _ctx.Memory.ReadPointer(innerEntity + _ctx.Offsets.ComponentListOffset + 8);
+ var innerCount = 0;
+ if (innerFirst != 0 && innerLast > innerFirst && (innerLast - innerFirst) < 0x2000)
+ innerCount = (int)((innerLast - innerFirst) / 8);
+ sb.AppendLine($"Inner ComponentList (inner+0x{_ctx.Offsets.ComponentListOffset:X}): First=0x{innerFirst:X}, count={innerCount}");
+
+ // List inner components
+ if (innerCount > 1)
+ {
+ var maxInner = Math.Min(innerCount, 20);
+ for (var i = 0; i < maxInner; i++)
+ {
+ var cp = _ctx.Memory.ReadPointer(innerFirst + i * 8);
+ if (cp == 0) { sb.AppendLine($" [{i,2}] (null)"); continue; }
+ var cv = _ctx.Memory.ReadPointer(cp);
+ string? cr = null;
+ if (cv != 0 && _ctx.IsModuleAddress(cv)) cr = _rtti.ResolveRttiName(cv);
+
+ var ht = _ctx.Memory.Read(cp + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalTotalOffset);
+ var hc = _ctx.Memory.Read(cp + _ctx.Offsets.LifeHealthOffset + _ctx.Offsets.VitalCurrentOffset);
+ var mt = _ctx.Memory.Read(cp + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalTotalOffset);
+ var mc = _ctx.Memory.Read(cp + _ctx.Offsets.LifeManaOffset + _ctx.Offsets.VitalCurrentOffset);
+ var hpOk2 = ht > 0 && ht < 200000 && hc >= 0 && hc <= ht + 1000;
+ var mOk2 = mt >= 0 && mt < 200000 && mc >= 0 && mc <= mt + 1000;
+ var lt = (hpOk2 && mOk2) ? $" ◄ LIFE HP={hc}/{ht} Mana={mc}/{mt}" : "";
+ sb.AppendLine($" [{i,2}] 0x{cp:X} {cr ?? "?"}{lt}");
+ }
+ }
+ }
+ }
+ sb.AppendLine();
+
+ // Scan entity memory for all StdVector-like patterns with >1 pointer-sized elements
+ sb.AppendLine("── StdVector scan (entity 0x000-0x300) ──");
+ var scanData = _ctx.Memory.ReadBytes(localPlayer, 0x300);
+ if (scanData is not null)
+ {
+ for (var off = 0; off + 24 <= scanData.Length; off += 8)
+ {
+ var f = (nint)BitConverter.ToInt64(scanData, off);
+ var l = (nint)BitConverter.ToInt64(scanData, off + 8);
+ if (f == 0 || l <= f) continue;
+ var sz = l - f;
+ if (sz < 16 || sz > 0x2000 || sz % 8 != 0) continue;
+ var n = (int)(sz / 8);
+
+ // Quick validate: first element should be non-zero
+ var firstEl = _ctx.Memory.ReadPointer(f);
+ if (firstEl == 0) continue;
+
+ var tag = "";
+ var elHigh = (ulong)firstEl >> 32;
+ if (elHigh > 0 && elHigh < 0x7FFF && (firstEl & 0x3) == 0)
+ tag = " [ptr elements]";
+ else if (firstEl < 0x100000)
+ tag = " [small int elements]";
+
+ sb.AppendLine($"entity+0x{off:X3}: StdVector count={n}{tag} First=0x{f:X}");
+ }
+ }
+ sb.AppendLine();
+
+ // Extended entity dump (+0x100 to +0x200) for finding component-related offsets
+ sb.AppendLine("── Entity extended dump (+0x100 to +0x200) ──");
+ var extData = _ctx.Memory.ReadBytes(localPlayer + 0x100, 0x100);
+ if (extData is not null)
+ {
+ for (var row = 0; row < extData.Length; row += 16)
+ {
+ var absOff = row + 0x100;
+ sb.Append($"+0x{absOff:X3}: ");
+ for (var col = 0; col < 16 && row + col < extData.Length; col++)
+ {
+ sb.Append($"{extData[row + col]:X2} ");
+ if (col == 7) sb.Append(' ');
+ }
+ sb.Append(" | ");
+ for (var slot = 0; slot < 16 && row + slot + 8 <= extData.Length; slot += 8)
+ {
+ var val = (nint)BitConverter.ToInt64(extData, row + slot);
+ if (val == 0) continue;
+ if (_ctx.IsModuleAddress(val))
+ {
+ var name = _rtti.ResolveRttiName(val);
+ sb.Append(name is not null ? $"[{name}] " : "[module] ");
+ }
+ else
+ {
+ var h = (ulong)val >> 32;
+ if (h > 0 && h < 0x7FFF && (val & 0x3) == 0)
+ sb.Append("[heap] ");
+ }
+ }
+ sb.AppendLine();
+ }
+ }
+
+ // ECS cache state
+ sb.AppendLine();
+ sb.AppendLine($"── ECS Cache ──");
+ sb.AppendLine($"Cached Life index: {_components.CachedLifeIndex}");
+ sb.AppendLine($"Last LocalPlayer: 0x{_components.LastLocalPlayer:X}");
+
+ // FindComponentList result
+ var (bestFirst, bestCount) = _components.FindComponentList(localPlayer);
+ sb.AppendLine($"FindComponentList result: First=0x{bestFirst:X}, count={bestCount}");
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Deep scan: follows pointers from AreaInstance and LocalPlayer, scanning each target for vital values.
+ /// Uses two-level search to find the correct pointer chain to vitals.
+ ///
+ public string DeepScanVitals(int hpValue, int manaValue, int esValue)
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (ingameData == 0) return "Error: AreaInstance not resolved";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"Deep vital scan — HP={hpValue}, Mana={manaValue}, ES={esValue}");
+ sb.AppendLine($"AreaInstance: 0x{ingameData:X}");
+
+ var searchValues = new List<(string name, int value)>();
+ if (hpValue > 0) searchValues.Add(("HP", hpValue));
+ if (manaValue > 0) searchValues.Add(("Mana", manaValue));
+ if (esValue > 0) searchValues.Add(("ES", esValue));
+
+ if (searchValues.Count == 0) return "Error: enter at least one vital value";
+
+ // Level 1: scan AreaInstance pointers (first 0x1200 bytes covers all known offsets)
+ var areaData = _ctx.Memory.ReadBytes(ingameData, 0x1200);
+ if (areaData is null) return "Error: failed to read AreaInstance";
+
+ var visited = new HashSet();
+ var results = new List();
+
+ for (var off1 = 0; off1 + 8 <= areaData.Length; off1 += 8)
+ {
+ var ptr1 = (nint)BitConverter.ToInt64(areaData, off1);
+ if (ptr1 == 0 || ptr1 == ingameData) continue;
+ var h = (ulong)ptr1 >> 32;
+ if (h == 0 || h >= 0x7FFF) continue;
+ if ((ptr1 & 0x3) != 0) continue;
+ if (_ctx.IsModuleAddress(ptr1)) continue;
+ if (!visited.Add(ptr1)) continue;
+
+ // Scan this target for vital values (first 0xC00 bytes)
+ var targetData = _ctx.Memory.ReadBytes(ptr1, 0xC00);
+ if (targetData is null) continue;
+
+ var hits = FindVitalHits(targetData, searchValues);
+ if (hits.Count > 0)
+ {
+ var vtable = _ctx.Memory.ReadPointer(ptr1);
+ var rtti = vtable != 0 && _ctx.IsModuleAddress(vtable) ? _rtti.ResolveRttiName(vtable) : null;
+ results.Add($"Area+0x{off1:X} → 0x{ptr1:X} [{rtti ?? "?"}]\n {string.Join(", ", hits)}");
+ }
+
+ // Level 2: follow pointers FROM this target (first 0x200 bytes)
+ for (var off2 = 0; off2 + 8 <= Math.Min(targetData.Length, 0x200); off2 += 8)
+ {
+ var ptr2 = (nint)BitConverter.ToInt64(targetData, off2);
+ if (ptr2 == 0 || ptr2 == ptr1 || ptr2 == ingameData) continue;
+ var h2 = (ulong)ptr2 >> 32;
+ if (h2 == 0 || h2 >= 0x7FFF) continue;
+ if ((ptr2 & 0x3) != 0) continue;
+ if (_ctx.IsModuleAddress(ptr2)) continue;
+ if (!visited.Add(ptr2)) continue;
+
+ var deepData = _ctx.Memory.ReadBytes(ptr2, 0xC00);
+ if (deepData is null) continue;
+
+ var deepHits = FindVitalHits(deepData, searchValues);
+ if (deepHits.Count > 0)
+ {
+ results.Add($"Area+0x{off1:X} → +0x{off2:X} → 0x{ptr2:X}\n {string.Join(", ", deepHits)}");
+ }
+ }
+ }
+
+ sb.AppendLine($"\nFound {results.Count} objects with vital matches:");
+ sb.AppendLine(new string('─', 80));
+ foreach (var r in results)
+ sb.AppendLine(r);
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Probes InGameState to find sub-structure offsets by looking for recognizable data patterns.
+ /// Scans the object for heap pointers and checks each for area level, entity counts, terrain, etc.
+ ///
+ public string ProbeInGameState()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ // Resolve InGameState
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return $"Error: InGameState not resolved (states: {snap.StatesCount})";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"InGameState: 0x{inGameState:X} (State[{_ctx.Offsets.InGameStateIndex}] of {snap.StatesCount})");
+
+ // Read InGameState vtable for identification
+ var igsVtable = _ctx.Memory.ReadPointer(inGameState);
+ sb.AppendLine($"Vtable: 0x{igsVtable:X}");
+ sb.AppendLine(new string('═', 80));
+
+ // Scan InGameState for all heap pointers (potential sub-object data pointers)
+ var igsSize = 0x8000;
+ var igsData = _ctx.Memory.ReadBytes(inGameState, igsSize);
+ if (igsData is null) return "Error: failed to read InGameState";
+
+ // Collect unique data pointers to probe
+ var candidates = new List<(int offset, nint ptr)>();
+ for (var off = 0; off + 8 <= igsData.Length; off += 8)
+ {
+ var val = (nint)BitConverter.ToInt64(igsData, off);
+ if (val == 0) continue;
+ // Only probe heap pointers (not module addresses, not self-references)
+ if (_ctx.IsModuleAddress(val)) continue;
+ if (val == inGameState) continue;
+ var high = (ulong)val >> 32;
+ if (high == 0 || high >= 0x7FFF) continue;
+ if ((val & 0x3) != 0) continue;
+ // Skip self-referencing pointers (InGameState + small offset)
+ if (val >= inGameState && val < inGameState + igsSize) continue;
+ candidates.Add((off, val));
+ }
+
+ sb.AppendLine($"Found {candidates.Count} heap pointer candidates");
+ sb.AppendLine();
+
+ // Probe patterns for AreaInstance identification
+ // Dump offsets + POE1 reference + nearby values for version drift
+ var probeOffsets = new (string name, int offset, string type)[]
+ {
+ // Area level — byte at dump 0xAC, strong signal (value 1-100)
+ ("AreaLevel byte (0xAC)", 0xAC, "byte_level"), // Dump
+ ("AreaLevel byte (0xBC)", 0xBC, "byte_level"), // Near dump (version drift)
+ ("AreaLevel int (0xAC)", 0xAC, "int_level"), // Dump as int
+ ("AreaLevel int (0xBC)", 0xBC, "int_level"), // Near dump as int
+ ("AreaLevel int (0xD4)", 0xD4, "int_level"), // POE1
+ // Area hash (dump: 0xEC)
+ ("AreaHash (0xEC)", 0xEC, "nonzero32"), // Dump
+ // Server data pointer (dump: 0x9F0 via LocalPlayerStruct)
+ ("ServerData (0x9F0)", 0x9F0, "ptr"), // Dump
+ ("ServerData (0xA00)", 0xA00, "ptr"), // Near dump
+ // Local player pointer (dump: 0xA10)
+ ("LocalPlayer (0xA10)", 0xA10, "ptr"), // Dump
+ ("LocalPlayer (0xA20)", 0xA20, "ptr"), // Near dump
+ // Entity list — StdMap at dump 0xAF8. StdMap.head at +0, .size at +8
+ ("Entities.head (0xAF8)", 0xAF8, "ptr"), // Dump
+ ("Entities.size (0xB00)", 0xB00, "int_count"), // Dump StdMap._Mysize
+ ("Entities.head (0xB58)", 0xB58, "ptr"), // Near dump
+ ("Entities.size (0xB60)", 0xB60, "int_count"), // Near dump
+ // Terrain inline (dump: 0xCC0)
+ ("Terrain (0xCC0)", 0xCC0, "nonzero64"), // Dump (inline, check for data)
+ };
+
+ // Track best matches
+ var matches = new List<(int igsOffset, nint ptr, string desc, int score)>();
+
+ foreach (var (off, ptr) in candidates)
+ {
+ var score = 0;
+ var details = new List();
+
+ // Also try RTTI on the candidate object itself
+ var candidateVtable = _ctx.Memory.ReadPointer(ptr);
+ string? candidateRtti = null;
+ if (candidateVtable != 0 && _ctx.IsModuleAddress(candidateVtable))
+ candidateRtti = _rtti.ResolveRttiName(candidateVtable);
+
+ // Try AreaInstance-like probes
+ foreach (var (name, probeOff, probeType) in probeOffsets)
+ {
+ if (probeType == "ptr")
+ {
+ var val = _ctx.Memory.ReadPointer(ptr + probeOff);
+ if (val != 0 && !_ctx.IsModuleAddress(val))
+ {
+ var h = (ulong)val >> 32;
+ if (h > 0 && h < 0x7FFF)
+ {
+ score++;
+ details.Add($" {name} = 0x{val:X} ✓");
+ }
+ }
+ }
+ else if (probeType == "byte_level")
+ {
+ var val = _ctx.Memory.Read(ptr + probeOff);
+ if (val > 0 && val <= 100)
+ {
+ score += 3; // Strong signal
+ details.Add($" {name} = {val} ✓✓✓");
+ }
+ }
+ else if (probeType == "int_level")
+ {
+ var val = _ctx.Memory.Read(ptr + probeOff);
+ if (val > 0 && val <= 100)
+ {
+ score += 3;
+ details.Add($" {name} = {val} ✓✓✓");
+ }
+ }
+ else if (probeType == "int_count")
+ {
+ var val = _ctx.Memory.Read(ptr + probeOff);
+ if (val > 0 && val < 10000)
+ {
+ score += 2;
+ details.Add($" {name} = {val} ✓✓");
+ }
+ }
+ else if (probeType == "nonzero32")
+ {
+ var val = _ctx.Memory.Read(ptr + probeOff);
+ if (val != 0)
+ {
+ score++;
+ details.Add($" {name} = 0x{val:X8} ✓");
+ }
+ }
+ else if (probeType == "nonzero64")
+ {
+ var val = _ctx.Memory.Read(ptr + probeOff);
+ if (val != 0)
+ {
+ score++;
+ details.Add($" {name} = 0x{val:X} ✓");
+ }
+ }
+ }
+
+ // RTTI bonus: if object has a known class name, boost score significantly
+ if (candidateRtti is not null)
+ {
+ details.Insert(0, $" RTTI: {candidateRtti}");
+ if (candidateRtti.Contains("AreaInstance") || candidateRtti.Contains("IngameData")
+ || candidateRtti.Contains("WorldInstance"))
+ score += 10; // Very strong signal
+ }
+
+ if (score >= 3)
+ {
+ matches.Add((off, ptr, string.Join("\n", details), score));
+ }
+ }
+
+ // Sort by score descending
+ matches.Sort((a, b) => b.score.CompareTo(a.score));
+
+ if (matches.Count == 0)
+ {
+ sb.AppendLine("No matches found with known offset patterns.");
+ sb.AppendLine("Try scanning InGameState with the raw Scan tool.");
+ }
+ else
+ {
+ sb.AppendLine($"── Top matches (by score) ──");
+ sb.AppendLine();
+ foreach (var (off, ptr, desc, score) in matches.Take(15))
+ {
+ sb.AppendLine($"IGS+0x{off:X3} → 0x{ptr:X} (score: {score})");
+ sb.AppendLine(desc);
+ sb.AppendLine();
+ }
+ }
+
+ // Check InGameState at dump-predicted offsets directly
+ sb.AppendLine("── Dump-predicted InGameState fields ──");
+ var dumpFields = new (string name, int offset)[]
+ {
+ ("AreaInstanceData", 0x290),
+ ("WorldData", 0x2F8),
+ ("UiRootPtr", 0x648),
+ ("IngameUi", 0xC40),
+ };
+ foreach (var (fname, foff) in dumpFields)
+ {
+ if (foff + 8 <= igsSize)
+ {
+ var val = (nint)BitConverter.ToInt64(igsData, foff);
+ if (val != 0)
+ {
+ var tag = _rtti.ClassifyPointer(val);
+ string extra = "";
+ // If it's a heap pointer, try RTTI
+ if (tag == "heap ptr")
+ {
+ var vt = _ctx.Memory.ReadPointer(val);
+ if (vt != 0 && _ctx.IsModuleAddress(vt))
+ {
+ var rtti = _rtti.ResolveRttiName(vt);
+ if (rtti != null) extra = $" RTTI={rtti}";
+ }
+ // Quick check: does it look like an AreaInstance?
+ var lvl = _ctx.Memory.Read(val + 0xAC);
+ var lvl2 = _ctx.Memory.Read(val + 0xBC);
+ if (lvl > 0 && lvl <= 100) extra += $" lvl@0xAC={lvl}";
+ if (lvl2 > 0 && lvl2 <= 100) extra += $" lvl@0xBC={lvl2}";
+ }
+ sb.AppendLine($" IGS+0x{foff:X}: {fname} = 0x{val:X} [{tag ?? "?"}]{extra}");
+ }
+ else
+ {
+ sb.AppendLine($" IGS+0x{foff:X}: {fname} = (null)");
+ }
+ }
+ }
+ sb.AppendLine();
+
+ // Also scan InGameState directly for position-like floats
+ sb.AppendLine("── Position-like floats in InGameState ──");
+ for (var off = 0; off + 12 <= igsData.Length; off += 4)
+ {
+ var x = BitConverter.ToSingle(igsData, off);
+ var y = BitConverter.ToSingle(igsData, off + 4);
+ var z = BitConverter.ToSingle(igsData, off + 8);
+ // Look for reasonable world coordinates (POE2: typically 100-10000 range)
+ if (x > 100 && x < 50000 && y > 100 && y < 50000 &&
+ !float.IsNaN(x) && !float.IsNaN(y) && !float.IsNaN(z) &&
+ MathF.Abs(z) < 5000)
+ {
+ sb.AppendLine($" IGS+0x{off:X4}: ({x:F1}, {y:F1}, {z:F1})");
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Diagnostic: scan the component lookup structure for an entity to figure out the POE2 layout.
+ /// Reads EntityDetails → ComponentLookupPtr and dumps memory, trying to find component name strings.
+ ///
+ public string ScanComponentLookup()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+
+ var sb = new StringBuilder();
+
+ // Get local player
+ var inGameState = _stateReader.ResolveInGameState(new GameStateSnapshot());
+ if (inGameState == 0) return "Error: can't resolve InGameState";
+
+ var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (ingameData == 0) return "Error: can't resolve AreaInstance";
+
+ var localPlayer = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
+ if (localPlayer == 0) return "Error: can't resolve LocalPlayer";
+
+ sb.AppendLine($"LocalPlayer entity: 0x{localPlayer:X}");
+
+ // Also try on a few other entities for comparison
+ var entitiesToScan = new List<(string label, nint entity)> { ("LocalPlayer", localPlayer) };
+
+ // Try to grab a couple more entities from the tree
+ var sentinel = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.EntityListOffset);
+ if (sentinel != 0)
+ {
+ var root = _ctx.Memory.ReadPointer(sentinel + _ctx.Offsets.EntityNodeParentOffset);
+ var count = 0;
+ _entities.WalkTreeInOrder(sentinel, root, 20, node =>
+ {
+ var entityPtr = _ctx.Memory.ReadPointer(node + _ctx.Offsets.EntityNodeValueOffset);
+ if (entityPtr == 0 || entityPtr == localPlayer) return;
+ var high = (ulong)entityPtr >> 32;
+ if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
+ if (count >= 2) return;
+
+ var path = _entities.TryReadEntityPath(entityPtr);
+ if (path is not null && !path.Contains("Effects"))
+ {
+ entitiesToScan.Add(($"Entity({path?.Split('/').LastOrDefault() ?? "?"})", entityPtr));
+ count++;
+ }
+ });
+ }
+
+ foreach (var (label, entity) in entitiesToScan)
+ {
+ sb.AppendLine($"\n═══ {label}: 0x{entity:X} ═══");
+ ScanOneEntityComponentLookup(sb, entity);
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Diagnostic: finds terrain struct layout by scanning AreaInstance memory for dimensions, vectors, and grid data.
+ ///
+ public string ScanTerrain()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ if (_ctx.GameStateBase == 0) return "Error: GameState base not resolved";
+
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0) return "Error: InGameState not resolved";
+
+ var areaInstance = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (areaInstance == 0) return "Error: AreaInstance is null";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"AreaInstance: 0x{areaInstance:X}");
+ sb.AppendLine($"TerrainListOffset: 0x{_ctx.Offsets.TerrainListOffset:X}");
+
+ var terrainBase = areaInstance + _ctx.Offsets.TerrainListOffset;
+ sb.AppendLine($"TerrainStruct addr: 0x{terrainBase:X}");
+ sb.AppendLine();
+
+ // ── 1. Raw hex dump of first 0x200 bytes ──
+ const int dumpSize = 0x200;
+ var rawDump = _ctx.Memory.ReadBytes(terrainBase, dumpSize);
+ if (rawDump is null)
+ return sb.Append("Error: failed to read TerrainStruct region").ToString();
+
+ sb.AppendLine($"═══ Raw dump: TerrainStruct (0x{dumpSize:X} bytes) ═══");
+ for (var off = 0; off < rawDump.Length; off += 16)
+ {
+ sb.Append($"+0x{off:X3}: ");
+ var end = Math.Min(off + 16, rawDump.Length);
+ for (var i = off; i < end; i++)
+ sb.Append($"{rawDump[i]:X2} ");
+ sb.AppendLine();
+ }
+ sb.AppendLine();
+
+ // ── 2. Scan for dimension-like int32 pairs (10–500) ──
+ sb.AppendLine("═══ Dimension candidates (int32 pairs, 10–500) ═══");
+ for (var off = 0; off + 8 <= rawDump.Length; off += 4)
+ {
+ var a = BitConverter.ToInt32(rawDump, off);
+ var b = BitConverter.ToInt32(rawDump, off + 4);
+ if (a >= 10 && a <= 500 && b >= 10 && b <= 500)
+ sb.AppendLine($" +0x{off:X3}: {a} x {b} (int32 pair)");
+ }
+
+ // int64 pairs cast to int32
+ sb.AppendLine("═══ Dimension candidates (int64 pairs → int32, 10–500) ═══");
+ for (var off = 0; off + 16 <= rawDump.Length; off += 8)
+ {
+ var a = (int)BitConverter.ToInt64(rawDump, off);
+ var b = (int)BitConverter.ToInt64(rawDump, off + 8);
+ if (a >= 10 && a <= 500 && b >= 10 && b <= 500)
+ sb.AppendLine($" +0x{off:X3}: {a} x {b} (int64 pair, low 32 bits)");
+ }
+ sb.AppendLine();
+
+ // ── 3. Scan for StdVector patterns (begin < end, end-begin reasonable, cap >= end) ──
+ sb.AppendLine("═══ StdVector candidates (begin/end/cap pointer triples) ═══");
+ var vectorCandidates = new List<(int Offset, nint Begin, nint End, long DataSize)>();
+ for (var off = 0; off + 24 <= rawDump.Length; off += 8)
+ {
+ var begin = (nint)BitConverter.ToInt64(rawDump, off);
+ var end = (nint)BitConverter.ToInt64(rawDump, off + 8);
+ var cap = (nint)BitConverter.ToInt64(rawDump, off + 16);
+
+ if (begin == 0 || end <= begin || cap < end) continue;
+ var dataSize = end - begin;
+ if (dataSize <= 0 || dataSize > 64 * 1024 * 1024) continue;
+
+ // Check high bits look like heap pointer
+ var highBegin = (ulong)begin >> 32;
+ if (highBegin == 0 || highBegin >= 0x7FFF) continue;
+
+ sb.AppendLine($" +0x{off:X3}: begin=0x{begin:X}, end=0x{end:X}, cap=0x{cap:X} size={dataSize:N0} (0x{dataSize:X})");
+ vectorCandidates.Add((off, begin, end, dataSize));
+ }
+ sb.AppendLine();
+
+ // ── 4. BytesPerRow candidates ──
+ // Expected: ceil(gridWidth / 2) where gridWidth = cols * 23
+ // For typical maps (cols 20-200): bytesPerRow range ~230 to ~2300
+ sb.AppendLine("═══ BytesPerRow candidates (int32, range 50–5000) ═══");
+ for (var off = 0; off + 4 <= rawDump.Length; off += 4)
+ {
+ var val = BitConverter.ToInt32(rawDump, off);
+ if (val >= 50 && val <= 5000)
+ sb.AppendLine($" +0x{off:X3}: {val} (0x{val:X})");
+ }
+ sb.AppendLine();
+
+ // ── 5. Try current offsets and report ──
+ sb.AppendLine("═══ Current offset readings ═══");
+ var dimOff = _ctx.Offsets.TerrainDimensionsOffset;
+ if (dimOff + 16 <= rawDump.Length)
+ {
+ var cols64 = BitConverter.ToInt64(rawDump, dimOff);
+ var rows64 = BitConverter.ToInt64(rawDump, dimOff + 8);
+ sb.AppendLine($" Dims @+0x{dimOff:X}: cols={cols64}, rows={rows64} (as int64)");
+ var cols32 = BitConverter.ToInt32(rawDump, dimOff);
+ var rows32 = BitConverter.ToInt32(rawDump, dimOff + 4);
+ sb.AppendLine($" Dims @+0x{dimOff:X}: cols={cols32}, rows={rows32} (as int32)");
+ }
+
+ var walkOff = _ctx.Offsets.TerrainWalkableGridOffset;
+ if (walkOff + 24 <= rawDump.Length)
+ {
+ var wBegin = (nint)BitConverter.ToInt64(rawDump, walkOff);
+ var wEnd = (nint)BitConverter.ToInt64(rawDump, walkOff + 8);
+ var wCap = (nint)BitConverter.ToInt64(rawDump, walkOff + 16);
+ sb.AppendLine($" WalkGrid @+0x{walkOff:X}: begin=0x{wBegin:X}, end=0x{wEnd:X}, cap=0x{wCap:X}, size={wEnd - wBegin:N0}");
+ }
+
+ var bprOff = _ctx.Offsets.TerrainBytesPerRowOffset;
+ if (bprOff + 4 <= rawDump.Length)
+ {
+ var bpr = BitConverter.ToInt32(rawDump, bprOff);
+ sb.AppendLine($" BytesPerRow @+0x{bprOff:X}: {bpr}");
+ }
+ sb.AppendLine();
+
+ // ── 6. If we can read the grid, dump sample + save image ──
+ var currentCols = (int)BitConverter.ToInt64(rawDump, Math.Min(dimOff, rawDump.Length - 8));
+ var currentRows = (dimOff + 8 < rawDump.Length) ? (int)BitConverter.ToInt64(rawDump, dimOff + 8) : 0;
+
+ if (currentCols > 0 && currentCols < 1000 && currentRows > 0 && currentRows < 1000)
+ {
+ var gridWidth = currentCols * _ctx.Offsets.SubTilesPerCell;
+ var gridHeight = currentRows * _ctx.Offsets.SubTilesPerCell;
+
+ // Try reading the walkable grid vector
+ if (walkOff + 16 <= rawDump.Length)
+ {
+ var gBegin = (nint)BitConverter.ToInt64(rawDump, walkOff);
+ var gEnd = (nint)BitConverter.ToInt64(rawDump, walkOff + 8);
+ var gSize = (int)(gEnd - gBegin);
+
+ if (gBegin != 0 && gSize > 0 && gSize < 16 * 1024 * 1024)
+ {
+ // Read sample (first 64 bytes)
+ var sample = _ctx.Memory.ReadBytes(gBegin, Math.Min(64, gSize));
+ if (sample != null)
+ {
+ sb.AppendLine($"═══ Grid sample (first {sample.Length} bytes from 0x{gBegin:X}) ═══");
+ sb.AppendLine($" Raw: {FormatBytes(sample)}");
+
+ // Nibble-unpacked view
+ sb.Append(" Nibbles: ");
+ for (var i = 0; i < Math.Min(32, sample.Length); i++)
+ {
+ var lo = sample[i] & 0x0F;
+ var hi = (sample[i] >> 4) & 0x0F;
+ sb.Append($"{lo},{hi} ");
+ }
+ sb.AppendLine();
+ }
+
+ var bytesPerRow = (bprOff + 4 <= rawDump.Length) ? BitConverter.ToInt32(rawDump, bprOff) : 0;
+ sb.AppendLine($" Expected grid: {gridWidth}x{gridHeight}, bytesPerRow={bytesPerRow}, expected raw size={bytesPerRow * gridHeight}");
+ sb.AppendLine($" Actual vector size: {gSize}");
+
+ // Try to read and save full grid image
+ if (bytesPerRow > 0 && bytesPerRow * gridHeight <= gSize + bytesPerRow)
+ {
+ var rawGrid = _ctx.Memory.ReadBytes(gBegin, Math.Min(gSize, bytesPerRow * gridHeight));
+ if (rawGrid != null)
+ {
+ // Unpack nibbles
+ var gridData = new byte[gridWidth * gridHeight];
+ for (var row = 0; row < gridHeight; row++)
+ {
+ var rowStart = row * bytesPerRow;
+ for (var col = 0; col < gridWidth; col++)
+ {
+ var byteIndex = rowStart + col / 2;
+ if (byteIndex >= rawGrid.Length) break;
+ gridData[row * gridWidth + col] = (col % 2 == 0)
+ ? (byte)(rawGrid[byteIndex] & 0x0F)
+ : (byte)((rawGrid[byteIndex] >> 4) & 0x0F);
+ }
+ }
+
+ // Stats
+ var walkable = gridData.Count(b => b == 0);
+ var total = gridData.Length;
+ sb.AppendLine($" Walkable: {walkable:N0} / {total:N0} ({100.0 * walkable / total:F1}%)");
+
+ // Value distribution
+ var counts = new int[16];
+ foreach (var b in gridData) counts[b & 0x0F]++;
+ sb.Append(" Value distribution: ");
+ for (var v = 0; v < 16; v++)
+ if (counts[v] > 0) sb.Append($"[{v}]={counts[v]:N0} ");
+ sb.AppendLine();
+
+ // Save terrain image
+ try
+ {
+ SaveTerrainImage(gridData, gridWidth, gridHeight, "terrain.png");
+ sb.AppendLine($" Saved terrain.png ({gridWidth}x{gridHeight})");
+ }
+ catch (Exception ex)
+ {
+ sb.AppendLine($" Error saving image: {ex.Message}");
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Raw explorer: parse hex address, follow offset chain, read as specified type.
+ ///
+ public string ReadAddress(string hexAddr, string offsetsCsv, string type)
+ {
+ if (_ctx.Memory is null)
+ return "Error: not attached";
+
+ if (!nint.TryParse(hexAddr, NumberStyles.HexNumber, null, out var addr))
+ return $"Error: invalid address '{hexAddr}'";
+
+ // Parse offsets
+ var offsets = Array.Empty();
+ if (!string.IsNullOrWhiteSpace(offsetsCsv))
+ {
+ var parts = offsetsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ offsets = new int[parts.Length];
+ for (var i = 0; i < parts.Length; i++)
+ {
+ if (!int.TryParse(parts[i], NumberStyles.HexNumber, null, out offsets[i]))
+ return $"Error: invalid offset '{parts[i]}'";
+ }
+ }
+
+ // Follow chain if offsets provided
+ if (offsets.Length > 0)
+ {
+ addr = _ctx.Memory.FollowChain(addr, offsets);
+ if (addr == 0)
+ return "Error: pointer chain broken (null)";
+ }
+
+ return type.ToLowerInvariant() switch
+ {
+ "int32" => _ctx.Memory.Read(addr).ToString(),
+ "int64" => _ctx.Memory.Read(addr).ToString(),
+ "float" => _ctx.Memory.Read(addr).ToString("F4"),
+ "double" => _ctx.Memory.Read(addr).ToString("F4"),
+ "pointer" => $"0x{_ctx.Memory.ReadPointer(addr):X}",
+ "bytes16" => FormatBytes(_ctx.Memory.ReadBytes(addr, 16)),
+ "string" => _strings.ReadNullTermString(addr),
+ _ => $"Error: unknown type '{type}'"
+ };
+ }
+
+ ///
+ /// Scans a memory region and returns all pointer-like values with their offsets.
+ ///
+ public string ScanRegion(string hexAddr, string offsetsCsv, int size)
+ {
+ if (_ctx.Memory is null)
+ return "Error: not attached";
+
+ if (!nint.TryParse(hexAddr, NumberStyles.HexNumber, null, out var addr))
+ return $"Error: invalid address '{hexAddr}'";
+
+ // Parse and follow offset chain
+ if (!string.IsNullOrWhiteSpace(offsetsCsv))
+ {
+ var parts = offsetsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var offsets = new int[parts.Length];
+ for (var i = 0; i < parts.Length; i++)
+ {
+ if (!int.TryParse(parts[i], NumberStyles.HexNumber, null, out offsets[i]))
+ return $"Error: invalid offset '{parts[i]}'";
+ }
+ addr = _ctx.Memory.FollowChain(addr, offsets);
+ if (addr == 0)
+ return "Error: pointer chain broken (null)";
+ }
+
+ // Read the full block
+ size = Math.Clamp(size, 0x10, 0x10000);
+ var data = _ctx.Memory.ReadBytes(addr, size);
+ if (data is null)
+ return "Error: read failed";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"Scan: 0x{addr:X} size: 0x{size:X}");
+ sb.AppendLine(new string('─', 60));
+
+ for (var offset = 0; offset + 8 <= data.Length; offset += 8)
+ {
+ var value = BitConverter.ToUInt64(data, offset);
+ if (value == 0) continue;
+
+ var nVal = (nint)(long)value;
+ var tag = _rtti.ClassifyPointer(nVal);
+ if (tag is null)
+ {
+ // Show non-zero non-pointer values only if they look like small ints or floats
+ if (value <= 0xFFFF)
+ {
+ sb.AppendLine($"+0x{offset:X3}: {value,-20} [int: {value}]");
+ }
+ else
+ {
+ var f1 = BitConverter.ToSingle(data, offset);
+ var f2 = BitConverter.ToSingle(data, offset + 4);
+ if (IsReasonableFloat(f1) || IsReasonableFloat(f2))
+ {
+ sb.AppendLine($"+0x{offset:X3}: {f1,12:F2} {f2,12:F2} [float pair]");
+ }
+ }
+ continue;
+ }
+
+ sb.AppendLine($"+0x{offset:X3}: 0x{value:X} [{tag}]");
+ }
+
+ return sb.ToString();
+ }
+
+ // ════════════════════════════════════════════════════════════════
+ // Private helpers
+ // ════════════════════════════════════════════════════════════════
+
+ private static List FindVitalHits(byte[] data, List<(string name, int value)> searchValues)
+ {
+ // Find hits and check for CLUSTERS (multiple vitals within 0x40 bytes of each other)
+ var allHits = new List<(string name, int value, int offset)>();
+ foreach (var (vName, vValue) in searchValues)
+ {
+ for (var off = 0; off + 4 <= data.Length; off += 4)
+ {
+ if (BitConverter.ToInt32(data, off) == vValue)
+ allHits.Add((vName, vValue, off));
+ }
+ }
+
+ // Only return if we have at least 2 different vitals, or a single vital at a reasonable offset
+ var distinctVitals = allHits.Select(h => h.name).Distinct().Count();
+ if (distinctVitals >= 2)
+ {
+ // Find clusters where 2+ vitals are within 0x80 bytes
+ var clusters = new List();
+ foreach (var h1 in allHits)
+ {
+ foreach (var h2 in allHits)
+ {
+ if (h1.name == h2.name) continue;
+ if (Math.Abs(h1.offset - h2.offset) <= 0x80)
+ {
+ clusters.Add($"{h1.name}@+0x{h1.offset:X}, {h2.name}@+0x{h2.offset:X}");
+ }
+ }
+ }
+ if (clusters.Count > 0)
+ return clusters.Distinct().Take(10).ToList();
+ }
+
+ return [];
+ }
+
+ private void ScanOneEntityComponentLookup(StringBuilder sb, nint entity)
+ {
+ // Step 1: Read EntityDetails — handle inner entity (ECS wrapper) pattern
+ var detailsPtr = _ctx.Memory.ReadPointer(entity + _ctx.Offsets.EntityHeaderOffset);
+ var high = (ulong)detailsPtr >> 32;
+
+ if (high == 0 || high >= 0x7FFF)
+ {
+ sb.AppendLine($" entity+0x{_ctx.Offsets.EntityHeaderOffset:X} = 0x{detailsPtr:X} (invalid — trying inner entity)");
+
+ // POE2 ECS wrapper: entity+0x00 → inner entity → +0x08 → EntityDetails
+ var innerEntity = _ctx.Memory.ReadPointer(entity);
+ if (innerEntity != 0 && innerEntity != entity && !_ctx.IsModuleAddress(innerEntity))
+ {
+ var innerHigh = (ulong)innerEntity >> 32;
+ if (innerHigh > 0 && innerHigh < 0x7FFF && (innerEntity & 0x7) == 0)
+ {
+ detailsPtr = _ctx.Memory.ReadPointer(innerEntity + _ctx.Offsets.EntityHeaderOffset);
+ sb.AppendLine($" Inner entity 0x{innerEntity:X} → details = 0x{detailsPtr:X}");
+ high = (ulong)detailsPtr >> 32;
+ }
+ }
+
+ if (high == 0 || high >= 0x7FFF)
+ {
+ sb.AppendLine($" ERROR: EntityDetails still invalid (0x{detailsPtr:X})");
+ return;
+ }
+ }
+
+ sb.AppendLine($" EntityDetails: 0x{detailsPtr:X}");
+
+ // Step 2: Component list for cross-reference
+ var (compFirst, compCount) = _components.FindComponentList(entity);
+ sb.AppendLine($" ComponentList: first=0x{compFirst:X}, count={compCount}");
+
+ // Step 3: Read ComponentLookup object at details+0x28
+ // Structure discovered: EntityDetails+0x28 → ComponentLookup object with:
+ // +0x00: VTablePtr
+ // +0x08: hash/data
+ // +0x10: Vec1 begin (component pointers, 8 bytes each)
+ // +0x18: Vec1 end
+ // +0x20: Vec1 capacity
+ // +0x28: Vec2 begin (name entries, ? bytes each)
+ // +0x30: Vec2 end
+ // +0x38: Vec2 capacity
+ var lookupObjPtr = _ctx.Memory.ReadPointer(detailsPtr + 0x28);
+ if (lookupObjPtr == 0 || !_ctx.IsValidHeapPtr(lookupObjPtr))
+ {
+ sb.AppendLine($" details+0x28 = 0x{lookupObjPtr:X} (not a valid pointer)");
+ return;
+ }
+
+ sb.AppendLine($" ComponentLookup object: 0x{lookupObjPtr:X}");
+
+ var objData = _ctx.Memory.ReadBytes(lookupObjPtr, 0x40);
+ if (objData is null) { sb.AppendLine(" ERROR: Can't read lookup object"); return; }
+
+ // Verify VTablePtr
+ var vtable = (nint)BitConverter.ToInt64(objData, 0);
+ sb.AppendLine($" VTable: 0x{vtable:X} ({(_ctx.IsModuleAddress(vtable) ? "module ✓" : "NOT module")})");
+
+ // Read Vec1 (component pointer array)
+ var vec1Begin = (nint)BitConverter.ToInt64(objData, 0x10);
+ var vec1End = (nint)BitConverter.ToInt64(objData, 0x18);
+ var vec1Cap = (nint)BitConverter.ToInt64(objData, 0x20);
+ var vec1Size = (vec1Begin != 0 && vec1End > vec1Begin) ? vec1End - vec1Begin : 0;
+ sb.AppendLine($" Vec1: 0x{vec1Begin:X}..0x{vec1End:X} (0x{vec1Size:X} bytes = {vec1Size / 8} ptrs)");
+
+ // Read Vec2 (name lookup entries)
+ var vec2Begin = (nint)BitConverter.ToInt64(objData, 0x28);
+ var vec2End = (nint)BitConverter.ToInt64(objData, 0x30);
+ var vec2Cap = (nint)BitConverter.ToInt64(objData, 0x38);
+ var vec2Size = (vec2Begin != 0 && vec2End > vec2Begin) ? vec2End - vec2Begin : 0;
+
+ if (vec2Size <= 0)
+ {
+ sb.AppendLine($" Vec2: empty or invalid (begin=0x{vec2Begin:X}, end=0x{vec2End:X})");
+ return;
+ }
+
+ // Determine entry size from vec2 size and component count
+ var entrySize = compCount > 0 ? (int)(vec2Size / compCount) : 0;
+ var entryCount = entrySize > 0 ? (int)(vec2Size / entrySize) : 0;
+ sb.AppendLine($" Vec2: 0x{vec2Begin:X}..0x{vec2End:X} (0x{vec2Size:X} bytes)");
+ sb.AppendLine($" Entry size: {entrySize} bytes ({entryCount} entries for {compCount} components)");
+
+ if (entrySize <= 0 || entrySize > 128 || entryCount != compCount)
+ {
+ sb.AppendLine(" WARNING: entry size doesn't evenly divide by component count");
+ // Try common sizes
+ foreach (var trySize in new[] { 8, 16, 24, 32 })
+ {
+ if (vec2Size % trySize == 0)
+ sb.AppendLine($" Alt: {trySize} bytes → {vec2Size / trySize} entries");
+ }
+ }
+
+ // Step 4: Read and dump Vec2 entries
+ var readCount = Math.Min(entryCount > 0 ? entryCount : 30, 30);
+ var actualEntrySize = entrySize > 0 ? entrySize : 16; // default guess
+ var vec2Data = _ctx.Memory.ReadBytes(vec2Begin, readCount * actualEntrySize);
+ if (vec2Data is null) { sb.AppendLine(" ERROR: Can't read Vec2 data"); return; }
+
+ sb.AppendLine($"\n Vec2 entries (first {readCount}):");
+ for (var i = 0; i < readCount; i++)
+ {
+ var entryOff = i * actualEntrySize;
+ if (entryOff + actualEntrySize > vec2Data.Length) break;
+
+ var hex = BitConverter.ToString(vec2Data, entryOff, actualEntrySize).Replace('-', ' ');
+ sb.Append($" [{i,2}]: {hex}");
+
+ // Try each 8-byte field as string pointer
+ for (var f = 0; f < actualEntrySize && f + 8 <= actualEntrySize; f += 8)
+ {
+ var fieldPtr = (nint)BitConverter.ToInt64(vec2Data, entryOff + f);
+ if (fieldPtr == 0) continue;
+ if (!_ctx.IsValidHeapPtr(fieldPtr)) continue;
+
+ // Try as std::string (MSVC narrow)
+ var name = _strings.ReadMsvcString(fieldPtr);
+ if (name is not null && name.Length >= 2 && name.Length <= 50 &&
+ name.All(c => c >= 0x20 && c <= 0x7E))
+ {
+ sb.Append($" ← field+0x{f:X}: std::string \"{name}\"");
+ continue;
+ }
+
+ // Try as std::wstring
+ var wname = _strings.ReadMsvcWString(fieldPtr);
+ if (wname is not null && wname.Length >= 2 && wname.Length <= 50)
+ {
+ sb.Append($" ← field+0x{f:X}: wstring \"{wname}\"");
+ continue;
+ }
+
+ // Try as char* (null-terminated)
+ var rawBytes = _ctx.Memory.ReadBytes(fieldPtr, 64);
+ if (rawBytes is not null)
+ {
+ var end = Array.IndexOf(rawBytes, (byte)0);
+ if (end >= 2 && end <= 50)
+ {
+ var s = Encoding.ASCII.GetString(rawBytes, 0, end);
+ if (s.All(c => c >= 0x20 && c <= 0x7E))
+ {
+ sb.Append($" ← field+0x{f:X}: char* \"{s}\"");
+ continue;
+ }
+ }
+ }
+
+ // Try following pointer one more level (ptr → std::string)
+ var innerPtr = _ctx.Memory.ReadPointer(fieldPtr);
+ if (innerPtr != 0 && _ctx.IsValidHeapPtr(innerPtr))
+ {
+ var innerName = _strings.ReadMsvcString(innerPtr);
+ if (innerName is not null && innerName.Length >= 2 && innerName.Length <= 50 &&
+ innerName.All(c => c >= 0x20 && c <= 0x7E))
+ {
+ sb.Append($" ← field+0x{f:X} → → std::string \"{innerName}\"");
+ continue;
+ }
+ }
+ }
+
+ // Also try 4-byte fields as small ints (potential indices)
+ for (var f = 0; f + 4 <= actualEntrySize; f += 4)
+ {
+ var intVal = BitConverter.ToInt32(vec2Data, entryOff + f);
+ if (intVal >= 0 && intVal < compCount && f >= 8) // only after pointer fields
+ sb.Append($" [+0x{f:X}={intVal}?]");
+ }
+
+ sb.AppendLine();
+ }
+
+ // Also try reading Vec2 entries with inline std::string
+ // (entry = { std::string name (0x20 bytes), ... })
+ if (actualEntrySize >= 0x20)
+ {
+ sb.AppendLine($"\n Trying inline std::string in entries (0x20 bytes per string):");
+ for (var i = 0; i < Math.Min(readCount, 5); i++)
+ {
+ var entryAddr = vec2Begin + i * actualEntrySize;
+ for (var f = 0; f + 0x20 <= actualEntrySize; f += 8)
+ {
+ var name = _strings.ReadMsvcString(entryAddr + f);
+ if (name is not null && name.Length >= 2 && name.Length <= 50 &&
+ name.All(c => c >= 0x20 && c <= 0x7E))
+ {
+ sb.AppendLine($" [{i}]+0x{f:X}: inline \"{name}\"");
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Scans InGameState for the camera's view-projection Matrix4x4 by:
+ /// 1. Scanning InGameState itself for inline matrix candidates
+ /// 2. Following pointers and scanning for resolution pair (2560×1440) + nearby matrix
+ /// Validates by checking if WorldToScreen(playerPos) ≈ screen center.
+ ///
+ public string ScanCamera()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ var (candidates, header, error) = CollectCameraCandidates();
+ if (error is not null) return error;
+
+ var sb = new StringBuilder();
+ sb.AppendLine("═══ Camera Scan ═══");
+ sb.Append(header);
+ FormatCameraCandidates(sb, candidates);
+ return sb.ToString();
+ }
+
+ ///
+ /// Camera diff — click once to capture baseline, move/zoom camera, click again to see diff.
+ /// Only shows candidates whose float values changed between captures.
+ ///
+ public string CameraDiff()
+ {
+ if (_ctx.Memory is null) return "Error: not attached";
+ var (candidates, header, error) = CollectCameraCandidates();
+ if (error is not null) return error;
+
+ // First click: capture baseline
+ if (_cameraDiffBaseline is null)
+ {
+ _cameraDiffBaseline = candidates;
+ var sb = new StringBuilder();
+ sb.AppendLine("═══ Camera Diff — BASELINE CAPTURED ═══");
+ sb.Append(header);
+ sb.AppendLine($"Captured {candidates.Count} candidates.");
+ sb.AppendLine();
+ sb.AppendLine("Now move or zoom the camera, then click Camera Diff again.");
+ return sb.ToString();
+ }
+
+ // Second click: diff against baseline
+ var baseline = _cameraDiffBaseline;
+ _cameraDiffBaseline = null; // reset for next pair
+
+ // Index baseline by source key for matching
+ var baselineBySource = new Dictionary();
+ foreach (var c in baseline)
+ baselineBySource[c.source] = c.floats;
+
+ var result = new StringBuilder();
+ result.AppendLine("═══ Camera Diff — RESULTS ═══");
+ result.Append(header);
+ result.AppendLine($"Baseline: {baseline.Count} candidates, Current: {candidates.Count} candidates");
+ result.AppendLine();
+
+ var changedCount = 0;
+ foreach (var c in candidates)
+ {
+ if (!baselineBySource.TryGetValue(c.source, out var oldFloats))
+ {
+ // New candidate not in baseline
+ result.AppendLine($"[NEW] {c.source} screen=({c.sx:F1},{c.sy:F1}) dist={c.dist:F0}px");
+ FormatFloatRow(result, c.floats);
+ result.AppendLine();
+ changedCount++;
+ continue;
+ }
+
+ // Compare floats — count how many changed
+ var diffs = 0;
+ var maxDelta = 0f;
+ for (var i = 0; i < 16; i++)
+ {
+ var delta = MathF.Abs(c.floats[i] - oldFloats[i]);
+ if (delta > 0.0001f)
+ {
+ diffs++;
+ if (delta > maxDelta) maxDelta = delta;
+ }
+ }
+
+ if (diffs == 0) continue; // unchanged — skip
+
+ changedCount++;
+ result.AppendLine($"[CHANGED {diffs}/16 floats, maxDelta={maxDelta:F6}] {c.source} screen=({c.sx:F1},{c.sy:F1}) dist={c.dist:F0}px");
+ result.Append(" OLD: ");
+ for (var i = 0; i < 16; i++)
+ {
+ result.Append($"{oldFloats[i]:F6}");
+ if (i < 15) result.Append(" | ");
+ }
+ result.AppendLine();
+ result.Append(" NEW: ");
+ for (var i = 0; i < 16; i++)
+ {
+ result.Append($"{c.floats[i]:F6}");
+ if (i < 15) result.Append(" | ");
+ }
+ result.AppendLine();
+ result.Append(" DIF: ");
+ for (var i = 0; i < 16; i++)
+ {
+ var delta = c.floats[i] - oldFloats[i];
+ if (MathF.Abs(delta) > 0.0001f)
+ result.Append($"{delta:+0.000000;-0.000000}");
+ else
+ result.Append(" .");
+ if (i < 15) result.Append(" | ");
+ }
+ result.AppendLine();
+ result.AppendLine();
+ }
+
+ // Check for candidates that disappeared
+ var currentSources = new HashSet(candidates.Select(c => c.source));
+ foreach (var c in baseline)
+ {
+ if (!currentSources.Contains(c.source))
+ {
+ result.AppendLine($"[GONE] {c.source}");
+ changedCount++;
+ }
+ }
+
+ result.AppendLine();
+ result.AppendLine($"═══ {changedCount} changed out of {candidates.Count} candidates ═══");
+
+ if (changedCount == 0)
+ result.AppendLine("No matrices changed — did you move/zoom the camera?");
+
+ return result.ToString();
+ }
+
+ private (List<(string source, float sx, float sy, float dist, float[] floats)> candidates, string header, string? error) CollectCameraCandidates()
+ {
+ var snap = new GameStateSnapshot();
+ var inGameState = _stateReader.ResolveInGameState(snap);
+ if (inGameState == 0)
+ return ([], "", "Error: InGameState not resolved");
+
+ snap.InGameStatePtr = inGameState;
+ var ingameData = _ctx.Memory.ReadPointer(inGameState + _ctx.Offsets.IngameDataFromStateOffset);
+ if (ingameData != 0)
+ {
+ snap.LocalPlayerPtr = _ctx.Memory.ReadPointer(ingameData + _ctx.Offsets.LocalPlayerDirectOffset);
+ if (snap.LocalPlayerPtr != 0)
+ {
+ _components.InvalidateCaches(snap.LocalPlayerPtr);
+ _components.ReadPlayerPosition(snap);
+ }
+ }
+
+ var headerSb = new StringBuilder();
+ headerSb.AppendLine($"InGameState: 0x{inGameState:X}");
+ headerSb.AppendLine($"Player pos: ({snap.PlayerX:F1}, {snap.PlayerY:F1}, {snap.PlayerZ:F1}) valid={snap.HasPosition}");
+ if (!snap.HasPosition)
+ return ([], headerSb.ToString(), headerSb + "ERROR: No player position — cannot validate matrices.");
+ headerSb.AppendLine();
+
+ const int screenW = 2560, screenH = 1440;
+ float halfW = screenW * 0.5f, halfH = screenH * 0.5f;
+ var playerWorld = new System.Numerics.Vector4(snap.PlayerX, snap.PlayerY, snap.PlayerZ, 1f);
+
+ var candidates = new List<(string source, float sx, float sy, float dist, float[] floats)>();
+
+ // Phase 1: Scan InGameState inline (camera struct is likely in first 0x800)
+ var igsData = _ctx.Memory.ReadBytes(inGameState, 0x2000);
+ if (igsData is not null)
+ {
+ for (var matOff = 0; matOff + 64 <= igsData.Length; matOff += 4)
+ {
+ if (!TryReadMatrix(igsData, matOff, out var matrix, out var floats)) continue;
+ if (!TryValidateMatrix(matrix, playerWorld, halfW, halfH, out var sx, out var sy, out var dist)) continue;
+ candidates.Add(($"IGS+0x{matOff:X} (inline)", sx, sy, dist, floats));
+ matOff += 60; // skip past this matrix (loop will add +4 = total +64)
+ }
+ }
+
+ // Phase 2: Follow pointers from InGameState (camera is likely a separate object)
+ // Only follow pointers in first 0x800 — camera ptr is a near field
+ if (igsData is not null)
+ {
+ var ptrScanLimit = Math.Min(igsData.Length, 0x800);
+ for (var off = 0; off + 8 <= ptrScanLimit; off += 8)
+ {
+ var ptr = (nint)BitConverter.ToInt64(igsData, off);
+ if (ptr == 0) continue;
+ var high = (ulong)ptr >> 32;
+ if (high == 0 || high >= 0x7FFF || (ptr & 0x3) != 0) continue;
+
+ // Camera object is small — scan 0x600 bytes max
+ var camData = _ctx.Memory.ReadBytes(ptr, 0x600);
+ if (camData is null) continue;
+
+ for (var matOff = 0; matOff + 64 <= camData.Length; matOff += 4)
+ {
+ if (!TryReadMatrix(camData, matOff, out var matrix, out var floats)) continue;
+ if (!TryValidateMatrix(matrix, playerWorld, halfW, halfH, out var sx, out var sy, out var dist)) continue;
+ candidates.Add(($"IGS+0x{off:X}→+0x{matOff:X} (ptr)", sx, sy, dist, floats));
+ matOff += 60; // skip past this matrix
+ }
+ }
+ }
+
+ // Deduplicate: multiple IGS pointers often resolve to the same underlying object.
+ // Group by float content, keep only the first (closest) source for each unique matrix.
+ var deduped = new List<(string source, float sx, float sy, float dist, float[] floats)>();
+ var seen = new HashSet();
+ candidates.Sort((a, b) => a.dist.CompareTo(b.dist));
+ foreach (var c in candidates)
+ {
+ // Build a content key from the 16 floats (round to avoid floating point noise)
+ var key = string.Join(",", c.floats.Select(f => MathF.Round(f, 3)));
+ if (!seen.Add(key))
+ {
+ // Duplicate content — append source to existing entry
+ var idx = deduped.FindIndex(d => string.Join(",", d.floats.Select(f => MathF.Round(f, 3))) == key);
+ if (idx >= 0)
+ deduped[idx] = (deduped[idx].source + " | " + c.source, deduped[idx].sx, deduped[idx].sy, deduped[idx].dist, deduped[idx].floats);
+ continue;
+ }
+ deduped.Add(c);
+ }
+ return (deduped, headerSb.ToString(), null);
+ }
+
+ private static void FormatCameraCandidates(StringBuilder sb, List<(string source, float sx, float sy, float dist, float[] floats)> candidates)
+ {
+ var ptrCandidates = candidates.Where(c => c.source.Contains("ptr")).ToList();
+ var inlineCandidates = candidates.Where(c => c.source.Contains("inline")).ToList();
+
+ sb.AppendLine($"TOTAL: {candidates.Count} (inline={inlineCandidates.Count}, ptr={ptrCandidates.Count})");
+ sb.AppendLine();
+
+ if (ptrCandidates.Count > 0)
+ {
+ sb.AppendLine("═══ POINTER CANDIDATES ═══");
+ for (var i = 0; i < ptrCandidates.Count; i++)
+ {
+ var c = ptrCandidates[i];
+ sb.AppendLine($"[P{i}] {c.source} screen=({c.sx:F1},{c.sy:F1}) dist={c.dist:F0}px");
+ FormatFloatRow(sb, c.floats);
+ sb.AppendLine();
+ }
+ }
+
+ if (inlineCandidates.Count > 0)
+ {
+ sb.AppendLine("═══ INLINE CANDIDATES ═══");
+ for (var i = 0; i < inlineCandidates.Count; i++)
+ {
+ var c = inlineCandidates[i];
+ sb.AppendLine($"[I{i}] {c.source} screen=({c.sx:F1},{c.sy:F1}) dist={c.dist:F0}px");
+ FormatFloatRow(sb, c.floats);
+ sb.AppendLine();
+ }
+ }
+ }
+
+ private static void FormatFloatRow(StringBuilder sb, float[] floats)
+ {
+ sb.Append(" ");
+ for (var i = 0; i < 16; i++)
+ {
+ sb.Append($"{floats[i]:F6}");
+ if (i < 15) sb.Append(" | ");
+ }
+ sb.AppendLine();
+ }
+
+ private static bool TryReadMatrix(byte[] data, int offset, out System.Numerics.Matrix4x4 matrix, out float[] rawFloats)
+ {
+ matrix = default;
+ rawFloats = Array.Empty();
+ if (offset + 64 > data.Length) return false;
+
+ var floats = new float[16];
+ for (var i = 0; i < 16; i++)
+ {
+ floats[i] = BitConverter.ToSingle(data, offset + i * 4);
+ if (float.IsNaN(floats[i]) || float.IsInfinity(floats[i])) return false;
+ }
+
+ // Need at least 8 non-zero values for a valid VP matrix
+ var nonZeroCount = 0;
+ for (var i = 0; i < 16; i++)
+ if (MathF.Abs(floats[i]) > 0.0001f) nonZeroCount++;
+ if (nonZeroCount < 8) return false;
+
+ rawFloats = floats;
+ matrix = new System.Numerics.Matrix4x4(
+ floats[0], floats[1], floats[2], floats[3],
+ floats[4], floats[5], floats[6], floats[7],
+ floats[8], floats[9], floats[10], floats[11],
+ floats[12], floats[13], floats[14], floats[15]);
+ return true;
+ }
+
+ private static bool TryValidateMatrix(
+ System.Numerics.Matrix4x4 matrix,
+ System.Numerics.Vector4 playerWorld,
+ float halfW, float halfH,
+ out float sx, out float sy, out float dist)
+ {
+ sx = sy = dist = 0;
+
+ var clip = System.Numerics.Vector4.Transform(playerWorld, matrix);
+ if (clip.W == 0 || float.IsNaN(clip.W) || float.IsInfinity(clip.W)) return false;
+ // W should be positive and in reasonable range for perspective divide
+ if (clip.W < 0.5f || clip.W > 10000f) return false;
+
+ var ndc = System.Numerics.Vector4.Divide(clip, clip.W);
+
+ // NDC should be in [-1, 1] for player at screen center
+ if (MathF.Abs(ndc.X) > 1.5f || MathF.Abs(ndc.Y) > 1.5f) return false;
+ if (float.IsNaN(ndc.Z) || float.IsInfinity(ndc.Z)) return false;
+
+ sx = (ndc.X + 1f) * halfW;
+ sy = (1f - ndc.Y) * halfH;
+
+ // Player should map to within 150px of screen center (real camera is very close)
+ dist = MathF.Sqrt((sx - halfW) * (sx - halfW) + (sy - halfH) * (sy - halfH));
+ return dist < 150;
+ }
+
+ private static bool IsReasonableFloat(float f)
+ {
+ if (float.IsNaN(f) || float.IsInfinity(f)) return false;
+ var abs = MathF.Abs(f);
+ return abs > 0.001f && abs < 100000f;
+ }
+
+ private static string FormatBytes(byte[]? data)
+ {
+ if (data is null) return "Error: read failed";
+ return BitConverter.ToString(data).Replace('-', ' ');
+ }
+
+ private static void SaveTerrainImage(byte[] gridData, int width, int height, string path)
+ {
+ using var bmp = new Bitmap(width, height, PixelFormat.Format8bppIndexed);
+
+ // Set palette: 0 = white (walkable), nonzero = black (blocked)
+ var palette = bmp.Palette;
+ palette.Entries[0] = Color.White;
+ for (var i = 1; i < 256; i++)
+ palette.Entries[i] = Color.Black;
+ bmp.Palette = palette;
+
+ // Lock bits and copy row by row (stride may differ from width)
+ var rect = new Rectangle(0, 0, width, height);
+ var bmpData = bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed);
+ try
+ {
+ for (var y = 0; y < height; y++)
+ {
+ Marshal.Copy(gridData, y * width, bmpData.Scan0 + y * bmpData.Stride, width);
+ }
+ }
+ finally
+ {
+ bmp.UnlockBits(bmpData);
+ }
+
+ bmp.Save(path, ImageFormat.Png);
+ }
+}
diff --git a/src/Automata.Memory/MsvcStringReader.cs b/src/Automata.Memory/MsvcStringReader.cs
new file mode 100644
index 0000000..9274be2
--- /dev/null
+++ b/src/Automata.Memory/MsvcStringReader.cs
@@ -0,0 +1,110 @@
+using System.Text;
+
+namespace Automata.Memory;
+
+///
+/// Reads MSVC std::string and std::wstring from process memory.
+/// Handles SSO (Small String Optimization) for both narrow and wide strings.
+///
+public sealed class MsvcStringReader
+{
+ private readonly MemoryContext _ctx;
+
+ public MsvcStringReader(MemoryContext ctx)
+ {
+ _ctx = ctx;
+ }
+
+ ///
+ /// Reads an MSVC std::wstring (UTF-16) from the given address.
+ /// Layout: _Bx (16 bytes: SSO buffer or heap ptr), _Mysize (8), _Myres (8).
+ /// wchar_t is 2 bytes on Windows. SSO threshold: capacity <= 7.
+ ///
+ public string? ReadMsvcWString(nint stringAddr)
+ {
+ var mem = _ctx.Memory;
+ var size = mem.Read(stringAddr + 0x10);
+ var capacity = mem.Read(stringAddr + 0x18);
+
+ if (size <= 0 || size > 512 || capacity < size) return null;
+
+ nint dataAddr;
+ if (capacity <= 7)
+ dataAddr = stringAddr; // SSO: inline in _Bx buffer
+ else
+ {
+ dataAddr = mem.ReadPointer(stringAddr);
+ if (dataAddr == 0) return null;
+ }
+
+ var bytes = mem.ReadBytes(dataAddr, (int)size * 2);
+ if (bytes is null) return null;
+
+ var str = Encoding.Unicode.GetString(bytes);
+ if (str.Length > 0 && str[0] >= 0x20 && str[0] <= 0x7E)
+ return str;
+
+ return null;
+ }
+
+ ///
+ /// Reads an MSVC std::string (narrow, UTF-8/ASCII) from the given address.
+ /// Layout: _Bx (16 bytes: SSO buffer or heap ptr), _Mysize (8), _Myres (8).
+ /// SSO threshold: capacity <= 15.
+ ///
+ public string? ReadMsvcString(nint stringAddr)
+ {
+ var mem = _ctx.Memory;
+ var size = mem.Read(stringAddr + 0x10);
+ var capacity = mem.Read(stringAddr + 0x18);
+
+ if (size <= 0 || size > 512 || capacity < size) return null;
+
+ nint dataAddr;
+ if (capacity <= 15)
+ dataAddr = stringAddr; // SSO: inline in _Bx buffer
+ else
+ {
+ dataAddr = mem.ReadPointer(stringAddr);
+ if (dataAddr == 0) return null;
+ }
+
+ var bytes = mem.ReadBytes(dataAddr, (int)size);
+ if (bytes is null) return null;
+
+ var str = Encoding.UTF8.GetString(bytes);
+ if (str.Length > 0 && str[0] >= 0x20 && str[0] <= 0x7E)
+ return str;
+
+ return null;
+ }
+
+ ///
+ /// Reads a null-terminated char* string from a module-range or heap address.
+ /// Component names are char* literals in .rdata, e.g. "Life", "Render", "Monster".
+ ///
+ public string? ReadCharPtr(nint ptr)
+ {
+ if (ptr == 0) return null;
+ var data = _ctx.Memory.ReadBytes(ptr, 64);
+ if (data is null) return null;
+ var end = Array.IndexOf(data, (byte)0);
+ if (end < 1 || end > 50) return null;
+ var str = Encoding.ASCII.GetString(data, 0, end);
+ if (str.Length > 0 && str.All(c => c >= 0x20 && c <= 0x7E))
+ return str;
+ return null;
+ }
+
+ ///
+ /// Reads a null-terminated UTF-8 string (up to 256 bytes).
+ ///
+ public string ReadNullTermString(nint addr)
+ {
+ var data = _ctx.Memory.ReadBytes(addr, 256);
+ if (data is null) return "Error: read failed";
+ var end = Array.IndexOf(data, (byte)0);
+ if (end < 0) end = data.Length;
+ return Encoding.UTF8.GetString(data, 0, end);
+ }
+}
diff --git a/src/Automata.Memory/RttiResolver.cs b/src/Automata.Memory/RttiResolver.cs
new file mode 100644
index 0000000..a38598d
--- /dev/null
+++ b/src/Automata.Memory/RttiResolver.cs
@@ -0,0 +1,81 @@
+using System.Text;
+
+namespace Automata.Memory;
+
+///
+/// Resolves MSVC x64 RTTI type names from vtable addresses and classifies pointers.
+///
+public sealed class RttiResolver
+{
+ private readonly MemoryContext _ctx;
+
+ public RttiResolver(MemoryContext ctx)
+ {
+ _ctx = ctx;
+ }
+
+ ///
+ /// Resolves a vtable address to its RTTI class name using MSVC x64 RTTI layout.
+ /// vtable[-1] → RTTICompleteObjectLocator → TypeDescriptor → mangled name
+ ///
+ public string? ResolveRttiName(nint vtableAddr)
+ {
+ var mem = _ctx.Memory;
+ if (mem is null || _ctx.ModuleBase == 0) return null;
+
+ try
+ {
+ var colPtr = mem.ReadPointer(vtableAddr - 8);
+ if (colPtr == 0) return null;
+
+ var signature = mem.Read(colPtr);
+ if (signature != 1) return null;
+
+ var typeDescOffset = mem.Read(colPtr + 0x0C);
+ if (typeDescOffset <= 0) return null;
+
+ var typeDesc = _ctx.ModuleBase + typeDescOffset;
+ var nameBytes = mem.ReadBytes(typeDesc + 0x10, 128);
+ if (nameBytes is null) return null;
+
+ var end = Array.IndexOf(nameBytes, (byte)0);
+ if (end <= 0) return null;
+
+ var mangled = Encoding.ASCII.GetString(nameBytes, 0, end);
+
+ if (mangled.StartsWith(".?AV") && mangled.EndsWith("@@"))
+ return mangled[4..^2];
+ if (mangled.StartsWith(".?AU") && mangled.EndsWith("@@"))
+ return mangled[4..^2];
+
+ return mangled;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Classifies a pointer value: returns a tag string ("module (vtable?)", "heap ptr", RTTI name) or null.
+ ///
+ public string? ClassifyPointer(nint value)
+ {
+ if (value == 0) return null;
+
+ if (_ctx.IsModuleAddress(value))
+ {
+ var name = ResolveRttiName(value);
+ return name ?? "module (vtable?)";
+ }
+
+ if (value > 0x10000 && value < (nint)0x7FFFFFFFFFFF && (value & 0x3) == 0)
+ {
+ var high = (ulong)value >> 32;
+ if (high > 0 && high < 0x7FFF)
+ return "heap ptr";
+ }
+
+ return null;
+ }
+}
diff --git a/src/Automata.Memory/TerrainReader.cs b/src/Automata.Memory/TerrainReader.cs
index 86c456d..a97fcb0 100644
--- a/src/Automata.Memory/TerrainReader.cs
+++ b/src/Automata.Memory/TerrainReader.cs
@@ -2,117 +2,126 @@ using Serilog;
namespace Automata.Memory;
-public sealed class WalkabilityGrid
+///
+/// Reads terrain walkability grid from AreaInstance, with caching and loading edge detection.
+///
+public sealed class TerrainReader
{
- public int Width { get; }
- public int Height { get; }
- public byte[] Data { get; }
+ private readonly MemoryContext _ctx;
+ private uint _cachedTerrainAreaHash;
+ private WalkabilityGrid? _cachedTerrain;
+ private bool _wasLoading;
- public WalkabilityGrid(int width, int height, byte[] data)
+ public TerrainReader(MemoryContext ctx)
{
- Width = width;
- Height = height;
- Data = data;
+ _ctx = ctx;
}
- public bool IsWalkable(int x, int y)
+ ///
+ /// Invalidates the terrain cache (called when LocalPlayer changes on zone change).
+ ///
+ public void InvalidateCache()
{
- if (x < 0 || x >= Width || y < 0 || y >= Height)
- return false;
- return Data[y * Width + x] == 0;
- }
-}
-
-public sealed class TerrainReader : IDisposable
-{
- private readonly TerrainOffsets _offsets;
- private ProcessMemory? _memory;
- private PatternScanner? _scanner;
- private nint _gameStateBase;
- private bool _disposed;
-
- public bool IsReady => _gameStateBase != 0;
-
- public TerrainReader(TerrainOffsets offsets)
- {
- _offsets = offsets;
+ _cachedTerrain = null;
+ _cachedTerrainAreaHash = 0;
}
- public bool Initialize()
+ ///
+ /// Reads terrain data from AreaInstance into the snapshot.
+ /// Handles both inline and pointer-based terrain layouts.
+ ///
+ public void ReadTerrain(GameStateSnapshot snap, nint areaInstance)
{
- _memory?.Dispose();
- _memory = ProcessMemory.Attach(_offsets.ProcessName);
- if (_memory is null)
- return false;
+ var mem = _ctx.Memory;
+ var offsets = _ctx.Offsets;
- if (string.IsNullOrWhiteSpace(_offsets.GameStatePattern))
+ if (!offsets.TerrainInline)
{
- Log.Warning("GameStatePattern is empty — offsets not yet configured for POE2");
- return false;
+ // Pointer-based: AreaInstance → TerrainList → first terrain → dimensions
+ var terrainListPtr = mem.ReadPointer(areaInstance + offsets.TerrainListOffset);
+ if (terrainListPtr == 0) return;
+
+ var terrainPtr = mem.ReadPointer(terrainListPtr);
+ if (terrainPtr == 0) return;
+
+ var dimsPtr = mem.ReadPointer(terrainPtr + offsets.TerrainDimensionsOffset);
+ if (dimsPtr == 0) return;
+
+ snap.TerrainCols = mem.Read(dimsPtr);
+ snap.TerrainRows = mem.Read(dimsPtr + 4);
+ if (snap.TerrainCols > 0 && snap.TerrainCols < 1000 &&
+ snap.TerrainRows > 0 && snap.TerrainRows < 1000)
+ {
+ snap.TerrainWidth = snap.TerrainCols * offsets.SubTilesPerCell;
+ snap.TerrainHeight = snap.TerrainRows * offsets.SubTilesPerCell;
+ }
+ else
+ {
+ snap.TerrainCols = 0;
+ snap.TerrainRows = 0;
+ }
+ return;
}
- _scanner = new PatternScanner(_memory);
- _gameStateBase = _scanner.FindPatternRip(_offsets.GameStatePattern);
+ // Inline mode: TerrainStruct is inline at AreaInstance + TerrainListOffset
+ var terrainBase = areaInstance + offsets.TerrainListOffset;
+ var cols = (int)mem.Read(terrainBase + offsets.TerrainDimensionsOffset);
+ var rows = (int)mem.Read(terrainBase + offsets.TerrainDimensionsOffset + 8);
- if (_gameStateBase == 0)
+ if (cols <= 0 || cols >= 1000 || rows <= 0 || rows >= 1000)
+ return;
+
+ snap.TerrainCols = cols;
+ snap.TerrainRows = rows;
+ snap.TerrainWidth = cols * offsets.SubTilesPerCell;
+ snap.TerrainHeight = rows * offsets.SubTilesPerCell;
+
+ // While loading, clear cached terrain and don't read (data is stale/invalid)
+ if (snap.IsLoading)
{
- Log.Error("Failed to resolve GameState base pointer");
- return false;
+ _cachedTerrain = null;
+ _cachedTerrainAreaHash = 0;
+ return;
}
- Log.Information("GameState base: 0x{Address:X}", _gameStateBase);
- return true;
- }
-
- public WalkabilityGrid? ReadTerrain()
- {
- if (_memory is null || _gameStateBase == 0)
- return null;
-
- // Follow pointer chain: GameState → InGameState → IngameData → TerrainData
- var terrainBase = _memory.FollowChain(_gameStateBase, [
- _offsets.InGameStateOffset,
- _offsets.IngameDataOffset,
- _offsets.TerrainDataOffset
- ]);
-
- if (terrainBase == 0)
+ // Loading just finished — clear cache to force a fresh read
+ if (_wasLoading)
{
- Log.Debug("Terrain pointer chain returned null");
- return null;
+ _cachedTerrain = null;
+ _cachedTerrainAreaHash = 0;
}
- var numCols = _memory.Read(terrainBase + _offsets.NumColsOffset);
- var numRows = _memory.Read(terrainBase + _offsets.NumRowsOffset);
- var bytesPerRow = _memory.Read(terrainBase + _offsets.BytesPerRowOffset);
-
- if (numCols <= 0 || numRows <= 0 || bytesPerRow <= 0)
+ // Return cached grid if same area
+ if (_cachedTerrain != null && _cachedTerrainAreaHash == snap.AreaHash)
{
- Log.Warning("Invalid terrain dimensions: {Cols}x{Rows}, bytesPerRow={Bpr}", numCols, numRows, bytesPerRow);
- return null;
+ snap.Terrain = _cachedTerrain;
+ snap.TerrainWalkablePercent = CalcWalkablePercent(_cachedTerrain);
+ return;
}
- var gridWidth = numCols * _offsets.SubTilesPerCell;
- var gridHeight = numRows * _offsets.SubTilesPerCell;
+ // Read GridWalkableData StdVector (begin/end/cap pointers)
+ var gridVecOffset = offsets.TerrainWalkableGridOffset;
+ var gridBegin = mem.ReadPointer(terrainBase + gridVecOffset);
+ var gridEnd = mem.ReadPointer(terrainBase + gridVecOffset + 8);
+ if (gridBegin == 0 || gridEnd <= gridBegin)
+ return;
- // Read melee layer pointer
- var layerPtr = _memory.ReadPointer(terrainBase + _offsets.LayerMeleeOffset);
- if (layerPtr == 0)
- {
- Log.Warning("Melee layer pointer is null");
- return null;
- }
+ var gridDataSize = (int)(gridEnd - gridBegin);
+ if (gridDataSize <= 0 || gridDataSize > 16 * 1024 * 1024)
+ return;
- // Read raw terrain data
- var rawSize = bytesPerRow * gridHeight;
- var rawData = _memory.ReadBytes(layerPtr, rawSize);
+ var bytesPerRow = mem.Read(terrainBase + offsets.TerrainBytesPerRowOffset);
+ if (bytesPerRow <= 0 || bytesPerRow > 0x10000)
+ return;
+
+ var gridWidth = cols * offsets.SubTilesPerCell;
+ var gridHeight = rows * offsets.SubTilesPerCell;
+
+ var rawData = mem.ReadBytes(gridBegin, gridDataSize);
if (rawData is null)
- {
- Log.Warning("Failed to read terrain data ({Size} bytes)", rawSize);
- return null;
- }
+ return;
- // Unpack 4-bit nibbles: each byte → 2 cells (low nibble = even col, high nibble = odd col)
+ // Unpack 4-bit nibbles: each byte → 2 cells
var data = new byte[gridWidth * gridHeight];
for (var row = 0; row < gridHeight; row++)
{
@@ -128,14 +137,30 @@ public sealed class TerrainReader : IDisposable
}
}
- Log.Information("Terrain read: {Width}x{Height} ({Cols}x{Rows} cells)", gridWidth, gridHeight, numCols, numRows);
- return new WalkabilityGrid(gridWidth, gridHeight, data);
+ var grid = new WalkabilityGrid(gridWidth, gridHeight, data);
+ snap.Terrain = grid;
+ snap.TerrainWalkablePercent = CalcWalkablePercent(grid);
+
+ _cachedTerrain = grid;
+ _cachedTerrainAreaHash = snap.AreaHash;
+
+ Log.Information("Terrain grid read: {W}x{H} ({Cols}x{Rows} cells), {Pct}% walkable",
+ gridWidth, gridHeight, cols, rows, snap.TerrainWalkablePercent);
}
- public void Dispose()
+ ///
+ /// Updates the loading edge detection state. Call after ReadTerrain.
+ ///
+ public void UpdateLoadingEdge(bool isLoading)
{
- if (_disposed) return;
- _disposed = true;
- _memory?.Dispose();
+ _wasLoading = isLoading;
+ }
+
+ public static int CalcWalkablePercent(WalkabilityGrid grid)
+ {
+ var walkable = 0;
+ for (var i = 0; i < grid.Data.Length; i++)
+ if (grid.Data[i] != 0) walkable++;
+ return grid.Data.Length > 0 ? (int)(100L * walkable / grid.Data.Length) : 0;
}
}
diff --git a/src/Automata.Memory/WalkabilityGrid.cs b/src/Automata.Memory/WalkabilityGrid.cs
new file mode 100644
index 0000000..d366c54
--- /dev/null
+++ b/src/Automata.Memory/WalkabilityGrid.cs
@@ -0,0 +1,22 @@
+namespace Automata.Memory;
+
+public sealed class WalkabilityGrid
+{
+ public int Width { get; }
+ public int Height { get; }
+ public byte[] Data { get; }
+
+ public WalkabilityGrid(int width, int height, byte[] data)
+ {
+ Width = width;
+ Height = height;
+ Data = data;
+ }
+
+ public bool IsWalkable(int x, int y)
+ {
+ if (x < 0 || x >= Width || y < 0 || y >= Height)
+ return false;
+ return Data[y * Width + x] != 0;
+ }
+}
diff --git a/src/Automata.Ui/App.axaml.cs b/src/Automata.Ui/App.axaml.cs
index 06a64fd..f370d39 100644
--- a/src/Automata.Ui/App.axaml.cs
+++ b/src/Automata.Ui/App.axaml.cs
@@ -70,6 +70,7 @@ public partial class App : Application
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
var provider = services.BuildServiceProvider();
@@ -95,6 +96,7 @@ public partial class App : Application
mainVm.AtlasVm = provider.GetRequiredService();
mainVm.CraftingVm = provider.GetRequiredService();
mainVm.MemoryVm = provider.GetRequiredService();
+ mainVm.RobotoVm = provider.GetRequiredService();
var window = new MainWindow { DataContext = mainVm };
window.SetConfigStore(store);
@@ -108,6 +110,7 @@ public partial class App : Application
{
overlay.Shutdown();
mainVm.Shutdown();
+ mainVm.RobotoVm?.Shutdown();
await bot.DisposeAsync();
};
}
diff --git a/src/Automata.Ui/Automata.Ui.csproj b/src/Automata.Ui/Automata.Ui.csproj
index f9881c3..5c4965d 100644
--- a/src/Automata.Ui/Automata.Ui.csproj
+++ b/src/Automata.Ui/Automata.Ui.csproj
@@ -24,6 +24,7 @@
+
diff --git a/src/Automata.Ui/Overlay/D2dOverlay.cs b/src/Automata.Ui/Overlay/D2dOverlay.cs
index 80b1732..8f91cac 100644
--- a/src/Automata.Ui/Overlay/D2dOverlay.cs
+++ b/src/Automata.Ui/Overlay/D2dOverlay.cs
@@ -72,6 +72,7 @@ public sealed class D2dOverlay
_layers.Add(new D2dEnemyBoxLayer(ctx));
_layers.Add(new D2dLootLabelLayer(ctx));
+ _layers.Add(new D2dEntityLabelLayer(ctx));
_layers.Add(new D2dHudInfoLayer());
_layers.Add(new D2dDebugTextLayer());
diff --git a/src/Automata.Ui/Overlay/Layers/D2dEntityLabelLayer.cs b/src/Automata.Ui/Overlay/Layers/D2dEntityLabelLayer.cs
new file mode 100644
index 0000000..18bec05
--- /dev/null
+++ b/src/Automata.Ui/Overlay/Layers/D2dEntityLabelLayer.cs
@@ -0,0 +1,71 @@
+using System.Drawing;
+using System.Numerics;
+using Automata.Ui.ViewModels;
+using Vortice.DirectWrite;
+
+namespace Automata.Ui.Overlay.Layers;
+
+internal sealed class D2dEntityLabelLayer : ID2dOverlayLayer, IDisposable
+{
+ private const int ScreenW = 2560;
+ private const int ScreenH = 1440;
+ private const float HalfW = ScreenW * 0.5f;
+ private const float HalfH = ScreenH * 0.5f;
+
+ private readonly D2dRenderContext _ctx;
+ private readonly Dictionary _labelCache = new();
+
+ public D2dEntityLabelLayer(D2dRenderContext ctx)
+ {
+ _ctx = ctx;
+ }
+
+ public void Draw(D2dRenderContext ctx, OverlayState state)
+ {
+ var data = RobotoViewModel.OverlayData;
+ if (data is null || data.Entries.Length == 0) return;
+ if (data.CameraMatrix is not { } matrix) return;
+
+ var rt = ctx.RenderTarget;
+ var playerZ = data.PlayerZ;
+
+ foreach (ref readonly var entry in data.Entries.AsSpan())
+ {
+ // WorldToScreen using camera's view-projection matrix (same as ExileCore)
+ var worldPos = new Vector4(entry.X, entry.Y, playerZ, 1f);
+ var clip = Vector4.Transform(worldPos, matrix);
+
+ // Perspective divide
+ if (clip.W is 0f or float.NaN) continue;
+ clip = Vector4.Divide(clip, clip.W);
+
+ // NDC → screen coordinates
+ var sx = (clip.X + 1f) * HalfW;
+ var sy = (1f - clip.Y) * HalfH;
+
+ // Skip off-screen
+ if (sx < -200 || sx > ScreenW + 200 || sy < -100 || sy > ScreenH + 100) continue;
+
+ if (!_labelCache.TryGetValue(entry.Label, out var layout))
+ {
+ layout = _ctx.CreateTextLayout(entry.Label, _ctx.LabelFormat);
+ _labelCache[entry.Label] = layout;
+ }
+
+ var m = layout.Metrics;
+ var labelX = sx - m.Width / 2f;
+ var labelY = sy - m.Height - 4;
+
+ rt.FillRectangle(
+ new RectangleF(labelX - 2, labelY - 1, m.Width + 4, m.Height + 2),
+ ctx.LabelBgBrush);
+
+ rt.DrawTextLayout(new Vector2(labelX, labelY), layout, ctx.Cyan);
+ }
+ }
+
+ public void Dispose()
+ {
+ foreach (var l in _labelCache.Values) l?.Dispose();
+ }
+}
diff --git a/src/Automata.Ui/ViewModels/MainWindowViewModel.cs b/src/Automata.Ui/ViewModels/MainWindowViewModel.cs
index c3c9a43..d556faf 100644
--- a/src/Automata.Ui/ViewModels/MainWindowViewModel.cs
+++ b/src/Automata.Ui/ViewModels/MainWindowViewModel.cs
@@ -183,6 +183,7 @@ public partial class MainWindowViewModel : ObservableObject
public AtlasViewModel? AtlasVm { get; set; }
public CraftingViewModel? CraftingVm { get; set; }
public MemoryViewModel? MemoryVm { get; set; }
+ public RobotoViewModel? RobotoVm { get; set; }
partial void OnBotModeChanged(BotMode value)
{
diff --git a/src/Automata.Ui/ViewModels/MemoryViewModel.cs b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
index af90843..91bca0f 100644
--- a/src/Automata.Ui/ViewModels/MemoryViewModel.cs
+++ b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
@@ -789,7 +789,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- RawResult = _reader.ReadAddress(RawAddress, RawOffsets, RawType);
+ RawResult = _reader.Diagnostics!.ReadAddress(RawAddress, RawOffsets, RawType);
}
[RelayCommand]
@@ -804,7 +804,7 @@ public partial class MemoryViewModel : ObservableObject
if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size))
size = 0x400;
- ScanResult = _reader.ScanRegion(ScanAddress, ScanOffsets, size);
+ ScanResult = _reader.Diagnostics!.ScanRegion(ScanAddress, ScanOffsets, size);
}
[RelayCommand]
@@ -816,7 +816,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- ScanResult = _reader.ScanAllStates();
+ ScanResult = _reader.Diagnostics!.ScanAllStates();
}
[RelayCommand]
@@ -828,7 +828,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- ScanResult = _reader.ProbeInGameState();
+ ScanResult = _reader.Diagnostics!.ProbeInGameState();
}
[RelayCommand]
@@ -844,7 +844,7 @@ public partial class MemoryViewModel : ObservableObject
int.TryParse(VitalMana, out var mana);
int.TryParse(VitalEs, out var es);
- ScanResult = _reader.ScanComponents(hp, mana, es);
+ ScanResult = _reader.Diagnostics!.ScanComponents(hp, mana, es);
}
[RelayCommand]
@@ -860,7 +860,7 @@ public partial class MemoryViewModel : ObservableObject
int.TryParse(VitalMana, out var mana);
int.TryParse(VitalEs, out var es);
- ScanResult = _reader.DeepScanVitals(hp, mana, es);
+ ScanResult = _reader.Diagnostics!.DeepScanVitals(hp, mana, es);
}
[RelayCommand]
@@ -872,7 +872,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- ScanResult = _reader.DiagnoseVitals();
+ ScanResult = _reader.Diagnostics!.DiagnoseVitals();
}
[RelayCommand]
@@ -884,7 +884,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- ScanResult = _reader.ScanPosition();
+ ScanResult = _reader.Diagnostics!.ScanPosition();
}
[RelayCommand]
@@ -896,7 +896,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- ScanResult = _reader.DiagnoseEntity();
+ ScanResult = _reader.Diagnostics!.DiagnoseEntity();
}
[RelayCommand]
@@ -908,7 +908,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- ScanResult = _reader.ScanEntities();
+ ScanResult = _reader.Diagnostics!.ScanEntities();
}
[RelayCommand]
@@ -920,7 +920,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- ScanResult = _reader.ScanComponentLookup();
+ ScanResult = _reader.Diagnostics!.ScanComponentLookup();
}
[RelayCommand]
@@ -932,7 +932,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- ScanResult = _reader.ScanAreaLoadingState();
+ ScanResult = _reader.Diagnostics!.ScanAreaLoadingState();
}
[RelayCommand]
@@ -944,7 +944,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- ScanResult = _reader.ScanMemoryDiff();
+ ScanResult = _reader.Diagnostics!.ScanMemoryDiff();
}
[RelayCommand]
@@ -956,7 +956,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- ScanResult = _reader.ScanActiveStatesVector();
+ ScanResult = _reader.Diagnostics!.ScanActiveStatesVector();
}
[RelayCommand]
@@ -968,7 +968,7 @@ public partial class MemoryViewModel : ObservableObject
return;
}
- ScanResult = _reader.ScanTerrain();
+ ScanResult = _reader.Diagnostics!.ScanTerrain();
}
[RelayCommand]
@@ -983,6 +983,30 @@ public partial class MemoryViewModel : ObservableObject
if (!int.TryParse(ScanSize, System.Globalization.NumberStyles.HexNumber, null, out var size))
size = 0x2000;
- ScanResult = _reader.ScanStructure(ScanAddress, ScanOffsets, size);
+ ScanResult = _reader.Diagnostics!.ScanStructure(ScanAddress, ScanOffsets, size);
+ }
+
+ [RelayCommand]
+ private void ScanCameraExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.ScanCamera();
+ }
+
+ [RelayCommand]
+ private void CameraDiffExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.Diagnostics!.CameraDiff();
}
}
diff --git a/src/Automata.Ui/ViewModels/RobotoViewModel.cs b/src/Automata.Ui/ViewModels/RobotoViewModel.cs
new file mode 100644
index 0000000..d2def47
--- /dev/null
+++ b/src/Automata.Ui/ViewModels/RobotoViewModel.cs
@@ -0,0 +1,269 @@
+using System.Collections.ObjectModel;
+using System.Numerics;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Roboto.Core;
+using Roboto.Engine;
+using Roboto.Input;
+using Roboto.Navigation;
+
+namespace Automata.Ui.ViewModels;
+
+///
+/// Thread-safe snapshot read by the overlay layer each frame.
+///
+public sealed class EntityOverlayData
+{
+ public Vector2 PlayerPosition;
+ public float PlayerZ;
+ public Matrix4x4? CameraMatrix;
+ public EntityOverlayEntry[] Entries = [];
+}
+
+public readonly struct EntityOverlayEntry
+{
+ public readonly float X, Y;
+ public readonly string Label;
+
+ public EntityOverlayEntry(float x, float y, string label)
+ {
+ X = x;
+ Y = y;
+ Label = label;
+ }
+}
+
+///
+/// View model item for entity checkbox list.
+///
+public partial class EntityListItem : ObservableObject
+{
+ public uint Id { get; }
+ public string Label { get; }
+ public string Category { get; }
+ public string Distance { get; set; }
+ public float X { get; set; }
+ public float Y { get; set; }
+
+ [ObservableProperty] private bool _isChecked;
+
+ public EntityListItem(uint id, string label, string category, float distance, float x, float y)
+ {
+ Id = id;
+ Label = label;
+ Category = category;
+ Distance = $"{distance:F0}";
+ X = x;
+ Y = y;
+ }
+}
+
+public partial class RobotoViewModel : ObservableObject, IDisposable
+{
+ private readonly BotEngine _engine;
+ private long _lastUiUpdate;
+ private bool _disposed;
+
+ [ObservableProperty] private string _statusText = "Stopped";
+ [ObservableProperty] private bool _isRunning;
+
+ // Player state
+ [ObservableProperty] private string _playerPosition = "—";
+ [ObservableProperty] private string _playerLife = "—";
+ [ObservableProperty] private string _playerMana = "—";
+ [ObservableProperty] private string _playerEs = "—";
+
+ // Game state
+ [ObservableProperty] private string _areaInfo = "—";
+ [ObservableProperty] private string _dangerLevel = "—";
+ [ObservableProperty] private string _entityCount = "—";
+ [ObservableProperty] private string _hostileCount = "—";
+ [ObservableProperty] private string _tickInfo = "—";
+
+ // Systems
+ [ObservableProperty] private string _systemsInfo = "—";
+
+ // Navigation
+ [ObservableProperty] private string _navMode = "Idle";
+ [ObservableProperty] private string _navStatus = "—";
+
+ // Entity list for checkbox UI
+ [ObservableProperty] private bool _showAllEntities;
+ public ObservableCollection Entities { get; } = [];
+
+ ///
+ /// Thread-safe snapshot for the overlay layer (written on UI thread, read on overlay thread).
+ ///
+ public static volatile EntityOverlayData? OverlayData;
+
+ public RobotoViewModel()
+ {
+ var config = new BotConfig();
+ var memory = new MemoryAdapter();
+ var humanizer = new Humanizer(config);
+ var input = new InterceptionInputController(humanizer);
+
+ _engine = new BotEngine(config, memory, input);
+
+ _engine.StatusChanged += status =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() => StatusText = status);
+ };
+
+ _engine.StateUpdated += OnStateUpdated;
+ }
+
+ [RelayCommand]
+ private void Start()
+ {
+ if (_engine.IsRunning) return;
+ var ok = _engine.Start();
+ IsRunning = _engine.IsRunning;
+ if (!ok)
+ StatusText = _engine.Status;
+ }
+
+ [RelayCommand]
+ private void Stop()
+ {
+ _engine.Stop();
+ IsRunning = false;
+ StatusText = "Stopped";
+ PlayerPosition = "—";
+ PlayerLife = "—";
+ PlayerMana = "—";
+ PlayerEs = "—";
+ AreaInfo = "—";
+ DangerLevel = "—";
+ EntityCount = "—";
+ HostileCount = "—";
+ TickInfo = "—";
+ NavMode = "Idle";
+ NavStatus = "—";
+ Entities.Clear();
+ OverlayData = null;
+ }
+
+ [RelayCommand]
+ private void Explore()
+ {
+ if (!_engine.IsRunning) return;
+ _engine.Nav.Explore();
+ }
+
+ [RelayCommand]
+ private void StopNav()
+ {
+ _engine.Nav.Stop();
+ }
+
+ private void OnStateUpdated()
+ {
+ // Throttle UI updates to ~10Hz
+ var now = Environment.TickCount64;
+ if (now - _lastUiUpdate < 100) return;
+ _lastUiUpdate = now;
+
+ var state = _engine.CurrentState;
+ if (state is null) return;
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() => UpdateFromState(state));
+ }
+
+ private void UpdateFromState(GameState state)
+ {
+ var p = state.Player;
+ PlayerPosition = p.HasPosition
+ ? $"({p.Position.X:F1}, {p.Position.Y:F1})"
+ : "—";
+ PlayerLife = p.LifeTotal > 0 ? $"{p.LifeCurrent}/{p.LifeTotal} ({p.LifePercent:F0}%)" : "—";
+ PlayerMana = p.ManaTotal > 0 ? $"{p.ManaCurrent}/{p.ManaTotal} ({p.ManaPercent:F0}%)" : "—";
+ PlayerEs = p.EsTotal > 0 ? $"{p.EsCurrent}/{p.EsTotal} ({p.EsPercent:F0}%)" : "—";
+
+ AreaInfo = state.AreaHash != 0
+ ? $"Level {state.AreaLevel} (0x{state.AreaHash:X8})"
+ : "—";
+ DangerLevel = state.Danger.ToString();
+ EntityCount = $"{state.Entities.Count} total";
+ HostileCount = $"{state.HostileMonsters.Count} hostile";
+ TickInfo = $"Tick {state.TickNumber}, dt={state.DeltaTime * 1000:F0}ms";
+
+ // Systems status
+ var systems = _engine.Systems;
+ var enabled = systems.Count(s => s.IsEnabled);
+ SystemsInfo = $"{enabled}/{systems.Count} active";
+
+ // Navigation
+ NavMode = _engine.Nav.Mode.ToString();
+ NavStatus = _engine.Nav.Status;
+
+ // Entity list
+ UpdateEntityList(state);
+ }
+
+ private void UpdateEntityList(GameState state)
+ {
+ // Build lookup of currently checked IDs
+ var checkedIds = new HashSet();
+ foreach (var item in Entities)
+ if (item.IsChecked) checkedIds.Add(item.Id);
+
+ // Rebuild the list
+ Entities.Clear();
+ foreach (var e in state.Entities)
+ {
+ var shortLabel = GetShortLabel(e.Path);
+ var item = new EntityListItem(e.Id, shortLabel, e.Category.ToString(), e.DistanceToPlayer, e.Position.X, e.Position.Y);
+ if (checkedIds.Contains(e.Id))
+ item.IsChecked = true;
+ Entities.Add(item);
+ }
+
+ // Build overlay snapshot from checked (or all) entities
+ var showAll = ShowAllEntities;
+ var overlayEntries = new List();
+ foreach (var item in Entities)
+ {
+ if (!showAll && !item.IsChecked) continue;
+ overlayEntries.Add(new EntityOverlayEntry(item.X, item.Y, item.Label));
+ }
+
+ if (overlayEntries.Count > 0)
+ {
+ OverlayData = new EntityOverlayData
+ {
+ PlayerPosition = state.Player.Position,
+ PlayerZ = state.Player.Z,
+ CameraMatrix = state.CameraMatrix,
+ Entries = overlayEntries.ToArray(),
+ };
+ }
+ else
+ {
+ OverlayData = null;
+ }
+ }
+
+ private static string GetShortLabel(string? path)
+ {
+ if (path is null) return "?";
+ // Strip @N instance suffix
+ var atIdx = path.LastIndexOf('@');
+ if (atIdx > 0) path = path[..atIdx];
+ // Take last segment
+ var slashIdx = path.LastIndexOf('/');
+ return slashIdx >= 0 ? path[(slashIdx + 1)..] : path;
+ }
+
+ public void Shutdown()
+ {
+ _engine.Stop();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _engine.Dispose();
+ }
+}
diff --git a/src/Automata.Ui/Views/MainWindow.axaml b/src/Automata.Ui/Views/MainWindow.axaml
index 14d3d8f..b4c1ec5 100644
--- a/src/Automata.Ui/Views/MainWindow.axaml
+++ b/src/Automata.Ui/Views/MainWindow.axaml
@@ -766,6 +766,10 @@
Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Roboto.Core/ActionQueue.cs b/src/Roboto.Core/ActionQueue.cs
new file mode 100644
index 0000000..0d3a4fd
--- /dev/null
+++ b/src/Roboto.Core/ActionQueue.cs
@@ -0,0 +1,81 @@
+namespace Roboto.Core;
+
+public class ActionQueue
+{
+ private readonly List _actions = [];
+
+ public IReadOnlyList Actions => _actions;
+
+ public void Submit(BotAction action)
+ {
+ // Bump lower-priority same-type actions
+ for (var i = _actions.Count - 1; i >= 0; i--)
+ {
+ if (_actions[i].GetType() == action.GetType() && _actions[i].Priority >= action.Priority)
+ _actions.RemoveAt(i);
+ }
+ _actions.Add(action);
+ }
+
+ public void Enqueue(BotAction action) => Submit(action);
+
+ public void Clear() => _actions.Clear();
+
+ public T? GetHighestPriority() where T : BotAction
+ {
+ T? best = null;
+ foreach (var action in _actions)
+ {
+ if (action is T typed && (best is null || typed.Priority < best.Priority))
+ best = typed;
+ }
+ return best;
+ }
+
+ ///
+ /// Resolve conflicts and return the final action list:
+ /// 1. FlaskActions always pass through
+ /// 2. Get highest-priority MoveAction + CastAction
+ /// 3. Urgent move (priority ≤ 10) → include move, BLOCK cast (flee)
+ /// 4. Normal → include both cast + move
+ /// 5. All other actions pass through
+ ///
+ public List Resolve()
+ {
+ var resolved = new List();
+
+ // Flasks always pass through
+ foreach (var action in _actions)
+ {
+ if (action is FlaskAction)
+ resolved.Add(action);
+ }
+
+ var bestMove = GetHighestPriority();
+ var bestCast = GetHighestPriority();
+
+ if (bestMove is not null)
+ {
+ resolved.Add(bestMove);
+
+ // Urgent flee (priority ≤ 10) blocks casting
+ if (bestMove.Priority > 10 && bestCast is not null)
+ resolved.Add(bestCast);
+ }
+ else if (bestCast is not null)
+ {
+ resolved.Add(bestCast);
+ }
+
+ // Pass through everything else (Key, Click, Chat, Wait) except types already handled
+ foreach (var action in _actions)
+ {
+ if (action is MoveAction or CastAction or FlaskAction)
+ continue;
+ resolved.Add(action);
+ }
+
+ Clear();
+ return resolved;
+ }
+}
diff --git a/src/Roboto.Core/Actions.cs b/src/Roboto.Core/Actions.cs
new file mode 100644
index 0000000..1703e64
--- /dev/null
+++ b/src/Roboto.Core/Actions.cs
@@ -0,0 +1,26 @@
+using System.Numerics;
+
+namespace Roboto.Core;
+
+public enum ClickType { Left, Right }
+public enum KeyActionType { Press, Down, Up }
+
+public abstract record BotAction(int Priority);
+
+public record MoveAction(int Priority, Vector2 Direction) : BotAction(Priority)
+{
+ /// Normalized movement direction in world space.
+ public Vector2 Direction { get; init; } = Direction;
+}
+
+public record ClickAction(int Priority, Vector2 ScreenPosition, ClickType Type = ClickType.Left) : BotAction(Priority);
+
+public record KeyAction(int Priority, ushort ScanCode, KeyActionType Type = KeyActionType.Press) : BotAction(Priority);
+
+public record CastAction(int Priority, ushort SkillScanCode, Vector2? TargetScreenPos = null) : BotAction(Priority);
+
+public record FlaskAction(int Priority, ushort FlaskScanCode) : BotAction(Priority);
+
+public record ChatAction(int Priority, string Message) : BotAction(Priority);
+
+public record WaitAction(int Priority, int DurationMs) : BotAction(Priority);
diff --git a/src/Roboto.Core/BotConfig.cs b/src/Roboto.Core/BotConfig.cs
new file mode 100644
index 0000000..b7302d6
--- /dev/null
+++ b/src/Roboto.Core/BotConfig.cs
@@ -0,0 +1,40 @@
+namespace Roboto.Core;
+
+public class BotConfig
+{
+ // Tick rates
+ public int LogicTickRateHz { get; set; } = 60;
+ public int MemoryPollRateHz { get; set; } = 30;
+
+ // Movement
+ public float SafeDistance { get; set; } = 400f;
+ public float RepulsionWeight { get; set; } = 1.5f;
+ public float WaypointReachedDistance { get; set; } = 80f;
+
+ // Navigation
+ public float WorldToGrid { get; set; } = 23f / 250f;
+
+ // Combat
+ public float CriticalHpPercent { get; set; } = 30f;
+ public float LowHpPercent { get; set; } = 50f;
+
+ // Loot
+ public float LootPickupRange { get; set; } = 600f;
+
+ // Humanization
+ public int MinReactionDelayMs { get; set; } = 50;
+ public int MaxReactionDelayMs { get; set; } = 150;
+ public float ClickJitterRadius { get; set; } = 3f;
+ public float TimingNoiseStdDev { get; set; } = 0.15f;
+ public int MaxApm { get; set; } = 250;
+
+ // Anti-detection
+ public float PollIntervalJitter { get; set; } = 0.2f;
+
+ // Flasks
+ public float LifeFlaskThreshold { get; set; } = 50f;
+ public float ManaFlaskThreshold { get; set; } = 50f;
+ public int FlaskCooldownMs { get; set; } = 4000;
+ public ushort LifeFlaskScanCode { get; set; } = 0x02; // Key1
+ public ushort ManaFlaskScanCode { get; set; } = 0x03; // Key2
+}
diff --git a/src/Roboto.Core/Buff.cs b/src/Roboto.Core/Buff.cs
new file mode 100644
index 0000000..e80ba65
--- /dev/null
+++ b/src/Roboto.Core/Buff.cs
@@ -0,0 +1,9 @@
+namespace Roboto.Core;
+
+public record Buff
+{
+ public string? Name { get; init; }
+ public float DurationRemaining { get; init; }
+ public int Charges { get; init; }
+ public bool IsDebuff { get; init; }
+}
diff --git a/src/Roboto.Core/EntitySnapshot.cs b/src/Roboto.Core/EntitySnapshot.cs
new file mode 100644
index 0000000..d36d88d
--- /dev/null
+++ b/src/Roboto.Core/EntitySnapshot.cs
@@ -0,0 +1,42 @@
+using System.Numerics;
+
+namespace Roboto.Core;
+
+public enum EntityCategory
+{
+ Unknown,
+ Player,
+ Monster,
+ Npc,
+ WorldItem,
+ Chest,
+ Portal,
+ AreaTransition,
+ Effect,
+ Terrain,
+ MiscObject,
+}
+
+public enum MonsterThreatLevel
+{
+ None,
+ Normal,
+ Magic,
+ Rare,
+ Unique,
+}
+
+public record EntitySnapshot
+{
+ public uint Id { get; init; }
+ public string? Path { get; init; }
+ public EntityCategory Category { get; init; }
+ public MonsterThreatLevel ThreatLevel { get; init; }
+ public Vector2 Position { get; init; }
+ public float DistanceToPlayer { get; init; }
+ public bool IsAlive { get; init; }
+ public int LifeCurrent { get; init; }
+ public int LifeTotal { get; init; }
+ public bool IsTargetable { get; init; }
+ public HashSet? Components { get; init; }
+}
diff --git a/src/Roboto.Core/Enums.cs b/src/Roboto.Core/Enums.cs
new file mode 100644
index 0000000..1f8821c
--- /dev/null
+++ b/src/Roboto.Core/Enums.cs
@@ -0,0 +1,20 @@
+namespace Roboto.Core;
+
+public enum DangerLevel
+{
+ Safe,
+ Low,
+ Medium,
+ High,
+ Critical,
+}
+
+public static class SystemPriority
+{
+ public const int Threat = 50;
+ public const int Movement = 100;
+ public const int Navigation = 200;
+ public const int Combat = 300;
+ public const int Resource = 400;
+ public const int Loot = 500;
+}
diff --git a/src/Roboto.Core/FlaskState.cs b/src/Roboto.Core/FlaskState.cs
new file mode 100644
index 0000000..6a4c074
--- /dev/null
+++ b/src/Roboto.Core/FlaskState.cs
@@ -0,0 +1,10 @@
+namespace Roboto.Core;
+
+public record FlaskState
+{
+ public int SlotIndex { get; init; }
+ public int ChargesCurrent { get; init; }
+ public int ChargesMax { get; init; }
+ public bool IsActive { get; init; }
+ public float CooldownRemaining { get; init; }
+}
diff --git a/src/Roboto.Core/GameState.cs b/src/Roboto.Core/GameState.cs
new file mode 100644
index 0000000..c57ce40
--- /dev/null
+++ b/src/Roboto.Core/GameState.cs
@@ -0,0 +1,28 @@
+using System.Numerics;
+
+namespace Roboto.Core;
+
+public class GameState
+{
+ public long TickNumber { get; set; }
+ public float DeltaTime { get; set; }
+ public long TimestampMs { get; set; }
+
+ public PlayerState Player { get; set; } = new();
+ public IReadOnlyList Entities { get; set; } = [];
+ public IReadOnlyList HostileMonsters { get; set; } = [];
+ public IReadOnlyList NearbyLoot { get; set; } = [];
+ public WalkabilitySnapshot? Terrain { get; set; }
+
+ public uint AreaHash { get; set; }
+ public int AreaLevel { get; set; }
+ public bool IsLoading { get; set; }
+ public bool IsEscapeOpen { get; set; }
+ public DangerLevel Danger { get; set; }
+ public Matrix4x4? CameraMatrix { get; set; }
+
+ // Derived (computed once per tick by GameStateEnricher)
+ public ThreatMap Threats { get; set; } = new();
+ public IReadOnlyList NearestEnemies { get; set; } = [];
+ public IReadOnlyList GroundEffects { get; set; } = [];
+}
diff --git a/src/Roboto.Core/GroundEffect.cs b/src/Roboto.Core/GroundEffect.cs
new file mode 100644
index 0000000..ab7f3f8
--- /dev/null
+++ b/src/Roboto.Core/GroundEffect.cs
@@ -0,0 +1,20 @@
+using System.Numerics;
+
+namespace Roboto.Core;
+
+public record GroundEffect
+{
+ public Vector2 Position { get; init; }
+ public float Radius { get; init; }
+ public GroundEffectType Type { get; init; }
+}
+
+public enum GroundEffectType
+{
+ Unknown,
+ Fire,
+ Cold,
+ Lightning,
+ Chaos,
+ Physical,
+}
diff --git a/src/Roboto.Core/IInputController.cs b/src/Roboto.Core/IInputController.cs
new file mode 100644
index 0000000..ed78e5e
--- /dev/null
+++ b/src/Roboto.Core/IInputController.cs
@@ -0,0 +1,17 @@
+namespace Roboto.Core;
+
+public interface IInputController
+{
+ bool IsInitialized { get; }
+ void KeyDown(ushort scanCode);
+ void KeyUp(ushort scanCode);
+ void KeyPress(ushort scanCode, int holdMs = 50);
+ void MouseMoveTo(int x, int y);
+ void MouseMoveBy(int dx, int dy);
+ void LeftClick(int x, int y);
+ void RightClick(int x, int y);
+ void LeftDown();
+ void LeftUp();
+ void RightDown();
+ void RightUp();
+}
diff --git a/src/Roboto.Core/IMemoryProvider.cs b/src/Roboto.Core/IMemoryProvider.cs
new file mode 100644
index 0000000..ddb8369
--- /dev/null
+++ b/src/Roboto.Core/IMemoryProvider.cs
@@ -0,0 +1,9 @@
+namespace Roboto.Core;
+
+public interface IMemoryProvider
+{
+ bool IsAttached { get; }
+ bool Attach();
+ void Detach();
+ GameState ReadGameState(GameState? previous);
+}
diff --git a/src/Roboto.Core/ISystem.cs b/src/Roboto.Core/ISystem.cs
new file mode 100644
index 0000000..05b4564
--- /dev/null
+++ b/src/Roboto.Core/ISystem.cs
@@ -0,0 +1,9 @@
+namespace Roboto.Core;
+
+public interface ISystem
+{
+ int Priority { get; }
+ string Name { get; }
+ bool IsEnabled { get; set; }
+ void Update(GameState state, ActionQueue actions);
+}
diff --git a/src/Roboto.Core/PlayerState.cs b/src/Roboto.Core/PlayerState.cs
new file mode 100644
index 0000000..e246afc
--- /dev/null
+++ b/src/Roboto.Core/PlayerState.cs
@@ -0,0 +1,30 @@
+using System.Numerics;
+
+namespace Roboto.Core;
+
+public record PlayerState
+{
+ public Vector2 Position { get; init; }
+ public float Z { get; init; }
+ public bool HasPosition { get; init; }
+
+ public int LifeCurrent { get; init; }
+ public int LifeTotal { get; init; }
+ public int ManaCurrent { get; init; }
+ public int ManaTotal { get; init; }
+ public int EsCurrent { get; init; }
+ public int EsTotal { get; init; }
+
+ public float LifePercent => LifeTotal > 0 ? (float)LifeCurrent / LifeTotal * 100f : 0f;
+ public float ManaPercent => ManaTotal > 0 ? (float)ManaCurrent / ManaTotal * 100f : 0f;
+ public float EsPercent => EsTotal > 0 ? (float)EsCurrent / EsTotal * 100f : 0f;
+
+ // Flask state (populated by memory when available)
+ public IReadOnlyList Flasks { get; init; } = [];
+
+ // Active buffs (populated by memory when available)
+ public IReadOnlyList Buffs { get; init; } = [];
+
+ // Skill slots (populated by memory when available)
+ public IReadOnlyList Skills { get; init; } = [];
+}
diff --git a/src/Roboto.Core/Roboto.Core.csproj b/src/Roboto.Core/Roboto.Core.csproj
new file mode 100644
index 0000000..b104a1a
--- /dev/null
+++ b/src/Roboto.Core/Roboto.Core.csproj
@@ -0,0 +1,7 @@
+
+
+ net8.0-windows10.0.19041.0
+ enable
+ enable
+
+
diff --git a/src/Roboto.Core/SkillState.cs b/src/Roboto.Core/SkillState.cs
new file mode 100644
index 0000000..af6b23e
--- /dev/null
+++ b/src/Roboto.Core/SkillState.cs
@@ -0,0 +1,12 @@
+namespace Roboto.Core;
+
+public record SkillState
+{
+ public int SlotIndex { get; init; }
+ public ushort ScanCode { get; init; }
+ public string? Name { get; init; }
+ public int ChargesCurrent { get; init; }
+ public int ChargesMax { get; init; }
+ public float CooldownRemaining { get; init; }
+ public bool CanUse => CooldownRemaining <= 0 && ChargesCurrent > 0;
+}
diff --git a/src/Roboto.Core/ThreatMap.cs b/src/Roboto.Core/ThreatMap.cs
new file mode 100644
index 0000000..79e0214
--- /dev/null
+++ b/src/Roboto.Core/ThreatMap.cs
@@ -0,0 +1,14 @@
+using System.Numerics;
+
+namespace Roboto.Core;
+
+public class ThreatMap
+{
+ public int TotalHostiles { get; init; }
+ public int CloseRange { get; init; } // < 300 units
+ public int MidRange { get; init; } // 300–600
+ public int FarRange { get; init; } // 600–1200
+ public float ClosestDistance { get; init; } = float.MaxValue;
+ public Vector2 ThreatCentroid { get; init; }
+ public bool HasRareOrUnique { get; init; }
+}
diff --git a/src/Roboto.Core/WalkabilitySnapshot.cs b/src/Roboto.Core/WalkabilitySnapshot.cs
new file mode 100644
index 0000000..bf4dfb0
--- /dev/null
+++ b/src/Roboto.Core/WalkabilitySnapshot.cs
@@ -0,0 +1,15 @@
+namespace Roboto.Core;
+
+public record WalkabilitySnapshot
+{
+ public int Width { get; init; }
+ public int Height { get; init; }
+ public byte[] Data { get; init; } = [];
+
+ public bool IsWalkable(int x, int y)
+ {
+ if (x < 0 || x >= Width || y < 0 || y >= Height)
+ return false;
+ return Data[y * Width + x] != 0;
+ }
+}
diff --git a/src/Roboto.Data/GameStateEnricher.cs b/src/Roboto.Data/GameStateEnricher.cs
new file mode 100644
index 0000000..9d0fca2
--- /dev/null
+++ b/src/Roboto.Data/GameStateEnricher.cs
@@ -0,0 +1,81 @@
+using System.Numerics;
+using Roboto.Core;
+
+namespace Roboto.Data;
+
+///
+/// Computes all derived fields on GameState once per tick.
+/// Static methods, no allocations beyond the sorted list.
+///
+public static class GameStateEnricher
+{
+ public static void Enrich(GameState state)
+ {
+ state.NearestEnemies = ComputeNearestEnemies(state.HostileMonsters);
+ state.Threats = ComputeThreatMap(state.HostileMonsters);
+ state.Danger = ComputeDangerLevel(state);
+ state.GroundEffects = []; // stub until memory reads ground effects
+ }
+
+ private static IReadOnlyList ComputeNearestEnemies(IReadOnlyList hostiles)
+ {
+ if (hostiles.Count == 0) return [];
+
+ var sorted = new List(hostiles);
+ sorted.Sort((a, b) => a.DistanceToPlayer.CompareTo(b.DistanceToPlayer));
+ return sorted;
+ }
+
+ private static ThreatMap ComputeThreatMap(IReadOnlyList hostiles)
+ {
+ if (hostiles.Count == 0) return new ThreatMap();
+
+ int close = 0, mid = 0, far = 0;
+ float closest = float.MaxValue;
+ var weightedSum = Vector2.Zero;
+ bool hasRareOrUnique = false;
+
+ foreach (var m in hostiles)
+ {
+ var d = m.DistanceToPlayer;
+ if (d < closest) closest = d;
+
+ if (d < 300f) close++;
+ else if (d < 600f) mid++;
+ else if (d < 1200f) far++;
+
+ weightedSum += m.Position;
+
+ if (m.ThreatLevel is MonsterThreatLevel.Rare or MonsterThreatLevel.Unique)
+ hasRareOrUnique = true;
+ }
+
+ return new ThreatMap
+ {
+ TotalHostiles = hostiles.Count,
+ CloseRange = close,
+ MidRange = mid,
+ FarRange = far,
+ ClosestDistance = closest,
+ ThreatCentroid = weightedSum / hostiles.Count,
+ HasRareOrUnique = hasRareOrUnique,
+ };
+ }
+
+ private static DangerLevel ComputeDangerLevel(GameState state)
+ {
+ if (state.Player.LifePercent < 30f) return DangerLevel.Critical;
+ if (state.Player.LifePercent < 50f) return DangerLevel.High;
+
+ var nearbyHostiles = 0;
+ foreach (var m in state.HostileMonsters)
+ {
+ if (m.DistanceToPlayer < 500f) nearbyHostiles++;
+ }
+
+ if (nearbyHostiles > 10) return DangerLevel.High;
+ if (nearbyHostiles > 5) return DangerLevel.Medium;
+ if (nearbyHostiles > 0) return DangerLevel.Low;
+ return DangerLevel.Safe;
+ }
+}
diff --git a/src/Roboto.Data/Roboto.Data.csproj b/src/Roboto.Data/Roboto.Data.csproj
new file mode 100644
index 0000000..bcebb88
--- /dev/null
+++ b/src/Roboto.Data/Roboto.Data.csproj
@@ -0,0 +1,13 @@
+
+