Scanner add scripts for developing

This commit is contained in:
Kalle
2026-08-09 20:37:48 +03:00
parent 41070f4594
commit 502385ce01
6 changed files with 1257 additions and 1 deletions

View File

@@ -20,6 +20,8 @@ pnpm test:scanner # golden-file suite over tests/fixtures/
pnpm scanner:report # accuracy table + name character error rate across fixtures
pnpm scanner:fixtures [name-substring] # run detectors over matching fixtures, verbose
pnpm scanner:replay <dir> <startT> <fps> # replay ffmpeg-extracted frames through the scheduler+detectors
pnpm scanner:scan-vod <video> # VoD-tab scan as a CLI (ffmpeg): video in, events CSV out
pnpm scanner:status-audit <events.csv> # diff the CSV's timeline vs scoreboard D/S, rank fixture candidates
pnpm scanner:bootstrap-atlas # harvest labeled fixture crops into the glyph atlases
pnpm scanner:build-glyph-atlas # add the font-rendered charset (fonts required, see below)
pnpm scanner:build-localized-entries # regen localized closed sets from ../splat3

View File

@@ -318,7 +318,7 @@ function eventCells(event: CsvEvent): Cell[] {
}
}
function eventsToCsv(events: CsvEvent[]): string {
export function eventsToCsv(events: CsvEvent[]): string {
const lines = [HEADER.join(",")];
for (const event of events)
lines.push(eventCells(event).map(csvCell).join(","));

View File

@@ -4,6 +4,7 @@ const config = {
type: true,
},
tags: ["-lintignore"],
ignoreBinaries: ["ffmpeg", "ffprobe"],
entry: [
"app/features/*/routes/**/*.{ts,tsx}",
"migrations/**/*.ts",

View File

@@ -32,6 +32,8 @@
"test:unit:browser:ui": "cross-env VITE_SITE_DOMAIN=http://localhost:5173 vitest --silent=passed-only",
"test:scanner": "vitest run --project scanner",
"scanner:report": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/report.ts",
"scanner:scan-vod": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/scan-vod.ts",
"scanner:status-audit": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/status-audit.ts",
"scanner:fixtures": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/run-fixtures.ts",
"scanner:replay": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/replay-frames.ts",
"scanner:bootstrap-atlas": "vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/bootstrap-atlas-from-fixture.ts",

321
scripts/scanner/scan-vod.ts Normal file
View File

@@ -0,0 +1,321 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* CLI equivalent of the VoD tab: scan a video file with the full detector
* registry and write the same events CSV the tab's Export menu downloads.
* ffmpeg decodes the video to raw RGBA frames piped through the
* DetectorScheduler + detectors, and every parse event goes through a
* TimelineBuilder with the tab's default merge/confidence options — so the
* CSV matches a browser scan of the same footage (minus the calm-stretch
* keyframe skimming, which only affects speed, not results).
*
* Requires ffmpeg (and ffprobe for the progress percentage) on PATH.
*
* Usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry]
* --telemetry prints the VoD tab's ?telemetry=true scan counters after the
* run (per-detector gate/parse time, scheduling savings).
*/
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
import { basename } from "node:path";
import {
type CsvEvent,
eventsToCsv,
} from "../../app/features/scanner/components/events-csv";
import { loadOpenCV } from "../../app/features/scanner/core/cv";
import { MAP_START_EVENT_TYPE } from "../../app/features/scanner/core/detectors/map-start/index";
import {
createAllDetectors,
SCOREBOARD_EVENT_TYPES,
} from "../../app/features/scanner/core/detectors/registry";
import { DetectorScheduler } from "../../app/features/scanner/core/detectors/scheduler";
import {
createScanTelemetry,
detectorTelemetry,
} from "../../app/features/scanner/core/detectors/telemetry";
import { normalizeFrame, toMat } from "../../app/features/scanner/core/image";
import { TimelineBuilder } from "../../app/features/scanner/core/timeline/index";
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
const FRAME_WIDTH = 1920;
const FRAME_HEIGHT = 1080;
const FRAME_BYTES = FRAME_WIDTH * FRAME_HEIGHT * 4;
/** Slightly over the scheduler's densest cadence (refineIntervalS 0.15s). */
const DEFAULT_FPS = 8;
const PROGRESS_INTERVAL_SECONDS = 60;
const options = parseArgs(process.argv.slice(2));
if (!options) {
console.error(
"usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry]",
);
process.exit(1);
}
const { videoPath, fps, start, duration, outPath, collectTelemetry } = options;
await loadOpenCV();
const detectors = createAllDetectors(await loadScoreboardResources());
const scheduler = new DetectorScheduler(detectors, {
matchOpeningTypes: [MAP_START_EVENT_TYPE],
matchClosingTypes: SCOREBOARD_EVENT_TYPES,
});
scheduler.reset(start);
const timeline = new TimelineBuilder();
const telemetry = collectTelemetry ? createScanTelemetry() : null;
const totalSeconds = await probeDurationSeconds(videoPath);
const scanEnd =
duration !== undefined
? start + duration
: totalSeconds !== null
? totalSeconds
: null;
const ffmpeg = spawn(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
...(start > 0 ? ["-ss", String(start)] : []),
"-i",
videoPath,
...(duration !== undefined ? ["-t", String(duration)] : []),
"-vf",
`fps=${fps},scale=${FRAME_WIDTH}:${FRAME_HEIGHT}`,
"-f",
"rawvideo",
"-pix_fmt",
"rgba",
"pipe:1",
],
{ stdio: ["ignore", "pipe", "inherit"] },
);
const frameBuffer = Buffer.alloc(FRAME_BYTES);
let frameFill = 0;
let frameIndex = 0;
let framesAnalyzed = 0;
let nextProgressT = start;
const startedAt = Date.now();
for await (const chunk of ffmpeg.stdout) {
let offset = 0;
while (offset < chunk.length) {
const take = Math.min(FRAME_BYTES - frameFill, chunk.length - offset);
chunk.copy(frameBuffer, frameFill, offset, offset + take);
frameFill += take;
offset += take;
if (frameFill < FRAME_BYTES) continue;
frameFill = 0;
processFrame(start + frameIndex / fps);
frameIndex++;
}
}
const exitCode = await new Promise<number | null>((resolve) =>
ffmpeg.on("close", resolve),
);
if (exitCode !== 0) {
console.error(`ffmpeg exited with code ${exitCode}`);
process.exit(1);
}
const csv = eventsToCsv(timeline.events as CsvEvent[]);
writeFileSync(outPath, csv);
printSummary();
function parseArgs(argv: string[]): {
videoPath: string;
fps: number;
start: number;
duration: number | undefined;
outPath: string;
collectTelemetry: boolean;
} | null {
let videoPath: string | undefined;
let fps = DEFAULT_FPS;
let start = 0;
let duration: number | undefined;
let outPath: string | undefined;
let collectTelemetry = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--fps") fps = Number(argv[++i]);
else if (arg === "--start") start = Number(argv[++i]);
else if (arg === "--duration") duration = Number(argv[++i]);
else if (arg === "--out") outPath = argv[++i];
else if (arg === "--telemetry") collectTelemetry = true;
else if (!arg.startsWith("--") && videoPath === undefined) videoPath = arg;
else return null;
}
if (
videoPath === undefined ||
Number.isNaN(fps) ||
fps <= 0 ||
Number.isNaN(start) ||
(duration !== undefined && Number.isNaN(duration))
) {
return null;
}
return {
videoPath,
fps,
start,
duration,
outPath:
outPath ?? `${basename(videoPath).replace(/\.[^.]+$/, "")}-events.csv`,
collectTelemetry,
};
}
function probeDurationSeconds(path: string): Promise<number | null> {
return new Promise((resolve) => {
const ffprobe = spawn(
"ffprobe",
[
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
path,
],
{ stdio: ["ignore", "pipe", "ignore"] },
);
let output = "";
ffprobe.stdout.on("data", (data) => {
output += data;
});
ffprobe.on("close", () => {
const seconds = Number.parseFloat(output.trim());
resolve(Number.isFinite(seconds) ? seconds : null);
});
ffprobe.on("error", () => resolve(null));
});
}
function processFrame(t: number): void {
if (t >= nextProgressT) {
const percent =
scanEnd === null
? ""
: ` (${Math.round(((t - start) / (scanEnd - start)) * 100)}%)`;
const rate = (t - start) / Math.max(0.001, (Date.now() - startedAt) / 1000);
console.error(
`scanning t=${Math.round(t)}s${percent} · ${rate.toFixed(1)}x realtime · ${timeline.events.length} events`,
);
nextProgressT += PROGRESS_INTERVAL_SECONDS;
}
if (telemetry) {
telemetry.decodedFrames++;
telemetry.activeVideoS += 1 / fps;
}
const due = scheduler.dueDetectors(t);
if (due.length === 0) return;
framesAnalyzed++;
if (telemetry) telemetry.analyzedFrames++;
const src = toMat({
width: FRAME_WIDTH,
height: FRAME_HEIGHT,
data: new Uint8ClampedArray(
frameBuffer.buffer,
frameBuffer.byteOffset,
FRAME_BYTES,
),
});
const frame = normalizeFrame(src);
src.delete();
for (const detector of detectors) {
if (!due.includes(detector.id)) continue;
const counters = telemetry
? detectorTelemetry(telemetry, detector.id)
: null;
const gateStart = counters ? performance.now() : 0;
const gate = detector.gate(frame);
if (counters) {
counters.checks++;
counters.gateMs += performance.now() - gateStart;
}
scheduler.recordGate(detector.id, t, gate.pass, gate.signature);
if (!gate.pass) continue;
if (counters) counters.gatePasses++;
if (!scheduler.shouldParse(detector.id, t)) {
if (counters) counters.suppressedParses++;
continue;
}
const parseStart = counters ? performance.now() : 0;
const events = detector.parse(frame, t, gate);
if (counters) {
counters.parses++;
counters.parseMs += performance.now() - parseStart;
}
scheduler.recordParse(detector.id, t, events);
for (const event of events) {
const action = timeline.push(event);
if (action.action === "added" || action.action === "replaced") {
console.error(
` event t=${event.t.toFixed(2)} ${event.type} conf=${event.confidence.toFixed(3)}`,
);
}
}
}
frame.delete();
}
function printSummary(): void {
const counts = new Map<string, number>();
for (const event of timeline.events) {
counts.set(event.type, (counts.get(event.type) ?? 0) + 1);
}
const countText =
[...counts.entries()].map(([type, n]) => `${type} ${n}`).join(", ") ||
"none";
console.error(
`analyzed ${framesAnalyzed}/${frameIndex} decoded frames in ${Math.round((Date.now() - startedAt) / 1000)}s`,
);
console.error(`timeline events: ${timeline.events.length} (${countText})`);
console.error(`wrote ${outPath}`);
if (telemetry) printTelemetry();
console.error(`next: pnpm scanner:status-audit ${outPath}`);
}
/** The VoD tab's ?telemetry=true panel, as an aligned stderr table. */
function printTelemetry(): void {
if (!telemetry) return;
telemetry.wallMs = Date.now() - startedAt;
console.error(
`telemetry · analyzed ${telemetry.analyzedFrames}/${telemetry.decodedFrames} decoded frames · ${(telemetry.wallMs / 1000).toFixed(1)}s wall · ${telemetry.activeVideoS.toFixed(0)}s video covered (dense, no skim in CLI)`,
);
const header = [
"detector",
"checks",
"gate pass",
"gate ms",
"parses",
"parse ms",
"suppressed",
];
const rows = Object.entries(telemetry.detectors)
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, d]) => [
id,
String(d.checks),
String(d.gatePasses),
String(Math.round(d.gateMs)),
String(d.parses),
String(Math.round(d.parseMs)),
String(d.suppressedParses),
]);
const widths = header.map((h, i) =>
Math.max(h.length, ...rows.map((row) => row[i]!.length)),
);
const line = (cells: string[]) =>
cells
.map((cell, i) =>
i === 0 ? cell.padEnd(widths[i]!) : cell.padStart(widths[i]!),
)
.join(" ");
console.error(line(header));
for (const row of rows) console.error(line(row));
}

