huge refactor

This commit is contained in:
Boki 2026-03-02 23:45:12 -05:00
parent e5ebe05571
commit a8341e8232
29 changed files with 3184 additions and 340 deletions

View file

@ -0,0 +1,63 @@
namespace Roboto.Memory.States;
/// <summary>
/// Reads fields from the AreaInstance (IngameData) address.
/// Individual field reads — the full struct is 3280B, too large to bulk-read.
/// Uses GameOffsets for configurable offsets.
/// </summary>
public sealed class AreaInstanceState : RemoteObject
{
public int AreaLevel { get; private set; }
public uint AreaHash { get; private set; }
public nint ServerDataPtr { get; private set; }
public nint LocalPlayerPtr { get; private set; }
public int EntityCount { get; private set; }
public AreaInstanceState(MemoryContext ctx) : base(ctx) { }
protected override bool ReadData()
{
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
// Area level
if (offsets.AreaLevelIsByte)
{
var level = mem.Read<byte>(Address + offsets.AreaLevelOffset);
AreaLevel = level is > 0 and < 200 ? level : 0;
}
else
{
var level = mem.Read<int>(Address + offsets.AreaLevelOffset);
AreaLevel = level is > 0 and < 200 ? level : 0;
}
// Area hash
AreaHash = mem.Read<uint>(Address + offsets.AreaHashOffset);
// ServerData pointer
ServerDataPtr = mem.ReadPointer(Address + offsets.ServerDataOffset);
// LocalPlayer — try direct offset first, fallback to ServerData chain
LocalPlayerPtr = 0;
if (offsets.LocalPlayerDirectOffset > 0)
LocalPlayerPtr = mem.ReadPointer(Address + offsets.LocalPlayerDirectOffset);
if (LocalPlayerPtr == 0 && ServerDataPtr != 0)
LocalPlayerPtr = mem.ReadPointer(ServerDataPtr + offsets.LocalPlayerOffset);
// Entity count from std::map _Mysize
var count = (int)mem.Read<long>(Address + offsets.EntityListOffset + offsets.EntityCountInternalOffset);
EntityCount = count is > 0 and < 50000 ? count : 0;
return true;
}
protected override void Clear()
{
AreaLevel = 0;
AreaHash = 0;
ServerDataPtr = 0;
LocalPlayerPtr = 0;
EntityCount = 0;
}
}

View file

@ -0,0 +1,36 @@
using Roboto.GameOffsets.States;
namespace Roboto.Memory.States;
/// <summary>
/// Reads AreaLoading state (slot 0). Individual field reads — the full struct is 3672B, wasteful to bulk-read.
/// </summary>
public sealed class AreaLoadingState : RemoteObject
{
// AreaLoading struct field offsets
private const int IsLoadingOffset = 0x660;
private const int TotalLoadingScreenTimeMsOffset = 0xDB8;
public bool IsLoading { get; private set; }
public long TotalLoadingScreenTimeMs { get; private set; }
public AreaLoadingState(MemoryContext ctx) : base(ctx) { }
protected override bool ReadData()
{
var mem = Ctx.Memory;
var loadingFlag = mem.Read<int>(Address + IsLoadingOffset);
IsLoading = loadingFlag != 0;
TotalLoadingScreenTimeMs = mem.Read<long>(Address + TotalLoadingScreenTimeMsOffset);
return true;
}
protected override void Clear()
{
IsLoading = false;
TotalLoadingScreenTimeMs = 0;
}
}

View file

@ -0,0 +1,22 @@
namespace Roboto.Memory.States;
/// <summary>
/// Game state types by slot index. Order must match the state array in the controller.
/// Matches ExileCore/GameHelper2 slot ordering.
/// </summary>
public enum GameStateType
{
AreaLoadingState, // 0
WaitingState, // 1
CreditsState, // 2
EscapeState, // 3
InGameState, // 4
ChangePasswordState, // 5
LoginState, // 6
PreGameState, // 7
CreateCharacterState, // 8
SelectCharacterState, // 9
DeleteCharacterState, // 10
LoadingState, // 11
GameNotLoaded, // sentinel — no valid state resolved
}

View file

