import fs from "node:fs/promises"; import http, { type Server } from "node:http"; import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { CaptureOptions, CaptureResult, CaptureSourceInfo, ClickResult, ReviewSampleListResult, NativeScannerCatalogStatus, NativeScannerDataStatus, NativeScannerImageLoadStatus, NativeScannerPreflightStatus, NativeScannerProcessOptions, NativeScannerProcessStatus, NativeScannerResultsLoadOptions, NativeScannerResultsLoadStatus, NativeScannerRunStatus, ScannerCommand, ScannerStatusPayload, AppRuntimeInfo, } from "../src/types/global.js"; import { validateLookupPackage } from "../src/lib/genshinLookup.js"; const execFileAsync = promisify(execFile); interface DevControlServerDependencies { registeredHotkeys: Record; appBuild: AppRuntimeInfo; hasMainWindow: () => boolean; sendScannerCommand: (command: ScannerCommand | "probe-click") => void; clickScreen: (x: number, y: number) => Promise; scannerStatus: () => ScannerStatusPayload; nativeScannerDataStatus: () => Promise; nativeScannerCatalog: () => Promise; nativeScannerPreflight: (options?: { category?: string }) => Promise; nativeScannerStart: (options?: { limit?: number; category?: string }) => Promise; nativeScannerStop: () => Promise; nativeScannerStatus: () => Promise; nativeScannerProcessRun: (options?: NativeScannerProcessOptions) => Promise; nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => Promise; nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => Promise; warmOcr: (engine: "current") => Promise; loadReviewSamples: (limit?: number) => Promise; listCaptureSources: () => Promise; captureSource: ( id: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions, ) => Promise; requestShutdown?: (reason: string) => void; } function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) { res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", }); res.end(JSON.stringify(payload)); } function dataUrlBase64(dataUrl: string) { return dataUrl.replace(/^data:image\/png;base64,/, ""); } function devCaptureOutputDir() { return path.join(process.cwd(), "outputs", "live-capture"); } function safeDebugFilePart(value: string) { return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "capture"; } function dataUrlFingerprint(dataUrl: string | undefined) { if (!dataUrl) return ""; let hash = 2166136261; const stride = Math.max(1, Math.floor(dataUrl.length / 4096)); for (let index = 0; index < dataUrl.length; index += stride) { hash ^= dataUrl.charCodeAt(index); hash = Math.imul(hash, 16777619); } return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`; } async function writeDevCaptureImage(filePath: string, dataUrl: string | undefined) { if (!dataUrl) return null; await fs.writeFile(filePath, Buffer.from(dataUrlBase64(dataUrl), "base64")); return filePath; } async function writeDevCaptureSnapshot(capture: CaptureResult) { const outputDir = devCaptureOutputDir(); await fs.mkdir(outputDir, { recursive: true }); const stamp = new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, ""); const prefix = safeDebugFilePart(`${stamp}-${capture.name}`); const files = { full: await writeDevCaptureImage(path.join(outputDir, `${prefix}-full.png`), capture.dataUrl), detail: await writeDevCaptureImage(path.join(outputDir, `${prefix}-detail.png`), capture.detailDataUrl), inventory: await writeDevCaptureImage(path.join(outputDir, `${prefix}-inventory.png`), capture.inventoryDataUrl), crops: [] as Array<{ id: string; label: string; path: string; rect: { x: number; y: number; width: number; height: number } }>, }; for (const crop of capture.crops ?? []) { const cropPath = path.join(outputDir, `${prefix}-${safeDebugFilePart(crop.id)}.png`); const written = await writeDevCaptureImage(cropPath, crop.dataUrl); if (written) files.crops.push({ id: crop.id, label: crop.label, path: written, rect: crop.rect }); } const summary = { id: capture.id, name: capture.name, width: capture.width, height: capture.height, capturedAt: capture.capturedAt, captureTarget: capture.captureTarget, ocrSkipped: capture.ocrSkipped, ocrTimedOut: capture.ocrTimedOut, layout: capture.layout, inventoryGrid: capture.inventoryGrid ? { rows: capture.inventoryGrid.rows, cols: capture.inventoryGrid.cols, confidence: capture.inventoryGrid.confidence, source: capture.inventoryGrid.source, firstCenter: capture.inventoryGrid.centers[0] ?? null, lastCenter: capture.inventoryGrid.centers.at(-1) ?? null, } : null, inventoryCount: capture.inventoryCount ?? null, locked: capture.locked, lockSignal: capture.lockSignal, crops: (capture.crops ?? []).map((crop) => ({ id: crop.id, label: crop.label, rect: crop.rect })), ocr: capture.ocr ?? [], files, }; const summaryPath = path.join(outputDir, `${prefix}-summary.json`); await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), "utf8"); return { ...summary, summaryPath }; } function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function findGenshinSource(sources: CaptureSourceInfo[], sourceId: string | null) { return sourceId ? sources.find((entry) => entry.id === sourceId) : sources.find((entry) => entry.isGenshinCandidate); } function sourceListForError(sources: CaptureSourceInfo[]) { return sources.map(({ id, name, isGenshinCandidate }) => ({ id, name, isGenshinCandidate })); } export function createDevControlServer(deps: DevControlServerDependencies): Server { const server = http.createServer((req, res) => { if (req.socket.remoteAddress && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress)) { writeDevJson(res, 403, { ok: false, error: "local only" }); return; } const url = new URL(req.url ?? "/", "http://127.0.0.1"); if (url.pathname === "/health") { writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow(), appBuild: deps.appBuild }); return; } if (url.pathname === "/dev/shutdown") { if (!deps.requestShutdown) { writeDevJson(res, 501, { ok: false, error: "shutdown not supported" }); return; } const reason = url.searchParams.get("reason") || "dev-control shutdown requested"; writeDevJson(res, 200, { ok: true, appBuild: deps.appBuild, reason }); setTimeout(() => deps.requestShutdown?.(reason), 50); return; } if (url.pathname === "/scanner/start") { const limit = Number(url.searchParams.get("limit") ?? Number.NaN); const hasLimit = Number.isFinite(limit) && limit > 0; const category = url.searchParams.get("category") ?? undefined; deps.nativeScannerStart({ ...(hasLimit ? { limit } : {}), ...(category ? { category } : {}) }) .then((scanner) => writeDevJson(res, 200, { ok: true, scanner })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/stop") { deps.nativeScannerStop() .then((scanner) => writeDevJson(res, 200, { ok: true, scanner })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/probe") { deps.sendScannerCommand("probe-click"); writeDevJson(res, 200, { ok: true, command: "probe-click" }); return; } if (url.pathname === "/automation/click") { const x = Number(url.searchParams.get("x")); const y = Number(url.searchParams.get("y")); if (!Number.isFinite(x) || !Number.isFinite(y)) { writeDevJson(res, 400, { ok: false, error: "x and y query params are required" }); return; } deps.clickScreen(Math.round(x), Math.round(y)) .then((payload: unknown) => writeDevJson(res, 200, { ok: true, payload })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/status") { deps.nativeScannerStatus() .then((nativeScanner) => writeDevJson(res, 200, { ok: true, status: deps.scannerStatus(), nativeScanner })) .catch(() => writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() })); return; } if (url.pathname === "/scanner/native/data") { deps.nativeScannerDataStatus() .then((status) => writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/native/catalog") { deps.nativeScannerCatalog() .then((catalog) => writeDevJson(res, catalog.data.valid ? 200 : 409, { ok: catalog.data.valid, catalog })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/native/preflight") { const category = url.searchParams.get("category") ?? undefined; deps.nativeScannerPreflight(category ? { category } : undefined) .then((status) => writeDevJson(res, status.ready ? 200 : 409, { ok: status.ready, status })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/native/process") { const runDir = url.searchParams.get("runDir") ?? undefined; const persist = url.searchParams.get("persist") === "1"; const stream = url.searchParams.get("stream") === "1"; const limit = Number(url.searchParams.get("limit") ?? Number.NaN); const expectedTotal = Number(url.searchParams.get("expectedTotal") ?? Number.NaN); deps.nativeScannerProcessRun({ runDir, persist, stream, limit: Number.isFinite(limit) && limit > 0 ? limit : undefined, expectedTotal: Number.isFinite(expectedTotal) && expectedTotal > 0 ? expectedTotal : undefined, }) .then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/native/results") { const runDir = url.searchParams.get("runDir") ?? undefined; const limit = Number(url.searchParams.get("limit") ?? Number.NaN); const afterSequence = Number(url.searchParams.get("afterSequence") ?? Number.NaN); deps.nativeScannerLoadResults({ runDir, limit: Number.isFinite(limit) && limit > 0 ? limit : undefined, afterSequence: Number.isFinite(afterSequence) && afterSequence >= 0 ? afterSequence : undefined, }) .then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/native/image") { const runDir = url.searchParams.get("runDir") ?? undefined; const imagePath = url.searchParams.get("imagePath"); if (!imagePath) { writeDevJson(res, 400, { ok: false, error: "imagePath query param is required" }); return; } deps.nativeScannerLoadImage({ runDir, imagePath }) .then((status) => writeDevJson(res, status.ok ? 200 : 409, { ok: status.ok, status })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/ocr/warmup") { deps.warmOcr("current") .then((status) => writeDevJson(res, 200, { ok: true, status })) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/lookup/status") { const status = validateLookupPackage(); writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status }); return; } if (url.pathname === "/scanner/lookup/regenerate") { execFileAsync("node", ["scripts/generate-genshin-data.cjs"], { cwd: process.cwd(), windowsHide: true, timeout: 120000 }) .then(({ stdout, stderr }) => { const status = validateLookupPackage(); writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status, stdout, stderr }); }) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/scanner/benchmark-ocr") { const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 1) || 1)); const sourceId = url.searchParams.get("sourceId"); const profileParam = url.searchParams.get("profile"); const ocrProfile: "full" | "fast" = profileParam === "full" ? "full" : "fast"; deps.listCaptureSources() .then(async (sources) => { const source = findGenshinSource(sources, sourceId); if (!source) { writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) }); return; } const benchmarkSource = source; async function runCurrentBenchmark() { const startedAt = Date.now(); const captures: Array<{ index: number; elapsedMs: number; ocrFields: number; timedOut: boolean; ocrSkipped: boolean; artifactDetailConfidence: number; sanctified: boolean; prepareMs: number; ocrMs: number; totalMs: number; ocrProfile?: "full" | "fast"; ocrWorkerPoolSize?: number; ocrFieldMs?: Record; }> = []; for (let index = 0; index < limit; index += 1) { const captureStartedAt = Date.now(); const capture = await deps.captureSource(benchmarkSource.id, index === 0 ? 150 : 0, true, { ocrMode: "artifact", ocrProfile, ocrEngine: "current", omitFullFrame: true, omitInventoryPreview: true, skipOcrUnlessArtifactDetail: true, }); captures.push({ index, elapsedMs: Date.now() - captureStartedAt, ocrFields: capture.ocr?.length ?? 0, timedOut: Boolean(capture.ocrTimedOut), ocrSkipped: Boolean(capture.ocrSkipped), artifactDetailConfidence: capture.artifactDetail?.confidence ?? 0, sanctified: Boolean(capture.sanctified), prepareMs: capture.timings?.prepareMs ?? 0, ocrMs: capture.timings?.ocrMs ?? 0, totalMs: capture.timings?.totalMs ?? 0, ocrProfile: capture.timings?.ocrProfile, ocrWorkerPoolSize: capture.timings?.ocrWorkerPoolSize, ocrFieldMs: capture.timings?.ocrFieldMs, }); } const elapsedMs = Date.now() - startedAt; const timings = captures.map((capture) => capture.elapsedMs).sort((left, right) => left - right); const ocrTimings = captures.map((capture) => capture.ocrMs).filter((value) => value > 0).sort((left, right) => left - right); const averageMs = Math.round(elapsedMs / limit); const averageOcrMs = ocrTimings.length > 0 ? Math.round(ocrTimings.reduce((total, value) => total + value, 0) / ocrTimings.length) : 0; const percentile = (ratio: number) => timings[Math.min(timings.length - 1, Math.max(0, Math.ceil(timings.length * ratio) - 1))] ?? 0; const ocrPercentile = (ratio: number) => ocrTimings[Math.min(ocrTimings.length - 1, Math.max(0, Math.ceil(ocrTimings.length * ratio) - 1))] ?? 0; const ocrFieldTotals = captures.reduce>((fields, capture) => { for (const [field, elapsed] of Object.entries(capture.ocrFieldMs ?? {})) { const current = fields[field] ?? { totalMs: 0, count: 0, maxMs: 0 }; current.totalMs += elapsed; current.count += 1; current.maxMs = Math.max(current.maxMs, elapsed); fields[field] = current; } return fields; }, {}); const ocrFieldAverages = Object.fromEntries( Object.entries(ocrFieldTotals).map(([field, timing]) => [ field, { averageMs: Math.round(timing.totalMs / Math.max(1, timing.count)), maxMs: timing.maxMs, count: timing.count, }, ]), ); return { engine: "current", nativeTesseract: "not-enabled", workerPoolSize: captures.find((capture) => capture.ocrWorkerPoolSize)?.ocrWorkerPoolSize ?? null, ocrProfile, limit, elapsedMs, averageMs, averageOcrMs, minMs: timings[0] ?? 0, p50Ms: percentile(0.5), p90Ms: percentile(0.9), maxMs: timings[timings.length - 1] ?? 0, ocrP50Ms: ocrPercentile(0.5), ocrP90Ms: ocrPercentile(0.9), ocrFieldAverages, projectedMs: { artifacts20: averageMs * 20, artifacts45: averageMs * 45, artifacts100: averageMs * 100, }, skippedOcrCaptures: captures.filter((capture) => capture.ocrSkipped).length, captures, }; } const summary = await runCurrentBenchmark(); writeDevJson(res, 200, { ok: true, summary, }); }) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/review/samples") { deps.loadReviewSamples(Number(url.searchParams.get("limit") ?? 20)) .then((payload: unknown) => writeDevJson(res, 200, payload)) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/capture/smart") { const sourceId = url.searchParams.get("sourceId"); const focus = url.searchParams.get("focus") !== "0"; const skipOcr = url.searchParams.get("skipOcr") === "1"; deps.listCaptureSources() .then(async (sources) => { const source = findGenshinSource(sources, sourceId); if (!source) { writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) }); return; } const capture = await deps.captureSource(source.id, 250, focus, { skipOcr }); const summary = await writeDevCaptureSnapshot(capture); writeDevJson(res, 200, { ok: true, source: { id: source.id, name: source.name }, summary }); }) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } if (url.pathname === "/automation/probe-click") { const sourceId = url.searchParams.get("sourceId"); const requestedIndex = Number(url.searchParams.get("index") ?? "1"); const requestedRow = Number(url.searchParams.get("row") ?? Number.NaN); const requestedCol = Number(url.searchParams.get("col") ?? Number.NaN); deps.listCaptureSources() .then(async (sources) => { const source = findGenshinSource(sources, sourceId); if (!source) { writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) }); return; } const before = await deps.captureSource(source.id, 150, true, { skipOcr: true }); const grid = before.inventoryGrid; const detail = before.artifactDetail; const gridReady = before.captureTarget === "genshin-client" && grid?.source === "detected" && (grid.confidence ?? 0) >= 60; const detailReady = Boolean(detail?.present && detail.confidence >= 45); if (!gridReady || !detailReady) { writeDevJson(res, 409, { ok: false, error: "Artifact inventory detail view is not ready for probe-click.", captureTarget: before.captureTarget, grid: grid ? { rows: grid.rows, cols: grid.cols, source: grid.source, confidence: grid.confidence } : null, artifactDetail: detail ?? null, }); return; } const centers = before.inventoryGrid?.centers ?? []; const target = Number.isFinite(requestedRow) && Number.isFinite(requestedCol) ? centers.find((center) => center.row === requestedRow && center.col === requestedCol) : centers[Math.max(0, Math.min(centers.length - 1, Number.isFinite(requestedIndex) ? requestedIndex : 1))]; if (!target) { writeDevJson(res, 409, { ok: false, error: "No inventory grid target available.", grid: before.inventoryGrid ?? null }); return; } const beforeFingerprint = dataUrlFingerprint(before.detailDataUrl); const click = await deps.clickScreen(target.x, target.y); await wait(650); const after = await deps.captureSource(source.id, 0, true, { skipOcr: true }); const afterFingerprint = dataUrlFingerprint(after.detailDataUrl); const changed = Boolean(beforeFingerprint && afterFingerprint && beforeFingerprint !== afterFingerprint); writeDevJson(res, 200, { ok: Boolean(click.ok && click.clicked && changed), changed, target, click, before: { captureTarget: before.captureTarget, grid: before.inventoryGrid ? { rows: before.inventoryGrid.rows, cols: before.inventoryGrid.cols, source: before.inventoryGrid.source, confidence: before.inventoryGrid.confidence } : null, detailFingerprint: beforeFingerprint, }, after: { captureTarget: after.captureTarget, grid: after.inventoryGrid ? { rows: after.inventoryGrid.rows, cols: after.inventoryGrid.cols, source: after.inventoryGrid.source, confidence: after.inventoryGrid.confidence } : null, detailFingerprint: afterFingerprint, }, }); }) .catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })); return; } writeDevJson(res, 404, { ok: false, error: "unknown endpoint" }); }); server.listen(17317, "127.0.0.1"); return server; }