Compare commits

...

12 Commits

Author SHA1 Message Date
sylph-decoder
f34d24941d formats: a music bank's third sub-wave was its own header
The port hit `sound_bank_riffs("BGM_103.slb")` returning three against a census
that says two, and refused to guess which to drop. It was our reader.

`to_xma_riffs`'s hybrid branch derives a leading packet stream's start as
`first_riff % XMA1_PACKET`. That is right only when the bank header is smaller
than one 2048-byte packet -- true of the voice banks the branch was written for
(1392/1468/1600/1728), false of a music bank, whose header is exactly five
packets. The modulus returned 0 and the whole 10 240-byte header was emitted as
sub-wave 0.

The header states its own length, so the guard needs no threshold: BE u32 0x800
at +0x18 with the bank id repeated at +0x00 and +0x20, header length in blocks at
+0x24. Disc-wide over sound.pak's 9 519 entries, 28 match at offset 0 -- every
music bank, ids 1001-1023 and 1101-1105 -- and on 28/28 the declared header ends
EXACTLY at the first RIFF. Zero have a gap, so a header and a leading packet
stream never coexist here; zero false positives among the other 9 491.

Controlled rather than argued: decoding the emitted region through the same
chain, on the same bank, in the same run gives 0.009 s of PCM where the bank's
real wave 0 gives 87.744 s against a declared 87.75. The region is also 99.1%
zero bytes. And the oracle had already said two -- the XMA probe at the main menu
saw exactly two streams, at BGM_103's two declared wave sizes.

BGM_106-109 are deliberately NOT in the 28: their entries start mid-bank, so they
have no header at offset 0 and their leading region is real audio. The
VOICE_D_453 recovery is untouched and its tests still pass, 10/10 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:30:16 +00:00
sylph-decoder
5fcc89be55 re: the PRESS-A plate arrives 2.13 s after the title settles, measured twice
The one number the port said decides a structural question on its side: whether
`ScreenView` has to draw two builds at once. It does. The boot title shows
build 4 alone, and 2.13 s after build 4 stops animating it composites build 2
over it. Two independent boots agree to 6 ms (2.138 / 2.132), which is under one
sample interval.

Measure from SETTLED, not from first pixels. "First drawn -> plate" is 3.78 s in
one run and 4.26 s in the other, because the build-in animation itself ran 1.64 s
and 2.13 s -- that spread is the emulator's frame pacing, and it is exactly the
kind of number that looks like a measurement.

Ruled out before believing it: that the plate was pulsing all along, too dim for
a thresholded glyph counter. The counter reads EXACTLY 154 -- the committed
no-plate title's own value -- for every frame of a plateau nearly one full pulse
period long, with zero variation, and the surface mean is flat to +/-0.03 across
it. A cycling overlay moves both.

Also settled, and also not:

  * the black hold between two screens is 0.14-0.30 s, which brackets the port's
    authored 0.17-0.23 s and the file's declared 12 units. Their constant stands.
  * the (A)->menu latency is STILL not available, and now the reason is known.
    Both runs freeze one frame for ~1.4 s at surface mean 26.626 -- agreeing
    between runs to six decimals, and reproduced in run 2 with stream restarts
    disabled, so it is not the capture path. It is a guest load stall: the (B)
    path, which loads nothing, has no freeze at all. Any figure from it would be
    an emulator load time.

Refutation attempt, recorded whether or not it survived: navigation.md's "the
title is not input-ready for about ten seconds, and even then (A) registers
roughly half the time". At 7.29 s and 7.28 s after the title settled, (A) was
accepted first press in both runs, as was (B) on the menu. n=2 only makes "half
the time" unlikely (p ~ 0.25); it contradicts the ten seconds outright.

And the standing red banner is withdrawn: the interactive title IS reachable in
this container, twice, with no pad input, in ~3.5 minutes. Why it changed is NOT
established -- this container came up with no Xenia storage root at all, so run 1
created a profile -- and that is written as a correlation for the next session to
test rather than as a cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:15:22 +00:00
sylph-decoder
3ee1a25f47 tools: a title timing probe that costs 8.7 ms a frame, not 1503
The four durations withdrawn yesterday were produced by a classifier costing
1503 ms/frame draining an 8 fps x11grab at 0.64 fps -- a backlog, which
preserves ordering and destroys durations. This is the instrument for retaking
them.

What makes it cheap: every committed capture aligns at exactly dy=0 dx=0
(five-screens-acceptance), so the +/-8 px offset search screen_match does at
full resolution is 25 ZNCCs buying nothing on this path. Decimate 4x, do one
ZNCC per reference. Measured 8.7 ms per frame including the glyph count -- 173x.

Controls, run before the measurement and not after it:
  * 9/9 content controls, including the two committed movie frames that are the
    class this oracle exists to reject;
  * 4/4 on the plate detector itself, which is a threshold on the green-glyph
    counter and so needs its own control (no-plate title 159, plate title 753,
    movie frames 0).

And three things learned from run 1, folded back in:
  * do NOT restart the stream once the measurement is under way. Run 1's restart
    landed 0.25 s after the (A) press and its stale frames straddled exactly the
    interval being timed;
  * press INLINE, not through pad.py's subprocess -- an interpreter start plus
    the 0.25 s hold sat between the press and the timestamp;
  * count the longest run of byte-identical surface means and report it. That is
    the freeze signature, and it is how run 2 showed the 26.626 hold is the
    guest rather than the capture path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:15:06 +00:00
sylph-decoder
dd4f30a79f tools: fade_quads could not run -- two pre-monorepo paths
`screen-transitions.md` cites `fade_quads.py` as the command behind its decoded
fade ramp, and the command had been dead since the monorepo migration: it read
regn_decode.py from /work/Syplheed-Reborn and defaulted its pak to
/work/sylph_extract, neither of which exists. Resolve the helper beside the
script and default the pak under $SYLPHEED_DISC, the way run-canary and
sylpheed-cli already do.

A cited command that no longer runs is a citation nobody can check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:15:06 +00:00
sylph-decoder
06c32a0fd4 Merge remote-tracking branch 'origin/main' into auto/no-disc-and-menu-captures 2026-08-29 11:53:43 +00:00
sylph-decoder
7a4a74f8d7 re: the main menu has no idle self-return -- and four of my own durations were a backlog
Two results and one retraction, all from the same session.

REFUTED: 'an ~8-10 s idle returns to the title' does not apply to the main menu.
Held untouched it stayed put for >= 60 s, correlation never leaving
0.9245-0.9249. That timer is real but belongs to the TITLE. It was the only
reason 'B leaves the main menu' was classed as authored, so Q5's B rule is
upgraded to measured-ordering: B is delivered (canary logs vk=5801) and is the
only input in >= 100 s before the return.

The PRESS (A) plate: the boot title presents build 4 WITHOUT the plate first --
green-glyph 154, against 159 on the committed no-plate capture and 753/977/1493
on plate titles -- and the plate arrives after. That is the port's third option.

RETRACTED: four durations taken the same day. classify_array costs 1503 ms per
frame; running it per frame against an 8 fps x11grab drained the pipe at
0.64 fps, so every classified frame was stale and increasingly so. It
manufactured 'plate 24.66 s after the title art', 'B->title 15.58 s', 'B->title
25.60 s' and 'A->menu 20.26 s'. The tell: a transition, a press and a fade do
not share a duration, and the two B figures GREW across a longer run.

A backlog preserves ordering and destroys durations, which is why the sequence
results above stand and every timing does not. The ring's period is unaffected
and that was checked, not assumed -- ring_period ran at 15.03 fps against a
requested 15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:50:46 +00:00
sylph-decoder
724e06b134 re: the main menu's focus ring spins continuously -- period 2.18 s, measured
Answers the port's ask: ptbtneff01 is ANIMATED while a button is focused, not
drawn once and held. The existing page said 'the ring SPINS' from one frame at a
large angle, which is equally consistent with a static draw at a fixed angle.

No angle is quoted anywhere. The 360-bin angular estimator written for this
FAILED its own control -- a synthetic 30 deg came back as 0 deg (peak 0.596)
while 90/180/270 came back exactly -- so it was not used. What settles it needs
no angle: total annulus brightness is conserved to 0.4 % while individual
angular bins swing by 24, i.e. brightness moving AROUND the ring, which excludes
a pulse. The temporal-std map is a clean annulus, falling to ~1 both inside and
outside the stroke, which excludes positional jitter.

Period from the profile's autocorrelation: eight evenly spaced peaks, mean
2.177 s over nine revolutions. Even spacing is the internal check a drifting
instrument cannot pass. That is 120 units = 60 frames = 2.00 s at a true 30 Hz.

Also measured, same run: the ring is the ONLY moving thing on the settled main
menu -- temporal std is exactly 0.000 on every unfocused button, the labels and
the footer. And the ring's centre, located from the std map at game
(520.7, 339.7), matches the declared leaf offset's prediction of (521, 340).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:50:32 +00:00
sylph-decoder
e2d2dd34f0 tools: a screen oracle that matches CONTENT, with movie frames as its controls
The statistics oracle (green/white/mean) cannot reject the class it exists to
reject. A frame of ADV.wmv with a bright green laser reads green 0.0018 /
white 0.086 / mean (53,67,76) -- the title's numbers -- and a probe built on it
tapped (A) into the movie, then waited 120 s for a menu that was never coming.

screen_match correlates against committed captures instead. Controls run before
it was ever used live: 8/8, and the negatives are COMMITTED movie frames rather
than scratch grabs -- an earlier list pointed at two scratch files and a later
run of the same probe overwrote one, failing the control for the wrong reason.

Two paths, both controlled. The exact path costs 1503 ms/frame, which is fine
offline and catastrophic in a live loop; fast=True decimates 4x for 38-75 ms and
agrees with the exact path to +/-0.005 on all eight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:50:18 +00:00
sylph-decoder
9f34e6f7b6 re: withdraw the no-disc banner -- the disc is mounted, and both instruments that said otherwise were blind to it
The container was replaced at 11:07:38 UTC, 25 minutes after 3db09a3 wrote
"the decoder container has no disc". /disc is a real read-only bind mount
(device 2050 against /'s 92), 6.2 GB, 74 entries under dat/, and
`sylpheed-cli screen list` returns 12 GP_TITLE builds.

The reusable half is the instruments. `find / -xdev` cannot cross into a bind
mount on another device, so its "no ISO, no default.xex, no GP_TITLE.pak"
is what it returns whether or not the disc is there. `sylph-doctor` only ever
looks under /work and never consults $SYLPHEED_DISC, so it still reports
"no ISO" against a disc that works. Two instruments, one blind spot, read as
corroboration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:12:36 +00:00
sylph-decoder
c88f5e87a9 game: fill in the player's-eye navigation map from the committed oracle frames
Sections 1 to 3 were almost entirely open questions. Everything a capture in
docs/re/captures/ actually shows is now written down from the chair: what is on
each screen, what the cursor does, and what each footer offers.

Boot: the publisher plate is SQUARE ENIX, the developer plate is GAME ARTS /
SETA / studio anima, both still pictures the game draws rather than video, then
the cinematic -- one A skips it, 57 s to the title against 193 s without.

Main menu: the five labels and where each goes, the wrap rule, and the caveat
that initial focus varied across four boots. Carries the footer warning from the
measurement in the same push.

Submenus: NEW GAME's DIFFICULTY and SELECT DATA; LOAD GAME's slot carousel,
Details panel and its five-button footer; the six tutorial lessons in two
groups; OPTIONS' four categories; EXTRAS' three items; and MISSION SELECT with
the locked-list explanation for the cursor that would not move.

What stays open is marked open, and it is now the residue no capture answers
rather than the residue nobody looked at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UxPvE5cz7zekXBKi7Xw2r
2026-08-29 10:43:12 +00:00
sylph-decoder
6f4b4d8b4c re: two menu facts re-measured from the committed captures, no disc needed
Refutation attempt, per the adversarial duty. Target: this page's own row "B on
the main menu goes to the title". Chosen because it is one of only two Q5 rows
with an empty evidence cell, and because it is the only exit from the main menu,
so the port will build on it.

Whole-frame colour test for the pad-glyph discs. The main menu carries ZERO
red-B pixels anywhere in the frame, on two independent captures, while the same
unchanged detector finds 514 on EXTRAS and 518 on DIFFICULTY. The control passes
twice: the A glyph reads 438/438/440/438 across all four screens, so it is one
asset at one size and a B of that family could not have slipped under a
threshold. The main menu's legend is "Select / OK"; every submenu adds "Back".

The claim SURVIVES -- a legend is not behaviour, and an absent glyph cannot
refute an observed press -- but it is downgraded to amber. The observation is
uncited and single, it is now the only Q5 row the game's own text contradicts,
and there is a named confound: the title-side screens auto-return after ~8-10 s
idle, which looks exactly like what was described. Reading 0x828A690C while
pressing B would separate them in one run; that run needs a disc this container
does not have.

Second finding, same method. MISSION SELECT's "sixteen d-pad presses never left
Stage 01" was a LOCKED stage list, not a broken one. The labels have three
brightnesses, not two -- locked 104, unlocked 183, focused 254 -- and the
all-story-unlocked capture is the control that separates the lower two while
holding row 1 at an identical 254. On that save the cursor reaches Stage16 at the
bottom of a scrolled list. The list is 16 long and shows 8 at a time.

Regenerator committed beside the finding; it reads only files already in git.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UxPvE5cz7zekXBKi7Xw2r
2026-08-29 10:43:01 +00:00
sylph-decoder
3db09a3806 re: the decoder container has no disc -- root-caused to the volume migration
find / -xdev turns up no ISO, no default.xex and no GP_TITLE.pak; /exchange is
empty; SYLPHEED_DISC is unset; sylph-doctor agrees. Everything else in the
container is healthy -- xenia_canary is built, :98 is up, screenshot works,
Vulkan enumerates. There is simply no game to boot.

The cause is in the launcher. Before c58196b, sylph-agent bind-mounted the
human's working tree at /work, and the ISO and sylph_extract/ live in that tree,
so the disc arrived incidentally with the repository mount. c58196b replaced
that with `-v sylpheed-decoder-repo:/work` -- correct for the collision class it
was written for -- and nothing was added to replace the disc. sylph-decoder
still forwards SYLPH_ISO, but as a bare environment variable naming a host path
that does not exist inside the container. sylph-port mounts the disc explicitly,
so the one container that owns the disc and the oracle is the one without them.

This shuts the oracle, every sylpheed-cli call that names a pak, the disc-gated
tests, and -- because the XEX is on the disc -- the static PPC route too. It
does not touch the committed corpus, which is what this iteration worked from.

A second, smaller casualty of the same migration: no git identity is configured
anywhere, so the first commit in a fresh container fails outright. Both are
recorded with their fixes; neither is worked around, since the launcher runs on
the host and this container cannot restart itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UxPvE5cz7zekXBKi7Xw2r
2026-08-29 10:42:47 +00:00
27 changed files with 6562 additions and 39 deletions

View File

@@ -377,6 +377,39 @@ pub fn leading_data_offset(first_riff: usize) -> usize {
first_riff % XMA1_PACKET
}
/// Length of the **bank header** when an entry begins with one, in bytes.
///
/// A music bank opens with a header the header itself sizes: big-endian, the
/// 2048-byte block size sits at `+0x18`, the bank id is repeated at `+0x00` and
/// `+0x20`, and `+0x24` is the header's length **in blocks** (5, i.e. 10 240 B,
/// on every music bank on this disc).
///
/// This exists because [`leading_data_offset`] derives a leading packet stream's
/// start as `first_riff % XMA1_PACKET`, which is only correct when the header is
/// SMALLER than one packet. A music bank's header is exactly five packets, so
/// the modulus returns 0 and the whole header was being emitted as a sub-wave —
/// a third "stem" on a bank the corpus documents as two
/// (`docs/re/structures/bgm-two-stems.md`).
///
/// Disc-wide over `sound.pak`'s 9 519 entries the signature fires on **28**, all
/// of them music banks (ids 10011023, 11011105), and on every one of the 28
/// the declared header ends **exactly** at the first `RIFF` — so no bank on this
/// disc has both a header at offset 0 and a leading packet stream. Zero false
/// positives on the 7 993 mid-bank windows, where the leading region IS real.
pub fn bank_header_len(slb: &[u8]) -> Option<usize> {
if slb.len() < 0x38 {
return None;
}
if slb[0x18..0x1c] != [0x00, 0x00, 0x08, 0x00] {
return None;
}
if slb[0x00..0x04] != slb[0x20..0x24] {
return None;
}
let blocks = u32::from_be_bytes(slb[0x24..0x28].try_into().ok()?) as usize;
blocks.checked_mul(XMA1_PACKET)
}
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let first_riff = find(slb, b"RIFF", 0);
@@ -422,7 +455,15 @@ pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
// bound to `VOICE_D_453`/`454`, i.e. precisely the broken ones — and
// ≤0.25 s to 66 of the rest. Callers clamp to the movie length anyway.
if let Some(ri) = first_riff {
let start = leading_data_offset(ri);
// A bank that carries its OWN header at offset 0 states how long it is,
// and on this disc that header always runs right up to the first `RIFF`
// — so there is no leading packet stream at all. Without this the
// modulus below returns 0 for a 5-packet header and the header itself is
// emitted as a sub-wave: `BGM_103.slb` came back as THREE waves against a
// census, an executable reference and a runtime XMA probe that all say
// two. It decodes to 0.009 s of PCM (the same chain returns 87.744 s for
// the bank's real wave 0), and it is 99.1 % zero bytes.
let start = bank_header_len(slb).unwrap_or_else(|| leading_data_offset(ri));
if ri > start {
if let Some(data) = slb.get(start..ri) {
if data.iter().any(|b| *b != 0) {

View File

@@ -269,3 +269,62 @@ fn a_waves_declared_size_is_confirmed_by_the_next_seek() {
assert!(checked >= 30, "expected banks to check, got {checked}");
eprintln!("wave-boundary identity held for {checked} banks");
}
/// A **music** bank has no leading segment — the bytes before its first `RIFF`
/// are the bank header, and emitting them made `BGM_103` look like three stems.
///
/// The header sizes itself (`+0x24`, in 2048-byte blocks), and on every bank on
/// this disc that size lands exactly on the first `RIFF`. So the guard is not a
/// heuristic and has no threshold: if a bank states a header, believe it.
#[test]
fn a_bank_that_states_its_own_header_has_no_leading_segment() {
skip_without_disc!(root);
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
let mut with_header = 0usize;
let mut mid_bank = 0usize;
// Peek at the 56-byte header through the archive's flat data rather than
// decompressing 9 519 entries: `sound.pak` stores them uncompressed, and a
// full read of all of them is several GB (it OOM-killed the test runner).
for entry in snd.entries() {
let Some(head) = snd.data_at(entry.offset as usize, 0x38) else { continue };
match slb::bank_header_len(head) {
Some(h) => {
let b = snd.read(entry).expect("read a bank that states a header");
let ri = b.windows(4).position(|w| w == b"RIFF").expect("has a RIFF");
// Declared header ends exactly at the first RIFF: no gap, so
// nothing before it can be a packet stream.
assert_eq!(h, ri, "a bank header that does not end at its first RIFF");
with_header += 1;
}
None => mid_bank += 1,
}
}
// 28 music banks (ids 1001-1023, 1101-1105); the rest are mid-bank windows,
// where the leading region IS real and must keep being emitted.
assert_eq!(with_header, 28, "banks stating their own header at offset 0");
assert!(mid_bank > 9000, "mid-bank windows, got {mid_bank}");
eprintln!("{with_header} banks state a header; {mid_bank} mid-bank windows");
}
/// The regression itself: the menu's music bank is **two** sub-waves, and they
/// are the two the corpus names — matching the executable's `BGM_103` and the
/// two streams the runtime XMA probe saw at the main menu.
#[test]
fn the_menu_music_bank_is_exactly_two_sub_waves() {
skip_without_disc!(root);
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
for (name, sizes) in [
("BGM_103.slb", [3_876_864usize, 3_930_112]),
("BGM_001.slb", [4_466_688, 4_673_536]),
] {
let entry = snd.find_by_name(name).expect("bank present");
let b = snd.read(entry).expect("read");
let riffs = slb::to_xma_riffs(&b);
assert_eq!(riffs.len(), 2, "{name}: sub-wave count");
for (r, want) in riffs.iter().zip(sizes) {
let di = r.windows(4).position(|w| w == b"data").expect("data chunk");
let got = u32::from_le_bytes(r[di + 4..di + 8].try_into().unwrap()) as usize;
assert_eq!(got, want, "{name}: sub-wave payload size");
}
}
}

View File

@@ -7,8 +7,9 @@ and wants to reach a mission — or who needs to script that journey.
Internal names (`ptbtn03`, `GP_LOAD`, build numbers) appear only as footnotes,
because they are how *we* find things, not what the game shows anyone.
**Status:** skeleton. Most of it is ❔ and is *meant* to be — this page exists to
be filled in by playing, not to look finished.
**Status:** filling in. §1§4 now carry what the committed oracle frames actually
show; what is still ❔ is what no capture answers. This page exists to be filled
in by playing, not to look finished.
> ## ⚠️ Fill this in from the real game
>
@@ -28,10 +29,20 @@ Confidence: ✅ seen in a capture · 🟡 inferred · ❔ unknown.
| # | What you see | What you do | What happens |
|---|---|---|---|
| 1 | Publisher and developer logos on black | nothing | plays through 🟡 |
| 2 | The opening cinematic | ❔ can it be skipped, and with which button? | ends into the title 🟡 |
| 3 | **Title screen** — the wordmark animates in, then a prompt | press **Ⓐ** | goes to the main menu ✅ |
| 4 | **Main menu** | — | see §2 |
| 1 | **SQUARE ENIX** in white on black, the two dots in red, `™` after it ✅ | nothing | fades on to the next logo |
| 2 | **GAME ARTS**, **SETA** and **studio anima** stacked on black ✅ | nothing | fades on into the cinematic |
| 3 | The opening cinematic | **Ⓐ** skips it ✅ | ends into the title |
| 4 | **Title screen** — the wordmark appears **first, with no prompt**; `PRESS Ⓐ BUTTON` fades in **2.13 s** later, above the 2006/2007 Square Enix copyright line, and then pulses about every 2.2 s ✅ | press **Ⓐ** | goes to the main menu ✅ |
| 5 | **Main menu** | — | see §2 |
Both logo screens are **still pictures the game draws**, not video — neither is a
`.wmv` on the disc. Captures:
[publisher](../re/captures/title-builds/live-splash-publisher.png) ·
[developer](../re/captures/title-builds/live-splash-developer.png) ·
[title](../re/captures/title-builds/live-title-press-a.png).
⚠️ **One Ⓐ skips the cinematic**, and it is worth a lot of time: the title
arrived at **57 s** with the skip against **193 s** without it ✅.
⚠️ **The title screen has two states that look identical.** The one that ends
the boot accepts Ⓐ. The one the attract loop returns to, after the game has sat
@@ -43,6 +54,21 @@ boot.
⚠️ **The title is not input-ready for about ten seconds** after it appears ✅.
And even then Ⓐ registers roughly half the time, with nothing yet found that
predicts which ✅ — budget retries.
🔴 **Refutation attempt, 2026-08-29 — both halves of that came out wrong on the
runs I could test.** Two boots, Ⓐ pressed **7.29 s** and **7.28 s** after the
title art settled (5.15 s and 5.15 s after the prompt appeared): **accepted both
times, first press, no retry**, and each went straight on to the main menu. Ⓑ on
the menu was then also accepted first press, both runs.
⚠️ Reach: **n = 2**, so "half the time" is only made unlikely (2/2 has p ≈ 0.25
under it), not excluded — but *"not input-ready for about ten seconds"* is
contradicted outright, because 7.3 s worked twice. Keep the retry budget; drop
the ten-second wait. Evidence:
[run 1](../re/data/plate-timing-run1.tsv) · [run 2](../re/data/plate-timing-run2.tsv) ·
[`title-plate-delay-measured.md`](../re/title-plate-delay-measured.md).
⚠️ **The prompt takes 2.13 s to arrive, measured twice (2.138 s / 2.132 s).**
Timed from the moment the wordmark stops animating, not from the moment it first
appears — the build-in itself varies by half a second between runs.
---
@@ -51,18 +77,57 @@ predicts which ✅ — budget retries.
Five options in a vertical stack, roughly centred, with a highlighted state on
the focused one.
> ✅ **The focused option carries a small ring to the left of its label, and the
> ring turns — continuously, about once every 2.2 s.** It has a bright head, so
> you can see it go round. It is the **only** thing moving on this screen once it
> has settled: the labels, the bracket and the footer are all completely still
> (temporal std exactly 0.000 over 20 s). Ⓑ
> [five frames, 4 s apart](../re/captures/focus-ring/ring-single-frames-4s-apart.png) ·
> [the measurement](../re/focus-ring-spin-measured.md)
| position | label | what it opens |
|---|---|---|
| 1 | ❔ | ❔ |
| 2 | ❔ | ❔ |
| 3 | ❔ | ❔ |
| 4 | ❔ | ❔ |
| 5 | ❔ | ❔ |
| 1 | **NEW GAME** | a **DIFFICULTY** prompt, then **SELECT DATA** |
| 2 | **LOAD GAME** | the save-slot list ✅ |
| 3 | **TUTORIAL** | the lesson list ✅ |
| 4 | **OPTIONS** | the settings menu ✅ |
| 5 | **EXTRAS** | a three-item submenu ✅ |
**To fill in, by looking:** read the five labels off the screen and say what each
one leads to. ❔ Which item is focused when the menu opens · ❔ does the cursor
wrap from the last item back to the first · ❔ does left/right do anything ·
❔ what B does here — back to the title, or nothing.
Read off [`live-main-menu.png`](../re/captures/title-builds/live-main-menu.png);
destinations off
[`q4-destinations.png`](../re/captures/menu-nav/q4-destinations.png) and
[`newgame-difficulty.png`](../re/captures/newgame-path/newgame-difficulty.png).
The screen is the title art gone dim, with the wordmark ghosted behind the list
and a bracket of glowing rule-lines drawn around it. The focused item is bright
white with a **spinning ring** to its left; the others are dim blue. Every item
carries a small dot-in-circle at the left end of its underline — that is on all
five all the time and is *not* the cursor.
**Moving around ✅**
| you press | what happens |
|---|---|
| ⬆ / ⬇ | one item, and it **wraps** at both ends |
| ⬅ / ➡ | nothing |
| Ⓐ | opens the focused item |
| Ⓑ | 🟡 back to the title — see the warning below |
**Which item is focused when the menu opens is not fixed.** Four boots of the
same harness opened on `TUTORIAL`, `TUTORIAL`, `NEW GAME`, `NEW GAME`. Do not
assume the top item, and do not assume the middle one either.
> ⚠️ **The main menu is the one screen whose footer does not offer Ⓑ.** It reads
> `⊙ : Select Ⓐ : OK` — every submenu adds `Ⓑ : Back`. Measured: **zero**
> red-Ⓑ glyph pixels anywhere in the frame, on two captures, with the same
> detector finding the glyph on `EXTRAS` and `DIFFICULTY` ✅.
> ✅ **But Ⓑ does leave it, and the objection that stood here is refuted
> (2026-08-29).** This page used to say the title "returns on its own after
> ~810 s idle", so an observer could not tell Ⓑ from the timer. That timer
> belongs to the **title**, not to this screen: the main menu was held untouched
> for **≥ 60 s** and never moved. Ⓑ is delivered and is the only input in ≥ 100 s
> before the return, so the ordering is measured — the *latency* is not
> ([the measurement](../re/menu-idle-and-b-2026-08-29.md)).
*Internals: `GP_TITLE.pak` build 5; buttons `ptbtn01``ptbtn05` top to bottom.*
@@ -73,26 +138,82 @@ wrap from the last item back to the first · ❔ does left/right do anything ·
One section each, in the shape of §2: what is on screen, what the cursor does,
what each choice leads to, and what a wrong choice shows you.
### Continue / Load ❔
❔ How saves are listed · ❔ what an empty slot looks like · ❔ the confirmation
prompt and where the cursor starts.
### New game ✅
Ⓐ on `NEW GAME` does **not** start a mission. It opens **DIFFICULTY**
`EASY` / `NORMAL` / `HARD` / `BACK`, opening focused on **NORMAL** ✅ — and Ⓐ
there opens **SELECT DATA**, a save-slot picker headed
`Current Storage: Dummy HDD` that asks you to choose a file for the auto-save.
Pick one and a movie plays ✅.
[DIFFICULTY](../re/captures/difficulty-screen.png)
### Load game ✅
A vertical list of numbered slots, **8 rows visible**, scrolling as a carousel —
one capture shows the order `19, 20, 01, 02, 03, 04` with `01` focused, so the
list runs past the end and back round to the start ✅. Each row shows
`Difficulty`, `Flight Time` and `Clear Ratio`; a **Details** panel to the right
gives `STAGE`, `Game Status`, `Points` and `Times Cleared`, and an empty slot
leaves every one of those blank ✅. `Current Storage: Dummy HDD` sits along the
top.
Its footer offers more than the other menus:
`⊙ : Select Ⓐ : OK Ⓑ : Back Ⓧ : Delete Ⓨ : Select Storage` ✅.
[capture](../re/captures/menu-nav/q4-destinations.png) (left panel)
❔ Still open: the overwrite / delete confirmation, and where its cursor starts.
Known: `title → LOAD GAME → slot 01 → YES → READY ROOM → TAKE OFF` reaches
flight ✅.
### Options ❔
❔ Which settings exist, what each ranges over, how a change is applied and
whether it needs confirming.
### Tutorial ✅
A list of lessons in two headed groups, with a one-line description shown on the
left for whichever is focused ✅ — e.g. `BASIC CONTROLS` reads
*"Learn how to move and attack"*. Opens focused on the first entry.
### Extras ❔
❔ What is in it — a movie theatre, a gallery, records? ❔ what is locked at the
start and what unlocks it.
| group | lessons |
|---|---|
| **Level 1** | `BASIC CONTROLS`, `HEADS-UP DISPLAY`, `RADAR` |
| **Level 2** | `SUPPLY AND SPECIAL MOVES`, `RADIO ORDERS`, `ADVANCED CONTROLS` |
| — | `BACK` |
### Mission select ❔
⚠️ **Stage select would not move**: sixteen d-pad presses never left Stage 01 ✅.
Whether that is because only one stage was unlocked, or because the list is
driven some other way, is unknown — worth settling early, since a scripted run
has to get past it.
[capture](../re/captures/menu-nav/q4-destinations.png) (middle panel)
### Options ✅ (one level in)
`GAME SETTINGS` · `CONTROL SETTINGS` · `SOUND SETTINGS` · `SCREEN SETTINGS` ·
`BACK`, opening focused on the first ✅.
[capture](../re/captures/menu-nav/q4-destinations.png) (right panel)
❔ Still open: what is inside each of the four, what each setting ranges over, and
whether a change needs confirming.
### Extras ✅
Three items: `MISSION SELECT` · `MOVIE THEATER` · `BACK`, opening focused on
`MISSION SELECT` ✅. The cursor wraps here too — it is a menu rule, not a
per-screen one ✅.
[capture](../re/captures/title-builds/live-extras.png)
`MOVIE THEATER` has never been opened.
### Mission select ✅ — and the "stuck cursor" is explained
The stage list on the left (**8 rows visible of 16**, with a scrollbar), a detail
panel showing the stage's name, a picture, `High Score` and `Best Time`, and a
**Wide Area Space Map** on the right with the named systems on it. The chosen
difficulty is printed top-right. Footer:
`⊙ : Select Ⓐ : OK Ⓑ : Back Ⓨ : Difficulty` ✅.
⚠️ **"Stage select would not move" — sixteen d-pad presses never left Stage 01 —
is now explained: the other fifteen stages were LOCKED** ✅. A locked row is
drawn *dimmer than an unfocused one*: measured, the labels sit at three distinct
brightnesses — focused **254**, unlocked **183**, locked **104** — and on a save
with the story unlocked the same rows read 183, with the cursor able to reach
**Stage16** at the bottom of the scrolled list.
[the measurement](../re/menu-navigation-semantics.md#-mission-select-the-cursor-was-stuck-because-the-stages-were-locked) ·
[locked](../re/captures/mission-select-stage01-only.png) ·
[unlocked](../re/captures/mission-select-all-story-unlocked.png) ·
[at Stage16](../re/captures/mission-select-ends-at-stage16.png)
So: if you are scripting a run, **check what the save has unlocked** before
concluding the list is broken. ❔ Whether the list wraps past Stage16, and
whether a locked row is skipped or simply unreachable, is not settled.
### Briefing and Ready Room ❔
❔ What you read, what you choose, and what finally launches the mission.

View File

@@ -23,6 +23,170 @@ There is no fourth kind. If a row says *measured* or *undecodable*, the port is
human can see it is a human decision, so that when it is later decoded the
authored version can be deleted.
## ✅ 2026-08-29 — the disc is back in the decoder container; the red banner that stood here is withdrawn
**This supersedes the "the decoder container has no disc" banner** written at
commit `b9aca6a` (10:42 UTC). That diagnosis was true for the container it was
written in, and a human has since fixed it: this container's PID 1 started at
**11:07:38 UTC**, 25 minutes later, and it has the disc mounted.
Verified, not assumed:
| | |
|---|---|
| `/disc` | a real read-only bind mount on device 2050 (`/` is device 92), **6.2 GB**, 74 entries under `dat/`, `default.xex` present |
| `/iso/game.iso` | present, 7 835 492 352 B |
| end-to-end | `sylpheed-cli screen list $SYLPHEED_DISC/dat/GP_TITLE.pak` returns **12 screen builds**, matching [`ui-title-build-map.md`](../re/ui-title-build-map.md) |
⚠️ **`sylph-doctor` still reports "no ISO" and "no extracted disc", and it is
wrong.** It looks only under `/work` (`find /work -maxdepth 2 -iname '*.iso'`
and `-d /work/sylph_extract/dat`); it never consults `$SYLPHEED_DISC`. Do not
take its two ✖/! lines as evidence about the disc — the disc is at `/disc`
and works. Same trap for `find / -xdev`, which by definition cannot cross into
a bind mount on another device, and which is what the withdrawn banner ran.
**What that means for you:** the decoder can boot the oracle, run
`sylpheed-cli` against a pak, run the disc-gated tests and read the executable
again. New measurements are available; ask for them.
## ✅ 2026-08-29 — the "third sub-wave" on a music bank was OUR reader, and it is fixed
**You were right to refuse to choose which one to drop.** `BGM_103.slb` really
does return three from `sound_bank_riffs` — and the third is the **bank header**,
not a stem. Our own `to_xma_riffs` was emitting it.
The cause is arithmetic, not a judgement call: the hybrid branch derives a
leading packet stream's start as `first_riff % 2048`, which is correct only when
the bank header is smaller than one XMA1 packet. A music bank's header is exactly
**five** packets (10 240 B), so the modulus returned 0 and the whole header came
back as sub-wave 0. Voice banks are unaffected — their headers really are shorter
than a packet, which is why the branch looked right for two months.
Checked before believing it, three ways:
* **disc-wide** — of `sound.pak`'s 9 519 entries, **28** carry a header at offset
0 (ids 10011023, 11011105 — every music bank), and on **28/28** the header's
own declared length ends *exactly* at the first `RIFF`. **Zero** have a gap, so
a header and a leading packet stream never coexist on this disc, and **zero**
false positives among the other 9 491;
* **decode control, same chain, same bank** — the emitted region gives **0.009 s**
of PCM; the same bank's real wave 0 gives **87.744 s** against a declared
87.75. It is also 99.1 % zero bytes;
* **the oracle already said two** — the XMA probe at the main menu saw exactly
two streams, of 3 876 864 and 3 930 112 B, which are `BGM_103`'s two declared
wave sizes.
**What you should do:** bump your `sylpheed-formats` pin to the tag below and
delete the manifest warning's special case — `sound_bank_riffs` now returns
**2** for every music bank, and your "count != 2" warning becomes a real
invariant rather than a symptom. ⚠️ Do **not** apply a "drop the smallest
sub-wave" rule; on a voice bank the leading region is genuine audio and dropping
it is the `VOICE_D_453` bug all over again.
[`structures/slb-bank-header-not-a-wave.md`](../re/structures/slb-bank-header-not-a-wave.md)
## ✅ 2026-08-29 — the interactive title is reachable again, and the "emulator-blocked" banner in MISSION is withdrawn
Two consecutive boots reached the interactive title **with no pad input at all**,
passed through the attract loop in ~3.5 minutes, took Ⓐ to the main menu and Ⓑ
back. The standing negative ("three runs, two locales, two launch paths, ~35
minutes of emulator time, no interactive title") does not hold in this container.
**Why it changed is not established.** The container came up with **no Xenia
storage root at all** — no profile, no `xconfig.settings`, no shader cache — so
run 1 created one with canary's `--create_profile_if_none`. That is a correlation
across two runs, not a cause, and it is written down so the next session can test
it rather than re-derive the reachability.
[`capture-harness-status.md`](../re/capture-harness-status.md)
**What it means for you:** the oracle is live. Anything you need timed or
observed on the five screens can now be asked for and taken, including the two
items MISSION parks as emulator-blocked.
## ✅ 2026-08-29 — three answers from one oracle session (the port's asks 1, 2 and 3)
* **1 — the focus ring SPINS CONTINUOUSLY. Period 2.18 s wall-clock; author it
as 120 units = 60 frames = 2.00 s at 30 Hz.** It does not ramp once and stop.
Measured with no angle estimated anywhere — the angular estimator written for
this **failed its own control** (a synthetic 30° came back as 0°) and was not
used. What settles it instead: total annulus brightness is conserved to
**0.4 %** while individual angular bins swing by **24** — brightness moving
*around* the ring, which excludes a pulse — and the profile's autocorrelation
has **eight evenly spaced peaks, mean 2.177 s**, over nine revolutions.
⚠️ Do not read the committed 20 s mean image as a frame: the spin averages to
a uniform circle, which is why it looks headless. Five single frames 4 s apart
show the head at five different angles.
[`focus-ring-spin-measured.md`](../re/focus-ring-spin-measured.md) ·
[frames](../re/captures/focus-ring/ring-single-frames-4s-apart.png)
***And the ring is the ONLY thing that moves on the settled main menu.**
Temporal std over 20 s untouched is **exactly 0.000** on every unfocused
button, on the labels and on the `ptmsg` footer. Static menu + spinning ring
draws everything that moves.
* **3 — the idle timer that made your Ⓑ rule unprovable is REFUTED on the main
menu.** Held untouched, the menu stayed put for **≥ 60 s** (49 samples, menu
correlation never leaving 0.92450.9249), against the "~810 s idle returns to
the title" this page carried. ✅ That timer is real but belongs to the
**title**, not the menu — the corpus had it attached to the wrong screen.
🟡 Ⓑ itself: delivered (Canary logs `vk=5801`), and in both runs the only input
in ≥ 100 s, followed by the title. **Ordering measured, timing not** — keep
Ⓑ→title, now better supported than authored.
[`menu-idle-and-b-2026-08-29.md`](../re/menu-idle-and-b-2026-08-29.md)
* **Your new #1 — the boot title shows build 4 FIRST, and the plate arrives
after.** It is your third option, not the first two. Green-Ⓐ glyph count on the
boot title went **154 → 781**, and 154 is the same reading the committed
`live-title-build4-no-plate.png` gives (159) while plate titles give
753/977/1493.
**AND THE DELAY IS NOW MEASURED — 2.13 s, replicated to 6 ms.** Two
independent boots: the plate arrives **2.138 s** and **2.132 s** after build 4
settles (the frame where the glyph counter first reads its no-plate 154 and
motion goes to zero). **So `ScreenView` does have to draw two builds at once,
and your `--boot` end state is NOT plate-free** — that is the structural answer
you said this question decides.
⚠️ Measure from **settled**, not from first pixels: "first drawn → plate" is
3.78 s in one run and 4.26 s in the other, because the build-in animation
itself ran 1.64 s and 2.13 s. That spread is the emulator's frame pacing, not
the game's clock.
Then pulse the plate at ≈ **2.24 s** (four intervals: 2.12 / 2.19 / 2.34 /
2.31), which replicates the corpus's ≈2.3 s rather than replacing it.
[`title-plate-delay-measured.md`](../re/title-plate-delay-measured.md) ·
[figure](../re/captures/ui-timing/plate-onset-two-runs.png) ·
[run 1](../re/data/plate-timing-run1.tsv) · [run 2](../re/data/plate-timing-run2.tsv)
* 🟡 **Your black hold survives a real clock — keep 0.170.23 s.** Measured on
the Ⓐ path in both runs, the frame is pure black (surface mean 0.070) for
**0.140.30 s** and **0.140.27 s**. At an 0.125 s sample interval that is as
tight as this instrument goes, and it brackets both your authored value and
the file's declared 12 units (0.20 s). It is the one authored constant you
ship that a measurement now agrees with.
* 🔴 **The Ⓐ→menu latency is STILL not a number you may have, and now I know
why.** Both runs contain a **frozen frame** on the Ⓐ path — 14 frames (1.53 s)
and 12 frames (1.39 s) held at surface mean **26.626**, agreeing between two
independent runs to six decimals. Run 2 had stream restarts disabled for the
whole window, so it is **not** the capture path: the guest starts the fade,
re-presents one frame for ~1.4 s, then shows the full title again and fades
properly. That is a **load stall**, and the Ⓑ path — nothing to load — has none.
So any Ⓐ→menu figure from this harness is an emulator load time. Your
zero-dwell sequencer is the right call; do not add one.
* 🔴 **Four durations I took the same day are WITHDRAWN, including the plate
delay.** `screen_match.classify_array` costs **1503 ms/frame**; a probe running
it per frame drained an 8 fps stream at **0.64 fps**, so its frames were stale
and increasingly so. It manufactured "plate 24.66 s after the title art",
"Ⓑ→title in 15.58 s", "Ⓑ→title in 25.60 s" and "Ⓐ→menu in 20.26 s". The tell:
a transition, a press and a fade do not share a duration — a backlog does.
A backlog **preserves ordering and destroys durations**, which is exactly why
the sequence results above stand and the timings do not. Fixed (`fast=True`,
3875 ms, re-controlled 8/8 on both paths, agreeing to ±0.005); the ring's
numbers are unaffected and that was checked, not assumed.
**One of the four is now re-taken properly** — the plate delay, above. The
probe that took it costs **8.7 ms/frame** (173× cheaper) and both runs sampled
at **7.97 / 7.98 fps against a requested 8**, so there was no backlog to
destroy them.
## Status
| | Question | State | Answer / link |
@@ -36,7 +200,7 @@ authored version can be deleted.
| Q7 | transitions | ✅ answered | a **fade through black**, drawn by the screen's own last-painting `.prm` quad. Fade-in ramp is **decoded** from its keyframes; the ~0.4 s fade-out is **measured** (not in the file) — [`screen-transitions.md`](../re/screen-transitions.md) |
| Q8 | menu audio bindings | ✅ answered | cue vocabulary + bank **decoded**; event binding is a **name match** (the authors' own event names). ✅ **You CAN have the SE audio** — ⚠️ an earlier version of this row said it was "undecodable from the disc"; that was **retracted** and the row was stale. Three cues are located in `Static.slb` and **decode to PCM**: d-pad move `0x1ec0` (4 packets), Ⓑ back `0x0ec0` (2), Ⓐ confirm `0x5d6c0` (6), all mono 48 kHz. The bank is a packed run of XMA waves with no delimiter, so a wave is only (offset, packet count) — and ⚠️ the file order is **not** cue-id order, so the index cannot be counted out — [`menu-audio-cues.md`](../re/menu-audio-cues.md) |
| Q9 | video binding + playback rules | ✅ answered | **decoded** from the movie manifest: `ADVERTISE_MOVIE``ADV.wmv` (boot intro *and* attract are one asset), `MS00A``S00A.wmv` is the new-game intro, `STAFF_ROLL`→the credits reel. ✅ **one Ⓐ skips a movie** (title at 57 s vs a 193 s baseline) — [`movie-binding.md`](../re/movie-binding.md) |
| Q10 | music-bank sub-wave roles (intro+loop?) | ✅ answered | **two stems of one performance, played together** — sample-synchronous, equal duration, 32/32 banks. **Concatenating is wrong.** Not a seamless loop either — [`structures/bgm-two-stems.md`](../re/structures/bgm-two-stems.md) |
| Q10 | music-bank sub-wave roles (intro+loop?) | ✅ answered | **two stems of one performance, played together** — sample-synchronous, equal duration, 32/32 banks. **Concatenating is wrong.** Not a seamless loop either — [`structures/bgm-two-stems.md`](../re/structures/bgm-two-stems.md). ⚠️ **Our reader said three until 2026-08-29** — the extra one was the **bank header**, emitted by `to_xma_riffs`; fixed, with a 28/28 disc-wide check and two regression tests — [`structures/slb-bank-header-not-a-wave.md`](../re/structures/slb-bank-header-not-a-wave.md) |
| S1 | Ready Room go/no-go | ✅ **no-go** | it is 2D and enumerates fine (60 builds), but `GP_READY_ROOM.pak` holds **briefing/tactical-map** content, not the six-button Ready Room menu — [`ready-room-probe.md`](../re/ready-room-probe.md) |
## Already settled — the port can rely on these today
@@ -900,6 +1064,7 @@ here until 2026-08-28 and is now settled.)
| 🟡 | **the paint-order tie-break** (Q3) | eight candidates refuted; costs one element's blend on one screen |
| 🟡 | **GamePart ids behind the buttons** (Q4) | the *screens* are measured; the ids are a name match onto the executable's class names |
| 🟡 | **the boot transitions in code** (Q6) | both levels decoded — phase at `this+132` (`entry→2`, `2→0`, `2→3`, `3→4`, `4→2`) and state at `this+136` inside phase 4. Phase 0 = splash (`LOGO`), phase 2 = title + `PRESS Ⓐ`, phase 4 = menu. Unknown: what the event *numbers* mean |
| 🟡 | **Ⓑ leaving the main menu** (Q5) | **upgraded 2026-08-29 (later).** The idle half of this objection is **refuted**: the main menu does not self-return for **≥ 60 s** untouched, and the ~810 s idle belongs to the **title**. Ⓑ is delivered (Canary logs `vk=5801`) and is the only input in ≥ 100 s before the return, so the **ordering is measured**; the latency is not (a backlogged probe void). The footer point stands — the main menu is still the only screen not advertising Ⓑ — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md#-refutation-attempt-2026-08-29--the-main-menus-own-footer-does-not-advertise-ⓑ) |
| ❔ | **builds 0/1 and 10/11**, the `DELTASABER` plates (Q2) | never seen anywhere in the boot path, the title-side screens or the attract loop. A mission load is the remaining candidate and this container kills runs before one completes |
(An earlier version of this table called the audio items blocked on "an emulator
@@ -912,6 +1077,15 @@ box sitting at ~1 GB free with swap exhausted. Dynamic experiments here have to
fit in roughly two minutes of guest time, which is why several of these residuals
are unfinished rather than unattempted.
## The player's-eye map of the menus
[`docs/game/navigation.md`](../game/navigation.md) is the screen-by-screen walk
through the game from the chair — every label, what the cursor does, what each
footer offers. It was filled in on 2026-08-29 from the committed oracle frames,
and it is the page to read if you want to know what a screen *looks like* rather
than how its bundle is laid out. Every ✅ there is a capture, and what is still ❔
is what no capture answers.
## Reference data
Committed alongside the findings, so the port can be built without a disc in the

View File

@@ -147,10 +147,11 @@ files, which is how the same ground got covered twice.
| [`structures/unit-struct-runtime.md`](structures/unit-struct-runtime.md) | Runtime `Unit` struct (craft / vessel definitions) — read from live guest memory | — |
| [`structures/weapon-struct-runtime.md`](structures/weapon-struct-runtime.md) | Runtime `Weapon` / `Shell` structs — read from live guest memory | — |
| [`structures/xbg7-mesh.md`](structures/xbg7-mesh.md) | XBG7 — mesh geometry (inside XPR2 model containers) | — |
| [`capture-harness-status.md`](capture-harness-status.md) | Why the harness stops reaching the title — and the two instruments that could not see the disc | ✅ **the disc is BACK** (2026-08-29, container replaced at 11:07:38): `/disc` is a real 6.2 GB read-only mount and `screen list` returns 12 builds. The "no disc" section is withdrawn — and its two instruments were blind either way: `find / -xdev` cannot cross into a bind mount on another device, and `sylph-doctor` only ever looks under `/work`. Earlier sections: `screenshot` costs 10.8 s under xenia (92×), and `trace_gpu_stream` is a no-op in the Release build |
| [`title-crash-stl-tree.md`](title-crash-stl-tree.md) | The title-screen crash is an STL `map`/`set` erase on a bad iterator | ✅ CONFIRMED — the guest throws std::out_of_range from an STL |
| [`ui-paint-order-third-permutation.md`](ui-paint-order-third-permutation.md) | A third measured paint order — tool built and validated, screen not reached | ✅ the reader works and is CONFIRMED against both previously |
| [`ui-quad-class-foothold.md`](ui-quad-class-foothold.md) | The guest's UI quad class — a foothold found from the capture's vertex layout | 🟡 PROBABLE for the identification below (it is a static read, but |
| [`menu-navigation-semantics.md`](menu-navigation-semantics.md) | The title menu — how it moves, and where each button goes | ✅ measured: wraps both ends, Ⓑ restores focus, ⬅➡ inert; 4 of 5 destinations driven. 🟡 GamePart id is a name match, ❔ `NEW GAME` untested |
| [`menu-navigation-semantics.md`](menu-navigation-semantics.md) | The title menu — how it moves, and where each button goes | ✅ measured: wraps both ends, Ⓑ restores focus, ⬅➡ inert; all 5 destinations driven. 🟡 GamePart id is a name match. 🟡 **Ⓑ leaving the MAIN menu downgraded 2026-08-29** — uncited, and the main menu is the only screen whose footer omits Ⓑ (0 glyph px in frame vs 514/518 elsewhere). ✅ **MISSION SELECT's stuck cursor was a LOCKED stage list** — labels have three brightnesses, locked 104 / unfocused 183 / focused 254 |
| [`screen-transitions.md`](screen-transitions.md) | Between two screens — a fade through black, and where its timing lives | ✅ the fade quad's keyframe group is decoded (disc-wide: per-pak all-or-nothing; `GP_TITLE` = the 6 screens, not the 6 overlays); the ~0.4 s fade-OUT is measured, not on the disc |
| [`menu-audio-cues.md`](menu-audio-cues.md) | Menu audio — the event vocabulary is on the disc, the binding is not | ✅ `SE_UI_*` cue names/ids decoded and `BANK_SE``Static.slb` (0/322 in FILES); 🟡 event binding is a name match; ❔ `Static.slb` has no wave boundaries, so SE audio is not extractable |
| [`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md) | What the game reads at boot — `config.ini`, and which GameParts exist | ✅ `config.ini` selects the language (the disc's only config); ❔ its `[SYSTEM]` is empty so the boot order is not in config; 🟡 24/29 ids bind to a class, `GP_ADVERTISE_DEMO` is never registered |
@@ -161,3 +162,7 @@ files, which is how the same ground got covered twice.
| [`upstream-baseline.md`](upstream-baseline.md) | A stock-upstream baseline runs Stage 02 crash-free | ✅ CONFIRMED — upstream canary_experimental + only the pad |
| [`weapon-datasheet-runtime.md`](weapon-datasheet-runtime.md) | Weapon DATA SHEET — runtime capture (Route B) | 🟡 first dynamic capture, 2026-07-28. The Arsenal's Gallery Mode panel is a |
| [`xpr2-colour-check.md`](xpr2-colour-check.md) | XPR2 colours: channel order ✅ confirmed against the running game | — |
| [`focus-ring-spin-measured.md`](focus-ring-spin-measured.md) | The main menu's focus ring spins continuously — and how fast | ✅ **measured**: period **2.177 s** over 9 revolutions (8 evenly spaced autocorrelation peaks) = 120 units = 60 frames = 2.00 s at 30 Hz. A pulse is excluded — annulus total conserved to 0.4 % while per-bin brightness swings by 24. ✅ the ring is the **only** moving thing on the settled main menu (std exactly 0.000 elsewhere). 🔴 no angle is quoted: the angular estimator FAILED its own control (30° → 0°) |
| [`structures/slb-bank-header-not-a-wave.md`](structures/slb-bank-header-not-a-wave.md) | Why a music bank read as THREE sub-waves when the census says two | ✅ **decoded**: the third is the **bank header**, emitted by our own reader. `to_xma_riffs`'s hybrid branch derives a leading packet stream's start as `first_riff % 2048`, which is right only for a header shorter than one packet; a music bank's header is exactly **5 packets (10 240 B)**, so the modulus gave 0 and the whole header came back as sub-wave 0. The header states its own length at `+0x24` in blocks. Disc-wide over 9 519 `sound.pak` entries: **28** match the header signature at offset 0 (ids 10011023, 11011105), **28/28** end exactly at the first `RIFF`, **0** have a gap, **0** false positives — so a header at offset 0 and a leading packet stream never coexist. Decode control, same chain, same bank: the emitted region gives **0.009 s** against **87.744 s** for the real wave 0. Corroborated by the runtime XMA probe, which saw exactly two streams at the main menu. Fixed + 2 regression tests; the `VOICE_D_453` recovery is untouched (10/10 green) |
| [`title-plate-delay-measured.md`](title-plate-delay-measured.md) | How long the boot title shows build 4 before the `PRESS Ⓐ` plate | ✅ **measured**, two independent boots: **2.138 s** and **2.132 s** from the frame build 4 settles (glyph = its no-plate 154, motion → 0). Agreeing to **6 ms**. So the boot title's end state is **not** plate-free and a compositor must draw **two builds at once**. ⚠️ Measure from *settled*, not from first pixels — "first drawn → plate" is 3.78 s vs 4.26 s across the same two runs, because the build-in animation's own duration varies with emulator frame pacing. Plate pulse re-measured at 2.12/2.19/2.34/2.31 s (mean 2.24), replicating the corpus's ≈2.3 s. ✅ black hold between screens bracketed at **0.140.30 s**, consistent with the declared 12 units. 🔴 the Ⓐ→menu latency is still **not** available: both runs freeze one frame for ~1.4 s at surface mean **26.626** — agreeing between runs to 1e-6, and reproduced with stream restarts disabled — which is a guest **load stall**, not the capture path. Probe: 8.7 ms/frame, 7.97/7.98 fps against a requested 8, controls 9/9 + 4/4 |
| [`menu-idle-and-b-2026-08-29.md`](menu-idle-and-b-2026-08-29.md) | The main menu does not idle back to the title — and four durations that were a pipeline | ✅ **refuted**: no self-return in **≥ 60 s** untouched; the ~810 s idle belongs to the **title**. 🟡 Ⓑ→title ordering measured, latency not. 🔴 `classify_array` at **1503 ms/frame** drained an 8 fps stream at 0.64 fps and manufactured four latencies (24.66 s / 15.58 s / 25.60 s / 20.26 s) — all withdrawn; a backlog preserves ordering and destroys durations |

View File

@@ -617,3 +617,35 @@ neighbourhood, not just the line.
`10 144 of 10 148 references resolve`) → **withdrawn; it is on the disc.** It is
the `GP_STAGE_CLEAR` child the same scan named `8AX`. With the name decoded the
count is **10 148 of 10 148**. [`ratc-child-names.md`](structures/ratc-child-names.md)
## The oracle harness and the container (2026-08-29)
* "the decoder container has no disc" → **refuted the same day.** The container
was replaced and `/disc` is a real 6.2 GB read-only mount. Worse, both
instruments behind the claim were blind to the answer either way:
`find / -xdev` **cannot cross** into a bind mount on another device, and
`sylph-doctor` only checks `/work` and never `$SYLPHEED_DISC`. "sylph-doctor
agrees" was two instruments sharing one blind spot.
→ To test for the disc, ask the variable that names it:
`sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_TITLE.pak"`.
* "the main menu returns to the title on its own after ~810 s idle" → **refuted.**
The menu sat untouched for **≥ 60 s** without moving (correlation never leaving
0.92450.9249). The ~810 s idle is real but belongs to the **title**. This was
the only reason "Ⓑ leaves the main menu" was classed as authored.
* "whole-image statistics (green / white / mean) can tell the title from the
attract movie" → **refuted.** A frame of `ADV.wmv` with a bright green laser
reads green 0.0018 / white 0.086 / mean (53,67,76) — the title's numbers. A
probe built on it tapped Ⓐ into the movie and waited 120 s for a menu that was
never coming. → Correlate against a committed capture instead, and keep movie
frames as the negative controls.
* "a 360-bin angular cross-correlation can measure the focus ring's rotation
angle" → **refuted by its own control**: a synthetic **30°** rotation of a live
frame came back as **0°** (peak 0.596), while 90/180/270° came back exactly
(peak 1.000) — it only resolves exact pixel permutations. No angle was quoted;
the spin was established from brightness conservation instead.
* "a latency read off a classified `x11grab` stream is a duration" → **refuted.**
At 1503 ms per classification against an 8 fps stream the consumer ran at
0.64 fps, so frames were stale and increasingly so. Four "durations" died with
it. The tell was that a screen transition, a button press and a plate fade all
came out at ~2025 s. → A backlog **preserves ordering and destroys
durations**; check consumed-fps against requested-fps before quoting a time.

View File

@@ -1,3 +1,62 @@
# ✅ WITHDRAWN 2026-08-29 (later the same day) — the interactive title IS reachable here, twice, with no pad input
**This banner supersedes everything below it about the title being unreachable,
and it supersedes the 🔴 "Emulator-side questions are blocked" section of
[MISSION](../port/MISSION.md).** Everything below is kept because the harness
defects it diagnoses were real and the fixes are in use; what it concluded about
the *game* is now refuted by measurement.
**Two consecutive boots reached the interactive title, with the `PRESS Ⓐ BUTTON`
plate, without a single pad press before it:**
| | run 1 | run 2 |
|---|---|---|
| plate on screen at | **205.4 s** into the probe | **218.4 s** |
| pad input before that | **none** | **none** |
| Ⓐ then reached the main menu | ✅ | ✅ |
| Ⓑ then returned to the title | ✅ | ✅ |
Full per-frame traces, 8 fps, 1783 and 1886 frames:
[`data/plate-timing-run1.tsv`](data/plate-timing-run1.tsv) ·
[`data/plate-timing-run2.tsv`](data/plate-timing-run2.tsv). The measurement they
were taken for is [`title-plate-delay-measured.md`](title-plate-delay-measured.md).
So the standing negative — "three runs, two locales, two launch paths, ~35
minutes of emulator time, no interactive title" — does not hold in this
container today. **The attract loop is simply passed through in ~3.5 minutes and
the title follows.**
## ❔ What changed is NOT established, and I am not going to guess it
What is different about this container, stated as facts rather than as a cause:
* it came up with **no Xenia storage root at all** — no
`~/.local/share/Xenia`, so no profile, no `xconfig.settings`, and no shader
cache. The earlier runs signed in a profile that already existed.
* run 1 therefore had to create one, with canary's own
`--create_profile_if_none=Decoder`. Run 2 signed in the profile run 1 made
(`B13EBABEBABEBABE`).
* the launch was otherwise `boot_menu.sh`'s, minus `skip_intro.sh` — this
measurement had to leave the title untouched, so nothing tapped Ⓐ at all.
⚠️ **A cold profile is a correlation across two runs, not a cause.** It is
written down so the next session can test it directly (delete the storage root,
boot, compare) instead of re-deriving that the title is reachable.
## 🔵 What this unblocks
* the **Japanese-locale capture** that MISSION parks as "🟡 needs one more run":
the mechanism (`set_console_language.py ja`, `user.language` at file offset
`0x912`) is in place, and the reason it was parked — *the title never
appears* — is gone. ⚠️ Note the storage root is new, so `xconfig.settings` has
been recreated and the byte offset should be re-located by its three landmarks
rather than assumed.
* the two items MISSION lists as emulator-blocked: the gamma control behind
[tone curve](structures/ui-render-tone-curve.md), and separating `8AX` from
`ptbase` in [8AX](structures/ui-8ax-fullres-background.md).
---
# 🔴 Why the boot harness stopped reaching the title — `screenshot` costs 10.8 s
**Status:****diagnosed, with a control.** Four consecutive runs on
@@ -362,3 +421,173 @@ The gamma run's flags plainly took effect — that run is where
`VdGetCurrentDisplayGamma` was captured — while its dump showed the file's
values. So the dump reflects the config file and cannot confirm or refute a
command-line override.
---
# ✅ 2026-08-29 (later) — the disc is back, and the section below is withdrawn as CURRENT status
Kept for its history, not as a live claim. The container was replaced: PID 1
here started at **11:07:38 UTC**, 25 minutes after commit `b9aca6a` wrote the
section below at 10:42, and the replacement has the disc mounted.
| check | result |
|---|---|
| `/proc/mounts` | `/dev/sda2 /disc ext4 ro,relatime` — a real bind mount |
| device | `/disc` is device **2050**; `/` is device **92** |
| size | 6.2 GB, 74 entries under `dat/`, `default.xex` = 3 497 984 B |
| ISO | `/iso/game.iso`, 7 835 492 352 B |
| end to end | `sylpheed-cli screen list /disc/dat/GP_TITLE.pak` → 12 builds, element/sprite counts matching the committed build map |
⚠️ **Two instruments would have said "no disc" either way, and both are still
in place.** This is the reusable lesson, and it is worth more than the
resolved incident:
* **`find / -xdev` cannot see `/disc`.** `-xdev` refuses to cross a filesystem
boundary; `/disc` is on a different device from `/`. The withdrawn section's
headline measurement — "no ISO, no `default.xex`, no `GP_TITLE.pak` anywhere"
— is what that command returns **whether or not the disc is mounted**. It had
no reach over the question it was used to answer.
* **`sylph-doctor` never checks `$SYLPHEED_DISC`.** Its two disc lines are
`find /work -maxdepth 2 -iname '*.iso'` and `[ -d /work/sylph_extract/dat ]`
(lines 7982). With the disc at `/disc` it reports "no ISO under /work" and
"no extracted disc — Reborn disc tests will SKIP" — as it does right now,
against a working disc. "`sylph-doctor` agrees" was two instruments sharing
one blind spot, not corroboration.
**To check for the disc, ask the variable that names it**: `ls "$SYLPHEED_DISC/dat"`,
or `sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_TITLE.pak"`, which fails
loudly and cheaply.
# 🔴 2026-08-29 — the disc is not in the decoder container at all *(WITHDRAWN — see the section immediately above)*
**Status:****diagnosed, root-caused in the launcher.** This supersedes every
"the emulator did not reach the title" entry above as the *current* reason the
oracle is unavailable: there is no game to run.
## The measurement
| looked for | result |
|---|---|
| `find / -xdev -iname '*.iso'` | **0** |
| `find / -xdev -iname 'default.xex'` | **0** |
| `find / -xdev -iname 'GP_TITLE.pak'` | **0** |
| `$SYLPHEED_DISC` | **empty** |
| `/work/sylph_extract` | does not exist |
| `/exchange/files` | **empty** |
`sylph-doctor` agrees and says so in its own words:
```
── project ──
✖ /work/xenia-canary not mounted
✖ /work/Syplheed-Reborn not mounted
! no ISO under /work — run-canary needs SYLPH_ISO
! no extracted disc — Reborn disc tests will SKIP
```
Everything else is healthy: `xenia_canary` is built and present, display `:98`
is up, `screenshot` works, Vulkan (llvmpipe) enumerates, cargo and the python
stack are fine. **The emulator has no disc to boot.**
## The cause — the volume migration, and a mount nobody replaced
Before [`06676d3`](#) the launcher bind-mounted the human's working tree:
```
-v "$PROJECT:$PROJECT"
-v "$PROJECT:/work"
```
The ISO and `sylph_extract/` live in that tree, so the disc arrived **incidentally
with the repository mount**, and `run-canary`'s `find "$PROJECT_DIR" -maxdepth 2
-iname '*.iso'` found it.
`06676d3` replaced that with the agent's own clone in a named volume —
```
-v "sylpheed-decoder-repo:/work"
```
— which is the right fix for the collision class it was written for, and it
removed the disc along with the working tree. **Nothing was added to replace
it.** The launcher still forwards
```
[ -n "${SYLPH_ISO:-}" ] && _out+=(-e "SYLPH_ISO=$SYLPH_ISO")
```
but that is an **environment variable with no bind mount behind it** — it names a
host path that does not exist inside the container, so it cannot help.
**The port container does not have this bug.** `docker/port/sylph-port` mounts
the disc explicitly:
```
_out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
```
So the one container that *owns* the disc and the oracle is the one container
without them.
## Reach of the negative
Whole-filesystem, single pass, `-xdev` per mount, three independent names (the
ISO, the executable, a pak the corpus names constantly). The exchange volume is
empty, so the disc is not arriving by `share` either. This is not "I looked in
the usual place".
## What it blocks — everything disc-side and everything dynamic
* the **oracle** — no boot, no capture, no `run-canary`;
* every `sylpheed-cli` invocation that names a pak — `screen list`, `screen info`,
`screen render`, `pak textures`;
* `build-reborn test` — the disc-gated tests self-skip, and per MISSION a green
run then means almost nothing. (`build-reborn` is *also* pointing at
`/work/Syplheed-Reborn`, a path the monorepo no longer has.)
* **static RE of the executable** — the XEX is on the disc, so the whole
PPC-disassembly route is shut too, not just the dynamic one.
## What it does not block
The committed corpus. `docs/re/captures/` is 99 MB of oracle frames and
`docs/re/data/` 2.5 MB of extracted tables, both in git — enough to re-measure
against captures, which is what this iteration did instead.
## 🔵 For the human — the one-line fix
Add a disc mount to `docker/decoder/sylph-decoder`, the way `sylph-port` already
has one:
```bash
[ -d "$DISC" ] && _out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
[ -f "$SYLPH_ISO" ] && _out+=(-v "$SYLPH_ISO:/disc.iso:ro" -e "SYLPH_ISO=/disc.iso")
```
Recorded rather than worked around, per *do not improvise around a blocker*
and **not attempted**, because the launcher runs on the host and this container
cannot restart itself.
⚠️ `sylph-doctor` reports the missing ISO as `!` (a warning) rather than `✖`. For
the decoder that is not a warning: it is the difference between having an oracle
and not having one.
### A second, smaller consequence of the same migration — no git identity
`git commit` in a fresh decoder container fails with *"Author identity
unknown"*: nothing in the image, the entrypoint or `sylph-decoder` sets
`user.name` / `user.email`, and the old bind mount used to bring the human's
`.git/config` along with the tree.
Set locally, per iteration if the volume is recreated:
```bash
git config --local user.name "sylph-decoder"
git config --local user.email "fabian@diekaulbachs.de"
```
⚠️ `push-work`'s header warns at length against `git config --local`, because
the credential helper it wrote there leaked a container-only path onto the host.
**That warning no longer applies to identity**: `/work` is a private named
volume now, not a shared bind mount, so nothing written to its `.git/config`
can reach a host checkout. The credential helper is still applied per-invocation
with `-c`, and should stay that way.

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
# ✅ The main menu's focus ring spins continuously — period **2.18 s**, measured
**Status:****measured** (not on the disc as a period; the disc declares the
ramp, the running game supplies the rate). Taken 2026-08-29 against Xenia Canary
with the disc mounted at `/disc`.
**Question this closes:** the port asked whether `ptbtneff01` — the 42×46 ring on
the focused button — is *animated* while a button sits focused, or drawn once and
held. It had shipped the ring at 0° and marked that known-wrong.
[`structures/ui-button-focus-record.md`](structures/ui-button-focus-record.md)
already said "the ring SPINS" from **one** frame showing it at a large angle;
that is consistent with a continuous spin *and* with a static draw at a fixed
angle, so it did not answer the question asked.
## What the ring actually does
Five single frames from one run, 4 s apart, focus held on `TUTORIAL` throughout:
![five frames](captures/focus-ring/ring-single-frames-4s-apart.png)
The ring carries a bright head, and the head is at a different angular position
in every frame. It is still moving 16 s in, so it does **not** ramp once and
stop.
⚠️ **The 20 s mean of the same run is a uniform circle**
([`ring-20s-mean-uniform.png`](captures/focus-ring/ring-20s-mean-uniform.png)) —
that is the spin smearing itself out, and it is why an averaged frame must never
be read as a single frame. A human looking at the live game sees the head; the
average does not have one.
## The measurement, and why it is not an angle
🔴 **No angle is estimated anywhere.** The corpus's centroid estimator fails its
own control by up to 19.8°, and a 360-bin angular cross-correlation written for
this measurement **also failed its control** — a synthetic 30° rotation of a live
frame came back as 0° (peak 0.596), while 90/180/270° came back exactly (peak
1.000), i.e. the estimator only resolves the exact pixel permutations. It was
therefore not used.
What was used needs no angle. Two observables separate *rotation* from a
*brightness pulse*, and both were taken in the same run:
| observable | rotation predicts | pulse predicts | **measured** |
|---|---|---|---|
| total annulus brightness | conserved | varies | **0.4 % spread over 16 s** (5 frames); **0.53 %** over 359 frames |
| per-angular-bin brightness | varies (a travelling feature) | varies together | **per-bin sd 24.2**, max 80.3, against a per-frame angular sd of 42.2 |
Brightness moves *around* the annulus while the total holds. A pulse is excluded.
The temporal standard deviation over 103 frames is **an annulus** and nothing
else — dark inside, dark outside, peaking exactly on the ring's stroke
(radial std: r 04 → 1.08, r 1013 → **36.75**, r 2026 → 1.18):
![std annulus](captures/focus-ring/ring-temporal-std-annulus.png)
⚠️ A positional *jitter* would smear variation outside the stroke. It does not:
variation falls to ~1 both inside and outside, so the ring is not moving, it is
turning.
### The period
A dense 359-frame filmstrip (24 s at **15.03 fps against a requested 15 fps**
the consumer kept up exactly, so these timestamps are not backlogged) gives the
annulus's 360-bin profile per frame, correlated against frame 0. A rotating ring
returns to itself once per revolution, so the trace's period **is** the spin
period — again with no angle estimated.
Autocorrelation local maxima, in seconds:
```
2.18 4.36 6.52 8.70 10.86 13.02 15.22 17.42
spacings: 2.18 2.16 2.18 2.16 2.16 2.20 2.20 mean 2.177 s
```
**Eight consecutive evenly-spaced peaks over nine revolutions.** A drifting
instrument cannot produce even spacing, which is the internal check on the
number.
Raw trace committed at [`data/focus-ring-period-corr.npy`](data/focus-ring-period-corr.npy)
(rows: t, correlation-with-frame-0, annulus mean).
### What the period is in the game's own units
⚠️ **2.18 s is wall-clock under this emulator, and the emulator is not running
the game at 30 Hz.** The corpus measures 27.628.8 fps here. `ptbtneff01`
declares its first keyframe at **t = 120**, and under the settled reading
(1 unit = 1/60 s, 2 units per rendered frame) 120 units is **60 rendered
frames** — which at 27.628.8 fps spans **2.082.17 s**. The measurement sits at
the top of that band.
**So the spin is one revolution per 120 units = 60 frames = 2.00 s at a true
30 Hz**, and no new constant is needed to account for it. 🟡 The 2.18 s is
consistent with the declared 120 rather than a re-derivation of it: the guest
frame rate was not measured in this same run, so the agreement is
consistency, not closure.
## Two other things the same run measured
***The focus ring is the ONLY moving thing on the settled main menu.** Over
103 frames / 20 s untouched, temporal std is **exactly 0.000** on every
unfocused button box, on the `NEW GAME` label, and on the `ptmsg` footer. Only
the focused button's box moves (std 4.46 against a background noise floor of
0.906). A port that draws the main menu statically plus a spinning ring is
drawing everything that moves.
***The ring is `ptbtneff01`, positionally confirmed.** Its centre was located
from the temporal-std map at game **(520.7, 339.7)**. The declared leaf offset
applied to button 3's rest position (542, 322) predicts **(521, 340)**. That
is a sub-pixel agreement between a decoded declaration and a live measurement,
and it is what ties the annulus to the record rather than to "a circle near the
cursor".
## Reach
* One run, one emulator, English locale, `GP_TITLE` build 5.
* The period is measured on **one** focused button (`OPTIONS`, button 4) and the
spin is shown on a second (`TUTORIAL`, button 3). Not checked on all five, and
not checked on `EXTRAS`.
* Says nothing about the direction of rotation — the estimator that would give a
signed angle failed its control and was not used.

View File

@@ -0,0 +1,120 @@
# 🔴 The main menu does NOT self-return to the title — and three "latencies" were my own pipeline
**Status:** one ✅ **measured** negative, one 🟡 ordering-only result, and one
🔴 **instrument defect that voids three numbers I took the same day.** Taken
2026-08-29.
## ✅ Refuted: "an ~810 s idle returns to the title" does not apply to the main menu
HANDOFF's residue table downgraded *Ⓑ leaves the main menu* to **authored**, on
the grounds that "an ~810 s idle also returns to the title, and nobody has
separated the two". That reason is now gone.
**Measured:** the main menu was held with **no input at all** and classified every
~1.2 s by [`screen_match.py`](../../tools/re-capture/screen_match.py):
| phase | duration untouched | screens seen |
|---|---|---|
| period capture | 24 s | menu only |
| idle probe | **60 s** (49 samples) | **menu only** |
Correlation against the committed main-menu capture never moved outside
**0.9245 0.9249** across the whole idle window — not a drift, not a fade, a
screen sitting still. Conservatively that is **≥ 60 s of continuous idle with no
self-return**, against a claim of 810 s.
**And the 810 s idle is real — it belongs to the TITLE.** Immediately after
this run, a probe that expected to find the title still on screen found it had
left on its own into the attract movie. So the corpus's idle timer is a property
of the **title screen** (title → `ADV.wmv` → title), and the residue table
attached it to the wrong screen.
**What this gives the port:** the idle alternative that made Ⓑ unprovable is
refuted *on the screen in question*. Ⓑ is no longer competing with a timer there.
## 🟡 Ⓑ on the main menu: the ordering survives, the timing does not
Ⓑ was **delivered** — Canary's own log records `[file-pad] keystroke vk=5801
down` / `up` and `XamInputGetKeystrokeEx -> user=0 vk=5801`, so this is not a
dropped press. In both runs the menu was followed by the title, and in both runs
**Ⓑ was the only input** in a window of ≥ 100 s either side.
🟡 **But it is still two observations with a confound I cannot yet exclude**, and
the reason is the next section: the "latency" I measured is worthless, so I
cannot say the return followed Ⓑ *promptly*. What stands is ordering plus the
absence of any other cause:
* no input for ≥ 100 s before → no transition;
* Ⓑ delivered → transition to the title.
**Classification: measured ordering, unmeasured timing.** The port should keep
Ⓑ→title, and it is now better supported than "authored" — but it is not yet a
timed measurement.
## 🔴 The defect: an oracle that cost 1503 ms per frame produced three fake latencies
`screen_match.classify_array` does a ±8 px ZNCC search over a 675×1279 surface
against two references. **Measured: 1503 ms per frame.** A probe calling it on
every frame of an 8 fps `x11grab` stream therefore drained the pipe at
**0.64 frames/s** — verified from the probe's own trace, 107 samples over 166 s.
The pipe backed up at ~7.4 fps, so every frame classified was **stale, and
increasingly so**. That is not a subtle bias; it manufactured three numbers:
| reported | actually |
|---|---|
| plate appears 24.66 s after the title art | unknown |
| Ⓑ → title in 15.58 s (run 1) | unknown |
| Ⓑ → title in 25.60 s (run 2) | unknown |
| Ⓐ → menu in 20.26 s | unknown |
🔴 **All four are withdrawn.** The tell was that they are all ~2025 s: a screen
transition, a button press and a plate fade do not share a duration, but a
backlog does. The two Ⓑ figures *growing* 15.6 → 25.6 s across a longer run is
the backlog accumulating, and it is the signature to remember.
⚠️ **What a backlog does and does not destroy.** It delays every frame by the
same growing amount, so it **preserves ordering** and destroys **durations**.
That is why the ordering results above survive and every duration here does not.
**Fixed and re-controlled.** `screen_match` now has a `fast=True` path
(4× decimation, ±2 decimated px) at **3875 ms**, a 2060× reduction, and the
control was re-run on **both** paths: 8/8 each, with the fast path agreeing with
the exact path to **±0.005** on every score.
**The ring measurements are NOT affected**, and this was checked rather than
assumed: `ring_period.py` does a greyscale conversion and a crop per frame, and
achieved **15.03 fps against a requested 15** — it kept up exactly, so its
timestamps carry no backlog. Its period also has an internal check a drifting
clock cannot pass: eight *evenly spaced* autocorrelation peaks
([`focus-ring-spin-measured.md`](focus-ring-spin-measured.md)).
## 🟡 The `PRESS Ⓐ` plate: sequence answered, duration not
The port asked whether the boot title is build 4 alone, build 4 with the plate
composited from the start, or build 4 **then** the plate after a delay.
**It is the third.** On the boot title the green-Ⓐ glyph count went
**154 → 781** with the title art already matching at 0.946. The 154 is the
decisive number: the committed no-plate capture
`live-title-build4-no-plate.png` reads **159** with the same counter, and plate
titles read 753 / 977 / 1493. So the title genuinely presents **without** the
plate first, and the plate arrives afterwards.
🔴 **How long afterwards is NOT measured** — that figure came from the backlogged
probe and is withdrawn with the rest. The port needs one more run with the fast
path to get it.
## Instrument controls, now committed
The negative controls for `screen_match` are **movie frames**, because that is
the class the oracle exists to reject — a statistics-based oracle
(green/white/mean) called a frame of `ADV.wmv` containing a bright green laser
`title`, and a probe built on it tapped Ⓐ into the movie and then waited 120 s
for a menu that was never coming.
An earlier version of the control list pointed at two **scratch** grabs, and a
later run of the same probe overwrote one of them — turning a negative control
into a title frame and failing the control for the wrong reason. They are now
committed fixtures under
[`captures/instrument-controls/`](captures/instrument-controls/).

View File

@@ -23,8 +23,8 @@ with [`tools/re-capture/menu_focus.py`](../../tools/re-capture/menu_focus.py).
| **wrap at the bottom** | ⬇ from the last item goes to the **first** | same, panels 3→4, and 4 presses from `EXTRAS` landing on `OPTIONS` — i.e. wrapping — is what makes the count come out |
| **left / right** | **nothing**, on the main menu | cursor unmoved across one ⬅ and one ➡ |
| **Ⓑ on a submenu** | returns to the parent **with focus restored to the item you entered from**`LOAD GAME``LOAD GAME`, `TUTORIAL``TUTORIAL`, `OPTIONS``OPTIONS`, `EXTRAS``EXTRAS` | 4/4 |
| **Ⓑ on the main menu** | goes to the **title**, which re-draws `PRESS Ⓐ BUTTON` after a beat | |
| **Ⓑ on the title** | **nothing** | |
| **Ⓑ on the main menu** | 🟡 goes to the **title**, which re-draws `PRESS Ⓐ BUTTON` after a beat | **none** — and the main menu's own footer does not advertise Ⓑ; [downgraded below](#-refutation-attempt-2026-08-29--the-main-menus-own-footer-does-not-advertise-ⓑ) |
| **Ⓑ on the title** | 🟡 **nothing** | **none** |
Wrap holds on both screens tested — the 5-item main menu and the 3-item `EXTRAS`
submenu — so it is a menu rule, not a per-screen table.
@@ -48,7 +48,7 @@ Measured by driving: focus the item, press Ⓐ, read the screen's own title.
| `TUTORIAL` | the lesson list, `TUTORIAL`, Level 1 / Level 2 | same, middle | 🟡 `25 GP_TUTORIAL` |
| `OPTIONS` | `OPTIONS` — GAME / CONTROL / SOUND / SCREEN SETTINGS / BACK | same, right | 🟡 `8 GP_OPTIONS` |
| `EXTRAS` | **`GP_TITLE.pak` build 6** — MISSION SELECT / MOVIE THEATER / BACK | [`ui-title-build-map.md`](ui-title-build-map.md) | 🟡 `5 GP_EXTRAS` |
| `EXTRAS ▸ MISSION SELECT` | the stage list + Wide Area Space Map | | 🟡 `7 GP_MISSION_SELECT` |
| `EXTRAS ▸ MISSION SELECT` | the stage list + Wide Area Space Map **8 rows visible of 16**, and rows below the first are **locked** on a fresh save ([below](#-mission-select-the-cursor-was-stuck-because-the-stages-were-locked)) | [`mission-select-stage01-only.png`](captures/mission-select-stage01-only.png) | 🟡 `7 GP_MISSION_SELECT` |
| `EXTRAS ▸ MOVIE THEATER` | ❔ not tested | | 🟡 `6 GP_MOVIE_THEATER` |
**Say which, as the gate asks.** The *screen* each button opens is **measured**
@@ -134,3 +134,109 @@ with nothing about `NEW GAME`. 🟡 n = 1 either way; do not read it as "fixed".
Worth recording because the first observation could easily have hardened into
"the new-game path crashes", which is what "A on NEW GAME hangs" had already
become once.
---
## 🟡 Refutation attempt 2026-08-29 — the main menu's own footer does **not** advertise Ⓑ
**Attempted claim:** this page's row *"Ⓑ on the main menu goes to the title,
which re-draws `PRESS Ⓐ BUTTON` after a beat"*.
**Why this row and not another.** It is one of only **two** rows in the Q5 table
with an **empty evidence cell** (the other is "Ⓑ on the title → nothing"); every
row that cites a capture cites one. And it is a rule the port will build on
directly — it is the only way out of the main menu.
**The measurement** — whole-frame colour test for the pad-glyph discs, run by
[`tools/re-capture/footer_and_locked_rows.py`](../../tools/re-capture/footer_and_locked_rows.py)
against the committed captures:
| capture | Ⓐ glyph px | Ⓑ glyph px |
|---|---|---|
| `live-main-menu.png` | 438 | **0** |
| `live-main-menu-options-focused.png` | 438 | **0** |
| `live-extras.png` | 440 | 514 |
| `difficulty-screen.png` | 438 | 518 |
**The control passes twice over.** The same detector, unchanged, finds the red Ⓑ
on the two screens that visibly have one; and the **Ⓐ** count is 438/438/440/438
across all four, i.e. the same glyph asset at the same size on every screen — so
a Ⓑ of that family would have been ~450520 px and cannot have fallen under a
threshold. The negative is over the **whole frame**, not a guessed footer band:
`live-main-menu.png` contains **zero** red-glyph pixels anywhere.
So the main menu's legend reads `⊙ : Select Ⓐ : OK` where every submenu reads
`⊙ : Select Ⓐ : OK Ⓑ : Back`.
**Verdict: the claim SURVIVES, at reduced confidence, and the row is downgraded
to 🟡.** A legend is not behaviour — a game may accept an unadvertised Ⓑ — so an
absent glyph cannot refute a press that was actually observed. But:
* the observation has **no capture behind it**, and it is now the only Q5 row
contradicted by the game's own on-screen text;
* there is a **named confound**: the title-side screens auto-return on idle, and
"I pressed Ⓑ and ended up at the title, which drew `PRESS Ⓐ BUTTON` after a
beat" is also exactly what an idle timeout looks like to an observer who does
not hold the two apart. The corpus documents that timeout at ~810 s
([`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)).
**What would settle it:** press Ⓑ on the main menu and read `0x828A690C`, the
live screen id (`1` title, `3` main menu, `4` extras) — a transition inside a
second is a Ⓑ, one at ~810 s regardless of the press is the timeout. Cheap, and
it needs no screenshots.
🔴 **Not runnable here.** This container has no disc and no ISO, so there is no
oracle at all — see
[`capture-harness-status.md`](capture-harness-status.md#-2026-08-29--the-disc-is-not-in-the-decoder-container-at-all).
**For the port:** Ⓑ from the main menu to the title is **measured, single
observation, uncited, and unadvertised by the game**. Implement it — it is the
only exit — but treat it as authored rather than transcribed, and do not also
build the idle-return on the assumption that the two are distinct until someone
has separated them.
---
## ✅ MISSION SELECT: the cursor was stuck because the stages were **locked**
**Measured 2026-08-29 from committed captures**, no disc needed. Settles the
⚠️ open in [`../game/navigation.md`](../game/navigation.md): *"sixteen d-pad
presses never left Stage 01 — whether that is because only one stage was
unlocked, or because the list is driven some other way, is unknown"*.
The stage list has **three** label brightnesses, not two, and that is what
discriminates. Sampling the label strip (x 190…320) of each of the 8 visible
rows, 95th percentile of luminance:
| capture | row 1 | rows 28 |
|---|---|---|
| `mission-select-stage01-only.png` | **254** | **104** |
| `mission-select-all-story-unlocked.png` | **254** | **183** |
| `mission-select-ends-at-stage16.png` | 183 | 183 ×6, then **254** on row 8 |
* **254** = focused (the row carrying the spinning focus ring)
* **183** = unlocked, not focused
* **104** = **locked**
The all-unlocked capture is the control: it holds row 1 focused at the identical
254 while rows 28 move 104 → 183 as one uniform step. So the dim rows in the
Stage01-only capture are **not** "unfocused"; unfocused is 183, and they are
79 levels below it.
**And the cursor does move when they are unlocked.** In
`mission-select-ends-at-stage16.png` the list has scrolled to show Stage09…16,
the scrollbar thumb is at the bottom, and the focus ring is on **Stage16** — the
last row. Locked list: 16 presses, no movement. Unlocked list: the cursor reaches
the end.
| | |
|---|---|
| **rows visible at once** | **8** |
| **list length** | **16** (`Stage01``Stage16`; the scrollbar bottoms out at 16) |
| **why 16 presses did nothing** | every row below the first was locked |
⚠️ **Reach.** This is a still image, so it says the cursor *reached* Stage16, not
how it got there and not whether the list wraps — the scroll thumb bottoming out
at row 16 is consistent with either. Whether a *locked* row is skipped or simply
unreachable is likewise not separated: with only row 1 unlocked the two are the
same observation.

View File

@@ -21,7 +21,16 @@ BGM_001.slb (9 178 040 B)
```
A bank is a 10 240-byte header and then **exactly two waves**, and the two always
have the **same duration** — different byte sizes and different bitrates, same
have the **same duration**
⚠️ **Our own reader disagreed with this page until 2026-08-29, and the page was
right.** `slb::to_xma_riffs` was emitting that 10 240-byte header as a third
sub-wave, so `sound_bank_riffs("BGM_103.slb")` returned **three** — which the
port caught while exporting the menu music. The header is not a wave (it decodes
to 0.009 s and is 99.1 % zero); the cause was a modulus that assumes a bank
header is shorter than one 2048-byte packet, and it is fixed with a disc-wide
28/28 check —
[`slb-bank-header-not-a-wave.md`](slb-bank-header-not-a-wave.md) — different byte sizes and different bitrates, same
number of seconds. Duration is `data_size / PsuedoBytesPerSec` (the u32 at
`RIFF+0x20`; `RIFF+0x24` is the sample rate, 48 000 Hz except `BGM_020``023`
at 44 100).

View File

@@ -0,0 +1,121 @@
# ✅ A music bank's "third sub-wave" is its **header**, and the bug was arithmetic
**Status:**`CONFIRMED`**decoded**, with a disc-wide check over all 9 519
`sound.pak` entries, a decode control, and independent corroboration from the
running game. Fixed in `sylpheed-formats` 2026-08-29.
**Raised by the port**, on its P6 critical path:
`sound_bank_riffs("BGM_103.slb")` returned **three** sub-waves against
[`bgm-two-stems.md`](bgm-two-stems.md)'s census, which says a music bank is
exactly two. Its exporter was summing all three, so the shipped menu music was
the sum of three things where the corpus predicted two. It declined to choose
which to drop, which was right — that is a decoding question.
## The answer
The third thing is **the bank header**. Not a stem, not an artefact of the disc:
our own reader was emitting it.
`to_xma_riffs` has a hybrid branch for banks that carry a headerless packet
stream *before* their first `RIFF` — the fix that recovered `VOICE_D_453`'s line
([`slb-data-offset.md`](slb-data-offset.md)). It derives that stream's start as
```rust
first_riff % XMA1_PACKET // XMA1_PACKET = 2048
```
which is correct **only when the bank header is smaller than one packet**. It is,
in the voice banks the branch was written for: their headers put the first `RIFF`
at 1392, 1468, 1600 or 1728 mod 2048.
A music bank's header is **exactly five packets — 10 240 bytes** — so the
modulus returns **0**, and the branch emitted `slb[0..10240]`: the whole header,
as sub-wave 0.
The header states its own length, so nothing here needs a heuristic:
```
BGM_103.slb
+0x00 BE u32 1103 bank id
+0x18 BE u32 0x00000800 block size = 2048
+0x1c BE u32 7839244 data size
+0x20 BE u32 1103 the id again ← signature, with +0x18
+0x24 BE u32 5 HEADER LENGTH IN BLOCKS → 5 × 2048 = 10240
+0x28 BE u32 0x00100002 16 bit / 2 ch
```
## The disc-wide check
Over all **9 519** entries of `sound.pak`
([`tools/re-capture/slb_segment_phase.py`](../../../tools/re-capture/slb_segment_phase.py)
supplies the reader):
| | |
|---|---|
| entries matching the header signature at offset 0 | **28** |
| ...whose declared header ends **exactly** at the first `RIFF` | **28 / 28** |
| ...with a real gap between header and first `RIFF` | **0** |
| false positives among the 9 491 others | **0** |
The 28 are exactly the music banks — ids **10011023** and **11011105**. So on
this disc a bank header at offset 0 and a leading packet stream **never
coexist**, and the guard is not a threshold: if a bank states a header, believe
it, and there is nothing before the first `RIFF`.
⚠️ `BGM_106``BGM_109` are **not** in the 28 and must not be: their pak entries
start mid-bank, so they have no header at offset 0 and their leading region is
real audio (the tail of the previous bank). That is the same straddle
[`bgm-two-stems.md`](bgm-two-stems.md) already documents.
## The decode control
Decoding the emitted region proves it is not audio, and the control is run
through **the same chain, on the same bank, in the same invocation**:
| | bytes | PCM decoded |
|---|---|---|
| `BGM_103` — what we emitted as "sub-wave 0" | 10 240 | **0.009 s** |
| `BGM_103` — its real wave 0 (control) | 3 876 864 | **87.744 s** (declared 87.75) |
| `BGM_001` — what we emitted as "sub-wave 0" | 10 240 | **0.009 s** |
| `BGM_001` — its real wave 0 (control) | 4 466 688 | **173.809 s** (declared 173.82) |
FFmpeg `xma1`, mono/stereo taken from the bank's own `fmt `. The region is also
**99.1 % zero bytes** (6793 non-zero of 10 240 across the 28 banks) and its last
non-zero byte is at 6431, so its final 1.86 packets are entirely empty.
## Corroboration from the oracle, which was already in the corpus
[`bgm-two-stems.md`](bgm-two-stems.md) records that at the **main menu**, with
`--xma_param_probe=true`, the decoder was handed **two** stereo 48 kHz streams —
of **3 876 864** and **3 930 112** bytes, byte-for-byte `BGM_103`'s two declared
waves. A third stem would have been a third stream. The running game was already
saying two.
## The fix
`slb::bank_header_len` (new, `pub`) reads the signature and returns the declared
length; the hybrid branch uses it in preference to the modulus:
```rust
let start = bank_header_len(slb).unwrap_or_else(|| leading_data_offset(ri));
if ri > start { /* emit the leading stream */ }
```
Two regression tests in
[`tests/slb_leading_segment_disc.rs`](../../../crates/sylpheed-formats/tests/slb_leading_segment_disc.rs):
the disc-wide 28/28 identity, and `BGM_103`/`BGM_001` returning exactly two
sub-waves at their declared payload sizes. The pre-existing voice-bank tests —
`broken_banks_recover_their_line`, `derived_offset_recovers_voice_banks_without_regressing_etc`
— still pass, so the `VOICE_D_453` recovery is untouched. 10/10 green with
`SYLPHEED_DISC` set.
## Reach
* The 28 are the only banks on the disc that state a header at offset 0. A bank
format elsewhere with a header ≥ 2048 B that we have not seen would have had
the same bug; nothing on this disc does.
* This says nothing about **which** of the two remaining waves is which — that is
still 🟡 in [`bgm-two-stems.md`](bgm-two-stems.md) (surround-rear pair vs a
second intensity layer), and both readings predict playing them together.
* It does not change the count for any voice bank: `VOICE_*` entries have no
header at offset 0, so their leading region is emitted exactly as before.

View File

@@ -0,0 +1,140 @@
# ✅ The boot title shows build 4 alone for **2.13 s**, then composites the plate
**Status:****measured** — two independent boots of the real game in Xenia
Canary, 2026-08-29. Not on the disc as a delay: build 2 (the `PRESS Ⓐ BUTTON`
plate) is an overlay with no fade quad of its own, and nothing in either
bundle's keyframe group carries the gap between them.
**Question this closes:** the port asked which of three things the boot title is
— build 4 alone, build 4 with the plate composited from the start, or build 4
**then** the plate after a delay — because the third case is the only one where
`ScreenView` has to draw **two builds at once**, which it has never done. The
sequence was already answered (it is the third,
[`menu-idle-and-b-2026-08-29.md`](menu-idle-and-b-2026-08-29.md)); **the delay
was withdrawn the same day** and is what this page supplies.
## The number
| | run 1 | run 2 |
|---|---|---|
| title art first drawn (surface leaves black) | 201.617 s | 214.130 s |
| **title settled** — glyph counter first reads its no-plate value **154** | 203.260 s | 216.261 s |
| **plate first counted** — glyph leaves 154 | 205.398 s | 218.393 s |
| **settled → plate** | **2.138 s** | **2.132 s** |
| first drawn → plate | 3.781 s | 4.263 s |
**Take 2.13 s, measured from the moment build 4's own build-in animation
finishes.** The two runs agree to **6 ms**, which is under one sample interval.
⚠️ **Do not take "first drawn → plate".** It differs by 0.48 s between the two
runs because the build-in itself ran 1.64 s and 2.13 s — the emulator's frame
pacing during an animation is not the game's clock, and this is exactly the sort
of number that looks like a measurement and is really the harness.
![plate onset, both runs](captures/ui-timing/plate-onset-two-runs.png)
Raw per-frame data, 8 fps, every frame of both runs:
[`data/plate-timing-run1.tsv`](data/plate-timing-run1.tsv) ·
[`data/plate-timing-run2.tsv`](data/plate-timing-run2.tsv).
## Why "then the plate", and not "the plate was pulsing all along too dim to see"
The plate's declared alpha never exceeds `0x50` (80/255,
[HANDOFF](../port/HANDOFF.md)), so a glyph counter with a hard threshold could in
principle miss its dim phase and produce a fake delay. It does not, on two
independent observables:
* the glyph count is **exactly 154** — the committed no-plate title's own value,
159 on `live-title-build4-no-plate.png` — for every frame of the plateau, with
**zero** variation, for 1.99 s (run 1) and 2.13 s (run 2). After onset the
same counter swings 714 ↔ 1520 continuously. A cycling plate cannot produce a
flat exact-154 plateau nearly one full period long;
* the **surface mean** is flat to ±0.03 across the plateau (61.09 → 61.15) and
then rises. A pulsing overlay moves the frame mean; the frame mean does not
move until onset.
## What the port should author
1. draw build 4, play its own keyframes;
2. when build 4 has settled, wait **2.13 s**;
3. composite build **2** over it and start its pulse — period measured here at
**2.12 / 2.19 / 2.34 / 2.31 s** across four peak- and trough-to-peak
intervals in the two runs, mean **2.24 s**, which replicates the corpus's
≈ 2.3 s rather than replacing it.
So yes: `ScreenView` needs two builds at once, and the boot's end state is
**not** plate-free.
## What is NOT measured here — the press latencies, again
Both runs pressed Ⓐ on the plate and Ⓑ on the menu, and both runs contain a
**frozen frame** on the Ⓐ path that makes the Ⓐ→menu duration meaningless:
| | run 1 | run 2 |
|---|---|---|
| frames held at surface mean **26.626**, motion exactly 0 | 14 (1.53 s) | 12 (1.39 s) |
🔴 **This is not the instrument.** Run 1's freeze straddled an x11grab restart,
so it looked exactly like the documented stale-stream failure; run 2 was run with
restarts **disabled** for the whole measuring window and reproduced the same
freeze, at the **same** surface mean to six decimals, in the same place relative
to the press. Two independent runs cannot agree to 1e-6 on a stalled buffer.
It is the guest: after Ⓐ, the fade-out starts (mean 64.4 → 51.0 → 26.6), the
frame is then **re-presented unchanged for ~1.4 s**, the full title reappears at
mean 64.28, and only then does the fade run to completion. That is the shape of
a **load stall**, and the Ⓑ path — menu → title, nothing to load — has no freeze
at all.
**So the Ⓐ→menu latency is an emulator load time, not a game beat, and the port
must not bake it in.** The parts of the transition that are stall-free:
| | run 1 | run 2 |
|---|---|---|
| press → first visible change (Ⓐ) | — | 0.290.37 s |
| press → first visible change (Ⓑ) | — | 0.280.33 s |
| **pure black between the two screens** (Ⓐ path) | 0.140.30 s | 0.140.27 s |
| black → menu settled | ≈ 1.0 s | ≈ 1.0 s |
| Ⓑ path: menu fade-out to black | — | 0.50 s |
| Ⓑ path: black → title art | — | ≤ 0.27 s, and it is a **cut**, not a fade |
⚠️ The two "first visible change" figures are **upper bounds that include this
harness**: the press is a file the emulator polls (`--hid=file`), so an unknown
poll interval sits inside them. They are quoted only because they bracket the
black hold, and they do **not** refute
[`screen-transitions.md`](screen-transitions.md)'s 0.07 s, which was taken a
different way.
**The black hold does check the port's authored constant.** The port ships
0.170.23 s; both runs put it in **0.140.30 s**. Consistent, at a sampling
resolution (0.125 s) that cannot do better — so the authored value stands and is
now bracketed by a measurement rather than only by the declared 12 units
(0.20 s).
## The instrument, and its controls
[`tools/re-capture/title_timing_probe.py`](../../tools/re-capture/title_timing_probe.py),
built because the four durations withdrawn on 2026-08-29 came from a classifier
costing **1503 ms per frame** draining an 8 fps stream at 0.64 fps.
* **8.7 ms of compute per frame** — measured, 173× cheaper. The saving is the
±8 px offset search: every committed capture aligns at exactly `dy=0 dx=0`
([`five-screens-acceptance.md`](five-screens-acceptance.md)), so the live path
decimates 4× and does one ZNCC per reference instead of 25 at full res.
* **Both runs sampled at 7.97 and 7.98 fps against a requested 8.** A backlog
preserves ordering and destroys durations; there was no backlog.
* `--control` **passed 9/9 content controls and 4/4 plate-detector controls**,
including the two committed movie frames that are the class this oracle exists
to reject.
* an **independent one-shot grab** every 20 s, through a separate process, is
logged beside the stream's own frame. On the static screens the two agree to
**0.000 / 0.001**; the large disagreements are all inside movies, where a
0.3 s difference in grab time is a different picture.
* and the plateau itself carries an internal clock check: the plate's ~2.2 s
pulse is visible in the same trace. A stalled stream cannot produce a periodic
signal.
## Reach
Two runs, English locale, one machine, Xenia Canary. It says nothing about the
**attract loop's** title (which the corpus records as accepting no input at all),
and nothing about the Japanese build 7.

View File

@@ -4,8 +4,12 @@
The screen-transition fade lives here -- see docs/re/screen-transitions.md.
Usage: PAK=<pak> fade_quads.py [build...] (default: GP_TITLE, builds 2 4 5 6)"""
import struct, sys, glob, os, zlib
sys.path.insert(0, "/work/Syplheed-Reborn/tools/re-capture")
src = open("/work/Syplheed-Reborn/tools/re-capture/regn_decode.py").read()
# The monorepo migration left this pointing at /work/Syplheed-Reborn, a path
# that no longer exists -- so the command screen-transitions.md cites as its
# evidence could not be re-run. Resolve beside this file instead.
_SD = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _SD)
src = open(os.path.join(_SD, "regn_decode.py")).read()
exec(src.split("# ── POF0")[0])
DECL_AT, DECL_ENTRY, KF = 0x20, 60, 40
@@ -34,7 +38,10 @@ def parse(bundle):
groups[idx]=g; pos=end
return names, groups
pak = os.environ.get("PAK", "/work/sylph_extract/dat/GP_TITLE.pak")
# ...and the default pak pointed at /work/sylph_extract, which the disc mount
# replaced. $SYLPHEED_DISC is what run-canary and sylpheed-cli both use.
pak = os.environ.get("PAK") or os.path.join(
os.environ.get("SYLPHEED_DISC", "/disc"), "dat", "GP_TITLE.pak")
E = pak_entries(pak)
E = [b for h,b in E]
# build index -> pak entry index, from `screen list`: 0..9 then 12, 15

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Does the main menu's focus ring KEEP spinning, or is it drawn once and held?
The question is not "is the ring rotated" -- one oracle frame already showed it
at a large angle (docs/re/structures/ui-button-focus-record.md). It is whether
that rotation is ANIMATED while a button sits focused, which is what decides
whether a port draws a static ring or runs a loop.
Instrument: a live x11grab filmstrip and the per-pixel TEMPORAL standard
deviation of the frames while nothing is touched. A spinning ring makes its
own box vary; a held one does not. No angle is estimated anywhere -- the
centroid estimator that would do that fails its own control by up to 19.8 deg
(same page), so this probe measures presence-of-change instead, which is the
question actually asked.
NO FIXED PIXEL BOXES. xenia's window has a menu bar and the game surface is
1279x675 inside a 1280x720 root, so game coordinates do not address grab
coordinates. This probe saves whole-frame accumulators; `focus_ring_report.py`
aligns them against a committed capture first and only then reads boxes.
Phases: A = 20 s untouched, then d-pad DOWN, then C = 12 s untouched.
The d-pad press is the POSITIVE CONTROL: |mean(A) - mean(C)| must fire at the
two ring locations, or a null in phase A is a dead instrument, not a finding.
Usage: focus_ring_probe.py OUTDIR
"""
import os, subprocess, sys, time
import numpy as np
from PIL import Image
W, H = 1280, 720
SD = os.path.dirname(os.path.abspath(__file__))
OUT = sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/ringcap"
os.makedirs(OUT, exist_ok=True)
RESTART_S = 25 # a long-lived x11grab stream stalls and repeats frames
def open_stream():
return subprocess.Popen(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", ":98", "-r", "4",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, bufsize=W * H * 3 * 2)
class Stream:
def __init__(self):
self.p = open_stream(); self.seg = time.time()
def read(self):
if time.time() - self.seg > RESTART_S:
self.p.kill(); self.p = open_stream(); self.seg = time.time()
buf = self.p.stdout.read(W * H * 3)
if len(buf) < W * H * 3:
self.p.kill(); self.p = open_stream(); self.seg = time.time()
return None
return np.frombuffer(buf, np.uint8).reshape(H, W, 3)
def close(self):
try: self.p.kill()
except Exception: pass
sys.path.insert(0, SD)
from screen_match import classify_array # controlled: 8/8, incl. the movie
# frames that broke the old oracle
def collect(st, secs, tag):
"""Whole-frame temporal mean and std over `secs`, plus a PNG filmstrip."""
t0 = time.time(); n = 0
acc = acc2 = None
next_shot = 0.0
while True:
el = time.time() - t0
if el >= secs:
break
a = st.read()
if a is None:
continue
f = a.astype(np.float64)
acc = f.copy() if acc is None else acc + f
acc2 = f * f if acc2 is None else acc2 + f * f
if el >= next_shot:
Image.fromarray(a).save(f"{OUT}/{tag}-t{el:05.1f}.png")
next_shot = el + 4.0
n += 1
mean = acc / n
std = np.sqrt(np.maximum(acc2 / n - mean * mean, 0))
np.save(f"{OUT}/{tag}-mean.npy", mean.astype(np.float32))
np.save(f"{OUT}/{tag}-std.npy", std.astype(np.float32))
Image.fromarray(mean.astype(np.uint8)).save(f"{OUT}/{tag}-mean.png")
# a visible std map, scaled x8 and clipped -- an artefact a human can look at
Image.fromarray(np.clip(std * 8, 0, 255).astype(np.uint8)).save(f"{OUT}/{tag}-std8.png")
print(f"[{tag}] {n} frames in {secs:.0f}s = {n/secs:.2f} fps; "
f"whole-frame std mean {std.mean():.4f} max {std.max():.2f}", flush=True)
return mean, std, n
def main():
st = Stream()
t0 = time.time(); seen = None; last = None; skipped = False
# ONE (A) ~45 s in skips the intro movie: measured, title at ~57 s against a
# ~193 s no-input baseline (HANDOFF, movie-binding.md). HAMMERING is what
# breaks the boot -- 88 presses left a permanent black screen -- so exactly
# one, and only once.
while time.time() - t0 < 620:
a = st.read()
if a is None:
continue
last = a
el = time.time() - t0
if not skipped and el > 45:
subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False)
skipped = True
print(f"t={el:6.1f}s one (A) to skip the intro movie", flush=True)
continue
c, sc = classify_array(a)
if c != seen:
print(f"t={el:6.1f}s screen={c} " +
" ".join(f"{k}={v:+.3f}" for k, v in sc.items()), flush=True)
seen = c
if c == "title":
break
if seen != "title":
print("NEVER REACHED THE TITLE"); st.close(); return 1
Image.fromarray(last).save(f"{OUT}/00-title.png")
subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False)
print("(A) on the title", flush=True)
t1 = time.time(); got = False
while time.time() - t1 < 150:
a = st.read()
if a is None:
continue
c, sc = classify_array(a)
if c == "menu":
got = True; break
if not got:
print("NO MENU AFTER A"); st.close(); return 2
time.sleep(4) # let the menu's ~1 s fade-in and element ramps settle
a = st.read()
if a is not None:
Image.fromarray(a).save(f"{OUT}/01-menu.png")
print("AT MAIN MENU", flush=True)
mA, sA, nA = collect(st, 20, "A")
subprocess.run(["python3", f"{SD}/pad.py", "dpad", "down"], check=False)
print(">>> d-pad DOWN pressed", flush=True)
time.sleep(2.0)
mC, sC, nC = collect(st, 12, "C")
d = np.abs(mA - mC)
np.save(f"{OUT}/AC-absdiff.npy", d.astype(np.float32))
Image.fromarray(np.clip(d * 4, 0, 255).astype(np.uint8)).save(f"{OUT}/AC-absdiff4.png")
print(f"[A-vs-C] absdiff mean {d.mean():.4f} max {d.max():.2f}", flush=True)
st.close()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Read focus_ring_probe.py's accumulators, after ALIGNING them to game space.
A grab is the whole root window; game coordinates only address it once the
window chrome offset is measured. This script measures that offset by
correlating the run's own mean frame against the committed `live-main-menu.png`
over a +/-12 px search, and refuses to report anything if the alignment is poor.
Then, in game coordinates:
ring boxes -- 80x80 around each button's declared rest position; the ring
`ptbtneff01` is 42x46 and sits left of the label
static boxes -- `ptmsg` (one untimed keyframe) and a background corner:
the NEGATIVE controls, which must read sensor noise
positive ctrl -- |mean(A) - mean(C)| across the d-pad press must fire at the
two rings that changed state, or a null in A is a dead
instrument rather than a finding.
"""
import os, sys
import numpy as np
from PIL import Image
OUT = sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/ringcap"
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
REF = os.path.join(REPO, "docs/re/captures/title-builds/live-main-menu.png")
BTN_Y = [162, 242, 322, 401, 482]
LABEL = ["NEW GAME", "LOAD GAME", "TUTORIAL", "OPTIONS", "EXTRAS"]
BOXES = {}
for i, y in enumerate(BTN_Y):
BOXES[f"ring{i+1} ({LABEL[i]})"] = (480, y - 20, 560, y + 60)
BOXES["ptmsg footer [static ctl]"] = (527, 595, 773, 633)
BOXES["bg corner [static ctl]"] = (10, 10, 130, 130)
BOXES["button1 label [same row]"] = (560, 142, 760, 202)
def gray(a):
return (0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]).astype(np.float32)
def zncc(x, y):
x = x - x.mean(); y = y - y.mean()
d = np.sqrt((x * x).sum() * (y * y).sum())
return float((x * y).sum() / d) if d else 0.0
def align(mean_rgb, ref_rgb):
"""Measure (dy,dx) taking GAME coords -> GRAB coords. Returns (dy,dx,corr)."""
g = gray(mean_rgb); r = gray(ref_rgb)
rh, rw = r.shape
best = (None, None, -1.0)
for dy in range(30, 60): # chrome is ~45 rows
for dx in range(-12, 13):
if dy + rh > g.shape[0] or dx < 0 or dx + rw > g.shape[1]:
continue
c = zncc(g[dy:dy + rh, dx:dx + rw], r)
if c > best[2]:
best = (dy, dx, c)
return best
def main():
mA = np.load(f"{OUT}/A-mean.npy"); sA = np.load(f"{OUT}/A-std.npy")
mC = np.load(f"{OUT}/C-mean.npy"); sC = np.load(f"{OUT}/C-std.npy")
ref = np.array(Image.open(REF).convert("RGB")).astype(np.float32)
dy, dx, corr = align(mA, ref)
print(f"alignment: game(0,0) sits at grab({dx},{dy}); ZNCC {corr:+.4f}")
if corr < 0.80:
print("ALIGNMENT TOO POOR — refusing to report boxes"); return 1
print(f" (independent check: the window chrome measured 45 rows)\n")
def box(arr, b):
x0, y0, x1, y1 = b
return arr[y0 + dy:y1 + dy, x0 + dx:x1 + dx, :]
d = np.abs(mA - mC)
print(f"{'box':<30} {'A std':>9} {'A p99.9':>9} {'C std':>9} "
f"{'|A-C| mean':>11} {'|A-C| max':>10}")
print("-" * 84)
rows = {}
for k, b in BOXES.items():
a_s = box(sA, b); c_s = box(sC, b); dd = box(d, b)
rows[k] = (float(a_s.mean()), float(np.percentile(a_s, 99.9)),
float(c_s.mean()), float(dd.mean()), float(dd.max()))
print(f"{k:<30} {rows[k][0]:9.3f} {rows[k][1]:9.3f} {rows[k][2]:9.3f} "
f"{rows[k][3]:11.3f} {rows[k][4]:10.2f}")
noise = max(rows["ptmsg footer [static ctl]"][0],
rows["bg corner [static ctl]"][0])
print(f"\nnegative-control noise floor (max of the two static boxes): {noise:.3f}")
print("A box only counts as MOVING if its phase-A std clears that floor.\n")
for k in BOXES:
if "ctl" in k:
continue
v = rows[k][0]
print(f" {k:<30} A std {v:7.3f} = {v/noise:6.2f}x the noise floor"
f" {'MOVING' if v > 3*noise else 'static'}")
# visual artefacts, cropped to the game surface
for tag, arr, sc in (("A-std", sA, 8), ("C-std", sC, 8), ("AC-absdiff", d, 4)):
g = arr[dy:dy + 675, dx:dx + 1279, :]
Image.fromarray(np.clip(g * sc, 0, 255).astype(np.uint8)).save(f"{OUT}/{tag}-game.png")
Image.fromarray(mA[dy:dy + 675, dx:dx + 1279, :].astype(np.uint8)).save(f"{OUT}/A-mean-game.png")
Image.fromarray(mC[dy:dy + 675, dx:dx + 1279, :].astype(np.uint8)).save(f"{OUT}/C-mean-game.png")
print(f"\nwrote game-space artefacts to {OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Re-measure two menu facts from the COMMITTED oracle captures — no disc needed.
1. Which screens advertise Ⓑ in their footer legend.
The pad glyphs are saturated green (Ⓐ) and red (Ⓑ) discs on a blue field,
so a colour test finds them without knowing where the footer is.
2. Whether a dim MISSION SELECT row is LOCKED or merely UNFOCUSED.
Three brightness levels discriminate; the all-unlocked capture is the
control that separates them.
Usage: python3 tools/re-capture/footer_and_locked_rows.py [repo-root]
"""
import sys
import pathlib
import numpy as np
from PIL import Image
ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
CAP = ROOT / "docs/re/captures"
def glyph_masks(rgb):
r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
green = (g > 110) & (g > r + 45) & (g > b + 45)
red = (r > 110) & (r > g + 45) & (r > b + 45)
return green, red
def blobs(mask, gap=20):
ys, xs = np.nonzero(mask)
if len(xs) == 0:
return []
o = np.argsort(xs)
xs, ys = xs[o], ys[o]
out, start = [], 0
for i in range(1, len(xs) + 1):
if i == len(xs) or xs[i] - xs[i - 1] > gap:
s = slice(start, i)
out.append((int(xs[s].min()), int(xs[s].max()),
int(ys[s].min()), int(ys[s].max()), i - start))
start = i
return out
def footers():
print("== 1. footer legends: does the screen advertise Ⓑ? ==")
print(f"{'capture':46} {'Ⓐ px':>7} {'Ⓑ px':>7} verdict")
shots = [
("title-builds/live-main-menu.png", "main menu"),
("title-builds/live-main-menu-options-focused.png", "main menu (OPTIONS focused)"),
("title-builds/live-extras.png", "EXTRAS"),
("difficulty-screen.png", "DIFFICULTY"),
]
for rel, _name in shots:
p = CAP / rel
if not p.exists():
print(f"{rel:46} MISSING")
continue
a = np.asarray(Image.open(p).convert("RGB")).astype(int)
g, r = glyph_masks(a) # WHOLE frame, not a guessed band
verdict = "no Ⓑ anywhere in frame" if r.sum() == 0 else f"Ⓑ at {blobs(r)[0][:2]}"
print(f"{rel:46} {g.sum():7d} {r.sum():7d} {verdict}")
ROW_Y0, ROW_PITCH, ROW_X = 201, 50, (190, 320)
def stage_rows():
print("\n== 2. MISSION SELECT rows: locked, or just unfocused? ==")
shots = [
("mission-select-stage01-only.png", "save with only Stage01 cleared"),
("mission-select-all-story-unlocked.png", "save with the story unlocked"),
("mission-select-ends-at-stage16.png", "unlocked, scrolled to the end"),
]
for rel, note in shots:
p = CAP / rel
if not p.exists():
print(f"{rel:44} MISSING")
continue
a = np.asarray(Image.open(p).convert("L")).astype(float)
p95 = []
for i in range(8):
y = ROW_Y0 + ROW_PITCH * i
p95.append(np.percentile(a[y - 14:y + 14, ROW_X[0]:ROW_X[1]], 95))
print(f"{rel:44} {note}")
print(" row p95: " + " ".join(f"{v:5.0f}" for v in p95))
if __name__ == "__main__":
footers()
stage_rows()

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Does (B) leave the main menu -- and does the menu self-return to the title?
HANDOFF downgraded "(B) on the main menu returns to the title" to authored,
because the corpus also carries "an ~8-10 s idle returns to the title" and one
unrecorded observation cannot separate the two causes. This separates them by
ordering: hold the menu UNTOUCHED for an idle window several times longer than
the claimed 8-10 s and timestamp what happens, THEN press (B) and timestamp
again. If the idle window passes with the menu still up, the idle cause is
gone and the (B) observation is unambiguous.
Screen identity comes from screen_match.py, whose control includes the movie
frames that broke the statistics-based oracle.
Usage: menu_b_probe.py IDLE_SECONDS AFTER_SECONDS
"""
import os, subprocess, sys, time
import numpy as np
from PIL import Image
SD = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SD)
from screen_match import classify_array
W, H = 1280, 720
IDLE = float(sys.argv[1]) if len(sys.argv) > 1 else 60.0
AFTER = float(sys.argv[2]) if len(sys.argv) > 2 else 30.0
OUT = "/sylph-home/re/ringcap"
def stream():
return subprocess.Popen(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", ":98", "-r", "4",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, bufsize=W * H * 3 * 2)
def main():
p = stream(); n = W * H * 3
t0 = time.time(); seg = t0; last = None; prev = None
phase = "IDLE"; pressed_at = None
log = []
while True:
el = time.time() - t0
if phase == "IDLE" and el >= IDLE:
subprocess.run(["python3", f"{SD}/pad.py", "tap", "B", "0.3"], check=False)
pressed_at = time.time() - t0
print(f"t={pressed_at:6.2f}s >>> (B) PRESSED", flush=True)
phase = "AFTER"
if phase == "AFTER" and el >= IDLE + AFTER:
break
if time.time() - seg > 25:
p.kill(); p = stream(); seg = time.time()
b = p.stdout.read(n)
if len(b) < n:
p.kill(); p = stream(); seg = time.time(); continue
a = np.frombuffer(b, np.uint8).reshape(H, W, 3)
last = a
c, sc = classify_array(a)
log.append((el, c, sc["title"], sc["menu"]))
if c != prev:
print(f"t={el:6.2f}s screen={c:<6} title={sc['title']:+.3f} "
f"menu={sc['menu']:+.3f}", flush=True)
Image.fromarray(a).save(f"{OUT}/b-{el:06.2f}-{c}.png")
prev = c
p.kill()
with open(f"{OUT}/menu-b-trace.tsv", "w") as f:
f.write("t_s\tscreen\tcorr_title\tcorr_menu\n")
for r in log:
f.write(f"{r[0]:.3f}\t{r[1]}\t{r[2]:.4f}\t{r[3]:.4f}\n")
idle = [r for r in log if r[0] < IDLE]
aft = [r for r in log if pressed_at and r[0] > pressed_at + 1.0]
print(f"\nIDLE phase : {len(idle)} samples over {IDLE:.0f}s, "
f"screens seen = {sorted(set(r[1] for r in idle))}")
print(f"AFTER (B) : {len(aft)} samples, "
f"screens seen = {sorted(set(r[1] for r in aft))}")
first_title = next((r[0] for r in aft if r[1] == "title"), None)
if first_title:
print(f" first 'title' at t={first_title:.2f}s = "
f"{first_title - pressed_at:.2f}s after the (B) press")
print(f"trace: {OUT}/menu-b-trace.tsv")
return 0
if __name__ == "__main__":
sys.exit(main())

191
tools/re-capture/quad_rects.py Executable file
View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Screen-space rectangles for every textured quad in a xenia draw log.
The draw logs under docs/re/captures/ record vertex positions in NDC, printed
to **two decimals**. That is the whole point of this script: it converts the
quads to screen space *and* carries the quantisation with them, so a
measurement taken off one of these logs cannot quietly claim more precision
than the log has.
NDC step 0.01 -> half-step 0.005 -> a single edge is +/- 3.2 px in X
and +/- 1.8 px in Y; a WIDTH or HEIGHT is a difference of two edges, so it
carries twice that: +/- 6.4 px and +/- 3.6 px. Getting this wrong is not
academic -- at the per-edge figure the control below fails 2 of 6.
Usage:
quad_rects.py LOG [LOG ...] # every textured quad, per frame
quad_rects.py --control LOG # check recovered sizes against
# known texture dimensions
The control is not optional in spirit. Any claim made from these numbers
should quote the control first: four sprites of known size are recovered from
the same log, and the residuals bound what the instrument can see.
"""
import math
import re
import sys
# Screen is 1280x720; NDC x in [-1,1] maps to [0,1280], y in [1,-1] to [0,720].
W, H = 1280.0, 720.0
NDC_HALF_STEP = 0.005
EDGE_X = NDC_HALF_STEP * W / 2.0 # 3.2 px on one edge
EDGE_Y = NDC_HALF_STEP * H / 2.0 # 1.8 px on one edge
SIZE_X = 2 * EDGE_X # 6.4 px on a width (two edges)
SIZE_Y = 2 * EDGE_Y # 3.6 px on a height (two edges)
# Decoded texture sizes for build 4 of GP_TITLE, from
# docs/re/ui-title-paint-order-capture.md and docs/re/ui-title-build-map.md.
# These are the known-positives the control checks against.
CONTROL_SIZES = {
"ptlogo1.t32": (919, 113),
"ptlogo2.t32": (992, 104),
"ptlogo_back2.t32": (1118, 262),
"ptlogo_back2eff.t32": (1133, 280),
"ptcopyright.t32": (694, 20),
"ptbtn00.t32": (513, 50),
"ptbtn00f.t32": (537, 76),
}
FRAME_RE = re.compile(r"--- frame (\d+) ---")
DRAW_RE = re.compile(r"\s*(\d+) prim=(\d+) indices=(\d+)")
TEX_RE = re.compile(r"tex\[base=(0x[0-9A-Fa-f]+) (\d+)x(\d+)")
VERT_RE = re.compile(r"\[(-?\d+\.\d+),(-?\d+\.\d+),z=")
def parse(path):
"""Yield dicts: frame, draw, tex base, and the quad's screen-space rect."""
frame, cur = 0, None
for line in open(path):
m = FRAME_RE.match(line)
if m:
frame = int(m.group(1))
continue
m = DRAW_RE.match(line)
if m:
t = TEX_RE.search(line)
cur = {"frame": frame, "draw": int(m.group(1)),
"tex": t.group(1) if t else None}
continue
if "v:" in line and cur is not None:
verts = [(float(a), float(b)) for a, b in VERT_RE.findall(line)]
# A draw can carry several quads; four vertices each.
for i in range(0, len(verts) - 3, 4):
q = verts[i:i + 4]
xs = [(x + 1.0) * W / 2.0 for x, _ in q]
ys = [(1.0 - y) * H / 2.0 for _, y in q]
# Vertex order is TL, TR, BR, BL, so edge 0->1 is the drawn
# width and 1->2 the drawn height. For a ROTATED quad the
# bounding box is not the sprite; the edges are.
e0 = math.hypot(xs[1] - xs[0], ys[1] - ys[0])
e1 = math.hypot(xs[2] - xs[1], ys[2] - ys[1])
ang = math.degrees(math.atan2(ys[1] - ys[0], xs[1] - xs[0]))
yield {**cur,
"left": min(xs), "top": min(ys),
"w": max(xs) - min(xs), "h": max(ys) - min(ys),
"ew": e0, "eh": e1, "rot": ang,
"cx": sum(xs) / 4.0, "cy": sum(ys) / 4.0}
cur = None
def dump(path):
print(f"# {path}")
print(f"# NDC printed to 2 dp -> edge +/- {EDGE_X:.1f}/{EDGE_Y:.1f} px, "
f"size +/- {SIZE_X:.1f}/{SIZE_Y:.1f} px (X/Y)")
print(f"{'frame':>5} {'draw':>5} {'tex':>12} "
f"{'left':>8} {'top':>8} {'bboxW':>8} {'bboxH':>8} "
f"{'edgeW':>8} {'edgeH':>8} {'rot':>7} {'cx':>8} {'cy':>8}")
for q in parse(path):
if q["tex"] is None:
continue
print(f"{q['frame']:>5} {q['draw']:>5} {q['tex']:>12} "
f"{q['left']:>8.1f} {q['top']:>8.1f} {q['w']:>8.1f} {q['h']:>8.1f} "
f"{q['ew']:>8.1f} {q['eh']:>8.1f} {q['rot']:>7.2f} "
f"{q['cx']:>8.1f} {q['cy']:>8.1f}")
def control(path):
"""Recover the known-positive sprites by size and report the residual."""
rects = [q for q in parse(path) if q["tex"] is not None]
print(f"# control: {path}")
print(f"{'sprite':<22} {'decoded':>11} {'measured':>13} "
f"{'dx':>6} {'dy':>6} verdict")
ok = True
for name, (tw, th) in CONTROL_SIZES.items():
best = min(rects, key=lambda q: abs(q["w"] - tw) + abs(q["h"] - th))
dx, dy = best["w"] - tw, best["h"] - th
good = abs(dx) <= SIZE_X and abs(dy) <= SIZE_Y
ok &= good
print(f"{name:<22} {tw:>5}x{th:<5} {best['w']:>6.1f}x{best['h']:<6.1f} "
f"{dx:>6.1f} {dy:>6.1f} {'PASS' if good else 'FAIL'}")
print(f"# {'CONTROL PASSES' if ok else 'CONTROL FAILS'}"
f"every known size recovered inside the log's own quantisation"
if ok else "# CONTROL FAILS — do not measure anything with this")
return 0 if ok else 1
# Every sprite the title's build-4 capture can draw, by decoded size. The two
# pteff03 entries are the nested ptloop leaves, whose declared vertical scales
# are 600 % and 800 %.
TITLE_SPRITES = {
(919, 113): "ptlogo1.t32",
(992, 104): "ptlogo2.t32",
(1118, 262): "ptlogo_back2.t32",
(1133, 280): "ptlogo_back2eff.t32",
(694, 20): "ptcopyright.t32",
(513, 50): "ptbtn00.t32",
(38, 18): "ptlogo_tm.t32",
(399, 180): "pteff03/pteff03a.t32",
(537, 76): "ptbtn00f.t32", # build 2's focus plate
}
def scales(path):
"""For each quad, the drawn size over the nearest decoded sprite size.
The question this answers: which elements are drawn at a scale other than
100 %? Only those can say anything about what scale is anchored on.
"""
print(f"# scale census: {path}")
print(f"{'frame':>5} {'sprite':<22} {'edgeW':>8} {'edgeH':>8} "
f"{'sx%':>7} {'sy%':>7} {'rot':>7}")
seen = set()
for q in parse(path):
if q["tex"] is None:
continue
if abs(q["ew"] - W) < SIZE_X and abs(q["eh"] - H) < SIZE_Y:
name, sx, sy = "full-screen layer", 1.0, 1.0
key = (name, 1.0, 1.0)
if key not in seen:
seen.add(key)
print(f"{q['frame']:>5} {name:<22} {q['ew']:>8.1f} "
f"{q['eh']:>8.1f} {100.0:>7.1f} {100.0:>7.1f} "
f"{q['rot']:>7.2f}")
continue
# Match on the edge lengths, allowing any uniform-ish scale factor.
best, bestcost = None, None
for (tw, th), name in TITLE_SPRITES.items():
sx, sy = q["ew"] / tw, q["eh"] / th
cost = abs(math.log(sx)) + abs(math.log(sy))
if bestcost is None or cost < bestcost:
best, bestcost = (name, tw, th, sx, sy), cost
name, tw, th, sx, sy = best
key = (name, round(sx, 2), round(sy, 2))
if key in seen:
continue
seen.add(key)
print(f"{q['frame']:>5} {name:<22} {q['ew']:>8.1f} {q['eh']:>8.1f} "
f"{100 * sx:>7.1f} {100 * sy:>7.1f} {q['rot']:>7.2f}")
return 0
if __name__ == "__main__":
args = sys.argv[1:]
if not args:
sys.exit(__doc__)
if args[0] == "--scales":
sys.exit(max(scales(p) for p in args[1:]))
if args[0] == "--control":
sys.exit(max(control(p) for p in args[1:]))
for p in args:
dump(p)

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Is the focus ring ROTATING, or just pulsing in brightness?
The temporal-std map of a focused button is an annulus, which both hypotheses
predict: a travelling bright feature varies every annulus pixel, and so does a
uniform fade. Two observables separate them, and this script reports both.
(1) TOTAL annulus brightness per frame. A rotation moves brightness around
the annulus and conserves the sum; an alpha pulse does not.
(2) The 360-bin ANGULAR PROFILE, cross-correlated between frames. A rotation
shifts the profile by a lag; a pulse scales it in place.
CONTROL FIRST. The angular estimator is run over a known synthetic rotation of
the run's own first frame (30/90/180/270 deg) and must recover it; the corpus
already has a centroid estimator that fails this by up to 19.8 deg, and that is
why one is not used here.
Usage: ring_angular.py CX CY [FRAME ...] (CX,CY in GAME coordinates)
"""
import os, sys
import numpy as np
from PIL import Image
DY, DX = 45, 1 # game(0,0) -> grab, measured by focus_ring_report.py
R_IN, R_OUT = 8.0, 18.0 # annulus radii, in px, read off the std map
NBINS = 360
def ndrotate(img, deg):
"""Bilinear rotation about the patch centre -- the control's known-positive."""
h, w = img.shape
cy, cx = (h - 1) / 2.0, (w - 1) / 2.0
yy, xx = np.mgrid[0:h, 0:w]
t = np.radians(deg)
ys = (yy - cy) * np.cos(t) - (xx - cx) * np.sin(t) + cy
xs = (yy - cy) * np.sin(t) + (xx - cx) * np.cos(t) + cx
y0 = np.floor(ys).astype(int); x0 = np.floor(xs).astype(int)
fy = ys - y0; fx = xs - x0
out = np.zeros_like(img)
for dy_, dx_, wgt in ((0, 0, (1 - fy) * (1 - fx)), (0, 1, (1 - fy) * fx),
(1, 0, fy * (1 - fx)), (1, 1, fy * fx)):
yi = np.clip(y0 + dy_, 0, h - 1); xi = np.clip(x0 + dx_, 0, w - 1)
ok = (y0 + dy_ >= 0) & (y0 + dy_ < h) & (x0 + dx_ >= 0) & (x0 + dx_ < w)
out += np.where(ok, img[yi, xi] * wgt, 0.0)
return out
def ndrotate(img, deg):
"""Bilinear rotation about the patch centre -- the control's known-positive."""
h, w = img.shape
cy, cx = (h - 1) / 2.0, (w - 1) / 2.0
yy, xx = np.mgrid[0:h, 0:w]
t = np.radians(deg)
ys = (yy - cy) * np.cos(t) - (xx - cx) * np.sin(t) + cy
xs = (yy - cy) * np.sin(t) + (xx - cx) * np.cos(t) + cx
y0 = np.floor(ys).astype(int); x0 = np.floor(xs).astype(int)
fy = ys - y0; fx = xs - x0
out = np.zeros_like(img)
for dy_, dx_, wgt in ((0, 0, (1 - fy) * (1 - fx)), (0, 1, (1 - fy) * fx),
(1, 0, fy * (1 - fx)), (1, 1, fy * fx)):
yi = np.clip(y0 + dy_, 0, h - 1); xi = np.clip(x0 + dx_, 0, w - 1)
ok = (y0 + dy_ >= 0) & (y0 + dy_ < h) & (x0 + dx_ >= 0) & (x0 + dx_ < w)
out += np.where(ok, img[yi, xi] * wgt, 0.0)
return out
def patch(path, cx, cy, half=28):
a = np.array(Image.open(path).convert("RGB")).astype(np.float32)
g = 0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]
return g[cy + DY - half:cy + DY + half, cx + DX - half:cx + DX + half]
def polar(p):
"""(total annulus brightness, 360-bin mean profile) of one patch."""
h, w = p.shape
yy, xx = np.mgrid[0:h, 0:w]
cy, cx = (h - 1) / 2.0, (w - 1) / 2.0
r = np.hypot(yy - cy, xx - cx)
m = (r >= R_IN) & (r <= R_OUT)
th = (np.degrees(np.arctan2(yy - cy, xx - cx)) + 360.0) % 360.0
idx = np.clip((th[m] / 360.0 * NBINS).astype(int), 0, NBINS - 1)
v = p[m]
prof = np.zeros(NBINS); cnt = np.zeros(NBINS)
np.add.at(prof, idx, v); np.add.at(cnt, idx, 1.0)
prof = np.where(cnt > 0, prof / np.maximum(cnt, 1), np.nan)
prof = np.nan_to_num(prof, nan=np.nanmean(prof))
return float(v.sum()), prof
def lag(p0, p1):
"""Circular cross-correlation lag in degrees taking p0 -> p1."""
a = p0 - p0.mean(); b = p1 - p1.mean()
c = np.fft.irfft(np.fft.rfft(b) * np.conj(np.fft.rfft(a)), NBINS)
k = int(np.argmax(c))
peak = c[k] / np.sqrt((a * a).sum() * (b * b).sum())
return (k if k <= 180 else k - 360), float(peak)
def main():
cx, cy = int(sys.argv[1]), int(sys.argv[2])
frames = sys.argv[3:]
p0 = patch(frames[0], cx, cy)
print("=== CONTROL: recover a known synthetic rotation of frame 0 ===")
ok = True
for deg in (30, 90, 180, 270):
rot = ndrotate(p0, -deg)
_, pr = polar(rot); _, pa = polar(p0)
d, pk = lag(pa, pr)
err = ((d - deg + 180) % 360) - 180
flag = "ok " if abs(err) <= 3 else "FAIL"
if abs(err) > 3:
ok = False
print(f" {flag} applied {deg:4d} deg -> recovered {d:5d} deg "
f"(err {err:+4d}, peak {pk:.3f})")
# negative control: a ring-free patch of the same frame must not correlate
off = patch(frames[0], cx + 160, cy)
_, po = polar(off); _, pa = polar(p0)
_, pk = lag(pa, po)
print(f" ring-free patch of the same frame: peak {pk:.3f} (must be low)")
if not ok:
print("\nCONTROL FAILED — the estimator cannot measure this; stopping.")
return 1
print(" CONTROL PASSED\n")
print("=== MEASUREMENT: successive live frames of the same focused ring ===")
print(f"{'frame':<24} {'annulus sum':>12} {'vs f0 %':>9} {'lag vs f0':>10} {'peak':>7}")
base_s, base_p = polar(p0)
for f in frames:
s, pr = polar(patch(f, cx, cy))
d, pk = lag(base_p, pr)
print(f"{os.path.basename(f):<24} {s:12.1f} {100*s/base_s:8.1f}% "
f"{d:9d}d {pk:7.3f}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Measure the focus ring's SPIN PERIOD from a dense live filmstrip.
No absolute angle is estimated. The corpus's centroid estimator fails its own
control by up to 19.8 deg, and a 360-bin angular cross-correlation also FAILED
the control written for it here (a synthetic 30 deg rotation of a live frame
came back as 0 deg, peak 0.596), so neither is trusted.
What is used instead needs no angle: the annulus's 360-bin brightness profile,
correlated against frame 0. A rotating ring's profile returns to itself once
per revolution, so the correlation trace is periodic and its first return to a
maximum IS the period. The ring is located from the data (the peak of the
temporal-std map over the button column), not from a declared coordinate.
Usage: ring_period.py SECONDS OUTDIR
"""
import os, subprocess, sys, time
import numpy as np
from PIL import Image
W, H, DY, DX = 1280, 720, 45, 1
R_IN, R_OUT, NB = 8.0, 18.0, 360
SECS = float(sys.argv[1]) if len(sys.argv) > 1 else 30.0
OUT = sys.argv[2] if len(sys.argv) > 2 else "/sylph-home/re/ringcap"
COL = (480, 130, 570, 530) # x0,y0,x1,y1 in GAME coords: the button column
def grab_stream(secs):
p = subprocess.Popen(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", ":98", "-r", "15",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, bufsize=W * H * 3 * 2)
n = W * H * 3
t0 = time.time(); frames = []; ts = []
x0, y0, x1, y1 = COL
while time.time() - t0 < secs:
b = p.stdout.read(n)
if len(b) < n:
break
a = np.frombuffer(b, np.uint8).reshape(H, W, 3)
g = (0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]).astype(np.float32)
frames.append(g[y0 + DY:y1 + DY, x0 + DX:x1 + DX].copy())
ts.append(time.time() - t0)
p.kill()
return np.array(frames), np.array(ts)
def annulus_profile(patch, cy, cx):
h, w = patch.shape
yy, xx = np.mgrid[0:h, 0:w]
r = np.hypot(yy - cy, xx - cx)
m = (r >= R_IN) & (r <= R_OUT)
th = (np.degrees(np.arctan2(yy - cy, xx - cx)) + 360) % 360
idx = np.clip((th[m] / 360 * NB).astype(int), 0, NB - 1)
v = patch[m]
prof = np.zeros(NB); cnt = np.zeros(NB)
np.add.at(prof, idx, v); np.add.at(cnt, idx, 1.0)
prof = np.where(cnt > 0, prof / np.maximum(cnt, 1), np.nan)
return np.nan_to_num(prof, nan=np.nanmean(prof)), float(v.mean())
def main():
F, T = grab_stream(SECS)
if len(F) < 10:
print("too few frames"); return 1
fps = len(F) / (T[-1] - T[0])
print(f"{len(F)} frames over {T[-1]-T[0]:.1f}s = {fps:.2f} fps", flush=True)
std = F.std(0)
cy, cx = np.unravel_index(np.argmax(
np.array([[std[max(0, i-14):i+14, max(0, j-14):j+14].mean()
for j in range(std.shape[1])] for i in range(std.shape[0])])), std.shape)
print(f"ring located from the data at patch({cx},{cy}) = "
f"GAME({COL[0]+cx},{COL[1]+cy}); local std {std[cy, cx]:.2f}", flush=True)
profs = []; means = []
for f in F:
p, m = annulus_profile(f, cy, cx)
profs.append(p); means.append(m)
P = np.array(profs); M = np.array(means)
print(f"annulus mean brightness: {M.mean():.2f} +/- {M.std():.3f} "
f"({100*M.std()/M.mean():.2f}% -- a PULSE would move this)", flush=True)
a = P[0] - P[0].mean()
corr = np.array([float(((p - p.mean()) * a).sum() /
np.sqrt(((p - p.mean())**2).sum() * (a * a).sum()))
for p in P])
np.save(f"{OUT}/period-corr.npy", np.vstack([T, corr, M]))
print("\n t(s) corr-with-frame0 annulus mean")
for t, c, m in zip(T, corr, M):
bar = "#" * max(0, int((c + 1) * 25))
print(f"{t:6.2f} {c:+.3f} {bar:<50} {m:7.2f}")
# first return to a local maximum after the trace has dipped
dip = np.argmax(corr < 0.3) if (corr < 0.3).any() else None
if dip:
after = corr[dip:]
k = dip + int(np.argmax(after))
print(f"\nfirst return to max after the dip: t = {T[k]:.2f}s (corr {corr[k]:+.3f})")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Identify a LIVE grab by correlating it against committed oracle captures.
Why not whole-image statistics (screen_id.py's green/white/mean)? Because the
class they have to reject is MOVIE FRAMES, and a movie frame can be anything.
Measured 2026-08-29: a frame of `ADV.wmv` containing a bright green laser beam
scored green=0.0018 white=0.086 mean=(53,67,76) -- numerically indistinguishable
from the title plate, and a probe built on those features tapped (A) into the
attract movie and then waited 120 s for a menu that was never coming.
So match on CONTENT instead. Zero-normalised correlation against the committed
captures, over a small offset search, with the movie frames that fooled the
statistics kept as permanent negative controls.
A live grab is the whole 1280x720 root: xenia's title bar and menu bar occupy
the top ~45 rows, and the game surface below them is 1279x675 -- the same size
as the committed captures, which is not a coincidence.
Usage:
screen_match.py IMAGE [IMAGE ...] classify each
screen_match.py --control run the controls and exit non-zero on failure
"""
import os, sys
import numpy as np
from PIL import Image
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CAP = os.path.join(REPO, "docs", "re", "captures")
REFS = {
"title": "title-builds/live-title-press-a.png",
"menu": "title-builds/live-main-menu.png",
}
SURFACE_TOP = 45 # rows of xenia window chrome on a 1280x720 root
SEARCH = 8 # +/- px offset search, as the corpus does elsewhere
THRESH = 0.70
FAST_DS = 4 # decimation for the live path (see below)
# 🔴 The exact path costs 1503 ms PER FRAME, measured. A probe that ran it on
# every frame of an 8 fps x11grab drained the pipe at 0.64 fps, so the frames it
# classified were tens of seconds stale -- and the staleness GREW, which is how
# three "latencies" of 15.6 s, 20.3 s and 25.6 s were produced by a pipeline
# rather than by the game. Ordering survives a backlog; durations do not.
# `fast=True` decimates 4x and searches +/-2 decimated px, and is controlled
# below against the same 8 captures as the exact path.
def load(p):
return np.array(Image.open(p).convert("L"), dtype=np.float32)
def surface(a):
"""Crop a grab to the game surface. A committed capture is passed through."""
h, w = a.shape
if h == 720 and w == 1280:
return a[SURFACE_TOP:, :1279]
return a
def zncc(x, y):
x = x - x.mean(); y = y - y.mean()
d = np.sqrt((x * x).sum() * (y * y).sum())
return float((x * y).sum() / d) if d else 0.0
def best_corr(img, ref, fast=False):
"""Max ZNCC over a small 2-D offset search."""
if fast:
img = img[::FAST_DS, ::FAST_DS]; ref = ref[::FAST_DS, ::FAST_DS]
rng, step = 2, 1
else:
rng, step = SEARCH, 2
h = min(img.shape[0], ref.shape[0]); w = min(img.shape[1], ref.shape[1])
best = -1.0
for dy in range(-rng, rng + 1, step):
for dx in range(-rng, rng + 1, step):
ys0, ys1 = max(0, dy), min(h, h + dy)
yr0, yr1 = max(0, -dy), min(h, h - dy)
xs0, xs1 = max(0, dx), min(w, w + dx)
xr0, xr1 = max(0, -dx), min(w, w - dx)
c = zncc(img[ys0:ys1, xs0:xs1], ref[yr0:yr1, xr0:xr1])
if c > best:
best = c
return best
_REF_CACHE = {}
def refs():
if not _REF_CACHE:
for k, v in REFS.items():
_REF_CACHE[k] = surface(load(os.path.join(CAP, v)))
return _REF_CACHE
def classify(a_gray, fast=False):
"""Return (label, {name: corr}). label is 'title' | 'menu' | 'other'."""
img = surface(a_gray)
scores = {k: best_corr(img, r, fast) for k, r in refs().items()}
k = max(scores, key=scores.get)
return (k if scores[k] >= THRESH else "other"), scores
def classify_array(rgb, fast=False):
g = (0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2]).astype(np.float32)
return classify(g, fast)
CONTROLS = [
# (path, expected) -- positives from the committed corpus ...
(os.path.join(CAP, "title-builds/live-title-press-a.png"), "title"),
(os.path.join(CAP, "title-screen-oracle.png"), "title"),
(os.path.join(CAP, "title-builds/live-main-menu.png"), "menu"),
(os.path.join(CAP, "main-menu-oracle.png"), "menu"),
(os.path.join(CAP, "main-menu-reached.png"), "menu"),
# ... and the NEGATIVES. Movie frames are the class this oracle exists to
# reject, so they are COMMITTED fixtures, not scratch: an earlier version of
# this list pointed at two scratch grabs and a later run of the same probe
# overwrote one of them, turning a negative control into a title frame and
# failing the control for the wrong reason.
(os.path.join(CAP, "instrument-controls/movie-frame-attract-a.png"), "other"),
(os.path.join(CAP, "instrument-controls/movie-frame-attract-b.png"), "other"),
(os.path.join(CAP, "difficulty-screen.png"), "other"),
]
def control():
import time as _t
bad = 0
for fast in (False, True):
print(f"--- {'FAST (live path)' if fast else 'EXACT'} ---")
for p, exp in CONTROLS:
if not os.path.exists(p):
print(f" SKIP (missing) {os.path.basename(p)}"); continue
t = _t.time(); got, sc = classify(load(p), fast); ms = (_t.time() - t) * 1000
ok = "ok " if got == exp else "FAIL"
if got != exp:
bad += 1
print(f" {ok} {os.path.basename(p):<34} -> {got:<6} (exp {exp:<6}) "
+ " ".join(f"{k}={v:+.3f}" for k, v in sc.items())
+ f" [{ms:.0f} ms]")
print(f"\n{'CONTROL PASSED' if not bad else f'CONTROL FAILED ({bad})'}")
return 1 if bad else 0
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--control":
sys.exit(control())
for p in sys.argv[1:]:
got, sc = classify(load(p))
print(f"{p}: {got} " + " ".join(f"{k}={v:+.3f}" for k, v in sc.items()))

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""One boot, two answers: when the PRESS (A) plate appears, and what (B) does.
Q(plate): the port's boot ends on GP_TITLE build 4, which carries no plate, and
(A) is the only way off it -- so it ships a screen that needs a press and does
not say so. Builds 2/3 are the plate. The open half is the SEQUENCE: build 4
alone, build 4 with the plate composited from the start, or build 4 and THEN
the plate after a delay. This logs the title-art correlation and the green-(A)
glyph count on EVERY frame from before the title appears, so the two crossings
are read off one trace rather than inferred.
Q(B): whether (B) leaves the main menu, timed against the idle alternative.
Controls, both pre-run on committed captures:
* screen identity -- screen_match.py, 8/8 including the movie frames that
broke the statistics oracle;
* the plate -- green-(A) glyph count: 753/977/1493 px on plate titles, 159 on
`live-title-build4-no-plate.png`, 327 on the main menu. Threshold 400.
Usage: title_plate_and_b_probe.py OUTDIR
"""
import os, subprocess, sys, time
import numpy as np
from PIL import Image
SD = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SD)
from screen_match import classify_array
W, H = 1280, 720
PLATE = 400
OUT = sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/platecap"
os.makedirs(OUT, exist_ok=True)
def stream():
return subprocess.Popen(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", ":98", "-r", "8",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, bufsize=W * H * 3 * 2)
def glyph(a):
r, g, b = a[:, :, 0].astype(int), a[:, :, 1].astype(int), a[:, :, 2].astype(int)
return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum())
def main():
p = stream(); n = W * H * 3
t0 = time.time(); seg = t0; prev = None
skipped = False; stage = 0; marks = {}
rows = []
while time.time() - t0 < 420:
if time.time() - seg > 25:
p.kill(); p = stream(); seg = time.time()
b = p.stdout.read(n)
if len(b) < n:
p.kill(); p = stream(); seg = time.time(); continue
a = np.frombuffer(b, np.uint8).reshape(H, W, 3)
el = time.time() - t0
c, sc = classify_array(a)
gl = glyph(a)
rows.append((el, c, sc["title"], sc["menu"], gl))
if c != prev:
print(f"t={el:7.2f}s screen={c:<6} title={sc['title']:+.3f} "
f"menu={sc['menu']:+.3f} glyph={gl}", flush=True)
prev = c
if not skipped and el > 45:
subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False)
skipped = True
print(f"t={el:7.2f}s one (A) to skip the intro movie", flush=True)
elif stage == 0 and c == "title":
marks["title_art"] = el; stage = 1
Image.fromarray(a).save(f"{OUT}/title-first-{el:07.2f}.png")
print(f"t={el:7.2f}s TITLE ART (glyph={gl}) — watching for the plate",
flush=True)
elif stage == 1 and gl >= PLATE:
marks["plate"] = el; stage = 2
Image.fromarray(a).save(f"{OUT}/title-plate-{el:07.2f}.png")
print(f"t={el:7.2f}s PLATE (glyph={gl}) — "
f"{el-marks['title_art']:.2f}s after the title art", flush=True)
elif stage == 2 and el > marks["plate"] + 6:
subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False)
marks["A"] = el; stage = 3
print(f"t={el:7.2f}s >>> (A) on the title", flush=True)
elif stage == 3 and c == "menu":
marks["menu"] = el; stage = 4
print(f"t={el:7.2f}s MENU — idling 25 s before (B)", flush=True)
elif stage == 4 and el > marks["menu"] + 25:
subprocess.run(["python3", f"{SD}/pad.py", "tap", "B", "0.3"], check=False)
marks["B"] = el; stage = 5
print(f"t={el:7.2f}s >>> (B) PRESSED", flush=True)
elif stage == 5 and c != "menu":
marks["left_menu"] = el
print(f"t={el:7.2f}s LEFT THE MENU -> {c}, "
f"{el-marks['B']:.2f}s after (B)", flush=True)
stage = 6
elif stage == 6 and el > marks["left_menu"] + 12:
break
p.kill()
with open(f"{OUT}/trace.tsv", "w") as f:
f.write("t_s\tscreen\tcorr_title\tcorr_menu\tglyph\n")
for r in rows:
f.write(f"{r[0]:.3f}\t{r[1]}\t{r[2]:.4f}\t{r[3]:.4f}\t{r[4]}\n")
print("\nmarks:", {k: round(v, 2) for k, v in marks.items()})
if "title_art" in marks and "plate" in marks:
print(f"PLATE DELAY: {marks['plate']-marks['title_art']:.2f}s "
f"after the title art first matched")
if "B" in marks and "left_menu" in marks:
print(f"(B) -> left the menu in {marks['left_menu']-marks['B']:.2f}s")
print(f"trace: {OUT}/trace.tsv")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""Time the boot title: when the PRESS (A) plate arrives, and what a press costs.
WHY THIS EXISTS. Four durations published on 2026-08-29 were withdrawn the same
day because `screen_match.classify_array` costs 1503 ms/frame and a probe calling
it per frame drained an 8 fps x11grab at 0.64 fps. A backlog PRESERVES ORDERING
and DESTROYS DURATIONS, so every "latency" it produced was really the queue
depth. See docs/re/menu-idle-and-b-2026-08-29.md.
So this probe is built the other way round:
* per-frame work is a few MILLISECONDS, not 1.5 s. The cost in screen_match is
the +/-8 px offset search over a full-res surface (25 znccs); every committed
capture aligns at exactly dy=0 dx=0 (five screens, +/-2 px search,
five-screens-acceptance.md), so this classifier decimates 4x and does ONE
zncc per reference. --control checks that shortcut against the same fixtures
screen_match uses, INCLUDING the movie-frame negatives.
* the stream is torn down and restarted every RESTART_S, because a long-lived
x11grab degrades and then freezes on a stale frame (fast_title_probe.py).
* an INDEPENDENT one-shot grab every CHECK_S is compared with the stream's own
latest frame. A stalled stream cannot pass that, and the check is logged so
a negative result can be audited rather than believed.
* the loop's real sample rate is reported. If frames/elapsed is not close to
the requested rate, the durations in the log are NOT trustworthy and the
probe says so in its own summary.
Every frame is written to a TSV; the durations are computed offline from it, so
nothing here depends on the probe having classified in real time.
title_timing_probe.py --control
title_timing_probe.py --run SECONDS OUT.tsv
"""
import os
import subprocess
import sys
import time
import numpy as np
from PIL import Image
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CAP = os.path.join(REPO, "docs", "re", "captures")
SD = os.path.dirname(os.path.abspath(__file__))
W, H = 1280, 720
SURFACE_TOP = 45 # xenia window chrome; the game surface is 1279x675
DS = 4 # decimation for both live frames and references
RATE = 8 # requested frames/s
RESTART_S = 30 # a long-lived x11grab freezes on a stale frame
CHECK_S = 20 # independent one-shot grab, cross-checked against the stream
THRESH = 0.70
DISPLAY = os.environ.get("DISPLAY", ":98")
REFS = {
# the interactive title WITH the plate -- what the run is waiting to see arrive
"title_plate": "title-builds/live-title-press-a.png",
# the same screen BEFORE the plate. This is the reference the plate delay is
# measured from, and it is a committed capture, not a render of ours.
"title_noplate": "title-builds/live-title-build4-no-plate.png",
"menu": "title-builds/live-main-menu.png",
}
def surface(a):
h, w = a.shape
if h == H and w == W:
return a[SURFACE_TOP:, :1279]
return a
def load_gray(p):
return np.asarray(Image.open(p).convert("L"), dtype=np.float32)
_R = {}
def refs():
if not _R:
for k, v in REFS.items():
r = surface(load_gray(os.path.join(CAP, v)))[::DS, ::DS]
_R[k] = (r - r.mean()) / (np.sqrt((r * r).sum() - r.size * r.mean() ** 2) or 1.0)
return _R
def scores(gray):
"""ZNCC of a frame against every reference, decimated, NO offset search."""
img = surface(gray)[::DS, ::DS]
out = {}
for k, rn in refs().items():
h = min(img.shape[0], rn.shape[0])
w = min(img.shape[1], rn.shape[1])
x = img[:h, :w]
y = rn[:h, :w]
xc = x - x.mean()
d = np.sqrt((xc * xc).sum())
out[k] = float((xc * y).sum() / d) if d else 0.0
return out
def label(sc):
k = max(sc, key=sc.get)
return k if sc[k] >= THRESH else "other"
def glyph(rgb):
"""Byte-identical to is_title.py's counter."""
r = rgb[:, :, 0].astype(np.int16)
g = rgb[:, :, 1].astype(np.int16)
b = rgb[:, :, 2].astype(np.int16)
return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum())
def gray_of(rgb):
return (0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2]).astype(np.float32)
# ---------------------------------------------------------------- control
CONTROLS = [
(os.path.join(CAP, "title-builds/live-title-press-a.png"), "title_plate"),
(os.path.join(CAP, "title-screen-oracle.png"), "title_plate"),
(os.path.join(CAP, "title-builds/live-title-build4-no-plate.png"), "title_noplate"),
(os.path.join(CAP, "title-builds/live-main-menu.png"), "menu"),
(os.path.join(CAP, "main-menu-oracle.png"), "menu"),
(os.path.join(CAP, "main-menu-reached.png"), "menu"),
# the class this oracle exists to reject
(os.path.join(CAP, "instrument-controls/movie-frame-attract-a.png"), "other"),
(os.path.join(CAP, "instrument-controls/movie-frame-attract-b.png"), "other"),
(os.path.join(CAP, "difficulty-screen.png"), "other"),
]
# The plate detector is a THRESHOLD on the glyph counter, so it needs its own
# control: the committed no-plate title reads ~159 and plate titles 753..1493.
GLYPH_CONTROLS = [
(os.path.join(CAP, "title-builds/live-title-build4-no-plate.png"), "lo"),
(os.path.join(CAP, "title-builds/live-title-press-a.png"), "hi"),
(os.path.join(CAP, "instrument-controls/movie-frame-attract-a.png"), "lo"),
(os.path.join(CAP, "instrument-controls/movie-frame-attract-b.png"), "lo"),
]
PLATE_GLYPH = 400
def control():
bad = 0
print("--- content classifier (decimated, no offset search) ---")
for p, exp in CONTROLS:
if not os.path.exists(p):
print(f" SKIP (missing) {os.path.basename(p)}")
continue
t = time.time()
sc = scores(load_gray(p))
got = label(sc)
ms = (time.time() - t) * 1000
ok = got == exp
bad += 0 if ok else 1
print(f" {'ok ' if ok else 'FAIL'} {os.path.basename(p):<36} -> {got:<13} "
f"(exp {exp:<13}) " + " ".join(f"{k}={v:+.3f}" for k, v in sc.items())
+ f" [{ms:.1f} ms]")
print(f"\n--- plate detector (glyph >= {PLATE_GLYPH}) ---")
for p, exp in GLYPH_CONTROLS:
if not os.path.exists(p):
print(f" SKIP (missing) {os.path.basename(p)}")
continue
n = glyph(np.asarray(Image.open(p).convert("RGB")))
got = "hi" if n >= PLATE_GLYPH else "lo"
ok = got == exp
bad += 0 if ok else 1
print(f" {'ok ' if ok else 'FAIL'} {os.path.basename(p):<36} glyph={n:<6} -> {got} (exp {exp})")
print(f"\n{'CONTROL PASSED' if not bad else f'CONTROL FAILED ({bad})'}")
return 1 if bad else 0
# ---------------------------------------------------------------- live run
def open_stream():
return subprocess.Popen(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", DISPLAY, "-r", str(RATE),
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, bufsize=W * H * 3 * 2)
def oneshot():
"""An INDEPENDENT grab, through a fresh short-lived process."""
p = subprocess.run(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", DISPLAY, "-frames:v", "1",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, timeout=20)
b = p.stdout
if len(b) < W * H * 3:
return None
return np.frombuffer(b[:W * H * 3], np.uint8).reshape(H, W, 3)
PAD = os.environ.get("XENIA_PAD_FILE", "/tmp/xenia_pad.txt")
def _pad_write(state):
tmp = PAD + ".tmp"
with open(tmp, "w") as f:
f.write(state)
os.replace(tmp, PAD)
def tap(button, secs=0.25):
"""Press INLINE and return the moment the press landed.
pad.py through subprocess.run costs a python start plus the hold before the
caller can timestamp anything, so run 1's press times were ~0.3 s late with
no way to tell how late. Same file, same rename-into-place, no interpreter.
"""
_pad_write(f"press={button}")
t = time.time()
time.sleep(secs)
_pad_write("")
return t
def run(limit, out_path, shots_dir):
os.makedirs(shots_dir, exist_ok=True)
n = W * H * 3
p = open_stream()
t0 = time.time()
seg = t0
chk = t0
frames = 0
saved = set()
ev = [] # (name, t) -- ordering only; durations come from the TSV
state = "wait" # wait -> title -> plate -> pressedA -> menu -> pressedB -> done
last_gray = None
prev_mean = -1.0
same = 0
longest_same = 0
fh = open(out_path, "w")
fh.write("#t\tglyph\tmean\tmotion\ttitle_plate\ttitle_noplate\tmenu\tlabel\n")
def mark(name):
t = time.time() - t0
ev.append((name, t))
print(f"EVENT {name} t={t:.3f}", flush=True)
return t
title_seen_at = None
while time.time() - t0 < limit and state != "done":
now = time.time()
# 🔴 Do NOT restart once the measurement is under way. Run 1 restarted
# 0.25 s after the (A) press and then reported 14 byte-identical frames
# over 1.5 s -- a stale stream straddling exactly the interval being
# timed, which is how a press latency gets inflated by 1.5 s. The
# degradation the restart guards against is a minutes-scale drift
# (fast_title_probe.py); the whole measuring window is under 30 s, so
# freezing the stream for it is strictly safer than restarting inside it.
if state == "wait" and now - seg > RESTART_S:
p.kill()
p = open_stream()
seg = now
fh.write(f"#restart\t{now - t0:.3f}\n")
buf = p.stdout.read(n)
if len(buf) < n:
p.kill()
p = open_stream()
seg = time.time()
continue
t = time.time() - t0
rgb = np.frombuffer(buf, np.uint8).reshape(H, W, 3)
g = gray_of(rgb)
gl = glyph(rgb)
sc = scores(g)
lb = label(sc)
surf = surface(g)
mn = float(surf.mean())
mo = float(np.abs(surf[::8, ::8] - last_gray).mean()) if last_gray is not None else -1.0
last_gray = surf[::8, ::8].copy()
frames += 1
if abs(mn - prev_mean) < 1e-6:
same += 1
longest_same = max(longest_same, same)
else:
same = 0
prev_mean = mn
fh.write(f"{t:.3f}\t{gl}\t{mn:.3f}\t{mo:.3f}\t{sc['title_plate']:+.4f}\t"
f"{sc['title_noplate']:+.4f}\t{sc['menu']:+.4f}\t{lb}\n")
# --- independent cross-check that the stream is not stale
if time.time() - chk > CHECK_S:
chk = time.time()
o = oneshot()
if o is None:
fh.write(f"#check\t{t:.3f}\tONESHOT_FAILED\n")
else:
om = float(surface(gray_of(o)).mean())
fh.write(f"#check\t{t:.3f}\tstream={mn:.3f}\toneshot={om:.3f}\t"
f"delta={abs(om - mn):.3f}\n")
fh.flush()
# --- the drive. DO NOT press during a movie: a run that taps through
# the intro reaches a title that accepts nothing (skip_intro.sh).
if state == "wait":
if lb in ("title_noplate", "title_plate") and 0 <= mo < 2.0:
title_seen_at = mark("title_static")
if gl >= PLATE_GLYPH:
mark("plate_already") # would mean the plate is not late
state = "plate"
else:
state = "title"
Image.fromarray(rgb).save(os.path.join(shots_dir, "t0-title.png"))
elif state == "title":
if gl >= PLATE_GLYPH:
mark("plate")
Image.fromarray(rgb).save(os.path.join(shots_dir, "t1-plate.png"))
state = "plate"
plate_at = t
elif state == "plate":
if t - ev[-1][1] > 5.0:
tp = tap("A") - t0
ev.append(("pressA", tp))
print(f"EVENT pressA t={tp:.3f}", flush=True)
state = "pressedA"
elif state == "pressedA":
if lb == "menu":
mark("menu")
Image.fromarray(rgb).save(os.path.join(shots_dir, "t2-menu.png"))
state = "menu"
elif state == "menu":
if t - ev[-1][1] > 8.0:
tp = tap("B") - t0
ev.append(("pressB", tp))
print(f"EVENT pressB t={tp:.3f}", flush=True)
state = "pressedB"
elif state == "pressedB":
if lb in ("title_plate", "title_noplate"):
mark("back_title")
Image.fromarray(rgb).save(os.path.join(shots_dir, "t3-back-title.png"))
state = "done"
p.kill()
dt = time.time() - t0
fps = frames / dt if dt else 0
fh.write(f"#summary\tframes={frames}\telapsed={dt:.1f}\tfps={fps:.2f}\trequested={RATE}"
f"\tlongest_identical_run={longest_same}\n")
for name, t in ev:
fh.write(f"#event\t{name}\t{t:.3f}\n")
fh.close()
print(f"\n{frames} frames in {dt:.1f}s = {fps:.2f} fps (requested {RATE})")
print(f"longest run of byte-identical surface means: {longest_same} frames "
f"({longest_same / RATE:.2f} s at the requested rate)")
if fps < RATE * 0.75:
print("🔴 SAMPLE RATE FELL BELOW 75% OF REQUESTED — durations in this log "
"are NOT trustworthy (this is the backlog failure mode).")
for name, t in ev:
print(f" {name:<14} {t:8.3f}")
return 0
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--control":
sys.exit(control())
if len(sys.argv) > 3 and sys.argv[1] == "--run":
sys.exit(run(float(sys.argv[2]), sys.argv[3],
sys.argv[4] if len(sys.argv) > 4 else "/sylph-home/re/shots/title-timing"))
print(__doc__)
sys.exit(2)