refactoring

This commit is contained in:
Boki 2026-03-02 16:23:23 -05:00
parent fbd0ba445a
commit 18d8721dd5
68 changed files with 2187 additions and 36 deletions

View file

@ -0,0 +1,390 @@
using System.Text;
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Reads entity components via ECS: component list discovery, vitals, position, component lookup.
/// </summary>
public sealed class ComponentReader
{
private readonly MemoryContext _ctx;
private readonly MsvcStringReader _strings;
// Cached component indices — invalidated when LocalPlayer changes
private int _cachedLifeIndex = -1;
private int _cachedRenderIndex = -1;
private nint _lastLocalPlayer;
/// <summary>Last resolved Render component pointer — used for fast per-frame position reads.</summary>
public nint CachedRenderComponentAddr { get; private set; }
/// <summary>Last resolved Life component pointer — used for fast per-frame vitals reads.</summary>
public nint CachedLifeComponentAddr { get; private set; }
public ComponentReader(MemoryContext ctx, MsvcStringReader strings)
{
_ctx = ctx;
_strings = strings;
}
/// <summary>
/// Invalidates cached component indices when LocalPlayer entity changes (zone change, new character).
/// </summary>
public void InvalidateCaches(nint newLocalPlayer)
{
if (newLocalPlayer != _lastLocalPlayer)
{
_cachedLifeIndex = -1;
_cachedRenderIndex = -1;
_lastLocalPlayer = newLocalPlayer;
}
}
public int CachedLifeIndex => _cachedLifeIndex;
public nint LastLocalPlayer => _lastLocalPlayer;
/// <summary>
/// Finds the best component list from the entity, trying multiple strategies:
/// 1. StdVector at entity+ComponentListOffset (ExileCore standard)
/// 2. Inner entity: entity+0x000 → deref → StdVector at +ComponentListOffset (POE2 wrapper)
/// 3. Scan entity memory for any StdVector with many pointer-sized elements
/// </summary>
public (nint First, int Count) FindComponentList(nint entity)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
// Strategy 1: direct StdVector at entity+ComponentListOffset
var compFirst = mem.ReadPointer(entity + offsets.ComponentListOffset);
var compLast = mem.ReadPointer(entity + offsets.ComponentListOffset + 8);
var count = 0;
if (compFirst != 0 && compLast > compFirst && (compLast - compFirst) < 0x2000)
count = (int)((compLast - compFirst) / 8);
if (count > 1) return (compFirst, count);
// Strategy 2: POE2 may wrap entities — entity+0x000 is a pointer to the real entity
var innerEntity = mem.ReadPointer(entity);
if (innerEntity != 0 && innerEntity != entity && !_ctx.IsModuleAddress(innerEntity))
{
var high = (ulong)innerEntity >> 32;
if (high > 0 && high < 0x7FFF && (innerEntity & 0x3) == 0)
{
var innerFirst = mem.ReadPointer(innerEntity + offsets.ComponentListOffset);
var innerLast = mem.ReadPointer(innerEntity + offsets.ComponentListOffset + 8);
if (innerFirst != 0 && innerLast > innerFirst && (innerLast - innerFirst) < 0x2000)
{
var innerCount = (int)((innerLast - innerFirst) / 8);
if (innerCount > count)
{
Log.Debug("ECS: Using inner entity 0x{Addr:X} component list ({Count} entries)",
innerEntity, innerCount);
return (innerFirst, innerCount);
}
}
}
}
// Strategy 3: scan entity memory for StdVector patterns with ≥3 pointer-sized elements
var entityData = mem.ReadBytes(entity, 0x300);
if (entityData is not null)
{
for (var off = 0; off + 24 <= entityData.Length; off += 8)
{
var f = (nint)BitConverter.ToInt64(entityData, off);
var l = (nint)BitConverter.ToInt64(entityData, off + 8);
if (f == 0 || l <= f) continue;
var sz = l - f;
if (sz < 24 || sz > 0x2000 || sz % 8 != 0) continue;
var n = (int)(sz / 8);
if (n <= count) continue;
var firstEl = mem.ReadPointer(f);
var h = (ulong)firstEl >> 32;
if (h == 0 || h >= 0x7FFF || (firstEl & 0x3) != 0) continue;
Log.Debug("ECS: Found StdVector at entity+0x{Off:X} with {Count} elements", off, n);
if (n > count) { compFirst = f; count = n; }
}
}
return (compFirst, count);
}
/// <summary>
/// Reads vitals via ECS: LocalPlayer → ComponentList → Life component.
/// Auto-discovers the Life component index, caches it.
/// </summary>
public void ReadPlayerVitals(GameStateSnapshot snap)
{
var entity = snap.LocalPlayerPtr;
if (entity == 0) return;
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var (compFirst, count) = FindComponentList(entity);
if (count <= 0) return;
// Try cached index first
if (_cachedLifeIndex >= 0 && _cachedLifeIndex < count)
{
var lifeComp = mem.ReadPointer(compFirst + _cachedLifeIndex * 8);
if (lifeComp != 0 && TryReadVitals(snap, lifeComp))
return;
_cachedLifeIndex = -1;
}
// Scan all component pointers for VitalStruct pattern
for (var i = 0; i < count; i++)
{
var compPtr = mem.ReadPointer(compFirst + i * 8);
if (compPtr == 0) continue;
var high = (ulong)compPtr >> 32;
if (high == 0 || high >= 0x7FFF) continue;
if ((compPtr & 0x3) != 0) continue;
var hpTotal = mem.Read<int>(compPtr + offsets.LifeHealthOffset + offsets.VitalTotalOffset);
if (hpTotal < 20 || hpTotal > 200000) continue;
var hpCurrent = mem.Read<int>(compPtr + offsets.LifeHealthOffset + offsets.VitalCurrentOffset);
if (hpCurrent < 0 || hpCurrent > hpTotal + 1000) continue;
var manaTotal = mem.Read<int>(compPtr + offsets.LifeManaOffset + offsets.VitalTotalOffset);
if (manaTotal < 0 || manaTotal > 200000) continue;
var manaCurrent = mem.Read<int>(compPtr + offsets.LifeManaOffset + offsets.VitalCurrentOffset);
if (manaCurrent < 0 || manaCurrent > manaTotal + 1000) continue;
var esTotal = mem.Read<int>(compPtr + offsets.LifeEsOffset + offsets.VitalTotalOffset);
if (manaTotal == 0 && esTotal == 0) continue;
_cachedLifeIndex = i;
Log.Information("ECS: Life component at index {Index} (0x{Addr:X}) — HP: {Hp}/{HpMax}, Mana: {Mana}/{ManaMax}",
i, compPtr, hpCurrent, hpTotal, manaCurrent, manaTotal);
TryReadVitals(snap, compPtr);
return;
}
}
/// <summary>
/// Attempts to read all vitals from a Life component pointer.
/// </summary>
public bool TryReadVitals(GameStateSnapshot snap, nint lifeComp)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var hp = mem.Read<int>(lifeComp + offsets.LifeHealthOffset + offsets.VitalCurrentOffset);
var hpMax = mem.Read<int>(lifeComp + offsets.LifeHealthOffset + offsets.VitalTotalOffset);
var mana = mem.Read<int>(lifeComp + offsets.LifeManaOffset + offsets.VitalCurrentOffset);
var manaMax = mem.Read<int>(lifeComp + offsets.LifeManaOffset + offsets.VitalTotalOffset);
var es = mem.Read<int>(lifeComp + offsets.LifeEsOffset + offsets.VitalCurrentOffset);
var esMax = mem.Read<int>(lifeComp + offsets.LifeEsOffset + offsets.VitalTotalOffset);
if (hpMax <= 0 || hpMax > 200000 || hp < 0 || hp > hpMax + 1000) return false;
if (manaMax < 0 || manaMax > 200000 || mana < 0) return false;
snap.HasVitals = true;
snap.LifeCurrent = hp;
snap.LifeTotal = hpMax;
snap.ManaCurrent = mana;
snap.ManaTotal = manaMax;
snap.EsCurrent = es;
snap.EsTotal = esMax;
CachedLifeComponentAddr = lifeComp;
return true;
}
/// <summary>
/// Reads player position from the Render component via ECS.
/// Auto-discovers the Render component index, caches it.
/// </summary>
public void ReadPlayerPosition(GameStateSnapshot snap)
{
var entity = snap.LocalPlayerPtr;
if (entity == 0) return;
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var (compFirst, count) = FindComponentList(entity);
if (count <= 0) return;
// Try configured index first
if (offsets.RenderComponentIndex >= 0 && offsets.RenderComponentIndex < count)
{
var renderComp = mem.ReadPointer(compFirst + offsets.RenderComponentIndex * 8);
if (renderComp != 0 && TryReadPosition(snap, renderComp))
return;
}
// Try cached index
if (_cachedRenderIndex >= 0 && _cachedRenderIndex < count)
{
var renderComp = mem.ReadPointer(compFirst + _cachedRenderIndex * 8);
if (renderComp != 0 && TryReadPosition(snap, renderComp))
return;
_cachedRenderIndex = -1;
}
// Auto-discover: scan for float triplet that looks like world coordinates
for (var i = 0; i < count; i++)
{
if (i == _cachedLifeIndex) continue;
var compPtr = mem.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 (TryReadPosition(snap, compPtr))
{
_cachedRenderIndex = i;
Log.Information("ECS: Render component at index {Index} (0x{Addr:X}) — Pos: ({X:F1}, {Y:F1}, {Z:F1})",
i, compPtr, snap.PlayerX, snap.PlayerY, snap.PlayerZ);
return;
}
}
}
/// <summary>
/// Attempts to read position from a Render component pointer.
/// </summary>
public bool TryReadPosition(GameStateSnapshot snap, nint renderComp)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var x = mem.Read<float>(renderComp + offsets.PositionXOffset);
var y = mem.Read<float>(renderComp + offsets.PositionYOffset);
var z = mem.Read<float>(renderComp + offsets.PositionZOffset);
if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) return false;
if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) return false;
if (x < 50 || x > 50000 || y < 50 || y > 50000) return false;
if (MathF.Abs(z) > 5000) return false;
snap.HasPosition = true;
snap.PlayerX = x;
snap.PlayerY = y;
snap.PlayerZ = z;
CachedRenderComponentAddr = renderComp;
return true;
}
/// <summary>
/// Reads position floats and validates as world coordinates (for entity position reading).
/// </summary>
public bool TryReadPositionRaw(nint comp, out float x, out float y, out float z)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
x = mem.Read<float>(comp + offsets.PositionXOffset);
y = mem.Read<float>(comp + offsets.PositionYOffset);
z = mem.Read<float>(comp + offsets.PositionZOffset);
if (float.IsNaN(x) || float.IsNaN(y) || float.IsNaN(z)) return false;
if (float.IsInfinity(x) || float.IsInfinity(y) || float.IsInfinity(z)) return false;
if (x < 50 || x > 50000 || y < 50 || y > 50000) return false;
if (MathF.Abs(z) > 5000) return false;
return true;
}
/// <summary>
/// Resolves EntityDetails pointer for an entity, handling ECS inner entity wrapper.
/// </summary>
public nint ResolveEntityDetails(nint entity)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var detailsPtr = mem.ReadPointer(entity + offsets.EntityHeaderOffset);
if (_ctx.IsValidHeapPtr(detailsPtr))
return detailsPtr;
var innerEntity = mem.ReadPointer(entity);
if (innerEntity == 0 || innerEntity == entity || _ctx.IsModuleAddress(innerEntity))
return 0;
if (!_ctx.IsValidHeapPtr(innerEntity))
return 0;
detailsPtr = mem.ReadPointer(innerEntity + offsets.EntityHeaderOffset);
return _ctx.IsValidHeapPtr(detailsPtr) ? detailsPtr : 0;
}
/// <summary>
/// Reads the component name→index mapping for an entity.
/// Chain: entity → EntityDetails(+0x28) → ComponentLookup obj(+0x28/+0x30) → Vec2 entries.
/// </summary>
public Dictionary<string, int>? ReadComponentLookup(nint entity)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
if (offsets.ComponentLookupEntrySize == 0) return null;
var detailsPtr = ResolveEntityDetails(entity);
if (detailsPtr == 0) return null;
var lookupObj = mem.ReadPointer(detailsPtr + offsets.ComponentLookupOffset);
if (!_ctx.IsValidHeapPtr(lookupObj)) return null;
var vec2Begin = mem.ReadPointer(lookupObj + offsets.ComponentLookupVec2Offset);
var vec2End = mem.ReadPointer(lookupObj + offsets.ComponentLookupVec2Offset + 8);
if (vec2Begin == 0 || vec2End <= vec2Begin) return null;
var size = vec2End - vec2Begin;
var entrySize = offsets.ComponentLookupEntrySize;
if (size % entrySize != 0 || size > 0x10000) return null;
var entryCount = (int)(size / entrySize);
var allData = mem.ReadBytes(vec2Begin, (int)size);
if (allData is null) return null;
var result = new Dictionary<string, int>(entryCount);
for (var i = 0; i < entryCount; i++)
{
var entryOff = i * entrySize;
var namePtr = (nint)BitConverter.ToInt64(allData, entryOff + offsets.ComponentLookupNameOffset);
if (namePtr == 0) continue;
var name = _strings.ReadCharPtr(namePtr);
if (name is null) continue;
var index = BitConverter.ToInt32(allData, entryOff + offsets.ComponentLookupIndexOffset);
if (index < 0 || index > 200) continue;
result[name] = index;
}
return result.Count > 0 ? result : null;
}
/// <summary>
/// Checks if an entity has a component by name.
/// </summary>
public bool HasComponent(nint entity, string componentName)
{
var lookup = ReadComponentLookup(entity);
return lookup?.ContainsKey(componentName) == true;
}
/// <summary>
/// Gets the component pointer by name from an entity's component list.
/// </summary>
public nint GetComponentAddress(nint entity, string componentName)
{
var lookup = ReadComponentLookup(entity);
if (lookup is null || !lookup.TryGetValue(componentName, out var index))
return 0;
var (compFirst, count) = FindComponentList(entity);
if (index < 0 || index >= count) return 0;
return _ctx.Memory.ReadPointer(compFirst + index * 8);
}
}

