switched to new way

This commit is contained in:
Boki 2026-02-13 01:12:51 -05:00
parent f22d182c8f
commit 4a65c8e17b
96 changed files with 4991 additions and 10025 deletions

View file

@ -0,0 +1,296 @@
using Microsoft.Playwright;
using Poe2Trade.Core;
using Poe2Trade.Game;
using Poe2Trade.Inventory;
using Poe2Trade.GameLog;
using Poe2Trade.Screen;
using Poe2Trade.Trade;
using Serilog;
namespace Poe2Trade.Bot;
public class BotStatus
{
public bool Paused { get; set; }
public string State { get; set; } = "Idle";
public List<TradeLink> Links { get; set; } = [];
public int TradesCompleted { get; set; }
public int TradesFailed { get; set; }
public long Uptime { get; set; }
}
public class BotOrchestrator : IAsyncDisposable
{
private bool _paused;
private string _state = "Idle";
private int _tradesCompleted;
private int _tradesFailed;
private readonly long _startTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private bool _started;
public LinkManager Links { get; }
public ConfigStore Store { get; }
public AppConfig Config { get; }
public GameController Game { get; private set; } = null!;
public ScreenReader Screen { get; private set; } = null!;
public ClientLogWatcher LogWatcher { get; private set; } = null!;
public TradeMonitor TradeMonitor { get; private set; } = null!;
public InventoryManager Inventory { get; private set; } = null!;
public TradeExecutor TradeExecutor { get; private set; } = null!;
public TradeQueue TradeQueue { get; private set; } = null!;
private readonly Dictionary<string, ScrapExecutor> _scrapExecutors = new();
// Events
public event Action? StatusUpdated;
public event Action<string, string>? LogMessage; // level, message
public BotOrchestrator(ConfigStore store, AppConfig config)
{
Store = store;
Config = config;
_paused = store.Settings.Paused;
Links = new LinkManager(store);
}
public bool IsReady => _started;
public bool IsPaused => _paused;
public string State
{
get => _state;
set
{
if (_state == value) return;
_state = value;
StatusUpdated?.Invoke();
}
}
public void Pause()
{
_paused = true;
Store.SetPaused(true);
Log.Information("Bot paused");
StatusUpdated?.Invoke();
}
public void Resume()
{
_paused = false;
Store.SetPaused(false);
Log.Information("Bot resumed");
StatusUpdated?.Invoke();
}
public TradeLink AddLink(string url, string? name = null, LinkMode? mode = null, PostAction? postAction = null)
{
var link = Links.AddLink(url, name ?? "", mode, postAction);
StatusUpdated?.Invoke();
return link;
}
public void RemoveLink(string id)
{
Links.RemoveLink(id);
StatusUpdated?.Invoke();
}
public void ToggleLink(string id, bool active)
{
var link = Links.ToggleLink(id, active);
if (link == null) return;
StatusUpdated?.Invoke();
if (active)
_ = ActivateLink(link);
else
_ = DeactivateLink(id);
}
public BotStatus GetStatus() => new()
{
Paused = _paused,
State = _state,
Links = Links.GetLinks(),
TradesCompleted = _tradesCompleted,
TradesFailed = _tradesFailed,
Uptime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _startTime,
};
public void UpdateExecutorState()
{
var execState = TradeExecutor.State;
if (execState != TradeState.Idle)
{
State = execState.ToString();
return;
}
foreach (var scrapExec in _scrapExecutors.Values)
{
if (scrapExec.State != ScrapState.Idle)
{
State = scrapExec.State.ToString();
return;
}
}
State = "Idle";
}
public async Task Start(IReadOnlyList<string> cliUrls)
{
Screen = new ScreenReader();
Game = new GameController(Config);
LogWatcher = new ClientLogWatcher(Config.Poe2LogPath);
LogWatcher.Start();
Emit("info", "Watching Client.txt for game events");
TradeMonitor = new TradeMonitor(Config);
await TradeMonitor.Start();
Emit("info", "Browser launched");
Inventory = new InventoryManager(Game, Screen, LogWatcher, Config);
// Warmup OCR daemon
var ocrWarmup = Screen.Warmup().ContinueWith(t =>
{
if (t.IsFaulted) Log.Warning(t.Exception!, "OCR warmup failed");
});
// Check if already in hideout
var inHideout = LogWatcher.CurrentArea.Contains("hideout", StringComparison.OrdinalIgnoreCase);
if (inHideout)
{
Log.Information("Already in hideout: {Area}", LogWatcher.CurrentArea);
Inventory.SetLocation(true);
}
else
{
Emit("info", "Sending /hideout command...");
await Game.FocusGame();
var arrivedHome = await Inventory.WaitForAreaTransition(Config.TravelTimeoutMs, () => Game.GoToHideout());
Inventory.SetLocation(true);
if (!arrivedHome)
Log.Warning("Timed out waiting for hideout transition on startup");
}
State = "InHideout";
Emit("info", "In hideout, ready to trade");
await ocrWarmup;
Emit("info", "Checking inventory for leftover items...");
await Inventory.ClearToStash();
Emit("info", "Inventory cleared");
// Create executors
TradeExecutor = new TradeExecutor(Game, Screen, TradeMonitor, Inventory, Config);
TradeExecutor.StateChanged += _ => UpdateExecutorState();
TradeQueue = new TradeQueue(TradeExecutor, Config);
TradeQueue.TradeCompleted += () => { _tradesCompleted++; StatusUpdated?.Invoke(); };
TradeQueue.TradeFailed += () => { _tradesFailed++; StatusUpdated?.Invoke(); };
// Load links
var allUrls = new HashSet<string>(cliUrls);
foreach (var l in Store.Settings.Links)
allUrls.Add(l.Url);
foreach (var url in allUrls)
{
var link = Links.AddLink(url);
if (link.Active)
await ActivateLink(link);
else
Emit("info", $"Loaded (inactive): {link.Name}");
}
// Wire trade monitor events
TradeMonitor.NewListings += OnNewListings;
_started = true;
Emit("info", $"Loaded {allUrls.Count} trade link(s)");
Log.Information("Bot started");
}
public async ValueTask DisposeAsync()
{
Log.Information("Shutting down bot...");
foreach (var exec in _scrapExecutors.Values)
await exec.Stop();
Screen.Dispose();
await TradeMonitor.DisposeAsync();
LogWatcher.Dispose();
}
private void OnNewListings(string searchId, List<string> itemIds, IPage page)
{
if (_paused)
{
Emit("warn", $"New listings ({itemIds.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}");
TradeQueue.Enqueue(new TradeInfo(
SearchId: searchId,
ItemIds: itemIds,
WhisperText: "",
Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
TradeUrl: "",
Page: page
));
}
private async Task ActivateLink(TradeLink link)
{
try
{
if (link.Mode == LinkMode.Scrap)
{
var scrapExec = new ScrapExecutor(Game, Screen, TradeMonitor, Inventory, Config);
scrapExec.StateChanged += _ => UpdateExecutorState();
scrapExec.ItemBought += () => { _tradesCompleted++; StatusUpdated?.Invoke(); };
scrapExec.ItemFailed += () => { _tradesFailed++; StatusUpdated?.Invoke(); };
_scrapExecutors[link.Id] = scrapExec;
Emit("info", $"Scrap loop started: {link.Name}");
StatusUpdated?.Invoke();
_ = scrapExec.RunScrapLoop(link.Url, link.PostAction).ContinueWith(t =>
{
if (t.IsFaulted)
{
Log.Error(t.Exception!, "Scrap loop error: {LinkId}", link.Id);
Emit("error", $"Scrap loop failed: {link.Name}");
_scrapExecutors.Remove(link.Id);
}
});
}
else
{
await TradeMonitor.AddSearch(link.Url);
Emit("info", $"Monitoring: {link.Name}");
StatusUpdated?.Invoke();
}
}
catch (Exception ex)
{
Log.Error(ex, "Failed to activate link: {Url}", link.Url);
Emit("error", $"Failed to activate: {link.Name}");
}
}
private async Task DeactivateLink(string id)
{
if (_scrapExecutors.TryGetValue(id, out var scrapExec))
{
await scrapExec.Stop();
_scrapExecutors.Remove(id);
}
await TradeMonitor.PauseSearch(id);
}
private void Emit(string level, string message) => LogMessage?.Invoke(level, message);
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
<ProjectReference Include="..\Poe2Trade.Game\Poe2Trade.Game.csproj" />
<ProjectReference Include="..\Poe2Trade.Screen\Poe2Trade.Screen.csproj" />
<ProjectReference Include="..\Poe2Trade.Trade\Poe2Trade.Trade.csproj" />
<ProjectReference Include="..\Poe2Trade.Log\Poe2Trade.Log.csproj" />
<ProjectReference Include="..\Poe2Trade.Inventory\Poe2Trade.Inventory.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,222 @@
using System.Text.Json;
using Microsoft.Playwright;
using Poe2Trade.Core;
using Poe2Trade.Game;
using Poe2Trade.Inventory;
using Poe2Trade.Screen;
using Poe2Trade.Trade;
using Serilog;
namespace Poe2Trade.Bot;
public class ScrapExecutor
{
private ScrapState _state = ScrapState.Idle;
private bool _stopped;
private IPage? _activePage;
private PostAction _postAction = PostAction.Salvage;
private readonly GameController _game;
private readonly ScreenReader _screen;
private readonly TradeMonitor _tradeMonitor;
private readonly InventoryManager _inventory;
private readonly AppConfig _config;
public event Action<ScrapState>? StateChanged;
public event Action? ItemBought;
public event Action? ItemFailed;
public ScrapExecutor(GameController game, ScreenReader screen, TradeMonitor tradeMonitor,
InventoryManager inventory, AppConfig config)
{
_game = game;
_screen = screen;
_tradeMonitor = tradeMonitor;
_inventory = inventory;
_config = config;
}
public ScrapState State => _state;
private void SetState(ScrapState s)
{
_state = s;
StateChanged?.Invoke(s);
}
public async Task Stop()
{
_stopped = true;
if (_activePage != null)
{
try { await _activePage.CloseAsync(); } catch { }
_activePage = null;
}
SetState(ScrapState.Idle);
Log.Information("Scrap executor stopped");
}
public async Task RunScrapLoop(string tradeUrl, PostAction postAction = PostAction.Salvage)
{
_stopped = false;
_postAction = postAction;
Log.Information("Starting scrap loop: {Url} postAction={Action}", tradeUrl, postAction);
await _inventory.ScanInventory(_postAction);
var (page, items) = await _tradeMonitor.OpenScrapPage(tradeUrl);
_activePage = page;
Log.Information("Trade page opened: {Count} items", items.Count);
while (!_stopped)
{
var salvageFailed = false;
foreach (var item in items)
{
if (_stopped) break;
if (!_inventory.Tracker.CanFit(item.W, item.H))
{
if (salvageFailed) continue;
Log.Information("No room for {W}x{H}, processing...", item.W, item.H);
await ProcessItems();
if (_state == ScrapState.Failed)
{
salvageFailed = true;
SetState(ScrapState.Idle);
continue;
}
await _inventory.ScanInventory(_postAction);
}
if (!_inventory.Tracker.CanFit(item.W, item.H))
{
Log.Warning("Item {W}x{H} still cannot fit after processing, skipping", item.W, item.H);
continue;
}
var success = await BuyItem(page, item);
if (!success) Log.Warning("Failed to buy item {Id}", item.Id);
await Helpers.RandomDelay(500, 1000);
}
if (_stopped) break;
Log.Information("Page exhausted, refreshing...");
items = await RefreshPage(page);
Log.Information("Page refreshed: {Count} items", items.Count);
if (items.Count == 0)
{
Log.Information("No items after refresh, waiting...");
await Helpers.Sleep(5000);
if (_stopped) break;
items = await RefreshPage(page);
}
}
_activePage = null;
SetState(ScrapState.Idle);
Log.Information("Scrap loop ended");
}
private async Task<bool> BuyItem(IPage page, TradeItem item)
{
try
{
var alreadyAtSeller = !_inventory.IsAtOwnHideout
&& !string.IsNullOrEmpty(item.Account)
&& item.Account == _inventory.SellerAccount;
if (alreadyAtSeller)
{
Log.Information("Already at seller hideout, skipping travel");
}
else
{
SetState(ScrapState.Traveling);
var arrived = await _inventory.WaitForAreaTransition(
_config.TravelTimeoutMs,
async () =>
{
if (!await _tradeMonitor.ClickTravelToHideout(page, 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(ScrapState.Failed);
return false;
}
_inventory.SetLocation(false, item.Account);
await _game.FocusGame();
await Helpers.Sleep(1500);
}
SetState(ScrapState.Buying);
var sellerLayout = GridLayouts.Seller;
var cellCenter = _screen.Grid.GetCellCenter(sellerLayout, item.StashY, item.StashX);
Log.Information("CTRL+clicking seller stash at ({X},{Y})", cellCenter.X, cellCenter.Y);
await _game.CtrlLeftClickAt(cellCenter.X, cellCenter.Y);
await Helpers.RandomDelay(200, 400);
_inventory.Tracker.TryPlace(item.W, item.H, _postAction);
Log.Information("Item bought: {Id} (free={Free})", item.Id, _inventory.Tracker.FreeCells);
SetState(ScrapState.Idle);
ItemBought?.Invoke();
return true;
}
catch (Exception ex)
{
Log.Error(ex, "Error buying item {Id}", item.Id);
SetState(ScrapState.Failed);
ItemFailed?.Invoke();
return false;
}
}
private async Task ProcessItems()
{
try
{
SetState(ScrapState.Salvaging);
await _inventory.ProcessInventory();
SetState(ScrapState.Idle);
Log.Information("Process cycle complete");
}
catch (Exception ex)
{
Log.Error(ex, "Process cycle failed");
SetState(ScrapState.Failed);
}
}
private async Task<List<TradeItem>> RefreshPage(IPage page)
{
var items = new List<TradeItem>();
void OnResponse(object? _, IResponse response)
{
if (!response.Url.Contains("/api/trade2/fetch/")) return;
try
{
var body = response.TextAsync().GetAwaiter().GetResult();
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("result", out var results) &&
results.ValueKind == JsonValueKind.Array)
{
foreach (var r in results.EnumerateArray())
items.Add(TradeMonitor.ParseTradeItem(r));
}
}
catch { }
}
page.Response += OnResponse;
await page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle });
await Helpers.Sleep(2000);
page.Response -= OnResponse;
return items;
}
}

View file

@ -0,0 +1,147 @@
using Microsoft.Playwright;
using Poe2Trade.Core;
using Poe2Trade.Game;
using Poe2Trade.Inventory;
using Poe2Trade.Screen;
using Poe2Trade.Trade;
using Serilog;
namespace Poe2Trade.Bot;
public class TradeExecutor
{
private TradeState _state = TradeState.Idle;
private readonly GameController _game;
private readonly ScreenReader _screen;
private readonly TradeMonitor _tradeMonitor;
private readonly InventoryManager _inventory;
private readonly AppConfig _config;
public event Action<TradeState>? StateChanged;
public TradeExecutor(GameController game, ScreenReader screen, TradeMonitor tradeMonitor,
InventoryManager inventory, AppConfig config)
{
_game = game;
_screen = screen;
_tradeMonitor = tradeMonitor;
_inventory = inventory;
_config = config;
}
public TradeState State => _state;
private void SetState(TradeState s)
{
_state = s;
StateChanged?.Invoke(s);
}
public async Task<bool> ExecuteTrade(TradeInfo trade)
{
var page = trade.Page as IPage;
if (page == null)
{
Log.Error("Trade has no page reference");
return false;
}
try
{
// Step 1: Travel to seller hideout
SetState(TradeState.Traveling);
Log.Information("Clicking Travel to Hideout for {SearchId}...", trade.SearchId);
var arrived = await _inventory.WaitForAreaTransition(
_config.TravelTimeoutMs,
async () =>
{
if (!await _tradeMonitor.ClickTravelToHideout(page, 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;
}
SetState(TradeState.InSellersHideout);
_inventory.SetLocation(false);
Log.Information("Arrived at seller hideout");
// Step 2: Focus game and find stash
await _game.FocusGame();
await Helpers.Sleep(1500);
var angePos = await _inventory.FindAndClickNameplate("Ange");
if (angePos == null)
Log.Warning("Could not find Ange nameplate, trying Stash directly");
else
await Helpers.Sleep(1000);
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(1000);
// Step 3: Scan stash and buy
SetState(TradeState.ScanningStash);
await ScanAndBuyItems();
// Step 4: Wait for more
SetState(TradeState.WaitingForMore);
Log.Information("Waiting {Ms}ms for more items...", _config.WaitForMoreItemsMs);
await Helpers.Sleep(_config.WaitForMoreItemsMs);
await ScanAndBuyItems();
// Step 5: Go home
SetState(TradeState.GoingHome);
await _game.FocusGame();
await Helpers.Sleep(300);
var home = await _inventory.WaitForAreaTransition(
_config.TravelTimeoutMs, () => _game.GoToHideout());
if (!home) Log.Warning("Timed out going home");
_inventory.SetLocation(true);
// Step 6: Store items
SetState(TradeState.InHideout);
await Helpers.Sleep(1000);
await _inventory.ProcessInventory();
SetState(TradeState.Idle);
return true;
}
catch (Exception ex)
{
Log.Error(ex, "Trade execution failed");
SetState(TradeState.Failed);
try
{
await _game.FocusGame();
await _game.PressEscape();
await Helpers.Sleep(500);
await _game.GoToHideout();
}
catch { /* best-effort recovery */ }
SetState(TradeState.Idle);
return false;
}
}
private async Task ScanAndBuyItems()
{
var stashRegion = new Region(20, 140, 630, 750);
var stashText = await _screen.ReadRegionText(stashRegion);
Log.Information("Stash OCR: {Text}", stashText.Length > 200 ? stashText[..200] : stashText);
SetState(TradeState.Buying);
}
}

View file

@ -0,0 +1,71 @@
using Poe2Trade.Core;
using Serilog;
namespace Poe2Trade.Bot;
public class TradeQueue
{
private readonly Queue<TradeInfo> _queue = new();
private readonly TradeExecutor _executor;
private readonly AppConfig _config;
private bool _processing;
public TradeQueue(TradeExecutor executor, AppConfig config)
{
_executor = executor;
_config = config;
}
public int Length => _queue.Count;
public bool IsProcessing => _processing;
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)
{
Log.Information("Skipping duplicate trade: {ItemIds}", string.Join(",", trade.ItemIds));
return;
}
var deduped = trade with { ItemIds = newIds };
_queue.Enqueue(deduped);
Log.Information("Trade enqueued: {Count} items, queue={QueueLen}", newIds.Count, _queue.Count);
_ = ProcessNext();
}
private async Task ProcessNext()
{
if (_processing || _queue.Count == 0) return;
_processing = true;
var trade = _queue.Dequeue();
try
{
Log.Information("Processing trade: {SearchId} ({Count} items)", trade.SearchId, trade.ItemIds.Count);
var success = await _executor.ExecuteTrade(trade);
if (success)
{
Log.Information("Trade completed");
TradeCompleted?.Invoke();
}
else
{
Log.Information("Trade failed");
TradeFailed?.Invoke();
}
}
catch (Exception ex)
{
Log.Error(ex, "Trade execution error");
}
_processing = false;
await Helpers.RandomDelay(_config.BetweenTradesDelayMs, _config.BetweenTradesDelayMs + 3000);
_ = ProcessNext();
}
}

View file

@ -0,0 +1,29 @@
using Microsoft.Extensions.Configuration;
namespace Poe2Trade.Core;
public class AppConfig
{
public List<string> TradeUrls { get; set; } = [];
public string Poe2LogPath { get; set; } = @"C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs\Client.txt";
public string Poe2WindowTitle { get; set; } = "Path of Exile 2";
public string BrowserUserDataDir { get; set; } = "./browser-data";
public int TravelTimeoutMs { get; set; } = 15000;
public int StashScanTimeoutMs { get; set; } = 10000;
public int WaitForMoreItemsMs { get; set; } = 20000;
public int BetweenTradesDelayMs { get; set; } = 5000;
public static AppConfig Load(string? configPath = null)
{
var builder = new ConfigurationBuilder();
var path = configPath ?? "appsettings.json";
if (File.Exists(path))
{
builder.AddJsonFile(path, optional: true);
}
var configuration = builder.Build();
var config = new AppConfig();
configuration.Bind(config);
return config;
}
}

View file

@ -0,0 +1,147 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Serilog;
namespace Poe2Trade.Core;
public class SavedLink
{
public string Url { get; set; } = "";
public string Name { get; set; } = "";
public bool Active { get; set; } = true;
public LinkMode Mode { get; set; } = LinkMode.Live;
public PostAction PostAction { get; set; } = PostAction.Stash;
public string AddedAt { get; set; } = DateTime.UtcNow.ToString("o");
}
public class SavedSettings
{
public bool Paused { get; set; }
public List<SavedLink> Links { get; set; } = [];
public string Poe2LogPath { get; set; } = @"C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs\Client.txt";
public string Poe2WindowTitle { get; set; } = "Path of Exile 2";
public string BrowserUserDataDir { get; set; } = "./browser-data";
public int TravelTimeoutMs { get; set; } = 15000;
public int StashScanTimeoutMs { get; set; } = 10000;
public int WaitForMoreItemsMs { get; set; } = 20000;
public int BetweenTradesDelayMs { get; set; } = 5000;
public double? WindowX { get; set; }
public double? WindowY { get; set; }
public double? WindowWidth { get; set; }
public double? WindowHeight { get; set; }
}
public class ConfigStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
private readonly string _filePath;
private SavedSettings _data;
public ConfigStore(string? configPath = null)
{
_filePath = configPath ?? Path.GetFullPath("config.json");
_data = Load();
}
public SavedSettings Settings => _data;
public IReadOnlyList<SavedLink> Links => _data.Links;
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;
_data.Links.Add(new SavedLink
{
Url = url,
Name = name,
Active = true,
Mode = mode,
PostAction = postAction ?? (mode == LinkMode.Scrap ? PostAction.Salvage : PostAction.Stash),
AddedAt = DateTime.UtcNow.ToString("o")
});
Save();
}
public void RemoveLink(string url)
{
_data.Links.RemoveAll(l => l.Url == url);
Save();
}
public void RemoveLinkById(string id)
{
_data.Links.RemoveAll(l => l.Url.Split('/').Last() == id);
Save();
}
public SavedLink? UpdateLinkById(string id, Action<SavedLink> update)
{
var link = _data.Links.FirstOrDefault(l => l.Url.Split('/').Last() == id);
if (link == null) return null;
update(link);
Save();
return link;
}
public void SetPaused(bool paused)
{
_data.Paused = paused;
Save();
}
public void UpdateSettings(Action<SavedSettings> update)
{
update(_data);
Save();
}
public void Save()
{
try
{
var json = JsonSerializer.Serialize(_data, JsonOptions);
File.WriteAllText(_filePath, json);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to save config.json to {Path}", _filePath);
}
}
private SavedSettings Load()
{
if (!File.Exists(_filePath))
{
Log.Information("No config.json found at {Path}, using defaults", _filePath);
return new SavedSettings();
}
try
{
var raw = File.ReadAllText(_filePath);
var parsed = JsonSerializer.Deserialize<SavedSettings>(raw, JsonOptions);
if (parsed == null) return new SavedSettings();
// Migrate links: strip /live from URLs
foreach (var link in parsed.Links)
{
link.Url = StripLive(link.Url);
}
Log.Information("Loaded config.json from {Path} ({LinkCount} links)", _filePath, parsed.Links.Count);
return parsed;
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to read config.json at {Path}, using defaults", _filePath);
return new SavedSettings();
}
}
private static string StripLive(string url) => System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", "");
}

View file

@ -0,0 +1,14 @@
namespace Poe2Trade.Core;
public static class Helpers
{
private static readonly Random Rng = new();
public static Task Sleep(int ms) => Task.Delay(ms);
public static Task RandomDelay(int minMs, int maxMs)
{
var delay = Rng.Next(minMs, maxMs + 1);
return Task.Delay(delay);
}
}

View file

@ -0,0 +1,129 @@
using Serilog;
namespace Poe2Trade.Core;
public class TradeLink
{
public string Id { get; set; } = "";
public string Url { get; set; } = "";
public string Name { get; set; } = "";
public string Label { get; set; } = "";
public bool Active { get; set; } = true;
public LinkMode Mode { get; set; } = LinkMode.Live;
public PostAction PostAction { get; set; } = PostAction.Stash;
public string AddedAt { get; set; } = DateTime.UtcNow.ToString("o");
}
public class LinkManager
{
private readonly Dictionary<string, TradeLink> _links = new();
private readonly ConfigStore _store;
public LinkManager(ConfigStore store)
{
_store = store;
}
public TradeLink AddLink(string url, string name = "", LinkMode? mode = null, PostAction? postAction = null)
{
url = StripLive(url);
var id = ExtractId(url);
var label = ExtractLabel(url);
var savedLink = _store.Links.FirstOrDefault(l => l.Url == url);
var resolvedMode = mode ?? savedLink?.Mode ?? LinkMode.Live;
var link = new TradeLink
{
Id = id,
Url = url,
Name = name != "" ? name : savedLink?.Name ?? "",
Label = label,
Active = savedLink?.Active ?? true,
Mode = resolvedMode,
PostAction = postAction ?? savedLink?.PostAction ?? (resolvedMode == LinkMode.Scrap ? PostAction.Salvage : PostAction.Stash),
AddedAt = DateTime.UtcNow.ToString("o")
};
_links[id] = link;
_store.AddLink(url, link.Name, link.Mode, link.PostAction);
Log.Information("Trade link added: {Id} {Url} mode={Mode}", id, url, link.Mode);
return link;
}
public void RemoveLink(string id)
{
if (_links.TryGetValue(id, out var link))
{
_links.Remove(id);
_store.RemoveLink(link.Url);
}
else
{
_store.RemoveLinkById(id);
}
Log.Information("Trade link removed: {Id}", id);
}
public TradeLink? ToggleLink(string id, bool active)
{
if (!_links.TryGetValue(id, out var link)) return null;
link.Active = active;
_store.UpdateLinkById(id, l => l.Active = active);
Log.Information("Trade link {Action}: {Id}", active ? "activated" : "deactivated", id);
return link;
}
public void UpdateName(string id, string name)
{
if (!_links.TryGetValue(id, out var link)) return;
link.Name = name;
_store.UpdateLinkById(id, l => l.Name = name);
}
public TradeLink? UpdateMode(string id, LinkMode mode)
{
if (!_links.TryGetValue(id, out var link)) return null;
link.Mode = mode;
_store.UpdateLinkById(id, l => l.Mode = mode);
return link;
}
public TradeLink? UpdatePostAction(string id, PostAction postAction)
{
if (!_links.TryGetValue(id, out var link)) return null;
link.PostAction = postAction;
_store.UpdateLinkById(id, l => l.PostAction = postAction);
return link;
}
public bool IsActive(string id) => _links.TryGetValue(id, out var link) && link.Active;
public List<TradeLink> GetLinks() => _links.Values.ToList();
public TradeLink? GetLink(string id) => _links.GetValueOrDefault(id);
private static string StripLive(string url) =>
System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", "");
private static string ExtractId(string url)
{
var parts = url.Split('/');
return parts.Length > 0 ? parts[^1] : url;
}
private static string ExtractLabel(string url)
{
try
{
var uri = new Uri(url);
var parts = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
var poe2Idx = Array.IndexOf(parts, "poe2");
if (poe2Idx >= 0 && parts.Length > poe2Idx + 2)
{
var league = Uri.UnescapeDataString(parts[poe2Idx + 1]);
var searchId = parts[poe2Idx + 2];
return $"{league} / {searchId}";
}
}
catch { /* fallback */ }
return url.Length > 60 ? url[..60] : url;
}
}

View file

@ -0,0 +1,19 @@
using Serilog;
using Serilog.Events;
namespace Poe2Trade.Core;
public static class Logging
{
public static void Setup()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File("logs/poe2trade-.log",
rollingInterval: RollingInterval.Day,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,70 @@
namespace Poe2Trade.Core;
public record Region(int X, int Y, int Width, int Height);
public record TradeInfo(
string SearchId,
List<string> ItemIds,
string WhisperText,
long Timestamp,
string TradeUrl,
object? Page // Playwright Page reference
);
public record TradeItem(
string Id,
int W,
int H,
int StashX,
int StashY,
string Account
);
public record LogEvent(
DateTime Timestamp,
LogEventType Type,
Dictionary<string, string> Data
);
public enum LogEventType
{
AreaEntered,
WhisperReceived,
TradeAccepted,
Unknown
}
public enum TradeState
{
Idle,
Traveling,
InSellersHideout,
ScanningStash,
Buying,
WaitingForMore,
GoingHome,
InHideout,
Failed
}
public enum ScrapState
{
Idle,
Traveling,
Buying,
Salvaging,
Storing,
Failed
}
public enum LinkMode
{
Live,
Scrap
}
public enum PostAction
{
Stash,
Salvage
}

View file

@ -0,0 +1,111 @@
using System.Runtime.InteropServices;
using System.Text;
namespace Poe2Trade.Game;
/// <summary>
/// Win32 clipboard access without WinForms dependency.
/// </summary>
public static class ClipboardHelper
{
public static string Read()
{
if (!ClipboardNative.OpenClipboard(IntPtr.Zero))
return "";
try
{
var handle = ClipboardNative.GetClipboardData(ClipboardNative.CF_UNICODETEXT);
if (handle == IntPtr.Zero) return "";
var ptr = ClipboardNative.GlobalLock(handle);
if (ptr == IntPtr.Zero) return "";
try
{
return Marshal.PtrToStringUni(ptr) ?? "";
}
finally
{
ClipboardNative.GlobalUnlock(handle);
}
}
finally
{
ClipboardNative.CloseClipboard();
}
}
public static void Write(string text)
{
if (!ClipboardNative.OpenClipboard(IntPtr.Zero))
return;
try
{
ClipboardNative.EmptyClipboard();
var bytes = Encoding.Unicode.GetBytes(text + "\0");
var hGlobal = ClipboardNative.GlobalAlloc(ClipboardNative.GMEM_MOVEABLE, (UIntPtr)bytes.Length);
if (hGlobal == IntPtr.Zero) return;
var ptr = ClipboardNative.GlobalLock(hGlobal);
if (ptr == IntPtr.Zero)
{
ClipboardNative.GlobalFree(hGlobal);
return;
}
try
{
Marshal.Copy(bytes, 0, ptr, bytes.Length);
}
finally
{
ClipboardNative.GlobalUnlock(hGlobal);
}
ClipboardNative.SetClipboardData(ClipboardNative.CF_UNICODETEXT, hGlobal);
}
finally
{
ClipboardNative.CloseClipboard();
}
}
}
internal static partial class ClipboardNative
{
public const uint CF_UNICODETEXT = 13;
public const uint GMEM_MOVEABLE = 0x0002;
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool OpenClipboard(IntPtr hWndNewOwner);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool CloseClipboard();
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool EmptyClipboard();
[LibraryImport("user32.dll")]
public static partial IntPtr GetClipboardData(uint uFormat);
[LibraryImport("user32.dll")]
public static partial IntPtr SetClipboardData(uint uFormat, IntPtr hMem);
[LibraryImport("kernel32.dll")]
public static partial IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes);
[LibraryImport("kernel32.dll")]
public static partial IntPtr GlobalLock(IntPtr hMem);
[LibraryImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GlobalUnlock(IntPtr hMem);
[LibraryImport("kernel32.dll")]
public static partial IntPtr GlobalFree(IntPtr hMem);
}

View file

@ -0,0 +1,77 @@
using Poe2Trade.Core;
using Serilog;
namespace Poe2Trade.Game;
public class GameController
{
private readonly WindowManager _windowManager;
private readonly InputSender _input;
public GameController(AppConfig config)
{
_windowManager = new WindowManager(config.Poe2WindowTitle);
_input = new InputSender();
}
public async Task<bool> FocusGame()
{
var result = _windowManager.FocusWindow();
if (result)
await Helpers.Sleep(300);
return result;
}
public bool IsGameFocused() => _windowManager.IsGameFocused();
public RECT? GetWindowRect() => _windowManager.GetWindowRect();
public async Task SendChat(string message)
{
Log.Information("Sending chat message: {Message}", message);
await _input.PressKey(InputSender.VK.RETURN);
await Helpers.RandomDelay(100, 200);
await _input.SelectAll();
await Helpers.Sleep(50);
await _input.PressKey(InputSender.VK.BACK);
await Helpers.Sleep(50);
await _input.TypeText(message);
await Helpers.RandomDelay(50, 100);
await _input.PressKey(InputSender.VK.RETURN);
await Helpers.Sleep(100);
}
public async Task SendChatViaPaste(string message)
{
Log.Information("Sending chat message via paste: {Message}", message);
ClipboardHelper.Write(message);
await Helpers.Sleep(50);
await _input.PressKey(InputSender.VK.RETURN);
await Helpers.RandomDelay(100, 200);
await _input.SelectAll();
await Helpers.Sleep(50);
await _input.PressKey(InputSender.VK.BACK);
await Helpers.Sleep(50);
await _input.Paste();
await Helpers.RandomDelay(100, 200);
await _input.PressKey(InputSender.VK.RETURN);
await Helpers.Sleep(100);
}
public Task GoToHideout()
{
Log.Information("Sending /hideout command");
return SendChatViaPaste("/hideout");
}
public Task CtrlRightClickAt(int x, int y) => _input.CtrlRightClick(x, y);
public Task MoveMouseTo(int x, int y) => _input.MoveMouse(x, y);
public void MoveMouseInstant(int x, int y) => _input.MoveMouseInstant(x, y);
public Task MoveMouseFast(int x, int y) => _input.MoveMouseFast(x, y);
public Task LeftClickAt(int x, int y) => _input.LeftClick(x, y);
public Task RightClickAt(int x, int y) => _input.RightClick(x, y);
public Task PressEscape() => _input.PressKey(InputSender.VK.ESCAPE);
public Task OpenInventory() => _input.PressKey(InputSender.VK.I);
public Task CtrlLeftClickAt(int x, int y) => _input.CtrlLeftClick(x, y);
public Task HoldCtrl() => _input.KeyDown(InputSender.VK.CONTROL);
public Task ReleaseCtrl() => _input.KeyUp(InputSender.VK.CONTROL);
}

View file

@ -0,0 +1,398 @@
using System.Runtime.InteropServices;
using Poe2Trade.Core;
namespace Poe2Trade.Game;
public class InputSender
{
private readonly int _screenWidth;
private readonly int _screenHeight;
private static readonly Random Rng = new();
public InputSender()
{
_screenWidth = InputNative.GetSystemMetrics(InputNative.SM_CXSCREEN);
_screenHeight = InputNative.GetSystemMetrics(InputNative.SM_CYSCREEN);
}
// Virtual key codes
public static class VK
{
public const int RETURN = 0x0D;
public const int CONTROL = 0x11;
public const int MENU = 0x12;
public const int SHIFT = 0x10;
public const int ESCAPE = 0x1B;
public const int TAB = 0x09;
public const int SPACE = 0x20;
public const int DELETE = 0x2E;
public const int BACK = 0x08;
public const int V = 0x56;
public const int A = 0x41;
public const int C = 0x43;
public const int I = 0x49;
}
public async Task PressKey(int vkCode)
{
var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0);
SendScanKeyDown(scanCode);
await Helpers.RandomDelay(30, 50);
SendScanKeyUp(scanCode);
await Helpers.RandomDelay(20, 40);
}
public async Task KeyDown(int vkCode)
{
var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0);
SendScanKeyDown(scanCode);
await Helpers.RandomDelay(15, 30);
}
public async Task KeyUp(int vkCode)
{
var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0);
SendScanKeyUp(scanCode);
await Helpers.RandomDelay(15, 30);
}
public async Task TypeText(string text)
{
foreach (var ch in text)
{
SendUnicodeChar(ch);
await Helpers.RandomDelay(20, 50);
}
}
public async Task Paste()
{
await KeyDown(VK.CONTROL);
await Helpers.Sleep(30);
await PressKey(VK.V);
await KeyUp(VK.CONTROL);
await Helpers.Sleep(50);
}
public async Task SelectAll()
{
await KeyDown(VK.CONTROL);
await Helpers.Sleep(30);
await PressKey(VK.A);
await KeyUp(VK.CONTROL);
await Helpers.Sleep(50);
}
public (int X, int Y) GetCursorPos()
{
InputNative.GetCursorPos(out var pt);
return (pt.X, pt.Y);
}
private void MoveMouseRaw(int x, int y)
{
var normalizedX = (int)Math.Round((double)x * 65535 / _screenWidth);
var normalizedY = (int)Math.Round((double)y * 65535 / _screenHeight);
SendMouseInput(normalizedX, normalizedY, 0,
InputNative.MOUSEEVENTF_MOVE | InputNative.MOUSEEVENTF_ABSOLUTE);
}
public async Task MoveMouse(int x, int y)
{
var (sx, sy) = GetCursorPos();
var dx = x - sx;
var dy = y - sy;
var distance = Math.Sqrt(dx * dx + dy * dy);
if (distance < 10)
{
MoveMouseRaw(x, y);
await Helpers.RandomDelay(10, 20);
return;
}
var perpX = -dy / distance;
var perpY = dx / distance;
var spread = distance * 0.3;
var cp1X = sx + dx * 0.25 + perpX * (Rng.NextDouble() - 0.5) * spread;
var cp1Y = sy + dy * 0.25 + perpY * (Rng.NextDouble() - 0.5) * spread;
var cp2X = sx + dx * 0.75 + perpX * (Rng.NextDouble() - 0.5) * spread;
var cp2Y = sy + dy * 0.75 + perpY * (Rng.NextDouble() - 0.5) * spread;
var steps = Math.Clamp((int)Math.Round(distance / 30), 8, 20);
for (var i = 1; i <= steps; i++)
{
var rawT = (double)i / steps;
var t = EaseInOutQuad(rawT);
var (px, py) = CubicBezier(t, sx, sy, cp1X, cp1Y, cp2X, cp2Y, x, y);
var jitterX = i < steps ? (int)Math.Round((Rng.NextDouble() - 0.5) * 2) : 0;
var jitterY = i < steps ? (int)Math.Round((Rng.NextDouble() - 0.5) * 2) : 0;
MoveMouseRaw((int)Math.Round(px) + jitterX, (int)Math.Round(py) + jitterY);
await Task.Delay(1 + Rng.Next(2));
}
MoveMouseRaw(x, y);
await Helpers.RandomDelay(5, 15);
}
public void MoveMouseInstant(int x, int y) => MoveMouseRaw(x, y);
public async Task MoveMouseFast(int x, int y)
{
var (sx, sy) = GetCursorPos();
var dx = x - sx;
var dy = y - sy;
var distance = Math.Sqrt(dx * dx + dy * dy);
if (distance < 10)
{
MoveMouseRaw(x, y);
return;
}
var perpX = -dy / distance;
var perpY = dx / distance;
var spread = distance * 0.15;
var cp1X = sx + dx * 0.3 + perpX * (Rng.NextDouble() - 0.5) * spread;
var cp1Y = sy + dy * 0.3 + perpY * (Rng.NextDouble() - 0.5) * spread;
var cp2X = sx + dx * 0.7 + perpX * (Rng.NextDouble() - 0.5) * spread;
var cp2Y = sy + dy * 0.7 + perpY * (Rng.NextDouble() - 0.5) * spread;
for (var i = 1; i <= 5; i++)
{
var t = EaseInOutQuad((double)i / 5);
var (px, py) = CubicBezier(t, sx, sy, cp1X, cp1Y, cp2X, cp2Y, x, y);
MoveMouseRaw((int)Math.Round(px), (int)Math.Round(py));
await Task.Delay(2);
}
MoveMouseRaw(x, y);
}
public async Task LeftClick(int x, int y)
{
await MoveMouse(x, y);
await Helpers.RandomDelay(20, 50);
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_LEFTDOWN);
await Helpers.RandomDelay(15, 40);
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_LEFTUP);
await Helpers.RandomDelay(15, 30);
}
public async Task RightClick(int x, int y)
{
await MoveMouse(x, y);
await Helpers.RandomDelay(20, 50);
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_RIGHTDOWN);
await Helpers.RandomDelay(15, 40);
SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_RIGHTUP);
await Helpers.RandomDelay(15, 30);
}
public async Task CtrlRightClick(int x, int y)
{
await KeyDown(VK.CONTROL);
await Helpers.RandomDelay(30, 60);
await RightClick(x, y);
await KeyUp(VK.CONTROL);
await Helpers.RandomDelay(30, 60);
}
public async Task CtrlLeftClick(int x, int y)
{
await KeyDown(VK.CONTROL);
await Helpers.RandomDelay(30, 60);
await LeftClick(x, y);
await KeyUp(VK.CONTROL);
await Helpers.RandomDelay(30, 60);
}
// -- Private helpers --
private void SendMouseInput(int dx, int dy, int mouseData, uint flags)
{
var input = new InputNative.INPUT
{
type = InputNative.INPUT_MOUSE,
u = new InputNative.InputUnion
{
mi = new InputNative.MOUSEINPUT
{
dx = dx, dy = dy,
mouseData = mouseData,
dwFlags = flags,
time = 0,
dwExtraInfo = UIntPtr.Zero
}
}
};
InputNative.SendInput(1, [input], Marshal.SizeOf<InputNative.INPUT>());
}
private void SendScanKeyDown(uint scanCode)
{
var input = new InputNative.INPUT
{
type = InputNative.INPUT_KEYBOARD,
u = new InputNative.InputUnion
{
ki = new InputNative.KEYBDINPUT
{
wVk = 0,
wScan = (ushort)scanCode,
dwFlags = InputNative.KEYEVENTF_SCANCODE,
time = 0,
dwExtraInfo = UIntPtr.Zero
}
}
};
InputNative.SendInput(1, [input], Marshal.SizeOf<InputNative.INPUT>());
}
private void SendScanKeyUp(uint scanCode)
{
var input = new InputNative.INPUT
{
type = InputNative.INPUT_KEYBOARD,
u = new InputNative.InputUnion
{
ki = new InputNative.KEYBDINPUT
{
wVk = 0,
wScan = (ushort)scanCode,
dwFlags = InputNative.KEYEVENTF_SCANCODE | InputNative.KEYEVENTF_KEYUP,
time = 0,
dwExtraInfo = UIntPtr.Zero
}
}
};
InputNative.SendInput(1, [input], Marshal.SizeOf<InputNative.INPUT>());
}
private void SendUnicodeChar(char ch)
{
var code = (ushort)ch;
var down = new InputNative.INPUT
{
type = InputNative.INPUT_KEYBOARD,
u = new InputNative.InputUnion
{
ki = new InputNative.KEYBDINPUT
{
wVk = 0, wScan = code,
dwFlags = InputNative.KEYEVENTF_UNICODE,
time = 0, dwExtraInfo = UIntPtr.Zero
}
}
};
var up = new InputNative.INPUT
{
type = InputNative.INPUT_KEYBOARD,
u = new InputNative.InputUnion
{
ki = new InputNative.KEYBDINPUT
{
wVk = 0, wScan = code,
dwFlags = InputNative.KEYEVENTF_UNICODE | InputNative.KEYEVENTF_KEYUP,
time = 0, dwExtraInfo = UIntPtr.Zero
}
}
};
InputNative.SendInput(1, [down], Marshal.SizeOf<InputNative.INPUT>());
InputNative.SendInput(1, [up], Marshal.SizeOf<InputNative.INPUT>());
}
private static double EaseInOutQuad(double t) =>
t < 0.5 ? 2 * t * t : 1 - Math.Pow(-2 * t + 2, 2) / 2;
private static (double X, double Y) CubicBezier(double t,
double p0x, double p0y, double p1x, double p1y,
double p2x, double p2y, double p3x, double p3y)
{
var u = 1 - t;
var u2 = u * u;
var u3 = u2 * u;
var t2 = t * t;
var t3 = t2 * t;
return (
u3 * p0x + 3 * u2 * t * p1x + 3 * u * t2 * p2x + t3 * p3x,
u3 * p0y + 3 * u2 * t * p1y + 3 * u * t2 * p2y + t3 * p3y
);
}
}
internal static partial class InputNative
{
public const uint INPUT_MOUSE = 0;
public const uint INPUT_KEYBOARD = 1;
public const uint KEYEVENTF_SCANCODE = 0x0008;
public const uint KEYEVENTF_KEYUP = 0x0002;
public const uint KEYEVENTF_UNICODE = 0x0004;
public const uint MOUSEEVENTF_MOVE = 0x0001;
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
public const uint MOUSEEVENTF_LEFTUP = 0x0004;
public const uint MOUSEEVENTF_RIGHTDOWN = 0x0008;
public const uint MOUSEEVENTF_RIGHTUP = 0x0010;
public const uint MOUSEEVENTF_ABSOLUTE = 0x8000;
public const int SM_CXSCREEN = 0;
public const int SM_CYSCREEN = 1;
[StructLayout(LayoutKind.Sequential)]
public struct MOUSEINPUT
{
public int dx;
public int dy;
public int mouseData;
public uint dwFlags;
public uint time;
public UIntPtr dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
public struct KEYBDINPUT
{
public ushort wVk;
public ushort wScan;
public uint dwFlags;
public uint time;
public UIntPtr dwExtraInfo;
}
[StructLayout(LayoutKind.Explicit)]
public struct InputUnion
{
[FieldOffset(0)] public MOUSEINPUT mi;
[FieldOffset(0)] public KEYBDINPUT ki;
}
[StructLayout(LayoutKind.Sequential)]
public struct INPUT
{
public uint type;
public InputUnion u;
}
[StructLayout(LayoutKind.Sequential)]
public struct POINT
{
public int X;
public int Y;
}
[LibraryImport("user32.dll")]
public static partial uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[LibraryImport("user32.dll")]
public static partial uint MapVirtualKeyW(uint uCode, uint uMapType);
[LibraryImport("user32.dll")]
public static partial int GetSystemMetrics(int nIndex);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GetCursorPos(out POINT lpPoint);
}

View file

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,113 @@
using System.Runtime.InteropServices;
using Serilog;
namespace Poe2Trade.Game;
public class WindowManager
{
private IntPtr _hwnd = IntPtr.Zero;
private readonly string _windowTitle;
public WindowManager(string windowTitle)
{
_windowTitle = windowTitle;
}
public IntPtr FindWindow()
{
_hwnd = NativeMethods.FindWindowW(null, _windowTitle);
if (_hwnd == IntPtr.Zero)
Log.Warning("Window not found: {Title}", _windowTitle);
else
Log.Information("Window found: {Title} hwnd={Hwnd}", _windowTitle, _hwnd);
return _hwnd;
}
public bool FocusWindow()
{
if (_hwnd == IntPtr.Zero || !NativeMethods.IsWindow(_hwnd))
FindWindow();
if (_hwnd == IntPtr.Zero) return false;
// Restore if minimized
NativeMethods.ShowWindow(_hwnd, NativeMethods.SW_RESTORE);
// Alt-key trick to bypass SetForegroundWindow restriction
var altScan = NativeMethods.MapVirtualKeyW(NativeMethods.VK_MENU, 0);
NativeMethods.keybd_event(NativeMethods.VK_MENU, (byte)altScan, 0, UIntPtr.Zero);
NativeMethods.keybd_event(NativeMethods.VK_MENU, (byte)altScan, NativeMethods.KEYEVENTF_KEYUP, UIntPtr.Zero);
NativeMethods.BringWindowToTop(_hwnd);
var result = NativeMethods.SetForegroundWindow(_hwnd);
if (!result)
Log.Warning("SetForegroundWindow failed");
return result;
}
public RECT? GetWindowRect()
{
if (_hwnd == IntPtr.Zero || !NativeMethods.IsWindow(_hwnd))
FindWindow();
if (_hwnd == IntPtr.Zero) return null;
if (NativeMethods.GetWindowRect(_hwnd, out var rect))
return rect;
return null;
}
public bool IsGameFocused()
{
var fg = NativeMethods.GetForegroundWindow();
return fg == _hwnd && _hwnd != IntPtr.Zero;
}
public IntPtr Hwnd => _hwnd;
}
[StructLayout(LayoutKind.Sequential)]
public struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
internal static partial class NativeMethods
{
public const int SW_RESTORE = 9;
public const byte VK_MENU = 0x12;
public const uint KEYEVENTF_KEYUP = 0x0002;
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)]
public static partial IntPtr FindWindowW(string? lpClassName, string lpWindowName);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool SetForegroundWindow(IntPtr hWnd);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool ShowWindow(IntPtr hWnd, int nCmdShow);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool BringWindowToTop(IntPtr hWnd);
[LibraryImport("user32.dll")]
public static partial IntPtr GetForegroundWindow();
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool IsWindow(IntPtr hWnd);
[LibraryImport("user32.dll")]
public static partial void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
[LibraryImport("user32.dll")]
public static partial uint MapVirtualKeyW(uint uCode, uint uMapType);
}