@ -0,0 +1,437 @@
namespace Roboto.Memory.States;
/// <summary>
/// Root state orchestrator. Reads controller from GameStateBase, resolves state slot pointers,
/// builds address→GameStateType dictionary, resolves current state, and cascades to children.
/// NOT a RemoteObject — owns the top-level resolution logic.
/// </summary>
public sealed class GameStates
{
private readonly MemoryContext _ctx;
private readonly Dictionary<nint, GameStateType> _allStates = new();
private nint[] _slotPointers = [];
private nint[] _prevPreSlotValues = [];
public nint ControllerPtr { get; private set; }
public int StatesCount { get; private set; }
public GameStateType CurrentState { get; private set; } = GameStateType.GameNotLoaded;
public IReadOnlyDictionary<nint, GameStateType> AllStates => _allStates;
public AreaLoadingState AreaLoading { get; }
public InGameStateReader InGame { get; }
/// <summary>Raw qwords from controller 0x00-0x48 (before state slots), for UI diagnostics.</summary>
public (int Offset, nint Value, string? Match, bool Changed, string? DerefInfo)[] ControllerPreSlots { get; private set; } = [];
public GameStates(MemoryContext ctx)
{
_ctx = ctx;
AreaLoading = new AreaLoadingState(ctx);
InGame = new InGameStateReader(ctx);
}
/// <summary>
/// Reads controller, resolves state slots, builds state dictionary, cascades to children,
/// then resolves current state from child flags. Returns true if InGameState was resolved.
/// </summary>
public bool Update()
{
ControllerPtr = 0;
StatesCount = 0;
_allStates.Clear();
CurrentState = GameStateType.GameNotLoaded;
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
if (_ctx.GameStateBase == 0)
return false;
var controller = mem.ReadPointer(_ctx.GameStateBase);
if (controller == 0)
return false;
ControllerPtr = controller;
nint igsPtr = 0;
// Mode 1: Direct offset — InGameState pointer at a known offset from controller
if (offsets.InGameStateDirectOffset > 0)
{
igsPtr = mem.ReadPointer(controller + offsets.InGameStateDirectOffset);
if (igsPtr != 0)
ReadSlotPointers(controller);
}
// Mode 2: Inline states — states are inline in the controller struct
if (igsPtr == 0 && offsets.StatesInline)
{
ReadSlotPointers(controller);
var inlineOffset = offsets.StatesBeginOffset
+ offsets.InGameStateIndex * offsets.StateStride
+ offsets.StatePointerOffset;
igsPtr = mem.ReadPointer(controller + inlineOffset);
}
// Mode 3: Vector of pointers — StatesBeginOffset points to begin/end pair
if (igsPtr == 0 && !offsets.StatesInline)
{
var statesBegin = mem.ReadPointer(controller + offsets.StatesBeginOffset);
if (statesBegin == 0)
return false;
var statesEnd = mem.ReadPointer(controller + offsets.StatesBeginOffset + 8);
if (statesEnd > statesBegin && statesEnd - statesBegin < 0x1000 && offsets.StateStride > 0)
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;
StatesCount++;
}
}
if (_slotPointers.Length < StatesCount)
_slotPointers = new nint[StatesCount];
for (var i = 0; i < StatesCount; i++)
_slotPointers[i] = mem.ReadPointer(statesBegin + i * offsets.StateStride + offsets.StatePointerOffset);
BuildStateDictionary();
if (offsets.InGameStateIndex >= 0 && offsets.InGameStateIndex < StatesCount)
igsPtr = _slotPointers[offsets.InGameStateIndex];
}
// Dump controller pre-slots region for diagnostics
DumpControllerPreSlots(controller);
// Cascade to children FIRST — we need their flags for current state resolution
var areaLoadingPtr = StatesCount > 0 ? _slotPointers[0] : (nint)0;
AreaLoading.Update(areaLoadingPtr);
if (igsPtr == 0)
{
InGame.Reset();
ResolveCurrentState(controller, igsPtr);
return false;
}
InGame.Update(igsPtr);
// Resolve current state AFTER children have read their flags
ResolveCurrentState(controller, igsPtr);
return true;
}
/// <summary>
/// Read slot pointers from inline state array in the controller.
/// </summary>
private void ReadSlotPointers(nint controller)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
StatesCount = 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;
StatesCount++;
}
if (_slotPointers.Length < StatesCount)
_slotPointers = new nint[StatesCount];
for (var i = 0; i < StatesCount; i++)
{
var slotOffset = offsets.StatesBeginOffset + i * offsets.StateStride + offsets.StatePointerOffset;
_slotPointers[i] = mem.ReadPointer(controller + slotOffset);
}
BuildStateDictionary();
}
/// <summary>
/// Dump the controller region before the state slots for UI diagnostics.
/// Tracks which values changed since last frame.
/// Dereferences pointer-like values and checks for indirect state slot matches.
/// </summary>
private void DumpControllerPreSlots(nint controller)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
var preSize = offsets.StatesBeginOffset;
if (preSize <= 0) { ControllerPreSlots = []; return; }
var data = mem.ReadBytes(controller, preSize);
if (data is null) { ControllerPreSlots = []; return; }
var count = preSize / 8;
var entries = new (int, nint, string?, bool, string?)[count];
for (var i = 0; i < count; i++)
{
var off = i * 8;
var val = (nint)BitConverter.ToInt64(data, off);
string? match = null;
if (val != 0 && _allStates.TryGetValue(val, out var st))
match = st.ToString();
var changed = i < _prevPreSlotValues.Length && _prevPreSlotValues[i] != val;
// Annotate discovered StdVector fields
string? derefInfo = null;
if (_discoveredVectorOffset >= 0)
{
if (off == _discoveredVectorOffset)
derefInfo = "vec.First";
else if (off == _discoveredVectorOffset + 8)
derefInfo = "vec.Last";
else if (off == _discoveredVectorOffset + 16)
derefInfo = "vec.End";
}
// Dereference pointer-like values and scan for state slot matches
if (derefInfo == null && val != 0 && match == null)
{
var high = (ulong)val >> 32;
if (high > 0 && high < 0x7FFF)
{
// Read first 16 qwords (128 bytes) of the target object
var targetData = mem.ReadBytes(val, 128);
if (targetData is not null)
{
for (var qi = 0; qi + 8 <= targetData.Length; qi += 8)
{
var innerVal = (nint)BitConverter.ToInt64(targetData, qi);
if (innerVal != 0 && _allStates.TryGetValue(innerVal, out var innerState))
{
derefInfo = $"*+0x{qi:X2} → {innerState}";
break;
}
}
// If no state match, show first qword as context
if (derefInfo == null)
{
var first = (nint)BitConverter.ToInt64(targetData, 0);
if (first != 0)
derefInfo = $"*→ 0x{first:X}";
}
}
}
}
entries[i] = (off, val, match, changed, derefInfo);
}
ControllerPreSlots = entries;
// Save for next frame diff
if (_prevPreSlotValues.Length != count)
_prevPreSlotValues = new nint[count];
for (var i = 0; i < count; i++)
_prevPreSlotValues[i] = entries[i].Item2;
}
/// <summary>
/// Build address→GameStateType dictionary from collected slot pointers.
/// </summary>
private void BuildStateDictionary()
{
_allStates.Clear();
var maxType = (int)GameStateType.GameNotLoaded;
for (var i = 0; i < StatesCount && i < maxType; i++)
{
if (_slotPointers[i] != 0)
_allStates[_slotPointers[i]] = (GameStateType)i;
}
}
/// <summary>
/// Cached base address and offset for the active states vector.
/// -1 = not yet scanned, -2 = scan failed.
/// </summary>
private int _discoveredVectorOffset = -1;
private nint _discoveredVectorBase;
/// <summary>
/// Resolve which GameStateType is currently active using the GameHelper2 approach:
/// Find the StdVector {First, Last, End}, read *(Last - 0x10) (second-to-last entry).
/// Searches BOTH the GameState object (*(originalPatternResult)) AND the controller,
/// since POE2 may have separated these into different objects.
/// </summary>
private void ResolveCurrentState(nint controller, nint igsPtr)
{
var mem = _ctx.Memory;
var offsets = _ctx.Offsets;
// Fast path: use cached vector location
if (_discoveredVectorOffset >= 0 && _discoveredVectorBase != 0)
{
var state = ReadStateFromVector(_discoveredVectorBase, _discoveredVectorOffset);
if (state != GameStateType.GameNotLoaded)
{
CurrentState = state;
return;
}
// Vector went invalid (zone change, etc) — re-scan
_discoveredVectorOffset = -1;
_discoveredVectorBase = 0;
}
// Scan for the active states StdVector
if (_discoveredVectorOffset == -1 && _allStates.Count > 0)
{
// === Priority 1: GameState object (original pattern result, before PatternResultAdjust) ===
// GameHelper2 reads CurrentStatePtr from GameState+0x08. In POE2, the controller
// is at pattern_result+0x18, but the "real" GameState object may be at *(pattern_result+0x00).
if (offsets.PatternResultAdjust > 0)
{
var originalPatternResult = _ctx.GameStateBase - offsets.PatternResultAdjust;
// Try each pointer in the static region as a potential GameState object
for (var ptrOff = 0; ptrOff < offsets.PatternResultAdjust; ptrOff += 8)
{
var gameStateObj = mem.ReadPointer(originalPatternResult + ptrOff);
if (gameStateObj == 0 || gameStateObj == controller) continue;
// Scan this object for StdVector containing state slot pointers
for (var off = 0; off + 24 <= 0x100; off += 8)
{
var state = ReadStateFromVector(gameStateObj, off);
if (state != GameStateType.GameNotLoaded)
{
_discoveredVectorBase = gameStateObj;
_discoveredVectorOffset = off;
CurrentState = state;
Serilog.Log.Information(
"Active states vector at GameState(patResult+0x{PtrOff:X})+0x{Offset:X} → {State} (obj=0x{Obj:X})",
ptrOff, off, state, gameStateObj);
return;
}
}
}
// Also try reading the vector directly from the static region itself
for (var off = 0; off + 24 <= offsets.PatternResultAdjust + 0x40; off += 8)
{
var state = ReadStateFromVector(originalPatternResult, off);
if (state != GameStateType.GameNotLoaded)
{
_discoveredVectorBase = originalPatternResult;
_discoveredVectorOffset = off;
CurrentState = state;
Serilog.Log.Information(
"Active states vector at staticRegion+0x{Offset:X} → {State}",
off, state);
return;
}
}
}
// === Priority 2: Controller pre-slots region ===
var stateArrayStart = offsets.StatesBeginOffset;
if (offsets.ActiveStatesOffset > 0)
{
var state = ReadStateFromVector(controller, offsets.ActiveStatesOffset);
if (state != GameStateType.GameNotLoaded)
{
_discoveredVectorBase = controller;
_discoveredVectorOffset = offsets.ActiveStatesOffset;
CurrentState = state;
return;
}
}
for (var off = 0; off + 24 <= stateArrayStart; off += 8)
{
if (off == offsets.ActiveStatesOffset) continue;
var state = ReadStateFromVector(controller, off);
if (state != GameStateType.GameNotLoaded)
{
_discoveredVectorBase = controller;
_discoveredVectorOffset = off;
CurrentState = state;
Serilog.Log.Information(
"Active states vector at controller+0x{Offset:X} → {State}", off, state);
return;
}
}
// Mark scan as failed so we don't re-scan every frame
_discoveredVectorOffset = -2;
}
// Fallback: IsLoadingOffset (legacy, if configured)
if (offsets.IsLoadingOffset > 0)
{
var currentStateAddr = mem.ReadPointer(controller + offsets.IsLoadingOffset);
if (currentStateAddr != 0 && _allStates.TryGetValue(currentStateAddr, out var stateType))
{
CurrentState = stateType;
return;
}
}
// Fallback: default to InGameState if resolved (preliminary — GameMemoryReader
// will reconcile with reliable snap.IsLoading / snap.IsEscapeOpen afterwards)
if (igsPtr != 0)
CurrentState = GameStateType.InGameState;
}
/// <summary>
/// Try to read the current state from a StdVector at the given controller offset.
/// GameHelper2 approach: StdVector = {First, Last, End}, current = *(Last - 0x10).
/// Returns GameNotLoaded if the vector is invalid or doesn't contain state matches.
/// </summary>
private GameStateType ReadStateFromVector(nint controller, int vectorOffset)
{
var mem = _ctx.Memory;
var first = mem.ReadPointer(controller + vectorOffset);
var last = mem.ReadPointer(controller + vectorOffset + 8);
if (first == 0 || last <= first) return GameStateType.GameNotLoaded;
var size = (int)(last - first);
if (size < 16 || size > 0x400) return GameStateType.GameNotLoaded; // need ≥2 entries for 2nd-to-last
// Read the full vector buffer
var buf = mem.ReadBytes(first, size);
if (buf is null) return GameStateType.GameNotLoaded;
// Validate: at least 1 entry must be a known state slot address
var matchCount = 0;
for (var i = 0; i + 8 <= buf.Length; i += 8)
{
var val = (nint)BitConverter.ToInt64(buf, i);
if (val != 0 && _allStates.ContainsKey(val))
matchCount++;
}
if (matchCount == 0) return GameStateType.GameNotLoaded;
// GameHelper2: current state = *(Last - 0x10) = second-to-last entry
var secondToLast = (nint)BitConverter.ToInt64(buf, buf.Length - 16);
if (secondToLast != 0 && _allStates.TryGetValue(secondToLast, out var stateType))
return stateType;
// Fallback: try last entry if second-to-last didn't match
var lastEntry = (nint)BitConverter.ToInt64(buf, buf.Length - 8);
if (lastEntry != 0 && _allStates.TryGetValue(lastEntry, out stateType))
return stateType;
return GameStateType.GameNotLoaded;
}
/// <summary>
/// Reset all state to zero.
/// </summary>
public void Reset()
{
ControllerPtr = 0;
StatesCount = 0;
CurrentState = GameStateType.GameNotLoaded;
_discoveredVectorOffset = -1;
_discoveredVectorBase = 0;
_allStates.Clear();
AreaLoading.Reset();
InGame.Reset();
}
}