201
src/Roboto.Memory/Entity.cs Normal file
View file

@ -0,0 +1,201 @@
namespace Roboto.Memory;
public enum EntityType
{
Unknown,
Player,
Monster,
Npc,
Effect,
WorldItem,
MiscellaneousObject,
Terrain,
Critter,
Chest,
Shrine,
Portal,
TownPortal,
Waypoint,
AreaTransition,
Door,
}
public enum MonsterRarity
{
White,
Magic,
Rare,
Unique,
}
public class Entity
{
public nint Address { get; }
public uint Id { get; }
public string? Path { get; }
public string? Metadata { get; }
// Position (from Render component)
public bool HasPosition { get; internal set; }
public float X { get; internal set; }
public float Y { get; internal set; }
public float Z { get; internal set; }
// Vitals (from Life component — only populated when explicitly read)
public bool HasVitals { get; internal set; }
public int LifeCurrent { get; internal set; }
public int LifeTotal { get; internal set; }
public int ManaCurrent { get; internal set; }
public int ManaTotal { get; internal set; }
public int EsCurrent { get; internal set; }
public int EsTotal { get; internal set; }
// Component info
public int ComponentCount { get; internal set; }
public HashSet<string>? Components { get; internal set; }
// Component-based properties (populated by GameMemoryReader)
public bool IsTargetable { get; internal set; }
public bool IsOpened { get; internal set; }
public bool IsAvailable { get; internal set; }
public MonsterRarity Rarity { get; internal set; }
// Derived properties
public bool IsAlive => HasVitals && LifeCurrent > 0;
public bool IsDead => HasVitals && LifeCurrent <= 0;
public bool IsHostile => Type == EntityType.Monster;
public bool IsNpc => Type == EntityType.Npc;
public bool IsPlayer => Type == EntityType.Player;
public bool HasComponent(string name) => Components?.Contains(name) == true;
/// <summary>
/// Grid-plane distance to another point (ignores Z).
/// </summary>
public float DistanceTo(float px, float py)
{
var dx = X - px;
var dy = Y - py;
return MathF.Sqrt(dx * dx + dy * dy);
}
/// <summary>
/// Grid-plane distance to another entity.
/// </summary>
public float DistanceTo(Entity other) => DistanceTo(other.X, other.Y);
/// <summary>
/// Short category string derived from path (e.g. "Monsters", "Effects", "NPC").
/// </summary>
public string Category
{
get
{
if (Path is null) return "?";
var parts = Path.Split('/');
return parts.Length >= 2 ? parts[1] : "?";
}
}
public EntityType Type { get; internal set; }
internal Entity(nint address, uint id, string? path)
{
Address = address;
Id = id;
Path = path;
Metadata = ExtractMetadata(path);
Type = ClassifyType(path);
}
/// <summary>
/// Reclassify entity type using component names (called after components are read).
/// Component-based classification is more reliable than path-based.
/// </summary>
internal void ReclassifyFromComponents()
{
if (Components is null || Components.Count == 0) return;
// Priority order matching ExileCore's ParseType logic
if (Components.Contains("Monster")) { Type = EntityType.Monster; return; }
if (Components.Contains("Chest")) { Type = EntityType.Chest; return; }
if (Components.Contains("Shrine")) { Type = EntityType.Shrine; return; }
if (Components.Contains("Waypoint")) { Type = EntityType.Waypoint; return; }
if (Components.Contains("AreaTransition")) { Type = EntityType.AreaTransition; return; }
if (Components.Contains("Portal")) { Type = EntityType.Portal; return; }
if (Components.Contains("TownPortal")) { Type = EntityType.TownPortal; return; }
if (Components.Contains("NPC")) { Type = EntityType.Npc; return; }
if (Components.Contains("Player")) { Type = EntityType.Player; return; }
// Don't override path-based classification for Effects/Terrain/etc.
}
/// <summary>
/// Strips the "@N" instance suffix from the path.
/// "Metadata/Monsters/Wolves/RottenWolf1_@2" → "Metadata/Monsters/Wolves/RottenWolf1_"
/// </summary>
private static string? ExtractMetadata(string? path)
{
if (path is null) return null;
var atIndex = path.LastIndexOf('@');
return atIndex > 0 ? path[..atIndex] : path;
}
private static EntityType ClassifyType(string? path)
{
if (path is null) return EntityType.Unknown;
// Check second path segment: "Metadata/<Category>/..."
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":
// Sub-classify: some "monsters" are actually NPCs or critters
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;
}
}
public override string ToString()
{
var pos = HasPosition ? $"({X:F0},{Y:F0})" : "no pos";
return $"[{Id}] {Type} {Path ?? "?"} {pos}";
}
}

