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

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