View file

@ -0,0 +1,51 @@
using Roboto.GameOffsets.States;
namespace Roboto.Memory.States;
/// <summary>
/// Reads InGameState struct (784B, 1 RPM instead of 4 individual reads).
/// Named "Reader" to avoid collision with <see cref="Roboto.GameOffsets.States.InGameState"/> struct.
/// Cascades to AreaInstanceState and WorldDataState children.
/// </summary>
public sealed class InGameStateReader : RemoteObject
{
private InGameState _data;
public bool IsEscapeOpen { get; private set; }
public AreaInstanceState AreaInstance { get; }
public WorldDataState WorldData { get; }
public InGameStateReader(MemoryContext ctx) : base(ctx)
{
AreaInstance = new AreaInstanceState(ctx);
WorldData = new WorldDataState(ctx);
}
protected override bool ReadData()
{
var mem = Ctx.Memory;
// Read the full InGameState struct (0x310 = 784 bytes, 1 RPM)
_data = mem.Read<InGameState>(Address);
// Escape state
IsEscapeOpen = _data.EscapeStateFlag != 0;
// Cascade to AreaInstance
AreaInstance.Update(_data.AreaInstanceDataPtr);
// Cascade to WorldData — set fallback camera before update
WorldData.FallbackCameraPtr = _data.CameraPtr;
WorldData.Update(_data.WorldDataPtr);
return true;
}
protected override void Clear()
{
_data = default;
IsEscapeOpen = false;
AreaInstance.Reset();
WorldData.Reset();
}
}