View file

@ -0,0 +1,200 @@
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, node =>
{
var entityPtr = mem.ReadPointer(node + offsets.EntityNodeValueOffset);
if (entityPtr == 0) return;
var high = (ulong)entityPtr >> 32;
if (high == 0 || high >= 0x7FFF || (entityPtr & 0x3) != 0) return;
var entityId = mem.Read<uint>(entityPtr + offsets.EntityIdOffset);
var path = TryReadEntityPath(entityPtr);
var entity = new Entity(entityPtr, entityId, 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
if (hasComponentLookup &&
entity.Type != EntityType.Effect &&
entity.Type != EntityType.Terrain &&
entity.Type != EntityType.Critter)
{
var lookup = _components.ReadComponentLookup(entityPtr);
if (lookup is not null)
{
entity.Components = new HashSet<string>(lookup.Keys);
entity.ReclassifyFromComponents();
if (registry["components"].Register(lookup.Keys))
dirty = true;
// Read HP for monsters to determine alive/dead
if (entity.Type == EntityType.Monster && lookup.TryGetValue("Life", out var lifeIdx))
{
var (compFirst, compCount) = _components.FindComponentList(entityPtr);
if (lifeIdx >= 0 && lifeIdx < compCount)
{
var lifeComp = mem.ReadPointer(compFirst + lifeIdx * 8);
if (lifeComp != 0)
{
var hp = mem.Read<int>(lifeComp + offsets.LifeHealthOffset + offsets.VitalCurrentOffset);
var hpMax = mem.Read<int>(lifeComp + offsets.LifeHealthOffset + offsets.VitalTotalOffset);
if (hpMax > 0 && hpMax < 200000 && hp >= 0 && hp <= hpMax + 1000)
{
entity.HasVitals = true;
entity.LifeCurrent = hp;
entity.LifeTotal = hpMax;
}
}
}
}
}
}
entities.Add(entity);
});
if (dirty)
registry.Flush();
snap.Entities = entities;
}
/// <summary>
/// Iterative in-order traversal of an MSVC std::map red-black tree.
/// </summary>
public void WalkTreeInOrder(nint sentinel, nint root, int maxNodes, Action<nint> visitor)
{
if (root == 0 || root == sentinel) return;
var offsets = _ctx.Offsets;
var mem = _ctx.Memory;
var stack = new Stack<nint>();
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;
}
stack.Push(current);
current = mem.ReadPointer(current + offsets.EntityNodeLeftOffset);
}
if (stack.Count == 0) break;
current = stack.Pop();
visitor(current);
count++;
if (count >= maxNodes) break;
current = mem.ReadPointer(current + offsets.EntityNodeRightOffset);
}
}
/// <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;
}
}

View file

