cleanup
This commit is contained in:
parent
8a0e4bb481
commit
0df70abad7
24 changed files with 0 additions and 1225 deletions
399
src/Roboto.Memory/Infrastructure/ComponentReader.cs
Normal file
399
src/Roboto.Memory/Infrastructure/ComponentReader.cs
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
using System.Text;
|
||||
using Roboto.GameOffsets.Components;
|
||||
using Roboto.GameOffsets.Natives;
|
||||
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 life = mem.Read<Life>(compPtr);
|
||||
|
||||
var hpTotal = life.Health.Total;
|
||||
if (hpTotal < 20 || hpTotal > 200000) continue;
|
||||
|
||||
var hpCurrent = life.Health.Current;
|
||||
if (hpCurrent < 0 || hpCurrent > hpTotal + 1000) continue;
|
||||
|
||||
var manaTotal = life.Mana.Total;
|
||||
if (manaTotal < 0 || manaTotal > 200000) continue;
|
||||
|
||||
var manaCurrent = life.Mana.Current;
|
||||
if (manaCurrent < 0 || manaCurrent > manaTotal + 1000) continue;
|
||||
|
||||
var esTotal = life.EnergyShield.Total;
|
||||
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 life = _ctx.Memory.Read<Life>(lifeComp);
|
||||
|
||||
var hp = life.Health.Current;
|
||||
var hpMax = life.Health.Total;
|
||||
var mana = life.Mana.Current;
|
||||
var manaMax = life.Mana.Total;
|
||||
var es = life.EnergyShield.Current;
|
||||
var esMax = life.EnergyShield.Total;
|
||||
|
||||
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 pos = _ctx.Memory.Read<StdTuple3D<float>>(renderComp + _ctx.Offsets.PositionXOffset);
|
||||
|
||||
if (float.IsNaN(pos.X) || float.IsNaN(pos.Y) || float.IsNaN(pos.Z)) return false;
|
||||
if (float.IsInfinity(pos.X) || float.IsInfinity(pos.Y) || float.IsInfinity(pos.Z)) return false;
|
||||
if (pos.X < 50 || pos.X > 50000 || pos.Y < 50 || pos.Y > 50000) return false;
|
||||
if (MathF.Abs(pos.Z) > 5000) return false;
|
||||
|
||||
snap.HasPosition = true;
|
||||
snap.PlayerX = pos.X;
|
||||
snap.PlayerY = pos.Y;
|
||||
snap.PlayerZ = pos.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 pos = _ctx.Memory.Read<StdTuple3D<float>>(comp + _ctx.Offsets.PositionXOffset);
|
||||
x = pos.X;
|
||||
y = pos.Y;
|
||||
z = pos.Z;
|
||||
|
||||
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>
|
||||
/// Reads the player character name from the Player component.
|
||||
/// </summary>
|
||||
public string? ReadPlayerName(nint localPlayerEntity)
|
||||
{
|
||||
if (localPlayerEntity == 0) return null;
|
||||
|
||||
var playerComp = GetComponentAddress(localPlayerEntity, "Player");
|
||||
if (playerComp == 0) return null;
|
||||
|
||||
return _strings.ReadMsvcWString(playerComp + 0x1B0);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
35
src/Roboto.Memory/Infrastructure/MemoryContext.cs
Normal file
35
src/Roboto.Memory/Infrastructure/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;
|
||||
}
|
||||
}
|
||||
142
src/Roboto.Memory/Infrastructure/MsvcStringReader.cs
Normal file
142
src/Roboto.Memory/Infrastructure/MsvcStringReader.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
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 wchar_t* (UTF-16) string, e.g. skill names.
|
||||
/// Validates that all characters are printable (0x20-0x7E ASCII range).
|
||||
/// </summary>
|
||||
public string? ReadNullTermWString(nint ptr)
|
||||
{
|
||||
if (ptr == 0) return null;
|
||||
var data = _ctx.Memory.ReadBytes(ptr, 256);
|
||||
if (data is null) return null;
|
||||
|
||||
int byteLen = -1;
|
||||
for (int i = 0; i + 1 < data.Length; i += 2)
|
||||
{
|
||||
if (data[i] == 0 && data[i + 1] == 0)
|
||||
{
|
||||
byteLen = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (byteLen <= 0) return null;
|
||||
|
||||
var str = Encoding.Unicode.GetString(data, 0, byteLen);
|
||||
|
||||
// Validate: all chars must be printable ASCII (skill/item names are ASCII in POE2)
|
||||
if (str.Length == 0) return null;
|
||||
foreach (var c in str)
|
||||
{
|
||||
if (c < 0x20 || c > 0x7E) return null;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/// <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/Infrastructure/Native.cs
Normal file
39
src/Roboto.Memory/Infrastructure/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/Infrastructure/ObjectRegistry.cs
Normal file
134
src/Roboto.Memory/Infrastructure/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/Infrastructure/PatternScanner.cs
Normal file
144
src/Roboto.Memory/Infrastructure/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/Infrastructure/ProcessMemory.cs
Normal file
129
src/Roboto.Memory/Infrastructure/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);
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/Roboto.Memory/Infrastructure/RttiResolver.cs
Normal file
81
src/Roboto.Memory/Infrastructure/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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue