Compare commits
12 Commits
human/r1-r
...
formats-pi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34d24941d | ||
|
|
5fcc89be55 | ||
|
|
3ee1a25f47 | ||
|
|
dd4f30a79f | ||
|
|
06c32a0fd4 | ||
|
|
7a4a74f8d7 | ||
|
|
724e06b134 | ||
|
|
e2d2dd34f0 | ||
|
|
9f34e6f7b6 | ||
|
|
c88f5e87a9 | ||
|
|
6f4b4d8b4c | ||
|
|
3db09a3806 |
@@ -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 1001–1023, 1101–1105), 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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
> ~8–10 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.
|
||||
|
||||
@@ -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 1001–1023, 1101–1105 — 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.9245–0.9249), against the "~8–10 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.17–0.23 s.** Measured on
|
||||
the Ⓐ path in both runs, the frame is pure black (surface mean 0.070) for
|
||||
**0.14–0.30 s** and **0.14–0.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`,
|
||||
38–75 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 ~8–10 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
|
||||
|
||||
@@ -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 1001–1023, 1101–1105), **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.14–0.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 ~8–10 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 |
|
||||
|
||||
@@ -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 ~8–10 s idle" → **refuted.**
|
||||
The menu sat untouched for **≥ 60 s** without moving (correlation never leaving
|
||||
0.9245–0.9249). The ~8–10 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 ~20–25 s. → A backlog **preserves ordering and destroys
|
||||
durations**; check consumed-fps against requested-fps before quoting a time.
|
||||
|
||||
@@ -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 79–82). 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.
|
||||
|
||||
BIN
docs/re/data/focus-ring-period-corr.npy
Normal file
BIN
docs/re/data/focus-ring-period-corr.npy
Normal file
Binary file not shown.
1809
docs/re/data/plate-timing-run1.tsv
Normal file
1809
docs/re/data/plate-timing-run1.tsv
Normal file
File diff suppressed because it is too large
Load Diff
1912
docs/re/data/plate-timing-run2.tsv
Normal file
1912
docs/re/data/plate-timing-run2.tsv
Normal file
File diff suppressed because it is too large
Load Diff
119
docs/re/focus-ring-spin-measured.md
Normal file
119
docs/re/focus-ring-spin-measured.md
Normal 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:
|
||||
|
||||

|
||||
|
||||
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 0–4 → 1.08, r 10–13 → **36.75**, r 20–26 → 1.18):
|
||||
|
||||

|
||||
|
||||
⚠️ 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.6–28.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.6–28.8 fps spans **2.08–2.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.
|
||||
120
docs/re/menu-idle-and-b-2026-08-29.md
Normal file
120
docs/re/menu-idle-and-b-2026-08-29.md
Normal 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 ~8–10 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 ~8–10 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 8–10 s.
|
||||
|
||||
✅ **And the 8–10 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 ~20–25 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 **38–75 ms**, a 20–60× 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/).
|
||||
@@ -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 ~450–520 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 ~8–10 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 ~8–10 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 2–8 |
|
||||
|---|---|---|
|
||||
| `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 2–8 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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
121
docs/re/structures/slb-bank-header-not-a-wave.md
Normal file
121
docs/re/structures/slb-bank-header-not-a-wave.md
Normal 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 **1001–1023** and **1101–1105**. 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** (67–93 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.
|
||||
140
docs/re/title-plate-delay-measured.md
Normal file
140
docs/re/title-plate-delay-measured.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
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.29–0.37 s |
|
||||
| press → first visible change (Ⓑ) | — | 0.28–0.33 s |
|
||||
| **pure black between the two screens** (Ⓐ path) | 0.14–0.30 s | 0.14–0.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.17–0.23 s; both runs put it in **0.14–0.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.
|
||||
@@ -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
|
||||
|
||||
159
tools/re-capture/focus_ring_probe.py
Executable file
159
tools/re-capture/focus_ring_probe.py
Executable 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())
|
||||
109
tools/re-capture/focus_ring_report.py
Normal file
109
tools/re-capture/focus_ring_report.py
Normal 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())
|
||||
92
tools/re-capture/footer_and_locked_rows.py
Executable file
92
tools/re-capture/footer_and_locked_rows.py
Executable 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()
|
||||
87
tools/re-capture/menu_b_probe.py
Normal file
87
tools/re-capture/menu_b_probe.py
Normal 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
191
tools/re-capture/quad_rects.py
Executable 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)
|
||||
138
tools/re-capture/ring_angular.py
Normal file
138
tools/re-capture/ring_angular.py
Normal 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())
|
||||
105
tools/re-capture/ring_period.py
Normal file
105
tools/re-capture/ring_period.py
Normal 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())
|
||||
151
tools/re-capture/screen_match.py
Normal file
151
tools/re-capture/screen_match.py
Normal 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()))
|
||||
119
tools/re-capture/title_plate_and_b_probe.py
Normal file
119
tools/re-capture/title_plate_and_b_probe.py
Normal 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())
|
||||
368
tools/re-capture/title_timing_probe.py
Executable file
368
tools/re-capture/title_timing_probe.py
Executable 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)
|
||||
Reference in New Issue
Block a user