639b0b7f59
Add native IK-style capture processing, Artifact Inventory, explicit promotion and single-result review. Confirm the three live OCR corrections in the eval corpus and preserve extraction/value separation.
1320 lines
49 KiB
C#
1320 lines
49 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;
|
|
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 sent = SendKeyPressBatch(ResolveVirtualKey(key));
|
|
response["key"] = key;
|
|
response["focused"] = info.Focused;
|
|
response["foregroundProcess"] = info.ForegroundProcess;
|
|
response["targetProcess"] = info.TargetProcess;
|
|
response["isElevated"] = IsElevated();
|
|
response["eventsSent"] = sent;
|
|
response["inputBlocked"] = sent < 2;
|
|
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)) 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 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>());
|
|
}
|
|
|
|
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 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, 1800);
|
|
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;
|
|
|
|
while (captured < limit)
|
|
{
|
|
foreach (var target in grid.Targets)
|
|
{
|
|
if (ShouldStop())
|
|
{
|
|
Finish("stopped", "stop requested");
|
|
return;
|
|
}
|
|
if (captured >= limit) break;
|
|
|
|
var screenX = bounds.Value.Left + target.X;
|
|
var screenY = bounds.Value.Top + target.Y;
|
|
Native.SetCursorPos(screenX, screenY);
|
|
var sent = SendMouseClickBatch();
|
|
Thread.Sleep(190);
|
|
|
|
var cardPath = Path.Combine(runDir, $"artifact-{captured + 1:0000}.png");
|
|
using (var card = CaptureArtifactCard(bounds.Value))
|
|
{
|
|
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);
|
|
NativeScannerFiles.AppendJsonLine(jobs, job.ToPayload());
|
|
|
|
captured++;
|
|
lock (gate)
|
|
{
|
|
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 = (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds);
|
|
}
|
|
WriteStatusFileSafe();
|
|
}
|
|
|
|
if (captured >= limit) break;
|
|
ScrollOneArtifactPage(grid, bounds.Value);
|
|
page++;
|
|
lock (gate)
|
|
{
|
|
current.Pages = page;
|
|
current.Message = $"scrolled to page {page}";
|
|
}
|
|
WriteStatusFileSafe();
|
|
Thread.Sleep(120);
|
|
}
|
|
|
|
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 static void ScrollOneArtifactPage(NativeGrid grid, Rect bounds)
|
|
{
|
|
Native.SetCursorPos(bounds.Left + grid.AnchorX, bounds.Top + grid.AnchorY);
|
|
Thread.Sleep(25);
|
|
for (var index = 0; index < 39; index++)
|
|
{
|
|
SendMouseWheel(-120);
|
|
Thread.Sleep(1);
|
|
}
|
|
}
|
|
|
|
private bool ShouldStop()
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (stopRequested) return true;
|
|
}
|
|
var state = GetCursorState();
|
|
return state.Escape || state.Enter || state.F9;
|
|
}
|
|
|
|
private void Finish(string status, string message)
|
|
{
|
|
lock (gate)
|
|
{
|
|
current.Running = false;
|
|
current.Status = status;
|
|
current.Message = message;
|
|
current.ActiveMs = current.StartedAt == DateTimeOffset.MinValue
|
|
? 0
|
|
: (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds);
|
|
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,
|
|
},
|
|
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 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 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,
|
|
message = Message,
|
|
outputRoot = OutputRoot,
|
|
runDir = RunDir,
|
|
manifestPath = ManifestPath,
|
|
jobsPath = JobsPath,
|
|
statusPath = StatusPath,
|
|
dataVersion = DataVersion,
|
|
category = Category,
|
|
lastArtifactPath = LastArtifactPath,
|
|
supportedCategories = SupportedCategories,
|
|
lastJob = LastJob,
|
|
};
|
|
}
|
|
|
|
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)
|
|
{
|
|
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,
|
|
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);
|
|
}
|