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;
- }
-}