View file

@ -0,0 +1,44 @@
namespace Roboto.Memory.States;
/// <summary>
/// Base class for state objects that read a section of game memory.
/// Each subclass reads its own struct/fields from a remote address.
/// </summary>
public abstract class RemoteObject
{
protected readonly MemoryContext Ctx;
public nint Address { get; protected set; }
public bool IsValid => Address != 0;
protected RemoteObject(MemoryContext ctx) => Ctx = ctx;
/// <summary>
/// Update this object from a new address. Returns false if address is 0 or read fails.
/// </summary>
public bool Update(nint address)
{
Address = address;
if (address == 0)
{
Clear();
return false;
}
return ReadData();
}
/// <summary>
/// Reset address to 0 and clear all cached data.
/// </summary>
public void Reset()
{
Address = 0;
Clear();
}
/// <summary>Read data from the remote process at <see cref="Address"/>.</summary>
protected abstract bool ReadData();
/// <summary>Zero out all cached fields.</summary>
protected abstract void Clear();
}

View file

@ -0,0 +1,60 @@
using System.Numerics;
using Roboto.GameOffsets.States;
namespace Roboto.Memory.States;
/// <summary>
/// Reads WorldData struct (168B, 1 RPM) and resolves the camera matrix.
/// Primary camera source: WorldData.CameraPtr. Fallback: InGameState.CameraPtr (set via FallbackCameraPtr).
/// </summary>
public sealed class WorldDataState : RemoteObject
{
private WorldData _data;
/// <summary>Camera pointer from InGameState, set by InGameStateReader before Update() is called.</summary>
public nint FallbackCameraPtr { get; set; }
public Matrix4x4? CameraMatrix { get; private set; }
/// <summary>Resolved address of the camera matrix for hot-path caching.</summary>
public nint CameraMatrixAddress { get; private set; }
public WorldDataState(MemoryContext ctx) : base(ctx) { }
protected override bool ReadData()
{
var mem = Ctx.Memory;
var offsets = Ctx.Offsets;
// Read the full WorldData struct (0xA8 = 168 bytes, 1 RPM)
_data = mem.Read<WorldData>(Address);
// Resolve camera: primary from WorldData, fallback from InGameState
if (offsets.CameraMatrixOffset <= 0)
return true;
var camPtr = _data.CameraPtr;
if (camPtr == 0)
camPtr = FallbackCameraPtr;
if (camPtr == 0)
return true;
var matrixAddr = camPtr + offsets.CameraMatrixOffset;
CameraMatrixAddress = matrixAddr;
var m = mem.Read<Matrix4x4>(matrixAddr);
if (float.IsNaN(m.M11) || float.IsInfinity(m.M11))
return true;
CameraMatrix = m;
return true;
}
protected override void Clear()
{
_data = default;
FallbackCameraPtr = 0;
CameraMatrix = null;
CameraMatrixAddress = 0;
}
}