This commit is contained in:
Boki 2026-02-16 13:18:04 -05:00
parent 2d6a6bd3a1
commit d80e723b94
28 changed files with 1801 additions and 352 deletions

View file

@ -24,8 +24,8 @@ public class BotOrchestrator : IAsyncDisposable
{
private bool _paused;
private string _state = "Idle";
private int _tradesCompleted;
private int _tradesFailed;
private volatile int _tradesCompleted;
private volatile int _tradesFailed;
private readonly long _startTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private bool _started;
@ -40,6 +40,11 @@ public class BotOrchestrator : IAsyncDisposable
public TradeExecutor TradeExecutor { get; }
public TradeQueue TradeQueue { get; }
public NavigationExecutor Navigation { get; }
public FramePipelineService PipelineService { get; }
public GameStateDetector GameState { get; }
public HudReader HudReader { get; }
public EnemyDetector EnemyDetector { get; }
public FrameSaver FrameSaver { get; }
private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new();
// Events
@ -49,7 +54,7 @@ public class BotOrchestrator : IAsyncDisposable
public BotOrchestrator(ConfigStore store, IGameController game, IScreenReader screen,
IClientLogWatcher logWatcher, ITradeMonitor tradeMonitor,
IInventoryManager inventory, TradeExecutor tradeExecutor,
TradeQueue tradeQueue, LinkManager links)
TradeQueue tradeQueue, LinkManager links, FramePipelineService pipelineService)
{
Store = store;
Game = game;
@ -60,7 +65,26 @@ public class BotOrchestrator : IAsyncDisposable
TradeExecutor = tradeExecutor;
TradeQueue = tradeQueue;
Links = links;
Navigation = new NavigationExecutor(game);
PipelineService = pipelineService;
// Create consumers
var minimapCapture = new MinimapCapture(new MinimapConfig(), pipelineService.Backend);
GameState = new GameStateDetector();
HudReader = new HudReader();
EnemyDetector = new EnemyDetector();
FrameSaver = new FrameSaver();
// Register on shared pipeline
pipelineService.Pipeline.AddConsumer(minimapCapture);
pipelineService.Pipeline.AddConsumer(GameState);
pipelineService.Pipeline.AddConsumer(HudReader);
pipelineService.Pipeline.AddConsumer(EnemyDetector);
pipelineService.Pipeline.AddConsumer(FrameSaver);
// Pass shared pipeline to NavigationExecutor
Navigation = new NavigationExecutor(game, pipelineService.Pipeline, minimapCapture,
enemyDetector: EnemyDetector);
logWatcher.AreaEntered += _ => Navigation.Reset();
logWatcher.Start(); // start early so area events fire even before Bot.Start()
_paused = store.Settings.Paused;
@ -205,8 +229,8 @@ public class BotOrchestrator : IAsyncDisposable
// Wire executor events
TradeExecutor.StateChanged += _ => UpdateExecutorState();
Navigation.StateChanged += _ => UpdateExecutorState();
TradeQueue.TradeCompleted += () => { _tradesCompleted++; StatusUpdated?.Invoke(); };
TradeQueue.TradeFailed += () => { _tradesFailed++; StatusUpdated?.Invoke(); };
TradeQueue.TradeCompleted += () => { Interlocked.Increment(ref _tradesCompleted); StatusUpdated?.Invoke(); };
TradeQueue.TradeFailed += () => { Interlocked.Increment(ref _tradesFailed); StatusUpdated?.Invoke(); };
Inventory.Updated += () => StatusUpdated?.Invoke();
_started = true;
@ -267,9 +291,11 @@ public class BotOrchestrator : IAsyncDisposable
Log.Information("Shutting down bot...");
foreach (var exec in _scrapExecutors.Values)
await exec.Stop();
EnemyDetector.Dispose();
Screen.Dispose();
await TradeMonitor.DisposeAsync();
LogWatcher.Dispose();
PipelineService.Dispose();
}
private void OnNewListings(string searchId, List<string> itemIds, IPage page)
@ -302,8 +328,8 @@ public class BotOrchestrator : IAsyncDisposable
{
var scrapExec = new ScrapExecutor(Game, Screen, TradeMonitor, Inventory, Config);
scrapExec.StateChanged += _ => UpdateExecutorState();
scrapExec.ItemBought += () => { _tradesCompleted++; StatusUpdated?.Invoke(); };
scrapExec.ItemFailed += () => { _tradesFailed++; StatusUpdated?.Invoke(); };
scrapExec.ItemBought += () => { Interlocked.Increment(ref _tradesCompleted); StatusUpdated?.Invoke(); };
scrapExec.ItemFailed += () => { Interlocked.Increment(ref _tradesFailed); StatusUpdated?.Invoke(); };
_scrapExecutors[link.Id] = scrapExec;
Emit("info", $"Scrap loop started: {link.Name}");
StatusUpdated?.Invoke();

View file

@ -81,3 +81,22 @@ public enum MapType
Temple,
Endgame
}
public enum GameUiState
{
Unknown,
Playing,
InventoryOpen,
StashOpen,
VendorOpen,
MapOverlay,
Loading,
Dead,
EscapeMenu
}
public interface IGameStateProvider
{
GameUiState CurrentState { get; }
event Action<GameUiState, GameUiState>? StateChanged;
}

View file

@ -8,67 +8,80 @@ namespace Poe2Trade.Game;
/// </summary>
public static class ClipboardHelper
{
private const int MaxRetries = 5;
private const int RetryDelayMs = 30;
public static string Read()
{
if (!ClipboardNative.OpenClipboard(IntPtr.Zero))
return "";
try
for (var attempt = 0; attempt < MaxRetries; attempt++)
{
var handle = ClipboardNative.GetClipboardData(ClipboardNative.CF_UNICODETEXT);
if (handle == IntPtr.Zero) return "";
var ptr = ClipboardNative.GlobalLock(handle);
if (ptr == IntPtr.Zero) return "";
try
if (ClipboardNative.OpenClipboard(IntPtr.Zero))
{
return Marshal.PtrToStringUni(ptr) ?? "";
}
finally
{
ClipboardNative.GlobalUnlock(handle);
try
{
var handle = ClipboardNative.GetClipboardData(ClipboardNative.CF_UNICODETEXT);
if (handle == IntPtr.Zero) return "";
var ptr = ClipboardNative.GlobalLock(handle);
if (ptr == IntPtr.Zero) return "";
try
{
return Marshal.PtrToStringUni(ptr) ?? "";
}
finally
{
ClipboardNative.GlobalUnlock(handle);
}
}
finally
{
ClipboardNative.CloseClipboard();
}
}
Thread.Sleep(RetryDelayMs);
}
finally
{
ClipboardNative.CloseClipboard();
}
return "";
}
public static void Write(string text)
{
if (!ClipboardNative.OpenClipboard(IntPtr.Zero))
return;
try
for (var attempt = 0; attempt < MaxRetries; attempt++)
{
ClipboardNative.EmptyClipboard();
var bytes = Encoding.Unicode.GetBytes(text + "\0");
var hGlobal = ClipboardNative.GlobalAlloc(ClipboardNative.GMEM_MOVEABLE, (UIntPtr)bytes.Length);
if (hGlobal == IntPtr.Zero) return;
var ptr = ClipboardNative.GlobalLock(hGlobal);
if (ptr == IntPtr.Zero)
if (ClipboardNative.OpenClipboard(IntPtr.Zero))
{
ClipboardNative.GlobalFree(hGlobal);
return;
}
try
{
ClipboardNative.EmptyClipboard();
var bytes = Encoding.Unicode.GetBytes(text + "\0");
var hGlobal = ClipboardNative.GlobalAlloc(ClipboardNative.GMEM_MOVEABLE, (UIntPtr)bytes.Length);
if (hGlobal == IntPtr.Zero) return;
try
{
Marshal.Copy(bytes, 0, ptr, bytes.Length);
}
finally
{
ClipboardNative.GlobalUnlock(hGlobal);
}
var ptr = ClipboardNative.GlobalLock(hGlobal);
if (ptr == IntPtr.Zero)
{
ClipboardNative.GlobalFree(hGlobal);
return;
}
ClipboardNative.SetClipboardData(ClipboardNative.CF_UNICODETEXT, hGlobal);
}
finally
{
ClipboardNative.CloseClipboard();
try
{
Marshal.Copy(bytes, 0, ptr, bytes.Length);
}
finally
{
ClipboardNative.GlobalUnlock(hGlobal);
}
ClipboardNative.SetClipboardData(ClipboardNative.CF_UNICODETEXT, hGlobal);
return;
}
finally
{
ClipboardNative.CloseClipboard();
}
}
Thread.Sleep(RetryDelayMs);
}
}
}

View file

