feat(scanner): add native artifact pipeline
Add native IK-style capture processing, Artifact Inventory, explicit promotion and single-result review. Confirm the three live OCR corrections in the eval corpus and preserve extraction/value separation.
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GenshinAssistant.InputHelper;
|
||||
|
||||
internal static class IkInventoryLists
|
||||
{
|
||||
public static IkInventoryListStatus Load(string dataDir)
|
||||
{
|
||||
var dir = ResolveDirectory(dataDir);
|
||||
var required = new[] { "artifacts.json", "weapons.json", "characters.json", "materials.json", "version.txt" };
|
||||
var missing = required.Where(file => !File.Exists(Path.Combine(dir, file))).ToArray();
|
||||
var status = new IkInventoryListStatus { Directory = dir, Missing = missing };
|
||||
if (missing.Length > 0) return status;
|
||||
|
||||
status.Version = File.ReadAllText(Path.Combine(dir, "version.txt")).Trim();
|
||||
status.ArtifactSets = CountJsonObjectProperties(Path.Combine(dir, "artifacts.json"));
|
||||
status.ArtifactPieces = CountArtifactPieces(Path.Combine(dir, "artifacts.json"));
|
||||
status.Weapons = CountJsonObjectProperties(Path.Combine(dir, "weapons.json"));
|
||||
status.Characters = CountJsonObjectProperties(Path.Combine(dir, "characters.json"));
|
||||
status.Materials = CountJsonObjectProperties(Path.Combine(dir, "materials.json"));
|
||||
return status;
|
||||
}
|
||||
|
||||
private static string ResolveDirectory(string dataDir)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(dataDir)) return dataDir;
|
||||
|
||||
var candidates = new List<string>();
|
||||
var envDir = Environment.GetEnvironmentVariable("IK_INVENTORYLISTS_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(envDir)) candidates.Add(envDir);
|
||||
|
||||
AddDirectoryCandidates(candidates, AppContext.BaseDirectory);
|
||||
AddDirectoryCandidates(candidates, Environment.CurrentDirectory);
|
||||
|
||||
var baseParent = Directory.GetParent(AppContext.BaseDirectory);
|
||||
for (var depth = 0; depth < 6 && baseParent != null; depth++)
|
||||
{
|
||||
AddDirectoryCandidates(candidates, baseParent.FullName);
|
||||
baseParent = baseParent.Parent;
|
||||
}
|
||||
|
||||
return candidates.FirstOrDefault(IsCompleteInventoryListDirectory)
|
||||
?? Path.Combine(AppContext.BaseDirectory, "inventorylists");
|
||||
}
|
||||
|
||||
private static void AddDirectoryCandidates(List<string> candidates, string root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root)) return;
|
||||
candidates.Add(Path.Combine(root, "inventorylists"));
|
||||
candidates.Add(Path.Combine(root, "ik-inventorylists"));
|
||||
candidates.Add(Path.Combine(root, "data", "ik-inventorylists"));
|
||||
}
|
||||
|
||||
private static bool IsCompleteInventoryListDirectory(string dir)
|
||||
{
|
||||
return File.Exists(Path.Combine(dir, "artifacts.json"))
|
||||
&& File.Exists(Path.Combine(dir, "weapons.json"))
|
||||
&& File.Exists(Path.Combine(dir, "characters.json"))
|
||||
&& File.Exists(Path.Combine(dir, "materials.json"))
|
||||
&& File.Exists(Path.Combine(dir, "version.txt"));
|
||||
}
|
||||
|
||||
public static object CatalogPayload(string dataDir)
|
||||
{
|
||||
var status = Load(dataDir);
|
||||
if (!status.Valid)
|
||||
{
|
||||
return new
|
||||
{
|
||||
data = status.ToPayload(),
|
||||
artifacts = Array.Empty<object>(),
|
||||
weapons = Array.Empty<object>(),
|
||||
characters = Array.Empty<object>(),
|
||||
materials = Array.Empty<object>(),
|
||||
};
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
data = status.ToPayload(),
|
||||
artifacts = LoadArtifactCatalog(Path.Combine(status.Directory, "artifacts.json")),
|
||||
weapons = LoadStringMapCatalog(Path.Combine(status.Directory, "weapons.json")),
|
||||
characters = LoadCharacterCatalog(Path.Combine(status.Directory, "characters.json")),
|
||||
materials = LoadStringMapCatalog(Path.Combine(status.Directory, "materials.json")),
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountJsonObjectProperties(string path)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
? doc.RootElement.EnumerateObject().Count()
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static int CountArtifactPieces(string path)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return 0;
|
||||
var count = 0;
|
||||
foreach (var set in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!set.Value.TryGetProperty("artifacts", out var artifacts)) continue;
|
||||
if (artifacts.ValueKind == JsonValueKind.Object) count += artifacts.EnumerateObject().Count();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static List<object> LoadStringMapCatalog(string path)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var entries = new List<object>();
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return entries;
|
||||
foreach (var entry in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
entries.Add(new
|
||||
{
|
||||
normalizedName = entry.Name,
|
||||
good = entry.Value.ValueKind == JsonValueKind.String ? entry.Value.GetString() ?? "" : "",
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static List<object> LoadCharacterCatalog(string path)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var entries = new List<object>();
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return entries;
|
||||
foreach (var entry in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
var value = entry.Value;
|
||||
entries.Add(new
|
||||
{
|
||||
normalizedName = entry.Name,
|
||||
good = JsonString(value, "GOOD"),
|
||||
element = JsonFirstString(value, "Element"),
|
||||
weaponType = JsonFirstInt(value, "WeaponType"),
|
||||
constellationName = JsonFirstString(value, "ConstellationName"),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static List<object> LoadArtifactCatalog(string path)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
var entries = new List<object>();
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return entries;
|
||||
foreach (var set in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
var pieces = new List<object>();
|
||||
if (set.Value.TryGetProperty("artifacts", out var artifacts) && artifacts.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var piece in artifacts.EnumerateObject())
|
||||
{
|
||||
pieces.Add(new
|
||||
{
|
||||
slot = piece.Name,
|
||||
artifactName = JsonString(piece.Value, "artifactName"),
|
||||
good = JsonString(piece.Value, "GOOD"),
|
||||
normalizedName = JsonString(piece.Value, "normalizedName"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.Add(new
|
||||
{
|
||||
normalizedName = set.Name,
|
||||
setName = JsonString(set.Value, "setName"),
|
||||
good = JsonString(set.Value, "GOOD"),
|
||||
pieces,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static string JsonString(JsonElement value, string propertyName)
|
||||
=> value.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
|
||||
? property.GetString() ?? ""
|
||||
: "";
|
||||
|
||||
private static string JsonFirstString(JsonElement value, string propertyName)
|
||||
{
|
||||
if (!value.TryGetProperty(propertyName, out var property)) return "";
|
||||
if (property.ValueKind == JsonValueKind.String) return property.GetString() ?? "";
|
||||
if (property.ValueKind == JsonValueKind.Array && property.GetArrayLength() > 0)
|
||||
{
|
||||
var first = property[0];
|
||||
return first.ValueKind == JsonValueKind.String ? first.GetString() ?? "" : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static int JsonFirstInt(JsonElement value, string propertyName)
|
||||
{
|
||||
if (!value.TryGetProperty(propertyName, out var property)) return -1;
|
||||
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var number)) return number;
|
||||
if (property.ValueKind == JsonValueKind.Array && property.GetArrayLength() > 0)
|
||||
{
|
||||
var first = property[0];
|
||||
return first.ValueKind == JsonValueKind.Number && first.TryGetInt32(out number) ? number : -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class IkInventoryListStatus
|
||||
{
|
||||
public string Directory { get; init; } = "";
|
||||
public string Version { get; set; } = "";
|
||||
public int ArtifactSets { get; set; }
|
||||
public int ArtifactPieces { get; set; }
|
||||
public int Weapons { get; set; }
|
||||
public int Characters { get; set; }
|
||||
public int Materials { get; set; }
|
||||
public string[] Missing { get; init; } = Array.Empty<string>();
|
||||
public bool Valid => Missing.Length == 0;
|
||||
|
||||
public object SupportedCategoriesPayload() => new
|
||||
{
|
||||
artifacts = ArtifactCategoryPayload("artifacts.json", ArtifactSets, ArtifactPieces),
|
||||
weapons = SimpleCategoryPayload("weapons.json", Weapons),
|
||||
characters = SimpleCategoryPayload("characters.json", Characters),
|
||||
materials = SimpleCategoryPayload("materials.json", Materials),
|
||||
};
|
||||
|
||||
private object ArtifactCategoryPayload(string file, int setCount, int pieceCount)
|
||||
{
|
||||
var catalogAvailable = Valid;
|
||||
var nativeCaptureSupported = Valid;
|
||||
return new
|
||||
{
|
||||
file,
|
||||
setCount,
|
||||
pieceCount,
|
||||
supported = nativeCaptureSupported,
|
||||
catalogAvailable,
|
||||
nativeCaptureSupported,
|
||||
scanStatus = nativeCaptureSupported ? "native_capture" : "missing_data",
|
||||
};
|
||||
}
|
||||
|
||||
private object SimpleCategoryPayload(string file, int count)
|
||||
{
|
||||
var catalogAvailable = Valid;
|
||||
const bool nativeCaptureSupported = false;
|
||||
return new
|
||||
{
|
||||
file,
|
||||
count,
|
||||
supported = nativeCaptureSupported,
|
||||
catalogAvailable,
|
||||
nativeCaptureSupported,
|
||||
scanStatus = catalogAvailable ? "catalog_only" : "missing_data",
|
||||
};
|
||||
}
|
||||
|
||||
public object ToPayload() => new
|
||||
{
|
||||
directory = Directory,
|
||||
version = Version,
|
||||
artifactSets = ArtifactSets,
|
||||
artifactPieces = ArtifactPieces,
|
||||
weapons = Weapons,
|
||||
characters = Characters,
|
||||
materials = Materials,
|
||||
totalEntries = ArtifactPieces + Weapons + Characters + Materials,
|
||||
categories = SupportedCategoriesPayload(),
|
||||
missing = Missing,
|
||||
valid = Valid,
|
||||
source = "InventoryKamera inventorylists",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GenshinAssistant.InputHelper;
|
||||
|
||||
internal static class NativeScannerFiles
|
||||
{
|
||||
private static readonly JsonSerializerOptions PrettyJson = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions LineJson = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static void WriteJson(string path, object payload)
|
||||
{
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(payload, PrettyJson));
|
||||
}
|
||||
|
||||
public static void AppendJsonLine(StreamWriter writer, object payload)
|
||||
{
|
||||
writer.WriteLine(JsonSerializer.Serialize(payload, LineJson));
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ namespace GenshinAssistant.InputHelper;
|
||||
internal static class Program
|
||||
{
|
||||
private static IntPtr _genshinHwnd = IntPtr.Zero;
|
||||
private static readonly NativeScannerService Scanner = new();
|
||||
|
||||
private static int Main()
|
||||
{
|
||||
@@ -109,9 +110,8 @@ internal static class Program
|
||||
|
||||
var targetX = GetInt(root, "x");
|
||||
var targetY = GetInt(root, "y");
|
||||
// Bare SetCursorPos then a batched down+up click, matching the
|
||||
// verified Inventory Kamera sequence: no extra move event, no
|
||||
// gap between move and click.
|
||||
// Bare SetCursorPos then a batched down+up click: no extra move
|
||||
// event, no gap between move and click.
|
||||
Native.SetCursorPos(targetX, targetY);
|
||||
Native.GetCursorPos(out var pt);
|
||||
var onTarget = Math.Abs(targetX - pt.X) <= 2 && Math.Abs(targetY - pt.Y) <= 2;
|
||||
@@ -238,6 +238,36 @@ internal static class Program
|
||||
break;
|
||||
}
|
||||
|
||||
case "scanner-data-status":
|
||||
response["scanner"] = Scanner.DataStatus(GetString(root, "dataDir"));
|
||||
break;
|
||||
|
||||
case "scanner-catalog":
|
||||
response["scanner"] = IkInventoryLists.CatalogPayload(GetString(root, "dataDir"));
|
||||
break;
|
||||
|
||||
case "scanner-preflight":
|
||||
response["scanner"] = Scanner.Preflight(
|
||||
GetString(root, "dataDir"),
|
||||
NormalizeScannerCategory(GetString(root, "category")));
|
||||
break;
|
||||
|
||||
case "scanner-start":
|
||||
response["scanner"] = Scanner.Start(
|
||||
GetString(root, "dataDir"),
|
||||
GetString(root, "outputRoot"),
|
||||
GetInt(root, "limit"),
|
||||
NormalizeScannerCategory(GetString(root, "category")));
|
||||
break;
|
||||
|
||||
case "scanner-stop":
|
||||
response["scanner"] = Scanner.Stop();
|
||||
break;
|
||||
|
||||
case "scanner-status":
|
||||
response["scanner"] = Scanner.Status();
|
||||
break;
|
||||
|
||||
default:
|
||||
response["ok"] = false;
|
||||
response["error"] = "unknown op";
|
||||
@@ -262,6 +292,24 @@ internal static class Program
|
||||
public bool F9;
|
||||
}
|
||||
|
||||
private static string NormalizeScannerCategory(string category)
|
||||
{
|
||||
var value = (category ?? "").Trim().ToLowerInvariant();
|
||||
return value switch
|
||||
{
|
||||
"" => "artifacts",
|
||||
"artifact" => "artifacts",
|
||||
"artifacts" => "artifacts",
|
||||
"weapon" => "weapons",
|
||||
"weapons" => "weapons",
|
||||
"character" => "characters",
|
||||
"characters" => "characters",
|
||||
"material" => "materials",
|
||||
"materials" => "materials",
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
|
||||
private struct FocusInfo
|
||||
{
|
||||
public IntPtr Hwnd;
|
||||
@@ -308,10 +356,9 @@ internal static class Program
|
||||
}
|
||||
|
||||
// Plain SetForegroundWindow from a background process is silently refused by
|
||||
// Windows' foreground lock. Inventory Kamera and other reliable automation
|
||||
// tools bypass it by attaching the calling thread's input queue to the target
|
||||
// (and current-foreground) window thread and clearing the lock timeout, so the
|
||||
// foreground change is honored. Without this the auto-scan aborts with
|
||||
// Windows' foreground lock. Attach the calling thread's input queue to the
|
||||
// target (and current-foreground) window thread and clear the lock timeout,
|
||||
// so the foreground change is honored. Without this the auto-scan aborts with
|
||||
// "Genshin konnte nicht in den Vordergrund geholt werden".
|
||||
private static bool ForceForeground(IntPtr hwnd)
|
||||
{
|
||||
@@ -478,6 +525,701 @@ internal static class Program
|
||||
if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class NativeScannerService
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private ScannerRunStatus current = ScannerRunStatus.Idle();
|
||||
private bool stopRequested;
|
||||
private Task? worker;
|
||||
|
||||
public object DataStatus(string dataDir)
|
||||
{
|
||||
return IkInventoryLists.Load(dataDir).ToPayload();
|
||||
}
|
||||
|
||||
public object Preflight(string dataDir, string category)
|
||||
{
|
||||
var data = IkInventoryLists.Load(dataDir);
|
||||
var supportedCategories = data.SupportedCategoriesPayload();
|
||||
if (!data.Valid)
|
||||
{
|
||||
return new
|
||||
{
|
||||
data = data.ToPayload(),
|
||||
category,
|
||||
supportedCategories,
|
||||
categoryReady = false,
|
||||
genshinFound = false,
|
||||
bounds = (object?)null,
|
||||
isSixteenNine = false,
|
||||
grid = NativeGrid.Empty().ToPayload(),
|
||||
ready = false,
|
||||
blockReason = $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}",
|
||||
};
|
||||
}
|
||||
|
||||
if (category != "artifacts")
|
||||
{
|
||||
return new
|
||||
{
|
||||
data = data.ToPayload(),
|
||||
category,
|
||||
supportedCategories,
|
||||
categoryReady = false,
|
||||
genshinFound = false,
|
||||
bounds = (object?)null,
|
||||
isSixteenNine = false,
|
||||
grid = NativeGrid.Empty().ToPayload(),
|
||||
ready = false,
|
||||
blockReason = NativeCaptureUnsupportedMessage(category),
|
||||
};
|
||||
}
|
||||
|
||||
var bounds = GetGenshinClientBounds();
|
||||
var grid = bounds is null ? NativeGrid.Empty() : NativeGrid.ForClient(bounds.Value.Width, bounds.Value.Height);
|
||||
var isSixteenNine = bounds is not null && IsSixteenNine(bounds.Value.Width, bounds.Value.Height);
|
||||
var layoutReady = bounds is not null && isSixteenNine && grid.Targets.Count >= 32;
|
||||
var visual = layoutReady ? CaptureVisualSignal(bounds!.Value) : null;
|
||||
var ready = layoutReady && (visual?.Ready ?? false);
|
||||
var blockReason = ready
|
||||
? ""
|
||||
: bounds is null
|
||||
? "Genshin window not found."
|
||||
: !isSixteenNine
|
||||
? $"Unsupported layout {bounds.Value.Width}x{bounds.Value.Height}; scanner requires 16:9."
|
||||
: grid.Targets.Count < 32
|
||||
? $"Native grid incomplete: {grid.Targets.Count}/32 targets."
|
||||
: visual?.BlockReason ?? "Genshin capture visual preflight failed.";
|
||||
return new
|
||||
{
|
||||
data = data.ToPayload(),
|
||||
category,
|
||||
supportedCategories,
|
||||
categoryReady = true,
|
||||
genshinFound = bounds is not null,
|
||||
bounds = bounds is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
left = bounds.Value.Left,
|
||||
top = bounds.Value.Top,
|
||||
width = bounds.Value.Width,
|
||||
height = bounds.Value.Height,
|
||||
},
|
||||
isSixteenNine,
|
||||
grid = grid.ToPayload(),
|
||||
visual = visual?.ToPayload(),
|
||||
ready,
|
||||
blockReason,
|
||||
};
|
||||
}
|
||||
|
||||
public object Start(string dataDir, string outputRoot, int limit, string category)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (current.Running) return current.ToPayload();
|
||||
stopRequested = false;
|
||||
var safeLimit = Math.Clamp(limit <= 0 ? 100 : limit, 1, 1800);
|
||||
var runId = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
|
||||
current = ScannerRunStatus.Started(runId, safeLimit, outputRoot, category);
|
||||
var data = IkInventoryLists.Load(dataDir);
|
||||
current.DataVersion = data.Version;
|
||||
current.SupportedCategories = data.SupportedCategoriesPayload();
|
||||
if (!data.Valid)
|
||||
{
|
||||
current.Running = false;
|
||||
current.Status = "blocked";
|
||||
current.Message = $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}";
|
||||
return current.ToPayload();
|
||||
}
|
||||
if (category != "artifacts")
|
||||
{
|
||||
current.Running = false;
|
||||
current.Status = "blocked";
|
||||
current.Message = NativeCaptureUnsupportedMessage(category);
|
||||
return current.ToPayload();
|
||||
}
|
||||
worker = Task.Run(() => RunCaptureLoop(dataDir, outputRoot, safeLimit, runId, category));
|
||||
return current.ToPayload();
|
||||
}
|
||||
}
|
||||
|
||||
public object Stop()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
stopRequested = true;
|
||||
current.Message = current.Running ? "stop requested" : current.Message;
|
||||
return current.ToPayload();
|
||||
}
|
||||
}
|
||||
|
||||
public object Status()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return current.ToPayload();
|
||||
}
|
||||
}
|
||||
|
||||
private void RunCaptureLoop(string dataDir, string outputRoot, int limit, string runId, string category)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = IkInventoryLists.Load(dataDir);
|
||||
if (!data.Valid)
|
||||
{
|
||||
Finish("blocked", $"IK inventorylists incomplete: {string.Join(", ", data.Missing)}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
current.DataVersion = data.Version;
|
||||
current.SupportedCategories = data.SupportedCategoriesPayload();
|
||||
}
|
||||
|
||||
if (category != "artifacts")
|
||||
{
|
||||
Finish("blocked", NativeCaptureUnsupportedMessage(category));
|
||||
return;
|
||||
}
|
||||
|
||||
var bounds = GetGenshinClientBounds();
|
||||
if (bounds is null)
|
||||
{
|
||||
Finish("blocked", "Genshin window not found.");
|
||||
return;
|
||||
}
|
||||
if (!IsSixteenNine(bounds.Value.Width, bounds.Value.Height))
|
||||
{
|
||||
Finish("blocked", $"Unsupported layout {bounds.Value.Width}x{bounds.Value.Height}; scanner requires 16:9.");
|
||||
return;
|
||||
}
|
||||
|
||||
var focus = FocusGenshinWindow(includeProcessNames: false);
|
||||
if (!focus.Focused)
|
||||
{
|
||||
Finish("blocked", "Genshin could not be focused.");
|
||||
return;
|
||||
}
|
||||
|
||||
var visual = CaptureVisualSignal(bounds.Value);
|
||||
if (!visual.Ready)
|
||||
{
|
||||
Finish("blocked", visual.BlockReason);
|
||||
return;
|
||||
}
|
||||
|
||||
var runDir = PrepareRunDirectory(outputRoot, runId);
|
||||
var manifestPath = Path.Combine(runDir, "manifest.json");
|
||||
var jobsPath = Path.Combine(runDir, "capture-jobs.jsonl");
|
||||
var statusPath = Path.Combine(runDir, "status.json");
|
||||
var grid = NativeGrid.ForClient(bounds.Value.Width, bounds.Value.Height);
|
||||
var detailRect = DetailRect(bounds.Value.Width, bounds.Value.Height);
|
||||
lock (gate)
|
||||
{
|
||||
current.RunDir = runDir;
|
||||
current.ManifestPath = manifestPath;
|
||||
current.JobsPath = jobsPath;
|
||||
current.StatusPath = statusPath;
|
||||
current.SupportedCategories = data.SupportedCategoriesPayload();
|
||||
}
|
||||
WriteRunManifest(manifestPath, data, bounds.Value, grid, detailRect, limit, runId, category);
|
||||
WriteStatusFileSafe();
|
||||
using var jobs = new StreamWriter(jobsPath, append: false, Encoding.UTF8);
|
||||
var captured = 0;
|
||||
var page = 1;
|
||||
|
||||
while (captured < limit)
|
||||
{
|
||||
foreach (var target in grid.Targets)
|
||||
{
|
||||
if (ShouldStop())
|
||||
{
|
||||
Finish("stopped", "stop requested");
|
||||
return;
|
||||
}
|
||||
if (captured >= limit) break;
|
||||
|
||||
var screenX = bounds.Value.Left + target.X;
|
||||
var screenY = bounds.Value.Top + target.Y;
|
||||
Native.SetCursorPos(screenX, screenY);
|
||||
var sent = SendMouseClickBatch();
|
||||
Thread.Sleep(190);
|
||||
|
||||
var cardPath = Path.Combine(runDir, $"artifact-{captured + 1:0000}.png");
|
||||
using (var card = CaptureArtifactCard(bounds.Value))
|
||||
{
|
||||
card.Save(cardPath, ImageFormat.Png);
|
||||
}
|
||||
|
||||
var job = new NativeCaptureJob(
|
||||
captured + 1,
|
||||
page,
|
||||
target.Row,
|
||||
target.Col,
|
||||
target.X,
|
||||
target.Y,
|
||||
screenX,
|
||||
screenY,
|
||||
sent,
|
||||
Path.GetFileName(cardPath),
|
||||
cardPath,
|
||||
DateTimeOffset.Now,
|
||||
detailRect.Width,
|
||||
detailRect.Height,
|
||||
category);
|
||||
NativeScannerFiles.AppendJsonLine(jobs, job.ToPayload());
|
||||
|
||||
captured++;
|
||||
lock (gate)
|
||||
{
|
||||
current.Captured = captured;
|
||||
current.Queued = captured;
|
||||
current.Clicked = captured;
|
||||
current.Pages = page;
|
||||
current.Message = sent >= 2 ? $"captured {category} card {captured}/{limit}" : "input may be blocked";
|
||||
current.LastArtifactPath = cardPath;
|
||||
current.LastJob = job.ToPayload();
|
||||
current.ActiveMs = (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds);
|
||||
}
|
||||
WriteStatusFileSafe();
|
||||
}
|
||||
|
||||
if (captured >= limit) break;
|
||||
ScrollOneArtifactPage(grid, bounds.Value);
|
||||
page++;
|
||||
lock (gate)
|
||||
{
|
||||
current.Pages = page;
|
||||
current.Message = $"scrolled to page {page}";
|
||||
}
|
||||
WriteStatusFileSafe();
|
||||
Thread.Sleep(120);
|
||||
}
|
||||
|
||||
Finish("done", $"captured {captured} {category} card crops");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Finish("blocked", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string PrepareRunDirectory(string outputRoot, string runId)
|
||||
{
|
||||
var root = string.IsNullOrWhiteSpace(outputRoot)
|
||||
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GenshinArtifactAssistant", "native-scans")
|
||||
: outputRoot;
|
||||
var runDir = Path.Combine(root, runId);
|
||||
Directory.CreateDirectory(runDir);
|
||||
return runDir;
|
||||
}
|
||||
|
||||
private static Bitmap CaptureArtifactCard(Rect bounds)
|
||||
{
|
||||
var card = DetailRect(bounds.Width, bounds.Height);
|
||||
var bitmap = new Bitmap(card.Width, card.Height, PixelFormat.Format32bppArgb);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.CopyFromScreen(bounds.Left + card.Left, bounds.Top + card.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private static Rect DetailRect(int width, int height)
|
||||
{
|
||||
return new Rect
|
||||
{
|
||||
Left = (int)Math.Round(width * 0.681),
|
||||
Top = (int)Math.Round(height * 0.111),
|
||||
Width = Math.Max(1, (int)Math.Round(width * 0.256)),
|
||||
Height = Math.Max(1, (int)Math.Round(height * 0.776)),
|
||||
};
|
||||
}
|
||||
|
||||
private static void ScrollOneArtifactPage(NativeGrid grid, Rect bounds)
|
||||
{
|
||||
Native.SetCursorPos(bounds.Left + grid.AnchorX, bounds.Top + grid.AnchorY);
|
||||
Thread.Sleep(25);
|
||||
for (var index = 0; index < 39; index++)
|
||||
{
|
||||
SendMouseWheel(-120);
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldStop()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (stopRequested) return true;
|
||||
}
|
||||
var state = GetCursorState();
|
||||
return state.Escape || state.Enter || state.F9;
|
||||
}
|
||||
|
||||
private void Finish(string status, string message)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
current.Running = false;
|
||||
current.Status = status;
|
||||
current.Message = message;
|
||||
current.ActiveMs = current.StartedAt == DateTimeOffset.MinValue
|
||||
? 0
|
||||
: (int)Math.Max(0, (DateTimeOffset.Now - current.StartedAt).TotalMilliseconds);
|
||||
stopRequested = false;
|
||||
}
|
||||
WriteStatusFileSafe();
|
||||
}
|
||||
|
||||
private void WriteStatusFileSafe()
|
||||
{
|
||||
string statusPath;
|
||||
object payload;
|
||||
lock (gate)
|
||||
{
|
||||
statusPath = current.StatusPath;
|
||||
payload = current.ToPayload();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(statusPath)) return;
|
||||
try
|
||||
{
|
||||
NativeScannerFiles.WriteJson(statusPath, new { scanner = payload });
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Status files are a recovery aid; scan control remains in memory.
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteRunManifest(
|
||||
string manifestPath,
|
||||
IkInventoryListStatus data,
|
||||
Rect bounds,
|
||||
NativeGrid grid,
|
||||
Rect detailRect,
|
||||
int target,
|
||||
string runId,
|
||||
string category)
|
||||
{
|
||||
NativeScannerFiles.WriteJson(manifestPath, new
|
||||
{
|
||||
schemaVersion = 1,
|
||||
kind = "native-ik-category-card-crop-scan",
|
||||
runId,
|
||||
category,
|
||||
createdAt = DateTimeOffset.Now,
|
||||
target,
|
||||
data = data.ToPayload(),
|
||||
bounds = new
|
||||
{
|
||||
left = bounds.Left,
|
||||
top = bounds.Top,
|
||||
width = bounds.Width,
|
||||
height = bounds.Height,
|
||||
},
|
||||
grid = grid.ToPayload(),
|
||||
detailRect = new
|
||||
{
|
||||
left = detailRect.Left,
|
||||
top = detailRect.Top,
|
||||
width = detailRect.Width,
|
||||
height = detailRect.Height,
|
||||
},
|
||||
outputs = new
|
||||
{
|
||||
jobs = "capture-jobs.jsonl",
|
||||
status = "status.json",
|
||||
},
|
||||
downstream = new
|
||||
{
|
||||
queue = "capture-jobs.jsonl",
|
||||
next = "ocr-parse-store",
|
||||
evaluation = "deferred",
|
||||
category,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsSixteenNine(int width, int height)
|
||||
{
|
||||
if (height <= 0) return false;
|
||||
const double ratio = 16.0 / 9.0;
|
||||
var actual = width / (double)height;
|
||||
return Math.Abs(actual - ratio) <= ratio * 0.02;
|
||||
}
|
||||
|
||||
private static NativeVisualSignal CaptureVisualSignal(Rect bounds)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
|
||||
}
|
||||
return NativeVisualSignal.FromBitmap(bitmap);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return NativeVisualSignal.Blocked($"Genshin capture visual preflight failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NativeCaptureUnsupportedMessage(string category)
|
||||
=> $"Native capture for category '{category}' is not implemented yet; IK catalog is available only.";
|
||||
|
||||
}
|
||||
|
||||
private sealed class NativeVisualSignal
|
||||
{
|
||||
public bool Ready { get; private init; }
|
||||
public int Samples { get; private init; }
|
||||
public double WhitePct { get; private init; }
|
||||
public double DarkPct { get; private init; }
|
||||
public double ColorPct { get; private init; }
|
||||
public double LumaStdDev { get; private init; }
|
||||
public string BlockReason { get; private init; } = "";
|
||||
|
||||
public static NativeVisualSignal Blocked(string reason) => new()
|
||||
{
|
||||
Ready = false,
|
||||
Samples = 0,
|
||||
BlockReason = reason,
|
||||
};
|
||||
|
||||
public static NativeVisualSignal FromBitmap(Bitmap bitmap)
|
||||
{
|
||||
var stepX = Math.Max(1, bitmap.Width / 120);
|
||||
var stepY = Math.Max(1, bitmap.Height / 80);
|
||||
var samples = 0;
|
||||
var white = 0;
|
||||
var dark = 0;
|
||||
var colorful = 0;
|
||||
double lumaSum = 0;
|
||||
double lumaSqSum = 0;
|
||||
|
||||
for (var y = 0; y < bitmap.Height; y += stepY)
|
||||
{
|
||||
for (var x = 0; x < bitmap.Width; x += stepX)
|
||||
{
|
||||
var pixel = bitmap.GetPixel(x, y);
|
||||
var max = Math.Max(pixel.R, Math.Max(pixel.G, pixel.B));
|
||||
var min = Math.Min(pixel.R, Math.Min(pixel.G, pixel.B));
|
||||
var luma = 0.2126 * pixel.R + 0.7152 * pixel.G + 0.0722 * pixel.B;
|
||||
samples++;
|
||||
if (pixel.R >= 245 && pixel.G >= 245 && pixel.B >= 245) white++;
|
||||
if (pixel.R <= 12 && pixel.G <= 12 && pixel.B <= 12) dark++;
|
||||
if (max - min >= 18) colorful++;
|
||||
lumaSum += luma;
|
||||
lumaSqSum += luma * luma;
|
||||
}
|
||||
}
|
||||
|
||||
if (samples <= 0) return Blocked("Genshin capture visual preflight produced no samples.");
|
||||
|
||||
var mean = lumaSum / samples;
|
||||
var variance = Math.Max(0, (lumaSqSum / samples) - mean * mean);
|
||||
var stdDev = Math.Sqrt(variance);
|
||||
var whitePct = white * 100.0 / samples;
|
||||
var darkPct = dark * 100.0 / samples;
|
||||
var colorPct = colorful * 100.0 / samples;
|
||||
var blankWhite = whitePct >= 96 && stdDev <= 10;
|
||||
var blankDark = darkPct >= 96 && stdDev <= 10;
|
||||
var tooUniform = stdDev <= 4 && colorPct <= 1.5;
|
||||
var ready = !(blankWhite || blankDark || tooUniform);
|
||||
var reason = ready
|
||||
? ""
|
||||
: blankWhite
|
||||
? "Genshin capture is blank or almost entirely white."
|
||||
: blankDark
|
||||
? "Genshin capture is blank or almost entirely black."
|
||||
: "Genshin capture is too uniform for native scanning.";
|
||||
|
||||
return new NativeVisualSignal
|
||||
{
|
||||
Ready = ready,
|
||||
Samples = samples,
|
||||
WhitePct = Math.Round(whitePct, 1),
|
||||
DarkPct = Math.Round(darkPct, 1),
|
||||
ColorPct = Math.Round(colorPct, 1),
|
||||
LumaStdDev = Math.Round(stdDev, 1),
|
||||
BlockReason = reason,
|
||||
};
|
||||
}
|
||||
|
||||
public object ToPayload() => new
|
||||
{
|
||||
ready = Ready,
|
||||
samples = Samples,
|
||||
whitePct = WhitePct,
|
||||
darkPct = DarkPct,
|
||||
colorPct = ColorPct,
|
||||
lumaStdDev = LumaStdDev,
|
||||
blockReason = BlockReason,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ScannerRunStatus
|
||||
{
|
||||
public bool Running { get; set; }
|
||||
public string Status { get; set; } = "idle";
|
||||
public string RunId { get; set; } = "";
|
||||
public int Target { get; set; }
|
||||
public int Captured { get; set; }
|
||||
public int Queued { get; set; }
|
||||
public int Clicked { get; set; }
|
||||
public int Pages { get; set; }
|
||||
public int ActiveMs { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
public string OutputRoot { get; set; } = "";
|
||||
public string RunDir { get; set; } = "";
|
||||
public string ManifestPath { get; set; } = "";
|
||||
public string JobsPath { get; set; } = "";
|
||||
public string StatusPath { get; set; } = "";
|
||||
public string DataVersion { get; set; } = "";
|
||||
public string Category { get; set; } = "artifacts";
|
||||
public string LastArtifactPath { get; set; } = "";
|
||||
public object? SupportedCategories { get; set; }
|
||||
public object? LastJob { get; set; }
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
|
||||
public static ScannerRunStatus Idle() => new() { Running = false, Status = "idle", Message = "native scanner idle" };
|
||||
|
||||
public static ScannerRunStatus Started(string runId, int target, string outputRoot, string category) => new()
|
||||
{
|
||||
Running = true,
|
||||
Status = "running",
|
||||
RunId = runId,
|
||||
Category = category,
|
||||
Target = target,
|
||||
OutputRoot = outputRoot,
|
||||
StartedAt = DateTimeOffset.Now,
|
||||
Message = $"native {category} scanner started",
|
||||
};
|
||||
|
||||
public object ToPayload() => new
|
||||
{
|
||||
running = Running,
|
||||
status = Status,
|
||||
runId = RunId,
|
||||
target = Target,
|
||||
captured = Captured,
|
||||
queued = Queued,
|
||||
clicked = Clicked,
|
||||
pages = Pages,
|
||||
activeMs = ActiveMs,
|
||||
message = Message,
|
||||
outputRoot = OutputRoot,
|
||||
runDir = RunDir,
|
||||
manifestPath = ManifestPath,
|
||||
jobsPath = JobsPath,
|
||||
statusPath = StatusPath,
|
||||
dataVersion = DataVersion,
|
||||
category = Category,
|
||||
lastArtifactPath = LastArtifactPath,
|
||||
supportedCategories = SupportedCategories,
|
||||
lastJob = LastJob,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record NativeCaptureJob(
|
||||
int Sequence,
|
||||
int Page,
|
||||
int Row,
|
||||
int Col,
|
||||
int ClientX,
|
||||
int ClientY,
|
||||
int ScreenX,
|
||||
int ScreenY,
|
||||
uint ClickEventsSent,
|
||||
string RelativePath,
|
||||
string AbsolutePath,
|
||||
DateTimeOffset CapturedAt,
|
||||
int DetailWidth,
|
||||
int DetailHeight,
|
||||
string Category)
|
||||
{
|
||||
public object ToPayload() => new
|
||||
{
|
||||
sequence = Sequence,
|
||||
category = Category,
|
||||
page = Page,
|
||||
row = Row,
|
||||
col = Col,
|
||||
clientX = ClientX,
|
||||
clientY = ClientY,
|
||||
screenX = ScreenX,
|
||||
screenY = ScreenY,
|
||||
clickEventsSent = ClickEventsSent,
|
||||
relativePath = RelativePath,
|
||||
absolutePath = AbsolutePath,
|
||||
capturedAt = CapturedAt,
|
||||
detail = new
|
||||
{
|
||||
width = DetailWidth,
|
||||
height = DetailHeight,
|
||||
},
|
||||
kind = $"{Category.TrimEnd('s')}-detail-card-crop",
|
||||
downstream = "ocr-parse-store",
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class NativeGrid
|
||||
{
|
||||
public List<NativeGridTarget> Targets { get; init; } = new();
|
||||
public int Rows { get; init; }
|
||||
public int Cols { get; init; }
|
||||
public int AnchorX { get; init; }
|
||||
public int AnchorY { get; init; }
|
||||
|
||||
public static NativeGrid Empty() => new();
|
||||
|
||||
public static NativeGrid ForClient(int width, int height)
|
||||
{
|
||||
const int rows = 4;
|
||||
const int cols = 8;
|
||||
var startX = (int)Math.Round(width * 0.093);
|
||||
var startY = (int)Math.Round(height * 0.235);
|
||||
var stepX = (int)Math.Round(width * 0.076);
|
||||
var stepY = (int)Math.Round(height * 0.163);
|
||||
var targets = new List<NativeGridTarget>();
|
||||
for (var row = 0; row < rows; row++)
|
||||
{
|
||||
for (var col = 0; col < cols; col++)
|
||||
{
|
||||
targets.Add(new NativeGridTarget(startX + col * stepX, startY + row * stepY, row, col));
|
||||
}
|
||||
}
|
||||
return new NativeGrid
|
||||
{
|
||||
Rows = rows,
|
||||
Cols = cols,
|
||||
Targets = targets,
|
||||
AnchorX = Math.Max(1, (int)Math.Round(width * 0.36)),
|
||||
AnchorY = Math.Max(1, (int)Math.Round(height * 0.5)),
|
||||
};
|
||||
}
|
||||
|
||||
public object ToPayload() => new
|
||||
{
|
||||
rows = Rows,
|
||||
cols = Cols,
|
||||
count = Targets.Count,
|
||||
anchorX = AnchorX,
|
||||
anchorY = AnchorY,
|
||||
first = Targets.FirstOrDefault()?.ToPayload(),
|
||||
last = Targets.LastOrDefault()?.ToPayload(),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record NativeGridTarget(int X, int Y, int Row, int Col)
|
||||
{
|
||||
public object ToPayload() => new { x = X, y = Y, row = Row, col = Col };
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Native
|
||||
|
||||
Reference in New Issue
Block a user