From 3456e0d62a34ea03bbc6690f460fb3a3763a8103 Mon Sep 17 00:00:00 2001 From: Boki Date: Thu, 19 Feb 2026 20:00:23 -0500 Subject: [PATCH] overlay and calibration --- src/Poe2Trade.Core/StashCalibration.cs | 1 + src/Poe2Trade.Inventory/StashCalibrator.cs | 7 + src/Poe2Trade.Screen/ImagePreprocessor.cs | 26 ++ src/Poe2Trade.Screen/ScreenReader.cs | 6 + src/Poe2Trade.Ui/App.axaml.cs | 6 +- src/Poe2Trade.Ui/Overlay/D2dNativeMethods.cs | 152 ++++++++++ src/Poe2Trade.Ui/Overlay/D2dOverlay.cs | 270 ++++++++++++++++++ src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs | 138 +++++++++ src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs | 17 +- .../Overlay/Layers/D2dDebugTextLayer.cs | 109 +++++++ .../Overlay/Layers/D2dEnemyBoxLayer.cs | 58 ++++ .../Overlay/Layers/D2dHudInfoLayer.cs | 72 +++++ .../Overlay/Layers/DebugTextLayer.cs | 60 ---- .../Overlay/Layers/EnemyBoxLayer.cs | 36 --- .../Overlay/Layers/HudInfoLayer.cs | 47 --- src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs | 104 ------- .../Overlay/OverlayNativeMethods.cs | 36 --- src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml | 13 - .../Overlay/OverlayWindow.axaml.cs | 38 --- src/Poe2Trade.Ui/Poe2Trade.Ui.csproj | 1 + src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs | 97 ------- .../ViewModels/SettingsViewModel.cs | 147 ++++++++++ .../ViewModels/StashTabViewModel.cs | 42 +++ src/Poe2Trade.Ui/Views/MainWindow.axaml | 149 +++++++++- 24 files changed, 1193 insertions(+), 439 deletions(-) create mode 100644 src/Poe2Trade.Ui/Overlay/D2dNativeMethods.cs create mode 100644 src/Poe2Trade.Ui/Overlay/D2dOverlay.cs create mode 100644 src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs create mode 100644 src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs create mode 100644 src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs create mode 100644 src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs delete mode 100644 src/Poe2Trade.Ui/Overlay/Layers/DebugTextLayer.cs delete mode 100644 src/Poe2Trade.Ui/Overlay/Layers/EnemyBoxLayer.cs delete mode 100644 src/Poe2Trade.Ui/Overlay/Layers/HudInfoLayer.cs delete mode 100644 src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs delete mode 100644 src/Poe2Trade.Ui/Overlay/OverlayNativeMethods.cs delete mode 100644 src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml delete mode 100644 src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml.cs create mode 100644 src/Poe2Trade.Ui/ViewModels/StashTabViewModel.cs diff --git a/src/Poe2Trade.Core/StashCalibration.cs b/src/Poe2Trade.Core/StashCalibration.cs index a72be97..c7052e4 100644 --- a/src/Poe2Trade.Core/StashCalibration.cs +++ b/src/Poe2Trade.Core/StashCalibration.cs @@ -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 SubTabs { get; set; } = []; } diff --git a/src/Poe2Trade.Inventory/StashCalibrator.cs b/src/Poe2Trade.Inventory/StashCalibrator.cs index a528ee1..0b01fdf 100644 --- a/src/Poe2Trade.Inventory/StashCalibrator.cs +++ b/src/Poe2Trade.Inventory/StashCalibrator.cs @@ -94,7 +94,14 @@ public class StashCalibrator /// private async Task> 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 []; diff --git a/src/Poe2Trade.Screen/ImagePreprocessor.cs b/src/Poe2Trade.Screen/ImagePreprocessor.cs index 20e8978..bb033f2 100644 --- a/src/Poe2Trade.Screen/ImagePreprocessor.cs +++ b/src/Poe2Trade.Screen/ImagePreprocessor.cs @@ -6,6 +6,32 @@ using OpenCvSharp.Extensions; static class ImagePreprocessor { + /// + /// 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) + /// + 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); + } + /// /// Pre-process an image for OCR using morphological white top-hat filtering. /// Isolates bright tooltip text, suppresses dim background text visible through overlay. diff --git a/src/Poe2Trade.Screen/ScreenReader.cs b/src/Poe2Trade.Screen/ScreenReader.cs index 1818bf0..11c53d9 100644 --- a/src/Poe2Trade.Screen/ScreenReader.cs +++ b/src/Poe2Trade.Screen/ScreenReader.cs @@ -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)); } diff --git a/src/Poe2Trade.Ui/App.axaml.cs b/src/Poe2Trade.Ui/App.axaml.cs index d177b9f..96149e5 100644 --- a/src/Poe2Trade.Ui/App.axaml.cs +++ b/src/Poe2Trade.Ui/App.axaml.cs @@ -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(); }; diff --git a/src/Poe2Trade.Ui/Overlay/D2dNativeMethods.cs b/src/Poe2Trade.Ui/Overlay/D2dNativeMethods.cs new file mode 100644 index 0000000..abd26c2 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/D2dNativeMethods.cs @@ -0,0 +1,152 @@ +using System.Runtime.InteropServices; + +namespace Poe2Trade.Ui.Overlay; + +/// Win32 P/Invoke for the D2D overlay window, DWM transparency, and frame timing. +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); +} diff --git a/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs b/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs new file mode 100644 index 0000000..cd2a697 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs @@ -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; + +/// +/// Fullscreen transparent overlay rendered with Direct2D on a dedicated thread. +/// Uses a software render target to avoid GPU contention with DXGI capture / YOLO / Avalonia. +/// +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 _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(), + 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); + } + } +} diff --git a/src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs b/src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs new file mode 100644 index 0000000..a8f9ca7 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs @@ -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(D2dFactoryType.SingleThreaded); + DWriteFactory = Vortice.DirectWrite.DWrite.DWriteCreateFactory(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(); + } + + /// + /// Call after EndDraw returns D2DERR_RECREATE_TARGET. + /// Recreates the render target and all device-dependent resources. + /// + public void RecreateTarget() + { + DisposeBrushes(); + RenderTarget?.Dispose(); + CreateRenderTarget(); + CreateBrushes(); + } + + /// Create a text layout for measurement + drawing. + 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(); + } +} diff --git a/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs b/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs index ff2a8c0..ce824cd 100644 --- a/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs +++ b/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs @@ -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); } diff --git a/src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs new file mode 100644 index 0000000..2afcdc0 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs @@ -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; + } +} diff --git a/src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs new file mode 100644 index 0000000..584e88a --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs @@ -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(); + } +} diff --git a/src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs new file mode 100644 index 0000000..0c89b53 --- /dev/null +++ b/src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs @@ -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(); + } +} diff --git a/src/Poe2Trade.Ui/Overlay/Layers/DebugTextLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/DebugTextLayer.cs deleted file mode 100644 index e632821..0000000 --- a/src/Poe2Trade.Ui/Overlay/Layers/DebugTextLayer.cs +++ /dev/null @@ -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(8) - { - $"FPS: {state.Fps:F0}", - $"Nav: {state.NavState}{(state.IsExploring ? " [exploring]" : "")}", - $"Pos: ({state.NavPosition.X:F0}, {state.NavPosition.Y:F0})", - $"Enemies: {state.Enemies.Count} YOLO: {state.InferenceMs:F1}ms" - }; - - if (state.Hud is { Timestamp: > 0 } hud) - { - lines.Add($"HP: {hud.LifePct:P0} MP: {hud.ManaPct:P0}"); - } - - // Measure max width for background - double maxWidth = 0; - double totalHeight = 0; - var formatted = new List(lines.Count); - - foreach (var line in lines) - { - var ft = new FormattedText(line, System.Globalization.CultureInfo.InvariantCulture, - FlowDirection.LeftToRight, MonoTypeface, FontSize, TextBrush); - formatted.Add(ft); - if (ft.Width > maxWidth) maxWidth = ft.Width; - totalHeight += ft.Height; - } - - // Draw background - dc.DrawRectangle(Background, null, - new Rect(StartX - PadX, StartY - PadY, - maxWidth + PadX * 2, totalHeight + PadY * 2)); - - // Draw text lines - var y = StartY; - foreach (var ft in formatted) - { - dc.DrawText(ft, new Point(StartX, y)); - y += ft.Height; - } - } -} diff --git a/src/Poe2Trade.Ui/Overlay/Layers/EnemyBoxLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/EnemyBoxLayer.cs deleted file mode 100644 index 6efcdd4..0000000 --- a/src/Poe2Trade.Ui/Overlay/Layers/EnemyBoxLayer.cs +++ /dev/null @@ -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)); - } - } -} diff --git a/src/Poe2Trade.Ui/Overlay/Layers/HudInfoLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/HudInfoLayer.cs deleted file mode 100644 index b284f87..0000000 --- a/src/Poe2Trade.Ui/Overlay/Layers/HudInfoLayer.cs +++ /dev/null @@ -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)); - } - } -} diff --git a/src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs b/src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs deleted file mode 100644 index 5bb11c6..0000000 --- a/src/Poe2Trade.Ui/Overlay/OverlayCanvas.cs +++ /dev/null @@ -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 _layers = []; - private BotOrchestrator? _bot; - private DispatcherTimer? _timer; - private nint _hwnd; - private bool _shown; - - // FPS tracking - private readonly Stopwatch _fpsWatch = new(); - private int _frameCount; - private double _fps; - - public void Initialize(BotOrchestrator bot) - { - _bot = bot; - - _layers.Add(new EnemyBoxLayer()); - _layers.Add(new HudInfoLayer()); - _layers.Add(new DebugTextLayer()); - - _fpsWatch.Start(); - - _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(33) }; // ~30fps - _timer.Tick += OnTick; - _timer.Start(); - } - - private void OnTick(object? sender, EventArgs e) - { - if (_bot == null) return; - - // Lazily grab the HWND once the window is realized - if (_hwnd == 0) - { - var handle = ((Window?)VisualRoot)?.TryGetPlatformHandle(); - if (handle != null) _hwnd = handle.Handle; - } - - // Show/hide overlay based on game focus — use native Win32 calls - // to avoid Avalonia's Show() which activates the window and steals focus - if (_hwnd != 0) - { - var focused = _bot.Game.IsGameFocused(); - if (focused && !_shown) - { - OverlayNativeMethods.ShowNoActivate(_hwnd); - _shown = true; - } - else if (!focused && _shown) - { - OverlayNativeMethods.HideWindow(_hwnd); - _shown = false; - } - } - - InvalidateVisual(); - } - - public override void Render(DrawingContext dc) - { - if (_bot == null) return; - - // Update FPS - _frameCount++; - var elapsed = _fpsWatch.Elapsed.TotalSeconds; - if (elapsed >= 1.0) - { - _fps = _frameCount / elapsed; - _frameCount = 0; - _fpsWatch.Restart(); - } - - // Build state snapshot from volatile sources - var detection = _bot.EnemyDetector.Latest; - var state = new OverlayState( - Enemies: detection.Enemies, - InferenceMs: detection.InferenceMs, - Hud: _bot.HudReader.Current, - NavState: _bot.Navigation.State, - NavPosition: _bot.Navigation.Position, - IsExploring: _bot.Navigation.IsExploring, - Fps: _fps); - - foreach (var layer in _layers) - layer.Draw(dc, state); - } - - public void Shutdown() - { - _timer?.Stop(); - _fpsWatch.Stop(); - } -} diff --git a/src/Poe2Trade.Ui/Overlay/OverlayNativeMethods.cs b/src/Poe2Trade.Ui/Overlay/OverlayNativeMethods.cs deleted file mode 100644 index 50730c9..0000000 --- a/src/Poe2Trade.Ui/Overlay/OverlayNativeMethods.cs +++ /dev/null @@ -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); -} diff --git a/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml b/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml deleted file mode 100644 index 200c058..0000000 --- a/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml.cs b/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml.cs deleted file mode 100644 index a91282e..0000000 --- a/src/Poe2Trade.Ui/Overlay/OverlayWindow.axaml.cs +++ /dev/null @@ -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); - } -} diff --git a/src/Poe2Trade.Ui/Poe2Trade.Ui.csproj b/src/Poe2Trade.Ui/Poe2Trade.Ui.csproj index 4505333..87bca2a 100644 --- a/src/Poe2Trade.Ui/Poe2Trade.Ui.csproj +++ b/src/Poe2Trade.Ui/Poe2Trade.Ui.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs index 5f0ff79..5d04872 100644 --- a/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs @@ -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 tabs, string indent) - { - foreach (var tab in tabs) - { - var folder = tab.IsFolder ? " [FOLDER]" : ""; - sb.AppendLine($"{indent}#{tab.Index} \"{tab.Name}\" @ ({tab.ClickX},{tab.ClickY}) grid={tab.GridCols}col{folder}"); - if (tab.IsFolder) - { - FormatTabs(sb, tab.SubTabs, indent + " "); - } - } - } } diff --git a/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs b/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs index 0240770..e0d5f37 100644 --- a/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs @@ -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 StashTabs { get; } = []; + public ObservableCollection 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; diff --git a/src/Poe2Trade.Ui/ViewModels/StashTabViewModel.cs b/src/Poe2Trade.Ui/ViewModels/StashTabViewModel.cs new file mode 100644 index 0000000..883f889 --- /dev/null +++ b/src/Poe2Trade.Ui/ViewModels/StashTabViewModel.cs @@ -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 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; +} diff --git a/src/Poe2Trade.Ui/Views/MainWindow.axaml b/src/Poe2Trade.Ui/Views/MainWindow.axaml index e07686e..4aca908 100644 --- a/src/Poe2Trade.Ui/Views/MainWindow.axaml +++ b/src/Poe2Trade.Ui/Views/MainWindow.axaml @@ -298,7 +298,6 @@