3 Commits

Author SHA1 Message Date
AzuTear 2c6c1a8b31 feat(ocr): resolution-anchored layout module + crop preprocessing
Implements ADR-009 (structure + preprocessing; exact IK fixed coordinates still
need calibration against a reference 16:9 screenshot).

- src/lib/layoutProfile.ts: pure, unit-tested geometry for the artifact screen -
  detail rect, the four detail crops, inventory rect/count crop, 5-col grid,
  16:9 detection, aspect label, and an off-16:9 support warning. Single source of
  truth; electron/main.ts now delegates all crop/grid geometry to it and keeps
  colour detection only as the detail-rect fallback.
- src/lib/ocrPreprocess.ts: pure, unit-tested Otsu binarization with inversion
  (artifact text is the bright foreground) over a BGRA bitmap.
- main.ts: OCR now reads an upscaled + binarized copy of each crop; the original
  crop is retained for the diagnostics UI. CaptureResult carries layout info
  { aspect, isSixteenNine, warning }.

NOTE: image preprocessing changes the OCR input and cannot be validated by the
text-level eval harness; it needs a live Genshin 16:9 capture to confirm/tune
(threshold, invert, upscale factor). 88 tests + build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:42:16 +02:00
AzuTear c7138b541d feat(native): C# input/capture sidecar replacing the PowerShell helper
Implements ADR-008. native/input-helper is a self-contained .NET 9 console exe
speaking the identical JSON-over-stdin/stdout protocol as the old PowerShell
helper (ping/cursor/runtime/focus/click/scroll/bounds/capture), so the
InputHelperService interface is unchanged.

- Win32 interop compiled once (native exe), not per call.
- PerMonitorV2 DPI via manifest so click/capture coordinates stay correct on
  mixed-DPI multi-monitor setups.
- capture returns base64 PNG bytes inline (imageBase64) instead of writing a
  temp file per frame; the client handles both base64 and the PowerShell path.
- InputHelperClient prefers the exe and falls back to the embedded PowerShell
  helper when the exe is absent, so the app still runs without the .NET build.
- main.ts resolves the exe (INPUT_HELPER_EXE env -> packaged resources/input-helper
  -> native/input-helper/bin/publish). electron-builder ships it via extraResources.
- npm run helper:build; README documents the build + fallback.

Verified end-to-end through the compiled client: sidecar spawns, runtime info
and a base64 primary-screen capture return correctly. Build stays green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:33:26 +02:00
AzuTear 92345ef51a fix(scan): repair pre-existing tsc errors so the build is green
The scan feature had 16 tsc errors from refactor drift; npm run build failed.