View file

@ -0,0 +1,260 @@
using Poe2Trade.Core;
using Poe2Trade.Game;
using Poe2Trade.GameLog;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Inventory;
public class InventoryManager
{
private static readonly string SalvageTemplate = Path.Combine("assets", "salvage.png");
public InventoryTracker Tracker { get; } = new();
private bool _atOwnHideout = true;
private string _sellerAccount = "";
private readonly GameController _game;
private readonly ScreenReader _screen;
private readonly ClientLogWatcher _logWatcher;
private readonly AppConfig _config;
public bool IsAtOwnHideout => _atOwnHideout;
public string SellerAccount => _sellerAccount;
public InventoryManager(GameController game, ScreenReader screen, ClientLogWatcher logWatcher, AppConfig config)
{
_game = game;
_screen = screen;
_logWatcher = logWatcher;
_config = config;
}
public void SetLocation(bool atHome, string? seller = null)
{
_atOwnHideout = atHome;
_sellerAccount = seller ?? "";
}
public async Task ScanInventory(PostAction defaultAction = PostAction.Stash)
{
Log.Information("Scanning inventory...");
await _game.FocusGame();
await Helpers.Sleep(300);
await _game.OpenInventory();
var result = await _screen.Grid.Scan("inventory");
var cells = new bool[5, 12];
foreach (var cell in result.Occupied)
{
if (cell.Row < 5 && cell.Col < 12)
cells[cell.Row, cell.Col] = true;
}
Tracker.InitFromScan(cells, result.Items, defaultAction);
await _game.PressEscape();
await Helpers.Sleep(300);
}
public async Task ClearToStash()
{
Log.Information("Checking inventory for leftover items...");
await ScanInventory(PostAction.Stash);
if (Tracker.GetItems().Count == 0)
{
Log.Information("Inventory empty, nothing to clear");
return;
}
Log.Information("Found {Count} leftover items, depositing to stash", Tracker.GetItems().Count);
await DepositItemsToStash(Tracker.GetItems());
Tracker.Clear();
Log.Information("Inventory cleared to stash");
}
public async Task<bool> EnsureAtOwnHideout()
{
if (_atOwnHideout)
{
Log.Information("Already at own hideout");
return true;
}
await _game.FocusGame();
await Helpers.Sleep(300);
var arrived = await WaitForAreaTransition(_config.TravelTimeoutMs, () => _game.GoToHideout());
if (!arrived)
{
Log.Error("Timed out going to own hideout");
return false;
}
await Helpers.Sleep(1500);
_atOwnHideout = true;
_sellerAccount = "";
return true;
}
public async Task DepositItemsToStash(List<PlacedItem> items)
{
if (items.Count == 0) return;
var stashPos = await FindAndClickNameplate("Stash");
if (stashPos == null)
{
Log.Error("Could not find Stash nameplate");
return;
}
await Helpers.Sleep(1000);
var inventoryLayout = GridLayouts.Inventory;
Log.Information("Depositing {Count} items to stash", items.Count);
await _game.HoldCtrl();
foreach (var item in items)
{
var center = _screen.Grid.GetCellCenter(inventoryLayout, item.Row, item.Col);
await _game.LeftClickAt(center.X, center.Y);
await Helpers.Sleep(150);
}
await _game.ReleaseCtrl();
await Helpers.Sleep(500);
await _game.PressEscape();
await Helpers.Sleep(500);
Log.Information("Items deposited to stash");
}
public async Task<bool> SalvageItems(List<PlacedItem> items)
{
if (items.Count == 0) return true;
var nameplate = await FindAndClickNameplate("SALVAGE BENCH");
if (nameplate == null)
{
Log.Error("Could not find Salvage nameplate");
return false;
}
await Helpers.Sleep(1000);
var salvageBtn = await _screen.TemplateMatch(SalvageTemplate);
if (salvageBtn != null)
{
await _game.LeftClickAt(salvageBtn.X, salvageBtn.Y);
await Helpers.Sleep(500);
}
else
{
Log.Warning("Could not find salvage button via template match");
}
var inventoryLayout = GridLayouts.Inventory;
Log.Information("Salvaging {Count} inventory items", items.Count);
await _game.HoldCtrl();
foreach (var item in items)
{
var center = _screen.Grid.GetCellCenter(inventoryLayout, item.Row, item.Col);
await _game.LeftClickAt(center.X, center.Y);
await Helpers.Sleep(150);
}
await _game.ReleaseCtrl();
await Helpers.Sleep(500);
await _game.PressEscape();
await Helpers.Sleep(500);
return true;
}
public async Task ProcessInventory()
{
try
{
var home = await EnsureAtOwnHideout();
if (!home)
{
Log.Error("Cannot process inventory: failed to reach hideout");
return;
}
if (Tracker.HasItemsWithAction(PostAction.Salvage))
{
var salvageItems = Tracker.GetItemsByAction(PostAction.Salvage);
if (await SalvageItems(salvageItems))
Tracker.RemoveItemsByAction(PostAction.Salvage);
else
Log.Warning("Salvage failed, depositing all to stash");
}
await ScanInventory(PostAction.Stash);
var allItems = Tracker.GetItems();
if (allItems.Count > 0)
await DepositItemsToStash(allItems);
Tracker.Clear();
Log.Information("Inventory processing complete");
}
catch (Exception ex)
{
Log.Error(ex, "Inventory processing failed");
try { await _game.PressEscape(); await Helpers.Sleep(300); } catch { }
Tracker.Clear();
}
}
public async Task<(int X, int Y)?> FindAndClickNameplate(string name, int maxRetries = 3, int retryDelayMs = 1000)
{
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);
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;
}
if (attempt < maxRetries)
await Helpers.Sleep(retryDelayMs);
}
Log.Warning("Nameplate '{Name}' not found after {Max} retries", name, maxRetries);
return null;
}
public async Task<bool> WaitForAreaTransition(int timeoutMs, Func<Task>? triggerAction = null)
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using var cts = new CancellationTokenSource(timeoutMs);
cts.Token.Register(() => tcs.TrySetResult(false));
void Handler(string _) => tcs.TrySetResult(true);
_logWatcher.AreaEntered += Handler;
try
{
if (triggerAction != null)
{
try { await triggerAction(); }
catch
{
tcs.TrySetResult(false);
}
}
return await tcs.Task;
}
finally
{
_logWatcher.AreaEntered -= Handler;
}
}
public (bool[,] Grid, List<PlacedItem> Items, int Free) GetInventoryState()
{
return (Tracker.GetGrid(), Tracker.GetItems(), Tracker.FreeCells);
}
}