@ -0,0 +1,320 @@
using System.Numerics;
using Serilog;
namespace Roboto.Memory;
public class GameMemoryReader : IDisposable
{
// ExileCore state slot names (index → name)
public static readonly string[] StateNames =
[
"AreaLoading", // 0
"Waiting", // 1
"Credits", // 2
"Escape", // 3
"InGame", // 4
"ChangePassword", // 5
"Login", // 6
"PreGame", // 7
"CreateChar", // 8
"SelectChar", // 9
"DeleteChar", // 10
"Loading", // 11
];
private readonly GameOffsets _offsets;
private readonly ObjectRegistry _registry;
private bool _disposed;
// Sub-readers (created on Attach)
private MemoryContext? _ctx;
private GameStateReader? _stateReader;
private nint _cachedCameraMatrixAddr;
private nint _lastInGameState;
private nint _lastController;
private ComponentReader? _components;
private EntityReader? _entities;
private TerrainReader? _terrain;
private MsvcStringReader? _strings;
private RttiResolver? _rtti;
public ObjectRegistry Registry => _registry;
public MemoryDiagnostics? Diagnostics { get; private set; }
public MemoryContext? Context => _ctx;
public ComponentReader? Components => _components;
public GameStateReader? StateReader => _stateReader;
public GameMemoryReader()
{
_offsets = GameOffsets.Load("offsets.json");
_registry = new ObjectRegistry();
}
public bool IsAttached => _ctx != null;
public bool Attach()
{
Detach();
var memory = ProcessMemory.Attach(_offsets.ProcessName);
if (memory is null)
return false;
_ctx = new MemoryContext(memory, _offsets, _registry);
var module = memory.GetMainModule();
if (module is not null)
{
_ctx.ModuleBase = module.Value.Base;
_ctx.ModuleSize = module.Value.Size;
}
// Try pattern scan first
if (!string.IsNullOrWhiteSpace(_offsets.GameStatePattern))
{
var scanner = new PatternScanner(memory);
_ctx.GameStateBase = scanner.FindPatternRip(_offsets.GameStatePattern);
if (_ctx.GameStateBase != 0)
{
_ctx.GameStateBase += _offsets.PatternResultAdjust;
Log.Information("GameState base (pattern+adjust): 0x{Address:X}", _ctx.GameStateBase);
}
}
// Fallback: manual offset from module base
if (_ctx.GameStateBase == 0 && _offsets.GameStateGlobalOffset > 0)
{
_ctx.GameStateBase = _ctx.ModuleBase + _offsets.GameStateGlobalOffset;
Log.Information("GameState base (manual): 0x{Address:X}", _ctx.GameStateBase);
}
// Create sub-readers
_strings = new MsvcStringReader(_ctx);
_rtti = new RttiResolver(_ctx);
_stateReader = new GameStateReader(_ctx);
_components = new ComponentReader(_ctx, _strings);
_entities = new EntityReader(_ctx, _components, _strings);
_terrain = new TerrainReader(_ctx);
Diagnostics = new MemoryDiagnostics(_ctx, _stateReader, _components, _entities, _strings, _rtti);
return true;
}
public void Detach()
{
_ctx?.Memory.Dispose();
_ctx = null;
_stateReader = null;
_components = null;
_entities = null;
_terrain = null;
_strings = null;
_rtti = null;
Diagnostics = null;
}
public GameStateSnapshot ReadSnapshot()
{
var snap = new GameStateSnapshot();
if (_ctx is null)
{
snap.Error = "Not attached";
return snap;
}
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
snap.Attached = true;
snap.ProcessId = mem.ProcessId;
snap.ModuleBase = _ctx.ModuleBase;
snap.ModuleSize = _ctx.ModuleSize;
snap.OffsetsConfigured = _ctx.GameStateBase != 0;
snap.GameStateBase = _ctx.GameStateBase;
if (_ctx.GameStateBase == 0)
return snap;
// Static area level — direct module offset, always reliable
if (offsets.AreaLevelStaticOffset > 0 && _ctx.ModuleBase != 0)
{
var level = mem.Read<int>(_ctx.ModuleBase + offsets.AreaLevelStaticOffset);
if (level > 0 && level < 200)
snap.AreaLevel = level;
}
try
{
// Resolve InGameState from controller
var inGameState = _stateReader!.ResolveInGameState(snap);
if (inGameState == 0)
return snap;
snap.InGameStatePtr = inGameState;
_lastInGameState = inGameState;
_lastController = snap.ControllerPtr;
// Read all state slot pointers
_stateReader.ReadStateSlots(snap);
// InGameState → AreaInstance
var ingameData = mem.ReadPointer(inGameState + offsets.IngameDataFromStateOffset);
snap.AreaInstancePtr = ingameData;
if (ingameData != 0)
{
// Area level
if (offsets.AreaLevelIsByte)
{
var level = mem.Read<byte>(ingameData + offsets.AreaLevelOffset);
if (level > 0 && level < 200)
snap.AreaLevel = level;
}
else
{
var level = mem.Read<int>(ingameData + offsets.AreaLevelOffset);
if (level > 0 && level < 200)
snap.AreaLevel = level;
}
// Area hash
snap.AreaHash = mem.Read<uint>(ingameData + offsets.AreaHashOffset);
// ServerData pointer
var serverData = mem.ReadPointer(ingameData + offsets.ServerDataOffset);
snap.ServerDataPtr = serverData;
// LocalPlayer — try direct offset first, fallback to ServerData chain
if (offsets.LocalPlayerDirectOffset > 0)
snap.LocalPlayerPtr = mem.ReadPointer(ingameData + offsets.LocalPlayerDirectOffset);
if (snap.LocalPlayerPtr == 0 && serverData != 0)
snap.LocalPlayerPtr = mem.ReadPointer(serverData + offsets.LocalPlayerOffset);
// Entity count and list
var entityCount = (int)mem.Read<long>(ingameData + offsets.EntityListOffset + offsets.EntityCountInternalOffset);
if (entityCount > 0 && entityCount < 50000)
{
snap.EntityCount = entityCount;
_entities!.ReadEntities(snap, ingameData);
}
// Player vitals & position — ECS
if (snap.LocalPlayerPtr != 0)
{
// Invalidate caches if LocalPlayer entity changed (zone change)
if (snap.LocalPlayerPtr != _components!.LastLocalPlayer)
_terrain!.InvalidateCache();
_components.InvalidateCaches(snap.LocalPlayerPtr);
_components.ReadPlayerVitals(snap);
_components.ReadPlayerPosition(snap);
}
// Camera matrix
ReadCameraMatrix(snap, inGameState);
// Loading and escape state
_stateReader.ReadIsLoading(snap);
_stateReader.ReadEscapeState(snap);
// Read state flag bytes
if (snap.InGameStatePtr != 0)
snap.StateFlagBytes = mem.ReadBytes(snap.InGameStatePtr + snap.StateFlagBaseOffset, 0x30);
// Terrain
_terrain!.ReadTerrain(snap, ingameData);
}
}
catch (Exception ex)
{
Log.Debug(ex, "Error reading snapshot");
}
// Update edge detection for next tick
_terrain!.UpdateLoadingEdge(snap.IsLoading);
return snap;
}
private void ReadCameraMatrix(GameStateSnapshot snap, nint inGameState)
{
var mem = _ctx!.Memory;
var offsets = _ctx.Offsets;
if (offsets.CameraMatrixOffset <= 0) return;
// If CameraOffset > 0: follow pointer from InGameState, then read matrix
// If CameraOffset == 0: matrix is inline in InGameState at CameraMatrixOffset
nint matrixAddr;
if (offsets.CameraOffset > 0)
{
var cam = mem.ReadPointer(inGameState + offsets.CameraOffset);
if (cam == 0) return;
matrixAddr = cam + offsets.CameraMatrixOffset;
}
else
{
matrixAddr = inGameState + offsets.CameraMatrixOffset;
}
// Cache the resolved address for fast per-frame reads
_cachedCameraMatrixAddr = matrixAddr;
// Read 64-byte Matrix4x4 as 16 floats
var bytes = mem.ReadBytes(matrixAddr, 64);
if (bytes is null || bytes.Length < 64) return;
var m = new Matrix4x4(
BitConverter.ToSingle(bytes, 0), BitConverter.ToSingle(bytes, 4), BitConverter.ToSingle(bytes, 8), BitConverter.ToSingle(bytes, 12),
BitConverter.ToSingle(bytes, 16), BitConverter.ToSingle(bytes, 20), BitConverter.ToSingle(bytes, 24), BitConverter.ToSingle(bytes, 28),
BitConverter.ToSingle(bytes, 32), BitConverter.ToSingle(bytes, 36), BitConverter.ToSingle(bytes, 40), BitConverter.ToSingle(bytes, 44),
BitConverter.ToSingle(bytes, 48), BitConverter.ToSingle(bytes, 52), BitConverter.ToSingle(bytes, 56), BitConverter.ToSingle(bytes, 60));
// Quick sanity check
if (float.IsNaN(m.M11) || float.IsInfinity(m.M11)) return;
snap.CameraMatrix = m;
}
/// <summary>
/// Resolved addresses for hot-path reads (camera, player position, player vitals, InGameState).
/// </summary>
public readonly struct HotAddresses
{
public readonly nint CameraMatrixAddr;
public readonly nint PlayerRenderAddr;
public readonly nint PlayerLifeAddr;
public readonly nint InGameStateAddr;
public readonly nint ControllerAddr;
public readonly bool IsValid;
public HotAddresses(nint cameraMatrix, nint playerRender, nint playerLife, nint inGameState, nint controller)
{
CameraMatrixAddr = cameraMatrix;
PlayerRenderAddr = playerRender;
PlayerLifeAddr = playerLife;
InGameStateAddr = inGameState;
ControllerAddr = controller;
IsValid = cameraMatrix != 0 || playerRender != 0;
}
}
/// <summary>
/// Returns resolved addresses for the hot path.
/// Call after ReadSnapshot() has populated the cached addresses.
/// </summary>
public HotAddresses ResolveHotAddresses()
{
return new HotAddresses(
_cachedCameraMatrixAddr,
_components?.CachedRenderComponentAddr ?? 0,
_components?.CachedLifeComponentAddr ?? 0,
_lastInGameState,
_lastController);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Detach();
}
}

View file

