Scanner kill feed event

This commit is contained in:
Kalle
2026-09-09 20:51:50 +03:00
parent 9fc1dde2ee
commit e6ab2e2c8e
56 changed files with 1740 additions and 99 deletions

View File

@@ -46,6 +46,14 @@ export function canonicalMatch(match: ScannerMatch): ScannerMatch {
match.playerStatus == null
? null
: canonicalPlayerStatus(match.playerStatus),
kills:
match.kills == null
? null
: match.kills.map((kill) => ({
t: kill.t,
time: kill.time,
name: kill.name,
})),
teams: [canonicalTeam(match.teams[0]), canonicalTeam(match.teams[1])],
winner: match.winner,
pov:
@@ -118,6 +126,7 @@ export function mergeMatches(
// series from different scans is not attempted
objective: existing.objective ?? oriented.objective,
playerStatus: existing.playerStatus ?? oriented.playerStatus,
kills: existing.kills ?? oriented.kills,
teams: [
mergeTeam(existing.teams[0], oriented.teams[0]),
mergeTeam(existing.teams[1], oriented.teams[1]),

View File

@@ -98,6 +98,7 @@ function testMatch({
cast: false,
objective,
playerStatus,
kills: null,
teams: [{ players: players.slice(0, 4) }, { players: players.slice(4) }],
winner: 0,
pov:

View File

@@ -43,6 +43,7 @@ export function scannerMatch(
cast: false,
objective: null,
playerStatus: null,
kills: null,
teams: [
{
players: NAMES.slice(0, 4).map((name, i) =>

View File

@@ -317,6 +317,7 @@ export function scannedGame(
cast,
objective: null,
playerStatus: null,
kills: null,
teams: seenFrom === "loser" ? [losers, winners] : [winners, losers],
winner: seenFrom === "loser" ? 1 : 0,
pov: cast ? null : { team: 0, index: 0 },

View File

@@ -80,14 +80,32 @@ sequenceDiagram
- The route (`routes/scanner.tsx`) is SSR-guarded: the client tree loads via
`React.lazy` after `useHydrated`; nothing from `core/worker/capture/store`
may be imported at route-module top level.
- Eight detectors: `scoreboard` (results screen),
- Nine detectors: `scoreboard` (results screen),
`scoreboard-battle-log-replay` (replay-browser detail),
`scoreboard-battle-log` (Recent Battles detail — same data sans replay
code, panels stacked), `scoreboard-own` (personal results), `death`
(respawn overlay), `map-start` (match intro), `minimap` (in-match overlay
+ casted 8-player spectator variant), `objective` (ranked counter overlay:
counts, penalties, holder, match timer — a mode-discriminated union with
only the SZ member so far). The objective parse also emits a second
only the SZ member so far), `kill` (the "Splatted <name>!" feed
bottom-center). The feed is the POV player's — on the SWS26 broadcast the
specced player's, so a cast's kills follow camera swaps. One `Kill` event
per frame carries the whole visible stack newest-first, up to four rows,
each read as one line against every language's row template
(`core/detectors/kill/localized-messages.ts`, generated) with the leftover
as the name, plus the match timer off the same frame (`objective/timer.ts`,
shared with the counter) so kills land on the game clock in every mode. The
builder reduces the stack reads to one kill per row entering the feed
(`deriveKills`: rows expire oldest-first and a blurred inner row can drop
out of a single read, so each read is matched newest-first as a
subsequence of the rows still remembered within
`KILL_ROW_LIFETIME_SECONDS`), on the same replay-wipe anchor as the
counter series; how long a row stays up is unattested, so a row outliving
that lifetime would count twice. Row text reads through the `kill-feed`
atlas, BlitzMain at the row's ~24px caps with the scoreboard-names
charset, under `parseName`'s opt-in plain-tie rule (at that size an i's
dot alone ranks the accented glyphs level with the plain one). The
objective parse also emits a second
event type per read: `PlayerStatus`
(`core/detectors/objective/player-status.ts`), per-player special/dead
flags off the icon strip flanking the timer (three geometries named by
@@ -263,7 +281,11 @@ new tests there whenever they can be written without a frame.
A test case is a directory `tests/fixtures/<detector>/<case-name>/` with
`frame.png|jpg` (raw capture, never re-encoded) and `expected.json` (partial
expectations, sendou ids; `stageLabel`/`weaponLabel` are informational for
the human corrector — tests compare only ids). Negative cases
the human corrector — tests compare only ids). A frame that already serves
another detector's fixture (a kill feed caught in an objective frame) is
symlinked (`ln -s ../../objective/<case>/frame.png frame.png`), not copied,
and the kill suite's cross-negative sweep skips shared frames by real path.
Negative cases
(`{ "event": "none" }`) go in the shared `tests/fixtures/negative/`; every
detector's suite sweeps them. Every live misread should become a fixture —
the live app's "Save fixture" button exports the byte-exact analyzed frame

View File

@@ -13,6 +13,7 @@ import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import { KILL_EVENT_TYPE, type KillData } from "../core/detectors/kill/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
@@ -45,6 +46,7 @@ import { DeathCard } from "./DeathCard";
import styles from "./EventCard.module.css";
import type { FixtureData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
import { KillCard } from "./KillCard";
import { MapStartCard } from "./MapStartCard";
import { MinimapCard } from "./MinimapCard";
import { ObjectiveCard } from "./ObjectiveCard";
@@ -162,6 +164,8 @@ function renderCard(
) {
return type === DEATH_EVENT_TYPE ? (
<DeathCard {...shared} data={data as DeathData} />
) : type === KILL_EVENT_TYPE ? (
<KillCard {...shared} data={data as KillData} />
) : type === MAP_START_EVENT_TYPE ? (
<MapStartCard {...shared} data={data as MapStartData} />
) : type === SCOREBOARD_OWN_EVENT_TYPE ? (

View File

@@ -5,6 +5,7 @@
import {
CircleHelp,
Crosshair,
History,
type LucideIcon,
Map as MapIcon,
@@ -16,6 +17,7 @@ import {
User,
} from "lucide-react";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import { KILL_EVENT_TYPE } from "../core/detectors/kill/index";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
@@ -27,6 +29,7 @@ import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/inde
const EVENT_TYPE_ICONS: Record<string, LucideIcon> = {
[MAP_START_EVENT_TYPE]: Play,
[DEATH_EVENT_TYPE]: Skull,
[KILL_EVENT_TYPE]: Crosshair,
[MINIMAP_EVENT_TYPE]: MapIcon,
[OBJECTIVE_EVENT_TYPE]: Target,
[SCOREBOARD_EVENT_TYPE]: Trophy,

View File

@@ -5,6 +5,7 @@
import * as R from "remeda";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import { KILL_EVENT_TYPE } from "../core/detectors/kill/index";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
@@ -18,6 +19,7 @@ import { EventTypeIcon } from "./EventTypeIcon";
const EVENT_TYPE_LABELS: Record<string, string> = {
[MAP_START_EVENT_TYPE]: "map start",
[DEATH_EVENT_TYPE]: "death",
[KILL_EVENT_TYPE]: "kill",
[MINIMAP_EVENT_TYPE]: "minimap",
[OBJECTIVE_EVENT_TYPE]: "objective",
[SCOREBOARD_EVENT_TYPE]: "scoreboard",

View File

@@ -0,0 +1,45 @@
import { KILL_EVENT_TYPE, type KillData } from "../core/detectors/kill/index";
import { EventCardMeta, EventCardShell } from "./EventCardShell";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
export function KillCard(props: {
t: number;
confidence: number;
data: KillData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const formatDetectedAt = useEventTimeFormatter();
// the feed reads newest-first; listing oldest-first reads as the order they happened
const names = data.names.toReversed().map((name) => name ?? "?");
return (
<EventCardShell>
<EventCardMeta>
<MetaPills
t={t}
confidence={confidence}
type={KILL_EVENT_TYPE}
label="kill"
/>
<span>
{data.time !== null ? `${formatClock(data.time)} · ` : null}
splatted <b>{names.join(", ")}</b>
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: KILL_EVENT_TYPE }}
/>
</EventCardMeta>
</EventCardShell>
);
}

View File

@@ -9,6 +9,7 @@ import {
} from "../capture/sampler";
import { connectAbilities } from "../core/ability-harvest";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import { KILL_EVENT_TYPE } from "../core/detectors/kill/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
@@ -83,6 +84,7 @@ const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const;
const INGESTABLE_TYPES = [
MAP_START_EVENT_TYPE,
DEATH_EVENT_TYPE,
KILL_EVENT_TYPE,
MINIMAP_EVENT_TYPE,
...SCOREBOARD_EVENT_TYPES,
];

View File

@@ -386,6 +386,44 @@ button.expand {
animation: scanner-detail-in 0.25s ease both;
}
/* the match's kills as one wrapping line of clock + name chips */
.kills {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-1) var(--s-1-5);
font-size: var(--font-2xs);
}
.killsLabel {
display: inline-flex;
align-items: center;
gap: var(--s-1);
margin-inline-end: var(--s-1);
color: var(--color-text-high);
font-weight: var(--weight-semi);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.kill {
display: inline-flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-0-5) var(--s-2);
border-radius: var(--radius-full);
border: var(--border-style);
background-color: var(--color-bg);
font-weight: var(--weight-bold);
white-space: nowrap;
}
.killClock {
color: var(--color-text-high);
font-weight: var(--weight-semi);
font-variant-numeric: tabular-nums;
}
@keyframes scanner-detail-in {
from {
opacity: 0;

View File

@@ -1,11 +1,11 @@
/**
* Glanceable card for one ScannerMatch in the live feed: stage banner, mode +
* stage, score, team weapons and /ingest status. Expanding reveals the source
* event cards below it.
* stage, score, team weapons and /ingest status. Expanding reveals the
* match's kills (from the feed) and the source event cards below it.
*/
import clsx from "clsx";
import { ChevronDown } from "lucide-react";
import { ChevronDown, Crosshair } from "lucide-react";
import type * as React from "react";
import { useState } from "react";
import { Ability } from "~/components/Ability";
@@ -17,18 +17,30 @@ import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest
import type {
AbilityWithUnknown,
MainWeaponId,
ModeShort,
} from "~/modules/in-game-lists/types";
import { sendouQMatchPage, tournamentMatchPage } from "~/utils/urls";
import type { IngestSkipReason } from "../core/match-builder";
import type { ScannerMatch, ScannerMatchPlayer } from "../core/scanner-match";
import type {
ScannerMatch,
ScannerMatchKill,
ScannerMatchPlayer,
} from "../core/scanner-match";
import type { SendStatus } from "../store/events";
import { formatTime, useEventTimeFormatter } from "./format";
import { formatClock, formatTime, useEventTimeFormatter } from "./format";
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
import styles from "./MatchCard.module.css";
/** the game score a knockout wins at */
const KO_MATCH_SCORE = 100;
/**
* Where the match clock starts, to turn a time-left read into time elapsed:
* Turf War runs 3:00, the ranked modes 5:00 (overtime reads clamp to the end).
*/
const MATCH_CLOCK_SECONDS: Partial<Record<ModeShort, number>> = { TW: 180 };
const DEFAULT_MATCH_CLOCK_SECONDS = 300;
/** one per gear slot: [head, clothes, shoes], the arc's left-to-right order */
const UNKNOWN_MAIN_ABILITIES: AbilityWithUnknown[] = [
"UNKNOWN",
@@ -186,7 +198,45 @@ export function MatchCard({
return (
<div className={styles.group}>
{card}
{expanded ? <div className={styles.events}>{children}</div> : null}
{expanded ? (
<div className={styles.events}>
{match.kills ? (
<MatchKills kills={match.kills} mode={match.mode} />
) : null}
{children}
</div>
) : null}
</div>
);
}
/** The POV player's splats off the kill feed, in the order they happened, stamped with match time elapsed. */
function MatchKills({
kills,
mode,
}: {
kills: readonly ScannerMatchKill[];
mode: ModeShort | null;
}) {
const clockStart =
(mode !== null ? MATCH_CLOCK_SECONDS[mode] : undefined) ??
DEFAULT_MATCH_CLOCK_SECONDS;
return (
<div className={styles.kills}>
<span className={styles.killsLabel}>
<Crosshair size={12} aria-hidden />
kills · {kills.length}
</span>
{kills.map((kill, i) => (
<span key={i} className={styles.kill}>
<span className={styles.killClock}>
{kill.time !== null
? formatClock(Math.max(0, clockStart - kill.time))
: "?:??"}
</span>
{kill.name ?? "?"}
</span>
))}
</div>
);
}

View File

@@ -12,6 +12,8 @@ import { mainWeaponImageUrl } from "~/utils/urls";
import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "../core/canonical";
import type { DeathData } from "../core/detectors/death/index";
import * as death from "../core/detectors/death/rois";
import type { KillData } from "../core/detectors/kill/index";
import * as kill from "../core/detectors/kill/rois";
import type { MapStartData } from "../core/detectors/map-start/index";
import * as mapStart from "../core/detectors/map-start/rois";
import type { MinimapData } from "../core/detectors/minimap/index";
@@ -224,6 +226,10 @@ function gateSummary(result: Result): string | null {
const data = event.data as unknown as ObjectiveData;
return `${confidence} · ${formatTimer(data.time)} · score ${data.score[0] ?? "?"}${data.score[1] ?? "?"}`;
}
case "kill": {
const data = event.data as unknown as KillData;
return `${confidence} · ${formatTimer(data.time)} · splatted ${data.names.map((name) => name ?? "?").join(", ")}`;
}
default: {
const data = event.data as CardData;
return `${confidence} · scores ${JSON.stringify(data.matchScores)} · ${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")}`;
@@ -292,6 +298,15 @@ function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) {
}
return;
}
if (detector === "kill") {
for (let row = 0; row < kill.MAX_ROWS; row++) {
rect(kill.textRoi(row), "#34d399");
rect(kill.skullRoi(row), "#60a5fa");
for (const roi of kill.darkProbes(row)) rect(roi, "#facc15");
}
rect(objective.TIMER_DIGIT_ROI, "#f87171");
return;
}
if (detector === "map-start") {
rect(mapStart.MODE_LABEL_ROI, "#34d399");
rect(mapStart.MODE_BLOCK_ROI, "#f87171");
@@ -501,6 +516,7 @@ export function ScreenshotPage() {
const isOwn = activeDetector === "scoreboard-own";
const isMinimap = activeDetector === "minimap";
const isObjective = activeDetector === "objective";
const isKill = activeDetector === "kill";
const winnerSide = String(event?.debug?.winnerSide ?? "left");
const rowRois = isReplay
? replayRows(winnerSide)
@@ -857,13 +873,52 @@ export function ScreenshotPage() {
</div>
) : null}
{frame && event && isKill ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as KillData;
const rows = (event.debug?.rows ?? []) as { raw?: string }[];
return (
<>
<div className={styles.detailStats}>
<Stat label="timer" raw={event.debug?.timerRaw}>
{formatTimer(data.time)}
</Stat>
{data.names.map((name, row) => (
<Stat key={row} label={`row ${row}`} raw={rows[row]?.raw}>
{name ?? "?"}
</Stat>
))}
</div>
<div className={styles.detailCrops}>
{data.names.map((_, row) => (
<LabeledCrop
key={row}
label={`row ${row}`}
frame={frame}
roi={kill.textRoi(row)}
/>
))}
<LabeledCrop
label="timer"
frame={frame}
roi={objective.TIMER_DIGIT_ROI}
/>
</div>
</>
);
})()}
</div>
) : null}
{frame &&
event &&
!isDeath &&
!isMapStart &&
!isOwn &&
!isMinimap &&
!isObjective ? (
!isObjective &&
!isKill ? (
<table className={styles.inspector}>
<thead>
<tr>

View File

@@ -9,6 +9,7 @@ import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import { KILL_EVENT_TYPE, type KillData } from "../core/detectors/kill/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
@@ -211,6 +212,29 @@ function eventCells(event: CsvEvent): Cell[] {
"",
];
}
case KILL_EVENT_TYPE: {
const d = event.data as KillData;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
"",
"",
"",
"",
"",
"",
// oldest first, the order the splats happened
`${clock}${d.names
.toReversed()
.map((name) => name ?? "?")
.join(" | ")}`,
"",
"",
"",
"",
];
}
case OBJECTIVE_EVENT_TYPE: {
const d = event.data as ObjectiveData;
const sideText = (side: 0 | 1) =>

View File

@@ -7,6 +7,7 @@ import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import { KILL_EVENT_TYPE, type KillData } from "../core/detectors/kill/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
@@ -48,7 +49,8 @@ export type FixtureData =
| MinimapData
| ObjectiveData
| PlayerStatusData
| StripWeaponsData;
| StripWeaponsData
| KillData;
function isDeath(_data: FixtureData, eventType: string): _data is DeathData {
return eventType === DEATH_EVENT_TYPE;
@@ -201,6 +203,14 @@ function buildExpectedJson(
2,
)}\n`;
}
if (eventType === KILL_EVENT_TYPE) {
const kill = data as KillData;
return `${JSON.stringify(
{ event: eventType, data: { time: kill.time, names: kill.names } },
null,
2,
)}\n`;
}
// NB: not a type-predicate helper — CardData is structurally assignable to
// MapStartData, so a predicate would narrow the fall-through case to never
if (eventType === MAP_START_EVENT_TYPE) {

View File

@@ -0,0 +1,193 @@
/**
* KillDetector: parses the kill feed — the "Splatted <name>!" pills stacked
* bottom-center whenever the POV player splats someone (on the SWS26
* broadcast: the specced player, so a cast's feed follows camera swaps).
* Each pill reads as one text line snapped
* against every language's row template (message.ts): the constant text
* doubles as confirmation, so a lookalike gate hit emits nothing, and what it
* leaves over is the splatted player's name. Rows are read bottom-up (newest
* first) until one is missing, and the top-center match timer is read off the
* same frame (objective/timer.ts) so kills plot on the game clock in every
* mode, not only where the counter is parsed.
*/
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
import { copyRoi, maxBrightness, meanBrightness, type Roi } from "../../image";
import {
readMatchTimer,
timerBoxChecks,
timerGlyphSets,
} from "../objective/timer";
import type { ScoreboardResources } from "../scoreboard/index";
import { type ParsedName, parseName } from "../scoreboard/names";
import type { DetectedEvent, Detector, GateResult } from "../types";
import { matchKillMessage } from "./message";
import {
darkProbes,
GATE_DARK_MAX_MEAN,
GATE_SKULL_MAX_MEAN,
GATE_SKULL_MIN_MAX,
GATE_SKULL_MIN_MEAN,
GATE_TEXT_MAX_FRACTION,
GATE_TEXT_MIN_FRACTION,
GATE_TEXT_MIN_MAX,
KILL_TEXT_BIN_THRESHOLD,
KILL_TEXT_READ_HEIGHT,
MAX_ROWS,
skullRoi,
textRoi,
} from "./rois";
export interface KillData {
/** match timer seconds ("3:06" = 186) off the top-center clock; null when unreadable */
time: number | null;
/**
* every feed row visible in the frame, bottom row first (index 0 = newest
* splat; older rows shift upward), up to MAX_ROWS. A row whose name is
* unreadable stays as null so it still counts as a splat.
*/
names: (string | null)[];
}
export const KILL_EVENT_TYPE = "Kill";
/**
* Template fit below this is a lookalike row (fixtures read 0.89-1.0; the
* garbled reads of a mis-scaled atlas peaked at 0.67).
*/
const MESSAGE_MIN_SCORE = 0.75;
/**
* parseName's plain-tie margin at feed size: the dot of 'i' alone lands 'í'/
* 'ì' 0.00-0.03 over 'i' (datkid, Burstie), a blurred 'l' lands 'í' 0.03 over
* the bar glyphs (leafi), a bracket lands 【 0.015 over '[' ([K]yo) — while a
* real accent or double stroke ranks the plain form well lower.
*/
const PLAIN_TIE_MARGIN = 0.05;
/** Timeline content guard: repeat frames of one stack merge, a row entering or leaving keeps its own event. */
export function sameKillData(a: unknown, b: unknown): boolean {
const da = a as KillData;
const db = b as KillData;
return (
da.names.length === db.names.length &&
da.names.every((name, i) => name === db.names[i])
);
}
export function createKillDetector(
resources: ScoreboardResources,
): Detector<KillData> {
const cv = getCV();
const glyphs: GlyphSet | null = resources.killFeedGlyphs
? scaleGlyphSet(
resources.killFeedGlyphs,
KILL_TEXT_READ_HEIGHT / resources.killFeedGlyphs.height,
)
: null;
const timerSets = timerGlyphSets(resources);
function whiteFraction(gray: Mat, roi: Roi): number {
const crop = copyRoi(gray, roi);
const bin = new cv.Mat();
cv.threshold(crop, bin, GATE_TEXT_MIN_MAX, 255, cv.THRESH_BINARY);
crop.delete();
const fraction = cv.countNonZero(bin) / (bin.rows * bin.cols);
bin.delete();
return fraction;
}
/** One row's presence checks: pill interior dark around the line, white skull art, one line of white text. */
function rowChecks(gray: Mat, row: number): boolean[] {
const skull = skullRoi(row);
const skullMean = meanBrightness(gray, skull);
const text = textRoi(row);
const textFraction = whiteFraction(gray, text);
return [
...darkProbes(row).map(
(roi) => meanBrightness(gray, roi) <= GATE_DARK_MAX_MEAN,
),
skullMean >= GATE_SKULL_MIN_MEAN &&
skullMean <= GATE_SKULL_MAX_MEAN &&
maxBrightness(gray, skull) >= GATE_SKULL_MIN_MAX,
maxBrightness(gray, text) >= GATE_TEXT_MIN_MAX &&
textFraction >= GATE_TEXT_MIN_FRACTION &&
textFraction <= GATE_TEXT_MAX_FRACTION,
];
}
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const checks = rowChecks(gray, 0);
gray.delete();
const passed = checks.filter(Boolean).length;
return { pass: passed === checks.length, score: passed / checks.length };
}
function readRow(gray: Mat, row: number): ParsedName {
const band = copyRoi(gray, textRoi(row));
const parsed = parseName(band, glyphs!, {
binThreshold: KILL_TEXT_BIN_THRESHOLD,
spaceGap: Math.max(6, Math.round(glyphs!.medianWidth * 0.55)),
plainTieMargin: PLAIN_TIE_MARGIN,
});
band.delete();
return parsed;
}
function parse(frame: Mat, t: number): DetectedEvent<KillData>[] {
if (!glyphs) return [];
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const names: (string | null)[] = [];
const confidences: number[] = [];
const rows: Record<string, unknown>[] = [];
for (let row = 0; row < MAX_ROWS; row++) {
if (row > 0 && !rowChecks(gray, row).every(Boolean)) break;
const parsed = readRow(gray, row);
const message = matchKillMessage(parsed.name);
rows.push({
raw: parsed.raw.text,
read: parsed.name,
readScore: parsed.confidence,
messageLangs: message?.template.langs,
messageScore: message?.score,
});
if (!message || message.score < MESSAGE_MIN_SCORE) break;
names.push(message.name);
confidences.push((message.score + parsed.confidence) / 2);
}
if (names.length === 0) {
gray.delete();
return [];
}
const timer = timerBoxChecks(gray).every(Boolean)
? readMatchTimer(gray, timerSets)
: { value: null, reading: "" };
gray.delete();
return [
{
type: KILL_EVENT_TYPE,
t,
confidence:
confidences.reduce((sum, c) => sum + c, 0) / confidences.length,
data: { time: timer.value, names },
debug: { rows, timerRaw: timer.reading },
},
];
}
// rows live a few seconds and a stack grows while it shows, so the feed is
// a long-lived screen with changing content: a fixed cadence instead of
// steady-frame suppression (a row samples several times before expiring)
return {
id: "kill",
checkIntervalS: 0.5,
gate,
parse,
};
}

View File

@@ -0,0 +1,84 @@
/**
* GENERATED by scripts/scanner/build-localized-entries.ts from the splat3 repo's
* language dumps — do not edit by hand; regenerate when the game adds
* content. Per-language kill-feed row templates: the "Splatted <name>!" row
* wraps the splatted player's name in language-specific text on either side
* (spaces kept as rendered; either side may be empty).
*/
export interface KillMessageTemplate {
/** languages sharing this exact template */
langs: readonly string[];
/** constant text before the name (may end with a space, or be empty) */
pre: string;
/** constant text after the name (may start with a space, or be empty) */
post: string;
}
export const KILL_MESSAGE_TEMPLATES: readonly KillMessageTemplate[] = [
{
langs: ["CNzh"],
pre: "击倒了",
post: "",
},
{
langs: ["EUde"],
pre: "",
post: " erledigt!",
},
{
langs: ["EUen", "USen"],
pre: "Splatted ",
post: "!",
},
{
langs: ["EUes"],
pre: "¡Has eliminado a ",
post: "!",
},
{
langs: ["EUfr"],
pre: "Tu as liquidé ",
post: " !",
},
{
langs: ["EUit"],
pre: "Hai splattato ",
post: "!",
},
{
langs: ["EUnl"],
pre: "",
post: " uitgeschakeld!",
},
{
langs: ["EUru"],
pre: "Плюхнут игрок ",
post: "!",
},
{
langs: ["JPja"],
pre: "",
post: " をたおした!",
},
{
langs: ["KRko"],
pre: "",
post: " 쓰러뜨렸다!",
},
{
langs: ["TWzh"],
pre: "將",
post: "擊倒了!",
},
{
langs: ["USes"],
pre: "¡Reventaste a ",
post: "!",
},
{
langs: ["USfr"],
pre: "Éclaboussé ",
post: "!",
},
];

View File

@@ -0,0 +1,132 @@
/**
* Kill-feed row text → splatted name. A row reads as one line ("Splatted
* nwrm!"); the language template whose constant text fits the line's head
* and tail best wins, and whatever sits between is the name. Characters are
* folded (case, diacritics) one-to-one so the cut points map back onto the
* original read; spaces only drop out inside the comparisons, since OCR word
* gaps wobble.
*/
import { editDistance } from "../../text";
import {
KILL_MESSAGE_TEMPLATES,
type KillMessageTemplate,
} from "./localized-messages";
export interface KillMessageMatch {
template: KillMessageTemplate;
/** 0..1 fit of the constant text, pre and post weighted by their length */
score: number;
/** what the constant parts leave over; null when nothing does */
name: string | null;
}
/** A cut may land this many characters off the constant text's length (dropped/doubled glyphs). */
const CUT_SLACK = 2;
/**
* Handicap of a cut inside a word where the constant text meets the name at a
* space: more than one glyph's share of the shortest such constant text
* (をたおした, 1/6), so a gap-aligned cut off by one glyph still wins.
*/
const WORD_SPLIT_PENALTY = 0.2;
/** At feed size the row's "!" reads as any of these; the tail comparison treats them as one. */
const EXCLAMATION_LOOKALIKES = new Set([
"!",
"|",
"l",
"i",
"1",
"「",
"¡",
"",
]);
/** The best-fitting template for a row read; null only when no template has constant text. */
export function matchKillMessage(
read: string,
templates: readonly KillMessageTemplate[] = KILL_MESSAGE_TEMPLATES,
): KillMessageMatch | null {
const chars = [...read];
const folded = chars.map(foldChar);
let best: KillMessageMatch | null = null;
for (const template of templates) {
const pre = foldConstant(template.pre);
const post = foldConstant(template.post);
if (pre.length + post.length === 0) continue;
const head = bestCut(folded, pre, "head", template.pre.endsWith(" "));
const tail = bestCut(folded, post, "tail", template.post.startsWith(" "));
if (head.length + tail.length > chars.length) continue;
const score =
(head.score * pre.length + tail.score * post.length) /
(pre.length + post.length);
if (best && score <= best.score) continue;
const name = chars
.slice(head.length, chars.length - tail.length)
.join("")
.trim();
best = { template, score, name: name.length > 0 ? name : null };
}
return best;
}
function foldChar(ch: string): string {
const base = ch.normalize("NFD").replace(/[̀-ͯ]/g, "");
return (base.length > 0 ? base : ch).toLowerCase()[0]!;
}
function foldConstant(text: string): string[] {
return [...text].filter((ch) => ch !== " ").map(foldChar);
}
function foldExclamations(chars: readonly string[]): string {
return chars
.map((ch) => (EXCLAMATION_LOOKALIKES.has(ch) ? "!" : ch))
.join("");
}
/**
* How many characters of the read's head or tail the constant text covers:
* every length within CUT_SLACK of the constant's is scored (spaces
* excluded), the best fit wins, ties go to the length nearest the constant's.
* Where the constant text meets the name at a space, a cut inside a word is
* handicapped (WORD_SPLIT_PENALTY): "Splatte datkid" must lose the 'd' of
* its constant, not the first letter of the name. Nothing is cut when nothing
* fits at all — better a name with a stray character than one missing its last.
*/
function bestCut(
folded: readonly string[],
constant: readonly string[],
side: "head" | "tail",
gapped: boolean,
): { length: number; score: number } {
if (constant.length === 0) return { length: 0, score: 1 };
const target =
side === "tail" ? foldExclamations(constant) : constant.join("");
let best = { length: 0, score: 0, adjusted: 0 };
const min = Math.max(0, constant.length - CUT_SLACK);
const max = Math.min(folded.length, constant.length + CUT_SLACK);
for (let length = min; length <= max; length++) {
const cut = side === "head" ? length : folded.length - length;
const segment = (
side === "head" ? folded.slice(0, cut) : folded.slice(cut)
).filter((ch) => ch !== " ");
const text = side === "tail" ? foldExclamations(segment) : segment.join("");
const score =
1 - editDistance(text, target) / Math.max(text.length, target.length, 1);
const atWordGap =
cut === 0 ||
cut === folded.length ||
folded[cut] === " " ||
folded[cut - 1] === " ";
const adjusted = gapped && !atWordGap ? score - WORD_SPLIT_PENALTY : score;
const closer =
adjusted === best.adjusted &&
Math.abs(length - constant.length) <
Math.abs(best.length - constant.length);
if (adjusted > best.adjusted || closer) best = { length, score, adjusted };
}
return best.score > 0
? { length: best.length, score: best.score }
: { length: 0, score: 0 };
}

View File

@@ -0,0 +1,74 @@
/**
* Kill-feed ROIs in canonical 1920x1080 space, calibrated against the kill/
* fixtures (row/column brightness profiling). Every splat by the POV player
* draws one dark pill bottom-center (x703..1217, 50px tall) holding a white
* skull, a team-ink squid and the "Splatted <name>!" line — BlitzMain, caps
* y1004..1028, centered in the text area right of the icons. A further splat
* adds a pill at the bottom and shifts the older ones up by ROW_PITCH (a
* ~15px gap of gameplay shows between pills); the bottom row is the newest.
*/
import type { Roi } from "../../canonical";
/** A wipeout is four rows; a respawned enemy can't re-enter before the oldest row expires. */
export const MAX_ROWS = 4;
/** Text caps sit at y1004 on the bottom row and y938 on the one above. */
const ROW_PITCH = 66;
/** Bottom row's text band: from the squid icon's right edge to the pill's plain right end, with drift headroom. */
const TEXT_ROI_BOTTOM: Roi = { x: 796, y: 997, w: 412, h: 38 };
/** Tight cap height of the row text (atlas nominal height). */
export const KILL_TEXT_HEIGHT = 24;
/**
* The atlas scaled up one pixel reads both the native-1080p and the
* 720p-upscaled fixture best (0.81-0.88 vs 0.73-0.76 at nominal): the game's
* compositing fattens strokes the way the cubic upscale does.
*/
export const KILL_TEXT_READ_HEIGHT = 25;
/** White text on the near-black pill; the squid icon's tint stays well under. */
export const KILL_TEXT_BIN_THRESHOLD = 150;
/**
* Pill-interior dark probes of the bottom row: the margin left of the skull,
* the plain right end (the barcode decoration there reads ~30 too), and the
* bands above and below the text line. Fixtures read 25..34 on each.
*/
const DARK_PROBES_BOTTOM: readonly Roi[] = [
{ x: 706, y: 998, w: 18, h: 34 },
{ x: 1196, y: 998, w: 10, h: 34 },
{ x: 850, y: 992, w: 250, h: 6 },
{ x: 850, y: 1031, w: 250, h: 6 },
];
export const GATE_DARK_MAX_MEAN = 70;
/** The white skull icon left of the squid: bright art on the dark pill. */
const SKULL_ROI_BOTTOM: Roi = { x: 729, y: 1002, w: 28, h: 28 };
export const GATE_SKULL_MIN_MAX = 200;
export const GATE_SKULL_MIN_MEAN = 60;
export const GATE_SKULL_MAX_MEAN = 210;
/** The text band must contain near-white pixels... */
export const GATE_TEXT_MIN_MAX = 210;
/** ...but not too many: it is one short line, not a white panel. */
export const GATE_TEXT_MAX_FRACTION = 0.35;
export const GATE_TEXT_MIN_FRACTION = 0.01;
function shiftedUp(roi: Roi, row: number): Roi {
return { ...roi, y: roi.y - row * ROW_PITCH };
}
/** Row 0 is the bottom (newest) pill; each further row sits ROW_PITCH higher. */
export function textRoi(row: number): Roi {
return shiftedUp(TEXT_ROI_BOTTOM, row);
}
export function darkProbes(row: number): Roi[] {
return DARK_PROBES_BOTTOM.map((roi) => shiftedUp(roi, row));
}
export function skullRoi(row: number): Roi {
return shiftedUp(SKULL_ROI_BOTTOM, row);
}

View File

@@ -10,20 +10,8 @@
* the same frame, paired downstream by the shared timer value.
*/
import { getCV, type Mat, minMaxLoc } from "../../cv";
import {
type GlyphSet,
type RecognizedChar,
recognizeText,
scaleGlyphSet,
} from "../../glyphs";
import {
copyRoi,
maxBrightness,
maxChannel,
meanBrightness,
minChannel,
type Roi,
} from "../../image";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import { copyRoi, maxChannel, minChannel, type Roi } from "../../image";
import { type InkRgb, meanInkColor } from "../../ink-color";
import {
type BannerScoreRead,
@@ -41,8 +29,6 @@ import {
CONTROL_PLATE_MIN_SATURATION,
GATE_PLATE_MAX_STD,
GATE_SCORE_MIN_MAX_BRIGHTNESS,
GATE_TIMER_MAX_MEAN,
GATE_TIMER_MIN_MAX_BRIGHTNESS,
PENALTY_BIN_THRESHOLD,
PENALTY_PROBE_MAX_MEAN,
PENALTY_PROBE_MAX_STD,
@@ -57,14 +43,9 @@ import {
SCORE_TEXT_HEIGHTS,
STATUS_LAYOUT_STICKY_MAX_GAP_S,
STRIP_WEAPON_SAMPLE_INTERVAL,
TIMER_BIN_THRESHOLD,
TIMER_DARK_PROBES,
TIMER_DIGIT_MIN_CONF,
TIMER_DIGIT_MIN_HEIGHT_RATIO,
TIMER_DIGIT_ROI,
TIMER_TEXT_HEIGHTS,
} from "./rois";
import { parseStripWeapons, type StripWeaponsData } from "./strip-weapons";
import { readMatchTimer, timerBoxChecks, timerGlyphSets } from "./timer";
export type ObjectiveData = SplatZonesObjectiveData;
@@ -140,14 +121,7 @@ export function createObjectiveDetector(
PENALTY_TEXT_HEIGHT / resources.paintDigits.height,
)
: null;
const timerSets: GlyphSet[] = resources.paintDigits
? TIMER_TEXT_HEIGHTS.map((h) =>
scaleGlyphSet(
resources.paintDigits!,
h / resources.paintDigits!.height,
),
)
: [];
const timerSets = timerGlyphSets(resources);
/** Mean and standard deviation of a grayscale ROI. */
function meanStd(gray: Mat, roi: Roi): { mean: number; std: number } {
@@ -177,10 +151,7 @@ export function createObjectiveDetector(
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const checks = [
...TIMER_DARK_PROBES.map(
(roi) => meanBrightness(gray, roi) <= GATE_TIMER_MAX_MEAN,
),
maxBrightness(gray, TIMER_DIGIT_ROI) >= GATE_TIMER_MIN_MAX_BRIGHTNESS,
...timerBoxChecks(gray),
plateProbeOk(gray, PLATE_PROBE_ROIS[0]),
plateProbeOk(gray, PLATE_PROBE_ROIS[1]),
scoreInkOk(frame, SCORE_ROIS[0]),
@@ -226,50 +197,6 @@ export function createObjectiveDetector(
return best;
}
/**
* M:SS timer: the colon's dots fall under the digit height floor, so a valid
* read is exactly three full-height digits. Each glyph size is tried (digits
* render bigger on upscaled 720p) and the most confident valid read wins.
*/
function readTimer(gray: Mat): { value: number | null; reading: string } {
const band = copyRoi(gray, TIMER_DIGIT_ROI);
let best: { value: number | null; reading: string; score: number } = {
value: null,
reading: "",
score: 0,
};
for (const timerSet of timerSets) {
const raw = recognizeText(band, timerSet, {
binThreshold: TIMER_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
if (!best.reading) best = { ...best, reading: raw.text };
const isTimerDigit = (c: RecognizedChar) =>
c.score >= TIMER_DIGIT_MIN_CONF &&
c.y1 - c.y0 >= timerSet.height * TIMER_DIGIT_MIN_HEIGHT_RATIO;
const chars = raw.chars.filter(isTimerDigit);
const digits = chars.map((c) => Number(c.char));
if (digits.length !== 3 || digits.some(Number.isNaN)) continue;
const [minutes, secondsTens, secondsOnes] = digits as [
number,
number,
number,
];
if (secondsTens >= 6) continue;
const score = chars.reduce((sum, c) => sum + c.score, 0) / chars.length;
if (score > best.score) {
best = {
value: minutes * 60 + secondsTens * 10 + secondsOnes,
reading: raw.text,
score,
};
}
}
band.delete();
return { value: best.value, reading: best.reading };
}
/**
* Penalty pill: presence probes at the rounded ends first, then the white
* "+N" digits. A nameplate badge can cover one end, so a lone pill-like
@@ -351,7 +278,7 @@ export function createObjectiveDetector(
]),
};
}) as [SideRead, SideRead];
const timer = readTimer(gray);
const timer = readMatchTimer(gray, timerSets);
gray.delete();
// no readable count on either side = the gate hit a lookalike

View File

@@ -0,0 +1,94 @@
/**
* The top-center match timer (M:SS in a near-black box), read by the objective
* counter and the kill feed alike so both land on the same game-clock axis.
* The colon's dots fall under the digit height floor, so a valid read is
* exactly three full-height digits; each glyph size is tried (digits render
* bigger on upscaled 720p) and the most confident valid read wins.
*/
import type { Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedChar,
recognizeText,
scaleGlyphSet,
} from "../../glyphs";
import { copyRoi, maxBrightness, meanBrightness } from "../../image";
import type { ScoreboardResources } from "../scoreboard/index";
import {
GATE_TIMER_MAX_MEAN,
GATE_TIMER_MIN_MAX_BRIGHTNESS,
TIMER_BIN_THRESHOLD,
TIMER_DARK_PROBES,
TIMER_DIGIT_MIN_CONF,
TIMER_DIGIT_MIN_HEIGHT_RATIO,
TIMER_DIGIT_ROI,
TIMER_TEXT_HEIGHTS,
} from "./rois";
export interface TimerRead {
/** match timer seconds ("3:35" = 215); null = unreadable */
value: number | null;
/** the raw digit-run reading, for debugging */
reading: string;
}
/** Paint digits rescaled to each attested timer digit size; empty without the atlas. */
export function timerGlyphSets(resources: ScoreboardResources): GlyphSet[] {
const digits = resources.paintDigits;
if (!digits) return [];
return TIMER_TEXT_HEIGHTS.map((h) =>
scaleGlyphSet(digits, h / digits.height),
);
}
/** Per-probe presence checks of the timer box: dark surround, then bright digits. */
export function timerBoxChecks(gray: Mat): boolean[] {
return [
...TIMER_DARK_PROBES.map(
(roi) => meanBrightness(gray, roi) <= GATE_TIMER_MAX_MEAN,
),
maxBrightness(gray, TIMER_DIGIT_ROI) >= GATE_TIMER_MIN_MAX_BRIGHTNESS,
];
}
export function readMatchTimer(
gray: Mat,
timerSets: readonly GlyphSet[],
): TimerRead {
const band = copyRoi(gray, TIMER_DIGIT_ROI);
let best: TimerRead & { score: number } = {
value: null,
reading: "",
score: 0,
};
for (const timerSet of timerSets) {
const raw = recognizeText(band, timerSet, {
binThreshold: TIMER_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
if (!best.reading) best = { ...best, reading: raw.text };
const isTimerDigit = (c: RecognizedChar) =>
c.score >= TIMER_DIGIT_MIN_CONF &&
c.y1 - c.y0 >= timerSet.height * TIMER_DIGIT_MIN_HEIGHT_RATIO;
const chars = raw.chars.filter(isTimerDigit);
const digits = chars.map((c) => Number(c.char));
if (digits.length !== 3 || digits.some(Number.isNaN)) continue;
const [minutes, secondsTens, secondsOnes] = digits as [
number,
number,
number,
];
if (secondsTens >= 6) continue;
const score = chars.reduce((sum, c) => sum + c.score, 0) / chars.length;
if (score > best.score) {
best = {
value: minutes * 60 + secondsTens * 10 + secondsOnes,
reading: raw.text,
score,
};
}
}
band.delete();
return { value: best.value, reading: best.reading };
}

View File

@@ -1,6 +1,7 @@
/** Every detector that runs on a frame; new event types register here. */
import { createDeathDetector } from "./death/index";
import { createKillDetector } from "./kill/index";
import { createMapStartDetector } from "./map-start/index";
import { createMinimapDetector } from "./minimap/index";
import { createObjectiveDetector } from "./objective/index";
@@ -39,5 +40,6 @@ export function createAllDetectors(
createMapStartDetector(resources) as Detector<unknown>,
createMinimapDetector(resources) as Detector<unknown>,
createObjectiveDetector(resources) as Detector<unknown>,
createKillDetector(resources) as Detector<unknown>,
];
}

View File

@@ -132,6 +132,11 @@ export interface ScoreboardResources {
*/
mapStartModeGlyphs?: GlyphSet | null;
mapStartStageGlyphs?: GlyphSet | null;
/**
* Kill-feed row glyphs (BlitzMain at the row's ~24px caps, name charset plus
* every language's row text); without them the kill detector emits nothing.
*/
killFeedGlyphs?: GlyphSet | null;
/**
* Minimap extras: main-weapon icons on the card-pill background, a
* light-background variant for special-ready camo, and ability icons at the

View File

@@ -240,16 +240,58 @@ function resolveCaseByDescent(raw: RecognizedText): string {
.join("");
}
/**
* Near-tie homoglyphs re-decided toward the plain form before the context
* rules run (opt-in via `plainTieMargin`; the kill feed's ~24px rows need it):
* the dot of 'i' alone ranks 'í'/'ì' level with 'i', a blurred 'l' ranks 'í'
* over the bar glyphs, and a fullwidth bracket lands level with its ASCII
* twin — while a real accent or double stroke ranks the plain form well
* lower. A dotted vowel may also fall to a bar glyph, which the bar rule
* then reads in context.
*/
const PLAIN_TWINS: Record<string, string> = { "【": "[", "】": "]" };
function preferPlainTies(raw: RecognizedText, margin: number): RecognizedText {
const chars = raw.chars.map((c) => {
const twin = PLAIN_TWINS[c.char];
const base = c.char.normalize("NFD").replace(/[̀-ͯ]/g, "");
const accented = twin === undefined && base.length === 1 && base !== c.char;
const plain = twin ?? (accented ? base : undefined);
if (plain === undefined || !c.candidates) return c;
const top = c.candidates[0]?.score ?? c.score;
const pick = c.candidates.find(
(k) =>
(k.char === plain || (accented && BAR_CHARS.has(k.char))) &&
top - k.score <= margin,
);
return pick ? { ...c, char: pick.char, score: pick.score } : c;
});
let ci = 0;
const text = [...raw.text]
.map((ch) => (ch === " " ? ch : chars[ci++]!.char))
.join("");
return { ...raw, text, chars };
}
export function parseName(
gray: Mat,
glyphs: GlyphSet,
options: { spaceGap?: number; binThreshold?: number } = {},
options: {
spaceGap?: number;
binThreshold?: number;
/** re-decide near-tie homoglyphs toward the plain form (preferPlainTies) */
plainTieMargin?: number;
} = {},
): ParsedName {
const raw = recognizeText(gray, glyphs, {
const recognized = recognizeText(gray, glyphs, {
spaceGap: options.spaceGap ?? 7,
binThreshold: options.binThreshold,
minCharScore: 0.35,
});
const raw =
options.plainTieMargin === undefined
? recognized
: preferPlainTies(recognized, options.plainTieMargin);
return {
name: normalizeLongBars(
normalizeOhs(

View File

@@ -19,6 +19,7 @@ import {
harvestCardMains,
} from "./ability-harvest";
import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index";
import { KILL_EVENT_TYPE, type KillData } from "./detectors/kill/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
@@ -55,6 +56,7 @@ import { hueDistance, hueOf, type InkRgb } from "./ink-color";
import { parseReplayTimestamp } from "./replay-time";
import type {
ScannerMatch,
ScannerMatchKill,
ScannerMatchObjective,
ScannerMatchObjectiveSample,
ScannerMatchPlayer,
@@ -69,6 +71,7 @@ import {
type SlotRowPermutation,
weaponSlotRowPermutation,
} from "./slot-row-assignment";
import { editDistance, matchKey } from "./text";
/** The lobby header value private battles (tournament games) carry. */
const TOURNAMENT_LOBBY = "PRIVATE";
@@ -121,6 +124,17 @@ const ALIVE_RUN_MIN_SECONDS = 2;
*/
const SPECIAL_REGAIN_MIN_SECONDS = 10;
/**
* Kill-feed stack reads further apart than this show independent rows even
* when the names repeat: a splatted player respawns in ~8.5s, so a repeated
* name inside it is the same row still up. How long a row stays up is
* unattested beyond single frames; a row outliving this would count twice.
*/
const KILL_ROW_LIFETIME_SECONDS = 8;
/** Name similarity (1 - edits / length) at which two stack reads show the same row. */
const KILL_SAME_ROW_MIN_SIMILARITY = 0.7;
export interface BuiltMatch<E extends DetectedEvent> {
match: ScannerMatch;
/** input events the match was built from, chronological — the send-status unit for callers */
@@ -145,6 +159,7 @@ export function buildScannerMatches<E extends DetectedEvent>(
let orphanObjectives: E[] = [];
let orphanPlayerStatuses: E[] = [];
let orphanStripWeapons: E[] = [];
let orphanKills: E[] = [];
const finalize = (): void => {
if (!open) return;
if (open.scoreboard || open.minimaps.length > 0) {
@@ -164,6 +179,7 @@ export function buildScannerMatches<E extends DetectedEvent>(
orphanObjectives = [];
orphanPlayerStatuses = [];
orphanStripWeapons = [];
orphanKills = [];
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
if (!open) {
open = startMatch();
@@ -179,6 +195,9 @@ export function buildScannerMatches<E extends DetectedEvent>(
open.stripWeapons = orphanStripWeapons.filter(
(read) => event.t - read.t <= FALLBACK_WINDOW_SECONDS,
);
open.kills = orphanKills.filter(
(read) => event.t - read.t <= FALLBACK_WINDOW_SECONDS,
);
}
open.scoreboard = event;
vote(open.stageVotes, (event.data as ScoreboardData).stage);
@@ -187,6 +206,7 @@ export function buildScannerMatches<E extends DetectedEvent>(
orphanObjectives = [];
orphanPlayerStatuses = [];
orphanStripWeapons = [];
orphanKills = [];
} else if (event.type === MINIMAP_EVENT_TYPE) {
const stage = (event.data as MinimapData).stage;
if (open) {
@@ -215,6 +235,8 @@ export function buildScannerMatches<E extends DetectedEvent>(
(open?.playerStatuses ?? orphanPlayerStatuses).push(event);
} else if (event.type === STRIP_WEAPONS_EVENT_TYPE) {
(open?.stripWeapons ?? orphanStripWeapons).push(event);
} else if (event.type === KILL_EVENT_TYPE) {
(open?.kills ?? orphanKills).push(event);
}
}
finalize();
@@ -339,6 +361,8 @@ interface OpenMatch<E extends DetectedEvent> {
playerStatuses: E[];
/** sampled per-slot weapon evidence for the slot→row assignment */
stripWeapons: E[];
/** kill-feed stack reads; become the match's `kills` */
kills: E[];
scoreboard: E | null;
/**
* per-stage read counts (a MapStart's stage seeds it); the plurality winner
@@ -356,6 +380,7 @@ function startMatch<E extends DetectedEvent>(): OpenMatch<E> {
objectives: [],
playerStatuses: [],
stripWeapons: [],
kills: [],
scoreboard: null,
stageVotes: new Map(),
lastMinimapT: null,
@@ -407,6 +432,7 @@ function toBuiltMatch<E extends DetectedEvent>(
...open.objectives,
...open.playerStatuses,
...open.stripWeapons,
...open.kills,
...(open.scoreboard ? [open.scoreboard] : []),
].sort((a, b) => a.t - b.t);
@@ -437,18 +463,24 @@ function toBuiltMatch<E extends DetectedEvent>(
t: event.t,
data: event.data as MinimapData,
}));
const killReads = open.kills.map((event) => ({
t: event.t,
data: event.data as KillData,
}));
const minimaps = minimapReads.map((read) => read.data);
const mode = board?.mode ?? start?.mode ?? null;
// only the SZ counter is parsed: reads on a known other-mode match are
// lookalike-overlay misreads (statuses ride along with counter reads).
// Minimap card states are mode-agnostic and feed status samples regardless
// Minimap card states and the kill feed are mode-agnostic and feed their
// samples regardless
const counterModeValid = mode === null || mode === "SZ";
const progress = buildProgress(
counterModeValid ? objectives : [],
counterModeValid ? playerStatuses : [],
counterModeValid ? stripWeapons : [],
minimapReads,
killReads,
board,
minimapTeamColors(minimaps),
);
@@ -483,6 +515,7 @@ function toBuiltMatch<E extends DetectedEvent>(
playerStatuses.some((read) => read.data.cast)),
objective: progress.objective,
playerStatus: progress.playerStatus,
kills: progress.kills,
teams: board
? teamsFromScoreboard(board, deaths, minimaps, progress.minimapEnemySide)
: teamsFromMinimaps(minimaps, deaths),
@@ -529,22 +562,34 @@ function floorOrNull(t: number | undefined): number | null {
* column, own column assumed symmetric). The POV diamond follows neither
* order, so its flags map by card name and stay as drawn when too few names
* resolve. A minimap-grouped match's samples stay as drawn by construction.
*
* Kill-feed stack reads share the replay-wipe anchor (they carry the same
* clock) and reduce to one kill per row entering a stack (deriveKills); the
* feed belongs to the POV (on a cast: specced) player, so they need no side
* orientation.
*/
function buildProgress(
objectives: readonly { t: number; data: ObjectiveData }[],
playerStatuses: readonly { t: number; data: PlayerStatusData }[],
stripWeapons: readonly { t: number; data: StripWeaponsData }[],
minimapReads: readonly { t: number; data: MinimapData }[],
killReads: readonly { t: number; data: KillData }[],
board: ScoreboardData | undefined,
minimapColors: [InkRgb | null, InkRgb | null] | null,
): {
objective: ScannerMatchObjective | null;
playerStatus: ScannerMatchPlayerStatus | null;
kills: ScannerMatchKill[] | null;
/** the `teams` side the minimap's enemy column is; null with no scoreboard */
minimapEnemySide: 0 | 1 | null;
} {
const dominant = dominantAnchorOf([...objectives, ...playerStatuses]);
const dominant = dominantAnchorOf([
...objectives,
...playerStatuses,
...killReads,
]);
const live = withoutReplayReads(objectives, dominant);
const liveKills = withoutReplayReads(killReads, dominant);
const statusReads = [
...playerStatuses.map(
(read): StatusRead => ({
@@ -631,10 +676,83 @@ function buildProgress(
return {
objective,
playerStatus,
kills: liveKills.length === 0 ? null : deriveKills(liveKills),
minimapEnemySide: board ? (minimapSwapped ? 0 : 1) : null,
};
}
/**
* One kill per feed row entering the feed. Rows expire oldest-first and a
* single read can miss an inner row (a blurred pill ends the bottom-up scan
* early), so each read is matched newest-first as a subsequence of the rows
* still remembered (first seen within KILL_ROW_LIFETIME_SECONDS): a row
* matching a remembered one is carried, anything else is a new kill.
* Remembered rows a read fails to show stay remembered until they age out,
* so the recovered read after a truncated one re-counts nothing.
*/
function deriveKills(
reads: readonly { t: number; data: KillData }[],
): ScannerMatchKill[] {
const kills: ScannerMatchKill[] = [];
// rows believed on screen, oldest first, by the read that first saw them
let known: { name: string | null; t: number }[] = [];
for (const read of reads) {
known = known.filter((row) => read.t - row.t <= KILL_ROW_LIFETIME_SECONDS);
const names = read.data.names.toReversed();
// newest-first greedy subsequence match: a row matches the newest
// remembered row not yet claimed, skipping remembered rows this read
// failed to show
const matched = new Map<number, number>();
let j = known.length - 1;
for (let i = names.length - 1; i >= 0; i--) {
let k = j;
while (k >= 0 && !sameRowName(names[i]!, known[k]!.name)) k--;
if (k >= 0) {
matched.set(k, i);
j = k - 1;
}
}
// rebuild the remembered stack in order: unmatched remembered rows stay
// (hidden or expiring), unmatched read rows are new kills
const t = Math.max(0, Math.floor(read.t));
const next: typeof known = [];
let placed = 0;
const placeNewUpTo = (end: number): void => {
for (; placed < end; placed++) {
const name = names[placed]!;
kills.push({ t, time: read.data.time, name });
next.push({ name, t: read.t });
}
};
for (const [k, row] of known.entries()) {
const i = matched.get(k);
if (i === undefined) {
next.push(row);
continue;
}
placeNewUpTo(i);
next.push(row);
placed = i + 1;
}
placeNewUpTo(names.length);
known = next;
}
// earliest first: the stack walk already emits in feed order, the sort
// pins it as the contract
return kills.toSorted((a, b) => a.t - b.t);
}
function sameRowName(a: string | null, b: string | null): boolean {
if (a === null || b === null) return true;
const ka = matchKey(a);
const kb = matchKey(b);
const similarity =
1 - editDistance(ka, kb) / Math.max(ka.length, kb.length, 1);
return similarity >= KILL_SAME_ROW_MIN_SIMILARITY;
}
/** The slot→row permutations of a scoreboard-closed match, per source. */
interface SlotRowPerms {
/** per teams side, for strip-seated slots (the strip and card columns) */

View File

@@ -61,6 +61,7 @@ const ATLASES = {
deathTagNameGlyphs: "death-tag-name",
mapStartModeGlyphs: "map-start-mode",
mapStartStageGlyphs: "map-start-stage",
killFeedGlyphs: "kill-feed",
} as const;
/** Memoize an expensive template/atlas build for the lazy resource getters. */
@@ -225,5 +226,8 @@ export async function assembleScoreboardResources(
get mapStartStageGlyphs() {
return atlas.mapStartStageGlyphs();
},
get killFeedGlyphs() {
return atlas.killFeedGlyphs();
},
};
}

View File

@@ -79,6 +79,16 @@ export interface ScannerMatchPlayerStatus {
samples: ScannerMatchPlayerStatusSample[];
}
/** One splat by the POV (or specced) player, off the kill feed. */
export interface ScannerMatchKill {
/** whole seconds into the video/stream the feed row was first seen at */
t: number;
/** seconds shown on the match timer — the same key the objective samples carry */
time: number | null;
/** the splatted player's name as read; null when unreadable */
name: string | null;
}
export interface ScannerMatch {
/** whole seconds into the video/stream the match starts at */
startsAt: number | null;
@@ -108,6 +118,12 @@ export interface ScannerMatch {
* objective samples; null when the icon strip was never read
*/
playerStatus: ScannerMatchPlayerStatus | null;
/**
* the POV player's splats (a broadcast's: the specced player's, so they
* follow camera swaps), chronological, derived from the kill feed's stack
* reads; null when the feed was never read
*/
kills: ScannerMatchKill[] | null;
/** on-screen order: scoreboard rows 0-3 are teams[0] (the winners), minimap own side is teams[0] */
teams: [ScannerMatchTeam, ScannerMatchTeam];
/** scoreboard-sourced matches know it (0); minimap-only matches don't */

View File

@@ -1,6 +1,7 @@
/** Small text utilities: edit distance and closed-set snapping for OCR output. */
function editDistance(a: string, b: string): number {
/** Levenshtein distance between two strings. */
export function editDistance(a: string, b: string): number {
const dp = Array.from({ length: a.length + 1 }, (_, i) => {
const row = new Array<number>(b.length + 1).fill(0);
row[0] = i;

View File

@@ -3,6 +3,7 @@
* (highest confidence kept); events below a confidence floor are dropped.
*/
import { KILL_EVENT_TYPE, sameKillData } from "../detectors/kill/index";
import {
MINIMAP_EVENT_TYPE,
sameMinimapStatusData,
@@ -48,13 +49,17 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
// own event via the content guard). Objective: reads repeat every second; the
// content guard keeps every change while static stretches collapse.
// PlayerStatus: a state can recur no sooner than a respawn (~9s), so the
// window stays under that. StripWeapons: sampled every ~5s, each distinct evidence
// window stays under that. StripWeapons: sampled every ~5s, each distinct evidence.
// Kill: the same stack re-read while it shows merges; a splatted player
// can't re-enter the feed before respawning (~8.5s), so the window stays
// under that and the content guard splits a growing stack.
mergeWindowByType: {
Death: 8,
[MINIMAP_EVENT_TYPE]: 5,
[OBJECTIVE_EVENT_TYPE]: 10,
[PLAYER_STATUS_EVENT_TYPE]: 5,
[STRIP_WEAPONS_EVENT_TYPE]: 2,
[KILL_EVENT_TYPE]: 8,
},
sameEventDataByType: {
[SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch,
@@ -63,6 +68,7 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
[MINIMAP_EVENT_TYPE]: sameMinimapStatusData,
[OBJECTIVE_EVENT_TYPE]: sameObjectiveData,
[PLAYER_STATUS_EVENT_TYPE]: samePlayerStatusData,
[KILL_EVENT_TYPE]: sameKillData,
},
minConfidence: 0.6,
minConfidenceByType: {

View File

@@ -72,6 +72,7 @@ interface ExpectedScoreboard {
| "Objective"
| "PlayerStatus"
| "StripWeapons"
| "Kill"
| "none";
data?: {
lobby?: ScannerLobby;
@@ -99,8 +100,10 @@ interface ExpectedScoreboard {
abilities?: AbilityWithUnknown[][];
/** Death only: killer's splash-tag name */
name?: string;
/** Objective only: match-timer seconds ("3:35" = 215); null = unreadable */
/** Objective + Kill: match-timer seconds ("3:35" = 215); null = unreadable */
time?: number | null;
/** Kill only: feed rows bottom (newest) first; null = row shown but name unreadable */
names?: (string | null)[];
/** Objective only: displayed counter per team, [alpha, bravo] */
score?: [number | null, number | null];
/** Objective only: penalty pill value per team; null = no pill */

View File

@@ -13,6 +13,7 @@ import type { Ability } from "~/modules/in-game-lists/types";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import type {
ScannerMatch,
ScannerMatchKill,
ScannerMatchObjective,
ScannerMatchPlayer,
ScannerMatchPlayerStatus,
@@ -96,6 +97,15 @@ const scannerMatchPlayerStatusSchema = v.object({
),
});
/** a splat every few seconds over a match runs to dozens, not hundreds */
const MAX_KILLS = 200;
const scannerMatchKillSchema = v.object({
t: v.pipe(v.number(), v.integer(), v.minValue(0)),
time: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))),
name: v.nullable(detectionText),
});
export const scannerMatchSchema = v.object({
startsAt: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))),
endsAt: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))),
@@ -111,6 +121,9 @@ export const scannerMatchSchema = v.object({
cast: v.boolean(),
objective: v.nullable(scannerMatchObjectiveSchema),
playerStatus: v.nullable(scannerMatchPlayerStatusSchema),
kills: v.nullable(
v.pipe(v.array(scannerMatchKillSchema), v.maxLength(MAX_KILLS)),
),
teams: v.tuple([scannerMatchTeamSchema, scannerMatchTeamSchema]),
winner: v.nullable(teamIndexSchema),
pov: v.nullable(
@@ -146,6 +159,10 @@ true satisfies MutuallyAssignable<
v.InferOutput<typeof scannerMatchPlayerStatusSchema>,
ScannerMatchPlayerStatus
>;
true satisfies MutuallyAssignable<
v.InferOutput<typeof scannerMatchKillSchema>,
ScannerMatchKill
>;
true satisfies MutuallyAssignable<
v.InferOutput<typeof scannerMatchSchema>,
ScannerMatch

View File

@@ -0,0 +1,10 @@
{
"event": "Kill",
"data": {
"time": 173,
"names": ["[K]yo!"]
},
"options": {
"notes": "Frame shared with objective/splat-zones-cast-penalty-pill-under-nameplate-badge (SWS26 broadcast). The name ends in its own '!', so the row shows two; only the message's is stripped. The opening bracket ranks the fullwidth 【 a hair over '[' (0.915 vs 0.900) — the plain-tie rule prefers the ASCII bracket."
}
}

View File

@@ -0,0 +1 @@
../../objective/splat-zones-cast-penalty-pill-under-nameplate-badge/frame.png

View File

@@ -0,0 +1,11 @@
{
"event": "Kill",
"data": {
"time": 262,
"names": ["Burstie", "leafi !!"]
},
"options": {
"skipFields": ["names.1"],
"notes": "Frame shared with objective/splat-zones-cast-lime-control-blur-left (SWS26 broadcast): the spectator HUD draws the specced player's kill feed. Bottom row Burstie (the dot of its i reads as a grave accent, re-decided by the plain-tie rule), above it 'leafi !!' — the row shows three marks, the last being the message's own. Stream blur merges 'fi' into one segment (reads ↑), hence the skip."
}
}

View File

@@ -0,0 +1 @@
../../objective/splat-zones-cast-lime-control-blur-left/frame.png

View File

@@ -0,0 +1,10 @@
{
"event": "Kill",
"data": {
"time": 214,
"names": ["datkid", "y0shell"]
},
"options": {
"notes": "Frame shared with player-status/cast-sws26-three-dead-one-special (SWS26 broadcast): a double. The second character of y0shell is the O/0 box BlitzMain draws for both; labeled as the digit the after-lowercase rule reads — UNCONFIRMED against the player's real name."
}
}

View File

@@ -0,0 +1 @@
../../player-status/cast-sws26-three-dead-one-special/frame.png

View File

@@ -0,0 +1,10 @@
{
"event": "Kill",
"data": {
"time": 249,
"names": ["leafi !!"]
},
"options": {
"notes": "Frame shared with objective/splat-zones-cast-lime-control-blur-right (SWS26 broadcast), seconds after the two-row frame: only the leafi row is left. Its leading 'l' ranks an accented í first, with the bar glyphs next; the plain-tie rule hands it to the bar rule, which reads 'l' off the lowercase neighbor."
}
}

View File

@@ -0,0 +1 @@
../../objective/splat-zones-cast-lime-control-blur-right/frame.png

View File

@@ -0,0 +1,10 @@
{
"event": "Kill",
"data": {
"time": 186,
"names": ["datkid", "24K"]
},
"options": {
"notes": "Native 1080p POV capture, Splat Zones (timer 3:06). Two rows stacked: datkid on the bottom (the newest splat — a trade, datkid's splash tag is on the death overlay up at the same time), 24K above. The death overlay's WIPEOUT burst and gear panel sit clear of the feed; the death gate legitimately fires on this frame too."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -0,0 +1,10 @@
{
"event": "Kill",
"data": {
"time": 155,
"names": ["heavy kez"]
},
"options": {
"notes": "Frame shared with death/rapid-blaster-deco-heavy-kez (720p JPEG POV capture): a trade — the death overlay is up while the feed shows the splat. A space inside the name."
}
}

View File

@@ -0,0 +1 @@
../../death/rapid-blaster-deco-heavy-kez/frame.jpg

View File

@@ -0,0 +1,10 @@
{
"event": "Kill",
"data": {
"time": 277,
"names": ["julufm", "K DICTATOR"]
},
"options": {
"notes": "Frame shared with player-status/pov-umami-three-splats-cast-geometry (Sendou POV, Um'ami Ruins SZ 4:37): a double with a space inside a name."
}
}

View File

@@ -0,0 +1 @@
../../player-status/pov-umami-three-splats-cast-geometry/frame.png

View File

@@ -0,0 +1,10 @@
{
"event": "Kill",
"data": {
"time": 150,
"names": ["nwrm"]
},
"options": {
"notes": "1280x720 POV capture, Splat Zones (counter 88-18, timer 2:30). One feed row with no other overlay in the way."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -0,0 +1,188 @@
/**
* Golden-file tests for the KillDetector over every fixture in kill/, plus
* cross-negative sweeps both ways: the kill gate must stay quiet on every
* other detector's positives and the shared negatives, and the gates of
* screens that replace live gameplay must stay quiet on kill positives
* (death and objective ride the same live HUD and legitimately fire).
*/
import assert from "node:assert/strict";
import { realpathSync } from "node:fs";
import { loadOpenCV } from "../core/cv";
import {
createKillDetector,
type KillData,
} from "../core/detectors/kill/index";
import { createMapStartDetector } from "../core/detectors/map-start/index";
import { createMinimapDetector } from "../core/detectors/minimap/index";
import { createScoreboardDetector } from "../core/detectors/scoreboard/index";
import { createScoreboardBattleLogDetector } from "../core/detectors/scoreboard-battle-log/index";
import { createScoreboardBattleLogReplayDetector } from "../core/detectors/scoreboard-battle-log-replay/index";
import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index";
import type { Detector } from "../core/detectors/types";
import {
type Fixture,
isFieldSkipped,
loadFixtures,
runDetectorOnFixture,
} from "../node/fixtures";
import { loadScoreboardResources } from "../node/resources";
import { test } from "./node-test-compat";
await loadOpenCV();
const resources = await loadScoreboardResources();
const detector = createKillDetector(resources);
const fixtures = loadFixtures("kill");
test("kill fixtures exist", () => {
assert.ok(fixtures.length > 0, "no fixtures found under kill/");
});
for (const fixture of fixtures) {
test(`kill/${fixture.name}`, async (t) => {
const { gate, events } = await runDetectorOnFixture<KillData>(
detector,
fixture,
);
const expectPositive = fixture.expected.event === "Kill";
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.pass
? "gate passed but parse emitted no event (no row read as a feed message?)"
: "no event (gate did not fire)",
);
const expected = fixture.expected.data ?? {};
const debug = () => JSON.stringify(event.debug);
await t.test(
"time",
{ skip: expected.time === undefined || skip(fixture, "time") },
() => {
assert.equal(
event.data.time,
expected.time,
`time mismatch (${debug()})`,
);
},
);
await t.test(
"rows",
{ skip: expected.names === undefined || skip(fixture, "names") },
() => {
assert.equal(
event.data.names.length,
expected.names!.length,
`row count mismatch (${debug()})`,
);
},
);
for (const [row, want] of (expected.names ?? []).entries()) {
await t.test(
`names[${row}]`,
{ skip: skip(fixture, `names.${row}`) },
() => {
assert.equal(
event.data.names[row],
want,
`name mismatch (${debug()})`,
);
},
);
}
});
}
// The feed sits on live gameplay, which the other screens replace; the kill
// gate must stay quiet on every other detector's positives — except the
// frames a kill fixture shares (symlinked): those legitimately show a feed.
const killFrames = new Set(fixtures.map((f) => realpathSync(f.framePath)));
const otherPositives: readonly [string, string][] = [
["scoreboard", "Scoreboard"],
["scoreboard-battle-log-replay", "ScoreboardBattleLogReplay"],
["scoreboard-battle-log", "ScoreboardBattleLog"],
["scoreboard-own", "ScoreboardOwn"],
["death", "Death"],
["map-start", "MapStart"],
["minimap", "Minimap"],
["objective", "Objective"],
["player-status", "PlayerStatus"],
["strip-weapons", "StripWeapons"],
];
for (const [dir, eventType] of otherPositives) {
for (const fixture of loadFixtures(dir).filter(
(f) =>
f.expected.event === eventType &&
!killFrames.has(realpathSync(f.framePath)),
)) {
test(`kill gate stays quiet on ${dir}/${fixture.name}`, async () => {
const { gate } = await runDetectorOnFixture(detector, fixture);
assert.equal(
gate.pass,
false,
`kill gate fired (score=${gate.score.toFixed(3)})`,
);
});
}
}
// ...and on the shared negatives (tests/fixtures/negative/).
for (const fixture of loadFixtures("negative")) {
test(`kill gate stays quiet on negative/${fixture.name}`, async () => {
const { gate } = await runDetectorOnFixture(detector, fixture);
assert.equal(
gate.pass,
false,
`kill gate fired (score=${gate.score.toFixed(3)})`,
);
});
}
// Death and objective overlays share the live HUD with the feed (the
// double-row fixture shows a trade with the death cam up), so only the
// screen-replacing detectors are swept the other way.
const otherDetectors: readonly [string, Detector<unknown>][] = [
["scoreboard", createScoreboardDetector(resources) as Detector<unknown>],
[
"scoreboard-battle-log-replay",
createScoreboardBattleLogReplayDetector(resources) as Detector<unknown>,
],
[
"scoreboard-battle-log",
createScoreboardBattleLogDetector(resources) as Detector<unknown>,
],
[
"scoreboard-own",
createScoreboardOwnDetector(resources) as Detector<unknown>,
],
["map-start", createMapStartDetector(resources) as Detector<unknown>],
["minimap", createMinimapDetector(resources) as Detector<unknown>],
];
for (const fixture of fixtures.filter((f) => f.expected.event === "Kill")) {
test(`other gates stay quiet on kill/${fixture.name}`, async () => {
for (const [name, other] of otherDetectors) {
const { gate } = await runDetectorOnFixture(other, fixture);
assert.equal(
gate.pass,
false,
`${name} 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,90 @@
import assert from "node:assert/strict";
import { matchKillMessage } from "../../core/detectors/kill/message";
import { test } from "../node-test-compat";
const cases: {
why: string;
read: string;
lang: string;
name: string | null;
minScore: number;
}[] = [
{
why: "clean English read",
read: "Splatted nwrm!",
lang: "USen",
name: "nwrm",
minScore: 1,
},
{
why: "a dropped constant glyph still strips the whole word",
read: "Splatte datkid!",
lang: "USen",
name: "datkid",
minScore: 0.85,
},
{
why: "a bar read for the exclamation mark counts as one",
read: "Splatted 24Kl",
lang: "USen",
name: "24K",
minScore: 1,
},
{
why: "a name with spaces survives",
read: "Splatted Now or Never!",
lang: "USen",
name: "Now or Never",
minScore: 1,
},
{
why: "a name-first language keys on the tail",
read: "nwrm erledigt!",
lang: "EUde",
name: "nwrm",
minScore: 1,
},
{
why: "case and accents fold in the constant text",
read: "éclaboussé nwrm!",
lang: "USfr",
name: "nwrm",
minScore: 1,
},
{
why: "a missing exclamation mark strips nothing off the name",
read: "Splatted nwrm",
lang: "USen",
name: "nwrm",
minScore: 0.85,
},
{
why: "the constant text alone leaves no name",
read: "Splatted !",
lang: "USen",
name: null,
minScore: 1,
},
];
for (const { why, read, lang, name, minScore } of cases) {
test(`matchKillMessage: ${why}`, () => {
const match = matchKillMessage(read);
assert.ok(match, "no template matched");
assert.ok(
match.template.langs.includes(lang),
`picked ${match.template.langs.join("/")}`,
);
assert.equal(match.name, name);
assert.ok(
match.score >= minScore,
`score ${match.score.toFixed(2)} under ${minScore}`,
);
});
}
test("matchKillMessage: unrelated text scores low", () => {
const match = matchKillMessage("Respawn in 01");
assert.ok(match);
assert.ok(match.score < 0.5, `score ${match.score.toFixed(2)}`);
});

View File

@@ -6,6 +6,7 @@ import type {
StageId,
} from "~/modules/in-game-lists/types";
import type { DeathData } from "../../core/detectors/death/index";
import type { KillData } from "../../core/detectors/kill/index";
import type {
MinimapData,
MinimapEnemy,
@@ -1298,3 +1299,127 @@ test("pov diamond cards map to scoreboard rows by name", () => {
[false, false, false, false],
]);
});
function kill(
t: number,
names: (string | null)[],
{ time = (300 - Math.round(t)) as number | null } = {},
): DetectedEvent {
const data: KillData = { time, names };
return { type: "Kill", t, confidence: 0.9, data };
}
test("kill reads become one kill per row entering the stack", () => {
const built = buildScannerMatches([
mapStart(0),
kill(60, ["24K"]),
kill(61, ["datkid", "24K"]),
kill(65, ["datkid"]),
scoreboard(300),
]);
assert.deepEqual(built[0]!.match.kills, [
{ t: 60, time: 240, name: "24K" },
{ t: 61, time: 239, name: "datkid" },
]);
});
test("a repeated name past the row lifetime is a fresh kill", () => {
const built = buildScannerMatches([
mapStart(0),
kill(60, ["24K"]),
kill(75, ["24K"]),
scoreboard(300),
]);
assert.deepEqual(
built[0]!.match.kills!.map((k) => k.t),
[60, 75],
);
});
test("a wobbling read of a persisting row is not a new kill", () => {
const built = buildScannerMatches([
mapStart(0),
kill(60, ["datkid"]),
kill(63, ["datkíd"]),
scoreboard(300),
]);
assert.equal(built[0]!.match.kills!.length, 1);
});
test("an unreadable row still counts as a kill", () => {
const built = buildScannerMatches([
mapStart(0),
kill(60, [null]),
scoreboard(300),
]);
assert.deepEqual(built[0]!.match.kills, [{ t: 60, time: 240, name: null }]);
});
test("kill reads off a replay wipe are dropped", () => {
const built = buildScannerMatches([
mapStart(0),
objective(60),
objective(70),
objective(80),
kill(65, ["24K"]),
// a broadcast re-running the 3:30 moment at t=200
kill(200, ["datkid"], { time: 210 }),
scoreboard(300),
]);
assert.deepEqual(
built[0]!.match.kills!.map((k) => k.name),
["24K"],
);
});
test("kills survive on a known non-SZ match", () => {
const built = buildScannerMatches([
mapStart(0, { mode: "TC" }),
kill(60, ["24K"]),
scoreboard(300, { mode: "TC" }),
]);
assert.equal(built[0]!.match.kills!.length, 1);
});
test("a match with no kill reads has null kills", () => {
const built = buildScannerMatches([mapStart(0), scoreboard(300)]);
assert.equal(built[0]!.match.kills, null);
});
test("a read that misses an inner row does not recount the rows it drops", () => {
const built = buildScannerMatches([
mapStart(0),
kill(50, ["Z"]),
// Z's pill blurred: the bottom-up scan stops after the new row
kill(50.5, ["X"]),
kill(51, ["X", "Z"]),
scoreboard(300),
]);
assert.deepEqual(
built[0]!.match.kills!.map((k) => [k.t, k.name]),
[
[50, "Z"],
[50, "X"],
],
);
});
test("the same name twice in one stack is two kills", () => {
const built = buildScannerMatches([
mapStart(0),
kill(60, ["A", "A"]),
kill(61, ["A", "A"]),
scoreboard(300),
]);
assert.equal(built[0]!.match.kills!.length, 2);
});
test("a same-name stack seen again inside the row lifetime is the same row", () => {
const built = buildScannerMatches([
mapStart(0),
kill(60, ["A"]),
kill(64, ["A"]),
scoreboard(300),
]);
assert.equal(built[0]!.match.kills!.length, 1);
});

View File

@@ -31,6 +31,7 @@ function match(
cast: false,
objective: null,
playerStatus: null,
kills: null,
teams: [{ players: alpha.map(player) }, { players: bravo.map(player) }],
winner: null,
pov: null,

View File

@@ -175,3 +175,14 @@ test("per-type merge window: repeat death frames merge, consecutive deaths do no
tl.push(event(200, 0.8, "Scoreboard"));
assert.equal(tl.push(event(212, 0.7, "Scoreboard")).action, "merged");
});
test("kill stacks merge while unchanged and split when a row enters", () => {
const tl = new TimelineBuilder();
const kill = (t: number, names: string[]) =>
tl.push({ type: "Kill", t, confidence: 0.8, data: { time: null, names } });
assert.equal(kill(100, ["24K"]).action, "added");
assert.equal(kill(100.5, ["24K"]).action, "merged");
assert.equal(kill(101, ["datkid", "24K"]).action, "added");
assert.equal(kill(104, ["datkid", "24K"]).action, "merged");
assert.equal(tl.events.length, 2);
});

View File

@@ -22,6 +22,8 @@ import {
LOCALIZED_WEAPON_NAMES,
} from "../../app/features/scanner/core/detectors/death/localized-messages";
import { ALL_WEAPON_ENTRIES } from "../../app/features/scanner/core/detectors/death/weapon-names";
import { KILL_MESSAGE_TEMPLATES } from "../../app/features/scanner/core/detectors/kill/localized-messages";
import { KILL_TEXT_HEIGHT } from "../../app/features/scanner/core/detectors/kill/rois";
import type { AtlasMeta } from "../../app/features/scanner/core/glyphs";
import {
ALL_LOBBY_ENTRIES,
@@ -456,3 +458,18 @@ await build("death-tag-name", 42, [
{ family: "BlitzBold", pxs: [53, 54], chars: nameCharset() },
{ family: "Rowdy", pxs: [52, 54], chars: nameCharset() },
]);
// kill feed: the "Splatted <name>!" rows bottom-center, BlitzMain with ~24px
// caps (kill/rois.ts) — the scoreboard-names charset plus every language's row text
const killFeedTexts = KILL_MESSAGE_TEMPLATES.flatMap((t) => [t.pre, t.post]);
await build("kill-feed", KILL_TEXT_HEIGHT, [
{
family: "BlitzMain",
pxs: [28, 29],
chars: [
...nameCharset(),
...NAME_GREEK,
...nameSymbols("BlitzMain"),
...localizedChars(killFeedTexts, "BlitzMain"),
],
},
]);

View File

@@ -13,11 +13,14 @@
* LayoutMsg/Mng_Result_00 replay-browser VICTORY / DEFEAT tags
* LayoutMsg/VS_Beaten_00 (999) death-burst message; the weapon placeholder sits on
* line 1 or 2 by language, so it becomes a per-language template
* LayoutMsg/VS_BeatMessage_00 (000) kill-feed row ("Splatted <name>!"); the name placeholder
* splits it into a per-language pre/post text pair
* CommonMsg/Weapon/WeaponName_* weapon names, mapped to canonical entries via USen
*
* Usage: pnpm scanner:build-localized-entries [path-to-splat3]
* Writes app/features/scanner/core/localized-entries.ts
* and app/features/scanner/core/detectors/death/localized-messages.ts
* and app/features/scanner/core/detectors/kill/localized-messages.ts
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -38,6 +41,10 @@ const OUT_MESSAGES = new URL(
"../../app/features/scanner/core/detectors/death/localized-messages.ts",
import.meta.url,
).pathname;
const OUT_KILL_MESSAGES = new URL(
"../../app/features/scanner/core/detectors/kill/localized-messages.ts",
import.meta.url,
).pathname;
const CANONICAL_LANG = "USen";
const misc = gameMisc as Record<string, string>;
@@ -270,6 +277,30 @@ for (const lang of languages) {
});
}
interface KillTemplate {
langs: string[];
pre: string;
post: string;
}
// the name placeholder is the first group; KRko carries a second (a particle
// chosen by the name's final syllable) that clean() drops along with markup
const killTemplates: KillTemplate[] = [];
for (const lang of languages) {
const raw = dumps.get(lang)!["LayoutMsg/VS_BeatMessage_00"]!["000"]!;
const line = raw.replace(PLACEHOLDER, SENTINEL).replace(/\[[^\]]*\]/g, "");
if (!line.includes(SENTINEL) || line.includes("\n"))
throw new Error(
`${lang}: kill message is not one line with a name: ${raw}`,
);
const [pre, post] = line
.split(SENTINEL)
.map((s) => s.replace(/[ \t]+/g, " ")) as [string, string];
const existing = killTemplates.find((t) => t.pre === pre && t.post === post);
if (existing) existing.langs.push(lang);
else killTemplates.push({ langs: [lang], pre, post });
}
const WEAPON_MSGS = [
"CommonMsg/Weapon/WeaponName_Main",
"CommonMsg/Weapon/WeaponName_Sub",
@@ -410,11 +441,37 @@ export const LOCALIZED_WEAPON_NAMES: Readonly<
`,
);
writeFileSync(
OUT_KILL_MESSAGES,
`${banner(
`Per-language kill-feed row templates: the "Splatted <name>!" row
* wraps the splatted player's name in language-specific text on either side
* (spaces kept as rendered; either side may be empty).`,
)}
export interface KillMessageTemplate {
/** languages sharing this exact template */
langs: readonly string[];
/** constant text before the name (may end with a space, or be empty) */
pre: string;
/** constant text after the name (may start with a space, or be empty) */
post: string;
}
export const KILL_MESSAGE_TEMPLATES: readonly KillMessageTemplate[] = ${JSON.stringify(
killTemplates,
null,
2,
)};
`,
);
console.info(
`localized-entries: ${languages.length} languages, ` +
`${languageEntries.reduce((n, l) => n + l.stages.length, 0)} stage strings`,
);
console.info(
`localized-messages: ${templates.length} death templates, ` +
`${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names`,
`${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names, ` +
`${killTemplates.length} kill templates`,
);

View File

@@ -5,8 +5,10 @@
*/
import { loadOpenCV, type Mat } from "../../app/features/scanner/core/cv";
import * as death from "../../app/features/scanner/core/detectors/death/rois";
import * as kill from "../../app/features/scanner/core/detectors/kill/rois";
import * as mapStart from "../../app/features/scanner/core/detectors/map-start/rois";
import * as minimap from "../../app/features/scanner/core/detectors/minimap/rois";
import { TIMER_DIGIT_ROI } from "../../app/features/scanner/core/detectors/objective/rois";
import * as sb from "../../app/features/scanner/core/detectors/scoreboard/rois";
import * as bl from "../../app/features/scanner/core/detectors/scoreboard-battle-log/rois";
import * as replay from "../../app/features/scanner/core/detectors/scoreboard-battle-log-replay/rois";
@@ -22,7 +24,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-battle-log-replay|scoreboard-battle-log|death|map-start|minimap]",
"usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-battle-log-replay|scoreboard-battle-log|death|kill|map-start|minimap]",
);
process.exit(1);
}
@@ -105,6 +107,13 @@ if (detector === "scoreboard") {
for (const roi of [...death.GATE_BURST_PROBES, ...death.GATE_PANEL_PROBES]) {
rect(frame, roi, [255, 255, 0]);
}
} else if (detector === "kill") {
for (let row = 0; row < kill.MAX_ROWS; row++) {
rect(frame, kill.textRoi(row), [0, 255, 0]);
rect(frame, kill.skullRoi(row), [0, 128, 255]);
for (const roi of kill.darkProbes(row)) rect(frame, roi, [255, 255, 0]);
}
rect(frame, TIMER_DIGIT_ROI, [255, 0, 0]);
} else if (detector === "map-start") {
rect(frame, mapStart.MODE_LABEL_ROI, [0, 255, 0]);
rect(frame, mapStart.MODE_BLOCK_ROI, [255, 0, 0]);

View File

@@ -9,6 +9,10 @@ import {
createDeathDetector,
type DeathData,
} from "../../app/features/scanner/core/detectors/death/index";
import {
createKillDetector,
type KillData,
} from "../../app/features/scanner/core/detectors/kill/index";
import {
createMapStartDetector,
type MapStartData,
@@ -523,3 +527,70 @@ for (const config of configs) {
for (const m of misses) console.info(` ${m}`);
}
}
// Kill fixtures: the feed's stacked rows (names, newest first) and the timer.
{
const detector = createKillDetector(resources);
const fixtures = loadFixtures("kill");
const tally = {
gate: { ok: 0, total: 0 } as Tally,
time: { ok: 0, total: 0 } as Tally,
rows: { ok: 0, total: 0 } as Tally,
names: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
const misses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<KillData>(
detector,
fixture,
);
const expectPositive = fixture.expected.event === "Kill";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.time !== undefined) {
tally.time.total++;
if (event.data.time === expected.time) tally.time.ok++;
else
misses.push(
`${fixture.name}: time ${event.data.time} != ${expected.time}`,
);
}
if (expected.names !== undefined) {
tally.rows.total++;
if (event.data.names.length === expected.names.length) tally.rows.ok++;
else
misses.push(
`${fixture.name}: ${event.data.names.length} rows != ${expected.names.length}`,
);
for (const [row, want] of expected.names.entries()) {
if (want === null) continue;
tally.names.total++;
const got = event.data.names[row] ?? "";
const dist = editDistance(got, want);
charEdits += dist;
charTotal += want.length;
if (dist === 0) tally.names.ok++;
else misses.push(`${fixture.name}: row ${row} "${got}" != "${want}"`);
}
}
}
console.info(`\n=== kill (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`time ${pct(tally.time)}`);
console.info(`rows ${pct(tally.rows)}`);
console.info(`names ${pct(tally.names)}`);
console.info(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);
if (misses.length > 0) {
console.info("misses:");
for (const m of misses) console.info(` ${m}`);
}
}