View file

@ -0,0 +1,126 @@
using Poe2Trade.Core;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Inventory;
public class PlacedItem
{
public int Row { get; init; }
public int Col { get; init; }
public int W { get; init; }
public int H { get; init; }
public PostAction PostAction { get; init; }
}
public class InventoryTracker
{
private const int Rows = 5;
private const int Cols = 12;
private readonly bool[,] _grid = new bool[Rows, Cols];
private readonly List<PlacedItem> _items = [];
public void InitFromScan(bool[,] cells, List<GridItem> items, PostAction defaultAction = PostAction.Stash)
{
Array.Clear(_grid);
_items.Clear();
var rowCount = Math.Min(cells.GetLength(0), Rows);
var colCount = Math.Min(cells.GetLength(1), Cols);
for (var r = 0; r < rowCount; r++)
for (var c = 0; c < colCount; c++)
_grid[r, c] = cells[r, c];
foreach (var item in items)
{
if (item.W > 2 || item.H > 4)
{
Log.Warning("Ignoring oversized item at ({Row},{Col}) {W}x{H}", item.Row, item.Col, item.W, item.H);
continue;
}
_items.Add(new PlacedItem { Row = item.Row, Col = item.Col, W = item.W, H = item.H, PostAction = defaultAction });
}
Log.Information("Inventory initialized: {Occupied} occupied, {Items} items, {Free} free",
Rows * Cols - FreeCells, _items.Count, FreeCells);
}
public (int Row, int Col)? TryPlace(int w, int h, PostAction postAction = PostAction.Stash)
{
for (var col = 0; col <= Cols - w; col++)
for (var row = 0; row <= Rows - h; row++)
{
if (!Fits(row, col, w, h)) continue;
Place(row, col, w, h, postAction);
Log.Information("Item placed at ({Row},{Col}) {W}x{H} free={Free}", row, col, w, h, FreeCells);
return (row, col);
}
return null;
}
public bool CanFit(int w, int h)
{
for (var col = 0; col <= Cols - w; col++)
for (var row = 0; row <= Rows - h; row++)
if (Fits(row, col, w, h)) return true;
return false;
}
public List<PlacedItem> GetItems() => [.. _items];
public List<PlacedItem> GetItemsByAction(PostAction action) => _items.Where(i => i.PostAction == action).ToList();
public bool HasItemsWithAction(PostAction action) => _items.Any(i => i.PostAction == action);
public void RemoveItem(PlacedItem item)
{
if (!_items.Remove(item)) return;
for (var r = item.Row; r < item.Row + item.H; r++)
for (var c = item.Col; c < item.Col + item.W; c++)
_grid[r, c] = false;
}
public void RemoveItemsByAction(PostAction action)
{
var toRemove = _items.Where(i => i.PostAction == action).ToList();
foreach (var item in toRemove)
RemoveItem(item);
Log.Information("Removed {Count} items with action {Action}", toRemove.Count, action);
}
public bool[,] GetGrid() => (bool[,])_grid.Clone();
public void Clear()
{
Array.Clear(_grid);
_items.Clear();
Log.Information("Inventory cleared");
}
public int FreeCells
{
get
{
var count = 0;
for (var r = 0; r < Rows; r++)
for (var c = 0; c < Cols; c++)
if (!_grid[r, c]) count++;
return count;
}
}
private bool Fits(int row, int col, int w, int h)
{
for (var r = row; r < row + h; r++)
for (var c = col; c < col + w; c++)
if (_grid[r, c]) return false;
return true;
}
private void Place(int row, int col, int w, int h, PostAction postAction)
{
for (var r = row; r < row + h; r++)
for (var c = col; c < col + w; c++)
_grid[r, c] = true;
_items.Add(new PlacedItem { Row = row, Col = col, W = w, H = h, PostAction = postAction });
}
}

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
<ProjectReference Include="..\Poe2Trade.Game\Poe2Trade.Game.csproj" />
<ProjectReference Include="..\Poe2Trade.Screen\Poe2Trade.Screen.csproj" />
<ProjectReference Include="..\Poe2Trade.Log\Poe2Trade.Log.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,59 @@
using Poe2Trade.Core;
using Poe2Trade.Game;
using Serilog;
namespace Poe2Trade.Items;
/// <summary>
/// Reads item data by hovering and pressing Ctrl+C to copy item text to clipboard.
/// Will be wired up to Sidekick's ItemParser once the submodule is added.
/// </summary>
public class ItemReader
{
private readonly GameController _game;
public ItemReader(GameController game)
{
_game = game;
}
/// <summary>
/// Hover over an item at (x, y), press Ctrl+C, and read clipboard text.
/// </summary>
public async Task<string?> ReadItemText(int x, int y)
{
// Move mouse to item position
await _game.MoveMouseTo(x, y);
await Helpers.Sleep(100);
// Ctrl+C to copy item text
ClipboardHelper.Write(""); // Clear clipboard
await Helpers.Sleep(50);
// Press Ctrl+C
var input = new InputSender();
await input.KeyDown(InputSender.VK.CONTROL);
await Helpers.Sleep(30);
await input.PressKey(InputSender.VK.C);
await input.KeyUp(InputSender.VK.CONTROL);
await Helpers.Sleep(100);
var text = ClipboardHelper.Read();
if (string.IsNullOrWhiteSpace(text))
{
Log.Warning("No item text in clipboard after Ctrl+C at ({X},{Y})", x, y);
return null;
}
Log.Information("Read item text ({Length} chars) from ({X},{Y})", text.Length, x, y);
return text;
}
// TODO: Wire up Sidekick's ItemParser
// public async Task<ParsedItem?> ParseItem(int x, int y)
// {
// var text = await ReadItemText(x, y);
// if (text == null) return null;
// return SidekickItemParser.Parse(text);
// }
}

