From 40d30115bfe5595ed4ef382636ce2b086f7928fa Mon Sep 17 00:00:00 2001 From: Boki Date: Fri, 20 Feb 2026 16:40:50 -0500 Subject: [PATCH] work on well of souls and yolo detection --- .gitignore | 8 + assets/black-cathedral.png | Bin 0 -> 10553 bytes assets/invitation.png | Bin 0 -> 7823 bytes assets/well-of-souls.png | Bin 0 -> 7079 bytes src/Poe2Trade.Bot/BossRunExecutor.cs | 529 ++++++++++++ src/Poe2Trade.Bot/BotOrchestrator.cs | 70 +- src/Poe2Trade.Core/ConfigStore.cs | 37 + src/Poe2Trade.Core/Types.cs | 18 +- src/Poe2Trade.Game/GameController.cs | 6 + src/Poe2Trade.Game/IGameController.cs | 6 + src/Poe2Trade.Game/InputSender.cs | 6 + src/Poe2Trade.Inventory/IInventoryManager.cs | 2 + src/Poe2Trade.Inventory/InventoryManager.cs | 149 +++- src/Poe2Trade.Screen/BossDetector.cs | 78 ++ src/Poe2Trade.Screen/DetectionTypes.cs | 11 + src/Poe2Trade.Screen/FrameSaver.cs | 17 +- src/Poe2Trade.Screen/HudReader.cs | 177 ++-- src/Poe2Trade.Screen/IScreenReader.cs | 2 + src/Poe2Trade.Screen/PythonDetectBridge.cs | 3 +- src/Poe2Trade.Screen/ScreenReader.cs | 142 ++++ src/Poe2Trade.Screen/TemplateMatchHandler.cs | 49 +- .../Converters/ValueConverters.cs | 1 + src/Poe2Trade.Ui/Overlay/D2dOverlay.cs | 3 + src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs | 6 + src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs | 2 + .../Overlay/Layers/D2dDebugTextLayer.cs | 2 +- .../Overlay/Layers/D2dEnemyBoxLayer.cs | 32 +- .../Overlay/Layers/D2dHudInfoLayer.cs | 87 +- src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs | 124 +++ .../ViewModels/MainWindowViewModel.cs | 3 +- .../ViewModels/MappingViewModel.cs | 68 +- .../ViewModels/SettingsViewModel.cs | 4 + src/Poe2Trade.Ui/Views/MainWindow.axaml | 37 + tools/python-detect/README.md | 123 +++ tools/python-detect/annotate.py | 760 ++++++++++++++++++ tools/python-detect/daemon.py | 33 +- tools/python-detect/fix_labels.py | 77 ++ tools/python-detect/manage.py | 353 ++++++++ tools/python-detect/prelabel.py | 78 ++ tools/python-detect/train.py | 76 +- tools/tessdata/eng.traineddata | Bin 0 -> 4113088 bytes 41 files changed, 3031 insertions(+), 148 deletions(-) create mode 100644 assets/black-cathedral.png create mode 100644 assets/invitation.png create mode 100644 assets/well-of-souls.png create mode 100644 src/Poe2Trade.Bot/BossRunExecutor.cs create mode 100644 src/Poe2Trade.Screen/BossDetector.cs create mode 100644 tools/python-detect/README.md create mode 100644 tools/python-detect/annotate.py create mode 100644 tools/python-detect/fix_labels.py create mode 100644 tools/python-detect/manage.py create mode 100644 tools/python-detect/prelabel.py create mode 100644 tools/tessdata/eng.traineddata diff --git a/.gitignore b/.gitignore index 4f919d8..7290581 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,16 @@ config.json browser-data/ *.log debug-screenshots/ +debug/ items/ +# YOLO / ML +runs/ +training-data/ +tools/python-detect/models/ +*.pt +*.engine + # IDE / tools .claude/ nul diff --git a/assets/black-cathedral.png b/assets/black-cathedral.png new file mode 100644 index 0000000000000000000000000000000000000000..3138314ebf66ff4222bcd146212b6cb0fefe6378 GIT binary patch literal 10553 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 MNhtxVBus+-7cc=#K>z>% literal 0 HcmV?d00001 diff --git a/assets/invitation.png b/assets/invitation.png new file mode 100644 index 0000000000000000000000000000000000000000..2f3f67295bf119264fbc35f470a9aa7ad63fee9e GIT binary patch literal 7823 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 literal 0 HcmV?d00001 diff --git a/assets/well-of-souls.png b/assets/well-of-souls.png new file mode 100644 index 0000000000000000000000000000000000000000..51425fe3762e7ac6cf006ad3a364c58c874c9237 GIT binary patch literal 7079 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 RQLF#}002ovPDHLkV1lCL%kBUG literal 0 HcmV?d00001 diff --git a/src/Poe2Trade.Bot/BossRunExecutor.cs b/src/Poe2Trade.Bot/BossRunExecutor.cs new file mode 100644 index 0000000..8132ec9 --- /dev/null +++ b/src/Poe2Trade.Bot/BossRunExecutor.cs @@ -0,0 +1,529 @@ +using System.Diagnostics; +using Poe2Trade.Core; +using Poe2Trade.Game; +using Poe2Trade.GameLog; +using Poe2Trade.Inventory; +using Poe2Trade.Screen; +using Serilog; + +namespace Poe2Trade.Bot; + +public class BossRunExecutor +{ + private static readonly string WellOfSoulsTemplate = Path.Combine("assets", "well-of-souls.png"); + private static readonly string BlackCathedralTemplate = Path.Combine("assets", "black-cathedral.png"); + private static readonly string InvitationTemplate = Path.Combine("assets", "invitation.png"); + + private BossRunState _state = BossRunState.Idle; + private bool _stopped; + private readonly IGameController _game; + private readonly IScreenReader _screen; + private readonly IInventoryManager _inventory; + private readonly IClientLogWatcher _logWatcher; + private readonly SavedSettings _config; + private readonly BossDetector _bossDetector; + + public event Action? StateChanged; + + public BossRunExecutor(IGameController game, IScreenReader screen, + IInventoryManager inventory, IClientLogWatcher logWatcher, SavedSettings config, + BossDetector bossDetector) + { + _game = game; + _screen = screen; + _inventory = inventory; + _logWatcher = logWatcher; + _config = config; + _bossDetector = bossDetector; + } + + public BossRunState State => _state; + + private void SetState(BossRunState s) + { + _state = s; + StateChanged?.Invoke(s); + } + + public void Stop() + { + _stopped = true; + Log.Information("Boss run executor stop requested"); + } + + public async Task RunBossLoop() + { + _stopped = false; + _bossDetector.SetBoss("kulemak"); + Log.Information("Starting boss run loop ({Count} invitations)", _config.Kulemak.InvitationCount); + + if (!await Prepare()) + { + SetState(BossRunState.Failed); + await RecoverToHideout(); + SetState(BossRunState.Idle); + return; + } + + var completed = 0; + for (var i = 0; i < _config.Kulemak.InvitationCount; i++) + { + if (_stopped) break; + + Log.Information("=== Boss run {N}/{Total} ===", i + 1, _config.Kulemak.InvitationCount); + + if (!await TravelToZone()) + { + Log.Error("Failed to travel to zone"); + await RecoverToHideout(); + break; + } + if (_stopped) break; + + var entrance = await WalkToEntrance(); + if (entrance == null) + { + Log.Error("Failed to find Black Cathedral entrance"); + await RecoverToHideout(); + break; + } + if (_stopped) break; + + if (!await UseInvitation(entrance.X, entrance.Y)) + { + Log.Error("Failed to use invitation"); + await RecoverToHideout(); + break; + } + if (_stopped) break; + + await Fight(); + if (_stopped) break; + + await Loot(); + if (_stopped) break; + + if (!await ReturnHome()) + { + Log.Error("Failed to return home"); + await RecoverToHideout(); + break; + } + if (_stopped) break; + + await StoreLoot(); + completed++; + + if (_stopped) break; + } + + Log.Information("Boss run loop finished: {Completed}/{Total} runs completed", completed, _config.Kulemak.InvitationCount); + SetState(BossRunState.Complete); + await Helpers.Sleep(1000); + SetState(BossRunState.Idle); + } + + private async Task Prepare() + { + SetState(BossRunState.Preparing); + Log.Information("Preparing: depositing inventory and grabbing invitations"); + + await _game.FocusGame(); + await Helpers.Sleep(Delays.PostFocus); + + // Open stash + var stashPos = await _inventory.FindAndClickNameplate("Stash"); + if (stashPos == null) + { + Log.Error("Could not find Stash nameplate"); + return false; + } + await Helpers.Sleep(Delays.PostStashOpen); + + // Click loot tab and deposit all inventory items + var (lootTab, lootFolder) = ResolveTabPath(_config.Kulemak.LootTabPath); + if (lootTab != null) + { + await _inventory.ClickStashTab(lootTab, lootFolder); + + // Deposit all inventory items via ctrl+click + var scanResult = await _screen.Grid.Scan("inventory"); + if (scanResult.Occupied.Count > 0) + { + Log.Information("Depositing {Count} inventory items to loot tab", scanResult.Occupied.Count); + await _game.KeyDown(InputSender.VK.SHIFT); + await _game.HoldCtrl(); + foreach (var cell in scanResult.Occupied) + { + var center = _screen.Grid.GetCellCenter(GridLayouts.Inventory, cell.Row, cell.Col); + await _game.LeftClickAt(center.X, center.Y); + await Helpers.Sleep(Delays.ClickInterval); + } + await _game.ReleaseCtrl(); + await _game.KeyUp(InputSender.VK.SHIFT); + await Helpers.Sleep(Delays.PostEscape); + } + } + else + { + Log.Warning("Loot tab path not configured or not found, skipping deposit"); + } + + // Click invitation tab and grab invitations + var (invTab, invFolder) = ResolveTabPath(_config.Kulemak.InvitationTabPath); + if (invTab != null) + { + await _inventory.ClickStashTab(invTab, invFolder); + + // Determine layout name based on tab config + var layoutName = (invTab.GridCols == 24, invFolder != null) switch + { + (true, true) => "stash24_folder", + (true, false) => "stash24", + (false, true) => "stash12_folder", + (false, false) => "stash12", + }; + + await _inventory.GrabItemsFromStash(layoutName, _config.Kulemak.InvitationCount, InvitationTemplate); + } + else + { + Log.Warning("Invitation tab path not configured or not found, skipping grab"); + } + + // Close stash + await _game.PressEscape(); + await Helpers.Sleep(Delays.PostEscape); + + Log.Information("Preparation complete"); + return true; + } + + private async Task TravelToZone() + { + SetState(BossRunState.TravelingToZone); + Log.Information("Traveling to Well of Souls via waypoint"); + + await _game.FocusGame(); + await Helpers.Sleep(Delays.PostFocus); + + // Find and click Waypoint + var wpPos = await _inventory.FindAndClickNameplate("Waypoint"); + if (wpPos == null) + { + Log.Error("Could not find Waypoint nameplate"); + return false; + } + await Helpers.Sleep(1000); + + // Template match well-of-souls.png and click + var match = await _screen.TemplateMatch(WellOfSoulsTemplate); + if (match == null) + { + Log.Error("Could not find Well of Souls on waypoint map"); + await _game.PressEscape(); + return false; + } + + Log.Information("Found Well of Souls at ({X},{Y}), clicking", match.X, match.Y); + await _game.LeftClickAt(match.X, match.Y); + + // Wait for area transition + var arrived = await _inventory.WaitForAreaTransition(_config.TravelTimeoutMs); + if (!arrived) + { + Log.Error("Timed out waiting for Well of Souls transition"); + return false; + } + + await Helpers.Sleep(Delays.PostTravel); + Log.Information("Arrived at Well of Souls"); + return true; + } + + private async Task WalkToEntrance() + { + SetState(BossRunState.WalkingToEntrance); + Log.Information("Walking to Black Cathedral entrance (W+D)"); + + return await WalkAndMatch(BlackCathedralTemplate, InputSender.VK.W, InputSender.VK.D, 15000); + } + + private async Task UseInvitation(int x, int y) + { + SetState(BossRunState.UsingInvitation); + Log.Information("Using invitation at ({X},{Y})", x, y); + + // Hover first so the game registers the target, then use invitation + await _game.MoveMouseTo(x, y); + await Helpers.Sleep(500); + await _game.CtrlLeftClickAt(x, y); + await Helpers.Sleep(1000); + + // Find "NEW" text — pick the leftmost instance + var ocr = await _screen.Ocr(); + var newWords = ocr.Lines + .SelectMany(l => l.Words) + .Where(w => w.Text.Equals("NEW", StringComparison.OrdinalIgnoreCase) + || w.Text.Equals("New", StringComparison.Ordinal)) + .OrderBy(w => w.X) + .ToList(); + + if (newWords.Count == 0) + { + Log.Error("Could not find 'NEW' text for instance selection"); + return false; + } + + var target = newWords[0]; + var clickX = target.X + target.Width / 2; + var clickY = target.Y + target.Height / 2; + Log.Information("Found {Count} 'NEW' matches, clicking leftmost at ({X},{Y})", newWords.Count, clickX, clickY); + await _game.MoveMouseTo(clickX, clickY); + await Helpers.Sleep(500); + await _game.LeftClickAt(clickX, clickY); + + // Wait for area transition into boss arena + var arrived = await _inventory.WaitForAreaTransition(_config.TravelTimeoutMs); + if (!arrived) + { + Log.Error("Timed out waiting for boss arena transition"); + return false; + } + + await Helpers.Sleep(Delays.PostTravel); + Log.Information("Entered boss arena"); + return true; + } + + private async Task Fight() + { + SetState(BossRunState.Fighting); + Log.Information("[PLACEHOLDER] Fight phase - waiting for manual combat"); + // Placeholder: user handles combat manually for now + await Helpers.Sleep(1000); + } + + private async Task Loot() + { + SetState(BossRunState.Looting); + Log.Information("[PLACEHOLDER] Loot phase - waiting for manual looting"); + // Placeholder: user handles looting manually for now + await Helpers.Sleep(1000); + } + + private async Task ReturnHome() + { + SetState(BossRunState.Returning); + Log.Information("Returning home"); + + await _game.FocusGame(); + await Helpers.Sleep(Delays.PostFocus); + + // Walk away from loot (hold S briefly) + await _game.KeyDown(InputSender.VK.S); + await Helpers.Sleep(1000); + await _game.KeyUp(InputSender.VK.S); + await Helpers.Sleep(300); + + // Press + to open portal + await _game.PressPlus(); + await Helpers.Sleep(1500); + + // Find "The Ardura Caravan" and click it + var caravanPos = await _inventory.FindAndClickNameplate("The Ardura Caravan", maxRetries: 5, retryDelayMs: 1500); + if (caravanPos == null) + { + Log.Error("Could not find 'The Ardura Caravan' portal"); + return false; + } + + // Wait for area transition to caravan + var arrivedCaravan = await _inventory.WaitForAreaTransition(_config.TravelTimeoutMs); + if (!arrivedCaravan) + { + Log.Error("Timed out waiting for caravan transition"); + return false; + } + await Helpers.Sleep(Delays.PostTravel); + + // /hideout to go home + var arrivedHome = await _inventory.WaitForAreaTransition( + _config.TravelTimeoutMs, () => _game.GoToHideout()); + if (!arrivedHome) + { + Log.Error("Timed out going to hideout"); + return false; + } + + await Helpers.Sleep(Delays.PostTravel); + _inventory.SetLocation(true); + Log.Information("Arrived at hideout"); + return true; + } + + private async Task StoreLoot() + { + SetState(BossRunState.StoringLoot); + Log.Information("Storing loot"); + + await _game.FocusGame(); + await Helpers.Sleep(Delays.PostFocus); + + // Open stash + var stashPos = await _inventory.FindAndClickNameplate("Stash"); + if (stashPos == null) + { + Log.Warning("Could not find Stash, skipping loot storage"); + return; + } + await Helpers.Sleep(Delays.PostStashOpen); + + // Click loot tab + var (lootTab, lootFolder) = ResolveTabPath(_config.Kulemak.LootTabPath); + if (lootTab != null) + await _inventory.ClickStashTab(lootTab, lootFolder); + + // Deposit all inventory items + var scanResult = await _screen.Grid.Scan("inventory"); + if (scanResult.Occupied.Count > 0) + { + Log.Information("Depositing {Count} items to loot tab", scanResult.Occupied.Count); + await _game.KeyDown(InputSender.VK.SHIFT); + await _game.HoldCtrl(); + foreach (var cell in scanResult.Occupied) + { + var center = _screen.Grid.GetCellCenter(GridLayouts.Inventory, cell.Row, cell.Col); + await _game.LeftClickAt(center.X, center.Y); + await Helpers.Sleep(Delays.ClickInterval); + } + await _game.ReleaseCtrl(); + await _game.KeyUp(InputSender.VK.SHIFT); + await Helpers.Sleep(Delays.PostEscape); + } + + // Close stash + await _game.PressEscape(); + await Helpers.Sleep(Delays.PostEscape); + + Log.Information("Loot stored"); + } + + private async Task WalkAndMatch(string templatePath, int vk1, int vk2, + int timeoutMs = 15000, int closeRadius = 350) + { + const int screenCx = 2560 / 2; + const int screenCy = 1440 / 2; + + await _game.KeyDown(vk1); + await _game.KeyDown(vk2); + try + { + var sw = Stopwatch.StartNew(); + bool spotted = false; + while (sw.ElapsedMilliseconds < timeoutMs) + { + if (_stopped) return null; + var match = await _screen.TemplateMatch(templatePath); + if (match == null) + { + await Helpers.Sleep(500); + continue; + } + + var dx = match.X - screenCx; + var dy = match.Y - screenCy; + var dist = Math.Sqrt(dx * dx + dy * dy); + + if (!spotted) + { + Log.Information("Template spotted at ({X},{Y}), dist={Dist:F0}px from center, approaching...", + match.X, match.Y, dist); + spotted = true; + } + + if (dist <= closeRadius) + { + Log.Information("Close enough at ({X},{Y}), dist={Dist:F0}px, stopping", match.X, match.Y, dist); + + // Stop, settle, re-match for accurate position + await _game.KeyUp(vk2); + await _game.KeyUp(vk1); + await Helpers.Sleep(300); + + var fresh = await _screen.TemplateMatch(templatePath); + if (fresh != null) + { + Log.Information("Final position at ({X},{Y})", fresh.X, fresh.Y); + return fresh; + } + Log.Warning("Re-match failed, using last known position"); + return match; + } + + await Helpers.Sleep(200); + } + Log.Error("WalkAndMatch timed out after {Ms}ms (spotted={Spotted})", timeoutMs, spotted); + return null; + } + finally + { + await _game.KeyUp(vk2); + await _game.KeyUp(vk1); + } + } + + private (StashTabInfo? Tab, StashTabInfo? Folder) ResolveTabPath(string tabPath) + { + if (string.IsNullOrEmpty(tabPath) || _config.StashCalibration == null) + return (null, null); + + var parts = tabPath.Split('/'); + if (parts.Length == 1) + { + // Simple tab name + var tab = _config.StashCalibration.Tabs.FirstOrDefault(t => t.Name == parts[0]); + return (tab, null); + } + + if (parts.Length == 2) + { + // Folder/SubTab + var folder = _config.StashCalibration.Tabs.FirstOrDefault(t => t.Name == parts[0] && t.IsFolder); + if (folder == null) return (null, null); + var subTab = folder.SubTabs.FirstOrDefault(t => t.Name == parts[1]); + return (subTab, folder); + } + + return (null, null); + } + + private async Task RecoverToHideout() + { + try + { + Log.Information("Recovering: escaping and going to hideout"); + await _game.FocusGame(); + await _game.PressEscape(); + await Helpers.Sleep(Delays.PostEscape); + await _game.PressEscape(); + await Helpers.Sleep(Delays.PostEscape); + + var arrived = await _inventory.WaitForAreaTransition( + _config.TravelTimeoutMs, () => _game.GoToHideout()); + if (arrived) + { + _inventory.SetLocation(true); + Log.Information("Recovery: arrived at hideout"); + } + else + { + Log.Warning("Recovery: timed out going to hideout"); + } + } + catch (Exception ex) + { + Log.Error(ex, "Recovery failed"); + } + } +} diff --git a/src/Poe2Trade.Bot/BotOrchestrator.cs b/src/Poe2Trade.Bot/BotOrchestrator.cs index 7796f2c..7c60b2c 100644 --- a/src/Poe2Trade.Bot/BotOrchestrator.cs +++ b/src/Poe2Trade.Bot/BotOrchestrator.cs @@ -44,7 +44,9 @@ public class BotOrchestrator : IAsyncDisposable public GameStateDetector GameState { get; } public HudReader HudReader { get; } public EnemyDetector EnemyDetector { get; } + public BossDetector BossDetector { get; } public FrameSaver FrameSaver { get; } + public BossRunExecutor BossRunExecutor { get; } private readonly Dictionary _scrapExecutors = new(); // Events @@ -72,6 +74,7 @@ public class BotOrchestrator : IAsyncDisposable GameState = new GameStateDetector(); HudReader = new HudReader(); EnemyDetector = new EnemyDetector(); + BossDetector = new BossDetector(); FrameSaver = new FrameSaver(); // Register on shared pipeline @@ -79,12 +82,15 @@ public class BotOrchestrator : IAsyncDisposable pipelineService.Pipeline.AddConsumer(GameState); pipelineService.Pipeline.AddConsumer(HudReader); pipelineService.Pipeline.AddConsumer(EnemyDetector); + pipelineService.Pipeline.AddConsumer(BossDetector); pipelineService.Pipeline.AddConsumer(FrameSaver); // Pass shared pipeline to NavigationExecutor Navigation = new NavigationExecutor(game, pipelineService.Pipeline, minimapCapture, enemyDetector: EnemyDetector); + BossRunExecutor = new BossRunExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector); + logWatcher.AreaEntered += _ => Navigation.Reset(); logWatcher.Start(); // start early so area events fire even before Bot.Start() _paused = store.Settings.Paused; @@ -182,6 +188,11 @@ public class BotOrchestrator : IAsyncDisposable return; } } + if (BossRunExecutor.State != BossRunState.Idle) + { + State = BossRunExecutor.State.ToString(); + return; + } if (Navigation.State != NavigationState.Idle) { State = Navigation.State.ToString(); @@ -264,26 +275,61 @@ public class BotOrchestrator : IAsyncDisposable { LogWatcher.Start(); await Game.FocusGame(); + await Screen.Warmup(); + BossRunExecutor.StateChanged += _ => UpdateExecutorState(); Navigation.StateChanged += _ => UpdateExecutorState(); _started = true; - Emit("info", "Starting map exploration..."); - State = "Exploring"; - _ = Navigation.RunExploreLoop().ContinueWith(t => + if (Config.MapType == MapType.Kulemak) { - if (t.IsFaulted) + // Boss run needs hideout first + var inHideout = LogWatcher.CurrentArea.Contains("hideout", StringComparison.OrdinalIgnoreCase); + if (!inHideout) { - Log.Error(t.Exception!, "Explore loop failed"); - Emit("error", $"Explore loop failed: {t.Exception?.InnerException?.Message}"); + Emit("info", "Sending /hideout command..."); + var arrivedHome = await Inventory.WaitForAreaTransition(Config.TravelTimeoutMs, () => Game.GoToHideout()); + if (!arrivedHome) + Log.Warning("Timed out waiting for hideout transition on startup"); } - else + Inventory.SetLocation(true); + + Emit("info", "Starting boss run loop..."); + State = "Preparing"; + _ = BossRunExecutor.RunBossLoop().ContinueWith(t => { - Emit("info", "Exploration finished"); - } - State = "Idle"; - StatusUpdated?.Invoke(); - }); + if (t.IsFaulted) + { + Log.Error(t.Exception!, "Boss run loop failed"); + Emit("error", $"Boss run failed: {t.Exception?.InnerException?.Message}"); + } + else + { + Emit("info", "Boss run loop finished"); + } + State = "Idle"; + StatusUpdated?.Invoke(); + }); + } + else + { + Emit("info", "Starting map exploration..."); + State = "Exploring"; + _ = Navigation.RunExploreLoop().ContinueWith(t => + { + if (t.IsFaulted) + { + Log.Error(t.Exception!, "Explore loop failed"); + Emit("error", $"Explore loop failed: {t.Exception?.InnerException?.Message}"); + } + else + { + Emit("info", "Exploration finished"); + } + State = "Idle"; + StatusUpdated?.Invoke(); + }); + } } public async ValueTask DisposeAsync() diff --git a/src/Poe2Trade.Core/ConfigStore.cs b/src/Poe2Trade.Core/ConfigStore.cs index 9f1bc8f..da09e1c 100644 --- a/src/Poe2Trade.Core/ConfigStore.cs +++ b/src/Poe2Trade.Core/ConfigStore.cs @@ -34,6 +34,16 @@ public class SavedSettings public MapType MapType { get; set; } = MapType.TrialOfChaos; public StashCalibration? StashCalibration { get; set; } public StashCalibration? ShopCalibration { get; set; } + public bool ShowHudDebug { get; set; } + public KulemakSettings Kulemak { get; set; } = new(); +} + +public class KulemakSettings +{ + public bool Enabled { get; set; } + public string InvitationTabPath { get; set; } = ""; + public string LootTabPath { get; set; } = ""; + public int InvitationCount { get; set; } = 15; } public class ConfigStore @@ -129,6 +139,33 @@ public class ConfigStore try { var raw = File.ReadAllText(_filePath); + + // Migrate: BossRun was removed from BotMode, now it's MapType.Kulemak + if (raw.Contains("\"bossRun\"") || raw.Contains("\"BossRun\"")) + { + const System.Text.RegularExpressions.RegexOptions ic = + System.Text.RegularExpressions.RegexOptions.IgnoreCase; + + // Mode enum: bossRun → mapping + using var doc = JsonDocument.Parse(raw); + if (doc.RootElement.TryGetProperty("Mode", out var modeProp) && + modeProp.GetString()?.Equals("bossRun", StringComparison.OrdinalIgnoreCase) == true) + { + raw = System.Text.RegularExpressions.Regex.Replace( + raw, @"""Mode""\s*:\s*""bossRun""", @"""Mode"": ""mapping""", ic); + raw = System.Text.RegularExpressions.Regex.Replace( + raw, @"""MapType""\s*:\s*""[^""]*""", @"""MapType"": ""kulemak""", ic); + Log.Information("Migrated config: Mode bossRun -> mapping + MapType kulemak"); + } + + // MapType enum value: bossRun → kulemak + raw = System.Text.RegularExpressions.Regex.Replace( + raw, @"""MapType""\s*:\s*""bossRun""", @"""MapType"": ""kulemak""", ic); + + // Settings property name: BossRun → Kulemak + raw = raw.Replace("\"BossRun\":", "\"Kulemak\":"); + } + var parsed = JsonSerializer.Deserialize(raw, JsonOptions); if (parsed == null) return new SavedSettings(); diff --git a/src/Poe2Trade.Core/Types.cs b/src/Poe2Trade.Core/Types.cs index a05fcc3..f91d79e 100644 --- a/src/Poe2Trade.Core/Types.cs +++ b/src/Poe2Trade.Core/Types.cs @@ -75,11 +75,27 @@ public enum BotMode Mapping } +public enum BossRunState +{ + Idle, + Preparing, + TravelingToZone, + WalkingToEntrance, + UsingInvitation, + Fighting, + Looting, + Returning, + StoringLoot, + Complete, + Failed +} + public enum MapType { TrialOfChaos, Temple, - Endgame + Endgame, + Kulemak } public enum GameUiState diff --git a/src/Poe2Trade.Game/GameController.cs b/src/Poe2Trade.Game/GameController.cs index 06b0725..559d2f9 100644 --- a/src/Poe2Trade.Game/GameController.cs +++ b/src/Poe2Trade.Game/GameController.cs @@ -77,4 +77,10 @@ public class GameController : IGameController public Task ToggleMinimap() => _input.PressKey(InputSender.VK.TAB); public Task KeyDown(int vkCode) => _input.KeyDown(vkCode); public Task KeyUp(int vkCode) => _input.KeyUp(vkCode); + public Task PressPlus() => _input.PressKey(0xBB); // VK_OEM_PLUS + public Task PressKey(int vkCode) => _input.PressKey(vkCode); + public void LeftMouseDown() => _input.LeftMouseDown(); + public void LeftMouseUp() => _input.LeftMouseUp(); + public void RightMouseDown() => _input.RightMouseDown(); + public void RightMouseUp() => _input.RightMouseUp(); } diff --git a/src/Poe2Trade.Game/IGameController.cs b/src/Poe2Trade.Game/IGameController.cs index 36fbbc5..d5e9dfe 100644 --- a/src/Poe2Trade.Game/IGameController.cs +++ b/src/Poe2Trade.Game/IGameController.cs @@ -22,4 +22,10 @@ public interface IGameController Task ToggleMinimap(); Task KeyDown(int vkCode); Task KeyUp(int vkCode); + Task PressPlus(); + Task PressKey(int vkCode); + void LeftMouseDown(); + void LeftMouseUp(); + void RightMouseDown(); + void RightMouseUp(); } diff --git a/src/Poe2Trade.Game/InputSender.cs b/src/Poe2Trade.Game/InputSender.cs index af66dde..c469c97 100644 --- a/src/Poe2Trade.Game/InputSender.cs +++ b/src/Poe2Trade.Game/InputSender.cs @@ -34,6 +34,7 @@ public class InputSender public const int W = 0x57; public const int S = 0x53; public const int D = 0x44; + public const int Z = 0x5A; } public async Task PressKey(int vkCode) @@ -142,6 +143,11 @@ public class InputSender await Helpers.RandomDelay(5, 15); } + public void LeftMouseDown() => SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_LEFTDOWN); + public void LeftMouseUp() => SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_LEFTUP); + public void RightMouseDown() => SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_RIGHTDOWN); + public void RightMouseUp() => SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_RIGHTUP); + public void MoveMouseInstant(int x, int y) => MoveMouseRaw(x, y); public async Task MoveMouseFast(int x, int y) diff --git a/src/Poe2Trade.Inventory/IInventoryManager.cs b/src/Poe2Trade.Inventory/IInventoryManager.cs index cedc791..641993b 100644 --- a/src/Poe2Trade.Inventory/IInventoryManager.cs +++ b/src/Poe2Trade.Inventory/IInventoryManager.cs @@ -19,4 +19,6 @@ public interface IInventoryManager Task DepositItemsToStash(List items); Task SalvageItems(List items); (bool[,] Grid, List Items, int Free) GetInventoryState(); + Task ClickStashTab(StashTabInfo tab, StashTabInfo? parentFolder = null); + Task GrabItemsFromStash(string layoutName, int maxItems, string? templatePath = null); } diff --git a/src/Poe2Trade.Inventory/InventoryManager.cs b/src/Poe2Trade.Inventory/InventoryManager.cs index d36e60a..01a87d4 100644 --- a/src/Poe2Trade.Inventory/InventoryManager.cs +++ b/src/Poe2Trade.Inventory/InventoryManager.cs @@ -155,6 +155,7 @@ public class InventoryManager : IInventoryManager private async Task CtrlClickItems(List items, GridLayout layout, int clickDelayMs = Delays.ClickInterval) { + await _game.KeyDown(Game.InputSender.VK.SHIFT); await _game.HoldCtrl(); foreach (var item in items) { @@ -163,6 +164,7 @@ public class InventoryManager : IInventoryManager await Helpers.Sleep(clickDelayMs); } await _game.ReleaseCtrl(); + await _game.KeyUp(Game.InputSender.VK.SHIFT); await Helpers.Sleep(Delays.PostEscape); } @@ -208,13 +210,31 @@ public class InventoryManager : IInventoryManager for (var attempt = 1; attempt <= maxRetries; attempt++) { Log.Information("Searching for nameplate '{Name}' (attempt {Attempt}/{Max})", name, attempt, maxRetries); - var pos = await _screen.FindTextOnScreen(name, fuzzy: true); + + // Move mouse to bottom-left so it doesn't cover nameplates + _game.MoveMouseInstant(0, 1440); + await Helpers.Sleep(100); + + // Nameplates hidden by default — capture clean reference + using var reference = _screen.CaptureRawBitmap(); + + // Hold Alt to show nameplates, capture, then release + await _game.KeyDown(Game.InputSender.VK.MENU); + await Helpers.Sleep(50); + using var current = _screen.CaptureRawBitmap(); + await _game.KeyUp(Game.InputSender.VK.MENU); + + // Diff OCR — only processes the bright nameplate regions + var result = await _screen.NameplateDiffOcr(reference, current); + var pos = FindWordInOcrResult(result, name, fuzzy: true); if (pos.HasValue) { Log.Information("Clicking nameplate '{Name}' at ({X},{Y})", name, pos.Value.X, pos.Value.Y); await _game.LeftClickAt(pos.Value.X, pos.Value.Y); return pos; } + + Log.Debug("Nameplate '{Name}' not found in diff OCR (attempt {Attempt}), text: {Text}", name, attempt, result.Text); if (attempt < maxRetries) await Helpers.Sleep(retryDelayMs); } @@ -223,6 +243,73 @@ public class InventoryManager : IInventoryManager return null; } + private static (int X, int Y)? FindWordInOcrResult(OcrResponse result, string needle, bool fuzzy) + { + var lower = needle.ToLowerInvariant(); + + // Multi-word: match against full line text + if (lower.Contains(' ')) + { + foreach (var line in result.Lines) + { + if (line.Words.Count == 0) continue; + if (line.Text.Contains(needle, StringComparison.OrdinalIgnoreCase)) + { + var first = line.Words[0]; + var last = line.Words[^1]; + return ((first.X + last.X + last.Width) / 2, (first.Y + last.Y + last.Height) / 2); + } + if (fuzzy) + { + var sim = BigramSimilarity(Normalize(needle), Normalize(line.Text)); + if (sim >= 0.55) + { + var first = line.Words[0]; + var last = line.Words[^1]; + return ((first.X + last.X + last.Width) / 2, (first.Y + last.Y + last.Height) / 2); + } + } + } + return null; + } + + // Single word + foreach (var line in result.Lines) + foreach (var word in line.Words) + { + if (word.Text.Contains(needle, StringComparison.OrdinalIgnoreCase)) + return (word.X + word.Width / 2, word.Y + word.Height / 2); + if (fuzzy && BigramSimilarity(Normalize(needle), Normalize(word.Text)) >= 0.55) + return (word.X + word.Width / 2, word.Y + word.Height / 2); + } + return null; + } + + private static string Normalize(string s) => + new(s.ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); + + private static double BigramSimilarity(string a, string b) + { + if (a.Length < 2 || b.Length < 2) return a == b ? 1 : 0; + var bigramsA = new Dictionary<(char, char), int>(); + for (var i = 0; i < a.Length - 1; i++) + { + var bg = (a[i], a[i + 1]); + bigramsA[bg] = bigramsA.GetValueOrDefault(bg) + 1; + } + var matches = 0; + for (var i = 0; i < b.Length - 1; i++) + { + var bg = (b[i], b[i + 1]); + if (bigramsA.TryGetValue(bg, out var count) && count > 0) + { + matches++; + bigramsA[bg] = count - 1; + } + } + return 2.0 * matches / (a.Length - 1 + b.Length - 1); + } + public async Task WaitForAreaTransition(int timeoutMs, Func? triggerAction = null) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -254,4 +341,64 @@ public class InventoryManager : IInventoryManager { return (Tracker.GetGrid(), Tracker.GetItems(), Tracker.FreeCells); } + + public async Task ClickStashTab(StashTabInfo tab, StashTabInfo? parentFolder = null) + { + if (parentFolder != null) + { + Log.Information("Clicking folder '{Folder}' at ({X},{Y})", parentFolder.Name, parentFolder.ClickX, parentFolder.ClickY); + await _game.LeftClickAt(parentFolder.ClickX, parentFolder.ClickY); + await Helpers.Sleep(200); + } + + Log.Information("Clicking tab '{Tab}' at ({X},{Y})", tab.Name, tab.ClickX, tab.ClickY); + await _game.LeftClickAt(tab.ClickX, tab.ClickY); + await Helpers.Sleep(Delays.PostStashOpen); + } + + public async Task GrabItemsFromStash(string layoutName, int maxItems, string? templatePath = null) + { + Log.Information("Grabbing up to {Max} items from stash layout '{Layout}' (template={Template})", + maxItems, layoutName, templatePath ?? "none"); + + var layout = GridLayouts.All[layoutName]; + + if (templatePath != null) + { + // Template matching mode: repeatedly find and click matching items + var grabbed = 0; + await _game.HoldCtrl(); + while (grabbed < maxItems) + { + var match = await _screen.TemplateMatch(templatePath, layout.Region); + if (match == null) break; + + Log.Information("Template match at ({X},{Y}), grabbing", match.X, match.Y); + await _game.LeftClickAt(match.X, match.Y); + await Helpers.Sleep(Delays.ClickInterval); + grabbed++; + } + await _game.ReleaseCtrl(); + await Helpers.Sleep(Delays.PostEscape); + Log.Information("Grabbed {Count} matching items from stash", grabbed); + } + else + { + // Grid scan mode: grab all occupied cells + var result = await _screen.Grid.Scan(layoutName); + var grabbed = 0; + await _game.HoldCtrl(); + foreach (var cell in result.Occupied) + { + if (grabbed >= maxItems) break; + var center = _screen.Grid.GetCellCenter(layout, cell.Row, cell.Col); + await _game.LeftClickAt(center.X, center.Y); + await Helpers.Sleep(Delays.ClickInterval); + grabbed++; + } + await _game.ReleaseCtrl(); + await Helpers.Sleep(Delays.PostEscape); + Log.Information("Grabbed {Count} items from stash", grabbed); + } + } } diff --git a/src/Poe2Trade.Screen/BossDetector.cs b/src/Poe2Trade.Screen/BossDetector.cs new file mode 100644 index 0000000..6ea8fd4 --- /dev/null +++ b/src/Poe2Trade.Screen/BossDetector.cs @@ -0,0 +1,78 @@ +using Poe2Trade.Core; +using Serilog; +using Region = Poe2Trade.Core.Region; + +namespace Poe2Trade.Screen; + +public class BossDetector : IFrameConsumer, IDisposable +{ + private const int DetectEveryNFrames = 6; + private const int MinConsecutiveFrames = 2; + + private readonly PythonDetectBridge _bridge = new(); + private volatile BossSnapshot _latest = new([], 0, 0); + private int _frameCounter; + private int _consecutiveDetections; + private string _modelName = "boss-kulemak"; + + public bool Enabled { get; set; } + public BossSnapshot Latest => _latest; + public event Action? BossDetected; + + public void SetBoss(string bossName) + { + _modelName = $"boss-{bossName}"; + _consecutiveDetections = 0; + } + + public void Process(ScreenFrame frame) + { + if (!Enabled) return; + if (++_frameCounter % DetectEveryNFrames != 0) return; + + try + { + // Use full frame — model was trained on full 2560x1440 screenshots + var fullRegion = new Region(0, 0, frame.Width, frame.Height); + using var bgr = frame.CropBgr(fullRegion); + var result = _bridge.Detect(bgr, conf: 0.60f, imgsz: 1280, model: _modelName); + + var bosses = new List(result.Count); + foreach (var det in result.Detections) + { + bosses.Add(new DetectedBoss( + det.ClassName, + det.Confidence, + det.X, + det.Y, + det.Width, + det.Height, + det.Cx, + det.Cy)); + } + + var snapshot = new BossSnapshot( + bosses.AsReadOnly(), + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + result.InferenceMs); + + _latest = snapshot; + if (bosses.Count > 0) + { + _consecutiveDetections++; + if (_consecutiveDetections >= MinConsecutiveFrames) + BossDetected?.Invoke(snapshot); + } + else + { + _consecutiveDetections = 0; + } + } + catch (Exception ex) + { + Log.Debug(ex, "BossDetector YOLO failed"); + } + } + + public void Dispose() => _bridge.Dispose(); +} diff --git a/src/Poe2Trade.Screen/DetectionTypes.cs b/src/Poe2Trade.Screen/DetectionTypes.cs index f76ef8c..6bb9b9f 100644 --- a/src/Poe2Trade.Screen/DetectionTypes.cs +++ b/src/Poe2Trade.Screen/DetectionTypes.cs @@ -10,3 +10,14 @@ public record DetectionSnapshot( IReadOnlyList Enemies, long Timestamp, float InferenceMs); + +public record DetectedBoss( + string ClassName, + float Confidence, + int X, int Y, int Width, int Height, + int Cx, int Cy); + +public record BossSnapshot( + IReadOnlyList Bosses, + long Timestamp, + float InferenceMs); diff --git a/src/Poe2Trade.Screen/FrameSaver.cs b/src/Poe2Trade.Screen/FrameSaver.cs index 65ad6bc..9bdfb17 100644 --- a/src/Poe2Trade.Screen/FrameSaver.cs +++ b/src/Poe2Trade.Screen/FrameSaver.cs @@ -16,6 +16,7 @@ public class FrameSaver : IFrameConsumer private const int JpegQuality = 95; private const int MinSaveIntervalMs = 1000; + private const int BurstIntervalMs = 200; private const int MinRedPixels = 50; private const int ThumbSize = 64; private const double MovementThreshold = 8.0; // mean absolute diff on 64x64 grayscale @@ -26,6 +27,7 @@ public class FrameSaver : IFrameConsumer private Mat? _prevThumb; public bool Enabled { get; set; } + public bool BurstMode { get; set; } public int SavedCount => _savedCount; public FrameSaver(string outputDir = "training-data/raw") @@ -35,10 +37,11 @@ public class FrameSaver : IFrameConsumer public void Process(ScreenFrame frame) { - if (!Enabled) return; + if (!Enabled && !BurstMode) return; var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - if (now - _lastSaveTime < MinSaveIntervalMs) return; + var interval = BurstMode ? BurstIntervalMs : MinSaveIntervalMs; + if (now - _lastSaveTime < interval) return; if (GameplayRegion.X + GameplayRegion.Width > frame.Width || GameplayRegion.Y + GameplayRegion.Height > frame.Height) @@ -46,10 +49,12 @@ public class FrameSaver : IFrameConsumer try { - using var bgr = frame.CropBgr(GameplayRegion); - - if (!HasHealthBars(bgr)) return; - if (!HasSceneChanged(bgr)) return; + if (!BurstMode) + { + using var bgr = frame.CropBgr(GameplayRegion); + if (!HasHealthBars(bgr)) return; + if (!HasSceneChanged(bgr)) return; + } if (!Directory.Exists(_outputDir)) Directory.CreateDirectory(_outputDir); diff --git a/src/Poe2Trade.Screen/HudReader.cs b/src/Poe2Trade.Screen/HudReader.cs index ef7769b..fb867a8 100644 --- a/src/Poe2Trade.Screen/HudReader.cs +++ b/src/Poe2Trade.Screen/HudReader.cs @@ -1,5 +1,3 @@ -using System.Drawing; -using System.Text.RegularExpressions; using OpenCvSharp; using Poe2Trade.Core; using Serilog; @@ -7,38 +5,41 @@ using Region = Poe2Trade.Core.Region; namespace Poe2Trade.Screen; -public record HudValues(int Current, int Max); - public record HudSnapshot { - public HudValues? Life { get; init; } - public HudValues? Mana { get; init; } - public HudValues? EnergyShield { get; init; } - public HudValues? Spirit { get; init; } + public float LifePct { get; init; } + public float ShieldPct { get; init; } + public float ManaPct { get; init; } public long Timestamp { get; init; } - - public float LifePct => Life is { Max: > 0 } l ? (float)l.Current / l.Max : 1f; - public float ManaPct => Mana is { Max: > 0 } m ? (float)m.Current / m.Max : 1f; } /// -/// Reads life/mana/ES/spirit values from HUD globe text via OCR. -/// Throttled to ~1 read per second (every 30 frames at 30fps). +/// Reads life/mana/shield fill levels by sampling pixel colors on the globes. +/// Finds the highest Y where the fill color appears — the fill drains from top down. +/// Samples a horizontal band (±SampleHalfWidth) at each Y for robustness against the frame ornaments. /// public class HudReader : IFrameConsumer { - private static readonly Regex ValuePattern = new(@"(\d+)\s*/\s*(\d+)", RegexOptions.Compiled); + // Globe centers at 2560x1440 + private const int LifeX = 167; + private const int ManaX = 2394; + private const int GlobeTop = 1185; + private const int GlobeBottom = 1411; - // Crop regions for HUD text at 2560x1440 — placeholders, need calibration - private static readonly Region LifeRegion = new(100, 1340, 200, 40); - private static readonly Region ManaRegion = new(2260, 1340, 200, 40); - private static readonly Region EsRegion = new(100, 1300, 200, 40); - private static readonly Region SpiritRegion = new(2260, 1300, 200, 40); + // Shield ring: circle centered at (168, 1294), radius 130 + private const int ShieldCX = 170; + private const int ShieldCY = 1298; + private const int ShieldRadius = 130; - private const int OcrEveryNFrames = 30; + // Sample a horizontal band of pixels at each Y level + private const int SampleHalfWidth = 8; + // Minimum pixels in the band that must match to count as "filled" + private const int MinHits = 2; - private readonly PythonOcrBridge _ocr = new(); - private volatile HudSnapshot _current = new() { Timestamp = 0 }; + private const int MinChannel = 60; + private const float DominanceRatio = 1.2f; + + private volatile HudSnapshot _current = new(); private int _frameCounter; public HudSnapshot Current => _current; @@ -47,64 +48,128 @@ public class HudReader : IFrameConsumer public void Process(ScreenFrame frame) { - if (++_frameCounter % OcrEveryNFrames != 0) return; + if (++_frameCounter % 2 != 0) return; try { - var life = ReadValue(frame, LifeRegion); - var mana = ReadValue(frame, ManaRegion); - var es = ReadValue(frame, EsRegion); - var spirit = ReadValue(frame, SpiritRegion); + var manaPct = SampleFillLevel(frame, ManaX, IsManaPixel); + var shieldPct = SampleShieldRing(frame); + + // If life globe is cyan (1-life build), life = 0 + var redFill = SampleFillLevel(frame, LifeX, IsLifePixel); + var cyanFill = SampleFillLevel(frame, LifeX, IsCyanPixel); + var lifePct = cyanFill > redFill ? 0f : redFill; var snapshot = new HudSnapshot { - Life = life, - Mana = mana, - EnergyShield = es, - Spirit = spirit, + LifePct = lifePct, + ManaPct = manaPct, + ShieldPct = shieldPct, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }; _current = snapshot; Updated?.Invoke(snapshot); - if (snapshot.LifePct < 0.3f) + if (lifePct < 0.3f) LowLife?.Invoke(snapshot); } catch (Exception ex) { - Log.Debug(ex, "HudReader OCR failed"); + Log.Debug(ex, "HudReader sample failed"); } } - private HudValues? ReadValue(ScreenFrame frame, Region region) + /// + /// Scan from top to bottom to find the first Y row where the fill color appears. + /// Fill % = 1 - (firstFilledY - GlobeTop) / (GlobeBottom - GlobeTop). + /// At each Y, sample a horizontal band of pixels and require MinHits matches. + /// + private static float SampleFillLevel(ScreenFrame frame, int centerX, Func colorTest) { - // Bounds check - if (region.X + region.Width > frame.Width || region.Y + region.Height > frame.Height) - return null; + if (centerX >= frame.Width || GlobeBottom >= frame.Height) return 0f; - using var bgr = frame.CropBgr(region); - using var gray = new Mat(); - Cv2.CvtColor(bgr, gray, ColorConversionCodes.BGR2GRAY); + int height = GlobeBottom - GlobeTop; + if (height <= 0) return 0f; - // Threshold for white text on dark background - using var thresh = new Mat(); - Cv2.Threshold(gray, thresh, 180, 255, ThresholdTypes.Binary); + int xMin = Math.Max(0, centerX - SampleHalfWidth); + int xMax = Math.Min(frame.Width - 1, centerX + SampleHalfWidth); - // Convert to Bitmap for OCR bridge - var bytes = thresh.ToBytes(".png"); - using var ms = new System.IO.MemoryStream(bytes); - using var bitmap = new Bitmap(ms); + // Scan from top down — find first row with enough matching pixels + for (int y = GlobeTop; y <= GlobeBottom; y++) + { + int hits = 0; + for (int x = xMin; x <= xMax; x++) + { + if (colorTest(frame.PixelAt(x, y))) + hits++; + if (hits >= MinHits) break; + } - var result = _ocr.OcrFromBitmap(bitmap); - if (string.IsNullOrWhiteSpace(result.Text)) return null; + if (hits >= MinHits) + { + // Fill level = how far down from top this first row is + // If found at GlobeTop → 100%, at GlobeBottom → 0% + return 1f - (float)(y - GlobeTop) / height; + } + } - var match = ValuePattern.Match(result.Text); - if (!match.Success) return null; - - return new HudValues( - int.Parse(match.Groups[1].Value), - int.Parse(match.Groups[2].Value) - ); + return 0f; // no fill found } + + /// + /// Sample the shield ring — right semicircle (12 o'clock to 6 o'clock) around the life globe. + /// Scans from bottom (6 o'clock) upward along the arc, tracking contiguous cyan fill. + /// + private static float SampleShieldRing(ScreenFrame frame) + { + int yTop = ShieldCY - ShieldRadius; + int yBot = ShieldCY + ShieldRadius; + if (yBot >= frame.Height) return 0f; + + int r2 = ShieldRadius * ShieldRadius; + + // Scan from top (12 o'clock) down along the right arc + // When we find the first cyan row, convert Y to arc fraction + for (int y = yTop; y <= yBot; y++) + { + int dy = y - ShieldCY; + int dx = (int)Math.Sqrt(r2 - dy * dy); + int arcX = ShieldCX + dx; + + if (arcX >= frame.Width) continue; + + int hits = 0; + for (int x = Math.Max(0, arcX - 3); x <= Math.Min(frame.Width - 1, arcX + 3); x++) + { + if (IsCyanPixel(frame.PixelAt(x, y))) + hits++; + if (hits >= MinHits) break; + } + + if (hits >= MinHits) + { + // Convert Y to angle on the semicircle: θ = arcsin((y - cy) / r) + // Arc fraction from top = (θ + π/2) / π + // Fill = 1 - arc_fraction + var theta = Math.Asin(Math.Clamp((double)(y - ShieldCY) / ShieldRadius, -1, 1)); + var arcFraction = (theta + Math.PI / 2) / Math.PI; + return (float)(1.0 - arcFraction); + } + } + + return 0f; + } + + // B=0, G=1, R=2, A=3 + private static bool IsLifePixel(Vec4b px) => + px[2] > MinChannel && px[2] > px[1] * DominanceRatio && px[2] > px[0] * DominanceRatio; + + private static bool IsManaPixel(Vec4b px) => + px[0] > MinChannel && px[0] > px[1] * DominanceRatio && px[0] > px[2] * DominanceRatio; + + private static bool IsCyanPixel(Vec4b px) => + px[0] > MinChannel && px[1] > MinChannel + && px[0] > px[2] * DominanceRatio + && px[1] > px[2] * DominanceRatio; } diff --git a/src/Poe2Trade.Screen/IScreenReader.cs b/src/Poe2Trade.Screen/IScreenReader.cs index 3293991..b9b9b95 100644 --- a/src/Poe2Trade.Screen/IScreenReader.cs +++ b/src/Poe2Trade.Screen/IScreenReader.cs @@ -17,6 +17,8 @@ public interface IScreenReader : IDisposable Task Snapshot(); Task DiffOcr(string? savePath = null, Region? region = null); Task TemplateMatch(string templatePath, Region? region = null); + Task NameplateDiffOcr(System.Drawing.Bitmap reference, System.Drawing.Bitmap current); + System.Drawing.Bitmap CaptureRawBitmap(); Task SaveScreenshot(string path); Task SaveRegion(Region region, string path); } diff --git a/src/Poe2Trade.Screen/PythonDetectBridge.cs b/src/Poe2Trade.Screen/PythonDetectBridge.cs index 7a398a3..c262cf1 100644 --- a/src/Poe2Trade.Screen/PythonDetectBridge.cs +++ b/src/Poe2Trade.Screen/PythonDetectBridge.cs @@ -35,7 +35,7 @@ class PythonDetectBridge : IDisposable /// /// Run YOLO detection on a BGR Mat. Returns parsed detection results. /// - public DetectResponse Detect(Mat bgrMat, float conf = 0.3f, float iou = 0.45f, int imgsz = 640) + public DetectResponse Detect(Mat bgrMat, float conf = 0.3f, float iou = 0.45f, int imgsz = 640, string? model = null) { EnsureRunning(); @@ -49,6 +49,7 @@ class PythonDetectBridge : IDisposable ["conf"] = conf, ["iou"] = iou, ["imgsz"] = imgsz, + ["model"] = model, }; return SendRequest(req); diff --git a/src/Poe2Trade.Screen/ScreenReader.cs b/src/Poe2Trade.Screen/ScreenReader.cs index 11c53d9..77f6d82 100644 --- a/src/Poe2Trade.Screen/ScreenReader.cs +++ b/src/Poe2Trade.Screen/ScreenReader.cs @@ -1,6 +1,10 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; using Poe2Trade.Core; using OpenCvSharp.Extensions; using Serilog; +using Region = Poe2Trade.Core.Region; namespace Poe2Trade.Screen; @@ -178,6 +182,144 @@ public class ScreenReader : IScreenReader return Task.CompletedTask; } + // -- Nameplate diff OCR -- + + public Bitmap CaptureRawBitmap() => ScreenCapture.CaptureOrLoad(null, null); + + public Task NameplateDiffOcr(Bitmap reference, Bitmap current) + { + int w = Math.Min(reference.Width, current.Width); + int h = Math.Min(reference.Height, current.Height); + + var refData = reference.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + var curData = current.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + byte[] refPx = new byte[refData.Stride * h]; + byte[] curPx = new byte[curData.Stride * h]; + Marshal.Copy(refData.Scan0, refPx, 0, refPx.Length); + Marshal.Copy(curData.Scan0, curPx, 0, curPx.Length); + int stride = refData.Stride; + reference.UnlockBits(refData); + current.UnlockBits(curData); + + // Build a binary mask of pixels that got significantly brighter (nameplates are bright text) + const int brightThresh = 30; + bool[] mask = new bool[w * h]; + Parallel.For(0, h, y => + { + int rowOff = y * stride; + for (int x = 0; x < w; x++) + { + int i = rowOff + x * 4; + int brighter = (curPx[i] - refPx[i]) + (curPx[i + 1] - refPx[i + 1]) + (curPx[i + 2] - refPx[i + 2]); + if (brighter > brightThresh) + mask[y * w + x] = true; + } + }); + + // Find connected clusters via row-scan: collect bounding boxes of bright regions + var boxes = FindBrightClusters(mask, w, h, minWidth: 40, minHeight: 10, maxGap: 8); + Log.Information("NameplateDiff: found {Count} bright clusters", boxes.Count); + + if (boxes.Count == 0) + return Task.FromResult(new OcrResponse { Text = "", Lines = [] }); + + // OCR each cluster crop, accumulate results with screen-space coordinates + var allLines = new List(); + var allText = new List(); + + foreach (var box in boxes) + { + // Pad the crop slightly + int pad = 4; + int cx = Math.Max(0, box.X - pad); + int cy = Math.Max(0, box.Y - pad); + int cw = Math.Min(w - cx, box.Width + pad * 2); + int ch = Math.Min(h - cy, box.Height + pad * 2); + + using var crop = current.Clone(new Rectangle(cx, cy, cw, ch), PixelFormat.Format32bppArgb); + var ocrResult = _pythonBridge.OcrFromBitmap(crop); + + // Offset word coordinates to screen space + foreach (var line in ocrResult.Lines) + { + foreach (var word in line.Words) + { + word.X += cx; + word.Y += cy; + } + allLines.Add(line); + allText.Add(line.Text); + } + } + + return Task.FromResult(new OcrResponse + { + Text = string.Join("\n", allText), + Lines = allLines, + }); + } + + private static List FindBrightClusters(bool[] mask, int w, int h, int minWidth, int minHeight, int maxGap) + { + // Row density + int[] rowCounts = new int[h]; + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + if (mask[y * w + x]) rowCounts[y]++; + + // Find horizontal bands of bright rows + int rowThresh = 3; + var bands = new List<(int Top, int Bottom)>(); + int bandStart = -1, lastActive = -1; + for (int y = 0; y < h; y++) + { + if (rowCounts[y] >= rowThresh) + { + if (bandStart < 0) bandStart = y; + lastActive = y; + } + else if (bandStart >= 0 && y - lastActive > maxGap) + { + if (lastActive - bandStart + 1 >= minHeight) + bands.Add((bandStart, lastActive)); + bandStart = -1; + } + } + if (bandStart >= 0 && lastActive - bandStart + 1 >= minHeight) + bands.Add((bandStart, lastActive)); + + // For each band, find column extents to get individual nameplate boxes + var boxes = new List(); + foreach (var (top, bottom) in bands) + { + int[] colCounts = new int[w]; + for (int y = top; y <= bottom; y++) + for (int x = 0; x < w; x++) + if (mask[y * w + x]) colCounts[x]++; + + int colThresh = 1; + int colStart = -1, lastCol = -1; + for (int x = 0; x < w; x++) + { + if (colCounts[x] >= colThresh) + { + if (colStart < 0) colStart = x; + lastCol = x; + } + else if (colStart >= 0 && x - lastCol > maxGap) + { + if (lastCol - colStart + 1 >= minWidth) + boxes.Add(new Rectangle(colStart, top, lastCol - colStart + 1, bottom - top + 1)); + colStart = -1; + } + } + if (colStart >= 0 && lastCol - colStart + 1 >= minWidth) + boxes.Add(new Rectangle(colStart, top, lastCol - colStart + 1, bottom - top + 1)); + } + + return boxes; + } + public void Dispose() => _pythonBridge.Dispose(); // -- OCR text matching -- diff --git a/src/Poe2Trade.Screen/TemplateMatchHandler.cs b/src/Poe2Trade.Screen/TemplateMatchHandler.cs index 3aef223..d030eb6 100644 --- a/src/Poe2Trade.Screen/TemplateMatchHandler.cs +++ b/src/Poe2Trade.Screen/TemplateMatchHandler.cs @@ -27,27 +27,56 @@ class TemplateMatchHandler else screenMat.CopyTo(screenBgr); - // Template must fit within screenshot - if (template.Rows > screenBgr.Rows || template.Cols > screenBgr.Cols) + // Try exact size first (fast path) + var exact = MatchAtScale(screenBgr, template, region, 1.0, threshold); + if (exact is { Confidence: > 0.95 }) + return exact; + + // Multi-scale: resize template from 50% to 150% in steps of 10% + TemplateMatchResult? best = exact; + for (var pct = 50; pct <= 150; pct += 10) + { + var scale = pct / 100.0; + if (pct == 100) continue; // already tried + + var match = MatchAtScale(screenBgr, template, region, scale, threshold); + if (match != null && (best == null || match.Confidence > best.Confidence)) + { + best = match; + if (best.Confidence > 0.95) break; + } + } + + return best; + } + + private static TemplateMatchResult? MatchAtScale(Mat screen, Mat template, + Region? region, double scale, double threshold) + { + using var scaled = scale == 1.0 ? template.Clone() + : template.Resize(new OpenCvSharp.Size( + Math.Max(1, (int)(template.Cols * scale)), + Math.Max(1, (int)(template.Rows * scale)))); + + if (scaled.Rows > screen.Rows || scaled.Cols > screen.Cols) return null; using var result = new Mat(); - Cv2.MatchTemplate(screenBgr, template, result, TemplateMatchModes.CCoeffNormed); - + Cv2.MatchTemplate(screen, scaled, result, TemplateMatchModes.CCoeffNormed); Cv2.MinMaxLoc(result, out _, out double maxVal, out _, out OpenCvSharp.Point maxLoc); if (maxVal < threshold) return null; - int offsetX = region?.X ?? 0; - int offsetY = region?.Y ?? 0; + var offsetX = region?.X ?? 0; + var offsetY = region?.Y ?? 0; return new TemplateMatchResult { - X = offsetX + maxLoc.X + template.Cols / 2, - Y = offsetY + maxLoc.Y + template.Rows / 2, - Width = template.Cols, - Height = template.Rows, + X = offsetX + maxLoc.X + scaled.Cols / 2, + Y = offsetY + maxLoc.Y + scaled.Rows / 2, + Width = scaled.Cols, + Height = scaled.Rows, Confidence = maxVal, }; } diff --git a/src/Poe2Trade.Ui/Converters/ValueConverters.cs b/src/Poe2Trade.Ui/Converters/ValueConverters.cs index e6de743..f9db3f2 100644 --- a/src/Poe2Trade.Ui/Converters/ValueConverters.cs +++ b/src/Poe2Trade.Ui/Converters/ValueConverters.cs @@ -103,6 +103,7 @@ public class MapRequirementsConverter : IValueConverter MapType.TrialOfChaos => "Trial Token x1", MapType.Temple => "Identity Scroll x20", MapType.Endgame => "Identity Scroll x20", + MapType.Kulemak => "Invitation x1", _ => "", }; } diff --git a/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs b/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs index cd2a697..dd11c49 100644 --- a/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs +++ b/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs @@ -181,13 +181,16 @@ public sealed class D2dOverlay private OverlayState BuildState(double fps, RenderTiming timing) { var detection = _bot.EnemyDetector.Latest; + var bossDetection = _bot.BossDetector.Latest; return new OverlayState( Enemies: detection.Enemies, + Bosses: bossDetection.Bosses, InferenceMs: detection.InferenceMs, Hud: _bot.HudReader.Current, NavState: _bot.Navigation.State, NavPosition: _bot.Navigation.Position, IsExploring: _bot.Navigation.IsExploring, + ShowHudDebug: _bot.Store.Settings.ShowHudDebug, Fps: fps, Timing: timing); } diff --git a/src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs b/src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs index a8f9ca7..178201e 100644 --- a/src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs +++ b/src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs @@ -24,11 +24,13 @@ public sealed class D2dRenderContext : IDisposable // Pre-created brushes public ID2D1SolidColorBrush Red { get; private set; } = null!; public ID2D1SolidColorBrush Yellow { get; private set; } = null!; + public ID2D1SolidColorBrush Cyan { 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 ShieldBrush { 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!; @@ -79,11 +81,13 @@ public sealed class D2dRenderContext : IDisposable { Red = RenderTarget.CreateSolidColorBrush(new Color4(1f, 0f, 0f, 1f)); Yellow = RenderTarget.CreateSolidColorBrush(new Color4(1f, 1f, 0f, 1f)); + Cyan = RenderTarget.CreateSolidColorBrush(new Color4(0f, 1f, 1f, 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)); + ShieldBrush = RenderTarget.CreateSolidColorBrush(new Color4(100 / 255f, 180 / 255f, 220 / 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)); @@ -95,11 +99,13 @@ public sealed class D2dRenderContext : IDisposable { Red?.Dispose(); Yellow?.Dispose(); + Cyan?.Dispose(); Green?.Dispose(); White?.Dispose(); Gray?.Dispose(); LifeBrush?.Dispose(); ManaBrush?.Dispose(); + ShieldBrush?.Dispose(); BarBgBrush?.Dispose(); LabelBgBrush?.Dispose(); DebugTextBrush?.Dispose(); diff --git a/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs b/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs index ce824cd..402742a 100644 --- a/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs +++ b/src/Poe2Trade.Ui/Overlay/IOverlayLayer.cs @@ -5,11 +5,13 @@ namespace Poe2Trade.Ui.Overlay; public record OverlayState( IReadOnlyList Enemies, + IReadOnlyList Bosses, float InferenceMs, HudSnapshot? Hud, NavigationState NavState, MapPosition NavPosition, bool IsExploring, + bool ShowHudDebug, double Fps, RenderTiming? Timing); diff --git a/src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs index 2afcdc0..6f8d94b 100644 --- a/src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs +++ b/src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs @@ -27,7 +27,7 @@ internal sealed class D2dDebugTextLayer : ID2dOverlayLayer, IDisposable 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); + UpdateCache(ctx, _left, ref lc, $"HP: {hud.LifePct:P0} ES: {hud.ShieldPct:P0} MP: {hud.ManaPct:P0}", ctx.DebugTextBrush); // Right column: timing if (state.Timing != null) diff --git a/src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs index 584e88a..48ea2c0 100644 --- a/src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs +++ b/src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs @@ -11,8 +11,13 @@ internal sealed class D2dEnemyBoxLayer : ID2dOverlayLayer, IDisposable private readonly IDWriteTextLayout[] _confirmedLabels = new IDWriteTextLayout[101]; private readonly IDWriteTextLayout[] _unconfirmedLabels = new IDWriteTextLayout[101]; + // Boss labels: cached by "classname NN%" string + private readonly Dictionary _bossLabels = new(); + private readonly D2dRenderContext _ctx; + public D2dEnemyBoxLayer(D2dRenderContext ctx) { + _ctx = ctx; for (int i = 0; i <= 100; i++) { var text = $"{i}%"; @@ -41,18 +46,43 @@ internal sealed class D2dEnemyBoxLayer : ID2dOverlayLayer, IDisposable 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); } + + // Boss bounding boxes (cyan) + foreach (var boss in state.Bosses) + { + var rect = new RectangleF(boss.X, boss.Y, boss.Width, boss.Height); + rt.DrawRectangle(rect, ctx.Cyan, 3f); + + var pct = Math.Clamp((int)(boss.Confidence * 100), 0, 100); + var key = $"{boss.ClassName} {pct}%"; + if (!_bossLabels.TryGetValue(key, out var layout)) + { + layout = _ctx.CreateTextLayout(key, _ctx.LabelFormat); + _bossLabels[key] = layout; + } + + var m = layout.Metrics; + var labelX = boss.X; + var labelY = boss.Y - m.Height - 2; + + 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, ctx.Cyan); + } } public void Dispose() { foreach (var l in _confirmedLabels) l?.Dispose(); foreach (var l in _unconfirmedLabels) l?.Dispose(); + foreach (var l in _bossLabels.Values) l?.Dispose(); } } diff --git a/src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs b/src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs index 0c89b53..8ce549c 100644 --- a/src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs +++ b/src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs @@ -7,15 +7,20 @@ namespace Poe2Trade.Ui.Overlay.Layers; internal sealed class D2dHudInfoLayer : ID2dOverlayLayer, IDisposable { - private const float BarWidth = 200; + private const float BarWidth = 160; private const float BarHeight = 16; - private const float BarY = 1300; - private const float LifeBarX = 1130; - private const float ManaBarX = 1230; + private const float BarGap = 8; + private const float BarY = 1416; // near bottom of 1440 + + // 3 bars centered: total = 160*3 + 8*2 = 496, start = (2560-496)/2 = 1032 + private const float LifeBarX = 1032; + private const float ShieldBarX = LifeBarX + BarWidth + BarGap; + private const float ManaBarX = ShieldBarX + BarWidth + BarGap; - // Cached bar value layouts private string? _lifeLabel; private IDWriteTextLayout? _lifeLayout; + private string? _shieldLabel; + private IDWriteTextLayout? _shieldLayout; private string? _manaLabel; private IDWriteTextLayout? _manaLayout; @@ -23,14 +28,24 @@ internal sealed class D2dHudInfoLayer : ID2dOverlayLayer, IDisposable { if (state.Hud == null || state.Hud.Timestamp == 0) return; - DrawBar(ctx, LifeBarX, BarY, state.Hud.LifePct, ctx.LifeBrush, state.Hud.Life, + DrawBar(ctx, LifeBarX, BarY, state.Hud.LifePct, ctx.LifeBrush, ref _lifeLabel, ref _lifeLayout); - DrawBar(ctx, ManaBarX, BarY, state.Hud.ManaPct, ctx.ManaBrush, state.Hud.Mana, + DrawBar(ctx, ShieldBarX, BarY, state.Hud.ShieldPct, ctx.ShieldBrush, + ref _shieldLabel, ref _shieldLayout); + DrawBar(ctx, ManaBarX, BarY, state.Hud.ManaPct, ctx.ManaBrush, ref _manaLabel, ref _manaLayout); + + // DEBUG: draw sampling lines + if (state.ShowHudDebug) + { + DrawShieldArc(ctx); + DrawSampleLine(ctx, 167, 1185, 1411, ctx.LifeBrush); // life + DrawSampleLine(ctx, 2394, 1185, 1411, ctx.ManaBrush); // mana + } } private static void DrawBar(D2dRenderContext ctx, float x, float y, float pct, - ID2D1SolidColorBrush fillBrush, Screen.HudValues? values, + ID2D1SolidColorBrush fillBrush, ref string? cachedLabel, ref IDWriteTextLayout? cachedLayout) { var rt = ctx.RenderTarget; @@ -42,31 +57,57 @@ internal sealed class D2dHudInfoLayer : ID2dOverlayLayer, IDisposable rt.DrawRectangle(outer, ctx.Gray, 1f); // Fill - var fillWidth = BarWidth * Math.Clamp(pct, 0, 1); + var clamped = Math.Clamp(pct, 0, 1); + var fillWidth = BarWidth * clamped; if (fillWidth > 0) rt.FillRectangle(new RectangleF(x, y, fillWidth, BarHeight), fillBrush); - // Value text - if (values != null) + // Percentage text + var label = $"{clamped:P0}"; + if (label != cachedLabel) { - 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); + 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); + } + + private static void DrawShieldArc(D2dRenderContext ctx) + { + const float cx = 170, cy = 1298, r = 130; + var rt = ctx.RenderTarget; + + // Draw dots along the right semicircle (-90° to +90°) + for (int deg = -90; deg <= 90; deg += 2) + { + var rad = deg * Math.PI / 180.0; + var x = (float)(cx + r * Math.Cos(rad)); + var y = (float)(cy + r * Math.Sin(rad)); + rt.FillRectangle(new RectangleF(x - 1, y - 1, 3, 3), ctx.Yellow); + } + + // Draw center cross + rt.FillRectangle(new RectangleF(cx - 3, cy - 1, 7, 3), ctx.Yellow); + rt.FillRectangle(new RectangleF(cx - 1, cy - 3, 3, 7), ctx.Yellow); + } + + private static void DrawSampleLine(D2dRenderContext ctx, float x, float yTop, float yBot, ID2D1SolidColorBrush brush) + { + ctx.RenderTarget.DrawLine( + new System.Numerics.Vector2(x, yTop), + new System.Numerics.Vector2(x, yBot), + brush, 2f); } public void Dispose() { _lifeLayout?.Dispose(); + _shieldLayout?.Dispose(); _manaLayout?.Dispose(); } } diff --git a/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs index 5d04872..b403995 100644 --- a/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs @@ -12,6 +12,11 @@ public partial class DebugViewModel : ObservableObject [ObservableProperty] private string _findText = ""; [ObservableProperty] private string _debugResult = ""; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(BurstCaptureLabel))] + private bool _isBurstCapturing; + + public string BurstCaptureLabel => IsBurstCapturing ? "Stop Capture" : "Burst Capture"; [ObservableProperty] private string _selectedGridLayout = "inventory"; [ObservableProperty] private decimal? _clickX; [ObservableProperty] private decimal? _clickY; @@ -148,6 +153,15 @@ public partial class DebugViewModel : ObservableObject } } + [RelayCommand] + private void DetectionStatus() + { + var enemy = _bot.EnemyDetector.Latest; + var boss = _bot.BossDetector.Latest; + DebugResult = $"Enemy: enabled={_bot.EnemyDetector.Enabled}, count={enemy.Enemies.Count}, ms={enemy.InferenceMs:F1}\n" + + $"Boss: enabled={_bot.BossDetector.Enabled}, count={boss.Bosses.Count}, ms={boss.InferenceMs:F1}"; + } + [RelayCommand] private void SaveMinimapDebug() { @@ -187,6 +201,116 @@ public partial class DebugViewModel : ObservableObject catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; } } + [RelayCommand] + private async Task AttackTest() + { + const int VK_Q = 0x51; + const int DurationMs = 30_000; + const int PollMs = 100; + const float ManaLow = 0.50f; + const float ManaResume = 0.75f; + const float ManaQThreshold = 0.60f; + const int QPhaseStableMs = 2_000; + const int QCooldownMs = 5_000; + + var rng = new Random(); + try + { + DebugResult = "Attack test: focusing game..."; + await _bot.Game.FocusGame(); + await _bot.Game.MoveMouseTo(1280, 720); + await Task.Delay(300); + + var holding = true; + _bot.Game.LeftMouseDown(); + _bot.Game.RightMouseDown(); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var manaStableStart = (long?)null; + var qPhase = false; + long lastQTime = -QCooldownMs; + + while (sw.ElapsedMilliseconds < DurationMs) + { + var mana = _bot.HudReader.Current.ManaPct; + var elapsed = sw.ElapsedMilliseconds; + + // Mana management + if (holding && mana < ManaLow) + { + _bot.Game.LeftMouseUp(); + _bot.Game.RightMouseUp(); + holding = false; + DebugResult = $"Attack test: mana low ({mana:P0}), waiting..."; + await Task.Delay(50 + rng.Next(100)); + } + else if (!holding && mana >= ManaResume) + { + await Task.Delay(50 + rng.Next(100)); + _bot.Game.LeftMouseDown(); + _bot.Game.RightMouseDown(); + holding = true; + DebugResult = $"Attack test: mana recovered ({mana:P0}), attacking..."; + } + + // Track Q phase activation + if (!qPhase) + { + if (mana > ManaQThreshold) + { + manaStableStart ??= elapsed; + if (elapsed - manaStableStart.Value >= QPhaseStableMs) + { + qPhase = true; + DebugResult = "Attack test: Q phase activated"; + } + } + else + { + manaStableStart = null; + } + } + + // Press Q+E periodically + if (qPhase && holding && elapsed - lastQTime >= QCooldownMs) + { + await _bot.Game.PressKey(VK_Q); + await Task.Delay(100 + rng.Next(100)); + _bot.Game.LeftMouseUp(); + _bot.Game.RightMouseUp(); + await Task.Delay(200 + rng.Next(100)); + _bot.Game.LeftMouseDown(); + _bot.Game.RightMouseDown(); + lastQTime = elapsed; + } + + await Task.Delay(PollMs + rng.Next(100)); + } + + DebugResult = "Attack test: completed (30s)"; + } + catch (Exception ex) + { + DebugResult = $"Attack test failed: {ex.Message}"; + Log.Error(ex, "Attack test failed"); + } + finally + { + _bot.Game.LeftMouseUp(); + _bot.Game.RightMouseUp(); + } + } + + [RelayCommand] + private void ToggleBurstCapture() + { + IsBurstCapturing = !IsBurstCapturing; + _bot.FrameSaver.BurstMode = IsBurstCapturing; + DebugResult = IsBurstCapturing + ? "Burst capture ON — saving every 200ms to training-data/raw/" + : $"Burst capture OFF — {_bot.FrameSaver.SavedCount} frames saved"; + } + [RelayCommand] private async Task ClickSalvage() { diff --git a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs index 32d1e69..edd75f9 100644 --- a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs @@ -194,11 +194,12 @@ public partial class MainWindowViewModel : ObservableObject { Log.Information("END pressed — emergency stop"); await _bot.Navigation.Stop(); + _bot.BossRunExecutor.Stop(); _bot.Pause(); Avalonia.Threading.Dispatcher.UIThread.Post(() => { IsPaused = true; - State = "Stopped (F12)"; + State = "Stopped (END)"; }); } f12WasDown = endDown; diff --git a/src/Poe2Trade.Ui/ViewModels/MappingViewModel.cs b/src/Poe2Trade.Ui/ViewModels/MappingViewModel.cs index 9104b4c..2b83730 100644 --- a/src/Poe2Trade.Ui/ViewModels/MappingViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/MappingViewModel.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using Timer = System.Timers.Timer; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -19,16 +20,33 @@ public partial class MappingViewModel : ObservableObject, IDisposable [ObservableProperty] private int _enemiesDetected; [ObservableProperty] private float _inferenceMs; [ObservableProperty] private bool _hasModel; + [ObservableProperty] private bool _isKulemak; + [ObservableProperty] private bool _kulemakEnabled; + [ObservableProperty] private string _invitationTabPath = ""; + [ObservableProperty] private string _lootTabPath = ""; + [ObservableProperty] private decimal? _invitationCount = 15; - public static MapType[] MapTypes { get; } = [MapType.TrialOfChaos, MapType.Temple, MapType.Endgame]; + public static MapType[] MapTypes { get; } = [MapType.TrialOfChaos, MapType.Temple, MapType.Endgame, MapType.Kulemak]; + public ObservableCollection StashTabPaths { get; } = []; - private static readonly string ModelPath = Path.GetFullPath("tools/python-detect/models/enemy-v1.pt"); + private static readonly string ModelsDir = Path.GetFullPath("tools/python-detect/models"); + + private static bool AnyModelExists() => + Directory.Exists(ModelsDir) && Directory.GetFiles(ModelsDir, "*.pt").Length > 0; public MappingViewModel(BotOrchestrator bot) { _bot = bot; _selectedMapType = bot.Config.MapType; - _hasModel = File.Exists(ModelPath); + _isKulemak = _selectedMapType == MapType.Kulemak; + _hasModel = AnyModelExists(); + + // Load Kulemak settings + _kulemakEnabled = bot.Config.Kulemak.Enabled; + _invitationTabPath = bot.Config.Kulemak.InvitationTabPath; + _lootTabPath = bot.Config.Kulemak.LootTabPath; + _invitationCount = bot.Config.Kulemak.InvitationCount; + LoadStashTabPaths(); _bot.EnemyDetector.DetectionUpdated += OnDetectionUpdated; @@ -40,6 +58,47 @@ public partial class MappingViewModel : ObservableObject, IDisposable partial void OnSelectedMapTypeChanged(MapType value) { _bot.Store.UpdateSettings(s => s.MapType = value); + IsKulemak = value == MapType.Kulemak; + } + + partial void OnKulemakEnabledChanged(bool value) + { + _bot.Store.UpdateSettings(s => s.Kulemak.Enabled = value); + } + + partial void OnInvitationTabPathChanged(string value) + { + _bot.Store.UpdateSettings(s => s.Kulemak.InvitationTabPath = value); + } + + partial void OnLootTabPathChanged(string value) + { + _bot.Store.UpdateSettings(s => s.Kulemak.LootTabPath = value); + } + + partial void OnInvitationCountChanged(decimal? value) + { + _bot.Store.UpdateSettings(s => s.Kulemak.InvitationCount = (int)(value ?? 15)); + } + + private void LoadStashTabPaths() + { + StashTabPaths.Clear(); + StashTabPaths.Add(""); // empty = not configured + var s = _bot.Store.Settings; + if (s.StashCalibration == null) return; + foreach (var tab in s.StashCalibration.Tabs) + { + if (tab.IsFolder) + { + foreach (var sub in tab.SubTabs) + StashTabPaths.Add($"{tab.Name}/{sub.Name}"); + } + else + { + StashTabPaths.Add(tab.Name); + } + } } partial void OnIsFrameSaverEnabledChanged(bool value) @@ -50,6 +109,7 @@ public partial class MappingViewModel : ObservableObject, IDisposable partial void OnIsDetectionEnabledChanged(bool value) { _bot.EnemyDetector.Enabled = value; + _bot.BossDetector.Enabled = value; } private void OnDetectionUpdated(DetectionSnapshot snapshot) @@ -64,7 +124,7 @@ public partial class MappingViewModel : ObservableObject, IDisposable private void RefreshStats() { FramesSaved = _bot.FrameSaver.SavedCount; - HasModel = File.Exists(ModelPath); + HasModel = AnyModelExists(); } public void Dispose() diff --git a/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs b/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs index e0d5f37..2aa3f55 100644 --- a/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs @@ -19,6 +19,7 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private decimal? _waitForMoreItemsMs = 20000; [ObservableProperty] private decimal? _betweenTradesDelayMs = 5000; [ObservableProperty] private bool _headless = true; + [ObservableProperty] private bool _showHudDebug; [ObservableProperty] private bool _isSaved; [ObservableProperty] private string _calibrationStatus = ""; [ObservableProperty] private string _stashCalibratedAt = ""; @@ -44,6 +45,7 @@ public partial class SettingsViewModel : ObservableObject WaitForMoreItemsMs = s.WaitForMoreItemsMs; BetweenTradesDelayMs = s.BetweenTradesDelayMs; Headless = s.Headless; + ShowHudDebug = s.ShowHudDebug; } private void LoadTabs() @@ -94,6 +96,7 @@ public partial class SettingsViewModel : ObservableObject s.WaitForMoreItemsMs = (int)(WaitForMoreItemsMs ?? 20000); s.BetweenTradesDelayMs = (int)(BetweenTradesDelayMs ?? 5000); s.Headless = Headless; + s.ShowHudDebug = ShowHudDebug; }); IsSaved = true; @@ -206,4 +209,5 @@ public partial class SettingsViewModel : ObservableObject partial void OnWaitForMoreItemsMsChanged(decimal? value) => IsSaved = false; partial void OnBetweenTradesDelayMsChanged(decimal? value) => IsSaved = false; partial void OnHeadlessChanged(bool value) => IsSaved = false; + partial void OnShowHudDebugChanged(bool value) => IsSaved = false; } diff --git a/src/Poe2Trade.Ui/Views/MainWindow.axaml b/src/Poe2Trade.Ui/Views/MainWindow.axaml index 4aca908..3ea8a1e 100644 --- a/src/Poe2Trade.Ui/Views/MainWindow.axaml +++ b/src/Poe2Trade.Ui/Views/MainWindow.axaml @@ -233,6 +233,36 @@ + + + + + + + + + + + + + + + + + + + + @@ -298,6 +328,10 @@