Improve splatted/special detection

This commit is contained in:
Kalle 2026-08-09 11:49:13 +03:00
parent 9e3fa42d41
commit 9bb37bb0fc
15 changed files with 465 additions and 72 deletions

3
.gitignore vendored
View File

@ -37,3 +37,6 @@ notepad.txt
# proprietary game fonts for the scanner glyph-atlas builders (scripts/scanner)
/assets/fonts/
*.mp4
*.mkv

View File

@ -87,9 +87,17 @@ sequenceDiagram
event type per read: `PlayerStatus`
(`core/detectors/objective/player-status.ts`), per-player special/dead
flags off the icon strip flanking the timer (POV and casted spectator
geometries; the cast layout is picked by its D-pad camera badges), with
geometries; D-pad camera badges prove the cast layout, but broadcasts
can hide them while keeping cast geometry, so a badge-less frame scores
both geometries on how decisively the bodies read and sticks with the
established layout unless the other wins clearly — the special-ready
wash also pulses, so its dim trough is told apart from a splat by its
pale body), with
the same `time` value so the two reads pair downstream; its fixtures
live under `tests/fixtures/player-status/`. Objective reads land on `ScannerMatch` as
live under `tests/fixtures/player-status/`. The builder additionally
flips sub-2s dead-flag runs flanked by dense opposite reads — a splat
outlasts the respawn wait, so those are misread blips (background ink
bleeding through a crossed-out icon). Objective reads land on `ScannerMatch` as
progress samples anchored to the game clock; broadcast replay wipes re-run
an earlier moment with the counter intact, so the builder keeps only the
dominant cluster of clock-zero projections (`t + time`) and drops replay

View File

@ -1,9 +1,9 @@
/**
* Flatten detected events into a single CSV for download. One row per event;
* the six event types share columns where they overlap (lobby/mode/stage,
* the event types share columns where they overlap (lobby/mode/stage,
* weapon/name/abilities) and a scoreboard's eight player rows or the
* minimap's teammates+enemies are packed into one cell, matching the
* compact per-event view of the live feed.
* minimap's teammates+enemies, or the objective HUD's icon strip are packed
* into one cell, matching the compact per-event view of the live feed.
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
@ -23,8 +23,19 @@ import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import {
SCOREBOARD_EVENT_TYPE,
type ScoreboardData,
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import {
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
type ScoreboardBattleLogReplayData,
} from "../core/detectors/scoreboard-battle-log-replay/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
@ -105,6 +116,13 @@ function formatMinimapPlayers(data: MinimapData): string {
].join("; ");
}
/** one team's four icons as ✕ splatted / ★ special ready / · alive */
function formatPlayerStatusSide(data: PlayerStatusData, side: 0 | 1): string {
return data.dead[side]
.map((dead, slot) => (dead ? "✕" : data.special[side][slot] ? "★" : "·"))
.join("");
}
function formatPlayers(data: ScoreboardData): string {
return data.players
.map(
@ -220,9 +238,29 @@ function eventCells(event: CsvEvent): Cell[] {
"",
];
}
default: {
// Scoreboard, ScoreboardBattleLogReplay and ScoreboardBattleLog share
// the base shape
case PLAYER_STATUS_EVENT_TYPE: {
const d = event.data as PlayerStatusData;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
"",
"",
"",
"",
"",
"",
"",
"",
`${clock}${formatPlayerStatusSide(d, 0)} vs ${formatPlayerStatusSide(d, 1)} (${d.layout})`,
"",
"",
];
}
case SCOREBOARD_EVENT_TYPE:
case SCOREBOARD_BATTLE_LOG_EVENT_TYPE:
case SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE: {
// the three scoreboard reads share the base shape
const d = event.data as ScoreboardData &
Partial<ScoreboardBattleLogReplayData>;
return [
@ -241,6 +279,8 @@ function eventCells(event: CsvEvent): Cell[] {
d.timestamp ?? "",
];
}
default:
return [...base, ...Array(HEADER.length - base.length).fill("")];
}
}

View File