@ -13,59 +13,67 @@ public class NavigationExecutor : IDisposable
private readonly FramePipeline _pipeline;
private readonly MinimapCapture _capture;
private readonly WorldMap _worldMap;
private readonly StuckDetector _stuck;
private readonly EnemyDetector? _enemyDetector;
private NavigationState _state = NavigationState.Idle;
private volatile bool _stopped;
private int _stuckCounter;
private MapPosition? _lastPosition;
private volatile byte[]? _cachedViewport;
private static readonly Random Rng = new();
// Input loop communication (capture loop writes, input loop reads)
// Thread-safe direction passing (capture loop writes, input loop reads)
private readonly object _dirLock = new();
private double _desiredDirX, _desiredDirY;
private volatile bool _directionChanged;
private bool _directionChanged;
// Valid state transitions (from → allowed targets)
private static readonly Dictionary<NavigationState, NavigationState[]> ValidTransitions = new()
{
[NavigationState.Idle] = [NavigationState.Capturing],
[NavigationState.Capturing] = [NavigationState.Processing, NavigationState.Idle],
[NavigationState.Processing] = [NavigationState.Planning, NavigationState.Stuck, NavigationState.Capturing, NavigationState.Failed, NavigationState.Idle],
[NavigationState.Planning] = [NavigationState.Moving, NavigationState.Completed, NavigationState.Idle],
[NavigationState.Moving] = [NavigationState.Capturing, NavigationState.Idle],
[NavigationState.Stuck] = [NavigationState.Moving, NavigationState.Completed, NavigationState.Idle],
[NavigationState.Completed] = [NavigationState.Idle],
[NavigationState.Failed] = [NavigationState.Capturing, NavigationState.Idle],
};
public event Action<NavigationState>? StateChanged;
public event Action<byte[]>? ViewportUpdated;
public NavigationState State => _state;
public NavigationExecutor(IGameController game, MinimapConfig? config = null)
public NavigationExecutor(IGameController game, FramePipeline pipeline,
MinimapCapture capture, MinimapConfig? config = null, EnemyDetector? enemyDetector = null)
{
_game = game;
_config = config ?? new MinimapConfig();
var backend = CreateBackend();
_pipeline = new FramePipeline(backend);
_capture = new MinimapCapture(_config, backend);
_pipeline.AddConsumer(_capture);
_stuck = new StuckDetector(_config.StuckThreshold, _config.StuckFrameCount);
_pipeline = pipeline;
_capture = capture;
_enemyDetector = enemyDetector;
_worldMap = new WorldMap(_config);
_capture.ModeChanged += _ =>
{
_worldMap.Rebootstrap();
_stuckCounter = 0;
_lastPosition = null;
_stuck.Reset();
};
}
private static IScreenCapture CreateBackend()
{
// DXGI primary → GDI fallback
try
{
var dxgi = new DesktopDuplication();
Log.Information("Screen capture: DXGI Desktop Duplication");
return dxgi;
}
catch (Exception ex)
{
Log.Warning(ex, "DXGI unavailable, falling back to GDI");
}
Log.Information("Screen capture: GDI (CopyFromScreen)");
return new GdiCapture();
}
private void SetState(NavigationState s)
{
var from = _state;
if (from == s) return;
// Validate transition (Stop() can set Idle from any state — always allowed)
if (s != NavigationState.Idle &&
ValidTransitions.TryGetValue(from, out var allowed) &&
!allowed.Contains(s))
{
Log.Warning("Invalid state transition: {From} → {To}", from, s);
}
Log.Debug("State: {From} → {To}", from, s);
_state = s;
StateChanged?.Invoke(s);
}
@ -83,8 +91,7 @@ public class NavigationExecutor : IDisposable
_worldMap.Reset();
_capture.ResetAdaptation();
_stopped = false;
_stuckCounter = 0;
_lastPosition = null;
_stuck.Reset();
SetState(NavigationState.Idle);
Log.Information("Navigation reset (new area)");
}
@ -92,7 +99,7 @@ public class NavigationExecutor : IDisposable
public async Task RunExploreLoop()
{
_stopped = false;
_directionChanged = false;
lock (_dirLock) _directionChanged = false;
Log.Information("Starting explore loop");
_cachedViewport = _worldMap.GetViewportSnapshot(_worldMap.Position);
@ -127,31 +134,21 @@ public class NavigationExecutor : IDisposable
_capture.CommitWallColors();
// Only re-render viewport when canvas was modified (avoids ~3ms PNG encode on dedup-skips)
_cachedViewport = _worldMap.GetViewportSnapshot(pos);
ViewportUpdated?.Invoke(_cachedViewport);
}
// Stuck detection
if (_lastPosition != null)
{
var dx = pos.X - _lastPosition.X;
var dy = pos.Y - _lastPosition.Y;
if (Math.Sqrt(dx * dx + dy * dy) < _config.StuckThreshold)
_stuckCounter++;
else
_stuckCounter = 0;
}
_lastPosition = pos;
_stuck.Update(pos);
// 2. Movement decisions — non-blocking, just post direction to input loop
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var moveInterval = _stuckCounter >= _config.StuckFrameCount
? 200
: _config.MovementWaitMs;
var moveInterval = _stuck.IsStuck ? 200 : _config.MovementWaitMs;
if (now - lastMoveTime >= moveInterval)
{
lastMoveTime = now;
if (_stuckCounter >= _config.StuckFrameCount)
if (_stuck.IsStuck)
SetState(NavigationState.Stuck);
else
SetState(NavigationState.Planning);
@ -167,13 +164,16 @@ public class NavigationExecutor : IDisposable
}
SetState(NavigationState.Moving);
// Post direction to input loop (non-blocking)
_desiredDirX = direction.Value.dirX;
_desiredDirY = direction.Value.dirY;
_directionChanged = true;
// Post direction to input loop (thread-safe)
lock (_dirLock)
{
_desiredDirX = direction.Value.dirX;
_desiredDirY = direction.Value.dirY;
_directionChanged = true;
}
if (_stuckCounter >= _config.StuckFrameCount)
_stuckCounter = 0; // reset after re-routing
if (_stuck.IsStuck)
_stuck.Reset(); // reset after re-routing
}
}
catch (Exception ex)
@ -217,27 +217,43 @@ public class NavigationExecutor : IDisposable
{
while (!_stopped)
{
// Apply direction changes from capture loop
if (_directionChanged)
// Apply direction changes from capture loop (thread-safe read)
bool changed;
double dirX, dirY;
lock (_dirLock)
{
changed = _directionChanged;
dirX = _desiredDirX;
dirY = _desiredDirY;
_directionChanged = false;
var dirX = _desiredDirX;
var dirY = _desiredDirY;
await UpdateWasdKeys(heldKeys, dirX, dirY);
}
// Combat clicks on timer
if (changed)
await UpdateWasdKeys(heldKeys, dirX, dirY);
// Combat clicks on timer — prefer detected enemies, fall back to random
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (now >= nextCombatTime)
{
nextCombatTime = now + 1000 + Rng.Next(1000);
var cx = 1280 + Rng.Next(-150, 150);
var cy = 720 + Rng.Next(-150, 150);
await _game.LeftClickAt(cx, cy);
await Helpers.Sleep(100 + Rng.Next(100));
cx = 1280 + Rng.Next(-150, 150);
cy = 720 + Rng.Next(-150, 150);
await _game.RightClickAt(cx, cy);
var target = GetBestTarget();
if (target != null)
{
await _game.LeftClickAt(target.Cx, target.Cy);
await Helpers.Sleep(100 + Rng.Next(100));
await _game.RightClickAt(target.Cx, target.Cy);
}
else
{
var cx = _config.ScreenCenterX + Rng.Next(-150, 150);
var cy = _config.ScreenCenterY + Rng.Next(-150, 150);
await _game.LeftClickAt(cx, cy);
await Helpers.Sleep(100 + Rng.Next(100));
cx = _config.ScreenCenterX + Rng.Next(-150, 150);
cy = _config.ScreenCenterY + Rng.Next(-150, 150);
await _game.RightClickAt(cx, cy);
}
}
await Helpers.Sleep(15);
@ -288,6 +304,26 @@ public class NavigationExecutor : IDisposable
}
}
/// <summary>
/// Pick the best enemy target from detection snapshot.
/// Prefers health-bar-confirmed enemies, then closest to screen center.
/// </summary>
private DetectedEnemy? GetBestTarget()
{
if (_enemyDetector == null) return null;
var snapshot = _enemyDetector.Latest;
if (snapshot.Enemies.Count == 0) return null;
var screenCx = _config.ScreenCenterX;
var screenCy = _config.ScreenCenterY;
return snapshot.Enemies
.OrderByDescending(e => e.HealthBarConfirmed)
.ThenBy(e => Math.Abs(e.Cx - screenCx) + Math.Abs(e.Cy - screenCy))
.FirstOrDefault();
}
private async Task ClickToMove(double dirX, double dirY)
{
// Player is at minimap center on screen; click offset from center
@ -373,7 +409,6 @@ public class NavigationExecutor : IDisposable
public void Dispose()
{
_capture.Dispose();
_pipeline.Dispose();
_worldMap.Dispose();
}
}

View file

@ -126,7 +126,7 @@ public class MinimapConfig
public int MovementWaitMs { get; set; } = 600;
// World map canvas
public int CanvasSize { get; set; } = 4000;
public int CanvasSize { get; set; } = 2000;
// Template matching: search radius around current position estimate (pixels)
public int MatchSearchRadius { get; set; } = 100;
@ -153,4 +153,8 @@ public class MinimapConfig
// Stuck detection
public double StuckThreshold { get; set; } = 2.0;
public int StuckFrameCount { get; set; } = 5;
// Screen center for combat clicks (2560x1440 → center = 1280, 720)
public int ScreenCenterX { get; set; } = 1280;
public int ScreenCenterY { get; set; } = 720;
}

View file

