Battle log initial

This commit is contained in:
Kalle 2026-08-07 09:55:34 +03:00
parent 9b5a3ef028
commit eaade46354
24 changed files with 1228 additions and 57 deletions

View File

@ -74,8 +74,10 @@ sequenceDiagram
assumes a browser, so the client tree loads via `React.lazy` after
`useHydrated`. Nothing from `core/worker/capture/store` may be imported at
route-module top level.
- Seven detectors: `scoreboard` (results screen), `scoreboard-replay`
(replay-browser detail), `scoreboard-own` (personal results), `death`
- Eight detectors: `scoreboard` (results screen), `scoreboard-replay`
(replay-browser detail), `battle-log` (Recent Battles detail — the same
data as the replay screen sans the replay code, panels stacked instead of
side by side), `scoreboard-own` (personal results), `death`
(respawn overlay), `map-start` (match intro), `minimap` (in-match overlay,
plus the casted 8-player spectator map as a gated variant), `objective`
(the ranked in-match counter overlay: per-team counts, penalties, who

View File

@ -5,6 +5,7 @@
import {
CircleHelp,
History,
type LucideIcon,
Map as MapIcon,
Play,
@ -14,6 +15,7 @@ import {
Trophy,
User,
} from "lucide-react";
import { BATTLE_LOG_EVENT_TYPE } from "../core/detectors/battle-log/index";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
@ -29,6 +31,7 @@ const EVENT_TYPE_ICONS: Record<string, LucideIcon> = {
[OBJECTIVE_EVENT_TYPE]: Target,
[SCOREBOARD_EVENT_TYPE]: Trophy,
[SCOREBOARD_REPLAY_EVENT_TYPE]: RotateCcw,
[BATTLE_LOG_EVENT_TYPE]: History,
[SCOREBOARD_OWN_EVENT_TYPE]: User,
};

View File

@ -5,6 +5,7 @@
* entirely once everything lives in a match.
*/
import { BATTLE_LOG_EVENT_TYPE } from "../core/detectors/battle-log/index";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
@ -21,6 +22,7 @@ const EVENT_TYPE_LABELS: Record<string, string> = {
[OBJECTIVE_EVENT_TYPE]: "objective",
[SCOREBOARD_EVENT_TYPE]: "scoreboard",
[SCOREBOARD_REPLAY_EVENT_TYPE]: "replay scoreboard",
[BATTLE_LOG_EVENT_TYPE]: "battle log",
[SCOREBOARD_OWN_EVENT_TYPE]: "own result",
};

View File

@ -1,5 +1,6 @@
import { WeaponImage } from "~/components/Image";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import { BATTLE_LOG_EVENT_TYPE } from "../core/detectors/battle-log/index";
import type {
ScoreboardData,
ScoreboardPlayer,
@ -77,6 +78,7 @@ export function ScoreboardCard(props: {
const eventType = props.eventType ?? "Scoreboard";
const data = props.data as CardData;
const isReplay = eventType === SCOREBOARD_REPLAY_EVENT_TYPE;
const isBattleLog = eventType === BATTLE_LOG_EVENT_TYPE;
return (
<div className="card">
<div className="meta">
@ -84,7 +86,9 @@ export function ScoreboardCard(props: {
t={t}
confidence={confidence}
type={eventType}
label={isReplay ? "replay" : "scoreboard"}
label={
isReplay ? "replay" : isBattleLog ? "battle log" : "scoreboard"
}
/>
{(data.mode !== null || data.stage !== null) && (
<span>

View File

@ -3,6 +3,7 @@ import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { mainWeaponImageUrl } from "~/utils/urls";
import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "../core/canonical";
import * as bl from "../core/detectors/battle-log/rois";
import type { DeathData } from "../core/detectors/death/index";
import * as death from "../core/detectors/death/rois";
import type { MapStartData } from "../core/detectors/map-start/index";
@ -77,6 +78,26 @@ function scoreboardRows(): RowRois[] {
}));
}
/** winnerSide comes from the event debug: players are ordered winners-first. */
function battleLogRows(winnerSide: string): RowRois[] {
const panels = winnerSide === "bottom" ? [bl.PANEL_DY, 0] : [0, bl.PANEL_DY];
return panels.flatMap((dy) =>
bl.ROW_CENTERS.map((base) => {
const cy = base + dy;
return {
weapon: bl.weaponRoi(cy),
name: bl.nameRoi(cy),
paint: bl.paintRoi(cy),
stats: [bl.statRoi(cy, 0), bl.statRoi(cy, 1), bl.statRoi(cy, 2)] as [
Roi,
Roi,
Roi,
],
};
}),
);
}
/** winnerSide comes from the event debug: players are ordered winners-first. */
function replayRows(winnerSide: string): RowRois[] {
const panels =
@ -170,6 +191,25 @@ function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) {
}
return;
}
if (detector === "battle-log") {
for (const dy of bl.PANEL_DYS) {
for (const base of bl.ROW_CENTERS) {
const cy = base + dy;
rect(bl.weaponRoi(cy), "#f87171");
rect(bl.nameRoi(cy), "#4ade80");
rect(bl.paintRoi(cy), "#60a5fa");
for (const i of [0, 1, 2] as const) rect(bl.statRoi(cy, i), "#e879f9");
rect(bl.gateDarkProbe(cy), "#facc15");
}
rect(bl.teamScoreRoi(dy), "#60a5fa");
rect(bl.resultTagRoi(dy), "#fb923c");
}
for (const roi of bl.MATCH_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of bl.GATE_COLOR_PROBES) rect(roi, "#facc15");
rect(bl.HEADER_TOP_BAND, "#34d399");
rect(bl.HEADER_BOTTOM_BAND, "#34d399");
return;
}
if (detector === "scoreboard-replay") {
for (const dx of replay.PANEL_XS) {
for (const cy of replay.ROW_CENTERS) {
@ -297,12 +337,17 @@ export function ScreenshotPage() {
const event = active?.events[0] as DetectedEvent<CardData> | undefined;
const rows = (event?.debug?.rows ?? []) as ScoreboardRowDebug[];
const isReplay = activeDetector === "scoreboard-replay";
const isBattleLog = activeDetector === "battle-log";
const isDeath = activeDetector === "death";
const isMapStart = activeDetector === "map-start";
const isOwn = activeDetector === "scoreboard-own";
const isMinimap = activeDetector === "minimap";
const winnerSide = String(event?.debug?.winnerSide ?? "left");
const rowRois = isReplay ? replayRows(winnerSide) : scoreboardRows();
const rowRois = isReplay
? replayRows(winnerSide)
: isBattleLog
? battleLogRows(winnerSide)
: scoreboardRows();
return (
<div>
@ -462,6 +507,17 @@ export function ScreenshotPage() {
</p>
)}
{frame && event && isBattleLog && (
<p>
timestamp <b>{event.data.timestamp ?? "?"}</b>
{" · "}match scores {JSON.stringify(event.data.matchScores)}
{" · "}winner panel <b>{winnerSide}</b>
<br />
<RoiCrop frame={frame} roi={bl.HEADER_TOP_BAND} />{" "}
<RoiCrop frame={frame} roi={bl.HEADER_BOTTOM_BAND} />
</p>
)}
{frame && event && isDeath && (
<p>
{(() => {

View File

@ -28,10 +28,7 @@ import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import {
SCOREBOARD_REPLAY_EVENT_TYPE,
type ScoreboardReplayData,
} from "../core/detectors/scoreboard-replay/index";
import type { ScoreboardReplayData } from "../core/detectors/scoreboard-replay/index";
import { formatClock, formatTime } from "./format";
import {
lobbyLabel,
@ -221,9 +218,8 @@ function eventCells(event: CsvEvent): Cell[] {
];
}
default: {
// Scoreboard and ScoreboardReplay share the base shape
// Scoreboard, ScoreboardReplay and BattleLog share the base shape
const d = event.data as ScoreboardData & Partial<ScoreboardReplayData>;
const isReplay = event.type === SCOREBOARD_REPLAY_EVENT_TYPE;
return [
...base,
lobbyLabel(d.lobby),
@ -236,8 +232,8 @@ function eventCells(event: CsvEvent): Cell[] {
"",
"",
formatPlayers(d),
isReplay ? d.replayCode : "",
isReplay ? d.timestamp : "",
d.replayCode ?? "",
d.timestamp ?? "",
];
}
}

View File

@ -0,0 +1,395 @@
/**
* BattleLogDetector: parses the Recent Battles detail screen (battle log)
* the same match data as the live scoreboard (header, team scores, 8 player
* rows) plus the recording timestamp, but no replay code.
*
* The two team panels sit STACKED (observed winner on top, confirmed by the
* VICTORY/DEFEAT tags) and the row text renders at the live scoreboard's
* sizes, so field parsing reuses the scoreboard helpers with the shared
* glyph sets unscaled only the ROI geometry is this screen's own. The
* header is the replay browser's (timestamp + stage / lobby + mode tags),
* parsed with battle-log bands.
*/
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import {
cropRoi,
maxBrightness,
maxChannel,
meanBrightness,
} from "../../image";
import { RESULT_TAG_ENTRIES } from "../../localized";
import { closestBy } from "../../text";
import {
type BannerScoreRead,
FULL_COUNT_TEAM_SCORE,
parseBannerScore,
resolveMatchScores,
} from "../scoreboard/banner";
import { type ParsedNumber, parseNumber } from "../scoreboard/digits";
import type {
ScoreboardData,
ScoreboardPlayer,
ScoreboardResources,
ScoreboardRowDebug,
} from "../scoreboard/index";
import { findPovIndex } from "../scoreboard/pov";
import { parseScoreboardRow, type RowRois } from "../scoreboard/row";
import {
type ParsedReplayHeader,
parseReplayHeader,
} from "../scoreboard-replay/header";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
GATE_COLOR_MIN_SATURATION,
GATE_COLOR_PROBES,
GATE_DARK_MAX_MEAN,
GATE_TEXT_MIN_MAX,
gateDarkProbe,
HEADER_BOTTOM_BAND,
HEADER_LINE_HEIGHT,
HEADER_TAG_COLUMN_FRACTION,
HEADER_TAG_LEAD_IN_MAX,
HEADER_TIMESTAMP_HEIGHT,
HEADER_TOP_BAND,
MATCH_SCORE_DIGIT_HEIGHT,
MATCH_SCORE_ROIS,
nameRoi,
PANEL_DYS,
paintRoi,
paintSuffixRoi,
povArrowRoi,
RESULT_TAG_TEXT_HEIGHT,
ROW_CENTERS,
resultTagRoi,
specialIconRoi,
statRoi,
teamScoreRoi,
weaponRoi,
} from "./rois";
export interface BattleLogData extends ScoreboardData {
/** recording timestamp as shown, e.g. "5/8/2026 19:16"; locale-formatted */
timestamp: string | null;
}
export const BATTLE_LOG_EVENT_TYPE = "BattleLog";
/**
* White outlined team totals on the panel's saturated color band a yellow
* band grays at ~190, so binarize just above it (the digit cores are ~250).
*/
const TEAM_SCORE_BIN_THRESHOLD = 205;
/** Canonical results the localized VICTORY/DEFEAT panel tags snap to. */
type PanelResult = "VICTORY" | "DEFEAT";
const RESULT_MIN_SCORE = 0.6;
/**
* The tag letters render in the team's ink color on the gray stamp box
* (~75 gray, trailing status icons ~120), so binarize the max-channel
* image just above the icons every ink color's brightest channel
* clears this.
*/
const RESULT_TAG_BIN_THRESHOLD = 140;
interface PanelParse {
players: ScoreboardPlayer[];
rows: ScoreboardRowDebug[];
teamScore: ParsedNumber | null;
result: PanelResult | null;
resultReading: string;
resultScore: number;
confidences: number[];
}
export function createBattleLogDetector(
resources: ScoreboardResources,
): Detector<BattleLogData> {
const cv = getCV();
const scaled = (set: GlyphSet | null, height: number): GlyphSet | null =>
set ? scaleGlyphSet(set, height / set.height) : null;
const teamDigits = resources.teamDigits ?? resources.paintDigits;
const matchScoreSets = teamDigits
? [scaleGlyphSet(teamDigits, MATCH_SCORE_DIGIT_HEIGHT / teamDigits.height)]
: [];
/** Timestamp needs digits + '/' + ':' — only the names atlas has them. */
const headerTopGlyphs = scaled(resources.nameGlyphs, HEADER_TIMESTAMP_HEIGHT);
const headerBottomGlyphs = scaled(
resources.headerLineGlyphs,
HEADER_LINE_HEIGHT,
);
// The tags render in FOT-RowdyStd — use the dedicated atlas when present;
// the BlitzMain-based fallback reads them only roughly.
const resultGlyphs =
scaled(resources.replayResultGlyphs ?? null, RESULT_TAG_TEXT_HEIGHT) ??
scaled(resources.headerLineGlyphs, RESULT_TAG_TEXT_HEIGHT);
/** Mean-RGB saturation (max minus min channel) of a probe ROI. */
function probeSaturation(frame: Mat, roi: (typeof GATE_COLOR_PROBES)[0]) {
const view = cropRoi(frame, roi);
const cont = new cv.Mat();
view.copyTo(cont);
view.delete();
const d = cont.data;
const n = cont.rows * cont.cols;
let r = 0;
let g = 0;
let b = 0;
for (let i = 0; i < n; i++) {
r += d[i * 4]!;
g += d[i * 4 + 1]!;
b += d[i * 4 + 2]!;
}
cont.delete();
if (n === 0) return 0;
return (Math.max(r, g, b) - Math.min(r, g, b)) / n;
}
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
let darkOk = 0;
let suffixOk = 0;
for (const dy of PANEL_DYS) {
for (const base of ROW_CENTERS) {
const cy = base + dy;
if (meanBrightness(frame, gateDarkProbe(cy)) < GATE_DARK_MAX_MEAN)
darkOk++;
if (maxBrightness(gray, paintSuffixRoi(cy)) > GATE_TEXT_MIN_MAX)
suffixOk++;
}
}
let colorOk = 0;
for (const roi of GATE_COLOR_PROBES) {
if (probeSaturation(frame, roi) >= GATE_COLOR_MIN_SATURATION) colorOk++;
}
gray.delete();
const rowCount = PANEL_DYS.length * ROW_CENTERS.length;
const score =
(darkOk / rowCount +
suffixOk / rowCount +
colorOk / GATE_COLOR_PROBES.length) /
3;
const pass = darkOk >= 7 && suffixOk >= 7 && colorOk === 3;
return { pass, score };
}
function parsePanel(gray: Mat, rgb: Mat, dy: number): PanelParse {
const players: ScoreboardPlayer[] = [];
const rows: ScoreboardRowDebug[] = [];
const confidences: number[] = [];
const rowRois: RowRois = {
weapon: weaponRoi,
specialIcon: specialIconRoi,
paint: paintRoi,
name: nameRoi,
stat: statRoi,
povArrow: povArrowRoi,
};
for (const base of ROW_CENTERS) {
const row = parseScoreboardRow(
gray,
rgb,
base + dy,
rowRois,
resources,
confidences,
);
players.push(row.player);
rows.push(row.debug);
}
// The panel's point total is read only to recognize a knockout (the
// count times five: only a knockout's full count reaches 500); it is
// never emitted as a score.
let teamScore: ParsedNumber | null = null;
if (teamDigits) {
const crop = cropRoi(gray, teamScoreRoi(dy));
teamScore = parseNumber(crop, teamDigits, {
binThreshold: TEAM_SCORE_BIN_THRESHOLD,
});
crop.delete();
}
let result: PanelParse["result"] = null;
let resultReading = "";
let resultScore = 0;
if (resultGlyphs) {
const bright = maxChannel(rgb, resultTagRoi(dy));
const raw = recognizeText(bright, resultGlyphs, {
binThreshold: RESULT_TAG_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.25,
});
bright.delete();
resultReading = raw.text;
if (resultReading) {
const match = closestBy(
resultReading,
RESULT_TAG_ENTRIES,
(e) => e.text,
);
if (match) {
resultScore = match.score;
if (match.score >= RESULT_MIN_SCORE)
result = match.entry.canonical as PanelResult;
}
}
}
return {
players,
rows,
teamScore,
result,
resultReading,
resultScore,
confidences,
};
}
function parse(frame: Mat, t: number): DetectedEvent<BattleLogData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const [top, bottom] = PANEL_DYS.map((dy) => parsePanel(gray, rgb, dy)) as [
PanelParse,
PanelParse,
];
let left: BannerScoreRead | null = null;
let right: BannerScoreRead | null = null;
if (matchScoreSets.length > 0) {
left = parseBannerScore(gray, MATCH_SCORE_ROIS[0], matchScoreSets);
right = parseBannerScore(gray, MATCH_SCORE_ROIS[1], matchScoreSets);
}
const swapped = decideSwapped(top, bottom, left, right);
const [winner, loser] = swapped ? [bottom, top] : [top, bottom];
// POV arrow row, indexed into the winners-first players ordering
const povIndex = findPovIndex(
[...winner.rows, ...loser.rows].map((r) => r.povFraction),
);
const knockout = winner.teamScore?.value === FULL_COUNT_TEAM_SCORE;
let matchScores: [number | null, number | null] = [null, null];
let bannerDebug: object | undefined;
if (left && right) {
matchScores = resolveMatchScores({ left, right, knockout });
winner.confidences.push(left.confidence, right.confidence);
bannerDebug = { left, right, knockout };
}
let header: ParsedReplayHeader | null = null;
if (headerTopGlyphs && headerBottomGlyphs) {
header = parseReplayHeader(gray, headerTopGlyphs, headerBottomGlyphs, {
top: HEADER_TOP_BAND,
bottom: HEADER_BOTTOM_BAND,
tagLeadInMax: HEADER_TAG_LEAD_IN_MAX,
tagColumnFraction: HEADER_TAG_COLUMN_FRACTION,
});
}
gray.delete();
rgb.delete();
const confidences = [
...winner.confidences,
...loser.confidences,
...(header ? [header.confidence] : []),
];
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: BATTLE_LOG_EVENT_TYPE,
t,
confidence,
data: {
lobby: header?.lobby ?? null,
mode: header?.mode ?? null,
stage: header?.stage ?? null,
timestamp: header?.timestamp ?? null,
matchScores,
players: [...winner.players, ...loser.players],
povIndex,
},
debug: {
rows: [...winner.rows, ...loser.rows],
teamScoreConf: [
winner.teamScore?.confidence ?? 0,
loser.teamScore?.confidence ?? 0,
],
matchScore: bannerDebug,
header: header?.debug,
winnerSide: swapped ? "bottom" : "top",
resultTags: {
top: {
reading: top.resultReading,
score: top.resultScore,
result: top.result,
},
bottom: {
reading: bottom.resultReading,
score: bottom.resultScore,
result: bottom.result,
},
},
},
},
];
}
// no rearm cooldown — distinct battles browsed in quick succession are
// told apart by content (same as the replay browser)
return {
id: "battle-log",
sufficientConfidence: 0.8,
gate,
parse,
};
}
/**
* Whether the winner sits in the bottom panel. A confident VICTORY/DEFEAT
* tag decides (the distressed tag texture usually reads below the floor);
* otherwise the panel totals are checked against the banner scores the
* total is the count times five, and only a knockout winner's reaches 500.
* Default: winner on top, which every observed battle-log screen shows.
*/
function decideSwapped(
top: PanelParse,
bottom: PanelParse,
left: BannerScoreRead | null,
right: BannerScoreRead | null,
): boolean {
if (top.result !== null || bottom.result !== null) {
return top.result === "DEFEAT" || bottom.result === "VICTORY";
}
const topTotal = top.teamScore?.value ?? null;
const bottomTotal = bottom.teamScore?.value ?? null;
if (topTotal === FULL_COUNT_TEAM_SCORE) return false;
if (bottomTotal === FULL_COUNT_TEAM_SCORE) return true;
if (
left?.value != null &&
right?.value != null &&
topTotal !== null &&
bottomTotal !== null &&
topTotal !== bottomTotal
) {
const hi = Math.max(left.value, right.value) * 5;
const lo = Math.min(left.value, right.value) * 5;
if (topTotal === lo && bottomTotal === hi) return true;
}
return false;
}

View File

@ -0,0 +1,161 @@
/**
* ALL battle-log ROI coordinates, in canonical 1920x1080 space.
* Calibrated against battle-log/private-battle-splat-zones-makomart and
* x-battle-clam-blitz-lemuria via scripts/scanner/dump-crops.ts and
* column-projection measurement.
*
* The Recent Battles detail screen (battle log) shows the two team panels
* STACKED (top panel first, bottom panel = top shifted by PANEL_DY), four
* near-black pill rows each on a dark panel whose top band and border carry
* the team's ink color. Text sizes match the live results scoreboard, so
* the row glyph sets are reused unscaled; only the column positions differ.
* Above the panels sit the split "Score:"/KNOCKOUT! banner and the
* stage-photo header with black auto-sized tags (line 1 = recording
* timestamp + stage, line 2 = lobby + mode, like the replay browser).
* There is no replay code line.
*/
import type { Roi } from "../../canonical";
/** Vertical centers of the 4 player rows within the top panel. */
export const ROW_CENTERS = [453, 519, 585, 651] as const;
/** Vertical shift from a top-panel ROI to its bottom-panel twin. */
export const PANEL_DY = 366;
/** dy per panel: [top (index 0), bottom (index 1)]. */
export const PANEL_DYS = [0, PANEL_DY] as const;
/**
* Weapon icon search region within a row icons render at the live
* scoreboard's sizes, and the 56px height intentionally excludes the larger
* replay-browser templates (matchTemplate silently skips taller ones).
*/
export function weaponRoi(cy: number): Roi {
return { x: 1040, y: cy - 28, w: 76, h: 56 };
}
/**
* The player's special-weapon icon, drawn on the pill above the third stat
* counter (measured art ~x 1645-1692, y cy-20..cy+2 across fixtures).
* Only read to break near-tied weapon-icon matches whose kits carry
* different specials. Bounded below at cy+2 so the counter digits
* (starting cy+5) stay out of the binarized shape.
*/
export function specialIconRoi(cy: number): Roi {
return { x: 1642, y: cy - 24, w: 52, h: 26 };
}
/**
* Player name text region (white text, left-aligned starting x=1114).
* Long names run into the paint column; parse paint first and trim this
* region at the leftmost paint digit.
*/
export function nameRoi(cy: number): Roi {
return { x: 1110, y: cy - 14, w: 208, h: 32 };
}
/** Paint amount digits, right-aligned ending at x=1396 (the "p" suffix is excluded). */
export function paintRoi(cy: number): Roi {
return { x: 1312, y: cy - 17, w: 88, h: 34 };
}
/** The constant white "p" after the paint number — used as a gate anchor. */
export function paintSuffixRoi(cy: number): Roi {
return { x: 1398, y: cy - 14, w: 18, h: 28 };
}
/** Stat counter digits (two, zero-padded; the small "x" prefix is excluded). */
export function statRoi(cy: number, index: 0 | 1 | 2): Roi {
const x = [1530, 1593, 1656][index]!;
return { x, y: cy + 3, w: 30, h: 22 };
}
/**
* POV arrow probe: the yellow arrow marking the recording player's row sits
* on the panel left of the pill (measured x 977-1024, y cy-31..cy+31 on the
* fixtures). Right edge stays short of the pill's rounded cap (~x 1032).
*/
export function povArrowRoi(cy: number): Roi {
return { x: 968, y: cy - 32, w: 60, h: 62 };
}
/**
* Team point totals ("500 p") on the panel's colored top band, digits
* right-aligned ending at x=1704. Only read to recognize a knockout
* (winner total 500) the totals are the count times five, not the score.
*/
export function teamScoreRoi(dy: number): Roi {
return { x: 1614, y: 371 + dy, w: 96, h: 38 };
}
/**
* VICTORY / DEFEAT tag on each panel's top band read to confirm which
* panel won (observed always the top one; the tag letters render in the
* team's ink color on the gray stamp).
*/
export function resultTagRoi(dy: number): Roi {
return { x: 990, y: 358 + dy, w: 215, h: 52 };
}
/**
* The two sides of the colored "Score:" banner wave above the panels. The
* left side's digits follow the localized label (left-aligned from x~985),
* the right side's are right-aligned ending at x~1744. A knockout replaces
* the winning side's value with the KNOCKOUT! burst.
*/
export const MATCH_SCORE_ROIS: readonly [Roi, Roi] = [
{ x: 985, y: 278, w: 265, h: 40 },
{ x: 1648, y: 278, w: 100, h: 40 },
];
/**
* Header bands on the stage-photo banner. Line 1 holds the recording
* timestamp and the stage tag (led by a rank icon on ranked lobbies, which
* shifts the text left edge); line 2 the lobby (bold, from x=809) and mode.
* The bands hug the tag rows tightly the stage photo directly below the
* boxes has bright pixels that would ink every glyph's bottom otherwise
* and start on the photo left of the boxes, so readTagBand scans for the
* tag start (HEADER_TAG_LEAD_IN_MAX) instead of anchoring at the edge.
*/
export const HEADER_TOP_BAND: Roi = { x: 800, y: 74, w: 668, h: 32 };
export const HEADER_BOTTOM_BAND: Roi = { x: 800, y: 122, w: 728, h: 46 };
export const HEADER_TAG_LEAD_IN_MAX = 40;
/** see TagBandOptions.tagColumnFraction — the tags are subtly tilted */
export const HEADER_TAG_COLUMN_FRACTION = 0.75;
/**
* Gate probe: the strip between the paint "p" suffix (ends 1413) and the
* first stat "x" (starts 1517) is always empty pill background (near-black
* ~20, like the live scoreboard's pills).
*/
export function gateDarkProbe(cy: number): Roi {
return { x: 1422, y: cy - 10, w: 78, h: 20 };
}
/**
* Ink-color probes, the discriminator against the two lookalike results
* screens: the panels' colored top bands (right of the result tag, left of
* the totals) and the score banner are always saturated team color here
* (measured saturation 109+ across fixtures), while the live scoreboard is
* neutral panel gray at all three spots and the replay browser only at the
* first (its own banner) its panel area is flat mid-gray.
*/
export const GATE_COLOR_PROBES: readonly Roi[] = [
{ x: 1300, y: 382, w: 100, h: 16 },
{ x: 1300, y: 748, w: 100, h: 16 },
{ x: 1400, y: 292, w: 60, h: 14 },
];
/** Mean-RGB saturation (max minus min channel) floor for the color probes. */
export const GATE_COLOR_MIN_SATURATION = 60;
/** The strip between p suffix and stats must stay near-black. */
export const GATE_DARK_MAX_MEAN = 45;
/** The paint "p" suffix region must contain bright (white) pixels. */
export const GATE_TEXT_MIN_MAX = 180;
/** Text metrics measured on the fixtures, used for glyph scaling / tooling. */
export const MATCH_SCORE_DIGIT_HEIGHT = 28;
export const HEADER_TIMESTAMP_HEIGHT = 21;
export const HEADER_LINE_HEIGHT = 26;
export const RESULT_TAG_TEXT_HEIGHT = 30;

View File

@ -4,6 +4,10 @@
* picked up by the analyzer worker.
*/
import {
BATTLE_LOG_EVENT_TYPE,
createBattleLogDetector,
} from "./battle-log/index";
import { createDeathDetector } from "./death/index";
import { createMapStartDetector } from "./map-start/index";
import { createMinimapDetector } from "./minimap/index";
@ -22,11 +26,13 @@ import type { Detector } from "./types";
/**
* Event types whose data is the full 8-player scoreboard shape
* (ScoreboardData): the results screen and the replay-browser detail.
* (ScoreboardData): the results screen, the replay-browser detail, and the
* battle-log detail.
*/
export const SCOREBOARD_EVENT_TYPES: readonly string[] = [
SCOREBOARD_EVENT_TYPE,
SCOREBOARD_REPLAY_EVENT_TYPE,
BATTLE_LOG_EVENT_TYPE,
];
export function createAllDetectors(
@ -35,6 +41,7 @@ export function createAllDetectors(
return [
createScoreboardDetector(resources) as Detector<unknown>,
createScoreboardReplayDetector(resources) as Detector<unknown>,
createBattleLogDetector(resources) as Detector<unknown>,
createScoreboardOwnDetector(resources) as Detector<unknown>,
createDeathDetector(resources) as Detector<unknown>,
createMapStartDetector(resources) as Detector<unknown>,

View File

@ -10,6 +10,7 @@
*/
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 { ALL_STAGE_ENTRIES, LOBBY_MODE_COMBOS } from "../../localized";
@ -49,10 +50,12 @@ const TAG_DARK_MAX_LIFTED = 120;
* (ja) so any . / - separated triple followed by a time is accepted.
* Adjacent skinny time digits can read with a spurious gap on compressed
* captures ("14:1 1"), so a lone space is tolerated between them and
* stripped when the timestamp is assembled.
* stripped when the timestamp is assembled. Not left-anchored: the
* battle-log line leads with a rank icon on ranked lobbies, which reads as
* a junk glyph before the date.
*/
const TIMESTAMP_RE =
/^(\d{1,4}[./-]\d{1,2}[./-]\d{1,4})\s+(\d(?: ?\d)?: ?\d ?\d)\s*(.*)$/;
/(\d{1,4}[./-]\d{1,2}[./-]\d{1,4})\s+(\d(?: ?\d)?: ?\d ?\d)\s*(.*)$/;
interface TopBandParse {
reading: string;
@ -86,27 +89,49 @@ function parseTopBand(reading: string): TopBandParse {
return { reading, timestamp, stage, stageScore };
}
/** The two header tag bands; the battle log passes its own coordinates. */
export interface ReplayHeaderBands {
top: Roi;
bottom: Roi;
/** see TagBandOptions.tagLeadInMax; the battle-log tags are not left-anchored */
tagLeadInMax?: number;
/** see TagBandOptions.tagColumnFraction; the battle-log tags are tilted */
tagColumnFraction?: number;
}
const REPLAY_BANDS: ReplayHeaderBands = {
top: HEADER_TOP_BAND,
bottom: HEADER_BOTTOM_BAND,
};
export function parseReplayHeader(
gray: Mat,
topGlyphs: GlyphSet,
bottomGlyphs: GlyphSet,
bands: ReplayHeaderBands = REPLAY_BANDS,
): ParsedReplayHeader {
let top = parseTopBand(readTagBand(gray, HEADER_TOP_BAND, topGlyphs));
const leadIn = {
tagLeadInMax: bands.tagLeadInMax,
tagColumnFraction: bands.tagColumnFraction,
};
let top = parseTopBand(readTagBand(gray, bands.top, topGlyphs, leadIn));
if (top.stage === null) {
const retry = parseTopBand(
readTagBand(gray, HEADER_TOP_BAND, topGlyphs, {
readTagBand(gray, bands.top, topGlyphs, {
...leadIn,
tagDarkMax: TAG_DARK_MAX_LIFTED,
}),
);
if (retry.stageScore >= top.stageScore) top = retry;
}
let bottomReading = readTagBand(gray, HEADER_BOTTOM_BAND, bottomGlyphs);
let bottomReading = readTagBand(gray, bands.bottom, bottomGlyphs, leadIn);
let bottomMatch = bottomReading
? closestBy(bottomReading, LOBBY_MODE_COMBOS, (c) => c.text)
: null;
if (!bottomMatch || bottomMatch.score < MIN_MATCH_SCORE) {
const reading = readTagBand(gray, HEADER_BOTTOM_BAND, bottomGlyphs, {
const reading = readTagBand(gray, bands.bottom, bottomGlyphs, {
...leadIn,
tagDarkMax: TAG_DARK_MAX_LIFTED,
});
const match = reading

View File

@ -48,27 +48,50 @@ const TAG_BRIGHT_MIN = 165;
const TAG_GAP_TOLERANCE = 6;
/**
* Trim a band crop to the black-tag extent starting from its left edge.
* Returns the trimmed width (0 when no tag is present at all).
* Trim a band crop to the black-tag extent: the longest run of tag columns
* starting within `maxLeadIn` of the left edge (a dark photo edge can fake
* a short run before the real tag), each run extended right until the tag
* ends. Returns a zero-width range when no tag is present at all.
*/
function tagExtent(crop: Mat, darkMax: number): number {
function tagExtent(
crop: Mat,
darkMax: number,
maxLeadIn: number,
columnFraction: number,
): { start: number; end: number } {
const { cols, rows, data } = crop;
let best = { start: 0, end: 0 };
let start = -1;
let end = 0;
let gap = 0;
const takeRun = () => {
if (start !== -1 && end - start > best.end - best.start) {
best = { start, end };
}
start = -1;
gap = 0;
};
for (let x = 0; x < cols; x++) {
let tagLike = 0;
for (let y = 0; y < rows; y++) {
const v = data[y * cols + x]!;
if (v < darkMax || v > TAG_BRIGHT_MIN) tagLike++;
}
if (tagLike / rows >= TAG_COLUMN_FRACTION) {
if (tagLike / rows >= columnFraction) {
if (start === -1) {
if (x > maxLeadIn) break;
start = x;
}
end = x + 1;
gap = 0;
} else if (++gap > TAG_GAP_TOLERANCE) {
} else if (start !== -1 && ++gap > TAG_GAP_TOLERANCE) {
takeRun();
} else if (start === -1 && x > maxLeadIn) {
break;
}
}
return end;
takeRun();
return best;
}
export interface TagBandOptions extends RecognizeOptions {
@ -79,6 +102,18 @@ export interface TagBandOptions extends RecognizeOptions {
* snap fails retry with a lifted ceiling.
*/
tagDarkMax?: number;
/**
* Non-tag columns tolerated before the tag begins. The battle-log tags
* are not left-anchored (a leading rank icon shifts line 1 per lobby
* type), so its bands start on the stage photo and scan for the tag.
*/
tagLeadInMax?: number;
/**
* Tag-like row fraction a column must reach. The battle-log tags are
* subtly tilted, so a horizontal band always catches a few photo rows
* above or below the box those bands pass a looser fraction.
*/
tagColumnFraction?: number;
}
/**
@ -93,13 +128,18 @@ export function readTagBand(
options: TagBandOptions = {},
): string {
const crop = copyRoi(gray, band);
const width = tagExtent(crop, options.tagDarkMax ?? TAG_DARK_MAX);
if (width < 12) {
const { start, end } = tagExtent(
crop,
options.tagDarkMax ?? TAG_DARK_MAX,
options.tagLeadInMax ?? TAG_GAP_TOLERANCE,
options.tagColumnFraction ?? TAG_COLUMN_FRACTION,
);
if (end - start < 12) {
crop.delete();
return "";
}
const cv = getCV();
const view = crop.roi(new cv.Rect(0, 0, width, crop.rows));
const view = crop.roi(new cv.Rect(start, 0, end - start, crop.rows));
const trimmed = new cv.Mat();
view.copyTo(trimmed);
view.delete();

View File

@ -17,6 +17,10 @@
*/
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import { harvestAbilities } from "./ability-harvest";
import {
BATTLE_LOG_EVENT_TYPE,
type BattleLogData,
} from "./detectors/battle-log/index";
import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index";
import {
MAP_START_EVENT_TYPE,
@ -349,9 +353,12 @@ function toBuiltMatch<E extends DetectedEvent>(
const board = open.scoreboard?.data as ScoreboardData | undefined;
const start = open.mapStart?.data as MapStartData | undefined;
const replay =
open.scoreboard?.type === SCOREBOARD_REPLAY_EVENT_TYPE
? (open.scoreboard.data as ScoreboardReplayData)
// the replay-browser and battle-log screens both carry the recording
// timestamp; only the former a replay code
const timestamped =
open.scoreboard?.type === SCOREBOARD_REPLAY_EVENT_TYPE ||
open.scoreboard?.type === BATTLE_LOG_EVENT_TYPE
? (open.scoreboard.data as BattleLogData & Partial<ScoreboardReplayData>)
: undefined;
const deaths = open.deaths.map((event) => event.data as DeathData);
const objectives = open.objectives.map((event) => ({
@ -365,14 +372,14 @@ function toBuiltMatch<E extends DetectedEvent>(
startsAt:
sources.length > 0 ? Math.max(0, Math.floor(sources[0]!.t)) : null,
endsAt: floorOrNull(open.scoreboard?.t ?? open.minimaps.at(-1)?.t),
playedAt: playedAt(open.scoreboard, replay),
playedAt: playedAt(open.scoreboard, timestamped),
lobby: board?.lobby ?? null,
mode,
stage: board?.stage ?? start?.stage ?? leadingStage(open.stageVotes),
matchScores: board?.matchScores.some((score) => score !== null)
? board.matchScores
: null,
replayCode: replay?.replayCode ?? null,
replayCode: timestamped?.replayCode ?? null,
cast: open.minimaps.some((event) => (event.data as MinimapData).spectator),
// only the SZ counter is parsed — reads on a known other-mode match
// are misreads of a lookalike overlay, not progress data
@ -446,20 +453,20 @@ function bestCount(
}
/**
* The wall-clock time the match was played: a replay scoreboard's on-screen
* recording timestamp (anchored to when the screen was seen, not a possibly
* much later send), else the closing scoreboard's detection time. Detection
* times ride richer event records (StoredEvent) and are read structurally so
* the builder stays generic.
* The wall-clock time the match was played: a replay/battle-log screen's
* on-screen recording timestamp (anchored to when the screen was seen, not
* a possibly much later send), else the closing scoreboard's detection
* time. Detection times ride richer event records (StoredEvent) and are
* read structurally so the builder stays generic.
*/
function playedAt(
scoreboard: DetectedEvent | null,
replay: ScoreboardReplayData | undefined,
timestamped: BattleLogData | undefined,
): number | null {
if (!scoreboard) return null;
const detectedAt = (scoreboard as { detectedAt?: number }).detectedAt ?? null;
if (replay?.timestamp) {
const recorded = parseReplayTimestamp(replay.timestamp, {
if (timestamped?.timestamp) {
const recorded = parseReplayTimestamp(timestamped.timestamp, {
now: detectedAt ?? undefined,
});
if (recorded !== null) return recorded;

View File

@ -3,6 +3,7 @@
* Same-type events within a merge window collapse into one, keeping the
* highest-confidence version; events below a confidence floor are dropped.
*/
import { BATTLE_LOG_EVENT_TYPE } from "../detectors/battle-log/index";
import {
OBJECTIVE_EVENT_TYPE,
sameObjectiveData,
@ -45,6 +46,7 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
sameEventDataByType: {
[SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch,
[SCOREBOARD_REPLAY_EVENT_TYPE]: sameScoreboardMatch,
[BATTLE_LOG_EVENT_TYPE]: sameScoreboardMatch,
[OBJECTIVE_EVENT_TYPE]: sameObjectiveData,
},
minConfidence: 0.6,

View File

@ -56,6 +56,7 @@ interface ExpectedScoreboard {
event:
| "Scoreboard"
| "ScoreboardReplay"
| "BattleLog"
| "ScoreboardOwn"
| "Death"
| "MapStart"
@ -68,7 +69,7 @@ interface ExpectedScoreboard {
stage?: StageId;
/** informational for the human corrector; tests compare `stage` */
stageLabel?: string;
/** ScoreboardReplay only */
/** ScoreboardReplay + BattleLog only */
timestamp?: string;
/** ScoreboardReplay only */
replayCode?: string;

View File

@ -0,0 +1,231 @@
/**
* Golden-file suite for the BattleLogDetector over every fixture in
* battle-log/, mirroring tests/suites/scoreboard-replay.ts (the battle log
* shows the same data sans the replay code), plus cross-negative sweeps:
* the battle-log gate must stay quiet on both lookalike results screens.
*/
import assert from "node:assert/strict";
import { loadOpenCV } from "../core/cv";
import {
BATTLE_LOG_EVENT_TYPE,
createBattleLogDetector,
} from "../core/detectors/battle-log/index";
import type {
ScoreboardPlayer,
ScoreboardRowDebug,
} from "../core/detectors/scoreboard/index";
import {
type Fixture,
isFieldSkipped,
loadFixtures,
runDetectorOnFixture,
} from "../node/fixtures";
import { loadScoreboardResources } from "../node/resources";
import test from "./node-test-compat";
await loadOpenCV();
const detector = createBattleLogDetector(await loadScoreboardResources());
const fixtures = loadFixtures("battle-log");
test("battle-log fixtures exist", () => {
assert.ok(fixtures.length > 0, "no fixtures found under battle-log/");
});
for (const fixture of fixtures) {
test(`battle-log/${fixture.name}`, async (t) => {
const { gate, events } = await runDetectorOnFixture(detector, fixture);
const expectPositive = fixture.expected.event === BATTLE_LOG_EVENT_TYPE;
await t.test("gate", () => {
assert.equal(
gate.pass,
expectPositive,
`gate ${gate.pass ? "fired" : "did not fire"} (score=${gate.score.toFixed(3)}), expected ${expectPositive ? "fire" : "no fire"}`,
);
});
if (!expectPositive) return;
const event = events[0];
assert.ok(event, "gate passed but no event parsed");
const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[];
const expected = fixture.expected.data ?? {};
await t.test("matchScores", { skip: skip(fixture, "matchScores") }, () => {
const dbg = event.debug?.matchScore as
| { left?: { reading?: string }; right?: { reading?: string } }
| undefined;
assert.deepEqual(
event.data.matchScores,
expected.matchScores,
`matchScores mismatch (readings: "${dbg?.left?.reading}" / "${dbg?.right?.reading}")`,
);
});
await t.test(
"header",
{ skip: expected.mode === undefined || skip(fixture, "header") },
() => {
const dbg = event.debug?.header as
| { topReading?: string; bottomReading?: string }
| undefined;
assert.deepEqual(
{
lobby: event.data.lobby,
mode: event.data.mode,
stage: event.data.stage,
},
{
lobby: expected.lobby ?? null,
mode: expected.mode ?? null,
stage: expected.stage ?? null,
},
`header mismatch (readings: "${dbg?.topReading}" / "${dbg?.bottomReading}")`,
);
},
);
await t.test(
"timestamp",
{
skip: expected.timestamp === undefined || skip(fixture, "timestamp"),
},
() => {
const dbg = event.debug?.header as { topReading?: string } | undefined;
assert.equal(
event.data.timestamp,
expected.timestamp,
`timestamp mismatch (reading: "${dbg?.topReading}")`,
);
},
);
await t.test(
"povIndex",
{ skip: expected.povIndex === undefined || skip(fixture, "povIndex") },
() => {
const fractions = rows.map((r) => r.povFraction.toFixed(3)).join(",");
assert.equal(
event.data.povIndex,
expected.povIndex,
`povIndex mismatch (yellow fractions=${fractions})`,
);
},
);
const players = expected.players ?? [];
await t.test(
"player count",
{ skip: expected.players === undefined || skip(fixture, "players") },
() => {
assert.equal(event.data.players.length, players.length);
},
);
for (const [i, want] of players.entries()) {
const got: ScoreboardPlayer | undefined = event.data.players[i];
const dbg = rows[i];
assert.ok(got, `row ${i} missing from parse`);
await t.test(
`row ${i} weapon`,
{
skip:
want.weaponId === undefined ||
skip(fixture, `players.${i}.weaponId`),
},
() => {
const top = dbg?.weapon?.top
.map((c) => `${c.id}:${c.score.toFixed(3)}`)
.join(" ");
assert.equal(
got.weaponId,
want.weaponId,
`weapon mismatch (candidates: ${top})`,
);
},
);
await t.test(
`row ${i} name`,
{
skip: want.name === undefined || skip(fixture, `players.${i}.name`),
},
() => {
assert.equal(
got.name,
want.name,
`name mismatch (min glyph score=${dbg?.nameScore.toFixed(3)})`,
);
},
);
await t.test(
`row ${i} paint`,
{
skip: want.paint === undefined || skip(fixture, `players.${i}.paint`),
},
() => {
assert.equal(
got.paint,
want.paint,
`paint mismatch (score=${dbg?.paintScore.toFixed(3)})`,
);
},
);
await t.test(
`row ${i} stats`,
{
skip: want.ka === undefined || skip(fixture, `players.${i}.stats`),
},
() => {
const scores = dbg?.statScores.map((s) => s.toFixed(3)).join(",");
assert.deepEqual(
{ ka: got.ka, d: got.d, s: got.s },
{ ka: want.ka, d: want.d ?? null, s: want.s ?? null },
`stat mismatch (scores=${scores})`,
);
},
);
}
});
}
// The three scoreboard-shaped screens must not trigger each other's
// detectors; the mirror sweeps live in scoreboard.test.ts and
// suites/scoreboard-replay.ts.
for (const fixture of [
...loadFixtures("scoreboard").filter(
(f) => f.expected.event === "Scoreboard",
),
...loadFixtures("scoreboard-replay").filter(
(f) => f.expected.event === "ScoreboardReplay",
),
]) {
test(`battle-log gate stays quiet on ${fixture.name}`, async () => {
const { gate } = await runDetectorOnFixture(detector, fixture);
assert.equal(
gate.pass,
false,
`battle-log gate fired (score=${gate.score.toFixed(3)})`,
);
});
}
// Shared negatives (tests/fixtures/negative/): frames no detector may fire on.
for (const fixture of loadFixtures("negative")) {
test(`battle-log gate stays quiet on negative/${fixture.name}`, async () => {
const { gate } = await runDetectorOnFixture(detector, fixture);
assert.equal(
gate.pass,
false,
`battle-log gate fired (score=${gate.score.toFixed(3)})`,
);
});
}
function skip(fixture: Fixture, field: string): boolean | string {
return isFieldSkipped(fixture, field) ? "skipFields" : false;
}

View File

@ -0,0 +1,88 @@
{
"event": "BattleLog",
"data": {
"lobby": "PRIVATE",
"mode": "SZ",
"stage": 10,
"timestamp": "5/8/2026 19:16",
"matchScores": [
100,
0
],
"povIndex": 3,
"players": [
{
"name": "BDAYBOY",
"paint": 1955,
"ka": 13,
"d": 5,
"s": 8,
"weaponId": 60
},
{
"name": "∴Columbina",
"paint": 1430,
"ka": 8,
"d": 7,
"s": 6,
"weaponId": 3020
},
{
"name": "xtsy",
"paint": 956,
"ka": 9,
"d": 4,
"s": 4,
"weaponId": 50
},
{
"name": "Sendou",
"paint": 917,
"ka": 6,
"d": 9,
"s": 3,
"weaponId": 211
},
{
"name": "Bronx Thug",
"paint": 1512,
"ka": 13,
"d": 7,
"s": 7,
"weaponId": 3011
},
{
"name": "mr 2how",
"paint": 1380,
"ka": 11,
"d": 9,
"s": 7,
"weaponId": 8002
},
{
"name": "Katfesh",
"paint": 1473,
"ka": 3,
"d": 3,
"s": 7,
"weaponId": 2071
},
{
"name": "-Artemis->",
"paint": 1031,
"ka": 10,
"d": 8,
"s": 5,
"weaponId": 1002
}
],
"stageLabel": "MakoMart"
},
"options": {
"skipFields": [
"players.0.name",
"players.1.name"
],
"notes": "720p stream capture with horizontal banding. players.0.name (BDAYBOY) reads BOAuBOY and players.1.name (∴Columbina) reads ∴Columhina — the banding cuts glyph cores at this size. Weapons row order: N-ZAP '85, Sloshing Machine, .52 Gal, Custom Blaster, Tri-Slosher Nouveau, Stickerz Splatana Stamper, Snipewriter 5B, Carbon Roller ANG-L."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

View File

@ -0,0 +1,87 @@
{
"event": "BattleLog",
"data": {
"lobby": "X",
"mode": "CB",
"stage": 23,
"timestamp": "3/8/2026 18:11",
"matchScores": [
30,
23
],
"povIndex": 5,
"players": [
{
"name": "Pony",
"paint": 1680,
"ka": 11,
"d": 6,
"s": 7,
"weaponId": 60
},
{
"name": "Charms",
"paint": 1367,
"ka": 9,
"d": 10,
"s": 6,
"weaponId": 0
},
{
"name": "Mesh Cap",
"paint": 857,
"ka": 4,
"d": 6,
"s": 2,
"weaponId": 2030
},
{
"name": "Retro",
"paint": 1034,
"ka": 9,
"d": 8,
"s": 4,
"weaponId": 251
},
{
"name": "Invisifloats",
"paint": 1762,
"ka": 14,
"d": 6,
"s": 5,
"weaponId": 60
},
{
"name": "Beanie",
"paint": 1194,
"ka": 11,
"d": 8,
"s": 2,
"weaponId": 252
},
{
"name": "Headphones",
"paint": 859,
"ka": 4,
"d": 5,
"s": 1,
"weaponId": 2030
},
{
"name": "Slugger",
"paint": 1023,
"ka": 11,
"d": 6,
"s": 3,
"weaponId": 1010
}
],
"stageLabel": "Lemuria Hub"
},
"options": {
"skipFields": [
"players.4.name"
],
"notes": "players.4.name (Invisifloats) reads lnvisifloats — I and l are pixel-identical bars in this face. Weapons row order: N-ZAP '85, Sploosh-o-matic, E-liter 4K, Rapid Blaster Pro Deco, N-ZAP '85, Rapid Blaster Pro WNT-R, E-liter 4K, Splat Roller (Big Bubbler special confirms the near-tied Flingza off)."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

View File

@ -4,6 +4,7 @@ import type {
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { BattleLogData } from "../core/detectors/battle-log/index";
import type { DeathData } from "../core/detectors/death/index";
import type {
MinimapData,
@ -108,6 +109,19 @@ function replayScoreboard(
return { type: "ScoreboardReplay", t, confidence: 0.9, data };
}
function battleLogScoreboard(
t: number,
{ timestamp = null as string | null } = {},
): DetectedEvent & { detectedAt?: number } {
const base = scoreboard(t).data as ScoreboardData;
const data: BattleLogData = {
...base,
timestamp,
matchScores: [100, 0],
};
return { type: "BattleLog", t, confidence: 0.9, data };
}
function teammate(weaponId: MainWeaponId | null, i: number): MinimapTeammate {
return {
slot: SPECTATOR_SLOTS[i]!,
@ -485,6 +499,16 @@ test("a replay scoreboard supplies replay code, set score and recording time", (
assert.equal(match.playedAt, new Date(2025, 11, 25, 21, 30).getTime());
});
test("a battle-log scoreboard closes a match and supplies the recording time without a replay code", () => {
const event = battleLogScoreboard(300, { timestamp: "25.12.2025 21:30" });
event.detectedAt = Date.UTC(2025, 11, 26, 12, 0);
const built = buildScannerMatches([event]);
const match = built[0]!.match;
assert.equal(match.replayCode, null);
assert.deepEqual(match.matchScores, [100, 0]);
assert.equal(match.playedAt, new Date(2025, 11, 25, 21, 30).getTime());
});
test("without a replay timestamp, playedAt falls back to the scoreboard's detection time", () => {
const event = scoreboard(300) as DetectedEvent & { detectedAt?: number };
event.detectedAt = 1_700_000_000_000;

View File

@ -163,12 +163,16 @@ for (const fixture of fixtures) {
});
}
// Mirror of the cross-negative sweep in suites/scoreboard-replay.ts: the live
// gate must stay quiet on every replay-browser positive.
for (const fixture of loadFixtures("scoreboard-replay").filter(
(f) => f.expected.event === "ScoreboardReplay",
)) {
test(`scoreboard gate stays quiet on scoreboard-replay/${fixture.name}`, async () => {
// Mirror of the cross-negative sweeps in suites/scoreboard-replay.ts and
// battle-log.test.ts: the live gate must stay quiet on every replay-browser
// and battle-log positive.
for (const fixture of [
...loadFixtures("scoreboard-replay").filter(
(f) => f.expected.event === "ScoreboardReplay",
),
...loadFixtures("battle-log").filter((f) => f.expected.event === "BattleLog"),
]) {
test(`scoreboard gate stays quiet on ${fixture.name}`, async () => {
const { gate } = await runDetectorOnFixture(detector, fixture);
assert.equal(
gate.pass,

View File

@ -226,12 +226,17 @@ export async function runScoreboardReplaySuite(
});
}
// The two scoreboard screens must not trigger each other's detectors; the
// mirror sweep (live gate over replay fixtures) lives in scoreboard.test.ts.
for (const fixture of mine(
loadFixtures("scoreboard").filter((f) => f.expected.event === "Scoreboard"),
)) {
test(`replay gate stays quiet on scoreboard/${fixture.name}`, async () => {
// The scoreboard-shaped screens must not trigger each other's detectors;
// the mirror sweeps live in scoreboard.test.ts and battle-log.test.ts.
for (const fixture of mine([
...loadFixtures("scoreboard").filter(
(f) => f.expected.event === "Scoreboard",
),
...loadFixtures("battle-log").filter(
(f) => f.expected.event === "BattleLog",
),
])) {
test(`replay gate stays quiet on ${fixture.name}`, async () => {
const { gate } = await runDetectorOnFixture(detector, fixture);
assert.equal(
gate.pass,

View File

@ -1,9 +1,10 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Draw all scoreboard ROIs on a (normalized) frame for visual calibration.
* Usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-replay]
* Usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-replay|battle-log]
*/
import { loadOpenCV, type Mat } from "../../app/features/scanner/core/cv";
import * as bl from "../../app/features/scanner/core/detectors/battle-log/rois";
import * as death from "../../app/features/scanner/core/detectors/death/rois";
import * as mapStart from "../../app/features/scanner/core/detectors/map-start/rois";
import * as minimap from "../../app/features/scanner/core/detectors/minimap/rois";
@ -21,7 +22,7 @@ const [imagePath, outPath = "roi-overlay.png", detector = "scoreboard"] =
process.argv.slice(2);
if (!imagePath) {
console.error(
"usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-replay|death|map-start|minimap]",
"usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-replay|battle-log|death|map-start|minimap]",
);
process.exit(1);
}
@ -71,6 +72,27 @@ if (detector === "scoreboard") {
rect(frame, replay.HEADER_TOP_BAND, [0, 255, 0]);
rect(frame, replay.HEADER_BOTTOM_BAND, [0, 255, 0]);
rect(frame, replay.REPLAY_CODE_ROI, [0, 255, 0]);
} else if (detector === "battle-log") {
for (const dy of bl.PANEL_DYS) {
for (const base of bl.ROW_CENTERS) {
const cy = base + dy;
rect(frame, bl.weaponRoi(cy), [255, 0, 0]);
rect(frame, bl.nameRoi(cy), [0, 255, 0]);
rect(frame, bl.paintRoi(cy), [0, 128, 255]);
rect(frame, bl.paintSuffixRoi(cy), [0, 255, 255]);
for (const i of [0, 1, 2] as const)
rect(frame, bl.statRoi(cy, i), [255, 0, 255]);
rect(frame, bl.gateDarkProbe(cy), [255, 255, 0]);
rect(frame, bl.povArrowRoi(cy), [255, 128, 0]);
rect(frame, bl.specialIconRoi(cy), [255, 0, 0]);
}
rect(frame, bl.teamScoreRoi(dy), [0, 128, 255]);
rect(frame, bl.resultTagRoi(dy), [255, 128, 0]);
}
for (const roi of bl.MATCH_SCORE_ROIS) rect(frame, roi, [0, 128, 255]);
for (const roi of bl.GATE_COLOR_PROBES) rect(frame, roi, [255, 255, 0]);
rect(frame, bl.HEADER_TOP_BAND, [0, 255, 0]);
rect(frame, bl.HEADER_BOTTOM_BAND, [0, 255, 0]);
} else if (detector === "death") {
rect(frame, death.SPLAT_LINE1_ROI, [0, 255, 0]);
rect(frame, death.WEAPON_LINE_ROI, [255, 0, 0]);

View File

@ -8,6 +8,7 @@
* Usage: pnpm scanner:report
*/
import { loadOpenCV } from "../../app/features/scanner/core/cv";
import { createBattleLogDetector } from "../../app/features/scanner/core/detectors/battle-log/index";
import {
createDeathDetector,
type DeathData,
@ -59,6 +60,12 @@ const configs: Config[] = [
fixturesDir: "scoreboard-replay",
event: "ScoreboardReplay",
},
{
label: "battle-log",
detector: createBattleLogDetector(resources),
fixturesDir: "battle-log",
event: "BattleLog",
},
];
interface Tally {
@ -190,8 +197,10 @@ for (const config of configs) {
console.info(`\n=== ${config.label} (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`header ${pct(tally.header)}`);
if (config.event === "ScoreboardReplay") {
if (tally.timestamp.total > 0) {
console.info(`timestamp ${pct(tally.timestamp)}`);
}
if (tally.replayCode.total > 0) {
console.info(`replayCode ${pct(tally.replayCode)}`);
}
console.info(`matchScores ${pct(tally.matchScores)}`);