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