@ -0,0 +1,145 @@
using OpenCvSharp;
using Serilog;
namespace Poe2Trade.Navigation;
/// <summary>
/// BFS pathfinding through the world map canvas. Pure function — reads canvas, never modifies it.
/// </summary>
internal class PathFinder
{
/// <summary>
/// BFS through walkable (Explored) cells to find the best frontier direction.
/// Instead of stopping at the nearest frontier, runs the full BFS and counts
/// frontier cells reachable per first-step direction. Prefers directions with
/// more frontier cells (corridors) over directions with few (dead-end rooms).
/// </summary>
public (double dirX, double dirY)? FindNearestUnexplored(Mat canvas, int canvasSize, MapPosition pos, int searchRadius = 400)
{
var cx = (int)Math.Round(pos.X);
var cy = (int)Math.Round(pos.Y);
// BFS at half resolution for speed (step=2 → ~200x200 effective grid for r=400)
const int step = 2;
var size = canvasSize;
var rr = searchRadius / step;
var gridW = 2 * rr + 1;
var visited = new bool[gridW * gridW];
// Propagate first step from start during BFS (avoids per-frontier trace-back)
var firstStepX = new short[gridW * gridW];
var firstStepY = new short[gridW * gridW];
var queue = new Queue<(int gx, int gy)>(4096);
var startGx = rr;
var startGy = rr;
visited[startGy * gridW + startGx] = true;
queue.Enqueue((startGx, startGy));
// 8-connected neighbors
ReadOnlySpan<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 1];
ReadOnlySpan<int> dys = [-1, -1, -1, 0, 0, 1, 1, 1];
// Count frontier cells per first-step direction
var frontierCounts = new Dictionary<int, int>();
var firstStepCoords = new Dictionary<int, (short gx, short gy)>();
while (queue.Count > 0)
{
var (gx, gy) = queue.Dequeue();
// Map grid coords back to canvas coords
var wx = cx + (gx - rr) * step;
var wy = cy + (gy - rr) * step;
// Check if this explored cell borders Unknown/Fog (= frontier)
if (gx != startGx || gy != startGy)
{
var cellIdx = gy * gridW + gx;
for (var d = 0; d < 8; d++)
{
var nx = wx + dxs[d] * step;
var ny = wy + dys[d] * step;
if (nx < 0 || nx >= size || ny < 0 || ny >= size) continue;
var neighbor = canvas.At<byte>(ny, nx);
if (neighbor == (byte)MapCell.Unknown || neighbor == (byte)MapCell.Fog)
{
var fsKey = firstStepY[cellIdx] * gridW + firstStepX[cellIdx];
if (frontierCounts.TryGetValue(fsKey, out var cnt))
frontierCounts[fsKey] = cnt + 1;
else
{
frontierCounts[fsKey] = 1;
firstStepCoords[fsKey] = (firstStepX[cellIdx], firstStepY[cellIdx]);
}
break; // don't double-count this cell
}
}
}
// Expand to walkable neighbors
for (var d = 0; d < 8; d++)
{
var ngx = gx + dxs[d];
var ngy = gy + dys[d];
if (ngx < 0 || ngx >= gridW || ngy < 0 || ngy >= gridW) continue;
var idx = ngy * gridW + ngx;
if (visited[idx]) continue;
var nwx = cx + (ngx - rr) * step;
var nwy = cy + (ngy - rr) * step;
if (nwx < 0 || nwx >= size || nwy < 0 || nwy >= size) continue;
var cell = canvas.At<byte>(nwy, nwx);
if (cell != (byte)MapCell.Explored) continue;
visited[idx] = true;
// Propagate first step: direct neighbors of start ARE the first step
if (gx == startGx && gy == startGy)
{
firstStepX[idx] = (short)ngx;
firstStepY[idx] = (short)ngy;
}
else
{
var parentIdx = gy * gridW + gx;
firstStepX[idx] = firstStepX[parentIdx];
firstStepY[idx] = firstStepY[parentIdx];
}
queue.Enqueue((ngx, ngy));
}
}
if (frontierCounts.Count == 0)
{
Log.Information("BFS: no reachable frontier within {Radius}px", searchRadius);
return null;
}
// Pick direction with the most frontier cells (prefers corridors over dead ends)
var bestKey = -1;
var bestCount = 0;
foreach (var (key, count) in frontierCounts)
{
if (count > bestCount)
{
bestCount = count;
bestKey = key;
}
}
var (bestGx, bestGy) = firstStepCoords[bestKey];
var dirX = (double)(bestGx - startGx);
var dirY = (double)(bestGy - startGy);
var len = Math.Sqrt(dirX * dirX + dirY * dirY);
if (len < 0.001) return (1, 0);
dirX /= len;
dirY /= len;
Log.Debug("BFS: {DirCount} directions, best={Best} frontier cells, dir=({Dx:F2},{Dy:F2})",
frontierCounts.Count, bestCount, dirX, dirY);
return (dirX, dirY);
}
}

View file

@ -0,0 +1,40 @@
namespace Poe2Trade.Navigation;
/// <summary>
/// Detects when the player hasn't moved significantly over a window of frames.
/// </summary>
internal class StuckDetector
{
private readonly double _threshold;
private readonly int _frameCount;
private int _counter;
private MapPosition? _lastPosition;
public bool IsStuck => _counter >= _frameCount;
public StuckDetector(double threshold, int frameCount)
{
_threshold = threshold;
_frameCount = frameCount;
}
public void Update(MapPosition position)
{
if (_lastPosition != null)
{
var dx = position.X - _lastPosition.X;
var dy = position.Y - _lastPosition.Y;
if (Math.Sqrt(dx * dx + dy * dy) < _threshold)
_counter++;
else
_counter = 0;
}
_lastPosition = position;
}
public void Reset()
{
_counter = 0;
_lastPosition = null;
}
}

View file

@ -24,6 +24,10 @@ internal class WallColorTracker
private readonly int[] _pendV = new int[256];
private int _pendCount;
// Generation counter: prevents committing samples from a previous mode after Reset()
private int _generation;
private int _pendGeneration;
private const int MinSamples = 3000;
private const double LoPercentile = 0.05;
private const double HiPercentile = 0.95;
@ -51,6 +55,7 @@ internal class WallColorTracker
Array.Clear(_pendS);
Array.Clear(_pendV);
_pendCount = 0;
_pendGeneration = _generation;
// Downsample: every 4th pixel in each direction (~10K samples for 400x400)
for (var r = 0; r < hsv.Rows; r += 4)
@ -72,6 +77,7 @@ internal class WallColorTracker
public void Commit()
{
if (_pendCount == 0) return;
if (_pendGeneration != _generation) return; // stale cross-mode samples
for (var i = 0; i < 180; i++) _hHist[i] += _pendH[i];
for (var i = 0; i < 256; i++) _sHist[i] += _pendS[i];
@ -144,5 +150,6 @@ internal class WallColorTracker
_pendCount = 0;
AdaptedLo = null;
AdaptedHi = null;
_generation++;
}
}

View file