@ -0,0 +1,192 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Serilog;
namespace Roboto.Memory;
public sealed class GameOffsets
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never
};
public string ProcessName { get; set; } = "PathOfExileSteam";
/// <summary>Pattern to find GameState global. Use ^ to mark the RIP displacement position.</summary>
public string GameStatePattern { get; set; } = "48 83 EC ?? 48 8B F1 33 ED 48 39 2D ^";
/// <summary>Fallback: manual offset from module base to GameState global (hex). Used when pattern is empty or fails.</summary>
public int GameStateGlobalOffset { get; set; }
/// <summary>Bytes to add to pattern scan result to reach the actual global.</summary>
public int PatternResultAdjust { get; set; } = 0x18;
// ── GameState → States ──
/// <summary>Offset to States begin/end pair in GameState (dump: 0x48).</summary>
public int StatesBeginOffset { get; set; } = 0x48;
/// <summary>Bytes per state entry (16 for inline slots, 8 for vector of pointers).</summary>
public int StateStride { get; set; } = 0x10;
/// <summary>Offset within each state entry to the actual state pointer.</summary>
public int StatePointerOffset { get; set; } = 0;
/// <summary>Total number of state slots (ExileCore: 12 states, State0-State11).</summary>
public int StateCount { get; set; } = 12;
/// <summary>Which state index is InGameState (typically 4).</summary>
public int InGameStateIndex { get; set; } = 4;
/// <summary>Offset from controller to active states vector begin/end pair (ExileCore: 0x20).</summary>
public int ActiveStatesOffset { get; set; } = 0x20;
/// <summary>Offset from controller to the active state pointer. When it != InGameState, we're loading. 0 = disabled.</summary>
public int IsLoadingOffset { get; set; } = 0;
/// <summary>If true, states are inline in the controller struct. If false, StatesBeginOffset points to a begin/end vector pair.</summary>
public bool StatesInline { get; set; } = true;
/// <summary>Direct offset from controller to InGameState pointer (bypasses state array). 0 = use state array instead. CE confirmed: 0x210.</summary>
public int InGameStateDirectOffset { get; set; } = 0x210;
// ── InGameState → sub-structures ──
/// <summary>InGameState → EscapeState int32 flag (0=closed, 1=open). Diff-scan confirmed: 0x20C. 0 = disabled.</summary>
public int EscapeStateOffset { get; set; } = 0x20C;
/// <summary>InGameState → AreaInstance (IngameData) pointer (dump: 0x298, CE confirmed: 0x290).</summary>
public int IngameDataFromStateOffset { get; set; } = 0x290;
/// <summary>InGameState → WorldData pointer (dump: 0x2F8).</summary>
public int WorldDataFromStateOffset { get; set; } = 0x2F8;
// ── AreaInstance (IngameData) → sub-structures ──
/// <summary>AreaInstance → CurrentAreaLevel (dump: byte at 0xAC, CE confirmed: byte at 0xC4).</summary>
public int AreaLevelOffset { get; set; } = 0xC4;
/// <summary>If true, AreaLevel is a byte. If false, read as int.</summary>
public bool AreaLevelIsByte { get; set; } = true;
/// <summary>Static offset from module base to cached area level int (CE: exe+3E84B78). 0 = disabled.</summary>
public int AreaLevelStaticOffset { get; set; } = 0;
/// <summary>AreaInstance → CurrentAreaHash uint (dump: 0xEC).</summary>
public int AreaHashOffset { get; set; } = 0xEC;
/// <summary>AreaInstance → ServerData pointer (dump: 0x9F0 via LocalPlayerStruct.ServerDataPtr).</summary>
public int ServerDataOffset { get; set; } = 0x9F0;
/// <summary>AreaInstance → LocalPlayer entity pointer (dump: 0x9F0+0x20 = 0xA10 via LocalPlayerStruct.LocalPlayerPtr).</summary>
public int LocalPlayerDirectOffset { get; set; } = 0xA10;
/// <summary>AreaInstance → EntityListStruct offset (dump: 0xAF8, CE confirmed: 0xB50).</summary>
public int EntityListOffset { get; set; } = 0xB50;
/// <summary>Offset within StdMap to _Mysize (entity count). MSVC std::map: head(8) + size(8).</summary>
public int EntityCountInternalOffset { get; set; } = 0x08;
// ── Entity list node layout (MSVC std::map red-black tree) ──
/// <summary>Tree node → left child pointer.</summary>
public int EntityNodeLeftOffset { get; set; } = 0x00;
/// <summary>Tree node → parent pointer.</summary>
public int EntityNodeParentOffset { get; set; } = 0x08;
/// <summary>Tree node → right child pointer.</summary>
public int EntityNodeRightOffset { get; set; } = 0x10;
/// <summary>Tree node → entity pointer (pair value at +0x28).</summary>
public int EntityNodeValueOffset { get; set; } = 0x28;
/// <summary>Entity → uint ID offset (EntityOffsets: +0x80).</summary>
public int EntityIdOffset { get; set; } = 0x80;
/// <summary>Entity → IsValid byte offset (EntityOffsets: +0x84).</summary>
public int EntityFlagsOffset { get; set; } = 0x84;
/// <summary>Entity → EntityDetailsPtr (Head/MainObject pointer, +0x08).</summary>
public int EntityDetailsOffset { get; set; } = 0x08;
/// <summary>EntityDetails → std::string path (MSVC layout). Offset within EntityDetails struct.</summary>
public int EntityPathStringOffset { get; set; } = 0x08;
// ServerData → fields
/// <summary>ServerData → LocalPlayer entity pointer (fallback if LocalPlayerDirectOffset is 0).</summary>
public int LocalPlayerOffset { get; set; } = 0x20;
// ── Entity / Component ──
public int ComponentListOffset { get; set; } = 0x10;
/// <summary>Entity → ObjectHeader pointer (for alternative component lookup via name→index map). ExileCore: 0x08.</summary>
public int EntityHeaderOffset { get; set; } = 0x08;
/// <summary>EntityDetails → ComponentLookup object pointer. Confirmed: 0x28.</summary>
public int ComponentLookupOffset { get; set; } = 0x28;
/// <summary>ComponentLookup object → Vec2 begin/end (name entry array).</summary>
public int ComponentLookupVec2Offset { get; set; } = 0x28;
/// <summary>Size of each entry in Vec2 (bytes). Confirmed: 16 = { char* name (8), int32 index (4), int32 flags (4) }.</summary>
public int ComponentLookupEntrySize { get; set; } = 16;
/// <summary>Offset to the char* name pointer within each lookup entry.</summary>
public int ComponentLookupNameOffset { get; set; } = 0;
/// <summary>Offset to the component index (int32) within each lookup entry.</summary>
public int ComponentLookupIndexOffset { get; set; } = 8;
/// <summary>Index of Life component in entity's component list. -1 = auto-discover via pattern scan.</summary>
public int LifeComponentIndex { get; set; } = -1;
/// <summary>Index of Render/Position component in entity's component list. -1 = unknown.</summary>
public int RenderComponentIndex { get; set; } = -1;
// ── Life component ──
/// <summary>First offset from AreaInstance to reach Life component (AreaInstance → ptr). 0 = use entity component list instead.</summary>
public int LifeComponentOffset1 { get; set; } = 0x420;
/// <summary>Second offset from intermediate pointer to Life component (ptr → Life).</summary>
public int LifeComponentOffset2 { get; set; } = 0x98;
public int LifeHealthOffset { get; set; } = 0x1A8;
public int LifeManaOffset { get; set; } = 0x1F8;
public int LifeEsOffset { get; set; } = 0x230;
public int VitalCurrentOffset { get; set; } = 0x30;
public int VitalTotalOffset { get; set; } = 0x2C;
// ── Render/Position component ──
public int PositionXOffset { get; set; } = 0x138;
public int PositionYOffset { get; set; } = 0x13C;
public int PositionZOffset { get; set; } = 0x140;
// ── Camera (for WorldToScreen projection) ──
/// <summary>Offset from InGameState to Camera pointer. 0 = disabled (use ScanCamera to discover).</summary>
public int CameraOffset { get; set; } = 0x308;
/// <summary>Offset within Camera struct to the Matrix4x4 (64 bytes). 0 = disabled.</summary>
public int CameraMatrixOffset { get; set; } = 0x1A0;
// ── Terrain (inline in AreaInstance) ──
/// <summary>Offset from AreaInstance to inline TerrainStruct (dump: 0xCC0).</summary>
public int TerrainListOffset { get; set; } = 0xCC0;
/// <summary>If true, terrain is inline in AreaInstance (no pointer dereference). If false, follow pointer.</summary>
public bool TerrainInline { get; set; } = true;
/// <summary>TerrainStruct → TotalTiles offset (scan: 0x90, StdTuple2D of long).</summary>
public int TerrainDimensionsOffset { get; set; } = 0x90;
/// <summary>TerrainStruct → GridWalkableData StdVector offset (scan: 0x148).</summary>
public int TerrainWalkableGridOffset { get; set; } = 0x148;
/// <summary>TerrainStruct → BytesPerRow (scan: 0x1A8).</summary>
public int TerrainBytesPerRowOffset { get; set; } = 0x1A8;
/// <summary>Kept for pointer-based terrain mode (TerrainInline=false).</summary>
public int TerrainGridPtrOffset { get; set; } = 0x08;
public int SubTilesPerCell { get; set; } = 23;
public static GameOffsets Load(string path)
{
if (!File.Exists(path))
{
Log.Information("Offsets file not found at '{Path}', using defaults", path);
var defaults = new GameOffsets();
defaults.Save(path);
return defaults;
}
try
{
var json = File.ReadAllText(path);
var offsets = JsonSerializer.Deserialize<GameOffsets>(json, JsonOptions);
if (offsets is null)
{
Log.Warning("Failed to deserialize '{Path}', using defaults", path);
return new GameOffsets();
}
Log.Information("Loaded offsets from '{Path}'", path);
return offsets;
}
catch (Exception ex)
{
Log.Error(ex, "Error loading offsets from '{Path}'", path);
return new GameOffsets();
}
}
public void Save(string path)
{
try
{
var json = JsonSerializer.Serialize(this, JsonOptions);
File.WriteAllText(path, json);
Log.Debug("Saved offsets to '{Path}'", path);
}
catch (Exception ex)
{
Log.Error(ex, "Error saving offsets to '{Path}'", path);
}
}
}

View file

