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