overlay and calibration

This commit is contained in:
Boki 2026-02-19 20:00:23 -05:00
parent 3062993f7c
commit 3456e0d62a
24 changed files with 1193 additions and 439 deletions

View file

@ -8,6 +8,7 @@ public class StashTabInfo
public int ClickY { get; set; }
public bool IsFolder { get; set; }
public int GridCols { get; set; } = 12;
public bool Enabled { get; set; } = true;
public List<StashTabInfo> SubTabs { get; set; } = [];
}

View file

@ -94,7 +94,14 @@ public class StashCalibrator
/// </summary>
private async Task<List<StashTabInfo>> OcrTabBar(Region region)
{
// Save debug capture of the region
Directory.CreateDirectory("debug");
var tag = region == TabBarRegion ? "tabbar" : "subtab";
await _screen.SaveRegion(region, $"debug/calibrate-{tag}-{DateTime.Now:HHmmss}.png");
var ocr = await _screen.Ocr(region);
Log.Information("StashCalibrator: OCR region ({Tag}) raw text: '{Text}'", tag, ocr.Text);
var allWords = ocr.Lines.SelectMany(l => l.Words).ToList();
if (allWords.Count == 0) return [];

View file

@ -6,6 +6,32 @@ using OpenCvSharp.Extensions;
static class ImagePreprocessor
{
/// <summary>
/// CLAHE (Contrast Limited Adaptive Histogram Equalization) preprocessing.
/// Enhances local contrast — works for both light and dark text on varying backgrounds.
/// Pipeline: grayscale -> CLAHE -> upscale (keeps grayscale, lets EasyOCR handle binarization)
/// </summary>
public static Bitmap PreprocessClahe(Bitmap src, double clipLimit = 3.0, int tileSize = 8, int upscale = 2)
{
using var mat = BitmapConverter.ToMat(src);
using var gray = new Mat();
Cv2.CvtColor(mat, gray, ColorConversionCodes.BGRA2GRAY);
using var clahe = Cv2.CreateCLAHE(clipLimit, new OpenCvSharp.Size(tileSize, tileSize));
using var enhanced = new Mat();
clahe.Apply(gray, enhanced);
if (upscale > 1)
{
using var upscaled = new Mat();
Cv2.Resize(enhanced, upscaled, new OpenCvSharp.Size(enhanced.Width * upscale, enhanced.Height * upscale),
interpolation: InterpolationFlags.Cubic);
return BitmapConverter.ToBitmap(upscaled);
}
return BitmapConverter.ToBitmap(enhanced);
}
/// <summary>
/// Pre-process an image for OCR using morphological white top-hat filtering.
/// Isolates bright tooltip text, suppresses dim background text visible through overlay.

View file

@ -54,6 +54,12 @@ public class ScreenReader : IScreenReader
return Task.FromResult(_pythonBridge.OcrFromBitmap(processed));
}
if (preprocess == "clahe")
{
using var processed = ImagePreprocessor.PreprocessClahe(bitmap);
return Task.FromResult(_pythonBridge.OcrFromBitmap(processed));
}
return Task.FromResult(_pythonBridge.OcrFromBitmap(bitmap));
}

View file

@ -68,12 +68,12 @@ public partial class App : Application
desktop.MainWindow = window;
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnMainWindowClose;
var overlay = new OverlayWindow(bot);
overlay.Show();
var overlay = new D2dOverlay(bot);
overlay.Start();
desktop.ShutdownRequested += async (_, _) =>
{
overlay.Close();
overlay.Shutdown();
mainVm.Shutdown();
await bot.DisposeAsync();
};

View file

