Compare commits
21 Commits
pi/clippy-
...
formats-pi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d71a74f938 | ||
|
|
2e04e24ce9 | ||
|
|
3e4864b7fa | ||
|
|
43f61a8d93 | ||
|
|
5744f379b2 | ||
|
|
b7104c6668 | ||
|
|
aafd7c1b6f | ||
|
|
14fced07b6 | ||
|
|
f24304248c | ||
|
|
f34d24941d | ||
|
|
5fcc89be55 | ||
|
|
3ee1a25f47 | ||
|
|
dd4f30a79f | ||
|
|
06c32a0fd4 | ||
|
|
7a4a74f8d7 | ||
|
|
724e06b134 | ||
|
|
e2d2dd34f0 | ||
|
|
9f34e6f7b6 | ||
|
|
c88f5e87a9 | ||
|
|
6f4b4d8b4c | ||
|
|
3db09a3806 |
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -4618,6 +4618,18 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sylpheed-export"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sylpheed-formats",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sylpheed-formats"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -111,12 +111,22 @@ pub struct Keyframe {
|
||||
/// starts are negative.
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
/// Keyframe time, or `None` for the group's **last** frame.
|
||||
/// The time at which this pose is reached.
|
||||
///
|
||||
/// A group's data stops 4 bytes short of its final block's time slot — that
|
||||
/// word is already the next group's element index. Reading it anyway is
|
||||
/// where a stray `time = 1869640736` comes from, and it silently corrupts
|
||||
/// the max-dwell pick in [`Element::rest`].
|
||||
/// ✅ Always `Some` since 2026-08-29. A placement group is an 8-byte header
|
||||
/// followed by `frames` records of `{ u32 time; 36-byte pose }`, so the time
|
||||
/// word **precedes** the pose it belongs to. Our block window starts at the
|
||||
/// pose, so pose `k`'s time is the previous stride's `+36` word, and pose
|
||||
/// 0's is the group's lead-in word at `header + 8`.
|
||||
///
|
||||
/// ⚠️ The old reading took `+36` as *this* pose's time. That left the final
|
||||
/// pose — the end of every fade-out — untimed, and it is where the "a
|
||||
/// group's data stops 4 bytes short of its final block's time slot" note and
|
||||
/// the stray `time = 1869640736` both came from. There is no short group and
|
||||
/// no missing word; the association was off by one.
|
||||
/// See `docs/re/ui-keyframe-record-layout.md`.
|
||||
///
|
||||
/// `Option` is retained for the `SYLPHEED_KF_TIME_LEGACY=1` escape hatch.
|
||||
pub time: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -493,8 +503,10 @@ fn mark_focused_states(elements: &mut [Element]) {
|
||||
/// Read the placement region that follows the declaration table, filling in each
|
||||
/// element's keyframe group.
|
||||
fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||||
// Experiment gate, default off; see the `time` field below.
|
||||
let shift_times = std::env::var("SYLPHEED_KF_TIME_SHIFT").as_deref() == Ok("1");
|
||||
// Escape hatch for the pre-2026-08-29 reading, which mis-associated every
|
||||
// keyframe time by one slot and could not time a group's final pose at all.
|
||||
// See `docs/re/ui-keyframe-record-layout.md`.
|
||||
let legacy_times = std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1");
|
||||
let count = elements.len();
|
||||
let mut order = Vec::with_capacity(count);
|
||||
let mut pos = DECL_TABLE_AT + count * DECL_ENTRY;
|
||||
@@ -507,7 +519,13 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||||
if idx >= count || frames == 0 || frames > 4096 {
|
||||
break;
|
||||
}
|
||||
// Group header is (index, count) then one lead-in word; blocks follow.
|
||||
// The region is `frames` records of 40 bytes, each `{ u32 time; 36-byte
|
||||
// pose }`, after an 8-byte header — so the word at `pos + 8` is the
|
||||
// FIRST pose's time, and each 40-byte stride's `+36` word is the time of
|
||||
// the pose that follows it. Our block window is offset 4 bytes into the
|
||||
// record (it starts at the pose), which is why the pose field offsets
|
||||
// below are right while the times were off by one.
|
||||
let first_time = be32(bundle, pos + 8);
|
||||
let first = pos + 12;
|
||||
// The region is packed so that the next group's header sits 4 bytes
|
||||
// inside the last block — i.e. the group owns `frames * 40 - 4` bytes of
|
||||
@@ -529,16 +547,17 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||||
tint: be32(bundle, blk + 24),
|
||||
x: be32(bundle, blk + 28) as i32,
|
||||
y: be32(bundle, blk + 32) as i32,
|
||||
// Only a block wholly inside the group carries a time.
|
||||
//
|
||||
// ⚠️ Which block a time word BELONGS TO is under test — see
|
||||
// `docs/re/ui-keyframe-time-unit.md`. Set `SYLPHEED_KF_TIME_SHIFT=1`
|
||||
// to read `W[k-1]` as block `k`'s time ("the word is the time the
|
||||
// NEXT pose is reached") instead of `W[k]`. Default is unchanged.
|
||||
time: if shift_times {
|
||||
(k >= 1).then(|| be32(bundle, blk - KEYFRAME + 36))
|
||||
} else {
|
||||
// Pose `k`'s time is the word that PRECEDES it: the group's
|
||||
// lead-in word for `k == 0`, and the previous stride's `+36`
|
||||
// otherwise. Every pose is timed; nothing is missing and nothing
|
||||
// is special-cased. Checked disc-wide — see
|
||||
// `docs/re/ui-keyframe-record-layout.md`.
|
||||
time: if legacy_times {
|
||||
(blk + 40 <= group_end).then(|| be32(bundle, blk + 36))
|
||||
} else if k == 0 {
|
||||
Some(first_time)
|
||||
} else {
|
||||
Some(be32(bundle, blk - KEYFRAME + 36))
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,8 +120,17 @@ fn header_0x08_against_the_keyframe_times() {
|
||||
worst.push(format!("{pak}: max keyframe {max_time} > header {dur}"));
|
||||
}
|
||||
}
|
||||
let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32;
|
||||
*ratio.entry(r.min(30)).or_default() += 1;
|
||||
// ⚠️ Bundles whose every group is a single static pose contribute
|
||||
// `max_time == 0` and say nothing about whether `+0x08` is a length.
|
||||
// Before 2026-08-29 they were invisible here, because the old keyframe
|
||||
// time reading left a one-frame group's only pose untimed; the corrected
|
||||
// record layout (`docs/re/ui-keyframe-record-layout.md`) gives it the
|
||||
// group's lead-in time, which is 0. They are excluded rather than
|
||||
// allowed to swamp the histogram's zero bucket — 546 of them do.
|
||||
if max_time > 0 {
|
||||
let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32;
|
||||
*ratio.entry(r.min(30)).or_default() += 1;
|
||||
}
|
||||
});
|
||||
|
||||
eprintln!("bundles with keyframe times and a non-zero +0x08: {animated}");
|
||||
@@ -142,11 +151,18 @@ fn header_0x08_against_the_keyframe_times() {
|
||||
|
||||
assert!(animated > 0, "no animated bundles — the sweep is broken");
|
||||
|
||||
// MEASURED 2026-08-24. +0x08 bounds the keyframe times in EVERY one of the
|
||||
// 2313 bundles that have both, and 444 of them reach it exactly. The
|
||||
// MEASURED 2026-08-24, re-measured 2026-08-29 under the corrected keyframe
|
||||
// record layout. +0x08 bounds the keyframe times in EVERY one of the 2 859
|
||||
// bundles that have both (2 313 before the correction, which could not time
|
||||
// a group's final pose at all), and 444 of them reach it exactly. The
|
||||
// spread-out ratio histogram is what rules out the boring explanation: a
|
||||
// large unrelated constant would bound everything too, but then the ratios
|
||||
// would pile up near zero instead of peaking at 1.0.
|
||||
//
|
||||
// ✅ The correction STRENGTHENS this result rather than weakening it: 546
|
||||
// more bundles now carry a readable last-pose time, and `over` is still 0 —
|
||||
// i.e. the newly-visible times, which are the LATEST in every group, still
|
||||
// do not run past the header's.
|
||||
assert_eq!(over, 0, "a keyframe time runs past the header's +0x08");
|
||||
assert!(exact > 400, "the bound is never attained — it may be unrelated");
|
||||
let near_one = ratio.get(&10).copied().unwrap_or(0);
|
||||
|
||||
204
crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs
Normal file
204
crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! A placement group's time word **precedes** the pose it belongs to.
|
||||
//!
|
||||
//! A group is an 8-byte header `{u32 element_index, u32 frame_count}` followed
|
||||
//! by `frame_count` records of 40 bytes, each `{u32 time; 36-byte pose}`. The
|
||||
//! parser's block window starts at the *pose*, four bytes into the record, so
|
||||
//! the word at a block's `+36` is the time of the pose that FOLLOWS it, and the
|
||||
//! first pose's time is the group's lead-in word at `header + 8`.
|
||||
//!
|
||||
//! The old reading took `+36` as the block's own time. That is off by one, and
|
||||
//! it is where two long-standing oddities came from: the group looked four bytes
|
||||
//! short, and the final pose — the end of every fade-out — carried no time.
|
||||
//!
|
||||
//! Full argument and disc-wide census: `docs/re/ui-keyframe-record-layout.md`.
|
||||
//!
|
||||
//! Two checks here, both disc-wide:
|
||||
//!
|
||||
//! 1. **Every pose is timed, and the times are non-decreasing.** Under the old
|
||||
//! reading the last pose has no time at all, so this cannot even be asked.
|
||||
//! 2. **A monotone alpha ramp of three or more segments runs at a constant
|
||||
//! rate.** Interpolation between keyframes is linear
|
||||
//! (`docs/re/ui-keyframe-time-unit.md`), so a correct time assignment makes
|
||||
//! multi-keyframe ramps come out at a constant d(alpha)/d(time). The old
|
||||
//! reading achieves this on **zero** ramps on the whole disc.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
||||
let p = PathBuf::from(p);
|
||||
if p.join("dat").is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let default = Path::new(
|
||||
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
|
||||
);
|
||||
if default.join("dat").is_dir() {
|
||||
return Some(default.to_path_buf());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) {
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
for p in &paks {
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
let Ok(arc) = PakArchive::open(p) else { continue };
|
||||
for e in arc.entries() {
|
||||
let Ok(bytes) = arc.read(e) else { continue };
|
||||
if ratc::is_ratc(&bytes) {
|
||||
f(&name, &bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximal runs of strictly monotone alpha with at least `min_seg` segments.
|
||||
fn monotone_ramps(alphas: &[i32], min_seg: usize) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let (mut i, n) = (0usize, alphas.len());
|
||||
while i + 1 < n {
|
||||
if alphas[i] == alphas[i + 1] {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let up = alphas[i + 1] > alphas[i];
|
||||
let mut j = i + 1;
|
||||
while j + 1 < n && ((alphas[j + 1] > alphas[j]) == up) && alphas[j + 1] != alphas[j] {
|
||||
j += 1;
|
||||
}
|
||||
if j - i >= min_seg {
|
||||
out.push((i, j));
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn constant_rate(times: &[u32], alphas: &[i32], a: usize, b: usize) -> Option<bool> {
|
||||
let mut rates = Vec::new();
|
||||
for k in a..b {
|
||||
let dt = times[k + 1].checked_sub(times[k])?;
|
||||
if dt == 0 {
|
||||
return None;
|
||||
}
|
||||
rates.push((alphas[k + 1] - alphas[k]).unsigned_abs() as f64 / dt as f64);
|
||||
}
|
||||
let mean = rates.iter().sum::<f64>() / rates.len() as f64;
|
||||
if mean == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let worst = rates.iter().map(|r| (r - mean).abs()).fold(0.0, f64::max);
|
||||
Some(worst / mean <= 0.06)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_pose_is_timed_and_the_times_are_ordered() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||
return;
|
||||
};
|
||||
if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") {
|
||||
eprintln!("SKIP: the legacy time reading is selected");
|
||||
return;
|
||||
}
|
||||
|
||||
let (mut groups, mut untimed, mut out_of_order) = (0usize, 0usize, 0usize);
|
||||
let mut examples: Vec<String> = Vec::new();
|
||||
for_each_build(&root, |pak, bytes| {
|
||||
let Some(build) = ui_layout::parse_build(bytes) else {
|
||||
return;
|
||||
};
|
||||
if build.from_fallback {
|
||||
return;
|
||||
}
|
||||
for el in &build.elements {
|
||||
if el.keyframes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
groups += 1;
|
||||
if el.keyframes.iter().any(|k| k.time.is_none()) {
|
||||
untimed += 1;
|
||||
if examples.len() < 8 {
|
||||
examples.push(format!("{pak}: {} has an untimed pose", el.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let t: Vec<u32> = el.keyframes.iter().map(|k| k.time.unwrap()).collect();
|
||||
if t.windows(2).any(|w| w[1] < w[0]) {
|
||||
out_of_order += 1;
|
||||
if examples.len() < 8 {
|
||||
examples.push(format!("{pak}: {} times {t:?} descend", el.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
eprintln!("placement groups: {groups}; untimed: {untimed}; out of order: {out_of_order}");
|
||||
for e in &examples {
|
||||
eprintln!(" {e}");
|
||||
}
|
||||
assert!(groups > 10_000, "expected the whole disc, saw {groups} groups");
|
||||
assert_eq!(untimed, 0, "every pose must carry a time");
|
||||
assert_eq!(out_of_order, 0, "keyframe times must not descend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_segment_alpha_ramps_run_at_a_constant_rate() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||
return;
|
||||
};
|
||||
if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") {
|
||||
eprintln!("SKIP: the legacy time reading is selected");
|
||||
return;
|
||||
}
|
||||
|
||||
let (mut ramps, mut constant) = (0usize, 0usize);
|
||||
for_each_build(&root, |_pak, bytes| {
|
||||
let Some(build) = ui_layout::parse_build(bytes) else {
|
||||
return;
|
||||
};
|
||||
if build.from_fallback {
|
||||
return;
|
||||
}
|
||||
for el in &build.elements {
|
||||
if el.keyframes.iter().any(|k| k.time.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let t: Vec<u32> = el.keyframes.iter().map(|k| k.time.unwrap()).collect();
|
||||
let a: Vec<i32> = el
|
||||
.keyframes
|
||||
.iter()
|
||||
.map(|k| ((k.fade >> 24) & 0xff) as i32)
|
||||
.collect();
|
||||
for (lo, hi) in monotone_ramps(&a, 3) {
|
||||
if let Some(ok) = constant_rate(&t, &a, lo, hi) {
|
||||
ramps += 1;
|
||||
constant += usize::from(ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let share = 100.0 * constant as f64 / ramps as f64;
|
||||
eprintln!("multi-segment alpha ramps: {constant}/{ramps} at a constant rate ({share:.1}%)");
|
||||
assert!(ramps > 500, "expected the whole disc, saw {ramps} ramps");
|
||||
// The old reading scores 0 of 1042. Half is a floor, not a target: the rest
|
||||
// are genuinely shaped ramps, authored with keyframes that are not evenly
|
||||
// spaced. Anything near zero means the time assignment has slipped again.
|
||||
assert!(
|
||||
share > 45.0,
|
||||
"only {share:.1}% of ramps run at a constant rate — the time \
|
||||
association has probably slipped (the old off-by-one scored 0%)"
|
||||
);
|
||||
}
|
||||
@@ -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,12 +23,285 @@ 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 — A KEYFRAME'S TIME COMES BEFORE ITS POSE. Change `pose_at`.
|
||||
|
||||
**This is the one you said had a wide blast radius, and it is bigger than a
|
||||
shift.** Read
|
||||
[`ui-keyframe-record-layout.md`](../re/ui-keyframe-record-layout.md) before
|
||||
touching `screen_view.gd`.
|
||||
|
||||
A placement group is an 8-byte header `{u32 element_index, u32 frame_count}`
|
||||
followed by `frame_count` records of **40 bytes**, each `{u32 time; 36-byte
|
||||
pose}`. The time word **precedes** the pose it belongs to. Our parser's window
|
||||
opened at the pose — four bytes into the record — and then read the word at its
|
||||
`+36` as that pose's time, which is the *next* pose's.
|
||||
|
||||
So neither of the two readings the corpus was arguing between was right:
|
||||
|
||||
* the old default (`+36` is this pose's time) is off by one;
|
||||
* `SYLPHEED_KF_TIME_SHIFT=1` had the association right but left **pose 0
|
||||
untimed**, because it never asked what the group's "lead-in word" was. It is
|
||||
pose 0's time.
|
||||
|
||||
`SYLPHEED_KF_TIME_SHIFT` is gone. `SYLPHEED_KF_TIME_LEGACY=1` restores the old
|
||||
reading if you want to A/B.
|
||||
|
||||
**Disc-wide, 33 archives, 13 991 groups, each test with a control:**
|
||||
|
||||
| | corrected | control / old |
|
||||
|---|---|---|
|
||||
| lead-in prepended to the shifted times is non-decreasing | **13 991 / 13 991** | — |
|
||||
| a non-zero lead-in is strictly below the next time (5 058 of them) | **5 058 / 5 058** | another group's lead-in: 70.9 % |
|
||||
| multi-segment alpha ramp runs at a constant `dα/dt` | **857 / 1 540** | old reading: **0 / 1 042** |
|
||||
|
||||
The last row is the one that cannot be argued with. Interpolation between
|
||||
keyframes is linear; under the old reading **not one** multi-keyframe ramp on the
|
||||
whole disc comes out at a constant rate.
|
||||
|
||||
### What it changes for you, by the list you sent me
|
||||
|
||||
* **`pose_at` (line ~184).** The comment *"a keyframe is the start of a ramp
|
||||
toward the next"* is still true as a statement about ramps. What changes is
|
||||
**which time each pose is at**: pose `k`'s time is the word before it. Every
|
||||
screen's build-in timing moves.
|
||||
* **The final pose now has a time.** Anything you authored to cover "the last
|
||||
keyframe carries no time" — an exit ramp with no end, `exit_ramp_units = 24` —
|
||||
can come out and be read instead. `exit_ramp_units` is now a decodable number,
|
||||
not an authored one.
|
||||
* **`settle_units` / `settle_time`.** `rest.t` still is not when a screen settles
|
||||
(`5b0a6e6` stands), but its *value* moves. Re-derive it.
|
||||
* **`spin_period_units`.** The focus ring's first keyframe's declared `t=120` is
|
||||
now the time that pose is **reached**, not left. Check which end of the ring's
|
||||
group you were reading.
|
||||
* **The `PRESS Ⓐ` plate.** `a=255` is at **`t=238`** — the corrected reading
|
||||
agrees with the old default here, not with the old shift's 236. Your `5b0a6e6`
|
||||
note is unaffected.
|
||||
* **`ramp: "linear"` in `authored/timing.json` — KEEP IT.** Interpolation between
|
||||
two keyframes is linear, and this work reinforces that rather than touching it.
|
||||
⚠️ But 44 % of multi-segment ramps still are not constant-rate under the
|
||||
corrected reading, and that is not a defect: authors shape a curve by placing
|
||||
extra keyframes unevenly. Your `_lerp_pose` is right; do not add an easing
|
||||
function.
|
||||
|
||||
### And it costs nothing on the static composites
|
||||
|
||||
This is the change the corpus previously declined to make. `SYLPHEED_KF_TIME_SHIFT=1`
|
||||
moved `GP_TITLE` build 7 by 13.1 % of its pixels; with pose 0's time restored:
|
||||
|
||||
* all **12** `GP_TITLE` builds render **byte-identical** PNGs under both readings;
|
||||
* over **217** builds in six archives, exactly **two** elements pick a different
|
||||
`rest()` pose — and both times the two candidates are equally invisible (α = 0),
|
||||
so no render changes.
|
||||
|
||||
So `screen render` is still the reference you diff against, unchanged. **If your
|
||||
static screens move, that is your bug, not this change.**
|
||||
|
||||
## ✅ 2026-08-29 — builds 0/1 and 10/11 are the LOADING SCREEN (your ask #2)
|
||||
|
||||
**Decoded** — the authors' own element names, straight out of the declaration
|
||||
table. Every element of all four bundles is prefixed `pgloading_`: the 7-element
|
||||
pair (builds 0/1) is the plain plate, the 10-element pair (builds 10/11) adds
|
||||
`pgloading_eff00.prm`, `pgloading_loop5.rat` and `pgloading_baseeff.t32` over a
|
||||
circuit-line background. `DELTASABER / SYLPHEED A.I.` is the caption art on
|
||||
`pgloading_str.t32`, not the screen's identity.
|
||||
|
||||
⚠️ **Do not name them `LOADING` / `LOADING2` in an asset path.** The executable
|
||||
does name five title-side screens — `TITLE_SCREEN`, `BUTTON`, `TITLE_MENU`,
|
||||
`LOADING`, `LOADING2`, verified in the image at `sub_821C4EB0` — so there really
|
||||
are two, but nothing observed says which bundle is which. 🟡 undecided.
|
||||
|
||||
🟡 **Which member of each pair is English: the first half of the data segment.**
|
||||
All eight pairs put exactly one member in each half of `GP_TITLE.p00`, and all
|
||||
three pairs whose language is visible (4/7, 5/8, 6/9) put English in the first.
|
||||
So builds **0, 2, 4, 5, 6, 10 are English**; 1, 3, 7, 8, 9, 11 Japanese. 🟡 not
|
||||
✅ — the three pairs this is *used* for are exactly the three no capture can
|
||||
check, and nothing in the bundle bytes differs between those twins at all.
|
||||
[`ui-title-build-map.md`](../re/ui-title-build-map.md)
|
||||
|
||||
## ✅ 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.
|
||||
✅ **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, and it is unchanged.
|
||||
🔴 **But my INSTRUCTION was wrong and you refuted it — do not author a delay at
|
||||
all.** I said "when build 4 has settled, wait 2.13 s, composite build 2". Build
|
||||
2 has a group of its own, and starting that group at settle puts the plate at
|
||||
settle + 2.13 + 3.97 s. **Correct instruction: run build 4 and build 2 on ONE
|
||||
clock, started together, and play both groups from their own keyframes.** The
|
||||
plate then arrives at its declared `t=238` with nothing authored.
|
||||
🔴 **The premise that broke it is yours and it will bite again: `rest.t` is NOT
|
||||
when a screen settles.** It is the last *hold* keyframe before the exit.
|
||||
`ptlogo1` has `rest.t = 251` and stops moving at **`t=42`**. The title's visible
|
||||
build-in is over at **`t≈118`**, where `pteff01`, `pteff02.prm` and
|
||||
`ptlogoall_eff` all end their ramps together — and `238 − 118 = 120 units =
|
||||
**2.000 s**`, which is the 2.13 s I measured. The number was on the disc.
|
||||
⚠️ **If you author a gap anyway, author 120 units, not my 2.13 s.** 120 units in
|
||||
2.135 s is the game presenting at **28.06 / 28.14 fps** against a nominal 30 —
|
||||
and the corpus had already measured the idle title at **28.5 fps**,
|
||||
independently and before these runs. My wall-clock was this emulator's frame
|
||||
rate baked into a game constant; a port at a true 30 Hz would be visibly late.
|
||||
✅ That it is presentation rate and not the game is checkable in the same two
|
||||
runs: *first pixels → settle* is 1.643 s and 2.131 s (a 30 % spread) while
|
||||
*settle → plate* is 2.138 s and 2.132 s. Frames are dropped during the
|
||||
build-in, not during the hold.
|
||||
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 |
|
||||
|---|---|---|---|
|
||||
| Q1 | keyframe time unit + ramp shape | ✅ answered, 🟡 one gap | ramp is **linear**; **2 units per rendered frame**; **`1 unit = 1/60 s` — settled**, the idle title presents at 28.5 fps so the game is 30 Hz. 🟡 **The interpolation law is settled; the group TIMELINE for multi-keyframe elements is not** — `palogo_gamearts` is still at full alpha 9 frames after its declared `a=32`, and its declared 80-frame fade-in never draws — [`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md). ✅ **REPLICATED 2026-08-29 — for ANIMATION, read `+36` as the time the NEXT pose is reached.** Three elements across two screens: `palogo_gamearts` and `palogo_seta` hold full alpha for **83 frames** and `palogo_sqex` for **≥77**, where the current reading predicts **6–8** and the shifted one **80–102**. The elements that cannot discriminate (the `_eff` glows, on which the linear law was measured) fit both. ⚠️ Our decoder still defaults to the other reading (`SYLPHEED_KF_TIME_SHIFT=1` to flip) because it changes `rest()` on one element — but that is an unsound fallback guessing either way, so **static rendering is unaffected and animation timing should use the shift** |
|
||||
| Q2 | which build is which screen state | ✅ answered | `GP_TITLE` is **8 screens shipped twice, EN/JP**: 4/7 title art, 2/3 the `PRESS Ⓐ` plate, 5/8 main menu, 6/9 `EXTRAS`, 0/1 and 10/11 two unidentified `DELTASABER` plates — [`ui-title-build-map.md`](../re/ui-title-build-map.md) |
|
||||
| Q1 | keyframe time unit + ramp shape | ✅ answered | ramp is **linear**; **2 units per rendered frame**; **`1 unit = 1/60 s` — measured**, the idle title presents at 28.5 fps so the game is 30 Hz — [`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md). ✅ **The group timeline is now DECODED too (2026-08-29) and the gap is closed**: a placement group is `frames` records of `{u32 time; 36-byte pose}` after an 8-byte header, so a pose's time is the word **before** it, pose 0's time is the group's lead-in word, and **every** pose is timed — including the last, which nothing could time before. Disc-wide over 13 991 groups with controls; the old reading makes **0 of 1 042** multi-segment alpha ramps constant-rate against 857 of 1 540. `SYLPHEED_KF_TIME_SHIFT` is retired (it had the association right but left pose 0 untimed, which is the whole reason it appeared to cost 13.1 % of build 7). Static renders are byte-identical — [`ui-keyframe-record-layout.md`](../re/ui-keyframe-record-layout.md) |
|
||||
| Q2 | which build is which screen state | ✅ answered | `GP_TITLE` is **8 screens shipped twice, EN/JP**: 4/7 title art, 2/3 the `PRESS Ⓐ` plate, 5/8 main menu, 6/9 `EXTRAS`, and ✅ **0/1 and 10/11 are the LOADING screen** — two variants, plain and dressed, decoded from their `pgloading_*` element names (2026-08-29). 🟡 which of the two is `LOADING` vs `LOADING2` is undecided; 🟡 the English member of a pair is the one in the first half of the data segment — [`ui-title-build-map.md`](../re/ui-title-build-map.md) |
|
||||
| Q3 | paint order for the six screens | ✅ answered, ❔ tie-break | **decoded**: a `u16` layer key at `+0x0A` of each `T8aD` sprite header, stable-sorted with declaration index; unkeyed elements get an implied key. Confirmed on 5 measured orders + `EXTRAS` vs a capture. One residual: the **tie-break** is unknown and bites on one element of the title — [`structures/ui-paint-order-key.md`](../re/structures/ui-paint-order-key.md). ⚠️ **The key does not fully order a screen**: elements sharing a key are tied, and the tie-break is ❔ **undecodable from the bundle** — declaration table, `T8aD` header (exhaustive: every offset 0x00–0x7f at u8/u16/u32, both directions, **0** fields match the measured order against **64** for the control) and the RATC child order all give the same order the game does *not* use. Your exposure is **2 overlapping tied pairs on `EXTRAS`** — [`structures/ui-paint-order-derived-check.md`](../re/structures/ui-paint-order-derived-check.md) |
|
||||
| Q4 | button → GamePart | ✅ answered | **measured** which screen all **5** buttons open — `NEW GAME` → `DIFFICULTY` → `SELECT DATA`, not a hang. The **GamePart id is still a name match**, not a measurement — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) |
|
||||
| Q5 | navigation semantics | ✅ answered | **measured**: initial focus varies boot to boot (2× `TUTORIAL`, 2× `NEW GAME`); ⬆⬇ one step, **wraps both ends**; ⬅➡ do nothing; Ⓑ returns to the parent **with focus restored**; Ⓑ on the main menu → title; Ⓑ on the title → nothing — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) |
|
||||
@@ -36,7 +309,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,7 +1173,8 @@ 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 |
|
||||
| ❔ | **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 |
|
||||
| 🟡 | **Ⓑ 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-ⓑ) |
|
||||
| 🟡 | **which loading bundle is `LOADING` and which `LOADING2`** (Q2) | ✅ the pair is identified — they are the loading screen, decoded from `pgloading_*` element names, and the executable names exactly two. What is open is only the assignment, and nothing observed maps a name to a bundle. ⚠️ The old row here said the pair was *unidentified `DELTASABER` plates never seen running*; that is withdrawn — a loading screen is not supposed to appear on the title path |
|
||||
|
||||
(An earlier version of this table called the audio items blocked on "an emulator
|
||||
whose audio path can be observed". That was wrong — this build already has
|
||||
@@ -912,6 +1186,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
|
||||
|
||||
@@ -136,6 +136,7 @@ files, which is how the same ground got covered twice.
|
||||
| [`structures/stage-mission-tables.md`](structures/stage-mission-tables.md) | The stage table set — phases, routes, sub-objectives and AI parameters | ✅ the table set and how the stage record reaches it, validated across; **`AIParams` disc-wide: 23 objects, one shared 34-profile roster (782 records), loader `sub_8233C368`; `Type`→field-count holds except the two `_Test` templates** |
|
||||
| [`structures/texture-color-k8888.md`](structures/texture-color-k8888.md) | Texture colour interpretation — `k_8_8_8_8` (32bpp UI/HUD textures) | — |
|
||||
| [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) | What a keyframe time is worth, and what shape the ramp has | ✅ CONFIRMED from the running game's own draw stream — the ramp is **linear** (a declared 15-unit fade lands on `round(255·k/15)` for all seven samples) and the animation clock advances **2 time units per submitted frame**. 🟡 the seconds conversion (`1 unit = 1/60 s`) rests on a measured 27.6 present-frames/second |
|
||||
| [`ui-keyframe-record-layout.md`](ui-keyframe-record-layout.md) | A keyframe's time word comes **before** its pose — the placement record, decoded | ✅ CONFIRMED, **decoded**. A group is an 8-byte header then `frames` records of `{u32 time; 36-byte pose}`, so the time precedes the pose; the group's lead-in word at `header+8` is pose 0's time and **every** pose is timed. Disc-wide over 13 991 groups in 33 archives, each test with a control: lead-in prepended is non-decreasing **13 991/13 991**; a non-zero lead-in is strictly below the next time **5 058/5 058** (control 70.9 %); a multi-segment alpha ramp runs at a constant `dα/dt` **857/1 540** against **0/1 042** under the old reading. 🔴 Retires two long-standing corpus claims — *"a group's data stops 4 bytes short of its final block's time slot"* and *"the last keyframe carries no time"* — both of which were this off-by-one. Adoption is free: all 12 `GP_TITLE` builds render byte-identically, and over 217 builds only two elements pick a different `rest()` pose, both between equally invisible ones. ❔ the executable's own parser was **not** found (the 40/60 stride query is weak, not negative) |
|
||||
| [`structures/ui-composable-bundles.md`](structures/ui-composable-bundles.md) | A screen build is not the only thing `compose` can draw | ✅ CONFIRMED by measurement over the disc, with the artifact to |
|
||||
| [`structures/ui-focus-and-effect-elements.md`](structures/ui-focus-and-effect-elements.md) | `_eff` glow layers are not focused-state records | ✅ CONFIRMED by measurement over all 965 screen builds on the disc, |
|
||||
| [`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md) | The paint order comes from a layer key in the T8aD sprite header | ✅ CONFIRMED on both screens whose paint order has been measured — |
|
||||
@@ -147,17 +148,22 @@ 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 |
|
||||
| [`movie-binding.md`](movie-binding.md) | Which movie plays where — boot intro, attract loop, new-game intro | ✅ decoded from the movie manifest (`ADVERTISE_MOVIE`→`ADV.wmv`, `MS00A`→`S00A.wmv`); attract identity confirmed independently by frame matching; 🟡 skippability unsettled |
|
||||
| [`ready-room-probe.md`](ready-room-probe.md) | S1 — the Ready Room probe: no-go, and not for the reason expected | ✅ it is 2D and enumerates (60 builds), but the pak is briefing/tactical-map content; and `kind == 0x3002` finds 0 buttons there |
|
||||
| [`ui-title-build-map.md`](ui-title-build-map.md) | Which `GP_TITLE` build is which screen state | ✅ CONFIRMED for title / `PRESS Ⓐ` / main menu / `EXTRAS` against live captures; the archive is 8 screens × EN/JP, and "6/8/9 are submenus" is withdrawn |
|
||||
| [`ui-title-build-map.md`](ui-title-build-map.md) | Which `GP_TITLE` build is which screen state | ✅ CONFIRMED for title / `PRESS Ⓐ` / main menu / `EXTRAS` against live captures; the archive is 8 screens × EN/JP, and "6/8/9 are submenus" is withdrawn ✅ **2026-08-29: the two "unidentified `DELTASABER` plates" are the LOADING screen** — builds 0/1 the plain variant, 10/11 the dressed one, decoded from their `pgloading_*` element names, and the executable (`sub_821C4EB0`, bytes checked in the image) names exactly five title-side screens: `TITLE_SCREEN`, `BUTTON`, `TITLE_MENU`, `LOADING`, `LOADING2`. 🟡 which loading bundle takes which of the two names is undecided. 🟡 the English member of a pair is the one in the first half of `GP_TITLE.p00` — 8/8 structurally, 3/3 where a capture can check it. |
|
||||
| [`ui-title-paint-order-capture.md`](ui-title-paint-order-capture.md) | The title screen's paint order, measured from the guest's draw submissions | ✅ CONFIRMED — the order in which the running game paints the title |
|
||||
| [`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 | ✅ **decoded after a refutation**: build 2 and build 4 run on **one clock started together**, and the plate's own `ptbtn00` reaches `a=255` at `t=238`; the last build-in ramp ends at `t=118`, so the interval is a declared **120 units = 2.000 s**. 🔴 The instruction that shipped first — "wait 2.13 s after build 4 settles" — was **refuted by the port** with disc arithmetic and is corrected in place; 🔴 `rest.t` is **not** when a screen settles (it is the last hold keyframe before the exit: `ptlogo1` rests at `t=251` and stops moving at `t=42`). ⚠️ The wall-clock 2.13 s is 6.7 % long because Canary presents at **28.06 / 28.14 fps** against a nominal 30, matching the corpus's independent **28.5 fps**; author the 120 units. ✅ **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,58 @@ 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)
|
||||
|
||||
## UI timing (2026-08-29)
|
||||
|
||||
* "a screen has SETTLED at its `rest.t`" → **refuted.** `rest.t` is the last
|
||||
*hold* keyframe before the exit, not the end of motion. Build 4's `ptlogo1`
|
||||
rests at `t=251` and stops moving at **`t=42`**; the title's visible build-in
|
||||
ends at `t≈118`, where `pteff01`, `pteff02.prm` and `ptlogoall_eff` end their
|
||||
ramps together. Believing `rest.t` put a port's plate 3.97 s late —
|
||||
[`title-plate-delay-measured.md`](title-plate-delay-measured.md).
|
||||
* "the `PRESS Ⓐ` plate is composited a measured 2.13 s after the title settles,
|
||||
and the port should author that" → **the measurement stands, the instruction
|
||||
was refuted by the port.** Build 2 has a keyframe group of its own; both builds
|
||||
run on **one clock started together** and the plate's declared `t=238` supplies
|
||||
the timing, so nothing is authored. `238 − 118 = 120 units = 2.000 s`, of which
|
||||
2.13 s was a wall-clock reading stretched by Canary presenting at ~28.1 fps.
|
||||
⚠️ General lesson: **a wall-clock duration off this emulator is ~6 % long**, so
|
||||
a measured interval that lands near a round number of units probably *is* that
|
||||
number of units.
|
||||
* "a music bank has three sub-waves" → **refuted; it was our reader.** The third
|
||||
is the bank header, emitted because `to_xma_riffs` derived a leading packet
|
||||
stream's start as `first_riff % 2048` — valid only for a header shorter than
|
||||
one packet. 28/28 disc-wide —
|
||||
[`structures/slb-bank-header-not-a-wave.md`](structures/slb-bank-header-not-a-wave.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.
|
||||
|
||||
4192
docs/re/data/boot-timeline-2026-08-29.tsv
Normal file
4192
docs/re/data/boot-timeline-2026-08-29.tsv
Normal file
File diff suppressed because it is too large
Load Diff
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.
14
docs/re/data/kf-record-census.txt
Normal file
14
docs/re/data/kf-record-census.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
paks scanned : 33
|
||||
placement groups : 13991
|
||||
|
||||
A. lead-in prepended to the shifted times is non-decreasing
|
||||
13991/13991 = 100.000%
|
||||
|
||||
B. non-zero lead-in is strictly less than the next time
|
||||
5058/5058 = 100.000%
|
||||
control (another group's lead-in, same bundle): 35837/50580 = 70.852%
|
||||
gap to the next time, most common: [(10, 2076), (1, 2022), (30, 116), (40, 80), (12, 78), (90, 78), (149, 78), (20, 78)]
|
||||
|
||||
C. constant d(alpha)/d(time) across a multi-segment ramp
|
||||
corrected (time precedes pose): 857/1540 = 55.649%
|
||||
old (+36 is own time) : 0/1042 = 0.000%
|
||||
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
4171
docs/re/data/present-rate-controls-2026-08-29.json
Normal file
4171
docs/re/data/present-rate-controls-2026-08-29.json
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.
|
||||
|
||||
115
docs/re/present-rate-instrument-failed.md
Normal file
115
docs/re/present-rate-instrument-failed.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 🔴 I cannot measure this emulator's presentation rate — and the 2 % between two of my pages is not a disagreement about the game
|
||||
|
||||
**Status:** one 🔴 **instrument failure** (recorded, not published as a number),
|
||||
and one ✅ **resolution of a challenge** that follows from it. 2026-08-29.
|
||||
|
||||
## The challenge
|
||||
|
||||
The port put two of my pages against each other. Both measure the same declared
|
||||
quantity — **120 keyframe units of wall clock during a static hold, in Canary** —
|
||||
and they differ by 2 %:
|
||||
|
||||
| page | measured | implied presentation |
|
||||
|---|---|---|
|
||||
| [`title-plate-delay-measured.md`](title-plate-delay-measured.md), settle→plate, 2 runs | 2.138, 2.132 → **2.135 s** | 28.10 fps |
|
||||
| [`focus-ring-spin-measured.md`](focus-ring-spin-measured.md), 7 spacings | 2.16 … 2.20 → **2.177 s** | 27.56 fps |
|
||||
|
||||
0.042 s apart — seven times the 6 ms run-to-run agreement the plate page rests
|
||||
on. Its own corroboration argument (*"the build-in is where frames are dropped;
|
||||
the static hold is not"*) is aimed at exactly this, and these are two static
|
||||
holds. Fair challenge.
|
||||
|
||||
## ✅ The resolution: 2 % is far inside this emulator's own variation
|
||||
|
||||
The question assumes the wall clock is stable enough for 2 % to mean something.
|
||||
It is not, and the counterexample is the same interval, in the same container, on
|
||||
the same day:
|
||||
|
||||
| run | conditions | settle → plate |
|
||||
|---|---|---|
|
||||
| 1 | 8 fps grab | **2.138 s** |
|
||||
| 2 | 8 fps grab | **2.132 s** |
|
||||
| **3** | 8 fps grab **+ `--log_ui_draws=true`** | **2.549 s** |
|
||||
|
||||
**A 19 % swing on the declared interval, from a logging flag.** The 2 % the two
|
||||
pages differ by is a fifth of that. They were taken in different sessions under
|
||||
different load, and nothing in either can separate "the game timed it
|
||||
differently" from "the emulator ran slower" — because both are wall clock.
|
||||
|
||||
**So the two pages were never in conflict about the game.** They are three
|
||||
readings of one declared quantity through a clock that moves. What settles the
|
||||
quantity is the disc: `t=118 → t=238` is 120 units, and the port's own structural
|
||||
rule for the ring (two keyframes differing only by a 360° rotation, first timed
|
||||
and second untimed — 16/212 elements matched, all of them focus rings) gives the
|
||||
ring's period the same way.
|
||||
|
||||
⚠️ **This removes the evidence that the ring is not 120 units. It does not prove
|
||||
that it is.** The disc-side rule does that, and it is the port's, not mine.
|
||||
|
||||
## 🔴 The instrument I built to answer it properly, and why it is dead
|
||||
|
||||
Wall clock cannot separate the two hypotheses; **frames** can. So I tried to
|
||||
measure the presentation rate.
|
||||
|
||||
### Canary's own frame counter perturbs by a third
|
||||
|
||||
`--log_ui_draws=true --ui_draw_capture_frames=N` logs `[UI-CAP] capture armed`
|
||||
and `[UI-CAP] done: D draws over F frames`. This is the instrument that produced
|
||||
the corpus's **28.5 fps** ([`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)).
|
||||
|
||||
Measured here, armed on the title with a concurrent 8 fps grab:
|
||||
|
||||
> **300 frames in 16.567 s = 18.11 fps**, against ~28 for the same screen without
|
||||
> it.
|
||||
|
||||
⚠️ **A frame counter that costs a third of the frame rate cannot measure the
|
||||
frame rate.** This does not overturn the 28.5 fps — that run had no concurrent
|
||||
grab — but it does mean the figure is a **lower bound taken under its own
|
||||
instrument's load**, and it should not be treated as *the* rate.
|
||||
|
||||
### And the unperturbing replacement FAILED its own decisive control
|
||||
|
||||
The alternative: count **distinct frames** in an oversampled crop of something
|
||||
that moves every frame (the spinning focus ring). At 60 fps against a source
|
||||
presenting at R, the fraction of consecutive samples that differ is R/60.
|
||||
|
||||
Three controls were written before the run. It failed the one that matters:
|
||||
|
||||
| control | result |
|
||||
|---|---|
|
||||
| a static crop must read ≈ 0 | **2.63 fps of "change"** — not clean |
|
||||
| two sampling rates must agree | 45 fps → **12.73**, 60 fps → **12.08** ✅ |
|
||||
| **must agree with the game's own counter while both run** | counter **15.88** vs `[UI-CAP]` **17.59** — **10 % low** 🔴 |
|
||||
|
||||
The third is decisive and it is a failure: **the ring does not change on every
|
||||
presented frame**, so the counter measures the ring's animation rate, not the
|
||||
presentation rate. It also drifted 12.1 → 15.9 → 18.1 across one session, which
|
||||
a real rate estimator on a settled screen should not do.
|
||||
|
||||
**Dead, not tuneable** — per [`METHOD.md`](METHOD.md). No rate is published from
|
||||
it. Controls preserved:
|
||||
[`data/present-rate-controls-2026-08-29.json`](data/present-rate-controls-2026-08-29.json).
|
||||
|
||||
## What this means for the port, and for everything I hand over
|
||||
|
||||
**Do not take a wall-clock duration off this container as a game constant.**
|
||||
Demonstrated range for one declared interval: 2.13 – 2.55 s, and the emulator's
|
||||
own rate read anywhere from 12 to 28 fps depending on what was watching it.
|
||||
|
||||
The rule that follows: **a measured interval landing near a round number of
|
||||
declared units almost certainly IS that number of units**, and the units are what
|
||||
to ship. Wall clock is for ordering and for sanity, not for constants.
|
||||
|
||||
## Reach
|
||||
|
||||
* One container, one day, one machine. It says nothing about how a different host
|
||||
runs Canary, and nothing about hardware.
|
||||
* It does **not** refute the 28.5 fps in `ui-keyframe-time-unit.md`; it reclassifies
|
||||
it as a load-dependent lower bound.
|
||||
* ❔ **The game's true update rate is still not grounded in the disc.** "2 units
|
||||
per submitted frame" *is* grounded — it was read off a frame-indexed draw
|
||||
capture, so it is independent of how fast the emulator runs. What rests on the
|
||||
emulator is only the step from *a submitted frame* to *1/30 s*, i.e. the
|
||||
present interval. That is a constant in the executable, and reading it is
|
||||
blocked on the missing disassembly route
|
||||
([`static-route-recovered.md`](static-route-recovered.md)).
|
||||
104
docs/re/static-route-recovered.md
Normal file
104
docs/re/static-route-recovered.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 🟡 The static PPC route was lost with the container migration — the image is back, the disassembly is not
|
||||
|
||||
**Status:** ✅ the **image** is recovered and validated; 🔴 the **disassembly
|
||||
database** it is analysed with does not exist in this repository at all, and
|
||||
never did. 2026-08-29.
|
||||
|
||||
## What broke
|
||||
|
||||
Four tools in `tools/re-capture/` open a DuckDB database at
|
||||
`/work/xenia-rs/sylpheed.db` — `name_block_bases.py`, `archive_naming.py`,
|
||||
`isl_cmdtab.py` and the census work that produced most of the `sub_82xxxxxx`
|
||||
findings in [`INDEX.md`](INDEX.md). In this container:
|
||||
|
||||
```
|
||||
$ ls /work/xenia-rs
|
||||
ls: cannot access '/work/xenia-rs': No such file or directory
|
||||
```
|
||||
|
||||
🔴 **And nothing in the repository builds it.** A grep for `duckdb` finds only
|
||||
*consumers*; there is no disassembler, no PPC decoder vendored, and `capstone` is
|
||||
not installed (and `pip install` is refused here by PEP 668). So the static route
|
||||
is four read-only clients of a producer that is not in the tree.
|
||||
|
||||
⚠️ **This is not a small gap.** Every finding that cites a function address —
|
||||
the GamePart registry, the challenge gate, the ISL command table, `PlayerParams`,
|
||||
the boot sequencer — is currently **unre-checkable in this container**. They are
|
||||
not wrong; they are unverifiable, which is a different and quieter problem.
|
||||
|
||||
## `default.xex` is not a substitute
|
||||
|
||||
The disc's executable is encrypted and LZX-compressed:
|
||||
|
||||
```
|
||||
$ xxd -l 16 /disc/default.xex
|
||||
00000000: 5845 5832 0000 0001 0000 3000 ... XEX2......0.
|
||||
$ strings -a /disc/default.xex | grep -c GamePart
|
||||
0
|
||||
```
|
||||
|
||||
The header is intact — `XEX2`, media id `535107D4`, original PE name
|
||||
`default.pe` — and everything after it is noise. No decrypt/decompress exists in
|
||||
the tree either.
|
||||
|
||||
## ✅ What is recovered, and how it is validated
|
||||
|
||||
Xenia decrypts, decompresses and relocates the image at load, so a **running
|
||||
guest holds the flat VA image** the corpus calls the `.pe`
|
||||
(`VA = 0x82000000 + offset`). `tools/re-capture/dump_image.py` reads it straight
|
||||
out of `/dev/shm/xenia_memory_*` — no debugger, no emulator patch, no pause.
|
||||
|
||||
```
|
||||
$ tools/re-capture/dump_image.py /sylph-home/re/sylpheed-image.pe
|
||||
wrote ... 4194304 bytes VA 0x82000000..0x82400000
|
||||
validated: GamePart id table + D3D runtime strings; 1013/1024 non-empty 4K pages
|
||||
```
|
||||
|
||||
**The validation is the corpus's own landmarks, not the tool's.** A mis-based or
|
||||
partial dump fails both:
|
||||
|
||||
* `0x820A1630` holds the **GamePart id table** — 29 `.rdata` pointers resolving
|
||||
to `GP_TITLE` (0) … `GP_TEST` (28), with `GP_CHALLENGE` at **26**, exactly as
|
||||
[`challenge-mission-gate.md`](challenge-mission-gate.md) records;
|
||||
* the image carries the **Xbox 360 D3D runtime's own error strings**
|
||||
(`ERR[D3D]: Unanticipated CPU_INTERRUPT`, `D3D9D.LIB`), which only the real
|
||||
loaded executable has.
|
||||
|
||||
So string search, table dumps and pointer chasing work again today. **What does
|
||||
not** is anything needing decoded instructions: no `mnemonic`/`operands`, so no
|
||||
xref search, no base solving, no call graphs.
|
||||
|
||||
⚠️ It also depends on a **booted emulator**, which is a bad dependency for the
|
||||
foundation of the static corpus. Dump once and keep the file.
|
||||
|
||||
## 🔵 For the human — what is actually needed
|
||||
|
||||
Not the image; that is solved. What is missing is the **producer of the
|
||||
database**, and its schema, which the four consumers pin exactly:
|
||||
|
||||
| table | columns used |
|
||||
|---|---|
|
||||
| `functions` | `address`, `end_address`, `name` |
|
||||
| `instructions` | `address`, `mnemonic`, `operands` |
|
||||
| `strings` | `address`, `content` |
|
||||
|
||||
An XEX unpacker would also be worth having on its own, so the static route stops
|
||||
needing a running emulator to bootstrap.
|
||||
|
||||
## What it blocks right now
|
||||
|
||||
The one open question this iteration wanted it for: **the game's present
|
||||
interval**, i.e. the step from *one submitted frame* to *1/30 s of real time*.
|
||||
|
||||
"2 units per submitted frame" is already grounded and emulator-independent — it
|
||||
was read off a frame-indexed draw capture
|
||||
([`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)). The remaining link is
|
||||
`D3DPRESENT_PARAMETERS.PresentationInterval` at device creation: `_ONE` means the
|
||||
game runs at the console's 60 Hz vblank, `_TWO` at 30. That is a constant loaded
|
||||
into a register, so **finding it needs disassembly, not string search** — the
|
||||
image contains the D3D runtime but the interval is an immediate, not a string.
|
||||
|
||||
Settling it would move Q1's seconds-per-unit from **measured** to **decoded**,
|
||||
and it is the only part of the UI clock still resting on a wall clock this
|
||||
container has now been shown to move by 19 %
|
||||
([`present-rate-instrument-failed.md`](present-rate-instrument-failed.md)).
|
||||
@@ -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.
|
||||
213
docs/re/title-plate-delay-measured.md
Normal file
213
docs/re/title-plate-delay-measured.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# ✅ 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.
|
||||
|
||||
## 🔴 The instruction below was WRONG, and the port refuted it — corrected 2026-08-29
|
||||
|
||||
**What stands:** every measurement on this page. **What was wrong:** what I told
|
||||
the port to do with it.
|
||||
|
||||
The instruction was *"when build 4 has settled, wait 2.13 s, composite build 2"*.
|
||||
The port implemented it literally, then pointed out with arithmetic off the disc
|
||||
that it cannot be right: build 2 has a **group of its own**, and playing that
|
||||
group from a start at "settle" puts the plate at settle + 2.13 + 3.97 s. The
|
||||
3.97 s is real — `ptbtn00.rat` reaches `a=255` at `t=238`, confirmed here
|
||||
independently of their message:
|
||||
|
||||
```
|
||||
$ sylpheed-cli screen info --build 2 --geometry $SYLPHEED_DISC/dat/GP_TITLE.pak
|
||||
build [2] 1280x720 1 elements
|
||||
0 ptbtn00.t32 214: 383,560 a=0 236: 383,550 a=0
|
||||
238: 383,550 a=255 244: 383,550 a=255 -: a=0
|
||||
```
|
||||
|
||||
### The reconciliation: one clock, and my landmark is `t≈118`, not `t=261`
|
||||
|
||||
**Build 2's group runs on the same clock as build 4's, starting together.** Then
|
||||
the plate's own keyframes say when it arrives and nothing needs authoring.
|
||||
|
||||
The port's premise that *"build 4 settles at `t=261` = 4.35 s"* is the part that
|
||||
fails, and it is worth stating plainly because it will bite elsewhere:
|
||||
🔴 **`rest.t` is not when a screen settles.** It is the last *hold* keyframe
|
||||
before the exit. `ptlogo1` has `rest.t = 251` and stops moving at **`t=42`** —
|
||||
after which it creeps 5 px over the next 209 units. The title's visible build-in
|
||||
is over at **`t≈118`**, where three elements' ramps end together (`pteff01`,
|
||||
`pteff02.prm`, `ptlogoall_eff`); the only later change is the copyright line and
|
||||
the ™.
|
||||
|
||||
That closes the gap exactly, with no free parameter:
|
||||
|
||||
| | units |
|
||||
|---|---|
|
||||
| last build-in ramp ends (`pteff01` / `pteff02.prm` / `ptlogoall_eff`) | `t = 118` |
|
||||
| `ptbtn00` reaches `a = 255` | `t = 238` |
|
||||
| **difference** | **120 units = 2.000 s** at 1 unit = 1/60 s |
|
||||
|
||||
against a measured **2.138 s** and **2.132 s**. So the interval the two runs agree
|
||||
on to 6 ms is a **declared** 120 units — the number was on the disc, and I handed
|
||||
over a wall-clock reading of it.
|
||||
|
||||
### ⚠️ And the wall-clock reading is 6.7 % long, for a reason the corpus already knew
|
||||
|
||||
120 units in 2.135 s is **56.2 units/s**, i.e. the game presenting at **28.06 /
|
||||
28.14 fps** against its nominal 30. The corpus independently measured the idle
|
||||
title at **28.5 fps** ([`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)) —
|
||||
1.3 % from these two runs, established before and separately from them.
|
||||
|
||||
✅ Corroborating that it is presentation rate and not the game: within these runs
|
||||
*first pixels → settle* is **1.643 s** and **2.131 s** — a 30 % spread — while
|
||||
*settle → plate* is **2.138 s** and **2.132 s**. The build-in is where frames are
|
||||
dropped; the static hold is not. A model in which the game's own timing varied
|
||||
would have to move both.
|
||||
|
||||
## What the port should author — nothing
|
||||
|
||||
1. draw build 4 and build **2** on **one clock, started together**, and play both
|
||||
groups from their own keyframes;
|
||||
2. the plate then appears at its declared `t = 238` with no authored constant;
|
||||
3. its pulse is the focus record `ptbtn00f`, measured here at **2.12 / 2.19 /
|
||||
2.34 / 2.31 s** over four intervals, mean **2.24 s** — replicating the
|
||||
corpus's ≈ 2.3 s rather than replacing it.
|
||||
|
||||
⚠️ **If you do author a gap anyway, author 120 units (2.00 s at 30 Hz), not my
|
||||
2.13 s.** The 2.13 s is this emulator's presentation rate baked into a game
|
||||
constant, and a port running at a true 30 Hz would be visibly late.
|
||||
|
||||
So yes: `ScreenView` needs two builds at once, and the boot's end state is
|
||||
**not** plate-free. That part of the answer is unchanged.
|
||||
|
||||
### ❔ What this does not settle
|
||||
|
||||
* **Which reading of the keyframe times** — the current one or Q1's replicated
|
||||
shift — is right. It barely matters here (the plate's `a=255` is `t=238`
|
||||
unshifted and `t=236` shifted, 0.03 s apart), but the two make different
|
||||
predictions for `ptcopyright`'s fade, and my traces contain **both** a 0.4 s
|
||||
rise and a 1.1 s creep before the plate. Not separated; Q1's 🟡 stands.
|
||||
* **My settle landmark to better than ±5 units.** At 56 units/s, 8 units is
|
||||
0.14 s — about one sample. `t=118` is identified from the file (three ramps
|
||||
ending together) and is *consistent with* the measurement, not pinned by it.
|
||||
|
||||
## 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.
|
||||
239
docs/re/ui-keyframe-record-layout.md
Normal file
239
docs/re/ui-keyframe-record-layout.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# A keyframe's time word comes **before** its pose — the placement record, decoded
|
||||
|
||||
**Status:** ✅ `CONFIRMED`, **decoded**. The field, plus a disc-wide check
|
||||
(13 991 placement groups over 33 archives, three tests, each with a control) and
|
||||
a regression test that runs against the disc
|
||||
(`crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs`).
|
||||
|
||||
This closes the one thing [MISSION](../port/MISSION.md) **Q1** still had open —
|
||||
*"the interpolation law is settled; the group TIMELINE for multi-keyframe
|
||||
elements is not"* — and it dissolves, rather than decides, the argument in
|
||||
[`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) about whether to adopt
|
||||
`SYLPHEED_KF_TIME_SHIFT`. Both sides of that argument were reasoning about a
|
||||
missing word that is not missing.
|
||||
|
||||
## The record
|
||||
|
||||
A build bundle's placement region is a run of groups, one per element. A group
|
||||
is an 8-byte header followed by `frame_count` **records of 40 bytes**:
|
||||
|
||||
```text
|
||||
u32 element_index
|
||||
u32 frame_count
|
||||
┐
|
||||
u32 time │ record 0 ← the time comes FIRST
|
||||
36 pose ┘
|
||||
u32 time ┐ record 1
|
||||
36 pose ┘
|
||||
…
|
||||
u32 time ┐ record n−1
|
||||
36 pose ┘
|
||||
```
|
||||
|
||||
Total group size: `8 + frame_count * 40`.
|
||||
|
||||
The 36-byte pose is what the parser already reads correctly — fade ARGB, the
|
||||
three signed rotation words, scale X/Y, tint, X, Y — at offsets 0…35 of the
|
||||
pose, i.e. 4…39 of the record.
|
||||
|
||||
## What was wrong, and why it looked right for so long
|
||||
|
||||
Our parser opened its 40-byte window **at the pose**, four bytes into record 0,
|
||||
and then read the word at window `+36` as that pose's time. That word is
|
||||
record `k+1`'s `time` — the time of the *next* pose. Every pose field lands
|
||||
correctly (the window is aligned to a pose, and poses are what it reads); only
|
||||
the time association slips by one.
|
||||
|
||||
Two long-standing oddities in the corpus are that off-by-one, and nothing else:
|
||||
|
||||
| the oddity as recorded | what it actually was |
|
||||
|---|---|
|
||||
| *"a group's data stops 4 bytes short of its final block's time slot — that word is already the next group's element index"* | the group is **not** short. `8 + frames*40` is exact. The parser was reading 4 bytes past the last pose because its window began 4 bytes early |
|
||||
| *"the last keyframe carries no time"* — `Keyframe::time` was `Option<u32>`, `None` on every group's final pose | the final pose's time is the *previous* stride's `+36` word. **Every** pose is timed |
|
||||
| the stray `time = 1869640736` (= `"ohnm"`, ASCII from the next record) that "silently corrupts the max-dwell pick in `Element::rest`" | the same over-read |
|
||||
|
||||
The first pose's time is the group's **lead-in word** at `header + 8` — the word
|
||||
[`parse_placements`](../../crates/sylpheed-formats/src/ui_layout.rs) skipped as
|
||||
*"one lead-in word"* without asking what it was.
|
||||
|
||||
## The disc-wide check
|
||||
|
||||
`tools/re-capture/kf_record_census.py`, output committed at
|
||||
[`data/kf-record-census.txt`](data/kf-record-census.txt). Run it with
|
||||
|
||||
```bash
|
||||
python3 tools/re-capture/kf_record_census.py "$SYLPHEED_DISC"/dat/*.pak
|
||||
```
|
||||
|
||||
### A. The lead-in word takes its place in the sequence
|
||||
|
||||
Prepending the lead-in to the shifted time series must give a non-decreasing
|
||||
sequence. **13 991 of 13 991 groups — 100.000 %.** (15 493 including
|
||||
single-pose groups, which are trivially ordered; the regression test counts
|
||||
those and also finds 0 out of order.)
|
||||
|
||||
### B. The 5 058 non-zero lead-ins are times, not padding
|
||||
|
||||
If the lead-in were padding, a flag, or a count, 5 058 of them would not all
|
||||
happen to fall strictly below the group's next time.
|
||||
|
||||
| | result |
|
||||
|---|---|
|
||||
| non-zero lead-ins | 5 058 |
|
||||
| strictly less than the next time | **5 058 — 100.000 %**, none equal |
|
||||
| **control**: another group's lead-in from the same bundle | 35 837 / 50 580 = **70.9 %** |
|
||||
|
||||
The gap to the next time piles up at **10** (2 076 groups) and **1** (2 022) —
|
||||
ramp lengths, not arbitrary numbers. And the values themselves read as times:
|
||||
`GP_DIALOG` entry 9's `pzeff02.t32` runs `167 → 197 → 217 → 232`; entry 25's
|
||||
`pznoise.rat` runs `40 → 80 → 230 → 260`.
|
||||
|
||||
### C. A multi-keyframe ramp only runs at a constant rate under this reading
|
||||
|
||||
Interpolation between two keyframes is linear — measured against the running
|
||||
game, in [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md). So where an
|
||||
author chains three or more keyframes through a monotone alpha ramp, a correct
|
||||
time assignment should often make `d(alpha)/d(time)` come out constant, and a
|
||||
wrong one should scramble it.
|
||||
|
||||
| reading | multi-segment alpha ramps at a constant rate (±6 %) |
|
||||
|---|---|
|
||||
| **corrected** — time precedes pose | **857 / 1 540 = 55.6 %** |
|
||||
| old — `+36` is the block's own time | **0 / 1 042 = 0.0 %** |
|
||||
|
||||
**Zero.** Not one ramp on the whole disc. The 44 % that are not constant under
|
||||
the corrected reading are genuinely shaped ramps — authors do place keyframes
|
||||
unevenly — so 56 % is a floor, not a fit.
|
||||
|
||||
A worked example, `pgloading_loop4.rat` on `GP_TITLE` build 11:
|
||||
|
||||
| | times | alphas | rate per unit |
|
||||
|---|---|---|---|
|
||||
| corrected | 0, 4, 6, 7, 8, 32, 38 | 0, 128, 192, 224, 255, 255, 0 | **32, 32, 32, 31** — then hold, then out |
|
||||
| old | 4, 6, 7, 8, 32, 38, *(none)* | 0, 128, 192, 224, 255, 255, 0 | 64, 64, 32, 1.3 — then hold, then an **untimed** fade-out |
|
||||
|
||||
## What it costs to adopt: nothing, on every static composite
|
||||
|
||||
This is the change the corpus previously declined to make, because
|
||||
`SYLPHEED_KF_TIME_SHIFT=1` moved `GP_TITLE` build 7 by 13.1 % of its pixels and
|
||||
made the EN/JP twin brightness disagree (70.94 vs 76.32 against build 4's
|
||||
71.41). **That was the missing first time word, not the shift.**
|
||||
|
||||
With the lead-in restored as pose 0's time:
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| `GP_TITLE`, all 12 builds rendered under both readings | **12 / 12 byte-identical PNGs**, build 7 included |
|
||||
| 217 builds over 6 UI archives, `rest()` pose per element | **2 builds differ**: `GP_TITLE` 7 and `GP_DIALOG` 31 |
|
||||
| what those 2 differences are | `ptlogo_eff3.t32`: `(98,42)` vs `(108,72)` — **both α = 0**, so neither paints. `pzstg14_2.t32`: one pixel of Y |
|
||||
| renders of those 2 builds | **identical** |
|
||||
|
||||
So the build-7 luminance objection is withdrawn: it was `rest()`'s dwell
|
||||
fallback picking the 200 %-scale bloom because pose 0 had no time to be compared
|
||||
against. Given a time, the dwell rule picks an invisible pose — the same
|
||||
*visible* result the old reading produced, by a rule that is now sound.
|
||||
|
||||
⚠️ **`Element::rest()` is unchanged and is still a heuristic.** The times feed
|
||||
it; they do not fix it. `structures/ui-resting-pose.md` stands as written.
|
||||
|
||||
## Against the oracle
|
||||
|
||||
The committed `log_ui_draws` capture of the developer splash
|
||||
([`captures/ui-timing/splash-build-quads.csv`](captures/ui-timing/splash-build-quads.csv))
|
||||
is the check that this is the game's reading and not merely a tidier one.
|
||||
|
||||
`palogo_gamearts_eff.t32` — lead-in 0, `W = [15, 30, 45, –]`, alphas
|
||||
`[0, 255, 255, 0]`:
|
||||
|
||||
| phase | corrected | old | captured |
|
||||
|---|---|---|---|
|
||||
| fade in | t 0→15 (7.5 f) | t 15→30 (7.5 f) | frames 94–101, **7 f** |
|
||||
| hold | t 15→30 (7.5 f) | t 30→45 (7.5 f) | frames 101–107, **7 f** |
|
||||
| fade out | t 30→45 (7.5 f) | **untimed** | frames 108–115, **8 f** |
|
||||
|
||||
The glow's *durations* do not discriminate — that was already recorded — but its
|
||||
**end does**: the old reading cannot say when the fade-out finishes, and the
|
||||
capture plainly shows it finishing.
|
||||
|
||||
`palogo_gamearts.t32` — lead-in 0, `W = [15, 30, 190, 194, 206, 210, –]`, alphas
|
||||
`[0, 0, 255, 255, 232, 32, 0]`:
|
||||
|
||||
| | corrected | old | captured |
|
||||
|---|---|---|---|
|
||||
| fade in | t 15→30, **7.5 f**, in the same window as its own glow | t 30→190, **80 f** | already at 255 when the quad first appears (frame 116) |
|
||||
| hold at 255 | t 30→190, **80 f** | t 190→194, **2 f** | frames ≤116–198, **≥ 83 f** |
|
||||
| fade out | t 190→210, **10 f** | t 194→? , untimed end | frames 199–211, **13 f** |
|
||||
|
||||
A logo whose bloom layer fades in over 7.5 frames while the logo itself takes 80
|
||||
is not a thing anyone authored. This replicates the 26× result already in
|
||||
[`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) and adds the reason.
|
||||
|
||||
⚠️ **Reach.** The capture's absolute frame numbers sit about 18 frames later than
|
||||
the glow-derived calibration `t = 2f − 171` predicts for the *logo* — the
|
||||
fade-out starts at frame 199 where the calibration says 180.5. Durations match;
|
||||
the two elements' groups do not appear to start on the same frame. That offset is
|
||||
**not explained here** and is not needed for this result, which is about which
|
||||
word is which. It is the same lateness `ui-keyframe-time-unit.md` records as
|
||||
"17 frames late" and leaves open.
|
||||
|
||||
### And the corpus had already used this reading without noticing
|
||||
|
||||
[`ui-title-build-map.md`](ui-title-build-map.md)'s splash timing table — written
|
||||
on 2026-08-28 against a 10 fps capture, and agreeing with it to ±0.1 s — reads
|
||||
`palogo_sqex.t32`'s declared `[15 30 235 239 251 255 –]` as
|
||||
|
||||
| | the table says | the OLD reading actually gives | the corrected reading gives |
|
||||
|---|---|---|---|
|
||||
| hold at α=255 | `30 → 235` = **3.42 s** ✅ measured ≈3.5 s | `235 → 239` = **0.07 s** | `30 → 235` = **3.42 s** |
|
||||
| fade out | `235 → 255` = **0.33 s** ✅ measured ≈0.3 s | `239 → ?` — the α=0 pose is **untimed** | `235 → 255` = **0.33 s** |
|
||||
|
||||
Its author paired each time with the pose that *reaches* it, by eye, because that
|
||||
is the only pairing that produces a sensible splash — and then checked it against
|
||||
a capture, which agreed. The record layout is what that pairing was.
|
||||
|
||||
## What changed in the code
|
||||
|
||||
[`crates/sylpheed-formats/src/ui_layout.rs`](../../crates/sylpheed-formats/src/ui_layout.rs):
|
||||
|
||||
* `parse_placements` reads `header + 8` as pose 0's time and the previous
|
||||
stride's `+36` as pose `k`'s. Every pose gets a time.
|
||||
* `SYLPHEED_KF_TIME_SHIFT` is gone. `SYLPHEED_KF_TIME_LEGACY=1` restores the old
|
||||
reading for A/B work.
|
||||
* `Keyframe::time` stays `Option<u32>` only so the legacy gate still type-checks.
|
||||
Under the default it is always `Some`.
|
||||
|
||||
New test, disc-gated: `tests/ui_keyframe_record_disc.rs` — every pose timed and
|
||||
ordered (15 493 groups), and ≥ 45 % of multi-segment alpha ramps at a constant
|
||||
rate (the old reading scores 0 %).
|
||||
|
||||
⚠️ `tests/ui_header_time_disc.rs` needed one line: bundles whose every group is a
|
||||
single static pose now report `max_time = 0` where before they reported no time
|
||||
at all, and 546 of them were swamping the ratio histogram's zero bucket. The
|
||||
result it guards **strengthened** — the bound `max_time ≤ header +0x08` now holds
|
||||
over **2 859** bundles instead of 2 313, still with **0** violations, and the
|
||||
newly readable times are the latest in every group.
|
||||
|
||||
## What is NOT established
|
||||
|
||||
❔ **The executable's own parser was not found.** Reach: queried the disassembly
|
||||
database for functions carrying a `mulli` by 40 (the record stride) and by 60
|
||||
(the declaration-entry stride) — 26 functions have the first, none have both, and
|
||||
PowerPC compilers synthesise both constants as shift-adds, so the query is weak
|
||||
rather than negative. Nothing here rests on a database row; every number above
|
||||
comes from the disc bytes or from a committed capture. Finding the interpolator
|
||||
would upgrade this from *decoded from the container's own arithmetic and a
|
||||
disc-wide census* to *decoded from the code*, and would also settle the 18-frame
|
||||
group-start offset above.
|
||||
|
||||
## For the port
|
||||
|
||||
The pose values you already have do not move. What moves is **when** each pose is
|
||||
reached:
|
||||
|
||||
* pose `k`'s time is the word **before** it, not after it;
|
||||
* pose 0 has a time — usually 0, but 5 058 groups on the disc start late;
|
||||
* the **last** pose has a time, so an exit ramp now has an end. Anything you
|
||||
authored to cover "the final keyframe has no time" can come out.
|
||||
|
||||
Static composites are unaffected: `screen render` produces byte-identical output
|
||||
on all 12 `GP_TITLE` builds.
|
||||
@@ -1,5 +1,21 @@
|
||||
# What a keyframe time is worth, and what shape the ramp has
|
||||
|
||||
> ## ✅ 2026-08-29 — the argument on this page about WHICH BLOCK OWNS A TIME is over
|
||||
>
|
||||
> It was never a choice between two readings. A placement group is
|
||||
> `frames` records of `{u32 time; 36-byte pose}` after an 8-byte header, so the
|
||||
> time word **precedes** its pose; the group's "lead-in word" is pose 0's time,
|
||||
> and **no** time is missing. `SYLPHEED_KF_TIME_SHIFT` had the association right
|
||||
> and pose 0 untimed, which is the only reason it looked like it cost build 7
|
||||
> 13.1 % of its pixels. Decoded disc-wide, with controls, in
|
||||
> [`ui-keyframe-record-layout.md`](ui-keyframe-record-layout.md); the gate is now
|
||||
> `SYLPHEED_KF_TIME_LEGACY=1`.
|
||||
>
|
||||
> **Everything else on this page stands** — the ramp is linear, the clock advances
|
||||
> 2 units per submitted frame, and `1 unit = 1/60 s` is measured. Read the
|
||||
> sections below with that correction applied: where a table pairs a time with a
|
||||
> pose, the pairing is the corrected one.
|
||||
|
||||
**Status:** ✅ `CONFIRMED` for the two things the port is blocked on — the ramp is
|
||||
**linear**, and the animation clock advances **2 keyframe time units per frame the
|
||||
game submits**. 🟡 the conversion to *seconds* rests on one further step: the game
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
**Status:** ✅ `CONFIRMED` for the four screens the boot path actually shows
|
||||
(title art, `PRESS Ⓐ BUTTON`, main menu, `EXTRAS`); 🟡 `PROBABLE` for their
|
||||
Japanese twins; ❔ open for the two `DELTASABER` plates.
|
||||
Japanese twins.
|
||||
|
||||
✅ **2026-08-29 — the two "unidentified `DELTASABER` plates" are the LOADING
|
||||
SCREEN**, and the ❔ on them is withdrawn. See
|
||||
[below](#-the-deltasaber-plates-are-the-loading-screen-and-there-are-two-of-them).
|
||||
|
||||
Answers [MISSION Q2](../port/MISSION.md). The previous statement — *"build 4
|
||||
title, 5 main menu, 6/8/9 submenus"* — is **partly wrong** and is withdrawn:
|
||||
@@ -53,7 +57,7 @@ Contact sheet of every render:
|
||||
|
||||
| build | what it is | confirmed how |
|
||||
|---|---|---|
|
||||
| 0, 1 | a `DELTASABER / SYLPHEED A.I.` plate low-left on black, no background | ❔ **not observed running.** Never seen in the boot path, the main menu, `EXTRAS` or `MISSION SELECT` |
|
||||
| 0, 1 | ✅ **the LOADING screen**, plain — a `DELTASABER / SYLPHEED A.I.` plate low-left on black, no background. 7 elements, all named `pgloading_*` | ✅ **decoded from the declaration table**, not observed running |
|
||||
| **2**, 3 | the `PRESS Ⓐ BUTTON` plate — **an overlay build of its own**, not a state of build 4 | ✅ seen composited over build 4 on the live title, at the same rect our render puts it |
|
||||
| **4** | title art, English (`PROJECT SYLPHEED`, ™, `(C)2006,2007 SQUARE ENIX`) | ✅ [`live-title-press-a.png`](captures/title-builds/live-title-press-a.png) |
|
||||
| 7 | the same, Japanese | 🟡 renders as the JP twin of build 4; the container runs an English locale, so it was not seen |
|
||||
@@ -61,7 +65,96 @@ Contact sheet of every render:
|
||||
| 8 | the same, Japanese | 🟡 as above |
|
||||
| **6** | the `EXTRAS` submenu, English: MISSION SELECT / MOVIE THEATER / BACK, footer `Ⓐ : OK Ⓑ : Back` | ✅ [`live-extras.png`](captures/title-builds/live-extras.png) |
|
||||
| 9 | the same, Japanese | 🟡 as above |
|
||||
| 10, 11 | the same `DELTASABER` plate as build 0, over a dark circuit-line background | ❔ **not observed running** |
|
||||
| 10, 11 | ✅ **the LOADING screen**, dressed — the same plate over a dark circuit-line background. 10 elements, the same seven plus `pgloading_eff00.prm`, `pgloading_loop5.rat` and `pgloading_baseeff.t32` | ✅ **decoded from the declaration table**, not observed running |
|
||||
|
||||
## ✅ The `DELTASABER` plates are the LOADING screen, and there are two of them
|
||||
|
||||
**2026-08-29. Decoded — the authors' own element names, out of the declaration
|
||||
table.** No renderer, no capture, no inference. Every element of all four
|
||||
bundles is prefixed `pgloading_`:
|
||||
|
||||
| build | entry | elements |
|
||||
|---|---|---|
|
||||
| 0, 1 | 0, 1 | `pgloading_loop1.rat` `pgloading_loop3.rat` `pgloading_str.t32` `pgloading_line.t32` `pgloading_loop4.rat` `pgloading_eff01.t32` `pgloading_eff02.t32` |
|
||||
| 10, 11 | 12, 15 | the same seven, plus `pgloading_eff00.prm` `pgloading_loop5.rat` `pgloading_baseeff.t32` |
|
||||
|
||||
Reproduce:
|
||||
|
||||
```bash
|
||||
sylpheed-cli screen info --build 0 "$SYLPHEED_DISC/dat/GP_TITLE.pak"
|
||||
sylpheed-cli screen info --build 10 "$SYLPHEED_DISC/dat/GP_TITLE.pak"
|
||||
```
|
||||
|
||||
`DELTASABER / SYLPHEED A.I.` is the artwork on `pgloading_str.t32` — the loading
|
||||
screen's caption, not the screen's identity. Reading the picture named the plate;
|
||||
reading the file names the screen.
|
||||
|
||||
⚠️ This also removes the reason the pair was open. The previous row said *"never
|
||||
seen in the boot path, the main menu, `EXTRAS` or `MISSION SELECT`… a mission
|
||||
load is the remaining candidate"*. It is a **loading** screen: it is not supposed
|
||||
to appear on any of those, and the remaining candidate was right.
|
||||
|
||||
### 🟡 Which is `LOADING` and which is `LOADING2` — the executable names five
|
||||
|
||||
`sub_821C4EB0` builds `GamePart_Title` and asks its table for five sub-entries in
|
||||
this order, one `bl sub_821CEDF8` each, setting an error flag on any failure:
|
||||
|
||||
| call site | string | VA |
|
||||
|---|---|---|
|
||||
| `0x821C503C` | `TITLE_SCREEN` | `0x820A3D3C` |
|
||||
| `0x821C5068` | `BUTTON` | `0x820A339C` |
|
||||
| `0x821C5090` | `TITLE_MENU` | `0x820A3D30` |
|
||||
| `0x821C50BC` | `LOADING` | `0x820A214C` |
|
||||
| `0x821C50E4` | `LOADING2` | `0x820A3D24` |
|
||||
|
||||
✅ **Checked against the image, not just the database** — `/image/sylpheed.pe` at
|
||||
`0x821C503C`, `0x821C5090`, `0x821C50BC`, `0x821C50E4` reads `38aa3d3c`,
|
||||
`3baa3d30`, `38aa214c`, `38aa3d24`, exactly the `addi rX, r10, <lo16>` the
|
||||
database shows, with `r10 = 0x820A0000` set two instructions earlier.
|
||||
|
||||
That is five named title-side screens, and `BUTTON` is what the `PRESS Ⓐ BUTTON`
|
||||
overlay would be called — which the corpus had already isolated as a build of its
|
||||
own (pair B) on capture evidence alone.
|
||||
|
||||
🟡 **Two loading screens named, two loading bundles found — but nothing observed
|
||||
maps one to the other.** `LOADING` is the 7-element plain plate and `LOADING2`
|
||||
the 10-element dressed one *if* the suffix means "the second, richer variant",
|
||||
and that is a guess about a name. The port should treat the pairing as
|
||||
**undecided** and not carry either name into an asset path.
|
||||
|
||||
Also note the lookup is **not** by pak TOC hash: at `0x821C5118`–`0x821C512C` the
|
||||
game hashes `BASE_INFO` and `TITLE_MENU` with `sub_82455C78` (the same name-hash
|
||||
[`hash.rs`](../../crates/sylpheed-formats/src/hash.rs) implements) and splices
|
||||
the two 32-bit results into one 64-bit key. So these strings key a **sub-table
|
||||
inside the GamePart's own record**, not an archive entry — which is why
|
||||
`pak list` resolves none of `GP_TITLE`'s 16 names.
|
||||
|
||||
## 🟡 Which member of each pair is English — the archive is packed in two halves
|
||||
|
||||
The port asked which member of pairs A, B and H is which locale, since those
|
||||
three render byte-identically and no capture can tell them apart. The **data
|
||||
segment** can:
|
||||
|
||||
| | entries | data offset |
|
||||
|---|---|---|
|
||||
| first half | 0, 2, 4, 5, 6, 10, 11, 12 | 0 … 507 904 |
|
||||
| second half | 1, 3, 7, 8, 9, 13, 14, 15 | 6 078 464 … 10 868 736 |
|
||||
|
||||
Every one of the eight pairs has **exactly one member in each half**, and in all
|
||||
three pairs whose language is visible — C (4/7), D (5/8), E (6/9) — the English
|
||||
build is the one in the **first** half. `GP_TITLE.p00` is one locale's eight
|
||||
bundles followed by the other's; the TOC interleaves them only because it is
|
||||
sorted by name hash.
|
||||
|
||||
So: **first half = English**, i.e. builds 0, 2, 4, 5, 6, 10 are English and 1, 3,
|
||||
7, 8, 9, 11 are Japanese.
|
||||
|
||||
⚠️ 🟡 not ✅. The rule is 8/8 structurally consistent and 3/3 where it can be
|
||||
checked, but the three pairs it is *used* for are exactly the three it cannot be
|
||||
checked on. Reach of the negative: nothing in the bundle bytes themselves — the
|
||||
header, the declaration table, the element names — differs between the twins of
|
||||
pairs A, B and H at all; they render byte-identical PNGs. If a locale marker
|
||||
exists it is not in the bundle.
|
||||
|
||||
## ✅ The splash — the four "non-build" entries, rendered
|
||||
|
||||
|
||||
234
tools/re-capture/boot_timeline_probe.py
Executable file
234
tools/re-capture/boot_timeline_probe.py
Executable file
@@ -0,0 +1,234 @@
|
||||
"""Time the whole boot, and measure the PRESENTATION RATE per screen.
|
||||
|
||||
Two jobs, one oracle session, because they need each other.
|
||||
|
||||
1. **The boot timeline the port asked for.** Every screen-to-screen transition
|
||||
from launch to the main menu, wall-clock, with the black holds marked. The
|
||||
port paces its boot off `ScreenView.settle_time()` = a group's `rest.t`, and
|
||||
`rest.t` is NOT when a screen settles (docs/re/REFUTED.md) — so every screen's
|
||||
dwell is currently wrong by an unknown amount.
|
||||
|
||||
2. **Frames, not seconds.** Two pages of the corpus measure the same declared
|
||||
120 keyframe units during a static hold and disagree by 2 %: settle→plate is
|
||||
2.135 s (28.10 fps implied) and one focus-ring revolution is 2.177 s (27.56
|
||||
implied). Either the presentation rate differed between those sessions, or one
|
||||
interval is not 120 units. The two were taken on DIFFERENT SCREENS, so this
|
||||
measures the rate on each — with the game's own frame counter, not a guess
|
||||
about what changes between grabs.
|
||||
|
||||
`--log_ui_draws=true --ui_draw_capture_frames=N` makes Canary log
|
||||
`[UI-CAP] capture armed` and then `[UI-CAP] done: D draws over F frames`. Timing
|
||||
between those two lines in its own stdout gives frames/second directly, and it
|
||||
re-arms (the log index is `{:02d}`), so one session can measure several screens.
|
||||
|
||||
⚠️ The instrument can perturb what it measures — writing a draw log costs the
|
||||
emulator something. Control built in: the ring period is measured both DURING a
|
||||
capture and OUTSIDE one, and a rate that is an artefact of logging would move it.
|
||||
|
||||
boot_timeline_probe.py --control
|
||||
boot_timeline_probe.py --run SECONDS OUT.tsv CANARY_STDOUT [shots_dir]
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
SD = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, SD)
|
||||
import title_timing_probe as T # noqa: E402 (same crop, same ZNCC, same controls)
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(SD))
|
||||
CAP = os.path.join(REPO, "docs", "re", "captures")
|
||||
|
||||
# The boot shows two splashes before the movie; both are committed captures.
|
||||
T.REFS["splash_pub"] = "title-builds/live-splash-publisher.png"
|
||||
T.REFS["splash_dev"] = "title-builds/live-splash-developer.png"
|
||||
T._R.clear()
|
||||
|
||||
RATE = 8
|
||||
CAPTURE_FRAMES = int(os.environ.get("UICAP_FRAMES", "300"))
|
||||
|
||||
|
||||
def control():
|
||||
"""Every control title_timing_probe has, plus the two splashes."""
|
||||
T.CONTROLS.extend([
|
||||
(os.path.join(CAP, "title-builds/live-splash-publisher.png"), "splash_pub"),
|
||||
(os.path.join(CAP, "title-builds/live-splash-developer.png"), "splash_dev"),
|
||||
])
|
||||
return T.control()
|
||||
|
||||
|
||||
def _tail(path, seen):
|
||||
"""New lines appended to the emulator's stdout since the last call."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(seen)
|
||||
b = f.read()
|
||||
return b.decode("utf-8", "replace"), seen + len(b)
|
||||
except OSError:
|
||||
return "", seen
|
||||
|
||||
|
||||
def arm_capture(log_path, seen, timeout=90.0):
|
||||
"""Press F10, then time the emulator's own armed->done lines.
|
||||
|
||||
Timed between the two LOG lines, not from the keypress: the arm latency is
|
||||
then excluded rather than folded into the rate.
|
||||
"""
|
||||
win = subprocess.run(["xdotool", "search", "--name", "Xenia-canary"],
|
||||
capture_output=True, text=True).stdout.split()
|
||||
if not win:
|
||||
return None, seen
|
||||
w = win[-1]
|
||||
subprocess.run(["xdotool", "windowactivate", w], capture_output=True)
|
||||
subprocess.run(["xdotool", "key", "--window", w, "F10"], capture_output=True)
|
||||
subprocess.run(["xdotool", "key", "F10"], capture_output=True)
|
||||
t_armed = t_done = None
|
||||
frames = draws = None
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
chunk, seen = _tail(log_path, seen)
|
||||
for line in chunk.splitlines():
|
||||
if "[UI-CAP] capture armed" in line and t_armed is None:
|
||||
t_armed = time.time()
|
||||
elif "[UI-CAP] done" in line and t_armed is not None:
|
||||
t_done = time.time()
|
||||
# "[UI-CAP] done: 1526 draws over 300 frames"
|
||||
parts = line.replace(":", " ").split()
|
||||
try:
|
||||
draws = int(parts[parts.index("done") + 1])
|
||||
frames = int(parts[parts.index("over") + 1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if t_done:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
if not (t_armed and t_done and frames):
|
||||
return None, seen
|
||||
dt = t_done - t_armed
|
||||
return {"frames": frames, "draws": draws, "seconds": dt, "fps": frames / dt}, seen
|
||||
|
||||
|
||||
def run(limit, out_path, log_path, shots_dir):
|
||||
os.makedirs(shots_dir, exist_ok=True)
|
||||
n = T.W * T.H * 3
|
||||
p = T.open_stream()
|
||||
t0 = time.time()
|
||||
seg = t0
|
||||
frames = 0
|
||||
seen = 0
|
||||
ev = []
|
||||
rates = {}
|
||||
state = "boot"
|
||||
last_label = None
|
||||
last_gray = None
|
||||
prev_mean = -1.0
|
||||
fh = open(out_path, "w")
|
||||
fh.write("#t\tglyph\tmean\tmotion\t" + "\t".join(T.REFS) + "\tlabel\n")
|
||||
|
||||
def mark(name, t=None):
|
||||
t = time.time() - t0 if t is None else t
|
||||
ev.append((name, t))
|
||||
print(f"EVENT {name} t={t:.3f}", flush=True)
|
||||
return t
|
||||
|
||||
while time.time() - t0 < limit and state != "done":
|
||||
now = time.time()
|
||||
# Restart only while still waiting; never across a measured interval.
|
||||
if state == "boot" and now - seg > 30:
|
||||
p.kill(); p = T.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 = T.open_stream(); seg = time.time(); continue
|
||||
t = time.time() - t0
|
||||
rgb = np.frombuffer(buf, np.uint8).reshape(T.H, T.W, 3)
|
||||
g = T.gray_of(rgb)
|
||||
gl = T.glyph(rgb)
|
||||
sc = T.scores(g)
|
||||
lb = T.label(sc)
|
||||
surf = T.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
|
||||
fh.write(f"{t:.3f}\t{gl}\t{mn:.3f}\t{mo:.3f}\t"
|
||||
+ "\t".join(f"{sc[k]:+.4f}" for k in T.REFS) + f"\t{lb}\n")
|
||||
|
||||
# Every label change and every entry/exit from pure black is a boot event.
|
||||
blk = "black" if mn < 1.0 else lb
|
||||
if blk != last_label:
|
||||
mark(f"screen:{blk}", t)
|
||||
last_label = blk
|
||||
if blk in ("splash_pub", "splash_dev", "title_noplate", "menu"):
|
||||
Image.fromarray(rgb).save(os.path.join(shots_dir, f"boot-{blk}.png"))
|
||||
|
||||
if state == "boot":
|
||||
if lb in ("title_noplate", "title_plate") and 0 <= mo < 2.0 and gl >= 100:
|
||||
mark("title_settled", t); state = "title"
|
||||
elif state == "title":
|
||||
if gl >= T.PLATE_GLYPH:
|
||||
mark("plate", t); state = "plate_hold"; hold_from = t
|
||||
elif state == "plate_hold":
|
||||
if t - ev[-1][1] > 3.0:
|
||||
fh.flush()
|
||||
r, seen = arm_capture(log_path, seen)
|
||||
rates["title"] = r
|
||||
mark(f"rate_title={r and round(r['fps'], 3)}")
|
||||
state = "press"
|
||||
elif state == "press":
|
||||
tp = T.tap("A") - t0
|
||||
ev.append(("pressA", tp)); print(f"EVENT pressA t={tp:.3f}", flush=True)
|
||||
state = "toMenu"
|
||||
elif state == "toMenu":
|
||||
if lb == "menu":
|
||||
mark("menu", t); state = "menuSettle"; menu_at = t
|
||||
elif state == "menuSettle":
|
||||
if t - ev[-1][1] > 6.0:
|
||||
fh.write(f"#ring_free_start\t{t:.3f}\n")
|
||||
state = "ringFree"; ring_from = t
|
||||
elif state == "ringFree":
|
||||
# 12 s of ring with NOTHING else running -- the outside-capture control
|
||||
if t - ring_from > 12.0:
|
||||
fh.write(f"#ring_free_end\t{t:.3f}\n")
|
||||
fh.flush()
|
||||
r, seen = arm_capture(log_path, seen)
|
||||
rates["menu"] = r
|
||||
mark(f"rate_menu={r and round(r['fps'], 3)}")
|
||||
fh.write(f"#ring_capture_end\t{time.time()-t0:.3f}\n")
|
||||
state = "ringAfter"; after_from = time.time() - t0
|
||||
elif state == "ringAfter":
|
||||
if t - after_from > 12.0:
|
||||
state = "done"
|
||||
|
||||
p.kill()
|
||||
dt = time.time() - t0
|
||||
fh.write(f"#summary\tframes={frames}\telapsed={dt:.1f}\tfps={frames/dt:.2f}\trequested={RATE}\n")
|
||||
for k, r in rates.items():
|
||||
if r:
|
||||
fh.write(f"#rate\t{k}\tframes={r['frames']}\tdraws={r['draws']}"
|
||||
f"\tseconds={r['seconds']:.3f}\tfps={r['fps']:.4f}\n")
|
||||
else:
|
||||
fh.write(f"#rate\t{k}\tFAILED\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 = {frames/dt:.2f} fps (requested {RATE})")
|
||||
for k, r in rates.items():
|
||||
print(f" presentation rate on {k}: "
|
||||
+ (f"{r['fps']:.4f} fps ({r['frames']} frames in {r['seconds']:.3f} s, "
|
||||
f"{r['draws']} draws)" if r else "FAILED"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--control":
|
||||
sys.exit(control())
|
||||
if len(sys.argv) > 4 and sys.argv[1] == "--run":
|
||||
sys.exit(run(float(sys.argv[2]), sys.argv[3], sys.argv[4],
|
||||
sys.argv[5] if len(sys.argv) > 5 else "/sylph-home/re/shots/boot-timeline"))
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
81
tools/re-capture/dump_image.py
Executable file
81
tools/re-capture/dump_image.py
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump the guest's DECOMPRESSED executable image out of live Xenia memory.
|
||||
|
||||
Why this exists: the static PPC route the corpus is built on ran against a
|
||||
disassembly database at `/work/xenia-rs/sylpheed.db`, and that file is **not in
|
||||
this container** — the same migration that took the Xenia storage root. Without
|
||||
it, every finding that cites a `sub_82xxxxxx` is unre-checkable.
|
||||
|
||||
`/disc/default.xex` cannot substitute: it is encrypted and LZX-compressed. Its
|
||||
header is intact (`XEX2`, original PE name `default.pe`) and everything after is
|
||||
noise — `strings` finds **zero** occurrences of `GamePart` in it.
|
||||
|
||||
Xenia decompresses, decrypts and relocates the image at load, so a running guest
|
||||
holds exactly the flat VA image the corpus calls the `.pe`. Dump it once and the
|
||||
static route works offline, with no emulator and no disc.
|
||||
|
||||
Validated on write, and both checks are the corpus's own, not this tool's:
|
||||
|
||||
* `0x820A1630` must hold the **GamePart id table** — 29 pointers into `.rdata`
|
||||
resolving to `GP_TITLE` … `GP_TEST`, with `GP_CHALLENGE` at id 26
|
||||
(docs/re/challenge-mission-gate.md);
|
||||
* the image must contain the Xbox 360 D3D runtime's own error strings, which a
|
||||
mis-based or partial dump does not.
|
||||
|
||||
dump_image.py [OUT.pe] # with Canary running
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
SD = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, SD)
|
||||
import gmem # noqa: E402
|
||||
|
||||
LO, HI = 0x82000000, 0x82400000
|
||||
BASE = LO
|
||||
|
||||
EXPECT = {0: "GP_TITLE", 3: "GP_LOAD", 11: "GP_READY_ROOM", 26: "GP_CHALLENGE", 28: "GP_TEST"}
|
||||
|
||||
|
||||
def validate(buf):
|
||||
def s_at(va):
|
||||
o = va - BASE
|
||||
e = buf.find(b"\0", o, o + 64)
|
||||
return buf[o:e].decode("ascii", "replace")
|
||||
|
||||
bad = []
|
||||
for i, want in EXPECT.items():
|
||||
p = struct.unpack_from(">I", buf, 0x820A1630 - BASE + 4 * i)[0]
|
||||
got = s_at(p) if LO <= p < HI else f"<ptr {p:#x} out of range>"
|
||||
if got != want:
|
||||
bad.append(f"GamePart id {i}: expected {want!r}, got {got!r}")
|
||||
if buf.count(b"ERR[D3D]") < 1:
|
||||
bad.append("no Xbox 360 D3D runtime strings — this is not the game image")
|
||||
return bad
|
||||
|
||||
|
||||
def main(out):
|
||||
path = gmem.mem_path()
|
||||
off = gmem.va_to_off(LO)
|
||||
with open(path, "rb") as f:
|
||||
f.seek(off)
|
||||
buf = f.read(HI - LO)
|
||||
if len(buf) < HI - LO:
|
||||
print(f"short read: {len(buf)} of {HI - LO}", file=sys.stderr)
|
||||
return 1
|
||||
bad = validate(buf)
|
||||
for b in bad:
|
||||
print("FAIL:", b, file=sys.stderr)
|
||||
if bad:
|
||||
return 2
|
||||
open(out, "wb").write(buf)
|
||||
pages = sum(1 for i in range(0, len(buf), 4096) if any(buf[i:i + 4096]))
|
||||
print(f"wrote {out} {len(buf)} bytes VA {LO:#x}..{HI:#x}")
|
||||
print(f"validated: GamePart id table + D3D runtime strings; "
|
||||
f"{pages}/{len(buf)//4096} non-empty 4K pages")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/sylpheed-image.pe"))
|
||||
@@ -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()
|
||||
214
tools/re-capture/kf_record_census.py
Executable file
214
tools/re-capture/kf_record_census.py
Executable file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Disc-wide test of the UI placement-group record layout.
|
||||
|
||||
A placement group in a screen bundle is an 8-byte header `{u32 element_index,
|
||||
u32 frame_count}` followed by `frame_count` records of 40 bytes, each
|
||||
`{u32 time; 36-byte pose}`. The time word therefore **precedes** the pose it
|
||||
belongs to.
|
||||
|
||||
Our parser's block window starts at the pose, so under the old reading a
|
||||
block's `+36` word was taken as *its own* time — off by one, which left the
|
||||
group's final pose (the end of every fade-out) untimed and made the group look
|
||||
4 bytes short.
|
||||
|
||||
This script tests the corrected reading against the whole disc, with controls:
|
||||
|
||||
A. monotonicity — prepending the lead-in word to the shifted time series must
|
||||
give a non-decreasing sequence, for every group.
|
||||
B. the non-zero lead-ins — a lead-in that is a *time* must be strictly less
|
||||
than the next time. Control: swap in another group's lead-in from the
|
||||
same bundle.
|
||||
C. ramp linearity — within a monotone alpha ramp of >=3 segments, is
|
||||
d(alpha)/d(time) constant? Keyframe interpolation is linear
|
||||
(`docs/re/ui-keyframe-time-unit.md`), so a correct time assignment should
|
||||
make multi-keyframe ramps come out at a constant rate far more often than
|
||||
an incorrect one.
|
||||
|
||||
Usage: python3 tools/re-capture/kf_record_census.py "$SYLPHEED_DISC"/dat/*.pak
|
||||
"""
|
||||
import collections
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
|
||||
def be32(b, o):
|
||||
return struct.unpack_from(">I", b, o)[0]
|
||||
|
||||
|
||||
def load_pak(pak_path):
|
||||
"""Entries of an IPFB archive, transparently un-Z1'd. See sylpheed-formats::pak."""
|
||||
idx = open(pak_path, "rb").read()
|
||||
if idx[:4] != b"IPFB":
|
||||
return []
|
||||
n = be32(idx, 4)
|
||||
data = b""
|
||||
base = os.path.splitext(pak_path)[0]
|
||||
for i in range(100):
|
||||
p = f"{base}.p{i:02d}"
|
||||
if not os.path.exists(p):
|
||||
break
|
||||
data += open(p, "rb").read()
|
||||
out = []
|
||||
for k in range(n):
|
||||
_h, off, sz = struct.unpack_from(">III", idx, 0x10 + 12 * k)
|
||||
blob = data[off : off + sz]
|
||||
if blob[:2] == b"Z1":
|
||||
try:
|
||||
blob = zlib.decompress(blob[0x0A:])
|
||||
except Exception:
|
||||
blob = b""
|
||||
out.append(blob)
|
||||
return out
|
||||
|
||||
|
||||
def elem_name(b, o):
|
||||
s = b[o : o + 28]
|
||||
z = s.find(b"\0")
|
||||
return (s[:z] if z >= 0 else s).decode("latin1")
|
||||
|
||||
|
||||
def groups(bundle):
|
||||
"""(element_index, name, frames, lead_in, W, alphas) per placement group.
|
||||
|
||||
`W[k]` is the word at the k-th 40-byte stride's `+36`; `W[-1]` is None
|
||||
because it lies outside the group. Under the corrected reading the pose
|
||||
times are `[lead_in] + W[:-1]`.
|
||||
"""
|
||||
n = be32(bundle, 0x14)
|
||||
names = [elem_name(bundle, 0x20 + i * 60) for i in range(n)]
|
||||
pos = 0x20 + n * 60
|
||||
out = []
|
||||
for _ in range(n):
|
||||
if pos + 8 > len(bundle):
|
||||
break
|
||||
idx, frames = be32(bundle, pos), be32(bundle, pos + 4)
|
||||
if idx >= n or frames == 0 or frames > 4096:
|
||||
break
|
||||
lead_in = be32(bundle, pos + 8)
|
||||
first = pos + 12
|
||||
end = first + frames * 40 - 4
|
||||
if end > len(bundle):
|
||||
break
|
||||
W, alphas = [], []
|
||||
for k in range(frames):
|
||||
blk = first + k * 40
|
||||
W.append(be32(bundle, blk + 36) if blk + 40 <= end else None)
|
||||
alphas.append(be32(bundle, blk) >> 24)
|
||||
out.append((idx, names[idx], frames, lead_in, W, alphas))
|
||||
pos = end
|
||||
return out
|
||||
|
||||
|
||||
def is_build(raw):
|
||||
if raw[:4] != b"RATC" or len(raw) < 0x20:
|
||||
return False
|
||||
n = be32(raw, 0x14)
|
||||
return 0 < n <= 4096 and 0x20 + n * 60 <= len(raw)
|
||||
|
||||
|
||||
def monotone_ramps(alphas, min_segments=3):
|
||||
out, i, n = [], 0, len(alphas)
|
||||
while i < n - 1:
|
||||
if alphas[i] == alphas[i + 1]:
|
||||
i += 1
|
||||
continue
|
||||
d = 1 if alphas[i + 1] > alphas[i] else -1
|
||||
j = i + 1
|
||||
while j < n - 1 and (alphas[j + 1] - alphas[j]) * d > 0:
|
||||
j += 1
|
||||
if j - i >= min_segments:
|
||||
out.append((i, j))
|
||||
i = j
|
||||
return out
|
||||
|
||||
|
||||
def constant_rate(times, alphas, a, b, tol=0.06):
|
||||
rates = []
|
||||
for k in range(a, b):
|
||||
dt = times[k + 1] - times[k]
|
||||
if dt <= 0:
|
||||
return None
|
||||
rates.append(abs(alphas[k + 1] - alphas[k]) / dt)
|
||||
mean = sum(rates) / len(rates)
|
||||
if mean == 0:
|
||||
return None
|
||||
return max(abs(r - mean) for r in rates) / mean <= tol
|
||||
|
||||
|
||||
def main(paks):
|
||||
rnd = random.Random(20260829)
|
||||
per_bundle = collections.defaultdict(list)
|
||||
total = mono = 0
|
||||
ramp_new = [0, 0]
|
||||
ramp_old = [0, 0]
|
||||
for p in paks:
|
||||
for ei, raw in enumerate(load_pak(p)):
|
||||
if not is_build(raw):
|
||||
continue
|
||||
try:
|
||||
gs = groups(raw)
|
||||
except Exception:
|
||||
continue
|
||||
for _idx, nm, _frames, lead_in, W, alphas in gs:
|
||||
rest = W[:-1]
|
||||
if not rest or any(w is None for w in rest):
|
||||
continue
|
||||
total += 1
|
||||
t_new = [lead_in] + rest
|
||||
if all(t_new[i] <= t_new[i + 1] for i in range(len(t_new) - 1)):
|
||||
mono += 1
|
||||
per_bundle[(os.path.basename(p), ei)].append((lead_in, rest, nm))
|
||||
for a, b in monotone_ramps(alphas):
|
||||
r = constant_rate(t_new, alphas, a, b)
|
||||
if r is not None:
|
||||
ramp_new[1] += 1
|
||||
ramp_new[0] += r
|
||||
# old reading: `+36` is the block's own time, last pose untimed
|
||||
a_old = alphas[:-1]
|
||||
for a, b in monotone_ramps(a_old):
|
||||
r = constant_rate(rest, a_old, a, b)
|
||||
if r is not None:
|
||||
ramp_old[1] += 1
|
||||
ramp_old[0] += r
|
||||
|
||||
nz = nz_ok = ctrl = ctrl_ok = 0
|
||||
gaps = collections.Counter()
|
||||
for rows in per_bundle.values():
|
||||
pool = [l for l, _, _ in rows]
|
||||
for lead_in, rest, _nm in rows:
|
||||
if lead_in == 0:
|
||||
continue
|
||||
nz += 1
|
||||
nz_ok += lead_in < rest[0]
|
||||
gaps[rest[0] - lead_in] += 1
|
||||
for _ in range(10):
|
||||
ctrl += 1
|
||||
ctrl_ok += rnd.choice(pool) < rest[0]
|
||||
|
||||
pct = lambda a, b: f"{100 * a / b:.3f}%" if b else "n/a"
|
||||
print(f"paks scanned : {len(paks)}")
|
||||
print(f"placement groups : {total}")
|
||||
print()
|
||||
print("A. lead-in prepended to the shifted times is non-decreasing")
|
||||
print(f" {mono}/{total} = {pct(mono, total)}")
|
||||
print()
|
||||
print("B. non-zero lead-in is strictly less than the next time")
|
||||
print(f" {nz_ok}/{nz} = {pct(nz_ok, nz)}")
|
||||
print(f" control (another group's lead-in, same bundle): "
|
||||
f"{ctrl_ok}/{ctrl} = {pct(ctrl_ok, ctrl)}")
|
||||
print(f" gap to the next time, most common: {gaps.most_common(8)}")
|
||||
print()
|
||||
print("C. constant d(alpha)/d(time) across a multi-segment ramp")
|
||||
print(f" corrected (time precedes pose): {ramp_new[0]}/{ramp_new[1]} = "
|
||||
f"{pct(ramp_new[0], ramp_new[1])}")
|
||||
print(f" old (+36 is own time) : {ramp_old[0]}/{ramp_old[1]} = "
|
||||
f"{pct(ramp_old[0], ramp_old[1])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
main(sys.argv[1:])
|
||||
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())
|
||||
181
tools/re-capture/present_rate_probe.py
Normal file
181
tools/re-capture/present_rate_probe.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Measure Canary's PRESENTATION RATE without perturbing it, and time the boot.
|
||||
|
||||
Why this exists: two pages of the corpus measure the same declared 120 keyframe
|
||||
units during a static hold and disagree by 2 % -- settle->plate 2.135 s (28.10
|
||||
fps implied) and one focus-ring revolution 2.177 s (27.56 implied). The port
|
||||
challenged it. Either the rate differed between the sessions, or one interval is
|
||||
not 120 units. Both were WALL-CLOCK, so nothing in either can tell them apart.
|
||||
|
||||
The obvious instrument is Canary's own `[UI-CAP]` frame counter, and it is the
|
||||
one the corpus used for "28.5 fps". 🔴 **It perturbs badly.** Measured here:
|
||||
armed on the title with a concurrent 8 fps grab, 300 frames took 16.567 s =
|
||||
**18.11 fps** against the ~28 the same screen gives without it. A frame counter
|
||||
that costs a third of the frame rate cannot measure the frame rate.
|
||||
|
||||
So: count DISTINCT FRAMES in an oversampled crop of something that moves every
|
||||
frame (the spinning focus ring). Sampling at 60 fps a source presenting at R,
|
||||
the fraction of consecutive samples that differ is R/60.
|
||||
|
||||
Its controls, all in one session and all required to believe a number:
|
||||
* a STATIC crop must read ~0 -- if it does not, the counter is seeing noise;
|
||||
* two sampling rates (45 and 60) must agree -- if the estimate tracks the
|
||||
sampler it is measuring the sampler;
|
||||
* and the decisive one: while `[UI-CAP]` runs, this counter and the emulator's
|
||||
own frame count must AGREE. Both are perturbed in that window, but agreeing
|
||||
there is what licenses using this counter alone outside it.
|
||||
|
||||
present_rate_probe.py --run SECONDS OUT.json CANARY_STDOUT
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
SD = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, SD)
|
||||
import title_timing_probe as T # noqa: E402
|
||||
import boot_timeline_probe as B # noqa: E402
|
||||
|
||||
# The button column, from ring_period.py: game coords (480,130)-(570,530), and
|
||||
# the game surface sits at +1,+45 in the root.
|
||||
RX, RY, RW, RH = 481, 175, 90, 400
|
||||
# A crop that must NOT move: the top-left of the menu's background.
|
||||
SX, SY, SW, SH = 60, 120, 90, 120
|
||||
|
||||
|
||||
def crop_stream(x, y, w, h, rate):
|
||||
return subprocess.Popen(
|
||||
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
|
||||
"-video_size", f"{w}x{h}", "-i", f"{T.DISPLAY}+{x},{y}", "-r", str(rate),
|
||||
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
||||
stdout=subprocess.PIPE, bufsize=w * h * 3 * 4)
|
||||
|
||||
|
||||
def count_distinct(x, y, w, h, rate, secs, thresh=0.02):
|
||||
"""Sample a crop and count how many frames differ from their predecessor.
|
||||
|
||||
Returns (samples, distinct, seconds, implied_fps, frames_list, times_list).
|
||||
`thresh` is a mean-abs-difference floor; a capture path with no noise makes
|
||||
an identical frame differ by exactly 0, so this only has to reject dither.
|
||||
"""
|
||||
p = crop_stream(x, y, w, h, rate)
|
||||
n = w * h * 3
|
||||
t0 = time.time()
|
||||
prev = None
|
||||
samples = distinct = 0
|
||||
prof, ts = [], []
|
||||
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)
|
||||
samples += 1
|
||||
if prev is not None and float(np.abs(g - prev).mean()) > thresh:
|
||||
distinct += 1
|
||||
prev = g
|
||||
prof.append(float(g.mean()))
|
||||
ts.append(time.time() - t0)
|
||||
p.kill()
|
||||
dt = time.time() - t0
|
||||
return dict(samples=samples, distinct=distinct, seconds=dt,
|
||||
sample_fps=samples / dt, implied_fps=distinct / dt), prof, ts
|
||||
|
||||
|
||||
def wait_for(pred, limit, rate=8):
|
||||
"""Classify a full-frame stream until `pred(label, glyph, mean)` or timeout."""
|
||||
p = T.open_stream()
|
||||
n = T.W * T.H * 3
|
||||
t0 = time.time()
|
||||
trail = []
|
||||
while time.time() - t0 < limit:
|
||||
buf = p.stdout.read(n)
|
||||
if len(buf) < n:
|
||||
p.kill(); p = T.open_stream(); continue
|
||||
rgb = np.frombuffer(buf, np.uint8).reshape(T.H, T.W, 3)
|
||||
g = T.gray_of(rgb)
|
||||
lb = T.label(T.scores(g))
|
||||
gl = T.glyph(rgb)
|
||||
mn = float(T.surface(g).mean())
|
||||
trail.append((round(time.time() - t0, 3), lb, gl, round(mn, 2)))
|
||||
if pred(lb, gl, mn):
|
||||
p.kill()
|
||||
return time.time() - t0, trail
|
||||
p.kill()
|
||||
return None, trail
|
||||
|
||||
|
||||
def main(limit, out_path, log_path):
|
||||
res = {}
|
||||
t0 = time.time()
|
||||
# --- reach the plate, then press A promptly: the title's idle window is short
|
||||
hit, trail = wait_for(lambda lb, gl, mn: lb in ("title_plate", "title_noplate")
|
||||
and gl >= T.PLATE_GLYPH, limit)
|
||||
res["trail_to_plate"] = trail[-40:]
|
||||
if hit is None:
|
||||
res["error"] = "never reached the plate"
|
||||
json.dump(res, open(out_path, "w"), indent=1)
|
||||
return 1
|
||||
res["plate_at"] = round(hit, 3)
|
||||
print(f"plate at {hit:.1f}s -> A", flush=True)
|
||||
T.tap("A")
|
||||
hit, trail = wait_for(lambda lb, gl, mn: lb == "menu", 120)
|
||||
if hit is None:
|
||||
res["error"] = "never reached the menu"
|
||||
res["trail_to_menu"] = trail[-40:]
|
||||
json.dump(res, open(out_path, "w"), indent=1)
|
||||
return 1
|
||||
print(f"menu at +{hit:.1f}s; settling", flush=True)
|
||||
time.sleep(8)
|
||||
|
||||
# --- CONTROL 1: a crop that must not move
|
||||
st, _, _ = count_distinct(SX, SY, SW, SH, 60, 6)
|
||||
res["control_static"] = st
|
||||
print(f"static control: {st['distinct']}/{st['samples']} distinct "
|
||||
f"({st['implied_fps']:.2f} implied)", flush=True)
|
||||
|
||||
# --- CONTROL 2: the same ring at two sampling rates
|
||||
for r in (45, 60):
|
||||
d, prof, ts = count_distinct(RX, RY, RW, RH, r, 12)
|
||||
res[f"ring_free_{r}"] = d
|
||||
res[f"ring_profile_{r}"] = [round(v, 4) for v in prof]
|
||||
res[f"ring_times_{r}"] = [round(v, 4) for v in ts]
|
||||
print(f"ring @{r}fps: {d['distinct']}/{d['samples']} -> "
|
||||
f"{d['implied_fps']:.2f} fps (sampled {d['sample_fps']:.1f})", flush=True)
|
||||
|
||||
# --- CONTROL 3 (the decisive one): agree with the game's own counter
|
||||
seen = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||
import threading
|
||||
box = {}
|
||||
|
||||
def _arm():
|
||||
box["uicap"], box["seen"] = B.arm_capture(log_path, seen, timeout=120)
|
||||
th = threading.Thread(target=_arm)
|
||||
th.start()
|
||||
d, prof, ts = count_distinct(RX, RY, RW, RH, 60, 30)
|
||||
th.join(timeout=60)
|
||||
res["ring_during_capture"] = d
|
||||
res["uicap"] = box.get("uicap")
|
||||
print(f"during UI-CAP: distinct-frame {d['implied_fps']:.2f} fps; "
|
||||
f"UI-CAP {box.get('uicap')}", flush=True)
|
||||
|
||||
# --- and back to unperturbed
|
||||
d, prof, ts = count_distinct(RX, RY, RW, RH, 60, 12)
|
||||
res["ring_after"] = d
|
||||
res["ring_profile_after"] = [round(v, 4) for v in prof]
|
||||
res["ring_times_after"] = [round(v, 4) for v in ts]
|
||||
print(f"after: {d['implied_fps']:.2f} fps", flush=True)
|
||||
|
||||
res["elapsed"] = round(time.time() - t0, 2)
|
||||
json.dump(res, open(out_path, "w"), indent=1)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 4 and sys.argv[1] == "--run":
|
||||
sys.exit(main(float(sys.argv[2]), sys.argv[3], sys.argv[4]))
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
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