feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
+251
-32
@@ -74,6 +74,7 @@ internal static class Program
|
||||
response["escapePressed"] = state.Escape;
|
||||
response["enterPressed"] = state.Enter;
|
||||
response["f9Pressed"] = state.F9;
|
||||
response["isElevated"] = IsElevated();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -175,14 +176,17 @@ internal static class Program
|
||||
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
|
||||
|
||||
var key = GetString(root, "key");
|
||||
var sent = SendKeyPressBatch(ResolveVirtualKey(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"] = sent;
|
||||
response["inputBlocked"] = sent < 2;
|
||||
response["eventsSent"] = dispatch.EventsSent;
|
||||
response["inputBlocked"] = dispatch.InputBlocked;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -402,17 +406,20 @@ internal static class Program
|
||||
|
||||
private static IntPtr FindGenshinWindow()
|
||||
{
|
||||
if (_genshinHwnd != IntPtr.Zero && Native.IsWindow(_genshinHwnd)) return _genshinHwnd;
|
||||
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 ((name.Contains("GenshinImpact", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Contains("YuanShen", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Contains("Genshin", StringComparison.OrdinalIgnoreCase))
|
||||
&& proc.MainWindowHandle != IntPtr.Zero)
|
||||
if (IsSupportedGenshinProcessName(name) && proc.MainWindowHandle != IntPtr.Zero)
|
||||
{
|
||||
_genshinHwnd = proc.MainWindowHandle;
|
||||
return _genshinHwnd;
|
||||
@@ -428,6 +435,12 @@ internal static class Program
|
||||
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();
|
||||
@@ -498,6 +511,15 @@ internal static class Program
|
||||
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
|
||||
@@ -528,6 +550,7 @@ internal static class Program
|
||||
|
||||
private sealed class NativeScannerService
|
||||
{
|
||||
private const int GuardPollIntervalMs = 25;
|
||||
private readonly object gate = new();
|
||||
private ScannerRunStatus current = ScannerRunStatus.Idle();
|
||||
private bool stopRequested;
|
||||
@@ -621,7 +644,10 @@ internal static class Program
|
||||
{
|
||||
if (current.Running) return current.ToPayload();
|
||||
stopRequested = false;
|
||||
var safeLimit = Math.Clamp(limit <= 0 ? 100 : limit, 1, 1800);
|
||||
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);
|
||||
@@ -732,27 +758,64 @@ internal static class Program
|
||||
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 (ShouldStop())
|
||||
if (captured >= limit) break;
|
||||
var beforeMove = GetInputGuardDecision(focus.Hwnd, $"artifact click {captured + 1}");
|
||||
if (!beforeMove.CanContinue)
|
||||
{
|
||||
Finish("stopped", "stop requested");
|
||||
FinishFromInputGuard(beforeMove);
|
||||
return;
|
||||
}
|
||||
if (captured >= limit) break;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -771,12 +834,16 @@ internal static class Program
|
||||
DateTimeOffset.Now,
|
||||
detailRect.Width,
|
||||
detailRect.Height,
|
||||
category);
|
||||
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;
|
||||
@@ -784,13 +851,19 @@ internal static class Program
|
||||
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);
|
||||
current.ActiveMs = ElapsedMs(current.CaptureStartedAt, now);
|
||||
current.TotalMs = ElapsedMs(current.StartedAt, now);
|
||||
}
|
||||
WriteStatusFileSafe();
|
||||
}
|
||||
|
||||
if (captured >= limit) break;
|
||||
ScrollOneArtifactPage(grid, bounds.Value);
|
||||
var pageScroll = ScrollOneArtifactPage(grid, bounds.Value, focus.Hwnd, page + 1);
|
||||
if (!pageScroll.CanContinue)
|
||||
{
|
||||
FinishFromInputGuard(pageScroll);
|
||||
return;
|
||||
}
|
||||
page++;
|
||||
lock (gate)
|
||||
{
|
||||
@@ -798,7 +871,6 @@ internal static class Program
|
||||
current.Message = $"scrolled to page {page}";
|
||||
}
|
||||
WriteStatusFileSafe();
|
||||
Thread.Sleep(120);
|
||||
}
|
||||
|
||||
Finish("done", $"captured {captured} {category} card crops");
|
||||
@@ -839,37 +911,144 @@ internal static class Program
|
||||
};
|
||||
}
|
||||
|
||||
private static void ScrollOneArtifactPage(NativeGrid grid, Rect bounds)
|
||||
private NativeScannerInputGuardDecision ScrollArtifactInventoryToTop(NativeGrid grid, Rect bounds, IntPtr genshinHwnd)
|
||||
{
|
||||
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);
|
||||
}
|
||||
return ScrollAtGridAnchor(
|
||||
grid,
|
||||
bounds,
|
||||
genshinHwnd,
|
||||
NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents,
|
||||
120,
|
||||
NativeScannerInputSafetyPolicy.InventoryTopResetStabilizationMs,
|
||||
"initial artifact inventory top reset");
|
||||
}
|
||||
|
||||
private bool ShouldStop()
|
||||
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)
|
||||
{
|
||||
if (stopRequested) return true;
|
||||
requested = stopRequested;
|
||||
}
|
||||
|
||||
var state = GetCursorState();
|
||||
return state.Escape || state.Enter || state.F9;
|
||||
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 = current.StartedAt == DateTimeOffset.MinValue
|
||||
? 0
|
||||
: (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds);
|
||||
current.ActiveMs = ElapsedMs(current.CaptureStartedAt, now);
|
||||
current.TotalMs = ElapsedMs(current.StartedAt, now);
|
||||
current.CaptureCompletedAt = now;
|
||||
stopRequested = false;
|
||||
}
|
||||
WriteStatusFileSafe();
|
||||
@@ -929,6 +1108,29 @@ internal static class Program
|
||||
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",
|
||||
@@ -1074,6 +1276,9 @@ internal static class Program
|
||||
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; } = "";
|
||||
@@ -1086,6 +1291,8 @@ internal static class Program
|
||||
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" };
|
||||
|
||||
@@ -1112,6 +1319,9 @@ internal static class Program
|
||||
clicked = Clicked,
|
||||
pages = Pages,
|
||||
activeMs = ActiveMs,
|
||||
totalMs = TotalMs,
|
||||
initialTopResetMs = InitialTopResetMs,
|
||||
initialTopResetCompleted = InitialTopResetCompleted,
|
||||
message = Message,
|
||||
outputRoot = OutputRoot,
|
||||
runDir = RunDir,
|
||||
@@ -1123,6 +1333,9 @@ internal static class Program
|
||||
lastArtifactPath = LastArtifactPath,
|
||||
supportedCategories = SupportedCategories,
|
||||
lastJob = LastJob,
|
||||
startedAt = StartedAt,
|
||||
captureStartedAt = CaptureStartedAt == DateTimeOffset.MinValue ? (DateTimeOffset?)null : CaptureStartedAt,
|
||||
captureCompletedAt = CaptureCompletedAt == DateTimeOffset.MinValue ? (DateTimeOffset?)null : CaptureCompletedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1141,7 +1354,10 @@ internal static class Program
|
||||
DateTimeOffset CapturedAt,
|
||||
int DetailWidth,
|
||||
int DetailHeight,
|
||||
string Category)
|
||||
string Category,
|
||||
int? StarCount,
|
||||
double StarConfidence,
|
||||
string? StarSource)
|
||||
{
|
||||
public object ToPayload() => new
|
||||
{
|
||||
@@ -1158,6 +1374,9 @@ internal static class Program
|
||||
relativePath = RelativePath,
|
||||
absolutePath = AbsolutePath,
|
||||
capturedAt = CapturedAt,
|
||||
starCount = StarCount,
|
||||
starConfidence = StarConfidence,
|
||||
starSource = StarSource,
|
||||
detail = new
|
||||
{
|
||||
width = DetailWidth,
|
||||
|
||||
Reference in New Issue
Block a user