372 lines
13 KiB
C#
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();
|
|
}
|
|
}
|