diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 967542b6f..a9adc7947 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -98,10 +98,15 @@ sequenceDiagram 0.25s — produced/casted VoDs cut screens to ~1s with transition flicker inside, so search cadence must not assume raw-gameplay screen lifetimes; gates are ~ms-cheap, so this costs little); once the gate passes it - drops to the dense refine cadence for best-read refinement. Suppression ends a refinement streak - once it stagnates by parse count AND elapsed time (~3s — the time floor + drops to the dense refine cadence for best-read refinement (detectors + with expensive parses override it via `refineIntervalS` — death 0.5s, + minimap 0.4s). Suppression ends a refinement streak + once it stagnates by parse count (default 6; per-detector + `maxStagnantParses` — death 3) AND elapsed time (~3s — the time floor keeps a dense cadence from suppressing during a screen's entry animation - before it is readable) or immediately at `sufficientConfidence`; death + before it is readable) or immediately at `sufficientConfidence`, which + sits just under each detector's measured clean-read confidence floor + (fixture suite + confirmed scan events); death adds `rearmCooldownS` (safe because it fits inside the Death timeline merge window). `checkIntervalS` (objective: 1s) still hard-caps both phases and exempts from suppression; a detector can diff --git a/app/features/scanner/core/detectors/death/index.ts b/app/features/scanner/core/detectors/death/index.ts index 11b7cced7..79d9a8747 100644 --- a/app/features/scanner/core/detectors/death/index.ts +++ b/app/features/scanner/core/detectors/death/index.ts @@ -97,6 +97,13 @@ export const DEATH_EVENT_TYPE = "Death"; /** The constant message line must read back at least this well to emit. */ const LINE1_MIN_SCORE = 0.5; +/** + * A Latin template's constant line reading at least this well settles the + * language and the (expensive) JA line reads are skipped. Fixture-measured: + * Latin-language frames read their template at 0.889+, while JA frames' + * best Latin-template score is 0.222. + */ +const LATIN_DECISIVE_SCORE = 0.85; /** Snapped weapon reading below this is reported as null (kept in debug). */ const WEAPON_MIN_SCORE = 0.55; /** Burst-icon fallback match below this is ignored (kept in debug). */ @@ -487,24 +494,32 @@ export function createDeathDetector( }; line1 = readLine(SPLAT_LINE1_ROI, weaponGlyphs); line2 = readLine(WEAPON_LINE_ROI, weaponGlyphs); - if (jaGlyphs) { - jaWeaponLine = readLine(JA_WEAPON_LINE_ROI, jaGlyphs); - jaConstLine = readLine(JA_CONST_LINE_ROI, jaGlyphs); - } for (const t of DEATH_MESSAGE_TEMPLATES) { - let constReading: string; - if (isJaTemplate(t)) { - if (!jaConstLine) continue; - constReading = jaConstLine.text; - } else { - constReading = t.weaponLine === 1 ? line2.text : line1.text; - } + if (isJaTemplate(t)) continue; + const constReading = t.weaponLine === 1 ? line2.text : line1.text; const score = closestEntry(constReading, [t.constText])?.score ?? 0; if (score > line1Score) { line1Score = score; template = t; } } + // the JA line reads cost ~2x the Latin ones (condensed-kana atlas), + // so they only run when no Latin template already owns the frame: + // measured constant-line scores separate cleanly (Latin frames read + // their template at 0.889+, JA frames' best Latin score is <= 0.222) + if (jaGlyphs && line1Score < LATIN_DECISIVE_SCORE) { + jaWeaponLine = readLine(JA_WEAPON_LINE_ROI, jaGlyphs); + jaConstLine = readLine(JA_CONST_LINE_ROI, jaGlyphs); + for (const t of DEATH_MESSAGE_TEMPLATES) { + if (!isJaTemplate(t)) continue; + const score = + closestEntry(jaConstLine.text, [t.constText])?.score ?? 0; + if (score > line1Score) { + line1Score = score; + template = t; + } + } + } if (!template || line1Score < LINE1_MIN_SCORE) { gray.delete(); rgb.delete(); @@ -822,11 +837,17 @@ export function createDeathDetector( // the death cam's animated background flickers the gate; after a // sufficient read the 4s rearm hold stays inside the timeline's 8s - // Death merge window, so every parse it skips would merge anyway + // Death merge window, so every parse it skips would merge anyway. + // sufficientConfidence sits just under the measured clean-read floor + // (fixtures 0.750-0.825, confirmed scan events 0.751+); the refine and + // stagnation overrides cap what a ~1.4s parse can cost when a dirty + // read never reaches it return { id: "death", - sufficientConfidence: 0.98, + refineIntervalS: 0.5, + sufficientConfidence: 0.74, rearmCooldownS: 4, + maxStagnantParses: 3, gate, parse, }; diff --git a/app/features/scanner/core/detectors/map-start/index.ts b/app/features/scanner/core/detectors/map-start/index.ts index 8e5d8b54f..1824cbed7 100644 --- a/app/features/scanner/core/detectors/map-start/index.ts +++ b/app/features/scanner/core/detectors/map-start/index.ts @@ -365,9 +365,11 @@ export function createMapStartDetector( ]; } + // just under the measured clean-read floor (fixtures 0.795-0.864, + // confirmed scan events 0.854-1.0) return { id: "map-start", - sufficientConfidence: 0.98, + sufficientConfidence: 0.79, gate, parse, }; diff --git a/app/features/scanner/core/detectors/minimap/index.ts b/app/features/scanner/core/detectors/minimap/index.ts index 845753bb8..3fadcfbc2 100644 --- a/app/features/scanner/core/detectors/minimap/index.ts +++ b/app/features/scanner/core/detectors/minimap/index.ts @@ -639,9 +639,16 @@ export function createMinimapDetector( ]; } + // sufficientConfidence sits just under the measured clean-read floor + // (fixtures 0.746-0.800; confirmed scan events reach down to 0.699, and + // those below the floor fall back to stagnation). The refine override + // matters here because a map-open's confidence keeps fluctuating upward, + // resetting the stagnation counter — without it a ~0.9s parse runs at + // the dense cadence for the whole map-open return { id: "minimap", - sufficientConfidence: 0.98, + refineIntervalS: 0.4, + sufficientConfidence: 0.73, gate, parse, }; diff --git a/app/features/scanner/core/detectors/scheduler.ts b/app/features/scanner/core/detectors/scheduler.ts index 729ac9535..2e5e48894 100644 --- a/app/features/scanner/core/detectors/scheduler.ts +++ b/app/features/scanner/core/detectors/scheduler.ts @@ -9,7 +9,9 @@ * cadence must not assume raw-gameplay screen lifetimes; gates are * ~1.6ms, so searching at the analysis floor costs nothing). Once the * gate passes it drops to the dense `refineIntervalS` so the best-read - * refinement loop still sees every frame it wants. `checkIntervalS` + * refinement loop still sees every frame it wants; a detector whose + * parse is expensive (death ~1.4s) declares its own refineIntervalS so + * the dense default cannot multiply that cost. `checkIntervalS` * (objective counter) remains a hard cap on both phases and exempts the * detector from suppression, as before. `nextDueT()` lets the caller * skip a frame's canvas readback + normalize entirely when no detector @@ -47,8 +49,10 @@ export interface SchedulingInfo { id: string; checkIntervalS?: number; searchIntervalS?: number; + refineIntervalS?: number; sufficientConfidence?: number; rearmCooldownS?: number; + maxStagnantParses?: number; } export interface SchedulerOptions { @@ -57,12 +61,15 @@ export interface SchedulerOptions { * parses are never suppressed */ suppressSteadyFrames: boolean; - /** check cadence while a detector's gate is passing */ + /** check cadence while a detector's gate is passing (per-detector + * refineIntervalS overrides — for detectors whose parse is expensive + * enough that the dense default multiplies real cost) */ refineIntervalS: number; /** check cadence while a detector's gate is failing (per-detector * searchIntervalS overrides) */ searchIntervalS: number; - /** consecutive non-improving parses tolerated before suppression */ + /** consecutive non-improving parses tolerated before suppression + * (per-detector maxStagnantParses overrides) */ maxStagnantParses: number; /** seconds without improvement tolerated before suppression */ stagnantAfterS: number; @@ -250,8 +257,10 @@ export class DetectorScheduler { return; } streak.stagnant += 1; + const maxStagnant = + state.info.maxStagnantParses ?? this.#options.maxStagnantParses; if ( - streak.stagnant >= this.#options.maxStagnantParses && + streak.stagnant >= maxStagnant && t - streak.lastImprovementT >= this.#options.stagnantAfterS ) { streak.suppressed = true; @@ -279,7 +288,8 @@ export class DetectorScheduler { // while suppressed only the gate keeps running (to spot the screen // changing), which the sparser search cadence covers if (state.streak?.suppressed) return search; - return state.gatePassing ? this.#options.refineIntervalS : search; + if (!state.gatePassing) return search; + return info.refineIntervalS ?? this.#options.refineIntervalS; } #recordMatchState( diff --git a/app/features/scanner/core/detectors/scoreboard-own/index.ts b/app/features/scanner/core/detectors/scoreboard-own/index.ts index 4369227cf..7482b9060 100644 --- a/app/features/scanner/core/detectors/scoreboard-own/index.ts +++ b/app/features/scanner/core/detectors/scoreboard-own/index.ts @@ -256,9 +256,12 @@ export function createScoreboardOwnDetector( ]; } + // just under the measured clean-read floor (fixtures 0.562-0.669, + // confirmed scan events 0.612-0.635 — this screen's ability-grid scores + // keep the mean low even on perfect reads) return { id: "scoreboard-own", - sufficientConfidence: 0.98, + sufficientConfidence: 0.55, gate, parse, }; diff --git a/app/features/scanner/core/detectors/scoreboard-replay/index.ts b/app/features/scanner/core/detectors/scoreboard-replay/index.ts index d7b65d530..30ffee355 100644 --- a/app/features/scanner/core/detectors/scoreboard-replay/index.ts +++ b/app/features/scanner/core/detectors/scoreboard-replay/index.ts @@ -427,10 +427,11 @@ export function createScoreboardReplayDetector( } // no rearm cooldown — distinct replays browsed in quick succession are - // told apart by content + // told apart by content. sufficientConfidence just under the measured + // clean-read floor (fixtures 0.808-0.890) return { id: "scoreboard-replay", - sufficientConfidence: 0.98, + sufficientConfidence: 0.8, gate, parse, }; diff --git a/app/features/scanner/core/detectors/scoreboard/index.ts b/app/features/scanner/core/detectors/scoreboard/index.ts index 7c1911c63..a7720def2 100644 --- a/app/features/scanner/core/detectors/scoreboard/index.ts +++ b/app/features/scanner/core/detectors/scoreboard/index.ts @@ -321,10 +321,11 @@ export function createScoreboardDetector( ]; } - // a 0.98 mean field score is a clean full read + // just under the measured clean-read floor (fixtures 0.865-0.899, + // confirmed scan events down to 0.799) return { id: "scoreboard", - sufficientConfidence: 0.98, + sufficientConfidence: 0.79, gate, parse, }; diff --git a/app/features/scanner/core/detectors/types.ts b/app/features/scanner/core/detectors/types.ts index e72d4c964..09ba41fa9 100644 --- a/app/features/scanner/core/detectors/types.ts +++ b/app/features/scanner/core/detectors/types.ts @@ -52,12 +52,28 @@ export interface Detector { * is effectively free; only override upward with strong evidence. */ searchIntervalS?: number; + /** + * Check cadence while the gate is passing (the refine phase); unset = + * the scheduler's dense default (0.15s). Only for detectors whose parse + * is expensive enough that refining at the dense cadence multiplies + * real cost — the sparser cadence still has to sample the screen's + * lifetime several times. + */ + refineIntervalS?: number; + /** + * Consecutive non-improving parses tolerated before stagnation + * suppression; unset = the scheduler's default (6). Lower for expensive + * parses to cap the worst-case cost of a stagnant streak. + */ + maxStagnantParses?: number; /** * A parse reaching this confidence ends the streak's refinement * immediately: the timeline keeps the best read per merge window, so a * read this good cannot be improved upon in any way that matters. - * Conservative by construction — if real reads never reach it, behavior - * falls back to stagnation-based suppression. + * Set just under the detector's measured clean-read confidence floor + * (fixture-suite reads + confirmed scan events), so a streak's first + * full read suppresses the rest; degraded-footage reads below the + * floor fall back to stagnation-based suppression. */ sufficientConfidence?: number; /** diff --git a/app/features/scanner/tests/scheduler.test.ts b/app/features/scanner/tests/scheduler.test.ts index 6e95d51e2..4b9827aac 100644 --- a/app/features/scanner/tests/scheduler.test.ts +++ b/app/features/scanner/tests/scheduler.test.ts @@ -65,6 +65,22 @@ test("a passing gate drops to the dense refine cadence", () => { assert.equal(feed(s, 0.2, { pass: true, confidence: 0.7 }), "parsed"); }); +test("a per-detector refine interval overrides the dense default", () => { + const s = make({ refineIntervalS: 0.5 }); + assert.equal(feed(s, 0, { pass: true, confidence: 0.7 }), "parsed"); + assert.equal(feed(s, 0.1, { pass: true, confidence: 0.7 }), "skipped"); + assert.equal(feed(s, 0.4, { pass: true, confidence: 0.7 }), "skipped"); + assert.equal(feed(s, 0.5, { pass: true, confidence: 0.7 }), "parsed"); +}); + +test("a per-detector stagnation budget suppresses sooner", () => { + const s = make({ maxStagnantParses: 2 }, { stagnantAfterS: 0.1 }); + assert.equal(feed(s, 0.0, { pass: true, confidence: 0.9 }), "parsed"); + assert.equal(feed(s, 0.1, { pass: true, confidence: 0.9 }), "parsed"); + assert.equal(feed(s, 0.2, { pass: true, confidence: 0.9 }), "parsed"); + assert.equal(feed(s, 0.3, { pass: true, confidence: 0.9 }), "gated"); +}); + test("suppresses after stagnant parses on a static screen", () => { const s = make({}); assert.equal(feed(s, 0.0, { pass: true, confidence: 0.9 }), "parsed");