View file

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
<ProjectReference Include="..\Poe2Trade.Game\Poe2Trade.Game.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,171 @@
using System.Text.Json.Serialization;
namespace Poe2Trade.Screen;
public class OcrWord
{
[JsonPropertyName("text")] public string Text { get; set; } = "";
[JsonPropertyName("x")] public int X { get; set; }
[JsonPropertyName("y")] public int Y { get; set; }
[JsonPropertyName("width")] public int Width { get; set; }
[JsonPropertyName("height")] public int Height { get; set; }
}
public class OcrLine
{
[JsonPropertyName("text")] public string Text { get; set; } = "";
[JsonPropertyName("words")] public List<OcrWord> Words { get; set; } = [];
}
public class OcrResponse
{
public string Text { get; set; } = "";
public List<OcrLine> Lines { get; set; } = [];
}
public class GridItem
{
[JsonPropertyName("row")] public int Row { get; set; }
[JsonPropertyName("col")] public int Col { get; set; }
[JsonPropertyName("w")] public int W { get; set; }
[JsonPropertyName("h")] public int H { get; set; }
}
public class GridMatch
{
[JsonPropertyName("row")] public int Row { get; set; }
[JsonPropertyName("col")] public int Col { get; set; }
[JsonPropertyName("similarity")] public double Similarity { get; set; }
}
public class GridScanResult
{
public bool[][] Cells { get; set; } = [];
public List<GridItem> Items { get; set; } = [];
public List<GridMatch>? Matches { get; set; }
}
public class DiffOcrResponse
{
public string Text { get; set; } = "";
public List<OcrLine> Lines { get; set; } = [];
public Poe2Trade.Core.Region? Region { get; set; }
}
public class TemplateMatchResult
{
public int X { get; set; }
public int Y { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public double Confidence { get; set; }
}
// -- Parameter types --
public sealed class DiffCropParams
{
[JsonPropertyName("diffThresh")]
public int DiffThresh { get; set; } = 20;
[JsonPropertyName("rowThreshDiv")]
public int RowThreshDiv { get; set; } = 40;
[JsonPropertyName("colThreshDiv")]
public int ColThreshDiv { get; set; } = 8;
[JsonPropertyName("maxGap")]
public int MaxGap { get; set; } = 20;
[JsonPropertyName("trimCutoff")]
public double TrimCutoff { get; set; } = 0.4;
[JsonPropertyName("ocrPad")]
public int OcrPad { get; set; } = 10;
}
public sealed class OcrParams
{
// preprocessing
[JsonPropertyName("kernelSize")]
public int KernelSize { get; set; } = 41;
[JsonPropertyName("upscale")]
public int Upscale { get; set; } = 2;
[JsonPropertyName("useBackgroundSub")]
public bool UseBackgroundSub { get; set; } = true;
[JsonPropertyName("dimPercentile")]
public int DimPercentile { get; set; } = 40;
[JsonPropertyName("textThresh")]
public int TextThresh { get; set; } = 60;
[JsonPropertyName("softThreshold")]
public bool SoftThreshold { get; set; } = false;
// EasyOCR tuning
[JsonPropertyName("mergeGap")]
public int MergeGap { get; set; } = 0;
[JsonPropertyName("linkThreshold")]
public double? LinkThreshold { get; set; }
[JsonPropertyName("textThreshold")]
public double? TextThreshold { get; set; }
[JsonPropertyName("lowText")]
public double? LowText { get; set; }
[JsonPropertyName("widthThs")]
public double? WidthThs { get; set; }
[JsonPropertyName("paragraph")]
public bool? Paragraph { get; set; }
}
public sealed class DiffOcrParams
{
[JsonPropertyName("crop")]
public DiffCropParams Crop { get; set; } = new();
[JsonPropertyName("ocr")]
public OcrParams Ocr { get; set; } = new();
}
public sealed class EdgeCropParams
{
[JsonPropertyName("darkThresh")]
public int DarkThresh { get; set; } = 40;
[JsonPropertyName("minDarkRun")]
public int MinDarkRun { get; set; } = 200;
[JsonPropertyName("runGapTolerance")]
public int RunGapTolerance { get; set; } = 15;
[JsonPropertyName("rowThreshDiv")]
public int RowThreshDiv { get; set; } = 40;
[JsonPropertyName("colThreshDiv")]
public int ColThreshDiv { get; set; } = 8;
[JsonPropertyName("maxGap")]
public int MaxGap { get; set; } = 15;
[JsonPropertyName("trimCutoff")]
public double TrimCutoff { get; set; } = 0.3;
[JsonPropertyName("ocrPad")]
public int OcrPad { get; set; } = 10;
}
public sealed class EdgeOcrParams
{
[JsonPropertyName("crop")]
public EdgeCropParams Crop { get; set; } = new();
[JsonPropertyName("ocr")]
public OcrParams Ocr { get; set; } = new();
}

View file

@ -0,0 +1,157 @@
namespace Poe2Trade.Screen;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using Serilog;
using Region = Poe2Trade.Core.Region;
class DetectGridHandler
{
public DetectGridResult Detect(Region region, int minCellSize = 20, int maxCellSize = 70,
string? file = null, bool debug = false)
{
Bitmap bitmap = ScreenCapture.CaptureOrLoad(file, region);
int w = bitmap.Width;
int h = bitmap.Height;
var bmpData = bitmap.LockBits(
new Rectangle(0, 0, w, h),
ImageLockMode.ReadOnly,
PixelFormat.Format32bppArgb
);
byte[] pixels = new byte[bmpData.Stride * h];
Marshal.Copy(bmpData.Scan0, pixels, 0, pixels.Length);
bitmap.UnlockBits(bmpData);
int stride = bmpData.Stride;
byte[] gray = new byte[w * h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int i = y * stride + x * 4;
gray[y * w + x] = (byte)((pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3);
}
bitmap.Dispose();
int bandH = 200;
int bandStep = 40;
const int veryDarkPixelThresh = 12;
const double gridSegThresh = 0.25;
var candidates = new List<(int bandY, int cellW, double hAc, int hLeft, int hRight)>();
for (int by = 0; by + bandH <= h; by += bandStep)
{
double[] darkDensity = new double[w];
for (int x = 0; x < w; x++)
{
int count = 0;
for (int y = by; y < by + bandH; y++)
{
if (gray[y * w + x] < veryDarkPixelThresh) count++;
}
darkDensity[x] = (double)count / bandH;
}
var gridSegs = SignalProcessing.FindDarkDensitySegments(darkDensity, gridSegThresh, 200);
foreach (var (segLeft, segRight) in gridSegs)
{
int segLen = segRight - segLeft;
double[] segment = new double[segLen];
Array.Copy(darkDensity, segLeft, segment, 0, segLen);
var (period, acScore) = SignalProcessing.FindPeriodWithScore(segment, minCellSize, maxCellSize);
if (period <= 0) continue;
var (extLeft, extRight) = SignalProcessing.FindGridExtent(segment, period);
if (extLeft < 0) continue;
int absLeft = segLeft + extLeft;
int absRight = segLeft + extRight;
int extent = absRight - absLeft;
if (extent < period * 8 || extent < 200) continue;
candidates.Add((by, period, acScore, absLeft, absRight));
}
}
candidates.Sort((a, b) =>
{
double sa = a.hAc * (a.hRight - a.hLeft);
double sb = b.hAc * (b.hRight - b.hLeft);
return sb.CompareTo(sa);
});
// Pass 2: Verify vertical periodicity
foreach (var cand in candidates.Take(10))
{
int colSpan = cand.hRight - cand.hLeft;
if (colSpan < cand.cellW * 3) continue;
double[] rowDensity = new double[h];
for (int y = 0; y < h; y++)
{
int count = 0;
for (int x = cand.hLeft; x < cand.hRight; x++)
{
if (gray[y * w + x] < veryDarkPixelThresh) count++;
}
rowDensity[y] = (double)count / colSpan;
}
var vGridSegs = SignalProcessing.FindDarkDensitySegments(rowDensity, gridSegThresh, 100);
if (vGridSegs.Count == 0) continue;
var (vSegTop, vSegBottom) = vGridSegs.OrderByDescending(s => s.end - s.start).First();
int vSegLen = vSegBottom - vSegTop;
double[] vSegment = new double[vSegLen];
Array.Copy(rowDensity, vSegTop, vSegment, 0, vSegLen);
var (cellH, vAc) = SignalProcessing.FindPeriodWithScore(vSegment, minCellSize, maxCellSize);
if (cellH <= 0) continue;
var (extTop, extBottom) = SignalProcessing.FindGridExtent(vSegment, cellH);
if (extTop < 0) continue;
int top = vSegTop + extTop;
int bottom = vSegTop + extBottom;
int vExtent = bottom - top;
if (vExtent < cellH * 3 || vExtent < 100) continue;
int gridW = cand.hRight - cand.hLeft;
int gridH = bottom - top;
int cols = Math.Max(2, (int)Math.Round((double)gridW / cand.cellW));
int rows = Math.Max(2, (int)Math.Round((double)gridH / cellH));
gridW = cols * cand.cellW;
gridH = rows * cellH;
return new DetectGridResult
{
Detected = true,
Region = new Region(region.X + cand.hLeft, region.Y + top, gridW, gridH),
Cols = cols,
Rows = rows,
CellWidth = Math.Round((double)gridW / cols, 1),
CellHeight = Math.Round((double)gridH / rows, 1),
};
}
return new DetectGridResult { Detected = false };
}
}
public class DetectGridResult
{
public bool Detected { get; set; }
public Region? Region { get; set; }
public int Cols { get; set; }
public int Rows { get; set; }
public double CellWidth { get; set; }
public double CellHeight { get; set; }
}

View file

@ -0,0 +1,368 @@
namespace Poe2Trade.Screen;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using Serilog;
using Region = Poe2Trade.Core.Region;
class DiffCropHandler
{
private Bitmap? _referenceFrame;
private Region? _referenceRegion;
public void HandleSnapshot(string? file = null, Region? region = null)
{
_referenceFrame?.Dispose();
_referenceFrame = ScreenCapture.CaptureOrLoad(file, region);
_referenceRegion = region;
}
public void HandleScreenshot(string path, Region? region = null)
{
var bitmap = _referenceFrame ?? ScreenCapture.CaptureOrLoad(null, region);
var format = ImageUtils.GetImageFormat(path);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
bitmap.Save(path, format);
if (bitmap != _referenceFrame) bitmap.Dispose();
}
public byte[] HandleCapture(Region? region = null)
{
using var bitmap = ScreenCapture.CaptureOrLoad(null, region);
using var ms = new MemoryStream();
bitmap.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
return ms.ToArray();
}
/// <summary>
/// Diff detection + crop only. Returns the raw tooltip crop bitmap and region,
/// or null if no tooltip detected. Caller is responsible for disposing the bitmaps.
/// </summary>
public (Bitmap cropped, Bitmap refCropped, Bitmap current, Region region)? DiffCrop(
DiffCropParams c, string? file = null, Region? region = null)
{
if (_referenceFrame == null)
return null;
var diffRegion = region ?? _referenceRegion;
int baseX = diffRegion?.X ?? 0;
int baseY = diffRegion?.Y ?? 0;
var current = ScreenCapture.CaptureOrLoad(file, diffRegion);
Bitmap refForDiff = _referenceFrame;
bool disposeRef = false;
if (diffRegion != null)
{
if (_referenceRegion == null)
{
var croppedRef = CropBitmap(_referenceFrame, diffRegion);
if (croppedRef == null)
{
current.Dispose();
return null;
}
refForDiff = croppedRef;
disposeRef = true;
}
else if (!RegionsEqual(diffRegion, _referenceRegion))
{
int offX = diffRegion.X - _referenceRegion.X;
int offY = diffRegion.Y - _referenceRegion.Y;
if (offX < 0 || offY < 0 || offX + diffRegion.Width > _referenceFrame.Width || offY + diffRegion.Height > _referenceFrame.Height)
{
current.Dispose();
return null;
}
var croppedRef = CropBitmap(_referenceFrame, new Region(offX, offY, diffRegion.Width, diffRegion.Height));
if (croppedRef == null)
{
current.Dispose();
return null;
}
refForDiff = croppedRef;
disposeRef = true;
}
}
int w = Math.Min(refForDiff.Width, current.Width);
int h = Math.Min(refForDiff.Height, current.Height);
var refData = refForDiff.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
byte[] refPx = new byte[refData.Stride * h];
Marshal.Copy(refData.Scan0, refPx, 0, refPx.Length);
refForDiff.UnlockBits(refData);
int stride = refData.Stride;
var curData = current.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
byte[] curPx = new byte[curData.Stride * h];
Marshal.Copy(curData.Scan0, curPx, 0, curPx.Length);
current.UnlockBits(curData);
int diffThresh = c.DiffThresh;
// Pass 1: parallel row diff
int[] rowCounts = new int[h];
Parallel.For(0, h, y =>
{
int count = 0;
int rowOffset = y * stride;
for (int x = 0; x < w; x++)
{
int i = rowOffset + x * 4;
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
if (darker > diffThresh)
count++;
}
rowCounts[y] = count;
});
int totalChanged = 0;
for (int y = 0; y < h; y++) totalChanged += rowCounts[y];
if (totalChanged == 0)
{
current.Dispose();
if (disposeRef) refForDiff.Dispose();
return null;
}
int maxGap = c.MaxGap;
int rowThresh = w / c.RowThreshDiv;
int bestRowStart = 0, bestRowEnd = 0, bestRowLen = 0;
int curRowStart = -1, lastActiveRow = -1;
for (int y = 0; y < h; y++)
{
if (rowCounts[y] >= rowThresh)
{
if (curRowStart < 0) curRowStart = y;
lastActiveRow = y;
}
else if (curRowStart >= 0 && y - lastActiveRow > maxGap)
{
int len = lastActiveRow - curRowStart + 1;
if (len > bestRowLen) { bestRowStart = curRowStart; bestRowEnd = lastActiveRow; bestRowLen = len; }
curRowStart = -1;
}
}
if (curRowStart >= 0)
{
int len = lastActiveRow - curRowStart + 1;
if (len > bestRowLen) { bestRowStart = curRowStart; bestRowEnd = lastActiveRow; bestRowLen = len; }
}
// Pass 2: parallel column diff
int[] colCounts = new int[w];
int rowRangeLen = bestRowEnd - bestRowStart + 1;
if (rowRangeLen <= 200)
{
for (int y = bestRowStart; y <= bestRowEnd; y++)
{
int rowOffset = y * stride;
for (int x = 0; x < w; x++)
{
int i = rowOffset + x * 4;
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
if (darker > diffThresh)
colCounts[x]++;
}
}
}
else
{
Parallel.For(bestRowStart, bestRowEnd + 1,
() => new int[w],
(y, _, localCols) =>
{
int rowOffset = y * stride;
for (int x = 0; x < w; x++)
{
int i = rowOffset + x * 4;
int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]);
if (darker > diffThresh)
localCols[x]++;
}
return localCols;
},
localCols =>
{
for (int x = 0; x < w; x++)
Interlocked.Add(ref colCounts[x], localCols[x]);
});
}
int tooltipHeight = bestRowEnd - bestRowStart + 1;
int colThresh = tooltipHeight / c.ColThreshDiv;
int bestColStart = 0, bestColEnd = 0, bestColLen = 0;
int curColStart = -1, lastActiveCol = -1;
for (int x = 0; x < w; x++)
{
if (colCounts[x] >= colThresh)
{
if (curColStart < 0) curColStart = x;
lastActiveCol = x;
}
else if (curColStart >= 0 && x - lastActiveCol > maxGap)
{
int len = lastActiveCol - curColStart + 1;
if (len > bestColLen) { bestColStart = curColStart; bestColEnd = lastActiveCol; bestColLen = len; }
curColStart = -1;
}
}
if (curColStart >= 0)
{
int len = lastActiveCol - curColStart + 1;
if (len > bestColLen) { bestColStart = curColStart; bestColEnd = lastActiveCol; bestColLen = len; }
}
Log.Debug("diff-crop: changed={Changed} rows={RowStart}-{RowEnd}({RowLen}) cols={ColStart}-{ColEnd}({ColLen})",
totalChanged, bestRowStart, bestRowEnd, bestRowLen, bestColStart, bestColEnd, bestColLen);
if (bestRowLen < 50 || bestColLen < 50)
{
Log.Debug("diff-crop: no tooltip-sized region found");
current.Dispose();
if (disposeRef) refForDiff.Dispose();
return null;
}
int minX = bestColStart;
int minY = bestRowStart;
int maxX = Math.Min(bestColEnd, w - 1);
int maxY = Math.Min(bestRowEnd, h - 1);
// Boundary extension
int extRowThresh = Math.Max(1, rowThresh / 4);
int extColThresh = Math.Max(1, colThresh / 4);
int extTop = Math.Max(0, minY - maxGap);
for (int y = minY - 1; y >= extTop; y--)
{
if (rowCounts[y] >= extRowThresh) minY = y;
else break;
}
int extBottom = Math.Min(h - 1, maxY + maxGap);
for (int y = maxY + 1; y <= extBottom; y++)
{
if (rowCounts[y] >= extRowThresh) maxY = y;
else break;
}
int extLeft = Math.Max(0, minX - maxGap);
for (int x = minX - 1; x >= extLeft; x--)
{
if (colCounts[x] >= extColThresh) minX = x;
else break;
}
int extRight = Math.Min(w - 1, maxX + maxGap);
for (int x = maxX + 1; x <= extRight; x++)
{
if (colCounts[x] >= extColThresh) maxX = x;
else break;
}
// Trim low-density edges
int colSpan = maxX - minX + 1;
if (colSpan > 50)
{
int q1 = minX + colSpan / 4;
int q3 = minX + colSpan * 3 / 4;
long midSum = 0;
int midCount = 0;
for (int x = q1; x <= q3; x++) { midSum += colCounts[x]; midCount++; }
double avgMidDensity = (double)midSum / Math.Max(1, midCount);
double cutoff = avgMidDensity * c.TrimCutoff;
while (minX < maxX - 50 && colCounts[minX] < cutoff)
minX++;
while (maxX > minX + 50 && colCounts[maxX] < cutoff)
maxX--;
}
int rowSpan = maxY - minY + 1;
if (rowSpan > 50)
{
int q1 = minY + rowSpan / 4;
int q3 = minY + rowSpan * 3 / 4;
long midSum = 0;
int midCount = 0;
for (int y = q1; y <= q3; y++) { midSum += rowCounts[y]; midCount++; }
double avgMidDensity = (double)midSum / Math.Max(1, midCount);
double cutoff = avgMidDensity * c.TrimCutoff;
while (minY < maxY - 50 && rowCounts[minY] < cutoff)
minY++;
while (maxY > minY + 50 && rowCounts[maxY] < cutoff)
maxY--;
}
int rw = maxX - minX + 1;
int rh = maxY - minY + 1;
var cropped = CropFromBytes(curPx, stride, minX, minY, rw, rh);
var refCropped = CropFromBytes(refPx, stride, minX, minY, rw, rh);
var resultRegion = new Region(baseX + minX, baseY + minY, rw, rh);
Log.Debug("diff-crop: tooltip region ({X},{Y}) {W}x{H}", minX, minY, rw, rh);
if (disposeRef) refForDiff.Dispose();
return (cropped, refCropped, current, resultRegion);
}
private static bool RegionsEqual(Region a, Region b) =>
a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height;
private static Bitmap? CropBitmap(Bitmap src, Region region)
{
int cx = Math.Max(0, region.X);
int cy = Math.Max(0, region.Y);
int cw = Math.Min(region.Width, src.Width - cx);
int ch = Math.Min(region.Height, src.Height - cy);
if (cw <= 0 || ch <= 0)
return null;
return src.Clone(new Rectangle(cx, cy, cw, ch), PixelFormat.Format32bppArgb);
}
/// <summary>
/// Fast crop from raw pixel bytes.
/// </summary>
private static Bitmap CropFromBytes(byte[] px, int srcStride, int cropX, int cropY, int cropW, int cropH)
{
var bmp = new Bitmap(cropW, cropH, PixelFormat.Format32bppArgb);
var data = bmp.LockBits(new Rectangle(0, 0, cropW, cropH), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
int dstStride = data.Stride;
int rowBytes = cropW * 4;
for (int y = 0; y < cropH; y++)
{
int srcOffset = (cropY + y) * srcStride + cropX * 4;
Marshal.Copy(px, srcOffset, data.Scan0 + y * dstStride, rowBytes);
}
bmp.UnlockBits(data);
return bmp;
}
public static double LevenshteinSimilarity(string a, string b)
{
a = a.ToLowerInvariant();
b = b.ToLowerInvariant();
if (a == b) return 1.0;
int la = a.Length, lb = b.Length;
if (la == 0 || lb == 0) return 0.0;
var d = new int[la + 1, lb + 1];
for (int i = 0; i <= la; i++) d[i, 0] = i;
for (int j = 0; j <= lb; j++) d[0, j] = j;
for (int i = 1; i <= la; i++)
for (int j = 1; j <= lb; j++)
{
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost);
}
return 1.0 - (double)d[la, lb] / Math.Max(la, lb);
}
}

View file

