This commit is contained in:
Kalle
2026-08-06 16:59:59 +03:00
parent 1da6ae37d0
commit 9d76a3f180
10 changed files with 112 additions and 30 deletions

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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(

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -52,12 +52,28 @@ export interface Detector<TData = unknown> {
* 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;
/**

View File

@@ -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");