diff --git a/components.json b/components.json
new file mode 100644
index 0000000..92a83dd
--- /dev/null
+++ b/components.json
@@ -0,0 +1,36 @@
+[
+ "Actor",
+ "Animated",
+ "AreaTransition",
+ "BaseEvents",
+ "Buffs",
+ "Chest",
+ "ControlZone",
+ "CritterAI",
+ "Functions",
+ "HideoutDoodad",
+ "InteractionAction",
+ "Inventories",
+ "Life",
+ "MinimapIcon",
+ "Monster",
+ "NPC",
+ "ObjectMagicProperties",
+ "Pathfinding",
+ "PetAi",
+ "Player",
+ "PlayerClass",
+ "Portal",
+ "Positioned",
+ "Preload",
+ "Projectile",
+ "ProximityTrigger",
+ "Render",
+ "StateMachine",
+ "Stats",
+ "Targetable",
+ "Timer",
+ "Transitionable",
+ "TriggerableBlockage",
+ "WorldItem"
+]
\ No newline at end of file
diff --git a/entities.json b/entities.json
new file mode 100644
index 0000000..bda2902
--- /dev/null
+++ b/entities.json
@@ -0,0 +1,94 @@
+[
+ "Metadata/Characters/Character_login",
+ "Metadata/Characters/Dex/DexFour",
+ "Metadata/Characters/Dex/DexFourb",
+ "Metadata/Characters/DexInt/DexIntFourb",
+ "Metadata/Characters/Int/IntFour",
+ "Metadata/Characters/Int/IntFourb",
+ "Metadata/Characters/Str/StrFourb",
+ "Metadata/Characters/StrDex/StrDexFourb",
+ "Metadata/Characters/StrInt/StrIntFourb",
+ "Metadata/Chests/EzomyteChest_05",
+ "Metadata/Chests/EzomyteChest_06",
+ "Metadata/Chests/MossyChest26",
+ "Metadata/Critters/Chicken/Chicken_kingsmarch",
+ "Metadata/Critters/Hedgehog/HedgehogSlow",
+ "Metadata/Critters/Weta/Basic",
+ "Metadata/Effects/Effect",
+ "Metadata/Effects/Microtransactions/foot_prints/delirium/footprints_delirium",
+ "Metadata/Effects/Microtransactions/foot_prints/harvest02/footprints_harvest",
+ "Metadata/Effects/PermanentEffect",
+ "Metadata/Effects/ServerEffect",
+ "Metadata/MiscellaneousObjects/AreaTransitionBlockage",
+ "Metadata/MiscellaneousObjects/AreaTransitionDoodad",
+ "Metadata/MiscellaneousObjects/AreaTransition_Animate",
+ "Metadata/MiscellaneousObjects/Checkpoint",
+ "Metadata/MiscellaneousObjects/Doodad",
+ "Metadata/MiscellaneousObjects/DoodadNoBlocking",
+ "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowBlocking_20_1",
+ "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSink_6_4",
+ "Metadata/MiscellaneousObjects/Environment/NonDefaultFlows/FlowSource_4.75_1",
+ "Metadata/MiscellaneousObjects/GuildStash",
+ "Metadata/MiscellaneousObjects/HealingWell",
+ "Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_1",
+ "Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_2",
+ "Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_3",
+ "Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_4",
+ "Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_5",
+ "Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalCrystal_6",
+ "Metadata/MiscellaneousObjects/LeagueIncursionNew/IncursionPedestalEncounter",
+ "Metadata/MiscellaneousObjects/MultiplexPortal",
+ "Metadata/MiscellaneousObjects/ServerDoodadHidden",
+ "Metadata/MiscellaneousObjects/Stash",
+ "Metadata/MiscellaneousObjects/Waypoint",
+ "Metadata/MiscellaneousObjects/WorldItem",
+ "Metadata/Monsters/Hags/UrchinHag1",
+ "Metadata/Monsters/Urchins/MeleeUrchin1",
+ "Metadata/Monsters/Urchins/SlingUrchin1",
+ "Metadata/Monsters/Wolves/RottenWolf1_",
+ "Metadata/Monsters/Wolves/RottenWolfDead",
+ "Metadata/Monsters/Zombies/CourtGuardZombieUnarmed",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryOneHandAxe",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmed",
+ "Metadata/Monsters/Zombies/Lumberjack/LumberingDrownedDryUnarmedPhysics",
+ "Metadata/NPC/Four_Act1/ClearfellPosting1",
+ "Metadata/NPC/Four_Act1/DogTrader_Entrance",
+ "Metadata/NPC/Four_Act1/ExecutionerFemaleNPCTown",
+ "Metadata/NPC/Four_Act1/EzomyteCivilianFemale01",
+ "Metadata/NPC/Four_Act1/EzomyteCivilianFemale02",
+ "Metadata/NPC/Four_Act1/EzomyteCivilianMale01",
+ "Metadata/NPC/Four_Act1/Finn",
+ "Metadata/NPC/Four_Act1/FinnHoodedMentorInjured",
+ "Metadata/NPC/Four_Act1/HoodedMentor",
+ "Metadata/NPC/Four_Act1/HoodedMentorAfterIronCount",
+ "Metadata/NPC/Four_Act1/HoodedMentorInjured",
+ "Metadata/NPC/Four_Act1/Renly",
+ "Metadata/NPC/Four_Act1/RenlyAfterIronCount",
+ "Metadata/NPC/Four_Act1/RenlyIntro",
+ "Metadata/NPC/Four_Act1/Una",
+ "Metadata/NPC/Four_Act1/UnaAfterHealHoodedMentor",
+ "Metadata/NPC/Four_Act1/UnaAfterIronCount",
+ "Metadata/NPC/Four_Act1/UnaHoodedOneInjured",
+ "Metadata/NPC/League/Incursion/AlvaIncursionWild",
+ "Metadata/Pet/BetaKiwis/FaridunKiwi",
+ "Metadata/Pet/FledglingBellcrow/FledglingBellcrow",
+ "Metadata/Pet/OrigamiPet/OrigamiPetBase",
+ "Metadata/Pet/Phoenix/PhoenixPetGreen",
+ "Metadata/Pet/Phoenix/PhoenixPetRed",
+ "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/RuleSet",
+ "Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/Act1_finished_LightController",
+ "Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBenchEzomyte",
+ "Metadata/Terrain/Gallows/Act1/1_town_ExileEncampment/Objects/CraftingBench_DisableRendering",
+ "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/RiverRapidsMedium",
+ "Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio",
+ "Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio"
+]
\ No newline at end of file
diff --git a/offsets.json b/offsets.json
index 5fbd7bb..49ac354 100644
--- a/offsets.json
+++ b/offsets.json
@@ -6,9 +6,13 @@
"StatesBeginOffset": 72,
"StateStride": 16,
"StatePointerOffset": 0,
+ "StateCount": 12,
"InGameStateIndex": 4,
+ "ActiveStatesOffset": 32,
"StatesInline": true,
"InGameStateDirectOffset": 528,
+ "IsLoadingOffset": 832,
+ "EscapeStateOffset": 524,
"IngameDataFromStateOffset": 656,
"WorldDataFromStateOffset": 760,
"AreaLevelOffset": 196,
@@ -19,8 +23,22 @@
"LocalPlayerDirectOffset": 2576,
"EntityListOffset": 2896,
"EntityCountInternalOffset": 8,
+ "EntityNodeLeftOffset": 0,
+ "EntityNodeParentOffset": 8,
+ "EntityNodeRightOffset": 16,
+ "EntityNodeValueOffset": 40,
+ "EntityIdOffset": 128,
+ "EntityFlagsOffset": 132,
+ "EntityDetailsOffset": 8,
+ "EntityPathStringOffset": 8,
"LocalPlayerOffset": 32,
"ComponentListOffset": 16,
+ "EntityHeaderOffset": 8,
+ "ComponentLookupOffset": 40,
+ "ComponentLookupVec2Offset": 40,
+ "ComponentLookupEntrySize": 16,
+ "ComponentLookupNameOffset": 0,
+ "ComponentLookupIndexOffset": 8,
"LifeComponentIndex": -1,
"RenderComponentIndex": -1,
"LifeComponentOffset1": 1056,
@@ -35,9 +53,9 @@
"PositionZOffset": 320,
"TerrainListOffset": 3264,
"TerrainInline": true,
- "TerrainDimensionsOffset": 24,
- "TerrainWalkableGridOffset": 208,
- "TerrainBytesPerRowOffset": 256,
+ "TerrainDimensionsOffset": 144,
+ "TerrainWalkableGridOffset": 328,
+ "TerrainBytesPerRowOffset": 424,
"TerrainGridPtrOffset": 8,
"SubTilesPerCell": 23,
"InGameStateOffset": 0,
diff --git a/src/Automata.Memory/Automata.Memory.csproj b/src/Automata.Memory/Automata.Memory.csproj
index fa49371..552a012 100644
--- a/src/Automata.Memory/Automata.Memory.csproj
+++ b/src/Automata.Memory/Automata.Memory.csproj
@@ -5,6 +5,9 @@
enable
true
+
+
+
diff --git a/src/Automata.Memory/Entity.cs b/src/Automata.Memory/Entity.cs
new file mode 100644
index 0000000..c4e55ba
--- /dev/null
+++ b/src/Automata.Memory/Entity.cs
@@ -0,0 +1,201 @@
+namespace Automata.Memory;
+
+public enum EntityType
+{
+ Unknown,
+ Player,
+ Monster,
+ Npc,
+ Effect,
+ WorldItem,
+ MiscellaneousObject,
+ Terrain,
+ Critter,
+ Chest,
+ Shrine,
+ Portal,
+ TownPortal,
+ Waypoint,
+ AreaTransition,
+ Door,
+}
+
+public enum MonsterRarity
+{
+ White,
+ Magic,
+ Rare,
+ Unique,
+}
+
+public class Entity
+{
+ public nint Address { get; }
+ public uint Id { get; }
+ public string? Path { get; }
+ public string? Metadata { get; }
+
+ // Position (from Render component)
+ public bool HasPosition { get; internal set; }
+ public float X { get; internal set; }
+ public float Y { get; internal set; }
+ public float Z { get; internal set; }
+
+ // Vitals (from Life component — only populated when explicitly read)
+ public bool HasVitals { get; internal set; }
+ public int LifeCurrent { get; internal set; }
+ public int LifeTotal { get; internal set; }
+ public int ManaCurrent { get; internal set; }
+ public int ManaTotal { get; internal set; }
+ public int EsCurrent { get; internal set; }
+ public int EsTotal { get; internal set; }
+
+ // Component info
+ public int ComponentCount { get; internal set; }
+ public HashSet? Components { get; internal set; }
+
+ // Component-based properties (populated by GameMemoryReader)
+ public bool IsTargetable { get; internal set; }
+ public bool IsOpened { get; internal set; }
+ public bool IsAvailable { get; internal set; }
+ public MonsterRarity Rarity { get; internal set; }
+
+ // Derived properties
+ public bool IsAlive => HasVitals && LifeCurrent > 0;
+ public bool IsDead => HasVitals && LifeCurrent <= 0;
+ public bool IsHostile => Type == EntityType.Monster;
+ public bool IsNpc => Type == EntityType.Npc;
+ public bool IsPlayer => Type == EntityType.Player;
+ public bool HasComponent(string name) => Components?.Contains(name) == true;
+
+ ///
+ /// Grid-plane distance to another point (ignores Z).
+ ///
+ public float DistanceTo(float px, float py)
+ {
+ var dx = X - px;
+ var dy = Y - py;
+ return MathF.Sqrt(dx * dx + dy * dy);
+ }
+
+ ///
+ /// Grid-plane distance to another entity.
+ ///
+ public float DistanceTo(Entity other) => DistanceTo(other.X, other.Y);
+
+ ///
+ /// Short category string derived from path (e.g. "Monsters", "Effects", "NPC").
+ ///
+ public string Category
+ {
+ get
+ {
+ if (Path is null) return "?";
+ var parts = Path.Split('/');
+ return parts.Length >= 2 ? parts[1] : "?";
+ }
+ }
+
+ public EntityType Type { get; internal set; }
+
+ internal Entity(nint address, uint id, string? path)
+ {
+ Address = address;
+ Id = id;
+ Path = path;
+ Metadata = ExtractMetadata(path);
+ Type = ClassifyType(path);
+ }
+
+ ///
+ /// Reclassify entity type using component names (called after components are read).
+ /// Component-based classification is more reliable than path-based.
+ ///
+ internal void ReclassifyFromComponents()
+ {
+ if (Components is null || Components.Count == 0) return;
+
+ // Priority order matching ExileCore's ParseType logic
+ if (Components.Contains("Monster")) { Type = EntityType.Monster; return; }
+ if (Components.Contains("Chest")) { Type = EntityType.Chest; return; }
+ if (Components.Contains("Shrine")) { Type = EntityType.Shrine; return; }
+ if (Components.Contains("Waypoint")) { Type = EntityType.Waypoint; return; }
+ if (Components.Contains("AreaTransition")) { Type = EntityType.AreaTransition; return; }
+ if (Components.Contains("Portal")) { Type = EntityType.Portal; return; }
+ if (Components.Contains("TownPortal")) { Type = EntityType.TownPortal; return; }
+ if (Components.Contains("NPC")) { Type = EntityType.Npc; return; }
+ if (Components.Contains("Player")) { Type = EntityType.Player; return; }
+ // Don't override path-based classification for Effects/Terrain/etc.
+ }
+
+ ///
+ /// Strips the "@N" instance suffix from the path.
+ /// "Metadata/Monsters/Wolves/RottenWolf1_@2" → "Metadata/Monsters/Wolves/RottenWolf1_"
+ ///
+ private static string? ExtractMetadata(string? path)
+ {
+ if (path is null) return null;
+ var atIndex = path.LastIndexOf('@');
+ return atIndex > 0 ? path[..atIndex] : path;
+ }
+
+ private static EntityType ClassifyType(string? path)
+ {
+ if (path is null) return EntityType.Unknown;
+
+ // Check second path segment: "Metadata//..."
+ var firstSlash = path.IndexOf('/');
+ if (firstSlash < 0) return EntityType.Unknown;
+
+ var secondSlash = path.IndexOf('/', firstSlash + 1);
+ var category = secondSlash > 0
+ ? path[(firstSlash + 1)..secondSlash]
+ : path[(firstSlash + 1)..];
+
+ switch (category)
+ {
+ case "Characters":
+ return EntityType.Player;
+
+ case "Monsters":
+ // Sub-classify: some "monsters" are actually NPCs or critters
+ if (path.Contains("/Critters/", StringComparison.OrdinalIgnoreCase))
+ return EntityType.Critter;
+ if (path.Contains("/NPC/", StringComparison.OrdinalIgnoreCase) ||
+ path.Contains("/TownNPC/", StringComparison.OrdinalIgnoreCase))
+ return EntityType.Npc;
+ return EntityType.Monster;
+
+ case "NPC":
+ return EntityType.Npc;
+
+ case "Effects":
+ return EntityType.Effect;
+
+ case "MiscellaneousObjects":
+ if (path.Contains("/Chest", StringComparison.OrdinalIgnoreCase) ||
+ path.Contains("/Stash", StringComparison.OrdinalIgnoreCase))
+ return EntityType.Chest;
+ if (path.Contains("/Shrine", StringComparison.OrdinalIgnoreCase))
+ return EntityType.Shrine;
+ if (path.Contains("/Portal", StringComparison.OrdinalIgnoreCase))
+ return EntityType.Portal;
+ return EntityType.MiscellaneousObject;
+
+ case "Terrain":
+ return EntityType.Terrain;
+
+ case "Items":
+ return EntityType.WorldItem;
+
+ default:
+ return EntityType.Unknown;
+ }
+ }
+
+ public override string ToString()
+ {
+ var pos = HasPosition ? $"({X:F0},{Y:F0})" : "no pos";
+ return $"[{Id}] {Type} {Path ?? "?"} {pos}";
+ }
+}
diff --git a/src/Automata.Memory/GameMemoryReader.cs b/src/Automata.Memory/GameMemoryReader.cs
index e51b017..e5757cd 100644
--- a/src/Automata.Memory/GameMemoryReader.cs
+++ b/src/Automata.Memory/GameMemoryReader.cs
@@ -1,11 +1,12 @@
+using System.Drawing;
+using System.Drawing.Imaging;
using System.Globalization;
+using System.Runtime.InteropServices;
using System.Text;
using Serilog;
namespace Automata.Memory;
-public record EntityInfo(uint Id, string? Path, string? Type, float X, float Y, float Z, bool HasPosition);
-
public class GameStateSnapshot
{
// Process
@@ -43,18 +44,54 @@ public class GameStateSnapshot
// Entities
public int EntityCount;
- public List? Entities;
+ 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)
+ public static readonly string[] StateNames =
+ [
+ "AreaLoading", // 0
+ "Waiting", // 1
+ "Credits", // 2
+ "Escape", // 3
+ "InGame", // 4
+ "ChangePassword", // 5
+ "Login", // 6
+ "PreGame", // 7
+ "CreateChar", // 8
+ "SelectChar", // 9
+ "DeleteChar", // 10
+ "Loading", // 11
+ ];
+
private ProcessMemory? _memory;
private PatternScanner? _scanner;
private readonly TerrainOffsets _offsets;
+ private readonly ObjectRegistry _registry;
private nint _moduleBase;
private int _moduleSize;
private nint _gameStateBase;
@@ -62,10 +99,18 @@ public class GameMemoryReader : IDisposable
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;
+
+ public ObjectRegistry Registry => _registry;
public GameMemoryReader()
{
_offsets = TerrainOffsets.Load("offsets.json");
+ _registry = new ObjectRegistry();
}
public bool IsAttached => _memory != null;
@@ -149,6 +194,9 @@ public class GameMemoryReader : IDisposable
return snap;
snap.InGameStatePtr = inGameState;
+ // Read all state slot pointers + candidate offsets for live state tracking
+ ReadStateSlots(snap);
+
// InGameState → AreaInstance (dump: InGameStateOffset.AreaInstanceData at +0x298)
var ingameData = _memory.ReadPointer(inGameState + _offsets.IngameDataFromStateOffset);
snap.AreaInstancePtr = ingameData;
@@ -199,6 +247,8 @@ public class GameMemoryReader : IDisposable
{
_cachedLifeIndex = -1;
_cachedRenderIndex = -1;
+ _cachedTerrain = null;
+ _cachedTerrainAreaHash = 0;
_lastLocalPlayer = snap.LocalPlayerPtr;
}
@@ -206,6 +256,14 @@ public class GameMemoryReader : IDisposable
ReadPlayerPosition(snap);
}
+ // AreaLoadingState
+ ReadIsLoading(snap);
+ ReadEscapeState(snap);
+
+ // Read state flag bytes from InGameState for live display
+ if (snap.InGameStatePtr != 0)
+ snap.StateFlagBytes = _memory!.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30);
+
// Terrain
ReadTerrain(snap, ingameData);
}
@@ -215,6 +273,9 @@ public class GameMemoryReader : IDisposable
Log.Debug(ex, "Error reading snapshot");
}
+ // Update edge detection for next tick (after ReadTerrain has used _wasLoading)
+ _wasLoading = snap.IsLoading;
+
return snap;
}
@@ -522,23 +583,7 @@ public class GameMemoryReader : IDisposable
private void ReadTerrain(GameStateSnapshot snap, nint areaInstance)
{
- if (_offsets.TerrainInline)
- {
- // Dump: TerrainStruct is inline at AreaInstance + 0xCC0
- // TotalTiles (StdTuple2D) at TerrainStruct + 0x18
- 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)
- {
- snap.TerrainCols = cols;
- snap.TerrainRows = rows;
- snap.TerrainWidth = cols * _offsets.SubTilesPerCell;
- snap.TerrainHeight = rows * _offsets.SubTilesPerCell;
- }
- }
- else
+ if (!_offsets.TerrainInline)
{
// Pointer-based: AreaInstance → TerrainList → first terrain → dimensions
var terrainListPtr = _memory!.ReadPointer(areaInstance + _offsets.TerrainListOffset);
@@ -563,7 +608,616 @@ public class GameMemoryReader : IDisposable
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();
}
///
@@ -602,48 +1256,38 @@ public class GameMemoryReader : IDisposable
sb.AppendLine(new string('═', 90));
// In-order tree traversal
- var entities = new List<(uint id, string? path, float x, float y, float z, bool hasPos)>();
- var pathCounts = new Dictionary();
+ var entities = new List();
var maxNodes = Math.Min(entityCount + 10, 500);
WalkTreeInOrder(sentinel, root, maxNodes, node =>
{
var entityPtr = _memory.ReadPointer(node + _offsets.EntityNodeValueOffset);
- uint entityId = 0;
- string? path = null;
- float x = 0, y = 0, z = 0;
- var hasPos = false;
+ if (entityPtr == 0) return;
- if (entityPtr != 0)
+ 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))
{
- var high = (ulong)entityPtr >> 32;
- if (high > 0 && high < 0x7FFF && (entityPtr & 0x3) == 0)
- {
- entityId = _memory.Read(entityPtr + _offsets.EntityIdOffset);
- path = TryReadEntityPath(entityPtr);
- hasPos = TryReadEntityPosition(entityPtr, out x, out y, out z);
- }
+ entity.HasPosition = true;
+ entity.X = x;
+ entity.Y = y;
+ entity.Z = z;
}
- // Derive short type from path: "Metadata/Monsters/Foo/Bar" → "Monsters"
- var shortType = "?";
- if (path is not null)
- {
- var parts = path.Split('/');
- if (parts.Length >= 2)
- shortType = parts[1]; // e.g. "Monsters", "Effects", "NPC", "MiscellaneousObjects"
- }
-
- pathCounts[shortType] = pathCounts.GetValueOrDefault(shortType) + 1;
- entities.Add((entityId, path, x, y, z, hasPos));
+ entities.Add(entity);
if (entities.Count <= 50)
{
- var posStr = hasPos ? $"({x:F1}, {y:F1}, {z:F1})" : "no pos";
- var displayPath = path ?? "?";
+ 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={entityId,-10} {displayPath}");
+ sb.AppendLine($"[{entities.Count - 1,3}] ID={entity.Id,-10} {entity.Type,-20} {displayPath}");
sb.AppendLine($" {posStr}");
}
});
@@ -651,12 +1295,16 @@ public class GameMemoryReader : IDisposable
// Summary
sb.AppendLine(new string('─', 90));
sb.AppendLine($"Total entities walked: {entities.Count}");
- sb.AppendLine($"With position: {entities.Count(e => e.hasPos)}");
- sb.AppendLine($"With path: {entities.Count(e => e.path is not null)}");
+ 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 counts:");
- foreach (var (type, count) in pathCounts.OrderByDescending(kv => kv.Value))
- sb.AppendLine($" {type}: {count}");
+ 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();
}
@@ -671,8 +1319,10 @@ public class GameMemoryReader : IDisposable
if (sentinel == 0) return;
var root = _memory.ReadPointer(sentinel + _offsets.EntityNodeParentOffset);
- var entities = new List();
+ 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 =>
{
@@ -685,10 +1335,63 @@ public class GameMemoryReader : IDisposable
var entityId = _memory.Read(entityPtr + _offsets.EntityIdOffset);
var path = TryReadEntityPath(entityPtr);
- var hasPos = TryReadEntityPosition(entityPtr, out var x, out var y, out var z);
- entities.Add(new EntityInfo(entityId, path, null, x, y, z, hasPos));
+ 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;
}
@@ -2196,6 +2899,676 @@ public class GameMemoryReader : IDisposable
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;
+
+ var detailsPtr = _memory.ReadPointer(entity + _offsets.EntityHeaderOffset);
+ if (IsValidHeapPtr(detailsPtr))
+ return detailsPtr;
+
+ // 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);
+ }
+
+ bmp.Save(path, ImageFormat.Png);
+ }
+
public void Dispose()
{
if (_disposed) return;
diff --git a/src/Automata.Memory/ObjectRegistry.cs b/src/Automata.Memory/ObjectRegistry.cs
new file mode 100644
index 0000000..788b562
--- /dev/null
+++ b/src/Automata.Memory/ObjectRegistry.cs
@@ -0,0 +1,134 @@
+using System.Text.Json;
+using Serilog;
+
+namespace Automata.Memory;
+
+///
+/// Persistent registry of discovered strings, organized by category.
+/// Saves each category to its own JSON file (e.g. components.json, entities.json).
+/// Loads on startup, saves whenever new entries are found.
+///
+public sealed class ObjectRegistry
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ private readonly Dictionary _categories = [];
+
+ ///
+ /// Get or create a category. Each category persists to its own JSON file.
+ ///
+ public Category this[string name]
+ {
+ get
+ {
+ if (!_categories.TryGetValue(name, out var cat))
+ {
+ cat = new Category(name);
+ _categories[name] = cat;
+ }
+ return cat;
+ }
+ }
+
+ ///
+ /// Flush all dirty categories to disk.
+ ///
+ public void Flush()
+ {
+ foreach (var cat in _categories.Values)
+ cat.Flush();
+ }
+
+ public sealed class Category
+ {
+ private readonly string _path;
+ private readonly HashSet _known = [];
+ private bool _dirty;
+
+ public IReadOnlySet Known => _known;
+ public int Count => _known.Count;
+
+ internal Category(string name)
+ {
+ _path = $"{name}.json";
+ Load();
+ }
+
+ ///
+ /// Register a single entry. Returns true if it was new.
+ ///
+ public bool Register(string? value)
+ {
+ if (string.IsNullOrEmpty(value)) return false;
+ if (_known.Add(value))
+ {
+ _dirty = true;
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Register multiple entries. Returns true if any were new.
+ ///
+ public bool Register(IEnumerable values)
+ {
+ var added = false;
+ foreach (var value in values)
+ {
+ if (!string.IsNullOrEmpty(value) && _known.Add(value))
+ {
+ added = true;
+ _dirty = true;
+ }
+ }
+ return added;
+ }
+
+ ///
+ /// Save to disk if there are unsaved changes.
+ ///
+ public void Flush()
+ {
+ if (!_dirty) return;
+ Save();
+ _dirty = false;
+ }
+
+ private void Load()
+ {
+ if (!File.Exists(_path)) return;
+
+ try
+ {
+ var json = File.ReadAllText(_path);
+ var list = JsonSerializer.Deserialize>(json);
+ if (list is not null)
+ {
+ foreach (var name in list)
+ _known.Add(name);
+ Log.Information("Loaded {Count} entries from '{Path}'", _known.Count, _path);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Error loading from '{Path}'", _path);
+ }
+ }
+
+ private void Save()
+ {
+ try
+ {
+ var sorted = _known.OrderBy(n => n, StringComparer.Ordinal).ToList();
+ var json = JsonSerializer.Serialize(sorted, JsonOptions);
+ File.WriteAllText(_path, json);
+ Log.Debug("Saved {Count} entries to '{Path}'", sorted.Count, _path);
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Error saving to '{Path}'", _path);
+ }
+ }
+ }
+}
diff --git a/src/Automata.Memory/TerrainOffsets.cs b/src/Automata.Memory/TerrainOffsets.cs
index 85cb1f1..99a3f24 100644
--- a/src/Automata.Memory/TerrainOffsets.cs
+++ b/src/Automata.Memory/TerrainOffsets.cs
@@ -32,15 +32,23 @@ public sealed class TerrainOffsets
public int StateStride { get; set; } = 0x10;
/// Offset within each state entry to the actual state pointer.
public int StatePointerOffset { get; set; } = 0;
+ /// Total number of state slots (ExileCore: 12 states, State0-State11).
+ public int StateCount { get; set; } = 12;
/// Which state index is InGameState (typically 4).
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.
+ 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;
/// Direct offset from controller to InGameState pointer (bypasses state array). 0 = use state array instead. CE confirmed: 0x210.
public int InGameStateDirectOffset { get; set; } = 0x210;
// ── InGameState → sub-structures ──
- // Dump: InGameStateOffset { [0x298] AreaInstanceData, [0x2F8] WorldData, [0x648] UiRootPtr, [0xC40] IngameUi }
+ // 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).
public int IngameDataFromStateOffset { get; set; } = 0x290;
/// InGameState → WorldData pointer (dump: 0x2F8).
@@ -102,8 +110,16 @@ public sealed class TerrainOffsets
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;
- /// ObjectHeader → NativePtrArray for component name→index lookup. ExileCore: 0x40.
- public int ComponentLookupOffset { get; set; } = 0x40;
+ /// EntityDetails → ComponentLookup object pointer. Confirmed: 0x28 (right after wstring path).
+ public int ComponentLookupOffset { get; set; } = 0x28;
+ /// ComponentLookup object → Vec2 begin/end (name entry array). Object layout: +0x10=Vec1(ptrs), +0x28=Vec2(names).
+ 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;
+ /// Offset to the char* name pointer within each lookup entry.
+ public int ComponentLookupNameOffset { get; set; } = 0;
+ /// Offset to the component index (int32) within each lookup entry.
+ public int ComponentLookupIndexOffset { get; set; } = 8;
/// Index of Life component in entity's component list. -1 = auto-discover via pattern scan.
public int LifeComponentIndex { get; set; } = -1;
/// Index of Render/Position component in entity's component list. -1 = unknown.
@@ -134,24 +150,25 @@ public sealed class TerrainOffsets
public int PositionZOffset { get; set; } = 0x140;
// ── Terrain (inline in AreaInstance) ──
- // Dump: TerrainStruct (at AreaInstance + 0xCC0) {
- // [0x18] StdTuple2D TotalTiles,
- // [0x28] StdVector TileDetailsPtr,
- // [0xD0] StdVector GridWalkableData,
- // [0xE8] StdVector GridLandscapeData,
- // [0x100] int BytesPerRow,
- // [0x104] short TileHeightMultiplier
- // }
+ // 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.
public bool TerrainInline { get; set; } = true;
- /// TerrainStruct → TotalTiles offset (dump: 0x18, StdTuple2D of long).
- public int TerrainDimensionsOffset { get; set; } = 0x18;
- /// TerrainStruct → GridWalkableData StdVector offset (dump: 0xD0).
- public int TerrainWalkableGridOffset { get; set; } = 0xD0;
- /// TerrainStruct → BytesPerRow (dump: 0x100).
- public int TerrainBytesPerRowOffset { get; set; } = 0x100;
+ /// TerrainStruct → TotalTiles offset (scan: 0x90, StdTuple2D of long).
+ public int TerrainDimensionsOffset { get; set; } = 0x90;
+ /// TerrainStruct → GridWalkableData StdVector offset (scan: 0x148).
+ public int TerrainWalkableGridOffset { get; set; } = 0x148;
+ /// TerrainStruct → BytesPerRow (scan: 0x1A8).
+ public int TerrainBytesPerRowOffset { get; set; } = 0x1A8;
/// Kept for pointer-based terrain mode (TerrainInline=false).
public int TerrainGridPtrOffset { get; set; } = 0x08;
public int SubTilesPerCell { get; set; } = 23;
diff --git a/src/Automata.Ui/ViewModels/MemoryViewModel.cs b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
index ae48332..af90843 100644
--- a/src/Automata.Ui/ViewModels/MemoryViewModel.cs
+++ b/src/Automata.Ui/ViewModels/MemoryViewModel.cs
@@ -1,4 +1,8 @@
using System.Collections.ObjectModel;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -32,11 +36,21 @@ public partial class MemoryViewModel : ObservableObject
private GameMemoryReader? _reader;
private CancellationTokenSource? _cts;
+ private const int ViewportRadius = 300; // grid pixels visible in each direction from player
+
[ObservableProperty] private bool _isEnabled;
[ObservableProperty] private string _statusText = "Not attached";
public ObservableCollection RootNodes { get; } = [];
+ // Minimap
+ [ObservableProperty] private Bitmap? _terrainImage;
+ private byte[]? _terrainBasePixels;
+ private byte[]? _minimapBuffer; // reused each frame
+ private int _terrainImageWidth, _terrainImageHeight;
+ private uint _terrainImageAreaHash;
+ private WalkabilityGrid? _terrainGridRef;
+
// Raw explorer
[ObservableProperty] private string _rawAddress = "";
[ObservableProperty] private string _rawOffsets = "";
@@ -75,10 +89,15 @@ public partial class MemoryViewModel : ObservableObject
private MemoryNodeViewModel? _playerLife;
private MemoryNodeViewModel? _playerMana;
private MemoryNodeViewModel? _playerEs;
+ private MemoryNodeViewModel? _isLoadingNode;
+ private MemoryNodeViewModel? _escapeStateNode;
+ private MemoryNodeViewModel? _statesNode;
private MemoryNodeViewModel? _terrainCells;
private MemoryNodeViewModel? _terrainGrid;
+ private MemoryNodeViewModel? _terrainWalkable;
private MemoryNodeViewModel? _entitySummary;
private MemoryNodeViewModel? _entityTypesNode;
+ private MemoryNodeViewModel? _entityListNode;
partial void OnIsEnabledChanged(bool value)
{
@@ -115,6 +134,13 @@ public partial class MemoryViewModel : ObservableObject
_reader = null;
RootNodes.Clear();
StatusText = "Not attached";
+
+ _terrainBasePixels = null;
+ _terrainImageAreaHash = 0;
+ _terrainGridRef = null;
+ var old = TerrainImage;
+ TerrainImage = null;
+ old?.Dispose();
}
private void BuildTree()
@@ -137,11 +163,17 @@ public partial class MemoryViewModel : ObservableObject
_gsController = new MemoryNodeViewModel("Controller:");
_gsStates = new MemoryNodeViewModel("States:");
_inGameState = new MemoryNodeViewModel("InGameState:");
+ _isLoadingNode = new MemoryNodeViewModel("Loading:");
+ _escapeStateNode = new MemoryNodeViewModel("Escape:");
+ _statesNode = new MemoryNodeViewModel("State Slots") { IsExpanded = true };
gameState.Children.Add(_gsPattern);
gameState.Children.Add(_gsBase);
gameState.Children.Add(_gsController);
gameState.Children.Add(_gsStates);
gameState.Children.Add(_inGameState);
+ gameState.Children.Add(_isLoadingNode);
+ gameState.Children.Add(_escapeStateNode);
+ gameState.Children.Add(_statesNode);
// InGameState children
var inGameStateGroup = new MemoryNodeViewModel("InGameState");
@@ -176,15 +208,19 @@ public partial class MemoryViewModel : ObservableObject
var entitiesGroup = new MemoryNodeViewModel("Entities");
_entitySummary = new MemoryNodeViewModel("Summary:");
_entityTypesNode = new MemoryNodeViewModel("Types:") { IsExpanded = false };
+ _entityListNode = new MemoryNodeViewModel("List:") { IsExpanded = false };
entitiesGroup.Children.Add(_entitySummary);
entitiesGroup.Children.Add(_entityTypesNode);
+ entitiesGroup.Children.Add(_entityListNode);
// Terrain
var terrain = new MemoryNodeViewModel("Terrain");
_terrainCells = new MemoryNodeViewModel("Cells:");
_terrainGrid = new MemoryNodeViewModel("Grid:");
+ _terrainWalkable = new MemoryNodeViewModel("Walkable:");
terrain.Children.Add(_terrainCells);
terrain.Children.Add(_terrainGrid);
+ terrain.Children.Add(_terrainWalkable);
inGameStateGroup.Children.Add(areaInstanceGroup);
inGameStateGroup.Children.Add(player);
@@ -198,7 +234,7 @@ public partial class MemoryViewModel : ObservableObject
private async Task ReadLoop(CancellationToken ct)
{
- using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500));
+ using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(30));
while (!ct.IsCancellationRequested)
{
try
@@ -240,6 +276,54 @@ public partial class MemoryViewModel : ObservableObject
_inGameState!.Set(
snap.InGameStatePtr != 0 ? $"0x{snap.InGameStatePtr:X}" : "not found",
snap.InGameStatePtr != 0);
+ _isLoadingNode!.Set(snap.IsLoading ? "Loading..." : "Ready", !snap.IsLoading);
+ _escapeStateNode!.Set(snap.IsEscapeOpen ? "Open" : "Closed", !snap.IsEscapeOpen);
+
+ // State Slots — show pointer + int32 at +0x08 for each state slot
+ if (_statesNode is not null && snap.StateSlots.Length > 0)
+ {
+ var slots = snap.StateSlots;
+ var needed = slots.Length;
+
+ while (_statesNode.Children.Count > needed)
+ _statesNode.Children.RemoveAt(_statesNode.Children.Count - 1);
+
+ for (var i = 0; i < needed; i++)
+ {
+ var ptr = slots[i];
+ var stateName = i < GameMemoryReader.StateNames.Length ? GameMemoryReader.StateNames[i] : $"State{i}";
+ var label = $"[{i}] {stateName}:";
+ string val;
+ string color;
+
+ if (ptr == 0)
+ {
+ val = "null";
+ color = "#484f58";
+ }
+ else
+ {
+ // Read int32 at state+0x08 (the value CE found)
+ var int32Val = snap.StateSlotValues?.Length > i ? snap.StateSlotValues[i] : 0;
+ val = $"0x{ptr:X} [+0x08]={int32Val}";
+ color = ptr == snap.InGameStatePtr ? "#3fb950" : "#8b949e";
+ }
+
+ if (i < _statesNode.Children.Count)
+ {
+ _statesNode.Children[i].Name = label;
+ _statesNode.Children[i].Set(val, true);
+ _statesNode.Children[i].ValueColor = color;
+ }
+ else
+ {
+ var node = new MemoryNodeViewModel(label);
+ node.Set(val, true);
+ node.ValueColor = color;
+ _statesNode.Children.Add(node);
+ }
+ }
+ }
// Status text
if (snap.Attached)
@@ -292,18 +376,15 @@ public partial class MemoryViewModel : ObservableObject
// Entities
if (snap.Entities is { Count: > 0 })
{
- var withPath = snap.Entities.Count(e => e.Path is not null);
var withPos = snap.Entities.Count(e => e.HasPosition);
- _entitySummary!.Set($"{snap.Entities.Count} entities, {withPath} with path, {withPos} with pos");
+ var withComps = snap.Entities.Count(e => e.Components is not null);
+ var monsters = snap.Entities.Count(e => e.Type == Automata.Memory.EntityType.Monster);
+ var knownComps = _reader?.Registry["components"].Count ?? 0;
+ _entitySummary!.Set($"{snap.Entities.Count} total, {withComps} with comps, {knownComps} known, {monsters} monsters");
- // Group by path category: "Metadata/Monsters/..." → "Monsters"
+ // Group by EntityType
var typeCounts = snap.Entities
- .GroupBy(e =>
- {
- if (e.Path is null) return "?";
- var parts = e.Path.Split('/');
- return parts.Length >= 2 ? parts[1] : "?";
- })
+ .GroupBy(e => e.Type)
.OrderByDescending(g => g.Count())
.Take(20);
@@ -314,11 +395,15 @@ public partial class MemoryViewModel : ObservableObject
node.Set(group.Count().ToString());
_entityTypesNode.Children.Add(node);
}
+
+ // Entity list grouped by type
+ UpdateEntityList(snap.Entities);
}
else
{
_entitySummary!.Set("—", false);
_entityTypesNode!.Children.Clear();
+ _entityListNode!.Children.Clear();
}
// Terrain
@@ -326,11 +411,372 @@ public partial class MemoryViewModel : ObservableObject
{
_terrainCells!.Set($"{snap.TerrainCols}x{snap.TerrainRows}");
_terrainGrid!.Set($"{snap.TerrainWidth}x{snap.TerrainHeight}");
+ if (snap.Terrain != null)
+ _terrainWalkable!.Set($"{snap.TerrainWalkablePercent}%");
+ else
+ _terrainWalkable!.Set("no grid data", false);
}
else
{
_terrainCells!.Set("?", false);
_terrainGrid!.Set("?", false);
+ _terrainWalkable!.Set("?", false);
+ }
+
+ UpdateMinimap(snap);
+ }
+
+ private void UpdateMinimap(GameStateSnapshot snap)
+ {
+ // Skip rendering entirely during loading — terrain data is stale/invalid
+ if (snap.IsLoading)
+ {
+ _terrainBasePixels = null;
+ _terrainImageAreaHash = 0;
+ _terrainGridRef = null;
+ var oldImg = TerrainImage;
+ TerrainImage = null;
+ oldImg?.Dispose();
+ return;
+ }
+
+ // Invalidate cache when area changes or terrain grid object changes
+ var terrainChanged = snap.Terrain is not null && !ReferenceEquals(snap.Terrain, _terrainGridRef);
+ if (terrainChanged || (snap.AreaHash != 0 && snap.AreaHash != _terrainImageAreaHash))
+ {
+ _terrainBasePixels = null;
+ _terrainImageAreaHash = 0;
+ _terrainGridRef = null;
+ var old = TerrainImage;
+ TerrainImage = null;
+ old?.Dispose();
+ }
+
+ // Rebuild base pixels from new terrain data
+ if (snap.Terrain is { } grid && _terrainBasePixels is null)
+ {
+ var w = grid.Width;
+ var h = grid.Height;
+ var pixels = new byte[w * h * 4];
+ for (var y = 0; y < h; y++)
+ {
+ var srcY = h - 1 - y; // flip Y: game Y-up → bitmap Y-down
+ for (var x = 0; x < w; x++)
+ {
+ var i = (y * w + x) * 4;
+ if (grid.Data[srcY * w + x] == 0) // walkable — transparent
+ {
+ pixels[i] = 0x00;
+ pixels[i + 1] = 0x00;
+ pixels[i + 2] = 0x00;
+ pixels[i + 3] = 0x00;
+ }
+ else // blocked
+ {
+ pixels[i] = 0x50; // B
+ pixels[i + 1] = 0x50; // G
+ pixels[i + 2] = 0x50; // R
+ pixels[i + 3] = 0xFF; // A
+ }
+ }
+ }
+
+ _terrainBasePixels = pixels;
+ _terrainImageWidth = w;
+ _terrainImageHeight = h;
+ _terrainImageAreaHash = snap.AreaHash;
+ _terrainGridRef = grid;
+ }
+
+ var basePixels = _terrainBasePixels;
+ if (basePixels is null) return;
+
+ var tw = _terrainImageWidth;
+ var th = _terrainImageHeight;
+ if (tw < 2 || th < 2) return;
+
+ // World-to-grid conversion: Render component gives world coords,
+ // terrain bitmap is in grid/subtile coords (NumCols*23 x NumRows*23).
+ // Each tile = 250 world units = 23 subtiles, so grid = world * 23/250.
+ const float worldToGrid = 23.0f / 250.0f;
+ const float cos45 = 0.70710678f;
+ const float sin45 = 0.70710678f;
+
+ var viewSize = ViewportRadius * 2;
+
+ // Player grid position — center of the output
+ float pgx, pgy;
+ if (snap.HasPosition)
+ {
+ pgx = snap.PlayerX * worldToGrid;
+ pgy = th - 1 - snap.PlayerY * worldToGrid;
+ }
+ else
+ {
+ pgx = tw * 0.5f;
+ pgy = th * 0.5f;
+ }
+
+ var bufSize = viewSize * viewSize * 4;
+ if (_minimapBuffer is null || _minimapBuffer.Length != bufSize)
+ _minimapBuffer = new byte[bufSize];
+ var buf = _minimapBuffer;
+ Array.Clear(buf, 0, bufSize);
+
+ var outStride = viewSize * 4;
+ var cx = viewSize * 0.5f;
+ var cy = viewSize * 0.5f;
+
+ // Sample terrain with -45° rotation baked in (nearest-neighbor, unsafe).
+ unsafe
+ {
+ fixed (byte* srcPtr = basePixels, dstPtr = buf)
+ {
+ var srcInt = (int*)srcPtr;
+ var dstInt = (int*)dstPtr;
+ for (var ry = 0; ry < viewSize; ry++)
+ {
+ var dy = ry - cy;
+ var baseX = -dy * sin45 + pgx;
+ var baseY = dy * cos45 + pgy;
+ for (var rx = 0; rx < viewSize; rx++)
+ {
+ var dx = rx - cx;
+ var sx = (int)(dx * cos45 + baseX);
+ var sy = (int)(dx * sin45 + baseY);
+ if ((uint)sx >= (uint)tw || (uint)sy >= (uint)th) continue;
+
+ dstInt[ry * viewSize + rx] = srcInt[sy * tw + sx];
+ }
+ }
+ }
+ }
+
+ // Draw entity dots — transform grid coords into rotated output space
+ if (snap.Entities is { Count: > 0 })
+ {
+ foreach (var e in snap.Entities)
+ {
+ if (!e.HasPosition) continue;
+ if (e.Type is not (EntityType.Player or EntityType.Monster or EntityType.Npc)) continue;
+ if (e.Address == snap.LocalPlayerPtr) continue;
+ if (e.Type == EntityType.Monster && e.IsDead) continue;
+
+ // Entity position relative to player in grid coords
+ var dx = e.X * worldToGrid - pgx;
+ var dy = (th - 1 - e.Y * worldToGrid) - pgy;
+ // Apply -45° rotation into output space
+ var ex = (int)(dx * cos45 + dy * sin45 + cx);
+ var ey = (int)(-dx * sin45 + dy * cos45 + cy);
+
+ if (ex < 0 || ex >= viewSize || ey < 0 || ey >= viewSize) continue;
+
+ byte b, g, r;
+ switch (e.Type)
+ {
+ case EntityType.Player: // other players — green #3FB950
+ b = 0x50; g = 0xB9; r = 0x3F; break;
+ case EntityType.Npc: // orange #FF8C00
+ b = 0x00; g = 0x8C; r = 0xFF; break;
+ case EntityType.Monster: // red #FF4444
+ b = 0x44; g = 0x44; r = 0xFF; break;
+ default: continue;
+ }
+
+ DrawDot(buf, outStride, viewSize, viewSize, ex, ey, 4, b, g, r);
+ }
+ }
+
+ // Draw player dot at center (white, on top)
+ if (snap.HasPosition)
+ DrawDot(buf, outStride, viewSize, viewSize, (int)cx, (int)cy, 5, 0xFF, 0xFF, 0xFF);
+
+ // Create WriteableBitmap
+ var bmp = new WriteableBitmap(
+ new PixelSize(viewSize, viewSize),
+ new Vector(96, 96),
+ Avalonia.Platform.PixelFormat.Bgra8888,
+ Avalonia.Platform.AlphaFormat.Premul);
+
+ using (var fb = bmp.Lock())
+ {
+ Marshal.Copy(buf, 0, fb.Address, buf.Length);
+ }
+
+ var old2 = TerrainImage;
+ TerrainImage = bmp;
+ old2?.Dispose();
+ }
+
+ private static void DrawDot(byte[] buf, int stride, int bw, int bh, int cx, int cy, int radius, byte b, byte g, byte r)
+ {
+ var outer = radius + 1;
+ for (var dy = -outer; dy <= outer; dy++)
+ {
+ for (var dx = -outer; dx <= outer; dx++)
+ {
+ var sx = cx + dx;
+ var sy = cy + dy;
+ if (sx < 0 || sx >= bw || sy < 0 || sy >= bh) continue;
+
+ var dist = MathF.Sqrt(dx * dx + dy * dy);
+ if (dist > radius + 0.5f) continue;
+
+ var alpha = Math.Clamp(radius + 0.5f - dist, 0f, 1f);
+ var i = sy * stride + sx * 4;
+ buf[i] = (byte)(b * alpha + buf[i] * (1 - alpha));
+ buf[i + 1] = (byte)(g * alpha + buf[i + 1] * (1 - alpha));
+ buf[i + 2] = (byte)(r * alpha + buf[i + 2] * (1 - alpha));
+ buf[i + 3] = (byte)(Math.Max(alpha, buf[i + 3] / 255f) * 255);
+ }
+ }
+ }
+
+ private void UpdateEntityList(List entities)
+ {
+ if (_entityListNode is null) return;
+
+ // Group by type, sorted by type name
+ var groups = entities
+ .GroupBy(e => e.Type)
+ .OrderBy(g => g.Key.ToString());
+
+ // Build a lookup of existing type group nodes by name for reuse
+ var existingGroups = new Dictionary();
+ foreach (var child in _entityListNode.Children)
+ existingGroups[child.Name] = child;
+
+ var usedGroups = new HashSet();
+
+ foreach (var group in groups)
+ {
+ var groupName = $"{group.Key} ({group.Count()})";
+ usedGroups.Add(groupName);
+
+ if (!existingGroups.TryGetValue(groupName, out var groupNode))
+ {
+ groupNode = new MemoryNodeViewModel(groupName) { IsExpanded = false };
+ _entityListNode.Children.Add(groupNode);
+ existingGroups[groupName] = groupNode;
+ }
+
+ // Sort: players first, then by distance to 0,0 (or by id)
+ var sorted = group.OrderBy(e => e.Id).ToList();
+
+ // Rebuild children — reuse by index to reduce churn
+ while (groupNode.Children.Count > sorted.Count)
+ groupNode.Children.RemoveAt(groupNode.Children.Count - 1);
+
+ for (var i = 0; i < sorted.Count; i++)
+ {
+ var e = sorted[i];
+ var label = FormatEntityName(e);
+ var value = FormatEntityValue(e);
+
+ if (i < groupNode.Children.Count)
+ {
+ var existing = groupNode.Children[i];
+ existing.Name = label;
+ existing.Set(value, e.HasPosition);
+ UpdateEntityChildren(existing, e);
+ }
+ else
+ {
+ var node = new MemoryNodeViewModel(label) { IsExpanded = false };
+ node.Set(value, e.HasPosition);
+ UpdateEntityChildren(node, e);
+ groupNode.Children.Add(node);
+ }
+ }
+ }
+
+ // Remove stale type groups
+ for (var i = _entityListNode.Children.Count - 1; i >= 0; i--)
+ {
+ if (!usedGroups.Contains(_entityListNode.Children[i].Name))
+ _entityListNode.Children.RemoveAt(i);
+ }
+ }
+
+ private static string FormatEntityName(Entity e)
+ {
+ // Short name: last path segment without @instance
+ if (e.Path is not null)
+ {
+ var lastSlash = e.Path.LastIndexOf('/');
+ var name = lastSlash >= 0 ? e.Path[(lastSlash + 1)..] : e.Path;
+ var at = name.IndexOf('@');
+ if (at > 0) name = name[..at];
+ return $"[{e.Id}] {name}";
+ }
+ return $"[{e.Id}] ?";
+ }
+
+ private static string FormatEntityValue(Entity e)
+ {
+ var parts = new List();
+
+ if (e.HasVitals)
+ parts.Add(e.IsAlive ? "Alive" : "Dead");
+
+ if (e.HasPosition)
+ parts.Add($"({e.X:F0},{e.Y:F0})");
+
+ if (e.HasVitals)
+ parts.Add($"HP:{e.LifeCurrent}/{e.LifeTotal}");
+
+ if (e.Components is { Count: > 0 })
+ parts.Add($"{e.Components.Count} comps");
+
+ return parts.Count > 0 ? string.Join(" ", parts) : "—";
+ }
+
+ private static void UpdateEntityChildren(MemoryNodeViewModel node, Entity e)
+ {
+ // Build children: address, position, vitals, components
+ var needed = new List<(string name, string value, bool valid)>();
+
+ needed.Add(("Addr:", $"0x{e.Address:X}", true));
+
+ if (e.Path is not null)
+ needed.Add(("Path:", e.Path, true));
+
+ if (e.HasPosition)
+ needed.Add(("Pos:", $"({e.X:F1}, {e.Y:F1}, {e.Z:F1})", true));
+
+ if (e.HasVitals)
+ {
+ needed.Add(("Life:", $"{e.LifeCurrent} / {e.LifeTotal}", e.LifeCurrent > 0));
+ if (e.ManaTotal > 0)
+ needed.Add(("Mana:", $"{e.ManaCurrent} / {e.ManaTotal}", true));
+ if (e.EsTotal > 0)
+ needed.Add(("ES:", $"{e.EsCurrent} / {e.EsTotal}", true));
+ }
+
+ if (e.Components is { Count: > 0 })
+ {
+ var compList = string.Join(", ", e.Components.OrderBy(c => c));
+ needed.Add(("Components:", compList, true));
+ }
+
+ // Reuse existing children by index
+ while (node.Children.Count > needed.Count)
+ node.Children.RemoveAt(node.Children.Count - 1);
+
+ for (var i = 0; i < needed.Count; i++)
+ {
+ var (name, value, valid) = needed[i];
+ if (i < node.Children.Count)
+ {
+ node.Children[i].Name = name;
+ node.Children[i].Set(value, valid);
+ }
+ else
+ {
+ var child = new MemoryNodeViewModel(name);
+ child.Set(value, valid);
+ node.Children.Add(child);
+ }
}
}
@@ -465,6 +911,66 @@ public partial class MemoryViewModel : ObservableObject
ScanResult = _reader.ScanEntities();
}
+ [RelayCommand]
+ private void ScanCompLookupExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.ScanComponentLookup();
+ }
+
+ [RelayCommand]
+ private void ScanAreaLoadingExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.ScanAreaLoadingState();
+ }
+
+ [RelayCommand]
+ private void ScanDiffExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.ScanMemoryDiff();
+ }
+
+ [RelayCommand]
+ private void ScanActiveVecExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.ScanActiveStatesVector();
+ }
+
+ [RelayCommand]
+ private void ScanTerrainExecute()
+ {
+ if (_reader is null || !_reader.IsAttached)
+ {
+ ScanResult = "Error: not attached";
+ return;
+ }
+
+ ScanResult = _reader.ScanTerrain();
+ }
+
[RelayCommand]
private void ScanStructureExecute()
{
diff --git a/src/Automata.Ui/Views/MainWindow.axaml b/src/Automata.Ui/Views/MainWindow.axaml
index 3b7432b..14d3d8f 100644
--- a/src/Automata.Ui/Views/MainWindow.axaml
+++ b/src/Automata.Ui/Views/MainWindow.axaml
@@ -126,11 +126,16 @@
+ RenderOptions.BitmapInterpolationMode="None"
+ IsVisible="{Binding MemoryVm.TerrainImage, Converter={x:Static ObjectConverters.IsNull}}" />
+
+
@@ -730,28 +735,38 @@
-
+
+ VerticalAlignment="Center" Margin="0,0,6,4" />
+ Width="60" FontFamily="Consolas" FontSize="11" Margin="0,0,6,4" />
+ Width="60" FontFamily="Consolas" FontSize="11" Margin="0,0,6,4" />
+ Width="60" FontFamily="Consolas" FontSize="11" Margin="0,0,6,4" />
+ Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
+ Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
+ Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
+ Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
+ Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
-
+ Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />
+
+
+
+
+
+