diff --git a/assets/empty35.png b/assets/empty35.png new file mode 100644 index 0000000..b368d13 Binary files /dev/null and b/assets/empty35.png differ diff --git a/assets/empty70.png b/assets/empty70.png new file mode 100644 index 0000000..9500757 Binary files /dev/null and b/assets/empty70.png differ diff --git a/src/dashboard/DashboardServer.ts b/src/dashboard/DashboardServer.ts index 183788d..4a93bad 100644 --- a/src/dashboard/DashboardServer.ts +++ b/src/dashboard/DashboardServer.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'url'; import { logger } from '../util/logger.js'; import type { BotController } from './BotController.js'; import type { ScreenReader } from '../game/ScreenReader.js'; +import { GRID_LAYOUTS } from '../game/GridReader.js'; import type { GameController } from '../game/GameController.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -150,6 +151,81 @@ export class DashboardServer { } }); + this.app.post('/api/debug/hideout', async (_req, res) => { + if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } + try { + await this.debug.gameController.focusGame(); + await this.debug.gameController.goToHideout(); + this.broadcastLog('info', 'Sent /hideout command'); + res.json({ ok: true }); + } catch (err) { + logger.error({ err }, 'Debug hideout failed'); + res.status(500).json({ error: 'Hideout command failed' }); + } + }); + + // Click first text, then wait for second text to appear and click it + this.app.post('/api/debug/click-then-click', async (req, res) => { + if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } + const { first, second, timeout = 5000 } = req.body as { first: string; second: string; timeout?: number }; + if (!first || !second) { res.status(400).json({ error: 'Missing first/second' }); return; } + try { + // Click the first target + const pos1 = await this.debug.screenReader.findTextOnScreen(first); + if (!pos1) { + this.broadcastLog('warn', `"${first}" not found on screen`); + res.json({ ok: true, found: false, step: 'first' }); + return; + } + await this.debug.gameController.focusGame(); + await this.debug.gameController.leftClickAt(pos1.x, pos1.y); + this.broadcastLog('info', `Clicked "${first}" at (${pos1.x}, ${pos1.y}), waiting for "${second}"...`); + + // Poll OCR until second text appears + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const pos2 = await this.debug.screenReader.findTextOnScreen(second); + if (pos2) { + await this.debug.gameController.leftClickAt(pos2.x, pos2.y); + this.broadcastLog('info', `Clicked "${second}" at (${pos2.x}, ${pos2.y})`); + res.json({ ok: true, found: true, position: pos2 }); + return; + } + } + this.broadcastLog('warn', `"${second}" not found after clicking "${first}" (timed out)`); + res.json({ ok: true, found: false, step: 'second' }); + } catch (err) { + logger.error({ err }, 'Debug click-then-click failed'); + res.status(500).json({ error: 'Click-then-click failed' }); + } + }); + + // Grid scan with calibrated positions + this.app.post('/api/debug/grid-scan', async (req, res) => { + if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } + const { layout: layoutName } = req.body as { layout: string }; + if (!GRID_LAYOUTS[layoutName]) { res.status(400).json({ error: `Unknown grid layout: ${layoutName}` }); return; } + try { + const result = await this.debug.screenReader.grid.scan(layoutName); + const imageBuffer = await this.debug.screenReader.captureRegion(result.layout.region); + const imageBase64 = imageBuffer.toString('base64'); + const r = result.layout.region; + this.broadcastLog('info', + `Grid scan (${layoutName}): ${result.layout.cols}x${result.layout.rows} at (${r.x},${r.y}) ${r.width}x${r.height} — ${result.occupied.length} occupied cells`); + res.json({ + ok: true, + occupied: result.occupied, + cols: result.layout.cols, + rows: result.layout.rows, + image: imageBase64, + region: result.layout.region, + }); + } catch (err) { + logger.error({ err }, 'Debug grid-scan failed'); + res.status(500).json({ error: 'Grid scan failed' }); + } + }); + this.app.post('/api/debug/find-and-click', async (req, res) => { if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; } const { text } = req.body as { text: string }; diff --git a/src/dashboard/index.html b/src/dashboard/index.html index 90cc279..4afd885 100644 --- a/src/dashboard/index.html +++ b/src/dashboard/index.html @@ -274,10 +274,48 @@ font-size: 12px; color: #8b949e; white-space: pre-wrap; - max-height: 200px; - overflow-y: auto; } .debug-result:empty { display: none; } + .grid-debug { + display: flex; + gap: 8px; + margin-top: 8px; + align-items: flex-start; + } + .grid-debug img { + border: 1px solid #30363d; + border-radius: 4px; + max-width: 280px; + max-height: 280px; + object-fit: contain; + } + .grid-view { + display: inline-grid; + gap: 1px; + background: #30363d; + border: 1px solid #30363d; + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; + } + .grid-cell { + width: 12px; height: 12px; + background: #0d1117; + } + .grid-cell.occupied { + background: #238636; + } + + .detect-badge { + display: inline-block; + font-size: 10px; + padding: 1px 6px; + border-radius: 4px; + margin-left: 6px; + font-weight: 600; + } + .detect-badge.ok { background: #238636; color: #fff; } + .detect-badge.fallback { background: #9e6a03; color: #fff; } @@ -339,6 +377,25 @@
+ +
+
+ + +
+
+ + + + +
+
+ + + + + +
@@ -588,6 +645,63 @@ } // Debug functions + async function debugGridScan(layout) { + showDebugResult(`Scanning ${layout}...`); + const res = await fetch('/api/debug/grid-scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ layout }), + }); + const data = await res.json(); + if (!data.ok) { showDebugResult(`Error: ${data.error}`); return; } + const el = document.getElementById('debugResult'); + const count = data.occupied.length; + const r = data.region; + let html = `${layout} ${data.cols}x${data.rows}`; + html += ` — ${count} occupied cell(s)`; + if (r) html += `
Region: (${r.x}, ${r.y}) ${r.width}x${r.height}`; + if (count > 0) { + html += `
` + data.occupied.map(c => `(${c.row},${c.col})`).join(' '); + } + html += '
'; + if (data.image) { + html += `Grid capture`; + } + html += `
`; + const set = new Set(data.occupied.map(c => c.row + ',' + c.col)); + for (let r = 0; r < data.rows; r++) { + for (let c = 0; c < data.cols; c++) { + html += `
`; + } + } + html += '
'; + el.innerHTML = html; + } + + async function debugAngeOption(option) { + showDebugResult(`Clicking ANGE → ${option}...`); + const res = await fetch('/api/debug/click-then-click', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ first: 'ANGE', second: option }), + }); + const data = await res.json(); + if (!data.found && data.step === 'first') { + showDebugResult('ANGE not found on screen'); + } else if (!data.found) { + showDebugResult(`"${option}" not found in ANGE menu (timed out)`); + } else { + showDebugResult(`Clicked "${option}" at (${data.position.x}, ${data.position.y})`); + } + } + + async function debugHideout() { + showDebugResult('Sending /hideout...'); + const res = await fetch('/api/debug/hideout', { method: 'POST' }); + const data = await res.json(); + showDebugResult(data.ok ? 'Sent /hideout command' : `Error: ${data.error}`); + } + async function debugScreenshot() { const res = await fetch('/api/debug/screenshot', { method: 'POST' }); const data = await res.json(); @@ -620,8 +734,8 @@ } } - async function debugFindAndClick() { - const text = document.getElementById('debugTextInput').value.trim(); + async function debugFindAndClick(directText) { + const text = directText || document.getElementById('debugTextInput').value.trim(); if (!text) return; showDebugResult(`Finding and clicking "${text}"...`); const res = await fetch('/api/debug/find-and-click', { diff --git a/src/game/GameController.ts b/src/game/GameController.ts index dec4463..ddb7fa4 100644 --- a/src/game/GameController.ts +++ b/src/game/GameController.ts @@ -40,7 +40,7 @@ export class GameController { // Clear any existing text await this.inputSender.selectAll(); await sleep(50); - await this.inputSender.pressKey(VK.DELETE); + await this.inputSender.pressKey(VK.BACK); await sleep(50); // Type the message @@ -66,7 +66,7 @@ export class GameController { // Clear any existing text await this.inputSender.selectAll(); await sleep(50); - await this.inputSender.pressKey(VK.DELETE); + await this.inputSender.pressKey(VK.BACK); await sleep(50); // Paste @@ -80,7 +80,7 @@ export class GameController { async goToHideout(): Promise { logger.info('Sending /hideout command'); - await this.sendChat('/hideout'); + await this.sendChatViaPaste('/hideout'); } async ctrlRightClickAt(x: number, y: number): Promise { diff --git a/src/game/GridReader.ts b/src/game/GridReader.ts new file mode 100644 index 0000000..35fd4bc --- /dev/null +++ b/src/game/GridReader.ts @@ -0,0 +1,157 @@ +import { logger } from '../util/logger.js'; +import type { OcrDaemon } from './OcrDaemon.js'; +import type { Region } from '../types.js'; + +// ── Grid type definitions ─────────────────────────────────────────────────── + +export interface GridLayout { + region: Region; + cols: number; + rows: number; +} + +export interface CellCoord { + row: number; + col: number; + x: number; + y: number; +} + +export interface ScanResult { + layout: GridLayout; + occupied: CellCoord[]; +} + +// ── Calibrated grid layouts (2560×1440) ───────────────────────────────────── + +export const GRID_LAYOUTS: Record = { + /** Player inventory — always 12×5, right side (below equipment slots) */ + inventory: { + region: { x: 1696, y: 788, width: 840, height: 350 }, + cols: 12, + rows: 5, + }, + /** Personal stash 12×12 — left side, tab not in folder */ + stash12: { + region: { x: 23, y: 169, width: 840, height: 840 }, + cols: 12, + rows: 12, + }, + /** Personal stash 12×12 — left side, tab in folder */ + stash12_folder: { + region: { x: 23, y: 216, width: 840, height: 840 }, + cols: 12, + rows: 12, + }, + /** Personal stash 24×24 (quad tab) — left side, tab not in folder */ + stash24: { + region: { x: 23, y: 169, width: 840, height: 840 }, + cols: 24, + rows: 24, + }, + /** Personal stash 24×24 (quad tab) — left side, tab in folder */ + stash24_folder: { + region: { x: 23, y: 216, width: 840, height: 840 }, + cols: 24, + rows: 24, + }, + /** Seller's public stash — always 12×12 */ + seller: { + region: { x: 416, y: 299, width: 840, height: 840 }, + cols: 12, + rows: 12, + }, + /** NPC shop — 12×12 */ + shop: { + region: { x: 23, y: 216, width: 840, height: 840 }, + cols: 12, + rows: 12, + }, + /** NPC vendor inventory — 12×12 */ + vendor: { + region: { x: 416, y: 369, width: 840, height: 840 }, + cols: 12, + rows: 12, + }, +}; + +// Backward-compat exports +export const INVENTORY = GRID_LAYOUTS.inventory; +export const STASH_12x12 = GRID_LAYOUTS.stash12; +export const STASH_24x24 = GRID_LAYOUTS.stash24; +export const SELLER_12x12 = GRID_LAYOUTS.seller; + +// ── GridReader ────────────────────────────────────────────────────────────── + +export class GridReader { + constructor(private daemon: OcrDaemon) {} + + /** + * Scan a named grid layout for occupied cells. + */ + async scan(layoutName: string, threshold?: number): Promise { + const layout = GRID_LAYOUTS[layoutName]; + if (!layout) throw new Error(`Unknown grid layout: ${layoutName}`); + + const t = performance.now(); + const occupied = await this.getOccupiedCells(layout, threshold); + + const ms = (performance.now() - t).toFixed(0); + logger.info( + { layoutName, cols: layout.cols, rows: layout.rows, occupied: occupied.length, ms }, + 'Grid scan complete', + ); + + return { layout, occupied }; + } + + /** Get the screen-space center of a grid cell */ + getCellCenter(layout: GridLayout, row: number, col: number): { x: number; y: number } { + const cellW = layout.region.width / layout.cols; + const cellH = layout.region.height / layout.rows; + return { + x: Math.round(layout.region.x + col * cellW + cellW / 2), + y: Math.round(layout.region.y + row * cellH + cellH / 2), + }; + } + + /** Scan the grid and return which cells are occupied */ + async getOccupiedCells(layout: GridLayout, threshold?: number): Promise { + const t = performance.now(); + const cells = await this.daemon.gridScan( + layout.region, + layout.cols, + layout.rows, + threshold, + ); + + const occupied: CellCoord[] = []; + for (let row = 0; row < cells.length; row++) { + for (let col = 0; col < cells[row].length; col++) { + if (cells[row][col]) { + const center = this.getCellCenter(layout, row, col); + occupied.push({ row, col, x: center.x, y: center.y }); + } + } + } + + const ms = (performance.now() - t).toFixed(0); + logger.info( + { layout: `${layout.cols}x${layout.rows}`, occupied: occupied.length, ms }, + 'Grid scan complete', + ); + return occupied; + } + + /** Get all cell centers in the grid */ + getAllCells(layout: GridLayout): CellCoord[] { + const cells: CellCoord[] = []; + for (let row = 0; row < layout.rows; row++) { + for (let col = 0; col < layout.cols; col++) { + const center = this.getCellCenter(layout, row, col); + cells.push({ row, col, x: center.x, y: center.y }); + } + } + return cells; + } +} diff --git a/src/game/InputSender.ts b/src/game/InputSender.ts index 585e18d..410ef26 100644 --- a/src/game/InputSender.ts +++ b/src/game/InputSender.ts @@ -202,7 +202,7 @@ export class InputSender { y: start.y + dy * 0.75 + perpY * (Math.random() - 0.5) * spread, }; - const steps = clamp(Math.round(distance / 15), 15, 40); + const steps = clamp(Math.round(distance / 30), 8, 20); for (let i = 1; i <= steps; i++) { const rawT = i / steps; @@ -214,30 +214,30 @@ export class InputSender { const jitterY = i < steps ? Math.round((Math.random() - 0.5) * 2) : 0; this.moveMouseRaw(Math.round(pt.x) + jitterX, Math.round(pt.y) + jitterY); - await sleep(2 + Math.random() * 3); // 2-5ms between steps + await sleep(1 + Math.random() * 2); // 1-3ms between steps } // Final exact landing this.moveMouseRaw(x, y); - await randomDelay(10, 25); + await randomDelay(5, 15); } async leftClick(x: number, y: number): Promise { await this.moveMouse(x, y); - await randomDelay(50, 100); + await randomDelay(20, 50); this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTDOWN); - await randomDelay(30, 80); + await randomDelay(15, 40); this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTUP); - await randomDelay(30, 60); + await randomDelay(15, 30); } async rightClick(x: number, y: number): Promise { await this.moveMouse(x, y); - await randomDelay(50, 100); + await randomDelay(20, 50); this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTDOWN); - await randomDelay(30, 80); + await randomDelay(15, 40); this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTUP); - await randomDelay(30, 60); + await randomDelay(15, 30); } async ctrlRightClick(x: number, y: number): Promise { diff --git a/src/game/OcrDaemon.ts b/src/game/OcrDaemon.ts index 256e7b5..5d1d162 100644 --- a/src/game/OcrDaemon.ts +++ b/src/game/OcrDaemon.ts @@ -24,10 +24,24 @@ export interface OcrResponse { lines: OcrLine[]; } +export interface DetectGridResult { + detected: boolean; + region?: Region; + cols?: number; + rows?: number; + cellWidth?: number; + cellHeight?: number; +} + interface DaemonRequest { cmd: string; region?: Region; path?: string; + cols?: number; + rows?: number; + threshold?: number; + minCellSize?: number; + maxCellSize?: number; } interface DaemonResponse { @@ -36,6 +50,13 @@ interface DaemonResponse { text?: string; lines?: OcrLine[]; image?: string; + cells?: boolean[][]; + detected?: boolean; + region?: Region; + cols?: number; + rows?: number; + cellWidth?: number; + cellHeight?: number; error?: string; } @@ -85,6 +106,28 @@ export class OcrDaemon { return Buffer.from(resp.image!, 'base64'); } + async gridScan(region: Region, cols: number, rows: number, threshold?: number): Promise { + const req: DaemonRequest = { cmd: 'grid', region, cols, rows }; + if (threshold) req.threshold = threshold; + const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT); + return resp.cells ?? []; + } + + async detectGrid(region: Region, minCellSize?: number, maxCellSize?: number): Promise { + const req: DaemonRequest = { cmd: 'detect-grid', region }; + if (minCellSize) req.minCellSize = minCellSize; + if (maxCellSize) req.maxCellSize = maxCellSize; + const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT); + return { + detected: resp.detected ?? false, + region: resp.region, + cols: resp.cols, + rows: resp.rows, + cellWidth: resp.cellWidth, + cellHeight: resp.cellHeight, + }; + } + async saveScreenshot(path: string, region?: Region): Promise { const req: DaemonRequest = { cmd: 'screenshot', path }; if (region) req.region = region; diff --git a/src/game/ScreenReader.ts b/src/game/ScreenReader.ts index bc34672..be451e4 100644 --- a/src/game/ScreenReader.ts +++ b/src/game/ScreenReader.ts @@ -2,6 +2,7 @@ import { mkdir } from 'fs/promises'; import { join } from 'path'; import { logger } from '../util/logger.js'; import { OcrDaemon, type OcrResponse } from './OcrDaemon.js'; +import { GridReader, type GridLayout, type CellCoord } from './GridReader.js'; import type { Region } from '../types.js'; function elapsed(start: number): string { @@ -10,6 +11,7 @@ function elapsed(start: number): string { export class ScreenReader { private daemon = new OcrDaemon(); + readonly grid = new GridReader(this.daemon); // ── Screenshot capture ────────────────────────────────────────────── diff --git a/src/index.ts b/src/index.ts index 01990d2..9c86d3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,14 @@ program const gameController = new GameController(config); dashboard.setDebugDeps({ screenReader, gameController }); + // Go to hideout on startup + dashboard.broadcastLog('info', 'Sending /hideout command...'); + await gameController.focusGame(); + await gameController.goToHideout(); + bot.state = 'IN_HIDEOUT'; + dashboard.broadcastStatus(); + dashboard.broadcastLog('info', 'In hideout, ready to trade'); + const logWatcher = new ClientLogWatcher(config.poe2LogPath); await logWatcher.start(); dashboard.broadcastLog('info', 'Watching Client.txt for game events'); diff --git a/tools/OcrDaemon/Program.cs b/tools/OcrDaemon/Program.cs index ea38c16..d74c87a 100644 --- a/tools/OcrDaemon/Program.cs +++ b/tools/OcrDaemon/Program.cs @@ -57,6 +57,12 @@ while ((line = stdin.ReadLine()) != null) case "capture": HandleCapture(request); break; + case "grid": + HandleGrid(request); + break; + case "detect-grid": + HandleDetectGrid(request); + break; default: WriteResponse(new ErrorResponse($"Unknown command: {request.Cmd}")); break; @@ -74,7 +80,7 @@ return 0; void HandleOcr(Request req, OcrEngine engine) { - using var bitmap = CaptureScreen(req.Region); + using var bitmap = CaptureOrLoad(req.File, req.Region); var softwareBitmap = BitmapToSoftwareBitmap(bitmap); var result = engine.RecognizeAsync(softwareBitmap).AsTask().GetAwaiter().GetResult(); @@ -107,7 +113,7 @@ void HandleScreenshot(Request req) return; } - using var bitmap = CaptureScreen(req.Region); + using var bitmap = CaptureOrLoad(req.File, req.Region); var format = GetImageFormat(req.Path); bitmap.Save(req.Path, format); WriteResponse(new OkResponse()); @@ -115,15 +121,573 @@ void HandleScreenshot(Request req) void HandleCapture(Request req) { - using var bitmap = CaptureScreen(req.Region); + using var bitmap = CaptureOrLoad(req.File, req.Region); using var ms = new MemoryStream(); bitmap.Save(ms, ImageFormat.Png); var base64 = Convert.ToBase64String(ms.ToArray()); WriteResponse(new CaptureResponse { Image = base64 }); } +// Pre-loaded empty cell templates (loaded lazily on first grid scan) +byte[]? emptyTemplate70Gray = null; +int emptyTemplate70W = 0, emptyTemplate70H = 0; +byte[]? emptyTemplate35Gray = null; +int emptyTemplate35W = 0, emptyTemplate35H = 0; + +void LoadTemplatesIfNeeded() +{ + if (emptyTemplate70Gray != null) return; + + // Look for templates relative to exe directory + var exeDir = AppContext.BaseDirectory; + // Templates are in assets/ at project root — walk up from bin/Release/net8.0-.../ + var projectRoot = System.IO.Path.GetFullPath(System.IO.Path.Combine(exeDir, "..", "..", "..", "..", "..")); + var t70Path = System.IO.Path.Combine(projectRoot, "assets", "empty70.png"); + var t35Path = System.IO.Path.Combine(projectRoot, "assets", "empty35.png"); + + if (System.IO.File.Exists(t70Path)) + { + using var bmp = new Bitmap(t70Path); + emptyTemplate70W = bmp.Width; + emptyTemplate70H = bmp.Height; + emptyTemplate70Gray = BitmapToGray(bmp); + } + if (System.IO.File.Exists(t35Path)) + { + using var bmp = new Bitmap(t35Path); + emptyTemplate35W = bmp.Width; + emptyTemplate35H = bmp.Height; + emptyTemplate35Gray = BitmapToGray(bmp); + } +} + +byte[] BitmapToGray(Bitmap bmp) +{ + int w = bmp.Width, h = bmp.Height; + var data = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + byte[] pixels = new byte[data.Stride * h]; + Marshal.Copy(data.Scan0, pixels, 0, pixels.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)((pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3); + } + return gray; +} + +void HandleGrid(Request req) +{ + if (req.Region == null || req.Cols <= 0 || req.Rows <= 0) + { + WriteResponse(new ErrorResponse("grid command requires region, cols, rows")); + return; + } + + LoadTemplatesIfNeeded(); + + using var bitmap = CaptureOrLoad(req.File, req.Region); + int cols = req.Cols; + int rows = req.Rows; + 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; + int templateW, templateH; + if (nominalCell <= 40 && emptyTemplate35Gray != null) + { + templateGray = emptyTemplate35Gray; + templateW = emptyTemplate35W; + templateH = emptyTemplate35H; + } + else if (emptyTemplate70Gray != null) + { + templateGray = emptyTemplate70Gray; + templateW = emptyTemplate70W; + templateH = emptyTemplate70H; + } + else + { + WriteResponse(new ErrorResponse("Empty cell templates not found in assets/")); + return; + } + + // Convert captured bitmap to grayscale + byte[] captureGray = BitmapToGray(bitmap); + int captureW = bitmap.Width; + + // Border to skip (outer pixels may differ between cells) + 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++; + } + + // Threshold for mean absolute difference — default 6 + double diffThreshold = req.Threshold > 0 ? req.Threshold : 2; + bool debug = req.Debug; + + if (debug) Console.Error.WriteLine($"Grid: {cols}x{rows}, cellW={cellW:F1}, cellH={cellH:F1}, border={border}, threshold={diffThreshold}"); + + var cells = new List>(); + for (int row = 0; row < rows; row++) + { + var rowList = new List(); + var debugDiffs = new List(); + 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); + + // Compare inner pixels of cell vs template + long diffSum = 0; + int compared = 0; + int innerW = Math.Min(cw, templateW) - border; + int innerH = Math.Min(ch, templateH) - border; + for (int py = border; py < innerH; py++) + { + for (int px = border; px < innerW; px++) + { + int cellVal = captureGray[(cy0 + py) * captureW + (cx0 + px)]; + int tmplVal = templateGray[py * templateW + px]; + diffSum += Math.Abs(cellVal - tmplVal); + compared++; + } + } + double meanDiff = compared > 0 ? (double)diffSum / compared : 0; + bool occupied = meanDiff > diffThreshold; + rowList.Add(occupied); + if (debug) debugDiffs.Add($"{meanDiff,5:F1}{(occupied ? "*" : " ")}"); + } + cells.Add(rowList); + if (debug) Console.Error.WriteLine($" Row {row,2}: {string.Join(" ", debugDiffs)}"); + } + + WriteResponse(new GridResponse { Cells = cells }); +} + +void HandleDetectGrid(Request req) +{ + if (req.Region == null) + { + WriteResponse(new ErrorResponse("detect-grid requires region")); + return; + } + + int minCell = req.MinCellSize > 0 ? req.MinCellSize : 20; + int maxCell = req.MaxCellSize > 0 ? req.MaxCellSize : 70; + bool debug = req.Debug; + + Bitmap bitmap = CaptureOrLoad(req.File, req.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(); + + // ── Pass 1: Scan horizontal bands using "very dark pixel density" ── + // Grid lines are nearly all very dark (density ~0.9), cell interiors are + // partially dark (0.3-0.5), game world is mostly bright (density ~0.05). + // This creates clear periodic peaks at grid line positions. + int bandH = 200; + int bandStep = 40; + const int veryDarkPixelThresh = 12; // pixels below this brightness = "very dark" + const double gridSegThresh = 0.25; // density above this = potential grid column + + var candidates = new List<(int bandY, int cellW, double hAc, int hLeft, int hRight)>(); + + for (int by = 0; by + bandH <= h; by += bandStep) + { + // "Very dark pixel density" per column: fraction of pixels below threshold + 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; + } + + // Find segments where density > gridSegThresh (grid panel regions) + var gridSegs = FindDarkDensitySegments(darkDensity, gridSegThresh, 200); + + foreach (var (segLeft, segRight) in gridSegs) + { + // Extract segment and run AC + int segLen = segRight - segLeft; + double[] segment = new double[segLen]; + Array.Copy(darkDensity, segLeft, segment, 0, segLen); + + var (period, acScore) = FindPeriodWithScore(segment, minCell, maxCell); + + if (period <= 0) continue; + + // FindGridExtent within the segment + var (extLeft, extRight) = FindGridExtent(segment, period); + if (extLeft < 0) continue; + + // Map back to full image coordinates + int absLeft = segLeft + extLeft; + int absRight = segLeft + extRight; + int extent = absRight - absLeft; + + // Require at least 8 cells wide AND 200px absolute minimum + if (extent < period * 8 || extent < 200) continue; + + if (debug) Console.Error.WriteLine( + $" Band y={by}: seg=[{segLeft}-{segRight}] period={period}, AC={acScore:F3}, " + + $"extent={absLeft}-{absRight}={extent}px ({extent / period} cells)"); + + candidates.Add((by, period, acScore, absLeft, absRight)); + } + } + + if (debug) Console.Error.WriteLine($"Pass 1: {candidates.Count} candidates"); + + // Sort by score = AC * extent (prefer large strongly-periodic areas) + 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; + + // Row "very dark pixel density" within the detected column range + 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; + } + + // Find grid panel vertical segment + var vGridSegs = FindDarkDensitySegments(rowDensity, gridSegThresh, 100); + if (vGridSegs.Count == 0) continue; + + // Use the largest segment + 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) = FindPeriodWithScore(vSegment, minCell, maxCell); + if (cellH <= 0) continue; + + var (extTop, extBottom) = FindGridExtent(vSegment, cellH); + if (extTop < 0) continue; + + int top = vSegTop + extTop; + int bottom = vSegTop + extBottom; + int vExtent = bottom - top; + + // Require at least 3 rows tall AND 100px absolute minimum + if (vExtent < cellH * 3 || vExtent < 100) continue; + + if (debug) Console.Error.WriteLine( + $" 2D candidate: cellW={cand.cellW}, cellH={cellH}, " + + $"region=({cand.hLeft},{top})-({cand.hRight},{bottom}), " + + $"vAC={vAc:F3}, extent={vExtent}px ({vExtent / cellH} rows)"); + + // ── Found a valid 2D grid ── + 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)); + + // Snap grid dimensions to exact multiples of cell size + gridW = cols * cand.cellW; + gridH = rows * cellH; + + if (debug) Console.Error.WriteLine( + $" => cols={cols}, rows={rows}, gridW={gridW}, gridH={gridH}"); + + WriteResponse(new DetectGridResponse + { + Detected = true, + Region = new RegionRect + { + X = req.Region.X + cand.hLeft, + Y = req.Region.Y + top, + Width = gridW, + Height = gridH, + }, + Cols = cols, + Rows = rows, + CellWidth = Math.Round((double)gridW / cols, 1), + CellHeight = Math.Round((double)gridH / rows, 1), + }); + return; + } + + if (debug) Console.Error.WriteLine(" No valid 2D grid found"); + WriteResponse(new DetectGridResponse { Detected = false }); +} + +/// Find the dominant period in a signal using autocorrelation. +/// Returns (period, score) where score is the autocorrelation strength. +(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; + } + + // Find the first significant peak — this is the fundamental period. + // Using "first" avoids picking harmonics (2x, 3x) or unrelated larger patterns. + 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); +} + +/// Find contiguous segments where values are ABOVE threshold. +/// Used to find grid panel regions by density of very dark pixels. +/// Allows brief gaps (up to 5px) to handle grid borders. +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; +} + +/// Debug: find the top N AC peaks in a signal +List<(int lag, double ac)> FindTopAcPeaks(double[] signal, int minPeriod, int maxPeriod, int topN) +{ + int n = signal.Length; + if (n < minPeriod * 3) return []; + + 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 []; + + int maxLag = Math.Min(maxPeriod, n / 3); + var peaks = new List<(int lag, double ac)>(); + 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] >= ac[lag - 1] && ac[lag] >= ac[lag + 1] && ac[lag] > 0.005) + peaks.Add((lag, ac[lag])); + } + peaks.Sort((a, b) => b.ac.CompareTo(a.ac)); + return peaks.Take(topN).ToList(); +} + +/// Find the extent of the grid in a 1D profile using local autocorrelation +/// at the specific detected period. Only regions where the signal actually +/// repeats at the given period will score high — much more precise than variance. +(int start, int end) FindGridExtent(double[] signal, int period) +{ + int n = signal.Length; + int halfWin = period * 2; // window radius: 2 periods each side + if (n < halfWin * 2 + period) return (-1, -1); + + // Compute local AC at the specific lag=period in a sliding window + 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; + + // Local mean + double sum = 0; + for (int i = wStart; i < wEnd; i++) + sum += signal[i]; + double mean = sum / count; + + // Local variance + double varSum = 0; + for (int i = wStart; i < wEnd; i++) + varSum += (signal[i] - mean) * (signal[i] - mean); + + if (varSum < 1.0) continue; + + // AC at the specific lag=period + 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); + } + + // Find the longest contiguous run above threshold + 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 curStart = -1; + for (int i = 0; i < n; i++) + { + if (localAc[i] > threshold) + { + if (curStart < 0) curStart = i; + } + else + { + if (curStart >= 0) + { + int len = i - curStart; + if (len > bestLen) + { + bestLen = len; + bestStart = curStart; + bestEnd = i; + } + curStart = -1; + } + } + } + // Handle run extending to end of signal + if (curStart >= 0) + { + int len = n - curStart; + if (len > bestLen) + { + bestStart = curStart; + bestEnd = n; + } + } + + if (bestStart < 0) return (-1, -1); + + // Small extension to include cell borders at edges + bestStart = Math.Max(0, bestStart - period / 4); + bestEnd = Math.Min(n - 1, bestEnd + period / 4); + + return (bestStart, bestEnd); +} + // ── Screen Capture ────────────────────────────────────────────────────────── +/// Capture from screen, or load from file if specified. +/// When file is set, loads the image and crops to region. +Bitmap CaptureOrLoad(string? file, RegionRect? 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); +} + Bitmap CaptureScreen(RegionRect? region) { int x, y, w, h; @@ -203,6 +767,27 @@ class Request [JsonPropertyName("path")] public string? Path { get; set; } + + [JsonPropertyName("cols")] + public int Cols { get; set; } + + [JsonPropertyName("rows")] + public int Rows { get; set; } + + [JsonPropertyName("threshold")] + public int Threshold { get; set; } + + [JsonPropertyName("minCellSize")] + public int MinCellSize { get; set; } + + [JsonPropertyName("maxCellSize")] + public int MaxCellSize { get; set; } + + [JsonPropertyName("file")] + public string? File { get; set; } + + [JsonPropertyName("debug")] + public bool Debug { get; set; } } class RegionRect @@ -291,3 +876,36 @@ class CaptureResponse [JsonPropertyName("image")] public string Image { get; set; } = ""; } + +class GridResponse +{ + [JsonPropertyName("ok")] + public bool Ok => true; + + [JsonPropertyName("cells")] + public List> Cells { get; set; } = []; +} + +class DetectGridResponse +{ + [JsonPropertyName("ok")] + public bool Ok => true; + + [JsonPropertyName("detected")] + public bool Detected { get; set; } + + [JsonPropertyName("region")] + public RegionRect? Region { get; set; } + + [JsonPropertyName("cols")] + public int Cols { get; set; } + + [JsonPropertyName("rows")] + public int Rows { get; set; } + + [JsonPropertyName("cellWidth")] + public double CellWidth { get; set; } + + [JsonPropertyName("cellHeight")] + public double CellHeight { get; set; } +}