@ -7,22 +7,57 @@ namespace Poe2Trade.Navigation;
public class WorldMap : IDisposable
{
private readonly MinimapConfig _config;
private readonly Mat _canvas;
private readonly Mat _confidence; // CV_16SC1: per-pixel wall confidence counter
private Mat _canvas;
private Mat _confidence; // CV_16SC1: per-pixel wall confidence counter
private int _canvasSize;
private MapPosition _position;
private int _frameCount;
private int _consecutiveMatchFails;
private Mat? _prevWallMask; // for frame deduplication
private readonly PathFinder _pathFinder = new();
public MapPosition Position => _position;
public bool LastMatchSucceeded { get; private set; }
public int CanvasSize => _canvasSize;
private const int GrowMargin = 500;
private const int GrowAmount = 2000; // 1000px added per side
public WorldMap(MinimapConfig config)
{
_config = config;
_canvas = new Mat(config.CanvasSize, config.CanvasSize, MatType.CV_8UC1, Scalar.Black);
_confidence = new Mat(config.CanvasSize, config.CanvasSize, MatType.CV_16SC1, Scalar.Black);
_position = new MapPosition(config.CanvasSize / 2.0, config.CanvasSize / 2.0);
_canvasSize = config.CanvasSize;
_canvas = new Mat(_canvasSize, _canvasSize, MatType.CV_8UC1, Scalar.Black);
_confidence = new Mat(_canvasSize, _canvasSize, MatType.CV_16SC1, Scalar.Black);
_position = new MapPosition(_canvasSize / 2.0, _canvasSize / 2.0);
}
private void EnsureCapacity()
{
var x = (int)Math.Round(_position.X);
var y = (int)Math.Round(_position.Y);
if (x >= GrowMargin && y >= GrowMargin &&
x < _canvasSize - GrowMargin && y < _canvasSize - GrowMargin)
return;
var oldSize = _canvasSize;
var newSize = oldSize + GrowAmount;
var offset = GrowAmount / 2;
var newCanvas = new Mat(newSize, newSize, MatType.CV_8UC1, Scalar.Black);
var newConf = new Mat(newSize, newSize, MatType.CV_16SC1, Scalar.Black);
using (var dst = new Mat(newCanvas, new Rect(offset, offset, oldSize, oldSize)))
_canvas.CopyTo(dst);
using (var dst = new Mat(newConf, new Rect(offset, offset, oldSize, oldSize)))
_confidence.CopyTo(dst);
_canvas.Dispose();
_confidence.Dispose();
_canvas = newCanvas;
_confidence = newConf;
_canvasSize = newSize;
_position = new MapPosition(_position.X + offset, _position.Y + offset);
Log.Information("Canvas grown: {Old}→{New}, offset={Offset}", oldSize, newSize, offset);
}
/// <summary>
@ -31,6 +66,7 @@ public class WorldMap : IDisposable
/// </summary>
public MapPosition MatchAndStitch(Mat classifiedMat, Mat wallMask, MinimapMode mode = MinimapMode.Overlay)
{
EnsureCapacity();
var sw = Stopwatch.StartNew();
_frameCount++;
@ -44,35 +80,10 @@ public class WorldMap : IDisposable
var wallCountBefore = Cv2.CountNonZero(wallMask);
// Block-based noise filter: only needed for overlay (game effects bleed through)
// Skip during warmup — we need walls to seed the canvas, confidence handles noise
if (!isCorner && !needsBootstrap)
{
var cleanFraction = FilterNoisyBlocks(wallMask, classifiedMat);
if (cleanFraction < 0.25)
{
Log.Information("Noise filter: {Clean:P0} clean, skipping ({Ms:F1}ms)",
cleanFraction, sw.Elapsed.TotalMilliseconds);
return _position;
}
}
if (ShouldSkipFrame(classifiedMat, wallMask, isCorner, needsBootstrap, sw))
return _position;
var wallCountAfter = Cv2.CountNonZero(wallMask);
// Frame deduplication: skip if minimap hasn't scrolled yet (but always allow warmup through)
if (!needsBootstrap && _prevWallMask != null && _frameCount > 1)
{
using var xor = new Mat();
Cv2.BitwiseXor(wallMask, _prevWallMask, xor);
var changedPixels = Cv2.CountNonZero(xor);
if (changedPixels < _config.FrameChangeThreshold)
{
Log.Debug("Frame dedup: {Changed} changed pixels, skipping ({Ms:F1}ms)",
changedPixels, sw.Elapsed.TotalMilliseconds);
return _position;
}
}
var dedupMs = sw.Elapsed.TotalMilliseconds;
// Store current wall mask for next frame's dedup check
@ -83,6 +94,7 @@ public class WorldMap : IDisposable
if (needsBootstrap)
{
StitchWithConfidence(classifiedMat, _position, boosted: true, mode: mode);
PaintExploredCircle(_position);
if (_consecutiveMatchFails >= 30)
{
Log.Information("Re-bootstrap: mode={Mode} pos=({X:F1},{Y:F1}) frameSize={FS} walls={W} stitch={Ms:F1}ms",
@ -118,6 +130,7 @@ public class WorldMap : IDisposable
_position = matched;
var stitchStart = sw.Elapsed.TotalMilliseconds;
StitchWithConfidence(classifiedMat, _position, boosted: false, mode: mode);
PaintExploredCircle(_position);
var stitchMs = sw.Elapsed.TotalMilliseconds - stitchStart;
var posDx = _position.X - prevPos.X;
@ -127,6 +140,41 @@ public class WorldMap : IDisposable
return _position;
}
/// <summary>
/// Noise filter + frame deduplication. Returns true if the frame should be skipped.
/// </summary>
private bool ShouldSkipFrame(Mat classifiedMat, Mat wallMask, bool isCorner, bool needsBootstrap, Stopwatch sw)
{
// Block-based noise filter: only needed for overlay (game effects bleed through)
// Skip during warmup — we need walls to seed the canvas, confidence handles noise
if (!isCorner && !needsBootstrap)
{
var cleanFraction = FilterNoisyBlocks(wallMask, classifiedMat);
if (cleanFraction < 0.25)
{
Log.Information("Noise filter: {Clean:P0} clean, skipping ({Ms:F1}ms)",
cleanFraction, sw.Elapsed.TotalMilliseconds);
return true;
}
}
// Frame deduplication: skip if minimap hasn't scrolled yet (but always allow warmup through)
if (!needsBootstrap && _prevWallMask != null && _frameCount > 1)
{
using var xor = new Mat();
Cv2.BitwiseXor(wallMask, _prevWallMask, xor);
var changedPixels = Cv2.CountNonZero(xor);
if (changedPixels < _config.FrameChangeThreshold)
{
Log.Debug("Frame dedup: {Changed} changed pixels, skipping ({Ms:F1}ms)",
changedPixels, sw.Elapsed.TotalMilliseconds);
return true;
}
}
return false;
}
private MapPosition? MatchPosition(Mat wallMask, MapPosition estimate)
{
var frameSize = wallMask.Width;
@ -143,8 +191,8 @@ public class WorldMap : IDisposable
// Clamp to canvas bounds
var sx0 = Math.Max(0, sx);
var sy0 = Math.Max(0, sy);
var sx1 = Math.Min(_config.CanvasSize, sx + searchSize);
var sy1 = Math.Min(_config.CanvasSize, sy + searchSize);
var sx1 = Math.Min(_canvasSize, sx + searchSize);
var sy1 = Math.Min(_canvasSize, sy + searchSize);
var sw = sx1 - sx0;
var sh = sy1 - sy0;
@ -213,8 +261,8 @@ public class WorldMap : IDisposable
var srcY = Math.Max(0, -canvasY);
var dstX = Math.Max(0, canvasX);
var dstY = Math.Max(0, canvasY);
var w = Math.Min(frameSize - srcX, _config.CanvasSize - dstX);
var h = Math.Min(frameSize - srcY, _config.CanvasSize - dstY);
var w = Math.Min(frameSize - srcX, _canvasSize - dstX);
var h = Math.Min(frameSize - srcY, _canvasSize - dstY);
if (w <= 0 || h <= 0) return;
@ -300,16 +348,23 @@ public class WorldMap : IDisposable
}
}
// Mark explored area: circle around player, overwrite Unknown and Fog
}
/// <summary>
/// Mark explored area: circle around player position, overwrite Unknown and Fog cells.
/// Called by MatchAndStitch after stitch in both bootstrap and match-success branches.
/// </summary>
private void PaintExploredCircle(MapPosition position)
{
var pcx = (int)Math.Round(position.X);
var pcy = (int)Math.Round(position.Y);
var r = _config.ExploredRadius;
var r2 = r * r;
var y0 = Math.Max(0, pcy - r);
var y1 = Math.Min(_config.CanvasSize - 1, pcy + r);
var y1 = Math.Min(_canvasSize - 1, pcy + r);
var x0 = Math.Max(0, pcx - r);
var x1 = Math.Min(_config.CanvasSize - 1, pcx + r);
var x1 = Math.Min(_canvasSize - 1, pcx + r);
for (var y = y0; y <= y1; y++)
for (var x = x0; x <= x1; x++)
@ -363,140 +418,8 @@ public class WorldMap : IDisposable
return totalBlocks > 0 ? (double)cleanBlocks / totalBlocks : 1.0;
}
/// <summary>
/// BFS through walkable (Explored) cells to find the best frontier direction.
/// Instead of stopping at the nearest frontier, runs the full BFS and counts
/// frontier cells reachable per first-step direction. Prefers directions with
/// more frontier cells (corridors) over directions with few (dead-end rooms).
/// </summary>
public (double dirX, double dirY)? FindNearestUnexplored(MapPosition pos, int searchRadius = 400)
{
var cx = (int)Math.Round(pos.X);
var cy = (int)Math.Round(pos.Y);
// BFS at half resolution for speed (step=2 → ~200x200 effective grid for r=400)
const int step = 2;
var size = _config.CanvasSize;
var rr = searchRadius / step;
var gridW = 2 * rr + 1;
var visited = new bool[gridW * gridW];
// Propagate first step from start during BFS (avoids per-frontier trace-back)
var firstStepX = new short[gridW * gridW];
var firstStepY = new short[gridW * gridW];
var queue = new Queue<(int gx, int gy)>(4096);
var startGx = rr;
var startGy = rr;
visited[startGy * gridW + startGx] = true;
queue.Enqueue((startGx, startGy));
// 8-connected neighbors
ReadOnlySpan<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 1];
ReadOnlySpan<int> dys = [-1, -1, -1, 0, 0, 1, 1, 1];
// Count frontier cells per first-step direction
var frontierCounts = new Dictionary<int, int>();
var firstStepCoords = new Dictionary<int, (short gx, short gy)>();
while (queue.Count > 0)
{
var (gx, gy) = queue.Dequeue();
// Map grid coords back to canvas coords
var wx = cx + (gx - rr) * step;
var wy = cy + (gy - rr) * step;
// Check if this explored cell borders Unknown/Fog (= frontier)
if (gx != startGx || gy != startGy)
{
var cellIdx = gy * gridW + gx;
for (var d = 0; d < 8; d++)
{
var nx = wx + dxs[d] * step;
var ny = wy + dys[d] * step;
if (nx < 0 || nx >= size || ny < 0 || ny >= size) continue;
var neighbor = _canvas.At<byte>(ny, nx);
if (neighbor == (byte)MapCell.Unknown || neighbor == (byte)MapCell.Fog)
{
var fsKey = firstStepY[cellIdx] * gridW + firstStepX[cellIdx];
if (frontierCounts.TryGetValue(fsKey, out var cnt))
frontierCounts[fsKey] = cnt + 1;
else
{
frontierCounts[fsKey] = 1;
firstStepCoords[fsKey] = (firstStepX[cellIdx], firstStepY[cellIdx]);
}
break; // don't double-count this cell
}
}
}
// Expand to walkable neighbors
for (var d = 0; d < 8; d++)
{
var ngx = gx + dxs[d];
var ngy = gy + dys[d];
if (ngx < 0 || ngx >= gridW || ngy < 0 || ngy >= gridW) continue;
var idx = ngy * gridW + ngx;
if (visited[idx]) continue;
var nwx = cx + (ngx - rr) * step;
var nwy = cy + (ngy - rr) * step;
if (nwx < 0 || nwx >= size || nwy < 0 || nwy >= size) continue;
var cell = _canvas.At<byte>(nwy, nwx);
if (cell != (byte)MapCell.Explored) continue;
visited[idx] = true;
// Propagate first step: direct neighbors of start ARE the first step
if (gx == startGx && gy == startGy)
{
firstStepX[idx] = (short)ngx;
firstStepY[idx] = (short)ngy;
}
else
{
var parentIdx = gy * gridW + gx;
firstStepX[idx] = firstStepX[parentIdx];
firstStepY[idx] = firstStepY[parentIdx];
}
queue.Enqueue((ngx, ngy));
}
}
if (frontierCounts.Count == 0)
{
Log.Information("BFS: no reachable frontier within {Radius}px", searchRadius);
return null;
}
// Pick direction with the most frontier cells (prefers corridors over dead ends)
var bestKey = -1;
var bestCount = 0;
foreach (var (key, count) in frontierCounts)
{
if (count > bestCount)
{
bestCount = count;
bestKey = key;
}
}
var (bestGx, bestGy) = firstStepCoords[bestKey];
var dirX = (double)(bestGx - startGx);
var dirY = (double)(bestGy - startGy);
var len = Math.Sqrt(dirX * dirX + dirY * dirY);
if (len < 0.001) return (1, 0);
dirX /= len;
dirY /= len;
Log.Debug("BFS: {DirCount} directions, best={Best} frontier cells, dir=({Dx:F2},{Dy:F2})",
frontierCounts.Count, bestCount, dirX, dirY);
return (dirX, dirY);
}
=> _pathFinder.FindNearestUnexplored(_canvas, _canvasSize, pos, searchRadius);
public byte[] GetMapSnapshot()
{
@ -510,8 +433,8 @@ public class WorldMap : IDisposable
var cy = (int)Math.Round(center.Y);
var half = viewSize / 2;
var x0 = Math.Clamp(cx - half, 0, _config.CanvasSize - viewSize);
var y0 = Math.Clamp(cy - half, 0, _config.CanvasSize - viewSize);
var x0 = Math.Clamp(cx - half, 0, _canvasSize - viewSize);
var y0 = Math.Clamp(cy - half, 0, _canvasSize - viewSize);
var roi = new Mat(_canvas, new Rect(x0, y0, viewSize, viewSize));
using var colored = new Mat(viewSize, viewSize, MatType.CV_8UC3, new Scalar(23, 17, 13));
@ -538,11 +461,14 @@ public class WorldMap : IDisposable
public void Reset()
{
_canvas.SetTo(Scalar.Black);
_confidence.SetTo(Scalar.Black);
_canvas.Dispose();
_confidence.Dispose();
_canvasSize = _config.CanvasSize;
_canvas = new Mat(_canvasSize, _canvasSize, MatType.CV_8UC1, Scalar.Black);
_confidence = new Mat(_canvasSize, _canvasSize, MatType.CV_16SC1, Scalar.Black);
_prevWallMask?.Dispose();
_prevWallMask = null;
_position = new MapPosition(_config.CanvasSize / 2.0, _config.CanvasSize / 2.0);
_position = new MapPosition(_canvasSize / 2.0, _canvasSize / 2.0);
_frameCount = 0;
_consecutiveMatchFails = 0;
}
@ -565,8 +491,8 @@ public class WorldMap : IDisposable
var cy = (int)Math.Round(_position.Y);
var x0 = Math.Max(0, cx - halfClear);
var y0 = Math.Max(0, cy - halfClear);
var w = Math.Min(_config.CanvasSize, cx + halfClear) - x0;
var h = Math.Min(_config.CanvasSize, cy + halfClear) - y0;
var w = Math.Min(_canvasSize, cx + halfClear) - x0;
var h = Math.Min(_canvasSize, cy + halfClear) - y0;
if (w > 0 && h > 0)
{
var rect = new Rect(x0, y0, w, h);

View file

@ -0,0 +1,12 @@
namespace Poe2Trade.Screen;
public record DetectedEnemy(
float Confidence,
int X, int Y, int Width, int Height,
int Cx, int Cy,
bool HealthBarConfirmed);
public record DetectionSnapshot(
IReadOnlyList<DetectedEnemy> Enemies,
long Timestamp,
float InferenceMs);

View file

@ -10,23 +10,30 @@ class DiffCropHandler
{
private Bitmap? _referenceFrame;
private Region? _referenceRegion;
private readonly object _refLock = new();
public void HandleSnapshot(string? file = null, Region? region = null)
{
_referenceFrame?.Dispose();
_referenceFrame = ScreenCapture.CaptureOrLoad(file, region);
_referenceRegion = region;
var newFrame = ScreenCapture.CaptureOrLoad(file, region);
lock (_refLock)
{
_referenceFrame?.Dispose();
_referenceFrame = newFrame;
_referenceRegion = region;
}
}
public void HandleScreenshot(string path, Region? region = null)
{
var bitmap = _referenceFrame ?? ScreenCapture.CaptureOrLoad(null, region);
Bitmap? refCopy;
lock (_refLock) { refCopy = _referenceFrame != null ? (Bitmap)_referenceFrame.Clone() : null; }
var bitmap = refCopy ?? ScreenCapture.CaptureOrLoad(null, region);
var format = ImageUtils.GetImageFormat(path);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
bitmap.Save(path, format);
if (bitmap != _referenceFrame) bitmap.Dispose();
bitmap.Dispose();
}
public byte[] HandleCapture(Region? region = null)
@ -44,47 +51,54 @@ class DiffCropHandler
public (Bitmap cropped, Bitmap refCropped, Bitmap current, Region region)? DiffCrop(
DiffCropParams c, string? file = null, Region? region = null)
{
if (_referenceFrame == null)
return null;
Bitmap refSnapshot;
Region? refRegion;
lock (_refLock)
{
if (_referenceFrame == null)
return null;
refSnapshot = (Bitmap)_referenceFrame.Clone();
refRegion = _referenceRegion;
}
var diffRegion = region ?? _referenceRegion;
var diffRegion = region ?? refRegion;
int baseX = diffRegion?.X ?? 0;
int baseY = diffRegion?.Y ?? 0;
var current = ScreenCapture.CaptureOrLoad(file, diffRegion);
Bitmap refForDiff = _referenceFrame;
bool disposeRef = false;
Bitmap refForDiff = refSnapshot;
if (diffRegion != null)
{
if (_referenceRegion == null)
if (refRegion == null)
{
var croppedRef = CropBitmap(_referenceFrame, diffRegion);
var croppedRef = CropBitmap(refSnapshot, diffRegion);
if (croppedRef == null)
{
current.Dispose();
refSnapshot.Dispose();
return null;
}
refForDiff = croppedRef;
disposeRef = true;
}
else if (!RegionsEqual(diffRegion, _referenceRegion))
else if (!RegionsEqual(diffRegion, refRegion))
{
int offX = diffRegion.X - _referenceRegion.X;
int offY = diffRegion.Y - _referenceRegion.Y;
if (offX < 0 || offY < 0 || offX + diffRegion.Width > _referenceFrame.Width || offY + diffRegion.Height > _referenceFrame.Height)
int offX = diffRegion.X - refRegion.X;
int offY = diffRegion.Y - refRegion.Y;
if (offX < 0 || offY < 0 || offX + diffRegion.Width > refSnapshot.Width || offY + diffRegion.Height > refSnapshot.Height)
{
current.Dispose();
refSnapshot.Dispose();
return null;
}
var croppedRef = CropBitmap(_referenceFrame, new Region(offX, offY, diffRegion.Width, diffRegion.Height));
var croppedRef = CropBitmap(refSnapshot, new Region(offX, offY, diffRegion.Width, diffRegion.Height));
if (croppedRef == null)
{
current.Dispose();
refSnapshot.Dispose();
return null;
}
refForDiff = croppedRef;
disposeRef = true;
}
}
@ -126,7 +140,8 @@ class DiffCropHandler
if (totalChanged == 0)
{
current.Dispose();
if (disposeRef) refForDiff.Dispose();
if (refForDiff != refSnapshot) refForDiff.Dispose();
refSnapshot.Dispose();
return null;
}
@ -226,7 +241,8 @@ class DiffCropHandler
{
Log.Debug("diff-crop: no tooltip-sized region found");
current.Dispose();
if (disposeRef) refForDiff.Dispose();
if (refForDiff != refSnapshot) refForDiff.Dispose();
refSnapshot.Dispose();
return null;
}
@ -307,7 +323,8 @@ class DiffCropHandler
Log.Debug("diff-crop: tooltip region ({X},{Y}) {W}x{H}", minX, minY, rw, rh);
if (disposeRef) refForDiff.Dispose();
if (refForDiff != refSnapshot) refForDiff.Dispose();
refSnapshot.Dispose();
return (cropped, refCropped, current, resultRegion);
}

View file

@ -0,0 +1,173 @@
using OpenCvSharp;
using Poe2Trade.Core;
using Serilog;
using Region = Poe2Trade.Core.Region;
namespace Poe2Trade.Screen;
/// <summary>
/// Detects enemies on screen using two-stage approach:
/// 1. YOLO detection via Python daemon (~5Hz, every 6th frame)
/// 2. Health bar confirmation via HSV threshold on bbox region (every frame, ~1ms)
/// </summary>
public class EnemyDetector : IFrameConsumer, IDisposable
{
// Crop region for gameplay area at 2560x1440 — excludes HUD globes, minimap
private static readonly Region GameplayRegion = new(320, 100, 1920, 1200);
private const int DetectEveryNFrames = 6; // ~5Hz at 30fps
private const int HealthBarHeight = 10; // px above bbox to scan for red bar
private const float RedPixelThreshold = 0.05f; // 5% red pixels = confirmed
private readonly PythonDetectBridge _bridge = new();
private volatile DetectionSnapshot _latest = new([], 0, 0);
private int _frameCounter;
private List<DetectedEnemy> _activeEnemies = [];
public bool Enabled { get; set; }
public DetectionSnapshot Latest => _latest;
public event Action<DetectionSnapshot>? DetectionUpdated;
public void Process(ScreenFrame frame)
{
if (!Enabled) return;
_frameCounter++;
// Health bar confirmation runs every frame for known enemies
if (_activeEnemies.Count > 0)
{
_activeEnemies = ConfirmHealthBars(frame, _activeEnemies);
}
// YOLO detection runs every Nth frame
if (_frameCounter % DetectEveryNFrames != 0) return;
try
{
// Bounds check
if (GameplayRegion.X + GameplayRegion.Width > frame.Width ||
GameplayRegion.Y + GameplayRegion.Height > frame.Height)
return;
using var cropped = frame.CropBgr(GameplayRegion);
var result = _bridge.Detect(cropped);
// Offset bbox coords by crop origin → screen-space coordinates
var enemies = new List<DetectedEnemy>(result.Count);
foreach (var det in result.Detections)
{
var screenX = det.X + GameplayRegion.X;
var screenY = det.Y + GameplayRegion.Y;
var screenCx = det.Cx + GameplayRegion.X;
var screenCy = det.Cy + GameplayRegion.Y;
// Check if this enemy was previously confirmed
var wasConfirmed = _activeEnemies.Any(e =>
Math.Abs(e.Cx - screenCx) < 50 && Math.Abs(e.Cy - screenCy) < 50 &&
e.HealthBarConfirmed);
enemies.Add(new DetectedEnemy(
det.Confidence,
screenX, screenY, det.Width, det.Height,
screenCx, screenCy,
wasConfirmed));
}
_activeEnemies = enemies;
var snapshot = new DetectionSnapshot(
enemies.AsReadOnly(),
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
result.InferenceMs);
_latest = snapshot;
DetectionUpdated?.Invoke(snapshot);
}
catch (Exception ex)
{
Log.Debug(ex, "EnemyDetector YOLO failed");
}
}
/// <summary>
/// Scan a narrow band above each enemy bbox for red health bar pixels (HSV threshold).
/// Returns updated list with HealthBarConfirmed set where detected.
/// </summary>
private static List<DetectedEnemy> ConfirmHealthBars(ScreenFrame frame, List<DetectedEnemy> enemies)
{
var updated = new List<DetectedEnemy>(enemies.Count);
foreach (var enemy in enemies)
{
if (enemy.HealthBarConfirmed)
{
updated.Add(enemy);
continue;
}
// Scan region: narrow strip above the bbox top edge
var scanY = Math.Max(0, enemy.Y - HealthBarHeight);
var scanHeight = Math.Min(HealthBarHeight, enemy.Y);
if (scanHeight <= 0 || enemy.Width <= 0)
{
updated.Add(enemy);
continue;
}
var scanRegion = new Region(
Math.Max(0, enemy.X),
scanY,
Math.Min(enemy.Width, frame.Width - Math.Max(0, enemy.X)),
scanHeight);
if (scanRegion.Width <= 0 || scanRegion.Height <= 0 ||
scanRegion.X + scanRegion.Width > frame.Width ||
scanRegion.Y + scanRegion.Height > frame.Height)
{
updated.Add(enemy);
continue;
}
try
{
var confirmed = HasRedHealthBar(frame, scanRegion);
updated.Add(enemy with { HealthBarConfirmed = confirmed });
}
catch
{
updated.Add(enemy);
}
}
return updated;
}
/// <summary>
/// Check if a region contains enough red pixels to indicate a health bar.
/// Red in HSV: H=0-10 or H=170-180, S>100, V>80.
/// </summary>
private static bool HasRedHealthBar(ScreenFrame frame, Region region)
{
using var bgr = frame.CropBgr(region);
using var hsv = new Mat();
Cv2.CvtColor(bgr, hsv, ColorConversionCodes.BGR2HSV);
// Red wraps around in HSV — check both ranges
using var mask1 = new Mat();
using var mask2 = new Mat();
Cv2.InRange(hsv, new Scalar(0, 100, 80), new Scalar(10, 255, 255), mask1);
Cv2.InRange(hsv, new Scalar(170, 100, 80), new Scalar(180, 255, 255), mask2);
using var combined = new Mat();
Cv2.BitwiseOr(mask1, mask2, combined);
var totalPixels = combined.Rows * combined.Cols;
var redPixels = Cv2.CountNonZero(combined);
var ratio = (float)redPixels / totalPixels;
return ratio >= RedPixelThreshold;
}
public void Dispose() => _bridge.Dispose();
}

View file

@ -13,6 +13,7 @@ public class FramePipeline : IDisposable
public IScreenCapture Capture => _capture;
public void AddConsumer(IFrameConsumer consumer) => _consumers.Add(consumer);
public void RemoveConsumer(IFrameConsumer consumer) => _consumers.Remove(consumer);
/// <summary>
/// Capture one frame, dispatch to all consumers in parallel, then dispose frame.

View file

@ -0,0 +1,37 @@
using Serilog;
namespace Poe2Trade.Screen;
public class FramePipelineService : IDisposable
{
public FramePipeline Pipeline { get; }
public IScreenCapture Backend { get; }
public FramePipelineService()
{
Backend = CreateBackend();
Pipeline = new FramePipeline(Backend);
}
private static IScreenCapture CreateBackend()
{
try
{
var dxgi = new DesktopDuplication();
Log.Information("Screen capture: DXGI Desktop Duplication");
return dxgi;
}
catch (Exception ex)
{
Log.Warning(ex, "DXGI unavailable, falling back to GDI");
}
Log.Information("Screen capture: GDI (CopyFromScreen)");
return new GdiCapture();
}
public void Dispose()
{
Pipeline.Dispose();
}
}

View file

@ -0,0 +1,118 @@
using OpenCvSharp;
using Serilog;
using Region = Poe2Trade.Core.Region;
namespace Poe2Trade.Screen;
/// <summary>
/// Saves full-screen frames as JPEGs for YOLO training data collection.
/// Only saves when: (1) health bars detected, (2) scene has changed since last save.
/// This avoids flooding disk when standing still in a dense pack.
/// </summary>
public class FrameSaver : IFrameConsumer
{
// Gameplay area at 2560x1440 — excludes HUD globes, minimap
private static readonly Region GameplayRegion = new(320, 100, 1920, 1200);
private const int JpegQuality = 95;
private const int MinSaveIntervalMs = 1000;
private const int MinRedPixels = 50;
private const int ThumbSize = 64;
private const double MovementThreshold = 8.0; // mean absolute diff on 64x64 grayscale
private readonly string _outputDir;
private int _savedCount;
private long _lastSaveTime;
private Mat? _prevThumb;
public bool Enabled { get; set; }
public int SavedCount => _savedCount;
public FrameSaver(string outputDir = "training-data/raw")
{
_outputDir = Path.GetFullPath(outputDir);
}
public void Process(ScreenFrame frame)
{
if (!Enabled) return;
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (now - _lastSaveTime < MinSaveIntervalMs) return;
if (GameplayRegion.X + GameplayRegion.Width > frame.Width ||
GameplayRegion.Y + GameplayRegion.Height > frame.Height)
return;
try
{
using var bgr = frame.CropBgr(GameplayRegion);
if (!HasHealthBars(bgr)) return;
if (!HasSceneChanged(bgr)) return;
if (!Directory.Exists(_outputDir))
Directory.CreateDirectory(_outputDir);
var fullRegion = new Region(0, 0, frame.Width, frame.Height);
using var fullBgr = frame.CropBgr(fullRegion);
var path = Path.Combine(_outputDir, $"frame_{now}.jpg");
var prms = new ImageEncodingParam(ImwriteFlags.JpegQuality, JpegQuality);
Cv2.ImWrite(path, fullBgr, [prms]);
_savedCount++;
_lastSaveTime = now;
if (_savedCount % 10 == 0)
Log.Information("FrameSaver: saved {Count} frames to {Dir}", _savedCount, _outputDir);
}
catch (Exception ex)
{
Log.Debug(ex, "FrameSaver failed to save frame");
}
}
/// <summary>
/// Scan for enemy health bar pixels (HSV threshold).
/// Target colors: #C1251E, #BB281C → H≈1-3, S≈215, V≈187-193.
/// </summary>
private static bool HasHealthBars(Mat bgr)
{
using var hsv = new Mat();
Cv2.CvtColor(bgr, hsv, ColorConversionCodes.BGR2HSV);
using var mask = new Mat();
Cv2.InRange(hsv, new Scalar(0, 150, 130), new Scalar(8, 255, 255), mask);
return Cv2.CountNonZero(mask) >= MinRedPixels;
}
/// <summary>
/// Compare a 64x64 grayscale thumbnail to the previous save.
/// Returns true if the scene changed enough (character moved).
/// </summary>
private bool HasSceneChanged(Mat bgr)
{
using var gray = new Mat();
Cv2.CvtColor(bgr, gray, ColorConversionCodes.BGR2GRAY);
var thumb = new Mat();
Cv2.Resize(gray, thumb, new Size(ThumbSize, ThumbSize), interpolation: InterpolationFlags.Area);
if (_prevThumb == null)
{
_prevThumb = thumb;
return true; // first frame, always save
}
using var diff = new Mat();
Cv2.Absdiff(thumb, _prevThumb, diff);
var mad = Cv2.Mean(diff).Val0;
_prevThumb.Dispose();
_prevThumb = thumb;
return mad >= MovementThreshold;
}
}

View file

@ -0,0 +1,35 @@
using Poe2Trade.Core;
using Serilog;
namespace Poe2Trade.Screen;
/// <summary>
/// Classifies the current game UI state by probing known pixel positions on each frame.
/// Near-zero cost (~0.01ms per frame) — just a few pixel reads.
/// </summary>
public class GameStateDetector : IFrameConsumer, IGameStateProvider
{
private volatile GameUiState _currentState = GameUiState.Unknown;
public GameUiState CurrentState => _currentState;
public event Action<GameUiState, GameUiState>? StateChanged;
public void Process(ScreenFrame frame)
{
var newState = Classify(frame);
var old = _currentState;
if (newState == old) return;
_currentState = newState;
Log.Debug("GameState: {Old} → {New}", old, newState);
StateChanged?.Invoke(old, newState);
}
private GameUiState Classify(ScreenFrame frame)
{
// TODO: Calibrate pixel probe positions from actual 2560x1440 screenshots.
// Each state has 2-3 characteristic pixels that distinguish it.
// For now, return Unknown — actual detection requires screenshot calibration.
return GameUiState.Unknown;
}
}

View file

@ -0,0 +1,110 @@
using System.Drawing;
using System.Text.RegularExpressions;
using OpenCvSharp;
using Poe2Trade.Core;
using Serilog;
using Region = Poe2Trade.Core.Region;
namespace Poe2Trade.Screen;
public record HudValues(int Current, int Max);
public record HudSnapshot
{
public HudValues? Life { get; init; }
public HudValues? Mana { get; init; }
public HudValues? EnergyShield { get; init; }
public HudValues? Spirit { get; init; }
public long Timestamp { get; init; }
public float LifePct => Life is { Max: > 0 } l ? (float)l.Current / l.Max : 1f;
public float ManaPct => Mana is { Max: > 0 } m ? (float)m.Current / m.Max : 1f;
}
/// <summary>
/// Reads life/mana/ES/spirit values from HUD globe text via OCR.
/// Throttled to ~1 read per second (every 30 frames at 30fps).
/// </summary>
public class HudReader : IFrameConsumer
{
private static readonly Regex ValuePattern = new(@"(\d+)\s*/\s*(\d+)", RegexOptions.Compiled);
// Crop regions for HUD text at 2560x1440 — placeholders, need calibration
private static readonly Region LifeRegion = new(100, 1340, 200, 40);
private static readonly Region ManaRegion = new(2260, 1340, 200, 40);
private static readonly Region EsRegion = new(100, 1300, 200, 40);
private static readonly Region SpiritRegion = new(2260, 1300, 200, 40);
private const int OcrEveryNFrames = 30;
private readonly PythonOcrBridge _ocr = new();
private volatile HudSnapshot _current = new() { Timestamp = 0 };
private int _frameCounter;
public HudSnapshot Current => _current;
public event Action<HudSnapshot>? Updated;
public event Action<HudSnapshot>? LowLife;
public void Process(ScreenFrame frame)
{
if (++_frameCounter % OcrEveryNFrames != 0) return;
try
{
var life = ReadValue(frame, LifeRegion);
var mana = ReadValue(frame, ManaRegion);
var es = ReadValue(frame, EsRegion);
var spirit = ReadValue(frame, SpiritRegion);
var snapshot = new HudSnapshot
{
Life = life,
Mana = mana,
EnergyShield = es,
Spirit = spirit,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
_current = snapshot;
Updated?.Invoke(snapshot);
if (snapshot.LifePct < 0.3f)
LowLife?.Invoke(snapshot);
}
catch (Exception ex)
{
Log.Debug(ex, "HudReader OCR failed");
}
}
private HudValues? ReadValue(ScreenFrame frame, Region region)
{
// Bounds check
if (region.X + region.Width > frame.Width || region.Y + region.Height > frame.Height)
return null;
using var bgr = frame.CropBgr(region);
using var gray = new Mat();
Cv2.CvtColor(bgr, gray, ColorConversionCodes.BGR2GRAY);
// Threshold for white text on dark background
using var thresh = new Mat();
Cv2.Threshold(gray, thresh, 180, 255, ThresholdTypes.Binary);
// Convert to Bitmap for OCR bridge
var bytes = thresh.ToBytes(".png");
using var ms = new System.IO.MemoryStream(bytes);
using var bitmap = new Bitmap(ms);
var result = _ocr.OcrFromBitmap(bitmap);
if (string.IsNullOrWhiteSpace(result.Text)) return null;
var match = ValuePattern.Match(result.Text);
if (!match.Success) return null;
return new HudValues(
int.Parse(match.Groups[1].Value),
int.Parse(match.Groups[2].Value)
);
}
}

View file

@ -0,0 +1,195 @@
namespace Poe2Trade.Screen;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenCvSharp;
using Serilog;
/// <summary>
/// Manages a persistent Python subprocess for YOLO object detection.
/// Lazy-starts on first request; reuses the process for subsequent calls.
/// Same stdin/stdout JSON-per-line protocol as PythonOcrBridge.
/// </summary>
class PythonDetectBridge : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private Process? _proc;
private readonly string _daemonScript;
private readonly string _pythonExe;
private readonly object _lock = new();
public PythonDetectBridge()
{
_daemonScript = Path.GetFullPath(Path.Combine("tools", "python-detect", "daemon.py"));
var venvPython = Path.GetFullPath(Path.Combine("tools", "python-detect", ".venv", "Scripts", "python.exe"));
_pythonExe = File.Exists(venvPython) ? venvPython : "python";
}
/// <summary>
/// Run YOLO detection on a BGR Mat. Returns parsed detection results.
/// </summary>
public DetectResponse Detect(Mat bgrMat, float conf = 0.3f, float iou = 0.45f, int imgsz = 640)
{
EnsureRunning();
var imageBytes = bgrMat.ToBytes(".png");
var imageBase64 = Convert.ToBase64String(imageBytes);
var req = new Dictionary<string, object?>
{
["cmd"] = "detect",
["imageBase64"] = imageBase64,
["conf"] = conf,
["iou"] = iou,
["imgsz"] = imgsz,
};
return SendRequest(req);
}
private DetectResponse SendRequest(object req)
{
var json = JsonSerializer.Serialize(req, JsonOptions);
string responseLine;
lock (_lock)
{
_proc!.StandardInput.WriteLine(json);
_proc.StandardInput.Flush();
responseLine = _proc.StandardOutput.ReadLine()
?? throw new Exception("Python detect daemon returned null");
}
var resp = JsonSerializer.Deserialize<PythonDetectResponse>(responseLine, JsonOptions);
if (resp == null)
throw new Exception("Failed to parse Python detect response");
if (!resp.Ok)
throw new Exception(resp.Error ?? "Python detect failed");
return new DetectResponse
{
Count = resp.Count,
InferenceMs = resp.InferenceMs,
Detections = resp.Detections ?? [],
};
}
private void EnsureRunning()
{
if (_proc != null && !_proc.HasExited)
return;
_proc?.Dispose();
_proc = null;
if (!File.Exists(_daemonScript))
throw new Exception($"Python detect daemon not found at {_daemonScript}");
Log.Information("Spawning Python detect daemon: {Python} {Script}", _pythonExe, _daemonScript);
var proc = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _pythonExe,
Arguments = $"\"{_daemonScript}\"",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
}
};
proc.ErrorDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
Log.Debug("[python-detect] {Line}", e.Data);
};
try
{
proc.Start();
proc.BeginErrorReadLine();
// Wait for ready signal (up to 60s for CUDA warmup)
var readyTask = Task.Run(() => proc.StandardOutput.ReadLine());
if (!readyTask.Wait(TimeSpan.FromSeconds(60)))
throw new Exception("Python detect daemon timed out waiting for ready signal");
var readyLine = readyTask.Result;
if (readyLine == null)
throw new Exception("Python detect daemon exited before ready signal");
var ready = JsonSerializer.Deserialize<PythonDetectResponse>(readyLine, JsonOptions);
if (ready?.Ready != true)
throw new Exception($"Python detect daemon did not send ready signal: {readyLine}");
}
catch
{
try { if (!proc.HasExited) proc.Kill(); } catch { /* best effort */ }
proc.Dispose();
throw;
}
_proc = proc;
Log.Information("Python detect daemon ready");
}
public void Dispose()
{
if (_proc != null && !_proc.HasExited)
{
try
{
_proc.StandardInput.Close();
_proc.WaitForExit(3000);
if (!_proc.HasExited) _proc.Kill();
}
catch { /* ignore */ }
}
_proc?.Dispose();
_proc = null;
}
// -- Response types --
public class DetectResponse
{
public int Count { get; set; }
public float InferenceMs { get; set; }
public List<Detection> Detections { get; set; } = [];
}
public class Detection
{
[JsonPropertyName("class")]
public string ClassName { get; set; } = "";
public int ClassId { get; set; }
public float Confidence { get; set; }
public int X { get; set; }
public int Y { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public int Cx { get; set; }
public int Cy { get; set; }
}
private class PythonDetectResponse
{
public bool Ok { get; set; }
public bool? Ready { get; set; }
public string? Error { get; set; }
public int Count { get; set; }
public float InferenceMs { get; set; }
public List<Detection>? Detections { get; set; }
}
}

View file

@ -104,7 +104,7 @@ class PythonOcrBridge : IDisposable
Log.Information("Spawning Python OCR daemon: {Python} {Script}", _pythonExe, _daemonScript);
_proc = new Process
var proc = new Process
{
StartInfo = new ProcessStartInfo
{
@ -118,24 +118,35 @@ class PythonOcrBridge : IDisposable
}
};
_proc.ErrorDataReceived += (_, e) =>
proc.ErrorDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
Log.Debug("[python-ocr] {Line}", e.Data);
};
_proc.Start();
_proc.BeginErrorReadLine();
try
{
proc.Start();
proc.BeginErrorReadLine();
// Wait for ready signal (up to 30s for first model load)
var readyLine = _proc.StandardOutput.ReadLine();
if (readyLine == null)
throw new Exception("Python OCR daemon exited before ready signal");
// Wait for ready signal (up to 30s for first model load)
var readyLine = proc.StandardOutput.ReadLine();
if (readyLine == null)
throw new Exception("Python OCR daemon exited before ready signal");
var ready = JsonSerializer.Deserialize<PythonResponse>(readyLine, JsonOptions);
if (ready?.Ready != true)
throw new Exception($"Python OCR daemon did not send ready signal: {readyLine}");
var ready = JsonSerializer.Deserialize<PythonResponse>(readyLine, JsonOptions);
if (ready?.Ready != true)
throw new Exception($"Python OCR daemon did not send ready signal: {readyLine}");
}
catch
{
// Kill orphaned process before re-throwing
try { if (!proc.HasExited) proc.Kill(); } catch { /* best effort */ }
proc.Dispose();
throw;
}
_proc = proc;
Log.Information("Python OCR daemon ready");
}

