This commit is contained in:
Boki 2026-03-04 16:49:23 -05:00
parent 8a0e4bb481
commit 0df70abad7
24 changed files with 0 additions and 1225 deletions

View file

@ -1,441 +0,0 @@
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; }
}
}

View file

@ -1,320 +0,0 @@
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Lightweight quest data from ServerData quest flags.
/// Stored in GameStateSnapshot; mapped to Roboto.Core.QuestProgress in the Data layer.
/// </summary>
public sealed class QuestSnapshot
{
/// <summary>QuestState.dat row index (int_vector mode) or 0 (pointer mode).</summary>
public int QuestStateIndex { get; init; }
public nint QuestDatPtr { get; init; }
public string? QuestName { get; init; }
/// <summary>Internal quest ID from dat row (e.g. "TreeOfSouls2", "IncursionQuest1_Act1").</summary>
public string? InternalId { get; init; }
/// <summary>Encounter state from quest state object: 1=locked/not encountered, 2=available/started.</summary>
public byte StateId { get; init; }
/// <summary>True if this quest is the currently tracked/active quest in the UI.</summary>
public bool IsTracked { get; init; }
public string? StateText { get; init; }
public string? ProgressText { get; init; }
}
/// <summary>
/// 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.
/// </summary>
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<nint, string?> _nameCache = new();
private nint _lastPsd;
public QuestReader(MemoryContext ctx, MsvcStringReader strings, QuestNameLookup? nameLookup = null)
{
_ctx = ctx;
_strings = strings;
_nameLookup = nameLookup;
}
/// <summary>
/// Reads quest flags from the ServerData pointer chain.
/// Returns null if offsets are not configured (EntrySize == 0) or data is unavailable.
/// </summary>
public List<QuestSnapshot>? 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;
}
/// <summary>
/// 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.
/// </summary>
private List<QuestSnapshot>? 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<QuestSnapshot>(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;
}
/// <summary>
/// Finds the QuestStates.dat row table base address.
/// Uses QuestDatTableBase offset from PSD if configured, otherwise returns 0.
/// </summary>
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;
}
/// <summary>Reads a wchar* pointer at the given address and returns the string.</summary>
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;
}
/// <summary>
/// Legacy/POE1 mode: reads struct entries with dat row pointers and string fields.
/// </summary>
private List<QuestSnapshot>? 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<QuestSnapshot>(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;
}
/// <summary>Clears cached names (call on area change).</summary>
public void InvalidateCache()
{
_nameCache.Clear();
_lastPsd = 0;
}
}

View file

@ -1,294 +0,0 @@
using Roboto.GameOffsets.Components;
namespace Roboto.Memory;
/// <summary>
/// Lightweight skill data from the Actor component's ActiveSkills vector.
/// Stored in GameStateSnapshot; mapped to Roboto.Core.SkillState in the Data layer.
/// </summary>
public sealed class SkillSnapshot
{
public string? Name { get; init; }
public string? InternalName { get; init; }
/// <summary>Address of ActiveSkillPtr in game memory (for CE inspection).</summary>
public nint Address { get; init; }
/// <summary>Raw bytes at ActiveSkillPtr for offset discovery.</summary>
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; }
/// <summary>From Cooldowns vector — number of active cooldown entries.</summary>
public int ActiveCooldowns { get; init; }
/// <summary>From Cooldowns vector — max uses (charges) for the skill.</summary>
public int MaxUses { get; init; }
/// <summary>Low 16 bits of UnknownIdAndEquipmentInfo — skill ID used for SkillBarIds matching.</summary>
public ushort Id { get; init; }
/// <summary>High 16 bits of UnknownIdAndEquipmentInfo — equipment slot / secondary ID.</summary>
public ushort Id2 { get; init; }
/// <summary>Skill bar slot index (0-12) from SkillBarIds, or -1 if not on the skill bar.</summary>
public int SkillBarSlot { get; init; } = -1;
}
/// <summary>
/// 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.
/// </summary>
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<nint, string?> _nameCache = new();
private nint _lastActorComp;
public SkillReader(MemoryContext ctx, ComponentReader components, MsvcStringReader strings)
{
_ctx = ctx;
_components = components;
_strings = strings;
}
public List<SkillSnapshot>? 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<SkillSnapshot>();
var seen = new HashSet<uint>(); // 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<ActiveSkillDetails>(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;
}
/// <summary>
/// Reads SkillBarIds from PlayerServerData: Buffer13 of (ushort Id, ushort Id2).
/// 13 slots × 4 bytes = 52 bytes total.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<ActiveSkillCooldown>(cdFirst + i * cdEntrySize);
result.Add((cd, cd.CooldownsList.First));
}
return result;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Clears cached names (call on area change).</summary>
public void InvalidateCache()
{
_nameCache.Clear();
_lastActorComp = 0;
}
}

View file

@ -1,170 +0,0 @@
using Roboto.GameOffsets.States;
using Serilog;
using Terrain = Roboto.GameOffsets.States.Terrain;
namespace Roboto.Memory;
/// <summary>
/// Reads terrain walkability grid from AreaInstance, with caching and loading edge detection.
/// </summary>
public sealed class TerrainReader
{
private readonly MemoryContext _ctx;
private uint _cachedTerrainAreaHash;
private WalkabilityGrid? _cachedTerrain;
private bool _wasLoading;
public TerrainReader(MemoryContext ctx)
{
_ctx = ctx;
}
/// <summary>
/// Invalidates the terrain cache (called when LocalPlayer changes on zone change).
/// </summary>
public void InvalidateCache()
{
_cachedTerrain = null;
_cachedTerrainAreaHash = 0;
}
/// <summary>
/// Reads terrain data from AreaInstance into the snapshot.
/// Handles both inline and pointer-based terrain layouts.
/// </summary>
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<int>(dimsPtr);
snap.TerrainRows = mem.Read<int>(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<Terrain> (0x1B0 = 432 bytes) replaces 5 individual reads
var terrainBase = areaInstance + offsets.TerrainListOffset;
var t = mem.Read<Terrain>(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);
}
/// <summary>
/// Updates the loading edge detection state. Call after ReadTerrain.
/// </summary>
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;
}
}