poe2-bot/src/Roboto.Memory/Objects/GameStates.cs
2026-03-04 16:49:30 -05:00

439 lines
17 KiB
C#

using Roboto.Memory;
namespace Roboto.Memory.Objects;
/// <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 AreaLoading AreaLoading { get; }
public InGameState 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, ComponentReader components, MsvcStringReader strings, QuestNameLookup? questNames)
{
_ctx = ctx;
AreaLoading = new AreaLoading(ctx);
InGame = new InGameState(ctx, components, strings, questNames);
}
/// <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();
}
}