From 1ba7c39c30a6312dc1ab479010270742bc2febe5 Mon Sep 17 00:00:00 2001 From: Boki Date: Sun, 1 Mar 2026 21:29:44 -0500 Subject: [PATCH] lots working good, minimap / rotation / follow / entities --- components.json | 36 + entities.json | 94 ++ offsets.json | 24 +- src/Automata.Memory/Automata.Memory.csproj | 3 + src/Automata.Memory/Entity.cs | 201 +++ src/Automata.Memory/GameMemoryReader.cs | 1485 ++++++++++++++++- src/Automata.Memory/ObjectRegistry.cs | 134 ++ src/Automata.Memory/TerrainOffsets.cs | 51 +- src/Automata.Ui/ViewModels/MemoryViewModel.cs | 526 +++++- src/Automata.Ui/Views/MainWindow.axaml | 41 +- terrain.png | Bin 0 -> 4523 bytes 11 files changed, 2496 insertions(+), 99 deletions(-) create mode 100644 components.json create mode 100644 entities.json create mode 100644 src/Automata.Memory/Entity.cs create mode 100644 src/Automata.Memory/ObjectRegistry.cs create mode 100644 terrain.png 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 @@