441 lines
17 KiB
C#
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<EntityTreeNode> — 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; }
|
|
}
|
|
}
|