@ -0,0 +1,243 @@
namespace Poe2Trade.Screen;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using Serilog;
using Region = Poe2Trade.Core.Region;
class EdgeCropHandler
{
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X, Y; }
[DllImport("user32.dll")]
private static extern bool GetCursorPos(out POINT lpPoint);
public (Bitmap cropped, Bitmap fullCapture, Region region)? EdgeCrop(
EdgeCropParams p, int? cursorX = null, int? cursorY = null, string? file = null)
{
int cx, cy;
if (cursorX.HasValue && cursorY.HasValue)
{
cx = cursorX.Value;
cy = cursorY.Value;
}
else
{
GetCursorPos(out var pt);
cx = pt.X;
cy = pt.Y;
}
var fullCapture = ScreenCapture.CaptureOrLoad(file, null);
int w = fullCapture.Width;
int h = fullCapture.Height;
cx = Math.Clamp(cx, 0, w - 1);
cy = Math.Clamp(cy, 0, h - 1);
var bmpData = fullCapture.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
byte[] px = new byte[bmpData.Stride * h];
Marshal.Copy(bmpData.Scan0, px, 0, px.Length);
fullCapture.UnlockBits(bmpData);
int stride = bmpData.Stride;
int darkThresh = p.DarkThresh;
int colGap = p.RunGapTolerance;
int maxGap = p.MaxGap;
// Phase 1: Per-row horizontal extent
int bandHalf = p.MinDarkRun;
int bandTop = Math.Max(0, cy - bandHalf);
int bandBot = Math.Min(h - 1, cy + bandHalf);
var leftExtents = new List<int>();
var rightExtents = new List<int>();
for (int y = bandTop; y <= bandBot; y++)
{
int rowOff = y * stride;
int seedX = FindDarkSeedInRow(px, stride, w, rowOff, cx, darkThresh, seedRadius: 6);
if (seedX < 0) continue;
int leftEdge = cx;
int gap = 0;
bool foundLeft = false;
int initialBridge = Math.Max(colGap * 4, 12);
for (int x = cx; x >= 0; x--)
{
int i = rowOff + x * 4;
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
if (brightness < darkThresh) { leftEdge = x; gap = 0; foundLeft = true; }
else if (++gap > (foundLeft ? colGap : initialBridge)) break;
}
int rightEdge = cx;
gap = 0;
bool foundRight = false;
for (int x = cx; x < w; x++)
{
int i = rowOff + x * 4;
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
if (brightness < darkThresh) { rightEdge = x; gap = 0; foundRight = true; }
else if (++gap > (foundRight ? colGap : initialBridge)) break;
}
leftExtents.Add(leftEdge);
rightExtents.Add(rightEdge);
}
if (leftExtents.Count < 10)
{
Log.Debug("edge-crop: too few dark rows ({Count})", leftExtents.Count);
fullCapture.Dispose();
return null;
}
leftExtents.Sort();
rightExtents.Sort();
int leftPctIdx = leftExtents.Count / p.RowThreshDiv;
int rightPctIdx = rightExtents.Count * (p.ColThreshDiv - 1) / p.ColThreshDiv;
leftPctIdx = Math.Clamp(leftPctIdx, 0, leftExtents.Count - 1);
rightPctIdx = Math.Clamp(rightPctIdx, 0, rightExtents.Count - 1);
int bestColStart = leftExtents[leftPctIdx];
int bestColEnd = rightExtents[rightPctIdx];
if (bestColEnd - bestColStart + 1 < 50)
{
Log.Debug("edge-crop: horizontal extent too small");
fullCapture.Dispose();
return null;
}
// Phase 2: Per-column vertical extent
int colBandHalf = (bestColEnd - bestColStart + 1) / 3;
int colBandLeft = Math.Max(bestColStart, cx - colBandHalf);
int colBandRight = Math.Min(bestColEnd, cx + colBandHalf);
var topExtents = new List<int>();
var bottomExtents = new List<int>();
int maxGapUp = maxGap * 3;
for (int x = colBandLeft; x <= colBandRight; x++)
{
int seedY = FindDarkSeedInColumn(px, stride, h, x, cy, darkThresh, seedRadius: 6);
if (seedY < 0) continue;
int topEdge = cy;
int gap = 0;
bool foundTop = false;
int initialBridgeUp = Math.Max(maxGapUp * 2, 12);
for (int y = cy; y >= 0; y--)
{
int i = y * stride + x * 4;
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
if (brightness < darkThresh) { topEdge = y; gap = 0; foundTop = true; }
else if (++gap > (foundTop ? maxGapUp : initialBridgeUp)) break;
}
int bottomEdge = cy;
gap = 0;
bool foundBottom = false;
int initialBridgeDown = Math.Max(maxGap * 2, 12);
for (int y = cy; y < h; y++)
{
int i = y * stride + x * 4;
int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3;
if (brightness < darkThresh) { bottomEdge = y; gap = 0; foundBottom = true; }
else if (++gap > (foundBottom ? maxGap : initialBridgeDown)) break;
}
topExtents.Add(topEdge);
bottomExtents.Add(bottomEdge);
}
if (topExtents.Count < 10)
{
Log.Debug("edge-crop: too few dark columns ({Count})", topExtents.Count);
fullCapture.Dispose();
return null;
}
topExtents.Sort();
bottomExtents.Sort();
int topPctIdx = topExtents.Count / p.RowThreshDiv;
int botPctIdx = topExtents.Count * (p.ColThreshDiv - 1) / p.ColThreshDiv;
topPctIdx = Math.Clamp(topPctIdx, 0, topExtents.Count - 1);
botPctIdx = Math.Clamp(botPctIdx, 0, bottomExtents.Count - 1);
int bestRowStart = topExtents[topPctIdx];
int bestRowEnd = bottomExtents[botPctIdx];
if (bestRowEnd - bestRowStart + 1 < 50)
{
Log.Debug("edge-crop: vertical extent too small");
fullCapture.Dispose();
return null;
}
int minX = bestColStart;
int minY = bestRowStart;
int maxX = bestColEnd;
int maxY = bestRowEnd;
int rw = maxX - minX + 1;
int rh = maxY - minY + 1;
if (rw < 50 || rh < 50)
{
Log.Debug("edge-crop: region too small ({W}x{H})", rw, rh);
fullCapture.Dispose();
return null;
}
var cropRect = new Rectangle(minX, minY, rw, rh);
var cropped = fullCapture.Clone(cropRect, PixelFormat.Format32bppArgb);
var region = new Region(minX, minY, rw, rh);
return (cropped, fullCapture, region);
}
private static int FindDarkSeedInRow(byte[] px, int stride, int w, int rowOff, int cursorX, int darkThresh, int seedRadius)
{
int maxR = Math.Min(seedRadius, Math.Min(cursorX, w - 1 - cursorX));
for (int r = 0; r <= maxR; r++)
{
int x1 = cursorX - r;
int i1 = rowOff + x1 * 4;
int b1 = (px[i1] + px[i1 + 1] + px[i1 + 2]) / 3;
if (b1 < darkThresh) return x1;
int x2 = cursorX + r;
int i2 = rowOff + x2 * 4;
int b2 = (px[i2] + px[i2 + 1] + px[i2 + 2]) / 3;
if (b2 < darkThresh) return x2;
}
return -1;
}
private static int FindDarkSeedInColumn(byte[] px, int stride, int h, int x, int cursorY, int darkThresh, int seedRadius)
{
int maxR = Math.Min(seedRadius, Math.Min(cursorY, h - 1 - cursorY));
for (int r = 0; r <= maxR; r++)
{
int y1 = cursorY - r;
int i1 = y1 * stride + x * 4;
int b1 = (px[i1] + px[i1 + 1] + px[i1 + 2]) / 3;
if (b1 < darkThresh) return y1;
int y2 = cursorY + r;
int i2 = y2 * stride + x * 4;
int b2 = (px[i2] + px[i2 + 1] + px[i2 + 2]) / 3;
if (b2 < darkThresh) return y2;
}
return -1;
}
}

View file

@ -0,0 +1,323 @@
namespace Poe2Trade.Screen;
using System.Drawing;
using Serilog;
using Region = Poe2Trade.Core.Region;
public class GridHandler
{
private byte[]? _emptyTemplate70Gray;
private byte[]? _emptyTemplate70Argb;
private int _emptyTemplate70W, _emptyTemplate70H, _emptyTemplate70Stride;
private byte[]? _emptyTemplate35Gray;
private byte[]? _emptyTemplate35Argb;
private int _emptyTemplate35W, _emptyTemplate35H, _emptyTemplate35Stride;
public GridScanResult Scan(Region region, int cols, int rows,
int threshold = 0, int? targetRow = null, int? targetCol = null,
string? file = null, bool debug = false)
{
LoadTemplatesIfNeeded();
using var bitmap = ScreenCapture.CaptureOrLoad(file, region);
float cellW = (float)bitmap.Width / cols;
float cellH = (float)bitmap.Height / rows;
// Pick the right empty template based on cell size
int nominalCell = (int)Math.Round(cellW);
byte[]? templateGray;
byte[]? templateArgb;
int templateW, templateH, templateStride;
if (nominalCell <= 40 && _emptyTemplate35Gray != null)
{
templateGray = _emptyTemplate35Gray;
templateArgb = _emptyTemplate35Argb!;
templateW = _emptyTemplate35W;
templateH = _emptyTemplate35H;
templateStride = _emptyTemplate35Stride;
}
else if (_emptyTemplate70Gray != null)
{
templateGray = _emptyTemplate70Gray;
templateArgb = _emptyTemplate70Argb!;
templateW = _emptyTemplate70W;
templateH = _emptyTemplate70H;
templateStride = _emptyTemplate70Stride;
}
else
{
throw new InvalidOperationException("Empty cell templates not found in assets/");
}
var (captureGray, captureArgb, captureStride) = ImageUtils.BitmapToGrayAndArgb(bitmap);
int captureW = bitmap.Width;
int border = Math.Max(2, nominalCell / 10);
// Pre-compute template average for the inner region
long templateSum = 0;
int innerCount = 0;
for (int ty = border; ty < templateH - border; ty++)
for (int tx = border; tx < templateW - border; tx++)
{
templateSum += templateGray[ty * templateW + tx];
innerCount++;
}
double tmplMean = innerCount > 0 ? (double)templateSum / innerCount : 0;
double diffThreshold = threshold > 0 ? threshold : 5;
if (debug) Log.Debug("Grid: {Cols}x{Rows}, cellW={CellW:F1}, cellH={CellH:F1}, border={Border}, threshold={Threshold}, tmplMean={TmplMean:F1}",
cols, rows, cellW, cellH, border, diffThreshold, tmplMean);
var cells = new List<List<bool>>();
for (int row = 0; row < rows; row++)
{
var rowList = new List<bool>();
for (int col = 0; col < cols; col++)
{
int cx0 = (int)(col * cellW);
int cy0 = (int)(row * cellH);
int cw = (int)Math.Min(cellW, captureW - cx0);
int ch = (int)Math.Min(cellH, bitmap.Height - cy0);
int innerW = Math.Min(cw, templateW) - border;
int innerH = Math.Min(ch, templateH) - border;
long cellSum = 0;
int compared = 0;
for (int py = border; py < innerH; py++)
for (int px = border; px < innerW; px++)
{
cellSum += captureGray[(cy0 + py) * captureW + (cx0 + px)];
compared++;
}
double cellMean = compared > 0 ? (double)cellSum / compared : 0;
double offset = cellMean - tmplMean;
long diffSum = 0;
for (int py = border; py < innerH; py++)
for (int px = border; px < innerW; px++)
{
double cellVal = captureGray[(cy0 + py) * captureW + (cx0 + px)];
double tmplVal = templateGray[py * templateW + px];
diffSum += (long)Math.Abs(cellVal - tmplVal - offset);
}
double meanDiff = compared > 0 ? (double)diffSum / compared : 0;
bool occupied = meanDiff > diffThreshold;
rowList.Add(occupied);
}
cells.Add(rowList);
}
// Item detection: union-find on border pixel comparison
int[] parent = new int[rows * cols];
for (int i = 0; i < parent.Length; i++) parent[i] = i;
int Find(int x) { while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
void Union(int a, int b) { parent[Find(a)] = Find(b); }
int stripWidth = Math.Max(2, border / 2);
int stripInset = (int)(cellW * 0.15);
double borderDiffThresh = 15.0;
for (int row = 0; row < rows; row++)
{
for (int col = 0; col < cols; col++)
{
if (!cells[row][col]) continue;
int cx0 = (int)(col * cellW);
int cy0 = (int)(row * cellH);
// Check right neighbor
if (col + 1 < cols && cells[row][col + 1])
{
long diffSum = 0; int cnt = 0;
int xStart = (int)((col + 1) * cellW) - stripWidth;
int yFrom = cy0 + stripInset;
int yTo = (int)((row + 1) * cellH) - stripInset;
for (int sy = yFrom; sy < yTo; sy += 2)
{
int tmplY = sy - cy0;
for (int sx = xStart; sx < xStart + stripWidth * 2; sx++)
{
if (sx < 0 || sx >= captureW) continue;
int tmplX = sx - cx0;
if (tmplX < 0 || tmplX >= templateW) continue;
diffSum += Math.Abs(captureGray[sy * captureW + sx] - templateGray[tmplY * templateW + tmplX]);
cnt++;
}
}
double meanDiff = cnt > 0 ? (double)diffSum / cnt : 0;
if (meanDiff > borderDiffThresh)
Union(row * cols + col, row * cols + col + 1);
}
// Check bottom neighbor
if (row + 1 < rows && cells[row + 1][col])
{
long diffSum = 0; int cnt = 0;
int yStart = (int)((row + 1) * cellH) - stripWidth;
int xFrom = cx0 + stripInset;
int xTo = (int)((col + 1) * cellW) - stripInset;
for (int sx = xFrom; sx < xTo; sx += 2)
{
int tmplX = sx - cx0;
for (int sy = yStart; sy < yStart + stripWidth * 2; sy++)
{
if (sy < 0 || sy >= bitmap.Height) continue;
int tmplY = sy - cy0;
if (tmplY < 0 || tmplY >= templateH) continue;
diffSum += Math.Abs(captureGray[sy * captureW + sx] - templateGray[tmplY * templateW + tmplX]);
cnt++;
}
}
double meanDiff = cnt > 0 ? (double)diffSum / cnt : 0;
if (meanDiff > borderDiffThresh)
Union(row * cols + col, (row + 1) * cols + col);
}
}
}
// Extract items from union-find groups
var groups = new Dictionary<int, List<(int row, int col)>>();
for (int row = 0; row < rows; row++)
for (int col = 0; col < cols; col++)
if (cells[row][col])
{
int root = Find(row * cols + col);
if (!groups.ContainsKey(root)) groups[root] = [];
groups[root].Add((row, col));
}
var items = new List<GridItem>();
foreach (var group in groups.Values)
{
int minR = group.Min(c => c.row);
int maxR = group.Max(c => c.row);
int minC = group.Min(c => c.col);
int maxC = group.Max(c => c.col);
items.Add(new GridItem { Row = minR, Col = minC, W = maxC - minC + 1, H = maxR - minR + 1 });
}
// Visual matching
List<GridMatch>? matches = null;
int tRow = targetRow ?? -1;
int tCol = targetCol ?? -1;
if (tRow >= 0 && tCol >= 0 && tRow < rows && tCol < cols && cells[tRow][tCol])
{
matches = FindMatchingCells(
captureGray, captureW, bitmap.Height,
cells, rows, cols, cellW, cellH, border,
tRow, tCol, debug);
}
// Convert cells to bool[][]
var cellsArr = cells.Select(r => r.ToArray()).ToArray();
return new GridScanResult { Cells = cellsArr, Items = items, Matches = matches };
}
private List<GridMatch> FindMatchingCells(
byte[] gray, int imgW, int imgH,
List<List<bool>> cells, int rows, int cols,
float cellW, float cellH, int border,
int targetRow, int targetCol, bool debug)
{
int innerW = (int)cellW - border * 2;
int innerH = (int)cellH - border * 2;
if (innerW <= 4 || innerH <= 4) return [];
int tCx0 = (int)(targetCol * cellW) + border;
int tCy0 = (int)(targetRow * cellH) + border;
int tInnerW = Math.Min(innerW, imgW - tCx0);
int tInnerH = Math.Min(innerH, imgH - tCy0);
if (tInnerW < innerW || tInnerH < innerH) return [];
int n = innerW * innerH;
double[] targetPixels = new double[n];
double tMean = 0;
for (int py = 0; py < innerH; py++)
for (int px = 0; px < innerW; px++)
{
double v = gray[(tCy0 + py) * imgW + (tCx0 + px)];
targetPixels[py * innerW + px] = v;
tMean += v;
}
tMean /= n;
double tStd = 0;
for (int i = 0; i < n; i++)
tStd += (targetPixels[i] - tMean) * (targetPixels[i] - tMean);
tStd = Math.Sqrt(tStd / n);
if (tStd < 3.0) return [];
double matchThreshold = 0.70;
var matches = new List<GridMatch>();
for (int row = 0; row < rows; row++)
{
for (int col = 0; col < cols; col++)
{
if (!cells[row][col]) continue;
if (row == targetRow && col == targetCol) continue;
int cx0 = (int)(col * cellW) + border;
int cy0 = (int)(row * cellH) + border;
int cInnerW = Math.Min(innerW, imgW - cx0);
int cInnerH = Math.Min(innerH, imgH - cy0);
if (cInnerW < innerW || cInnerH < innerH) continue;
double cMean = 0;
for (int py = 0; py < innerH; py++)
for (int px = 0; px < innerW; px++)
cMean += gray[(cy0 + py) * imgW + (cx0 + px)];
cMean /= n;
double cStd = 0, cross = 0;
for (int py = 0; py < innerH; py++)
for (int px = 0; px < innerW; px++)
{
double cv = gray[(cy0 + py) * imgW + (cx0 + px)] - cMean;
double tv = targetPixels[py * innerW + px] - tMean;
cStd += cv * cv;
cross += tv * cv;
}
cStd = Math.Sqrt(cStd / n);
double ncc = (tStd > 0 && cStd > 0) ? cross / (n * tStd * cStd) : 0;
if (ncc >= matchThreshold)
matches.Add(new GridMatch { Row = row, Col = col, Similarity = Math.Round(ncc, 3) });
}
}
return matches;
}
private void LoadTemplatesIfNeeded()
{
if (_emptyTemplate70Gray != null) return;
// Templates are in assets/ at project root (working directory)
var t70Path = Path.Combine("assets", "empty70.png");
var t35Path = Path.Combine("assets", "empty35.png");
if (File.Exists(t70Path))
{
using var bmp = new Bitmap(t70Path);
_emptyTemplate70W = bmp.Width;
_emptyTemplate70H = bmp.Height;
(_emptyTemplate70Gray, _emptyTemplate70Argb, _emptyTemplate70Stride) = ImageUtils.BitmapToGrayAndArgb(bmp);
}
if (File.Exists(t35Path))
{
using var bmp = new Bitmap(t35Path);
_emptyTemplate35W = bmp.Width;
_emptyTemplate35H = bmp.Height;
(_emptyTemplate35Gray, _emptyTemplate35Argb, _emptyTemplate35Stride) = ImageUtils.BitmapToGrayAndArgb(bmp);
}
}
}

View file

@ -0,0 +1,136 @@
using Poe2Trade.Core;
using Serilog;
namespace Poe2Trade.Screen;
public class GridLayout
{
public required Region Region { get; init; }
public required int Cols { get; init; }
public required int Rows { get; init; }
}
public record CellCoord(int Row, int Col, int X, int Y);
public class ScanResult
{
public required GridLayout Layout { get; init; }
public required List<CellCoord> Occupied { get; init; }
public required List<GridItem> Items { get; init; }
public List<GridMatch>? Matches { get; init; }
}
public static class GridLayouts
{
public static readonly Dictionary<string, GridLayout> All = new()
{
["inventory"] = new GridLayout
{
Region = new Region(1696, 788, 840, 350),
Cols = 12, Rows = 5
},
["stash12"] = new GridLayout
{
Region = new Region(23, 169, 840, 840),
Cols = 12, Rows = 12
},
["stash12_folder"] = new GridLayout
{
Region = new Region(23, 216, 840, 840),
Cols = 12, Rows = 12
},
["stash24"] = new GridLayout
{
Region = new Region(23, 169, 840, 840),
Cols = 24, Rows = 24
},
["stash24_folder"] = new GridLayout
{
Region = new Region(23, 216, 840, 840),
Cols = 24, Rows = 24
},
["seller"] = new GridLayout
{
Region = new Region(416, 299, 840, 840),
Cols = 12, Rows = 12
},
["shop"] = new GridLayout
{
Region = new Region(23, 216, 840, 840),
Cols = 12, Rows = 12
},
["vendor"] = new GridLayout
{
Region = new Region(416, 369, 840, 840),
Cols = 12, Rows = 12
},
};
public static readonly GridLayout Inventory = All["inventory"];
public static readonly GridLayout Seller = All["seller"];
}
public class GridReader
{
private readonly GridHandler _handler;
public GridReader(GridHandler handler)
{
_handler = handler;
}
public Task<ScanResult> Scan(string layoutName, int threshold = 0,
int? targetRow = null, int? targetCol = null)
{
if (!GridLayouts.All.TryGetValue(layoutName, out var layout))
throw new ArgumentException($"Unknown grid layout: {layoutName}");
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = _handler.Scan(layout.Region, layout.Cols, layout.Rows,
threshold, targetRow, targetCol);
var occupied = new List<CellCoord>();
for (var row = 0; row < result.Cells.Length; row++)
for (var col = 0; col < result.Cells[row].Length; col++)
{
if (result.Cells[row][col])
{
var center = GetCellCenter(layout, row, col);
occupied.Add(new CellCoord(row, col, center.X, center.Y));
}
}
Log.Information("Grid scan {Layout}: {Occupied} occupied, {Items} items, {Ms}ms",
layoutName, occupied.Count, result.Items.Count, sw.ElapsedMilliseconds);
return Task.FromResult(new ScanResult
{
Layout = layout,
Occupied = occupied,
Items = result.Items,
Matches = result.Matches
});
}
public (int X, int Y) GetCellCenter(GridLayout layout, int row, int col)
{
var cellW = (double)layout.Region.Width / layout.Cols;
var cellH = (double)layout.Region.Height / layout.Rows;
return (
(int)Math.Round(layout.Region.X + col * cellW + cellW / 2),
(int)Math.Round(layout.Region.Y + row * cellH + cellH / 2)
);
}
public List<CellCoord> GetAllCells(GridLayout layout)
{
var cells = new List<CellCoord>();
for (var row = 0; row < layout.Rows; row++)
for (var col = 0; col < layout.Cols; col++)
{
var center = GetCellCenter(layout, row, col);
cells.Add(new CellCoord(row, col, center.X, center.Y));
}
return cells;
}
}

View file

@ -0,0 +1,218 @@
namespace Poe2Trade.Screen;
using System.Drawing;
using OpenCvSharp;
using OpenCvSharp.Extensions;
static class ImagePreprocessor
{
/// <summary>
/// Pre-process an image for OCR using morphological white top-hat filtering.
/// Isolates bright tooltip text, suppresses dim background text visible through overlay.
/// Pipeline: grayscale -> morphological top-hat -> Otsu binary -> upscale
/// </summary>
public static Bitmap PreprocessForOcr(Bitmap src, int kernelSize = 41, int upscale = 2)
{
using var mat = BitmapConverter.ToMat(src);
using var gray = new Mat();
Cv2.CvtColor(mat, gray, ColorConversionCodes.BGRA2GRAY);
// Morphological white top-hat: isolates bright text on dark background
using var kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new OpenCvSharp.Size(kernelSize, kernelSize));
using var tophat = new Mat();
Cv2.MorphologyEx(gray, tophat, MorphTypes.TopHat, kernel);
// Otsu binarization: automatic threshold, black text on white
using var binary = new Mat();
Cv2.Threshold(tophat, binary, 0, 255, ThresholdTypes.BinaryInv | ThresholdTypes.Otsu);
// Upscale for better LSTM recognition
if (upscale > 1)
{
using var upscaled = new Mat();
Cv2.Resize(binary, upscaled, new OpenCvSharp.Size(binary.Width * upscale, binary.Height * upscale),
interpolation: InterpolationFlags.Cubic);
return BitmapConverter.ToBitmap(upscaled);
}
return BitmapConverter.ToBitmap(binary);
}
/// <summary>
/// Background-subtraction preprocessing: uses the reference frame to remove
/// background bleed-through from the semi-transparent tooltip overlay.
/// Pipeline: estimate dimming factor -> subtract expected background -> threshold -> upscale
/// Returns the upscaled binary Mat directly (caller must dispose).
/// </summary>
public static Mat PreprocessWithBackgroundSubMat(Bitmap tooltipCrop, Bitmap referenceCrop,
int dimPercentile = 25, int textThresh = 30, int upscale = 2, bool softThreshold = true)
{
using var curMat = BitmapConverter.ToMat(tooltipCrop);
using var refMat = BitmapConverter.ToMat(referenceCrop);
using var curGray = new Mat();
using var refGray = new Mat();
Cv2.CvtColor(curMat, curGray, ColorConversionCodes.BGRA2GRAY);
Cv2.CvtColor(refMat, refGray, ColorConversionCodes.BGRA2GRAY);
int rows = curGray.Rows, cols = curGray.Cols;
// Estimate the dimming factor of the tooltip overlay.
var ratios = new List<double>();
unsafe
{
byte* curPtr = (byte*)curGray.Data;
byte* refPtr = (byte*)refGray.Data;
int curStep = (int)curGray.Step();
int refStep = (int)refGray.Step();
for (int y = 0; y < rows; y++)
for (int x = 0; x < cols; x++)
{
byte r = refPtr[y * refStep + x];
byte c = curPtr[y * curStep + x];
if (r > 30)
ratios.Add((double)c / r);
}
}
if (ratios.Count == 0)
{
using var fallbackBmp = PreprocessForOcr(tooltipCrop, 41, upscale);
return BitmapConverter.ToMat(fallbackBmp);
}
ratios.Sort();
int idx = Math.Clamp(ratios.Count * dimPercentile / 100, 0, ratios.Count - 1);
double dimFactor = ratios[idx];
dimFactor = Math.Clamp(dimFactor, 0.05, 0.95);
// Subtract expected background: text_signal = current - reference * dimFactor
using var textSignal = new Mat(rows, cols, MatType.CV_8UC1);
unsafe
{
byte* curPtr = (byte*)curGray.Data;
byte* refPtr = (byte*)refGray.Data;
byte* outPtr = (byte*)textSignal.Data;
int curStep = (int)curGray.Step();
int refStep = (int)refGray.Step();
int outStep = (int)textSignal.Step();
for (int y = 0; y < rows; y++)
for (int x = 0; x < cols; x++)
{
double expected = refPtr[y * refStep + x] * dimFactor;
double signal = curPtr[y * curStep + x] - expected;
outPtr[y * outStep + x] = (byte)Math.Clamp(signal, 0, 255);
}
}
Mat result;
if (softThreshold)
{
result = new Mat(rows, cols, MatType.CV_8UC1);
unsafe
{
byte* srcPtr = (byte*)textSignal.Data;
byte* dstPtr = (byte*)result.Data;
int srcStep = (int)textSignal.Step();
int dstStep = (int)result.Step();
int maxClipped = 1;
for (int y = 0; y < rows; y++)
for (int x = 0; x < cols; x++)
{
int val = srcPtr[y * srcStep + x] - textThresh;
if (val > maxClipped) maxClipped = val;
}
for (int y = 0; y < rows; y++)
for (int x = 0; x < cols; x++)
{
int clipped = srcPtr[y * srcStep + x] - textThresh;
if (clipped <= 0)
{
dstPtr[y * dstStep + x] = 255;
}
else
{
int stretched = clipped * 255 / maxClipped;
dstPtr[y * dstStep + x] = (byte)(255 - stretched);
}
}
}
}
else
{
result = new Mat();
Cv2.Threshold(textSignal, result, textThresh, 255, ThresholdTypes.BinaryInv);
}
using var _result = result;
return UpscaleMat(result, upscale);
}
/// <summary>
/// Background-subtraction preprocessing returning a Bitmap (convenience wrapper).
/// </summary>
public static Bitmap PreprocessWithBackgroundSub(Bitmap tooltipCrop, Bitmap referenceCrop,
int dimPercentile = 25, int textThresh = 30, int upscale = 2, bool softThreshold = true)
{
using var mat = PreprocessWithBackgroundSubMat(tooltipCrop, referenceCrop, dimPercentile, textThresh, upscale, softThreshold);
return BitmapConverter.ToBitmap(mat);
}
/// <summary>
/// Detect text lines via horizontal projection on a binary image.
/// Binary should be inverted: text=black(0), background=white(255).
/// Returns list of (yStart, yEnd) row ranges for each detected text line.
/// </summary>
public static List<(int yStart, int yEnd)> DetectTextLines(
Mat binary, int minRowPixels = 2, int gapTolerance = 5)
{
int rows = binary.Rows, cols = binary.Cols;
var rowCounts = new int[rows];
unsafe
{
byte* ptr = (byte*)binary.Data;
int step = (int)binary.Step();
for (int y = 0; y < rows; y++)
for (int x = 0; x < cols; x++)
if (ptr[y * step + x] < 128)
rowCounts[y]++;
}
var lines = new List<(int yStart, int yEnd)>();
int lineStart = -1, lastActive = -1;
for (int y = 0; y < rows; y++)
{
if (rowCounts[y] >= minRowPixels)
{
if (lineStart < 0) lineStart = y;
lastActive = y;
}
else if (lineStart >= 0 && y - lastActive > gapTolerance)
{
lines.Add((lineStart, lastActive));
lineStart = -1;
}
}
if (lineStart >= 0)
lines.Add((lineStart, lastActive));
return lines;
}
/// <summary>Returns a new Mat (caller must dispose). Does NOT dispose src.</summary>
private static Mat UpscaleMat(Mat src, int factor)
{
if (factor > 1)
{
var upscaled = new Mat();
Cv2.Resize(src, upscaled, new OpenCvSharp.Size(src.Width * factor, src.Height * factor),
interpolation: InterpolationFlags.Cubic);
return upscaled;
}
return src.Clone();
}
}

