adding stash calibration

This commit is contained in:
Boki 2026-02-18 19:41:05 -05:00
parent 23c581cff9
commit 3ae65d0e64
17 changed files with 848 additions and 111 deletions

View file

@ -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

View file

@ -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<StashTabInfo> SubTabs { get; set; } = [];
}
public class StashCalibration
{
public List<StashTabInfo> Tabs { get; set; } = [];
public long CalibratedAt { get; set; }
}

View file

@ -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;
}
/// <summary>
/// Calibrates an already-open stash/shop panel.
/// OCRs tab bar, clicks each tab, detects folders and grid size.
/// </summary>
public async Task<StashCalibration> 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()
};
}
/// <summary>
/// OCR a region and group words into tab names by horizontal gap.
/// Returns list with screen-absolute click positions.
/// </summary>
private async Task<List<StashTabInfo>> 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);
}
/// <summary>
/// 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.
/// </summary>
private static List<StashTabInfo> GroupWordsIntoTabs(List<OcrWord> words, Region region, int gapThreshold)
{
// Sort left-to-right by X position
var sorted = words.OrderBy(w => w.X).ToList();
var tabs = new List<StashTabInfo>();
var currentWords = new List<OcrWord> { 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<OcrWord> 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
};
}
/// <summary>
/// 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.
/// </summary>
private async Task<int> 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;
}
}

View file

@ -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
}
}
/// <summary>
/// Compute a new path (checkpoint → nearby checkpoint → frontier).
/// Returns true if map is fully explored (no more frontiers).
/// </summary>
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;
}
/// <summary>
/// Whether a new BFS path is needed: path consumed, stuck, or stale (>3s).
/// </summary>
@ -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);
}

View file

@ -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<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 1];
ReadOnlySpan<int> 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<byte>(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<byte>(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<Point>();
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<int> dxs = [-1, 0, 1, -1, 1, -1, 0, 1];
ReadOnlySpan<int> 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<byte>(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<byte>(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);
}
}
}

View file

@ -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();
};

View file

@ -0,0 +1,19 @@
using Avalonia.Media;
using Poe2Trade.Navigation;
using Poe2Trade.Screen;
namespace Poe2Trade.Ui.Overlay;
public record OverlayState(
IReadOnlyList<DetectedEnemy> Enemies,
float InferenceMs,
HudSnapshot? Hud,
NavigationState NavState,
MapPosition NavPosition,
bool IsExploring,
double Fps);
public interface IOverlayLayer
{
void Draw(DrawingContext dc, OverlayState state);
}

View file

@ -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<string>(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<FormattedText>(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;
}
}
}

View file

@ -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));
}
}
}

View file

@ -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));
}
}
}

View file

@ -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<IOverlayLayer> _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();
}
}

View file

@ -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);
}

View file

@ -0,0 +1,13 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:overlay="using:Poe2Trade.Ui.Overlay"
x:Class="Poe2Trade.Ui.Overlay.OverlayWindow"
SystemDecorations="None"
Background="Transparent"
TransparencyLevelHint="Transparent"
Topmost="True"
ShowInTaskbar="False"
Width="2560" Height="1440"
CanResize="False">
<overlay:OverlayCanvas x:Name="Canvas" />
</Window>

View file

@ -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);
}
}

View file

@ -5,6 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.3" />

View file

@ -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<StashTabInfo> 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 + " ");
}
}
}
}

View file

@ -298,6 +298,7 @@
<Button Content="ANGE" Command="{Binding ClickAngeCommand}" />
<Button Content="STASH" Command="{Binding ClickStashCommand}" />
<Button Content="SALVAGE" Command="{Binding ClickSalvageCommand}" />
<Button Content="Calibrate Stash" Command="{Binding CalibrateStashCommand}" />
</StackPanel>
</StackPanel>
</Border>