View File

@@ -0,0 +1,930 @@
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
/**
* Audit death/special detection against scoreboard truth, from an events CSV
* downloaded from the scanner UI (Live/VoD tab → Download CSV). The CSV rows
* are parsed back into DetectedEvents, run through the real match builder,
* and rendered into the same status spans the timeline would draw — then
* each player's span counts are diffed against the scoreboard's D/S numbers,
* which are near-always correct. A special span ending in a death (or held
* at the final whistle) is a legit non-use and does not count toward S.
*
* The CSV is lossy (no ink colors, only top-1 strip-weapon candidates), so
* cast-footage side orientation and slot→row assignment can degrade to
* their fallbacks; the output flags when a mismatch looks like a
* slot-mapping artifact rather than a detection error.
*
* Output is structured for triage: every discrepancy lists the exact
* read timestamps most likely to yield a new failing fixture.
*
* Usage: pnpm scanner:status-audit <events.csv> [--all]
*/
import { readFileSync } from "node:fs";
import { statusSpans } from "../../app/components/PlayerStatusTimeline";
import { formatTime } from "../../app/features/scanner/components/format";
import {
lobbyLabel,
mainWeaponLabel,
modeLabel,
stageLabel,
} from "../../app/features/scanner/components/labels";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../../app/features/scanner/core/detectors/death/index";
import { ALL_WEAPON_ENTRIES } from "../../app/features/scanner/core/detectors/death/weapon-names";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../../app/features/scanner/core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
type MinimapEnemy,
type MinimapTeammate,
} from "../../app/features/scanner/core/detectors/minimap/index";
import type { CardSlot } from "../../app/features/scanner/core/detectors/minimap/rois";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../../app/features/scanner/core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
type PlayerStatusFlags,
type PlayerStatusLayout,
} from "../../app/features/scanner/core/detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../../app/features/scanner/core/detectors/objective/strip-weapons";
import {
SCOREBOARD_EVENT_TYPE,
type ScoreboardData,
type ScoreboardPlayer,
} from "../../app/features/scanner/core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../../app/features/scanner/core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../../app/features/scanner/core/detectors/scoreboard-battle-log-replay/index";
import type { DetectedEvent } from "../../app/features/scanner/core/detectors/types";
import {
type BuiltMatch,
buildScannerMatches,
} from "../../app/features/scanner/core/match-builder";
import type {
ScannerMatch,
ScannerMatchPlayerStatusSample,
} from "../../app/features/scanner/core/scanner-match";
import type { ScannerLobby } from "../../app/features/scanner/scanner-types";
import { modesShort } from "../../app/modules/in-game-lists/modes";
import { stageIds } from "../../app/modules/in-game-lists/stage-ids";
import type {
AbilityWithUnknown,
MainWeaponId,
ModeShort,
StageId,
} from "../../app/modules/in-game-lists/types";
import { mainWeaponIds } from "../../app/modules/in-game-lists/weapon-ids";
/** Mirrors PlayerStatusTimeline's MAX_BRIDGE_SECONDS: longer sample gaps render as unobserved. */
const OBSERVATION_GAP_SECONDS = 15;
/** Mirrors match-builder's DEAD_RUN_MIN_SECONDS: no true splat is shorter. */
const MIN_DEAD_SECONDS = 3.5;
const SPAN_EPSILON_SECONDS = 0.001;
const SCOREBOARD_TYPES = new Set([
SCOREBOARD_EVENT_TYPE,
SCOREBOARD_BATTLE_LOG_EVENT_TYPE,
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
]);
const CARD_SLOTS = new Set<CardSlot>(["up", "left", "right", "self", "down"]);
const MODE_BY_LABEL = new Map<string, ModeShort>(
modesShort.map((mode) => [modeLabel(mode) ?? mode, mode]),
);
const STAGE_BY_LABEL = new Map<string, StageId>(
stageIds.map((id) => [stageLabel(id) ?? String(id), id]),
);
const MAIN_WEAPON_BY_LABEL = new Map<string, MainWeaponId>(
mainWeaponIds.map((id) => [mainWeaponLabel(id) ?? String(id), id]),
);
const DEATH_WEAPON_BY_LABEL = new Map(
ALL_WEAPON_ENTRIES.map((entry) => [entry.name, entry]),
);
const LOBBY_BY_LABEL = new Map<string, ScannerLobby>(
(["X", "SERIES", "OPEN", "PRIVATE"] as const).map((lobby) => [
lobbyLabel(lobby) ?? lobby,
lobby,
]),
);
const args = process.argv.slice(2);
const csvPath = args.find((arg) => !arg.startsWith("--"));
const showAllSpans = args.includes("--all");
if (!csvPath) {
console.error("Usage: pnpm scanner:status-audit <events.csv> [--all]");
process.exit(1);
}
const rows = parseCsv(readFileSync(csvPath, "utf8"));
const header = rows[0] ?? [];
const col = (name: string) => header.indexOf(name);
const columns = {
type: col("type"),
t: col("t_seconds"),
confidence: col("confidence"),
lobby: col("lobby"),
mode: col("mode"),
stage: col("stage"),
winnerScore: col("winner_score"),
loserScore: col("loser_score"),
pov: col("pov"),
weapon: col("weapon"),
name: col("name"),
abilities: col("abilities"),
players: col("players"),
replayCode: col("replay_code"),
replayTimestamp: col("replay_timestamp"),
};
if (columns.type === -1 || columns.t === -1 || columns.players === -1) {
console.error(
`Not an events CSV (missing type/t_seconds/players columns): ${csvPath}`,
);
process.exit(1);
}
const events: DetectedEvent[] = [];
const skippedTypes = new Map<string, number>();
const parseFailures: string[] = [];
for (const row of rows.slice(1)) {
if (row.length === 0 || (row.length === 1 && row[0] === "")) continue;
const type = row[columns.type] ?? "";
try {
const event = eventFromRow(type, row);
if (event) events.push(event);
else skippedTypes.set(type, (skippedTypes.get(type) ?? 0) + 1);
} catch (error) {
parseFailures.push(
`t=${row[columns.t]} ${type}: ${error instanceof Error ? error.message : error}`,
);
}
}
const built = buildScannerMatches(events);
const candidates: FixtureCandidate[] = [];
printHeader();
for (const [index, builtMatch] of built.entries()) {
printMatch(index, builtMatch);
}
printCandidates();
printGuidance();
// ---------------------------------------------------------------------------
// CSV → DetectedEvent reconstruction
function parseCsv(text: string): string[][] {
const result: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i++;
} else if (ch === '"') {
quoted = false;
} else {
cell += ch;
}
} else if (ch === '"') {
quoted = true;
} else if (ch === ",") {
row.push(cell);
cell = "";
} else if (ch === "\n" || ch === "\r") {
if (ch === "\r" && text[i + 1] === "\n") i++;
row.push(cell);
result.push(row);
row = [];
cell = "";
} else {
cell += ch;
}
}
if (cell !== "" || row.length > 0) {
row.push(cell);
result.push(row);
}
return result;
}
function eventFromRow(type: string, row: string[]): DetectedEvent | null {
const base = {
t: Number(row[columns.t]),
confidence: Number(row[columns.confidence] || 0),
};
if (Number.isNaN(base.t)) throw new Error("unparseable t_seconds");
const players = row[columns.players] ?? "";
if (type === PLAYER_STATUS_EVENT_TYPE) {
return { type, ...base, data: parsePlayerStatusCell(players) };
}
if (type === OBJECTIVE_EVENT_TYPE) {
return { type, ...base, data: parseObjectiveCell(players) };
}
if (type === STRIP_WEAPONS_EVENT_TYPE) {
return { type, ...base, data: parseStripWeaponsCell(players) };
}
if (type === MINIMAP_EVENT_TYPE) {
return {
type,
...base,
data: parseMinimapCell(players, row[columns.stage] ?? ""),
};
}
if (type === MAP_START_EVENT_TYPE) {
const data: MapStartData = {
mode: MODE_BY_LABEL.get(row[columns.mode] ?? "") ?? null,
stage: STAGE_BY_LABEL.get(row[columns.stage] ?? "") ?? null,
};
return { type, ...base, data };
}
if (type === DEATH_EVENT_TYPE) {
return { type, ...base, data: parseDeathRow(row) };
}
if (SCOREBOARD_TYPES.has(type)) {
return { type, ...base, data: parseScoreboardRow(row) };
}
return null;
}
function parseClock(m: string, s: string): number {
return Number(m) * 60 + Number(s);
}
function parsePlayerStatusCell(cell: string): PlayerStatusData {
const match = cell.match(
/^(?:(\d+):(\d{2}) · )?([·]{4}) vs ([·]{4}) \((pov|cast|cast-mirror)\)$/u,
);
if (!match) throw new Error(`bad PlayerStatus cell: ${cell}`);
const [, m, s, left, right, layout] = match;
const sideFlags = (icons: string) => {
const chars = [...icons];
return {
dead: chars.map((c) => c === "✕") as PlayerStatusFlags,
special: chars.map((c) => c === "★") as PlayerStatusFlags,
};
};
const a = sideFlags(left!);
const b = sideFlags(right!);
return {
time: m === undefined ? null : parseClock(m, s!),
special: [a.special, b.special],
dead: [a.dead, b.dead],
layout: layout as PlayerStatusLayout,
};
}
function parseObjectiveCell(cell: string): ObjectiveData {
const match = cell.match(
/^(?:(\d+):(\d{2}) · )?(\d+|\?)(?: \(\+(\d+)\))?( ctrl)? vs (\d+|\?)(?: \(\+(\d+)\))?( ctrl)?$/u,
);
if (!match) throw new Error(`bad Objective cell: ${cell}`);
const [, m, s, scoreA, penA, ctrlA, scoreB, penB, ctrlB] = match;
const num = (v: string | undefined) =>
v === undefined || v === "?" ? null : Number(v);
return {
mode: "SZ",
time: m === undefined ? null : parseClock(m, s!),
score: [num(scoreA), num(scoreB)],
penalty: [num(penA), num(penB)],
control: [ctrlA !== undefined, ctrlB !== undefined],
teamColor: [null, null],
};
}
function parseStripWeaponsCell(cell: string): StripWeaponsData {
const match = cell.match(
/^(?:(\d+):(\d{2}) · )?(.*) vs (.*) \((pov|cast|cast-mirror)\)$/u,
);
if (!match) throw new Error(`bad StripWeapons cell: ${cell}`);
const [, m, s, left, right, layout] = match;
const side = (text: string) =>
text.split(" | ").map((entry) => {
if (entry === "✕") return null;
if (entry === "?") return [];
const weaponId = MAIN_WEAPON_BY_LABEL.get(entry);
return weaponId === undefined ? [] : [{ weaponId, score: 1 }];
});
return {
time: m === undefined ? null : parseClock(m, s!),
layout: layout as PlayerStatusLayout,
slots: [side(left!), side(right!)],
};
}
function parseAbilityTokens(text: string): (AbilityWithUnknown | null)[] {
if (text === "") return [];
return text
.split("+")
.map((token) => (token === "?" ? null : (token as AbilityWithUnknown)));
}
function parseMinimapCell(cell: string, stageCell: string): MinimapData {
const teammates: MinimapTeammate[] = [];
const enemies: MinimapEnemy[] = [];
for (const entry of cell === "" ? [] : cell.split("; ")) {
const parts = entry.split(" · ");
let specialReady = false;
let dead = false;
if (parts.at(-1) === "special") {
specialReady = true;
parts.pop();
}
if (parts.at(-1) === "splatted") {
dead = true;
parts.pop();
}
if (parts.length < 3) throw new Error(`bad Minimap entry: ${entry}`);
const abilities = parseAbilityTokens(parts.pop()!);
const weaponCell = parts.pop()!;
const weaponId =
weaponCell === "?"
? null
: (MAIN_WEAPON_BY_LABEL.get(weaponCell) ?? null);
const head = parts.join(" · ");
const spaceAt = head.indexOf(" ");
const label = spaceAt === -1 ? head : head.slice(0, spaceAt);
const rawName = spaceAt === -1 ? "" : head.slice(spaceAt + 1);
const name = rawName === "?" || rawName === "" ? null : rawName;
const player = { name, weaponId, abilities, dead, specialReady };
if (CARD_SLOTS.has(label as CardSlot)) {
teammates.push({ slot: label as CardSlot, ...player });
} else if (/^enemy[1-4]$/.test(label)) {
enemies.push(player);
} else {
throw new Error(`bad Minimap slot label: ${label}`);
}
}
return {
stage: STAGE_BY_LABEL.get(stageCell) ?? null,
spectator: enemies.some((enemy) => enemy.name !== null),
teammates,
enemies,
teamColors: [null, null],
};
}
function parseDeathRow(row: string[]): DeathData {
const weaponCell = row[columns.weapon] ?? "";
const entry = DEATH_WEAPON_BY_LABEL.get(weaponCell);
const abilitiesCell = row[columns.abilities] ?? "";
const nameCell = row[columns.name] ?? "";
return {
weaponId:
entry === undefined ? null : (Number(entry.id) as DeathData["weaponId"]),
weaponType: entry?.type ?? null,
abilities:
abilitiesCell === ""
? []
: abilitiesCell
.split(" | ")
.map(
(gearRow) => parseAbilityTokens(gearRow) as AbilityWithUnknown[],
),
name: nameCell === "" ? null : nameCell,
};
}
function parseScoreboardRow(row: string[]): ScoreboardData & {
replayCode?: string | null;
timestamp?: string | null;
} {
const players: ScoreboardPlayer[] = [];
const cell = row[columns.players] ?? "";
for (const entry of cell === "" ? [] : cell.split("; ")) {
const parts = entry.split(" · ");
const stats = parts
.pop()
?.match(/^(\d+|\?)p (\d+|\?)\/(\d+|\?)\/(\d+|\?)$/u);
const weaponCell = parts.pop();
const head = parts.join(" · ");
if (!stats || weaponCell === undefined || !/^[WL] /.test(head)) {
throw new Error(`bad Scoreboard player entry: ${entry}`);
}
const num = (v: string) => (v === "?" ? null : Number(v));
players.push({
name: head.slice(2),
weaponId:
weaponCell === "?" ? null : (Number(weaponCell) as MainWeaponId),
paint: num(stats[1]!),
ka: num(stats[2]!),
d: num(stats[3]!),
s: num(stats[4]!),
});
}
const score = (cellValue: string | undefined) =>
cellValue === undefined || cellValue === "" ? null : Number(cellValue);
const povName = row[columns.pov] ?? "";
const povIndex = players.findIndex((p) => p.name === povName);
const replayCode = row[columns.replayCode] ?? "";
const timestamp = row[columns.replayTimestamp] ?? "";
return {
lobby: LOBBY_BY_LABEL.get(row[columns.lobby] ?? "") ?? null,
mode: MODE_BY_LABEL.get(row[columns.mode] ?? "") ?? null,
stage: STAGE_BY_LABEL.get(row[columns.stage] ?? "") ?? null,
matchScores: [
score(row[columns.winnerScore]),
score(row[columns.loserScore]),
],
players,
povIndex: povName === "" || povIndex === -1 ? null : povIndex,
replayCode: replayCode === "" ? null : replayCode,
timestamp: timestamp === "" ? null : timestamp,
};
}
// ---------------------------------------------------------------------------
// Analysis
interface FixtureCandidate {
score: number;
t: number;
matchIndex: number;
description: string;
reads: number[];
}
interface StatusSpan {
start: number;
end: number;
/** sample timestamps that read the flag true inside the span */
confirmingReads: number[];
/**
* widest the true state could really have held: from the last false read
* before the span to the false read that closed it (the builder's
* flank-to-flank measure); the rendered span bounds where a flank is
* unobserved (series edge or gap-split)
*/
maxPossibleSeconds: number;
}
interface SlotAnalysis {
side: 0 | 1;
slot: number;
deadSpans: StatusSpan[];
specialSpans: StatusSpan[];
specialUses: number;
diedWithSpecial: number;
heldAtEnd: boolean;
unknownSpecialEnds: number;
}
function annotatedSpans(
samples: readonly ScannerMatchPlayerStatusSample[],
flagOf: (sample: ScannerMatchPlayerStatusSample) => boolean,
): StatusSpan[] {
return statusSpans(samples, flagOf).map((span) => {
const confirmingReads = samples
.filter(
(sample) =>
flagOf(sample) &&
sample.t >= span.start - SPAN_EPSILON_SECONDS &&
sample.t <= span.end + SPAN_EPSILON_SECONDS,
)
.map((sample) => sample.t);
const prev = samples.findLast(
(sample) => sample.t < span.start - SPAN_EPSILON_SECONDS,
);
const closedByFalseRead = samples.some(
(sample) =>
!flagOf(sample) &&
Math.abs(sample.t - span.end) <= SPAN_EPSILON_SECONDS,
);
const boundedBefore = prev !== undefined && !flagOf(prev);
return {
start: span.start,
end: span.end,
confirmingReads,
maxPossibleSeconds:
boundedBefore && closedByFalseRead
? span.end - prev.t
: Number.POSITIVE_INFINITY,
};
});
}
function analyzeSlot(
samples: readonly ScannerMatchPlayerStatusSample[],
side: 0 | 1,
slot: number,
): SlotAnalysis {
const deadSpans = annotatedSpans(samples, (s) => s.dead[side][slot]!);
const specialSpans = annotatedSpans(samples, (s) => s.special[side][slot]!);
let specialUses = 0;
let diedWithSpecial = 0;
let unknownSpecialEnds = 0;
for (let i = 1; i < samples.length; i++) {
const prev = samples[i - 1]!;
const cur = samples[i]!;
if (!prev.special[side][slot] || cur.special[side][slot]) continue;
if (cur.t - prev.t > OBSERVATION_GAP_SECONDS) unknownSpecialEnds++;
else if (cur.dead[side][slot]) diedWithSpecial++;
else specialUses++;
}
const last = samples.at(-1);
const heldAtEnd = last !== undefined && last.special[side][slot] === true;
return {
side,
slot,
deadSpans,
specialSpans,
specialUses,
diedWithSpecial,
heldAtEnd,
unknownSpecialEnds,
};
}
function observationGaps(
samples: readonly ScannerMatchPlayerStatusSample[],
): StatusSpan[] {
const gaps: StatusSpan[] = [];
for (let i = 1; i < samples.length; i++) {
const dt = samples[i]!.t - samples[i - 1]!.t;
if (dt > OBSERVATION_GAP_SECONDS) {
gaps.push({ start: samples[i - 1]!.t, end: samples[i]!.t });
}
}
return gaps;
}
function readsInWindow(
sources: readonly DetectedEvent[],
start: number,
end: number,
): number[] {
return sources
.filter(
(event) =>
(event.type === PLAYER_STATUS_EVENT_TYPE ||
event.type === MINIMAP_EVENT_TYPE) &&
event.t >= start - 0.5 &&
event.t <= end + 0.5,
)
.map((event) => Math.round(event.t * 100) / 100);
}
// ---------------------------------------------------------------------------
// Output
function ts(t: number): string {
return `t=${Math.round(t * 10) / 10} (${formatTime(t)})`;
}
function spanText(span: StatusSpan, suspicions: string[]): string {
const duration = Math.round((span.end - span.start) * 10) / 10;
const marks = suspicions.length > 0 ? `${suspicions.join(", ")}` : "";
return `${ts(span.start)} ${duration}s/${span.confirmingReads.length}r${marks}`;
}
function deadSpanSuspicions(span: StatusSpan): string[] {
const suspicions: string[] = [];
if (span.maxPossibleSeconds < MIN_DEAD_SECONDS) {
suspicions.push(
`even flank-to-flank shorter than min respawn ${MIN_DEAD_SECONDS}s`,
);
}
if (span.confirmingReads.length === 1) suspicions.push("single read");
return suspicions;
}
function specialSpanSuspicions(span: StatusSpan): string[] {
return span.confirmingReads.length === 1 ? ["single read"] : [];
}
function playerLabelOf(match: ScannerMatch, side: 0 | 1, slot: number): string {
const player = match.teams[side].players[slot];
const weapon = mainWeaponLabel(player?.weaponId ?? null);
return `${player?.name ?? "?"}${weapon ? ` · ${weapon}` : ""}`;
}
function printHeader(): void {
console.log(`# Player-status audit: ${csvPath}`);
const counts = new Map<string, number>();
for (const event of events) {
counts.set(event.type, (counts.get(event.type) ?? 0) + 1);
}
const countText = [...counts.entries()]
.map(([type, n]) => `${type} ${n}`)
.join(", ");
console.log(`events reconstructed: ${events.length} (${countText})`);
for (const [type, n] of skippedTypes) {
console.log(`ignored: ${n} × ${type} (not used by the match builder)`);
}
for (const failure of parseFailures) {
console.log(`PARSE FAILURE (row skipped): ${failure}`);
}
console.log(`matches built: ${built.length}`);
console.log("");
}
function printMatch(
index: number,
builtMatch: BuiltMatch<DetectedEvent>,
): void {
const { match, sources } = builtMatch;
const headline = [
match.mode ? modeLabel(match.mode) : "mode?",
match.stage !== null ? stageLabel(match.stage) : "stage?",
match.startsAt !== null && match.endsAt !== null
? `${ts(match.startsAt)} ${ts(match.endsAt)}`
: "span?",
match.cast ? "cast footage" : "POV footage",
].join(" · ");
console.log(`## Match ${index + 1} · ${headline}`);
const samples = match.playerStatus?.samples ?? [];
if (samples.length === 0) {
console.log(
match.mode !== null && match.mode !== "SZ"
? "no status samples (non-SZ match: counter/status reads voided as lookalike misreads)"
: "no status samples — nothing to audit",
);
console.log("");
return;
}
const sorted = samples.toSorted((a, b) => a.t - b.t);
const gaps = observationGaps(sorted);
const windowSeconds = sorted.at(-1)!.t - sorted[0]!.t;
const gapSeconds = gaps.reduce((acc, gap) => acc + (gap.end - gap.start), 0);
const coverage =
windowSeconds <= 0 ? 1 : (windowSeconds - gapSeconds) / windowSeconds;
console.log(
`status samples: ${sorted.length} over ${ts(sorted[0]!.t)} ${ts(sorted.at(-1)!.t)} · observed ${Math.round(coverage * 100)}% of the window`,
);
for (const gap of gaps) {
console.log(
` unobserved gap: ${ts(gap.start)}${ts(gap.end)} (${Math.round(gap.end - gap.start)}s) — deaths/specials in here are invisible to the timeline`,
);
}
const hasScoreboard = match.winner !== null;
if (!hasScoreboard) {
console.log(
"no scoreboard closed this match — no ground truth to diff against; flagging only implausible spans",
);
}
for (const side of [0, 1] as const) {
const teamLabel =
match.winner === null
? `Team ${side + 1}`
: side === 0
? "Team 1 (winner)"
: "Team 2 (loser)";
console.log(`### ${teamLabel}`);
const analyses = [0, 1, 2, 3].map((slot) =>
analyzeSlot(sorted, side, slot as 0 | 1 | 2 | 3),
);
const sbDeaths = analyses.map(
(a) => match.teams[side].players[a.slot]?.d ?? null,
);
const tlDeaths = analyses.map((a) => a.deadSpans.length);
const deathsMultisetMatch =
sbDeaths.every((d) => d !== null) &&
multisetEquals(sbDeaths as number[], tlDeaths);
for (const analysis of analyses) {
printSlot(index, builtMatch, analysis, deathsMultisetMatch);
}
if (
deathsMultisetMatch &&
sbDeaths.some((d, slot) => d !== tlDeaths[slot])
) {
console.log(
" note: death counts match as a set but not per row — likely a slot→row assignment artifact (CSV carries only top-1 strip-weapon candidates), not a detection error",
);
}
}
printPovCrossCheck(match, sources);
console.log("");
}
function printSlot(
matchIndex: number,
builtMatch: BuiltMatch<DetectedEvent>,
analysis: SlotAnalysis,
deathsMultisetMatch: boolean,
): void {
const { match, sources } = builtMatch;
const { side, slot } = analysis;
const player = match.teams[side].players[slot];
const label = playerLabelOf(match, side, slot);
const sbD = player?.d ?? null;
const sbS = player?.s ?? null;
const tlD = analysis.deadSpans.length;
const tlUses = analysis.specialUses;
const deathVerdict = verdict(sbD, tlD);
const specialNotes = [
analysis.diedWithSpecial > 0
? `${analysis.diedWithSpecial} died holding special (legit non-use)`
: null,
analysis.heldAtEnd ? "held at match end (legit non-use)" : null,
analysis.unknownSpecialEnds > 0
? `${analysis.unknownSpecialEnds} special end(s) lost in observation gaps`
: null,
].filter((note) => note !== null);
const specialVerdict = verdict(sbS, tlUses);
console.log(
`row${slot} ${label} — deaths sb=${sbD ?? "?"} tl=${tlD} ${deathVerdict.text} · specials sb=${sbS ?? "?"} used tl=${tlUses} (spans ${analysis.specialSpans.length}) ${specialVerdict.text}${specialNotes.length > 0 ? ` [${specialNotes.join("; ")}]` : ""}`,
);
const deadSuspicious = analysis.deadSpans.some(
(span) => deadSpanSuspicions(span).length > 0,
);
const showDetail =
showAllSpans ||
deathVerdict.mismatch !== 0 ||
specialVerdict.mismatch !== 0 ||
deadSuspicious;
if (showDetail) {
if (analysis.deadSpans.length > 0) {
console.log(
` dead spans: ${analysis.deadSpans.map((span) => spanText(span, deadSpanSuspicions(span))).join(" | ")}`,
);
}
if (analysis.specialSpans.length > 0) {
console.log(
` special spans: ${analysis.specialSpans.map((span) => spanText(span, specialSpanSuspicions(span))).join(" | ")}`,
);
}
}
collectCandidates(
matchIndex,
match,
sources,
analysis,
sbD,
sbS,
deathsMultisetMatch,
);
}
function verdict(
sb: number | null,
tl: number,
): { text: string; mismatch: number } {
if (sb === null) return { text: "(scoreboard unread)", mismatch: 0 };
const delta = tl - sb;
if (delta === 0) return { text: "✓", mismatch: 0 };
return {
text:
delta > 0
? `✗ +${delta} EXTRA on timeline`
: `${delta} MISSING on timeline`,
mismatch: delta,
};
}
function multisetEquals(a: readonly number[], b: readonly number[]): boolean {
const as = a.toSorted((x, y) => x - y);
const bs = b.toSorted((x, y) => x - y);
return as.length === bs.length && as.every((v, i) => v === bs[i]);
}
function collectCandidates(
matchIndex: number,
match: ScannerMatch,
sources: readonly DetectedEvent[],
analysis: SlotAnalysis,
sbD: number | null,
sbS: number | null,
deathsMultisetMatch: boolean,
): void {
const { side, slot } = analysis;
const label = `match ${matchIndex + 1} team ${side + 1} row${slot} (${playerLabelOf(match, side, slot)})`;
const deadExcess = sbD !== null && analysis.deadSpans.length > sbD;
const specialExcess = sbS !== null && analysis.specialUses > sbS;
for (const span of analysis.deadSpans) {
const suspicions = deadSpanSuspicions(span);
if (suspicions.length === 0 && !deadExcess) continue;
let score = 0;
if (span.maxPossibleSeconds < MIN_DEAD_SECONDS) score += 4;
if (span.confirmingReads.length === 1) score += 1;
if (deadExcess) score += deathsMultisetMatch ? 1 : 3;
if (score === 0) continue;
const confirmed = span.confirmingReads.at(-1)! - span.confirmingReads[0]!;
candidates.push({
score: score + 1 / (1 + confirmed),
t: span.start,
matchIndex,
description: `${label} — DEAD span ${Math.round((span.end - span.start) * 10) / 10}s over ${span.confirmingReads.length} read(s)${suspicions.length > 0 ? ` (${suspicions.join(", ")})` : ""}${deadExcess ? ` · row has +${analysis.deadSpans.length - (sbD ?? 0)} extra death(s) vs scoreboard` : ""}`,
reads: readsInWindow(sources, span.start, span.end),
});
}
if (specialExcess) {
const shortestFirst = analysis.specialSpans.toSorted(
(a, b) => a.confirmingReads.length - b.confirmingReads.length,
);
for (const span of shortestFirst.slice(
0,
analysis.specialUses - (sbS ?? 0),
)) {
const duration = span.end - span.start;
candidates.push({
score: 2 + 1 / (1 + span.confirmingReads.length),
t: span.start,
matchIndex,
description: `${label} — SPECIAL span ${Math.round(duration * 10) / 10}s over ${span.confirmingReads.length} read(s), row has +${analysis.specialUses - (sbS ?? 0)} extra special use(s) vs scoreboard`,
reads: readsInWindow(sources, span.start, span.end),
});
}
}
const deadMissing = sbD !== null && analysis.deadSpans.length < sbD;
const specialMissing = sbS !== null && analysis.specialUses < sbS;
if (deadMissing || specialMissing) {
const missing = [
deadMissing ? `${sbD - analysis.deadSpans.length} death(s)` : null,
specialMissing ? `${sbS - analysis.specialUses} special use(s)` : null,
].filter((part) => part !== null);
candidates.push({
score: 1.5,
t: match.startsAt ?? 0,
matchIndex,
description: `${label} — timeline MISSING ${missing.join(" and ")}: either lost in unobserved gaps (see match coverage) or the detector never flagged the state — scan this row's footage`,
reads: [],
});
}
}
function printPovCrossCheck(
match: ScannerMatch,
sources: readonly DetectedEvent[],
): void {
if (match.pov === null) return;
const overlayDeaths = sources.filter(
(event) => event.type === DEATH_EVENT_TYPE,
).length;
const povRow = match.teams[match.pov.team].players[match.pov.index];
if (povRow?.d == null) return;
const agrees = overlayDeaths === povRow.d ? "✓" : "✗ disagrees";
console.log(
`pov cross-check: ${overlayDeaths} respawn-overlay death event(s) vs scoreboard d=${povRow.d} for ${povRow.name ?? "?"} ${agrees} (independent of the icon-strip pipeline)`,
);
}
function printCandidates(): void {
console.log("## Fixture candidates (most suspect first)");
if (candidates.length === 0) {
console.log("none — timeline agrees with every scoreboard count");
console.log("");
return;
}
const ranked = candidates.toSorted((a, b) => b.score - a.score);
for (const [i, candidate] of ranked.entries()) {
console.log(`${i + 1}. ${ts(candidate.t)} · ${candidate.description}`);
if (candidate.reads.length > 0) {
console.log(
` reads to inspect: ${candidate.reads
.slice(0, 8)
.map((t) => `t=${t}`)
.join(
", ",
)}${candidate.reads.length > 8 ? ` (+${candidate.reads.length - 8} more)` : ""}`,
);
}
}
console.log("");
}
function printGuidance(): void {
console.log("## How to turn a candidate into a fixture");
console.log(
"1. Seek the VoD to the candidate's read timestamps (t = seconds into the footage; hh:mm:ss given alongside).",
);
console.log(
"2. In the scanner UI, analyze that frame and use “Save fixture” — the misread frame lands byte-exact with a prefilled expected.json.",
);
console.log(
"3. Hand-correct expected.json (icon-strip states go under tests/fixtures/player-status/, minimap card states under tests/fixtures/minimap/), then run pnpm test:scanner.",
);
console.log(
"Legend: a DEAD span shorter than 3.5s cannot be a real splat (min respawn); single-read spans are one frame's misread away from vanishing; EXTRA counts point at phantom bands, MISSING counts at undetected states or coverage gaps.",
);
}