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" />