From 3ae65d0e641436eb2ec6ce98c9483124fc62088c Mon Sep 17 00:00:00 2001 From: Boki Date: Wed, 18 Feb 2026 19:41:05 -0500 Subject: [PATCH] adding stash calibration --- src/Poe2Trade.Core/ConfigStore.cs | 2 + src/Poe2Trade.Core/StashCalibration.cs | 18 ++ src/Poe2Trade.Inventory/StashCalibrator.cs | 177 ++++++++++++++++++ .../NavigationExecutor.cs | 129 ++++++++----- src/Poe2Trade.Navigation/PathFinder.cs | 156 ++++++++++----- src/Poe2Trade.Ui/App.axaml.cs | 5 + src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs | 19 ++ .../Overlay/Layers/DebugTextLayer.cs | 60 ++++++ .../Overlay/Layers/EnemyBoxLayer.cs | 36 ++++ .../Overlay/Layers/HudInfoLayer.cs | 47 +++++ src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs | 104 ++++++++++ .../Overlay/OverlayNativeMethods.cs | 36 ++++ src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml | 13 ++ .../Overlay/OverlayWindow.axaml.cs | 38 ++++ src/Poe2Trade.Ui/Poe2Trade.Ui.csproj | 1 + src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs | 117 ++++++++++-- src/Poe2Trade.Ui/Views/MainWindow.axaml | 1 + 17 files changed, 848 insertions(+), 111 deletions(-) create mode 100644 src/Poe2Trade.Core/StashCalibration.cs create mode 100644 src/Poe2Trade.Inventory/StashCalibrator.cs create mode 100644 src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs create mode 100644 src/Poe2Trade.Ui/Overlay/Layers/DebugTextLayer.cs create mode 100644 src/Poe2Trade.Ui/Overlay/Layers/EnemyBoxLayer.cs create mode 100644 src/Poe2Trade.Ui/Overlay/Layers/HudInfoLayer.cs create mode 100644 src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs create mode 100644 src/Poe2Trade.Ui/Overlay/OverlayNativeMethods.cs create mode 100644 src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml create mode 100644 src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml.cs diff --git a/src/Poe2Trade.Core/ConfigStore.cs b/src/Poe2Trade.Core/ConfigStore.cs index dcade9d..9f1bc8f 100644 --- a/src/Poe2Trade.Core/ConfigStore.cs +++ b/src/Poe2Trade.Core/ConfigStore.cs @@ -32,6 +32,8 @@ public class SavedSettings public bool Headless { get; set; } = true; public BotMode Mode { get; set; } = BotMode.Trading; public MapType MapType { get; set; } = MapType.TrialOfChaos; + public StashCalibration? StashCalibration { get; set; } + public StashCalibration? ShopCalibration { get; set; } } public class ConfigStore diff --git a/src/Poe2Trade.Core/StashCalibration.cs b/src/Poe2Trade.Core/StashCalibration.cs new file mode 100644 index 0000000..a72be97 --- /dev/null +++ b/src/Poe2Trade.Core/StashCalibration.cs @@ -0,0 +1,18 @@ +namespace Poe2Trade.Core; + +public class StashTabInfo +{ + public string Name { get; set; } = ""; + public int Index { get; set; } + public int ClickX { get; set; } + public int ClickY { get; set; } + public bool IsFolder { get; set; } + public int GridCols { get; set; } = 12; + public List SubTabs { get; set; } = []; +} + +public class StashCalibration +{ + public List Tabs { get; set; } = []; + public long CalibratedAt { get; set; } +} diff --git a/src/Poe2Trade.Inventory/StashCalibrator.cs b/src/Poe2Trade.Inventory/StashCalibrator.cs new file mode 100644 index 0000000..105e9f0 --- /dev/null +++ b/src/Poe2Trade.Inventory/StashCalibrator.cs @@ -0,0 +1,177 @@ +using Poe2Trade.Core; +using Poe2Trade.Game; +using Poe2Trade.Screen; +using Serilog; + +namespace Poe2Trade.Inventory; + +public class StashCalibrator +{ + private readonly IScreenReader _screen; + private readonly IGameController _game; + + // Tab bar sits above the stash grid + private static readonly Region TabBarRegion = new(23, 95, 840, 75); + // Sub-tab row between tab bar and folder grid (folders push grid down) + private static readonly Region SubTabRegion = new(23, 165, 840, 50); + // Horizontal gap (px) between OCR words to split into separate tab names + private const int TabGapThreshold = 25; + private const int PostClickDelay = 600; + + public StashCalibrator(IScreenReader screen, IGameController game) + { + _screen = screen; + _game = game; + } + + /// + /// Calibrates an already-open stash/shop panel. + /// OCRs tab bar, clicks each tab, detects folders and grid size. + /// + public async Task CalibrateOpenPanel() + { + var tabs = await OcrTabBar(TabBarRegion); + Log.Information("StashCalibrator: found {Count} tabs: {Names}", + tabs.Count, string.Join(", ", tabs.Select(t => t.Name))); + + for (var i = 0; i < tabs.Count; i++) + { + var tab = tabs[i]; + tab.Index = i; + + // Click this tab + await _game.LeftClickAt(tab.ClickX, tab.ClickY); + await Helpers.Sleep(PostClickDelay); + + // Check for sub-tabs (folder detection) + var subTabs = await OcrTabBar(SubTabRegion); + if (subTabs.Count > 0) + { + tab.IsFolder = true; + Log.Information("StashCalibrator: tab '{Name}' is a folder with {Count} sub-tabs", + tab.Name, subTabs.Count); + + for (var j = 0; j < subTabs.Count; j++) + { + var sub = subTabs[j]; + sub.Index = j; + + // Click sub-tab to detect its grid size + await _game.LeftClickAt(sub.ClickX, sub.ClickY); + await Helpers.Sleep(PostClickDelay); + sub.GridCols = await DetectGridSize(isFolder: true); + } + + tab.SubTabs = subTabs; + // Folder's own grid cols = first sub-tab's (they're usually the same) + tab.GridCols = subTabs[0].GridCols; + } + else + { + tab.IsFolder = false; + tab.GridCols = await DetectGridSize(isFolder: false); + } + } + + return new StashCalibration + { + Tabs = tabs, + CalibratedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + } + + /// + /// OCR a region and group words into tab names by horizontal gap. + /// Returns list with screen-absolute click positions. + /// + private async Task> OcrTabBar(Region region) + { + var ocr = await _screen.Ocr(region); + var allWords = ocr.Lines.SelectMany(l => l.Words).ToList(); + if (allWords.Count == 0) return []; + + return GroupWordsIntoTabs(allWords, region, TabGapThreshold); + } + + /// + /// Groups OCR words into tab names based on horizontal gaps. + /// Words within gapThreshold px → same tab. Larger gaps → separate tabs. + /// Converts region-relative coords to screen-absolute. + /// + private static List GroupWordsIntoTabs(List words, Region region, int gapThreshold) + { + // Sort left-to-right by X position + var sorted = words.OrderBy(w => w.X).ToList(); + var tabs = new List(); + + var currentWords = new List { sorted[0] }; + + for (var i = 1; i < sorted.Count; i++) + { + var prev = currentWords[^1]; + var curr = sorted[i]; + var gap = curr.X - (prev.X + prev.Width); + + if (gap > gapThreshold) + { + // Flush current group as a tab + tabs.Add(BuildTab(currentWords, region)); + currentWords = [curr]; + } + else + { + currentWords.Add(curr); + } + } + + // Flush last group + tabs.Add(BuildTab(currentWords, region)); + + return tabs; + } + + private static StashTabInfo BuildTab(List words, Region region) + { + var name = string.Join(" ", words.Select(w => w.Text)); + var minX = words.Min(w => w.X); + var maxX = words.Max(w => w.X + w.Width); + var minY = words.Min(w => w.Y); + var maxY = words.Max(w => w.Y + w.Height); + + // Click center of the bounding box, converted to screen-absolute + var clickX = region.X + (minX + maxX) / 2; + var clickY = region.Y + (minY + maxY) / 2; + + return new StashTabInfo + { + Name = name, + ClickX = clickX, + ClickY = clickY + }; + } + + /// + /// Detect grid size (12 or 24 columns) by scanning with both layouts. + /// The correct layout aligns with actual cells, so empty cells match the + /// empty template → lower occupancy. The wrong layout misaligns → ~100% occupied. + /// + private async Task DetectGridSize(bool isFolder) + { + var layout12 = isFolder ? "stash12_folder" : "stash12"; + var layout24 = isFolder ? "stash24_folder" : "stash24"; + + var scan12 = await _screen.Grid.Scan(layout12); + var scan24 = await _screen.Grid.Scan(layout24); + + var total12 = scan12.Layout.Cols * scan12.Layout.Rows; + var total24 = scan24.Layout.Cols * scan24.Layout.Rows; + + var rate12 = (double)scan12.Occupied.Count / total12; + var rate24 = (double)scan24.Occupied.Count / total24; + + Log.Information("StashCalibrator: grid detection - 12col={Rate12:P1} ({Occ12}/{Tot12}), 24col={Rate24:P1} ({Occ24}/{Tot24})", + rate12, scan12.Occupied.Count, total12, rate24, scan24.Occupied.Count, total24); + + return rate12 <= rate24 ? 12 : 24; + } +} diff --git a/src/Poe2Trade.Navigation/NavigationExecutor.cs b/src/Poe2Trade.Navigation/NavigationExecutor.cs index 32771b7..5470900 100644 --- a/src/Poe2Trade.Navigation/NavigationExecutor.cs +++ b/src/Poe2Trade.Navigation/NavigationExecutor.cs @@ -171,61 +171,27 @@ public class NavigationExecutor : IDisposable var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); if (NeedsRepath(now)) { - if (_stuck.IsStuck) - SetState(NavigationState.Stuck); - else - SetState(NavigationState.Planning); - - (double dirX, double dirY)? direction = null; - - if (_checkpointGoal is { } cpGoal) + if (TryRepath(pos, now)) { - // Try to path to checkpoint goal - direction = _worldMap.FindPathToTarget(pos, cpGoal); - if (direction == null) - { - Log.Information("Checkpoint unreachable, clearing goal"); - _checkpointGoal = null; - } + SetState(NavigationState.Completed); + break; } - - if (_checkpointGoal == null) - { - // Look for nearby checkpoint to collect opportunistically - var nearCp = _worldMap.GetNearestCheckpointOff(pos); - if (nearCp != null) - { - _checkpointGoal = nearCp.Value; - direction = _worldMap.FindPathToTarget(pos, nearCp.Value); - if (direction != null) - Log.Information("Detouring to checkpoint at ({X},{Y})", nearCp.Value.X, nearCp.Value.Y); - else - _checkpointGoal = null; // unreachable, fall through to frontier - } - } - - // Fall back to normal frontier exploration - if (direction == null) - { - direction = _worldMap.FindNearestUnexplored(pos); - if (direction == null) - { - Log.Information("Map fully explored"); - SetState(NavigationState.Completed); - break; - } - } - - _currentPath = _worldMap.LastBfsPath; - _pathIndex = 0; - _lastPathTime = now; - - if (_stuck.IsStuck) - _stuck.Reset(); } - // 3. Every frame: follow path waypoints → post direction to input loop + // 4. Follow path → if endpoint reached, immediately repath for seamless movement var dir = FollowPath(pos); + if (dir == null) + { + _currentPath = null; // force NeedsRepath on retry + now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (TryRepath(pos, now)) + { + SetState(NavigationState.Completed); + break; + } + dir = FollowPath(pos); + } + if (dir != null) { SetState(NavigationState.Moving); @@ -365,6 +331,63 @@ public class NavigationExecutor : IDisposable } } + /// + /// Compute a new path (checkpoint → nearby checkpoint → frontier). + /// Returns true if map is fully explored (no more frontiers). + /// + private bool TryRepath(MapPosition pos, long nowMs) + { + if (_stuck.IsStuck) + SetState(NavigationState.Stuck); + else + SetState(NavigationState.Planning); + + (double dirX, double dirY)? direction = null; + + if (_checkpointGoal is { } cpGoal) + { + direction = _worldMap.FindPathToTarget(pos, cpGoal); + if (direction == null) + { + Log.Information("Checkpoint unreachable, clearing goal"); + _checkpointGoal = null; + } + } + + if (_checkpointGoal == null) + { + var nearCp = _worldMap.GetNearestCheckpointOff(pos); + if (nearCp != null) + { + _checkpointGoal = nearCp.Value; + direction = _worldMap.FindPathToTarget(pos, nearCp.Value); + if (direction != null) + Log.Information("Detouring to checkpoint at ({X},{Y})", nearCp.Value.X, nearCp.Value.Y); + else + _checkpointGoal = null; + } + } + + if (direction == null) + { + direction = _worldMap.FindNearestUnexplored(pos); + if (direction == null) + { + Log.Information("Map fully explored"); + return true; + } + } + + _currentPath = _worldMap.LastBfsPath; + _pathIndex = 0; + _lastPathTime = nowMs; + + if (_stuck.IsStuck) + _stuck.Reset(); + + return false; + } + /// /// Whether a new BFS path is needed: path consumed, stuck, or stale (>3s). /// @@ -415,7 +438,11 @@ public class NavigationExecutor : IDisposable var tdx = target.X - px; var tdy = target.Y - py; var len = Math.Sqrt(tdx * tdx + tdy * tdy); - if (len < 1) return null; // essentially at target + if (len < 1) + { + _pathIndex = _currentPath.Count; // mark consumed so NeedsRepath triggers + return null; + } return (tdx / len, tdy / len); } diff --git a/src/Poe2Trade.Navigation/PathFinder.cs b/src/Poe2Trade.Navigation/PathFinder.cs index ea783ac..b36faf1 100644 --- a/src/Poe2Trade.Navigation/PathFinder.cs +++ b/src/Poe2Trade.Navigation/PathFinder.cs @@ -40,32 +40,57 @@ internal class PathFinder var gridLen = gridW * gridW; var visited = new bool[gridLen]; - var dist = new short[gridLen]; + var cost = new int[gridLen]; var parentX = new short[gridLen]; var parentY = new short[gridLen]; - var queue = new Queue<(int gx, int gy)>(4096); - var startGx = rr; - var startGy = rr; - var startIdx = startGy * gridW + startGx; - visited[startIdx] = true; - parentX[startIdx] = (short)startGx; - parentY[startIdx] = (short)startGy; - queue.Enqueue((startGx, startGy)); - // 8-connected neighbors ReadOnlySpan dxs = [-1, 0, 1, -1, 1, -1, 0, 1]; ReadOnlySpan dys = [-1, -1, -1, 0, 0, 1, 1, 1]; - // Step A: BFS flood-fill, recording dist and parents + // Precompute wall proximity: count of 8 canvas-level neighbors that are Wall + var wallNear = new byte[gridLen]; + for (var gy = 0; gy < gridW; gy++) + { + for (var gx = 0; gx < gridW; gx++) + { + var wx = cx + (gx - rr) * step; + var wy = cy + (gy - rr) * step; + if (wx < 1 || wx >= size - 1 || wy < 1 || wy >= size - 1) continue; + byte count = 0; + for (var d = 0; d < 8; d++) + { + if (canvas.At(wy + dys[d], wx + dxs[d]) == (byte)MapCell.Wall) + count++; + } + wallNear[gy * gridW + gx] = count; + } + } + + // Dijkstra setup + Array.Fill(cost, int.MaxValue); + var startGx = rr; + var startGy = rr; + var startIdx = startGy * gridW + startGx; + cost[startIdx] = 0; + parentX[startIdx] = (short)startGx; + parentY[startIdx] = (short)startGy; + + var pq = new PriorityQueue<(int gx, int gy), int>(4096); + pq.Enqueue((startGx, startGy), 0); + + // Step A: Dijkstra flood-fill with wall-proximity cost var isFrontier = new bool[gridLen]; var frontierCells = new List<(int gx, int gy)>(); - while (queue.Count > 0) + while (pq.Count > 0) { - var (gx, gy) = queue.Dequeue(); + var (gx, gy) = pq.Dequeue(); var cellIdx = gy * gridW + gx; + if (visited[cellIdx]) continue; + visited[cellIdx] = true; + // Map grid coords back to canvas coords var wx = cx + (gx - rr) * step; var wy = cy + (gy - rr) * step; @@ -105,11 +130,14 @@ internal class PathFinder var cell = canvas.At(nwy, nwx); if (cell != (byte)MapCell.Explored && cell != (byte)MapCell.Fog) continue; - visited[idx] = true; - dist[idx] = (short)(dist[cellIdx] + 1); - parentX[idx] = (short)gx; - parentY[idx] = (short)gy; - queue.Enqueue((ngx, ngy)); + var newCost = cost[cellIdx] + 10 + wallNear[idx] * 3; + if (newCost < cost[idx]) + { + cost[idx] = newCost; + parentX[idx] = (short)gx; + parentY[idx] = (short)gy; + pq.Enqueue((ngx, ngy), newCost); + } } } @@ -138,7 +166,7 @@ internal class PathFinder clusterCount++; // Flood-fill this cluster var clusterCells = new List<(int gx, int gy)>(); - var minDist = dist[fIdx]; + var minCost = cost[fIdx]; var entryGx = fgx; var entryGy = fgy; @@ -151,9 +179,9 @@ internal class PathFinder clusterCells.Add((cgx, cgy)); var cIdx = cgy * gridW + cgx; - if (dist[cIdx] < minDist) + if (cost[cIdx] < minCost) { - minDist = dist[cIdx]; + minCost = cost[cIdx]; entryGx = cgx; entryGy = cgy; } @@ -170,21 +198,30 @@ internal class PathFinder } } - // Step D: Score this cluster + // Step D: Score this cluster — skip tiny nooks + const int MinClusterSize = 8; var gain = clusterCells.Count; - var cost = (int)minDist; - var score = gain / (cost + 1.0); + if (gain < MinClusterSize) continue; + var pathCost = (int)minCost; + var score = gain / (pathCost + 1.0); if (score > bestClusterScore) { bestClusterScore = score; bestClusterGain = gain; - bestClusterCost = cost; + bestClusterCost = pathCost; bestEntryGx = entryGx; bestEntryGy = entryGy; } } + if (bestEntryGx < 0) + { + Log.Information("BFS: all {Count} frontier clusters too small (< 8 cells)", clusterCount); + LastResult = null; + return null; + } + // Step E: Trace path from entry cell of winning cluster back to start var rawPath = new List(); var traceGx = bestEntryGx; @@ -240,31 +277,60 @@ internal class PathFinder var rr = searchRadius / step; var gridW = 2 * rr + 1; - var visited = new bool[gridW * gridW]; - var parentX = new short[gridW * gridW]; - var parentY = new short[gridW * gridW]; - - var queue = new Queue<(int gx, int gy)>(4096); - var startGx = rr; - var startGy = rr; - var startIdx = startGy * gridW + startGx; - visited[startIdx] = true; - parentX[startIdx] = (short)startGx; - parentY[startIdx] = (short)startGy; - queue.Enqueue((startGx, startGy)); + var gridLen = gridW * gridW; + var visited = new bool[gridLen]; + var cost = new int[gridLen]; + var parentX = new short[gridLen]; + var parentY = new short[gridLen]; ReadOnlySpan dxs = [-1, 0, 1, -1, 1, -1, 0, 1]; ReadOnlySpan dys = [-1, -1, -1, 0, 0, 1, 1, 1]; + // Precompute wall proximity: count of 8 canvas-level neighbors that are Wall + var wallNear = new byte[gridLen]; + for (var gy = 0; gy < gridW; gy++) + { + for (var gx = 0; gx < gridW; gx++) + { + var wx = cx + (gx - rr) * step; + var wy = cy + (gy - rr) * step; + if (wx < 1 || wx >= canvasSize - 1 || wy < 1 || wy >= canvasSize - 1) continue; + byte count = 0; + for (var d = 0; d < 8; d++) + { + if (canvas.At(wy + dys[d], wx + dxs[d]) == (byte)MapCell.Wall) + count++; + } + wallNear[gy * gridW + gx] = count; + } + } + + // Dijkstra setup + Array.Fill(cost, int.MaxValue); + var startGx = rr; + var startGy = rr; + var startIdx = startGy * gridW + startGx; + cost[startIdx] = 0; + parentX[startIdx] = (short)startGx; + parentY[startIdx] = (short)startGy; + + var pq = new PriorityQueue<(int gx, int gy), int>(4096); + pq.Enqueue((startGx, startGy), 0); + const int arrivalDist = 10; const int arrivalDist2 = arrivalDist * arrivalDist; var foundGx = -1; var foundGy = -1; - while (queue.Count > 0) + while (pq.Count > 0) { - var (gx, gy) = queue.Dequeue(); + var (gx, gy) = pq.Dequeue(); + var cellIdx = gy * gridW + gx; + + if (visited[cellIdx]) continue; + visited[cellIdx] = true; + var wx = cx + (gx - rr) * step; var wy = cy + (gy - rr) * step; @@ -294,10 +360,14 @@ internal class PathFinder var cell = canvas.At(nwy, nwx); if (cell != (byte)MapCell.Explored && cell != (byte)MapCell.Fog) continue; - visited[idx] = true; - parentX[idx] = (short)gx; - parentY[idx] = (short)gy; - queue.Enqueue((ngx, ngy)); + var newCost = cost[cellIdx] + 10 + wallNear[idx] * 3; + if (newCost < cost[idx]) + { + cost[idx] = newCost; + parentX[idx] = (short)gx; + parentY[idx] = (short)gy; + pq.Enqueue((ngx, ngy), newCost); + } } } diff --git a/src/Poe2Trade.Ui/App.axaml.cs b/src/Poe2Trade.Ui/App.axaml.cs index 2417683..1fe3ca7 100644 --- a/src/Poe2Trade.Ui/App.axaml.cs +++ b/src/Poe2Trade.Ui/App.axaml.cs @@ -9,6 +9,7 @@ using Poe2Trade.GameLog; using Poe2Trade.Inventory; using Poe2Trade.Screen; using Poe2Trade.Trade; +using Poe2Trade.Ui.Overlay; using Poe2Trade.Ui.ViewModels; using Poe2Trade.Ui.Views; @@ -66,8 +67,12 @@ public partial class App : Application window.SetConfigStore(store); desktop.MainWindow = window; + var overlay = new OverlayWindow(bot); + overlay.Show(); + desktop.ShutdownRequested += async (_, _) => { + overlay.Close(); mainVm.Shutdown(); await bot.DisposeAsync(); }; diff --git a/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs b/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs new file mode 100644 index 0000000..ff2a8c0 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs @@ -0,0 +1,19 @@ +using Avalonia.Media; +using Poe2Trade.Navigation; +using Poe2Trade.Screen; + +namespace Poe2Trade.Ui.Overlay; + +public record OverlayState( + IReadOnlyList Enemies, + float InferenceMs, + HudSnapshot? Hud, + NavigationState NavState, + MapPosition NavPosition, + bool IsExploring, + double Fps); + +public interface IOverlayLayer +{ + void Draw(DrawingContext dc, OverlayState state); +} diff --git a/src/Poe2Trade.Ui/Overlay/Layers/DebugTextLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/DebugTextLayer.cs new file mode 100644 index 0000000..e632821 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/Layers/DebugTextLayer.cs @@ -0,0 +1,60 @@ +using Avalonia; +using Avalonia.Media; + +namespace Poe2Trade.Ui.Overlay.Layers; + +public class DebugTextLayer : IOverlayLayer +{ + private static readonly Typeface MonoTypeface = new("Consolas"); + private static readonly IBrush TextBrush = new SolidColorBrush(Color.FromRgb(80, 255, 80)); + private static readonly IBrush Background = new SolidColorBrush(Color.FromArgb(160, 0, 0, 0)); + + private const double PadX = 8; + private const double PadY = 4; + private const double StartX = 10; + private const double StartY = 10; + private const double FontSize = 13; + + public void Draw(DrawingContext dc, OverlayState state) + { + var lines = new List(8) + { + $"FPS: {state.Fps:F0}", + $"Nav: {state.NavState}{(state.IsExploring ? " [exploring]" : "")}", + $"Pos: ({state.NavPosition.X:F0}, {state.NavPosition.Y:F0})", + $"Enemies: {state.Enemies.Count} YOLO: {state.InferenceMs:F1}ms" + }; + + if (state.Hud is { Timestamp: > 0 } hud) + { + lines.Add($"HP: {hud.LifePct:P0} MP: {hud.ManaPct:P0}"); + } + + // Measure max width for background + double maxWidth = 0; + double totalHeight = 0; + var formatted = new List(lines.Count); + + foreach (var line in lines) + { + var ft = new FormattedText(line, System.Globalization.CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, MonoTypeface, FontSize, TextBrush); + formatted.Add(ft); + if (ft.Width > maxWidth) maxWidth = ft.Width; + totalHeight += ft.Height; + } + + // Draw background + dc.DrawRectangle(Background, null, + new Rect(StartX - PadX, StartY - PadY, + maxWidth + PadX * 2, totalHeight + PadY * 2)); + + // Draw text lines + var y = StartY; + foreach (var ft in formatted) + { + dc.DrawText(ft, new Point(StartX, y)); + y += ft.Height; + } + } +} diff --git a/src/Poe2Trade.Ui/Overlay/Layers/EnemyBoxLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/EnemyBoxLayer.cs new file mode 100644 index 0000000..6efcdd4 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/Layers/EnemyBoxLayer.cs @@ -0,0 +1,36 @@ +using Avalonia; +using Avalonia.Media; + +namespace Poe2Trade.Ui.Overlay.Layers; + +public class EnemyBoxLayer : IOverlayLayer +{ + // Pre-allocated pens — zero allocation per frame + private static readonly IPen ConfirmedPen = new Pen(Brushes.Red, 2); + private static readonly IPen UnconfirmedPen = new Pen(Brushes.Yellow, 2); + private static readonly Typeface LabelTypeface = new("Consolas"); + private static readonly IBrush LabelBackground = new SolidColorBrush(Color.FromArgb(160, 0, 0, 0)); + + public void Draw(DrawingContext dc, OverlayState state) + { + foreach (var enemy in state.Enemies) + { + var pen = enemy.HealthBarConfirmed ? ConfirmedPen : UnconfirmedPen; + var rect = new Rect(enemy.X, enemy.Y, enemy.Width, enemy.Height); + dc.DrawRectangle(null, pen, rect); + + // Confidence label above the box + var label = $"{enemy.Confidence:P0}"; + var text = new FormattedText(label, System.Globalization.CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, LabelTypeface, 12, pen.Brush); + + var labelX = enemy.X; + var labelY = enemy.Y - text.Height - 2; + + // Background for readability + dc.DrawRectangle(LabelBackground, null, + new Rect(labelX - 1, labelY - 1, text.Width + 2, text.Height + 2)); + dc.DrawText(text, new Point(labelX, labelY)); + } + } +} diff --git a/src/Poe2Trade.Ui/Overlay/Layers/HudInfoLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/HudInfoLayer.cs new file mode 100644 index 0000000..b284f87 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/Layers/HudInfoLayer.cs @@ -0,0 +1,47 @@ +using Avalonia; +using Avalonia.Media; + +namespace Poe2Trade.Ui.Overlay.Layers; + +public class HudInfoLayer : IOverlayLayer +{ + private static readonly IBrush LifeBrush = new SolidColorBrush(Color.FromRgb(200, 40, 40)); + private static readonly IBrush ManaBrush = new SolidColorBrush(Color.FromRgb(40, 80, 200)); + private static readonly IBrush BarBackground = new SolidColorBrush(Color.FromArgb(140, 20, 20, 20)); + private static readonly IPen BarBorder = new Pen(Brushes.Gray, 1); + private static readonly Typeface ValueTypeface = new("Consolas"); + + // Bar dimensions — positioned bottom-center above globe area + private const double BarWidth = 200; + private const double BarHeight = 16; + private const double BarY = 1300; // above the globe at 2560x1440 + private const double LifeBarX = 1130; // left of center + private const double ManaBarX = 1230; // right of center + + public void Draw(DrawingContext dc, OverlayState state) + { + if (state.Hud == null || state.Hud.Timestamp == 0) return; + + DrawBar(dc, LifeBarX, BarY, state.Hud.LifePct, LifeBrush, state.Hud.Life); + DrawBar(dc, ManaBarX, BarY, state.Hud.ManaPct, ManaBrush, state.Hud.Mana); + } + + private static void DrawBar(DrawingContext dc, double x, double y, float pct, + IBrush fillBrush, Screen.HudValues? values) + { + var outer = new Rect(x, y, BarWidth, BarHeight); + dc.DrawRectangle(BarBackground, BarBorder, outer); + + var fillWidth = BarWidth * Math.Clamp(pct, 0, 1); + if (fillWidth > 0) + dc.DrawRectangle(fillBrush, null, new Rect(x, y, fillWidth, BarHeight)); + + if (values != null) + { + var label = $"{values.Current}/{values.Max}"; + var text = new FormattedText(label, System.Globalization.CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, ValueTypeface, 11, Brushes.White); + dc.DrawText(text, new Point(x + (BarWidth - text.Width) / 2, y + (BarHeight - text.Height) / 2)); + } + } +} diff --git a/src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs b/src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs new file mode 100644 index 0000000..5bb11c6 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs @@ -0,0 +1,104 @@ +using System.Diagnostics; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using Poe2Trade.Bot; +using Poe2Trade.Navigation; +using Poe2Trade.Ui.Overlay.Layers; + +namespace Poe2Trade.Ui.Overlay; + +public class OverlayCanvas : Control +{ + private readonly List _layers = []; + private BotOrchestrator? _bot; + private DispatcherTimer? _timer; + private nint _hwnd; + private bool _shown; + + // FPS tracking + private readonly Stopwatch _fpsWatch = new(); + private int _frameCount; + private double _fps; + + public void Initialize(BotOrchestrator bot) + { + _bot = bot; + + _layers.Add(new EnemyBoxLayer()); + _layers.Add(new HudInfoLayer()); + _layers.Add(new DebugTextLayer()); + + _fpsWatch.Start(); + + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(33) }; // ~30fps + _timer.Tick += OnTick; + _timer.Start(); + } + + private void OnTick(object? sender, EventArgs e) + { + if (_bot == null) return; + + // Lazily grab the HWND once the window is realized + if (_hwnd == 0) + { + var handle = ((Window?)VisualRoot)?.TryGetPlatformHandle(); + if (handle != null) _hwnd = handle.Handle; + } + + // Show/hide overlay based on game focus — use native Win32 calls + // to avoid Avalonia's Show() which activates the window and steals focus + if (_hwnd != 0) + { + var focused = _bot.Game.IsGameFocused(); + if (focused && !_shown) + { + OverlayNativeMethods.ShowNoActivate(_hwnd); + _shown = true; + } + else if (!focused && _shown) + { + OverlayNativeMethods.HideWindow(_hwnd); + _shown = false; + } + } + + InvalidateVisual(); + } + + public override void Render(DrawingContext dc) + { + if (_bot == null) return; + + // Update FPS + _frameCount++; + var elapsed = _fpsWatch.Elapsed.TotalSeconds; + if (elapsed >= 1.0) + { + _fps = _frameCount / elapsed; + _frameCount = 0; + _fpsWatch.Restart(); + } + + // Build state snapshot from volatile sources + var detection = _bot.EnemyDetector.Latest; + var state = new OverlayState( + Enemies: detection.Enemies, + InferenceMs: detection.InferenceMs, + Hud: _bot.HudReader.Current, + NavState: _bot.Navigation.State, + NavPosition: _bot.Navigation.Position, + IsExploring: _bot.Navigation.IsExploring, + Fps: _fps); + + foreach (var layer in _layers) + layer.Draw(dc, state); + } + + public void Shutdown() + { + _timer?.Stop(); + _fpsWatch.Stop(); + } +} diff --git a/src/Poe2Trade.Ui/Overlay/OverlayNativeMethods.cs b/src/Poe2Trade.Ui/Overlay/OverlayNativeMethods.cs new file mode 100644 index 0000000..50730c9 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/OverlayNativeMethods.cs @@ -0,0 +1,36 @@ +using System.Runtime.InteropServices; + +namespace Poe2Trade.Ui.Overlay; + +internal static partial class OverlayNativeMethods +{ + private const int GWL_EXSTYLE = -20; + + internal const int WS_EX_TRANSPARENT = 0x00000020; + internal const int WS_EX_LAYERED = 0x00080000; + internal const int WS_EX_TOOLWINDOW = 0x00000080; + internal const int WS_EX_NOACTIVATE = 0x08000000; + + private const int SW_SHOWNOACTIVATE = 4; + private const int SW_HIDE = 0; + + [LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + private static partial nint GetWindowLongPtr(nint hWnd, int nIndex); + + [LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool ShowWindow(nint hWnd, int nCmdShow); + + internal static void MakeClickThrough(nint hwnd) + { + var style = GetWindowLongPtr(hwnd, GWL_EXSTYLE); + SetWindowLongPtr(hwnd, GWL_EXSTYLE, + style | WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE); + } + + internal static void ShowNoActivate(nint hwnd) => ShowWindow(hwnd, SW_SHOWNOACTIVATE); + internal static void HideWindow(nint hwnd) => ShowWindow(hwnd, SW_HIDE); +} diff --git a/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml b/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml new file mode 100644 index 0000000..200c058 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml @@ -0,0 +1,13 @@ + + + diff --git a/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml.cs b/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml.cs new file mode 100644 index 0000000..a91282e --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml.cs @@ -0,0 +1,38 @@ +using Avalonia.Controls; +using Poe2Trade.Bot; + +namespace Poe2Trade.Ui.Overlay; + +public partial class OverlayWindow : Window +{ + private readonly BotOrchestrator _bot = null!; + + // Designer/XAML loader requires parameterless constructor + public OverlayWindow() => InitializeComponent(); + + public OverlayWindow(BotOrchestrator bot) + { + _bot = bot; + InitializeComponent(); + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + + // Position at top-left corner + Position = new Avalonia.PixelPoint(0, 0); + + // Apply Win32 click-through extended styles + if (TryGetPlatformHandle() is { } handle) + OverlayNativeMethods.MakeClickThrough(handle.Handle); + + Canvas.Initialize(_bot); + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + Canvas.Shutdown(); + base.OnClosing(e); + } +} diff --git a/src/Poe2Trade.Ui/Poe2Trade.Ui.csproj b/src/Poe2Trade.Ui/Poe2Trade.Ui.csproj index bd6901d..4505333 100644 --- a/src/Poe2Trade.Ui/Poe2Trade.Ui.csproj +++ b/src/Poe2Trade.Ui/Poe2Trade.Ui.csproj @@ -5,6 +5,7 @@ enable enable app.manifest + true diff --git a/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs index 6eca8e1..fd8875f 100644 --- a/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs @@ -1,6 +1,9 @@ +using System.Text; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Poe2Trade.Bot; +using Poe2Trade.Core; +using Poe2Trade.Inventory; using Poe2Trade.Screen; using Serilog; @@ -27,17 +30,9 @@ public partial class DebugViewModel : ObservableObject _bot = bot; } - private bool EnsureReady() - { - if (_bot.IsReady) return true; - DebugResult = "Bot not started yet. Press Start first."; - return false; - } - [RelayCommand] private async Task TakeScreenshot() { - if (!EnsureReady()) return; try { var path = Path.Combine("debug", $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"); @@ -55,7 +50,6 @@ public partial class DebugViewModel : ObservableObject [RelayCommand] private async Task RunOcr() { - if (!EnsureReady()) return; try { var text = await _bot.Screen.ReadFullScreen(); @@ -71,7 +65,6 @@ public partial class DebugViewModel : ObservableObject [RelayCommand] private async Task GoHideout() { - if (!EnsureReady()) return; try { await _bot.Game.FocusGame(); @@ -88,7 +81,7 @@ public partial class DebugViewModel : ObservableObject [RelayCommand] private async Task FindTextOnScreen() { - if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return; + if (string.IsNullOrWhiteSpace(FindText)) return; try { var pos = await _bot.Screen.FindTextOnScreen(FindText, fuzzy: true); @@ -106,7 +99,7 @@ public partial class DebugViewModel : ObservableObject [RelayCommand] private async Task FindAndClick() { - if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return; + if (string.IsNullOrWhiteSpace(FindText)) return; try { await _bot.Game.FocusGame(); @@ -125,7 +118,6 @@ public partial class DebugViewModel : ObservableObject [RelayCommand] private async Task ClickAt() { - if (!EnsureReady()) return; var x = (int)(ClickX ?? 0); var y = (int)(ClickY ?? 0); try @@ -144,7 +136,6 @@ public partial class DebugViewModel : ObservableObject [RelayCommand] private async Task ScanGrid() { - if (!EnsureReady()) return; try { var result = await _bot.Screen.Grid.Scan(SelectedGridLayout); @@ -178,7 +169,6 @@ public partial class DebugViewModel : ObservableObject [RelayCommand] private async Task ClickAnge() { - if (!EnsureReady()) return; try { await _bot.Game.FocusGame(); @@ -191,7 +181,6 @@ public partial class DebugViewModel : ObservableObject [RelayCommand] private async Task ClickStash() { - if (!EnsureReady()) return; try { await _bot.Game.FocusGame(); @@ -204,7 +193,6 @@ public partial class DebugViewModel : ObservableObject [RelayCommand] private async Task ClickSalvage() { - if (!EnsureReady()) return; try { await _bot.Game.FocusGame(); @@ -213,4 +201,99 @@ public partial class DebugViewModel : ObservableObject } catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; } } + + [RelayCommand] + private async Task CalibrateStash() + { + try + { + var calibrator = new StashCalibrator(_bot.Screen, _bot.Game); + DebugResult = "Calibrating stash tabs..."; + + // Focus game and open stash + await _bot.Game.FocusGame(); + await Helpers.Sleep(Delays.PostFocus); + + var stashPos = await _bot.Inventory.FindAndClickNameplate("STASH"); + if (!stashPos.HasValue) + { + DebugResult = "STASH nameplate not found. Stand near your stash."; + return; + } + await Helpers.Sleep(Delays.PostStashOpen); + + // Calibrate stash + var stashCal = await calibrator.CalibrateOpenPanel(); + + // Close stash, try shop + await _bot.Game.PressEscape(); + await Helpers.Sleep(Delays.PostEscape); + + StashCalibration? shopCal = null; + var angePos = await _bot.Inventory.FindAndClickNameplate("ANGE"); + if (angePos.HasValue) + { + await Helpers.Sleep(Delays.PostStashOpen); + // ANGE opens a dialog — click "Manage Shop" to open shop tabs + var managePos = await _bot.Screen.FindTextOnScreen("Manage Shop", fuzzy: true); + if (managePos.HasValue) + { + await _bot.Game.LeftClickAt(managePos.Value.X, managePos.Value.Y); + await Helpers.Sleep(Delays.PostStashOpen); + } + shopCal = await calibrator.CalibrateOpenPanel(); + await _bot.Game.PressEscape(); + await Helpers.Sleep(Delays.PostEscape); + } + + // Save + _bot.Store.UpdateSettings(s => + { + s.StashCalibration = stashCal; + s.ShopCalibration = shopCal; + }); + + // Format results + DebugResult = FormatCalibration(stashCal, shopCal); + } + catch (Exception ex) + { + DebugResult = $"Calibration failed: {ex.Message}"; + Log.Error(ex, "Stash calibration failed"); + } + } + + private static string FormatCalibration(StashCalibration stash, StashCalibration? shop) + { + var sb = new StringBuilder(); + sb.AppendLine("=== STASH CALIBRATION ==="); + FormatTabs(sb, stash.Tabs, indent: ""); + + if (shop != null) + { + sb.AppendLine(); + sb.AppendLine("=== SHOP CALIBRATION ==="); + FormatTabs(sb, shop.Tabs, indent: ""); + } + else + { + sb.AppendLine(); + sb.AppendLine("(Shop: ANGE not found, skipped)"); + } + + return sb.ToString(); + } + + private static void FormatTabs(StringBuilder sb, List tabs, string indent) + { + foreach (var tab in tabs) + { + var folder = tab.IsFolder ? " [FOLDER]" : ""; + sb.AppendLine($"{indent}#{tab.Index} \"{tab.Name}\" @ ({tab.ClickX},{tab.ClickY}) grid={tab.GridCols}col{folder}"); + if (tab.IsFolder) + { + FormatTabs(sb, tab.SubTabs, indent + " "); + } + } + } } diff --git a/src/Poe2Trade.Ui/Views/MainWindow.axaml b/src/Poe2Trade.Ui/Views/MainWindow.axaml index eb3b332..e07686e 100644 --- a/src/Poe2Trade.Ui/Views/MainWindow.axaml +++ b/src/Poe2Trade.Ui/Views/MainWindow.axaml @@ -298,6 +298,7 @@