View file

@ -0,0 +1,39 @@
namespace Poe2Trade.Screen;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using SdImageFormat = System.Drawing.Imaging.ImageFormat;
static class ImageUtils
{
public static (byte[] gray, byte[] argb, int stride) BitmapToGrayAndArgb(Bitmap bmp)
{
int w = bmp.Width, h = bmp.Height;
var data = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
byte[] argb = new byte[data.Stride * h];
Marshal.Copy(data.Scan0, argb, 0, argb.Length);
bmp.UnlockBits(data);
int stride = data.Stride;
byte[] gray = new byte[w * h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int i = y * stride + x * 4;
gray[y * w + x] = (byte)((argb[i] + argb[i + 1] + argb[i + 2]) / 3);
}
return (gray, argb, stride);
}
public static SdImageFormat GetImageFormat(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch
{
".jpg" or ".jpeg" => SdImageFormat.Jpeg,
".bmp" => SdImageFormat.Bmp,
_ => SdImageFormat.Png,
};
}
}

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenCvSharp4" Version="4.11.0.*" />
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.11.0.*" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.11.0.*" />
<PackageReference Include="System.Drawing.Common" Version="8.0.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,175 @@
namespace Poe2Trade.Screen;
using System.Diagnostics;
using System.Drawing;
using System.Text.Json;
using System.Text.Json.Serialization;
using Serilog;
using SdImageFormat = System.Drawing.Imaging.ImageFormat;
/// <summary>
/// Manages a persistent Python subprocess for EasyOCR.
/// Lazy-starts on first request; reuses the process for subsequent calls.
/// Same stdin/stdout JSON-per-line protocol.
/// </summary>
class PythonOcrBridge : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private Process? _proc;
private readonly string _daemonScript;
private readonly string _pythonExe;
private readonly object _lock = new();
public PythonOcrBridge()
{
// Resolve paths relative to working directory
_daemonScript = Path.GetFullPath(Path.Combine("tools", "python-ocr", "daemon.py"));
var venvPython = Path.GetFullPath(Path.Combine("tools", "python-ocr", ".venv", "Scripts", "python.exe"));
_pythonExe = File.Exists(venvPython) ? venvPython : "python";
}
/// <summary>
/// Run OCR on a bitmap via the Python EasyOCR engine (base64 PNG over pipe).
/// </summary>
public OcrResponse OcrFromBitmap(Bitmap bitmap, OcrParams? ocrParams = null)
{
EnsureRunning();
using var ms = new MemoryStream();
bitmap.Save(ms, SdImageFormat.Png);
var imageBase64 = Convert.ToBase64String(ms.ToArray());
var pyReq = BuildPythonRequest(ocrParams);
pyReq["imageBase64"] = imageBase64;
return SendPythonRequest(pyReq);
}
private static Dictionary<string, object?> BuildPythonRequest(OcrParams? ocrParams)
{
var req = new Dictionary<string, object?> { ["cmd"] = "ocr", ["engine"] = "easyocr" };
if (ocrParams == null) return req;
if (ocrParams.MergeGap > 0) req["mergeGap"] = ocrParams.MergeGap;
if (ocrParams.LinkThreshold.HasValue) req["linkThreshold"] = ocrParams.LinkThreshold.Value;
if (ocrParams.TextThreshold.HasValue) req["textThreshold"] = ocrParams.TextThreshold.Value;
if (ocrParams.LowText.HasValue) req["lowText"] = ocrParams.LowText.Value;
if (ocrParams.WidthThs.HasValue) req["widthThs"] = ocrParams.WidthThs.Value;
if (ocrParams.Paragraph.HasValue) req["paragraph"] = ocrParams.Paragraph.Value;
return req;
}
private OcrResponse SendPythonRequest(object pyReq)
{
var json = JsonSerializer.Serialize(pyReq, JsonOptions);
string responseLine;
lock (_lock)
{
_proc!.StandardInput.WriteLine(json);
_proc.StandardInput.Flush();
responseLine = _proc.StandardOutput.ReadLine()
?? throw new Exception("Python daemon returned null");
}
var resp = JsonSerializer.Deserialize<PythonResponse>(responseLine, JsonOptions);
if (resp == null)
throw new Exception("Failed to parse Python OCR response");
if (!resp.Ok)
throw new Exception(resp.Error ?? "Python OCR failed");
return new OcrResponse
{
Text = resp.Text ?? "",
Lines = resp.Lines ?? [],
};
}
private void EnsureRunning()
{
if (_proc != null && !_proc.HasExited)
return;
_proc?.Dispose();
_proc = null;
if (!File.Exists(_daemonScript))
throw new Exception($"Python OCR daemon not found at {_daemonScript}");
Log.Information("Spawning Python OCR daemon: {Python} {Script}", _pythonExe, _daemonScript);
_proc = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _pythonExe,
Arguments = $"\"{_daemonScript}\"",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
}
};
_proc.ErrorDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
Log.Debug("[python-ocr] {Line}", e.Data);
};
_proc.Start();
_proc.BeginErrorReadLine();
// Wait for ready signal (up to 30s for first model load)
var readyLine = _proc.StandardOutput.ReadLine();
if (readyLine == null)
throw new Exception("Python OCR daemon exited before ready signal");
var ready = JsonSerializer.Deserialize<PythonResponse>(readyLine, JsonOptions);
if (ready?.Ready != true)
throw new Exception($"Python OCR daemon did not send ready signal: {readyLine}");
Log.Information("Python OCR daemon ready");
}
public void Dispose()
{
if (_proc != null && !_proc.HasExited)
{
try
{
_proc.StandardInput.Close();
_proc.WaitForExit(3000);
if (!_proc.HasExited) _proc.Kill();
}
catch { /* ignore */ }
}
_proc?.Dispose();
_proc = null;
}
private class PythonResponse
{
[JsonPropertyName("ok")]
public bool Ok { get; set; }
[JsonPropertyName("ready")]
public bool? Ready { get; set; }
[JsonPropertyName("text")]
public string? Text { get; set; }
[JsonPropertyName("lines")]
public List<OcrLine>? Lines { get; set; }
[JsonPropertyName("error")]
public string? Error { get; set; }
}
}

View file

@ -0,0 +1,66 @@
namespace Poe2Trade.Screen;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using Region = Poe2Trade.Core.Region;
static class ScreenCapture
{
[DllImport("user32.dll")]
private static extern bool SetProcessDPIAware();
[DllImport("user32.dll")]
private static extern int GetSystemMetrics(int nIndex);
public static void InitDpiAwareness() => SetProcessDPIAware();
/// <summary>
/// Capture from screen, or load from file if specified.
/// When file is set, loads the image and crops to region.
/// </summary>
public static Bitmap CaptureOrLoad(string? file, Region? region)
{
if (!string.IsNullOrEmpty(file))
{
var fullBmp = new Bitmap(file);
if (region != null)
{
int cx = Math.Max(0, region.X);
int cy = Math.Max(0, region.Y);
int cw = Math.Min(region.Width, fullBmp.Width - cx);
int ch = Math.Min(region.Height, fullBmp.Height - cy);
var cropped = fullBmp.Clone(new Rectangle(cx, cy, cw, ch), PixelFormat.Format32bppArgb);
fullBmp.Dispose();
return cropped;
}
return fullBmp;
}
return CaptureScreen(region);
}
public static Bitmap CaptureScreen(Region? region)
{
int x, y, w, h;
if (region != null)
{
x = region.X;
y = region.Y;
w = region.Width;
h = region.Height;
}
else
{
// Primary monitor only (0,0 origin, SM_CXSCREEN / SM_CYSCREEN)
x = 0;
y = 0;
w = GetSystemMetrics(0); // SM_CXSCREEN
h = GetSystemMetrics(1); // SM_CYSCREEN
}
var bitmap = new Bitmap(w, h, PixelFormat.Format32bppArgb);
using var g = Graphics.FromImage(bitmap);
g.CopyFromScreen(x, y, 0, 0, new System.Drawing.Size(w, h), CopyPixelOperation.SourceCopy);
return bitmap;
}
}

View file

@ -0,0 +1,259 @@
using Poe2Trade.Core;
using OpenCvSharp.Extensions;
using Serilog;
namespace Poe2Trade.Screen;
public class ScreenReader : IDisposable
{
private readonly DiffCropHandler _diffCrop = new();
private readonly GridHandler _gridHandler = new();
private readonly TemplateMatchHandler _templateMatch = new();
private readonly EdgeCropHandler _edgeCrop = new();
private readonly PythonOcrBridge _pythonBridge = new();
private bool _initialized;
public GridReader Grid { get; }
public ScreenReader()
{
Grid = new GridReader(_gridHandler);
}
public Task Warmup()
{
if (!_initialized)
{
ScreenCapture.InitDpiAwareness();
_initialized = true;
}
return Task.CompletedTask;
}
// -- Capture --
public Task<byte[]> CaptureScreen()
{
return Task.FromResult(_diffCrop.HandleCapture());
}
public Task<byte[]> CaptureRegion(Region region)
{
return Task.FromResult(_diffCrop.HandleCapture(region));
}
// -- OCR --
public Task<OcrResponse> Ocr(Region? region = null, string? preprocess = null)
{
using var bitmap = ScreenCapture.CaptureOrLoad(null, region);
if (preprocess == "tophat")
{
using var processed = ImagePreprocessor.PreprocessForOcr(bitmap);
return Task.FromResult(_pythonBridge.OcrFromBitmap(processed));
}
return Task.FromResult(_pythonBridge.OcrFromBitmap(bitmap));
}
public async Task<(int X, int Y)?> FindTextOnScreen(string searchText, bool fuzzy = false)
{
var result = await Ocr();
var pos = FindWordInOcrResult(result, searchText, fuzzy);
if (pos.HasValue)
Log.Information("Found text '{Text}' at ({X},{Y})", searchText, pos.Value.X, pos.Value.Y);
else
Log.Information("Text '{Text}' not found on screen", searchText);
return pos;
}
public async Task<string> ReadFullScreen()
{
var result = await Ocr();
return result.Text;
}
public async Task<(int X, int Y)?> FindTextInRegion(Region region, string searchText)
{
var result = await Ocr(region);
var pos = FindWordInOcrResult(result, searchText);
if (pos.HasValue)
return (region.X + pos.Value.X, region.Y + pos.Value.Y);
return null;
}
public async Task<string> ReadRegionText(Region region)
{
var result = await Ocr(region);
return result.Text;
}
public async Task<bool> CheckForText(Region region, string searchText)
{
var pos = await FindTextInRegion(region, searchText);
return pos.HasValue;
}
// -- Snapshot / Diff OCR --
public Task Snapshot()
{
_diffCrop.HandleSnapshot();
return Task.CompletedTask;
}
public Task<DiffOcrResponse> DiffOcr(string? savePath = null, Region? region = null)
{
var p = new DiffOcrParams();
var cropResult = _diffCrop.DiffCrop(p.Crop, region: region);
if (cropResult == null)
return Task.FromResult(new DiffOcrResponse { Text = "", Lines = [] });
var (cropped, refCropped, current, cropRegion) = cropResult.Value;
using var _current = current;
using var _cropped = cropped;
using var _refCropped = refCropped;
// Save raw crop if path is provided
if (!string.IsNullOrEmpty(savePath))
{
var dir = Path.GetDirectoryName(savePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
cropped.Save(savePath, ImageUtils.GetImageFormat(savePath));
}
// Preprocess with background subtraction
var ocr = p.Ocr;
using var processedBmp = ocr.UseBackgroundSub
? ImagePreprocessor.PreprocessWithBackgroundSub(cropped, refCropped, ocr.DimPercentile, ocr.TextThresh, 1, ocr.SoftThreshold)
: ImagePreprocessor.PreprocessForOcr(cropped, ocr.KernelSize, 1);
var ocrResult = _pythonBridge.OcrFromBitmap(processedBmp, ocr);
// Offset coordinates to screen space
foreach (var line in ocrResult.Lines)
foreach (var word in line.Words)
{
word.X += cropRegion.X;
word.Y += cropRegion.Y;
}
return Task.FromResult(new DiffOcrResponse
{
Text = ocrResult.Text,
Lines = ocrResult.Lines,
Region = cropRegion,
});
}
// -- Template matching --
public Task<TemplateMatchResult?> 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);
}
// -- Save --
public Task SaveScreenshot(string path)
{
_diffCrop.HandleScreenshot(path);
return Task.CompletedTask;
}
public Task SaveRegion(Region region, string path)
{
_diffCrop.HandleScreenshot(path, region);
return Task.CompletedTask;
}
public void Dispose() => _pythonBridge.Dispose();
// -- OCR text matching --
private static (int X, int Y)? FindWordInOcrResult(OcrResponse result, string needle, bool fuzzy = false)
{
var lower = needle.ToLowerInvariant();
const double fuzzyThreshold = 0.55;
if (lower.Contains(' '))
{
var needleNorm = Normalize(needle);
foreach (var line in result.Lines)
{
if (line.Words.Count == 0) continue;
if (line.Text.ToLowerInvariant().Contains(lower))
return LineBoundsCenter(line);
if (fuzzy)
{
var lineNorm = Normalize(line.Text);
var windowLen = needleNorm.Length;
for (var i = 0; i <= lineNorm.Length - windowLen + 2; i++)
{
var end = Math.Min(i + windowLen + 2, lineNorm.Length);
var window = lineNorm[i..end];
if (BigramSimilarity(needleNorm, window) >= fuzzyThreshold)
return LineBoundsCenter(line);
}
}
}
return null;
}
var needleN = Normalize(needle);
foreach (var line in result.Lines)
{
foreach (var word in line.Words)
{
if (word.Text.ToLowerInvariant().Contains(lower))
return (word.X + word.Width / 2, word.Y + word.Height / 2);
if (fuzzy && BigramSimilarity(needleN, Normalize(word.Text)) >= fuzzyThreshold)
return (word.X + word.Width / 2, word.Y + word.Height / 2);
}
}
return null;
}
private static (int X, int Y) LineBoundsCenter(OcrLine line)
{
var first = line.Words[0];
var last = line.Words[^1];
var x1 = first.X;
var y1 = first.Y;
var x2 = last.X + last.Width;
var y2 = line.Words.Max(w => w.Y + w.Height);
return ((x1 + x2) / 2, (y1 + y2) / 2);
}
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<string, int>();
for (var i = 0; i < a.Length - 1; i++)
{
var bg = a.Substring(i, 2);
bigramsA[bg] = bigramsA.GetValueOrDefault(bg) + 1;
}
var matches = 0;
for (var i = 0; i < b.Length - 1; i++)
{
var bg = b.Substring(i, 2);
if (bigramsA.TryGetValue(bg, out var count) && count > 0)
{
matches++;
bigramsA[bg] = count - 1;
}
}
return 2.0 * matches / (a.Length - 1 + b.Length - 1);
}
}

View file

