Fix scanner reading battle log AM times as PM and X Power caught mid-count
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-10-17 17:39:30 +03:00
parent 250b256f70
commit e14d91c6dc
13 changed files with 295 additions and 32 deletions

View File

@@ -302,7 +302,10 @@ sequenceDiagram
order-free (a misread or unread row still joins), stage not contradicting
it, recording time within 20 min of its play time — joins that match's sources instead of forming a new
one, so browsing the log after playing neither adds a card nor re-uploads
(the match was already sent). Any other history screen closes the game
(the match was already sent). The recording time's AM/PM letter reads as
"o" either way, so the header tells them apart by the letter's bottom rows
(A stands on two legs, P on its stem): a flipped one puts the time 12h off
and the screen turns into a duplicate card. Any other history screen closes the game
being gathered — a missed results screen amended from the log — unless
the intro/minimap stage, intro mode or recording time (vs. the game's
first read) contradicts it. Then, or with no game being gathered, it
@@ -810,7 +813,10 @@ direction. So no detector sets `sufficientConfidence` (the first clean read
is the stale one), each gate returns a `signature` over its numbers, and the
timeline orders same-type reads by `animationProgressByType` instead of
confidence: a further-along read replaces the kept one, an earlier one
merges into it. A count read whose digits disagree with its slots, or a set
merges into it. The count's end can fall between two sampled frames (a 2 fps
live capture), so a set result read after the change lands takes its power
from the power before it plus the change whenever an earlier read showed the
old value (`carryOverByType`); the old value holds ~1.5s, the final one ~1s. A count read whose digits disagree with its slots, or a set
result whose header doesn't describe a decided set of tiles (tiles still
popping in), scores under the timeline floor. The result tiles center on
the card (five games three over two, four two over two, three in one row

View File

@@ -523,7 +523,7 @@ function onResult(
action.action === "added" ? undefined : storedIds.get(action.replaced);
// a run's trailing read only moves its time; the run's first read keeps the frame
const frame = action.action === "extended" ? undefined : result.frame;
const saving = persist(event, frame, stale);
const saving = persist(action.event, frame, stale);
persisting.add(saving);
void saving.finally(() => persisting.delete(saving));
}

View File

@@ -297,7 +297,7 @@ export async function startVodScan(
? undefined
: events.find((e) => sameEvent(e, action.replaced));
const scanEvent: ScanEvent = {
...event,
...action.event,
hasFrame: frame !== undefined,
};
if (frame) frames.set(scanEvent, frame);

View File

@@ -7,11 +7,11 @@ import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { ScannerLobby } from "../../../scanner-types";
import type { Roi } from "../../canonical";
import type { Mat } from "../../cv";
import type { GlyphSet } from "../../glyphs";
import type { GlyphSet, RecognizedChar } from "../../glyphs";
import { ALL_STAGE_ENTRIES, LOBBY_MODE_COMBOS } from "../../localized";
import { all, type MatchSteps } from "../../match-steps";
import { closestBy } from "../../text";
import { readTagBandSteps } from "../scoreboard/header";
import { readTagBandGlyphsSteps, readTagBandSteps } from "../scoreboard/header";
import { HEADER_BOTTOM_BAND, HEADER_TOP_BAND } from "./rois";
export interface ParsedReplayHeader {
@@ -31,6 +31,16 @@ export interface ParsedReplayHeader {
const MIN_MATCH_SCORE = 0.62;
type Meridiem = "AM" | "PM";
/** The clock's AM/PM letter reads as "o" (or a lookalike) whichever it is; the probe decides. */
const MERIDIEM_LETTER_LOOKALIKES = /^[AaPpoO0n]$/;
const CLOCK_DIGIT_LOOKALIKES = /^[0-9OoIl|S]$/;
/** Lowest share of the letter's ink rows probed for legs. */
const MERIDIEM_LEG_ROWS_FRACTION = 0.25;
/** Recognizer's binarization (its default), so the probe sees the ink it segmented. */
const MERIDIEM_INK_MIN = 150;
/**
* Lifted-blacks captures raise the tag background to ~80-115 gray, above
* readTagBand's default ceiling, truncating the band to a sliver. A failed snap
@@ -80,7 +90,10 @@ function foldDigitLookalikes(s: string): string {
return s.replace(/0/g, "o").replace(/[1|I]/g, "l");
}
function parseTopBand(reading: string): TopBandParse {
function parseTopBand(
reading: string,
probedMeridiem: Meridiem | null,
): TopBandParse {
let timestamp: string | null = null;
let stage: StageId | null = null;
let stageScore = 0;
@@ -98,8 +111,7 @@ function parseTopBand(reading: string): TopBandParse {
? reading.slice(reading.length - m[4]!.length)
: reading;
if (m) {
// P's bowl can read as "o", folded to "0" above
const meridiem = m[3] ? ` ${m[3].replace("0", "P")}` : "";
const meridiem = m[3] ? ` ${probedMeridiem ?? m[3].replace("0", "P")}` : "";
timestamp = `${m[1]!.replace(/ /g, "")} ${m[2]!.replace(/ /g, "")}${meridiem}`;
}
if (stageReading) {
@@ -143,20 +155,21 @@ export function* parseReplayHeaderSteps(
tagLeadInMax: bands.tagLeadInMax,
tagColumnFraction: bands.tagColumnFraction,
};
const readTop = function* (): MatchSteps<ReturnType<typeof parseTopBand>> {
let top = parseTopBand(
yield* readTagBandSteps(gray, bands.top, topGlyphs, leadIn, speculative),
const readTopBand = function* (tagDarkMax?: number) {
const read = yield* readTagBandGlyphsSteps(
gray,
bands.top,
topGlyphs,
{ ...leadIn, tagDarkMax },
speculative,
probeMeridiem,
);
return parseTopBand(read.text, read.inspected);
};
const readTop = function* (): MatchSteps<ReturnType<typeof parseTopBand>> {
let top = yield* readTopBand();
if (top.stage === null) {
const retry = parseTopBand(
yield* readTagBandSteps(
gray,
bands.top,
topGlyphs,
{ ...leadIn, tagDarkMax: TAG_DARK_MAX_LIFTED },
speculative,
),
);
const retry = yield* readTopBand(TAG_DARK_MAX_LIFTED);
if (retry.stageScore >= top.stageScore) top = retry;
}
return top;
@@ -216,3 +229,41 @@ export function* parseReplayHeaderSteps(
},
};
}
/**
* A 12h clock's AM/PM, off the letter before the "M" that follows the time:
* A stands on two legs, P on its stem alone, so the letter's bottom rows
* carry ink right of its middle only for A. Both letters read as "o".
*/
function probeMeridiem(
band: Mat,
chars: readonly RecognizedChar[],
): Meridiem | null {
const colonAt = chars.findIndex((c) => c.char === ":");
if (colonAt === -1) return null;
const mAt = chars.findIndex(
(c, i) =>
i > colonAt + 1 &&
c.char === "M" &&
MERIDIEM_LETTER_LOOKALIKES.test(chars[i - 1]!.char) &&
CLOCK_DIGIT_LOOKALIKES.test(chars[i - 2]!.char),
);
if (mAt === -1) return null;
const letter = chars[mAt - 1]!;
const { cols, data } = band;
const middle = Math.floor((letter.x0 + letter.x1) / 2);
const legRows = Math.max(
1,
Math.round((letter.y1 - letter.y0) * MERIDIEM_LEG_ROWS_FRACTION),
);
let rowsWithRightLeg = 0;
for (let y = letter.y1 - legRows; y < letter.y1; y++) {
for (let x = middle + 1; x < letter.x1; x++) {
if (data[y * cols + x]! >= MERIDIEM_INK_MIN) {
rowsWithRightLeg++;
break;
}
}
}
return rowsWithRightLeg * 2 > legRows ? "AM" : "PM";
}

View File

@@ -126,16 +126,37 @@ export function* readTagBandSteps(
options: TagBandOptions = {},
speculative = false,
): MatchSteps<string> {
const read = yield* readTagBandGlyphsSteps(
gray,
band,
glyphs,
options,
speculative,
() => null,
);
return read.text;
}
/** `readTagBandSteps` that also hands the trimmed band and its recognized glyphs to `inspect` before the band is freed. */
export function* readTagBandGlyphsSteps<T>(
gray: Mat,
band: { x: number; y: number; w: number; h: number },
glyphs: GlyphSet,
options: TagBandOptions,
speculative: boolean,
inspect: (trimmed: Mat, chars: readonly RecognizedChar[]) => T,
): MatchSteps<{ text: string; inspected: T | null }> {
const trimmed = trimTagBand(gray, band, options);
if (!trimmed) return "";
if (!trimmed) return { text: "", inspected: null };
const result = yield* recognizeTextSteps(
trimmed,
glyphs,
{ ...TAG_RECOGNIZE_OPTIONS, ...options },
speculative,
);
const inspected = inspect(trimmed, result.chars);
trimmed.delete();
return result.text.trim();
return { text: result.text.trim(), inspected };
}
/**

View File

@@ -6,7 +6,8 @@
* in the splat and the power counts toward the new value for ~0.3s before
* the card fades. A read only counts once the header agrees with a decided
* set of tiles, and the timeline keeps the furthest-along read
* (`xSetResultProgress`).
* (`xSetResultProgress`), its power settled from the power before the change
* when an earlier read showed it (`xSetResultCarryOver`).
*/
import type { RankedModeShort } from "~/modules/in-game-lists/types";
import type { Mat } from "../../cv";
@@ -97,6 +98,32 @@ export function xSetResultProgress(a: unknown, b: unknown): number {
return Math.sign(delta * Math.sign(db.powerChange));
}
/**
* The data a further-along read keeps when it replaces `kept` in the
* timeline: once the change lands, the new X Power is the power before it
* plus the change, so a read caught mid-count (or with its power unread)
* settles to that sum. A read power off the count between the two is left
* as read.
*/
export function xSetResultCarryOver(kept: unknown, further: unknown): unknown {
const before = kept as XSetResultData;
const after = further as XSetResultData;
if (
before.powerChange !== null ||
before.power === null ||
after.powerChange === null ||
before.results.length !== after.results.length
) {
return after;
}
const settled = Math.round((before.power + after.powerChange) * 10) / 10;
const onCount =
after.power === null ||
Math.abs(after.power - before.power) + Math.abs(settled - after.power) <=
Math.abs(after.powerChange) + POWER_EPSILON;
return onCount ? { ...after, power: settled } : after;
}
export function createXSetResultDetector(
resources: ScoreboardResources,
): Detector<XSetResultData> {

View File

@@ -34,6 +34,7 @@ import {
} from "../detectors/x-rank/set-count";
import {
X_SET_RESULT_EVENT_TYPE,
xSetResultCarryOver,
xSetResultProgress,
} from "../detectors/x-rank/set-result";
import { sameScoreboardMatch } from "./same-scoreboard";
@@ -78,6 +79,12 @@ export interface TimelineOptions {
* it; only reads level on the animation fall back to confidence.
*/
animationProgressByType: Record<string, (a: unknown, b: unknown) => number>;
/**
* per-type data a further-along read takes over from the read it replaces:
* an earlier frame of the animation can show what the later one has moved
* past (the X Power before the change).
*/
carryOverByType: Record<string, (kept: unknown, further: unknown) => unknown>;
}
const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
@@ -128,6 +135,9 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
[X_SET_RESULT_EVENT_TYPE]: xSetResultProgress,
[X_RANK_POSITION_EVENT_TYPE]: xRankPositionProgress,
},
carryOverByType: {
[X_SET_RESULT_EVENT_TYPE]: xSetResultCarryOver,
},
};
export type TimelineAction =
@@ -180,18 +190,30 @@ export class TimelineBuilder {
event.data,
) ?? 0;
if (progress < 0) return { action: "merged", into: near };
if (progress > 0) {
const carryOver = this.#options.carryOverByType[event.type];
return this.#replace(
near,
carryOver
? { ...event, data: carryOver(near.data, event.data) }
: event,
);
}
if (
progress > 0 ||
(event.confidence > near.confidence &&
!this.#options.firstReadTypes.includes(event.type))
event.confidence > near.confidence &&
!this.#options.firstReadTypes.includes(event.type)
) {
this.#events[this.#events.indexOf(near)] = event;
this.#events.sort((a, b) => a.t - b.t);
return { action: "replaced", event, replaced: near };
return this.#replace(near, event);
}
return { action: "merged", into: near };
}
#replace(kept: DetectedEvent, event: DetectedEvent): TimelineAction {
this.#events[this.#events.indexOf(kept)] = event;
this.#events.sort((a, b) => a.t - b.t);
return { action: "replaced", event, replaced: kept };
}
/**
* Same-state neighbours (by `t`) decide a sample's fate: none within the
* window → added; already bracketed by two → merged (a late-arriving VoD

View File

@@ -75,9 +75,10 @@
"weaponId": 50
}
],
"stageLabel": "Bluefin Depot"
"stageLabel": "Bluefin Depot",
"timestamp": "6/27/2026 10:43 PM"
},
"options": {
"notes": "1080p VoD frame (InTheZone #52, t=11491.5) of the lobby's quick battle log, opened 16s after this game's results screen. players.4.stats and players.7.stats (deaths x07) once read 1: the rectified card's digits are softer than the atlas crops, so at the default binarization the 7's antialiased bar thins out and the ink penalty hands the segment to 1. The misread broke the match builder's history-screen fingerprint and the revisit became a duplicate match card. players.1.name once read R_O_B_Or: the 4x3 dots were too short for the dot templates (only '_' qualified) and the soft T lost to r on the ink penalty. players.2.name once read ι′ちごシロッフ: い's split strokes read ι + ′ (a raised mark on the baseline no longer blocks the merge), and プ's ring touches the bar through one antialiased row, so the mark probe now retries on the stroke cores. timestamp omitted: the screen shows 10:43PM on a 12h clock and the parse drops the PM."
"notes": "1080p VoD frame (InTheZone #52, t=11491.5) of the lobby's quick battle log, opened 16s after this game's results screen. players.4.stats and players.7.stats (deaths x07) once read 1: the rectified card's digits are softer than the atlas crops, so at the default binarization the 7's antialiased bar thins out and the ink penalty hands the segment to 1. The misread broke the match builder's history-screen fingerprint and the revisit became a duplicate match card. players.1.name once read R_O_B_Or: the 4x3 dots were too short for the dot templates (only '_' qualified) and the soft T lost to r on the ink penalty. players.2.name once read ι′ちごシロッフ: い's split strokes read ι + ′ (a raised mark on the baseline no longer blocks the merge), and プ's ring touches the bar through one antialiased row, so the mark probe now retries on the stroke cores. timestamp (10:43PM on a 12h clock) once had its PM dropped; the meridiem letter reads as o for both A and P, now told apart by the letter probe."
}
}

View File

@@ -0,0 +1,87 @@
{
"event": "QuickScoreboardBattleLog",
"data": {
"lobby": "X",
"mode": "RM",
"stage": 16,
"stageLabel": "Humpback Pump Track",
"timestamp": "10/16/2026 2:01 AM",
"matchScores": [
100,
0
],
"players": [
{
"name": "かめちゃん.★",
"weaponId": 241,
"paint": 277,
"ka": 7,
"d": 0,
"s": 1
},
{
"name": "ニディ",
"weaponId": 8001,
"paint": 412,
"ka": 2,
"d": 0,
"s": 0
},
{
"name": "Bowser",
"weaponId": 7011,
"paint": 382,
"ka": 2,
"d": 0,
"s": 1
},
{
"name": "ゆう",
"weaponId": 5010,
"paint": 308,
"ka": 2,
"d": 0,
"s": 1
},
{
"name": "エナスタ GIRL",
"weaponId": 1020,
"paint": 390,
"ka": 0,
"d": 2,
"s": 1
},
{
"name": "マンポマンポママンポ",
"weaponId": 211,
"paint": 153,
"ka": 0,
"d": 3,
"s": 0
},
{
"name": "'BM' ψそうまψ",
"weaponId": 7030,
"paint": 193,
"ka": 0,
"d": 2,
"s": 0
},
{
"name": "△Dr. Lion",
"weaponId": 8001,
"paint": 192,
"ka": 0,
"d": 3,
"s": 1
}
]
},
"options": {
"skipFields": [
"players.0.name",
"players.6.name"
],
"notes": "1080p capture of the lobby's quick battle log while matchmaking (X Battle, the POV player on the losing side), opened minutes after a game whose results screen was never shown. timestamp read 2:01 PM: the AM marker read as PM, the recording time landed 12h off and the history screen became a duplicate match card instead of completing the game. players.0.name shows かめちゃん,★ (comma, ASCII or fullwidth unclear) and players.6.name's quote marks around BM may be curly; both skipped. Weapons from the prefilled read, matching the icons."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

View File

@@ -78,6 +78,44 @@ describe("TimelineBuilder X Battle cards", () => {
]);
});
test.each([
{ why: "caught mid-count", power: 2411.3 },
{ why: "unread", power: null },
])(
"settles an X Power $why to the power before the change plus the change",
({ power }) => {
const timeline = new TimelineBuilder();
timeline.push(result(0, { powerChange: null, power: 2373.8 }));
timeline.push(result(0.5, { powerChange: null, power: 2373.8 }));
timeline.push(result(1, { powerChange: 75, power }));
expect(timeline.events.map((e) => e.data)).toMatchObject([
{ powerChange: 75, power: 2448.8 },
]);
},
);
test("keeps the settled X Power over a later read still counting", () => {
const timeline = new TimelineBuilder();
timeline.push(result(0, { powerChange: null, power: 2752.4 }));
timeline.push(result(0.5, { powerChange: -29.2, power: 2752.4 }));
const action = timeline.push(
result(1, { powerChange: -29.2, power: 2742.6 }),
);
expect(action.action).toBe("merged");
expect(timeline.events[0]!.data).toMatchObject({ power: 2723.2 });
});
test("leaves an X Power read off the count from the power before the change as read", () => {
const timeline = new TimelineBuilder();
timeline.push(result(0, { powerChange: null, power: 2373.8 }));
timeline.push(result(1, { powerChange: 75, power: 2911.3 }));
expect(timeline.events[0]!.data).toMatchObject({ power: 2911.3 });
});
test("ignores an X Power read further away than the whole change", () => {
const timeline = new TimelineBuilder();
timeline.push(result(1.4, { powerChange: -29.2, power: 2728.0 }));

View File

@@ -0,0 +1,5 @@
---
navItem: scanner
type: bug
---
Fixed the scanner adding a duplicate card when the battle log of a game played in the AM is opened after a game with no results screen

View File

@@ -0,0 +1,5 @@
---
navItem: scanner
type: bug
---
Fixed the scanner sometimes showing an X Power that was still counting up or down after a set