diff --git a/debug_loot_capture.png b/debug_loot_capture.png
index 1fb1a3e..abea196 100644
Binary files a/debug_loot_capture.png and b/debug_loot_capture.png differ
diff --git a/debug_loot_detected.png b/debug_loot_detected.png
index 3199c9e..68e75b2 100644
Binary files a/debug_loot_detected.png and b/debug_loot_detected.png differ
diff --git a/debug_loot_edges.png b/debug_loot_edges.png
index cc4da60..07b4b1f 100644
Binary files a/debug_loot_edges.png and b/debug_loot_edges.png differ
diff --git a/src/Poe2Trade.Bot/BossRunExecutor.cs b/src/Poe2Trade.Bot/BossRunExecutor.cs
index c78b804..c706344 100644
--- a/src/Poe2Trade.Bot/BossRunExecutor.cs
+++ b/src/Poe2Trade.Bot/BossRunExecutor.cs
@@ -481,22 +481,70 @@ public class BossRunExecutor : GameExecutor
///
/// Wait for the boss healthbar to appear (boss spawns/becomes active).
///
+ // -- Async combat: combat runs in background, checks run on main thread --
+
+ private volatile int _combatTargetX = 1280;
+ private volatile int _combatTargetY = 720;
+ private volatile int _combatJitter = 30;
+
+ ///
+ /// Background combat loop — calls Tick continuously until cancelled.
+ /// Target position is updated via volatile fields from the main thread.
+ ///
+ private async Task RunCombatLoop(CancellationToken ct)
+ {
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ await _combat.Tick(_combatTargetX, _combatTargetY, _combatJitter);
+ }
+ catch (OperationCanceledException) { }
+ }
+
+ ///
+ /// Start background combat loop, returning the task and CTS.
+ /// Caller must cancel + await + ReleaseAll when done.
+ ///
+ private (Task combatTask, CancellationTokenSource cts) StartCombatLoop(int x = 1280, int y = 720, int jitter = 30)
+ {
+ _combatTargetX = x;
+ _combatTargetY = y;
+ _combatJitter = jitter;
+ _combat.Reset().GetAwaiter().GetResult();
+ var cts = new CancellationTokenSource();
+ var task = Task.Run(() => RunCombatLoop(cts.Token));
+ return (task, cts);
+ }
+
+ private async Task StopCombatLoop(Task combatTask, CancellationTokenSource cts)
+ {
+ cts.Cancel();
+ try { await combatTask; } catch { /* expected */ }
+ await _combat.ReleaseAll();
+ cts.Dispose();
+ }
+
private async Task WaitForBossSpawn(int timeoutMs = 30_000)
{
Log.Information("Waiting for boss healthbar to appear...");
var sw = Stopwatch.StartNew();
- var atkX = 1280;
- var atkY = 720;
- var lastCheckMs = 0L;
- while (sw.ElapsedMilliseconds < timeoutMs)
+ var (combatTask, cts) = StartCombatLoop();
+ try
{
- if (_stopped) return false;
-
- // Only check healthbar + death every ~500ms to avoid blocking combat
- if (sw.ElapsedMilliseconds - lastCheckMs > 500)
+ while (sw.ElapsedMilliseconds < timeoutMs)
{
- lastCheckMs = sw.ElapsedMilliseconds;
+ if (_stopped) return false;
+
+ // Update target from YOLO (fast, no capture)
+ var snapshot = _bossDetector.Latest;
+ if (snapshot.Bosses.Count > 0)
+ {
+ _combatTargetX = snapshot.Bosses[0].Cx;
+ _combatTargetY = snapshot.Bosses[0].Cy;
+ }
+
+ // Check death + healthbar (blocks main thread, combat continues in background)
if (await CheckDeath()) continue;
if (await IsBossAlive())
@@ -506,18 +554,13 @@ public class BossRunExecutor : GameExecutor
}
}
- // Attack toward YOLO-detected boss if available, otherwise last known position
- var snapshot = _bossDetector.Latest;
- if (snapshot.Bosses.Count > 0)
- {
- atkX = snapshot.Bosses[0].Cx;
- atkY = snapshot.Bosses[0].Cy;
- }
- await _combat.Tick(atkX, atkY);
+ Log.Warning("WaitForBossSpawn timed out after {Ms}ms", timeoutMs);
+ return false;
+ }
+ finally
+ {
+ await StopCombatLoop(combatTask, cts);
}
-
- Log.Warning("WaitForBossSpawn timed out after {Ms}ms", timeoutMs);
- return false;
}
///
@@ -532,60 +575,41 @@ public class BossRunExecutor : GameExecutor
const int screenCx = 1280;
const int screenCy = 720;
-
- // Attack target — defaults to screen center, updated by YOLO
- var atkX = screenCx;
- var atkY = screenCy;
(double X, double Y)? lastBossWorldPos = null;
- await _game.MoveMouseFast(atkX, atkY);
- await Helpers.Sleep(200);
-
- var sw = Stopwatch.StartNew();
- var lastCheckMs = 0L;
-
- await _combat.Reset();
Log.Information("Boss is alive, engaging");
+ var (combatTask, cts) = StartCombatLoop(screenCx, screenCy);
try
{
+ var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < timeoutMs)
{
if (_stopped) return lastBossWorldPos;
- // Update attack target from YOLO when available
+ // Update attack target from YOLO (fast, no capture)
var snapshot = _bossDetector.Latest;
if (snapshot.Bosses.Count > 0)
{
var boss = snapshot.Bosses[0];
- atkX = boss.Cx;
- atkY = boss.Cy;
+ _combatTargetX = boss.Cx;
+ _combatTargetY = boss.Cy;
- // Convert boss screen offset to world position
- // Scale: sqrt(72²+65²)/835 ≈ 0.116 minimap px per screen px
const double screenToWorld = 97.0 / 835.0;
var wp = _nav.WorldPosition;
- var bossWorldX = wp.X + (boss.Cx - screenCx) * screenToWorld;
- var bossWorldY = wp.Y + (boss.Cy - screenCy) * screenToWorld;
- lastBossWorldPos = (bossWorldX, bossWorldY);
+ lastBossWorldPos = (
+ wp.X + (boss.Cx - screenCx) * screenToWorld,
+ wp.Y + (boss.Cy - screenCy) * screenToWorld);
}
- // Re-check healthbar every ~0.5s, first miss = phase over
- if (sw.ElapsedMilliseconds - lastCheckMs > 500)
+ // Check death + healthbar (combat keeps running in background)
+ if (await CheckDeath()) continue;
+
+ if (!await IsBossAlive())
{
- if (await CheckDeath()) continue;
-
- var bossAlive = await IsBossAlive();
- lastCheckMs = sw.ElapsedMilliseconds;
-
- if (!bossAlive)
- {
- Log.Information("Healthbar not found, boss phase over after {Ms}ms", sw.ElapsedMilliseconds);
- return lastBossWorldPos;
- }
+ Log.Information("Healthbar not found, boss phase over after {Ms}ms", sw.ElapsedMilliseconds);
+ return lastBossWorldPos;
}
-
- await _combat.Tick(atkX, atkY);
}
Log.Warning("AttackBossUntilGone timed out after {Ms}ms", timeoutMs);
@@ -593,26 +617,26 @@ public class BossRunExecutor : GameExecutor
}
finally
{
- await _combat.ReleaseAll();
+ await StopCombatLoop(combatTask, cts);
}
}
private async Task AttackAtPosition(int x, int y, int durationMs)
{
- await _combat.Reset();
- var sw = Stopwatch.StartNew();
+ var (combatTask, cts) = StartCombatLoop(x, y, jitter: 20);
try
{
+ var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < durationMs)
{
if (_stopped) return;
if (await CheckDeath()) continue;
- await _combat.Tick(x, y, jitter: 20);
+ await Helpers.Sleep(500);
}
}
finally
{
- await _combat.ReleaseAll();
+ await StopCombatLoop(combatTask, cts);
}
}
diff --git a/src/Poe2Trade.Screen/ScreenReader.cs b/src/Poe2Trade.Screen/ScreenReader.cs
index b4c64ef..200542c 100644
--- a/src/Poe2Trade.Screen/ScreenReader.cs
+++ b/src/Poe2Trade.Screen/ScreenReader.cs
@@ -171,18 +171,24 @@ public class ScreenReader : IScreenReader
public Task TemplateMatch(string templatePath, Region? region = null)
{
- var result = _templateMatch.Match(templatePath, region);
- if (result != null)
- Log.Information("Template match found: ({X},{Y}) confidence={Conf:F3}", result.X, result.Y, result.Confidence);
- return Task.FromResult(result);
+ return Task.Run(() =>
+ {
+ var result = _templateMatch.Match(templatePath, region);
+ if (result != null)
+ Log.Information("Template match found: ({X},{Y}) confidence={Conf:F3}", result.X, result.Y, result.Confidence);
+ return result;
+ });
}
public Task> TemplateMatchAll(string templatePath, Region? region = null, double threshold = 0.7, bool silent = false)
{
- var results = _templateMatch.MatchAll(templatePath, region, threshold);
- if (!silent)
- Log.Information("TemplateMatchAll: {Count} matches for {Template}", results.Count, Path.GetFileName(templatePath));
- return Task.FromResult(results);
+ return Task.Run(() =>
+ {
+ var results = _templateMatch.MatchAll(templatePath, region, threshold);
+ if (!silent)
+ Log.Information("TemplateMatchAll: {Count} matches for {Template}", results.Count, Path.GetFileName(templatePath));
+ return results;
+ });
}
// -- Save --