This commit is contained in:
Boki 2026-02-28 15:13:22 -05:00
parent bef61f841d
commit c3de5fdb63
107 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,29 @@
using Poe2Trade.Core;
using Poe2Trade.Screen;
namespace Poe2Trade.Inventory;
public interface IInventoryManager
{
event Action? Updated;
InventoryTracker Tracker { get; }
byte[]? LastScreenshot { get; }
bool IsAtOwnHideout { get; }
string SellerAccount { get; }
void SetLocation(bool atHome, string? seller = null);
Task ScanInventory(PostAction defaultAction = PostAction.Stash);
Task<ScanResult> SnapshotInventory();
Task ClearToStash();
Task<bool> EnsureAtOwnHideout();
Task ProcessInventory();
Task<bool> WaitForAreaTransition(int timeoutMs, Func<Task>? triggerAction = null);
Task<(int X, int Y)?> FindAndClickNameplate(string name, int maxRetries = 3, int retryDelayMs = 1000, System.Drawing.Rectangle? scanRegion = null, string? savePath = null);
Task DepositItemsToStash(List<PlacedItem> items);
Task DepositAllToOpenStash();
Task<bool> SalvageItems(List<PlacedItem> items);
Task<bool> IdentifyItems();
(bool[,] Grid, List<PlacedItem> Items, int Free) GetInventoryState();
Task ClickStashTab(StashTabInfo tab, StashTabInfo? parentFolder = null);
void ResetStashTabState();
Task GrabItemsFromStash(string layoutName, int maxItems, string? templatePath = null);
}

View file

