feat(scanner): complete localized artifact quality checkpoint

This commit is contained in:
AzuTear
2026-07-11 15:59:19 +02:00
parent 639b0b7f59
commit 8b9f948c6b
215 changed files with 35440 additions and 7273 deletions
+314
View File
@@ -0,0 +1,314 @@
using System.Buffers;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
namespace GenshinAssistant.InputHelper;
/// <summary>
/// A visual-only rarity signal taken from the row of gold stars on an Artifact
/// detail-card crop. It is deliberately conservative: a malformed or
/// inconsistent row produces no count instead of guessing a rarity.
/// </summary>
internal readonly record struct ArtifactStarDetection(
int? StarCount,
double Confidence,
string? Source)
{
internal static ArtifactStarDetection Unknown() => new(null, 0, null);
}
/// <summary>
/// Counts the bright gold Artifact-star glyphs inside the stable, normalized
/// star-row region of the native 492x838-style detail crop. This detector only
/// reads the captured bitmap; it has no screen, process, or input side effects.
/// </summary>
internal static class ArtifactStarDetector
{
internal const string SourceName = "native-star-row-v1";
private const double MinimumAcceptedConfidence = 0.80;
private const double RoiLeft = 0.025;
private const double RoiRight = 0.56;
private const double RoiTop = 0.245;
private const double RoiBottom = 0.345;
internal static ArtifactStarDetection Detect(Bitmap? bitmap)
{
if (bitmap is null || bitmap.Width < 120 || bitmap.Height < 200)
{
return ArtifactStarDetection.Unknown();
}
var roi = StarRowRoiFor(bitmap.Width, bitmap.Height);
if (roi.Width < 24 || roi.Height < 16)
{
return ArtifactStarDetection.Unknown();
}
var bytesPerRow = roi.Width * 4;
var buffer = ArrayPool<byte>.Shared.Rent(bytesPerRow * roi.Height);
BitmapData? data = null;
try
{
// Native capture creates Format32bppArgb cards. Locking explicitly
// to that format keeps the detector allocation-bounded and avoids
// slow per-pixel GDI calls in the scan hot path.
data = bitmap.LockBits(roi, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
for (var y = 0; y < roi.Height; y++)
{
Marshal.Copy(
IntPtr.Add(data.Scan0, y * data.Stride),
buffer,
y * bytesPerRow,
bytesPerRow);
}
}
catch
{
return ArtifactStarDetection.Unknown();
}
finally
{
if (data is not null)
{
bitmap.UnlockBits(data);
}
}
try
{
return DetectFromBgra(buffer, roi.Width, roi.Height, bitmap.Width);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
internal static Rectangle StarRowRoiFor(int width, int height)
{
if (width <= 0 || height <= 0) return Rectangle.Empty;
var left = Math.Clamp((int)Math.Floor(width * RoiLeft), 0, Math.Max(0, width - 1));
var right = Math.Clamp((int)Math.Ceiling(width * RoiRight), left + 1, width);
var top = Math.Clamp((int)Math.Floor(height * RoiTop), 0, Math.Max(0, height - 1));
var bottom = Math.Clamp((int)Math.Ceiling(height * RoiBottom), top + 1, height);
return Rectangle.FromLTRB(left, top, right, bottom);
}
private static ArtifactStarDetection DetectFromBgra(byte[] pixels, int width, int height, int cardWidth)
{
var yellowByColumn = new int[width];
var minYByColumn = Enumerable.Repeat(height, width).ToArray();
var maxYByColumn = Enumerable.Repeat(-1, width).ToArray();
for (var y = 0; y < height; y++)
{
var rowOffset = y * width * 4;
for (var x = 0; x < width; x++)
{
var offset = rowOffset + x * 4;
var blue = pixels[offset];
var green = pixels[offset + 1];
var red = pixels[offset + 2];
if (!IsStrongGold(red, green, blue)) continue;
yellowByColumn[x]++;
minYByColumn[x] = Math.Min(minYByColumn[x], y);
maxYByColumn[x] = Math.Max(maxYByColumn[x], y);
}
}
var minimumYellowPixelsPerColumn = Math.Max(3, (int)Math.Ceiling(height * 0.045));
var rawSegments = FindColumnSegments(yellowByColumn, minYByColumn, maxYByColumn, minimumYellowPixelsPerColumn);
var stars = rawSegments
.Where(segment => IsPlausibleStarSegment(segment, cardWidth, height))
.ToList();
if (stars.Count is < 1 or > 5)
{
return ArtifactStarDetection.Unknown();
}
if (!HasPlausibleRowGeometry(stars, cardWidth, height))
{
return ArtifactStarDetection.Unknown();
}
var confidence = CalculateConfidence(stars, cardWidth, height);
if (confidence < MinimumAcceptedConfidence)
{
return ArtifactStarDetection.Unknown();
}
return new ArtifactStarDetection(stars.Count, confidence, SourceName);
}
private static bool IsStrongGold(byte red, byte green, byte blue)
{
// The card background is warm brown/orange, while a rendered star has a
// noticeably brighter yellow core. Favor precision here because a false
// five-star classification would make a lower-rarity Artifact appear
// ineligible for downstream review instead of preserving uncertainty.
return red >= 215
&& green >= 165
&& blue <= 80
&& red >= green + 24
&& green >= blue + 72;
}
private static List<StarSegment> FindColumnSegments(
int[] yellowByColumn,
int[] minYByColumn,
int[] maxYByColumn,
int minimumYellowPixelsPerColumn)
{
const int maximumInternalGap = 2;
var segments = new List<StarSegment>();
var start = -1;
var lastActive = -1;
for (var x = 0; x < yellowByColumn.Length; x++)
{
var active = yellowByColumn[x] >= minimumYellowPixelsPerColumn;
if (active)
{
if (start < 0) start = x;
lastActive = x;
continue;
}
if (start >= 0 && x - lastActive > maximumInternalGap)
{
segments.Add(CreateSegment(start, lastActive, yellowByColumn, minYByColumn, maxYByColumn));
start = -1;
lastActive = -1;
}
}
if (start >= 0)
{
segments.Add(CreateSegment(start, lastActive, yellowByColumn, minYByColumn, maxYByColumn));
}
return segments;
}
private static StarSegment CreateSegment(
int start,
int end,
int[] yellowByColumn,
int[] minYByColumn,
int[] maxYByColumn)
{
var pixels = 0;
var peakColumnPixels = 0;
var minY = int.MaxValue;
var maxY = -1;
for (var x = start; x <= end; x++)
{
pixels += yellowByColumn[x];
peakColumnPixels = Math.Max(peakColumnPixels, yellowByColumn[x]);
minY = Math.Min(minY, minYByColumn[x]);
maxY = Math.Max(maxY, maxYByColumn[x]);
}
return new StarSegment(start, end, pixels, peakColumnPixels, minY, maxY);
}
private static bool IsPlausibleStarSegment(StarSegment segment, int cardWidth, int roiHeight)
{
var minimumWidth = Math.Max(8, (int)Math.Round(cardWidth * 0.020));
var maximumWidth = Math.Max(minimumWidth + 1, (int)Math.Round(cardWidth * 0.090));
var minimumHeight = Math.Max(8, (int)Math.Round(roiHeight * 0.10));
var maximumHeight = Math.Max(minimumHeight + 1, (int)Math.Round(roiHeight * 0.75));
var minimumPixels = Math.Max(35, (int)Math.Round(cardWidth * 0.16));
var minimumPeakColumnPixels = Math.Max(8, (int)Math.Round(roiHeight * 0.12));
var density = segment.PixelCount / (double)Math.Max(1, segment.Width * segment.Height);
return segment.Width >= minimumWidth
&& segment.Width <= maximumWidth
&& segment.Height >= minimumHeight
&& segment.Height <= maximumHeight
&& segment.PixelCount >= minimumPixels
&& segment.PeakColumnPixels >= minimumPeakColumnPixels
&& density is >= 0.12 and <= 0.78;
}
private static bool HasPlausibleRowGeometry(IReadOnlyList<StarSegment> stars, int cardWidth, int roiHeight)
{
var firstCenter = stars[0].CenterX;
var lastCenter = stars[^1].CenterX;
if (firstCenter < cardWidth * 0.035 || firstCenter > cardWidth * 0.16)
{
return false;
}
if (lastCenter > cardWidth * 0.47)
{
return false;
}
for (var index = 1; index < stars.Count; index++)
{
var spacing = stars[index].CenterX - stars[index - 1].CenterX;
if (spacing < cardWidth * 0.045 || spacing > cardWidth * 0.12)
{
return false;
}
}
var meanCenterY = stars.Average(star => star.CenterY);
return stars.All(star => Math.Abs(star.CenterY - meanCenterY) <= roiHeight * 0.18);
}
private static double CalculateConfidence(IReadOnlyList<StarSegment> stars, int cardWidth, int roiHeight)
{
var widths = stars.Select(star => (double)star.Width).ToArray();
var heights = stars.Select(star => (double)star.Height).ToArray();
var densities = stars.Select(star => star.PixelCount / (double)Math.Max(1, star.Width * star.Height)).ToArray();
var widthConsistency = Consistency(widths);
var heightConsistency = Consistency(heights);
var densityQuality = densities.Average(density => Clamp01(1 - Math.Abs(density - 0.45) / 0.45));
var evidence = Clamp01(stars.Sum(star => star.PixelCount) / (stars.Count * cardWidth * 0.35));
var spacingConsistency = 0.82;
if (stars.Count > 1)
{
var spacings = Enumerable.Range(1, stars.Count - 1)
.Select(index => stars[index].CenterX - stars[index - 1].CenterX)
.ToArray();
spacingConsistency = Consistency(spacings);
}
var meanCenterY = stars.Average(star => star.CenterY);
var alignment = Clamp01(1 - stars.Average(star => Math.Abs(star.CenterY - meanCenterY)) / Math.Max(1, roiHeight * 0.18));
var confidence = 0.20
+ 0.20 * widthConsistency
+ 0.15 * heightConsistency
+ 0.20 * spacingConsistency
+ 0.10 * densityQuality
+ 0.10 * evidence
+ 0.05 * alignment;
return Math.Round(Clamp01(confidence), 3, MidpointRounding.AwayFromZero);
}
private static double Consistency(IReadOnlyList<double> values)
{
if (values.Count <= 1) return 0.88;
var mean = values.Average();
if (mean <= 0) return 0;
var variance = values.Average(value => Math.Pow(value - mean, 2));
return Clamp01(1 - Math.Sqrt(variance) / mean);
}
private static double Clamp01(double value) => Math.Clamp(value, 0, 1);
private sealed record StarSegment(int StartX, int EndX, int PixelCount, int PeakColumnPixels, int MinY, int MaxY)
{
public int Width => EndX - StartX + 1;
public int Height => MaxY - MinY + 1;
public double CenterX => (StartX + EndX) / 2.0;
public double CenterY => (MinY + MaxY) / 2.0;
}
}
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("InputHelper.Tests")]
@@ -0,0 +1,90 @@
namespace GenshinAssistant.InputHelper;
internal enum NativeScannerInputGuardOutcome
{
Ready,
Stopped,
Blocked,
}
internal readonly record struct NativeScannerInputGuardDecision(
NativeScannerInputGuardOutcome Outcome,
string Message)
{
public bool CanContinue => Outcome == NativeScannerInputGuardOutcome.Ready;
public static NativeScannerInputGuardDecision Ready()
=> new(NativeScannerInputGuardOutcome.Ready, "");
public static NativeScannerInputGuardDecision Blocked(string message)
=> new(NativeScannerInputGuardOutcome.Blocked, message);
}
internal static class NativeScannerInputSafetyPolicy
{
// The live inventory capacity is 2,400 Artifacts. A full visible page
// contains 32 safe targets and the live-calibrated page transition uses 39
// wheel events. 3,200 upward events therefore cover the complete inventory
// with a deterministic safety margin while remaining explicitly bounded.
internal const int MaximumArtifactInventoryCapacity = 2400;
internal const int VisibleArtifactTargetsPerPage = 32;
internal const int PageScrollWheelEvents = 39;
internal const int InventoryTopResetWheelEvents = 3200;
internal const int InventoryTopResetStabilizationMs = 300;
internal const int PageScrollStabilizationMs = 120;
internal const int WheelPacingBatchEvents = PageScrollWheelEvents;
internal const int WheelPacingDelayMs = 1;
internal static bool ShouldPauseAfterWheelEvent(int completedEventCount)
=> completedEventCount > 0 && completedEventCount % WheelPacingBatchEvents == 0;
internal static NativeScannerInputGuardDecision Evaluate(
bool stopRequested,
bool escapePressed,
bool enterPressed,
bool f9Pressed,
long expectedGenshinHwnd,
long foregroundHwnd,
string foregroundProcess,
string action)
{
if (stopRequested)
{
return new NativeScannerInputGuardDecision(
NativeScannerInputGuardOutcome.Stopped,
$"stop requested before {action}");
}
if (escapePressed)
{
return new NativeScannerInputGuardDecision(
NativeScannerInputGuardOutcome.Stopped,
$"ESC held - native scan stopped before {action}");
}
if (enterPressed)
{
return new NativeScannerInputGuardDecision(
NativeScannerInputGuardOutcome.Stopped,
$"Enter held - native scan stopped before {action}");
}
if (f9Pressed)
{
return new NativeScannerInputGuardDecision(
NativeScannerInputGuardOutcome.Stopped,
$"F9 held - native scan stopped before {action}");
}
if (expectedGenshinHwnd == 0)
{
return NativeScannerInputGuardDecision.Blocked(
$"Genshin target window is unavailable before {action}; native scan stopped without refocusing.");
}
if (foregroundHwnd != expectedGenshinHwnd)
{
var actual = string.IsNullOrWhiteSpace(foregroundProcess) ? "unknown" : foregroundProcess;
return NativeScannerInputGuardDecision.Blocked(
$"Genshin lost foreground before {action}; native scan stopped without refocusing (foreground: {actual}).");
}
return NativeScannerInputGuardDecision.Ready();
}
}
+251 -32
View File
@@ -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,