@ -0,0 +1,203 @@
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Resolves GameState → Controller → InGameState, reads state slots, loading/escape state.
/// </summary>
public sealed class GameStateReader
{
private readonly MemoryContext _ctx;
public GameStateReader(MemoryContext ctx)
{
_ctx = ctx;
}
/// <summary>
/// Resolves InGameState pointer from the GameState controller.
/// </summary>
public nint ResolveInGameState(GameStateSnapshot snap)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var controller = mem.ReadPointer(_ctx.GameStateBase);
if (controller == 0) return 0;
snap.ControllerPtr = controller;
// Direct offset mode: read InGameState straight from controller
if (offsets.InGameStateDirectOffset > 0)
{
var igs = mem.ReadPointer(controller + offsets.InGameStateDirectOffset);
if (igs != 0)
{
for (var i = 0; i < 20; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
var ptr = mem.ReadPointer(controller + slotOffset);
if (ptr == 0) break;
snap.StatesCount++;
}
return igs;
}
}
if (offsets.StatesInline)
{
var inlineOffset = offsets.StatesBeginOffset
+ offsets.InGameStateIndex * offsets.StateStride
+ offsets.StatePointerOffset;
for (var i = 0; i < 20; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
var ptr = mem.ReadPointer(controller + slotOffset);
if (ptr == 0) break;
snap.StatesCount++;
}
return mem.ReadPointer(controller + inlineOffset);
}
else
{
var statesBegin = mem.ReadPointer(controller + offsets.StatesBeginOffset);
if (statesBegin == 0) return 0;
var statesEnd = mem.ReadPointer(controller + offsets.StatesBeginOffset + 8);
if (statesEnd > statesBegin && statesEnd - statesBegin < 0x1000 && offsets.StateStride > 0)
{
snap.StatesCount = (int)((statesEnd - statesBegin) / offsets.StateStride);
}
else
{
for (var i = 0; i < 20; i++)
{
if (mem.ReadPointer(statesBegin + i * offsets.StateStride + offsets.StatePointerOffset) == 0) break;
snap.StatesCount++;
}
}
if (offsets.InGameStateIndex < 0 || offsets.InGameStateIndex >= snap.StatesCount)
return 0;
return mem.ReadPointer(statesBegin + offsets.InGameStateIndex * offsets.StateStride + offsets.StatePointerOffset);
}
}
/// <summary>
/// Reads all state slot pointers and active states vector from the controller.
/// </summary>
public void ReadStateSlots(GameStateSnapshot snap)
{
var controller = snap.ControllerPtr;
if (controller == 0) return;
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var count = offsets.StateCount;
var slots = new nint[count];
for (var i = 0; i < count; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
slots[i] = mem.ReadPointer(controller + slotOffset);
}
snap.StateSlots = slots;
var values = new int[count];
for (var i = 0; i < count; i++)
{
if (slots[i] != 0)
values[i] = mem.Read<int>(slots[i] + 0x08);
}
snap.StateSlotValues = values;
// Read active states vector
if (offsets.ActiveStatesOffset > 0)
{
var beginPtr = mem.ReadPointer(controller + offsets.ActiveStatesOffset);
var endPtr = mem.ReadPointer(controller + offsets.ActiveStatesOffset + 16);
snap.ActiveStatesBegin = beginPtr;
snap.ActiveStatesEnd = endPtr;
if (beginPtr != 0 && endPtr > beginPtr)
{
var size = (int)(endPtr - beginPtr);
if (size is > 0 and < 0x1000)
{
var data = mem.ReadBytes(beginPtr, size);
if (data is not null)
{
var rawList = new List<nint>();
for (var i = 0; i + 8 <= data.Length; i += offsets.StateStride)
{
var ptr = (nint)BitConverter.ToInt64(data, i);
rawList.Add(ptr);
if (ptr != 0)
snap.ActiveStates.Add(ptr);
}
snap.ActiveStatesRaw = rawList.ToArray();
}
}
}
}
// Read all non-null pointer-like qwords from controller (outside state array)
var stateArrayStart = offsets.StatesBeginOffset;
var stateArrayEnd = stateArrayStart + count * offsets.StateStride;
var watches = new List<(int, nint)>();
var ctrlData = mem.ReadBytes(controller, 0x350);
if (ctrlData is not null)
{
for (var offset = 0; offset + 8 <= ctrlData.Length; offset += 8)
{
if (offset >= stateArrayStart && offset < stateArrayEnd) continue;
var value = (nint)BitConverter.ToInt64(ctrlData, offset);
if (value == 0) continue;
var high = (ulong)value >> 32;
if (high > 0 && high < 0x7FFF && (value & 0x3) == 0)
watches.Add((offset, value));
}
}
snap.WatchOffsets = watches.ToArray();
}
/// <summary>
/// Detects loading by comparing the active state pointer to InGameStatePtr.
/// </summary>
public void ReadIsLoading(GameStateSnapshot snap)
{
var controller = snap.ControllerPtr;
if (controller == 0 || _ctx.Offsets.IsLoadingOffset <= 0)
return;
var value = _ctx.Memory.ReadPointer(controller + _ctx.Offsets.IsLoadingOffset);
if (value == snap.InGameStatePtr && snap.InGameStatePtr != 0)
snap.IsLoading = false;
else if (value == 0)
snap.IsLoading = false;
else
snap.IsLoading = true;
}
/// <summary>
/// Reads escape menu state from active states vector or InGameState flag.
/// </summary>
public void ReadEscapeState(GameStateSnapshot snap)
{
if (snap.ActiveStates.Count > 0 && snap.StateSlots.Length > 3 && snap.StateSlots[3] != 0)
{
snap.IsEscapeOpen = snap.ActiveStates.Contains(snap.StateSlots[3]);
return;
}
if (snap.InGameStatePtr == 0 || _ctx.Offsets.EscapeStateOffset <= 0)
return;
var value = _ctx.Memory.Read<int>(snap.InGameStatePtr + _ctx.Offsets.EscapeStateOffset);
snap.IsEscapeOpen = value != 0;
}
}

View file

@ -0,0 +1,68 @@
using System.Numerics;
namespace Roboto.Memory;
public class GameStateSnapshot
{
// Process
public bool Attached;
public int ProcessId;
public nint ModuleBase;
public int ModuleSize;
public string? Error;
// GameState
public nint GameStateBase;
public bool OffsetsConfigured;
public int StatesCount;
// Pointers
public nint ControllerPtr;
public nint InGameStatePtr;
public nint AreaInstancePtr;
public nint ServerDataPtr;
public nint LocalPlayerPtr;
// Area
public int AreaLevel;
public uint AreaHash;
// Player position (Render component)
public bool HasPosition;
public float PlayerX, PlayerY, PlayerZ;
// Player vitals (Life component)
public bool HasVitals;
public int LifeCurrent, LifeTotal;
public int ManaCurrent, ManaTotal;
public int EsCurrent, EsTotal;
// Entities
public int EntityCount;
public List<Entity>? Entities;
// Loading state
public bool IsLoading;
public bool IsEscapeOpen;
// Live state flags — individual bytes from InGameState+0x200 region
public byte[]? StateFlagBytes; // raw bytes from InGameState+0x200, length 0x30
public int StateFlagBaseOffset = 0x200;
// Active game states (ExileCore state machine)
public nint[] StateSlots = Array.Empty<nint>(); // State[0]..State[N] pointer values (all 12 slots)
public int[]? StateSlotValues; // int32 at state+0x08 for each slot
public HashSet<nint> ActiveStates = new(); // which state pointers are in the active list
public nint ActiveStatesBegin, ActiveStatesEnd; // debug: raw vector pointers
public nint[] ActiveStatesRaw = Array.Empty<nint>(); // debug: all pointers in the vector
public (int Offset, nint Value)[] WatchOffsets = []; // candidate controller offsets
// Camera
public Matrix4x4? CameraMatrix;
// Terrain
public int TerrainWidth, TerrainHeight;
public int TerrainCols, TerrainRows;
public WalkabilityGrid? Terrain;
public int TerrainWalkablePercent;
}

View file