View file

@ -238,16 +238,16 @@ public class ScreenReader : IScreenReader
private static double BigramSimilarity(string a, string b)
{
if (a.Length < 2 || b.Length < 2) return a == b ? 1 : 0;
var bigramsA = new Dictionary<string, int>();
var bigramsA = new Dictionary<(char, char), int>();
for (var i = 0; i < a.Length - 1; i++)
{
var bg = a.Substring(i, 2);
var bg = (a[i], a[i + 1]);
bigramsA[bg] = bigramsA.GetValueOrDefault(bg) + 1;
}
var matches = 0;
for (var i = 0; i < b.Length - 1; i++)
{
var bg = b.Substring(i, 2);
var bg = (b[i], b[i + 1]);
if (bigramsA.TryGetValue(bg, out var count) && count > 0)
{
matches++;

View file

@ -40,6 +40,7 @@ public partial class App : Application
services.AddSingleton<IInventoryManager, InventoryManager>();
// Bot
services.AddSingleton<FramePipelineService>();
services.AddSingleton<LinkManager>();
services.AddSingleton<TradeExecutor>();
services.AddSingleton<TradeQueue>();

View file

@ -1,25 +1,76 @@
using Timer = System.Timers.Timer;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using Poe2Trade.Bot;
using Poe2Trade.Core;
using Poe2Trade.Screen;
namespace Poe2Trade.Ui.ViewModels;
public partial class MappingViewModel : ObservableObject
public partial class MappingViewModel : ObservableObject, IDisposable
{
private readonly BotOrchestrator _bot;
private readonly Timer _statsTimer;
[ObservableProperty] private MapType _selectedMapType;
[ObservableProperty] private bool _isFrameSaverEnabled;
[ObservableProperty] private int _framesSaved;
[ObservableProperty] private bool _isDetectionEnabled;
[ObservableProperty] private int _enemiesDetected;
[ObservableProperty] private float _inferenceMs;
[ObservableProperty] private bool _hasModel;
public static MapType[] MapTypes { get; } = [MapType.TrialOfChaos, MapType.Temple, MapType.Endgame];
private static readonly string ModelPath = Path.GetFullPath("tools/python-detect/models/enemy-v1.pt");
public MappingViewModel(BotOrchestrator bot)
{
_bot = bot;
_selectedMapType = bot.Config.MapType;
_hasModel = File.Exists(ModelPath);
_bot.EnemyDetector.DetectionUpdated += OnDetectionUpdated;
_statsTimer = new Timer(1000);
_statsTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(RefreshStats);
_statsTimer.Start();
}
partial void OnSelectedMapTypeChanged(MapType value)
{
_bot.Store.UpdateSettings(s => s.MapType = value);
}
partial void OnIsFrameSaverEnabledChanged(bool value)
{
_bot.FrameSaver.Enabled = value;
}
partial void OnIsDetectionEnabledChanged(bool value)
{
_bot.EnemyDetector.Enabled = value;
}
private void OnDetectionUpdated(DetectionSnapshot snapshot)
{
Dispatcher.UIThread.Post(() =>
{
EnemiesDetected = snapshot.Enemies.Count;
InferenceMs = snapshot.InferenceMs;
});
}
private void RefreshStats()
{
FramesSaved = _bot.FrameSaver.SavedCount;
HasModel = File.Exists(ModelPath);
}
public void Dispose()
{
_statsTimer.Stop();
_statsTimer.Dispose();
_bot.EnemyDetector.DetectionUpdated -= OnDetectionUpdated;
}
}

View file

@ -213,20 +213,66 @@
<!-- ========== MAPPING TAB ========== -->
<TabItem Header="Mapping">
<Border DataContext="{Binding MappingVm}" Background="#161b22"
BorderBrush="#30363d" BorderThickness="1" CornerRadius="8"
Padding="10" Margin="0,6,0,0">
<StackPanel Spacing="8" x:DataType="vm:MappingViewModel">
<TextBlock Text="MAP TYPE" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<ComboBox ItemsSource="{x:Static vm:MappingViewModel.MapTypes}"
SelectedItem="{Binding SelectedMapType}" Width="200" />
<TextBlock Text="REQUIRED ITEMS" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" Margin="0,8,0,0" />
<TextBlock Text="{Binding SelectedMapType, Converter={StaticResource MapRequirementsText}}"
FontSize="13" Foreground="#e6edf3" />
<ScrollViewer DataContext="{Binding MappingVm}" Margin="0,6,0,0">
<StackPanel Spacing="8" Margin="0" x:DataType="vm:MappingViewModel">
<!-- Map Type -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="MAP TYPE" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<ComboBox ItemsSource="{x:Static vm:MappingViewModel.MapTypes}"
SelectedItem="{Binding SelectedMapType}" Width="200" />
<TextBlock Text="REQUIRED ITEMS" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" Margin="0,8,0,0" />
<TextBlock Text="{Binding SelectedMapType, Converter={StaticResource MapRequirementsText}}"
FontSize="13" Foreground="#e6edf3" />
</StackPanel>
</Border>
<!-- Training Data -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="TRAINING DATA" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<ToggleSwitch IsChecked="{Binding IsFrameSaverEnabled}"
OnContent="Save Frames" OffContent="Save Frames" />
<TextBlock FontSize="12" Foreground="#8b949e">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} frames saved to training-data/raw/">
<Binding Path="FramesSaved" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
</Border>
<!-- Enemy Detection -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="ENEMY DETECTION" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<ToggleSwitch IsChecked="{Binding IsDetectionEnabled}"
IsEnabled="{Binding HasModel}"
OnContent="Detection On" OffContent="Detection Off" />
<StackPanel Orientation="Horizontal" Spacing="16"
IsVisible="{Binding HasModel}">
<TextBlock Text="{Binding EnemiesDetected, StringFormat='{}{0} enemies'}"
FontSize="12" Foreground="#e6edf3" />
<TextBlock Text="{Binding InferenceMs, StringFormat='{}{0:F1}ms inference'}"
FontSize="12" Foreground="#8b949e" />
</StackPanel>
<TextBlock Text="No model found. Train one first."
FontSize="12" Foreground="#484f58"
IsVisible="{Binding !HasModel}" />
</StackPanel>
</Border>
</StackPanel>
</Border>
</ScrollViewer>
</TabItem>
<!-- ========== DEBUG TAB ========== -->