@ -0,0 +1,152 @@
using System.Runtime.InteropServices;
namespace Poe2Trade.Ui.Overlay;
/// <summary>Win32 P/Invoke for the D2D overlay window, DWM transparency, and frame timing.</summary>
internal static partial class D2dNativeMethods
{
// --- Window styles ---
internal const uint WS_POPUP = 0x80000000;
internal const uint WS_VISIBLE = 0x10000000;
internal const uint WS_EX_TOPMOST = 0x00000008;
internal const uint WS_EX_TRANSPARENT = 0x00000020;
internal const uint WS_EX_TOOLWINDOW = 0x00000080;
internal const uint WS_EX_LAYERED = 0x00080000;
internal const uint WS_EX_NOACTIVATE = 0x08000000;
// --- Window messages ---
internal const uint WM_DESTROY = 0x0002;
internal const uint WM_ERASEBKGND = 0x0014;
// --- ShowWindow / PeekMessage ---
internal const int SW_SHOWNOACTIVATE = 4;
internal const int SW_HIDE = 0;
internal const uint PM_REMOVE = 0x0001;
internal const int IDC_ARROW = 32512;
internal const uint LWA_ALPHA = 0x02;
// --- High-resolution waitable timer (Windows 10 1803+) ---
internal const uint CREATE_WAITABLE_TIMER_HIGH_RESOLUTION = 0x00000002;
internal const uint TIMER_ALL_ACCESS = 0x1F0003;
internal const uint INFINITE = 0xFFFFFFFF;
internal delegate nint WndProcDelegate(nint hWnd, uint msg, nint wParam, nint lParam);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct WNDCLASSEXW
{
public uint cbSize;
public uint style;
public nint lpfnWndProc;
public int cbClsExtra;
public int cbWndExtra;
public nint hInstance;
public nint hIcon;
public nint hCursor;
public nint hbrBackground;
public string? lpszMenuName;
public string lpszClassName;
public nint hIconSm;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MSG
{
public nint hwnd;
public uint message;
public nint wParam;
public nint lParam;
public uint time;
public int pt_x;
public int pt_y;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MARGINS
{
public int Left, Right, Top, Bottom;
}
// --- user32.dll ---
// DllImport required: WNDCLASSEXW has managed string fields unsupported by LibraryImport source generator
[DllImport("user32.dll", EntryPoint = "RegisterClassExW")]
internal static extern ushort RegisterClassExW(ref WNDCLASSEXW wc);
[LibraryImport("user32.dll", EntryPoint = "CreateWindowExW", StringMarshalling = StringMarshalling.Utf16)]
internal static partial nint CreateWindowExW(
uint dwExStyle, string lpClassName, string lpWindowName,
uint dwStyle, int x, int y, int nWidth, int nHeight,
nint hWndParent, nint hMenu, nint hInstance, nint lpParam);
[LibraryImport("user32.dll", EntryPoint = "DefWindowProcW")]
internal static partial nint DefWindowProcW(nint hWnd, uint msg, nint wParam, nint lParam);
[LibraryImport("user32.dll")]
internal static partial void PostQuitMessage(int nExitCode);
[LibraryImport("user32.dll", EntryPoint = "PeekMessageW")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool PeekMessageW(out MSG lpMsg, nint hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool TranslateMessage(ref MSG lpMsg);
[LibraryImport("user32.dll", EntryPoint = "DispatchMessageW")]
internal static partial nint DispatchMessageW(ref MSG lpMsg);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool DestroyWindow(nint hWnd);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool ShowWindow(nint hWnd, int nCmdShow);
[LibraryImport("user32.dll", EntryPoint = "LoadCursorW")]
internal static partial nint LoadCursorW(nint hInstance, int lpCursorName);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetLayeredWindowAttributes(nint hwnd, uint crKey, byte bAlpha, uint dwFlags);
// --- kernel32.dll ---
[LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleW")]
internal static partial nint GetModuleHandleW(nint lpModuleName);
[LibraryImport("kernel32.dll", EntryPoint = "CreateWaitableTimerExW")]
internal static partial nint CreateWaitableTimerExW(
nint lpTimerAttributes, nint lpTimerName, uint dwFlags, uint dwDesiredAccess);
[LibraryImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetWaitableTimer(
nint hTimer, ref long lpDueTime, int lPeriod,
nint pfnCompletionRoutine, nint lpArgToCompletionRoutine,
[MarshalAs(UnmanagedType.Bool)] bool fResume);
[LibraryImport("kernel32.dll")]
internal static partial uint WaitForSingleObject(nint hHandle, uint dwMilliseconds);
[LibraryImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool CloseHandle(nint hObject);
// --- dwmapi.dll / winmm.dll ---
[LibraryImport("dwmapi.dll")]
internal static partial int DwmExtendFrameIntoClientArea(nint hWnd, ref MARGINS pMarInset);
[LibraryImport("winmm.dll")]
internal static partial uint timeBeginPeriod(uint uPeriod);
[LibraryImport("winmm.dll")]
internal static partial uint timeEndPeriod(uint uPeriod);
// --- Helpers ---
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,270 @@
using System.Diagnostics;
using System.Runtime;
using System.Runtime.InteropServices;
using Poe2Trade.Bot;
using Poe2Trade.Ui.Overlay.Layers;
using Vortice.Mathematics;
using static Poe2Trade.Ui.Overlay.D2dNativeMethods;
namespace Poe2Trade.Ui.Overlay;
/// <summary>
/// Fullscreen transparent overlay rendered with Direct2D on a dedicated thread.
/// Uses a software render target to avoid GPU contention with DXGI capture / YOLO / Avalonia.
/// </summary>
public sealed class D2dOverlay
{
private const int Width = 2560;
private const int Height = 1440;
private const double TargetFrameMs = 16.0; // ~60 fps
private const int FocusCheckInterval = 60; // frames between focus checks (~1 Hz)
private const string ClassName = "Poe2D2dOverlay";
private readonly BotOrchestrator _bot;
private readonly List<ID2dOverlayLayer> _layers = [];
private Thread? _thread;
private volatile bool _shutdown;
// Must be stored as field to prevent GC collection of the delegate
private WndProcDelegate? _wndProcDelegate;
public D2dOverlay(BotOrchestrator bot)
{
_bot = bot;
}
public void Start()
{
_thread = new Thread(RenderLoop)
{
Name = "D2dOverlay",
IsBackground = true,
Priority = ThreadPriority.AboveNormal,
};
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
}
public void Shutdown()
{
_shutdown = true;
_thread?.Join(2000);
}
private void RenderLoop()
{
nint hwnd = 0;
nint hTimer = 0;
try
{
hwnd = CreateOverlayWindow();
if (hwnd == 0)
{
Console.WriteLine("[D2dOverlay] CreateOverlayWindow failed");
return;
}
SetLayeredWindowAttributes(hwnd, 0, 200, LWA_ALPHA);
var margins = new MARGINS { Left = -1, Right = -1, Top = -1, Bottom = -1 };
DwmExtendFrameIntoClientArea(hwnd, ref margins);
using var ctx = new D2dRenderContext(hwnd, Width, Height);
_layers.Add(new D2dEnemyBoxLayer(ctx));
_layers.Add(new D2dHudInfoLayer());
_layers.Add(new D2dDebugTextLayer());
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
timeBeginPeriod(1);
hTimer = CreateWaitableTimerExW(0, 0, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
// Pre-allocate per-frame arrays
var layerCount = _layers.Count;
var layerMs = new double[layerCount];
var layerNames = new string[layerCount];
for (int i = 0; i < layerCount; i++)
layerNames[i] = _layers[i].GetType().Name;
var timing = new RenderTiming();
var fpsWatch = Stopwatch.StartNew();
int frameCount = 0;
double fps = 0;
int focusCounter = 0;
bool shown = true;
long lastFrameTimestamp = Stopwatch.GetTimestamp();
ShowNoActivate(hwnd);
Console.WriteLine($"[D2dOverlay] Started (hwnd={hwnd:X})");
while (!_shutdown)
{
var frameStart = Stopwatch.GetTimestamp();
var frameInterval = (frameStart - lastFrameTimestamp) * 1000.0 / Stopwatch.Frequency;
lastFrameTimestamp = frameStart;
DrainMessages();
UpdateFocusVisibility(ref focusCounter, ref shown, hwnd);
UpdateFps(fpsWatch, ref frameCount, ref fps);
var state = BuildState(fps, timing);
var snapMs = ElapsedMs(frameStart);
Render(ctx, state, layerMs, layerCount);
var totalRenderMs = ElapsedMs(frameStart);
timing.SnapshotMs = snapMs;
timing.LayerMs = layerMs;
timing.LayerNames = layerNames;
timing.TotalRenderMs = totalRenderMs;
timing.IntervalMs = frameInterval;
WaitForNextFrame(frameStart, hTimer);
}
}
catch (Exception ex)
{
Console.WriteLine($"[D2dOverlay] Exception: {ex}");
}
finally
{
foreach (var layer in _layers)
(layer as IDisposable)?.Dispose();
if (hTimer != 0) CloseHandle(hTimer);
timeEndPeriod(1);
if (hwnd != 0) DestroyWindow(hwnd);
}
}
// --- Render loop helpers ---
private static void DrainMessages()
{
while (PeekMessageW(out var msg, 0, 0, 0, PM_REMOVE))
{
TranslateMessage(ref msg);
DispatchMessageW(ref msg);
}
}
private void UpdateFocusVisibility(ref int counter, ref bool shown, nint hwnd)
{
if (++counter < FocusCheckInterval) return;
counter = 0;
var gameExists = _bot.Game.GetWindowRect() != null;
var focused = _bot.Game.IsGameFocused();
if (!shown && (focused || !gameExists))
{
ShowNoActivate(hwnd);
shown = true;
}
else if (shown && gameExists && !focused)
{
HideWindow(hwnd);
shown = false;
}
}
private static void UpdateFps(Stopwatch watch, ref int frameCount, ref double fps)
{
frameCount++;
var elapsed = watch.Elapsed.TotalSeconds;
if (elapsed >= 1.0)
{
fps = frameCount / elapsed;
frameCount = 0;
watch.Restart();
}
}
private OverlayState BuildState(double fps, RenderTiming timing)
{
var detection = _bot.EnemyDetector.Latest;
return 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,
Timing: timing);
}
private void Render(D2dRenderContext ctx, OverlayState state, double[] layerMs, int layerCount)
{
var rt = ctx.RenderTarget;
rt.BeginDraw();
rt.Clear(new Color4(0, 0, 0, 0));
for (int i = 0; i < layerCount; i++)
{
var start = Stopwatch.GetTimestamp();
_layers[i].Draw(ctx, state);
layerMs[i] = (Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency;
}
var hr = rt.EndDraw();
if (hr.Failure)
ctx.RecreateTarget();
}
private static void WaitForNextFrame(long frameStart, nint hTimer)
{
var waitMs = TargetFrameMs - ElapsedMs(frameStart);
if (waitMs > 0.5 && hTimer != 0)
{
var dueTime = -(long)(waitMs * 10_000); // negative = relative, 100ns units
SetWaitableTimer(hTimer, ref dueTime, 0, 0, 0, false);
WaitForSingleObject(hTimer, INFINITE);
}
}
private static double ElapsedMs(long since)
=> (Stopwatch.GetTimestamp() - since) * 1000.0 / Stopwatch.Frequency;
// --- Window creation ---
private nint CreateOverlayWindow()
{
var hInstance = GetModuleHandleW(0);
_wndProcDelegate = WndProc;
var fnPtr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
var wc = new WNDCLASSEXW
{
cbSize = (uint)Marshal.SizeOf<WNDCLASSEXW>(),
lpfnWndProc = fnPtr,
hInstance = hInstance,
hCursor = LoadCursorW(0, IDC_ARROW),
lpszClassName = ClassName,
};
if (RegisterClassExW(ref wc) == 0) return 0;
const uint exStyle = WS_EX_TOPMOST | WS_EX_TRANSPARENT | WS_EX_LAYERED
| WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
return CreateWindowExW(
exStyle, ClassName, "Poe2Overlay",
WS_POPUP | WS_VISIBLE,
0, 0, Width, Height,
0, 0, hInstance, 0);
}
private static nint WndProc(nint hWnd, uint msg, nint wParam, nint lParam)
{
switch (msg)
{
case WM_DESTROY:
PostQuitMessage(0);
return 0;
case WM_ERASEBKGND:
return 1;
default:
return DefWindowProcW(hWnd, msg, wParam, lParam);
}
}
}

View file

@ -0,0 +1,138 @@
using Vortice.Direct2D1;
using Vortice.DirectWrite;
using Vortice.DXGI;
using Vortice.Mathematics;
using DWriteFactory = Vortice.DirectWrite.IDWriteFactory;
using D2dFactoryType = Vortice.Direct2D1.FactoryType;
using DwFactoryType = Vortice.DirectWrite.FactoryType;
namespace Poe2Trade.Ui.Overlay;
public sealed class D2dRenderContext : IDisposable
{
private readonly nint _hwnd;
private readonly int _width;
private readonly int _height;
// Factories
public ID2D1Factory1 D2dFactory { get; }
public DWriteFactory DWriteFactory { get; }
// Render target (recreated on device loss)
public ID2D1HwndRenderTarget RenderTarget { get; private set; } = null!;
// Pre-created brushes
public ID2D1SolidColorBrush Red { get; private set; } = null!;
public ID2D1SolidColorBrush Yellow { get; private set; } = null!;
public ID2D1SolidColorBrush Green { get; private set; } = null!;
public ID2D1SolidColorBrush White { get; private set; } = null!;
public ID2D1SolidColorBrush Gray { get; private set; } = null!;
public ID2D1SolidColorBrush LifeBrush { get; private set; } = null!;
public ID2D1SolidColorBrush ManaBrush { get; private set; } = null!;
public ID2D1SolidColorBrush BarBgBrush { get; private set; } = null!;
public ID2D1SolidColorBrush LabelBgBrush { get; private set; } = null!;
public ID2D1SolidColorBrush DebugTextBrush { get; private set; } = null!;
public ID2D1SolidColorBrush TimingBrush { get; private set; } = null!;
public ID2D1SolidColorBrush DebugBgBrush { get; private set; } = null!;
// Text formats
public IDWriteTextFormat LabelFormat { get; } // 12pt — enemy labels
public IDWriteTextFormat BarValueFormat { get; } // 11pt — bar values
public IDWriteTextFormat DebugFormat { get; } // 13pt — debug overlay
public D2dRenderContext(nint hwnd, int width, int height)
{
_hwnd = hwnd;
_width = width;
_height = height;
D2dFactory = D2D1.D2D1CreateFactory<ID2D1Factory1>(D2dFactoryType.SingleThreaded);
DWriteFactory = Vortice.DirectWrite.DWrite.DWriteCreateFactory<DWriteFactory>(DwFactoryType.Shared);
CreateRenderTarget();
CreateBrushes();
// Text formats (these survive device loss — they're DWrite, not D2D)
LabelFormat = DWriteFactory.CreateTextFormat("Consolas", 12f);
BarValueFormat = DWriteFactory.CreateTextFormat("Consolas", 11f);
DebugFormat = DWriteFactory.CreateTextFormat("Consolas", 13f);
}
private void CreateRenderTarget()
{
var rtProps = new RenderTargetProperties
{
Type = RenderTargetType.Software,
PixelFormat = new Vortice.DCommon.PixelFormat(Format.B8G8R8A8_UNorm, Vortice.DCommon.AlphaMode.Premultiplied),
};
var hwndProps = new HwndRenderTargetProperties
{
Hwnd = _hwnd,
PixelSize = new SizeI(_width, _height),
PresentOptions = PresentOptions.Immediately,
};
RenderTarget = D2dFactory.CreateHwndRenderTarget(rtProps, hwndProps);
RenderTarget.TextAntialiasMode = Vortice.Direct2D1.TextAntialiasMode.Grayscale;
}
private void CreateBrushes()
{
Red = RenderTarget.CreateSolidColorBrush(new Color4(1f, 0f, 0f, 1f));
Yellow = RenderTarget.CreateSolidColorBrush(new Color4(1f, 1f, 0f, 1f));
Green = RenderTarget.CreateSolidColorBrush(new Color4(0.31f, 1f, 0.31f, 1f)); // 80,255,80
White = RenderTarget.CreateSolidColorBrush(new Color4(1f, 1f, 1f, 1f));
Gray = RenderTarget.CreateSolidColorBrush(new Color4(0.5f, 0.5f, 0.5f, 1f));
LifeBrush = RenderTarget.CreateSolidColorBrush(new Color4(200 / 255f, 40 / 255f, 40 / 255f, 1f));
ManaBrush = RenderTarget.CreateSolidColorBrush(new Color4(40 / 255f, 80 / 255f, 200 / 255f, 1f));
BarBgBrush = RenderTarget.CreateSolidColorBrush(new Color4(20 / 255f, 20 / 255f, 20 / 255f, 140 / 255f));
LabelBgBrush = RenderTarget.CreateSolidColorBrush(new Color4(0f, 0f, 0f, 160 / 255f));
DebugTextBrush = RenderTarget.CreateSolidColorBrush(new Color4(80 / 255f, 1f, 80 / 255f, 1f));
TimingBrush = RenderTarget.CreateSolidColorBrush(new Color4(1f, 200 / 255f, 80 / 255f, 1f));
DebugBgBrush = RenderTarget.CreateSolidColorBrush(new Color4(0f, 0f, 0f, 160 / 255f));
}
private void DisposeBrushes()
{
Red?.Dispose();
Yellow?.Dispose();
Green?.Dispose();
White?.Dispose();
Gray?.Dispose();
LifeBrush?.Dispose();
ManaBrush?.Dispose();
BarBgBrush?.Dispose();
LabelBgBrush?.Dispose();
DebugTextBrush?.Dispose();
TimingBrush?.Dispose();
DebugBgBrush?.Dispose();
}
/// <summary>
/// Call after EndDraw returns D2DERR_RECREATE_TARGET.
/// Recreates the render target and all device-dependent resources.
/// </summary>
public void RecreateTarget()
{
DisposeBrushes();
RenderTarget?.Dispose();
CreateRenderTarget();
CreateBrushes();
}
/// <summary>Create a text layout for measurement + drawing.</summary>
public IDWriteTextLayout CreateTextLayout(string text, IDWriteTextFormat format, float maxWidth = 4096f, float maxHeight = 4096f)
{
return DWriteFactory.CreateTextLayout(text, format, maxWidth, maxHeight);
}
public void Dispose()
{
DisposeBrushes();
RenderTarget?.Dispose();
LabelFormat?.Dispose();
BarValueFormat?.Dispose();
DebugFormat?.Dispose();
DWriteFactory?.Dispose();
D2dFactory?.Dispose();
}
}

View file

@ -1,4 +1,3 @@
using Avalonia.Media;
using Poe2Trade.Navigation;
using Poe2Trade.Screen;
@ -11,9 +10,19 @@ public record OverlayState(
NavigationState NavState,
MapPosition NavPosition,
bool IsExploring,
double Fps);
double Fps,
RenderTiming? Timing);
public interface IOverlayLayer
public class RenderTiming
{
void Draw(DrawingContext dc, OverlayState state);
public double SnapshotMs { get; set; }
public double[] LayerMs { get; set; } = [];
public string[] LayerNames { get; set; } = [];
public double TotalRenderMs { get; set; }
public double IntervalMs { get; set; }
}
public interface ID2dOverlayLayer
{
void Draw(D2dRenderContext ctx, OverlayState state);
}

View file

@ -0,0 +1,109 @@
using System.Drawing;
using Vortice.Direct2D1;
using Vortice.DirectWrite;
using Vortice.Mathematics;
namespace Poe2Trade.Ui.Overlay.Layers;
internal sealed class D2dDebugTextLayer : ID2dOverlayLayer, IDisposable
{
private const float PadX = 8;
private const float PadY = 4;
private const float StartX = 10;
private const float StartY = 10;
private const float ColumnGap = 16;
private readonly CachedLine[] _left = new CachedLine[8];
private readonly CachedLine[] _right = new CachedLine[8];
public void Draw(D2dRenderContext ctx, OverlayState state)
{
var rt = ctx.RenderTarget;
int lc = 0, rc = 0;
// Left column: game state
UpdateCache(ctx, _left, ref lc, $"FPS: {state.Fps:F0}", ctx.DebugTextBrush);
UpdateCache(ctx, _left, ref lc, $"Nav: {state.NavState}{(state.IsExploring ? " [exploring]" : "")}", ctx.DebugTextBrush);
UpdateCache(ctx, _left, ref lc, $"Pos: ({state.NavPosition.X:F0}, {state.NavPosition.Y:F0})", ctx.DebugTextBrush);
UpdateCache(ctx, _left, ref lc, $"Enemies: {state.Enemies.Count} YOLO: {state.InferenceMs:F1}ms", ctx.DebugTextBrush);
if (state.Hud is { Timestamp: > 0 } hud)
UpdateCache(ctx, _left, ref lc, $"HP: {hud.LifePct:P0} MP: {hud.ManaPct:P0}", ctx.DebugTextBrush);
// Right column: timing
if (state.Timing != null)
{
var t = state.Timing;
UpdateCache(ctx, _right, ref rc, $"interval: {t.IntervalMs:F1}ms", ctx.TimingBrush);
UpdateCache(ctx, _right, ref rc, $"snapshot: {t.SnapshotMs:F2}ms", ctx.TimingBrush);
for (int i = 0; i < t.LayerMs.Length; i++)
UpdateCache(ctx, _right, ref rc, $"{t.LayerNames[i]}: {t.LayerMs[i]:F2}ms", ctx.TimingBrush);
UpdateCache(ctx, _right, ref rc, $"render total: {t.TotalRenderMs:F2}ms", ctx.TimingBrush);
}
// Measure columns
Measure(_left, lc, out var leftW, out var leftH);
Measure(_right, rc, out var rightW, out var rightH);
var totalW = leftW + (rc > 0 ? ColumnGap + rightW : 0);
var totalH = Math.Max(leftH, rightH);
// Background
rt.FillRectangle(
new RectangleF(StartX - PadX, StartY - PadY, totalW + PadX * 2, totalH + PadY * 2),
ctx.DebugBgBrush);
// Draw columns
DrawColumn(rt, _left, lc, StartX, StartY);
if (rc > 0)
DrawColumn(rt, _right, rc, StartX + leftW + ColumnGap, StartY);
}
private static void Measure(CachedLine[] col, int count, out float maxW, out float totalH)
{
maxW = 0;
totalH = 0;
for (int i = 0; i < count; i++)
{
var m = col[i].Layout!.Metrics;
if (m.Width > maxW) maxW = m.Width;
totalH += m.Height;
}
}
private static void DrawColumn(ID2D1RenderTarget rt, CachedLine[] col, int count, float x, float y)
{
for (int i = 0; i < count; i++)
{
ref var entry = ref col[i];
rt.DrawTextLayout(new System.Numerics.Vector2(x, y), entry.Layout!, entry.Brush!);
y += entry.Layout!.Metrics.Height;
}
}
private static void UpdateCache(D2dRenderContext ctx, CachedLine[] col, ref int index, string value, ID2D1SolidColorBrush brush)
{
if (index >= col.Length) return;
ref var entry = ref col[index];
if (entry.Value != value)
{
entry.Layout?.Dispose();
entry.Value = value;
entry.Layout = ctx.CreateTextLayout(value, ctx.DebugFormat);
}
entry.Brush = brush;
index++;
}
public void Dispose()
{
for (int i = 0; i < _left.Length; i++) _left[i].Layout?.Dispose();
for (int i = 0; i < _right.Length; i++) _right[i].Layout?.Dispose();
}
private struct CachedLine
{
public string? Value;
public IDWriteTextLayout? Layout;
public ID2D1SolidColorBrush? Brush;
}
}

View file

@ -0,0 +1,58 @@
using System.Drawing;
using Vortice.Direct2D1;
using Vortice.DirectWrite;
using Vortice.Mathematics;
namespace Poe2Trade.Ui.Overlay.Layers;
internal sealed class D2dEnemyBoxLayer : ID2dOverlayLayer, IDisposable
{
// Pre-built label layouts for 0%-100% (confirmed = red, unconfirmed = yellow)
private readonly IDWriteTextLayout[] _confirmedLabels = new IDWriteTextLayout[101];
private readonly IDWriteTextLayout[] _unconfirmedLabels = new IDWriteTextLayout[101];
public D2dEnemyBoxLayer(D2dRenderContext ctx)
{
for (int i = 0; i <= 100; i++)
{
var text = $"{i}%";
_confirmedLabels[i] = ctx.CreateTextLayout(text, ctx.LabelFormat);
_unconfirmedLabels[i] = ctx.CreateTextLayout(text, ctx.LabelFormat);
}
}
public void Draw(D2dRenderContext ctx, OverlayState state)
{
var rt = ctx.RenderTarget;
foreach (var enemy in state.Enemies)
{
var confirmed = enemy.HealthBarConfirmed;
var boxBrush = confirmed ? ctx.Red : ctx.Yellow;
var rect = new RectangleF(enemy.X, enemy.Y, enemy.Width, enemy.Height);
rt.DrawRectangle(rect, boxBrush, 2f);
// Confidence label above the box
var pctIndex = Math.Clamp((int)(enemy.Confidence * 100), 0, 100);
var layout = confirmed ? _confirmedLabels[pctIndex] : _unconfirmedLabels[pctIndex];
var textBrush = confirmed ? ctx.Red : ctx.Yellow;
var m = layout.Metrics;
var labelX = enemy.X;
var labelY = enemy.Y - m.Height - 2;
// Background behind label
rt.FillRectangle(
new RectangleF(labelX - 1, labelY - 1, m.Width + 2, m.Height + 2),
ctx.LabelBgBrush);
rt.DrawTextLayout(new System.Numerics.Vector2(labelX, labelY), layout, textBrush);
}
}
public void Dispose()
{
foreach (var l in _confirmedLabels) l?.Dispose();
foreach (var l in _unconfirmedLabels) l?.Dispose();
}
}

View file

@ -0,0 +1,72 @@
using System.Drawing;
using Vortice.Direct2D1;
using Vortice.DirectWrite;
using Vortice.Mathematics;
namespace Poe2Trade.Ui.Overlay.Layers;
internal sealed class D2dHudInfoLayer : ID2dOverlayLayer, IDisposable
{
private const float BarWidth = 200;
private const float BarHeight = 16;
private const float BarY = 1300;
private const float LifeBarX = 1130;
private const float ManaBarX = 1230;
// Cached bar value layouts
private string? _lifeLabel;
private IDWriteTextLayout? _lifeLayout;
private string? _manaLabel;
private IDWriteTextLayout? _manaLayout;
public void Draw(D2dRenderContext ctx, OverlayState state)
{
if (state.Hud == null || state.Hud.Timestamp == 0) return;
DrawBar(ctx, LifeBarX, BarY, state.Hud.LifePct, ctx.LifeBrush, state.Hud.Life,
ref _lifeLabel, ref _lifeLayout);
DrawBar(ctx, ManaBarX, BarY, state.Hud.ManaPct, ctx.ManaBrush, state.Hud.Mana,
ref _manaLabel, ref _manaLayout);
}
private static void DrawBar(D2dRenderContext ctx, float x, float y, float pct,
ID2D1SolidColorBrush fillBrush, Screen.HudValues? values,
ref string? cachedLabel, ref IDWriteTextLayout? cachedLayout)
{
var rt = ctx.RenderTarget;
var outer = new RectangleF(x, y, BarWidth, BarHeight);
// Background
rt.FillRectangle(outer, ctx.BarBgBrush);
// Border
rt.DrawRectangle(outer, ctx.Gray, 1f);
// Fill
var fillWidth = BarWidth * Math.Clamp(pct, 0, 1);
if (fillWidth > 0)
rt.FillRectangle(new RectangleF(x, y, fillWidth, BarHeight), fillBrush);
// Value text
if (values != null)
{
var label = $"{values.Current}/{values.Max}";
if (label != cachedLabel)
{
cachedLayout?.Dispose();
cachedLabel = label;
cachedLayout = ctx.CreateTextLayout(label, ctx.BarValueFormat);
}
var m = cachedLayout!.Metrics;
var textX = x + (BarWidth - m.Width) / 2;
var textY = y + (BarHeight - m.Height) / 2;
rt.DrawTextLayout(new System.Numerics.Vector2(textX, textY), cachedLayout, ctx.White);
}
}
public void Dispose()
{
_lifeLayout?.Dispose();
_manaLayout?.Dispose();
}
}

View file

@ -1,60 +0,0 @@
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

@ -1,36 +0,0 @@
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

@ -1,47 +0,0 @@
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

@ -1,104 +0,0 @@
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

@ -1,36 +0,0 @@
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

@ -1,13 +0,0 @@
<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

@ -1,38 +0,0 @@
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

@ -13,6 +13,7 @@
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Vortice.Direct2D1" Version="3.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />

View file

@ -1,9 +1,6 @@
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;
@ -202,98 +199,4 @@ 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.RandomDelay(150, 300);
var stashPos = await _bot.Inventory.FindAndClickNameplate("STASH");
if (!stashPos.HasValue)
{
DebugResult = "STASH nameplate not found. Stand near your stash.";
return;
}
await Helpers.RandomDelay(300, 500);
// Calibrate stash
var stashCal = await calibrator.CalibrateOpenPanel();
// Close stash, try shop
await _bot.Game.PressEscape();
await Helpers.RandomDelay(200, 400);
StashCalibration? shopCal = null;
var angePos = await _bot.Inventory.FindAndClickNameplate("ANGE");
if (angePos.HasValue)
{
await Helpers.RandomDelay(300, 500);
// 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.RandomDelay(300, 500);
}
shopCal = await calibrator.CalibrateOpenPanel(firstFolderOnly: true);
await _bot.Game.PressEscape();
await Helpers.RandomDelay(200, 400);
}
// 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

@ -1,6 +1,10 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Poe2Trade.Bot;
using Poe2Trade.Core;
using Poe2Trade.Inventory;
using Serilog;
namespace Poe2Trade.Ui.ViewModels;
@ -16,11 +20,18 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private decimal? _betweenTradesDelayMs = 5000;
[ObservableProperty] private bool _headless = true;
[ObservableProperty] private bool _isSaved;
[ObservableProperty] private string _calibrationStatus = "";
[ObservableProperty] private string _stashCalibratedAt = "";
[ObservableProperty] private string _shopCalibratedAt = "";
public ObservableCollection<StashTabViewModel> StashTabs { get; } = [];
public ObservableCollection<StashTabViewModel> ShopTabs { get; } = [];
public SettingsViewModel(BotOrchestrator bot)
{
_bot = bot;
LoadFromConfig();
LoadTabs();
}
private void LoadFromConfig()
@ -35,6 +46,42 @@ public partial class SettingsViewModel : ObservableObject
Headless = s.Headless;
}
private void LoadTabs()
{
var s = _bot.Store.Settings;
StashTabs.Clear();
if (s.StashCalibration != null)
{
foreach (var tab in s.StashCalibration.Tabs)
StashTabs.Add(new StashTabViewModel(tab));
StashCalibratedAt = FormatTimestamp(s.StashCalibration.CalibratedAt);
}
else
{
StashCalibratedAt = "Not calibrated";
}
ShopTabs.Clear();
if (s.ShopCalibration != null)
{
foreach (var tab in s.ShopCalibration.Tabs)
ShopTabs.Add(new StashTabViewModel(tab));
ShopCalibratedAt = FormatTimestamp(s.ShopCalibration.CalibratedAt);
}
else
{
ShopCalibratedAt = "Not calibrated";
}
}
private static string FormatTimestamp(long unixMs)
{
if (unixMs == 0) return "Not calibrated";
var dt = DateTimeOffset.FromUnixTimeMilliseconds(unixMs).LocalDateTime;
return dt.ToString("yyyy-MM-dd HH:mm");
}
[RelayCommand]
private void SaveSettings()
{
@ -52,6 +99,106 @@ public partial class SettingsViewModel : ObservableObject
IsSaved = true;
}
[RelayCommand]
private void SaveTabs()
{
_bot.Store.UpdateSettings(s =>
{
// Models are already updated via write-through in StashTabViewModel
// Just trigger a save
if (s.StashCalibration != null)
s.StashCalibration = s.StashCalibration;
if (s.ShopCalibration != null)
s.ShopCalibration = s.ShopCalibration;
});
CalibrationStatus = "Tabs saved!";
}
[RelayCommand]
private async Task CalibrateStash()
{
try
{
var calibrator = new StashCalibrator(_bot.Screen, _bot.Game);
CalibrationStatus = "Calibrating stash...";
await _bot.Game.FocusGame();
await Helpers.RandomDelay(150, 300);
var pos = await _bot.Inventory.FindAndClickNameplate("STASH");
if (!pos.HasValue)
{
CalibrationStatus = "STASH not found. Stand near your stash.";
return;
}
await Helpers.RandomDelay(300, 500);
var cal = await calibrator.CalibrateOpenPanel();
await _bot.Game.PressEscape();
await Helpers.RandomDelay(200, 400);
_bot.Store.UpdateSettings(s => s.StashCalibration = cal);
LoadTabs();
CalibrationStatus = $"Stash calibrated — {cal.Tabs.Count} tabs found.";
}
catch (Exception ex)
{
CalibrationStatus = $"Stash calibration failed: {ex.Message}";
Log.Error(ex, "Stash calibration failed");
}
}
[RelayCommand]
private async Task CalibrateShop()
{
try
{
var calibrator = new StashCalibrator(_bot.Screen, _bot.Game);
CalibrationStatus = "Calibrating shop...";
await _bot.Game.FocusGame();
await Helpers.RandomDelay(150, 300);
var pos = await _bot.Inventory.FindAndClickNameplate("ANGE");
if (!pos.HasValue)
{
CalibrationStatus = "ANGE not found. Stand near the vendor.";
return;
}
await Helpers.RandomDelay(800, 1200);
// ANGE opens a dialog — click "Manage Shop"
var dialogRegion = new Region(1080, 600, 400, 300);
var managePos = await _bot.Screen.FindTextInRegion(dialogRegion, "Manage");
if (managePos.HasValue)
{
await _bot.Game.LeftClickAt(managePos.Value.X, managePos.Value.Y);
await Helpers.RandomDelay(300, 500);
}
else
{
Log.Warning("'Manage Shop' not found in dialog region, saving debug capture");
await _bot.Screen.SaveRegion(dialogRegion, "debug/calibrate-dialog.png");
}
var cal = await calibrator.CalibrateOpenPanel(firstFolderOnly: true);
await _bot.Game.PressEscape();
await Helpers.RandomDelay(200, 400);
_bot.Store.UpdateSettings(s => s.ShopCalibration = cal);
LoadTabs();
CalibrationStatus = $"Shop calibrated — {cal.Tabs.Count} tabs found.";
}
catch (Exception ex)
{
CalibrationStatus = $"Shop calibration failed: {ex.Message}";
Log.Error(ex, "Shop calibration failed");
}
}
partial void OnPoe2LogPathChanged(string value) => IsSaved = false;
partial void OnWindowTitleChanged(string value) => IsSaved = false;
partial void OnTravelTimeoutMsChanged(decimal? value) => IsSaved = false;

View file

@ -0,0 +1,42 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Poe2Trade.Core;
namespace Poe2Trade.Ui.ViewModels;
public partial class StashTabViewModel : ObservableObject
{
private readonly StashTabInfo _model;
public StashTabViewModel(StashTabInfo model)
{
_model = model;
_name = model.Name;
_gridCols = model.GridCols;
_enabled = model.Enabled;
if (model.IsFolder)
{
foreach (var sub in model.SubTabs)
SubTabs.Add(new StashTabViewModel(sub));
}
}
public StashTabInfo Model => _model;
public int Index => _model.Index;
public int ClickX => _model.ClickX;
public int ClickY => _model.ClickY;
public bool IsFolder => _model.IsFolder;
[ObservableProperty] private string _name;
[ObservableProperty] private int _gridCols;
[ObservableProperty] private bool _enabled;
public ObservableCollection<StashTabViewModel> SubTabs { get; } = [];
public static int[] GridColOptions { get; } = [12, 24];
partial void OnNameChanged(string value) => _model.Name = value;
partial void OnGridColsChanged(int value) => _model.GridCols = value;
partial void OnEnabledChanged(bool value) => _model.Enabled = value;
}

View file

@ -298,7 +298,6 @@
<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>
@ -417,6 +416,154 @@
</StackPanel>
</StackPanel>
</Border>
<!-- Status -->
<TextBlock Text="{Binding CalibrationStatus}" FontSize="11"
Foreground="#3fb950" Margin="0,0,0,4"
IsVisible="{Binding CalibrationStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- STASH TABS -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="10">
<StackPanel Spacing="8">
<DockPanel>
<TextBlock Text="STASH TABS" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" VerticalAlignment="Center" />
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right"
Spacing="6" HorizontalAlignment="Right">
<Button Content="Calibrate" Command="{Binding CalibrateStashCommand}" />
<Button Content="Save" Command="{Binding SaveTabsCommand}" />
</StackPanel>
</DockPanel>
<TextBlock Text="{Binding StashCalibratedAt, StringFormat='Last calibrated: {0}'}"
FontSize="11" Foreground="#484f58" />
<ItemsControl ItemsSource="{Binding StashTabs}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StashTabViewModel">
<StackPanel>
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
<DockPanel>
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
Margin="0,0,6,0" VerticalAlignment="Center" />
<TextBlock DockPanel.Dock="Left"
Text="{Binding Index, StringFormat='#{0}'}"
Width="28" VerticalAlignment="Center"
FontSize="11" Foreground="#8b949e" />
<TextBlock DockPanel.Dock="Right" Text="FOLDER"
IsVisible="{Binding IsFolder}"
FontSize="10" Foreground="#58a6ff"
VerticalAlignment="Center" Margin="6,0,0,0" />
<ComboBox DockPanel.Dock="Right"
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
SelectedItem="{Binding GridCols}"
Width="70" Margin="6,0,0,0"
IsVisible="{Binding !IsFolder}" />
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
</DockPanel>
</Border>
<!-- Nested sub-tabs for folders -->
<ItemsControl ItemsSource="{Binding SubTabs}" Margin="24,0,0,0"
IsVisible="{Binding IsFolder}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StashTabViewModel">
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
<DockPanel>
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
Margin="0,0,6,0" VerticalAlignment="Center" />
<TextBlock DockPanel.Dock="Left"
Text="{Binding Index, StringFormat='#{0}'}"
Width="28" VerticalAlignment="Center"
FontSize="11" Foreground="#8b949e" />
<ComboBox DockPanel.Dock="Right"
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
SelectedItem="{Binding GridCols}"
Width="70" Margin="6,0,0,0" />
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- SHOP TABS -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="10">
<StackPanel Spacing="8">
<DockPanel>
<TextBlock Text="SHOP TABS" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" VerticalAlignment="Center" />
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right"
Spacing="6" HorizontalAlignment="Right">
<Button Content="Calibrate" Command="{Binding CalibrateShopCommand}" />
<Button Content="Save" Command="{Binding SaveTabsCommand}" />
</StackPanel>
</DockPanel>
<TextBlock Text="{Binding ShopCalibratedAt, StringFormat='Last calibrated: {0}'}"
FontSize="11" Foreground="#484f58" />
<ItemsControl ItemsSource="{Binding ShopTabs}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StashTabViewModel">
<StackPanel>
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
<DockPanel>
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
Margin="0,0,6,0" VerticalAlignment="Center" />
<TextBlock DockPanel.Dock="Left"
Text="{Binding Index, StringFormat='#{0}'}"
Width="28" VerticalAlignment="Center"
FontSize="11" Foreground="#8b949e" />
<TextBlock DockPanel.Dock="Right" Text="FOLDER"
IsVisible="{Binding IsFolder}"
FontSize="10" Foreground="#58a6ff"
VerticalAlignment="Center" Margin="6,0,0,0" />
<ComboBox DockPanel.Dock="Right"
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
SelectedItem="{Binding GridCols}"
Width="70" Margin="6,0,0,0"
IsVisible="{Binding !IsFolder}" />
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
</DockPanel>
</Border>
<!-- Nested sub-tabs for folders -->
<ItemsControl ItemsSource="{Binding SubTabs}" Margin="24,0,0,0"
IsVisible="{Binding IsFolder}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StashTabViewModel">
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
<DockPanel>
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
Margin="0,0,6,0" VerticalAlignment="Center" />
<TextBlock DockPanel.Dock="Left"
Text="{Binding Index, StringFormat='#{0}'}"
Width="28" VerticalAlignment="Center"
FontSize="11" Foreground="#8b949e" />
<ComboBox DockPanel.Dock="Right"
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
SelectedItem="{Binding GridCols}"
Width="70" Margin="6,0,0,0" />
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>