@ -0,0 +1,35 @@
namespace Roboto.Memory;
/// <summary>
/// Shared state for all memory reader classes. Holds the process handle, offsets, registry,
/// and resolved module/GameState base addresses.
/// </summary>
public sealed class MemoryContext
{
public ProcessMemory Memory { get; }
public GameOffsets Offsets { get; }
public ObjectRegistry Registry { get; }
public nint ModuleBase { get; set; }
public int ModuleSize { get; set; }
public nint GameStateBase { get; set; }
public MemoryContext(ProcessMemory memory, GameOffsets offsets, ObjectRegistry registry)
{
Memory = memory;
Offsets = offsets;
Registry = registry;
}
public bool IsModuleAddress(nint value)
{
return ModuleBase != 0 && ModuleSize > 0 &&
value >= ModuleBase && value < ModuleBase + ModuleSize;
}
public bool IsValidHeapPtr(nint ptr)
{
if (ptr == 0) return false;
var high = (ulong)ptr >> 32;
return high > 0 && high < 0x7FFF && (ptr & 0x3) == 0;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,110 @@
using System.Text;
namespace Roboto.Memory;
/// <summary>
/// Reads MSVC std::string and std::wstring from process memory.
/// Handles SSO (Small String Optimization) for both narrow and wide strings.
/// </summary>
public sealed class MsvcStringReader
{
private readonly MemoryContext _ctx;
public MsvcStringReader(MemoryContext ctx)
{
_ctx = ctx;
}
/// <summary>
/// Reads an MSVC std::wstring (UTF-16) from the given address.
/// Layout: _Bx (16 bytes: SSO buffer or heap ptr), _Mysize (8), _Myres (8).
/// wchar_t is 2 bytes on Windows. SSO threshold: capacity &lt;= 7.
/// </summary>
public string? ReadMsvcWString(nint stringAddr)
{
var mem = _ctx.Memory;
var size = mem.Read<long>(stringAddr + 0x10);
var capacity = mem.Read<long>(stringAddr + 0x18);
if (size <= 0 || size > 512 || capacity < size) return null;
nint dataAddr;
if (capacity <= 7)
dataAddr = stringAddr; // SSO: inline in _Bx buffer
else
{
dataAddr = mem.ReadPointer(stringAddr);
if (dataAddr == 0) return null;
}
var bytes = mem.ReadBytes(dataAddr, (int)size * 2);
if (bytes is null) return null;
var str = Encoding.Unicode.GetString(bytes);
if (str.Length > 0 && str[0] >= 0x20 && str[0] <= 0x7E)
return str;
return null;
}
/// <summary>
/// Reads an MSVC std::string (narrow, UTF-8/ASCII) from the given address.
/// Layout: _Bx (16 bytes: SSO buffer or heap ptr), _Mysize (8), _Myres (8).
/// SSO threshold: capacity &lt;= 15.
/// </summary>
public string? ReadMsvcString(nint stringAddr)
{
var mem = _ctx.Memory;
var size = mem.Read<long>(stringAddr + 0x10);
var capacity = mem.Read<long>(stringAddr + 0x18);
if (size <= 0 || size > 512 || capacity < size) return null;
nint dataAddr;
if (capacity <= 15)
dataAddr = stringAddr; // SSO: inline in _Bx buffer
else
{
dataAddr = mem.ReadPointer(stringAddr);
if (dataAddr == 0) return null;
}
var bytes = mem.ReadBytes(dataAddr, (int)size);
if (bytes is null) return null;
var str = Encoding.UTF8.GetString(bytes);
if (str.Length > 0 && str[0] >= 0x20 && str[0] <= 0x7E)
return str;
return null;
}
/// <summary>
/// Reads a null-terminated char* string from a module-range or heap address.
/// Component names are char* literals in .rdata, e.g. "Life", "Render", "Monster".
/// </summary>
public string? ReadCharPtr(nint ptr)
{
if (ptr == 0) return null;
var data = _ctx.Memory.ReadBytes(ptr, 64);
if (data is null) return null;
var end = Array.IndexOf(data, (byte)0);
if (end < 1 || end > 50) return null;
var str = Encoding.ASCII.GetString(data, 0, end);
if (str.Length > 0 && str.All(c => c >= 0x20 && c <= 0x7E))
return str;
return null;
}
/// <summary>
/// Reads a null-terminated UTF-8 string (up to 256 bytes).
/// </summary>
public string ReadNullTermString(nint addr)
{
var data = _ctx.Memory.ReadBytes(addr, 256);
if (data is null) return "Error: read failed";
var end = Array.IndexOf(data, (byte)0);
if (end < 0) end = data.Length;
return Encoding.UTF8.GetString(data, 0, end);
}
}

View file

@ -0,0 +1,39 @@
using System.Runtime.InteropServices;
namespace Roboto.Memory;
internal static partial class Native
{
public const uint PROCESS_VM_READ = 0x0010;
public const uint PROCESS_QUERY_INFORMATION = 0x0400;
public const uint LIST_MODULES_ALL = 0x03;
[StructLayout(LayoutKind.Sequential)]
public struct MODULEINFO
{
public nint lpBaseOfDll;
public int SizeOfImage;
public nint EntryPoint;
}
// kernel32.dll
[LibraryImport("kernel32.dll", SetLastError = true)]
public static partial nint OpenProcess(uint dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, int dwProcessId);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool CloseHandle(nint hObject);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool ReadProcessMemory(nint hProcess, nint lpBaseAddress, nint lpBuffer, nint nSize, out nint lpNumberOfBytesRead);
// psapi.dll
[LibraryImport("psapi.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool EnumProcessModulesEx(nint hProcess, nint[] lphModule, int cb, out int lpcbNeeded, uint dwFilterFlag);
[LibraryImport("psapi.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GetModuleInformation(nint hProcess, nint hModule, out MODULEINFO lpmodinfo, int cb);
}

View file

@ -0,0 +1,134 @@
using System.Text.Json;
using Serilog;
namespace Roboto.Memory;
/// <summary>
/// Persistent registry of discovered strings, organized by category.
/// Saves each category to its own JSON file (e.g. components.json, entities.json).
/// Loads on startup, saves whenever new entries are found.
/// </summary>
public sealed class ObjectRegistry
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private readonly Dictionary<string, Category> _categories = [];
/// <summary>
/// Get or create a category. Each category persists to its own JSON file.
/// </summary>
public Category this[string name]
{
get
{
if (!_categories.TryGetValue(name, out var cat))
{
cat = new Category(name);
_categories[name] = cat;
}
return cat;
}
}
/// <summary>
/// Flush all dirty categories to disk.
/// </summary>
public void Flush()
{
foreach (var cat in _categories.Values)
cat.Flush();
}
public sealed class Category
{
private readonly string _path;
private readonly HashSet<string> _known = [];
private bool _dirty;
public IReadOnlySet<string> Known => _known;
public int Count => _known.Count;
internal Category(string name)
{
_path = $"{name}.json";
Load();
}
/// <summary>
/// Register a single entry. Returns true if it was new.
/// </summary>
public bool Register(string? value)
{
if (string.IsNullOrEmpty(value)) return false;
if (_known.Add(value))
{
_dirty = true;
return true;
}
return false;
}
/// <summary>
/// Register multiple entries. Returns true if any were new.
/// </summary>
public bool Register(IEnumerable<string> values)
{
var added = false;
foreach (var value in values)
{
if (!string.IsNullOrEmpty(value) && _known.Add(value))
{
added = true;
_dirty = true;
}
}
return added;
}
/// <summary>
/// Save to disk if there are unsaved changes.
/// </summary>
public void Flush()
{
if (!_dirty) return;
Save();
_dirty = false;
}
private void Load()
{
if (!File.Exists(_path)) return;
try
{
var json = File.ReadAllText(_path);
var list = JsonSerializer.Deserialize<List<string>>(json);
if (list is not null)
{
foreach (var name in list)
_known.Add(name);
Log.Information("Loaded {Count} entries from '{Path}'", _known.Count, _path);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error loading from '{Path}'", _path);
}
}
private void Save()
{
try
{
var sorted = _known.OrderBy(n => n, StringComparer.Ordinal).ToList();
var json = JsonSerializer.Serialize(sorted, JsonOptions);
File.WriteAllText(_path, json);
Log.Debug("Saved {Count} entries to '{Path}'", sorted.Count, _path);
}
catch (Exception ex)
{
Log.Error(ex, "Error saving to '{Path}'", _path);
}
}
}
}

View file

@ -0,0 +1,144 @@
using Serilog;
namespace Roboto.Memory;
public sealed class PatternScanner
{
private readonly ProcessMemory _memory;
private byte[]? _imageCache;
private nint _moduleBase;
private int _moduleSize;
public PatternScanner(ProcessMemory memory)
{
_memory = memory;
}
/// <summary>
/// Finds a pattern in the main module and returns the absolute address at the ^ marker position.
/// Pattern format: "48 8B ?? ?? ?? ?? ?? 4C ^ 8B 05" where ?? = wildcard, ^ = result offset.
/// </summary>
public nint FindPattern(string pattern)
{
EnsureImageCached();
if (_imageCache is null)
return 0;
var (bytes, mask, resultOffset) = Parse(pattern);
var matchIndex = Scan(_imageCache, bytes, mask);
if (matchIndex < 0)
{
Log.Warning("Pattern not found: {Pattern}", pattern);
return 0;
}
var absolute = _moduleBase + matchIndex + resultOffset;
Log.Debug("Pattern matched at 0x{Address:X} (module+0x{Offset:X})", absolute, matchIndex + resultOffset);
return absolute;
}
/// <summary>
/// FindPattern + RIP-relative resolution: reads int32 displacement at matched address and resolves to absolute address.
/// Result = matchAddr + 4 + displacement
/// </summary>
public nint FindPatternRip(string pattern)
{
var addr = FindPattern(pattern);
if (addr == 0) return 0;
EnsureImageCached();
if (_imageCache is null) return 0;
var bufferOffset = (int)(addr - _moduleBase);
if (bufferOffset + 4 > _imageCache.Length)
{
Log.Warning("RIP resolution out of bounds at 0x{Address:X}", addr);
return 0;
}
var displacement = BitConverter.ToInt32(_imageCache, bufferOffset);
var resolved = addr + 4 + displacement;
Log.Debug("RIP resolved: 0x{Address:X} + 4 + {Disp} = 0x{Result:X}", addr, displacement, resolved);
return resolved;
}
private void EnsureImageCached()
{
if (_imageCache is not null)
return;
var module = _memory.GetMainModule();
if (module is null)
{
Log.Error("Failed to get main module for pattern scanning");
return;
}
(_moduleBase, _moduleSize) = module.Value;
_imageCache = _memory.ReadBytes(_moduleBase, _moduleSize);
if (_imageCache is null)
Log.Error("Failed to read main module image ({Size} bytes)", _moduleSize);
else
Log.Information("Cached module image: base=0x{Base:X}, size={Size}", _moduleBase, _moduleSize);
}
private static int Scan(byte[] image, byte[] pattern, bool[] mask)
{
var end = image.Length - pattern.Length;
for (var i = 0; i <= end; i++)
{
var match = true;
for (var j = 0; j < pattern.Length; j++)
{
if (mask[j] && image[i + j] != pattern[j])
{
match = false;
break;
}
}
if (match) return i;
}
return -1;
}
/// <summary>
/// Parses a pattern string into bytes, mask, and result offset.
/// Tokens: hex byte (e.g. "4C") = must-match, "??" = wildcard, "^" = result offset marker.
/// </summary>
internal static (byte[] Bytes, bool[] Mask, int ResultOffset) Parse(string pattern)
{
var tokens = pattern.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var bytes = new List<byte>();
var mask = new List<bool>();
var resultOffset = 0;
var markerFound = false;
foreach (var token in tokens)
{
if (token == "^")
{
markerFound = true;
resultOffset = bytes.Count;
continue;
}
if (token == "??")
{
bytes.Add(0);
mask.Add(false);
}
else
{
bytes.Add(Convert.ToByte(token, 16));
mask.Add(true);
}
}
if (!markerFound)
resultOffset = 0;
return (bytes.ToArray(), mask.ToArray(), resultOffset);
}
}

View file

@ -0,0 +1,129 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
using Serilog;
namespace Roboto.Memory;
public sealed class ProcessMemory : IDisposable
{
private nint _handle;
private bool _disposed;
public string ProcessName { get; }
public int ProcessId { get; private set; }
private ProcessMemory(string processName, nint handle, int processId)
{
ProcessName = processName;
_handle = handle;
ProcessId = processId;
}
public static ProcessMemory? Attach(string processName)
{
var procs = Process.GetProcessesByName(processName);
if (procs.Length == 0)
{
Log.Warning("Process '{Name}' not found", processName);
return null;
}
var proc = procs[0];
var handle = Native.OpenProcess(
Native.PROCESS_VM_READ | Native.PROCESS_QUERY_INFORMATION,
false,
proc.Id);
if (handle == 0)
{
Log.Error("Failed to open process '{Name}' (PID {Pid})", processName, proc.Id);
return null;
}
Log.Information("Attached to '{Name}' (PID {Pid})", processName, proc.Id);
return new ProcessMemory(processName, handle, proc.Id);
}
public bool ReadBytes(nint address, Span<byte> buffer)
{
unsafe
{
fixed (byte* ptr = buffer)
{
return Native.ReadProcessMemory(_handle, address, (nint)ptr, buffer.Length, out _);
}
}
}
public T Read<T>(nint address) where T : unmanaged
{
Span<byte> buf = stackalloc byte[Unsafe.SizeOf<T>()];
if (!ReadBytes(address, buf))
return default;
return Unsafe.ReadUnaligned<T>(ref buf[0]);
}
public nint ReadPointer(nint address) => Read<nint>(address);
public byte[]? ReadBytes(nint address, int length)
{
var buffer = new byte[length];
if (!ReadBytes(address, buffer.AsSpan()))
return null;
return buffer;
}
public (nint Base, int Size)? GetMainModule()
{
var modules = new nint[1];
if (!Native.EnumProcessModulesEx(_handle, modules, nint.Size, out _, Native.LIST_MODULES_ALL))
{
Log.Error("EnumProcessModulesEx failed");
return null;
}
if (!Native.GetModuleInformation(_handle, modules[0], out var info, Unsafe.SizeOf<Native.MODULEINFO>()))
{
Log.Error("GetModuleInformation failed");
return null;
}
return (info.lpBaseOfDll, info.SizeOfImage);
}
/// <summary>
/// Follows a pointer chain. Dereferences all offsets except the last one (which is added).
/// Example: FollowChain(base, [136, 536, 2768]) reads ptr at base+136, reads ptr at result+536, returns result+2768.
/// </summary>
public nint FollowChain(nint baseAddr, ReadOnlySpan<int> offsets)
{
if (offsets.Length == 0)
return baseAddr;
var current = baseAddr;
for (var i = 0; i < offsets.Length - 1; i++)
{
current = ReadPointer(current + offsets[i]);
if (current == 0)
{
Log.Debug("Pointer chain broken at offset index {Index} (offset 0x{Offset:X})", i, offsets[i]);
return 0;
}
}
return current + offsets[^1];
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_handle != 0)
{
Native.CloseHandle(_handle);
_handle = 0;
Log.Debug("Detached from '{Name}'", ProcessName);
}
}
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Drawing.Common" Version="8.0.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Automata.Core\Automata.Core.csproj" />
<ProjectReference Include="..\Roboto.GameOffsets\Roboto.GameOffsets.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,81 @@
using System.Text;
namespace Roboto.Memory;
/// <summary>
/// Resolves MSVC x64 RTTI type names from vtable addresses and classifies pointers.
/// </summary>
public sealed class RttiResolver
{
private readonly MemoryContext _ctx;
public RttiResolver(MemoryContext ctx)
{
_ctx = ctx;
}
/// <summary>
/// Resolves a vtable address to its RTTI class name using MSVC x64 RTTI layout.
/// vtable[-1] → RTTICompleteObjectLocator → TypeDescriptor → mangled name
/// </summary>
public string? ResolveRttiName(nint vtableAddr)
{
var mem = _ctx.Memory;
if (mem is null || _ctx.ModuleBase == 0) return null;
try
{
var colPtr = mem.ReadPointer(vtableAddr - 8);
if (colPtr == 0) return null;
var signature = mem.Read<int>(colPtr);
if (signature != 1) return null;
var typeDescOffset = mem.Read<int>(colPtr + 0x0C);
if (typeDescOffset <= 0) return null;
var typeDesc = _ctx.ModuleBase + typeDescOffset;
var nameBytes = mem.ReadBytes(typeDesc + 0x10, 128);
if (nameBytes is null) return null;
var end = Array.IndexOf(nameBytes, (byte)0);
if (end <= 0) return null;
var mangled = Encoding.ASCII.GetString(nameBytes, 0, end);
if (mangled.StartsWith(".?AV") && mangled.EndsWith("@@"))
return mangled[4..^2];
if (mangled.StartsWith(".?AU") && mangled.EndsWith("@@"))
return mangled[4..^2];
return mangled;
}
catch
{
return null;
}
}
/// <summary>
/// Classifies a pointer value: returns a tag string ("module (vtable?)", "heap ptr", RTTI name) or null.
/// </summary>
public string? ClassifyPointer(nint value)
{
if (value == 0) return null;
if (_ctx.IsModuleAddress(value))
{
var name = ResolveRttiName(value);
return name ?? "module (vtable?)";
}
if (value > 0x10000 && value < (nint)0x7FFFFFFFFFFF && (value & 0x3) == 0)
{
var high = (ulong)value >> 32;
if (high > 0 && high < 0x7FFF)
return "heap ptr";
}
return null;
}
}

View file

@ -0,0 +1,166 @@
using Serilog;
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
var terrainBase = areaInstance + offsets.TerrainListOffset;
var cols = (int)mem.Read<long>(terrainBase + offsets.TerrainDimensionsOffset);
var rows = (int)mem.Read<long>(terrainBase + offsets.TerrainDimensionsOffset + 8);
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;
}
// Read GridWalkableData StdVector (begin/end/cap pointers)
var gridVecOffset = offsets.TerrainWalkableGridOffset;
var gridBegin = mem.ReadPointer(terrainBase + gridVecOffset);
var gridEnd = mem.ReadPointer(terrainBase + gridVecOffset + 8);
if (gridBegin == 0 || gridEnd <= gridBegin)
return;
var gridDataSize = (int)(gridEnd - gridBegin);
if (gridDataSize <= 0 || gridDataSize > 16 * 1024 * 1024)
return;
var bytesPerRow = mem.Read<int>(terrainBase + offsets.TerrainBytesPerRowOffset);
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;
}
}

View file

@ -0,0 +1,22 @@
namespace Roboto.Memory;
public sealed class WalkabilityGrid
{
public int Width { get; }
public int Height { get; }
public byte[] Data { get; }
public WalkabilityGrid(int width, int height, byte[] data)
{
Width = width;
Height = height;
Data = data;
}
public bool IsWalkable(int x, int y)
{
if (x < 0 || x >= Width || y < 0 || y >= Height)
return false;
return Data[y * Width + x] != 0;
}
}