feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>InputHelper.Tests</AssemblyName>
|
||||
<RootNamespace>GenshinAssistant.InputHelper.Tests</RootNamespace>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\input-helper\InputHelper.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,375 @@
|
||||
using GenshinAssistant.InputHelper;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GenshinAssistant.InputHelper.Tests;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static int failures;
|
||||
|
||||
private sealed record ArtifactStarReportEntry(
|
||||
int? Sequence,
|
||||
string ImagePath,
|
||||
int? StarCount,
|
||||
double Confidence,
|
||||
string? Source);
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
if (TryRunArtifactStarReport(args, out var reportExitCode)) return reportExitCode;
|
||||
|
||||
VerifyReadyOnlyForExpectedForeground();
|
||||
VerifyEveryStopSignalWins();
|
||||
VerifyFocusLossBlocksWithoutRefocus();
|
||||
VerifyKeyDispatchRequiresConfirmedFocus();
|
||||
VerifyTopResetIsBoundedAndCoversTheSupportedRange();
|
||||
VerifyWheelPacingKeepsEveryEventGuardableWithoutPerEventSleep();
|
||||
VerifyOnlyTheRealGameProcessCanBecomeTheTarget();
|
||||
VerifyArtifactStarDetectorCountsThreeFourAndFiveStars();
|
||||
VerifyArtifactStarDetectorFailsClosedForAmbiguousNoise();
|
||||
VerifyArtifactStarDetectorRealCropsFromEnvironment();
|
||||
|
||||
if (failures == 0)
|
||||
{
|
||||
Console.WriteLine("InputHelper safety policy and ArtifactStarDetector: all tests passed.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"InputHelper safety policy: {failures} test(s) failed.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only batch report for already captured native Artifact cards. This
|
||||
/// gives the review-corpus workflow an auditable rarity signal without
|
||||
/// focusing Genshin or changing any saved scan result.
|
||||
/// </summary>
|
||||
private static bool TryRunArtifactStarReport(string[] args, out int exitCode)
|
||||
{
|
||||
exitCode = 0;
|
||||
if (args.Length == 0 || !string.Equals(args[0], "--report-artifact-stars", StringComparison.Ordinal)) return false;
|
||||
|
||||
var directory = args.Skip(1)
|
||||
.FirstOrDefault(argument => argument.StartsWith("--directory=", StringComparison.OrdinalIgnoreCase))?
|
||||
.Split('=', 2)[1]
|
||||
.Trim();
|
||||
var output = args.Skip(1)
|
||||
.FirstOrDefault(argument => argument.StartsWith("--output=", StringComparison.OrdinalIgnoreCase))?
|
||||
.Split('=', 2)[1]
|
||||
.Trim();
|
||||
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
|
||||
{
|
||||
Console.Error.WriteLine("--report-artifact-stars requires --directory=<existing native run directory>.");
|
||||
exitCode = 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entries = Directory
|
||||
.EnumerateFiles(directory, "artifact-*.png", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(ReportArtifactStar)
|
||||
.ToArray();
|
||||
var report = new
|
||||
{
|
||||
version = "native-artifact-star-report-v1",
|
||||
createdAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
runDir = Path.GetFullPath(directory),
|
||||
total = entries.Length,
|
||||
counts = entries
|
||||
.GroupBy(entry => entry.StarCount?.ToString() ?? "unknown")
|
||||
.OrderBy(group => group.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Count()),
|
||||
entries,
|
||||
};
|
||||
var json = JsonSerializer.Serialize(report, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
});
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
Console.WriteLine(json);
|
||||
}
|
||||
else
|
||||
{
|
||||
var fullOutputPath = Path.GetFullPath(output);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath)!);
|
||||
File.WriteAllText(fullOutputPath, json + Environment.NewLine);
|
||||
Console.WriteLine($"Artifact star report: {fullOutputPath}");
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"Artifact star report failed: {exception.Message}");
|
||||
exitCode = 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ArtifactStarReportEntry ReportArtifactStar(string path)
|
||||
{
|
||||
using var bitmap = new Bitmap(path);
|
||||
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||
return new ArtifactStarReportEntry(
|
||||
SequenceFromArtifactPath(path),
|
||||
Path.GetFullPath(path),
|
||||
detection.StarCount,
|
||||
detection.Confidence,
|
||||
detection.Source);
|
||||
}
|
||||
|
||||
private static int? SequenceFromArtifactPath(string path)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(path);
|
||||
var prefix = "artifact-";
|
||||
return fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(fileName[prefix.Length..], out var sequence)
|
||||
? sequence
|
||||
: null;
|
||||
}
|
||||
|
||||
private static void VerifyReadyOnlyForExpectedForeground()
|
||||
{
|
||||
var decision = Evaluate(expectedHwnd: 42, foregroundHwnd: 42);
|
||||
Check(decision.CanContinue, "expected Genshin foreground should allow input");
|
||||
Check(decision.Outcome == NativeScannerInputGuardOutcome.Ready, "ready decision should use Ready outcome");
|
||||
}
|
||||
|
||||
private static void VerifyEveryStopSignalWins()
|
||||
{
|
||||
var cases = new[]
|
||||
{
|
||||
(Name: "stop request", Decision: Evaluate(stopRequested: true, foregroundHwnd: 7)),
|
||||
(Name: "ESC", Decision: Evaluate(escapePressed: true, foregroundHwnd: 7)),
|
||||
(Name: "Enter", Decision: Evaluate(enterPressed: true, foregroundHwnd: 7)),
|
||||
(Name: "F9", Decision: Evaluate(f9Pressed: true, foregroundHwnd: 7)),
|
||||
};
|
||||
|
||||
foreach (var item in cases)
|
||||
{
|
||||
Check(!item.Decision.CanContinue, $"{item.Name} should reject input");
|
||||
Check(item.Decision.Outcome == NativeScannerInputGuardOutcome.Stopped, $"{item.Name} should stop cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
private static void VerifyFocusLossBlocksWithoutRefocus()
|
||||
{
|
||||
var mismatch = Evaluate(expectedHwnd: 42, foregroundHwnd: 7, foregroundProcess: "explorer");
|
||||
Check(!mismatch.CanContinue, "foreground mismatch should reject input");
|
||||
Check(mismatch.Outcome == NativeScannerInputGuardOutcome.Blocked, "foreground mismatch should block the run");
|
||||
Check(mismatch.Message.Contains("without refocusing", StringComparison.Ordinal), "focus-loss message should make no-refocus behavior explicit");
|
||||
Check(mismatch.Message.Contains("explorer", StringComparison.Ordinal), "focus-loss message should name the actual foreground process");
|
||||
|
||||
var missingTarget = Evaluate(expectedHwnd: 0, foregroundHwnd: 0);
|
||||
Check(missingTarget.Outcome == NativeScannerInputGuardOutcome.Blocked, "missing target window should block the run");
|
||||
}
|
||||
|
||||
private static void VerifyKeyDispatchRequiresConfirmedFocus()
|
||||
{
|
||||
var sendCalls = 0;
|
||||
var unfocused = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(false, () =>
|
||||
{
|
||||
sendCalls++;
|
||||
return 2;
|
||||
});
|
||||
Check(sendCalls == 0, "unfocused key dispatch must not call SendInput");
|
||||
Check(unfocused.EventsSent == 0, "unfocused key dispatch should report zero events");
|
||||
Check(unfocused.InputBlocked, "unfocused key dispatch should report blocked input");
|
||||
|
||||
var focused = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(true, () =>
|
||||
{
|
||||
sendCalls++;
|
||||
return 2;
|
||||
});
|
||||
Check(sendCalls == 1, "focused key dispatch should call SendInput exactly once");
|
||||
Check(focused.EventsSent == 2, "focused key dispatch should report both key events");
|
||||
Check(!focused.InputBlocked, "focused complete key dispatch should not report blocked input");
|
||||
|
||||
var partial = GenshinAssistant.InputHelper.Program.DispatchKeyPressWhenFocused(true, () => 1);
|
||||
Check(partial.EventsSent == 1, "partial key dispatch should preserve the event count");
|
||||
Check(partial.InputBlocked, "partial key dispatch should report blocked input");
|
||||
}
|
||||
|
||||
private static void VerifyTopResetIsBoundedAndCoversTheSupportedRange()
|
||||
{
|
||||
var requiredPages = (int)Math.Ceiling(
|
||||
NativeScannerInputSafetyPolicy.MaximumArtifactInventoryCapacity /
|
||||
(double)NativeScannerInputSafetyPolicy.VisibleArtifactTargetsPerPage);
|
||||
var minimumEvents = requiredPages * NativeScannerInputSafetyPolicy.PageScrollWheelEvents;
|
||||
|
||||
Check(NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents >= minimumEvents,
|
||||
"top reset should cover every supported artifact page");
|
||||
Check(NativeScannerInputSafetyPolicy.InventoryTopResetWheelEvents <= 3200,
|
||||
"top reset should remain explicitly bounded");
|
||||
Check(NativeScannerInputSafetyPolicy.InventoryTopResetStabilizationMs >= 250,
|
||||
"top reset should include a visible stabilization window");
|
||||
}
|
||||
|
||||
private static void VerifyWheelPacingKeepsEveryEventGuardableWithoutPerEventSleep()
|
||||
{
|
||||
Check(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents == NativeScannerInputSafetyPolicy.PageScrollWheelEvents,
|
||||
"wheel pacing should yield once per calibrated page-sized batch");
|
||||
Check(NativeScannerInputSafetyPolicy.WheelPacingDelayMs > 0 && NativeScannerInputSafetyPolicy.WheelPacingDelayMs <= 5,
|
||||
"wheel pacing delay should remain short and explicitly bounded");
|
||||
Check(!NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(1),
|
||||
"wheel pacing should not sleep after every single event");
|
||||
Check(NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents),
|
||||
"wheel pacing should yield at the batch boundary");
|
||||
Check(NativeScannerInputSafetyPolicy.ShouldPauseAfterWheelEvent(NativeScannerInputSafetyPolicy.WheelPacingBatchEvents * 2),
|
||||
"wheel pacing should yield at repeated batch boundaries");
|
||||
}
|
||||
|
||||
private static void VerifyOnlyTheRealGameProcessCanBecomeTheTarget()
|
||||
{
|
||||
Check(GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("GenshinImpact"),
|
||||
"the global Genshin client process should be allowed");
|
||||
Check(GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("yuanshen"),
|
||||
"the Chinese Genshin client process should be allowed case-insensitively");
|
||||
Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("Genshin Artifact Assistant"),
|
||||
"the assistant must never target itself");
|
||||
Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("GenshinLauncher"),
|
||||
"a launcher or similarly named process must not become the game target");
|
||||
Check(!GenshinAssistant.InputHelper.Program.IsSupportedGenshinProcessName("Genshin"),
|
||||
"partial process-name matches must not be accepted");
|
||||
}
|
||||
|
||||
private static void VerifyArtifactStarDetectorCountsThreeFourAndFiveStars()
|
||||
{
|
||||
foreach (var count in new[] { 3, 4, 5 })
|
||||
{
|
||||
using var bitmap = CreateSyntheticArtifactCard(count);
|
||||
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||
|
||||
Check(detection.StarCount == count, $"synthetic {count}-star card should retain its exact count");
|
||||
Check(detection.Confidence >= 0.80, $"synthetic {count}-star card should be high confidence");
|
||||
Check(detection.Source == ArtifactStarDetector.SourceName, $"synthetic {count}-star card should name the native source");
|
||||
}
|
||||
}
|
||||
|
||||
private static void VerifyArtifactStarDetectorFailsClosedForAmbiguousNoise()
|
||||
{
|
||||
using var bitmap = new Bitmap(492, 838, PixelFormat.Format32bppArgb);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.Clear(Color.FromArgb(188, 138, 87));
|
||||
|
||||
var roi = ArtifactStarDetector.StarRowRoiFor(bitmap.Width, bitmap.Height);
|
||||
using var gold = new SolidBrush(Color.FromArgb(255, 204, 50));
|
||||
var rowY = roi.Y + roi.Height / 2;
|
||||
// Thin gold marks and scattered pixels resemble a noisy capture but do
|
||||
// not form full star glyphs. The detector must not convert this into a
|
||||
// lower-rarity decision.
|
||||
for (var index = 0; index < 5; index++)
|
||||
{
|
||||
graphics.FillRectangle(gold, roi.X + 22 + index * 34, rowY, 22, 4);
|
||||
}
|
||||
var random = new Random(20260711);
|
||||
for (var index = 0; index < 120; index++)
|
||||
{
|
||||
graphics.FillRectangle(
|
||||
gold,
|
||||
roi.X + random.Next(roi.Width),
|
||||
roi.Y + random.Next(roi.Height),
|
||||
1,
|
||||
1);
|
||||
}
|
||||
|
||||
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||
Check(detection.StarCount is null, "ambiguous gold noise must not produce a star count");
|
||||
Check(detection.Confidence < 0.80, "ambiguous gold noise must remain low confidence");
|
||||
Check(detection.Source is null, "ambiguous gold noise must not claim a native rarity source");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional offline regression check for user-provided, already-captured
|
||||
/// cards. The environment value is intentionally explicit rather than
|
||||
/// probing a user's inventory: <c>3=C:\\sample3.png;4=C:\\sample4.png</c>.
|
||||
/// This keeps the test read-only and makes the expected rarity auditable.
|
||||
/// </summary>
|
||||
private static void VerifyArtifactStarDetectorRealCropsFromEnvironment()
|
||||
{
|
||||
var samples = Environment.GetEnvironmentVariable("ARTIFACT_STAR_REAL_CROPS");
|
||||
if (string.IsNullOrWhiteSpace(samples)) return;
|
||||
|
||||
foreach (var sample in samples.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var separator = sample.IndexOf('=');
|
||||
var expectedText = separator > 0 ? sample[..separator] : string.Empty;
|
||||
var path = separator > 0 ? sample[(separator + 1)..].Trim() : string.Empty;
|
||||
if (!int.TryParse(expectedText, out var expected) || expected is < 1 or > 5 || !File.Exists(path))
|
||||
{
|
||||
Check(false, $"real-card sample must use '<1-5>=<existing png path>': {sample}");
|
||||
continue;
|
||||
}
|
||||
|
||||
using var bitmap = new Bitmap(path);
|
||||
var detection = ArtifactStarDetector.Detect(bitmap);
|
||||
Check(detection.StarCount == expected,
|
||||
$"real-card sample '{Path.GetFileName(path)}' should detect {expected} stars, got {detection.StarCount?.ToString() ?? "unknown"}");
|
||||
Check(detection.Confidence >= 0.80,
|
||||
$"real-card sample '{Path.GetFileName(path)}' should meet the accepted confidence threshold");
|
||||
Check(detection.Source == ArtifactStarDetector.SourceName,
|
||||
$"real-card sample '{Path.GetFileName(path)}' should retain the native source");
|
||||
}
|
||||
}
|
||||
|
||||
private static Bitmap CreateSyntheticArtifactCard(int starCount)
|
||||
{
|
||||
var bitmap = new Bitmap(492, 838, PixelFormat.Format32bppArgb);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.Clear(Color.FromArgb(188, 138, 87));
|
||||
|
||||
var roi = ArtifactStarDetector.StarRowRoiFor(bitmap.Width, bitmap.Height);
|
||||
var centerY = roi.Y + roi.Height / 2;
|
||||
var firstCenterX = roi.X + 28;
|
||||
using var gold = new SolidBrush(Color.FromArgb(255, 204, 50));
|
||||
for (var index = 0; index < starCount; index++)
|
||||
{
|
||||
DrawStar(graphics, gold, firstCenterX + index * 34, centerY, 13, 5.5f);
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private static void DrawStar(Graphics graphics, Brush brush, int centerX, int centerY, float outerRadius, float innerRadius)
|
||||
{
|
||||
var points = new PointF[10];
|
||||
for (var index = 0; index < points.Length; index++)
|
||||
{
|
||||
var radius = index % 2 == 0 ? outerRadius : innerRadius;
|
||||
var angle = -Math.PI / 2 + index * Math.PI / 5;
|
||||
points[index] = new PointF(
|
||||
centerX + radius * (float)Math.Cos(angle),
|
||||
centerY + radius * (float)Math.Sin(angle));
|
||||
}
|
||||
graphics.FillPolygon(brush, points);
|
||||
}
|
||||
|
||||
private static NativeScannerInputGuardDecision Evaluate(
|
||||
bool stopRequested = false,
|
||||
bool escapePressed = false,
|
||||
bool enterPressed = false,
|
||||
bool f9Pressed = false,
|
||||
long expectedHwnd = 42,
|
||||
long foregroundHwnd = 42,
|
||||
string foregroundProcess = "GenshinImpact")
|
||||
=> NativeScannerInputSafetyPolicy.Evaluate(
|
||||
stopRequested,
|
||||
escapePressed,
|
||||
enterPressed,
|
||||
f9Pressed,
|
||||
expectedHwnd,
|
||||
foregroundHwnd,
|
||||
foregroundProcess,
|
||||
"test input");
|
||||
|
||||
private static void Check(bool condition, string message)
|
||||
{
|
||||
if (condition) return;
|
||||
failures++;
|
||||
Console.Error.WriteLine($"FAIL: {message}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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