@ -0,0 +1,164 @@
namespace Poe2Trade.Screen;
static class SignalProcessing
{
/// <summary>
/// Find the dominant period in a signal using autocorrelation.
/// Returns (period, score) where score is the autocorrelation strength.
/// </summary>
public static (int period, double score) FindPeriodWithScore(double[] signal, int minPeriod, int maxPeriod)
{
int n = signal.Length;
if (n < minPeriod * 3) return (-1, 0);
double mean = signal.Average();
double variance = 0;
for (int i = 0; i < n; i++)
variance += (signal[i] - mean) * (signal[i] - mean);
if (variance < 1.0) return (-1, 0);
int maxLag = Math.Min(maxPeriod, n / 3);
double[] ac = new double[maxLag + 1];
for (int lag = minPeriod; lag <= maxLag; lag++)
{
double sum = 0;
for (int i = 0; i < n - lag; i++)
sum += (signal[i] - mean) * (signal[i + lag] - mean);
ac[lag] = sum / variance;
}
for (int lag = minPeriod + 1; lag < maxLag; lag++)
{
if (ac[lag] > 0.01 && ac[lag] >= ac[lag - 1] && ac[lag] >= ac[lag + 1])
return (lag, ac[lag]);
}
return (-1, 0);
}
/// <summary>
/// Find contiguous segments where values are ABOVE threshold.
/// </summary>
public static List<(int start, int end)> FindDarkDensitySegments(double[] profile, double threshold, int minLength)
{
var segments = new List<(int start, int end)>();
int n = profile.Length;
int curStart = -1;
int maxGap = 5;
int gapCount = 0;
for (int i = 0; i < n; i++)
{
if (profile[i] >= threshold)
{
if (curStart < 0) curStart = i;
gapCount = 0;
}
else
{
if (curStart >= 0)
{
gapCount++;
if (gapCount > maxGap)
{
int end = i - gapCount;
if (end - curStart >= minLength)
segments.Add((curStart, end));
curStart = -1;
gapCount = 0;
}
}
}
}
if (curStart >= 0)
{
int end = gapCount > 0 ? n - gapCount : n;
if (end - curStart >= minLength)
segments.Add((curStart, end));
}
return segments;
}
/// <summary>
/// Find the extent of the grid in a 1D profile using local autocorrelation.
/// </summary>
public static (int start, int end) FindGridExtent(double[] signal, int period)
{
int n = signal.Length;
int halfWin = period * 2;
if (n < halfWin * 2 + period) return (-1, -1);
double[] localAc = new double[n];
for (int center = halfWin; center < n - halfWin; center++)
{
int wStart = center - halfWin;
int wEnd = center + halfWin;
int count = wEnd - wStart;
double sum = 0;
for (int i = wStart; i < wEnd; i++)
sum += signal[i];
double mean = sum / count;
double varSum = 0;
for (int i = wStart; i < wEnd; i++)
varSum += (signal[i] - mean) * (signal[i] - mean);
if (varSum < 1.0) continue;
double acSum = 0;
for (int i = wStart; i < wEnd - period; i++)
acSum += (signal[i] - mean) * (signal[i + period] - mean);
localAc[center] = Math.Max(0, acSum / varSum);
}
double maxAc = 0;
for (int i = 0; i < n; i++)
if (localAc[i] > maxAc) maxAc = localAc[i];
if (maxAc < 0.02) return (-1, -1);
double threshold = maxAc * 0.25;
int bestStart = -1, bestEnd = -1, bestLen = 0;
int curStartPos = -1;
for (int i = 0; i < n; i++)
{
if (localAc[i] > threshold)
{
if (curStartPos < 0) curStartPos = i;
}
else
{
if (curStartPos >= 0)
{
int len = i - curStartPos;
if (len > bestLen)
{
bestLen = len;
bestStart = curStartPos;
bestEnd = i;
}
curStartPos = -1;
}
}
}
if (curStartPos >= 0)
{
int len = n - curStartPos;
if (len > bestLen)
{
bestStart = curStartPos;
bestEnd = n;
}
}
if (bestStart < 0) return (-1, -1);
bestStart = Math.Max(0, bestStart - period / 4);
bestEnd = Math.Min(n - 1, bestEnd + period / 4);
return (bestStart, bestEnd);
}
}

View file

@ -0,0 +1,54 @@
namespace Poe2Trade.Screen;
using System.Drawing;
using OpenCvSharp;
using OpenCvSharp.Extensions;
using Region = Poe2Trade.Core.Region;
class TemplateMatchHandler
{
public TemplateMatchResult? Match(string templatePath, Region? region = null,
string? file = null, double threshold = 0.7)
{
if (!System.IO.File.Exists(templatePath))
throw new FileNotFoundException($"Template file not found: {templatePath}");
using var screenshot = ScreenCapture.CaptureOrLoad(file, region);
using var screenMat = BitmapConverter.ToMat(screenshot);
using var template = Cv2.ImRead(templatePath, ImreadModes.Color);
if (template.Empty())
throw new InvalidOperationException($"Failed to load template image: {templatePath}");
// Convert screenshot from BGRA to BGR if needed
using var screenBgr = new Mat();
if (screenMat.Channels() == 4)
Cv2.CvtColor(screenMat, screenBgr, ColorConversionCodes.BGRA2BGR);
else
screenMat.CopyTo(screenBgr);
// Template must fit within screenshot
if (template.Rows > screenBgr.Rows || template.Cols > screenBgr.Cols)
return null;
using var result = new Mat();
Cv2.MatchTemplate(screenBgr, template, 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;
return new TemplateMatchResult
{
X = offsetX + maxLoc.X + template.Cols / 2,
Y = offsetY + maxLoc.Y + template.Rows / 2,
Width = template.Cols,
Height = template.Rows,
Confidence = maxVal,
};
}
}

View file

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="1.49.0" />
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,30 @@
namespace Poe2Trade.Trade;
public static class Selectors
{
public const string LiveSearchButton =
"button.livesearch-btn, button:has-text(\"Activate Live Search\")";
public const string ListingRow =
".resultset .row, [class*=\"result\"]";
public static string ListingById(string id) => $"[data-id=\"{id}\"]";
public const string TravelToHideoutButton =
"button:has-text(\"Travel to Hideout\"), button:has-text(\"Visit Hideout\"), a:has-text(\"Travel to Hideout\"), [class*=\"hideout\"]";
public const string WhisperButton =
".whisper-btn, button[class*=\"whisper\"], [data-tooltip=\"Whisper\"], button:has-text(\"Whisper\")";
public const string ConfirmDialog =
"[class*=\"modal\"], [class*=\"dialog\"], [class*=\"confirm\"]";
public const string ConfirmYesButton =
"button:has-text(\"Yes\"), button:has-text(\"Confirm\"), button:has-text(\"OK\"), button:has-text(\"Accept\")";
public const string ConfirmNoButton =
"button:has-text(\"No\"), button:has-text(\"Cancel\"), button:has-text(\"Decline\")";
public const string ResultsContainer =
".resultset, [class*=\"results\"]";
}

View file

@ -0,0 +1,296 @@
using System.Text.Json;
using Microsoft.Playwright;
using Poe2Trade.Core;
using Serilog;
namespace Poe2Trade.Trade;
public class TradeMonitor : IAsyncDisposable
{
private IBrowserContext? _context;
private readonly Dictionary<string, IPage> _pages = new();
private readonly HashSet<string> _pausedSearches = new();
private readonly AppConfig _config;
private const string StealthScript = """
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
{ name: 'Native Client', filename: 'internal-nacl-plugin' },
],
});
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
delete window.__playwright;
delete window.__pw_manual;
if (!window.chrome) window.chrome = {};
if (!window.chrome.runtime) window.chrome.runtime = { id: undefined };
const originalQuery = window.navigator.permissions?.query;
if (originalQuery) {
window.navigator.permissions.query = (params) => {
if (params.name === 'notifications')
return Promise.resolve({ state: Notification.permission });
return originalQuery(params);
};
}
""";
public event Action<string, List<string>, IPage>? NewListings;
public TradeMonitor(AppConfig config)
{
_config = config;
}
public async Task Start(string? dashboardUrl = null)
{
Log.Information("Launching Playwright browser (stealth mode)...");
var playwright = await Playwright.CreateAsync();
_context = await playwright.Chromium.LaunchPersistentContextAsync(
_config.BrowserUserDataDir,
new BrowserTypeLaunchPersistentContextOptions
{
Headless = false,
ViewportSize = null,
Args = [
"--disable-blink-features=AutomationControlled",
"--disable-features=AutomationControlled",
"--no-first-run",
"--no-default-browser-check",
"--disable-infobars",
],
IgnoreDefaultArgs = ["--enable-automation"],
});
await _context.AddInitScriptAsync(StealthScript);
if (dashboardUrl != null)
{
var pages = _context.Pages;
if (pages.Count > 0)
await pages[0].GotoAsync(dashboardUrl);
else
await (await _context.NewPageAsync()).GotoAsync(dashboardUrl);
Log.Information("Dashboard opened: {Url}", dashboardUrl);
}
Log.Information("Browser launched (stealth active)");
}
public async Task AddSearch(string tradeUrl)
{
if (_context == null) throw new InvalidOperationException("Browser not started");
var searchId = ExtractSearchId(tradeUrl);
if (_pages.ContainsKey(searchId))
{
Log.Information("Search already open: {SearchId}", searchId);
return;
}
Log.Information("Adding trade search: {Url} ({SearchId})", tradeUrl, searchId);
var page = await _context.NewPageAsync();
_pages[searchId] = page;
await page.GotoAsync(tradeUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
await Helpers.Sleep(2000);
page.WebSocket += (_, ws) => HandleWebSocket(ws, searchId, page);
try
{
var liveBtn = page.Locator(Selectors.LiveSearchButton).First;
await liveBtn.ClickAsync(new LocatorClickOptions { Timeout = 5000 });
Log.Information("Live search activated: {SearchId}", searchId);
}
catch
{
Log.Warning("Could not click Activate Live Search: {SearchId}", searchId);
}
}
public async Task PauseSearch(string searchId)
{
_pausedSearches.Add(searchId);
if (_pages.TryGetValue(searchId, out var page))
{
await page.CloseAsync();
_pages.Remove(searchId);
}
Log.Information("Search paused: {SearchId}", searchId);
}
public async Task<bool> ClickTravelToHideout(IPage page, string? itemId = null)
{
try
{
if (itemId != null)
{
var row = page.Locator(Selectors.ListingById(itemId));
if (await WaitForVisible(row, 5000))
{
var travelBtn = row.Locator(Selectors.TravelToHideoutButton).First;
if (await WaitForVisible(travelBtn, 3000))
{
await travelBtn.ClickAsync();
Log.Information("Clicked Travel to Hideout for item {ItemId}", itemId);
await HandleConfirmDialog(page);
return true;
}
}
}
var btn = page.Locator(Selectors.TravelToHideoutButton).First;
await btn.ClickAsync(new LocatorClickOptions { Timeout = 5000 });
Log.Information("Clicked Travel to Hideout");
await HandleConfirmDialog(page);
return true;
}
catch (Exception ex)
{
Log.Error(ex, "Failed to click Travel to Hideout");
return false;
}
}
public async Task<(IPage Page, List<TradeItem> Items)> OpenScrapPage(string tradeUrl)
{
if (_context == null) throw new InvalidOperationException("Browser not started");
var page = await _context.NewPageAsync();
var items = new List<TradeItem>();
page.Response += async (_, response) =>
{
if (!response.Url.Contains("/api/trade2/fetch/")) return;
try
{
var body = await response.TextAsync();
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("result", out var results) &&
results.ValueKind == JsonValueKind.Array)
{
foreach (var r in results.EnumerateArray())
items.Add(ParseTradeItem(r));
}
}
catch { /* Response may not be JSON */ }
};
await page.GotoAsync(tradeUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
await Helpers.Sleep(2000);
Log.Information("Scrap page opened: {Url} ({Count} items)", tradeUrl, items.Count);
return (page, items);
}
public string ExtractSearchId(string url)
{
var cleaned = System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", "");
var parts = cleaned.Split('/');
return parts.Length > 0 ? parts[^1] : url;
}
public static TradeItem ParseTradeItem(JsonElement r)
{
var id = r.GetProperty("id").GetString() ?? "";
int w = 1, h = 1, stashX = 0, stashY = 0;
var account = "";
if (r.TryGetProperty("item", out var item))
{
if (item.TryGetProperty("w", out var wProp)) w = wProp.GetInt32();
if (item.TryGetProperty("h", out var hProp)) h = hProp.GetInt32();
}
if (r.TryGetProperty("listing", out var listing))
{
if (listing.TryGetProperty("stash", out var stash))
{
if (stash.TryGetProperty("x", out var sx)) stashX = sx.GetInt32();
if (stash.TryGetProperty("y", out var sy)) stashY = sy.GetInt32();
}
if (listing.TryGetProperty("account", out var acc) &&
acc.TryGetProperty("name", out var accName))
account = accName.GetString() ?? "";
}
return new TradeItem(id, w, h, stashX, stashY, account);
}
public async ValueTask DisposeAsync()
{
foreach (var page in _pages.Values)
await page.CloseAsync();
_pages.Clear();
if (_context != null)
{
await _context.CloseAsync();
_context = null;
}
Log.Information("Trade monitor stopped");
}
private void HandleWebSocket(IWebSocket ws, string searchId, IPage page)
{
if (!ws.Url.Contains("/api/trade") || !ws.Url.Contains("/live/"))
return;
Log.Information("WebSocket connected for live search: {SearchId}", searchId);
ws.FrameReceived += (_, frame) =>
{
if (_pausedSearches.Contains(searchId)) return;
try
{
var payload = frame.Text ?? "";
using var doc = JsonDocument.Parse(payload);
if (doc.RootElement.TryGetProperty("new", out var newItems) &&
newItems.ValueKind == JsonValueKind.Array)
{
var ids = newItems.EnumerateArray()
.Select(e => e.GetString()!)
.Where(s => s != null)
.ToList();
if (ids.Count > 0)
{
Log.Information("New listings: {SearchId} ({Count} items)", searchId, ids.Count);
NewListings?.Invoke(searchId, ids, page);
}
}
}
catch { /* Not all frames are JSON */ }
};
ws.Close += (_, _) => Log.Warning("WebSocket closed: {SearchId}", searchId);
}
private async Task HandleConfirmDialog(IPage page)
{
await Helpers.Sleep(500);
try
{
var confirmBtn = page.Locator(Selectors.ConfirmYesButton).First;
if (await WaitForVisible(confirmBtn, 2000))
{
await confirmBtn.ClickAsync();
Log.Information("Confirmed dialog");
}
}
catch { /* No dialog */ }
}
private static async Task<bool> WaitForVisible(ILocator locator, int timeoutMs)
{
try
{
await locator.WaitForAsync(new LocatorWaitForOptions
{
State = WaitForSelectorState.Visible,
Timeout = timeoutMs
});
return true;
}
catch (TimeoutException) { return false; }
}
}

View file

@ -0,0 +1,17 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="using:Poe2Trade.Ui.Converters"
x:Class="Poe2Trade.Ui.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
<Application.Resources>
<conv:LogLevelToBrushConverter x:Key="LogLevelBrush" />
<conv:BoolToOccupiedBrushConverter x:Key="OccupiedBrush" />
<conv:LinkModeToColorConverter x:Key="ModeBrush" />
<conv:StatusDotBrushConverter x:Key="StatusDotBrush" />
<conv:ActiveOpacityConverter x:Key="ActiveOpacity" />
<conv:CellBorderConverter x:Key="CellBorderConverter" />
</Application.Resources>
</Application>

View file

@ -0,0 +1,44 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Poe2Trade.Bot;
using Poe2Trade.Core;
using Poe2Trade.Ui.ViewModels;
using Poe2Trade.Ui.Views;
namespace Poe2Trade.Ui;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var store = new ConfigStore();
var config = AppConfig.Load();
var bot = new BotOrchestrator(store, config);
var mainVm = new MainWindowViewModel(bot)
{
DebugVm = new DebugViewModel(bot),
SettingsVm = new SettingsViewModel(bot)
};
var window = new MainWindow { DataContext = mainVm };
window.SetConfigStore(store);
desktop.MainWindow = window;
desktop.ShutdownRequested += async (_, _) =>
{
await bot.DisposeAsync();
};
}
base.OnFrameworkInitializationCompleted();
}
}

View file

