From 0df70abad7cd52040b0c90fa49fd6c7e8af559de Mon Sep 17 00:00:00 2001 From: Boki Date: Wed, 4 Mar 2026 16:49:23 -0500 Subject: [PATCH] cleanup --- .../{ => Diagnostics}/MemoryDiagnostics.cs | 0 .../{ => Diagnostics}/QuestNameLookup.cs | 0 src/Roboto.Memory/EntityReader.cs | 441 ------------------ .../{ => Infrastructure}/ComponentReader.cs | 0 .../{ => Infrastructure}/MemoryContext.cs | 0 .../{ => Infrastructure}/MsvcStringReader.cs | 0 .../{ => Infrastructure}/Native.cs | 0 .../{ => Infrastructure}/ObjectRegistry.cs | 0 .../{ => Infrastructure}/PatternScanner.cs | 0 .../{ => Infrastructure}/ProcessMemory.cs | 0 .../{ => Infrastructure}/RttiResolver.cs | 0 .../AreaInstance.cs} | 0 .../AreaLoading.cs} | 0 .../{States => Objects}/GameStateType.cs | 0 .../{States => Objects}/GameStates.cs | 0 .../InGameState.cs} | 0 .../WorldData.cs} | 0 src/Roboto.Memory/QuestReader.cs | 320 ------------- .../{States => }/RemoteObject.cs | 0 src/Roboto.Memory/SkillReader.cs | 294 ------------ src/Roboto.Memory/{ => Snapshots}/Entity.cs | 0 .../{ => Snapshots}/GameStateSnapshot.cs | 0 .../{ => Snapshots}/WalkabilityGrid.cs | 0 src/Roboto.Memory/TerrainReader.cs | 170 ------- 24 files changed, 1225 deletions(-) rename src/Roboto.Memory/{ => Diagnostics}/MemoryDiagnostics.cs (100%) rename src/Roboto.Memory/{ => Diagnostics}/QuestNameLookup.cs (100%) delete mode 100644 src/Roboto.Memory/EntityReader.cs rename src/Roboto.Memory/{ => Infrastructure}/ComponentReader.cs (100%) rename src/Roboto.Memory/{ => Infrastructure}/MemoryContext.cs (100%) rename src/Roboto.Memory/{ => Infrastructure}/MsvcStringReader.cs (100%) rename src/Roboto.Memory/{ => Infrastructure}/Native.cs (100%) rename src/Roboto.Memory/{ => Infrastructure}/ObjectRegistry.cs (100%) rename src/Roboto.Memory/{ => Infrastructure}/PatternScanner.cs (100%) rename src/Roboto.Memory/{ => Infrastructure}/ProcessMemory.cs (100%) rename src/Roboto.Memory/{ => Infrastructure}/RttiResolver.cs (100%) rename src/Roboto.Memory/{States/AreaInstanceState.cs => Objects/AreaInstance.cs} (100%) rename src/Roboto.Memory/{States/AreaLoadingState.cs => Objects/AreaLoading.cs} (100%) rename src/Roboto.Memory/{States => Objects}/GameStateType.cs (100%) rename src/Roboto.Memory/{States => Objects}/GameStates.cs (100%) rename src/Roboto.Memory/{States/InGameStateReader.cs => Objects/InGameState.cs} (100%) rename src/Roboto.Memory/{States/WorldDataState.cs => Objects/WorldData.cs} (100%) delete mode 100644 src/Roboto.Memory/QuestReader.cs rename src/Roboto.Memory/{States => }/RemoteObject.cs (100%) delete mode 100644 src/Roboto.Memory/SkillReader.cs rename src/Roboto.Memory/{ => Snapshots}/Entity.cs (100%) rename src/Roboto.Memory/{ => Snapshots}/GameStateSnapshot.cs (100%) rename src/Roboto.Memory/{ => Snapshots}/WalkabilityGrid.cs (100%) delete mode 100644 src/Roboto.Memory/TerrainReader.cs diff --git a/src/Roboto.Memory/MemoryDiagnostics.cs b/src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs similarity index 100% rename from src/Roboto.Memory/MemoryDiagnostics.cs rename to src/Roboto.Memory/Diagnostics/MemoryDiagnostics.cs diff --git a/src/Roboto.Memory/QuestNameLookup.cs b/src/Roboto.Memory/Diagnostics/QuestNameLookup.cs similarity index 100% rename from src/Roboto.Memory/QuestNameLookup.cs rename to src/Roboto.Memory/Diagnostics/QuestNameLookup.cs diff --git a/src/Roboto.Memory/EntityReader.cs b/src/Roboto.Memory/EntityReader.cs deleted file mode 100644 index 98bc15d..0000000 --- a/src/Roboto.Memory/EntityReader.cs +++ /dev/null @@ -1,441 +0,0 @@ -using Roboto.GameOffsets.Components; -using Roboto.GameOffsets.Entities; -using Serilog; - -namespace Roboto.Memory; - -/// -/// Reads entity list from AreaInstance's std::map red-black tree. -/// -public sealed class EntityReader -{ - private readonly MemoryContext _ctx; - private readonly ComponentReader _components; - private readonly MsvcStringReader _strings; - - public EntityReader(MemoryContext ctx, ComponentReader components, MsvcStringReader strings) - { - _ctx = ctx; - _components = components; - _strings = strings; - } - - /// - /// Reads entity list into the snapshot for continuous display. - /// - public void ReadEntities(GameStateSnapshot snap, nint areaInstance) - { - var mem = _ctx.Memory; - var offsets = _ctx.Offsets; - var registry = _ctx.Registry; - - var sentinel = mem.ReadPointer(areaInstance + offsets.EntityListOffset); - if (sentinel == 0) return; - - var root = mem.ReadPointer(sentinel + offsets.EntityNodeParentOffset); - var entities = new List(); - var maxNodes = Math.Min(snap.EntityCount + 10, 500); - var hasComponentLookup = offsets.ComponentLookupEntrySize > 0; - var dirty = false; - - WalkTreeInOrder(sentinel, root, maxNodes, (_, treeNode) => - { - var entityPtr = treeNode.Data.EntityPtr; - if (entityPtr == 0) return; - - var high = (ulong)entityPtr >> 32; - if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return; - - var entityId = treeNode.Data.Key.EntityId; - var path = TryReadEntityPath(entityPtr); - - // Never process doodads — they are decorative and waste RPM calls - if (IsDoodadPath(path)) return; - - var entity = new Entity(entityPtr, entityId, path); - entity.Type = ClassifyType(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) - if (hasComponentLookup && !IsLowPriorityPath(entity.Type)) - { - var lookup = _components.ReadComponentLookup(entityPtr); - if (lookup is not null) - { - entity.Components = new HashSet(lookup.Keys); - ReclassifyFromComponents(entity); - - if (registry["components"].Register(lookup.Keys)) - dirty = true; - - var (compFirst, compCount) = _components.FindComponentList(entityPtr); - - // Read Targetable for any entity that has it - if (lookup.TryGetValue("Targetable", out var targetIdx) && targetIdx >= 0 && targetIdx < compCount) - { - var targetComp = mem.ReadPointer(compFirst + targetIdx * 8); - if (targetComp != 0) - { - var targetable = mem.Read(targetComp); - entity.IsTargetable = targetable.IsTargetable != 0; - } - } - - // Read HP/Actor/Mods for monsters - if (entity.Components.Contains("Monster")) - { - if (lookup.TryGetValue("Life", out var lifeIdx) && lifeIdx >= 0 && lifeIdx < compCount) - { - var lifeComp = mem.ReadPointer(compFirst + lifeIdx * 8); - if (lifeComp != 0) - { - var life = mem.Read(lifeComp); - if (life.Health.Total > 0 && life.Health.Total < 200000 && - life.Health.Current >= 0 && life.Health.Current <= life.Health.Total + 1000) - { - entity.HasVitals = true; - entity.LifeCurrent = life.Health.Current; - entity.LifeTotal = life.Health.Total; - } - } - } - - // Read Actor AnimationId — offset 0x370 confirmed from ExileCore2 - if (lookup.TryGetValue("Actor", out var actorIdx) && actorIdx >= 0 && actorIdx < compCount) - { - var actorComp = mem.ReadPointer(compFirst + actorIdx * 8); - if (actorComp != 0) - { - var animId = mem.Read(actorComp + ActorOffsets.AnimationId); - entity.ActionId = (short)(animId & 0xFFFF); - } - } - - // Read Mods — rarity + explicit mod names - if (lookup.TryGetValue("Mods", out var modsIdx) && modsIdx >= 0 && modsIdx < compCount) - { - var modsComp = mem.ReadPointer(compFirst + modsIdx * 8); - if (modsComp != 0) - ReadEntityMods(entity, modsComp); - } - } - - // Read AreaTransition destination + Transitionable state - if (entity.Components.Contains("AreaTransition") && - lookup.TryGetValue("AreaTransition", out var atIdx) && atIdx >= 0 && atIdx < compCount) - { - var atComp = mem.ReadPointer(compFirst + atIdx * 8); - if (atComp != 0) - entity.TransitionName = ReadAreaTransitionName(atComp); - } - - if (entity.Components.Contains("Transitionable") && - lookup.TryGetValue("Transitionable", out var trIdx) && trIdx >= 0 && trIdx < compCount) - { - var trComp = mem.ReadPointer(compFirst + trIdx * 8); - if (trComp != 0) - { - var tr = mem.Read(trComp); - entity.TransitionState = tr.CurrentStateEnum; - } - } - } - } - - entities.Add(entity); - }); - - if (dirty) - registry.Flush(); - - snap.Entities = entities; - } - - /// - /// Iterative in-order traversal of an MSVC std::map red-black tree. - /// Backward-compatible overload — delegates to the struct-based version. - /// - public void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action visitor) - { - WalkTreeInOrder(sentinel, root, maxNodes, (addr, _) => visitor(addr)); - } - - /// - /// Iterative in-order traversal using Read<EntityTreeNode> — 1 kernel call per node - /// instead of separate Left/Right reads. The visitor receives both the node address - /// and the parsed struct data. - /// - public void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action visitor) - { - if (root == 0 || root == sentinel) return; - - var mem = _ctx.Memory; - var stack = new Stack<(nint Addr, EntityTreeNode Node)>(); - var current = root; - var count = 0; - var visited = new HashSet { sentinel }; - - while ((current != sentinel && current != 0) || stack.Count > 0) - { - while (current != sentinel && current != 0) - { - if (!visited.Add(current)) - { - current = sentinel; - break; - } - var node = mem.Read(current); - stack.Push((current, node)); - current = node.Left; - } - - if (stack.Count == 0) break; - - var (nodeAddr, treeNode) = stack.Pop(); - visitor(nodeAddr, treeNode); - count++; - if (count >= maxNodes) break; - - current = treeNode.Right; - } - } - - /// - /// Reads area transition destination by scanning the AreaTransition component - /// for pointer chains leading to a readable string. Returns the raw area ID - /// (e.g. "G1_4"); display name resolution is done in the Data layer. - /// - private string? ReadAreaTransitionName(nint atComp) - { - var mem = _ctx.Memory; - - var data = mem.ReadBytes(atComp, 0x80); - if (data is null) return null; - - for (var off = 0x10; off + 8 <= data.Length; off += 8) - { - var ptr = (nint)BitConverter.ToInt64(data, off); - if (ptr == 0) continue; - if (((ulong)ptr >> 32) is 0 or >= 0x7FFF) continue; - - var raw = _strings.ReadNullTermWString(ptr); - if (raw is not null && raw.Length >= 3) - return raw; - - var inner = mem.ReadPointer(ptr); - if (inner != 0 && ((ulong)inner >> 32) is > 0 and < 0x7FFF) - { - raw = _strings.ReadNullTermWString(inner); - if (raw is not null && raw.Length >= 3) - return raw; - } - } - - return null; - } - - /// - /// Reads Mods component: rarity from ObjectMagicProperties, mod names from ExplicitMods vector. - /// ModPtr in each mod entry → .dat row → first field is a wchar* mod identifier. - /// - private void ReadEntityMods(Entity entity, nint modsComp) - { - var mem = _ctx.Memory; - - var mods = mem.Read(modsComp); - - // Read rarity from ObjectMagicProperties - if (mods.ObjectMagicPropertiesPtr != 0 && - ((ulong)mods.ObjectMagicPropertiesPtr >> 32) is > 0 and < 0x7FFF) - { - var props = mem.Read(mods.ObjectMagicPropertiesPtr); - if (props.Rarity is >= 0 and <= 3) - entity.Rarity = props.Rarity; - } - - // Read explicit mod names from AllModsPtr - if (mods.AllModsPtr == 0 || ((ulong)mods.AllModsPtr >> 32) is 0 or >= 0x7FFF) - return; - - var allMods = mem.Read(mods.AllModsPtr); - var explicitCount = (int)allMods.ExplicitMods.TotalElements(16); // ModArrayStruct = 16 bytes - if (explicitCount <= 0 || explicitCount > 20) return; - - var modNames = new List(); - for (var i = 0; i < explicitCount; i++) - { - var modEntry = mem.Read(allMods.ExplicitMods.First + i * 16); - if (modEntry.ModPtr == 0) continue; - if (((ulong)modEntry.ModPtr >> 32) is 0 or >= 0x7FFF) continue; - - // Mods.dat row: first field is typically a wchar* or std::wstring key - // Try reading as wchar* first (null-terminated UTF-16) - var name = _strings.ReadNullTermWString(modEntry.ModPtr); - if (name is not null) - { - modNames.Add(name); - continue; - } - - // Try as std::wstring - name = _strings.ReadMsvcWString(modEntry.ModPtr); - if (name is not null) - modNames.Add(name); - } - - if (modNames.Count > 0) - entity.ModNames = modNames; - } - - /// - /// Reads entity path string via EntityDetailsPtr → std::wstring. - /// - public string? TryReadEntityPath(nint entity) - { - var mem = _ctx.Memory; - var offsets = _ctx.Offsets; - - var detailsPtr = mem.ReadPointer(entity + offsets.EntityDetailsOffset); - if (detailsPtr == 0) return null; - - var high = (ulong)detailsPtr >> 32; - if (high == 0 || high >= 0x7FFF) return null; - - return _strings.ReadMsvcWString(detailsPtr + offsets.EntityPathStringOffset); - } - - /// - /// Tries to read position from an entity by scanning its component list for the Render component. - /// - public bool TryReadEntityPosition(nint entity, out float x, out float y, out float z) - { - x = y = z = 0; - var offsets = _ctx.Offsets; - - var (compFirst, count) = _components.FindComponentList(entity); - if (count <= 0) return false; - - // If we know the Render component index, try it directly - if (offsets.RenderComponentIndex >= 0 && offsets.RenderComponentIndex < count) - { - var renderComp = _ctx.Memory.ReadPointer(compFirst + offsets.RenderComponentIndex * 8); - if (renderComp != 0 && _components.TryReadPositionRaw(renderComp, out x, out y, out z)) - return true; - } - - // Scan components (limit to avoid performance issues with many entities) - var scanLimit = Math.Min(count, 20); - for (var i = 0; i < scanLimit; i++) - { - var compPtr = _ctx.Memory.ReadPointer(compFirst + i * 8); - if (compPtr == 0) continue; - var high = (ulong)compPtr >> 32; - if (high == 0 || high >= 0x7FFF) continue; - if ((compPtr & 0x3) != 0) continue; - - if (_components.TryReadPositionRaw(compPtr, out x, out y, out z)) - return true; - } - - return false; - } - - // ── Classification helpers (Memory-internal) ───────────────────────── - - private static bool IsDoodadPath(string? path) - { - if (path is null) return false; - return path.Contains("Doodad", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Returns true for entity types that don't need component reading (effects, terrain, critters). - /// - private static bool IsLowPriorityPath(EntityType type) - => type is EntityType.Effect or EntityType.Terrain or EntityType.Critter; - - /// - /// Path-based entity type classification. Mirrors the logic previously in Entity.ClassifyType. - /// - private static EntityType ClassifyType(string? path) - { - if (path is null) return EntityType.Unknown; - - 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": - 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; - } - } - - /// - /// Reclassify entity type using component names (called after components are read). - /// Component-based classification is more reliable than path-based. - /// - private static void ReclassifyFromComponents(Entity entity) - { - var components = entity.Components; - if (components is null || components.Count == 0) return; - - if (components.Contains("Monster")) { entity.Type = EntityType.Monster; return; } - if (components.Contains("Chest")) { entity.Type = EntityType.Chest; return; } - if (components.Contains("Shrine")) { entity.Type = EntityType.Shrine; return; } - if (components.Contains("Waypoint")) { entity.Type = EntityType.Waypoint; return; } - if (components.Contains("AreaTransition")) { entity.Type = EntityType.AreaTransition; return; } - if (components.Contains("Portal")) { entity.Type = EntityType.Portal; return; } - if (components.Contains("TownPortal")) { entity.Type = EntityType.TownPortal; return; } - if (components.Contains("NPC")) { entity.Type = EntityType.Npc; return; } - if (components.Contains("Player")) { entity.Type = EntityType.Player; return; } - } -} diff --git a/src/Roboto.Memory/ComponentReader.cs b/src/Roboto.Memory/Infrastructure/ComponentReader.cs similarity index 100% rename from src/Roboto.Memory/ComponentReader.cs rename to src/Roboto.Memory/Infrastructure/ComponentReader.cs diff --git a/src/Roboto.Memory/MemoryContext.cs b/src/Roboto.Memory/Infrastructure/MemoryContext.cs similarity index 100% rename from src/Roboto.Memory/MemoryContext.cs rename to src/Roboto.Memory/Infrastructure/MemoryContext.cs diff --git a/src/Roboto.Memory/MsvcStringReader.cs b/src/Roboto.Memory/Infrastructure/MsvcStringReader.cs similarity index 100% rename from src/Roboto.Memory/MsvcStringReader.cs rename to src/Roboto.Memory/Infrastructure/MsvcStringReader.cs diff --git a/src/Roboto.Memory/Native.cs b/src/Roboto.Memory/Infrastructure/Native.cs similarity index 100% rename from src/Roboto.Memory/Native.cs rename to src/Roboto.Memory/Infrastructure/Native.cs diff --git a/src/Roboto.Memory/ObjectRegistry.cs b/src/Roboto.Memory/Infrastructure/ObjectRegistry.cs similarity index 100% rename from src/Roboto.Memory/ObjectRegistry.cs rename to src/Roboto.Memory/Infrastructure/ObjectRegistry.cs diff --git a/src/Roboto.Memory/PatternScanner.cs b/src/Roboto.Memory/Infrastructure/PatternScanner.cs similarity index 100% rename from src/Roboto.Memory/PatternScanner.cs rename to src/Roboto.Memory/Infrastructure/PatternScanner.cs diff --git a/src/Roboto.Memory/ProcessMemory.cs b/src/Roboto.Memory/Infrastructure/ProcessMemory.cs similarity index 100% rename from src/Roboto.Memory/ProcessMemory.cs rename to src/Roboto.Memory/Infrastructure/ProcessMemory.cs diff --git a/src/Roboto.Memory/RttiResolver.cs b/src/Roboto.Memory/Infrastructure/RttiResolver.cs similarity index 100% rename from src/Roboto.Memory/RttiResolver.cs rename to src/Roboto.Memory/Infrastructure/RttiResolver.cs diff --git a/src/Roboto.Memory/States/AreaInstanceState.cs b/src/Roboto.Memory/Objects/AreaInstance.cs similarity index 100% rename from src/Roboto.Memory/States/AreaInstanceState.cs rename to src/Roboto.Memory/Objects/AreaInstance.cs diff --git a/src/Roboto.Memory/States/AreaLoadingState.cs b/src/Roboto.Memory/Objects/AreaLoading.cs similarity index 100% rename from src/Roboto.Memory/States/AreaLoadingState.cs rename to src/Roboto.Memory/Objects/AreaLoading.cs diff --git a/src/Roboto.Memory/States/GameStateType.cs b/src/Roboto.Memory/Objects/GameStateType.cs similarity index 100% rename from src/Roboto.Memory/States/GameStateType.cs rename to src/Roboto.Memory/Objects/GameStateType.cs diff --git a/src/Roboto.Memory/States/GameStates.cs b/src/Roboto.Memory/Objects/GameStates.cs similarity index 100% rename from src/Roboto.Memory/States/GameStates.cs rename to src/Roboto.Memory/Objects/GameStates.cs diff --git a/src/Roboto.Memory/States/InGameStateReader.cs b/src/Roboto.Memory/Objects/InGameState.cs similarity index 100% rename from src/Roboto.Memory/States/InGameStateReader.cs rename to src/Roboto.Memory/Objects/InGameState.cs diff --git a/src/Roboto.Memory/States/WorldDataState.cs b/src/Roboto.Memory/Objects/WorldData.cs similarity index 100% rename from src/Roboto.Memory/States/WorldDataState.cs rename to src/Roboto.Memory/Objects/WorldData.cs diff --git a/src/Roboto.Memory/QuestReader.cs b/src/Roboto.Memory/QuestReader.cs deleted file mode 100644 index c3987c3..0000000 --- a/src/Roboto.Memory/QuestReader.cs +++ /dev/null @@ -1,320 +0,0 @@ -using Serilog; - -namespace Roboto.Memory; - -/// -/// Lightweight quest data from ServerData quest flags. -/// Stored in GameStateSnapshot; mapped to Roboto.Core.QuestProgress in the Data layer. -/// -public sealed class QuestSnapshot -{ - /// QuestState.dat row index (int_vector mode) or 0 (pointer mode). - public int QuestStateIndex { get; init; } - public nint QuestDatPtr { get; init; } - public string? QuestName { get; init; } - /// Internal quest ID from dat row (e.g. "TreeOfSouls2", "IncursionQuest1_Act1"). - public string? InternalId { get; init; } - /// Encounter state from quest state object: 1=locked/not encountered, 2=available/started. - public byte StateId { get; init; } - /// True if this quest is the currently tracked/active quest in the UI. - public bool IsTracked { get; init; } - public string? StateText { get; init; } - public string? ProgressText { get; init; } -} - -/// -/// Reads quest flags from ServerData → PlayerServerData → QuestFlags. -/// Supports two modes: -/// - "int_vector": flat StdVector of int32 QuestState.dat row indices (POE2) -/// - "vector": struct entries with dat row pointers and string fields (POE1/legacy) -/// When QuestFlagEntrySize == 0, gracefully returns null. -/// -public sealed class QuestReader -{ - private readonly MemoryContext _ctx; - private readonly MsvcStringReader _strings; - private readonly QuestNameLookup? _nameLookup; - - // Name cache — quest names are static, only refresh on ServerData change - private readonly Dictionary _nameCache = new(); - private nint _lastPsd; - - public QuestReader(MemoryContext ctx, MsvcStringReader strings, QuestNameLookup? nameLookup = null) - { - _ctx = ctx; - _strings = strings; - _nameLookup = nameLookup; - } - - /// - /// Reads quest flags from the ServerData pointer chain. - /// Returns null if offsets are not configured (EntrySize == 0) or data is unavailable. - /// - public List? ReadQuestFlags(nint serverDataPtr) - { - if (serverDataPtr == 0) return null; - - var offsets = _ctx.Offsets; - if (offsets.QuestFlagEntrySize <= 0) return null; - - var mem = _ctx.Memory; - - // ServerData → PlayerServerData StdVector (vector of pointers, deref [0]) - var psdVecBegin = mem.ReadPointer(serverDataPtr + offsets.PlayerServerDataOffset); - if (psdVecBegin == 0) return null; - - var playerServerData = mem.ReadPointer(psdVecBegin); - if (playerServerData == 0) return null; - - // Invalidate cache on PSD change (area transition) - if (playerServerData != _lastPsd) - { - _nameCache.Clear(); - _lastPsd = playerServerData; - } - - var questFlagsAddr = playerServerData + offsets.QuestFlagsOffset; - - if (offsets.QuestFlagsContainerType == "int_vector") - return ReadIntVectorQuests(questFlagsAddr, offsets); - - if (offsets.QuestFlagsContainerType == "vector") - return ReadStructVectorQuests(questFlagsAddr, offsets); - - return null; - } - - /// - /// POE2 mode: reads a StdVector of int32 QuestState.dat row indices (QF+0x000), - /// plus a companion StdVector of 24-byte structs (QF+0x018) that contain: - /// +0x00 int32: QuestStateId (= .dat row index, same as int32 vector value) - /// +0x04 uint32: TrackedFlag (0x43020000 = currently tracked quest in UI, 0 = not tracked) - /// +0x10 ptr: Quest state object (runtime object, NOT a .dat row) - /// The quest state object has encounter state at +0x008 (byte: 1=locked, 2=started). - /// Quest names are resolved from the .dat table base if configured. - /// - private List? ReadIntVectorQuests(nint questFlagsAddr, GameOffsets offsets) - { - var mem = _ctx.Memory; - - // Read int32 index vector (QF+0x000) - var vecBegin = mem.ReadPointer(questFlagsAddr); - var vecEnd = mem.ReadPointer(questFlagsAddr + 8); - if (vecBegin == 0 || vecEnd <= vecBegin) return null; - - var totalBytes = (int)(vecEnd - vecBegin); - var entryCount = totalBytes / 4; // int32 = 4 bytes - if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null; - - var vecData = mem.ReadBytes(vecBegin, totalBytes); - if (vecData is null) return null; - - // Read companion vector (QF+0x018) for quest state objects - byte[]? compData = null; - var compEntryCount = 0; - if (offsets.QuestCompanionOffset > 0 && offsets.QuestCompanionEntrySize > 0) - { - var compBegin = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset); - var compEnd = mem.ReadPointer(questFlagsAddr + offsets.QuestCompanionOffset + 8); - if (compBegin != 0 && compEnd > compBegin) - { - var compBytes = (int)(compEnd - compBegin); - compEntryCount = compBytes / offsets.QuestCompanionEntrySize; - if (compEntryCount > 0 && compBytes < 0x100000) - compData = mem.ReadBytes(compBegin, compBytes); - } - } - - // Find .dat table base if configured (for quest name resolution) - var datTableBase = FindDatTableBase(offsets); - - var result = new List(entryCount); - - for (var i = 0; i < entryCount; i++) - { - var idx = BitConverter.ToInt32(vecData, i * 4); - string? questName = null; - string? internalId = null; - byte stateId = 0; - bool isTracked = false; - nint questObjPtr = 0; - - if (compData is not null && i < compEntryCount) - { - var compOff = i * offsets.QuestCompanionEntrySize; - - // Read tracked flag from companion +0x04 - if (offsets.QuestCompanionTrackedOffset > 0 && - compOff + offsets.QuestCompanionTrackedOffset + 4 <= compData.Length) - { - var trackedVal = BitConverter.ToUInt32(compData, compOff + offsets.QuestCompanionTrackedOffset); - isTracked = trackedVal == offsets.QuestTrackedMarker; - } - - // Read quest state object pointer from companion +0x10 - if (compOff + offsets.QuestCompanionObjPtrOffset + 8 <= compData.Length) - { - questObjPtr = (nint)BitConverter.ToInt64(compData, compOff + offsets.QuestCompanionObjPtrOffset); - - // Read encounter state byte from quest state object +0x008 - if (questObjPtr != 0 && ((ulong)questObjPtr >> 32) is > 0 and < 0x7FFF - && offsets.QuestObjEncounterStateOffset > 0) - { - var stateByte = mem.ReadBytes(questObjPtr + offsets.QuestObjEncounterStateOffset, 1); - if (stateByte is { Length: 1 }) - stateId = stateByte[0]; - } - } - } - - // Resolve quest name: try .dat table first, then JSON lookup fallback - if (datTableBase != 0 && offsets.QuestDatRowSize > 0) - { - var rowAddr = datTableBase + idx * offsets.QuestDatRowSize; - questName = ResolveDatString(rowAddr + offsets.QuestDatNameOffset); - internalId = ResolveDatString(rowAddr + offsets.QuestDatInternalIdOffset); - } - else if (_nameLookup is not null && _nameLookup.TryGet(idx, out var entry)) - { - questName = entry?.Name; - internalId = entry?.InternalId; - } - - result.Add(new QuestSnapshot - { - QuestStateIndex = idx, - QuestDatPtr = questObjPtr, - QuestName = questName, - InternalId = internalId, - StateId = stateId, - IsTracked = isTracked, - }); - } - - return result; - } - - /// - /// Finds the QuestStates.dat row table base address. - /// Uses QuestDatTableBase offset from PSD if configured, otherwise returns 0. - /// - private nint FindDatTableBase(GameOffsets offsets) - { - if (offsets.QuestDatRowSize <= 0) return 0; - // Future: auto-discover table base by scanning for known patterns - // For now, table base must be found externally and is not resolved here - return 0; - } - - /// Reads a wchar* pointer at the given address and returns the string. - private string? ResolveDatString(nint fieldAddr) - { - if (_nameCache.TryGetValue(fieldAddr, out var cached)) - return cached; - - var mem = _ctx.Memory; - var strPtr = mem.ReadPointer(fieldAddr); - string? result = null; - - if (strPtr != 0 && ((ulong)strPtr >> 32) is > 0 and < 0x7FFF) - result = _strings.ReadNullTermWString(strPtr); - - _nameCache[fieldAddr] = result; - return result; - } - - /// - /// Legacy/POE1 mode: reads struct entries with dat row pointers and string fields. - /// - private List? ReadStructVectorQuests(nint questFlagsAddr, GameOffsets offsets) - { - var mem = _ctx.Memory; - - var vecBegin = mem.ReadPointer(questFlagsAddr); - var vecEnd = mem.ReadPointer(questFlagsAddr + 8); - if (vecBegin == 0 || vecEnd <= vecBegin) return null; - - var totalBytes = (int)(vecEnd - vecBegin); - var entrySize = offsets.QuestFlagEntrySize; - var entryCount = totalBytes / entrySize; - if (entryCount <= 0 || entryCount > offsets.QuestFlagsMaxEntries) return null; - - var vecData = mem.ReadBytes(vecBegin, totalBytes); - if (vecData is null) return null; - - var result = new List(entryCount); - - for (var i = 0; i < entryCount; i++) - { - var entryOffset = i * entrySize; - - nint questDatPtr = 0; - if (entryOffset + offsets.QuestEntryQuestPtrOffset + 8 <= vecData.Length) - questDatPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryQuestPtrOffset); - - byte stateId = 0; - if (entryOffset + offsets.QuestEntryStateIdOffset < vecData.Length) - stateId = vecData[entryOffset + offsets.QuestEntryStateIdOffset]; - - var questName = ResolveQuestName(questDatPtr); - - string? stateText = null; - if (offsets.QuestEntryStateTextOffset > 0 && - entryOffset + offsets.QuestEntryStateTextOffset + 8 <= vecData.Length) - { - var stateTextPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryStateTextOffset); - if (stateTextPtr != 0 && ((ulong)stateTextPtr >> 32) is > 0 and < 0x7FFF) - stateText = _strings.ReadNullTermWString(stateTextPtr); - } - - string? progressText = null; - if (offsets.QuestEntryProgressTextOffset > 0 && - entryOffset + offsets.QuestEntryProgressTextOffset + 8 <= vecData.Length) - { - var progressTextPtr = (nint)BitConverter.ToInt64(vecData, entryOffset + offsets.QuestEntryProgressTextOffset); - if (progressTextPtr != 0 && ((ulong)progressTextPtr >> 32) is > 0 and < 0x7FFF) - progressText = _strings.ReadNullTermWString(progressTextPtr); - } - - result.Add(new QuestSnapshot - { - QuestDatPtr = questDatPtr, - QuestName = questName, - StateId = stateId, - StateText = stateText, - ProgressText = progressText, - }); - } - - return result; - } - - private string? ResolveQuestName(nint questDatPtr) - { - if (questDatPtr == 0) return null; - - if (_nameCache.TryGetValue(questDatPtr, out var cached)) - return cached; - - var mem = _ctx.Memory; - string? name = null; - - var high = (ulong)questDatPtr >> 32; - if (high is > 0 and < 0x7FFF) - { - var namePtr = mem.ReadPointer(questDatPtr); - if (namePtr != 0) - name = _strings.ReadNullTermWString(namePtr); - } - - _nameCache[questDatPtr] = name; - return name; - } - - /// Clears cached names (call on area change). - public void InvalidateCache() - { - _nameCache.Clear(); - _lastPsd = 0; - } -} diff --git a/src/Roboto.Memory/States/RemoteObject.cs b/src/Roboto.Memory/RemoteObject.cs similarity index 100% rename from src/Roboto.Memory/States/RemoteObject.cs rename to src/Roboto.Memory/RemoteObject.cs diff --git a/src/Roboto.Memory/SkillReader.cs b/src/Roboto.Memory/SkillReader.cs deleted file mode 100644 index 7bfdb76..0000000 --- a/src/Roboto.Memory/SkillReader.cs +++ /dev/null @@ -1,294 +0,0 @@ -using Roboto.GameOffsets.Components; - -namespace Roboto.Memory; - -/// -/// Lightweight skill data from the Actor component's ActiveSkills vector. -/// Stored in GameStateSnapshot; mapped to Roboto.Core.SkillState in the Data layer. -/// -public sealed class SkillSnapshot -{ - public string? Name { get; init; } - public string? InternalName { get; init; } - /// Address of ActiveSkillPtr in game memory (for CE inspection). - public nint Address { get; init; } - /// Raw bytes at ActiveSkillPtr for offset discovery. - public byte[]? RawBytes { get; init; } - public bool CanBeUsed { get; init; } - public int UseStage { get; init; } - public int CastType { get; init; } - public int TotalUses { get; init; } - public int CooldownTimeMs { get; init; } - - /// From Cooldowns vector — number of active cooldown entries. - public int ActiveCooldowns { get; init; } - /// From Cooldowns vector — max uses (charges) for the skill. - public int MaxUses { get; init; } - - /// Low 16 bits of UnknownIdAndEquipmentInfo — skill ID used for SkillBarIds matching. - public ushort Id { get; init; } - /// High 16 bits of UnknownIdAndEquipmentInfo — equipment slot / secondary ID. - public ushort Id2 { get; init; } - /// Skill bar slot index (0-12) from SkillBarIds, or -1 if not on the skill bar. - public int SkillBarSlot { get; init; } = -1; -} - -/// -/// Reads active skills from the local player's Actor component. -/// Uses ExileCore2 offsets: Actor+0xB00 = ActiveSkills vector (shared_ptr pairs), -/// follow ptr1 (ActiveSkillPtr) → ActiveSkillDetails for GEPL, cooldown, uses. -/// Actor+0xB18 = Cooldowns vector for dynamic cooldown state. -/// -public sealed class SkillReader -{ - private readonly MemoryContext _ctx; - private readonly ComponentReader _components; - private readonly MsvcStringReader _strings; - - // Name cache — skill names are static per area, only refresh on actor change - private readonly Dictionary _nameCache = new(); - private nint _lastActorComp; - - public SkillReader(MemoryContext ctx, ComponentReader components, MsvcStringReader strings) - { - _ctx = ctx; - _components = components; - _strings = strings; - } - - public List? ReadPlayerSkills(nint localPlayerPtr, nint psdPtr = 0) - { - if (localPlayerPtr == 0) return null; - var mem = _ctx.Memory; - - var actorComp = _components.GetComponentAddress(localPlayerPtr, "Actor"); - if (actorComp == 0) return null; - - // Invalidate name cache if actor component address changed (area transition) - if (actorComp != _lastActorComp) - { - _nameCache.Clear(); - _lastActorComp = actorComp; - } - - // Read SkillBarIds from PSD if offset is configured - var skillBarIds = ReadSkillBarIds(psdPtr); - - // Read ActiveSkills vector at Actor+0xB00 - var vecFirst = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector); - var vecLast = mem.ReadPointer(actorComp + ActorOffsets.ActiveSkillsVector + 8); - if (vecFirst == 0 || vecLast <= vecFirst) return null; - - var totalBytes = (int)(vecLast - vecFirst); - const int entrySize = 0x10; // ActiveSkillEntry: ActiveSkillPtr + ControlBlockPtr - var entryCount = totalBytes / entrySize; - if (entryCount <= 0 || entryCount > 128) return null; - - // Bulk read all entries - var vecData = mem.ReadBytes(vecFirst, totalBytes); - if (vecData is null) return null; - - // Read cooldowns for dynamic CanBeUsed state - var cooldowns = ReadCooldowns(actorComp); - - var result = new List(); - var seen = new HashSet(); // deduplicate by UnknownIdAndEquipmentInfo - - for (var i = 0; i < entryCount; i++) - { - // Follow ptr1 (ActiveSkillPtr) — ExileCore convention - var activeSkillPtr = (nint)BitConverter.ToInt64(vecData, i * entrySize); - if (activeSkillPtr == 0) continue; - var high = (ulong)activeSkillPtr >> 32; - if (high == 0 || high >= 0x7FFF) continue; - - // Read ActiveSkillDetails struct — ptr points 0x10 into the object (past vtable+header) - var details = mem.Read(activeSkillPtr - 0x10); - - // Resolve skill name via GEPL FK chain (cached) - var name = ResolveSkillName(activeSkillPtr, details); - - // Skip entries with no resolved name (support gems, passives, internal skills) - if (name is null) continue; - - // Deduplicate by UnknownIdAndEquipmentInfo - if (!seen.Add(details.UnknownIdAndEquipmentInfo)) continue; - - // Extract Id/Id2 from UnknownIdAndEquipmentInfo - var id = (ushort)(details.UnknownIdAndEquipmentInfo & 0xFFFF); - var id2 = (ushort)(details.UnknownIdAndEquipmentInfo >> 16); - - // Match to skill bar slot - var slot = -1; - if (skillBarIds is not null) - { - for (var s = 0; s < skillBarIds.Length; s++) - { - if (skillBarIds[s].Id == id && skillBarIds[s].Id2 == id2) - { - slot = s; - break; - } - } - } - - // Match cooldown entry by UnknownIdAndEquipmentInfo - var canBeUsed = true; - var activeCooldowns = 0; - var cdMaxUses = 0; - if (cooldowns is not null) - { - foreach (var (cd, _) in cooldowns) - { - if (cd.UnknownIdAndEquipmentInfo == details.UnknownIdAndEquipmentInfo) - { - canBeUsed = !cd.CannotBeUsed; - activeCooldowns = cd.TotalActiveCooldowns; - cdMaxUses = cd.MaxUses; - break; - } - } - } - - // Read raw bytes for offset discovery (from true object base) - var rawBytes = mem.ReadBytes(activeSkillPtr - 0x10, 0xC0); - - result.Add(new SkillSnapshot - { - Name = name, - InternalName = name, - Address = activeSkillPtr - 0x10, - RawBytes = rawBytes, - CanBeUsed = canBeUsed, - UseStage = details.UseStage, - CastType = details.CastType, - TotalUses = details.TotalUses, - CooldownTimeMs = details.TotalCooldownTimeInMs, - ActiveCooldowns = activeCooldowns, - MaxUses = cdMaxUses, - Id = id, - Id2 = id2, - SkillBarSlot = slot, - }); - } - - return result; - } - - /// - /// Reads SkillBarIds from PlayerServerData: Buffer13 of (ushort Id, ushort Id2). - /// 13 slots × 4 bytes = 52 bytes total. - /// - private (ushort Id, ushort Id2)[]? ReadSkillBarIds(nint psdPtr) - { - var offset = _ctx.Offsets.SkillBarIdsOffset; - if (offset <= 0 || psdPtr == 0) return null; - - const int slotCount = 13; - const int bufferSize = slotCount * 4; // 52 bytes - var data = _ctx.Memory.ReadBytes(psdPtr + offset, bufferSize); - if (data is null) return null; - - var slots = new (ushort Id, ushort Id2)[slotCount]; - for (var i = 0; i < slotCount; i++) - { - var off = i * 4; - slots[i] = ( - BitConverter.ToUInt16(data, off), - BitConverter.ToUInt16(data, off + 2) - ); - } - return slots; - } - - /// - /// Reads the Cooldowns vector at Actor+0xB18. - /// Each entry is an ActiveSkillCooldown struct (0x48 bytes). - /// Returns tuples of (struct, vectorFirstPtr) so callers can read timer entries. - /// - private List<(ActiveSkillCooldown Cd, nint FirstPtr)>? ReadCooldowns(nint actorComp) - { - var mem = _ctx.Memory; - var cdFirst = mem.ReadPointer(actorComp + ActorOffsets.CooldownsVector); - var cdLast = mem.ReadPointer(actorComp + ActorOffsets.CooldownsVector + 8); - if (cdFirst == 0 || cdLast <= cdFirst) return null; - - var totalBytes = (int)(cdLast - cdFirst); - const int cdEntrySize = 0x48; - var count = totalBytes / cdEntrySize; - if (count <= 0 || count > 64) return null; - - var result = new List<(ActiveSkillCooldown, nint)>(count); - for (var i = 0; i < count; i++) - { - var cd = mem.Read(cdFirst + i * cdEntrySize); - result.Add((cd, cd.CooldownsList.First)); - } - - return result; - } - - /// - /// Resolves skill name via multiple paths: - /// 1. ActiveSkillDetails.ActiveSkillsDatPtr (+0x20) → wchar* (most direct) - /// 2. GEPL FK chain: GrantedEffectsPerLevelDatRow → GEPL+0x00 FK → GE row → GE+0x00 → wchar* - /// 3. GE+0xA8 → ptr → +0x00 → wchar* - /// Results are cached since names don't change per-area. - /// - private string? ResolveSkillName(nint activeSkillPtr, ActiveSkillDetails details) - { - if (_nameCache.TryGetValue(activeSkillPtr, out var cached)) - return cached; - - var mem = _ctx.Memory; - string? name = null; - - // Path 1: ActiveSkillsDatPtr (+0x20) → read wchar* directly from the .dat row - var asDatDirect = details.ActiveSkillsDatPtr; - if (asDatDirect != 0 && ((ulong)asDatDirect >> 32) is > 0 and < 0x7FFF) - name = _strings.ReadNullTermWString(asDatDirect); - - // Path 2: GEPL FK chain - if (name is null) - { - var geplPtr = details.GrantedEffectsPerLevelDatRow; - if (geplPtr != 0) - { - var geFk = mem.ReadPointer(geplPtr); - if (geFk != 0 && ((ulong)geFk >> 32) is > 0 and < 0x7FFF) - { - var geData = mem.ReadBytes(geFk, 0xB0); - if (geData is not null) - { - // GE+0x00 → ActiveSkills.dat row → wchar* - var asDatPtr = (nint)BitConverter.ToInt64(geData, 0x00); - if (asDatPtr != 0 && ((ulong)asDatPtr >> 32) is > 0 and < 0x7FFF) - name = _strings.ReadNullTermWString(asDatPtr); - - // GE+0xA8 → ptr → +0x00 → wchar* - if (name is null && 0xA8 + 8 <= geData.Length) - { - var nameObjPtr = (nint)BitConverter.ToInt64(geData, 0xA8); - if (nameObjPtr != 0 && ((ulong)nameObjPtr >> 32) is > 0 and < 0x7FFF) - { - var namePtr = mem.ReadPointer(nameObjPtr); - if (namePtr != 0) - name = _strings.ReadNullTermWString(namePtr); - } - } - } - } - } - } - - _nameCache[activeSkillPtr] = name; - return name; - } - - /// Clears cached names (call on area change). - public void InvalidateCache() - { - _nameCache.Clear(); - _lastActorComp = 0; - } -} diff --git a/src/Roboto.Memory/Entity.cs b/src/Roboto.Memory/Snapshots/Entity.cs similarity index 100% rename from src/Roboto.Memory/Entity.cs rename to src/Roboto.Memory/Snapshots/Entity.cs diff --git a/src/Roboto.Memory/GameStateSnapshot.cs b/src/Roboto.Memory/Snapshots/GameStateSnapshot.cs similarity index 100% rename from src/Roboto.Memory/GameStateSnapshot.cs rename to src/Roboto.Memory/Snapshots/GameStateSnapshot.cs diff --git a/src/Roboto.Memory/WalkabilityGrid.cs b/src/Roboto.Memory/Snapshots/WalkabilityGrid.cs similarity index 100% rename from src/Roboto.Memory/WalkabilityGrid.cs rename to src/Roboto.Memory/Snapshots/WalkabilityGrid.cs diff --git a/src/Roboto.Memory/TerrainReader.cs b/src/Roboto.Memory/TerrainReader.cs deleted file mode 100644 index 36eac67..0000000 --- a/src/Roboto.Memory/TerrainReader.cs +++ /dev/null @@ -1,170 +0,0 @@ -using Roboto.GameOffsets.States; -using Serilog; -using Terrain = Roboto.GameOffsets.States.Terrain; - -namespace Roboto.Memory; - -/// -/// Reads terrain walkability grid from AreaInstance, with caching and loading edge detection. -/// -public sealed class TerrainReader -{ - private readonly MemoryContext _ctx; - private uint _cachedTerrainAreaHash; - private WalkabilityGrid? _cachedTerrain; - private bool _wasLoading; - - public TerrainReader(MemoryContext ctx) - { - _ctx = ctx; - } - - /// - /// Invalidates the terrain cache (called when LocalPlayer changes on zone change). - /// - public void InvalidateCache() - { - _cachedTerrain = null; - _cachedTerrainAreaHash = 0; - } - - /// - /// Reads terrain data from AreaInstance into the snapshot. - /// Handles both inline and pointer-based terrain layouts. - /// - public void ReadTerrain(GameStateSnapshot snap, nint areaInstance) - { - var mem = _ctx.Memory; - var offsets = _ctx.Offsets; - - if (!offsets.TerrainInline) - { - // Pointer-based: AreaInstance → TerrainList → first terrain → dimensions - var terrainListPtr = mem.ReadPointer(areaInstance + offsets.TerrainListOffset); - if (terrainListPtr == 0) return; - - var terrainPtr = mem.ReadPointer(terrainListPtr); - if (terrainPtr == 0) return; - - var dimsPtr = mem.ReadPointer(terrainPtr + offsets.TerrainDimensionsOffset); - if (dimsPtr == 0) return; - - snap.TerrainCols = mem.Read(dimsPtr); - snap.TerrainRows = mem.Read(dimsPtr + 4); - if (snap.TerrainCols > 0 && snap.TerrainCols < 1000 && - snap.TerrainRows > 0 && snap.TerrainRows < 1000) - { - snap.TerrainWidth = snap.TerrainCols * offsets.SubTilesPerCell; - snap.TerrainHeight = snap.TerrainRows * offsets.SubTilesPerCell; - } - else - { - snap.TerrainCols = 0; - snap.TerrainRows = 0; - } - return; - } - - // Inline mode: TerrainStruct is inline at AreaInstance + TerrainListOffset - // Single Read (0x1B0 = 432 bytes) replaces 5 individual reads - var terrainBase = areaInstance + offsets.TerrainListOffset; - var t = mem.Read(terrainBase); - - var cols = (int)t.Dimensions.X; - var rows = (int)t.Dimensions.Y; - - 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; - } - - // Grid vector pointers already available from the Terrain struct read - var gridBegin = t.WalkableGrid.First; - var gridEnd = t.WalkableGrid.Last; - if (gridBegin == 0 || gridEnd <= gridBegin) - return; - - var gridDataSize = (int)(gridEnd - gridBegin); - if (gridDataSize <= 0 || gridDataSize > 16 * 1024 * 1024) - return; - - var bytesPerRow = t.BytesPerRow; - if (bytesPerRow <= 0 || bytesPerRow > 0x10000) - return; - - var gridWidth = cols * offsets.SubTilesPerCell; - var gridHeight = rows * offsets.SubTilesPerCell; - - var rawData = mem.ReadBytes(gridBegin, gridDataSize); - if (rawData is null) - return; - - // Unpack 4-bit nibbles: each byte → 2 cells - 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); - } - - /// - /// Updates the loading edge detection state. Call after ReadTerrain. - /// - public void UpdateLoadingEdge(bool isLoading) - { - _wasLoading = isLoading; - } - - public static int CalcWalkablePercent(WalkabilityGrid grid) - { - var walkable = 0; - for (var i = 0; i < grid.Data.Length; i++) - if (grid.Data[i] != 0) walkable++; - return grid.Data.Length > 0 ? (int)(100L * walkable / grid.Data.Length) : 0; - } -}