- modals/hooks/* are five levels deep but imported ../../../../lib (four);
  corrected to ../../../../../lib.
- useScanDiagnosticsModalModel / useScanReviewQueueModalModel picked
  saveReviewSample / canSaveReviewSample / loadReviewQueue from the modal Props,
  but the components wire those through from controller.*; source them from the
  controller type instead (fixes the downstream unknown-type errors).
- useScanResultCardModel let its field-row tuple array widen to
  (string | ParsedField)[][]; annotate it Array<[string, ParsedField]> like the
  sibling hook.
- ScanTopControlsModel was missing autoScanRunning (destructured by the
  component); add it. useScanTopControlsModel does not use refreshCaptureSources,
  so its input is Omit<...,"refreshCaptureSources">.

tsc, vite build, and the electron build all pass; 74 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:25:34 +02:00
19 changed files with 1147 additions and 161 deletions
+14
View File
@@ -34,6 +34,20 @@ npm run dev
Use the Electron app window for scanner work. The browser preview does not expose the local capture bridge. Use the Electron app window for scanner work. The browser preview does not expose the local capture bridge.
### Input/Capture helper (C# sidecar)
Input automation and screen capture run through a compiled C# sidecar
(`native/input-helper`, see ADR-008). Build it once:
```powershell
npm run helper:build # requires the .NET SDK; produces a self-contained exe
```
The app auto-detects the exe (`INPUT_HELPER_EXE` env override → packaged
`resources/input-helper``native/input-helper/bin/publish`). If the exe is not
present it falls back to the embedded PowerShell helper, so the app still runs
without the .NET build - just slower and with the old per-frame temp-file capture.
### Automatischer Scan: als Administrator starten ### Automatischer Scan: als Administrator starten
Genshin läuft erhöht (Administrator). Windows (UIPI) verwirft dann alle simulierten Maus-Eingaben aus einer nicht-erhöhten App - SendInput meldet dabei trotzdem Erfolg. Für den automatischen Scan muss die App deshalb ebenfalls erhöht laufen: Genshin läuft erhöht (Administrator). Windows (UIPI) verwirft dann alle simulierten Maus-Eingaben aus einer nicht-erhöhten App - SendInput meldet dabei trotzdem Erfolg. Für den automatischen Scan muss die App deshalb ebenfalls erhöht laufen:
+72 -132
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron"; import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import http, { type Server } from "node:http"; import http, { type Server } from "node:http";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -21,6 +22,17 @@ import type {
ReviewSamplesRepositoryPort, ReviewSamplesRepositoryPort,
ScannerLearningRepositoryPort, ScannerLearningRepositoryPort,
} from "./repositories/index.js"; } from "./repositories/index.js";
import {
aspectRatioLabel,
detailCropRects,
inventoryCountCropRect,
inventoryGrid as layoutInventoryGrid,
inventoryRect as layoutInventoryRect,
isSixteenNine,
layoutSupportWarning,
profileDetailRect,
} from "../src/lib/layoutProfile.js";
import { binarizeForOcr } from "../src/lib/ocrPreprocess.js";
// Chromium's renderer sandbox can refuse to fully initialize (or silently // Chromium's renderer sandbox can refuse to fully initialize (or silently
// crash the GPU/renderer process) when the hosting process runs with a full // crash the GPU/renderer process) when the hosting process runs with a full
@@ -70,6 +82,25 @@ function getInputHelperService() {
return inputHelperService; return inputHelperService;
} }
// Locate the compiled C# input/capture sidecar (ADR-008). Falls back to null so
// the service uses the embedded PowerShell helper when the exe was never built.
function resolveInputHelperExePath(): string | null {
const candidates = [
process.env.INPUT_HELPER_EXE,
path.join(process.resourcesPath, "input-helper", "InputHelper.exe"),
path.join(app.getAppPath(), "native", "input-helper", "bin", "publish", "InputHelper.exe"),
].filter((candidate): candidate is string => Boolean(candidate));
for (const candidate of candidates) {
try {
if (existsSync(candidate)) return candidate;
} catch {
// Unreadable path; try the next candidate.
}
}
return null;
}
function getRepositoryContext() { function getRepositoryContext() {
if (!repositoryContext) { if (!repositoryContext) {
throw new Error("Repository context has not been initialized."); throw new Error("Repository context has not been initialized.");
@@ -726,84 +757,48 @@ function imageCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, im
return sourceImage.crop(safeRect).toDataURL(); return sourceImage.crop(safeRect).toDataURL();
} }
// Preprocessed copy of a crop for OCR (ADR-009): upscale for more pixels, then
// grayscale + Otsu-binarize with inversion (artifact text is the bright
// foreground). The original crop is kept separately for the diagnostics UI.
function preprocessedCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) {
const safeRect = clampCaptureRect(rect, imageSize);
const upscaled = sourceImage.crop(safeRect).resize({ width: Math.max(1, safeRect.width * 2), quality: "best" });
const size = upscaled.getSize();
if (!size.width || !size.height) return upscaled.toDataURL();
const binarized = binarizeForOcr({ data: upscaled.getBitmap(), width: size.width, height: size.height });
return nativeImage
.createFromBitmap(Buffer.from(binarized.data), { width: binarized.width, height: binarized.height })
.toDataURL();
}
function createCrops( function createCrops(
sourceImage: NativeImage, sourceImage: NativeImage,
imageSize: { width: number; height: number }, imageSize: { width: number; height: number },
detailRect: Electron.Rectangle, detailRect: Electron.Rectangle,
inventoryRect: Electron.Rectangle, inventoryRect: Electron.Rectangle,
) { ) {
const templates: CropTemplate[] = [ const templates: CropTemplate[] = detailCropRects(detailRect, imageSize);
{
id: "artifact-title",
label: "Artifact title",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.05),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.16),
},
},
{
id: "artifact-main-stat",
label: "Main stat",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.20),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.18),
},
},
{
id: "artifact-substats",
label: "Substats",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.41),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.25),
},
},
{
id: "artifact-footer",
label: "Footer",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.78),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.16),
},
},
];
if (inventoryRect.width > 120 && inventoryRect.height > 80) { if (inventoryRect.width > 120 && inventoryRect.height > 80) {
templates.push({ templates.push({
id: "inventory-count", id: "inventory-count",
label: "Inventory count", label: "Inventory count",
rect: { rect: inventoryCountCropRect(inventoryRect, imageSize),
x: Math.round(inventoryRect.x + inventoryRect.width * 0.62),
y: Math.round(inventoryRect.y + inventoryRect.height * 0.02),
width: Math.round(inventoryRect.width * 0.34),
height: Math.round(inventoryRect.height * 0.09),
},
}); });
} }
return templates return templates
.map((template) => ({ .map((template) => {
...template, const rect = clampCaptureRect(template.rect, imageSize);
rect: clampCaptureRect(template.rect, imageSize), return {
dataUrl: imageCropDataUrl(sourceImage, template.rect, imageSize), id: template.id,
})) label: template.label,
.filter((crop) => crop.rect.width > 0 && crop.rect.height > 0) rect,
.map((crop) => ({ dataUrl: imageCropDataUrl(sourceImage, rect, imageSize),
...crop, ocrDataUrl: preprocessedCropDataUrl(sourceImage, rect, imageSize),
rect: { };
x: crop.rect.x, })
y: crop.rect.y, .filter((crop) => crop.rect.width > 0 && crop.rect.height > 0);
width: crop.rect.width,
height: crop.rect.height,
},
}));
} }
function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) { function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) {
@@ -845,79 +840,17 @@ function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: num
return clampCaptureRect({ x: left, y: top, width: widthGuess, height: heightGuess }, imageSize); return clampCaptureRect({ x: left, y: top, width: widthGuess, height: heightGuess }, imageSize);
} }
if (width > 0 && height > 0) { // Colour detection found nothing usable; fall back to the resolution-anchored
return clampCaptureRect( // profile rect (single source of truth in layoutProfile).
{ return profileDetailRect(imageSize);
x: Math.round(width * 0.50),
y: Math.round(height * 0.08),
width: Math.round(width * 0.46),
height: Math.round(height * 0.74),
},
imageSize,
);
}
return { x: 0, y: 0, width, height };
} }
function inferInventoryRect(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) { function inferInventoryRect(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
const { width, height } = imageSize; return layoutInventoryRect(imageSize, detailRect);
const preferredWidth = Math.max(140, Math.round(width * 0.48));
const x = Math.round(width * 0.03);
const y = Math.round(detailRect.y + detailRect.height * 0.09);
const availableWidth = Math.max(100, detailRect.x - Math.round(width * 0.04));
const panelWidth = Math.max(100, Math.min(preferredWidth, availableWidth));
const safeWidth = panelWidth > width * 0.85 ? Math.round(width * 0.55) : panelWidth;
return clampCaptureRect(
{
x,
y,
width: Math.min(safeWidth, Math.max(width - x - Math.round(width * 0.02), 100)),
height: Math.max(140, Math.round(height * 0.70)),
},
imageSize,
);
} }
function inferInventoryGrid(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) { function inferInventoryGrid(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
const inventoryRect = inferInventoryRect(imageSize, detailRect); return layoutInventoryGrid(imageSize, detailRect);
const cols = 5;
if (inventoryRect.width < 160 || inventoryRect.height < 140) {
return {
centers: [],
rows: 0,
cols: 0,
confidence: 0,
source: "missing" as const,
};
}
const cellWidth = Math.max(56, Math.round(inventoryRect.width / cols));
const stepX = Math.round(cellWidth * 0.96);
const stepY = Math.round(cellWidth * 1.03);
const visibleRows = Math.max(2, Math.min(6, Math.round(inventoryRect.height / Math.max(stepY, 1))));
const startX = inventoryRect.x + Math.max(6, Math.round(stepX * 0.45));
const startY = inventoryRect.y + Math.max(6, Math.round(stepY * 0.45));
const centers = [];
for (let row = 0; row < visibleRows; row++) {
for (let col = 0; col < cols; col++) {
const x = startX + col * stepX;
const y = startY + row * stepY;
if (x < imageSize.width && y < imageSize.height) {
centers.push({ x, y, row, col });
}
}
}
const trimmed = centers.filter((center) => center.x > 0 && center.y > 0);
return {
centers: trimmed,
rows: visibleRows,
cols,
confidence: trimmed.length >= cols * 2 ? 76 : trimmed.length >= cols ? 58 : 36,
source: "detected" as const,
};
} }
async function buildCaptureResult( async function buildCaptureResult(
@@ -939,7 +872,9 @@ async function buildCaptureResult(
const croppedPayload = crops.map((crop) => ({ const croppedPayload = crops.map((crop) => ({
id: crop.id, id: crop.id,
label: crop.label, label: crop.label,
dataUrl: crop.dataUrl, // OCR reads the preprocessed (upscaled + binarized) crop; the original is
// kept below for the diagnostics UI.
dataUrl: crop.ocrDataUrl ?? crop.dataUrl,
})); }));
const recognized = options.skipOcr ? { ocr: [], timedOut: false } : await runOcrOnCropsWithTimeout(croppedPayload); const recognized = options.skipOcr ? { ocr: [], timedOut: false } : await runOcrOnCropsWithTimeout(croppedPayload);
@@ -970,6 +905,11 @@ async function buildCaptureResult(
})), })),
inventoryGrid: inferInventoryGrid(size, detailRect), inventoryGrid: inferInventoryGrid(size, detailRect),
inventoryCount: count, inventoryCount: count,
layout: {
aspect: aspectRatioLabel(size),
isSixteenNine: isSixteenNine(size),
warning: layoutSupportWarning(size),
},
}; };
} }
@@ -1032,7 +972,7 @@ function initializeAppLifecycle() {
artifactStoreRepository = repositoryContext.artifactStoreRepository; artifactStoreRepository = repositoryContext.artifactStoreRepository;
reviewSamplesRepository = repositoryContext.reviewSamplesRepository; reviewSamplesRepository = repositoryContext.reviewSamplesRepository;
scannerLearningRepository = repositoryContext.scannerLearningRepository; scannerLearningRepository = repositoryContext.scannerLearningRepository;
inputHelperService = createInputHelperService({ userDataPath }); inputHelperService = createInputHelperService({ userDataPath, exePath: resolveInputHelperExePath() });
registerIpcHandlers({ registerIpcHandlers({
focusMainWindow: () => focusMainWindow(), focusMainWindow: () => focusMainWindow(),
+48 -11
View File
@@ -382,7 +382,7 @@ class InputHelperClient {
private starting: Promise<void> | null = null; private starting: Promise<void> | null = null;
private disposed = false; private disposed = false;
constructor(private readonly scriptUserDataPath: string) {} constructor(private readonly options: { scriptUserDataPath: string; exePath?: string | null }) {}
private async ensureStarted() { private async ensureStarted() {
if (this.child) return; if (this.child) return;
@@ -396,14 +396,31 @@ class InputHelperClient {
} }
private async start() { private async start() {
const scriptPath = path.join(this.scriptUserDataPath, "input-helper.ps1"); // Prefer the compiled C# sidecar (ADR-008). If it is missing or fails to
// start, fall back to the embedded PowerShell helper so the app keeps working
// on machines where the native exe was never built.
if (this.options.exePath) {
try {
await this.startWith(spawn(this.options.exePath, [], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }));
return;
} catch {
this.teardownChild();
}
}
await this.startWith(await this.spawnPowershell());
}
private async spawnPowershell() {
const scriptPath = path.join(this.options.scriptUserDataPath, "input-helper.ps1");
await fs.mkdir(path.dirname(scriptPath), { recursive: true }); await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(scriptPath, INPUT_HELPER_SCRIPT, "utf8"); await fs.writeFile(scriptPath, INPUT_HELPER_SCRIPT, "utf8");
return spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
const child = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
windowsHide: true, windowsHide: true,
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}); });
}
private async startWith(child: ChildProcessWithoutNullStreams) {
child.stdout.setEncoding("utf8"); child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => this.handleStdout(chunk)); child.stdout.on("data", (chunk: string) => this.handleStdout(chunk));
child.stderr.setEncoding("utf8"); child.stderr.setEncoding("utf8");
@@ -419,10 +436,22 @@ class InputHelperClient {
}); });
this.child = child; this.child = child;
// First request compiles the Win32 interop; give it extra time. // The C# sidecar answers ping immediately; the PowerShell fallback compiles
// Win32 interop on the first request, so give it extra time.
await this.send("ping", {}, 20000); await this.send("ping", {}, 20000);
} }
private teardownChild() {
const child = this.child;
this.child = null;
this.buffer = "";
try {
child?.kill();
} catch {
// Child was never spawned or already gone.
}
}
private handleStdout(chunk: string) { private handleStdout(chunk: string) {
this.buffer += chunk; this.buffer += chunk;
let newlineIndex = this.buffer.indexOf("\n"); let newlineIndex = this.buffer.indexOf("\n");
@@ -485,8 +514,8 @@ export interface InputHelperService {
dispose(): void; dispose(): void;
} }
export function createInputHelperService(options: { userDataPath: string }): InputHelperService { export function createInputHelperService(options: { userDataPath: string; exePath?: string | null }): InputHelperService {
const inputHelper = new InputHelperClient(options.userDataPath); const inputHelper = new InputHelperClient({ scriptUserDataPath: options.userDataPath, exePath: options.exePath ?? null });
async function request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) { async function request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
return inputHelper.request(op, params, timeoutMs); return inputHelper.request(op, params, timeoutMs);
@@ -590,16 +619,24 @@ export function createInputHelperService(options: { userDataPath: string }): Inp
async function capturePrimaryScreenViaGdi() { async function capturePrimaryScreenViaGdi() {
const result = await request("capture", {}, 15000); const result = await request("capture", {}, 15000);
const capturePath = String(result.path); // The C# sidecar returns PNG bytes inline (no temp file). The PowerShell
const buffer = await fs.readFile(capturePath); // fallback writes a temp PNG and returns its path.
await fs.unlink(capturePath).catch(() => undefined); let base64: string;
if (typeof result.imageBase64 === "string" && result.imageBase64) {
base64 = result.imageBase64;
} else {
const capturePath = String(result.path);
const buffer = await fs.readFile(capturePath);
await fs.unlink(capturePath).catch(() => undefined);
base64 = buffer.toString("base64");
}
const captureTargetRaw = typeof result.captureTarget === "string" ? result.captureTarget : ""; const captureTargetRaw = typeof result.captureTarget === "string" ? result.captureTarget : "";
const captureTarget: "primary-screen" | "genshin-client" = captureTargetRaw === "primary-screen" || captureTargetRaw === "genshin-client" const captureTarget: "primary-screen" | "genshin-client" = captureTargetRaw === "primary-screen" || captureTargetRaw === "genshin-client"
? captureTargetRaw ? captureTargetRaw
: "primary-screen"; : "primary-screen";
return { return {
dataUrl: `data:image/png;base64,${buffer.toString("base64")}`, dataUrl: `data:image/png;base64,${base64}`,
width: Number(result.width), width: Number(result.width),
height: Number(result.height), height: Number(result.height),
originX: Number(result.originX), originX: Number(result.originX),
+4
View File
@@ -0,0 +1,4 @@
# .NET build outputs — the self-contained exe is built via `npm run helper:build`,
# not committed (it is ~100 MB).
bin/
obj/
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>InputHelper</AssemblyName>
<RootNamespace>GenshinAssistant.InputHelper</RootNamespace>
<!-- Screen.PrimaryScreen (Forms) + Bitmap/Graphics.CopyFromScreen (Drawing). -->
<UseWindowsForms>true</UseWindowsForms>
<!-- Single self-contained exe: no .NET install needed on the user's machine. -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Per-monitor DPI v2 so SendInput/capture coordinates match a mixed-DPI
multi-monitor setup (same reason as the old PowerShell helper). -->
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
</Project>
+466
View File
@@ -0,0 +1,466 @@
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Security.Principal;
using System.Text;
using System.Text.Json;
using System.Windows.Forms;
// Long-lived input/capture sidecar for the Genshin Artifact Assistant.
// Drop-in replacement for the old PowerShell helper (see ADR-008): identical
// JSON-over-stdin/stdout protocol - one JSON request per line, one JSON response
// per line - so the Electron-side InputHelperService is unchanged. Win32 interop
// is compiled once (this is a native exe), and capture returns base64 PNG bytes
// directly instead of writing a temp file per frame.
namespace GenshinAssistant.InputHelper;
internal static class Program
{
private static IntPtr _genshinHwnd = IntPtr.Zero;
private static int Main()
{
// Manifest already declares PerMonitorV2; this is a belt-and-suspenders
// call for hosts that ignore the manifest.
try { Native.SetProcessDpiAwarenessContext(Native.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); }
catch { try { Native.SetProcessDpiAwareness(2); } catch { /* oldest fallback */ Native.SetProcessDPIAware(); } }
Console.OutputEncoding = Encoding.UTF8;
var stdout = Console.Out;
string? line;
while ((line = Console.In.ReadLine()) != null)
{
if (line.Trim().Length == 0) continue;
var response = new Dictionary<string, object?> { ["id"] = "", ["ok"] = true };
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
response["id"] = GetString(root, "id");
var op = GetString(root, "op");
Handle(op, root, response);
}
catch (Exception ex)
{
response["ok"] = false;
response["error"] = ex.Message;
}
stdout.WriteLine(JsonSerializer.Serialize(response));
stdout.Flush();
}
return 0;
}
private static void Handle(string op, JsonElement root, Dictionary<string, object?> response)
{
switch (op)
{
case "ping":
response["pong"] = true;
break;
case "cursor":
{
var state = GetCursorState();
response["cursorX"] = state.X;
response["cursorY"] = state.Y;
response["escapePressed"] = state.Escape;
response["enterPressed"] = state.Enter;
response["f9Pressed"] = state.F9;
break;
}
case "runtime":
{
var hwnd = FindGenshinWindow();
var fgHwnd = Native.GetForegroundWindow();
response["isElevated"] = IsElevated();
response["genshinFound"] = hwnd != IntPtr.Zero;
response["genshinHwnd"] = hwnd.ToInt64();
response["targetProcess"] = ProcessNameFromHwnd(hwnd);
response["foregroundProcess"] = ProcessNameFromHwnd(fgHwnd);
response["foregroundHwnd"] = fgHwnd.ToInt64();
response["helperPid"] = Environment.ProcessId;
break;
}
case "focus":
{
var info = FocusGenshinWindow();
response["focused"] = info.Focused;
response["alreadyForeground"] = info.AlreadyForeground;
response["foregroundProcess"] = info.ForegroundProcess;
response["targetProcess"] = info.TargetProcess;
response["genshinFound"] = info.Hwnd != IntPtr.Zero;
response["setForegroundResult"] = info.SetForegroundResult;
break;
}
case "click":
{
var info = FocusGenshinWindow();
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
var targetX = GetInt(root, "x");
var targetY = GetInt(root, "y");
// Bare SetCursorPos then a batched down+up click, matching the
// verified Inventory Kamera sequence: no extra move event, no
// gap between move and click.
Native.SetCursorPos(targetX, targetY);
Native.GetCursorPos(out var pt);
var onTarget = Math.Abs(targetX - pt.X) <= 2 && Math.Abs(targetY - pt.Y) <= 2;
var clickEventsSent = onTarget ? SendMouseClickBatch() : 0u;
var state = GetCursorState();
response["cursorX"] = state.X;
response["cursorY"] = state.Y;
response["escapePressed"] = state.Escape;
response["enterPressed"] = state.Enter;
response["f9Pressed"] = state.F9;
response["moved"] = onTarget;
response["focused"] = info.Focused;
response["alreadyForeground"] = info.AlreadyForeground;
response["foregroundProcess"] = info.ForegroundProcess;
response["targetProcess"] = info.TargetProcess;
response["isElevated"] = IsElevated();
// Only report a click when the cursor is verifiably on target and
// SendInput injected both events; real acceptance is proven later
// by the detail-panel fingerprint.
response["clicked"] = onTarget && clickEventsSent >= 2;
response["inputBlocked"] = onTarget && clickEventsSent < 2;
break;
}
case "scroll":
{
var info = FocusGenshinWindow();
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
if (TryGetInt(root, "x", out var ax) && TryGetInt(root, "y", out var ay))
{
Native.SetCursorPos(ax, ay);
Thread.Sleep(30);
}
Native.GetCursorPos(out var pt);
response["cursorX"] = pt.X;
response["cursorY"] = pt.Y;
response["focused"] = info.Focused;
response["foregroundProcess"] = info.ForegroundProcess;
response["isElevated"] = IsElevated();
var notches = GetInt(root, "notches");
var stepDelta = notches < 0 ? -120 : 120;
var count = Math.Min(60, Math.Abs(notches));
uint sentTotal = 0;
for (var i = 0; i < count; i++)
{
sentTotal += SendMouseWheel(stepDelta);
Thread.Sleep(45);
}
response["notchesSent"] = sentTotal;
response["inputBlocked"] = count > 0 && sentTotal == 0;
break;
}
case "bounds":
{
var bounds = GetGenshinClientBounds();
if (bounds == null)
{
response["found"] = false;
}
else
{
response["found"] = true;
response["left"] = bounds.Value.Left;
response["top"] = bounds.Value.Top;
response["width"] = bounds.Value.Width;
response["height"] = bounds.Value.Height;
}
break;
}
case "capture":
{
var bounds = GetGenshinClientBounds();
string captureTarget;
Rect area;
if (bounds == null)
{
var screen = Screen.PrimaryScreen!.Bounds;
area = new Rect { Left = screen.Left, Top = screen.Top, Width = screen.Width, Height = screen.Height };
captureTarget = "primary-screen";
}
else
{
area = bounds.Value;
captureTarget = "genshin-client";
}
using var bitmap = new Bitmap(area.Width, area.Height, PixelFormat.Format32bppArgb);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.CopyFromScreen(area.Left, area.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
}
using var stream = new MemoryStream();
bitmap.Save(stream, ImageFormat.Png);
response["imageBase64"] = Convert.ToBase64String(stream.ToArray());
response["width"] = area.Width;
response["height"] = area.Height;
response["originX"] = area.Left;
response["originY"] = area.Top;
response["captureTarget"] = captureTarget;
break;
}
default:
response["ok"] = false;
response["error"] = "unknown op";
break;
}
}
private readonly struct Rect
{
public int Left { get; init; }
public int Top { get; init; }
public int Width { get; init; }
public int Height { get; init; }
}
private struct CursorState
{
public int X;
public int Y;
public bool Escape;
public bool Enter;
public bool F9;
}
private struct FocusInfo
{
public IntPtr Hwnd;
public bool Focused;
public bool AlreadyForeground;
public string ForegroundProcess;
public string TargetProcess;
public bool SetForegroundResult;
}
private static CursorState GetCursorState()
{
Native.GetCursorPos(out var pt);
// Only 0x8000 (held right now). The 0x0001 "pressed since last call" bit
// is unreliable and fires for ESC presses used to navigate Genshin menus.
var esc = (Native.GetAsyncKeyState(0x1B) & 0x8000) != 0;
var enter = (Native.GetAsyncKeyState(0x0D) & 0x8000) != 0;
var f9 = (Native.GetAsyncKeyState(0x78) & 0x8000) != 0;
return new CursorState { X = pt.X, Y = pt.Y, Escape = esc, Enter = enter, F9 = f9 };
}
private static FocusInfo FocusGenshinWindow()
{
var hwnd = FindGenshinWindow();
var info = new FocusInfo
{
Hwnd = hwnd,
ForegroundProcess = "",
TargetProcess = ProcessNameFromHwnd(hwnd),
};
if (hwnd == IntPtr.Zero) return info;
info.AlreadyForeground = Native.GetForegroundWindow() == hwnd;
if (!info.AlreadyForeground)
{
Native.ShowWindowAsync(hwnd, 9); // SW_RESTORE
// No ALT tap: this app and Genshin run at the same (elevated)
// integrity level, so SetForegroundWindow succeeds on its own. An ALT
// tap would toggle menu-mnemonic mode and swallow the next inputs.
info.SetForegroundResult = Native.SetForegroundWindow(hwnd);
Thread.Sleep(140);
}
var foreground = Native.GetForegroundWindow();
info.Focused = foreground == hwnd;
info.ForegroundProcess = ProcessNameFromHwnd(foreground);
return info;
}
private static IntPtr FindGenshinWindow()
{
if (_genshinHwnd != IntPtr.Zero && Native.IsWindow(_genshinHwnd)) return _genshinHwnd;
foreach (var proc in Process.GetProcesses())
{
try
{
var name = proc.ProcessName;
if ((name.Contains("GenshinImpact", StringComparison.OrdinalIgnoreCase)
|| name.Contains("YuanShen", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Genshin", StringComparison.OrdinalIgnoreCase))
&& proc.MainWindowHandle != IntPtr.Zero)
{
_genshinHwnd = proc.MainWindowHandle;
return _genshinHwnd;
}
}
catch
{
// Process exited between enumeration and inspection; ignore.
}
}
_genshinHwnd = IntPtr.Zero;
return _genshinHwnd;
}
private static Rect? GetGenshinClientBounds()
{
var hwnd = FindGenshinWindow();
if (hwnd == IntPtr.Zero) return null;
if (!Native.GetClientRect(hwnd, out var rect)) return null;
var topLeft = new Native.POINT { X = 0, Y = 0 };
if (!Native.ClientToScreen(hwnd, ref topLeft)) return null;
var width = rect.Right - rect.Left;
var height = rect.Bottom - rect.Top;
if (width <= 0 || height <= 0) return null;
return new Rect { Left = topLeft.X, Top = topLeft.Y, Width = width, Height = height };
}
private static string ProcessNameFromHwnd(IntPtr hwnd)
{
if (hwnd == IntPtr.Zero) return "";
Native.GetWindowThreadProcessId(hwnd, out var pid);
if (pid == 0) return "";
try { return Process.GetProcessById((int)pid).ProcessName; }
catch { return ""; }
}
private static bool IsElevated()
{
using var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
private static uint SendMouseClickBatch()
{
var inputs = new Native.INPUT[2];
inputs[0].type = 0; // INPUT_MOUSE
inputs[0].mi.dwFlags = Native.MOUSEEVENTF_LEFTDOWN;
inputs[1].type = 0;
inputs[1].mi.dwFlags = Native.MOUSEEVENTF_LEFTUP;
return Native.SendInput(2, inputs, Marshal.SizeOf<Native.INPUT>());
}
private static uint SendMouseWheel(int wheelData)
{
var inputs = new Native.INPUT[1];
inputs[0].type = 0;
inputs[0].mi.mouseData = unchecked((uint)wheelData);
inputs[0].mi.dwFlags = Native.MOUSEEVENTF_WHEEL;
return Native.SendInput(1, inputs, Marshal.SizeOf<Native.INPUT>());
}
private static string GetString(JsonElement root, string name)
=> root.TryGetProperty(name, out var value) ? value.ToString() : "";
private static int GetInt(JsonElement root, string name)
=> TryGetInt(root, name, out var value) ? value : 0;
private static bool TryGetInt(JsonElement root, string name, out int value)
{
value = 0;
if (!root.TryGetProperty(name, out var element)) return false;
if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out value)) return true;
if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) return true;
return false;
}
}
internal static class Native
{
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
public const uint MOUSEEVENTF_LEFTUP = 0x0004;
public const uint MOUSEEVENTF_WHEEL = 0x0800;
public static readonly IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = new(-4);
[StructLayout(LayoutKind.Sequential)]
public struct POINT { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)]
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
// Binary-compatible with the Win32 INPUT for mouse-only use on x64:
// type(4) + 4 pad + MOUSEINPUT(32) = 40 bytes = sizeof(INPUT).
[StructLayout(LayoutKind.Sequential)]
public struct MOUSEINPUT
{
public int dx;
public int dy;
public uint mouseData;
public uint dwFlags;
public uint time;
public UIntPtr dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
public struct INPUT
{
public int type;
public MOUSEINPUT mi;
}
[DllImport("user32.dll")]
public static extern bool SetProcessDPIAware();
[DllImport("shcore.dll")]
public static extern int SetProcessDpiAwareness(int value);
[DllImport("user32.dll")]
public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
[DllImport("user32.dll")]
public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")]
public static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
[DllImport("user32.dll")]
public static extern short GetAsyncKeyState(int vKey);
[DllImport("user32.dll", SetLastError = true)]
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
}
+11
View File
@@ -0,0 +1,11 @@
<?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="GenshinAssistant.InputHelper" type="win32" />
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- PerMonitorV2: coordinates stay correct across mixed-DPI monitors. -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
</windowsSettings>
</application>
</assembly>
+10
View File
@@ -15,6 +15,7 @@
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"eval": "vitest run src/eval/ocrEval.test.ts", "eval": "vitest run src/eval/ocrEval.test.ts",
"helper:build": "dotnet publish native/input-helper/InputHelper.csproj -c Release -o native/input-helper/bin/publish",
"data:genshin": "node scripts/generate-genshin-data.cjs" "data:genshin": "node scripts/generate-genshin-data.cjs"
}, },
"dependencies": { "dependencies": {
@@ -48,6 +49,15 @@
"dist-electron/**/*", "dist-electron/**/*",
"package.json" "package.json"
], ],
"extraResources": [
{
"from": "native/input-helper/bin/publish",
"to": "input-helper",
"filter": [
"**/*"
]
}
],
"win": { "win": {
"target": "nsis", "target": "nsis",
"requestedExecutionLevel": "requireAdministrator" "requestedExecutionLevel": "requireAdministrator"
@@ -60,19 +60,20 @@ export function useScanResultCardModel({
parsed, parsed,
}: Pick<ArtifactResultCardProps, "parsed">): ScanResultCardModel { }: Pick<ArtifactResultCardProps, "parsed">): ScanResultCardModel {
const substats = parsed.substats; const substats = parsed.substats;
const rows: Array<[string, ParsedField]> = [
["Name", parsed.fields.name],
["Slot", parsed.fields.slot],
["Level", getLevelField(parsed)],
["Main", mergeField(parsed.fields.mainStat, parsed.fields.mainValue)],
["Set", parsed.fields.setName],
["Equipped", parsed.fields.equipped],
["Substats", parsed.fields.substats],
];
return { return {
levelField: getLevelField(parsed), levelField: getLevelField(parsed),
quality: resolveQuality(parsed.confidence), quality: resolveQuality(parsed.confidence),
fieldRows: [ fieldRows: rows.map(([label, field]) => ({
["Name", parsed.fields.name],
["Slot", parsed.fields.slot],
["Level", getLevelField(parsed)],
["Main", mergeField(parsed.fields.mainStat, parsed.fields.mainValue)],
["Set", parsed.fields.setName],
["Equipped", parsed.fields.equipped],
["Substats", parsed.fields.substats],
].map(([label, field]) => ({
label, label,
field, field,
confidenceClassName: resolveFieldConfidenceClass(field), confidenceClassName: resolveFieldConfidenceClass(field),
@@ -8,6 +8,7 @@ export interface ScanTopControlsModel {
canStartAutoScan: boolean; canStartAutoScan: boolean;
canStartManualScan: boolean; canStartManualScan: boolean;
canCaptureSingle: boolean; canCaptureSingle: boolean;
autoScanRunning: boolean;
handleSourceChange: (event: ChangeEvent<HTMLSelectElement>) => void; handleSourceChange: (event: ChangeEvent<HTMLSelectElement>) => void;
selectGenshinSource: () => void; selectGenshinSource: () => void;
openSettings: () => void; openSettings: () => void;
@@ -41,7 +42,7 @@ export function useScanTopControlsModel({
bridgeReady, bridgeReady,
isScanning, isScanning,
controller, controller,
}: ScanTopControlsSectionProps): ScanTopControlsModel { }: Omit<ScanTopControlsSectionProps, "refreshCaptureSources">): ScanTopControlsModel {
const { const {
setSettingsOpen, setSettingsOpen,
setDiagnosticsOpen, setDiagnosticsOpen,
@@ -116,6 +117,7 @@ export function useScanTopControlsModel({
canStartAutoScan: hasSourceSelected && !isScanning && !autoScanRunning && canAutoScan && !requiresAdminForAutoScan, canStartAutoScan: hasSourceSelected && !isScanning && !autoScanRunning && canAutoScan && !requiresAdminForAutoScan,
canStartManualScan: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource, canStartManualScan: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
canCaptureSingle: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource, canCaptureSingle: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
autoScanRunning,
handleSourceChange, handleSourceChange,
selectGenshinSource, selectGenshinSource,
openSettings, openSettings,
@@ -1,6 +1,6 @@
import { useCallback, type MouseEvent } from "react"; import { useCallback, type MouseEvent } from "react";
import type { ScanDetailsModalProps } from "../types"; import type { ScanDetailsModalProps } from "../types";
import type { ParsedArtifactCandidate } from "../../../../lib/artifactOcrParser"; import type { ParsedArtifactCandidate } from "../../../../../lib/artifactOcrParser";
export interface ScanDetailsModalModel { export interface ScanDetailsModalModel {
closeDetails: () => void; closeDetails: () => void;
@@ -1,5 +1,5 @@
import { detailFingerprint } from "../../../../lib/autoScanLoop"; import { detailFingerprint } from "../../../../../lib/autoScanLoop";
import { sourceVersion } from "../../../../lib/genshinData"; import { sourceVersion } from "../../../../../lib/genshinData";
import { useCallback, useMemo, type MouseEvent } from "react"; import { useCallback, useMemo, type MouseEvent } from "react";
import type { ScanDiagnosticsModalProps } from "../types"; import type { ScanDiagnosticsModalProps } from "../types";
@@ -39,16 +39,20 @@ export interface ScanDiagnosticsModalModel {
canSaveReviewSample: boolean; canSaveReviewSample: boolean;
} }
type ScanDiagnosticsController = ScanDiagnosticsModalProps["controller"];
interface UseScanDiagnosticsModalModelInput extends Pick< interface UseScanDiagnosticsModalModelInput extends Pick<
ScanDiagnosticsModalProps, ScanDiagnosticsModalProps,
| "setDetailsOpen" | "setDetailsOpen"
| "setDiagnosticsOpen" | "setDiagnosticsOpen"
| "saveReviewSample"
| "canSaveReviewSample"
| "latestCapture" | "latestCapture"
| "controller" | "controller"
> { > {
captureStatus: string; captureStatus: string;
// saveReviewSample / canSaveReviewSample live on the controller, not the modal
// props; the component wires them through from controller.* .
saveReviewSample: ScanDiagnosticsController["saveReviewSample"];
canSaveReviewSample: ScanDiagnosticsController["canSaveReviewSample"];
} }
export function useScanDiagnosticsModalModel({ export function useScanDiagnosticsModalModel({
@@ -1,5 +1,5 @@
import { useCallback, useMemo, type MouseEvent } from "react"; import { useCallback, useMemo, type MouseEvent } from "react";
import type { ReviewSampleAnalysis } from "../../../../lib/reviewSampleAnalysis"; import type { ReviewSampleAnalysis } from "../../../../../lib/reviewSampleAnalysis";
import type { ScanReviewQueueModalProps } from "../types"; import type { ScanReviewQueueModalProps } from "../types";
export interface ScanReviewQueueRow { export interface ScanReviewQueueRow {
@@ -18,8 +18,10 @@ export interface ScanReviewQueueModalModel {
interface UseScanReviewQueueModalModelInput extends Pick< interface UseScanReviewQueueModalModelInput extends Pick<
ScanReviewQueueModalProps, ScanReviewQueueModalProps,
"setReviewQueueOpen" | "loadReviewQueue" "setReviewQueueOpen"
> { > {
// loadReviewQueue is wired through from controller.* by the component.
loadReviewQueue: ScanReviewQueueModalProps["controller"]["loadReviewQueue"];
reviewAnalysis: ReviewSampleAnalysis; reviewAnalysis: ReviewSampleAnalysis;
reviewSampleTotal: number; reviewSampleTotal: number;
} }
@@ -1,6 +1,6 @@
import { useCallback, type ChangeEvent, type MouseEvent } from "react"; import { useCallback, type ChangeEvent, type MouseEvent } from "react";
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global"; import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
import { clampScanLimit, clampSkipRows } from "../../../../lib/scannerSession"; import { clampScanLimit, clampSkipRows } from "../../../../../lib/scannerSession";
export interface ScanSettingsModalModel { export interface ScanSettingsModalModel {
closeSettings: () => void; closeSettings: () => void;
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
aspectRatioLabel,
detailCropRects,
inventoryCountCropRect,
inventoryGrid,
inventoryRect,
isSixteenNine,
layoutSupportWarning,
profileDetailRect,
} from "./layoutProfile";
const HD = { width: 1920, height: 1080 };
const QHD = { width: 2560, height: 1440 };
const ULTRAWIDE = { width: 3440, height: 1440 };
describe("layoutProfile", () => {
it("detects 16:9 across common resolutions and rejects ultrawide", () => {
expect(isSixteenNine(HD)).toBe(true);
expect(isSixteenNine(QHD)).toBe(true);
expect(isSixteenNine({ width: 3840, height: 2160 })).toBe(true);
expect(isSixteenNine(ULTRAWIDE)).toBe(false);
expect(isSixteenNine({ width: 1920, height: 1200 })).toBe(false); // 16:10
});
it("labels the aspect ratio", () => {
expect(aspectRatioLabel(HD)).toBe("1.78:1");
expect(aspectRatioLabel({ width: 0, height: 0 })).toBe("unknown");
});
it("warns only for non-16:9 resolutions", () => {
expect(layoutSupportWarning(HD)).toBe("");
expect(layoutSupportWarning(QHD)).toBe("");
expect(layoutSupportWarning(ULTRAWIDE)).toContain("nicht 16:9");
expect(layoutSupportWarning({ width: 0, height: 0 })).toBe("");
});
it("keeps the detail rect inside the image and on the right half", () => {
const rect = profileDetailRect(QHD);
expect(rect.x).toBeGreaterThanOrEqual(QHD.width * 0.45);
expect(rect.x + rect.width).toBeLessThanOrEqual(QHD.width);
expect(rect.y + rect.height).toBeLessThanOrEqual(QHD.height);
});
it("produces the four artifact crops in top-to-bottom order, all clamped", () => {
const detail = profileDetailRect(QHD);
const crops = detailCropRects(detail, QHD);
expect(crops.map((crop) => crop.id)).toEqual([
"artifact-title",
"artifact-main-stat",
"artifact-substats",
"artifact-footer",
]);
let previousY = -1;
for (const crop of crops) {
expect(crop.rect.x).toBeGreaterThanOrEqual(0);
expect(crop.rect.y).toBeGreaterThan(previousY);
expect(crop.rect.x + crop.rect.width).toBeLessThanOrEqual(QHD.width);
expect(crop.rect.y + crop.rect.height).toBeLessThanOrEqual(QHD.height);
previousY = crop.rect.y;
}
});
it("places the inventory count crop inside the inventory panel", () => {
const detail = profileDetailRect(QHD);
const inv = inventoryRect(QHD, detail);
const count = inventoryCountCropRect(inv, QHD);
expect(count.x).toBeGreaterThanOrEqual(inv.x);
expect(count.x + count.width).toBeLessThanOrEqual(QHD.width);
});
it("builds a 5-column inventory grid on the left", () => {
const detail = profileDetailRect(QHD);
const grid = inventoryGrid(QHD, detail);
expect(grid.cols).toBe(5);
expect(grid.source).toBe("detected");
expect(grid.centers.length).toBeGreaterThanOrEqual(10);
expect(grid.centers.every((center) => center.x < detail.x)).toBe(true);
});
it("reports a missing grid when the inventory panel is too small", () => {
const tiny = { width: 320, height: 180 };
const grid = inventoryGrid(tiny, profileDetailRect(tiny));
expect(grid.source).toBe("missing");
expect(grid.centers).toHaveLength(0);
});
});
+205
View File
@@ -0,0 +1,205 @@
// Resolution-anchored layout geometry for the artifact inventory screen
// (ADR-009). Inventory Kamera's proven approach is to require borderless 16:9 and
// derive crop/grid coordinates from the client rectangle instead of detecting the
// panel by colour each frame. This module is the single, pure, unit-tested source
// of that geometry; electron/main.ts consumes it for cropping and keeps a
// colour-based detail-rect detector only as a fallback for off-profile setups.
//
// NOTE: the per-field detail crop fractions below are the current working values.
// True IK-style fixed coordinates need calibration against a reference 16:9
// screenshot; the structure here is what those calibrated numbers slot into.
export interface LayoutRect {
x: number;
y: number;
width: number;
height: number;
}
export interface CropTemplateRect {
id: string;
label: string;
rect: LayoutRect;
}
export interface InventoryGridLayout {
centers: Array<{ x: number; y: number; row: number; col: number }>;
rows: number;
cols: number;
confidence: number;
source: "detected" | "missing";
}
const SIXTEEN_NINE = 16 / 9;
export function aspectRatio(size: { width: number; height: number }): number {
if (!size.height) return 0;
return size.width / size.height;
}
export function aspectRatioLabel(size: { width: number; height: number }): string {
const ratio = aspectRatio(size);
if (ratio === 0) return "unknown";
return `${ratio.toFixed(2)}:1`;
}
// Genshin's UI is authored for 16:9; other aspect ratios letterbox or reflow and
// the anchored crops no longer line up. Allow a small tolerance for rounding.
export function isSixteenNine(size: { width: number; height: number }, tolerance = 0.02): boolean {
const ratio = aspectRatio(size);
if (ratio === 0) return false;
return Math.abs(ratio - SIXTEEN_NINE) <= SIXTEEN_NINE * tolerance;
}
// Empty when the client is a supported 16:9; otherwise a warning explaining that
// the anchored crops are unreliable off-profile (ADR-009: non-16:9 is explicitly
// unsupported for the auto scanner).
export function layoutSupportWarning(size: { width: number; height: number }): string {
if (size.width <= 0 || size.height <= 0) return "";
if (isSixteenNine(size)) return "";
return `Aufloesung ${size.width}x${size.height} ist nicht 16:9 (${aspectRatioLabel(size)}). Der Auto-Scan ist auf 16:9 im randlosen Fenstermodus ausgelegt; die Erkennung kann daneben liegen.`;
}
export function clampRect(rect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
const x = Math.max(0, Math.min(imageSize.width - 1, rect.x));
const y = Math.max(0, Math.min(imageSize.height - 1, rect.y));
const maxWidth = Math.max(1, imageSize.width - x);
const maxHeight = Math.max(1, imageSize.height - y);
return {
x,
y,
width: Math.max(1, Math.min(maxWidth, rect.width)),
height: Math.max(1, Math.min(maxHeight, rect.height)),
};
}
// Anchored guess for the artifact detail panel on the right of the screen. Used
// as the primary rect for a clean 16:9 client and as the fallback when colour
// detection cannot find the panel.
export function profileDetailRect(imageSize: { width: number; height: number }): LayoutRect {
const { width, height } = imageSize;
if (width <= 0 || height <= 0) return { x: 0, y: 0, width: Math.max(1, width), height: Math.max(1, height) };
return clampRect(
{
x: Math.round(width * 0.5),
y: Math.round(height * 0.08),
width: Math.round(width * 0.46),
height: Math.round(height * 0.74),
},
imageSize,
);
}
// The four OCR crops inside the detail panel, as fractions of the detail rect.
export function detailCropRects(detailRect: LayoutRect, imageSize: { width: number; height: number }): CropTemplateRect[] {
const templates: CropTemplateRect[] = [
{
id: "artifact-title",
label: "Artifact title",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.05),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.16),
},
},
{
id: "artifact-main-stat",
label: "Main stat",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.2),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.18),
},
},
{
id: "artifact-substats",
label: "Substats",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.41),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.25),
},
},
{
id: "artifact-footer",
label: "Footer",
rect: {
x: Math.round(detailRect.x + detailRect.width * 0.055),
y: Math.round(detailRect.y + detailRect.height * 0.78),
width: Math.round(detailRect.width * 0.82),
height: Math.round(detailRect.height * 0.16),
},
},
];
return templates.map((template) => ({ ...template, rect: clampRect(template.rect, imageSize) }));
}
export function inventoryCountCropRect(inventoryRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
return clampRect(
{
x: Math.round(inventoryRect.x + inventoryRect.width * 0.62),
y: Math.round(inventoryRect.y + inventoryRect.height * 0.02),
width: Math.round(inventoryRect.width * 0.34),
height: Math.round(inventoryRect.height * 0.09),
},
imageSize,
);
}
export function inventoryRect(imageSize: { width: number; height: number }, detailRect: LayoutRect): LayoutRect {
const { width, height } = imageSize;
const preferredWidth = Math.max(140, Math.round(width * 0.48));
const x = Math.round(width * 0.03);
const y = Math.round(detailRect.y + detailRect.height * 0.09);
const availableWidth = Math.max(100, detailRect.x - Math.round(width * 0.04));
const panelWidth = Math.max(100, Math.min(preferredWidth, availableWidth));
const safeWidth = panelWidth > width * 0.85 ? Math.round(width * 0.55) : panelWidth;
return clampRect(
{
x,
y,
width: Math.min(safeWidth, Math.max(width - x - Math.round(width * 0.02), 100)),
height: Math.max(140, Math.round(height * 0.7)),
},
imageSize,
);
}
export function inventoryGrid(imageSize: { width: number; height: number }, detailRect: LayoutRect): InventoryGridLayout {
const rect = inventoryRect(imageSize, detailRect);
const cols = 5;
if (rect.width < 160 || rect.height < 140) {
return { centers: [], rows: 0, cols: 0, confidence: 0, source: "missing" };
}
const cellWidth = Math.max(56, Math.round(rect.width / cols));
const stepX = Math.round(cellWidth * 0.96);
const stepY = Math.round(cellWidth * 1.03);
const visibleRows = Math.max(2, Math.min(6, Math.round(rect.height / Math.max(stepY, 1))));
const startX = rect.x + Math.max(6, Math.round(stepX * 0.45));
const startY = rect.y + Math.max(6, Math.round(stepY * 0.45));
const centers: InventoryGridLayout["centers"] = [];
for (let row = 0; row < visibleRows; row++) {
for (let col = 0; col < cols; col++) {
const x = startX + col * stepX;
const y = startY + row * stepY;
if (x < imageSize.width && y < imageSize.height) {
centers.push({ x, y, row, col });
}
}
}
const trimmed = centers.filter((center) => center.x > 0 && center.y > 0);
return {
centers: trimmed,
rows: visibleRows,
cols,
confidence: trimmed.length >= cols * 2 ? 76 : trimmed.length >= cols ? 58 : 36,
source: "detected",
};
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { binarizeForOcr, computeLuminanceHistogram, otsuThreshold, type Bitmap } from "./ocrPreprocess";
// Build a BGRA bitmap from a grid of [b,g,r] pixels.
function bitmapFrom(pixels: Array<[number, number, number]>, width: number, height: number): Bitmap {
const data = Buffer.alloc(width * height * 4);
pixels.forEach(([b, g, r], index) => {
data[index * 4] = b;
data[index * 4 + 1] = g;
data[index * 4 + 2] = r;
data[index * 4 + 3] = 255;
});
return { data, width, height };
}
describe("ocrPreprocess", () => {
it("computes a luminance histogram over all pixels", () => {
const bitmap = bitmapFrom([
[0, 0, 0],
[255, 255, 255],
[0, 0, 0],
[255, 255, 255],
], 2, 2);
const histogram = computeLuminanceHistogram(bitmap);
expect(histogram[0]).toBe(2);
expect(histogram[255]).toBe(2);
expect(histogram.reduce((sum, count) => sum + count, 0)).toBe(4);
});
it("otsu splits a clean bimodal image between the two peaks", () => {
const histogram = new Array<number>(256).fill(0);
histogram[20] = 50;
histogram[220] = 50;
const threshold = otsuThreshold(histogram);
expect(threshold).toBeGreaterThanOrEqual(20);
expect(threshold).toBeLessThan(220);
});
it("otsu is safe on an empty histogram", () => {
expect(otsuThreshold(new Array<number>(256).fill(0))).toBe(127);
});
it("inverts bright foreground to black-on-white by default", () => {
// Bright text pixel + dark background pixel.
const bitmap = bitmapFrom([
[255, 255, 255], // bright -> should become black
[0, 0, 0], // dark -> should become white
], 2, 1);
const out = binarizeForOcr(bitmap, { threshold: 128 });
expect([out.data[0], out.data[1], out.data[2]]).toEqual([0, 0, 0]);
expect([out.data[4], out.data[5], out.data[6]]).toEqual([255, 255, 255]);
expect(out.data[3]).toBe(255);
});
it("keeps bright foreground white when inversion is disabled", () => {
const bitmap = bitmapFrom([
[255, 255, 255],
[0, 0, 0],
], 2, 1);
const out = binarizeForOcr(bitmap, { threshold: 128, invertBrightForeground: false });
expect(out.data[0]).toBe(255);
expect(out.data[4]).toBe(0);
});
it("preserves dimensions and always emits opaque pixels", () => {
const bitmap = bitmapFrom(Array.from({ length: 9 }, () => [100, 100, 100] as [number, number, number]), 3, 3);
const out = binarizeForOcr(bitmap);
expect(out.width).toBe(3);
expect(out.height).toBe(3);
for (let pixel = 0; pixel < 9; pixel++) {
expect(out.data[pixel * 4 + 3]).toBe(255);
}
});
});
+101
View File
@@ -0,0 +1,101 @@
// OCR preprocessing for artifact crops (ADR-009). Tesseract reads a clean, high
// contrast, dark-text-on-light image far more reliably than Genshin's native
// bright-text-on-dark UI. This binarizes a crop with Otsu thresholding and (by
// default) inverts, because artifact text is the bright foreground.
//
// Works on a raw BGRA bitmap (Electron NativeImage.getBitmap() layout on
// Windows). Kept pure and channel-order-agnostic for luminance so it is unit
// testable without Electron. Upscaling is done separately via NativeImage.resize
// before this runs - interpolated upscaling of small crops is a big Tesseract win
// and NativeImage does it better than hand-rolled JS.
export interface Bitmap {
data: Uint8Array | Buffer;
width: number;
height: number;
}
export interface BinarizeOptions {
/** Artifact text is the bright foreground, so invert to dark-on-light. */
invertBrightForeground?: boolean;
/** Override Otsu with a fixed 0-255 luminance threshold. */
threshold?: number;
}
const BYTES_PER_PIXEL = 4;
// Rec. 601 luma. Channel order does not matter for a weighted sum as long as we
// read the same three bytes; BGRA and RGBA give the same luminance here because
// we weight by position-independent coefficients applied to the actual R/G/B.
function luminanceAt(data: Uint8Array | Buffer, index: number): number {
// NativeImage on Windows is BGRA: byte0=B, byte1=G, byte2=R.
const b = data[index];
const g = data[index + 1];
const r = data[index + 2];
return 0.299 * r + 0.587 * g + 0.114 * b;
}
export function computeLuminanceHistogram(bitmap: Bitmap): number[] {
const histogram = new Array<number>(256).fill(0);
const { data, width, height } = bitmap;
const pixels = width * height;
for (let pixel = 0; pixel < pixels; pixel++) {
const value = Math.round(luminanceAt(data, pixel * BYTES_PER_PIXEL));
histogram[Math.max(0, Math.min(255, value))]++;
}
return histogram;
}
// Otsu's method: pick the threshold that maximizes between-class variance.
export function otsuThreshold(histogram: readonly number[]): number {
const total = histogram.reduce((sum, count) => sum + count, 0);
if (total === 0) return 127;
let sumAll = 0;
for (let level = 0; level < 256; level++) sumAll += level * histogram[level];
let sumBackground = 0;
let weightBackground = 0;
let maxVariance = -1;
let threshold = 127;
for (let level = 0; level < 256; level++) {
weightBackground += histogram[level];
if (weightBackground === 0) continue;
const weightForeground = total - weightBackground;
if (weightForeground === 0) break;
sumBackground += level * histogram[level];
const meanBackground = sumBackground / weightBackground;
const meanForeground = (sumAll - sumBackground) / weightForeground;
const betweenVariance = weightBackground * weightForeground * (meanBackground - meanForeground) ** 2;
if (betweenVariance > maxVariance) {
maxVariance = betweenVariance;
threshold = level;
}
}
return threshold;
}
export function binarizeForOcr(bitmap: Bitmap, options: BinarizeOptions = {}): Bitmap {
const { data, width, height } = bitmap;
const invert = options.invertBrightForeground ?? true;
const threshold = options.threshold ?? otsuThreshold(computeLuminanceHistogram(bitmap));
const output = Buffer.alloc(width * height * BYTES_PER_PIXEL);
const pixels = width * height;
for (let pixel = 0; pixel < pixels; pixel++) {
const index = pixel * BYTES_PER_PIXEL;
const isBright = luminanceAt(data, index) > threshold;
// Bright foreground text -> black; dark background -> white (inverted).
const value = invert ? (isBright ? 0 : 255) : (isBright ? 255 : 0);
output[index] = value;
output[index + 1] = value;
output[index + 2] = value;
output[index + 3] = 255;
}
return { data: output, width, height };
}
+5
View File
@@ -56,6 +56,11 @@ export interface CaptureResult {
source: "ocr" | "missing"; source: "ocr" | "missing";
text: string; text: string;
}; };
layout?: {
aspect: string;
isSixteenNine: boolean;
warning: string;
};
} }
export interface WindowBounds { export interface WindowBounds {