Improve IK-style artifact scanner pipeline

This commit is contained in:
AzuTear
2026-07-07 22:02:24 +02:00
parent 8ebbe91c39
commit f791d1464c
70 changed files with 7408 additions and 445 deletions
+16
View File
@@ -56,6 +56,22 @@ try {
& (Join-Path $PSScriptRoot "kill-stale-instances.ps1")
$devPort = 5173
$devControlPort = 17317
$devControlInUse = $null
try {
$devControlInUse = Get-NetTCPConnection -LocalPort $devControlPort -State Listen -ErrorAction Stop | Select-Object -First 1
} catch {
# Get-NetTCPConnection kann auf manchen Systemen fehlen; das ist kein Fehler.
$devControlInUse = $null
}
if ($devControlInUse) {
$portOwner = Get-Process -Id $devControlInUse.OwningProcess -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "FEHLER: Dev-Control-Port $devControlPort ist noch belegt (Prozess: $($portOwner.ProcessName), PID $($devControlInUse.OwningProcess))." -ForegroundColor Red
Write-Host "Das wuerde Live-Tests gegen eine alte App-Instanz laufen lassen. Schliesse die alte App oder beende diesen Prozess und starte npm run dev:admin erneut." -ForegroundColor Red
throw "Dev-Control-Port $devControlPort ist durch PID $($devControlInUse.OwningProcess) belegt."
}
$portInUse = $null
try {
$portInUse = Get-NetTCPConnection -LocalPort $devPort -State Listen -ErrorAction Stop | Select-Object -First 1
+106
View File
@@ -44,6 +44,9 @@ const artifactPieces = artifacts
const slotByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.slot]));
const setByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.setName]));
const setToPieces = Object.fromEntries(
artifacts.map((set) => [set.name, artifactPieces.filter((piece) => piece.setName === set.name).map((piece) => piece.name)]),
);
const mainStats = [
'Elemental Mastery',
@@ -198,6 +201,23 @@ const data = {
'Qiqi ': 'Qiqi',
},
},
lookup: {
normalizedKeys: {
sets: normalizedMap(artifacts.map((set) => set.name)),
pieces: normalizedMap(artifactPieces.map((piece) => piece.name)),
slots: normalizedMap(['Flower of Life', 'Plume of Death', 'Sands of Eon', 'Goblet of Eonothem', 'Circlet of Logos']),
stats: normalizedMap([...mainStats, ...substats]),
characters: normalizedMap(characters.map((character) => character.name)),
},
goodKeys: {
sets: Object.fromEntries(artifacts.map((set) => [set.name, goodKey(set.name)])),
pieces: Object.fromEntries(artifactPieces.map((piece) => [piece.name, goodKey(piece.name)])),
stats: Object.fromEntries([...mainStats, ...substats].map((stat) => [stat, goodStatKey(stat)])),
characters: Object.fromEntries(characters.map((character) => [character.name, goodKey(character.name)])),
},
setToPieces,
validation: validateLookupPackage({ artifacts, artifactPieces, characters, mainStats, substats, setToPieces }),
},
uiProfiles: {
artifactDetailEn: {
language: 'English',
@@ -231,3 +251,89 @@ function slotFromRelicType(relicType) {
return '';
}
}
function normalizeLookupKey(value) {
return String(value)
.toLowerCase()
.normalize('NFKD')
.replace(/[']/g, '')
.replace(/[^a-z0-9]+/g, '');
}
function normalizedMap(values) {
return Object.fromEntries(values.filter(Boolean).map((value) => [normalizeLookupKey(value), value]));
}
function goodKey(value) {
return String(value)
.replace(/[']/g, '')
.replace(/[^A-Za-z0-9]+(.)/g, (_match, next) => String(next).toUpperCase())
.replace(/^[a-z]/, (first) => first.toUpperCase())
.replace(/[^A-Za-z0-9]/g, '');
}
function goodStatKey(stat) {
switch (stat) {
case 'HP':
return 'hp';
case 'HP%':
return 'hp_';
case 'ATK':
return 'atk';
case 'ATK%':
return 'atk_';
case 'DEF':
return 'def';
case 'DEF%':
return 'def_';
case 'Elemental Mastery':
return 'eleMas';
case 'Energy Recharge':
return 'enerRech_';
case 'CRIT Rate':
return 'critRate_';
case 'CRIT DMG':
return 'critDMG_';
case 'Healing Bonus':
return 'heal_';
case 'Physical DMG Bonus':
return 'physical_dmg_';
default:
return stat.toLowerCase().replace(' dmg bonus', '_dmg_').replace(/\s+/g, '');
}
}
function validateLookupPackage({ artifacts, artifactPieces, characters, mainStats, substats, setToPieces }) {
const errors = [];
const warnings = [];
const setNames = new Set(artifacts.map((set) => set.name));
const slotNames = new Set(['Flower of Life', 'Plume of Death', 'Sands of Eon', 'Goblet of Eonothem', 'Circlet of Logos']);
const goodSetKeys = new Set();
for (const set of artifacts) {
const key = goodKey(set.name);
if (goodSetKeys.has(key)) errors.push(`Duplicate GOOD set key: ${key}`);
goodSetKeys.add(key);
if ((setToPieces[set.name] ?? []).length === 0) warnings.push(`Set has no pieces: ${set.name}`);
}
for (const piece of artifactPieces) {
if (!setNames.has(piece.setName)) errors.push(`Piece ${piece.name} references missing set ${piece.setName}`);
if (!slotNames.has(piece.slot)) errors.push(`Piece ${piece.name} references missing slot ${piece.slot}`);
}
if (!characters.length) warnings.push('No characters generated.');
if (!mainStats.length || !substats.length) errors.push('Stats were not generated.');
return {
valid: errors.length === 0,
errors,
warnings,
summary: {
artifactSets: artifacts.length,
artifactPieces: artifactPieces.length,
characters: characters.length,
stats: mainStats.length + substats.length,
},
};
}
+54 -5
View File
@@ -16,8 +16,25 @@
$ErrorActionPreference = "Stop"
$project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
$electronPath = Join-Path $project "node_modules\electron\dist\electron.exe"
$devControlPort = 17317
$killed = 0
$candidatePids = @{}
try {
$health = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$devControlPort/health" -TimeoutSec 2 -ErrorAction Stop
if ($health.appBuild -and $health.appBuild.cwd -eq $project) {
Write-Host "Dev-Control-Port $devControlPort gehoert zu diesem Projekt (PID $($health.appBuild.pid), Signatur $($health.appBuild.signature)). Versuche Self-Shutdown..."
try {
Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$devControlPort/dev/shutdown?reason=restart" -TimeoutSec 2 -ErrorAction Stop | Out-Null
Start-Sleep -Milliseconds 900
} catch {
Write-Host "Self-Shutdown nicht verfuegbar oder fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
} catch {
# No dev-control server or an older/stuck process; continue with process scan.
}
Get-CimInstance Win32_Process |
Where-Object {
@@ -26,15 +43,47 @@ Get-CimInstance Win32_Process |
($_.Name -eq "powershell.exe" -and $_.CommandLine -like "*input-helper.ps1*")
} |
ForEach-Object {
Write-Host "Beende alte Instanz: $($_.Name) (PID $($_.ProcessId))"
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
$killed++
$candidatePids[[int]$_.ProcessId] = $_.Name
}
try {
Get-NetTCPConnection -LocalPort $devControlPort -State Listen -ErrorAction Stop |
ForEach-Object {
if ($_.OwningProcess -gt 0) {
$owner = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
$candidatePids[[int]$_.OwningProcess] = if ($owner) { "$($owner.ProcessName) port $devControlPort" } else { "port $devControlPort owner" }
}
}
} catch {
# Get-NetTCPConnection can be unavailable on some machines; process matching
# above still handles the normal non-elevated path.
}
$failed = 0
foreach ($entry in $candidatePids.GetEnumerator()) {
Write-Host "Beende alte Instanz: $($entry.Value) (PID $($entry.Key))"
try {
Stop-Process -Id $entry.Key -Force -ErrorAction Stop
$killed++
} catch {
Write-Host "WARNUNG: Konnte PID $($entry.Key) nicht beenden: $($_.Exception.Message)" -ForegroundColor Yellow
$failed++
}
}
if ($killed -gt 0) {
# Windows braucht einen Moment, um Ports/Handles wirklich freizugeben.
Start-Sleep -Milliseconds 500
Write-Host "$killed alte Prozess(e) beendet."
} else {
Write-Host "Keine alten Instanzen gefunden."
}
if ($failed -gt 0) {
Write-Host "$failed alte Prozess(e) konnten nicht beendet werden. Wenn das ein Admin-Prozess ist, starte npm run dev:admin und bestaetige UAC oder schliesse die alte App manuell." -ForegroundColor Yellow
throw "$failed alte Prozess(e) konnten nicht beendet werden."
}
if ($killed -eq 0 -and $failed -eq 0) {
Write-Host "Keine alten Instanzen gefunden."
} else {
Start-Sleep -Milliseconds 300
}
+685
View File
@@ -0,0 +1,685 @@
param(
[string]$BaseUrl = "http://127.0.0.1:17317",
[int[]]$ProbeIndices = @(1, 3),
[int[]]$Limits = @(2, 5, 10, 20),
[int]$PollIntervalSeconds = 2,
[int]$TimeoutSeconds = 600,
[string]$OutputRoot = (Join-Path (Resolve-Path -LiteralPath ".").Path "outputs\live-soak"),
[switch]$GoalRun,
[ValidateSet("current", "ik-traineddata", "compare")]
[string]$ScanEngine = "current",
[switch]$BenchmarkOcr,
[int]$BenchmarkLimit = 5,
[ValidateSet("current", "ik-traineddata", "compare")]
[string]$BenchmarkEngine = "compare",
[ValidateSet("fast", "full")]
[string]$BenchmarkProfile = "fast",
[switch]$SkipSmartCapture,
[switch]$SaveFullReviewSamples,
[switch]$AllowStaleBuild,
[string]$ExpectedAppSignature,
[switch]$ContinueAfterBlocked,
[switch]$SelfTestAssessment
)
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($ExpectedAppSignature)) {
$mainPath = Join-Path (Resolve-Path -LiteralPath ".").Path "electron\main.ts"
if (Test-Path -LiteralPath $mainPath) {
$mainSource = Get-Content -LiteralPath $mainPath -Raw
$match = [regex]::Match($mainSource, 'APP_RUNTIME_SIGNATURE\s*=\s*"([^"]+)"')
if ($match.Success) {
$ExpectedAppSignature = $match.Groups[1].Value
}
}
}
function ConvertTo-SafeFilePart([string]$Value) {
$safe = $Value -replace "[^A-Za-z0-9._-]+", "-"
$safe = $safe.Trim("-")
if ($safe.Length -eq 0) { return "item" }
if ($safe.Length -gt 80) { return $safe.Substring(0, 80) }
return $safe
}
function Invoke-DevJson([string]$Path) {
$uri = if ($Path.StartsWith("http")) { $Path } else { "$BaseUrl$Path" }
Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 60
}
function Save-Json([string]$Name, [object]$Payload) {
$path = Join-Path $RunDir "$(ConvertTo-SafeFilePart $Name).json"
$Payload | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $path -Encoding UTF8
return $path
}
function Assert-CurrentAppBuild([object]$HealthPayload) {
if ($AllowStaleBuild) { return }
if ($null -eq $HealthPayload.appBuild) {
throw "Dev endpoint is stale: /health has no appBuild signature. Close the old elevated Genshin Artifact Assistant/Electron instance, restart with npm run dev:admin, then rerun this script. Use -AllowStaleBuild only for debugging old instances."
}
if ([string]::IsNullOrWhiteSpace([string]$HealthPayload.appBuild.signature)) {
throw "Dev endpoint is stale: appBuild.signature is empty. Restart the elevated app before live scanner timing."
}
if (-not [string]::IsNullOrWhiteSpace($ExpectedAppSignature) -and [string]$HealthPayload.appBuild.signature -ne $ExpectedAppSignature) {
throw "Dev endpoint is stale: appBuild.signature='$($HealthPayload.appBuild.signature)' but current source expects '$ExpectedAppSignature'. Close the old Electron instance, restart with npm run dev:admin, then rerun this script. Use -AllowStaleBuild only for debugging old instances."
}
}
function Get-ScannerStatus {
Invoke-DevJson "/scanner/status"
}
function Test-ProbeSucceeded([object]$ProbePayload) {
if ($ProbePayload.ok) { return $true }
if ($ProbePayload.changed) { return $true }
return $false
}
function Get-CompletedScanSummary([object]$StatusPayload, [int]$Limit) {
if ($null -ne $StatusPayload.status.summary) {
return $StatusPayload.status.summary
}
$reviewStatus = [string]$StatusPayload.status.reviewStatus
$status = "done"
if ($reviewStatus -match "blockiert") {
$status = "blocked"
} elseif ($reviewStatus -match "gestoppt") {
$status = "stopped"
}
$stats = $StatusPayload.status.stats
return [pscustomobject]@{
mode = "Automatischer Scan"
status = $status
clicked = [int]$stats.clicked
attempted = [int]$stats.attempted
verified = [int]$stats.verified
parsed = [int]$stats.parsed
stored = [int]$stats.stored
review = [int]$stats.review
duplicates = [int]$stats.duplicates
misses = [int]$stats.misses
pages = [int]$stats.pages
targetCount = $Limit
gridLabel = $reviewStatus
}
}
function Convert-ScanTimingSummary([object]$StatusPayload, [int]$Limit, [string]$Engine) {
$summary = Get-CompletedScanSummary $StatusPayload $Limit
$stats = $StatusPayload.status.stats
return [pscustomobject]@{
engine = $Engine
limit = $Limit
status = $summary.status
clicked = $summary.clicked
attempted = $summary.attempted
verified = $summary.verified
parsed = $summary.parsed
stored = $summary.stored
review = $summary.review
duplicates = $summary.duplicates
misses = $summary.misses
pages = $summary.pages
elapsedMs = [int]$stats.elapsedMs
activeScanMs = [int]$stats.activeScanMs
writeFlushMs = [int]$stats.writeFlushMs
averageMsPerParsed = [int]$stats.averageMsPerParsed
activeAverageMsPerParsed = [int]$stats.activeAverageMsPerParsed
artifactsPerMinute = [double]$stats.artifactsPerMinute
activeArtifactsPerMinute = [double]$stats.activeArtifactsPerMinute
projectedMsFor100 = [int]$stats.projectedMsFor100
activeProjectedMsFor100 = [int]$stats.activeProjectedMsFor100
averageCaptureMs = [int]$stats.averageCaptureMs
averageOcrMs = [int]$stats.averageOcrMs
captureP50Ms = [int]$stats.captureP50Ms
captureP90Ms = [int]$stats.captureP90Ms
ocrP50Ms = [int]$stats.ocrP50Ms
ocrP90Ms = [int]$stats.ocrP90Ms
cardReadyMs = [int]$stats.cardReadyMs
cardReadyCount = [int]$stats.cardReadyCount
averageCardReadyMs = [int]$stats.averageCardReadyMs
scrollReadyMs = [int]$stats.scrollReadyMs
scrollReadyCount = [int]$stats.scrollReadyCount
averageScrollReadyMs = [int]$stats.averageScrollReadyMs
captureMs = [int]$stats.captureMs
ocrMs = [int]$stats.ocrMs
reviewStatus = [string]$StatusPayload.status.reviewStatus
}
}
function Write-ScanTimingLine([object]$Timing) {
Write-Host ("engine={0} limit={1} status={2} parsed={3} review={4} miss={5} elapsed={6}ms active={7}ms flush={8}ms avg={9}ms activeAvg={10}ms captureAvg={11}ms captureP50={12}ms captureP90={13}ms ocrAvg={14}ms ocrP50={15}ms ocrP90={16}ms cardReadyAvg={17}ms scrollReadyAvg={18}ms ppm={19} activePpm={20} projected100={21}ms activeProjected100={22}ms" -f `
$Timing.engine,
$Timing.limit,
$Timing.status,
$Timing.parsed,
$Timing.review,
$Timing.misses,
$Timing.elapsedMs,
$Timing.activeScanMs,
$Timing.writeFlushMs,
$Timing.averageMsPerParsed,
$Timing.activeAverageMsPerParsed,
$Timing.averageCaptureMs,
$Timing.captureP50Ms,
$Timing.captureP90Ms,
$Timing.averageOcrMs,
$Timing.ocrP50Ms,
$Timing.ocrP90Ms,
$Timing.averageCardReadyMs,
$Timing.averageScrollReadyMs,
$Timing.artifactsPerMinute,
$Timing.activeArtifactsPerMinute,
$Timing.projectedMsFor100,
$Timing.activeProjectedMsFor100)
}
function Get-TimingBottleneck([object]$Timing) {
$parts = @(
[pscustomobject]@{ name = "ocr"; value = [int]$Timing.averageOcrMs },
[pscustomobject]@{ name = "capture"; value = [int]$Timing.averageCaptureMs },
[pscustomobject]@{ name = "card-ready"; value = [int]$Timing.averageCardReadyMs },
[pscustomobject]@{ name = "scroll-ready"; value = [int]$Timing.averageScrollReadyMs }
) | Sort-Object -Property value -Descending
if ($parts.Count -eq 0 -or $parts[0].value -le 0) { return "unknown" }
return $parts[0].name
}
function Get-TimingRecommendation([object]$Timing) {
$bottleneck = Get-TimingBottleneck $Timing
switch ($bottleneck) {
"ocr" { return "OCR dominates; compare engine, crop count, worker pool, and parser-derived fields first." }
"capture" { return "Capture dominates; reduce payloads/crops and avoid full-frame or Base64 work in the hot loop." }
"card-ready" { return "Card-ready dominates; tune detail fingerprint polling against IK's 200ms item wait." }
"scroll-ready" { return "Scroll-ready dominates; tune page fingerprint polling against IK's 100ms fast-scroll wait." }
default { return "No dominant timing component detected; inspect misses/review/duplicates and raw diagnostic events." }
}
}
function Get-ScanQuality([object]$Timing) {
$parsed = [int]$Timing.parsed
$misses = [int]$Timing.misses
$review = [int]$Timing.review
$target = [int]$Timing.limit
$processed = [Math]::Max(1, $parsed + $misses)
$parseCoverage = if ($target -gt 0) { [Math]::Round($parsed / $target, 4) } else { 0 }
$missRate = [Math]::Round($misses / $processed, 4)
$reviewRate = if ($parsed -gt 0) { [Math]::Round($review / $parsed, 4) } else { 1 }
$statusOk = [string]$Timing.status -eq "done"
$coverageOk = $parsed -ge $target
$missOk = $missRate -le 0.02
$reviewOk = $reviewRate -le 0.15
$qualityPenalty = [Math]::Round(($missRate * 1000000) + ($reviewRate * 250000) + ((1 - $parseCoverage) * 1000000), 0)
$qualityDecision = if (-not $statusOk) {
"not-qualified: scan did not finish cleanly"
} elseif (-not $coverageOk) {
"not-qualified: parsed fewer artifacts than requested"
} elseif (-not $missOk) {
"not-qualified: miss rate above 2%"
} elseif (-not $reviewOk) {
"not-qualified: review rate above 15%"
} else {
"qualified"
}
return [pscustomobject]@{
parseCoverage = $parseCoverage
missRate = $missRate
reviewRate = $reviewRate
qualityDecision = $qualityDecision
qualityPenalty = $qualityPenalty
qualified = ($statusOk -and $coverageOk -and $missOk -and $reviewOk)
}
}
function New-PerformanceAssessment([object[]]$Summaries) {
$limitReports = @()
foreach ($group in ($Summaries | Group-Object -Property limit | Sort-Object { [int]$_.Name })) {
$entries = @($group.Group | ForEach-Object {
$quality = Get-ScanQuality $_
$_ | Add-Member -NotePropertyName parseCoverage -NotePropertyValue $quality.parseCoverage -Force
$_ | Add-Member -NotePropertyName missRate -NotePropertyValue $quality.missRate -Force
$_ | Add-Member -NotePropertyName reviewRate -NotePropertyValue $quality.reviewRate -Force
$_ | Add-Member -NotePropertyName qualityDecision -NotePropertyValue $quality.qualityDecision -Force
$_ | Add-Member -NotePropertyName qualityPenalty -NotePropertyValue $quality.qualityPenalty -Force
$_ | Add-Member -NotePropertyName qualified -NotePropertyValue $quality.qualified -Force
$_
} | Sort-Object -Property @{ Expression = "qualified"; Descending = $true }, qualityPenalty, activeAverageMsPerParsed, averageMsPerParsed)
if ($entries.Count -eq 0) { continue }
$winner = $entries[0]
$engineReports = @()
foreach ($entry in $entries) {
$engineReports += [pscustomobject]@{
engine = $entry.engine
status = $entry.status
parsed = $entry.parsed
review = $entry.review
misses = $entry.misses
parseCoverage = $entry.parseCoverage
missRate = $entry.missRate
reviewRate = $entry.reviewRate
qualified = $entry.qualified
qualityDecision = $entry.qualityDecision
qualityPenalty = $entry.qualityPenalty
activeAverageMsPerParsed = $entry.activeAverageMsPerParsed
activeProjectedMsFor100 = $entry.activeProjectedMsFor100
averageOcrMs = $entry.averageOcrMs
averageCaptureMs = $entry.averageCaptureMs
averageCardReadyMs = $entry.averageCardReadyMs
averageScrollReadyMs = $entry.averageScrollReadyMs
bottleneck = Get-TimingBottleneck $entry
recommendation = Get-TimingRecommendation $entry
}
}
$limitReports += [pscustomobject]@{
limit = [int]$group.Name
winnerEngine = $winner.engine
winnerQualified = $winner.qualified
winnerMissRate = $winner.missRate
winnerReviewRate = $winner.reviewRate
winnerActiveAverageMsPerParsed = $winner.activeAverageMsPerParsed
winnerActiveProjectedMsFor100 = $winner.activeProjectedMsFor100
engines = $engineReports
}
}
$goal100 = @($limitReports | Where-Object { $_.limit -eq 100 } | Select-Object -First 1)
return [pscustomobject]@{
createdAt = (Get-Date).ToString("o")
goal100 = if ($goal100.Count -gt 0) { $goal100[0] } else { $null }
limits = $limitReports
}
}
function Write-PerformanceAssessment([object]$Assessment) {
foreach ($limit in @($Assessment.limits)) {
Write-Host ("assessment limit={0}: winner={1} qualified={2} missRate={3:P1} reviewRate={4:P1} activeAvg={5}ms projected100={6}ms" -f `
$limit.limit,
$limit.winnerEngine,
$limit.winnerQualified,
$limit.winnerMissRate,
$limit.winnerReviewRate,
$limit.winnerActiveAverageMsPerParsed,
$limit.winnerActiveProjectedMsFor100)
foreach ($engine in @($limit.engines)) {
Write-Host (" {0}: qualified={1}, missRate={2:P1}, reviewRate={3:P1}, bottleneck={4}, activeAvg={5}ms, ocr={6}ms, capture={7}ms, cardReady={8}ms, scrollReady={9}ms, decision={10}" -f `
$engine.engine,
$engine.qualified,
$engine.missRate,
$engine.reviewRate,
$engine.bottleneck,
$engine.activeAverageMsPerParsed,
$engine.averageOcrMs,
$engine.averageCaptureMs,
$engine.averageCardReadyMs,
$engine.averageScrollReadyMs,
$engine.qualityDecision)
}
}
}
function Invoke-AssessmentSelfTest {
$synthetic = @(
[pscustomobject]@{
engine = "current"
limit = 100
status = "done"
parsed = 100
review = 22
misses = 0
activeAverageMsPerParsed = 700
averageMsPerParsed = 760
activeProjectedMsFor100 = 70000
averageOcrMs = 260
averageCaptureMs = 160
averageCardReadyMs = 190
averageScrollReadyMs = 60
},
[pscustomobject]@{
engine = "ik-traineddata"
limit = 100
status = "done"
parsed = 100
review = 4
misses = 0
activeAverageMsPerParsed = 820
averageMsPerParsed = 870
activeProjectedMsFor100 = 82000
averageOcrMs = 210
averageCaptureMs = 170
averageCardReadyMs = 205
averageScrollReadyMs = 80
},
[pscustomobject]@{
engine = "broken-fast"
limit = 45
status = "done"
parsed = 45
review = 0
misses = 3
activeAverageMsPerParsed = 300
averageMsPerParsed = 330
activeProjectedMsFor100 = 30000
averageOcrMs = 100
averageCaptureMs = 90
averageCardReadyMs = 40
averageScrollReadyMs = 20
},
[pscustomobject]@{
engine = "current"
limit = 45
status = "done"
parsed = 45
review = 1
misses = 0
activeAverageMsPerParsed = 600
averageMsPerParsed = 650
activeProjectedMsFor100 = 60000
averageOcrMs = 230
averageCaptureMs = 130
averageCardReadyMs = 160
averageScrollReadyMs = 50
}
)
$assessment = New-PerformanceAssessment -Summaries $synthetic
$goal100 = $assessment.goal100
$limit45 = @($assessment.limits | Where-Object { $_.limit -eq 45 } | Select-Object -First 1)[0]
if ($goal100.winnerEngine -ne "ik-traineddata") {
throw "Assessment self-test failed: expected ik-traineddata to win limit=100, got '$($goal100.winnerEngine)'."
}
if (-not $goal100.winnerQualified) {
throw "Assessment self-test failed: expected limit=100 winner to be qualified."
}
if ($limit45.winnerEngine -ne "current") {
throw "Assessment self-test failed: expected current to win limit=45, got '$($limit45.winnerEngine)'."
}
if (@($goal100.engines | Where-Object { $_.engine -eq "current" })[0].qualityDecision -ne "not-qualified: review rate above 15%") {
throw "Assessment self-test failed: expected high-review current run to be rejected."
}
if (@($limit45.engines | Where-Object { $_.engine -eq "broken-fast" })[0].qualityDecision -ne "not-qualified: miss rate above 2%") {
throw "Assessment self-test failed: expected broken-fast run to be rejected for miss rate."
}
Write-PerformanceAssessment $assessment
Write-Host "Assessment self-test passed." -ForegroundColor Green
return $assessment
}
function Invoke-OcrBenchmark {
param(
[int]$Limit,
[string]$Engine,
[string]$Profile
)
Write-Host "Warming current OCR workers..."
$warmCurrent = Invoke-DevJson "/scanner/ocr/warmup?engine=current"
Save-Json "benchmark-warmup-current" $warmCurrent | Out-Null
if ($Engine -eq "compare" -or $Engine -eq "ik-traineddata") {
Write-Host "Warming IK-traineddata OCR workers..."
$warmIk = Invoke-DevJson "/scanner/ocr/warmup?engine=ik-traineddata"
Save-Json "benchmark-warmup-ik-traineddata" $warmIk | Out-Null
}
Write-Host "Running OCR benchmark engine=$Engine profile=$Profile limit=$Limit"
$benchmark = Invoke-DevJson "/scanner/benchmark-ocr?limit=$Limit&engine=$Engine&profile=$Profile"
Save-Json "benchmark-ocr-$Engine-$Profile-limit-$Limit" $benchmark | Out-Null
if ($benchmark.summary.mode -eq "compare") {
foreach ($engineSummary in @($benchmark.summary.engines)) {
Write-Host ("benchmark {0}: avg={1}ms ocrAvg={2}ms p50={3}ms p90={4}ms projected100={5}ms skipped={6} pool={7}" -f `
$engineSummary.engine,
$engineSummary.averageMs,
$engineSummary.averageOcrMs,
$engineSummary.p50Ms,
$engineSummary.p90Ms,
$engineSummary.projectedMs.artifacts100,
$engineSummary.skippedOcrCaptures,
$engineSummary.workerPoolSize)
}
} else {
$summary = $benchmark.summary
Write-Host ("benchmark {0}: avg={1}ms ocrAvg={2}ms p50={3}ms p90={4}ms projected100={5}ms skipped={6} pool={7}" -f `
$summary.engine,
$summary.averageMs,
$summary.averageOcrMs,
$summary.p50Ms,
$summary.p90Ms,
$summary.projectedMs.artifacts100,
$summary.skippedOcrCaptures,
$summary.workerPoolSize)
}
return $benchmark
}
function Convert-ReviewSamplesSummary([object]$ReviewPayload) {
$samples = @()
foreach ($record in @($ReviewPayload.samples)) {
$parsed = $record.sample.parsed
$capture = $record.sample.capture
$ocr = @()
foreach ($entry in @($capture.ocr)) {
$ocr += [pscustomobject]@{
id = $entry.id
label = $entry.label
text = $entry.text
confidence = $entry.confidence
}
}
$samples += [pscustomobject]@{
savedAt = $record.savedAt
reason = $record.sample.reason
parsed = [pscustomobject]@{
name = $parsed.name
slot = $parsed.slot
level = $parsed.level
mainStat = $parsed.mainStat
mainValue = $parsed.mainValue
setName = $parsed.setName
equipped = $parsed.equipped
confidence = $parsed.confidence
notes = $parsed.notes
fields = $parsed.fields
}
capture = [pscustomobject]@{
name = $capture.name
width = $capture.width
height = $capture.height
captureTarget = $capture.captureTarget
capturedAt = $capture.capturedAt
inventoryGrid = if ($capture.inventoryGrid) {
[pscustomobject]@{
rows = $capture.inventoryGrid.rows
cols = $capture.inventoryGrid.cols
confidence = $capture.inventoryGrid.confidence
source = $capture.inventoryGrid.source
}
} else { $null }
inventoryCount = $capture.inventoryCount
locked = $capture.locked
ocr = $ocr
}
}
}
return [pscustomobject]@{
ok = $ReviewPayload.ok
total = $ReviewPayload.total
path = $ReviewPayload.path
samples = $samples
}
}
function Wait-ForScannerIdle([int]$Limit, [string]$Engine) {
$startedAt = Get-Date
$pollIndex = 0
$lastStatus = $null
while ($true) {
Start-Sleep -Seconds $PollIntervalSeconds
$pollIndex += 1
$statusPayload = Get-ScannerStatus
$lastStatus = $statusPayload
Save-Json "scan-$Engine-limit-$Limit-poll-$pollIndex" $statusPayload | Out-Null
$running = [bool]$statusPayload.status.running
if (-not $running) {
return $statusPayload
}
$elapsed = ((Get-Date) - $startedAt).TotalSeconds
if ($elapsed -gt $TimeoutSeconds) {
$stop = Invoke-DevJson "/scanner/stop"
Save-Json "scan-$Engine-limit-$Limit-timeout-stop" $stop | Out-Null
throw "Scanner timed out after $TimeoutSeconds seconds for limit=$Limit engine=$Engine. Stop command was sent."
}
}
}
if ($SelfTestAssessment) {
Invoke-AssessmentSelfTest | Out-Null
exit 0
}
$stamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss"
$RunDir = Join-Path $OutputRoot $stamp
New-Item -ItemType Directory -Force -Path $RunDir | Out-Null
if ($GoalRun) {
$Limits = @(2, 5, 20, 45, 100)
$BenchmarkOcr = $true
}
$ScanEngines = if ($ScanEngine -eq "compare") { @("current", "ik-traineddata") } else { @($ScanEngine) }
$RunSummaries = @()
$transcriptPath = Join-Path $RunDir "transcript.log"
try {
Start-Transcript -Path $transcriptPath -Append | Out-Null
} catch {
Write-Warning "Could not start transcript: $($_.Exception.Message)"
}
try {
Write-Host "Live soak output: $RunDir"
Write-Host "Checking dev control server at $BaseUrl"
$health = Invoke-DevJson "/health"
Save-Json "00-health" $health | Out-Null
Assert-CurrentAppBuild $health
if ($health.appBuild) {
Write-Host "App build: signature=$($health.appBuild.signature), pid=$($health.appBuild.pid), startedAt=$($health.appBuild.startedAt), ocrWorkers=$($health.appBuild.expectedOcrWorkerPoolSize)"
if ($health.appBuild.expectedOcrWorkerPoolSize -lt 4) {
Write-Host "WARNUNG: OCR worker pool is below 4. This is valid for constrained debugging, but not ideal for IK-speed comparison." -ForegroundColor Yellow
}
}
$initialStatus = Get-ScannerStatus
Save-Json "01-status-before" $initialStatus | Out-Null
if ($initialStatus.status.runtimeInfo) {
$runtime = $initialStatus.status.runtimeInfo
Write-Host "Runtime: elevated=$($runtime.isElevated), genshinFound=$($runtime.genshinFound), target=$($runtime.targetProcess)"
} else {
Write-Host "Runtime info is not published yet. Open the app scanner view before running broad scans." -ForegroundColor Yellow
}
if ($BenchmarkOcr) {
Invoke-OcrBenchmark -Limit $BenchmarkLimit -Engine $BenchmarkEngine -Profile $BenchmarkProfile | Out-Null
}
if (-not $SkipSmartCapture) {
Write-Host "Capturing smart preflight snapshot without OCR..."
$capture = Invoke-DevJson "/capture/smart?skipOcr=1"
Save-Json "02-smart-capture-skip-ocr" $capture | Out-Null
}
foreach ($index in $ProbeIndices) {
Write-Host "Running probe click index=$index"
$probe = Invoke-DevJson "/automation/probe-click?index=$index"
Save-Json "probe-index-$index" $probe | Out-Null
if (-not (Test-ProbeSucceeded $probe)) {
Write-Host "Probe index=$index did not fully pass. Review the saved JSON before broader scans." -ForegroundColor Yellow
if (-not $ContinueAfterBlocked) {
throw "Stopping after failed probe index=$index. Re-run with -ContinueAfterBlocked only if you deliberately want to continue."
}
} elseif (-not $probe.ok -and $probe.changed) {
Write-Host "Probe index=$index changed the detail panel even though helper cursor/click readback was not clean; continuing." -ForegroundColor Yellow
}
}
foreach ($engine in $ScanEngines) {
foreach ($limit in $Limits) {
if ($limit -lt 1) { continue }
Write-Host "Starting bounded scanner run limit=$limit engine=$engine"
$start = Invoke-DevJson "/scanner/start?limit=$limit&engine=$engine"
Save-Json "scan-$engine-limit-$limit-start" $start | Out-Null
$finalStatus = Wait-ForScannerIdle -Limit $limit -Engine $engine
Save-Json "scan-$engine-limit-$limit-final" $finalStatus | Out-Null
$summary = Get-CompletedScanSummary $finalStatus $limit
Write-Host "engine=$engine limit=$limit summary: status=$($summary.status), attempted=$($summary.attempted), verified=$($summary.verified), parsed=$($summary.parsed), stored=$($summary.stored), review=$($summary.review), misses=$($summary.misses), pages=$($summary.pages)"
$timing = Convert-ScanTimingSummary $finalStatus $limit $engine
$RunSummaries += $timing
Write-ScanTimingLine $timing
if (($summary.status -eq "blocked" -or $summary.status -eq "stopped") -and -not $ContinueAfterBlocked) {
throw "Stopping after scan status '$($summary.status)' for limit=$limit engine=$engine."
}
}
}
$review = Invoke-DevJson "/review/samples?limit=30"
Save-Json "review-samples-tail-summary" (Convert-ReviewSamplesSummary $review) | Out-Null
if ($SaveFullReviewSamples) {
Save-Json "review-samples-tail-full" $review | Out-Null
}
$afterStatus = Get-ScannerStatus
Save-Json "99-status-after" $afterStatus | Out-Null
Save-Json "scan-run-summary" ([pscustomobject]@{
createdAt = (Get-Date).ToString("o")
goalRun = [bool]$GoalRun
scanEngine = $ScanEngine
scanEngines = $ScanEngines
benchmarkOcr = [bool]$BenchmarkOcr
limits = $Limits
summaries = $RunSummaries
}) | Out-Null
if ($RunSummaries.Count -gt 0) {
$assessment = New-PerformanceAssessment -Summaries $RunSummaries
Save-Json "scan-performance-assessment" $assessment | Out-Null
Write-PerformanceAssessment $assessment
$csvPath = Join-Path $RunDir "scan-run-summary.csv"
$RunSummaries | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8
Write-Host "Timing summary CSV: $csvPath"
}
Write-Host "Live soak complete. Output: $RunDir" -ForegroundColor Green
} catch {
Write-Host "Live soak failed: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Output so far: $RunDir" -ForegroundColor Yellow
exit 1
} finally {
try {
Stop-Transcript | Out-Null
} catch {
}
}