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(_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(ingameData + offsets.AreaLevelOffset); if (level > 0 && level < 200) snap.AreaLevel = level; } else { var level = mem.Read(ingameData + offsets.AreaLevelOffset); if (level > 0 && level < 200) snap.AreaLevel = level; } // Area hash snap.AreaHash = mem.Read(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(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(matrixAddr); // Quick sanity check if (float.IsNaN(m.M11) || float.IsInfinity(m.M11)) return; snap.CameraMatrix = m; } /// /// Resolved addresses for hot-path reads (camera, player position, player vitals, InGameState). /// 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; } } /// /// Returns resolved addresses for the hot path. /// Call after ReadSnapshot() has populated the cached addresses. /// public HotAddresses ResolveHotAddresses() { return new HotAddresses( _cachedCameraMatrixAddr, _components?.CachedRenderComponentAddr ?? 0, _components?.CachedLifeComponentAddr ?? 0, _lastInGameState, _lastController); } public void Dispose() { if (_disposed) return; _disposed = true; Detach(); } }