@ -44,7 +44,11 @@ import {
} from "../scoreboard/banner";
import type { ScoreboardResources } from "../scoreboard/index";
import type { DetectedEvent, Detector, GateResult } from "../types";
import { type PlayerStatusData, parsePlayerStatus } from "./player-status";
import {
type PlayerStatusData,
type PlayerStatusLayout,
parsePlayerStatus,
} from "./player-status";
import {
CONTROL_PLATE_MIN_SATURATION,
GATE_PLATE_MAX_STD,
@ -62,6 +66,7 @@ import {
SCORE_EXTEND_MIN_CONF,
SCORE_ROIS,
SCORE_TEXT_HEIGHTS,
STATUS_LAYOUT_STICKY_MAX_GAP_S,
TIMER_BIN_THRESHOLD,
TIMER_DARK_PROBES,
TIMER_DIGIT_MIN_CONF,
@ -134,6 +139,7 @@ export function createObjectiveDetector(
resources: ScoreboardResources,
): Detector<ObjectiveData | PlayerStatusData> {
const cv = getCV();
let lastStatus: { layout: PlayerStatusLayout; t: number } | undefined;
const scoreSets: GlyphSet[] = resources.paintDigits
? SCORE_TEXT_HEIGHTS.map((h) =>
@ -368,6 +374,16 @@ export function createObjectiveDetector(
// no readable count on either side = the gate hit a lookalike
if (sides.every((side) => side.score.value === null)) return [];
const playerStatus = parsePlayerStatus(
frame,
t,
timer.value,
lastStatus && t - lastStatus.t <= STATUS_LAYOUT_STICKY_MAX_GAP_S
? lastStatus.layout
: undefined,
);
lastStatus = { layout: playerStatus.data.layout, t };
const confidences = sides.flatMap((side) => [
...(side.score.value !== null ? [side.score.confidence] : []),
...(side.penalty?.value != null ? [side.penalty.confidence] : []),
@ -399,7 +415,7 @@ export function createObjectiveDetector(
plateFills: sides.map((side) => side.fill),
},
},
parsePlayerStatus(frame, t, timer.value),
playerStatus,
];
}

View File

@ -3,12 +3,17 @@
* flanking the match timer, emitted by the ObjectiveDetector alongside each
* Objective read (same frame, same `time`, so callers can pair the two).
*
* Per slot, two pixel-class fractions decide the state (rois.ts documents
* Per slot, three pixel-class fractions decide the state (rois.ts documents
* the calibration): an alive icon's body is saturated team ink; holding
* special washes the upper body into a bright pale glow (the shoulder
* probe); a splatted icon is an unsaturated grey/dark X with neither. The
* casted spectator HUD draws the same strip at its own geometry the
* white D-pad camera badges under the right team pick the layout.
* special washes the body into a bright pale glow that PULSES bright
* frames light the shoulder probe past the glow floor, trough frames only
* read as pale and a splatted icon is an unsaturated grey/dark X with
* none of the three. The casted spectator HUD draws the same strip at its
* own geometry. White D-pad camera badges under the right team prove the
* cast layout, but broadcasts can hide them while keeping cast geometry,
* so a badge-less frame picks whichever geometry reads more decisively
* (bodies far from the dead threshold on either side) instead of assuming
* POV.
*/
import type { Mat } from "../../cv";
import { copyRoi, type Roi } from "../../image";
@ -18,11 +23,17 @@ import {
STATUS_BODY_BOX_POV,
STATUS_CAST_MIN_DPAD_WHITE,
STATUS_DEAD_MAX_BODY_INK,
STATUS_DEAD_MAX_BODY_PALE,
STATUS_DEAD_MAX_SHOULDER_GLOW,
STATUS_DPAD_PROBES,
STATUS_GLOW_MIN_VALUE,
STATUS_INK_MIN_SPREAD,
STATUS_INK_MIN_VALUE,
STATUS_LAYOUT_SCORE_CAP,
STATUS_LAYOUT_STICKY_MARGIN,
STATUS_PALE_MAX_SPREAD,
STATUS_PALE_MIN_VALUE,
STATUS_READY_MIN_BODY_PALE,
STATUS_READY_MIN_SHOULDER_GLOW,
STATUS_SHOULDER_BOX_CAST,
STATUS_SHOULDER_BOX_POV,
@ -75,6 +86,7 @@ interface SlotRead {
special: boolean;
confidence: number;
bodyInk: number;
bodyPale: number;
shoulderGlow: number;
}
@ -82,38 +94,19 @@ interface SlotRead {
* Parse the icon strip of a frame the objective gate already anchored as
* the in-match counter HUD. Callers emit the event only alongside a
* successful Objective read the counter parse carries the lookalike
* rejection for both.
* rejection for both. `prevLayout` is the layout of the caller's previous
* read (sticky: a badge-less frame only switches geometry when the other
* layout wins the decisiveness score by a clear margin the footage type
* does not flip frame to frame, but a busy scene can nudge the score).
*/
export function parsePlayerStatus(
frame: Mat,
t: number,
time: number | null,
prevLayout?: PlayerStatusLayout,
): DetectedEvent<PlayerStatusData> {
const layout: PlayerStatusLayout = isCastLayout(frame) ? "cast" : "pov";
const centers =
layout === "cast" ? STATUS_SLOT_CENTERS_CAST : STATUS_SLOT_CENTERS_POV;
const shoulderBox =
layout === "cast" ? STATUS_SHOULDER_BOX_CAST : STATUS_SHOULDER_BOX_POV;
const bodyBox =
layout === "cast" ? STATUS_BODY_BOX_CAST : STATUS_BODY_BOX_POV;
const sides = centers.map((sideCenters) =>
sideCenters.map((cx): SlotRead => {
const shoulder = classFractions(frame, {
x: cx + shoulderBox.dx,
y: shoulderBox.y,
w: shoulderBox.w,
h: shoulderBox.h,
});
const body = classFractions(frame, {
x: cx + bodyBox.dx,
y: bodyBox.y,
w: bodyBox.w,
h: bodyBox.h,
});
return classifySlot(body.ink, shoulder.glow);
}),
) as [SlotRead[], SlotRead[]];
const { layout, scores } = pickLayout(frame, prevLayout);
const sides = readSlots(frame, layout);
const reads = sides.flat();
return {
@ -133,39 +126,145 @@ export function parsePlayerStatus(
},
debug: {
layout,
layoutScores: scores
? {
pov: Number(scores.pov.toFixed(3)),
cast: Number(scores.cast.toFixed(3)),
}
: "badges",
bodyInk: reads.map((read) => Number(read.bodyInk.toFixed(2))),
bodyPale: reads.map((read) => Number(read.bodyPale.toFixed(2))),
shoulderGlow: reads.map((read) => Number(read.shoulderGlow.toFixed(2))),
},
};
}
function readSlots(
frame: Mat,
layout: PlayerStatusLayout,
): [SlotRead[], SlotRead[]] {
const centers =
layout === "cast" ? STATUS_SLOT_CENTERS_CAST : STATUS_SLOT_CENTERS_POV;
const shoulderBox =
layout === "cast" ? STATUS_SHOULDER_BOX_CAST : STATUS_SHOULDER_BOX_POV;
const bodyBox =
layout === "cast" ? STATUS_BODY_BOX_CAST : STATUS_BODY_BOX_POV;
return centers.map((sideCenters) =>
sideCenters.map((cx): SlotRead => {
const shoulder = classFractions(frame, {
x: cx + shoulderBox.dx,
y: shoulderBox.y,
w: shoulderBox.w,
h: shoulderBox.h,
});
const body = classFractions(frame, {
x: cx + bodyBox.dx,
y: bodyBox.y,
w: bodyBox.w,
h: bodyBox.h,
});
return classifySlot(body.ink, body.pale, shoulder.glow);
}),
) as [SlotRead[], SlotRead[]];
}
/**
* State from the two fractions, with a confidence scaled by the distance
* Camera badges prove the cast layout outright. Badge-less frames are NOT
* proven POV broadcasts can hide the badges while keeping the cast icon
* geometry so both geometries are tried and the one whose body reads
* land decisively on either side of the dead threshold wins. A mispicked
* geometry puts outer-slot boxes between icons or on backdrop, which reads
* mid-range ink exactly what the score punishes; boxes on featureless
* dark backdrop still read "decisively dead" though, so a busy scene can
* nudge a frame's score across the sticky margin keeps a single noisy
* frame from flipping an established layout.
*/
function pickLayout(
frame: Mat,
prevLayout: PlayerStatusLayout | undefined,
): {
layout: PlayerStatusLayout;
scores: { pov: number; cast: number } | null;
} {
if (isCastLayout(frame)) return { layout: "cast", scores: null };
const scores = {
pov: layoutDecisiveness(frame, "pov"),
cast: layoutDecisiveness(frame, "cast"),
};
if (prevLayout) {
const other: PlayerStatusLayout = prevLayout === "pov" ? "cast" : "pov";
return {
layout:
scores[other] > scores[prevLayout] + STATUS_LAYOUT_STICKY_MARGIN
? other
: prevLayout,
scores,
};
}
return { layout: scores.pov >= scores.cast ? "pov" : "cast", scores };
}
function layoutDecisiveness(frame: Mat, layout: PlayerStatusLayout): number {
const reads = readSlots(frame, layout).flat();
return (
reads.reduce(
(sum, read) =>
sum +
Math.min(
Math.abs(read.bodyInk - STATUS_DEAD_MAX_BODY_INK),
STATUS_LAYOUT_SCORE_CAP,
),
0,
) / reads.length
);
}
/**
* State from the three fractions, with a confidence scaled by the distance
* to the nearest decision boundary (1 at twice the threshold / at zero).
*/
function classifySlot(bodyInk: number, shoulderGlow: number): SlotRead {
function classifySlot(
bodyInk: number,
bodyPale: number,
shoulderGlow: number,
): SlotRead {
const dead =
bodyInk <= STATUS_DEAD_MAX_BODY_INK &&
shoulderGlow <= STATUS_DEAD_MAX_SHOULDER_GLOW;
const special = !dead && shoulderGlow >= STATUS_READY_MIN_SHOULDER_GLOW;
shoulderGlow <= STATUS_DEAD_MAX_SHOULDER_GLOW &&
bodyPale <= STATUS_DEAD_MAX_BODY_PALE;
const special =
!dead &&
(shoulderGlow >= STATUS_READY_MIN_SHOULDER_GLOW ||
bodyPale >= STATUS_READY_MIN_BODY_PALE);
const confidence = dead
? Math.min(
1,
(STATUS_DEAD_MAX_BODY_INK - bodyInk) / STATUS_DEAD_MAX_BODY_INK,
)
: special
? Math.min(1, shoulderGlow / (STATUS_READY_MIN_SHOULDER_GLOW * 2))
? Math.min(
1,
Math.max(
shoulderGlow / (STATUS_READY_MIN_SHOULDER_GLOW * 2),
bodyPale / (STATUS_READY_MIN_BODY_PALE * 2),
),
)
: Math.min(1, bodyInk / (STATUS_DEAD_MAX_BODY_INK * 2));
return { dead, special, confidence, bodyInk, shoulderGlow };
return { dead, special, confidence, bodyInk, bodyPale, shoulderGlow };
}
/** Ink and glow pixel fractions of a ROI (see rois.ts for the classes). */
function classFractions(frame: Mat, roi: Roi): { ink: number; glow: number } {
/** Ink, glow, and pale pixel fractions of a ROI (see rois.ts for the classes). */
function classFractions(
frame: Mat,
roi: Roi,
): { ink: number; glow: number; pale: number } {
const crop = copyRoi(frame, roi);
const { data } = crop;
const channels = crop.channels();
let ink = 0;
let glow = 0;
let pale = 0;
let count = 0;
for (let i = 0; i < data.length; i += channels) {
const r = data[i]!;
@ -175,10 +274,12 @@ function classFractions(frame: Mat, roi: Roi): { ink: number; glow: number } {
const spread = value - Math.min(r, g, b);
if (spread >= STATUS_INK_MIN_SPREAD && value >= STATUS_INK_MIN_VALUE) ink++;
if (value >= STATUS_GLOW_MIN_VALUE) glow++;
if (value >= STATUS_PALE_MIN_VALUE && spread <= STATUS_PALE_MAX_SPREAD)
pale++;
count++;
}
crop.delete();
return { ink: ink / count, glow: glow / count };
return { ink: ink / count, glow: glow / count, pale: pale / count };
}
/** All four D-pad probes reading white = the casted spectator layout. */

View File

@ -141,6 +141,13 @@ export const CONTROL_PLATE_MIN_SATURATION = 60;
// measured per side off the fixtures; neither layout is mirror-symmetric
// (POV inner icons sit 108px left / 130px right of screen center) and the
// cast sides don't even share a pitch (~103 left vs ~88 right).
//
// Broadcasts can hide the camera badges while keeping the cast icon
// geometry (attested in the AREA CUP VoD), so badge absence alone cannot
// pick the POV layout — player-status.ts scores both geometries and keeps
// the one whose body reads sit decisively on either side of the dead
// threshold; a mispicked layout puts outer-slot boxes between icons and
// flickers phantom deaths on the outermost players.
/** Per-side slot center x's, slots left-to-right. */
export const STATUS_SLOT_CENTERS_POV: readonly [
@ -171,9 +178,13 @@ export const STATUS_SHOULDER_BOX_CAST = { dx: -30, y: 35, w: 24, h: 30 };
* Body probe: the widest band of the icon that dodges the cast camera
* badges below (y>=100) and the POV coin trinkets (y>=95). Team ink
* presence here separates alive icons from the grey/dark splatted ones.
* Sized to cover most of the icon: a slimmer box left alive icons whose
* body is largely weapon silhouette/badges reading ink 0.24 while stage
* ink bleeding around a translucent dead icon read 0.23 (AREA CUP VoD)
* this footprint separates them at 0.26 vs 0.20.
*/
export const STATUS_BODY_BOX_POV = { dx: -32, y: 44, w: 64, h: 48 };
export const STATUS_BODY_BOX_CAST = { dx: -32, y: 55, w: 64, h: 43 };
export const STATUS_BODY_BOX_POV = { dx: -40, y: 40, w: 80, h: 50 };
export const STATUS_BODY_BOX_CAST = { dx: -40, y: 45, w: 80, h: 50 };
/**
* An ink pixel: saturated and bright enough to be team color. The value
@ -191,16 +202,52 @@ export const STATUS_INK_MIN_VALUE = 105;
export const STATUS_GLOW_MIN_VALUE = 225;
/**
* Splatted: body ink under the floor (attested dead <=0.09 vs alive
* >=0.40) with the shoulder-glow guard keeping the near-white ready wash
* (body ink as low as 0.03, glow >=0.40 vs dead <=0.03) out of it.
* A pale pixel: bright but unsaturated, the ready wash across its whole
* pulse cycle. The wash PULSES its trough dims below the glow floor
* (~190-220) while staying pale, so a trough frame reads no ink and no
* glow; without the pale class it is indistinguishable from a splat.
*/
export const STATUS_DEAD_MAX_BODY_INK = 0.22;
export const STATUS_PALE_MIN_VALUE = 185;
export const STATUS_PALE_MAX_SPREAD = 70;
/**
* Splatted: body ink under the floor (attested dead <=0.20 vs alive
* >=0.26 with the current body box) with two guards: shoulder glow keeps
* the bright ready wash out (glow >=0.40 vs dead <=0.03), body pale keeps
* the wash's dim pulse trough out (pale >=0.27 vs dead <=0.07).
*/
export const STATUS_DEAD_MAX_BODY_INK = 0.23;
export const STATUS_DEAD_MAX_SHOULDER_GLOW = 0.2;
export const STATUS_DEAD_MAX_BODY_PALE = 0.15;
/** Special ready: shoulder glow past this (attested >=0.40 vs <=0.06). */
export const STATUS_READY_MIN_SHOULDER_GLOW = 0.25;
/**
* Special ready off the body when the shoulder misses the wash (pulse
* trough, or wash dimmed on compressed footage): pale-dominant body
* (attested ready >=0.40 vs alive <=0.25 plain alive bodies show some
* pale from weapon-silhouette whites).
*/
export const STATUS_READY_MIN_BODY_PALE = 0.32;
/**
* Layout scoring (see player-status.ts): per-slot decisiveness is the
* body-ink distance from the dead threshold, capped so one saturated slot
* cannot carry a misaligned geometry. The sticky margin is what the
* non-established layout must win the score by before a badge-less frame
* switches an established geometry.
*/
export const STATUS_LAYOUT_SCORE_CAP = 0.3;
export const STATUS_LAYOUT_STICKY_MARGIN = 0.04;
/**
* An established layout only carries forward across reads this close in
* time in-match reads land ~1s apart, while a longer silence means a new
* match (possibly new footage type) and the next frame picks fresh.
*/
export const STATUS_LAYOUT_STICKY_MAX_GAP_S = 30;
/**
* Cast-layout discriminator: the spectator HUD always draws white D-pad
* camera badges under the right team's icons; nothing fixed sits there on

View File

@ -95,6 +95,20 @@ const MIN_TEAM_HUE_SEPARATION = 30;
*/
const REPLAY_ANCHOR_TOLERANCE_SECONDS = 10;
/**
* Dead-flag runs that fit between their flanking opposite-state reads in
* less than these spans are physically impossible and get flipped to their
* surroundings: the fastest respawn in Splatoon is 3.5s, so no true dead
* stretch is shorter while a respawned player CAN be re-splatted quickly
* (spawncamps are real), so the alive floor stays a conservative 2s and
* only clears blips like background ink bleeding through a crossed-out
* icon. Judging by the flank-to-flank span (the longest the state could
* truly have held) keeps sparse sampling honest: a lone dead read between
* far-apart reads spans wide and is left alone.
*/
const DEAD_RUN_MIN_SECONDS = 3.5;
const ALIVE_RUN_MIN_SECONDS = 2;
export interface BuiltMatch<E extends DetectedEvent> {
match: ScannerMatch;
/**
@ -552,23 +566,77 @@ function buildProgress(
liveStatuses.length === 0
? null
: {
samples: liveStatuses.map((read): ScannerMatchPlayerStatusSample => {
const swapped = read.fromMinimap
? minimapSwapped
: nearestSwapFlag(live, swapFlags, read.t) !== swap;
const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const);
return {
t: Math.max(0, Math.floor(read.t)),
time: read.data.time,
special: [read.data.special[a], read.data.special[b]],
dead: [read.data.dead[a], read.data.dead[b]],
};
}),
samples: withImpossibleDeadRunsFlipped(
liveStatuses.map((read): ScannerMatchPlayerStatusSample => {
const swapped = read.fromMinimap
? minimapSwapped
: nearestSwapFlag(live, swapFlags, read.t) !== swap;
const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const);
return {
t: Math.max(0, Math.floor(read.t)),
time: read.data.time,
special: [read.data.special[a], read.data.special[b]],
dead: [read.data.dead[a], read.data.dead[b]],
};
}),
),
};
return { objective, playerStatus };
}
/**
* Debounce per-slot dead flags across a match's samples: an interior run
* whose flanking opposite-state reads sit closer together than the state
* could truly have held (DEAD/ALIVE_RUN_MIN_SECONDS) is a misread blip
* (background ink bleeding through a translucent splatted icon, or a box
* over a mid-animation icon) and takes the flanking state instead. Edge
* runs stay nothing attests what came before or after the match window.
*/
function withImpossibleDeadRunsFlipped(
samples: ScannerMatchPlayerStatusSample[],
): ScannerMatchPlayerStatusSample[] {
let smoothed = samples;
for (const side of [0, 1] as const) {
for (let slot = 0; slot < PLAYERS_PER_TEAM; slot++) {
// flipping one blip can expose the next (alternating flicker), so
// each slot's series is re-swept until it settles
let changed = true;
while (changed) {
changed = false;
const series = smoothed.map((sample) => sample.dead[side][slot]);
let runStart = 0;
for (let i = 1; i <= series.length; i++) {
if (i < series.length && series[i] === series[runStart]) continue;
const interior = runStart > 0 && i < series.length;
const impossiblyShort =
interior &&
smoothed[i]!.t - smoothed[runStart - 1]!.t <=
(series[runStart] ? DEAD_RUN_MIN_SECONDS : ALIVE_RUN_MIN_SECONDS);
if (impossiblyShort) {
if (smoothed === samples) {
smoothed = samples.map((sample) => ({
...sample,
dead: [[...sample.dead[0]], [...sample.dead[1]]] as [
PlayerStatusFlags,
PlayerStatusFlags,
],
}));
}
for (let j = runStart; j < i; j++) {
smoothed[j]!.dead[side][slot] = !series[runStart];
}
changed = true;
break;
}
runStart = i;
}
}
}
}
return smoothed;
}
/** A status read from either source, sides as read (pre-orientation). */
interface StatusRead {
t: number;

View File

@ -0,0 +1,18 @@
{
"event": "PlayerStatus",
"data": {
"layout": "cast",
"time": 258,
"special": [
[false, false, false, false],
[false, false, false, false]
],
"dead": [
[false, false, false, false],
[false, false, false, false]
]
},
"options": {
"notes": "JP broadcast (AREA CUP) spectator HUD with the camera-button badges hidden — the strip keeps the cast icon geometry, so the layout must resolve to cast via the geometry score, not the badge probe (reading it as POV drifts the outer-slot boxes off their icons and flickers phantom deaths). All eight players alive."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@ -0,0 +1,18 @@
{
"event": "PlayerStatus",
"data": {
"layout": "cast",
"time": 256,
"special": [
[true, false, false, false],
[false, false, false, false]
],
"dead": [
[false, false, false, false],
[true, false, true, false]
]
},
"options": {
"notes": "Same badge-less AREA CUP spectator HUD two seconds on. Left team's outermost slot holds special caught at the dim trough of the ready wash's pulse — pale but below the glow floor, previously misread as splatted. Right team's first and third slots are splatted (grey X'd icons)."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@ -0,0 +1,18 @@
{
"event": "PlayerStatus",
"data": {
"layout": "cast",
"time": 248,
"special": [
[false, false, true, false],
[false, false, false, false]
],
"dead": [
[false, false, false, false],
[false, false, false, true]
]
},
"options": {
"notes": "AREA CUP spectator HUD with the camera-button badges visible. Right team's outermost slot is splatted with the arena behind it covered in that team's ink color — the ink bleeding around the translucent crossed-out icon previously read as an alive body. Left team's third slot holds special (pale wash behind the icon)."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -948,6 +948,58 @@ test("status reads inherit the nearest counter read's cast orientation", () => {
assert.equal(built[0]!.match.cast, true);
});
test("sub-2s dead-flag blips between dense opposite reads get flipped", () => {
const deadAt = (slots: number[]) =>
[
[false, false, false, false],
[0, 1, 2, 3].map((slot) => slots.includes(slot)),
] as PlayerStatusData["dead"];
const built = buildScannerMatches([
mapStart(0),
// slot0: a real death 101-107 with a one-read false "respawn" at 104
// (background ink bleeding through the crossed-out icon), plus a
// one-read false death at 111 after the real respawn
playerStatus(100, { dead: deadAt([]) }),
playerStatus(101, { dead: deadAt([0]) }),
playerStatus(102, { dead: deadAt([0]) }),
playerStatus(103, { dead: deadAt([0]) }),
playerStatus(104, { dead: deadAt([]) }),
playerStatus(105, { dead: deadAt([0]) }),
playerStatus(106, { dead: deadAt([0]) }),
playerStatus(107, { dead: deadAt([0]) }),
playerStatus(108, { dead: deadAt([]) }),
playerStatus(109, { dead: deadAt([]) }),
playerStatus(110, { dead: deadAt([]) }),
playerStatus(111, { dead: deadAt([0]) }),
playerStatus(112, { dead: deadAt([]) }),
playerStatus(113, { dead: deadAt([]) }),
scoreboard(300),
]);
const slot0Deads = built[0]!.match.playerStatus!.samples.map(
(sample) => sample.dead[1][0],
);
assert.deepEqual(slot0Deads, [
false,
...Array.from({ length: 7 }, () => true),
...Array.from({ length: 6 }, () => false),
]);
});
test("a lone dead read between sparse reads is kept", () => {
const dead = [
[false, false, false, false],
[true, false, false, false],
] as PlayerStatusData["dead"];
const built = buildScannerMatches([
mapStart(0),
playerStatus(60),
playerStatus(120, { dead }),
playerStatus(180),
scoreboard(300),
]);
assert.deepEqual(built[0]!.match.playerStatus!.samples[1]!.dead, dead);
});
test("a known non-SZ match drops its player-status reads too", () => {
const events = [
mapStart(0, { mode: "CB" }),

View File

@ -33,7 +33,6 @@ import test from "./node-test-compat";
await loadOpenCV();
const resources = await loadScoreboardResources();
const detector = createObjectiveDetector(resources);
const fixtures = loadFixtures("player-status");
test("player-status fixtures exist", () => {
@ -42,7 +41,12 @@ test("player-status fixtures exist", () => {
for (const fixture of fixtures) {
test(`player-status/${fixture.name}`, async (t) => {
const { gate, events } = await runDetectorOnFixture(detector, fixture);
// fresh detector per fixture: the objective detector carries sticky
// layout state across reads, and fixtures are unrelated frames
const { gate, events } = await runDetectorOnFixture(
createObjectiveDetector(resources),
fixture,
);
const expectPositive = fixture.expected.event === "PlayerStatus";
await t.test("gate", () => {