@ -0,0 +1,99 @@
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
using Poe2Trade.Core;
using Poe2Trade.Ui.ViewModels;
namespace Poe2Trade.Ui.Converters;
public class LogLevelToBrushConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value?.ToString()?.ToUpperInvariant() switch
{
"INFO" => new SolidColorBrush(Color.Parse("#58a6ff")),
"WARN" or "WARNING" => new SolidColorBrush(Color.Parse("#d29922")),
"ERROR" => new SolidColorBrush(Color.Parse("#f85149")),
"DEBUG" => new SolidColorBrush(Color.Parse("#8b949e")),
_ => new SolidColorBrush(Color.Parse("#e6edf3")),
};
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class BoolToOccupiedBrushConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var occupied = value is true;
return new SolidColorBrush(occupied ? Color.Parse("#238636") : Color.Parse("#161b22"));
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class LinkModeToColorConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value switch
{
LinkMode.Live => new SolidColorBrush(Color.Parse("#1f6feb")),
LinkMode.Scrap => new SolidColorBrush(Color.Parse("#9e6a03")),
_ => new SolidColorBrush(Color.Parse("#30363d")),
};
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class StatusDotBrushConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var state = value?.ToString() ?? "Idle";
return state switch
{
"Idle" => new SolidColorBrush(Color.Parse("#8b949e")),
"Paused" => new SolidColorBrush(Color.Parse("#d29922")),
_ => new SolidColorBrush(Color.Parse("#3fb950")),
};
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class ActiveOpacityConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is true ? 1.0 : 0.5;
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class CellBorderConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is CellState cell)
{
return new Thickness(
cell.BorderLeft ? 2 : 0,
cell.BorderTop ? 2 : 0,
cell.BorderRight ? 2 : 0,
cell.BorderBottom ? 2 : 0);
}
return new Thickness(0);
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.3" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
<ProjectReference Include="..\Poe2Trade.Bot\Poe2Trade.Bot.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,19 @@
using Avalonia;
using Poe2Trade.Core;
namespace Poe2Trade.Ui;
class Program
{
[STAThread]
public static void Main(string[] args)
{
Logging.Setup();
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
}

View file

@ -0,0 +1,201 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Poe2Trade.Bot;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Ui.ViewModels;
public partial class DebugViewModel : ObservableObject
{
private readonly BotOrchestrator _bot;
[ObservableProperty] private string _findText = "";
[ObservableProperty] private string _debugResult = "";
[ObservableProperty] private string _selectedGridLayout = "inventory";
[ObservableProperty] private decimal? _clickX;
[ObservableProperty] private decimal? _clickY;
public string[] GridLayoutNames { get; } =
[
"inventory", "stash12", "stash12_folder", "stash24",
"stash24_folder", "seller", "shop", "vendor"
];
public DebugViewModel(BotOrchestrator bot)
{
_bot = bot;
}
private bool EnsureReady()
{
if (_bot.IsReady) return true;
DebugResult = "Bot not started yet. Press Start first.";
return false;
}
[RelayCommand]
private async Task TakeScreenshot()
{
if (!EnsureReady()) return;
try
{
var path = Path.Combine("debug", $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png");
Directory.CreateDirectory("debug");
await _bot.Screen.SaveScreenshot(path);
DebugResult = $"Screenshot saved: {path}";
}
catch (Exception ex)
{
DebugResult = $"Screenshot failed: {ex.Message}";
Log.Error(ex, "Screenshot failed");
}
}
[RelayCommand]
private async Task RunOcr()
{
if (!EnsureReady()) return;
try
{
var text = await _bot.Screen.ReadFullScreen();
DebugResult = string.IsNullOrWhiteSpace(text) ? "(no text detected)" : text;
}
catch (Exception ex)
{
DebugResult = $"OCR failed: {ex.Message}";
Log.Error(ex, "OCR failed");
}
}
[RelayCommand]
private async Task GoHideout()
{
if (!EnsureReady()) return;
try
{
await _bot.Game.FocusGame();
await _bot.Game.GoToHideout();
DebugResult = "Sent /hideout command";
}
catch (Exception ex)
{
DebugResult = $"Go hideout failed: {ex.Message}";
Log.Error(ex, "Go hideout failed");
}
}
[RelayCommand]
private async Task FindTextOnScreen()
{
if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return;
try
{
var pos = await _bot.Screen.FindTextOnScreen(FindText, fuzzy: true);
DebugResult = pos.HasValue
? $"Found '{FindText}' at ({pos.Value.X}, {pos.Value.Y})"
: $"Text '{FindText}' not found";
}
catch (Exception ex)
{
DebugResult = $"Find text failed: {ex.Message}";
Log.Error(ex, "Find text failed");
}
}
[RelayCommand]
private async Task FindAndClick()
{
if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return;
try
{
await _bot.Game.FocusGame();
var pos = await _bot.Inventory.FindAndClickNameplate(FindText);
DebugResult = pos.HasValue
? $"Clicked '{FindText}' at ({pos.Value.X}, {pos.Value.Y})"
: $"Text '{FindText}' not found";
}
catch (Exception ex)
{
DebugResult = $"Find & click failed: {ex.Message}";
Log.Error(ex, "Find & click failed");
}
}
[RelayCommand]
private async Task ClickAt()
{
if (!EnsureReady()) return;
var x = (int)(ClickX ?? 0);
var y = (int)(ClickY ?? 0);
try
{
await _bot.Game.FocusGame();
await _bot.Game.LeftClickAt(x, y);
DebugResult = $"Clicked at ({x}, {y})";
}
catch (Exception ex)
{
DebugResult = $"Click failed: {ex.Message}";
Log.Error(ex, "Click at failed");
}
}
[RelayCommand]
private async Task ScanGrid()
{
if (!EnsureReady()) return;
try
{
var result = await _bot.Screen.Grid.Scan(SelectedGridLayout);
DebugResult = $"Grid scan '{SelectedGridLayout}': " +
$"{result.Layout.Cols}x{result.Layout.Rows}, " +
$"{result.Occupied.Count} occupied, " +
$"{result.Items.Count} items";
}
catch (Exception ex)
{
DebugResult = $"Grid scan failed: {ex.Message}";
Log.Error(ex, "Grid scan failed");
}
}
[RelayCommand]
private async Task ClickAnge()
{
if (!EnsureReady()) return;
try
{
await _bot.Game.FocusGame();
var pos = await _bot.Inventory.FindAndClickNameplate("ANGE");
DebugResult = pos.HasValue ? $"Clicked ANGE at ({pos.Value.X}, {pos.Value.Y})" : "ANGE not found";
}
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
}
[RelayCommand]
private async Task ClickStash()
{
if (!EnsureReady()) return;
try
{
await _bot.Game.FocusGame();
var pos = await _bot.Inventory.FindAndClickNameplate("STASH");
DebugResult = pos.HasValue ? $"Clicked STASH at ({pos.Value.X}, {pos.Value.Y})" : "STASH not found";
}
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
}
[RelayCommand]
private async Task ClickSalvage()
{
if (!EnsureReady()) return;
try
{
await _bot.Game.FocusGame();
var pos = await _bot.Inventory.FindAndClickNameplate("SALVAGE BENCH");
DebugResult = pos.HasValue ? $"Clicked SALVAGE at ({pos.Value.X}, {pos.Value.Y})" : "SALVAGE BENCH not found";
}
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
}
}

View file

@ -0,0 +1,183 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Poe2Trade.Bot;
using Poe2Trade.Core;
using Serilog;
namespace Poe2Trade.Ui.ViewModels;
public class LogEntry
{
public string Time { get; init; } = "";
public string Level { get; init; } = "";
public string Message { get; init; } = "";
}
public partial class CellState : ObservableObject
{
[ObservableProperty] private bool _isOccupied;
[ObservableProperty] private bool _borderTop;
[ObservableProperty] private bool _borderBottom;
[ObservableProperty] private bool _borderLeft;
[ObservableProperty] private bool _borderRight;
}
public partial class MainWindowViewModel : ObservableObject
{
private readonly BotOrchestrator _bot;
[ObservableProperty]
private string _state = "Idle";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PauseButtonText))]
private bool _isPaused;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartCommand))]
[NotifyCanExecuteChangedFor(nameof(PauseCommand))]
private bool _isStarted;
[ObservableProperty] private string _newUrl = "";
[ObservableProperty] private string _newLinkName = "";
[ObservableProperty] private LinkMode _newLinkMode = LinkMode.Live;
[ObservableProperty] private int _tradesCompleted;
[ObservableProperty] private int _tradesFailed;
[ObservableProperty] private int _activeLinksCount;
public static LinkMode[] LinkModes { get; } = [LinkMode.Live, LinkMode.Scrap];
public MainWindowViewModel(BotOrchestrator bot)
{
_bot = bot;
_isPaused = bot.IsPaused;
for (var i = 0; i < 60; i++)
InventoryCells.Add(new CellState());
bot.StatusUpdated += () =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
State = bot.State;
IsPaused = bot.IsPaused;
var status = bot.GetStatus();
TradesCompleted = status.TradesCompleted;
TradesFailed = status.TradesFailed;
ActiveLinksCount = status.Links.Count(l => l.Active);
OnPropertyChanged(nameof(Links));
UpdateInventoryGrid();
});
};
bot.LogMessage += (level, message) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
Logs.Add(new LogEntry
{
Time = DateTime.Now.ToString("HH:mm:ss"),
Level = level.ToUpperInvariant(),
Message = message
});
if (Logs.Count > 500) Logs.RemoveAt(0);
});
};
}
public string PauseButtonText => IsPaused ? "Resume" : "Pause";
public List<TradeLink> Links => _bot.Links.GetLinks();
public ObservableCollection<LogEntry> Logs { get; } = [];
public ObservableCollection<CellState> InventoryCells { get; } = [];
public int InventoryFreeCells => _bot.IsReady ? _bot.Inventory.Tracker.FreeCells : 60;
// Sub-ViewModels for tabs
public DebugViewModel? DebugVm { get; set; }
public SettingsViewModel? SettingsVm { get; set; }
[RelayCommand(CanExecute = nameof(CanStart))]
private async Task Start()
{
try
{
await _bot.Start(_bot.Config.TradeUrls);
IsStarted = true;
}
catch (Exception ex)
{
Log.Error(ex, "Failed to start bot");
Logs.Add(new LogEntry
{
Time = DateTime.Now.ToString("HH:mm:ss"),
Level = "ERROR",
Message = $"Start failed: {ex.Message}"
});
}
}
private bool CanStart() => !IsStarted;
[RelayCommand(CanExecute = nameof(CanPause))]
private void Pause()
{
if (_bot.IsPaused) _bot.Resume();
else _bot.Pause();
}
private bool CanPause() => IsStarted;
[RelayCommand]
private void AddLink()
{
if (string.IsNullOrWhiteSpace(NewUrl)) return;
_bot.AddLink(NewUrl, NewLinkName, NewLinkMode);
NewUrl = "";
NewLinkName = "";
}
[RelayCommand]
private void RemoveLink(string? id)
{
if (id != null) _bot.RemoveLink(id);
}
[RelayCommand]
private void ToggleLink(string? id)
{
if (id == null) return;
var link = _bot.Links.GetLink(id);
if (link != null) _bot.ToggleLink(id, !link.Active);
}
private void UpdateInventoryGrid()
{
if (!_bot.IsReady) return;
var (grid, items, _) = _bot.Inventory.GetInventoryState();
for (var r = 0; r < 5; r++)
for (var c = 0; c < 12; c++)
{
var cell = InventoryCells[r * 12 + c];
cell.IsOccupied = grid[r, c];
cell.BorderTop = false;
cell.BorderBottom = false;
cell.BorderLeft = false;
cell.BorderRight = false;
}
foreach (var item in items)
{
for (var r = item.Row; r < item.Row + item.H; r++)
for (var c = item.Col; c < item.Col + item.W; c++)
{
if (r >= 5 || c >= 12) continue;
var cell = InventoryCells[r * 12 + c];
if (r == item.Row) cell.BorderTop = true;
if (r == item.Row + item.H - 1) cell.BorderBottom = true;
if (c == item.Col) cell.BorderLeft = true;
if (c == item.Col + item.W - 1) cell.BorderRight = true;
}
}
OnPropertyChanged(nameof(InventoryFreeCells));
}
}

View file

@ -0,0 +1,65 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Poe2Trade.Bot;
namespace Poe2Trade.Ui.ViewModels;
public partial class SettingsViewModel : ObservableObject
{
private readonly BotOrchestrator _bot;
[ObservableProperty] private string _poe2LogPath = "";
[ObservableProperty] private string _windowTitle = "";
[ObservableProperty] private decimal? _travelTimeoutMs = 15000;
[ObservableProperty] private decimal? _stashScanTimeoutMs = 10000;
[ObservableProperty] private decimal? _waitForMoreItemsMs = 20000;
[ObservableProperty] private decimal? _betweenTradesDelayMs = 5000;
[ObservableProperty] private bool _isSaved;
public SettingsViewModel(BotOrchestrator bot)
{
_bot = bot;
LoadFromConfig();
}
private void LoadFromConfig()
{
var s = _bot.Store.Settings;
Poe2LogPath = s.Poe2LogPath;
WindowTitle = s.Poe2WindowTitle;
TravelTimeoutMs = s.TravelTimeoutMs;
StashScanTimeoutMs = s.StashScanTimeoutMs;
WaitForMoreItemsMs = s.WaitForMoreItemsMs;
BetweenTradesDelayMs = s.BetweenTradesDelayMs;
}
[RelayCommand]
private void SaveSettings()
{
_bot.Store.UpdateSettings(s =>
{
s.Poe2LogPath = Poe2LogPath;
s.Poe2WindowTitle = WindowTitle;
s.TravelTimeoutMs = (int)(TravelTimeoutMs ?? 15000);
s.StashScanTimeoutMs = (int)(StashScanTimeoutMs ?? 10000);
s.WaitForMoreItemsMs = (int)(WaitForMoreItemsMs ?? 20000);
s.BetweenTradesDelayMs = (int)(BetweenTradesDelayMs ?? 5000);
});
_bot.Config.Poe2LogPath = Poe2LogPath;
_bot.Config.Poe2WindowTitle = WindowTitle;
_bot.Config.TravelTimeoutMs = (int)(TravelTimeoutMs ?? 15000);
_bot.Config.StashScanTimeoutMs = (int)(StashScanTimeoutMs ?? 10000);
_bot.Config.WaitForMoreItemsMs = (int)(WaitForMoreItemsMs ?? 20000);
_bot.Config.BetweenTradesDelayMs = (int)(BetweenTradesDelayMs ?? 5000);
IsSaved = true;
}
partial void OnPoe2LogPathChanged(string value) => IsSaved = false;
partial void OnWindowTitleChanged(string value) => IsSaved = false;
partial void OnTravelTimeoutMsChanged(decimal? value) => IsSaved = false;
partial void OnStashScanTimeoutMsChanged(decimal? value) => IsSaved = false;
partial void OnWaitForMoreItemsMsChanged(decimal? value) => IsSaved = false;
partial void OnBetweenTradesDelayMsChanged(decimal? value) => IsSaved = false;
}

View file

@ -0,0 +1,329 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Poe2Trade.Ui.ViewModels"
x:Class="Poe2Trade.Ui.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="POE2 Trade Bot"
Width="960" Height="720"
Background="#0d1117">
<DockPanel Margin="12">
<!-- STATUS HEADER -->
<Border DockPanel.Dock="Top" Padding="12" Margin="0,0,0,8"
Background="#161b22" BorderBrush="#30363d"
BorderThickness="1" CornerRadius="8">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Status dot + text -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center">
<Ellipse Width="10" Height="10"
Fill="{Binding State, Converter={StaticResource StatusDotBrush}}" />
<TextBlock Text="{Binding State}" FontWeight="SemiBold"
Foreground="#e6edf3" VerticalAlignment="Center" />
</StackPanel>
<!-- Stats cards -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="16"
HorizontalAlignment="Center">
<Border Background="#21262d" CornerRadius="6" Padding="16,8">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="{Binding ActiveLinksCount}"
FontSize="20" FontWeight="Bold" Foreground="#58a6ff"
HorizontalAlignment="Center" />
<TextBlock Text="ACTIVE LINKS" FontSize="10" Foreground="#8b949e"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
<Border Background="#21262d" CornerRadius="6" Padding="16,8">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="{Binding TradesCompleted}"
FontSize="20" FontWeight="Bold" Foreground="#3fb950"
HorizontalAlignment="Center" />
<TextBlock Text="TRADES DONE" FontSize="10" Foreground="#8b949e"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
<Border Background="#21262d" CornerRadius="6" Padding="16,8">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="{Binding TradesFailed}"
FontSize="20" FontWeight="Bold" Foreground="#f85149"
HorizontalAlignment="Center" />
<TextBlock Text="FAILED" FontSize="10" Foreground="#8b949e"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
</StackPanel>
<!-- Controls -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
<Button Content="Start" Command="{Binding StartCommand}" />
<Button Content="{Binding PauseButtonText}" Command="{Binding PauseCommand}" />
</StackPanel>
</Grid>
</Border>
<!-- TABBED CONTENT -->
<TabControl>
<!-- ========== MAIN TAB ========== -->
<TabItem Header="Main">
<Grid RowDefinitions="Auto,*" Margin="0,8,0,0">
<!-- Inventory Grid (12x5) -->
<Border Grid.Row="0" Background="#161b22" BorderBrush="#30363d"
BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,0,0,8">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="INVENTORY" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<TextBlock Text="{Binding InventoryFreeCells, StringFormat='{}{0}/60 free'}"
FontSize="11" Foreground="#8b949e" Margin="12,0,0,0" />
</StackPanel>
<ItemsControl ItemsSource="{Binding InventoryCells}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="12" Rows="5" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CellState">
<Border Margin="1" CornerRadius="2" Height="22"
Background="{Binding IsOccupied, Converter={StaticResource OccupiedBrush}}"
BorderBrush="#3fb950" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DockPanel>
</Border>
<!-- Links + Logs split -->
<Grid Grid.Row="1" ColumnDefinitions="350,*">
<!-- Left: Trade Links -->
<Border Grid.Column="0" Background="#161b22" BorderBrush="#30363d"
BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,0,8,0">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="TRADE LINKS"
FontSize="11" FontWeight="SemiBold" Foreground="#8b949e"
Margin="0,0,0,8" />
<!-- Add link form -->
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,8">
<TextBox Text="{Binding NewLinkName}" Watermark="Name (optional)" />
<TextBox Text="{Binding NewUrl}" Watermark="Paste trade URL..." />
<StackPanel Orientation="Horizontal" Spacing="6">
<ComboBox ItemsSource="{x:Static vm:MainWindowViewModel.LinkModes}"
SelectedItem="{Binding NewLinkMode}" Width="100" />
<Button Content="Add" Command="{Binding AddLinkCommand}" />
</StackPanel>
</StackPanel>
<!-- Links list -->
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Links}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="0,2" Padding="8" Background="#21262d"
CornerRadius="4"
Opacity="{Binding Active, Converter={StaticResource ActiveOpacity}}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="X" FontSize="10"
VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).RemoveLinkCommand}"
CommandParameter="{Binding Id}" />
<CheckBox DockPanel.Dock="Left"
IsChecked="{Binding Active}"
Margin="0,0,8,0" VerticalAlignment="Center" />
<StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Background="{Binding Mode, Converter={StaticResource ModeBrush}}"
CornerRadius="4" Padding="6,2">
<TextBlock Text="{Binding Mode}"
FontSize="10" FontWeight="Bold"
Foreground="White" />
</Border>
<TextBlock Text="{Binding Name}" FontSize="12"
FontWeight="SemiBold" Foreground="#e6edf3" />
</StackPanel>
<TextBlock Text="{Binding Label}" FontSize="10"
Foreground="#8b949e" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<!-- Right: Logs -->
<Border Grid.Column="1" Background="#161b22" BorderBrush="#30363d"
BorderThickness="1" CornerRadius="8" Padding="10">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="ACTIVITY LOG"
FontSize="11" FontWeight="SemiBold" Foreground="#8b949e"
Margin="0,0,0,8" />
<ListBox ItemsSource="{Binding Logs}" x:Name="LogList"
Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LogEntry">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Time}" Foreground="#484f58"
FontSize="11" FontFamily="Consolas" />
<TextBlock Text="{Binding Message}" FontSize="11"
FontFamily="Consolas" TextWrapping="Wrap"
Foreground="{Binding Level, Converter={StaticResource LogLevelBrush}}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
</Grid>
</Grid>
</TabItem>
<!-- ========== DEBUG TAB ========== -->
<TabItem Header="Debug">
<ScrollViewer DataContext="{Binding DebugVm}" Margin="0,8,0,0">
<StackPanel Spacing="12" Margin="8" x:DataType="vm:DebugViewModel">
<!-- Row 1: Quick actions -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="12">
<StackPanel Spacing="8">
<TextBlock Text="QUICK ACTIONS" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Screenshot" Command="{Binding TakeScreenshotCommand}" />
<Button Content="OCR Screen" Command="{Binding RunOcrCommand}" />
<Button Content="Go Hideout" Command="{Binding GoHideoutCommand}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="ANGE" Command="{Binding ClickAngeCommand}" />
<Button Content="STASH" Command="{Binding ClickStashCommand}" />
<Button Content="SALVAGE" Command="{Binding ClickSalvageCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Row 2: Find text -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="12">
<StackPanel Spacing="8">
<TextBlock Text="FIND TEXT" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding FindText}" Watermark="Text to find..."
Width="300" />
<Button Content="Find" Command="{Binding FindTextOnScreenCommand}" />
<Button Content="Find &amp; Click" Command="{Binding FindAndClickCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Row 3: Grid scan -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="12">
<StackPanel Spacing="8">
<TextBlock Text="GRID SCAN" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Orientation="Horizontal" Spacing="8">
<ComboBox ItemsSource="{Binding GridLayoutNames}"
SelectedItem="{Binding SelectedGridLayout}" Width="160" />
<Button Content="Scan" Command="{Binding ScanGridCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Row 4: Click At -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="12">
<StackPanel Spacing="8">
<TextBlock Text="CLICK AT POSITION" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Orientation="Horizontal" Spacing="8">
<NumericUpDown Value="{Binding ClickX}" Watermark="X"
Width="100" Minimum="0" Maximum="2560" />
<NumericUpDown Value="{Binding ClickY}" Watermark="Y"
Width="100" Minimum="0" Maximum="1440" />
<Button Content="Click" Command="{Binding ClickAtCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Debug result output -->
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="12" MinHeight="60">
<StackPanel>
<TextBlock Text="OUTPUT" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" Margin="0,0,0,6" />
<TextBlock Text="{Binding DebugResult}" FontFamily="Consolas"
FontSize="11" Foreground="#e6edf3" TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- ========== SETTINGS TAB ========== -->
<TabItem Header="Settings">
<ScrollViewer DataContext="{Binding SettingsVm}" Margin="0,8,0,0">
<StackPanel Spacing="12" Margin="8" MaxWidth="600"
x:DataType="vm:SettingsViewModel">
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
CornerRadius="8" Padding="16">
<StackPanel Spacing="12">
<TextBlock Text="GENERAL SETTINGS" FontSize="11" FontWeight="SemiBold"
Foreground="#8b949e" />
<StackPanel Spacing="4">
<TextBlock Text="POE2 Client.txt Path" FontSize="11" Foreground="#8b949e" />
<TextBox Text="{Binding Poe2LogPath}" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Window Title" FontSize="11" Foreground="#8b949e" />
<TextBox Text="{Binding WindowTitle}" />
</StackPanel>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto">
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4" Margin="0,0,6,8">
<TextBlock Text="Travel Timeout (ms)" FontSize="11" Foreground="#8b949e" />
<NumericUpDown Value="{Binding TravelTimeoutMs}" Minimum="1000"
Maximum="60000" Increment="1000" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Spacing="4" Margin="6,0,0,8">
<TextBlock Text="Stash Scan Timeout (ms)" FontSize="11" Foreground="#8b949e" />
<NumericUpDown Value="{Binding StashScanTimeoutMs}" Minimum="1000"
Maximum="60000" Increment="1000" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="4" Margin="0,0,6,0">
<TextBlock Text="Wait for More Items (ms)" FontSize="11" Foreground="#8b949e" />
<NumericUpDown Value="{Binding WaitForMoreItemsMs}" Minimum="1000"
Maximum="120000" Increment="1000" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Spacing="4" Margin="6,0,0,0">
<TextBlock Text="Delay Between Trades (ms)" FontSize="11" Foreground="#8b949e" />
<NumericUpDown Value="{Binding BetweenTradesDelayMs}" Minimum="0"
Maximum="60000" Increment="1000" />
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,4,0,0">
<Button Content="Save Settings" Command="{Binding SaveSettingsCommand}" />
<TextBlock Text="Saved!" Foreground="#3fb950" VerticalAlignment="Center"
IsVisible="{Binding IsSaved}" FontWeight="SemiBold" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</DockPanel>
</Window>

View file

@ -0,0 +1,69 @@
using System.Collections.Specialized;
using Avalonia;
using Avalonia.Controls;
using Poe2Trade.Core;
using Poe2Trade.Ui.ViewModels;
namespace Poe2Trade.Ui.Views;
public partial class MainWindow : Window
{
private ConfigStore? _store;
public MainWindow()
{
InitializeComponent();
}
public void SetConfigStore(ConfigStore store)
{
_store = store;
var s = store.Settings;
if (s.WindowWidth.HasValue && s.WindowHeight.HasValue)
{
Width = s.WindowWidth.Value;
Height = s.WindowHeight.Value;
}
if (s.WindowX.HasValue && s.WindowY.HasValue)
{
Position = new PixelPoint((int)s.WindowX.Value, (int)s.WindowY.Value);
WindowStartupLocation = WindowStartupLocation.Manual;
}
else
{
WindowStartupLocation = WindowStartupLocation.CenterScreen;
}
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is MainWindowViewModel vm)
{
vm.Logs.CollectionChanged += (_, args) =>
{
if (args.Action == NotifyCollectionChangedAction.Add)
{
var logList = this.FindControl<ListBox>("LogList");
if (logList != null && vm.Logs.Count > 0)
logList.ScrollIntoView(vm.Logs[^1]);
}
};
}
}
protected override void OnClosing(WindowClosingEventArgs e)
{
if (_store != null)
{
_store.UpdateSettings(s =>
{
s.WindowX = Position.X;
s.WindowY = Position.Y;
s.WindowWidth = Width;
s.WindowHeight = Height;
});
}
base.OnClosing(e);
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Poe2Trade"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>