poe2-bot/src/Roboto.Memory/GameMemoryReader.cs
2026-03-02 16:36:12 -05:00

313 lines
10 KiB
C#

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 a single struct (System.Numerics.Matrix4x4 is already unmanaged/sequential)
var m = mem.Read<Matrix4x4>(matrixAddr);
// 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();
}
}