refactoring
This commit is contained in:
parent
fbd0ba445a
commit
18d8721dd5
68 changed files with 2187 additions and 36 deletions
390
src/Roboto.Memory/ComponentReader.cs
Normal file
390
src/Roboto.Memory/ComponentReader.cs
Normal 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
201
src/Roboto.Memory/Entity.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
200
src/Roboto.Memory/EntityReader.cs
Normal file
200
src/Roboto.Memory/EntityReader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
320
src/Roboto.Memory/GameMemoryReader.cs
Normal file
320
src/Roboto.Memory/GameMemoryReader.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
192
src/Roboto.Memory/GameOffsets.cs
Normal file
192
src/Roboto.Memory/GameOffsets.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
203
src/Roboto.Memory/GameStateReader.cs
Normal file
203
src/Roboto.Memory/GameStateReader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
68
src/Roboto.Memory/GameStateSnapshot.cs
Normal file
68
src/Roboto.Memory/GameStateSnapshot.cs
Normal 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;
|
||||
}
|
||||
35
src/Roboto.Memory/MemoryContext.cs
Normal file
35
src/Roboto.Memory/MemoryContext.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2625
src/Roboto.Memory/MemoryDiagnostics.cs
Normal file
2625
src/Roboto.Memory/MemoryDiagnostics.cs
Normal file
File diff suppressed because it is too large
Load diff
110
src/Roboto.Memory/MsvcStringReader.cs
Normal file
110
src/Roboto.Memory/MsvcStringReader.cs
Normal 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 <= 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 <= 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);
|
||||
}
|
||||
}
|
||||
39
src/Roboto.Memory/Native.cs
Normal file
39
src/Roboto.Memory/Native.cs
Normal 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);
|
||||
}
|
||||
134
src/Roboto.Memory/ObjectRegistry.cs
Normal file
134
src/Roboto.Memory/ObjectRegistry.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
144
src/Roboto.Memory/PatternScanner.cs
Normal file
144
src/Roboto.Memory/PatternScanner.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
129
src/Roboto.Memory/ProcessMemory.cs
Normal file
129
src/Roboto.Memory/ProcessMemory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/Roboto.Memory/Roboto.Memory.csproj
Normal file
15
src/Roboto.Memory/Roboto.Memory.csproj
Normal 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>
|
||||
81
src/Roboto.Memory/RttiResolver.cs
Normal file
81
src/Roboto.Memory/RttiResolver.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
166
src/Roboto.Memory/TerrainReader.cs
Normal file
166
src/Roboto.Memory/TerrainReader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
22
src/Roboto.Memory/WalkabilityGrid.cs
Normal file
22
src/Roboto.Memory/WalkabilityGrid.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue