Scanner · Overview
3 modules · 29 exports · 0 repositories · 14 e2e tests · 1 route
Browser app (route /scanner, linked from the Tools menu) that watches a
capture card or OBS Virtual Camera while you play, or scans VoD files,
detects Splatoon 3 UI screens with OpenCV.js in a Web Worker, and parses them
into events speaking sendou.ink ids (ModeShort/StageId/weapon ids/Ability).
Events aggregate client-side into ScannerMatch objects
(core/scanner-match.ts) — one detected game per object, every field
nullable — which feed /ingest (features/scanner-ingest, live captures only), the /vods/new
prefill, the match cards and the clip cutter. Imported from the emberz repo;
see MIGRATION.md there.
Deliberate convention exceptions (ported wholesale): the UI is English-only
(no i18next; the public release should flip that), tests/node-test-compat.ts
uses a default export to stay a node:test drop-in, and the suites assert
with node:assert/strict rather than the repo-wide expect. Keep whichever
file you touch on the idiom it already uses — a half-migration would leave
three idioms behind.
Product shape
Section titled “Product shape”Anyone can capture, scan files and get clips locally; only uploading needs a
login (components/upload.ts mirrors the root loader’s user for the
controllers). The landing (components/LandingView.tsx) is a title and a
“How it works” disclosure (requirements, what’s read, privacy, what’s
uploaded — keep it in sync with UPLOADED_LOBBIES), two entry cards
(Live / File), the clip history strip and the sessions list; everything else
is one component, components/SessionView.tsx, rendered identically for the
running capture (LiveView), a past session (PastSessionView) and a
scanned VoD (VodView): header, clip strip, then match cards
(components/MatchCard.tsx) newest first. Views are picked by the view
search param (scanner-search-params.ts: live, session&id=,
vod&name=, coach&name= (a scanned file’s coach events beside its video), clips, debug and the dev-only fixtures and montage). Nothing links to
the debug screenshot view: dropping an image on the landing’s File card
opens it, for anyone, through the same handoff Inspect uses.
- Controllers are module singletons, not view state: the capture
(
components/live-session.ts) and a running VoD scan (components/vod-scan.ts) are mounted above the view switch, so moving between views never stops them; a capture runs until Stop (even off the page), a scan until it finishes or the scanner page is left. Views subscribe throughuseSyncExternalStorehooks, as they do to the event feed (components/events-feed.ts, sessions built once per refresh), the clip list (clips-feed.ts), the VoD list (vods-feed.ts) and the localStorage settings (settings.ts: source, upload toggle, clip toggle). - Sessions are client-only and derived at render (
core/sessions.ts): live events ordered bydetectedAt, split wherever a gap of ≥ 2 h opens, keyed by the first event’sdetectedAt(the URL id). A VoD is its own session keyed by file name. Livetis wall-clock seconds (Date.now()) so events from different page loads share one timeline; the ring buffer stamps footage the same way. - Retention (
store/events.ts, on a throttled pass at every save): whole sessions older than 30 days, beyond the newest 20 or past theMAX_STORED_EVENTSbudget (~140 SZ games; the newest session is never cut) go — never part of one, so a kept session’s cards always expand to their full timeline and scoreboards. A saved event re-reads only what was saved since the last read (plus the events a send status write named), and a match whose events are unchanged objects is reused rather than rebuilt (MatchBuildCache); live sends take their matches from the feed. So neither the store’s size nor a long session costs much per saved event. - Compaction (
events-feed.tson refresh,store/compacted-matches.ts): 72 h after a session ends its games are frozen as built, one record each, keeping every source event except the per-second Objective/PlayerStatus/ StripWeapons reads (compactSources; ~85% of a game’s events, ~6× its bytes), and its raw events are deleted in the same transaction. Cards, uploads (sendCompacted) and the game data zip read a compacted session like a raw one, but it no longer picks up match builder fixes and itsRaw detectionsCSV option is disabled. Compacted sessions share the 30-day / 20-session retention with the raw ones. Every listed event keeps its full-res analyzed frame as a lossless WebP (~1.1 MB at 1080p, half a PNG; the montage’s VoD scans skip it withattachFrames: false), live and VoD frames alike bounded by 72 h and a shared 500 MB budget, newest first (store/frames.ts), the event staying withhasFrame: false. A VoD scan also keeps every frame of each map open from its first kept minimap read on, downscaled to 960 px lossy WebP (vod-minimaps,snapshotMinimaps), for coach mode’s “map as last opened”; those live as long as the VoD and are not trimmed. Clips have their own cap and outlive session deletion. - Upload is on by default when logged in (settings toggle, persisted).
Live: a scoreboard closes its match and sends it, a 15 s tick retries
unlinked matches on a backoff (
sendou-ingest.ts) and flushes closed matches whose send was skipped; Stop sends what is left. Server-side, a game report (tournament or SendouQ) links the stored, still unlinked matches hinted to its context (scanner-ingest/core/relink.server.ts), and a SendouQ report undo drops the map’s links (a tournament undo deletes the result, cascading them), so a correction relinks without a resend./ingestrefuses (429) a sender past their quota (stored matches per hour, stored size per day), a game links at most one read per sender, and a daily routine deletes linkable reads still unlinked after 30 days. Per-event send statuses back the cards’ upload status button (UploadStatus.tsx) and its Retry/Upload. VoD scans never upload: their reads carry no wall clock, and without one a stranger’s game can’t be told apart from the uploader’s (linking needs aplayedAtwithin 30 min of the game’s report). - CSV is a normal feature:
⇩ CSVin every session header offersMatches(core/csv/matches.ts, one row per game, the rows the cards render) andRaw detections(core/csv/events.ts, one row per event). Column names stay English keys. - Game data (
components/match-zip.ts): an expanded match card’s download, what users report a misread with —match.json(the card’s match plus its clips’ kills and stream/filestart/end),events.json(every source event, each naming its frame folder) andframes/<n>-<type>-<position>/with the frame plus a prefilledexpected.json, fixture-ready. - Debug gate (
use-debug.ts: DEV/ADMIN role or?debug=true, which Settings → Debug →Enable debugsets):Save frame as fixture(Settings → Debug, live only) and?telemetry=true. The screenshot view (ScreenshotPage.tsx) is not gated (drop a frame from a game data zip on the landing to inspect it); the fixtures and montage views are dev-only.
Clips are real video with audio, cut in the browser. core/clips/scoring.ts
is the pure, swappable scorer: only the POV player’s own kills feed it
(ScannerMatch.kills, off the kill feed); a streak is consecutive kills with
no POV death between them and no pause over STREAK_MAX_GAP_S, cut where the
clip would outgrow MAX_CLIP_SECONDS; MIN_KILLS (4) makes it a window,
scored kills² + kills / span. Both controllers run the same
scoreWindows(match, deaths) → cut → store/clips.ts path, over the
session’s matches plus the stretches no scoreboard or minimap backs
(buildScannerMatches(…, { unbacked: true }), LiveSession.clipMatches):
a streak must not wait for a results screen that may never be read, and live
the ring would have dropped it by then:
- One capture per browser profile:
startCaptureholds a Web Lock (CAPTURE_LOCK) for its lifetime, so a second tab gets an error instead of a second pipeline writing every game twice (two samplers on one store double each event, and the repeated scoreboard opens a duplicate match). - Live (
capture/ring-buffer.ts+ring-buffer.worker.ts): the stream’s tracks becomeMediaStreamTrackProcessorstreams, transferred to a worker so a busy page never costs the footage a frame (on the main thread ~12% of a 60 fps track was lost to the one-frame processor buffer). There the video runs through aVideoEncoder(H.264, keyframe every 2 s, bitrate scaled by pixel rate: ~12.5 Mbps at 1080p60) into a ring of GOPs holding the lastRING_BUFFER_SECONDS; the audio through anAudioEncoder(AAC, else Opus) into the same ring.openCaptureasks for 60 fps explicitly, as Chromium’s default of 30 would halve a capture card. Packets carry the wall-clock time their frame was captured (noted at encoder input, claimed at output, so encoder latency never shifts audio against video): video by frame timestamp, audio by sample position (SampleClock), because audio timestamps drift against the sample count on some sources (a display capture, an element’scaptureStream) while the AAC encoder counts samples — matching by timestamp pushed later packets into the future and a cut lost its second half of audio. In the MP4 the audio runs on by sample count from its first packet and only jumps forward on a delivery gap overAUDIO_RESYNC_S. The remaining constant offset between a capture card’s picture and another path’s sound (desktop audio, a loopback device) is theaudioOffsetMssetting, applied at cut time. The audio input is watched for signal: a device that opens but sends silence, and an encoder that gives up, show on the live status line. A window is cut oncewindowClosed(no kill can join and the tail is captured): the GOP at or before its start through its end, muxed to MP4 with mediabunny’sEncodedVideoPacketSource— no decode; a ring that only begins more thanMAX_MISSING_LEAD_Safter the asked start (a capture restarted mid-streak) yields nothing rather than a clip missing its kills. A kill belongs to one clip: a window over footage already cut — redrawn by a late-read kill, or seen again because the session outlived the capture that first cut it — is skipped unless it scores higher, when it is cut and the clips it overlaps are deleted (cutsplus the session’s saved clips,live-session.ts). What the clips hear is theaudioSourcesetting: the source’s own input (audioInputFor: samegroupId, else a shared label prefix; OBS Virtual Camera carries none), the desktop’s sound (openDesktopAudio: the share picker’s audio track, opened before the camera so the click’s activation still covers it, its video surface dropped), any audio input, or off. - VoD (
capture/vod-clips.ts): packets from the keyframe at or before the window start are copied into a fresh MP4 (video + audio, no re-encode, so a minute of 1080p takes well under a second). mediabunny’sConversionwithtrimalways transcodes; keep using the packet copy. - Encoder: hardware first, then software — Chromium’s
prefer-hardwarenever falls back by itself (Linux, VMs, older GPUs). A hardware encoder can also fail only once frames flow (NVENC sessions taken by OBS): the worker swaps in software and restarts the ring. If nothing is left the status line shows clips as failed; the ring still cuts what it holds. - Buckets (
store/clips.ts):sessionholds the running session’s clips (kept until Stop, budget permitting); Stop rolls them intohistory, whereMAX_HISTORY_CLIPS(20) applies by score — download to keep. A file’s clips (vod) live for one visit and are purged on the next page load; the file is on disk. Bytes are capped too: the live clips (session and history together) stay underLIVE_CLIPS_MAX_BYTES(4 GB) and each file’s clips underVOD_CLIPS_MAX_BYTES(2 GB, they keep the source bitrate), lowest score dropped first, and a clip that would eat into the last 250 MB of quota (the events share it) is not saved — on a file the scan carries on with the next window. Clip records carry the realstart/endseconds (a packet-copied clip starts at a keyframe), and a card’s deaths and kills get a ▶ when a clip covers theirt.
Tournament montage (dev only, PoC)
Section titled “Tournament montage (dev only, PoC)”view=montage (Settings → Debug → Tournament montage, development only)
builds the basis of a tournament highlight video. pnpm vods:download <id>
writes the match VoDs of the top 8 teams (--all-teams for every team) to
scripts/output/vods/<id>/ together with tournament.json (name, start,
logo, top 8, every team that played, each VoD’s POV team —
MontageManifest in core/montage.ts) and the logos it names; the view
opens that folder through the File System Access API’s directory picker
(Chrome). components/montage.ts scans every VoD without games yet through
startVodScan (clips: false: saved like any file scan, so they also show
in the landing’s list) and lists every streak meeting the criteria as a
candidate, best score first (montageWindows): min splats (default 3) and
a length cap per splat count before fast-forwarding (defaults 2: 20 s,
3: 30 s, 4: 45 s, 5+: 60 s), picked on the page and kept in localStorage.
Candidates are grouped by the POV player’s base weapon (read off the
game’s scoreboard seat, variants folded to their base) and can be filtered
by team. Only top teams’ candidates are listed: a POV VoD’s under its team,
a cast’s under either side (candidateTeamIds), and each pick is added
under one of them. The scanned games, the picks (stored whole, so changing
the criteria never drops one) and the folder’s handle live in the
montages store (store/montages.ts, one record per tournament): a reload
restores the latest montage at once and reconnects the folder itself when
Chrome kept the permission, else Reconnect folder asks again.
Rendering (capture/montage-render.ts, overlays in montage-overlays.ts)
re-encodes everything at 1920×1080 60 fps: the intro card (the tournament
and every team’s avatar), then the top 8 counting down to the winner
(montageTeamSections), each team’s card (placement, roster and comp)
followed by its picks in order. The comp:
montage-comps.ts builds it the way the results image export does, from
the scans alone — every scoreboard names both sides’ weapons and the POV
seat tells which side is the streamer’s team, so a team needs VoDs of its
own or of its opponents to get one). The cards and each
clip’s ribbon (round, both teams, the POV player and their profile link;
fades out after 5 s) are the image export’s graphic blocks
(features/img-export), mounted off screen by MontageGraphics.tsx in the
dark theme and rasterized with snapdom at the scale that makes them full HD —
the renderer asks for them through setMontageGraphicCapture, so the page
must stay open. Each card is captured twice, with and without its revealed
items (the intro’s team avatars, a team card’s roster): the renderer draws
the empty one and brings the items in from the other (REVEAL_TIMINGS in
montage-overlays.ts), with a moving wave along the bottom. The page’s
Cards panel shows each card live with the same animations (to tweak its
styles with hot reload; Replay restarts them) and downloads it as captured. The manifest carries what they show (round names, both
sides, the streamer, rosters, tier, organization, counts — no
weapons: those come from the scans) and the
download script saves every image it names next to the VoDs. Dead time
between two kills — from 3 s after one to 3 s before the next, when at
least 2 s — plays at 4×, muted and badged (playbackSegments). Each clip’s
sound is normalized to -16 LUFS (core/loudness.ts: BS.1770 integrated
loudness, at most +24 dB) with a look-ahead limiter holding peaks under
-1 dBFS. An optional music (or video) file replaces the game sound for the
whole video: looped as needed, faded in and out, normalized the same way;
it is kept in the montage’s record. Chrome’s save dialog streams the MP4 to disk; without it the file
is built in memory and downloaded.
Commands
Section titled “Commands”pnpm test:scanner # golden-file suite over tests/fixtures/ (Vitest, Node)pnpm test:unit:browser # includes tests/logic/ — the fixture-free half, see belowpnpm scanner:report # accuracy table + name character error rate across fixturespnpm scanner:fixtures [name-substring] # run detectors over matching fixtures, verbosepnpm scanner:replay <dir> <startT> <fps> # replay ffmpeg-extracted frames through the scheduler+detectorspnpm scanner:unpack-zip <zip> <new-dir> # validate + unpack a user's Game data zip (refuses anything matchZip doesn't write)pnpm scanner:scan-vod <video> # VoD scan as a CLI (ffmpeg): video in, events CSV out (--gpu, --record, see "WebGPU")pnpm scanner:status-audit <events.csv> # diff the CSV's timeline vs scoreboard D/S, rank fixture candidatespnpm scanner:bootstrap-atlas # harvest labeled fixture crops into the glyph atlasespnpm scanner:build-glyph-atlas # add the font-rendered charset (fonts required, see below)pnpm scanner:build-localized-entries # regen localized closed sets from ../splat3pnpm scanner:build-planner-signatures # regen the minimap stage-ID atlas from the assets repopnpm scanner:gpu-parity # every fixture: OpenCV vs WebGPU parse decisions + GPU upscale pixelspnpm scanner:gpu-replay <corpus-dir> # replay recorded match requests on WebGPU, timed, exact-checkedScanner scripts run through vite-node -c scripts/scanner/vite-node.config.ts:
the root vite config pre-bundles @techstark/opencv-js for the browser worker
and vite-node must not consume that prebundle. The package is pnpm-patched
(patches/) to wrap its thenable CJS export as { cvReadyPromise },
unwrapped in core/cv.ts, and to expose its heap’s WebAssembly.Memory as
cv.wasmMemory (captured from its one WebAssembly.instantiate call, all
in the short wrapper lines: the minified runtime line is never touched, so
the patch stays small). core/frame-kernels.ts runs on that memory.
Architecture
Section titled “Architecture”sequenceDiagram
participant Cap as capture (sampler / vod-frames)
participant W as analyzer.worker (OpenCV)
participant TL as TimelineBuilder
participant MB as match-builder
participant UI as live-session / vod-scan
participant ING as /ingest (scanner-ingest)
participant DB as IngestedMatch / IngestedMatchLink
Cap->>W: frame + t (live/screenshot/seek) — VoD: worker decodes its own slice
W->>W: scheduler dueDetectors() → gate() → parse()
W-->>TL: DetectedEvents
TL-->>UI: deduped timeline, status reads kept per run end (IndexedDB: events / vod-events)
UI->>MB: buildScannerMatches(events)
MB-->>UI: ScannerMatch[] + source events
UI->>ING: POST { matches } (live only: on match close / stop)
ING->>ING: resolve context (current tournament/SendouQ activity, casts via staff roles, else games reported around the play times ≥2)
ING->>DB: merge-store IngestedMatch (matchHash, isSameMatch + merge, context hints)
ING->>DB: link matches to games the sender played (POV seat in the game's active roster) → IngestedMatchLink (POV weapon → ReportedWeapon#59; scoreboards derived at read time)
Note over UI: VoD "Add to VoDs": ScannerMatch → slim prefill param → /vods/new
Note over UI: scoreWindows(match) → ring buffer / file packet copy → clips store
core/is pure (mats in, events/matches out) and runs in the worker, the debug view, and Node tests. No DOM/browser APIs; Node-only helpers live innode/. Pure data/type imports from~/modulesand~/features/build-analyzer/dataare fine — valibot and the app config graph are not (schemas live inscanner-schemas.ts; core onlyimport types the shapes).core/match-builder.tsturns a timeline intoScannerMatches: a MapStart opens a match, a scoreboard closes one (claiming the last 8 min of deaths when the intro was missed). A match needs a scoreboard, a minimap or a minute of counter reads after its intro to be kept (the last for footage with neither, e.g. no results screen shown and the map never opened; it has no players, so it is not uploaded). Minimaps group per map by confirmed stage change and >5 min gap — an intro’s stage outranks minimap reads, so a match it opened splits only once no later minimap of that game reads it. A battle history screen (battle log, replay browser) showing a game already built — at least 6 paint totals shared order-free (a misread or unread row still joins), stage not contradicting it, and either 6 whole stat lines (paint, K/A, D, S) shared or the recording time within 20 min of its play time (the console clock can be hours off the PC’s: another timezone, a misread AM/PM) — joins that match’s sources instead of forming a new one, so browsing the log after playing neither adds a card nor re-uploads (the match was already sent). X Battle shows every player under a random alias (a gear name) until the log is asked for the real names: a history screen whose rows pair with the results screen’s by stat line under other names puts its names on the match (and on the POV’s kills), and the timeline keeps it apart from the alias read of the same entry (renamedRows). Only a results screen’s names count as aliases, so a match a history screen closed keeps its first names; the upload is not redone, so sendou.ink keeps the aliases. The recording time’s AM/PM letter reads as “o” either way, so the header tells them apart by the letter’s bottom rows (A stands on two legs, P on its stem): a flipped one puts the time 12h off and the screen turns into a duplicate card. Any other history screen closes the game being gathered — a missed results screen amended from the log — unless the intro/minimap stage, intro mode or recording time (vs. the game’s first read) contradicts it. Then, or with no game being gathered, it completes an earlier match finalized without a results screen (the next game’s intro came first) when one fits — stage and mode not contradicting, recording time within 20 min of its first read, the closest winning; on VoD scans (no wall clock) only a sole stage/mode fit — so the card keeps its place and a match first sent without a scoreboard is sent again. Otherwise it forms its own match and the game stays open. A MapStart within 30s of a replay-browser entry of its stage (and mode) is that replay played back: the entry’s match reopens with the intro, its gameplay joins it, and the entry shown again after the replay closes it. A history screen with its stage unread (typically a frame caught mid-transition) forms no match of its own; the timeline also holds history screens to a 0.75 confidence floor (clean reads score 0.81+). Likewise a results screen read again with no match opened since (a lost-connection dialog hid it and the detector re-armed) joins the last match. A no-contest results screen (the host ended the game or a disconnect did early: DRAW tags on both team boxes, drawn grey, and “No contest.” for a score —noContest) discards the game being gathered, so the replay of that map is its only card. An event belongs to at most one match; deaths reveal enemy builds (ability-harvest.ts), the personal results screen (ScoreboardOwn, seen withinOWN_RESULTS_WINDOW_SECONDSof a closed match’s scoreboard) completes the POV player’s full build, and minimap cards contribute everyone else’s mains — so a card’s Builds section covers both teams, each row rendered as far as it was read. Partial matches are fine — scanner-ingest merges them server-side. Senders filter withingestSkipReasons: private/unread lobby only, no games without a player read, and no games a disconnect cut short (scoreless + counter left more time than the footage did, or with no counter read, results came before the clock could run out and the same map was replayed right after — the latter only resolves after the fact, so a live scan may already have sent the game).- The route (
routes/scanner.tsx) is SSR-guarded: the client tree loads viaReact.lazyafteruseHydrated; nothing fromcore/worker/capture/storemay be imported at route-module top level. There is no feature flag: the page and/ingestare open to everyone (ingest still requires a login). - Thirteen detectors:
scoreboard(results screen),scoreboard-battle-log-replay(replay-browser detail),scoreboard-battle-log(Recent Battles detail — same data sans replay code, panels stacked),quick-scoreboard-battle-log(the same detail as the lobby’s quick view draws it: a card in slight perspective, so its frames are rectified by a homography —RECTIFYin itsrois.ts,core/rectify.ts— before the shared parser inscoreboard-battle-log/detector.tsreads it),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: the plates, and the Tower Control / Rainmaker track, see below. Splat Zones and Clam Blitz draw the same plates, so a CB read carriesmode: "SZ"too and the match’s mode tells them apart; in CB a filled plate means that team’s attack window is open, charted as control),x-set-count,x-set-resultandx-rank-position(the X Battle lobby cards, see below),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. OneKillevent 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 withinKILL_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 thekill-feedatlas, BlitzMain at the row’s ~24px caps with the scoreboard-names charset, underparseName‘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. The strip’s geometry is fitted per read, not picked from known arrangements: the game resizes each side continuously (S3 POV swings it with the objective, a splatted POV player shrinks it, spectators toggle the overhead map, broadcasts mirror it), through in-between pitches, so each side’s (pitch, inner icon center) is searched for the hypothesis whose four kite apexes stand out most cleanly from the backdrop beside them — the apex is the one part no weapon render, gauge digit or badge covers, and its triangle core sits inside an octoling’s dome too — plus how much more team ink they hold than their surroundings; the previous read’s fit holds unless clearly beaten. Each state then reads off the apex core alone: saturated ink of any hue = alive, pale = special ready (the wash pulses from lilac or pink to near-white but never saturates or greys) unless both sides of the body still hold team ink (a weapon render pale over the apex, Painbrush’s head: the wash pales the whole icon), else splatted (dark plate under grey X strokes) — unless the whole body is opaque black with no X or weapon render in it, an empty seat (a 1v1 lobby, a disconnect), which sets neither flag. No geometry implies a footage type: the D-pad camera badges prove a broadcast, reported ascast: true | null, but never place the strip, since a white backdrop under a POV strip fakes them now and then. Emitted with the sametimevalue so the two reads pair downstream; its fixtures live undertests/fixtures/player-status/. Within a side the strip keeps its own slot order (it can change every game), while the results scoreboard re-sorts each team (attested in the sendou-triton VoD: strip [Planetz, .52, Neo Splash, Snipewriter] vs rows [.52, Neo Splash, Snipewriter, Planetz]) — so every 5th counter read also samples aStripWeaponsevidence event: a ranked weapon-icon match per alive slot. The fit pins each icon’s center and scale, so the slot is resampled to the reference pitch and scored against every weapon’s game icon at that one size over the art’s opaque pixels only (masked NCC — the team-ink plate and the scene behind it never weigh in); the true weapon ranks top-1 on ~93% of single reads, including special-ready washes. The builder solves each side’s slot→row assignment once per game from one evidence matrix (core/slot-row-assignment.ts): strip votes (a read’s score over its candidate floor), the minimap cards’ parsed weapons (the enemy column mirrors the strip seating), and the states of players known by name — the POV’s own death screens, its kill-feed victims and the POV minimap’s named teammate cards — against the strip read beside them, which tells apart two rows sharing a weapon. Thin or tied evidence keeps the as-drawn order. The POV overlay’s teammate diamond follows neither order and maps by card name instead. Strip-weapon fixtures live undertests/fixtures/strip-weapons/. 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) — and bridges sub-10s not-ready gaps between ready reads when no death inside the gap explains them (no special regains that fast, so the gap is the wash’s dim pulse trough); for the same reason ready reads in the clock’s first 10s (5:00-4:51, TW 3:00-2:51) or before it starts are dropped before bridging, timerless minimap reads timed by the match’s dominant clock anchor. Objective reads land onScannerMatchas 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 reads outright (timerless reads follow their preceding anchored neighbor). A displayed count only ever ticks down, so the builder keeps each side’s longest non-increasing score run and voids reads off it (surviving OCR blips chart as gaps, not dips). Each read also carries a per-side team ink color (core/ink-color.ts— the plate fill in control, the digit ink otherwise): casted footage keeps the specced player’s team on the left plate, so the builder orients samples by ink hue and anchors them toteamsorder via the minimap sub-tile colors (casts never show a results screen). Reads of the overlay the match’s mode doesn’t draw (plates on TC/RM, a track on SZ/CB) are lookalike misreads the builder leaves out, and on Turf War every read is: the builder nulls that match’sobjectiveand callers discard the events (invalidObjectiveEvents; Live also stops collecting them once a MapStart reveals Turf War). A match with no mode read, or whose intro and results screen disagree on it, builds from its majority overlay (a plates objective’smodethen stays null: SZ or CB) and discards nothing. Turf War draws the timer and icon strip with no counter under them: the gate’stimervariant (timer box plus its colon’s two dots, which a dark minimap card pill lacks) emits the strip reads alone, timed by the timer read (no readable timer, nothing emitted) — as it does on a ranked frame whose counter fails to read. PlayerStatus reads follow the objective pipeline: same replay-wipe anchor, cast orientation inherited from the nearest counter read (none on TW: as drawn), and rendered as per-player splat/special bands (~/components/PlayerStatusTimeline.tsx, shared with the match page) above the objective chart. Minimap reads feed the same samples: every card/row carriesdead(respawn cross-out) andspecialReady(special camo) flags, merged in timerless on the shared replay anchor. Parsing details are in each detector’s module header; accuracy-critical matching internals incore/glyphs.tsandcore/detectors/scoreboard/weapons.ts— read those before touching recognition code. Parse cost matters live (a stalled worker drops frames): a CJK splash-tag name once cost tens of seconds per death parse, which is why the death detector memoizes tag reads on a downscaled tag signature (same killer recurs pixel-identical; a per-cell cap keeps two names on one plain banner apart, their means barely differ), the kill detector memoizes each feed row’s read on its text-band signature (a row is re-read twice a second for as long as it shows, and shifts up intact when a newer one enters; the per-cell cap of the signature compare is what keeps near-twin names apart) andclassifySegmentprescreens oversized eligibility lists at half scale — all tuned soscanner:reportstays bit-identical. Within one recognition, classifications are memoized by span, floor and candidate cap (a pure function of those: scores are exact), which the speculative prefetch and the sequential pass after it otherwise repeat. Glyph sets (atlas slices and everyscaleGlyphSet, shared per source set and factor) and the template sets build on first use, not at detector creation.
- casted 8-player spectator variant),
- Scheduling (
core/detectors/scheduler.ts): the per-session DetectorScheduler decides which detectors see a frame. Failing gates are re-checked everysearchIntervalS(0.25s — produced VoDs cut screens to ~1s, and gates are ~ms-cheap); a passing gate drops to the dense refine cadence (refineIntervalSoverrides for expensive parses). Suppression ends a refinement streak on parse-count stagnation AND ~3s elapsed (the floor spans entry animations), or immediately atsufficientConfidence(set just under each detector’s measured clean-read floor); death addsrearmCooldownS. A spectator map read with any card’s weapon unread (the screen’s opening wipe still crossing it) is capped under the minimap’ssufficientConfidence, so the open is read on until settled; a cast match’s players merge from its most confident minimap read first. Battle-log/replay gates return a contentsignatureso browsing distinct entries re-parses once per battle instead of dropping the gate.checkIntervalShard-caps both phases;attachFrame: falsekeeps continuously-firing events from storing a frame PNG each, and the worker only encodes a frame at all when a shadowTimelineBuilder(same defaults as the page’s) says an event would be listed rather than merged into an earlier read (or extend a status run’s trailing read) — a 1080p PNG per repeat read cost more than the parse once the kill feed re-read its stack twice a second. Frames no detector is due for skip canvas readback, and everything is counted incore/detectors/telemetry.ts— but only when a VoD is scanned with?telemetry=truein the URL (nothing links there) by a debug user; otherwise the workers skip collection and the panel stays hidden. A match’s objective reads render as one step-line timeline (~/components/ObjectiveTimeline.tsx, shared with the match page). The live capture buffers frames sampled while the worker is busy; past the buffer limit the backlog is decimated toward even time-spacing (worker/frame-queue.ts) rather than truncated oldest-first, so a parse stall can no longer swallow a results screen whole (the exact failure that cost a live match its scoreboard on 2026-08-22). - VoD scans (
components/vod-scan.ts): on the WebCodecs path each worker scans its own contiguous slice (no frames cross the main thread), with helper workers of its own that keep the waits off its thread: its slice decodes inworker/decode.worker.ts(mediabunny; the per-sample bookkeeping of a 60 fps slice cost the analyzer ~0.15 ms a sample), frames are read back in twoworker/readback.worker.tsand the analyzed frames’ images encoded inworker/frame-encode.worker.ts(a lossless 1080p WebP blocks its thread ~30 ms; the results still post in order). The analyzer takes every sample’s step strictly in stream order — bookkeeping, analysis when due, the calm check — against the scheduler state the previous analysis left, so the analyzed frames are exactly those of a one-frame-at-a-time scan. Only the waits overlap: while a pass runs, the next samples are decoded and held, those beforenextDueLowerBound(certainly skipped: a gate or parse only moves a detector between its refine and search cadences) released, and the onespredictDueTimesandnextDueCandidatesexpect next read back and normalized ahead. The decode worker is told that lower bound (floor) and closes the samples before it itself, sending bare timestamps — and on AV1 never decodes a sample before it whose unit refreshes no reference slot (core/av1-refs.ts: such a unit leaves the decoder as it found it). It is also handed the scheduler’s check plan (core/detectors/check-plan.ts): each detector is checked at the first sample its interval has elapsed by, and its interval is one of its few cadences, so where its next checks can land branches over the stream’s real timestamps; such units no branch reaches go undecoded too (~30% fewer decodes on a 1080p60 AV1 scan; non-reference frames are cheap to decode, so the scan itself gains a few percent). When the scheduler reports calm (no gate pass for a quiet period, no open match), the worker skims keyframe-to-keyframe (hop capped at 2.5s so short screens can’t hide), snapping back to dense decode on any gate pass. Dense and skim stretches read one decode stream per slice (worker/frame-source.ts): a hopseeks it to the samplegetSamplewould return, decoding forward from where it stands or jumping to the hop’s keyframe when that lies ahead, rather than starting a decoder at the keyframe for every hop. The seek fallback drives one worker and widens its stride over calm footage the same way; its metadata wait is bounded so an undecodable file errors instead of hanging. A scan is all or nothing: leaving the page cancels it and nothing is saved. The hardware decoder caps a scan: Chromium serializes every hardware decode of the page on one GPU-process thread (an M5 Pro decodes 1080p60 AV1 at ~2,700 frames a second, ~45× realtime, however many lanes share it), and the slices’ software decoders cost more CPU than they add. - VoD frame readback (
worker/readback.ts): the 2D canvas readback is the reference, but every canvas readback of a hardware frame is also served one at a time on the GPU process’s main thread, which the decoder needs too. Where it reads the same pixels (worker/readback-parity.tscompares one frame per slice) a scan reads through WebGPU instead. The hardware frame itselfcopyExternalImageToTextureconverts differently, but anImageBitmapof it is converted on the GPU exactly as the canvas converts it; failing parity, the frame’s planes copied out to a CPU frame are too (two readbacks and an upload on the GPU process’s main thread, ~45% of its time on a 720p scan). Either is copied in and mapped back asynchronously. In the same submission the GPU upscales a sub-1080p picture (worker/cubic-upscaler.ts, assuming no bars; a picture with bars takes a second trip) and converts the canonical picture to gray (worker/gpu-gray.ts, bit-identical toframeGray), so the analyzer starts its gates with neither left to do. - Recognition is language-agnostic: OCR output snaps against every game
language at once (
core/localized-entries.ts, generated) and events carry sendou ids. English display names come fromcore/labels.ts. - ROI coordinates live in each detector’s
rois.ts, in canonical 1920×1080 space; every frame is normalized to that size first — black bars around the picture (letterbox/pillarbox, or a scene drawing the game smaller than its canvas) are cropped away before the resize (detectContentBoxincore/canonical.ts). Each edge is split into 16 segments that each walk inward while level with the edge (plus a soft inner edge when lit picture follows within 1%); segments agreeing on a depth vote for it, so a webcam or alert box drawn over part of a bar only costs the segments it covers, and the per-edge depths combine into the best-voted 16:9 box (within 0.5%). A bar must be level and ≥1% deep, since the Recent Battles screen’s own scanline-textured edge is dark but neither. A layout drawn in perspective (the quick battle log card) additionally names aRECTIFYquad: its ROIs are in the frame warped by that homography, which the debug overlays map back onto the raw frame. - New event types implement
Detector(core/detectors/types.ts): a cheapgate(mat)at sample rate plusparseSteps(match steps, see “WebGPU”) when the gate fires,parsebeingrunSyncof it. Register incore/detectors/registry.ts. Gates and parses read the frame’s gray/RGB/HSV throughframeGray/frameRgb/frameHsv(core/image.ts), converted once per frame and shared: never delete or write them, and never pass them a derived mat. Gray and RGB come from WebAssembly SIMD kernels (core/frame-kernels.c) bit-identical tocvtColor(tested on every 24-bit color) at under half its cost. A gate probing a few small ROIs of a rectified region usescreateProbeWarp, which replays OpenCV’s f32 bilinear warp at those pixels only (exact;tests/frame-kernels.test.ts).
WebGPU
Section titled “WebGPU”Template matching (every TM_CCOEFF_NORMED the recognizers run) and the
sub-1080p frame upscale can run on the GPU; every other step stays on the CPU.
The GPU is an accelerator only: the same algorithms make the same decisions.
- Match steps (
core/match-steps.ts): recognizers are generators that yield every match their next decision needs (MatchRequest: an image, templates, a placement window per template, optionally a contentkey) and resume with the max scores.runSyncanswers lazily on the calling thread and is whatDetector.parseruns;allsteps generators in lockstep so independent reads share a round trip. Every detector implementsparseSteps; within a parse, reads are lockstepped wherever the sequential code’s consumption order, memo reads/writes (death tag, kill rows: the kill parse predicts its memo misses on a copy of the memo and reads only those ahead) and detector state stay exactly as before.speculativeadditionally prefetches merge / recut candidate sets in lockstep (batching drivers only; on the CPU it is wasted work). - Exact scores on both drivers: a score is TM_CCOEFF_NORMED computed
exactly — integer cross, window and square sums, one f64 normalization with
OpenCV’s guards (
normalizeNcc), f32 result — so the CPU and the GPU give bit-identical scores and the same events. OpenCV’s ownmatchTemplate(the pre-migration CPU path) runs a float DFT that wanders up to ~3e-4 from the exact score: never a decision on the fixtures or the VoD test slices, but enough to reorder a near-tie (seen once in a browser scan: an 8th-ranked strip-weapon candidate, scores 7e-7 apart), hence exact on both. On the CPU the cross sums run in WebAssembly SIMD (core/cross-sums.c, compiled intocross-sums.ts; regeneration steps in the C file), large jobs as one f64filter2D(rounded: exact), and the window sums come from integral images — faster than thematchTemplatepath it replaced. - Frame pass (
core/detectors/frame-pass.ts): the worker and the CLI gate every due detector in registry order, then run all approved parses — one lockstep of their steps on the GPU — and record results in registry order. Scheduler decisions within a frame depend only on each detector’s own state, so this equals gating and parsing one detector at a time. - Matcher (
worker/gpu-matcher.ts): each step is one submit of three passes — per-image integral images (window sums), a score pass (one thread per 4 vertically adjacent placements; cross sums as packed u8 dot products; an f32 estimate folded into the job’s max) and a select pass returning the exact 64-bit integer sums of the placements withinEPSof that max. The CPU finishes those withnormalizeNcc— the exact score, identical on every GPU and torunSync’s. More thanKnear-tied placements, or a request the kernel cannot take, are finished on the CPU with the same exact arithmetic. Scores are cached per run by (key, template, window) — the window is part of a score’s identity. - Frame upscale (
worker/gpu-frame-scaler.ts, kernel inworker/cubic-upscaler.ts):normalizeFrame’s INTER_CUBIC upscale of sub-1080p pictures (13-25 ms of WASM per 720p frame) as an integer kernel reproducing OpenCV’s 8-bit cubic resize bit for bit; 1080p copies and INTER_AREA downscales stay on the CPU. A VoD scan reading back through WebGPU runs the same kernel in its readback worker instead (see “VoD frame readback”). Importing a hardware-decoded VideoFrame as a GPU texture converts its YUV differently from the 2D canvas; a CPU copy of its planes converts the same. - Worker (
worker/analyzer.worker.ts): creates the matcher (and scaler on its device) at init when enabled and an adapter exists; a failed creation or a device lost mid-run (device.lost, or a failed readback) hands the pending step torunSync, so the generators still run exactly once and no event is dropped or duplicated, and later frames stay on the CPU. A device whose submit →mapAsyncround trip is slow (hasFastReadback; Firefox resolves GPU promises on a ~100 ms timer, Chrome in ~0.2 ms) is dropped at init: every step and upscale waits on one, so there the CPU is far faster. - Node (
node/webgpu.ts): scripts get WebGPU from Dawn, thewebgpunpm package — deliberately not a dependency:npm i webgpuanywhere and pointWEBGPU_NODEat its package dir.scanner:scan-vod --gpuscans on it (the CSV must stay byte-identical to a CPU scan),--record <dir>writes a replay corpus (scripts/scanner/match-corpus.ts),scanner:gpu-replaytimes it and checks every score against an exact JS reference and, with--cpu, againstrunSync(0 mismatches is the bar for any kernel change), andscanner:gpu-parityrequires byte-identical events from both paths on every fixture.
Assets (CDN) and fonts
Section titled “Assets (CDN) and fonts”Weapon/ability/special/sub template sources are the site’s shared game icons
in the sendou-ink/assets repo under assets/img/** (.avif; ids from
~/modules/in-game-lists, plus the scanner-only UNKNOWN ability badge —
toAbilityWithUnknown narrows template ids back to sendou ids).
Scanner-specific sets — glyph atlases and the planner signature atlas — live
in the same repo under assets/scanner/v1/** (override the local path with
SCANNER_ASSETS_DIR). These are the only assets that mutate at a fixed URL —
the CDN serves them with max-age=3600 and each atlas’s .png and .json
cache independently. The worker fetches them no-cache (an ETag
revalidation), which keeps the browser cache from pairing a fresh image with
a stale meta, but the CDN edge can still do so for an hour, so a regen that
moves glyph boxes must bump the version segment. Otherwise a client can
silently read garbage (old dirs are deleted from the Space by the sync’s
--delete-removed).
Loading never fails init over one asset: every fetch is retried once, an
icon that still fails only drops its template (a weapon added to
in-game-lists before the assets push), and an atlas that fails reads as
null. The worker’s ready names the missing atlases; a live capture refuses
to start with any (its games would upload unread — an unread lobby passes
ingestSkipReasons), VoD/screenshot scans carry on. Node loading
(node/resources.ts) stays strict and throws on a missing icon.
- Browser/worker: everything from
Config.staticAssetsUrl— icons atimg/**, atlases atscanner/v1/**(base URL rides the worker init message; the DO Space needs CORS for GET from sendou.ink + localhost). Local dev against fresh regens:npx serve /Users/kalle/Developer/assets/assets -l 9100 --corsandVITE_STATIC_ASSETS_URL=http://localhost:9100in.env. - Node (tests/scripts): both sets from the sibling
../assetscheckout, never the CDN. AVIF decodes throughsharp(node/image-io.ts) —@napi-rs/canvasmis-decodes AVIF partial-alpha. - Atlas regens overwrite the checkout’s
assets/scanner/v1in place, so shipping one means pushing the assets repo (its deploy workflow mirrorsassets/to the Space).
Fonts are proprietary and gitignored: BlitzMain.otf, BlitzBold.otf,
FOT-RowdyStd-EB.otf, FOT-KurokaneStd-EB.otf in assets/fonts/ (repo
root; from the splatoon3-fonts repo). Atlas builders fail loudly without
them. Names and row digits use BlitzMain; team totals BlitzBold; the replay
code line and VICTORY/DEFEAT tags FOT-RowdyStd-EB; the JP death message mixes
condensed Kurokane and Rowdy (death-weapon-ja); the Japanese results
header reads through its own Blitz-kana pair (scoreboard-header-lobby-ja,
scoreboard-header-line-ja), tried only on a band the Latin atlases don’t
snap. Regeneration order:
scanner:bootstrap-atlas (fixture crops win via tie-break) →
scanner:build-glyph-atlas; localized sets via
scanner:build-localized-entries (expects a splat3 checkout at ../splat3)
then the atlas rebuild; planner atlas via scanner:build-planner-signatures
(reads the assets repo’s assets/planner-maps/, MINI variant).
tests/*.test.ts are the golden-file suites: they read frames from
tests/fixtures/ and need game icons from a sibling sendou-ink/assets
checkout, so they run in their own Vitest project (vitest.scanner.config.ts)
and stay out of CI.
tests/logic/*.test.ts are pure logic over synthetic events — no images, no
assets checkout — so they belong to the unit project and do run in CI. Put
new tests there whenever they can be written without a frame.
Fixtures
Section titled “Fixtures”A test case is a directory tests/fixtures/<detector>/<case-name>/ with
frame.png|webp|jpg (raw capture, never lossily re-encoded) and expected.json (partial
expectations, sendou ids; stageLabel/weaponLabel are informational for
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 —
an expanded match card’s Game data zip holds each analyzed frame
(lossless WebP, pixel-exact) with a prefilled expected.json in a folder
that drops into tests/fixtures/<detector>/ as is. A zip sent in by a user is
untrusted: unpack it only with pnpm scanner:unpack-zip
(node/game-data-zip.ts), which accepts nothing but that exact shape — the
scanner-misread Claude skill drives the zip → fixtures → fix loop. Fixture ground-truth labels are
hand-corrected by the user (the Splatoon domain authority) — treat them as
definitive over any matcher output. The dev-only fixtures view
(/scanner?view=fixtures) renders every fixture’s frame beside its
expected.json for that ground-truth review — player-status and
strip-weapons cases get per-slot icon crops with the expected label under
each icon, and Inspect re-analyzes any frame in the debug view (/scanner?view=debug). The q
param narrows by case-name substring (comma = OR) and lives in the URL, so
finished labeling work can be handed over as a reviewable link, e.g.
/scanner?view=fixtures&q=gauge-overlay,ready-trough. Fixtures are committed as plain blobs
(no LFS for now); keep additions deliberate — fixture IO is isolated in
node/fixtures.ts if a retreat to LFS/an external corpus is needed.
Tower Control / Rainmaker track
Section titled “Tower Control / Rainmaker track”Both modes draw one overlay (core/detectors/objective/track.ts, geometry in
rois.ts): a dotted track under the icon strip, ends x514..1405 at y155,
with the objective’s icon riding it and a “Remaining” plate per team hanging
under the furthest point that team pushed to — so a team’s plate always sits
on the half it pushes into, and the left team pushes right. The gate tells the
track from the SZ plates by the dots, which sit at a fixed pitch and phase in
every lobby (a comb projected at that phase, per window, with the two
worst windows dropped so a marker or backdrop edge can’t sink it). The icon is scored
procedurally (held: team-ink disc around a white glyph; neutral: white ring
around an olive disc), its x mapped linearly to position -100..100; the
holder is the icon’s ink against each team’s ink off its own track end (end
marker plus first dots, which the backdrop can’t drown like the strip); the
counts are every confident digit run in the plate band, each assigned by the
plate ink around it (no label is read: localized). The checkpoint markers
tell the modes apart (TC black-framed squares, RM pedestals); when neither
reads, the read’s mode is null and the match’s mode decides. control is
the holder’s side (0 | 1 | null) on every overlay — in SZ too, where a
plate fills only while its team holds every zone. Samples add position,
positive toward the end teams[0] pushes to: every side swap (cast
orientation, winner-first ingest, display order) negates it and flips
control. A team with no plate yet has
never pushed past the middle: the charts show its full count until its
first plate read (withUnpushedTrackCounts). In every mode, footage covering
the game start charts both counts full from the clock’s start until each
side’s first read (withFullCountsAtGameStart); later unread counts stay
gaps, since a count never rises. Fixtures: objective/tower-*,
objective/rainmaker-* (tower_control / rainmaker VoDs plus TC/RM death
frames symlinked). Turf War strip reads: player-status/turf-war-* (tw VoD,
kill and strip-weapon fixtures symlinked to them).
X Battle cards
Section titled “X Battle cards”After an X Battle game the lobby shows one near-black card
(core/detectors/x-rank/, geometry in rois.ts): XSetCount (“WINS
LOSSES 1 - 2” over win slots and loss squids) after every game that leaves
the set undecided, then after the deciding game XSetResult (the “2 - 3”
header, one VICTORY/DEFEAT tile per game in play order, X Power and its
signed change) followed by XRankPosition (“#259” and an up/down arrow, or a
flat one when the position held).
Every card carries the mode off its icon (img/modes/*, RGB templates on
black). The Anarchy Series card after a series game draws the count card’s
layout too, but with five win slots: ink where only it has slots fails the
count gate (negative/anarchy-series-card-*). Numbers are BlitzBold, read with the team-digit atlas rescaled;
punctuation (“.” “-” “+”) is told apart by ink-run geometry since the digit
templates can’t match anything smaller than themselves.
All three animate toward their final value and hold it only briefly: the
count card opens on the score before the game, fills the new slot and only
then flips the digits (~0.5s before fading); the set result counts the old
X Power to the new one in ~0.3s; the position counts in the arrow’s
direction. So no detector sets sufficientConfidence (the first clean read
is the stale one), each gate returns a signature over its numbers, and the
timeline orders same-type reads by animationProgressByType instead of
confidence: a further-along read replaces the kept one, an earlier one
merges into it. The count’s end can fall between two sampled frames (a 2 fps
live capture), so a set result read after the change lands takes its power
from the power before it plus the change whenever an earlier read showed the
old value (carryOverByType); the old value holds ~1.5s, the final one ~1s. A count read whose digits disagree with its slots, or a set
result whose header doesn’t describe a decided set of tiles (tiles still
popping in), scores under the timeline floor. The result tiles center on
the card (five games three over two, four two over two, three in one row
midway between), so each row is found as a band of VICTORY yellow / DEFEAT
purple text and each tile as a run of its columns rather than at a fixed
slot. A gain shows “+30.0” on a teal splat and an orange up arrow, a loss
“-29.2” on a grey splat and a grey down arrow, a held position a green
right arrow (SAME). The arrow follows the number, so it is read as the
card’s rightmost ink. The Japanese cards say WIN/LOSE on the tiles and
draw the position as a white “1位”: number reads drop poorly matching
glyphs at either end of the line (the “#”, the “位”). Not yet attested: the
card while X Power is still being calculated, a Japanese position that
moved (yellow or white digits?), and modes other than Tower Control (the
other mode icons are matched untested). The lobby shows the cards after the
personal results screen but before the game’s results screen (read while
matchmaking for the next game), so the match builder hands each card to the
game still being gathered, else to an X Battle game closed within 90s
before it, else to a results screen within 90s after it;
core/x-battle.ts reads them back off sources, so compacted sessions keep
them. Only X Battle shows the cards, so a game carrying one with its lobby
unread (results screen missed or its header misread) is an X Battle game: the match card shows
the set count in its meta line, and the session’s X list draws the set
result and position as a divider above the deciding game. Fixtures come from the
fW5h-Ooc-Cg VoD (x-set-count/, x-set-result/, x-rank-position/), the
Japanese ones (*-ja) from -I23PqqYtHQ.
Routes
1 of 1 page rendered by an e2e test in the last pnpm run docs:capture.