cleanup
This commit is contained in:
parent
8a0e4bb481
commit
0df70abad7
24 changed files with 0 additions and 1225 deletions
|
|
@ -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<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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue