diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index a4f2b55a7..6d923780c 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -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 diff --git a/app/features/scanner/components/live-session.ts b/app/features/scanner/components/live-session.ts index 56f6ce8e9..c41e08726 100644 --- a/app/features/scanner/components/live-session.ts +++ b/app/features/scanner/components/live-session.ts @@ -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)); } diff --git a/app/features/scanner/components/vod-scan.ts b/app/features/scanner/components/vod-scan.ts index a48c83b1e..4b380b95e 100644 --- a/app/features/scanner/components/vod-scan.ts +++ b/app/features/scanner/components/vod-scan.ts @@ -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); diff --git a/app/features/scanner/core/detectors/scoreboard-battle-log-replay/header.ts b/app/features/scanner/core/detectors/scoreboard-battle-log-replay/header.ts index 58613d3ca..4f81ccdea 100644 --- a/app/features/scanner/core/detectors/scoreboard-battle-log-replay/header.ts +++ b/app/features/scanner/core/detectors/scoreboard-battle-log-replay/header.ts @@ -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> { - 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> { + 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"; +} diff --git a/app/features/scanner/core/detectors/scoreboard/header.ts b/app/features/scanner/core/detectors/scoreboard/header.ts index e15defb02..0fb404ff3 100644 --- a/app/features/scanner/core/detectors/scoreboard/header.ts +++ b/app/features/scanner/core/detectors/scoreboard/header.ts @@ -126,16 +126,37 @@ export function* readTagBandSteps( options: TagBandOptions = {}, speculative = false, ): MatchSteps { + 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( + 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 }; } /** diff --git a/app/features/scanner/core/detectors/x-rank/set-result.ts b/app/features/scanner/core/detectors/x-rank/set-result.ts index 3800ab5c8..884a048ab 100644 --- a/app/features/scanner/core/detectors/x-rank/set-result.ts +++ b/app/features/scanner/core/detectors/x-rank/set-result.ts @@ -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 { diff --git a/app/features/scanner/core/timeline/index.ts b/app/features/scanner/core/timeline/index.ts index 079b84ffa..1b47f0cf5 100644 --- a/app/features/scanner/core/timeline/index.ts +++ b/app/features/scanner/core/timeline/index.ts @@ -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 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 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 diff --git a/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/private-battle-splat-zones-bluefin-depot/expected.json b/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/private-battle-splat-zones-bluefin-depot/expected.json index 6c327a2cc..d93626a3f 100644 --- a/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/private-battle-splat-zones-bluefin-depot/expected.json +++ b/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/private-battle-splat-zones-bluefin-depot/expected.json @@ -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." } } diff --git a/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/x-battle-rainmaker-humpback-am-timestamp/expected.json b/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/x-battle-rainmaker-humpback-am-timestamp/expected.json new file mode 100644 index 000000000..e6b260d91 --- /dev/null +++ b/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/x-battle-rainmaker-humpback-am-timestamp/expected.json @@ -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." + } +} diff --git a/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/x-battle-rainmaker-humpback-am-timestamp/frame.webp b/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/x-battle-rainmaker-humpback-am-timestamp/frame.webp new file mode 100644 index 000000000..88e641ce3 Binary files /dev/null and b/app/features/scanner/tests/fixtures/quick-scoreboard-battle-log/x-battle-rainmaker-humpback-am-timestamp/frame.webp differ diff --git a/app/features/scanner/tests/logic/x-rank-timeline.test.ts b/app/features/scanner/tests/logic/x-rank-timeline.test.ts index 92821b2fa..233ab8d35 100644 --- a/app/features/scanner/tests/logic/x-rank-timeline.test.ts +++ b/app/features/scanner/tests/logic/x-rank-timeline.test.ts @@ -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 })); diff --git a/changelog/2026-10-17-scanner-battle-log-am-time.md b/changelog/2026-10-17-scanner-battle-log-am-time.md new file mode 100644 index 000000000..cf1b16c95 --- /dev/null +++ b/changelog/2026-10-17-scanner-battle-log-am-time.md @@ -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 diff --git a/changelog/2026-10-17-scanner-x-power-count.md b/changelog/2026-10-17-scanner-x-power-count.md new file mode 100644 index 000000000..2d9d4ab0f --- /dev/null +++ b/changelog/2026-10-17-scanner-x-power-count.md @@ -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