This commit is contained in:
Boki 2026-03-04 16:49:23 -05:00
parent 8a0e4bb481
commit 0df70abad7
24 changed files with 0 additions and 1225 deletions

View 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);
}
}

View file

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

View 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 &lt;= 7.
/// </summary>
public string? ReadMsvcWString(nint stringAddr)
{
var mem = _ctx.Memory;
var size = mem.Read<long>(stringAddr + 0x10);
var capacity = mem.Read<long>(stringAddr + 0x18);
if (size <= 0 || size > 512 || capacity < size) return null;
nint dataAddr;
if (capacity <= 7)
dataAddr = stringAddr; // SSO: inline in _Bx buffer
else
{
dataAddr = mem.ReadPointer(stringAddr);
if (dataAddr == 0) return null;
}
var bytes = mem.ReadBytes(dataAddr, (int)size * 2);
if (bytes is null) return null;
var str = Encoding.Unicode.GetString(bytes);
if (str.Length > 0 && str[0] >= 0x20 && str[0] <= 0x7E)
return str;
return null;
}
/// <summary>
/// Reads an MSVC std::string (narrow, UTF-8/ASCII) from the given address.
/// Layout: _Bx (16 bytes: SSO buffer or heap ptr), _Mysize (8), _Myres (8).
/// SSO threshold: capacity &lt;= 15.
/// </summary>
public string? ReadMsvcString(nint stringAddr)
{
var mem = _ctx.Memory;
var size = mem.Read<long>(stringAddr + 0x10);
var capacity = mem.Read<long>(stringAddr + 0x18);
if (size <= 0 || size > 512 || capacity < size) return null;
nint dataAddr;
if (capacity <= 15)
dataAddr = stringAddr; // SSO: inline in _Bx buffer
else
{
dataAddr = mem.ReadPointer(stringAddr);
if (dataAddr == 0) return null;
}
var bytes = mem.ReadBytes(dataAddr, (int)size);
if (bytes is null) return null;
var str = Encoding.UTF8.GetString(bytes);
if (str.Length > 0 && str[0] >= 0x20 && str[0] <= 0x7E)
return str;
return null;
}
/// <summary>
/// Reads a null-terminated char* string from a module-range or heap address.
/// Component names are char* literals in .rdata, e.g. "Life", "Render", "Monster".
/// </summary>
public string? ReadCharPtr(nint ptr)
{
if (ptr == 0) return null;
var data = _ctx.Memory.ReadBytes(ptr, 64);
if (data is null) return null;
var end = Array.IndexOf(data, (byte)0);
if (end < 1 || end > 50) return null;
var str = Encoding.ASCII.GetString(data, 0, end);
if (str.Length > 0 && str.All(c => c >= 0x20 && c <= 0x7E))
return str;
return null;
}
/// <summary>
/// Reads a null-terminated 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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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;
}
}