diff --git a/assets/merchant.png b/assets/merchant.png
new file mode 100644
index 0000000..db0d74c
Binary files /dev/null and b/assets/merchant.png differ
diff --git a/atlas/atlas-20260224-135428.png b/atlas/atlas-20260224-135428.png
new file mode 100644
index 0000000..7daa245
Binary files /dev/null and b/atlas/atlas-20260224-135428.png differ
diff --git a/atlas/atlas-20260224-142451.png b/atlas/atlas-20260224-142451.png
new file mode 100644
index 0000000..d62d65f
Binary files /dev/null and b/atlas/atlas-20260224-142451.png differ
diff --git a/debug_loot_capture.png b/debug_loot_capture.png
index caf2708..ce062e7 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 d4a2737..e96616c 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 68e41d6..25d92de 100644
Binary files a/debug_loot_edges.png and b/debug_loot_edges.png differ
diff --git a/src/Poe2Trade.Bot/AtlasExecutor.cs b/src/Poe2Trade.Bot/AtlasExecutor.cs
new file mode 100644
index 0000000..b31aece
--- /dev/null
+++ b/src/Poe2Trade.Bot/AtlasExecutor.cs
@@ -0,0 +1,225 @@
+using Poe2Trade.Core;
+using Poe2Trade.Game;
+using Poe2Trade.Inventory;
+using Poe2Trade.Navigation;
+using Poe2Trade.Screen;
+using Serilog;
+
+namespace Poe2Trade.Bot;
+
+///
+/// Captures the full endgame atlas as a panorama image.
+/// Registers AtlasPanorama as a pipeline consumer so it shares the single DXGI capture.
+///
+public class AtlasExecutor : GameExecutor
+{
+ private readonly FramePipeline _pipeline;
+
+ public event Action? ProgressUpdated;
+ public event Action? StateChanged;
+
+ public AtlasPanorama? ActivePanorama { get; private set; }
+
+ ///
+ /// Perspective correction factor. Updated by CalibratePerspective(), used by CaptureAtlasPanorama().
+ ///
+ public float PerspectiveFactor { get; set; } = 0.115f;
+
+ private string _state = "Idle";
+ public string State => _state;
+
+ public AtlasExecutor(IGameController game, IScreenReader screen,
+ IInventoryManager inventory, SavedSettings config, FramePipeline pipeline)
+ : base(game, screen, inventory, config)
+ {
+ _pipeline = pipeline;
+ }
+
+ private void SetState(string s)
+ {
+ _state = s;
+ StateChanged?.Invoke(s);
+ }
+
+ ///
+ /// Capture the full endgame atlas panorama.
+ /// The atlas map must already be open — just focuses the game and starts capturing.
+ /// Returns the path to the saved PNG, or null on failure.
+ ///
+ public async Task CaptureAtlasPanorama(string outputDir = "atlas")
+ {
+ ResetStop();
+
+ var panorama = new AtlasPanorama(PerspectiveFactor);
+ panorama.ProgressUpdated += p => ProgressUpdated?.Invoke(p);
+ ActivePanorama = panorama;
+ _pipeline.AddConsumer(panorama);
+
+ try
+ {
+ await _game.FocusGame();
+ await Sleep(300);
+
+ SetState("Capturing");
+ Log.Information("AtlasExecutor: starting panorama capture (factor={F:F3})", PerspectiveFactor);
+
+ await panorama.Run(StopToken);
+
+ // Save result
+ if (!Directory.Exists(outputDir))
+ Directory.CreateDirectory(outputDir);
+
+ var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
+ var path = Path.Combine(outputDir, $"atlas-{timestamp}.png");
+ panorama.SaveResult(path);
+
+ SetState("Idle");
+ Log.Information("AtlasExecutor: panorama saved to {Path}", path);
+ return path;
+ }
+ catch (OperationCanceledException)
+ {
+ Log.Information("AtlasExecutor: capture cancelled");
+ SetState("Idle");
+ return null;
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "AtlasExecutor: capture failed");
+ SetState("Failed");
+ return null;
+ }
+ finally
+ {
+ _pipeline.RemoveConsumer(panorama);
+ ActivePanorama = null;
+ panorama.Dispose();
+ }
+ }
+
+ ///
+ /// Auto-scroll the atlas in 4 directions, collect frames, and test different
+ /// perspective factors to find the optimal one. Updates PerspectiveFactor on success.
+ ///
+ public async Task CalibratePerspective()
+ {
+ ResetStop();
+
+ var calibrator = new PerspectiveCalibrator();
+ calibrator.FrameCollected += n =>
+ ProgressUpdated?.Invoke(new AtlasProgress(n, 0, "Collecting"));
+ _pipeline.AddConsumer(calibrator);
+
+ try
+ {
+ await _game.FocusGame();
+ await Sleep(300);
+
+ SetState("Calibrating");
+ Log.Information("AtlasExecutor: calibration started — auto-scrolling atlas");
+
+ // Auto-scroll in 4 directions via click-drag
+ await AutoScrollCircle(calibrator);
+
+ _pipeline.RemoveConsumer(calibrator);
+
+ if (calibrator.FramesCollected < 3)
+ {
+ Log.Warning("AtlasExecutor: not enough frames for calibration ({N})", calibrator.FramesCollected);
+ SetState("Idle");
+ return null;
+ }
+
+ SetState("Analyzing");
+ ProgressUpdated?.Invoke(new AtlasProgress(calibrator.FramesCollected, 0, "Analyzing"));
+
+ var result = calibrator.Calibrate();
+ PerspectiveFactor = result.BestFactor;
+
+ SetState("Idle");
+ Log.Information("AtlasExecutor: calibration complete — factor={F:F3} conf={C:F4}",
+ result.BestFactor, result.BestConfidence);
+ return result;
+ }
+ catch (OperationCanceledException)
+ {
+ _pipeline.RemoveConsumer(calibrator);
+
+ // Still try to calibrate with whatever frames we got
+ if (calibrator.FramesCollected >= 3)
+ {
+ SetState("Analyzing");
+ var result = calibrator.Calibrate();
+ PerspectiveFactor = result.BestFactor;
+ SetState("Idle");
+ return result;
+ }
+
+ SetState("Idle");
+ return null;
+ }
+ catch (Exception ex)
+ {
+ _pipeline.RemoveConsumer(calibrator);
+ Log.Error(ex, "AtlasExecutor: calibration failed");
+ SetState("Failed");
+ return null;
+ }
+ finally
+ {
+ calibrator.Dispose();
+ }
+ }
+
+ ///
+ /// Click-drag the atlas in 4 directions (right, down, left, up) to collect
+ /// frames with movement in all directions for calibration.
+ ///
+ private async Task AutoScrollCircle(PerspectiveCalibrator calibrator)
+ {
+ const int cx = 1280; // screen center at 2560x1440
+ const int cy = 720;
+ const int dragDist = 500;
+ const int dragMs = 3000;
+ const int stepDelayMs = 30;
+
+ // 8 legs: R, D, L, U, then diagonals for more direction variety
+ (int dx, int dy)[] dirs =
+ [
+ (dragDist, 0), (0, dragDist), (-dragDist, 0), (0, -dragDist),
+ (dragDist, dragDist / 2), (-dragDist, -dragDist / 2),
+ (-dragDist / 2, dragDist), (dragDist / 2, -dragDist),
+ ];
+
+ foreach (var (dx, dy) in dirs)
+ {
+ if (StopToken.IsCancellationRequested || calibrator.FramesCollected >= 100) break;
+
+ // Move to center (no drag)
+ _game.MoveMouseInstant(cx, cy);
+ await Sleep(200);
+
+ // Start drag
+ _game.LeftMouseDown();
+ await Sleep(50);
+
+ // Slow drag over ~2 seconds
+ var steps = dragMs / stepDelayMs;
+ for (int i = 1; i <= steps; i++)
+ {
+ if (StopToken.IsCancellationRequested) break;
+ var t = (float)i / steps;
+ var x = (int)(cx + dx * t);
+ var y = (int)(cy + dy * t);
+ _game.MoveMouseInstant(x, y);
+ await Task.Delay(stepDelayMs, StopToken);
+ }
+
+ _game.LeftMouseUp();
+ await Sleep(200);
+ }
+
+ // Return mouse to center
+ _game.MoveMouseInstant(cx, cy);
+ }
+}
diff --git a/src/Poe2Trade.Bot/BotOrchestrator.cs b/src/Poe2Trade.Bot/BotOrchestrator.cs
index 625d1e3..605c6b4 100644
--- a/src/Poe2Trade.Bot/BotOrchestrator.cs
+++ b/src/Poe2Trade.Bot/BotOrchestrator.cs
@@ -47,9 +47,11 @@ public class BotOrchestrator : IAsyncDisposable
public FrameSaver FrameSaver { get; }
public LootDebugDetector LootDebugDetector { get; }
public KulemakExecutor KulemakExecutor { get; }
+ public AtlasExecutor AtlasExecutor { get; }
public volatile bool ShowYoloOverlay = true;
public volatile bool ShowFightPositionOverlay = true;
private readonly Dictionary _scrapExecutors = new();
+ private readonly Dictionary _diamondExecutors = new();
// Events
public event Action? StatusUpdated;
@@ -94,6 +96,7 @@ public class BotOrchestrator : IAsyncDisposable
enemyDetector: EnemyDetector);
KulemakExecutor = new KulemakExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector, HudReader, Navigation);
+ AtlasExecutor = new AtlasExecutor(game, screen, inventory, store.Settings, pipelineService.Pipeline);
logWatcher.AreaEntered += area =>
{
@@ -144,6 +147,31 @@ public class BotOrchestrator : IAsyncDisposable
}
}
+ public async Task EmergencyStop()
+ {
+ Log.Warning("EMERGENCY STOP triggered");
+ _paused = true;
+ Store.SetPaused(true);
+
+ // Stop all trade executors
+ foreach (var exec in _scrapExecutors.Values)
+ await exec.Stop();
+ _scrapExecutors.Clear();
+
+ foreach (var exec in _diamondExecutors.Values)
+ await exec.Stop();
+ _diamondExecutors.Clear();
+
+ TradeQueue.Clear();
+
+ // Stop navigation and mapping
+ await Navigation.Stop();
+ KulemakExecutor.Stop();
+
+ State = "Stopped (END)";
+ StatusUpdated?.Invoke();
+ }
+
public void Pause()
{
_paused = true;
@@ -185,6 +213,29 @@ public class BotOrchestrator : IAsyncDisposable
_ = DeactivateLink(id);
}
+ public void ChangeLinkMode(string id, LinkMode newMode)
+ {
+ var link = Links.UpdateMode(id, newMode);
+ if (link == null) return;
+ StatusUpdated?.Invoke();
+
+ if (!_started || !link.Active) return;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await DeactivateLink(id);
+ await ActivateLink(link);
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failed to change link mode: {Id} → {Mode}", id, newMode);
+ Emit("error", $"Failed to switch mode: {link.Name}");
+ }
+ });
+ }
+
public BotStatus GetStatus() => new()
{
Paused = _paused,
@@ -211,6 +262,14 @@ public class BotOrchestrator : IAsyncDisposable
return;
}
}
+ foreach (var diamondExec in _diamondExecutors.Values)
+ {
+ if (diamondExec.State != DiamondState.Idle && diamondExec.State != DiamondState.WaitingForListings)
+ {
+ State = diamondExec.State.ToString();
+ return;
+ }
+ }
if (KulemakExecutor.State != MappingState.Idle)
{
State = KulemakExecutor.State.ToString();
@@ -273,6 +332,10 @@ public class BotOrchestrator : IAsyncDisposable
await Inventory.ClearToStash();
Emit("info", "Inventory cleared");
+ // Wire trade monitor events before activating links to avoid race
+ TradeMonitor.NewListings += OnNewListings;
+ TradeMonitor.DiamondListings += OnDiamondListings;
+
// Load links
var allUrls = new HashSet(cliUrls);
foreach (var l in Store.Settings.Links)
@@ -287,9 +350,6 @@ public class BotOrchestrator : IAsyncDisposable
Emit("info", $"Loaded (inactive): {link.Name}");
}
- // Wire trade monitor events
- TradeMonitor.NewListings += OnNewListings;
-
Emit("info", $"Loaded {allUrls.Count} trade link(s)");
Log.Information("Bot started");
}
@@ -360,6 +420,8 @@ public class BotOrchestrator : IAsyncDisposable
Log.Information("Shutting down bot...");
foreach (var exec in _scrapExecutors.Values)
await exec.Stop();
+ foreach (var exec in _diamondExecutors.Values)
+ await exec.Stop();
EnemyDetector.Dispose();
Screen.Dispose();
await TradeMonitor.DisposeAsync();
@@ -367,25 +429,46 @@ public class BotOrchestrator : IAsyncDisposable
PipelineService.Dispose();
}
- private void OnNewListings(string searchId, List itemIds)
+ private void OnNewListings(string searchId, List items)
{
if (_paused)
{
- Emit("warn", $"New listings ({itemIds.Count}) skipped - bot paused");
+ Emit("warn", $"New listings ({items.Count}) skipped - bot paused");
+ return;
+ }
+ if (!Links.IsActive(searchId))
+ {
+ Emit("warn", $"New listings ({items.Count}) skipped - link {searchId} inactive");
+ return;
+ }
+
+ Log.Information("New listings: {SearchId} ({Count} items)", searchId, items.Count);
+ Emit("info", $"New listings: {items.Count} items from {searchId}");
+
+ TradeQueue.Enqueue(new TradeInfo(
+ SearchId: searchId,
+ Items: items,
+ Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
+ ));
+ }
+
+ private void OnDiamondListings(string searchId, List items)
+ {
+ if (_paused)
+ {
+ Emit("warn", $"Diamond listings ({items.Count}) skipped - bot paused");
return;
}
if (!Links.IsActive(searchId)) return;
- Log.Information("New listings: {SearchId} ({Count} items)", searchId, itemIds.Count);
- Emit("info", $"New listings: {itemIds.Count} items from {searchId}");
+ foreach (var item in items)
+ {
+ var display = DiamondSettings.KnownDiamonds.GetValueOrDefault(item.Name, item.Name);
+ Emit("info", $"Diamond: {display} @ {item.PriceAmount} {item.PriceCurrency}");
+ }
- TradeQueue.Enqueue(new TradeInfo(
- SearchId: searchId,
- ItemIds: itemIds,
- WhisperText: "",
- Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- TradeUrl: ""
- ));
+ if (_diamondExecutors.TryGetValue(searchId, out var exec))
+ exec.EnqueueItems(items);
}
private async Task ActivateLink(TradeLink link)
@@ -412,6 +495,29 @@ public class BotOrchestrator : IAsyncDisposable
}
});
}
+ else if (link.Mode == LinkMode.Diamond)
+ {
+ var searchId = TradeMonitor.ExtractSearchId(link.Url);
+ var diamondExec = new DiamondExecutor(searchId, Game, Screen, TradeMonitor, Inventory, Config);
+ diamondExec.StateChanged += _ => UpdateExecutorState();
+ diamondExec.ItemBought += () => { Interlocked.Increment(ref _tradesCompleted); StatusUpdated?.Invoke(); };
+ diamondExec.ItemFailed += () => { Interlocked.Increment(ref _tradesFailed); StatusUpdated?.Invoke(); };
+ _diamondExecutors[searchId] = diamondExec;
+
+ await TradeMonitor.AddDiamondSearch(link.Url);
+ Emit("info", $"Diamond search started: {link.Name}");
+ StatusUpdated?.Invoke();
+
+ _ = diamondExec.RunLoop().ContinueWith(t =>
+ {
+ if (t.IsFaulted)
+ {
+ Log.Error(t.Exception!, "Diamond loop error: {LinkId}", link.Id);
+ Emit("error", $"Diamond loop failed: {link.Name}");
+ _diamondExecutors.Remove(searchId);
+ }
+ });
+ }
else
{
await TradeMonitor.AddSearch(link.Url);
@@ -433,6 +539,14 @@ public class BotOrchestrator : IAsyncDisposable
await scrapExec.Stop();
_scrapExecutors.Remove(id);
}
+
+ // Diamond executors are keyed by searchId, not link id — but they're the same
+ if (_diamondExecutors.TryGetValue(id, out var diamondExec))
+ {
+ await diamondExec.Stop();
+ _diamondExecutors.Remove(id);
+ }
+
await TradeMonitor.PauseSearch(id);
}
diff --git a/src/Poe2Trade.Bot/DiamondExecutor.cs b/src/Poe2Trade.Bot/DiamondExecutor.cs
new file mode 100644
index 0000000..f3e9833
--- /dev/null
+++ b/src/Poe2Trade.Bot/DiamondExecutor.cs
@@ -0,0 +1,264 @@
+using System.Collections.Concurrent;
+using Poe2Trade.Core;
+using Poe2Trade.Game;
+using Poe2Trade.Inventory;
+using Poe2Trade.Screen;
+using Poe2Trade.Trade;
+using Serilog;
+
+namespace Poe2Trade.Bot;
+
+public class DiamondExecutor
+{
+ private DiamondState _state = DiamondState.Idle;
+ private bool _stopped;
+ private readonly string _searchId;
+ private readonly ConcurrentQueue _queue = new();
+ private readonly SemaphoreSlim _signal = new(0);
+ private readonly IGameController _game;
+ private readonly IScreenReader _screen;
+ private readonly ITradeMonitor _tradeMonitor;
+ private readonly IInventoryManager _inventory;
+ private readonly SavedSettings _config;
+
+ public event Action? StateChanged;
+ public event Action? ItemBought;
+ public event Action? ItemFailed;
+
+ public DiamondExecutor(string searchId, IGameController game, IScreenReader screen,
+ ITradeMonitor tradeMonitor, IInventoryManager inventory, SavedSettings config)
+ {
+ _searchId = searchId;
+ _game = game;
+ _screen = screen;
+ _tradeMonitor = tradeMonitor;
+ _inventory = inventory;
+ _config = config;
+ }
+
+ public DiamondState State => _state;
+
+ private void SetState(DiamondState s)
+ {
+ _state = s;
+ StateChanged?.Invoke(s);
+ }
+
+ public void EnqueueItems(List items)
+ {
+ foreach (var item in items)
+ _queue.Enqueue(item);
+ _signal.Release(items.Count);
+ }
+
+ public Task Stop()
+ {
+ _stopped = true;
+ _signal.Release(); // unblock wait
+ SetState(DiamondState.Idle);
+ Log.Information("Diamond executor stopped: {SearchId}", _searchId);
+ return Task.CompletedTask;
+ }
+
+ public async Task RunLoop()
+ {
+ _stopped = false;
+ Log.Information("Diamond executor started: {SearchId}", _searchId);
+
+ await _inventory.ScanInventory(PostAction.Stash);
+ SetState(DiamondState.WaitingForListings);
+
+ while (!_stopped)
+ {
+ await _signal.WaitAsync();
+ if (_stopped) break;
+
+ while (_queue.TryDequeue(out var item))
+ {
+ if (_stopped) break;
+ await ProcessItem(item);
+ }
+
+ if (!_stopped)
+ SetState(DiamondState.WaitingForListings);
+ }
+
+ SetState(DiamondState.Idle);
+ Log.Information("Diamond executor loop ended: {SearchId}", _searchId);
+ }
+
+ private async Task ProcessItem(PricedTradeItem item)
+ {
+ SetState(DiamondState.Filtering);
+
+ if (!ShouldBuy(item))
+ return;
+
+ // Check inventory space
+ if (!_inventory.Tracker.CanFit(item.W, item.H))
+ {
+ Log.Information("No room for {W}x{H}, going home to stash", item.W, item.H);
+ await GoHomeAndStash();
+ await _inventory.ScanInventory(PostAction.Stash);
+
+ if (!_inventory.Tracker.CanFit(item.W, item.H))
+ {
+ Log.Warning("Still no room for {W}x{H} after stash, skipping {Name}", item.W, item.H, DisplayName(item));
+ return;
+ }
+ }
+
+ if (!await TravelToSeller(item))
+ return;
+
+ await BuyItem(item);
+ }
+
+ private static string DisplayName(PricedTradeItem item) =>
+ DiamondSettings.KnownDiamonds.GetValueOrDefault(item.Name, item.Name);
+
+ // Units per 1 divine
+ private static readonly Dictionary CurrencyPerDivine = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["divine"] = 1,
+ ["annul"] = 7,
+ ["exalted"] = 275,
+ ["chaos"] = 269,
+ ["vaal"] = 65,
+ };
+
+ private static double ToDivineEquivalent(double amount, string currency)
+ {
+ foreach (var (key, rate) in CurrencyPerDivine)
+ {
+ if (currency.Contains(key))
+ return amount / rate;
+ }
+ return -1;
+ }
+
+ private bool ShouldBuy(PricedTradeItem item)
+ {
+ var settings = _config.Diamond;
+ var currency = item.PriceCurrency.ToLowerInvariant();
+ var name = DisplayName(item);
+
+ // Look up per-item config
+ var priceConfig = settings.Prices.FirstOrDefault(p =>
+ p.ItemName.Equals(item.Name, StringComparison.OrdinalIgnoreCase));
+
+ if (priceConfig == null)
+ {
+ Log.Debug("Diamond skip (not configured): {Name} ({Icon}) @ {Amount} {Currency}", name, item.Name, item.PriceAmount, item.PriceCurrency);
+ return false;
+ }
+
+ if (!priceConfig.Enabled)
+ {
+ Log.Debug("Diamond skip (disabled): {Name}", name);
+ return false;
+ }
+
+ // Convert any currency to divine equivalent
+ var divinePrice = ToDivineEquivalent(item.PriceAmount, currency);
+ if (divinePrice < 0)
+ {
+ Log.Information("Diamond skip (unknown currency): {Name} @ {Amount} {Currency}", name, item.PriceAmount, item.PriceCurrency);
+ return false;
+ }
+
+ if (divinePrice <= priceConfig.MaxDivinePrice)
+ {
+ Log.Information("Diamond buy: {Name} @ {Amount} {Currency} (={DivPrice:F2} div, max={Max})",
+ name, item.PriceAmount, item.PriceCurrency, divinePrice, priceConfig.MaxDivinePrice);
+ return true;
+ }
+
+ Log.Information("Diamond skip (price): {Name} @ {Amount} {Currency} (={DivPrice:F2} div, max={Max})",
+ name, item.PriceAmount, item.PriceCurrency, divinePrice, priceConfig.MaxDivinePrice);
+ return false;
+ }
+
+ private async Task TravelToSeller(PricedTradeItem item)
+ {
+ var alreadyAtSeller = !_inventory.IsAtOwnHideout
+ && !string.IsNullOrEmpty(item.Account)
+ && item.Account == _inventory.SellerAccount;
+
+ if (alreadyAtSeller)
+ {
+ Log.Information("Already at seller hideout, skipping travel");
+ return true;
+ }
+
+ SetState(DiamondState.Traveling);
+ var arrived = await _inventory.WaitForAreaTransition(
+ _config.TravelTimeoutMs,
+ async () =>
+ {
+ if (!await _tradeMonitor.ClickTravelToHideout(_searchId, item.Id))
+ throw new Exception("Failed to click Travel to Hideout");
+ });
+
+ if (!arrived)
+ {
+ Log.Error("Timed out waiting for hideout arrival: {ItemId}", item.Id);
+ SetState(DiamondState.Failed);
+ ItemFailed?.Invoke();
+ return false;
+ }
+
+ _inventory.SetLocation(false, item.Account);
+ await _game.FocusGame();
+ await Helpers.Sleep(Delays.PostTravel);
+ return true;
+ }
+
+ private async Task BuyItem(PricedTradeItem item)
+ {
+ try
+ {
+ SetState(DiamondState.Buying);
+ var sellerLayout = GridLayouts.Seller;
+ var cellCenter = _screen.Grid.GetCellCenter(sellerLayout, item.StashY, item.StashX);
+ Log.Information("CTRL+clicking seller stash at ({X},{Y}) for {Name}", cellCenter.X, cellCenter.Y, DisplayName(item));
+
+ await _game.CtrlLeftClickAt(cellCenter.X, cellCenter.Y);
+ await Helpers.RandomDelay(200, 400);
+
+ _inventory.Tracker.TryPlace(item.W, item.H, PostAction.Stash);
+ Log.Information("Diamond bought: {Name} @ {Amount} {Currency} (free={Free})",
+ DisplayName(item), item.PriceAmount, item.PriceCurrency, _inventory.Tracker.FreeCells);
+ ItemBought?.Invoke();
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Error buying diamond item {Name}", DisplayName(item));
+ SetState(DiamondState.Failed);
+ ItemFailed?.Invoke();
+ }
+ }
+
+ private async Task GoHomeAndStash()
+ {
+ try
+ {
+ SetState(DiamondState.GoingHome);
+ var home = await _inventory.EnsureAtOwnHideout();
+ if (!home)
+ {
+ Log.Error("Failed to reach own hideout for stashing");
+ SetState(DiamondState.Failed);
+ return;
+ }
+
+ SetState(DiamondState.Storing);
+ await _inventory.ProcessInventory();
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "GoHomeAndStash failed");
+ SetState(DiamondState.Failed);
+ }
+ }
+}
diff --git a/src/Poe2Trade.Bot/GameExecutor.cs b/src/Poe2Trade.Bot/GameExecutor.cs
index 44d4ee4..0d8b143 100644
--- a/src/Poe2Trade.Bot/GameExecutor.cs
+++ b/src/Poe2Trade.Bot/GameExecutor.cs
@@ -133,10 +133,10 @@ public abstract class GameExecutor
{
Log.Information("Recovering: escaping and going to hideout");
await _game.FocusGame();
- await _game.PressEscape();
- await Sleep(Delays.PostEscape);
- await _game.PressEscape();
- await Sleep(Delays.PostEscape);
+ // await _game.PressEscape();
+ // await Sleep(Delays.PostEscape);
+ // await _game.PressEscape();
+ // await Sleep(Delays.PostEscape);
var arrived = await _inventory.WaitForAreaTransition(
_config.TravelTimeoutMs, () => _game.GoToHideout());
diff --git a/src/Poe2Trade.Bot/KulemakExecutor.cs b/src/Poe2Trade.Bot/KulemakExecutor.cs
index 266903e..d80c99e 100644
--- a/src/Poe2Trade.Bot/KulemakExecutor.cs
+++ b/src/Poe2Trade.Bot/KulemakExecutor.cs
@@ -229,7 +229,7 @@ public class KulemakExecutor : MappingExecutor
SetState(MappingState.WalkingToEntrance);
Log.Information("Walking to Black Cathedral entrance (W+D)");
- return await WalkAndMatch(BlackCathedralTemplate, InputSender.VK.W, InputSender.VK.D, 15000);
+ return await WalkAndMatch(BlackCathedralTemplate, InputSender.VK.W, InputSender.VK.D, 10000);
}
private async Task UseInvitation(int x, int y)
@@ -335,21 +335,21 @@ public class KulemakExecutor : MappingExecutor
}
Log.Information("Phase {Phase} done, walking to well", phase);
- await Sleep(500);
+ await Sleep(100);
await WalkToWorldPosition(wellWorldX, wellWorldY);
- await Sleep(500);
- if (!await TryClickWell())
+ await Sleep(100);
+ for (var attempt = 0; attempt < 5; attempt++)
{
- Log.Warning("Well not found, walking A+W to get closer");
+ if (await TryClickWell()) break;
+ Log.Warning("Well not found (attempt {Attempt}), walking A+W to get closer", attempt + 1);
await _game.KeyDown(InputSender.VK.A);
- await _game.KeyDown(InputSender.VK.W);
- await Sleep(1500);
- await _game.KeyUp(InputSender.VK.W);
+ if(attempt == 0) await _game.KeyDown(InputSender.VK.W);
+ await Sleep(1000);
await _game.KeyUp(InputSender.VK.A);
- await Sleep(500);
- await TryClickWell();
+ if(attempt == 0) await _game.KeyUp(InputSender.VK.W);
+ await Sleep(100);
}
- await Sleep(200);
+ await Sleep(1500);
await WalkToWorldPosition(fightWorldX + 20, fightWorldY +20, cancelWhen: IsBossAlive);
}
@@ -378,7 +378,7 @@ public class KulemakExecutor : MappingExecutor
}
Log.Information("Ring phase: using fightArea=({FX:F0},{FY:F0})", fightWorldX, fightWorldY);
- await WalkToWorldPosition(-440, -330);
+ await WalkToWorldPosition(-450, -340);
await Sleep(1000);
if (_stopped) return;
diff --git a/src/Poe2Trade.Bot/TradeExecutor.cs b/src/Poe2Trade.Bot/TradeExecutor.cs
index d0f9183..66a7384 100644
--- a/src/Poe2Trade.Bot/TradeExecutor.cs
+++ b/src/Poe2Trade.Bot/TradeExecutor.cs
@@ -16,6 +16,13 @@ public class TradeExecutor
private readonly IInventoryManager _inventory;
private readonly SavedSettings _config;
+ // Merchant template for detecting seller stash is open
+ private static readonly string MerchantTemplatePath = Path.GetFullPath("assets/merchant.png");
+ private static readonly Region MerchantRegion = new(715, 180, 245, 50);
+ private const double MerchantThreshold = 0.7;
+ private const int MerchantTimeoutMs = 15000;
+ private const int MerchantPollMs = 100;
+
public event Action? StateChanged;
public TradeExecutor(IGameController game, IScreenReader screen, ITradeMonitor tradeMonitor,
@@ -40,19 +47,52 @@ public class TradeExecutor
{
try
{
- if (!await TravelToSeller(trade))
+ // Start travel and begin polling for merchant stash immediately
+ SetState(TradeState.Traveling);
+ var firstId = trade.Items[0].Id;
+ Log.Information("Clicking Travel to Hideout for {SearchId} item {ItemId}...", trade.SearchId, firstId);
+
+ // Click travel button in the browser
+ if (!await _tradeMonitor.ClickTravelToHideout(trade.SearchId, firstId))
+ {
+ Log.Error("Failed to click Travel to Hideout");
+ SetState(TradeState.Failed);
return false;
+ }
- if (!await FindSellerStash())
+ // Focus game and immediately start polling for merchant stash
+ await _game.FocusGame();
+
+ var merchantFound = await WaitForMerchantStash();
+ if (!merchantFound)
+ {
+ Log.Error("Timed out waiting for merchant stash to appear");
+ SetState(TradeState.Failed);
return false;
+ }
- SetState(TradeState.ScanningStash);
- await ScanAndBuyItems();
+ _inventory.SetLocation(false);
- SetState(TradeState.WaitingForMore);
- Log.Information("Waiting {Ms}ms for more items...", _config.WaitForMoreItemsMs);
- await Helpers.Sleep(_config.WaitForMoreItemsMs);
- await ScanAndBuyItems();
+ // Merchant stash is visible — buy immediately
+ SetState(TradeState.Buying);
+ foreach (var item in trade.Items)
+ {
+ if (!_inventory.Tracker.CanFit(item.W, item.H))
+ {
+ Log.Warning("No room for {W}x{H}, skipping {Id}", item.W, item.H, item.Id);
+ continue;
+ }
+
+ var sellerLayout = GridLayouts.Seller;
+ var cellCenter = _screen.Grid.GetCellCenter(sellerLayout, item.StashY, item.StashX);
+ Log.Information("CTRL+clicking seller stash at ({X},{Y}) for item {Id} ({W}x{H})",
+ cellCenter.X, cellCenter.Y, item.Id, item.W, item.H);
+
+ await _game.CtrlLeftClickAt(cellCenter.X, cellCenter.Y);
+ await Helpers.RandomDelay(200, 400);
+
+ _inventory.Tracker.TryPlace(item.W, item.H, PostAction.Stash);
+ }
await ReturnHome();
@@ -73,51 +113,35 @@ public class TradeExecutor
}
}
- private async Task TravelToSeller(TradeInfo trade)
+ ///
+ /// Polls a small screen region for the merchant template.
+ /// Returns true as soon as the template is detected.
+ ///
+ private async Task WaitForMerchantStash()
{
- SetState(TradeState.Traveling);
- Log.Information("Clicking Travel to Hideout for {SearchId}...", trade.SearchId);
+ var sw = System.Diagnostics.Stopwatch.StartNew();
- var arrived = await _inventory.WaitForAreaTransition(
- _config.TravelTimeoutMs,
- async () =>
+ while (sw.ElapsedMilliseconds < MerchantTimeoutMs)
+ {
+ try
{
- if (!await _tradeMonitor.ClickTravelToHideout(trade.SearchId, trade.ItemIds[0]))
- throw new Exception("Failed to click Travel to Hideout");
- });
- if (!arrived)
- {
- Log.Error("Timed out waiting for hideout arrival");
- SetState(TradeState.Failed);
- return false;
+ var match = await _screen.TemplateMatch(MerchantTemplatePath, MerchantRegion);
+ if (match != null && match.Confidence >= MerchantThreshold)
+ {
+ Log.Information("Merchant stash detected (confidence={Conf:F3}, elapsed={Ms}ms)",
+ match.Confidence, sw.ElapsedMilliseconds);
+ return true;
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ex, "Merchant poll error");
+ }
+
+ await Task.Delay(MerchantPollMs);
}
- SetState(TradeState.InSellersHideout);
- _inventory.SetLocation(false);
- Log.Information("Arrived at seller hideout");
- return true;
- }
-
- private async Task FindSellerStash()
- {
- await _game.FocusGame();
- await Helpers.Sleep(Delays.PostTravel);
-
- var angePos = await _inventory.FindAndClickNameplate("Ange");
- if (angePos == null)
- Log.Warning("Could not find Ange nameplate, trying Stash directly");
- else
- await Helpers.Sleep(Delays.PostStashOpen);
-
- var stashPos = await _inventory.FindAndClickNameplate("Stash");
- if (stashPos == null)
- {
- Log.Error("Could not find Stash in seller hideout");
- SetState(TradeState.Failed);
- return false;
- }
- await Helpers.Sleep(Delays.PostStashOpen);
- return true;
+ return false;
}
private async Task ReturnHome()
@@ -147,11 +171,4 @@ public class TradeExecutor
Log.Debug(ex, "Recovery failed");
}
}
-
- private async Task ScanAndBuyItems()
- {
- var stashText = await _screen.ReadRegionText(GridLayouts.SellerStashOcr);
- Log.Information("Stash OCR: {Text}", stashText.Length > 200 ? stashText[..200] : stashText);
- SetState(TradeState.Buying);
- }
}
diff --git a/src/Poe2Trade.Bot/TradeQueue.cs b/src/Poe2Trade.Bot/TradeQueue.cs
index 134f3b0..b3eb16a 100644
--- a/src/Poe2Trade.Bot/TradeQueue.cs
+++ b/src/Poe2Trade.Bot/TradeQueue.cs
@@ -19,22 +19,28 @@ public class TradeQueue
public int Length => _queue.Count;
public bool IsProcessing => _processing;
+ public void Clear()
+ {
+ _queue.Clear();
+ Log.Information("Trade queue cleared");
+ }
+
public event Action? TradeCompleted;
public event Action? TradeFailed;
public void Enqueue(TradeInfo trade)
{
- var existingIds = _queue.SelectMany(t => t.ItemIds).ToHashSet();
- var newIds = trade.ItemIds.Where(id => !existingIds.Contains(id)).ToList();
- if (newIds.Count == 0)
+ var existingIds = _queue.SelectMany(t => t.Items.Select(i => i.Id)).ToHashSet();
+ var newItems = trade.Items.Where(i => !existingIds.Contains(i.Id)).ToList();
+ if (newItems.Count == 0)
{
- Log.Information("Skipping duplicate trade: {ItemIds}", string.Join(",", trade.ItemIds));
+ Log.Information("Skipping duplicate trade: {ItemIds}", string.Join(",", trade.Items.Select(i => i.Id)));
return;
}
- var deduped = trade with { ItemIds = newIds };
+ var deduped = trade with { Items = newItems };
_queue.Enqueue(deduped);
- Log.Information("Trade enqueued: {Count} items, queue={QueueLen}", newIds.Count, _queue.Count);
+ Log.Information("Trade enqueued: {Count} items, queue={QueueLen}", newItems.Count, _queue.Count);
_ = ProcessNext();
}
@@ -46,7 +52,7 @@ public class TradeQueue
var trade = _queue.Dequeue();
try
{
- Log.Information("Processing trade: {SearchId} ({Count} items)", trade.SearchId, trade.ItemIds.Count);
+ Log.Information("Processing trade: {SearchId} ({Count} items)", trade.SearchId, trade.Items.Count);
var success = await _executor.ExecuteTrade(trade);
if (success)
{
diff --git a/src/Poe2Trade.Core/ConfigStore.cs b/src/Poe2Trade.Core/ConfigStore.cs
index 933c68c..14107e4 100644
--- a/src/Poe2Trade.Core/ConfigStore.cs
+++ b/src/Poe2Trade.Core/ConfigStore.cs
@@ -37,6 +37,58 @@ public class SavedSettings
public bool ShowHudDebug { get; set; }
public string OcrEngine { get; set; } = "WinOCR";
public KulemakSettings Kulemak { get; set; } = new();
+ public DiamondSettings Diamond { get; set; } = new();
+}
+
+public class DiamondPriceConfig
+{
+ public string ItemName { get; set; } = "";
+ public string DisplayName { get; set; } = "";
+ public double MaxDivinePrice { get; set; }
+ public bool Enabled { get; set; } = true;
+}
+
+public class DiamondSettings
+{
+ public static readonly Dictionary KnownDiamonds = new()
+ {
+ ["SanctumJewel"] = "Time-Lost Diamond",
+ ["SacredFlameJewel"] = "Prism of Belief",
+ ["TrialmasterJewel"] = "The Adorned",
+ ["DeliriumJewel"] = "Megalomaniac Diamond",
+ ["ApostatesHeart"] = "Heart of the Well",
+ };
+
+ public List Prices { get; set; } = DefaultPrices();
+
+ private static List DefaultPrices() =>
+ KnownDiamonds.Select(kv => new DiamondPriceConfig
+ {
+ ItemName = kv.Key,
+ DisplayName = kv.Value,
+ MaxDivinePrice = 0,
+ Enabled = false,
+ }).ToList();
+
+ /// Ensure all known diamonds exist in the list (adds missing ones as disabled).
+ public void BackfillKnown()
+ {
+ // Remove blank entries
+ Prices.RemoveAll(p => string.IsNullOrWhiteSpace(p.ItemName));
+
+ var existing = new HashSet(Prices.Select(p => p.ItemName), StringComparer.OrdinalIgnoreCase);
+ foreach (var kv in KnownDiamonds)
+ {
+ if (existing.Contains(kv.Key)) continue;
+ Prices.Add(new DiamondPriceConfig
+ {
+ ItemName = kv.Key,
+ DisplayName = kv.Value,
+ MaxDivinePrice = 0,
+ Enabled = false,
+ });
+ }
+ }
}
public class KulemakSettings
@@ -71,7 +123,16 @@ public class ConfigStore
public void AddLink(string url, string name = "", LinkMode mode = LinkMode.Live, PostAction? postAction = null)
{
url = StripLive(url);
- if (_data.Links.Any(l => l.Url == url)) return;
+ var existing = _data.Links.FirstOrDefault(l => l.Url == url);
+ if (existing != null)
+ {
+ // Update mode/postAction/name if re-added with different settings
+ existing.Mode = mode;
+ existing.PostAction = postAction ?? (mode == LinkMode.Scrap ? PostAction.Salvage : PostAction.Stash);
+ if (!string.IsNullOrEmpty(name)) existing.Name = name;
+ Save();
+ return;
+ }
_data.Links.Add(new SavedLink
{
Url = url,
@@ -177,6 +238,9 @@ public class ConfigStore
link.Url = StripLive(link.Url);
}
+ // Backfill known diamonds
+ parsed.Diamond.BackfillKnown();
+
Log.Information("Loaded config.json from {Path} ({LinkCount} links)", _filePath, parsed.Links.Count);
return parsed;
}
diff --git a/src/Poe2Trade.Core/LinkManager.cs b/src/Poe2Trade.Core/LinkManager.cs
index 0837609..87b10cd 100644
--- a/src/Poe2Trade.Core/LinkManager.cs
+++ b/src/Poe2Trade.Core/LinkManager.cs
@@ -22,6 +22,24 @@ public class LinkManager
public LinkManager(ConfigStore store)
{
_store = store;
+
+ // Pre-populate from saved config so UI shows links before Start()
+ foreach (var saved in store.Links)
+ {
+ var url = StripLive(saved.Url);
+ var id = ExtractId(url);
+ _links[id] = new TradeLink
+ {
+ Id = id,
+ Url = url,
+ Name = saved.Name,
+ Label = ExtractLabel(url),
+ Active = saved.Active,
+ Mode = saved.Mode,
+ PostAction = saved.PostAction,
+ AddedAt = saved.AddedAt,
+ };
+ }
}
public TradeLink AddLink(string url, string name = "", LinkMode? mode = null, PostAction? postAction = null)
diff --git a/src/Poe2Trade.Core/Types.cs b/src/Poe2Trade.Core/Types.cs
index 677f08a..3287afd 100644
--- a/src/Poe2Trade.Core/Types.cs
+++ b/src/Poe2Trade.Core/Types.cs
@@ -4,10 +4,8 @@ public record Region(int X, int Y, int Width, int Height);
public record TradeInfo(
string SearchId,
- List ItemIds,
- string WhisperText,
- long Timestamp,
- string TradeUrl
+ List Items,
+ long Timestamp
);
public record TradeItem(
@@ -59,9 +57,34 @@ public enum ScrapState
public enum LinkMode
{
Live,
- Scrap
+ Scrap,
+ Diamond
}
+public enum DiamondState
+{
+ Idle,
+ WaitingForListings,
+ Filtering,
+ Traveling,
+ Buying,
+ GoingHome,
+ Storing,
+ Failed
+}
+
+public record PricedTradeItem(
+ string Id,
+ int W,
+ int H,
+ int StashX,
+ int StashY,
+ string Account,
+ string Name,
+ double PriceAmount,
+ string PriceCurrency
+) : TradeItem(Id, W, H, StashX, StashY, Account);
+
public enum PostAction
{
Stash,
diff --git a/src/Poe2Trade.Inventory/InventoryManager.cs b/src/Poe2Trade.Inventory/InventoryManager.cs
index a47582c..bb67f0a 100644
--- a/src/Poe2Trade.Inventory/InventoryManager.cs
+++ b/src/Poe2Trade.Inventory/InventoryManager.cs
@@ -133,6 +133,8 @@ public class InventoryManager : IInventoryManager
Log.Information("Depositing {Count} items to stash", items.Count);
await CtrlClickItems(items, GridLayouts.Inventory);
+ await SnapshotInventory();
+
await _game.PressEscape();
await Helpers.Sleep(Delays.PostEscape);
Log.Information("Items deposited to stash");
diff --git a/src/Poe2Trade.Navigation/AtlasPanorama.cs b/src/Poe2Trade.Navigation/AtlasPanorama.cs
new file mode 100644
index 0000000..78192e3
--- /dev/null
+++ b/src/Poe2Trade.Navigation/AtlasPanorama.cs
@@ -0,0 +1,352 @@
+using System.Diagnostics;
+using OpenCvSharp;
+using Poe2Trade.Core;
+using Poe2Trade.Screen;
+using Serilog;
+
+namespace Poe2Trade.Navigation;
+
+public record AtlasProgress(int TilesCaptured, int Row, string Phase);
+
+///
+/// Stitches atlas frames into a panorama canvas.
+/// Implements IFrameConsumer so it receives shared pipeline frames — no separate DXGI needed.
+///
+public class AtlasPanorama : IFrameConsumer, IDisposable
+{
+ // Canvas — CV_8UC3 BGR, growable
+ private Mat _canvas;
+ private int _canvasSize;
+ private int _viewX, _viewY; // current viewport position on canvas
+
+ private int _tilesCaptured;
+ private bool _firstFrame = true;
+
+ // HUD-free capture region at 2560x1440
+ // Top 150px: header + act tabs
+ // Bottom 200px: skill bar + globes
+ // Sides 300px: globe frames + padding
+ private static readonly Region CaptureRegion = new(300, 150, 1960, 1090);
+
+ // Canvas management
+ private const int InitialCanvasSize = 10000;
+ private const int GrowMargin = 500;
+ private const int GrowAmount = 4000;
+
+ // Template matching — done at reduced resolution for speed
+ private const int MatchScale = 4; // downscale factor for template matching
+ private const int TemplateSize = 200; // at full res, becomes 50px at match scale
+ private const int SearchMargin = 300; // max scroll shift between frames at ~30fps
+ private const double MatchThreshold = 0.70;
+
+ // Movement detection: minimum viewport shift (px) to count as a new tile
+ private const int MinViewportShift = 20;
+
+ // Perspective correction: the atlas camera looks from the south, so the top of the
+ // screen is further away and horizontally compressed.
+ // Measured: bottom edge ≈ 24cm, top edge ≈ 17cm on 2560x1440
+ // Default 0.15 — use CalibratePerspective to find the optimal value.
+ private readonly float _perspectiveFactor;
+
+ private Mat? _warpMatrix;
+
+ public int TilesCaptured => _tilesCaptured;
+
+ public event Action? ProgressUpdated;
+
+ private void ReportProgress(string phase)
+ {
+ ProgressUpdated?.Invoke(new AtlasProgress(_tilesCaptured, 0, phase));
+ }
+
+ public AtlasPanorama(float perspectiveFactor = 0.115f)
+ {
+ _perspectiveFactor = perspectiveFactor;
+ _canvasSize = InitialCanvasSize;
+ _canvas = new Mat(_canvasSize, _canvasSize, MatType.CV_8UC3, Scalar.Black);
+ _viewX = _canvasSize / 2;
+ _viewY = _canvasSize / 2;
+ }
+
+ ///
+ /// IFrameConsumer — called by the pipeline with each shared screen frame.
+ /// Crops the atlas region and stitches it onto the canvas.
+ ///
+ public void Process(ScreenFrame frame)
+ {
+ var sw = Stopwatch.StartNew();
+ using var bgr = frame.CropBgr(CaptureRegion);
+ using var corrected = CorrectPerspective(bgr);
+
+ if (_firstFrame)
+ {
+ PasteFrame(corrected, _viewX - corrected.Width / 2, _viewY - corrected.Height / 2);
+ _tilesCaptured++;
+ _firstFrame = false;
+ ReportProgress("Capturing");
+ Log.Debug("AtlasPanorama: first frame pasted in {Ms:F1}ms", sw.Elapsed.TotalMilliseconds);
+ return;
+ }
+
+ StitchFrame(corrected);
+ if (sw.ElapsedMilliseconds > 50)
+ Log.Warning("AtlasPanorama: Process took {Ms}ms", sw.ElapsedMilliseconds);
+ }
+
+ ///
+ /// Warp the frame from the tilted atlas camera view to a top-down projection.
+ /// The top of the screen is further from the camera and appears narrower —
+ /// we stretch it back to equal width.
+ ///
+ private Mat CorrectPerspective(Mat frame)
+ {
+ var w = frame.Width;
+ var h = frame.Height;
+ var inset = (int)(w * _perspectiveFactor);
+
+ // Compute warp matrix once (all frames are the same size)
+ if (_warpMatrix == null)
+ {
+ // Source: trapezoid as seen on screen (top edge is narrower)
+ var src = new Point2f[]
+ {
+ new(inset, 0), // top-left (shifted inward)
+ new(w - inset, 0), // top-right (shifted inward)
+ new(w, h), // bottom-right (full width, close to camera)
+ new(0, h), // bottom-left
+ };
+
+ // Destination: rectangle (top-down)
+ var dst = new Point2f[]
+ {
+ new(0, 0),
+ new(w, 0),
+ new(w, h),
+ new(0, h),
+ };
+
+ _warpMatrix = Cv2.GetPerspectiveTransform(src, dst);
+ Log.Information("AtlasPanorama: perspective matrix computed (factor={F}, inset={I}px)",
+ _perspectiveFactor, inset);
+ }
+
+ var result = new Mat();
+ Cv2.WarpPerspective(frame, result, _warpMatrix, new Size(w, h));
+ return result;
+ }
+
+ ///
+ /// Wait until cancelled. Stitching happens via Process() on the pipeline thread.
+ ///
+ public async Task Run(CancellationToken ct)
+ {
+ Log.Information("AtlasPanorama: started (pipeline consumer)");
+ _tilesCaptured = 0;
+ _firstFrame = true;
+
+ try
+ {
+ await Task.Delay(Timeout.Infinite, ct);
+ }
+ catch (OperationCanceledException) { }
+
+ Log.Information("AtlasPanorama: stopped — {Tiles} tiles", _tilesCaptured);
+ }
+
+ ///
+ /// Return a PNG of the full canvas built so far, downscaled to fit maxDim.
+ ///
+ public byte[]? GetViewportSnapshot(int maxDim = 900)
+ {
+ if (_tilesCaptured == 0) return null;
+
+ using var trimmed = TrimCanvas();
+ if (trimmed.Empty()) return null;
+
+ // Downscale to fit within maxDim, preserving aspect ratio
+ var scale = Math.Min((double)maxDim / trimmed.Width, (double)maxDim / trimmed.Height);
+ if (scale >= 1.0)
+ {
+ Cv2.ImEncode(".png", trimmed, out var buf);
+ return buf;
+ }
+
+ using var small = new Mat();
+ Cv2.Resize(trimmed, small, new Size(
+ (int)(trimmed.Width * scale),
+ (int)(trimmed.Height * scale)));
+ Cv2.ImEncode(".png", small, out var buf2);
+ return buf2;
+ }
+
+ ///
+ /// Encode the trimmed canvas as PNG bytes.
+ ///
+ public byte[] GetResultPng()
+ {
+ using var trimmed = TrimCanvas();
+ Cv2.ImEncode(".png", trimmed, out var buf);
+ return buf;
+ }
+
+ ///
+ /// Save the trimmed canvas to a file.
+ ///
+ public void SaveResult(string path)
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ using var trimmed = TrimCanvas();
+ Cv2.ImWrite(path, trimmed);
+ Log.Information("AtlasPanorama: saved to {Path} ({W}x{H})", path, trimmed.Width, trimmed.Height);
+ }
+
+ ///
+ /// Template-match at reduced resolution, then paste at full resolution.
+ ///
+ private void StitchFrame(Mat frame)
+ {
+ EnsureCapacity();
+
+ // Search region at full res
+ var halfW = frame.Width / 2 + SearchMargin;
+ var halfH = frame.Height / 2 + SearchMargin;
+ var sx0 = Math.Max(0, _viewX - halfW);
+ var sy0 = Math.Max(0, _viewY - halfH);
+ var sx1 = Math.Min(_canvasSize, _viewX + halfW);
+ var sy1 = Math.Min(_canvasSize, _viewY + halfH);
+ var sW = sx1 - sx0;
+ var sH = sy1 - sy0;
+
+ // Template: center strip at full res
+ var tW = Math.Min(TemplateSize, frame.Width);
+ var tH = Math.Min(TemplateSize, frame.Height);
+ var tX = (frame.Width - tW) / 2;
+ var tY = (frame.Height - tH) / 2;
+
+ if (sW <= tW || sH <= tH)
+ {
+ Log.Debug("AtlasPanorama: search region too small, pasting at viewport");
+ PasteFrame(frame, _viewX - frame.Width / 2, _viewY - frame.Height / 2);
+ _tilesCaptured++;
+ return;
+ }
+
+ // Downscale template + search region for fast matching
+ using var templateFull = new Mat(frame, new Rect(tX, tY, tW, tH));
+ using var templateSmall = new Mat();
+ Cv2.Resize(templateFull, templateSmall, new Size(tW / MatchScale, tH / MatchScale));
+
+ using var searchRoi = new Mat(_canvas, new Rect(sx0, sy0, sW, sH));
+ using var searchSmall = new Mat();
+ Cv2.Resize(searchRoi, searchSmall, new Size(sW / MatchScale, sH / MatchScale));
+
+ using var result = new Mat();
+ Cv2.MatchTemplate(searchSmall, templateSmall, result, TemplateMatchModes.CCoeffNormed);
+ Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var maxLoc);
+
+ if (maxVal >= MatchThreshold)
+ {
+ // Scale match position back to full res
+ var frameCanvasX = sx0 + maxLoc.X * MatchScale - tX;
+ var frameCanvasY = sy0 + maxLoc.Y * MatchScale - tY;
+
+ var newVx = frameCanvasX + frame.Width / 2;
+ var newVy = frameCanvasY + frame.Height / 2;
+ var dx = Math.Abs(newVx - _viewX);
+ var dy = Math.Abs(newVy - _viewY);
+
+ // Only paste when the viewport actually moved — prevents cumulative
+ // drift from the 4x downscale quantization on still frames
+ if (dx >= MinViewportShift || dy >= MinViewportShift)
+ {
+ PasteFrame(frame, frameCanvasX, frameCanvasY);
+ _viewX = newVx;
+ _viewY = newVy;
+ _tilesCaptured++;
+ ReportProgress("Capturing");
+ Log.Information("AtlasPanorama: tile {N} at ({X},{Y}) shift=({Dx},{Dy}) conf={Conf:F3}",
+ _tilesCaptured, frameCanvasX, frameCanvasY, dx, dy, maxVal);
+ }
+ }
+ else
+ {
+ Log.Information("AtlasPanorama: match failed (conf={Conf:F3}), skipping frame", maxVal);
+ }
+ }
+
+ private void PasteFrame(Mat frame, int canvasX, int canvasY)
+ {
+ var srcX = Math.Max(0, -canvasX);
+ var srcY = Math.Max(0, -canvasY);
+ var dstX = Math.Max(0, canvasX);
+ var dstY = Math.Max(0, canvasY);
+ var w = Math.Min(frame.Width - srcX, _canvasSize - dstX);
+ var h = Math.Min(frame.Height - srcY, _canvasSize - dstY);
+
+ if (w <= 0 || h <= 0) return;
+
+ using var srcRoi = new Mat(frame, new Rect(srcX, srcY, w, h));
+ using var dstRoi = new Mat(_canvas, new Rect(dstX, dstY, w, h));
+ srcRoi.CopyTo(dstRoi);
+ }
+
+ private void EnsureCapacity()
+ {
+ if (_viewX >= GrowMargin && _viewY >= GrowMargin &&
+ _viewX < _canvasSize - GrowMargin && _viewY < _canvasSize - GrowMargin)
+ return;
+
+ var oldSize = _canvasSize;
+ var newSize = oldSize + GrowAmount;
+ var offset = GrowAmount / 2;
+
+ var newCanvas = new Mat(newSize, newSize, MatType.CV_8UC3, Scalar.Black);
+ using (var dst = new Mat(newCanvas, new Rect(offset, offset, oldSize, oldSize)))
+ _canvas.CopyTo(dst);
+
+ _canvas.Dispose();
+ _canvas = newCanvas;
+ _canvasSize = newSize;
+ _viewX += offset;
+ _viewY += offset;
+
+ Log.Information("AtlasPanorama: canvas grown {Old}x{Old} -> {New}x{New}", oldSize, oldSize, newSize, newSize);
+ }
+
+ private Mat TrimCanvas()
+ {
+ using var gray = new Mat();
+ Cv2.CvtColor(_canvas, gray, ColorConversionCodes.BGR2GRAY);
+ using var mask = new Mat();
+ Cv2.Threshold(gray, mask, 1, 255, ThresholdTypes.Binary);
+
+ var points = new Mat();
+ Cv2.FindNonZero(mask, points);
+
+ if (points.Empty())
+ {
+ Log.Warning("AtlasPanorama: canvas is empty after trim");
+ return _canvas.Clone();
+ }
+
+ var bbox = Cv2.BoundingRect(points);
+ points.Dispose();
+
+ const int pad = 10;
+ var x = Math.Max(0, bbox.X - pad);
+ var y = Math.Max(0, bbox.Y - pad);
+ var w = Math.Min(_canvasSize - x, bbox.Width + 2 * pad);
+ var h = Math.Min(_canvasSize - y, bbox.Height + 2 * pad);
+
+ return new Mat(_canvas, new Rect(x, y, w, h)).Clone();
+ }
+
+ public void Dispose()
+ {
+ _canvas.Dispose();
+ _warpMatrix?.Dispose();
+ }
+}
diff --git a/src/Poe2Trade.Navigation/PerspectiveCalibrator.cs b/src/Poe2Trade.Navigation/PerspectiveCalibrator.cs
new file mode 100644
index 0000000..fe1e9ac
--- /dev/null
+++ b/src/Poe2Trade.Navigation/PerspectiveCalibrator.cs
@@ -0,0 +1,264 @@
+using OpenCvSharp;
+using Poe2Trade.Core;
+using Poe2Trade.Screen;
+using Serilog;
+
+namespace Poe2Trade.Navigation;
+
+public record CalibrationResult(float BestFactor, double BestConfidence, Dictionary AllResults);
+
+///
+/// Collects atlas frames and tests different perspective factors to find the optimal one.
+/// For each candidate factor, stitches all frames into a mini-canvas and measures seam
+/// quality (pixel difference in overlap regions). The correct factor minimizes seam error
+/// because frames from different scroll directions align properly.
+///
+public class PerspectiveCalibrator : IFrameConsumer, IDisposable
+{
+ private readonly List _frames = new();
+ private Mat? _lastKept;
+
+ private static readonly Region CaptureRegion = new(300, 150, 1960, 1090);
+ private const int MaxFrames = 100;
+ private const double MovementThreshold = 4.0;
+
+ public int FramesCollected => _frames.Count;
+ public event Action? FrameCollected;
+
+ public void Process(ScreenFrame frame)
+ {
+ if (_frames.Count >= MaxFrames) return;
+
+ using var bgr = frame.CropBgr(CaptureRegion);
+
+ // Movement check at 1/8 scale
+ using var small = new Mat();
+ Cv2.Resize(bgr, small, new Size(bgr.Width / 8, bgr.Height / 8));
+
+ if (_lastKept != null)
+ {
+ using var diff = new Mat();
+ Cv2.Absdiff(small, _lastKept, diff);
+ var mean = Cv2.Mean(diff);
+ var avgDiff = (mean.Val0 + mean.Val1 + mean.Val2) / 3.0;
+ if (avgDiff < MovementThreshold)
+ return;
+ }
+
+ _lastKept?.Dispose();
+ _lastKept = small.Clone();
+ _frames.Add(bgr.Clone());
+ FrameCollected?.Invoke(_frames.Count);
+ Log.Debug("PerspectiveCalibrator: kept frame {N}/{Max}", _frames.Count, MaxFrames);
+ }
+
+ public CalibrationResult Calibrate()
+ {
+ if (_frames.Count < 3)
+ throw new InvalidOperationException($"Need at least 3 frames, got {_frames.Count}");
+
+ Log.Information("PerspectiveCalibrator: analyzing {N} frames with seam-error metric", _frames.Count);
+
+ var results = new Dictionary();
+
+ // Coarse pass: 0.00 to 0.25, step 0.01
+ for (int fi = 0; fi <= 25; fi++)
+ {
+ var f = fi * 0.01f;
+ var quality = MeasureFactorQuality(f);
+ results[f] = quality;
+ Log.Information("Calibrate: factor={F:F2} seamQuality={Q:F6}", f, quality);
+ }
+
+ // Fine pass around the best coarse value
+ var coarseBest = results.MaxBy(kv => kv.Value).Key;
+ for (int fi = -9; fi <= 9; fi += 2)
+ {
+ var f = MathF.Round(coarseBest + fi * 0.001f, 3);
+ if (f < 0 || f > 0.30f || results.ContainsKey(f)) continue;
+ var quality = MeasureFactorQuality(f);
+ results[f] = quality;
+ Log.Information("Calibrate (fine): factor={F:F3} seamQuality={Q:F6}", f, quality);
+ }
+
+ var best = results.MaxBy(kv => kv.Value);
+ Log.Information("PerspectiveCalibrator: BEST factor={F:F3} seamQuality={Q:F6}", best.Key, best.Value);
+
+ return new CalibrationResult(best.Key, best.Value, results);
+ }
+
+ ///
+ /// Stitches all frames into a mini-canvas using the given perspective factor,
+ /// and measures the average seam quality (pixel alignment in overlap regions).
+ /// When the factor is correct, overlapping regions from different scroll directions
+ /// align perfectly → low pixel difference → high quality score.
+ ///
+ private double MeasureFactorQuality(float factor)
+ {
+ const int scale = 4;
+ var w = _frames[0].Width;
+ var h = _frames[0].Height;
+ var sw = w / scale;
+ var sh = h / scale;
+
+ // Compute warp matrix at reduced resolution
+ Mat? warpMatrix = null;
+ if (factor > 0.001f)
+ {
+ var inset = (int)(sw * factor);
+ var src = new Point2f[]
+ {
+ new(inset, 0), new(sw - inset, 0),
+ new(sw, sh), new(0, sh),
+ };
+ var dst = new Point2f[]
+ {
+ new(0, 0), new(sw, 0),
+ new(sw, sh), new(0, sh),
+ };
+ warpMatrix = Cv2.GetPerspectiveTransform(src, dst);
+ }
+
+ var smallFrames = new List();
+ try
+ {
+ // Downscale first, then warp
+ foreach (var frame in _frames)
+ {
+ var small = new Mat();
+ Cv2.Resize(frame, small, new Size(sw, sh));
+
+ if (warpMatrix != null)
+ {
+ var warped = new Mat();
+ Cv2.WarpPerspective(small, warped, warpMatrix, new Size(sw, sh));
+ small.Dispose();
+ smallFrames.Add(warped);
+ }
+ else
+ {
+ smallFrames.Add(small);
+ }
+ }
+
+ // Build mini-canvas and measure seam quality
+ const int canvasSize = 4000;
+ using var canvas = new Mat(canvasSize, canvasSize, MatType.CV_8UC3, Scalar.Black);
+ var vx = canvasSize / 2;
+ var vy = canvasSize / 2;
+
+ // Paste first frame
+ PasteMini(canvas, canvasSize, smallFrames[0], vx - sw / 2, vy - sh / 2);
+
+ const int templateW = 50;
+ const int templateH = 50;
+ const int searchMargin = 80; // at 1/4 scale ≈ 320px full res
+
+ var seamQualities = new List();
+
+ for (int i = 1; i < smallFrames.Count; i++)
+ {
+ var frame = smallFrames[i];
+
+ // Find position via center template match against canvas
+ var tx = (sw - templateW) / 2;
+ var ty = (sh - templateH) / 2;
+
+ var halfW = sw / 2 + searchMargin;
+ var halfH = sh / 2 + searchMargin;
+ var sx0 = Math.Max(0, vx - halfW);
+ var sy0 = Math.Max(0, vy - halfH);
+ var sx1 = Math.Min(canvasSize, vx + halfW);
+ var sy1 = Math.Min(canvasSize, vy + halfH);
+ var sW = sx1 - sx0;
+ var sH = sy1 - sy0;
+
+ if (sW <= templateW || sH <= templateH) continue;
+
+ using var tmpl = new Mat(frame, new Rect(tx, ty, templateW, templateH));
+ using var searchRoi = new Mat(canvas, new Rect(sx0, sy0, sW, sH));
+ using var result = new Mat();
+ Cv2.MatchTemplate(searchRoi, tmpl, result, TemplateMatchModes.CCoeffNormed);
+ Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var maxLoc);
+
+ if (maxVal < 0.5) continue;
+
+ var frameX = sx0 + maxLoc.X - tx;
+ var frameY = sy0 + maxLoc.Y - ty;
+ var newVx = frameX + sw / 2;
+ var newVy = frameY + sh / 2;
+
+ // Skip near-static frames
+ if (Math.Abs(newVx - vx) < 5 && Math.Abs(newVy - vy) < 5) continue;
+
+ // Measure seam quality: pixel difference in overlap with existing canvas
+ var overlapX0 = Math.Max(frameX, 0);
+ var overlapY0 = Math.Max(frameY, 0);
+ var overlapX1 = Math.Min(frameX + sw, canvasSize);
+ var overlapY1 = Math.Min(frameY + sh, canvasSize);
+ var overlapW = overlapX1 - overlapX0;
+ var overlapH = overlapY1 - overlapY0;
+
+ if (overlapW > 20 && overlapH > 20)
+ {
+ var fsx = overlapX0 - frameX;
+ var fsy = overlapY0 - frameY;
+
+ using var canvasOverlap = new Mat(canvas, new Rect(overlapX0, overlapY0, overlapW, overlapH));
+ using var frameOverlap = new Mat(frame, new Rect(fsx, fsy, overlapW, overlapH));
+
+ // Only measure where canvas already has content (non-black)
+ using var gray = new Mat();
+ Cv2.CvtColor(canvasOverlap, gray, ColorConversionCodes.BGR2GRAY);
+ using var mask = new Mat();
+ Cv2.Threshold(gray, mask, 5, 255, ThresholdTypes.Binary);
+
+ var nonZero = Cv2.CountNonZero(mask);
+ if (nonZero > 500)
+ {
+ using var diff = new Mat();
+ Cv2.Absdiff(canvasOverlap, frameOverlap, diff);
+ var meanDiff = Cv2.Mean(diff, mask);
+ var avgDiff = (meanDiff.Val0 + meanDiff.Val1 + meanDiff.Val2) / 3.0;
+ seamQualities.Add(1.0 - avgDiff / 255.0);
+ }
+ }
+
+ // Paste frame onto canvas for future overlap comparisons
+ PasteMini(canvas, canvasSize, frame, frameX, frameY);
+ vx = newVx;
+ vy = newVy;
+ }
+
+ return seamQualities.Count > 0 ? seamQualities.Average() : 0;
+ }
+ finally
+ {
+ foreach (var s in smallFrames) s.Dispose();
+ warpMatrix?.Dispose();
+ }
+ }
+
+ private static void PasteMini(Mat canvas, int canvasSize, Mat frame, int canvasX, int canvasY)
+ {
+ var srcX = Math.Max(0, -canvasX);
+ var srcY = Math.Max(0, -canvasY);
+ var dstX = Math.Max(0, canvasX);
+ var dstY = Math.Max(0, canvasY);
+ var w = Math.Min(frame.Width - srcX, canvasSize - dstX);
+ var h = Math.Min(frame.Height - srcY, canvasSize - dstY);
+
+ if (w <= 0 || h <= 0) return;
+
+ using var srcRoi = new Mat(frame, new Rect(srcX, srcY, w, h));
+ using var dstRoi = new Mat(canvas, new Rect(dstX, dstY, w, h));
+ srcRoi.CopyTo(dstRoi);
+ }
+
+ public void Dispose()
+ {
+ foreach (var f in _frames) f.Dispose();
+ _frames.Clear();
+ _lastKept?.Dispose();
+ }
+}
diff --git a/src/Poe2Trade.Trade/ITradeMonitor.cs b/src/Poe2Trade.Trade/ITradeMonitor.cs
index 726350b..24a0917 100644
--- a/src/Poe2Trade.Trade/ITradeMonitor.cs
+++ b/src/Poe2Trade.Trade/ITradeMonitor.cs
@@ -4,9 +4,11 @@ namespace Poe2Trade.Trade;
public interface ITradeMonitor : IAsyncDisposable
{
- event Action>? NewListings;
+ event Action>? NewListings;
+ event Action>? DiamondListings;
Task Start(string? dashboardUrl = null);
Task AddSearch(string tradeUrl);
+ Task AddDiamondSearch(string tradeUrl);
Task PauseSearch(string searchId);
Task ClickTravelToHideout(string pageId, string? itemId = null);
Task<(string ScrapId, List Items)> OpenScrapPage(string tradeUrl);
diff --git a/src/Poe2Trade.Trade/TradeDaemonBridge.cs b/src/Poe2Trade.Trade/TradeDaemonBridge.cs
index 080d5b1..459ff89 100644
--- a/src/Poe2Trade.Trade/TradeDaemonBridge.cs
+++ b/src/Poe2Trade.Trade/TradeDaemonBridge.cs
@@ -22,7 +22,8 @@ public class TradeDaemonBridge : ITradeMonitor
private readonly string _daemonScript;
private readonly string _nodeExe;
- public event Action>? NewListings;
+ public event Action>? NewListings;
+ public event Action>? DiamondListings;
public TradeDaemonBridge(SavedSettings config)
{
@@ -52,6 +53,12 @@ public class TradeDaemonBridge : ITradeMonitor
await SendCommand("addSearch", new { url = tradeUrl });
}
+ public async Task AddDiamondSearch(string tradeUrl)
+ {
+ EnsureDaemonRunning();
+ await SendCommand("addDiamondSearch", new { url = tradeUrl });
+ }
+
public async Task PauseSearch(string searchId)
{
EnsureDaemonRunning();
@@ -293,14 +300,21 @@ public class TradeDaemonBridge : ITradeMonitor
{
case "newListings":
var searchId = root.GetProperty("searchId").GetString()!;
- var itemIds = root.GetProperty("itemIds").EnumerateArray()
- .Select(e => e.GetString()!)
- .Where(s => s != null)
- .ToList();
- if (itemIds.Count > 0)
+ var tradeItems = ParseItems(root);
+ if (tradeItems.Count > 0)
{
- Log.Information("New listings from daemon: {SearchId} ({Count} items)", searchId, itemIds.Count);
- NewListings?.Invoke(searchId, itemIds);
+ Log.Information("New listings from daemon: {SearchId} ({Count} items)", searchId, tradeItems.Count);
+ NewListings?.Invoke(searchId, tradeItems);
+ }
+ break;
+
+ case "diamondListings":
+ var diamondSearchId = root.GetProperty("searchId").GetString()!;
+ var pricedItems = ParsePricedItems(root);
+ if (pricedItems.Count > 0)
+ {
+ Log.Information("Diamond listings from daemon: {SearchId} ({Count} items)", diamondSearchId, pricedItems.Count);
+ DiamondListings?.Invoke(diamondSearchId, pricedItems);
}
break;
@@ -315,6 +329,29 @@ public class TradeDaemonBridge : ITradeMonitor
}
}
+ private static List ParsePricedItems(JsonElement resp)
+ {
+ var items = new List();
+ if (resp.TryGetProperty("items", out var arr) && arr.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var el in arr.EnumerateArray())
+ {
+ items.Add(new PricedTradeItem(
+ el.GetProperty("id").GetString() ?? "",
+ el.TryGetProperty("w", out var w) ? w.GetInt32() : 1,
+ el.TryGetProperty("h", out var h) ? h.GetInt32() : 1,
+ el.TryGetProperty("stashX", out var sx) ? sx.GetInt32() : 0,
+ el.TryGetProperty("stashY", out var sy) ? sy.GetInt32() : 0,
+ el.TryGetProperty("account", out var acc) ? acc.GetString() ?? "" : "",
+ el.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "",
+ el.TryGetProperty("priceAmount", out var pa) ? pa.GetDouble() : 0,
+ el.TryGetProperty("priceCurrency", out var pc) ? pc.GetString() ?? "" : ""
+ ));
+ }
+ }
+ return items;
+ }
+
private static List ParseItems(JsonElement resp)
{
var items = new List();
diff --git a/src/Poe2Trade.Ui/App.axaml.cs b/src/Poe2Trade.Ui/App.axaml.cs
index 6a3c9c2..590fa93 100644
--- a/src/Poe2Trade.Ui/App.axaml.cs
+++ b/src/Poe2Trade.Ui/App.axaml.cs
@@ -58,6 +58,7 @@ public partial class App : Application
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
var provider = services.BuildServiceProvider();
@@ -68,6 +69,7 @@ public partial class App : Application
mainVm.DebugVm = provider.GetRequiredService();
mainVm.SettingsVm = provider.GetRequiredService();
mainVm.MappingVm = provider.GetRequiredService();
+ mainVm.AtlasVm = provider.GetRequiredService();
var window = new MainWindow { DataContext = mainVm };
window.SetConfigStore(store);
diff --git a/src/Poe2Trade.Ui/Converters/ValueConverters.cs b/src/Poe2Trade.Ui/Converters/ValueConverters.cs
index f9db3f2..6546878 100644
--- a/src/Poe2Trade.Ui/Converters/ValueConverters.cs
+++ b/src/Poe2Trade.Ui/Converters/ValueConverters.cs
@@ -45,7 +45,7 @@ public class LinkModeToColorConverter : IValueConverter
{
LinkMode.Live => new SolidColorBrush(Color.Parse("#1f6feb")),
LinkMode.Scrap => new SolidColorBrush(Color.Parse("#9e6a03")),
-
+ LinkMode.Diamond => new SolidColorBrush(Color.Parse("#8957e5")),
_ => new SolidColorBrush(Color.Parse("#30363d")),
};
}
diff --git a/src/Poe2Trade.Ui/ViewModels/AtlasViewModel.cs b/src/Poe2Trade.Ui/ViewModels/AtlasViewModel.cs
new file mode 100644
index 0000000..abd00e4
--- /dev/null
+++ b/src/Poe2Trade.Ui/ViewModels/AtlasViewModel.cs
@@ -0,0 +1,150 @@
+using System.IO;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Poe2Trade.Bot;
+using Poe2Trade.Navigation;
+using Serilog;
+
+namespace Poe2Trade.Ui.ViewModels;
+
+public partial class AtlasViewModel : ObservableObject, IDisposable
+{
+ private readonly BotOrchestrator _bot;
+ private readonly CancellationTokenSource _pollCts = new();
+
+ [ObservableProperty] private Bitmap? _canvasImage;
+ [ObservableProperty] private bool _isCapturing;
+ [ObservableProperty] private string _atlasStatus = "";
+ [ObservableProperty] private int _tilesCaptured;
+
+ public AtlasViewModel(BotOrchestrator bot)
+ {
+ _bot = bot;
+ _ = PollLoop(_pollCts.Token);
+ }
+
+ [RelayCommand]
+ private async Task CaptureAtlas()
+ {
+ if (IsCapturing) return;
+
+ IsCapturing = true;
+ AtlasStatus = "Starting...";
+ TilesCaptured = 0;
+
+ void OnProgress(AtlasProgress p)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ AtlasStatus = $"{p.Phase} ({p.TilesCaptured} tiles)";
+ TilesCaptured = p.TilesCaptured;
+ });
+ }
+
+ _bot.AtlasExecutor.ProgressUpdated += OnProgress;
+
+ try
+ {
+ var path = await Task.Run(() => _bot.AtlasExecutor.CaptureAtlasPanorama());
+ AtlasStatus = path != null ? $"Saved: {path}" : "Capture failed or cancelled";
+ }
+ catch (Exception ex)
+ {
+ AtlasStatus = $"Error: {ex.Message}";
+ }
+ finally
+ {
+ _bot.AtlasExecutor.ProgressUpdated -= OnProgress;
+ IsCapturing = false;
+ }
+ }
+
+ [RelayCommand]
+ private void StopAtlasCapture()
+ {
+ _bot.AtlasExecutor.Stop();
+ AtlasStatus = "Stopping...";
+ }
+
+ [RelayCommand]
+ private async Task CalibratePerspective()
+ {
+ if (IsCapturing) return;
+
+ IsCapturing = true;
+ AtlasStatus = "Auto-scrolling atlas...";
+ TilesCaptured = 0;
+
+ void OnProgress(AtlasProgress p)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ AtlasStatus = $"{p.Phase} ({p.TilesCaptured} frames)";
+ TilesCaptured = p.TilesCaptured;
+ });
+ }
+
+ _bot.AtlasExecutor.ProgressUpdated += OnProgress;
+
+ try
+ {
+ var result = await Task.Run(() => _bot.AtlasExecutor.CalibratePerspective());
+ if (result != null)
+ AtlasStatus = $"Best factor: {result.BestFactor:F3} (conf: {result.BestConfidence:F4})";
+ else
+ AtlasStatus = "Calibration cancelled (not enough frames)";
+ }
+ catch (Exception ex)
+ {
+ AtlasStatus = $"Calibration error: {ex.Message}";
+ Log.Error(ex, "Calibration failed");
+ }
+ finally
+ {
+ _bot.AtlasExecutor.ProgressUpdated -= OnProgress;
+ IsCapturing = false;
+ }
+ }
+
+ ///
+ /// Poll ActivePanorama for viewport snapshots at ~2fps while capturing.
+ ///
+ private async Task PollLoop(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ var bytes = _bot.AtlasExecutor.ActivePanorama?.GetViewportSnapshot();
+ if (bytes != null)
+ {
+ var bmp = new Bitmap(new MemoryStream(bytes));
+ Dispatcher.UIThread.Post(() =>
+ {
+ var old = CanvasImage;
+ CanvasImage = bmp;
+ TilesCaptured = _bot.AtlasExecutor.ActivePanorama?.TilesCaptured ?? TilesCaptured;
+ old?.Dispose();
+ });
+ }
+
+ await Task.Delay(500, ct);
+ }
+ catch (OperationCanceledException) { break; }
+ catch (Exception ex)
+ {
+ Log.Debug(ex, "Atlas poll error");
+ await Task.Delay(1000, ct);
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ _pollCts.Cancel();
+ _pollCts.Dispose();
+ CanvasImage?.Dispose();
+ }
+}
diff --git a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs
index 9134473..ece47bd 100644
--- a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs
+++ b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs
@@ -27,6 +27,64 @@ public partial class CellState : ObservableObject
[ObservableProperty] private bool _borderRight;
}
+public partial class TradeLinkViewModel : ObservableObject
+{
+ private readonly TradeLink _model;
+ private readonly Action _onChanged;
+ private bool _syncing;
+
+ [ObservableProperty] private string _name;
+ [ObservableProperty] private LinkMode _mode;
+ [ObservableProperty] private bool _active;
+ [ObservableProperty] private bool _isExpanded;
+
+ public string Id => _model.Id;
+ public string Url => _model.Url;
+ public string Label => _model.Label;
+ public bool IsDiamond => Mode == LinkMode.Diamond;
+
+ public TradeLinkViewModel(TradeLink model, Action onChanged)
+ {
+ _model = model;
+ _onChanged = onChanged;
+ _name = model.Name;
+ _mode = model.Mode;
+ _active = model.Active;
+ }
+
+ public void SyncFrom(TradeLink model)
+ {
+ _syncing = true;
+ Name = model.Name;
+ Mode = model.Mode;
+ Active = model.Active;
+ _syncing = false;
+ OnPropertyChanged(nameof(Label));
+ }
+
+ partial void OnNameChanged(string value)
+ {
+ if (_syncing) return;
+ _model.Name = value;
+ _onChanged(this, nameof(Name));
+ }
+
+ partial void OnModeChanged(LinkMode value)
+ {
+ OnPropertyChanged(nameof(IsDiamond));
+ if (_syncing) return;
+ _model.Mode = value;
+ _onChanged(this, nameof(Mode));
+ }
+
+ partial void OnActiveChanged(bool value)
+ {
+ if (_syncing) return;
+ _model.Active = value;
+ _onChanged(this, nameof(Active));
+ }
+}
+
public partial class MainWindowViewModel : ObservableObject
{
private readonly BotOrchestrator _bot;
@@ -63,7 +121,7 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private int _activeLinksCount;
[ObservableProperty] private BotMode _botMode;
- public static LinkMode[] LinkModes { get; } = [LinkMode.Live, LinkMode.Scrap];
+ public static LinkMode[] LinkModes { get; } = [LinkMode.Live, LinkMode.Scrap, LinkMode.Diamond];
public static BotMode[] BotModes { get; } = [BotMode.Trading, BotMode.Mapping];
public MainWindowViewModel(BotOrchestrator bot)
@@ -75,6 +133,9 @@ public partial class MainWindowViewModel : ObservableObject
for (var i = 0; i < 60; i++)
InventoryCells.Add(new CellState());
+ // Pre-populate links from config
+ SyncLinks();
+
bot.StatusUpdated += () =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
@@ -86,7 +147,7 @@ public partial class MainWindowViewModel : ObservableObject
TradesCompleted = status.TradesCompleted;
TradesFailed = status.TradesFailed;
ActiveLinksCount = status.Links.Count(l => l.Active);
- OnPropertyChanged(nameof(Links));
+ SyncLinks();
UpdateInventoryGrid();
});
};
@@ -110,7 +171,7 @@ public partial class MainWindowViewModel : ObservableObject
}
public string PauseButtonText => IsPaused ? "Resume" : "Pause";
- public List Links => _bot.Links.GetLinks();
+ public ObservableCollection Links { get; } = [];
public ObservableCollection Logs { get; } = [];
public ObservableCollection InventoryCells { get; } = [];
public int InventoryFreeCells => _bot.IsReady ? _bot.Inventory.Tracker.FreeCells : 60;
@@ -119,6 +180,7 @@ public partial class MainWindowViewModel : ObservableObject
public DebugViewModel? DebugVm { get; set; }
public SettingsViewModel? SettingsVm { get; set; }
public MappingViewModel? MappingVm { get; set; }
+ public AtlasViewModel? AtlasVm { get; set; }
partial void OnBotModeChanged(BotMode value)
{
@@ -162,6 +224,7 @@ public partial class MainWindowViewModel : ObservableObject
{
if (string.IsNullOrWhiteSpace(NewUrl)) return;
_bot.AddLink(NewUrl, NewLinkName, NewLinkMode);
+ SyncLinks();
NewUrl = "";
NewLinkName = "";
}
@@ -169,15 +232,48 @@ public partial class MainWindowViewModel : ObservableObject
[RelayCommand]
private void RemoveLink(string? id)
{
- if (id != null) _bot.RemoveLink(id);
+ if (id == null) return;
+ _bot.RemoveLink(id);
+ SyncLinks();
}
- [RelayCommand]
- private void ToggleLink(string? id)
+ private void SyncLinks()
{
- if (id == null) return;
- var link = _bot.Links.GetLink(id);
- if (link != null) _bot.ToggleLink(id, !link.Active);
+ var current = _bot.Links.GetLinks();
+ var currentIds = new HashSet(current.Select(l => l.Id));
+
+ // Remove gone
+ for (var i = Links.Count - 1; i >= 0; i--)
+ {
+ if (!currentIds.Contains(Links[i].Id))
+ Links.RemoveAt(i);
+ }
+
+ // Add new, update existing
+ foreach (var model in current)
+ {
+ var existing = Links.FirstOrDefault(l => l.Id == model.Id);
+ if (existing != null)
+ existing.SyncFrom(model);
+ else
+ Links.Add(new TradeLinkViewModel(model, OnLinkChanged));
+ }
+ }
+
+ private void OnLinkChanged(TradeLinkViewModel vm, string prop)
+ {
+ switch (prop)
+ {
+ case nameof(TradeLinkViewModel.Name):
+ _bot.Links.UpdateName(vm.Id, vm.Name);
+ break;
+ case nameof(TradeLinkViewModel.Mode):
+ _bot.ChangeLinkMode(vm.Id, vm.Mode);
+ break;
+ case nameof(TradeLinkViewModel.Active):
+ _bot.ToggleLink(vm.Id, vm.Active);
+ break;
+ }
}
private async Task RunBackgroundLoop(CancellationToken ct)
@@ -193,9 +289,7 @@ public partial class MainWindowViewModel : ObservableObject
if (endDown && !f12WasDown)
{
Log.Information("END pressed — emergency stop");
- await _bot.Navigation.Stop();
- _bot.KulemakExecutor.Stop();
- _bot.Pause();
+ await _bot.EmergencyStop();
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
IsPaused = true;
diff --git a/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs b/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs
index 2f2e631..283f580 100644
--- a/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs
+++ b/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs
@@ -25,11 +25,11 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _calibrationStatus = "";
[ObservableProperty] private string _stashCalibratedAt = "";
[ObservableProperty] private string _shopCalibratedAt = "";
-
public static string[] OcrEngineOptions { get; } = ["WinOCR", "OneOCR", "EasyOCR"];
public ObservableCollection StashTabs { get; } = [];
public ObservableCollection ShopTabs { get; } = [];
+ public ObservableCollection DiamondPrices { get; } = [];
public SettingsViewModel(BotOrchestrator bot)
{
@@ -50,6 +50,9 @@ public partial class SettingsViewModel : ObservableObject
Headless = s.Headless;
ShowHudDebug = s.ShowHudDebug;
OcrEngine = s.OcrEngine;
+ DiamondPrices.Clear();
+ foreach (var p in s.Diamond.Prices)
+ DiamondPrices.Add(new DiamondPriceViewModel(p));
}
private void LoadTabs()
@@ -102,6 +105,7 @@ public partial class SettingsViewModel : ObservableObject
s.Headless = Headless;
s.ShowHudDebug = ShowHudDebug;
s.OcrEngine = OcrEngine;
+ s.Diamond.Prices = DiamondPrices.Select(p => p.ToModel()).ToList();
});
IsSaved = true;
@@ -207,6 +211,20 @@ public partial class SettingsViewModel : ObservableObject
}
}
+ [RelayCommand]
+ private void AddDiamondPrice()
+ {
+ DiamondPrices.Add(new DiamondPriceViewModel(new DiamondPriceConfig()));
+ IsSaved = false;
+ }
+
+ [RelayCommand]
+ private void RemoveDiamondPrice(DiamondPriceViewModel? item)
+ {
+ if (item != null) DiamondPrices.Remove(item);
+ IsSaved = false;
+ }
+
partial void OnPoe2LogPathChanged(string value) => IsSaved = false;
partial void OnWindowTitleChanged(string value) => IsSaved = false;
partial void OnTravelTimeoutMsChanged(decimal? value) => IsSaved = false;
@@ -218,3 +236,29 @@ public partial class SettingsViewModel : ObservableObject
partial void OnShowHudDebugChanged(bool value) => IsSaved = false;
partial void OnOcrEngineChanged(string value) => IsSaved = false;
}
+
+public partial class DiamondPriceViewModel : ObservableObject
+{
+ [ObservableProperty] private string _itemName;
+ [ObservableProperty] private string _displayName;
+ [ObservableProperty] private decimal? _maxDivinePrice;
+ [ObservableProperty] private bool _enabled;
+
+ public DiamondPriceViewModel(DiamondPriceConfig model)
+ {
+ _itemName = model.ItemName;
+ _displayName = !string.IsNullOrEmpty(model.DisplayName)
+ ? model.DisplayName
+ : DiamondSettings.KnownDiamonds.GetValueOrDefault(model.ItemName, "");
+ _maxDivinePrice = (decimal)model.MaxDivinePrice;
+ _enabled = model.Enabled;
+ }
+
+ public DiamondPriceConfig ToModel() => new()
+ {
+ ItemName = ItemName,
+ DisplayName = DisplayName,
+ MaxDivinePrice = (double)(MaxDivinePrice ?? 0),
+ Enabled = Enabled,
+ };
+}
diff --git a/src/Poe2Trade.Ui/Views/MainWindow.axaml b/src/Poe2Trade.Ui/Views/MainWindow.axaml
index d4a9089..a8bbbad 100644
--- a/src/Poe2Trade.Ui/Views/MainWindow.axaml
+++ b/src/Poe2Trade.Ui/Views/MainWindow.axaml
@@ -177,33 +177,107 @@
-
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -304,6 +378,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/trade-daemon/daemon.mjs b/tools/trade-daemon/daemon.mjs
index 62f00a6..de4c578 100644
--- a/tools/trade-daemon/daemon.mjs
+++ b/tools/trade-daemon/daemon.mjs
@@ -68,6 +68,32 @@ function parseTradeItem(r) {
return { id, w, h, stashX, stashY, account };
}
+function parseTradeItemWithPrice(r) {
+ const base = parseTradeItem(r);
+ let name = "";
+ let priceAmount = 0;
+ let priceCurrency = "";
+
+ if (r.item?.icon) {
+ try {
+ const pathname = new URL(r.item.icon).pathname;
+ const filename = pathname.split("/").pop() || "";
+ name = filename.replace(".png", "");
+ } catch {
+ // fallback: regex on last path segment
+ const match = r.item.icon.match(/\/([^/]+)\.png/);
+ if (match) name = match[1];
+ }
+ }
+
+ if (r.listing?.price) {
+ if (r.listing.price.amount != null) priceAmount = r.listing.price.amount;
+ if (r.listing.price.currency) priceCurrency = r.listing.price.currency;
+ }
+
+ return { ...base, name, priceAmount, priceCurrency };
+}
+
async function waitForVisible(locator, timeoutMs) {
try {
await locator.waitFor({ state: "visible", timeout: timeoutMs });
@@ -97,19 +123,8 @@ function handleWebSocket(ws, searchId) {
ws.on("framereceived", (frame) => {
if (pausedSearches.has(searchId)) return;
- try {
- const payload = typeof frame === "string" ? frame : frame.payload?.toString() ?? "";
- const doc = JSON.parse(payload);
- if (doc.new && Array.isArray(doc.new)) {
- const ids = doc.new.filter((s) => s != null);
- if (ids.length > 0) {
- log(`New listings: ${searchId} (${ids.length} items)`);
- sendEvent("newListings", { searchId, itemIds: ids });
- }
- }
- } catch {
- /* Non-JSON WebSocket frame */
- }
+ // Note: trade site now sends JWTs, not raw item IDs.
+ // We rely on the fetch response interceptor instead.
});
ws.on("close", () => {
@@ -160,14 +175,42 @@ async function cmdAddSearch(reqId, params) {
const page = await context.newPage();
searchPages.set(searchId, page);
+ // Register WebSocket handler BEFORE navigation so we catch connections during page load
+ page.on("websocket", (ws) => handleWebSocket(ws, searchId));
+
+ // Track whether live search is active — don't emit items from initial page load
+ let liveActive = false;
+ const seenIds = new Set();
+
+ // Intercept fetch responses to get full item data (WebSocket now sends JWTs, not raw IDs)
+ page.on("response", async (response) => {
+ if (!liveActive) return;
+ if (!response.url().includes("/api/trade2/fetch/")) return;
+ try {
+ const body = await response.text();
+ const doc = JSON.parse(body);
+ if (doc.result && Array.isArray(doc.result)) {
+ const items = doc.result
+ .map((r) => parseTradeItem(r))
+ .filter((i) => i.id && !seenIds.has(i.id));
+ items.forEach((i) => seenIds.add(i.id));
+ if (items.length > 0) {
+ log(`New listings (fetch): ${searchId} (${items.length} new items)`);
+ sendEvent("newListings", { searchId, items });
+ }
+ }
+ } catch {
+ /* Non-JSON trade response */
+ }
+ });
+
await page.goto(url, { waitUntil: "networkidle" });
await new Promise((r) => setTimeout(r, 2000)); // PageLoad delay
- page.on("websocket", (ws) => handleWebSocket(ws, searchId));
-
try {
const liveBtn = page.locator(Selectors.LiveSearchButton).first();
await liveBtn.click({ timeout: 5000 });
+ liveActive = true;
log(`Live search activated: ${searchId}`);
} catch {
log(`Could not click Activate Live Search: ${searchId}`);
@@ -222,6 +265,56 @@ async function cmdClickTravel(reqId, params) {
}
}
+async function cmdAddDiamondSearch(reqId, params) {
+ if (!context) throw new Error("Browser not started");
+ const { url } = params;
+ const searchId = extractSearchId(url);
+
+ if (searchPages.has(searchId)) {
+ log(`Diamond search already open: ${searchId}`);
+ sendResponse(reqId, { searchId });
+ return;
+ }
+
+ log(`Adding diamond search: ${url} (${searchId})`);
+ const page = await context.newPage();
+ searchPages.set(searchId, page);
+
+ // Intercept fetch responses for item data + prices
+ page.on("response", async (response) => {
+ if (!response.url().includes("/api/trade2/fetch/")) return;
+ try {
+ const body = await response.text();
+ const doc = JSON.parse(body);
+ if (doc.result && Array.isArray(doc.result)) {
+ const items = doc.result.map((r) => parseTradeItemWithPrice(r));
+ if (items.length > 0) {
+ log(`Diamond listings: ${searchId} (${items.length} items)`);
+ sendEvent("diamondListings", { searchId, items });
+ }
+ }
+ } catch {
+ /* Non-JSON trade response */
+ }
+ });
+
+ // Hook WebSocket BEFORE navigation so we catch connections during page load
+ page.on("websocket", (ws) => handleWebSocket(ws, searchId));
+
+ await page.goto(url, { waitUntil: "networkidle" });
+ await new Promise((r) => setTimeout(r, 2000));
+
+ try {
+ const liveBtn = page.locator(Selectors.LiveSearchButton).first();
+ await liveBtn.click({ timeout: 5000 });
+ log(`Diamond live search activated: ${searchId}`);
+ } catch {
+ log(`Could not click Activate Live Search: ${searchId}`);
+ }
+
+ sendResponse(reqId, { searchId });
+}
+
async function cmdOpenScrapPage(reqId, params) {
if (!context) throw new Error("Browser not started");
const { url } = params;
@@ -339,6 +432,7 @@ async function cmdStop(reqId) {
const handlers = {
start: cmdStart,
addSearch: cmdAddSearch,
+ addDiamondSearch: cmdAddDiamondSearch,
pauseSearch: cmdPauseSearch,
clickTravel: cmdClickTravel,
openScrapPage: cmdOpenScrapPage,