poe2-bot/src/Roboto.Data/MemoryPoller.cs
2026-03-02 16:23:23 -05:00

372 lines
13 KiB
C#

using System.Diagnostics;
using System.Numerics;
using Roboto.Memory;
using Roboto.Core;
using Serilog;
using MemEntity = Roboto.Memory.Entity;
using MemEntityType = Roboto.Memory.EntityType;
namespace Roboto.Data;
/// <summary>
/// Owns the memory read thread. Runs a two-tier loop:
/// - Hot tick (60Hz): 4 RPM calls using cached addresses → updates GameDataCache hot fields
/// - Cold tick (10Hz): full snapshot via ReadSnapshot() → updates cold fields + re-resolves addresses
/// </summary>
public sealed class MemoryPoller : IDisposable
{
private readonly GameMemoryReader _reader;
private readonly GameDataCache _cache;
private readonly BotConfig _config;
private Thread? _thread;
private volatile bool _running;
private bool _disposed;
// Cached resolved addresses (re-resolved on each cold tick)
private nint _cameraMatrixAddr;
private nint _playerRenderAddr;
private nint _playerLifeAddr;
private nint _inGameStateAddr;
private nint _controllerAddr;
private Roboto.Memory.GameOffsets? _offsets;
private ProcessMemory? _mem;
private int _hotHz;
private int _coldHz;
private long _coldTickNumber;
public event Action? StateUpdated;
public MemoryPoller(GameMemoryReader reader, GameDataCache cache, BotConfig config)
{
_reader = reader;
_cache = cache;
_config = config;
}
public void Start(int hotHz = 60, int coldHz = 10)
{
if (_running) return;
_hotHz = hotHz;
_coldHz = coldHz;
_running = true;
_thread = new Thread(PollLoop)
{
Name = "Roboto.MemoryPoller",
IsBackground = true,
};
_thread.Start();
}
public void Stop()
{
if (!_running) return;
_running = false;
_thread?.Join(2000);
_thread = null;
}
private void PollLoop()
{
var hotIntervalMs = 1000.0 / _hotHz;
var coldEveryN = Math.Max(1, _hotHz / _coldHz); // e.g. 60/10 = every 6th hot tick
var sw = Stopwatch.StartNew();
var hotTickCount = 0;
GameState? previousState = null;
while (_running)
{
try
{
var isColdTick = hotTickCount % coldEveryN == 0;
if (isColdTick)
{
// ── Cold tick: full snapshot + re-resolve addresses ──
previousState = DoColdTick(previousState);
}
else
{
// ── Hot tick: minimal reads from cached addresses ──
DoHotTick();
}
hotTickCount++;
}
catch (Exception ex)
{
Log.Debug(ex, "MemoryPoller error");
}
var elapsed = sw.Elapsed.TotalMilliseconds;
var sleepMs = hotIntervalMs - (elapsed % hotIntervalMs);
if (sleepMs > 1)
Thread.Sleep((int)sleepMs);
}
}
/// <summary>
/// Full snapshot: read everything, build GameState, update cache cold + hot fields, re-resolve addresses.
/// </summary>
private GameState? DoColdTick(GameState? previous)
{
if (!_reader.IsAttached) return previous;
var ctx = _reader.Context;
if (ctx is null) return previous;
_mem = ctx.Memory;
_offsets = ctx.Offsets;
// Full snapshot
var snap = _reader.ReadSnapshot();
if (!snap.Attached) return previous;
// Re-resolve hot addresses
var hot = _reader.ResolveHotAddresses();
_cameraMatrixAddr = hot.CameraMatrixAddr;
_playerRenderAddr = hot.PlayerRenderAddr;
_playerLifeAddr = hot.PlayerLifeAddr;
_inGameStateAddr = hot.InGameStateAddr;
_controllerAddr = hot.ControllerAddr;
// Build full GameState
var state = BuildGameState(snap, previous);
_coldTickNumber++;
// Update cache — cold fields
_cache.Entities = state.Entities;
_cache.HostileMonsters = state.HostileMonsters;
_cache.NearbyLoot = state.NearbyLoot;
_cache.Terrain = state.Terrain;
_cache.AreaHash = state.AreaHash;
_cache.AreaLevel = state.AreaLevel;
_cache.LatestState = state;
_cache.ColdTickTimestamp = Environment.TickCount64;
// Also update hot fields from the snapshot (so they're never stale)
_cache.CameraMatrix = snap.CameraMatrix.HasValue ? new CameraMatrixData(snap.CameraMatrix.Value) : null;
_cache.PlayerPosition = snap.HasPosition
? new PlayerPositionData(snap.PlayerX, snap.PlayerY, snap.PlayerZ)
: PlayerPositionData.Empty;
_cache.PlayerVitals = snap.HasVitals
? new PlayerVitalsData(snap.LifeCurrent, snap.LifeTotal, snap.ManaCurrent, snap.ManaTotal, snap.EsCurrent, snap.EsTotal)
: PlayerVitalsData.Empty;
_cache.IsLoading = snap.IsLoading;
_cache.IsEscapeOpen = snap.IsEscapeOpen;
_cache.HotTickTimestamp = Environment.TickCount64;
StateUpdated?.Invoke();
return state;
}
/// <summary>
/// Hot tick: minimal reads (4 RPM calls) using pre-resolved addresses.
/// </summary>
private void DoHotTick()
{
if (_mem is null || _offsets is null) return;
// 1. Camera matrix (64 bytes, 1 RPM)
if (_cameraMatrixAddr != 0)
{
var bytes = _mem.ReadBytes(_cameraMatrixAddr, 64);
if (bytes is { Length: >= 64 })
{
var m = new Matrix4x4(
BitConverter.ToSingle(bytes, 0), BitConverter.ToSingle(bytes, 4), BitConverter.ToSingle(bytes, 8), BitConverter.ToSingle(bytes, 12),
BitConverter.ToSingle(bytes, 16), BitConverter.ToSingle(bytes, 20), BitConverter.ToSingle(bytes, 24), BitConverter.ToSingle(bytes, 28),
BitConverter.ToSingle(bytes, 32), BitConverter.ToSingle(bytes, 36), BitConverter.ToSingle(bytes, 40), BitConverter.ToSingle(bytes, 44),
BitConverter.ToSingle(bytes, 48), BitConverter.ToSingle(bytes, 52), BitConverter.ToSingle(bytes, 56), BitConverter.ToSingle(bytes, 60));
if (!float.IsNaN(m.M11) && !float.IsInfinity(m.M11))
_cache.CameraMatrix = new CameraMatrixData(m);
}
}
// 2. Player position (12 bytes, 1 RPM)
if (_playerRenderAddr != 0)
{
var bytes = _mem.ReadBytes(_playerRenderAddr + _offsets.PositionXOffset, 12);
if (bytes is { Length: >= 12 })
{
var x = BitConverter.ToSingle(bytes, 0);
var y = BitConverter.ToSingle(bytes, 4);
var z = BitConverter.ToSingle(bytes, 8);
if (!float.IsNaN(x) && !float.IsNaN(y) && x >= 50 && x <= 50000 && y >= 50 && y <= 50000)
_cache.PlayerPosition = new PlayerPositionData(x, y, z);
}
}
// 3. Player vitals (24 bytes across 3 VitalStruct regions, 1 RPM via bulk read)
if (_playerLifeAddr != 0)
{
// Read a contiguous block covering HP, Mana, ES structs
// HP at LifeHealthOffset, Mana at LifeManaOffset, ES at LifeEsOffset
// Each has VitalCurrentOffset (+0x30) and VitalTotalOffset (+0x2C)
var hpOff = _offsets.LifeHealthOffset;
var esOff = _offsets.LifeEsOffset;
var endOff = esOff + _offsets.VitalCurrentOffset + 4; // last byte we need
var regionSize = endOff - hpOff;
if (regionSize > 0 && regionSize < 0x200)
{
var bytes = _mem.ReadBytes(_playerLifeAddr + hpOff, regionSize);
if (bytes is not null && bytes.Length >= regionSize)
{
var hpCur = BitConverter.ToInt32(bytes, _offsets.VitalCurrentOffset);
var hpMax = BitConverter.ToInt32(bytes, _offsets.VitalTotalOffset);
var manaCur = BitConverter.ToInt32(bytes, _offsets.LifeManaOffset - hpOff + _offsets.VitalCurrentOffset);
var manaMax = BitConverter.ToInt32(bytes, _offsets.LifeManaOffset - hpOff + _offsets.VitalTotalOffset);
var esCur = BitConverter.ToInt32(bytes, _offsets.LifeEsOffset - hpOff + _offsets.VitalCurrentOffset);
var esMax = BitConverter.ToInt32(bytes, _offsets.LifeEsOffset - hpOff + _offsets.VitalTotalOffset);
if (hpMax > 0 && hpMax <= 200000 && hpCur >= 0)
{
_cache.PlayerVitals = new PlayerVitalsData(hpCur, hpMax, manaCur, manaMax, esCur, esMax);
}
}
}
}
// 4. Loading/escape state (~16 bytes, 1 RPM)
if (_controllerAddr != 0 && _offsets.IsLoadingOffset > 0 && _inGameStateAddr != 0)
{
var activePtr = _mem.ReadPointer(_controllerAddr + _offsets.IsLoadingOffset);
_cache.IsLoading = activePtr != 0 && activePtr != _inGameStateAddr;
}
if (_inGameStateAddr != 0 && _offsets.EscapeStateOffset > 0)
{
var escVal = _mem.Read<int>(_inGameStateAddr + _offsets.EscapeStateOffset);
_cache.IsEscapeOpen = escVal != 0;
}
_cache.HotTickTimestamp = Environment.TickCount64;
}
private GameState BuildGameState(GameStateSnapshot snap, GameState? previous)
{
var state = new GameState
{
TickNumber = (previous?.TickNumber ?? 0) + 1,
TimestampMs = Environment.TickCount64,
};
if (previous is not null)
state.DeltaTime = (state.TimestampMs - previous.TimestampMs) / 1000f;
state.AreaHash = snap.AreaHash;
state.AreaLevel = snap.AreaLevel;
state.IsLoading = snap.IsLoading;
state.IsEscapeOpen = snap.IsEscapeOpen;
state.CameraMatrix = snap.CameraMatrix;
state.Player = new PlayerState
{
HasPosition = snap.HasPosition,
Position = snap.HasPosition ? new Vector2(snap.PlayerX, snap.PlayerY) : Vector2.Zero,
Z = snap.PlayerZ,
LifeCurrent = snap.LifeCurrent,
LifeTotal = snap.LifeTotal,
ManaCurrent = snap.ManaCurrent,
ManaTotal = snap.ManaTotal,
EsCurrent = snap.EsCurrent,
EsTotal = snap.EsTotal,
};
if (snap.Entities is { Count: > 0 })
{
var playerPos = state.Player.Position;
var allEntities = new List<EntitySnapshot>(snap.Entities.Count);
var hostiles = new List<EntitySnapshot>();
var loot = new List<EntitySnapshot>();
foreach (var e in snap.Entities)
{
if (e.Address == snap.LocalPlayerPtr) continue;
var es = MapEntity(e, playerPos);
allEntities.Add(es);
if (es.Category == EntityCategory.Monster && es.IsAlive)
hostiles.Add(es);
else if (es.Category == EntityCategory.WorldItem)
loot.Add(es);
}
state.Entities = allEntities;
state.HostileMonsters = hostiles;
state.NearbyLoot = loot;
}
if (snap.Terrain is not null)
{
state.Terrain = new WalkabilitySnapshot
{
Width = snap.Terrain.Width,
Height = snap.Terrain.Height,
Data = snap.Terrain.Data,
};
}
return state;
}
private static EntitySnapshot MapEntity(MemEntity e, Vector2 playerPos)
{
var pos = e.HasPosition ? new Vector2(e.X, e.Y) : Vector2.Zero;
var dist = e.HasPosition ? Vector2.Distance(pos, playerPos) : float.MaxValue;
return new EntitySnapshot
{
Id = e.Id,
Path = e.Path,
Category = MapCategory(e.Type),
ThreatLevel = MapThreatLevel(e),
Position = pos,
DistanceToPlayer = dist,
IsAlive = e.IsAlive || !e.HasVitals,
LifeCurrent = e.LifeCurrent,
LifeTotal = e.LifeTotal,
IsTargetable = e.IsTargetable,
Components = e.Components,
};
}
private static EntityCategory MapCategory(MemEntityType type) => type switch
{
MemEntityType.Player => EntityCategory.Player,
MemEntityType.Monster => EntityCategory.Monster,
MemEntityType.Npc => EntityCategory.Npc,
MemEntityType.WorldItem => EntityCategory.WorldItem,
MemEntityType.Chest => EntityCategory.Chest,
MemEntityType.Portal or MemEntityType.TownPortal => EntityCategory.Portal,
MemEntityType.AreaTransition => EntityCategory.AreaTransition,
MemEntityType.Effect => EntityCategory.Effect,
MemEntityType.Terrain => EntityCategory.Terrain,
_ => EntityCategory.MiscObject,
};
private static MonsterThreatLevel MapThreatLevel(MemEntity e)
{
if (e.Type != MemEntityType.Monster) return MonsterThreatLevel.None;
return e.Rarity switch
{
MonsterRarity.White => MonsterThreatLevel.Normal,
MonsterRarity.Magic => MonsterThreatLevel.Magic,
MonsterRarity.Rare => MonsterThreatLevel.Rare,
MonsterRarity.Unique => MonsterThreatLevel.Unique,
_ => MonsterThreatLevel.Normal,
};
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Stop();
}
}