@ -0,0 +1,551 @@
using Poe2Trade.Core;
using Poe2Trade.Game;
using Poe2Trade.GameLog;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Inventory;
public class InventoryManager : IInventoryManager
{
private static readonly string SalvageTemplate = Path.Combine("assets", "salvage.png");
public event Action? Updated;
public InventoryTracker Tracker { get; } = new();
private bool _atOwnHideout = true;
private string _sellerAccount = "";
private string? _currentFolder;
private string? _currentTab;
private readonly IGameController _game;
private readonly IScreenReader _screen;
private readonly IClientLogWatcher _logWatcher;
private readonly SavedSettings _config;
public byte[]? LastScreenshot { get; private set; }
public bool IsAtOwnHideout => _atOwnHideout;
public string SellerAccount => _sellerAccount;
public InventoryManager(IGameController game, IScreenReader screen, IClientLogWatcher logWatcher, SavedSettings 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(Delays.PostFocus);
await _game.OpenInventory();
_game.MoveMouseInstant(1700, 700);
await Helpers.Sleep(100);
var result = await _screen.Grid.Scan("inventory");
LastScreenshot = await _screen.CaptureRegion(GridLayouts.Inventory.Region);
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);
Updated?.Invoke();
await _game.PressEscape();
await Helpers.Sleep(Delays.PostFocus);
}
public async Task<ScanResult> SnapshotInventory()
{
_game.MoveMouseInstant(1700, 700);
await Helpers.Sleep(100);
var result = await _screen.Grid.Scan("inventory");
LastScreenshot = await _screen.CaptureRegion(GridLayouts.Inventory.Region);
Updated?.Invoke();
return result;
}
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(Delays.PostFocus);
var arrived = await WaitForAreaTransition(_config.TravelTimeoutMs, () => _game.GoToHideout());
if (!arrived)
{
Log.Error("Timed out going to own hideout");
return false;
}
await Helpers.Sleep(Delays.PostTravel);
_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(Delays.PostStashOpen);
await DepositAllToOpenStash();
await _game.PressEscape();
await Helpers.Sleep(Delays.PostEscape);
Log.Information("Deposit complete");
}
/// <summary>
/// Deposit one item at a time while stash is already open.
/// Scans → clicks first occupied cell → rescans → repeats.
/// Cells that remain occupied after clicking are marked as false positives and skipped.
/// </summary>
public async Task DepositAllToOpenStash()
{
await _game.KeyDown(Game.InputSender.VK.SHIFT);
await _game.HoldCtrl();
var falsePositives = new HashSet<(int Row, int Col)>();
(int Row, int Col)? lastClicked = null;
for (var i = 0; i < 60; i++)
{
if (lastClicked == null)
{
_game.MoveMouseInstant(1700, 700);
await Helpers.Sleep(50);
}
var scan = await _screen.Grid.Scan("inventory");
LastScreenshot = await _screen.CaptureRegion(GridLayouts.Inventory.Region);
Updated?.Invoke();
// If we clicked a cell last iteration, check if it's still there (= false positive)
if (lastClicked.HasValue &&
scan.Occupied.Any(c => c.Row == lastClicked.Value.Row && c.Col == lastClicked.Value.Col))
{
Log.Debug("Cell ({Row},{Col}) still occupied after click — false positive",
lastClicked.Value.Row, lastClicked.Value.Col);
falsePositives.Add(lastClicked.Value);
}
lastClicked = null;
var candidates = scan.Occupied
.Where(c => !falsePositives.Contains((c.Row, c.Col)))
.ToList();
if (candidates.Count == 0)
{
Log.Information(scan.Occupied.Count > 0
? $"All {scan.Occupied.Count} remaining occupied cells are false positives, done"
: "Inventory empty");
break;
}
var cell = candidates[0];
var center = _screen.Grid.GetCellCenter(GridLayouts.Inventory, cell.Row, cell.Col);
Log.Information("Depositing cell ({Row},{Col}) — {Real} candidates / {Total} detected",
cell.Row, cell.Col, candidates.Count, scan.Occupied.Count);
_game.MoveMouseInstant(center.X, center.Y);
await Helpers.RandomDelay(50, 100);
_game.LeftMouseDown();
_game.LeftMouseUp();
lastClicked = (cell.Row, cell.Col);
await Helpers.RandomDelay(50, 100);
}
await _game.ReleaseCtrl();
await _game.KeyUp(Game.InputSender.VK.SHIFT);
}
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(Delays.PostStashOpen);
var salvageBtn = await _screen.TemplateMatch(SalvageTemplate);
if (salvageBtn != null)
{
await _game.LeftClickAt(salvageBtn.X, salvageBtn.Y);
await Helpers.Sleep(Delays.PostEscape);
}
else
{
Log.Warning("Could not find salvage button via template match");
}
Log.Information("Salvaging {Count} inventory items", items.Count);
await CtrlClickItems(items, GridLayouts.Inventory);
await _game.PressEscape();
await Helpers.Sleep(Delays.PostEscape);
return true;
}
public async Task<bool> IdentifyItems()
{
var nameplate = await FindAndClickNameplate("Doryani");
if (nameplate == null)
{
Log.Error("Could not find Doryani nameplate");
return false;
}
await Helpers.Sleep(Delays.PostStashOpen);
// Dialog appears below and to the right of the nameplate
var dialogRegion = new Region(
nameplate.Value.X, nameplate.Value.Y,
460, 600);
var identifyPos = await _screen.FindTextInRegion(dialogRegion, "Identify");
if (identifyPos.HasValue)
{
await _game.LeftClickAt(identifyPos.Value.X, identifyPos.Value.Y);
await Helpers.Sleep(Delays.PostEscape);
}
else
{
Log.Warning("'Identify Items' not found in dialog region");
}
await _game.PressEscape();
await Helpers.Sleep(Delays.PostEscape);
return true;
}
private async Task CtrlClickItems(List<PlacedItem> items, GridLayout layout, int clickDelayMs = Delays.ClickInterval)
{
await _game.KeyDown(Game.InputSender.VK.SHIFT);
await _game.HoldCtrl();
foreach (var item in items)
{
var center = _screen.Grid.GetCellCenter(layout, item.Row, item.Col);
await _game.LeftClickAt(center.X, center.Y);
await Helpers.Sleep(clickDelayMs);
}
await _game.ReleaseCtrl();
await _game.KeyUp(Game.InputSender.VK.SHIFT);
await Helpers.Sleep(Delays.PostEscape);
}
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);
await DepositItemsToStash(Tracker.GetItems());
Tracker.Clear();
Log.Information("Inventory processing complete");
}
catch (Exception ex)
{
Log.Error(ex, "Inventory processing failed");
try { await _game.PressEscape(); await Helpers.Sleep(Delays.PostFocus); } catch { }
Tracker.Clear();
}
}
public async Task<(int X, int Y)?> FindAndClickNameplate(string name, int maxRetries = 3, int retryDelayMs = 1000, System.Drawing.Rectangle? scanRegion = null, string? savePath = null)
{
for (var attempt = 1; attempt <= maxRetries; attempt++)
{
Log.Information("Searching for nameplate '{Name}' (attempt {Attempt}/{Max})", name, attempt, maxRetries);
// Move mouse to bottom-left so it doesn't cover nameplates
_game.MoveMouseInstant(0, 1440);
await Helpers.Sleep(100);
// Nameplates hidden by default — capture clean reference
using var reference = _screen.CaptureRawBitmap();
// Hold Alt to show nameplates, capture, then release
await _game.KeyDown(Game.InputSender.VK.MENU);
await Helpers.Sleep(50);
using var current = _screen.CaptureRawBitmap();
await _game.KeyUp(Game.InputSender.VK.MENU);
// Diff OCR — only processes the bright nameplate regions
var attemptSavePath = savePath != null
? Path.Combine(Path.GetDirectoryName(savePath)!, $"{Path.GetFileNameWithoutExtension(savePath)}_attempt{attempt}{Path.GetExtension(savePath)}")
: null;
var result = await _screen.NameplateDiffOcr(reference, current, scanRegion, attemptSavePath);
var pos = FindWordInOcrResult(result, name, fuzzy: true);
if (pos.HasValue)
{
Log.Information("Clicking nameplate '{Name}' at ({X},{Y})", name, pos.Value.X, pos.Value.Y);
await _game.LeftClickAt(pos.Value.X, pos.Value.Y);
return pos;
}
Log.Information("Nameplate '{Name}' not found in diff OCR (attempt {Attempt}), text: {Text}", name, attempt, result.Text);
if (attempt < maxRetries)
await Helpers.Sleep(retryDelayMs);
}
Log.Warning("Nameplate '{Name}' not found after {Max} retries", name, maxRetries);
return null;
}
private static (int X, int Y)? FindWordInOcrResult(OcrResponse result, string needle, bool fuzzy)
{
var lower = needle.ToLowerInvariant();
// Multi-word: match against full line text
if (lower.Contains(' '))
{
foreach (var line in result.Lines)
{
if (line.Words.Count == 0) continue;
if (line.Text.Contains(needle, StringComparison.OrdinalIgnoreCase))
{
var first = line.Words[0];
var last = line.Words[^1];
return ((first.X + last.X + last.Width) / 2, (first.Y + last.Y + last.Height) / 2);
}
if (fuzzy)
{
var sim = BigramSimilarity(Normalize(needle), Normalize(line.Text));
if (sim >= 0.55)
{
var first = line.Words[0];
var last = line.Words[^1];
return ((first.X + last.X + last.Width) / 2, (first.Y + last.Y + last.Height) / 2);
}
}
}
return null;
}
// Single word — prefer exact line match ("STASH") over substring ("Guild Stash")
(int X, int Y)? containsMatch = null;
(int X, int Y)? fuzzyMatch = null;
foreach (var line in result.Lines)
{
// Exact line match — the entire line is just this word
if (line.Text.Equals(needle, StringComparison.OrdinalIgnoreCase) && line.Words.Count > 0)
{
var first = line.Words[0];
var last = line.Words[^1];
return ((first.X + last.X + last.Width) / 2, (first.Y + last.Y + last.Height) / 2);
}
foreach (var word in line.Words)
{
if (word.Text.Equals(needle, StringComparison.OrdinalIgnoreCase))
return (word.X + word.Width / 2, word.Y + word.Height / 2);
containsMatch ??= word.Text.Contains(needle, StringComparison.OrdinalIgnoreCase)
? (word.X + word.Width / 2, word.Y + word.Height / 2)
: null;
if (fuzzy)
fuzzyMatch ??= BigramSimilarity(Normalize(needle), Normalize(word.Text)) >= 0.55
? (word.X + word.Width / 2, word.Y + word.Height / 2)
: null;
}
}
return containsMatch ?? fuzzyMatch;
}
private static string Normalize(string s) =>
new(s.ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
private static double BigramSimilarity(string a, string b)
{
if (a.Length < 2 || b.Length < 2) return a == b ? 1 : 0;
var bigramsA = new Dictionary<(char, char), int>();
for (var i = 0; i < a.Length - 1; i++)
{
var bg = (a[i], a[i + 1]);
bigramsA[bg] = bigramsA.GetValueOrDefault(bg) + 1;
}
var matches = 0;
for (var i = 0; i < b.Length - 1; i++)
{
var bg = (b[i], b[i + 1]);
if (bigramsA.TryGetValue(bg, out var count) && count > 0)
{
matches++;
bigramsA[bg] = count - 1;
}
}
return 2.0 * matches / (a.Length - 1 + b.Length - 1);
}
public async Task<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);
}
public async Task ClickStashTab(StashTabInfo tab, StashTabInfo? parentFolder = null)
{
var folderName = parentFolder?.Name;
var tabName = tab.Name;
// Already on the right folder and tab — skip
if (_currentFolder == folderName && _currentTab == tabName)
{
Log.Debug("Already on tab '{Tab}' (folder '{Folder}'), skipping", tabName, folderName ?? "none");
return;
}
if (parentFolder != null && _currentFolder != folderName)
{
Log.Information("Clicking folder '{Folder}' at ({X},{Y})", parentFolder.Name, parentFolder.ClickX, parentFolder.ClickY);
await _game.LeftClickAt(parentFolder.ClickX, parentFolder.ClickY);
await Helpers.Sleep(200);
}
Log.Information("Clicking tab '{Tab}' at ({X},{Y})", tab.Name, tab.ClickX, tab.ClickY);
await _game.LeftClickAt(tab.ClickX, tab.ClickY);
await Helpers.Sleep(Delays.PostStashOpen);
_currentFolder = folderName;
_currentTab = tabName;
}
public void ResetStashTabState()
{
_currentFolder = null;
_currentTab = null;
}
public async Task GrabItemsFromStash(string layoutName, int maxItems, string? templatePath = null)
{
Log.Information("Grabbing up to {Max} items from stash layout '{Layout}' (template={Template})",
maxItems, layoutName, templatePath ?? "none");
var layout = GridLayouts.All[layoutName];
if (templatePath != null)
{
// Template matching mode: repeatedly find and click matching items
var grabbed = 0;
await _game.HoldCtrl();
while (grabbed < maxItems)
{
var match = await _screen.TemplateMatch(templatePath, layout.Region);
if (match == null) break;
Log.Information("Template match at ({X},{Y}), grabbing", match.X, match.Y);
await _game.LeftClickAt(match.X, match.Y);
await Helpers.Sleep(Delays.ClickInterval);
grabbed++;
}
await _game.ReleaseCtrl();
await Helpers.Sleep(Delays.PostEscape);
Log.Information("Grabbed {Count} matching items from stash", grabbed);
}
else
{
// Grid scan mode: grab all occupied cells
var result = await _screen.Grid.Scan(layoutName);
var grabbed = 0;
await _game.HoldCtrl();
foreach (var cell in result.Occupied)
{
if (grabbed >= maxItems) break;
var center = _screen.Grid.GetCellCenter(layout, cell.Row, cell.Col);
await _game.LeftClickAt(center.X, center.Y);
await Helpers.Sleep(Delays.ClickInterval);
grabbed++;
}
await _game.ReleaseCtrl();
await Helpers.Sleep(Delays.PostEscape);
Log.Information("Grabbed {Count} items from stash", grabbed);
}
}
}

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,192 @@
using Poe2Trade.Core;
using Poe2Trade.Game;
using Poe2Trade.Screen;
using Serilog;
namespace Poe2Trade.Inventory;
public class StashCalibrator
{
private readonly IScreenReader _screen;
private readonly IGameController _game;
// Tab bar sits above the stash grid
private static readonly Region TabBarRegion = new(23, 95, 840, 75);
// Sub-tab row between tab bar and folder grid (folders push grid down)
private static readonly Region SubTabRegion = new(23, 165, 840, 50);
// Horizontal gap (px) between OCR words to split into separate tab names
private const int TabGapThreshold = 25;
private const int PostTabClickMin = 120;
private const int PostTabClickMax = 250;
public StashCalibrator(IScreenReader screen, IGameController game)
{
_screen = screen;
_game = game;
}
/// <summary>
/// Calibrates an already-open stash/shop panel.
/// OCRs tab bar, clicks each tab, detects folders and grid size.
/// When firstFolderOnly is true (shop), only the first folder is inspected.
/// </summary>
public async Task<StashCalibration> CalibrateOpenPanel(bool firstFolderOnly = false)
{
var tabs = await OcrTabBar(TabBarRegion);
Log.Information("StashCalibrator: found {Count} tabs: {Names}",
tabs.Count, string.Join(", ", tabs.Select(t => t.Name)));
var limit = firstFolderOnly ? Math.Min(1, tabs.Count) : tabs.Count;
for (var i = 0; i < limit; i++)
{
var tab = tabs[i];
tab.Index = i;
// Click this tab
await _game.LeftClickAt(tab.ClickX, tab.ClickY);
await Helpers.RandomDelay(PostTabClickMin, PostTabClickMax);
// Check for sub-tabs (folder detection)
var subTabs = await OcrTabBar(SubTabRegion);
if (subTabs.Count > 0)
{
tab.IsFolder = true;
Log.Information("StashCalibrator: tab '{Name}' is a folder with {Count} sub-tabs",
tab.Name, subTabs.Count);
for (var j = 0; j < subTabs.Count; j++)
{
var sub = subTabs[j];
sub.Index = j;
// Click sub-tab to detect its grid size
await _game.LeftClickAt(sub.ClickX, sub.ClickY);
await Helpers.RandomDelay(PostTabClickMin, PostTabClickMax);
sub.GridCols = await DetectGridSize(isFolder: true);
}
tab.SubTabs = subTabs;
// Folder's own grid cols = first sub-tab's (they're usually the same)
tab.GridCols = subTabs[0].GridCols;
}
else
{
tab.IsFolder = false;
tab.GridCols = await DetectGridSize(isFolder: false);
}
}
// For firstFolderOnly, trim to only the inspected tabs
if (firstFolderOnly && tabs.Count > limit)
tabs = tabs.GetRange(0, limit);
return new StashCalibration
{
Tabs = tabs,
CalibratedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
}
/// <summary>
/// OCR a region and group words into tab names by horizontal gap.
/// Returns list with screen-absolute click positions.
/// </summary>
private async Task<List<StashTabInfo>> OcrTabBar(Region region)
{
// Save debug capture of the region
Directory.CreateDirectory("debug");
var tag = region == TabBarRegion ? "tabbar" : "subtab";
await _screen.SaveRegion(region, $"debug/calibrate-{tag}-{DateTime.Now:HHmmss}.png");
var ocr = await _screen.Ocr(region);
Log.Information("StashCalibrator: OCR region ({Tag}) raw text: '{Text}'", tag, ocr.Text);
var allWords = ocr.Lines.SelectMany(l => l.Words).ToList();
if (allWords.Count == 0) return [];
return GroupWordsIntoTabs(allWords, region, TabGapThreshold);
}
/// <summary>
/// Groups OCR words into tab names based on horizontal gaps.
/// Words within gapThreshold px → same tab. Larger gaps → separate tabs.
/// Converts region-relative coords to screen-absolute.
/// </summary>
private static List<StashTabInfo> GroupWordsIntoTabs(List<OcrWord> words, Region region, int gapThreshold)
{
// Sort left-to-right by X position
var sorted = words.OrderBy(w => w.X).ToList();
var tabs = new List<StashTabInfo>();
var currentWords = new List<OcrWord> { sorted[0] };
for (var i = 1; i < sorted.Count; i++)
{
var prev = currentWords[^1];
var curr = sorted[i];
var gap = curr.X - (prev.X + prev.Width);
if (gap > gapThreshold)
{
// Flush current group as a tab
tabs.Add(BuildTab(currentWords, region));
currentWords = [curr];
}
else
{
currentWords.Add(curr);
}
}
// Flush last group
tabs.Add(BuildTab(currentWords, region));
return tabs;
}
private static StashTabInfo BuildTab(List<OcrWord> words, Region region)
{
var name = string.Join(" ", words.Select(w => w.Text));
var minX = words.Min(w => w.X);
var maxX = words.Max(w => w.X + w.Width);
var minY = words.Min(w => w.Y);
var maxY = words.Max(w => w.Y + w.Height);
// Click center of the bounding box, converted to screen-absolute
var clickX = region.X + (minX + maxX) / 2;
var clickY = region.Y + (minY + maxY) / 2;
return new StashTabInfo
{
Name = name,
ClickX = clickX,
ClickY = clickY
};
}
/// <summary>
/// Detect grid size (12 or 24 columns) by scanning with both layouts.
/// The correct layout aligns with actual cells, so empty cells match the
/// empty template → lower occupancy. The wrong layout misaligns → ~100% occupied.
/// </summary>
private async Task<int> DetectGridSize(bool isFolder)
{
var layout12 = isFolder ? "stash12_folder" : "stash12";
var layout24 = isFolder ? "stash24_folder" : "stash24";
var scan12 = await _screen.Grid.Scan(layout12);
var scan24 = await _screen.Grid.Scan(layout24);
var total12 = scan12.Layout.Cols * scan12.Layout.Rows;
var total24 = scan24.Layout.Cols * scan24.Layout.Rows;
var rate12 = (double)scan12.Occupied.Count / total12;
var rate24 = (double)scan24.Occupied.Count / total24;
Log.Information("StashCalibrator: grid detection - 12col={Rate12:P1} ({Occ12}/{Tot12}), 24col={Rate24:P1} ({Occ24}/{Tot24})",
rate12, scan12.Occupied.Count, total12, rate24, scan24.Occupied.Count, total24);
return rate12 <= rate24 ? 12 : 24;
}
}