Files
genshin-assistant/native/input-helper/Program.cs
T

1539 lines
59 KiB
C#

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 readonly NativeScannerService Scanner = new();
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;
response["isElevated"] = IsElevated();
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(includeProcessNames: false);
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: 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 "key":
{
var info = FocusGenshinWindow();
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
var key = GetString(root, "key");
var virtualKey = ResolveVirtualKey(key);
var dispatch = DispatchKeyPressWhenFocused(
info.Focused,
() => SendKeyPressBatch(virtualKey));
response["key"] = key;
response["focused"] = info.Focused;
response["foregroundProcess"] = info.ForegroundProcess;
response["targetProcess"] = info.TargetProcess;
response["isElevated"] = IsElevated();
response["eventsSent"] = dispatch.EventsSent;
response["inputBlocked"] = dispatch.InputBlocked;
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;
}
case "scanner-data-status":
response["scanner"] = Scanner.DataStatus(GetString(root, "dataDir"));
break;
case "scanner-catalog":
response["scanner"] = IkInventoryLists.CatalogPayload(GetString(root, "dataDir"));
break;
case "scanner-preflight":
response["scanner"] = Scanner.Preflight(
GetString(root, "dataDir"),
NormalizeScannerCategory(GetString(root, "category")));
break;
case "scanner-start":
response["scanner"] = Scanner.Start(
GetString(root, "dataDir"),
GetString(root, "outputRoot"),
GetInt(root, "limit"),
NormalizeScannerCategory(GetString(root, "category")));
break;
case "scanner-stop":
response["scanner"] = Scanner.Stop();
break;
case "scanner-status":
response["scanner"] = Scanner.Status();
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 static string NormalizeScannerCategory(string category)
{
var value = (category ?? "").Trim().ToLowerInvariant();
return value switch
{
"" => "artifacts",
"artifact" => "artifacts",
"artifacts" => "artifacts",
"weapon" => "weapons",
"weapons" => "weapons",
"character" => "characters",
"characters" => "characters",
"material" => "materials",
"materials" => "materials",
_ => value,
};
}
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(bool includeProcessNames = true)
{
var hwnd = FindGenshinWindow();
var info = new FocusInfo
{
Hwnd = hwnd,
ForegroundProcess = "",
TargetProcess = includeProcessNames ? ProcessNameFromHwnd(hwnd) : "",
};
if (hwnd == IntPtr.Zero) return info;
info.AlreadyForeground = Native.GetForegroundWindow() == hwnd;
if (!info.AlreadyForeground)
{
info.SetForegroundResult = ForceForeground(hwnd);
Thread.Sleep(140);
}
var foreground = Native.GetForegroundWindow();
info.Focused = foreground == hwnd;
info.ForegroundProcess = includeProcessNames ? ProcessNameFromHwnd(foreground) : "";
return info;
}
// Plain SetForegroundWindow from a background process is silently refused by
// Windows' foreground lock. Attach the calling thread's input queue to the
// target (and current-foreground) window thread and clear the lock timeout,
// so the foreground change is honored. Without this the auto-scan aborts with
// "Genshin konnte nicht in den Vordergrund geholt werden".
private static bool ForceForeground(IntPtr hwnd)
{
var current = Native.GetCurrentThreadId();
var target = Native.GetWindowThreadProcessId(hwnd, out _);
var foregroundHwnd = Native.GetForegroundWindow();
var foreground = foregroundHwnd != IntPtr.Zero ? Native.GetWindowThreadProcessId(foregroundHwnd, out _) : 0u;
var attachedTarget = false;
var attachedForeground = false;
uint oldTimeout = 0;
var timeoutRead = false;
try
{
if (target != 0 && target != current) attachedTarget = Native.AttachThreadInput(current, target, true);
if (foreground != 0 && foreground != current && foreground != target)
attachedForeground = Native.AttachThreadInput(current, foreground, true);
timeoutRead = Native.SystemParametersInfo(Native.SPI_GETFOREGROUNDLOCKTIMEOUT, 0, ref oldTimeout, 0);
Native.SystemParametersInfo(Native.SPI_SETFOREGROUNDLOCKTIMEOUT, 0, IntPtr.Zero, Native.SPIF_SENDCHANGE);
// Inject a no-op input (0,0 relative mouse move) so this process counts
// as the last input source - one of the conditions Windows requires to
// allow a foreground change. This is what the removed ALT tap did, but
// without the menu-mnemonic side effect.
NudgeInput();
Native.ShowWindowAsync(hwnd, 9); // SW_RESTORE
Native.BringWindowToTop(hwnd);
var ok = Native.SetForegroundWindow(hwnd);
return ok;
}
finally
{
if (timeoutRead)
Native.SystemParametersInfo(Native.SPI_SETFOREGROUNDLOCKTIMEOUT, 0, new IntPtr((long)oldTimeout), Native.SPIF_SENDCHANGE);
if (attachedForeground) Native.AttachThreadInput(current, foreground, false);
if (attachedTarget) Native.AttachThreadInput(current, target, false);
}
}
private static IntPtr FindGenshinWindow()
{
if (_genshinHwnd != IntPtr.Zero
&& Native.IsWindow(_genshinHwnd)
&& IsSupportedGenshinProcessName(ProcessNameFromHwnd(_genshinHwnd)))
{
return _genshinHwnd;
}
_genshinHwnd = IntPtr.Zero;
foreach (var proc in Process.GetProcesses())
{
try
{
var name = proc.ProcessName;
if (IsSupportedGenshinProcessName(name) && proc.MainWindowHandle != IntPtr.Zero)
{
_genshinHwnd = proc.MainWindowHandle;
return _genshinHwnd;
}
}
catch
{
// Process exited between enumeration and inspection; ignore.
}
}
_genshinHwnd = IntPtr.Zero;
return _genshinHwnd;
}
internal static bool IsSupportedGenshinProcessName(string? processName)
{
return string.Equals(processName, "GenshinImpact", StringComparison.OrdinalIgnoreCase)
|| string.Equals(processName, "YuanShen", StringComparison.OrdinalIgnoreCase);
}
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 void NudgeInput()
{
var move = new Native.INPUT[1];
move[0].type = 0; // INPUT_MOUSE
move[0].mi.dwFlags = Native.MOUSEEVENTF_MOVE; // dx=dy=0 -> no cursor movement
Native.SendInput(1, move, Marshal.SizeOf<Native.INPUT>());
}
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 uint SendKeyPressBatch(int virtualKey)
{
var inputs = new Native.INPUT[2];
inputs[0].type = 1; // INPUT_KEYBOARD
inputs[0].mi.dx = virtualKey; // same union bytes as KEYBDINPUT.wVk
inputs[1].type = 1;
inputs[1].mi.dx = virtualKey;
inputs[1].mi.dy = Native.KEYEVENTF_KEYUP; // same union bytes as KEYBDINPUT.dwFlags
return Native.SendInput(2, inputs, Marshal.SizeOf<Native.INPUT>());
}
internal static (uint EventsSent, bool InputBlocked) DispatchKeyPressWhenFocused(
bool focused,
Func<uint> sendKeyPress)
{
if (!focused) return (0, true);
var sent = sendKeyPress();
return (sent, sent < 2);
}
private static int ResolveVirtualKey(string key)
{
return key.Trim().ToUpperInvariant() switch
{
"ESC" or "ESCAPE" => 0x1B,
"ENTER" => 0x0D,
"B" => 0x42,
"C" => 0x43,
"1" => 0x31,
_ => throw new ArgumentOutOfRangeException(nameof(key), $"unsupported key: {key}")
};
}
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;
}
private sealed class NativeScannerService
{
private const int GuardPollIntervalMs = 25;
private readonly object gate = new();
private ScannerRunStatus current = ScannerRunStatus.Idle();
private bool stopRequested;
private Task? worker;
public object DataStatus(string dataDir)
{
return IkInventoryLists.Load(dataDir).ToPayload();
}
public object Preflight(string dataDir, string category)
{
var data = IkInventoryLists.Load(dataDir);
var supportedCategories = data.SupportedCategoriesPayload();
if (!data.Valid)
{
return new
{
data = data.ToPayload(),
category,
supportedCategories,
categoryReady = false,
genshinFound = false,
bounds = (object?)null,
isSixteenNine = false,
grid = NativeGrid.Empty().ToPayload(),
ready = false,
blockReason = $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}",
};
}
if (category != "artifacts")
{
return new
{
data = data.ToPayload(),
category,
supportedCategories,
categoryReady = false,
genshinFound = false,
bounds = (object?)null,
isSixteenNine = false,
grid = NativeGrid.Empty().ToPayload(),
ready = false,
blockReason = NativeCaptureUnsupportedMessage(category),
};
}
var bounds = GetGenshinClientBounds();
var grid = bounds is null ? NativeGrid.Empty() : NativeGrid.ForClient(bounds.Value.Width, bounds.Value.Height);
var isSixteenNine = bounds is not null && IsSixteenNine(bounds.Value.Width, bounds.Value.Height);
var layoutReady = bounds is not null && isSixteenNine && grid.Targets.Count >= 32;
var visual = layoutReady ? CaptureVisualSignal(bounds!.Value) : null;
var ready = layoutReady && (visual?.Ready ?? false);
var blockReason = ready
? ""
: bounds is null
? "Genshin window not found."
: !isSixteenNine
? $"Unsupported layout {bounds.Value.Width}x{bounds.Value.Height}; scanner requires 16:9."
: grid.Targets.Count < 32
? $"Native grid incomplete: {grid.Targets.Count}/32 targets."
: visual?.BlockReason ?? "Genshin capture visual preflight failed.";
return new
{
data = data.ToPayload(),
category,
supportedCategories,
categoryReady = true,
genshinFound = bounds is not null,
bounds = bounds is null
? null
: new
{
left = bounds.Value.Left,
top = bounds.Value.Top,
width = bounds.Value.Width,
height = bounds.Value.Height,
},
isSixteenNine,
grid = grid.ToPayload(),
visual = visual?.ToPayload(),
ready,
blockReason,
};
}
public object Start(string dataDir, string outputRoot, int limit, string category)
{
lock (gate)
{
if (current.Running) return current.ToPayload();
stopRequested = false;
var safeLimit = Math.Clamp(
limit <= 0 ? 100 : limit,
1,
NativeScannerInputSafetyPolicy.MaximumArtifactInventoryCapacity);
var runId = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
current = ScannerRunStatus.Started(runId, safeLimit, outputRoot, category);
var data = IkInventoryLists.Load(dataDir);
current.DataVersion = data.Version;
current.SupportedCategories = data.SupportedCategoriesPayload();
if (!data.Valid)
{
current.Running = false;
current.Status = "blocked";
current.Message = $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}";
return current.ToPayload();
}
if (category != "artifacts")
{
current.Running = false;
current.Status = "blocked";
current.Message = NativeCaptureUnsupportedMessage(category);
return current.ToPayload();
}
worker = Task.Run(() => RunCaptureLoop(dataDir, outputRoot, safeLimit, runId, category));
return current.ToPayload();
}
}
public object Stop()
{
lock (gate)
{
stopRequested = true;
current.Message = current.Running ? "stop requested" : current.Message;
return current.ToPayload();
}
}
public object Status()
{
lock (gate)
{
return current.ToPayload();
}
}
private void RunCaptureLoop(string dataDir, string outputRoot, int limit, string runId, string category)
{
try
{
var data = IkInventoryLists.Load(dataDir);
if (!data.Valid)
{
Finish("blocked", $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}");
return;
}
lock (gate)
{
current.DataVersion = data.Version;
current.SupportedCategories = data.SupportedCategoriesPayload();
}
if (category != "artifacts")
{
Finish("blocked", NativeCaptureUnsupportedMessage(category));
return;
}
var bounds = GetGenshinClientBounds();
if (bounds is null)
{
Finish("blocked", "Genshin window not found.");
return;
}
if (!IsSixteenNine(bounds.Value.Width, bounds.Value.Height))
{
Finish("blocked", $"Unsupported layout {bounds.Value.Width}x{bounds.Value.Height}; scanner requires 16:9.");
return;
}
var focus = FocusGenshinWindow(includeProcessNames: false);
if (!focus.Focused)
{
Finish("blocked", "Genshin could not be focused.");
return;
}
var visual = CaptureVisualSignal(bounds.Value);
if (!visual.Ready)
{
Finish("blocked", visual.BlockReason);
return;
}
var runDir = PrepareRunDirectory(outputRoot, runId);
var manifestPath = Path.Combine(runDir, "manifest.json");
var jobsPath = Path.Combine(runDir, "capture-jobs.jsonl");
var statusPath = Path.Combine(runDir, "status.json");
var grid = NativeGrid.ForClient(bounds.Value.Width, bounds.Value.Height);
var detailRect = DetailRect(bounds.Value.Width, bounds.Value.Height);
lock (gate)
{
current.RunDir = runDir;
current.ManifestPath = manifestPath;
current.JobsPath = jobsPath;
current.StatusPath = statusPath;
current.SupportedCategories = data.SupportedCategoriesPayload();
}
WriteRunManifest(manifestPath, data, bounds.Value, grid, detailRect, limit, runId, category);
WriteStatusFileSafe();
using var jobs = new StreamWriter(jobsPath, append: false, Encoding.UTF8);
var captured = 0;
var page = 1;
var topResetStartedAt = DateTimeOffset.Now;
lock (gate)
{
current.Message = "resetting artifact inventory to top";
}
WriteStatusFileSafe();
var topReset = ScrollArtifactInventoryToTop(grid, bounds.Value, focus.Hwnd);
RecordInitialTopResetTiming(topResetStartedAt, topReset.CanContinue);
if (!topReset.CanContinue)
{
FinishFromInputGuard(topReset);
return;
}
lock (gate)
{
current.Message = "artifact inventory reset to top";
}
WriteStatusFileSafe();
while (captured < limit)
{
foreach (var target in grid.Targets)
{
if (captured >= limit) break;
var beforeMove = GetInputGuardDecision(focus.Hwnd, $"artifact click {captured + 1}");
if (!beforeMove.CanContinue)
{
FinishFromInputGuard(beforeMove);
return;
}
var screenX = bounds.Value.Left + target.X;
var screenY = bounds.Value.Top + target.Y;
Native.SetCursorPos(screenX, screenY);
var beforeClick = GetInputGuardDecision(focus.Hwnd, $"artifact click {captured + 1}");
if (!beforeClick.CanContinue)
{
FinishFromInputGuard(beforeClick);
return;
}
var sent = SendMouseClickBatch();
Thread.Sleep(190);
var beforeCapture = GetInputGuardDecision(focus.Hwnd, $"artifact card capture {captured + 1}");
if (!beforeCapture.CanContinue)
{
FinishFromInputGuard(beforeCapture);
return;
}
var cardPath = Path.Combine(runDir, $"artifact-{captured + 1:0000}.png");
ArtifactStarDetection starDetection;
using (var card = CaptureArtifactCard(bounds.Value))
{
// This is a visual-only signal from the captured
// card. An unclear row remains null and never
// changes input behavior or scan control flow.
starDetection = ArtifactStarDetector.Detect(card);
card.Save(cardPath, ImageFormat.Png);
}
var job = new NativeCaptureJob(
captured + 1,
page,
target.Row,
target.Col,
target.X,
target.Y,
screenX,
screenY,
sent,
Path.GetFileName(cardPath),
cardPath,
DateTimeOffset.Now,
detailRect.Width,
detailRect.Height,
category,
starDetection.StarCount,
starDetection.Confidence,
starDetection.Source);
NativeScannerFiles.AppendJsonLine(jobs, job.ToPayload());
captured++;
lock (gate)
{
var now = DateTimeOffset.Now;
current.Captured = captured;
current.Queued = captured;
current.Clicked = captured;
current.Pages = page;
current.Message = sent >= 2 ? $"captured {category} card {captured}/{limit}" : "input may be blocked";
current.LastArtifactPath = cardPath;
current.LastJob = job.ToPayload();
current.ActiveMs = ElapsedMs(current.CaptureStartedAt, now);
current.TotalMs = ElapsedMs(current.StartedAt, now);
}
WriteStatusFileSafe();
}
if (captured >= limit) break;
var pageScroll = ScrollOneArtifactPage(grid, bounds.Value, focus.Hwnd, page + 1);
if (!pageScroll.CanContinue)
{
FinishFromInputGuard(pageScroll);
return;
}
page++;
lock (gate)
{
current.Pages = page;
current.Message = $"scrolled to page {page}";
}
WriteStatusFileSafe();
}
Finish("done", $"captured {captured} {category} card crops");
}
catch (Exception ex)
{
Finish("blocked", ex.Message);
}
}
private static string PrepareRunDirectory(string outputRoot, string runId)
{
var root = string.IsNullOrWhiteSpace(outputRoot)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GenshinArtifactAssistant", "native-scans")
: outputRoot;
var runDir = Path.Combine(root, runId);
Directory.CreateDirectory(runDir);
return runDir;
}
private static Bitmap CaptureArtifactCard(Rect bounds)
{
var card = DetailRect(bounds.Width, bounds.Height);
var bitmap = new Bitmap(card.Width, card.Height, PixelFormat.Format32bppArgb);
using var graphics = Graphics.FromImage(bitmap);
graphics.CopyFromScreen(bounds.Left + card.Left, bounds.Top + card.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
return bitmap;
}
private static Rect DetailRect(int width, int height)
{
return new Rect
{
Left = (int)Math.Round(width * 0.681),
Top = (int)Math.Round(height * 0.111),
Width = Math.Max(1, (int)Math.Round(width * 0.256)),
Height = Math.Max(1, (int)Math.Round(height * 0.776)),
};
}
private NativeScannerInputGuardDecision ScrollArtifactInventoryToTop(NativeGrid grid, Rect bounds, IntPtr genshinHwnd)
{
return ScrollAtGridAnchor(
grid,
bounds,
genshinHwnd,
NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents,
120,
NativeScannerInputSafetyPolicy.InventoryTopResetStabilizationMs,
"initial artifact inventory top reset");
}
private NativeScannerInputGuardDecision ScrollOneArtifactPage(NativeGrid grid, Rect bounds, IntPtr genshinHwnd, int page)
{
return ScrollAtGridAnchor(
grid,
bounds,
genshinHwnd,
NativeScannerInputSafetyPolicy.PageScrollWheelEvents,
-120,
NativeScannerInputSafetyPolicy.PageScrollStabilizationMs,
$"artifact page {page} scroll");
}
private NativeScannerInputGuardDecision ScrollAtGridAnchor(
NativeGrid grid,
Rect bounds,
IntPtr genshinHwnd,
int wheelEvents,
int wheelDelta,
int stabilizationMs,
string action)
{
var beforeMove = GetInputGuardDecision(genshinHwnd, action);
if (!beforeMove.CanContinue) return beforeMove;
Native.SetCursorPos(bounds.Left + grid.AnchorX, bounds.Top + grid.AnchorY);
Thread.Sleep(GuardPollIntervalMs);
for (var index = 0; index < wheelEvents; index++)
{
var beforeWheel = GetInputGuardDecision(genshinHwnd, action);
if (!beforeWheel.CanContinue) return beforeWheel;
if (SendMouseWheel(wheelDelta) < 1)
{
return NativeScannerInputGuardDecision.Blocked(
$"Windows blocked input during {action}; native scan stopped before continuing.");
}
// Windows can round Thread.Sleep(1) to roughly 15.6 ms. Sleeping
// after every event made the bounded 3,200-event reset take
// about 50 seconds live. Keep the per-event foreground/stop
// guard, but yield only once per calibrated page-sized batch.
if (NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(index + 1))
{
Thread.Sleep(NativeScannerInputSafetyPolicy.WheelPacingDelayMs);
}
}
return WaitForInputStabilization(genshinHwnd, stabilizationMs, $"{action} stabilization");
}
private NativeScannerInputGuardDecision WaitForInputStabilization(IntPtr genshinHwnd, int durationMs, string action)
{
var remaining = Math.Max(0, durationMs);
while (remaining > 0)
{
var wait = Math.Min(GuardPollIntervalMs, remaining);
Thread.Sleep(wait);
remaining -= wait;
var decision = GetInputGuardDecision(genshinHwnd, action);
if (!decision.CanContinue) return decision;
}
return NativeScannerInputGuardDecision.Ready();
}
private NativeScannerInputGuardDecision GetInputGuardDecision(IntPtr genshinHwnd, string action)
{
bool requested;
lock (gate)
{
requested = stopRequested;
}
var state = GetCursorState();
var foreground = Native.GetForegroundWindow();
var foregroundProcess = foreground == genshinHwnd ? "" : ProcessNameFromHwnd(foreground);
return NativeScannerInputSafetyPolicy.Evaluate(
requested,
state.Escape,
state.Enter,
state.F9,
genshinHwnd.ToInt64(),
foreground.ToInt64(),
foregroundProcess,
action);
}
private void FinishFromInputGuard(NativeScannerInputGuardDecision decision)
{
var status = decision.Outcome == NativeScannerInputGuardOutcome.Stopped ? "stopped" : "blocked";
Finish(status, decision.Message);
}
private void RecordInitialTopResetTiming(DateTimeOffset startedAt, bool completed)
{
var now = DateTimeOffset.Now;
lock (gate)
{
current.InitialTopResetMs = ElapsedMs(startedAt, now);
current.InitialTopResetCompleted = completed;
current.TotalMs = ElapsedMs(current.StartedAt, now);
if (completed)
{
current.CaptureStartedAt = now;
current.ActiveMs = 0;
}
}
WriteStatusFileSafe();
}
private static int ElapsedMs(DateTimeOffset startedAt, DateTimeOffset now)
{
return startedAt == DateTimeOffset.MinValue
? 0
: (int)Math.Max(0, (now - startedAt).TotalMilliseconds);
}
private void Finish(string status, string message)
{
var now = DateTimeOffset.Now;
lock (gate)
{
current.Running = false;
current.Status = status;
current.Message = message;
current.ActiveMs = ElapsedMs(current.CaptureStartedAt, now);
current.TotalMs = ElapsedMs(current.StartedAt, now);
current.CaptureCompletedAt = now;
stopRequested = false;
}
WriteStatusFileSafe();
}
private void WriteStatusFileSafe()
{
string statusPath;
object payload;
lock (gate)
{
statusPath = current.StatusPath;
payload = current.ToPayload();
}
if (string.IsNullOrWhiteSpace(statusPath)) return;
try
{
NativeScannerFiles.WriteJson(statusPath, new { scanner = payload });
}
catch
{
// Status files are a recovery aid; scan control remains in memory.
}
}
private static void WriteRunManifest(
string manifestPath,
IkInventoryListStatus data,
Rect bounds,
NativeGrid grid,
Rect detailRect,
int target,
string runId,
string category)
{
NativeScannerFiles.WriteJson(manifestPath, new
{
schemaVersion = 1,
kind = "native-ik-category-card-crop-scan",
runId,
category,
createdAt = DateTimeOffset.Now,
target,
data = data.ToPayload(),
bounds = new
{
left = bounds.Left,
top = bounds.Top,
width = bounds.Width,
height = bounds.Height,
},
grid = grid.ToPayload(),
detailRect = new
{
left = detailRect.Left,
top = detailRect.Top,
width = detailRect.Width,
height = detailRect.Height,
},
inputSafety = new
{
initialTopReset = new
{
enabled = true,
direction = "up",
anchorX = grid.AnchorX,
anchorY = grid.AnchorY,
inventoryCapacity = NativeScannerInputSafetyPolicy.MaximumArtifactInventoryCapacity,
wheelEvents = NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents,
foregroundGuardPerEvent = true,
pacingBatchEvents = NativeScannerInputSafetyPolicy.WheelPacingBatchEvents,
pacingDelayMs = NativeScannerInputSafetyPolicy.WheelPacingDelayMs,
stabilizationMs = NativeScannerInputSafetyPolicy.InventoryTopResetStabilizationMs,
},
foregroundGuard = new
{
beforeEveryClick = true,
beforeEveryWheelEvent = true,
refocusAfterStart = false,
},
stopKeys = new[] { "ESC", "Enter", "F9" },
},
outputs = new
{
jobs = "capture-jobs.jsonl",
status = "status.json",
},
downstream = new
{
queue = "capture-jobs.jsonl",
next = "ocr-parse-store",
evaluation = "deferred",
category,
},
});
}
private static bool IsSixteenNine(int width, int height)
{
if (height <= 0) return false;
const double ratio = 16.0 / 9.0;
var actual = width / (double)height;
return Math.Abs(actual - ratio) <= ratio * 0.02;
}
private static NativeVisualSignal CaptureVisualSignal(Rect bounds)
{
try
{
using var bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
}
return NativeVisualSignal.FromBitmap(bitmap);
}
catch (Exception ex)
{
return NativeVisualSignal.Blocked($"Genshin capture visual preflight failed: {ex.Message}");
}
}
private static string NativeCaptureUnsupportedMessage(string category)
=> $"Native capture for category '{category}' is not implemented yet; IK catalog is available only.";
}
private sealed class NativeVisualSignal
{
public bool Ready { get; private init; }
public int Samples { get; private init; }
public double WhitePct { get; private init; }
public double DarkPct { get; private init; }
public double ColorPct { get; private init; }
public double LumaStdDev { get; private init; }
public string BlockReason { get; private init; } = "";
public static NativeVisualSignal Blocked(string reason) => new()
{
Ready = false,
Samples = 0,
BlockReason = reason,
};
public static NativeVisualSignal FromBitmap(Bitmap bitmap)
{
var stepX = Math.Max(1, bitmap.Width / 120);
var stepY = Math.Max(1, bitmap.Height / 80);
var samples = 0;
var white = 0;
var dark = 0;
var colorful = 0;
double lumaSum = 0;
double lumaSqSum = 0;
for (var y = 0; y < bitmap.Height; y += stepY)
{
for (var x = 0; x < bitmap.Width; x += stepX)
{
var pixel = bitmap.GetPixel(x, y);
var max = Math.Max(pixel.R, Math.Max(pixel.G, pixel.B));
var min = Math.Min(pixel.R, Math.Min(pixel.G, pixel.B));
var luma = 0.2126 * pixel.R + 0.7152 * pixel.G + 0.0722 * pixel.B;
samples++;
if (pixel.R >= 245 && pixel.G >= 245 && pixel.B >= 245) white++;
if (pixel.R <= 12 && pixel.G <= 12 && pixel.B <= 12) dark++;
if (max - min >= 18) colorful++;
lumaSum += luma;
lumaSqSum += luma * luma;
}
}
if (samples <= 0) return Blocked("Genshin capture visual preflight produced no samples.");
var mean = lumaSum / samples;
var variance = Math.Max(0, (lumaSqSum / samples) - mean * mean);
var stdDev = Math.Sqrt(variance);
var whitePct = white * 100.0 / samples;
var darkPct = dark * 100.0 / samples;
var colorPct = colorful * 100.0 / samples;
var blankWhite = whitePct >= 96 && stdDev <= 10;
var blankDark = darkPct >= 96 && stdDev <= 10;
var tooUniform = stdDev <= 4 && colorPct <= 1.5;
var ready = !(blankWhite || blankDark || tooUniform);
var reason = ready
? ""
: blankWhite
? "Genshin capture is blank or almost entirely white."
: blankDark
? "Genshin capture is blank or almost entirely black."
: "Genshin capture is too uniform for native scanning.";
return new NativeVisualSignal
{
Ready = ready,
Samples = samples,
WhitePct = Math.Round(whitePct, 1),
DarkPct = Math.Round(darkPct, 1),
ColorPct = Math.Round(colorPct, 1),
LumaStdDev = Math.Round(stdDev, 1),
BlockReason = reason,
};
}
public object ToPayload() => new
{
ready = Ready,
samples = Samples,
whitePct = WhitePct,
darkPct = DarkPct,
colorPct = ColorPct,
lumaStdDev = LumaStdDev,
blockReason = BlockReason,
};
}
private sealed class ScannerRunStatus
{
public bool Running { get; set; }
public string Status { get; set; } = "idle";
public string RunId { get; set; } = "";
public int Target { get; set; }
public int Captured { get; set; }
public int Queued { get; set; }
public int Clicked { get; set; }
public int Pages { get; set; }
public int ActiveMs { get; set; }
public int TotalMs { get; set; }
public int InitialTopResetMs { get; set; }
public bool InitialTopResetCompleted { get; set; }
public string Message { get; set; } = "";
public string OutputRoot { get; set; } = "";
public string RunDir { get; set; } = "";
public string ManifestPath { get; set; } = "";
public string JobsPath { get; set; } = "";
public string StatusPath { get; set; } = "";
public string DataVersion { get; set; } = "";
public string Category { get; set; } = "artifacts";
public string LastArtifactPath { get; set; } = "";
public object? SupportedCategories { get; set; }
public object? LastJob { get; set; }
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset CaptureStartedAt { get; set; }
public DateTimeOffset CaptureCompletedAt { get; set; }
public static ScannerRunStatus Idle() => new() { Running = false, Status = "idle", Message = "native scanner idle" };
public static ScannerRunStatus Started(string runId, int target, string outputRoot, string category) => new()
{
Running = true,
Status = "running",
RunId = runId,
Category = category,
Target = target,
OutputRoot = outputRoot,
StartedAt = DateTimeOffset.Now,
Message = $"native {category} scanner started",
};
public object ToPayload() => new
{
running = Running,
status = Status,
runId = RunId,
target = Target,
captured = Captured,
queued = Queued,
clicked = Clicked,
pages = Pages,
activeMs = ActiveMs,
totalMs = TotalMs,
initialTopResetMs = InitialTopResetMs,
initialTopResetCompleted = InitialTopResetCompleted,
message = Message,
outputRoot = OutputRoot,
runDir = RunDir,
manifestPath = ManifestPath,
jobsPath = JobsPath,
statusPath = StatusPath,
dataVersion = DataVersion,
category = Category,
lastArtifactPath = LastArtifactPath,
supportedCategories = SupportedCategories,
lastJob = LastJob,
startedAt = StartedAt,
captureStartedAt = CaptureStartedAt == DateTimeOffset.MinValue ? (DateTimeOffset?)null : CaptureStartedAt,
captureCompletedAt = CaptureCompletedAt == DateTimeOffset.MinValue ? (DateTimeOffset?)null : CaptureCompletedAt,
};
}
private sealed record NativeCaptureJob(
int Sequence,
int Page,
int Row,
int Col,
int ClientX,
int ClientY,
int ScreenX,
int ScreenY,
uint ClickEventsSent,
string RelativePath,
string AbsolutePath,
DateTimeOffset CapturedAt,
int DetailWidth,
int DetailHeight,
string Category,
int? StarCount,
double StarConfidence,
string? StarSource)
{
public object ToPayload() => new
{
sequence = Sequence,
category = Category,
page = Page,
row = Row,
col = Col,
clientX = ClientX,
clientY = ClientY,
screenX = ScreenX,
screenY = ScreenY,
clickEventsSent = ClickEventsSent,
relativePath = RelativePath,
absolutePath = AbsolutePath,
capturedAt = CapturedAt,
starCount = StarCount,
starConfidence = StarConfidence,
starSource = StarSource,
detail = new
{
width = DetailWidth,
height = DetailHeight,
},
kind = $"{Category.TrimEnd('s')}-detail-card-crop",
downstream = "ocr-parse-store",
};
}
private sealed class NativeGrid
{
public List<NativeGridTarget> Targets { get; init; } = new();
public int Rows { get; init; }
public int Cols { get; init; }
public int AnchorX { get; init; }
public int AnchorY { get; init; }
public static NativeGrid Empty() => new();
public static NativeGrid ForClient(int width, int height)
{
const int rows = 4;
const int cols = 8;
var startX = (int)Math.Round(width * 0.093);
var startY = (int)Math.Round(height * 0.235);
var stepX = (int)Math.Round(width * 0.076);
var stepY = (int)Math.Round(height * 0.163);
var targets = new List<NativeGridTarget>();
for (var row = 0; row < rows; row++)
{
for (var col = 0; col < cols; col++)
{
targets.Add(new NativeGridTarget(startX + col * stepX, startY + row * stepY, row, col));
}
}
return new NativeGrid
{
Rows = rows,
Cols = cols,
Targets = targets,
AnchorX = Math.Max(1, (int)Math.Round(width * 0.36)),
AnchorY = Math.Max(1, (int)Math.Round(height * 0.5)),
};
}
public object ToPayload() => new
{
rows = Rows,
cols = Cols,
count = Targets.Count,
anchorX = AnchorX,
anchorY = AnchorY,
first = Targets.FirstOrDefault()?.ToPayload(),
last = Targets.LastOrDefault()?.ToPayload(),
};
}
private sealed record NativeGridTarget(int X, int Y, int Row, int Col)
{
public object ToPayload() => new { x = X, y = Y, row = Row, col = Col };
}
}
internal static class Native
{
public const uint MOUSEEVENTF_MOVE = 0x0001;
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
public const uint MOUSEEVENTF_LEFTUP = 0x0004;
public const uint MOUSEEVENTF_WHEEL = 0x0800;
public const int KEYEVENTF_KEYUP = 0x0002;
public const uint SPI_GETFOREGROUNDLOCKTIMEOUT = 0x2000;
public const uint SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001;
public const uint SPIF_SENDCHANGE = 0x0002;
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 bool BringWindowToTop(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
[DllImport("kernel32.dll")]
public static extern uint GetCurrentThreadId();
[DllImport("user32.dll", SetLastError = true)]
public static extern bool SystemParametersInfo(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool SystemParametersInfo(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni);
[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);
}