poe2-bot/src/Roboto.Memory/EntityReader.cs
2026-03-03 12:54:30 -05:00

441 lines
17 KiB
C#

using Roboto.GameOffsets.Components;
using Roboto.GameOffsets.Entities;
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Reads entity list from AreaInstance's std::map red-black tree.
/// </summary>
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;
}
/// <summary>
/// Reads entity list into the snapshot for continuous display.
/// </summary>
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<Entity>();
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<string>(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<Targetable>(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<Life>(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<int>(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<Transitionable>(trComp);
entity.TransitionState = tr.CurrentStateEnum;
}
}
}
}
entities.Add(entity);
});
if (dirty)
registry.Flush();
snap.Entities = entities;
}
/// <summary>
/// Iterative in-order traversal of an MSVC std::map red-black tree.
/// Backward-compatible overload — delegates to the struct-based version.
/// </summary>
public void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action<nint> visitor)
{
WalkTreeInOrder(sentinel, root, maxNodes, (addr, _) => visitor(addr));
}
/// <summary>
/// Iterative in-order traversal using Read&lt;EntityTreeNode&gt; — 1 kernel call per node
/// instead of separate Left/Right reads. The visitor receives both the node address
/// and the parsed struct data.
/// </summary>
public void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action<nint, EntityTreeNode> 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<nint> { 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<EntityTreeNode>(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;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private void ReadEntityMods(Entity entity, nint modsComp)
{
var mem = _ctx.Memory;
var mods = mem.Read<Mods>(modsComp);
// Read rarity from ObjectMagicProperties
if (mods.ObjectMagicPropertiesPtr != 0 &&
((ulong)mods.ObjectMagicPropertiesPtr >> 32) is > 0 and < 0x7FFF)
{
var props = mem.Read<ObjectMagicProperties>(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<AllModsType>(mods.AllModsPtr);
var explicitCount = (int)allMods.ExplicitMods.TotalElements(16); // ModArrayStruct = 16 bytes
if (explicitCount <= 0 || explicitCount > 20) return;
var modNames = new List<string>();
for (var i = 0; i < explicitCount; i++)
{
var modEntry = mem.Read<ModArrayStruct>(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;
}
/// <summary>
/// Reads entity path string via EntityDetailsPtr → std::wstring.
/// </summary>
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);
}
/// <summary>
/// Tries to read position from an entity by scanning its component list for the Render component.
/// </summary>
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);
}
/// <summary>
/// Returns true for entity types that don't need component reading (effects, terrain, critters).
/// </summary>
private static bool IsLowPriorityPath(EntityType type)
=> type is EntityType.Effect or EntityType.Terrain or EntityType.Critter;
/// <summary>
/// Path-based entity type classification. Mirrors the logic previously in Entity.ClassifyType.
/// </summary>
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;
}
}
/// <summary>
/// Reclassify entity type using component names (called after components are read).
/// Component-based classification is more reliable than path-based.
/// </summary>
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; }
}
}