refactor
This commit is contained in:
parent
4424f4c3a8
commit
0e7de0a5f3
281 changed files with 3188 additions and 611 deletions
470
src/Nexus.Data/MemoryPoller.cs
Normal file
470
src/Nexus.Data/MemoryPoller.cs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
using System.Diagnostics;
|
||||
using System.Numerics;
|
||||
using Nexus.Memory;
|
||||
using Nexus.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Nexus.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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot tick: minimal reads (4 RPM calls) using pre-resolved addresses.
|
||||
/// </summary>
|
||||
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<int>(_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<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 = 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue