using System.Diagnostics; using System.Numerics; using Nexus.Memory; using Nexus.Core; using Serilog; namespace Nexus.Data; /// /// 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 /// 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; private int _lastQuestCount; // 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 Nexus.Memory.GameOffsets? _offsets; private ProcessMemory? _mem; private int _hotHz; private int _coldHz; private long _coldTickNumber; // Stats snapshot (updated once per second) private long _lastStatsMs; private volatile int _readsPerSec; private volatile int _kbPerSec; private volatile int _entityCount; public int ReadsPerSec => _readsPerSec; public int KBPerSec => _kbPerSec; public int EntityCount => _entityCount; 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 = "Nexus.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++; // Update stats once per second var nowMs = Environment.TickCount64; if (nowMs - _lastStatsMs >= 1000) { _lastStatsMs = nowMs; if (_mem is not null) { var (reads, bytes) = _mem.SnapshotAndResetCounters(); _readsPerSec = (int)reads; _kbPerSec = (int)(bytes / 1024); } _entityCount = _cache.Entities.Count; if (MemoryProfiler.IsEnabled) MemoryProfiler.LatestData = MemoryProfiler.SnapshotAndReset(); else MemoryProfiler.LatestData = null; } } 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); } } /// /// Full snapshot: read everything, build GameState, update cache cold + hot fields, re-resolve addresses. /// 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; // Slow data (quests, character name) every 10th cold tick (~1Hz) var isSlowTick = _coldTickNumber % 10 == 0; // Full snapshot var snap = _reader.ReadSnapshot(readSlowData: isSlowTick); 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 (every tick) _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.GameUiPtr = snap.GameUiPtr; _cache.LatestState = state; // Slow fields — only update when actually read (1Hz) if (isSlowTick) { _cache.CharacterName = state.Player.CharacterName; _cache.UiQuestGroups = snap.UiQuestGroups; _cache.QuestLinkedList = snap.QuestLinkedList; _cache.QuestStates = snap.QuestStates; } _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; } /// /// Hot tick: minimal reads (4 RPM calls) using pre-resolved addresses. /// private void DoHotTick() { if (_mem is null || _offsets is null) return; MemoryProfiler.BeginSection("Hot"); // 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(_inGameStateAddr + _offsets.EscapeStateOffset); _cache.IsEscapeOpen = escVal != 0; } _cache.HotTickTimestamp = Environment.TickCount64; MemoryProfiler.EndSection(); } 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.CurrentAreaName = _cache.CurrentAreaName; state.IsLoading = snap.IsLoading; state.IsEscapeOpen = snap.IsEscapeOpen; state.CameraMatrix = snap.CameraMatrix; state.Player = new PlayerState { CharacterName = snap.CharacterName ?? previous?.Player.CharacterName, 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, Skills = snap.PlayerSkills? .Where(s => s.SkillBarSlot >= 0) .Select(s => new SkillState { SlotIndex = s.SkillBarSlot, SkillId = s.Id, Id2 = s.Id2, Name = StripPlayerSuffix(s.Name), InternalName = s.InternalName, UseStage = s.UseStage, CastType = s.CastType, CooldownTimeMs = s.CooldownTimeMs, SkillBarSlot = s.SkillBarSlot, ChargesCurrent = Math.Max(0, s.MaxUses - s.ActiveCooldowns), ChargesMax = s.MaxUses, CooldownRemaining = s.ActiveCooldowns > 0 ? s.CooldownTimeMs / 1000f : 0f, CanBeUsed = s.CanBeUsed, }).ToList() ?? [], }; if (snap.Entities is { Count: > 0 }) { // Extract player action state before filtering var playerEntity = snap.Entities.FirstOrDefault(e => e.Address == snap.LocalPlayerPtr); if (playerEntity is not null) { state.Player = state.Player with { ActionId = playerEntity.ActionId }; } var playerPos = state.Player.Position; var allEntities = new List(snap.Entities.Count); var hostiles = new List(); var loot = new List(); foreach (var e in snap.Entities) { if (e.Address == snap.LocalPlayerPtr) continue; var es = EntityMapper.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.QuestLinkedList is { Count: > 0 }) { // StateId: 0=done, -1=locked, positive=in-progress state.ActiveQuests = snap.QuestLinkedList .Where(q => q.StateId > 0) // in-progress only .Select(q => new QuestProgress { QuestName = q.DisplayName, InternalId = q.InternalId, StateId = (byte)Math.Clamp(q.StateId, 0, 255), IsTracked = q.IsTracked, StateText = q.StateText, }).ToList(); var activeCount = state.ActiveQuests.Count; if (_lastQuestCount != activeCount) { Log.Debug("Active quests: {Active}/{Total}", activeCount, snap.QuestLinkedList.Count); _lastQuestCount = activeCount; } } else if (previous is not null) { state.ActiveQuests = previous.ActiveQuests; } if (snap.UiQuestGroups is { Count: > 0 }) { state.UiQuests = snap.UiQuestGroups.Select(g => new UiQuestInfo { Title = g.Title, Objectives = g.Steps .Where(s => s.Text is not null) .Select(s => s.Text!) .ToList(), }).ToList(); } else if (previous is not null) { state.UiQuests = previous.UiQuests; } if (snap.QuestLinkedList is { Count: > 0 }) { state.Quests = snap.QuestLinkedList .Where(q => q.StateId > 0) .Select(q => new QuestInfo { InternalId = q.InternalId, DisplayName = q.DisplayName, Act = q.Act, StateId = q.StateId, StateText = q.StateText, IsTracked = q.IsTracked, MapPinsText = q.MapPinsText, TargetAreas = q.TargetAreas?.Select(a => new QuestTargetArea { Id = a.Id, Name = a.Name, Act = a.Act, IsTown = a.IsTown, }).ToList(), PathToTarget = q.PathToTarget, }).ToList(); } else if (previous is not null) { state.Quests = previous.Quests; } 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 string? StripPlayerSuffix(string? name) { if (name is null) return null; if (name.EndsWith("Player", StringComparison.Ordinal)) return name[..^6]; return name; } public void Dispose() { if (_disposed) return; _disposed = true; Stop(); } }