Compare commits
114 Commits
auto/re-ke
...
auto/re-ra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26b5afeaa3 | ||
|
|
3491d30066 | ||
|
|
7eeae3006a | ||
|
|
d110cf38c7 | ||
|
|
b21c8e4118 | ||
|
|
9ca1eb50fd | ||
|
|
9a0ca0d71f | ||
|
|
f817dd5939 | ||
|
|
56cc7acfc3 | ||
|
|
49f109deb1 | ||
|
|
7a4e4333f4 | ||
|
|
2532c056be | ||
|
|
7988f52ce7 | ||
|
|
7156591654 | ||
|
|
2a086051b3 | ||
|
|
baed44a9ea | ||
|
|
7fb6bdfad8 | ||
|
|
1f2b469f7e | ||
|
|
458585d136 | ||
|
|
4012d5b555 | ||
|
|
515456c59a | ||
|
|
93e9b185ea | ||
|
|
0265da31a1 | ||
|
|
60285a6ead | ||
|
|
fb6376a548 | ||
|
|
0efd692b4c | ||
|
|
145046f88f | ||
|
|
6d9214fd07 | ||
|
|
714a26769d | ||
|
|
489ea12759 | ||
|
|
5f701db588 | ||
|
|
c463f98164 | ||
|
|
921a3cfc44 | ||
|
|
421c61a517 | ||
|
|
c6ce000002 | ||
|
|
93b0a6b20f | ||
|
|
3490fba9e3 | ||
|
|
aaaa08b164 | ||
|
|
7373035868 | ||
|
|
f7f9b555f6 | ||
|
|
ba47bdebe8 | ||
|
|
c3cf3c2e81 | ||
|
|
46006406a4 | ||
|
|
a3946e5005 | ||
|
|
e9924ff9e8 | ||
|
|
cc4e5e04a7 | ||
|
|
ee73de50be | ||
|
|
2b4ec20f00 | ||
|
|
7d07eb945f | ||
|
|
8783adda14 | ||
|
|
f10edf1e79 | ||
|
|
0f1c0f4e14 | ||
|
|
6d246c7a97 | ||
|
|
b973d8cde3 | ||
|
|
c2c69b02be | ||
|
|
b947fa8e8e | ||
|
|
6f23f4d113 | ||
|
|
531840b417 | ||
|
|
f11fde51e5 | ||
|
|
67fa1a1b0b | ||
|
|
130ad8d1b3 | ||
|
|
5115b4894a | ||
|
|
0ecb20bdbc | ||
|
|
6930d301c1 | ||
|
|
e10a6d6f2c | ||
|
|
5d02d5cd60 | ||
|
|
e0d02dce71 | ||
|
|
c10802536f | ||
|
|
2a0afc1405 | ||
|
|
e249b2e2ff | ||
|
|
cdb5a99def | ||
|
|
d217c07db2 | ||
|
|
5414db34bf | ||
|
|
4bc970688f | ||
|
|
ed4e5c7b37 | ||
|
|
1b95001feb | ||
|
|
c176fa53bc | ||
|
|
83293c3f81 | ||
|
|
3717293fa9 | ||
|
|
00ce0f33d0 | ||
|
|
a048910497 | ||
|
|
5f3d8f86a4 | ||
|
|
113c25aac1 | ||
|
|
9f39e8ee72 | ||
|
|
b302558d26 | ||
|
|
cc72f8b5a8 | ||
|
|
4830c63402 | ||
|
|
4380305921 | ||
|
|
86e52b01d0 | ||
|
|
a98e96b0f0 | ||
|
|
52275ddd53 | ||
|
|
6e92ae4597 | ||
|
|
c8c7677f6d | ||
|
|
8b4965f0a5 | ||
|
|
2be5ba7934 | ||
|
|
2b0e66b6ca | ||
|
|
b1b2576769 | ||
|
|
0d7199bc22 | ||
|
|
aea529e7dc | ||
|
|
dfcaac221e | ||
|
|
bc6354dce0 | ||
|
|
2c4005795b | ||
|
|
f5e7426cfa | ||
|
|
c760cdc509 | ||
|
|
c8e8dc0427 | ||
|
|
935f7ecab8 | ||
|
|
1f822f8b30 | ||
|
|
e81dcad632 | ||
|
|
e64965b3f9 | ||
|
|
b3c8632006 | ||
|
|
7fef19b3a0 | ||
|
|
4e745c8177 | ||
|
|
bebdb18a02 | ||
|
|
88b3ce9af5 |
@@ -523,7 +523,7 @@ fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) {
|
||||
println!();
|
||||
println!("geometry — decoded sprite size vs the declared pivot, and every keyframe");
|
||||
println!(
|
||||
"{:<3} {:<26} {:>11} {:>11} {:>5} keyframes t: x,y sx%,sy% a=fade-alpha",
|
||||
"{:<3} {:<26} {:>11} {:>11} {:>5} keyframes t: x,y sx%,sy% a=alpha r=rot°",
|
||||
"#", "sprite", "decoded", "pivot*2", "same"
|
||||
);
|
||||
for el in &b.elements {
|
||||
@@ -543,8 +543,13 @@ fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) {
|
||||
.keyframes
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let rot = if f.rotation_deg != 0 {
|
||||
format!(" r={}", f.rotation_deg)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"{}: {},{} {}%,{}% a={}",
|
||||
"{}: {},{} {}%,{}% a={}{rot}",
|
||||
f.time.map(|v| v.to_string()).unwrap_or_else(|| "-".into()),
|
||||
f.x,
|
||||
f.y,
|
||||
|
||||
67
crates/sylpheed-formats/examples/decl_word_probe.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! Does any unread declaration word POINT at the element's T8aD child?
|
||||
//!
|
||||
//! `pteff05.t32`/`pteff04.t32` resolve to no sprite because the `T8aD` they want
|
||||
//! is registered under the name `8AX`. Elimination says `8AX` is the one they
|
||||
//! mean -- one unresolved element, one unclaimed non-focus-state child, in 6 of
|
||||
//! 6 title-side builds. Elimination is not a pointer, so: the 60-byte
|
||||
//! declaration reads name[0..28], parent@32, kind@40, pivot@48/52. The words at
|
||||
//! +28, +36, +44 and +56 are unread. If one of them indexes the RATC child
|
||||
//! table, the RESOLVED elements are the control -- their child index is known,
|
||||
//! so a candidate field must reproduce it for them before it may be believed for
|
||||
//! the unresolved one.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example decl_word_probe -- <pak> [entry]
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
const OFFS: [usize; 4] = [28, 36, 44, 56];
|
||||
|
||||
fn be32(b: &[u8], o: usize) -> u32 {
|
||||
if o + 4 > b.len() { return 0; }
|
||||
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: decl_word_probe <pak> [entry]");
|
||||
let want: Option<usize> = std::env::args().nth(2).and_then(|s| s.parse().ok());
|
||||
let ar = pak::PakArchive::open(&path).expect("open pak");
|
||||
let entries: Vec<_> = ar.entries().to_vec();
|
||||
// Per candidate offset, across every build: control hits / control total.
|
||||
let mut hit = [0usize; 4];
|
||||
let mut tot = 0usize;
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
if want.is_some_and(|w| w != i) { continue; }
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(build) = ui_layout::parse_build(&bytes) else { continue };
|
||||
let Some(kids) = ratc::parse(&bytes) else { continue };
|
||||
// Index space to test against: the T8aD children, in child order.
|
||||
let t8: Vec<&ratc::RatcChild> = kids.iter().filter(|c| c.kind == "T8aD").collect();
|
||||
if build.elements.iter().all(|el| el.sprite.is_some() || el.kind & 0x10 != 0) {
|
||||
continue;
|
||||
}
|
||||
println!("== entry {i} ({} elements, {} T8aD children)", build.elements.len(), t8.len());
|
||||
for (n, c) in t8.iter().enumerate() { println!(" child[{n:2}] {}", c.name); }
|
||||
for el in &build.elements {
|
||||
if el.kind & 0x10 != 0 { continue; }
|
||||
let d = &bytes[0x20 + el.index * 60..0x20 + (el.index + 1) * 60];
|
||||
let words: Vec<u32> = OFFS.iter().map(|&o| be32(d, o)).collect();
|
||||
// The control: for a RESOLVED element, which T8aD child is it?
|
||||
let truth = el.sprite.as_ref()
|
||||
.and_then(|s| t8.iter().position(|c| &c.name == s));
|
||||
if let Some(t) = truth {
|
||||
tot += 1;
|
||||
for (k, w) in words.iter().enumerate() {
|
||||
if *w as usize == t { hit[k] += 1; }
|
||||
}
|
||||
}
|
||||
println!(
|
||||
" [{:2}] {:26} sprite={:?} child={:?} +28={} +36={} +44={} +56={}",
|
||||
el.index, el.name, el.sprite, truth,
|
||||
words[0] as i32, words[1] as i32, words[2] as i32, words[3] as i32
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("\nCONTROL: resolved elements whose child index a word reproduces, of {tot}:");
|
||||
for (k, o) in OFFS.iter().enumerate() {
|
||||
println!(" +{o:<3} {:3}/{tot}", hit[k]);
|
||||
}
|
||||
}
|
||||
51
crates/sylpheed-formats/examples/name_resolution.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Why does an element's sprite fail to resolve? Dump the two name spaces.
|
||||
//!
|
||||
//! `parse_build` resolves an element to a sprite by looking its DECLARED name up
|
||||
//! in (a) the `.rat` record table, then (b) the `T8aD` child table. `pteff05.t32`
|
||||
//! is in neither -- the `T8aD` it wants is registered as `8AX` -- so it resolves
|
||||
//! to None and `compose` drops it without recording it as missing. This prints
|
||||
//! both spaces, so the link between the two can be CHECKED rather than assumed.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example name_resolution -- <pak> <entry>
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: name_resolution <pak> [entry]");
|
||||
let want: Option<usize> = std::env::args().nth(2).and_then(|s| s.parse().ok());
|
||||
let ar = pak::PakArchive::open(&path).expect("open pak");
|
||||
let entries: Vec<_> = ar.entries().to_vec();
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
if want.is_some_and(|w| w != i) {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(build) = ui_layout::parse_build(&bytes) else { continue };
|
||||
let unresolved: Vec<&ui_layout::Element> = build
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|el| el.sprite.is_none() && el.kind & 0x10 == 0)
|
||||
.collect();
|
||||
let claimed: std::collections::HashSet<&str> =
|
||||
build.elements.iter().filter_map(|e| e.sprite.as_deref()).collect();
|
||||
let unclaimed: Vec<&String> =
|
||||
build.sprites.keys().filter(|k| !claimed.contains(k.as_str())).collect();
|
||||
if want.is_none() && unresolved.is_empty() && unclaimed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let un: Vec<&str> = unresolved.iter().map(|e| e.name.as_str()).collect();
|
||||
let uc: Vec<String> = unclaimed
|
||||
.iter()
|
||||
.map(|k| format!("{k}({} B)", build.sprites[*k].1))
|
||||
.collect();
|
||||
println!(
|
||||
"entry {i:3} {:2} elements UNRESOLVED {:?} UNCLAIMED {:?}",
|
||||
build.elements.len(), un, uc
|
||||
);
|
||||
if want.is_some() {
|
||||
for el in &build.elements {
|
||||
let mark = if el.sprite.is_none() && el.kind & 0x10 == 0 { " <-- UNRESOLVED" } else { "" };
|
||||
println!(" [{:2}] kind {:#06x} {:28} -> {:?}{mark}", el.index, el.kind, el.name, el.sprite);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
136
crates/sylpheed-formats/examples/paint_order_audit.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
//! Does the DERIVED paint order reproduce the ones measured from the game?
|
||||
//!
|
||||
//! `compose` uses a measured order for the three builds that have one and falls
|
||||
//! back to `derived_paint_order` (a sort on each sprite's layer key) everywhere
|
||||
//! else. The doc comment claims the derived order "reproduces both measured
|
||||
//! orders up to ties" — this checks that claim against all three, and says what
|
||||
//! the ties actually cost.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example paint_order_audit -- <GP_TITLE.pak>
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
|
||||
fn measured(names: &[&str]) -> Option<(&'static str, Vec<usize>)> {
|
||||
const TITLE: [&str; 24] = [
|
||||
"ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32",
|
||||
"ptlogo2.t32", "pteff01.t32", "ptlogo_tm.t32", "pteff00.prm", "ptbase2.t32",
|
||||
"pteff04.t32", "ptloop01.rat", "ptloop02.rat", "pteff02.prm",
|
||||
"ptlogo_back2eff1.t32", "ptlogo_back2eff2.t32", "ptlogo_back2eff3.t32",
|
||||
"ptlogo_back2eff4.t32", "ptlogo_back2eff5.t32", "ptlogo_back2.t32",
|
||||
"ptlogo_back2eff.t32", "ptcopyright.t32", "ptlogoall_eff.t32",
|
||||
"ptlogoall_eff2.t32",
|
||||
];
|
||||
const SPLASH: [&str; 7] = [
|
||||
"palogo_eff0.prm", "palogo_gamearts.t32", "palogo_gamearts_eff.t32",
|
||||
"palogo_seta.t32", "palogo_seta_eff.t32", "palogo_anima.t32",
|
||||
"palogo_anima_eff.t32",
|
||||
];
|
||||
const MENU: [&str; 16] = [
|
||||
"pteff00.prm", "ptbase.t32", "pteff05.t32", "ptloop01.rat",
|
||||
"ptloop02.rat", "pteff02.prm", "ptframe1.t32", "ptframe2.t32",
|
||||
"pteff10.t32", "pteff12.t32", "ptbtn01.rat", "ptbtn02.rat",
|
||||
"ptbtn03.rat", "ptbtn04.rat", "ptbtn05.rat", "ptmsg.t32",
|
||||
];
|
||||
if names == TITLE {
|
||||
return Some(("title", vec![9,11,12,10,13,6,20,19,14,15,18,16,17,0,2,4,7,1,3,5,22,23,21,8]));
|
||||
}
|
||||
if names == SPLASH { return Some(("splash", vec![0,2,4,6,1,3,5])); }
|
||||
if names == MENU {
|
||||
return Some(("main menu", vec![1,3,4,2,5,8,9,6,7,15,10,11,12,13,14,0]));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: paint_order_audit <pak>");
|
||||
let ar = pak::PakArchive::open(&path).expect("open pak");
|
||||
let mut checked = 0;
|
||||
let entries: Vec<_> = ar.entries().to_vec();
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(build) = ui_layout::parse_build(&bytes) else { continue };
|
||||
let names: Vec<&str> = build.elements.iter().map(|e| e.name.as_str()).collect();
|
||||
// Every build: how exposed is it to tie-breaking? A tie between
|
||||
// OVERLAPPING elements is where a derived order can go visibly wrong.
|
||||
let keys_all: Vec<u32> = build.elements.iter()
|
||||
.map(|e| ui_layout::sprite_layer_key(&build, &bytes, e).unwrap_or(u32::MAX))
|
||||
.collect();
|
||||
let mut tie_pairs = 0;
|
||||
for a in 0..keys_all.len() {
|
||||
for b in (a + 1)..keys_all.len() {
|
||||
if keys_all[a] == keys_all[b] && keys_all[a] != u32::MAX { tie_pairs += 1; }
|
||||
}
|
||||
}
|
||||
// Of the tied pairs, how many OVERLAP? Only those can paint visibly
|
||||
// differently under an arbitrary tie-break. Rect from the declared
|
||||
// pivot (= half the sprite for a .t32) at the resting placement.
|
||||
let rect = |e: &ui_layout::Element| -> Option<(i32,i32,i32,i32)> {
|
||||
let kf = e.rest()?;
|
||||
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
|
||||
if w == 0 || h == 0 { return None; }
|
||||
Some((kf.x, kf.y, w, h))
|
||||
};
|
||||
let mut tie_overlap = 0;
|
||||
for a in 0..keys_all.len() {
|
||||
for b in (a + 1)..keys_all.len() {
|
||||
if keys_all[a] != keys_all[b] || keys_all[a] == u32::MAX { continue; }
|
||||
let (Some(ra), Some(rb)) = (rect(&build.elements[a]), rect(&build.elements[b]))
|
||||
else { continue };
|
||||
let ox = (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0);
|
||||
let oy = (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1);
|
||||
if ox > 0 && oy > 0 { tie_overlap += 1; }
|
||||
}
|
||||
}
|
||||
let Some((label, want)) = measured(&names) else {
|
||||
println!("entry {i:2} (no measured order) {} elements, {tie_pairs} tied pairs, \
|
||||
{tie_overlap} of them OVERLAPPING", build.elements.len());
|
||||
// Name them: these are the only pairs whose order can show.
|
||||
for a in 0..keys_all.len() {
|
||||
for b in (a + 1)..keys_all.len() {
|
||||
if keys_all[a] != keys_all[b] || keys_all[a] == u32::MAX { continue; }
|
||||
let (Some(ra), Some(rb)) = (rect(&build.elements[a]), rect(&build.elements[b]))
|
||||
else { continue };
|
||||
let ox = (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0);
|
||||
let oy = (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1);
|
||||
if ox > 0 && oy > 0 {
|
||||
println!(" overlapping tie: [{a}] {} x [{b}] {} key {} rect {:?} / {:?} overlap {}x{}",
|
||||
build.elements[a].name, build.elements[b].name, keys_all[a], ra, rb, ox, oy);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
checked += 1;
|
||||
let got = ui_layout::derived_paint_order(&build, &bytes);
|
||||
let keys: Vec<u32> = build.elements.iter()
|
||||
.map(|e| ui_layout::sprite_layer_key(&build, &bytes, e).unwrap_or(u32::MAX))
|
||||
.collect();
|
||||
let exact = got == want;
|
||||
// How many adjacent pairs in the MEASURED order does derived get wrong,
|
||||
// and of those, how many are between elements sharing a layer key (a
|
||||
// tie the sort cannot resolve) versus a genuine key-order conflict?
|
||||
let pos_got: Vec<usize> = {
|
||||
let mut p = vec![0; got.len()];
|
||||
for (r, &e) in got.iter().enumerate() { p[e] = r; }
|
||||
p
|
||||
};
|
||||
let (mut inv, mut tied) = (0, 0);
|
||||
for a in 0..want.len() {
|
||||
for b in (a + 1)..want.len() {
|
||||
let (x, y) = (want[a], want[b]);
|
||||
if pos_got[x] > pos_got[y] {
|
||||
inv += 1;
|
||||
if keys[x] == keys[y] { tied += 1; }
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("entry {i:2} {label:10} {} elements", want.len());
|
||||
println!(" derived == measured : {}", if exact { "YES" } else { "NO" });
|
||||
println!(" inverted pairs : {inv} (of which same-layer-key ties: {tied})");
|
||||
if !exact {
|
||||
println!(" measured: {want:?}");
|
||||
println!(" derived : {got:?}");
|
||||
println!(" keys : {keys:?}");
|
||||
}
|
||||
}
|
||||
println!("\n{checked} build(s) with a measured order were checked");
|
||||
}
|
||||
52
crates/sylpheed-formats/examples/plateauless_suppression.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! Does DRAWING NOTHING beat guessing, for an element with no held pose?
|
||||
//!
|
||||
//! A keyframe group is entry → hold → exit, and the exit ends invisible (on the
|
||||
//! five port screens the final keyframe is invisible for 21/24, 8/16, 12/18, 2/3
|
||||
//! and 6/7 elements). So the screen "as seen" is the HOLD — which is why
|
||||
//! `rest_plateau` is the primary rule. An element with **no** plateau has no
|
||||
//! hold, and `rest()` currently falls back to guessing an endpoint of a movement.
|
||||
//!
|
||||
//! This renders each screen twice — as-is, and with every plateau-less element
|
||||
//! suppressed via `compose`'s `visible` mask — and correlates both against the
|
||||
//! live capture. If suppression wins, the fallback should draw nothing.
|
||||
//! Writes both composites as raw RGBA (`<out>/entryNN_{asis,suppressed}.raw`,
|
||||
//! 1280x720) so the correlation is done outside — this crate has no image
|
||||
//! decoder and the comparison is not worth a dependency.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let pak_path = std::env::args().nth(1).expect("usage: <GP_TITLE.pak> <outdir> <entry>...");
|
||||
let ar = pak::PakArchive::open(&pak_path).expect("open");
|
||||
let entries: Vec<_> = ar.entries().to_vec();
|
||||
let outdir = std::env::args().nth(2).expect("outdir");
|
||||
std::fs::create_dir_all(&outdir).ok();
|
||||
for spec in std::env::args().skip(3) {
|
||||
let idx: usize = spec.parse().unwrap();
|
||||
let bytes = ar.read(&entries[idx]).expect("read");
|
||||
let Some(build) = ui_layout::parse_build(&bytes) else { continue };
|
||||
// plateau-less = rest() had to guess: no two adjacent keyframes share a pose
|
||||
// Default: suppress plateau-less elements. With SUPPRESS_SUBSTR set,
|
||||
// suppress every element whose NAME contains it instead — used to test
|
||||
// the entry→hold→exit model's prediction that the splash glows are all
|
||||
// finished by the moment the logos are up.
|
||||
let by_name = std::env::var("SUPPRESS_SUBSTR").ok();
|
||||
let mask: Vec<bool> = build.elements.iter().map(|e| {
|
||||
if let Some(sub) = &by_name {
|
||||
return !e.name.to_lowercase().contains(sub.as_str());
|
||||
}
|
||||
let k = &e.keyframes;
|
||||
(0..k.len().saturating_sub(1)).any(|i| {
|
||||
k[i].fade == k[i+1].fade && k[i].scale_x == k[i+1].scale_x
|
||||
&& k[i].scale_y == k[i+1].scale_y && k[i].x == k[i+1].x && k[i].y == k[i+1].y
|
||||
})
|
||||
}).collect();
|
||||
let suppressed = mask.iter().filter(|m| !**m).count();
|
||||
let opts = ui_layout::ComposeOptions::default();
|
||||
let a = ui_layout::compose(&build, &bytes, opts, None);
|
||||
let b = ui_layout::compose(&build, &bytes, opts, Some(&mask));
|
||||
std::fs::write(format!("{outdir}/entry{idx:02}_asis.raw"), &a.rgba).unwrap();
|
||||
std::fs::write(format!("{outdir}/entry{idx:02}_suppressed.raw"), &b.rgba).unwrap();
|
||||
println!("entry {idx:2} {}x{} elements {:2} plateau-less suppressed {suppressed}",
|
||||
a.width, a.height, build.elements.len());
|
||||
}
|
||||
}
|
||||
71
crates/sylpheed-formats/examples/rat_leaf_placement.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Does a `.rat` leaf record decode with the same reader as a whole bundle?
|
||||
//!
|
||||
//! The port needs the position of `ptbtneff01.t32`, the focus ring, which is
|
||||
//! declared *inside* the nested `ptbtn0Nf.rat` leaf and is therefore invisible
|
||||
//! to anything that walks only a bundle's top-level elements.
|
||||
//!
|
||||
//! The leaf's first 32 bytes have the same shape as a bundle header --
|
||||
//! `"RATC"`, `0x3c` declaration-entry size at `+4`, element count at `+20`,
|
||||
//! design size at `+24`/`+28` -- so the hypothesis is that `parse_build` reads
|
||||
//! it unchanged. The control is the BASE record `ptbtn0N.rat`, whose single
|
||||
//! element's position is already known independently: the parent screen's
|
||||
//! `screen info` reports `ptbtn01.rat` resting at (542,162).
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example rat_leaf_placement -- <GP_TITLE.pak>
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: … <pak>");
|
||||
let ar = pak::PakArchive::open(&path).expect("open pak");
|
||||
let entries: Vec<_> = ar.entries().to_vec();
|
||||
|
||||
for (ei, e) in entries.iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(kids) = ratc::parse(&bytes) else { continue };
|
||||
// Only the title-family bundles carry ptbtn records.
|
||||
if !kids.iter().any(|c| c.name == "ptbtn01f.rat") {
|
||||
continue;
|
||||
}
|
||||
println!("=== pak entry {ei}");
|
||||
for c in &kids {
|
||||
if c.kind != "RATC" || !c.name.starts_with("ptbtn") {
|
||||
continue;
|
||||
}
|
||||
let leaf = &bytes[c.offset..c.offset + c.size];
|
||||
match ui_layout::parse_build(leaf) {
|
||||
None => println!(" {:16} …parse_build says no", c.name),
|
||||
Some(b) => {
|
||||
println!(
|
||||
" {:16} {}x{} {} element(s), fallback={}",
|
||||
c.name,
|
||||
b.design_w,
|
||||
b.design_h,
|
||||
b.elements.len(),
|
||||
b.from_fallback
|
||||
);
|
||||
for el in &b.elements {
|
||||
let r = el.rest();
|
||||
println!(
|
||||
" [{}] {:18} pivot ({:4},{:4}) rest ({:5},{:5}) kf {}",
|
||||
el.index,
|
||||
el.name,
|
||||
el.pivot_x,
|
||||
el.pivot_y,
|
||||
r.map(|k| k.x).unwrap_or(-1),
|
||||
r.map(|k| k.y).unwrap_or(-1),
|
||||
el.keyframes.len()
|
||||
);
|
||||
for (i, k) in el.keyframes.iter().enumerate() {
|
||||
println!(
|
||||
" kf{i} t={:?} pos=({},{}) scale={}%,{}% a={} rot={} tint={:#010x}",
|
||||
k.time, k.x, k.y, k.scale_x, k.scale_y,
|
||||
k.fade >> 24, k.rotation_deg, k.tint
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
78
crates/sylpheed-formats/examples/ratc_child_names.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! Is a RATC child's name the printable run before its magic, or the `opt ` block?
|
||||
//!
|
||||
//! `ratc::parse` names each child by scanning backwards for the ASCII run that
|
||||
//! precedes its magic. That is usually right, but it is a HEURISTIC, and the
|
||||
//! real format states the name explicitly: immediately before each child sits
|
||||
//!
|
||||
//! "opt " | BE32 length | name | NUL | 3 bytes | <child magic>
|
||||
//!
|
||||
//! -- the same `opt ` block `ui_layout::opt_link` already decodes for a button's
|
||||
//! focus link. When those 3 trailing bytes happen to be printable the heuristic
|
||||
//! reads THEM as the name: the title screens' full-resolution background comes
|
||||
//! out as `8AX` (bytes 38 41 58) instead of `pteff05.t32`, its element then
|
||||
//! resolves to no sprite, and `compose` drops the screen's background.
|
||||
//!
|
||||
//! This compares the two readings for every RATC child in the paks given.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example ratc_child_names -- <pak>...
|
||||
use sylpheed_formats::{pak, ratc};
|
||||
|
||||
/// The name stated by the `opt ` block that ends just before `at`.
|
||||
fn opt_name(buf: &[u8], at: usize) -> Option<String> {
|
||||
// The block is short; search back a bounded window for the tag.
|
||||
let lo = at.saturating_sub(128);
|
||||
let win = &buf[lo..at];
|
||||
let pos = lo + win.windows(4).rposition(|w| w == b"opt ")?;
|
||||
let len = u32::from_be_bytes(buf[pos + 4..pos + 8].try_into().ok()?) as usize;
|
||||
if len == 0 || len > 64 || pos + 8 + len > at {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&buf[pos + 8..pos + 8 + len]).to_string();
|
||||
// It must be THIS child's block: name, NUL, then a short run to the magic.
|
||||
if at - (pos + 8 + len) > 8 {
|
||||
return None;
|
||||
}
|
||||
(!s.is_empty() && s.chars().all(|c| c.is_ascii_graphic())).then_some(s)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut children = 0usize;
|
||||
let mut with_opt = 0usize;
|
||||
let mut agree = 0usize;
|
||||
let mut disagree: Vec<(String, usize, String, String)> = Vec::new();
|
||||
for path in std::env::args().skip(1) {
|
||||
let Ok(ar) = pak::PakArchive::open(&path) else { continue };
|
||||
let short = path.rsplit('/').next().unwrap_or(&path).to_string();
|
||||
let entries: Vec<_> = ar.entries().to_vec();
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(kids) = ratc::parse(&bytes) else { continue };
|
||||
for c in &kids {
|
||||
children += 1;
|
||||
let Some(o) = opt_name(&bytes, c.offset) else { continue };
|
||||
with_opt += 1;
|
||||
if o == c.name {
|
||||
agree += 1;
|
||||
} else {
|
||||
disagree.push((short.clone(), i, c.name.clone(), o));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("RATC children scanned : {children}");
|
||||
println!(" with an `opt ` block: {with_opt}");
|
||||
println!(" scanned name AGREES : {agree}");
|
||||
println!(" scanned name DIFFERS : {}", disagree.len());
|
||||
let mut by_pair: std::collections::BTreeMap<(String, String), usize> = Default::default();
|
||||
for (_, _, scanned, opt) in &disagree {
|
||||
*by_pair.entry((scanned.clone(), opt.clone())).or_default() += 1;
|
||||
}
|
||||
println!("\ndistinct disagreements (scanned -> opt), with counts:");
|
||||
for ((s, o), n) in &by_pair {
|
||||
println!(" {s:24} -> {o:24} x{n}");
|
||||
}
|
||||
println!("\nfirst 20 occurrences:");
|
||||
for (p, i, s, o) in disagree.iter().take(20) {
|
||||
println!(" {p} entry {i:4} {s:20} -> {o}");
|
||||
}
|
||||
}
|
||||
119
crates/sylpheed-formats/examples/ratc_optless_children.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
//! The 60 RATC children that carry no `opt ` block — do they lack it, or is our
|
||||
//! window too small?
|
||||
//!
|
||||
//! [`ratc::parse`] now prefers the name a child's own `opt ` block states, and
|
||||
//! falls back to the old backwards printable-run scan when there is no block
|
||||
//! within 128 bytes. That fallback fires for 60 of the disc's 18 002 children
|
||||
//! (0.3 %), and the reach of the finding in `docs/re/structures/ratc-child-names.md`
|
||||
//! stops there: "whether they genuinely lack the block or sit past the search
|
||||
//! window is not established".
|
||||
//!
|
||||
//! This settles that. For every child with no accepted block it reports
|
||||
//!
|
||||
//! * whether an `opt ` tag exists at all further back, and how far;
|
||||
//! * which guard rejected a tag that WAS in the window (length, gap, charset);
|
||||
//! * the child's position in its bundle and its magic, in case the opt-less
|
||||
//! ones are structurally distinct (e.g. always the first child);
|
||||
//! * the raw bytes before the magic, so the fallback's answer can be judged.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example ratc_optless_children -- <pak>...
|
||||
use sylpheed_formats::{pak, ratc};
|
||||
|
||||
/// Why a child has no accepted `opt ` name. Mirrors `ratc::opt_name`'s guards
|
||||
/// one for one, so a rejection here is the same rejection the parser made.
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
enum Why {
|
||||
/// No `opt ` tag in the 128-byte window, and none anywhere before it either.
|
||||
NoTagAtAll,
|
||||
/// No tag in the window, but one exists further back, this many bytes away.
|
||||
TagBeyondWindow(usize),
|
||||
/// Tag found, but its BE32 length is 0 or > 64.
|
||||
BadLength(usize),
|
||||
/// Tag found, name ends more than 8 bytes before the magic — a neighbour's.
|
||||
GapTooBig(usize),
|
||||
/// Tag found, the named bytes are not all printable ASCII.
|
||||
NotGraphic,
|
||||
}
|
||||
|
||||
/// The parser's own window.
|
||||
const WINDOW: usize = 128;
|
||||
|
||||
fn classify(buf: &[u8], at: usize) -> Option<Why> {
|
||||
let lo = at.saturating_sub(WINDOW);
|
||||
let pos = match buf[lo..at].windows(4).rposition(|w| w == b"opt ") {
|
||||
Some(p) => lo + p,
|
||||
None => {
|
||||
// Widen to the whole buffer before the child: is it merely far away?
|
||||
return Some(match buf[..at].windows(4).rposition(|w| w == b"opt ") {
|
||||
Some(p) => Why::TagBeyondWindow(at - p),
|
||||
None => Why::NoTagAtAll,
|
||||
});
|
||||
}
|
||||
};
|
||||
let len = u32::from_be_bytes(buf.get(pos + 4..pos + 8)?.try_into().ok()?) as usize;
|
||||
if len == 0 || len > 64 || pos + 8 + len > at {
|
||||
return Some(Why::BadLength(len));
|
||||
}
|
||||
let gap = at - (pos + 8 + len);
|
||||
if gap > 8 {
|
||||
return Some(Why::GapTooBig(gap));
|
||||
}
|
||||
let s = String::from_utf8_lossy(&buf[pos + 8..pos + 8 + len]);
|
||||
if s.is_empty() || !s.chars().all(|c| c.is_ascii_graphic()) {
|
||||
return Some(Why::NotGraphic);
|
||||
}
|
||||
None // accepted — this child is not one of the 60
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut total = 0usize;
|
||||
let mut rows: Vec<(String, usize, usize, String, String, Why)> = Vec::new();
|
||||
let mut first_child_of_bundle = 0usize;
|
||||
for path in std::env::args().skip(1) {
|
||||
let Ok(ar) = pak::PakArchive::open(&path) else { continue };
|
||||
let short = path.rsplit('/').next().unwrap_or(&path).to_string();
|
||||
let entries: Vec<_> = ar.entries().to_vec();
|
||||
for (ei, e) in entries.iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(kids) = ratc::parse(&bytes) else { continue };
|
||||
for (ci, c) in kids.iter().enumerate() {
|
||||
total += 1;
|
||||
let Some(why) = classify(&bytes, c.offset) else { continue };
|
||||
if ci == 0 {
|
||||
first_child_of_bundle += 1;
|
||||
}
|
||||
let lo = c.offset.saturating_sub(24);
|
||||
let hex = bytes[lo..c.offset]
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
rows.push((short.clone(), ei, ci, c.name.clone(), hex, why));
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("RATC children scanned : {total}");
|
||||
println!(" with NO accepted `opt ` block: {}", rows.len());
|
||||
println!(" ... of which are child #0 : {first_child_of_bundle}");
|
||||
|
||||
let mut by_why: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
for r in &rows {
|
||||
let k = match &r.5 {
|
||||
Why::TagBeyondWindow(_) => "TagBeyondWindow".to_string(),
|
||||
Why::BadLength(_) => "BadLength".to_string(),
|
||||
Why::GapTooBig(_) => "GapTooBig".to_string(),
|
||||
other => format!("{other:?}"),
|
||||
};
|
||||
*by_why.entry(k).or_default() += 1;
|
||||
}
|
||||
println!("\nwhy, by cause:");
|
||||
for (k, n) in &by_why {
|
||||
println!(" {k:20} x{n}");
|
||||
}
|
||||
|
||||
println!("\nevery occurrence (name is what the FALLBACK scan returned):");
|
||||
for (p, ei, ci, name, hex, why) in &rows {
|
||||
println!(" {p:28} entry {ei:4} child {ci:3} {name:24} {why:?}");
|
||||
println!(" 24 bytes before the magic: {hex}");
|
||||
}
|
||||
}
|
||||
27
crates/sylpheed-formats/examples/se_wave_dump.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Dump the menu's three SE cues as decodable `RIFF`s, to prove `se_wave_riff`
|
||||
//! produces something ffmpeg actually accepts.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example se_wave_dump -- <outdir>
|
||||
//! ffmpeg -i <outdir>/move.riff move.wav
|
||||
use sylpheed_formats::media::{self, DirectorySource};
|
||||
|
||||
fn main() {
|
||||
let out = std::env::args().nth(1).unwrap_or_else(|| "/tmp".into());
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(&disc);
|
||||
for (name, off, pkts) in [("move", 0x1ec0usize, 4usize), ("back", 0x0ec0, 2), ("confirm", 0x5d6c0, 6)] {
|
||||
match media::se_wave_riff(&src, "Static.slb", off, pkts, 1, 48000) {
|
||||
Ok(riff) => {
|
||||
let p = format!("{out}/{name}.riff");
|
||||
std::fs::write(&p, &riff).unwrap();
|
||||
println!("{p}: {} bytes ({pkts} packets at {off:#x})", riff.len());
|
||||
}
|
||||
Err(e) => println!("{name}: ERROR {e}"),
|
||||
}
|
||||
}
|
||||
// The refusal path: a packet count the bank cannot satisfy.
|
||||
match media::se_wave_riff(&src, "Static.slb", 0x1ec0, 1 << 20, 1, 48000) {
|
||||
Ok(_) => println!("REFUSAL PATH FAILED — returned a short stream"),
|
||||
Err(e) => println!("refusal path ok: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,62 @@ pub fn sound_bank_riffs<S: DiscSource + ?Sized>(
|
||||
Ok(riffs_of(&bytes))
|
||||
}
|
||||
|
||||
/// One sound-effect wave out of a **delimiter-less** bank, as a decodable `RIFF`.
|
||||
///
|
||||
/// `Static.slb` — where the menu's cues live — has no `RIFF`, no `seek` chunk and
|
||||
/// no XACT container: it is a packed run of whole 2048-byte XMA1 packets. So
|
||||
/// [`sound_bank_riffs`] finds nothing to split on, and a wave is defined *only*
|
||||
/// by `(offset, packet_count)`. Both come from the running game, not from the
|
||||
/// file: launch Canary with `--xma_param_probe=true`, trigger the sound, and the
|
||||
/// log prints the stream's packet count and first 32 bytes; searching those bytes
|
||||
/// in the bank gives the offset. ⚠️ The file order is **not** cue-id order, so the
|
||||
/// index cannot be counted out — see `docs/re/menu-audio-cues.md`.
|
||||
///
|
||||
/// The three cues a menu needs, all mono 48 kHz:
|
||||
///
|
||||
/// | event | offset | packets |
|
||||
/// |---|---|---|
|
||||
/// | d-pad move | `0x1ec0` | 4 |
|
||||
/// | Ⓑ back | `0x0ec0` | 2 |
|
||||
/// | Ⓐ confirm | `0x5d6c0` | 6 |
|
||||
///
|
||||
/// Returns an error rather than a short stream if the bank does not actually
|
||||
/// hold `packet_count` whole packets at `offset` — a truncated XMA stream decodes
|
||||
/// to plausible-sounding garbage, which is the failure worth refusing.
|
||||
pub fn se_wave_riff<S: DiscSource + ?Sized>(
|
||||
source: &S,
|
||||
bank: &str,
|
||||
offset: usize,
|
||||
packet_count: usize,
|
||||
channels: u8,
|
||||
rate: u32,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let len = packet_count * crate::slb::XMA1_PACKET;
|
||||
// Read only the packets asked for, not the whole bank. That is not just an
|
||||
// efficiency point: `Static.slb` is 8.97 MB and is the ONE entry in
|
||||
// `sound.pak` whose declared extent runs past the end of the extracted
|
||||
// segments (by 616 768 B), so reading it whole fails outright on a disc
|
||||
// extraction that is short at the tail. Every cue we need sits in the first
|
||||
// few hundred KB. See `docs/re/menu-audio-cues.md`.
|
||||
let toc = source.read_file("dat/sound.pak")?;
|
||||
let entries = PakArchive::parse_toc(&toc).map_err(|e| e.to_string())?;
|
||||
let hash = crate::hash::name_hash(bank);
|
||||
let idx = entries
|
||||
.binary_search_by_key(&hash, |e| e.name_hash)
|
||||
.map_err(|_| format!("{bank}: not present in sound.pak"))?;
|
||||
let e = &entries[idx];
|
||||
if offset + len > e.comp_size as usize {
|
||||
return Err(format!(
|
||||
"{bank}: {packet_count} packets at {offset:#x} need {len} bytes, \
|
||||
but the bank declares only {} bytes",
|
||||
e.comp_size
|
||||
));
|
||||
}
|
||||
let packets =
|
||||
source.read_segment_range("dat/sound", e.offset as u64 + offset as u64, len)?;
|
||||
Ok(crate::slb::xma1_wave_riff(&packets, channels, rate))
|
||||
}
|
||||
|
||||
/// The XMA `RIFF`s of a continuous byte region of the voice stream, as returned
|
||||
/// by [`resolve_movie_voice_region`].
|
||||
pub fn voice_region_riffs<S: DiscSource + ?Sized>(
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn parse(bytes: &[u8]) -> Option<Vec<RatcChild>> {
|
||||
for (idx, &(off, kind)) in offs.iter().enumerate() {
|
||||
let next = offs.get(idx + 1).map(|&(o, _)| o).unwrap_or(bytes.len());
|
||||
children.push(RatcChild {
|
||||
name: name_before(bytes, off),
|
||||
name: opt_name(bytes, off).unwrap_or_else(|| name_before(bytes, off)),
|
||||
kind: kind.to_string(),
|
||||
offset: off,
|
||||
size: next.saturating_sub(off),
|
||||
@@ -71,9 +71,51 @@ pub fn parse(bytes: &[u8]) -> Option<Vec<RatcChild>> {
|
||||
Some(children)
|
||||
}
|
||||
|
||||
/// The name a child's own `opt ` block states, if it has one.
|
||||
///
|
||||
/// The real format is explicit. Immediately before each child sits
|
||||
///
|
||||
/// ```text
|
||||
/// "opt " | BE32 length | name | NUL | 3 bytes | <child magic>
|
||||
/// ```
|
||||
///
|
||||
/// -- the same `opt ` block `ui_layout`'s focus link already reads. Prefer it,
|
||||
/// because [`name_before`] is a heuristic and those 3 trailing bytes are
|
||||
/// sometimes printable, in which case the heuristic reads THEM as the name.
|
||||
/// Measured disc-wide: of 18 002 RATC children, 17 942 carry an `opt ` block,
|
||||
/// 17 918 of which agree with the scan and **24 do not** -- every one of the 24
|
||||
/// a 3-byte tail (`8AX` x22, `'OX` x2) beating a real name. On the title screens
|
||||
/// that cost the whole background: `pteff05.t32` came out as `8AX`, its element
|
||||
/// then resolved to no sprite, and `compose` silently dropped it. See
|
||||
/// `docs/re/structures/ratc-child-names.md`.
|
||||
fn opt_name(bytes: &[u8], off: usize) -> Option<String> {
|
||||
let lo = off.saturating_sub(128);
|
||||
let win = &bytes[lo..off];
|
||||
let pos = lo + win.windows(4).rposition(|w| w == b"opt ")?;
|
||||
let len = u32::from_be_bytes(bytes.get(pos + 4..pos + 8)?.try_into().ok()?) as usize;
|
||||
if len == 0 || len > 64 || pos + 8 + len > off {
|
||||
return None;
|
||||
}
|
||||
// It must be THIS child's block: the name, its NUL and a short run to the
|
||||
// magic. Anything further away is a neighbour's block, so fall back.
|
||||
if off - (pos + 8 + len) > 8 {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&bytes[pos + 8..pos + 8 + len]).to_string();
|
||||
(!s.is_empty() && s.chars().all(|c| c.is_ascii_graphic())).then_some(s)
|
||||
}
|
||||
|
||||
/// The nearest name string preceding `off`: the *last* printable run (len ≥ 3)
|
||||
/// in the 96 bytes before the child magic. A few record-header bytes usually sit
|
||||
/// between the name and the magic, so an exact-adjacency scan isn't enough.
|
||||
///
|
||||
/// Fallback only -- [`opt_name`] is the stated name. 60 of the disc's 18 002
|
||||
/// children have no `opt ` block and still rely on this, and all 60 are
|
||||
/// accounted for: they are the ten frames of the disc's only `.tan` frame
|
||||
/// sequence, in six language copies of one `GP_READY_ROOM` bundle, where a
|
||||
/// single `opt ` block names the whole run. See
|
||||
/// `docs/re/structures/ratc-tan-frame-sequence.md` -- and note that this means
|
||||
/// `parse` OVER-reports there, listing frames as children.
|
||||
fn name_before(bytes: &[u8], off: usize) -> String {
|
||||
let start = off.saturating_sub(96);
|
||||
let window = &bytes[start..off];
|
||||
@@ -101,6 +143,49 @@ fn name_before(bytes: &[u8], off: usize) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `opt ` block wins over a printable tail.
|
||||
///
|
||||
/// This is the `pteff05.t32` / `8AX` case, byte for byte: the name is stated
|
||||
/// with an explicit length, then a NUL, then three payload bytes that happen
|
||||
/// to spell `8AX` in ASCII. The old backwards printable-run scan returned
|
||||
/// `8AX` here, which is what dropped the background from every menu screen.
|
||||
#[test]
|
||||
fn opt_block_beats_a_printable_tail() {
|
||||
let mut b = RATC_MAGIC.to_vec();
|
||||
b.extend_from_slice(&[0u8; 28]);
|
||||
b.extend_from_slice(b"opt ");
|
||||
b.extend_from_slice(&11u32.to_be_bytes()); // len("pteff05.t32")
|
||||
b.extend_from_slice(b"pteff05.t32\0");
|
||||
b.extend_from_slice(b"8AX"); // payload, printable by accident
|
||||
let off = b.len();
|
||||
b.extend_from_slice(b"T8aD");
|
||||
b.extend_from_slice(&[0u8; 16]);
|
||||
|
||||
let kids = parse(&b).expect("parse");
|
||||
assert_eq!(kids.len(), 1);
|
||||
assert_eq!(kids[0].name, "pteff05.t32");
|
||||
assert_eq!(kids[0].offset, off);
|
||||
// And the heuristic on its own really would have said `8AX` -- so this
|
||||
// test fails for the right reason if the preference is ever reversed.
|
||||
assert_eq!(name_before(&b, off), "8AX");
|
||||
}
|
||||
|
||||
/// No `opt ` block: 60 of the disc's 18 002 children are like this, and they
|
||||
/// must keep working off the scan.
|
||||
#[test]
|
||||
fn falls_back_to_the_scan_without_an_opt_block() {
|
||||
let mut b = RATC_MAGIC.to_vec();
|
||||
b.extend_from_slice(&[0u8; 28]);
|
||||
b.extend_from_slice(b"plain.t32");
|
||||
b.extend_from_slice(&[0x0e, 0x10, 0xa4]);
|
||||
let off = b.len();
|
||||
b.extend_from_slice(b"T8aD");
|
||||
b.extend_from_slice(&[0u8; 16]);
|
||||
|
||||
let kids = parse(&b).expect("parse");
|
||||
assert_eq!(kids[0].name, "plain.t32");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_named_children() {
|
||||
let mut b = RATC_MAGIC.to_vec();
|
||||
|
||||
@@ -535,6 +535,28 @@ pub fn to_xma_riff_best(slb: &[u8]) -> Option<Vec<u8>> {
|
||||
(!data.is_empty()).then(|| build_riff(&synth_xma1_fmt(2, 2, 48000), data))
|
||||
}
|
||||
|
||||
/// Wrap a run of **raw XMA1 packets** as a standalone, decodable `RIFF/WAVE`.
|
||||
///
|
||||
/// For a bank with no internal delimiters — `Static.slb` is a packed run of whole
|
||||
/// 2048-byte packets with no `RIFF`, no `seek` and no `WAVE` — a wave is defined
|
||||
/// *only* by `(offset, packet count)`, both of which come from the running game
|
||||
/// (`--xma_param_probe`). There is nothing in the file to parse, so the header
|
||||
/// has to be synthesized, and that is the step worth doing exactly once, here,
|
||||
/// rather than in each consumer.
|
||||
///
|
||||
/// `packets` must be a whole number of [`XMA1_PACKET`] bytes; anything else is a
|
||||
/// short read and produces a stream the decoder will run off the end of.
|
||||
/// The `channel_mask` follows the same convention as the rest of this module:
|
||||
/// `1` for mono, `2` for stereo.
|
||||
///
|
||||
/// The three menu cues in `docs/re/menu-audio-cues.md` are
|
||||
/// `(0x1ec0, 4)` d-pad move, `(0x0ec0, 2)` Ⓑ back and `(0x5d6c0, 6)` Ⓐ confirm,
|
||||
/// all mono 48 kHz.
|
||||
pub fn xma1_wave_riff(packets: &[u8], channels: u8, rate: u32) -> Vec<u8> {
|
||||
let mask = if channels == 1 { 1 } else { 2 };
|
||||
build_riff(&synth_xma1_fmt(channels, mask, rate), packets)
|
||||
}
|
||||
|
||||
/// A minimal `fmt ` chunk carrying an XMA1 `XMAWAVEFORMAT` (one stream).
|
||||
fn synth_xma1_fmt(channels: u8, channel_mask: u16, rate: u32) -> Vec<u8> {
|
||||
let mut fmt = Vec::with_capacity(40);
|
||||
|
||||
@@ -38,6 +38,18 @@ pub struct T8adImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
/// The header word at `+0x04`. A flag word; **bit `0x02`** is a candidate
|
||||
/// blend selector — it separates the title's effect sprites from its ordinary
|
||||
/// ones exactly, and disc-wide it toggles independently of the rest of the
|
||||
/// word in 27.1 % of 19 216 sprites. See
|
||||
/// `docs/re/structures/ui-paint-order-key.md`.
|
||||
///
|
||||
/// ⚠️ **Additive was tested and REFUTED.** Blending bit-`0x02` sprites
|
||||
/// additively moved every measure against the title capture the wrong way:
|
||||
/// whole-frame mean diff +0.55 → +1.04, swoosh-band mean +1.83 → +3.98, band
|
||||
/// edge-correlation 0.6971 → 0.5578. The bit is real and independent, but it
|
||||
/// does not select an additive blend. Carried, not acted on.
|
||||
pub flags: u32,
|
||||
}
|
||||
|
||||
/// Whether `bytes` starts with the T8aD magic.
|
||||
@@ -108,6 +120,7 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
|
||||
}
|
||||
}
|
||||
Some(T8adImage {
|
||||
flags: be32(bytes, 4),
|
||||
width: width as u32,
|
||||
height: height as u32,
|
||||
rgba,
|
||||
|
||||
@@ -59,9 +59,9 @@ const DESIGN_H: u32 = 720;
|
||||
///
|
||||
/// ```text
|
||||
/// +0 u32 ARGB fade colour — alpha ramps 0x00 → 0x80 → 0xd5 … over the group
|
||||
/// +4 u32 0
|
||||
/// +8 u32 0
|
||||
/// +12 u32 0
|
||||
/// +4 i32 ⚠️ NOT always 0 — see below
|
||||
/// +8 i32 ⚠️ NOT always 0 — see below
|
||||
/// +12 i32 ⚠️ NOT always 0 — see below
|
||||
/// +16 u32 scale X, percent
|
||||
/// +20 u32 scale Y, percent
|
||||
/// +24 u32 tint (0xffff_ffff on every frame seen)
|
||||
@@ -69,10 +69,39 @@ const DESIGN_H: u32 = 720;
|
||||
/// +32 i32 Y ← signed
|
||||
/// +36 u32 time
|
||||
/// ```
|
||||
///
|
||||
/// ## `+12` is the screen-plane ROTATION, in degrees (2026-08-28)
|
||||
///
|
||||
/// ✅ **Measured against the framebuffer, not against our own renderer.** The
|
||||
/// title's two light sweeps are the nested leaf records `ptloop01.rat` /
|
||||
/// `ptloop02.rat`, and their keyframe blocks read `+12` = `30` and `-45`. A
|
||||
/// `log_ui_draws` capture of the live title submits those two quads rotated by
|
||||
/// **+30.26°** and **-45.28°** — magnitude *and* sign, on two different values.
|
||||
/// Positive is clockwise in screen space (Y down).
|
||||
///
|
||||
/// `+4` and `+8` are 🟡 still unexplained: signed, non-zero in ~4.7 % / 4.6 %
|
||||
/// of blocks disc-wide, dominated by `±180` and `±90`. Plausibly rotation about
|
||||
/// the other two axes, but nothing observed turns on them.
|
||||
///
|
||||
/// ⚠️ **`rotation_deg` is decoded but NOT rendered.** [`crate::ui_layout`]'s
|
||||
/// blitter draws axis-aligned quads only, so `screen render` still paints a
|
||||
/// rotated element upright. See `docs/re/ui-title-build-map.md`.
|
||||
///
|
||||
/// ⚠️ The earlier note here — *"every element of `GP_TITLE` build 4 has all
|
||||
/// three at zero"* — was **wrong about reach, not about the bytes**: build 4's
|
||||
/// top-level elements do read zero, but the rotated quads come from its two
|
||||
/// **nested** `.rat` leaf records, which the census never opened.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Keyframe {
|
||||
/// The fade colour, ARGB. Its alpha is what ramps an element in.
|
||||
pub fade: u32,
|
||||
/// Screen-plane rotation in **degrees**, clockwise-positive (`+12`).
|
||||
/// Confirmed against a GPU capture; see the type's docs. Not rendered.
|
||||
pub rotation_deg: i32,
|
||||
/// `+4` / `+8` — signed, meaning unexplained. Carried rather than dropped
|
||||
/// so a consumer can see them instead of assuming they are zero.
|
||||
pub unknown_4: i32,
|
||||
pub unknown_8: i32,
|
||||
/// Scale in percent (100 = 1:1).
|
||||
pub scale_x: u32,
|
||||
pub scale_y: u32,
|
||||
@@ -154,6 +183,15 @@ impl Element {
|
||||
/// Falls back to the longest-dwell rule when no two adjacent keyframes
|
||||
/// agree — a group that ramps through every frame and never holds.
|
||||
pub fn rest(&self) -> Option<&Keyframe> {
|
||||
// `lastall`: the LAST keyframe for every element, bypassing the plateau
|
||||
// rule entirely. This is what the shifted time reading predicts — under
|
||||
// it the final pose is reached at a definite time and nothing follows,
|
||||
// so "rest" needs no heuristic. Testing it against the captures is an
|
||||
// independent check on that reading, from static composites rather than
|
||||
// from animation timing.
|
||||
if std::env::var("SYLPHEED_REST_RULE").as_deref() == Ok("lastall") {
|
||||
return self.keyframes.last();
|
||||
}
|
||||
if let Some(k) = self.rest_plateau() {
|
||||
return Some(k);
|
||||
}
|
||||
@@ -161,6 +199,26 @@ impl Element {
|
||||
0 => None,
|
||||
1 => self.keyframes.first(),
|
||||
n => {
|
||||
// ⚠️ EXPERIMENT GATE, default off. Both published alternatives to
|
||||
// the longest-dwell fallback died by argument rather than by
|
||||
// measurement, and `compose` can score a rule against the live
|
||||
// captures — so they are reachable here to be tested.
|
||||
// SYLPHEED_REST_RULE=last -> the final keyframe
|
||||
// SYLPHEED_REST_RULE=maxalpha -> the most opaque keyframe
|
||||
match std::env::var("SYLPHEED_REST_RULE").as_deref() {
|
||||
Ok("last") => return self.keyframes.last(),
|
||||
Ok("maxalpha") => {
|
||||
let mut best = (0usize, 0u32);
|
||||
for (k, f) in self.keyframes.iter().enumerate() {
|
||||
let a = (f.fade >> 24) & 0xff;
|
||||
if a >= best.1 {
|
||||
best = (k, a);
|
||||
}
|
||||
}
|
||||
return self.keyframes.get(best.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut best = (0usize, 0u32);
|
||||
for k in 0..n - 1 {
|
||||
let (Some(t0), Some(t1)) =
|
||||
@@ -227,7 +285,24 @@ impl Element {
|
||||
any = Some(i);
|
||||
any_len = len;
|
||||
}
|
||||
if j != n - 1 && len >= best_len {
|
||||
// A trailing run is normally the EXIT and is excluded (see the
|
||||
// doc comment) — but not always, and the tell is its ALPHA.
|
||||
//
|
||||
// An exit fades the element out, so its final keyframe is
|
||||
// transparent: `pgptitle.rat`'s trailing run is `0x00ffffff`, and
|
||||
// taking it erases the word PAUSE. An element with **no exit**
|
||||
// ends on its hold, which is whatever it looks like on screen —
|
||||
// opaque: `ptframe1`/`ptframe2` on the title main menu end on a
|
||||
// run of three at `0xffffffff`, and excluding it dropped the
|
||||
// bright circuit bracket the capture plainly shows.
|
||||
//
|
||||
// So a trailing run is the hold exactly when it is **visible**.
|
||||
// (The port agent's report proposed "the final untimed keyframe
|
||||
// has the same pose as the last timed one"; that is true of
|
||||
// `pgptitle` too and would erase PAUSE, so it is the alpha and
|
||||
// not the pose-equality that separates the two.)
|
||||
let trailing_is_the_hold = j == n - 1 && (self.keyframes[i].fade >> 24) != 0;
|
||||
if (j != n - 1 || trailing_is_the_hold) && len >= best_len {
|
||||
best = Some(i);
|
||||
best_len = len;
|
||||
}
|
||||
@@ -247,6 +322,20 @@ pub struct UiBuild {
|
||||
pub elements: Vec<Element>,
|
||||
/// Sprite name → (offset, size) of its `T8aD` child within the bundle.
|
||||
pub sprites: HashMap<String, (usize, usize)>,
|
||||
/// Record name → (offset, size) of its nested `.rat` **leaf** within the
|
||||
/// bundle, e.g. `ptbtn01f.rat`.
|
||||
///
|
||||
/// Exposed because a leaf is where a focused button's extra elements live —
|
||||
/// `ptbtn0Nf.rat` declares the focus ring `ptbtneff01.t32` **and** the bright
|
||||
/// label, and the parent bundle declares no element for the `f` record at
|
||||
/// all. A consumer that walks only top-level elements cannot see either.
|
||||
///
|
||||
/// A leaf is itself readable by [`parse_build`]: its first 32 bytes have the
|
||||
/// same shape as a bundle header, so
|
||||
/// `parse_build(&bundle[off..off + size])` returns its elements with
|
||||
/// absolute design-space placements. See
|
||||
/// `docs/re/structures/ui-button-focus-record.md`.
|
||||
pub records: HashMap<String, (usize, usize)>,
|
||||
/// A guessed context from the sprite naming (e.g. `"tutorial"`), if any.
|
||||
pub context_hint: Option<String>,
|
||||
/// True when the declaration table was unusable and the build was recovered
|
||||
@@ -404,6 +493,8 @@ 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");
|
||||
let count = elements.len();
|
||||
let mut order = Vec::with_capacity(count);
|
||||
let mut pos = DECL_TABLE_AT + count * DECL_ENTRY;
|
||||
@@ -430,13 +521,25 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||||
}
|
||||
group.push(Keyframe {
|
||||
fade: be32(bundle, blk),
|
||||
rotation_deg: be32(bundle, blk + 12) as i32,
|
||||
unknown_4: be32(bundle, blk + 4) as i32,
|
||||
unknown_8: be32(bundle, blk + 8) as i32,
|
||||
scale_x: be32(bundle, blk + 16),
|
||||
scale_y: be32(bundle, blk + 20),
|
||||
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.
|
||||
time: (blk + 40 <= group_end).then(|| be32(bundle, blk + 36)),
|
||||
//
|
||||
// ⚠️ 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 {
|
||||
(blk + 40 <= group_end).then(|| be32(bundle, blk + 36))
|
||||
},
|
||||
});
|
||||
}
|
||||
elements[idx].keyframes = group;
|
||||
@@ -510,6 +613,7 @@ pub fn parse_build(bundle: &[u8]) -> Option<UiBuild> {
|
||||
design_h,
|
||||
elements,
|
||||
sprites,
|
||||
records,
|
||||
context_hint,
|
||||
from_fallback,
|
||||
placement_order,
|
||||
@@ -539,6 +643,9 @@ fn fallback_elements(bundle: &[u8], records: &HashMap<String, (usize, usize)>) -
|
||||
pivot_y: be32(rec, 0x54),
|
||||
keyframes: vec![Keyframe {
|
||||
fade: 0xffff_ffff,
|
||||
rotation_deg: 0,
|
||||
unknown_4: 0,
|
||||
unknown_8: 0,
|
||||
scale_x: 100,
|
||||
scale_y: 100,
|
||||
tint,
|
||||
@@ -811,8 +918,16 @@ pub fn compose(
|
||||
// Measured paint order when one exists for this build, declaration order
|
||||
// otherwise — see `measured_paint_order`.
|
||||
// Measured order when this build is one of the two read off the running
|
||||
// game; otherwise the order DERIVED from the sprites' layer keys, which
|
||||
// reproduces both measured orders up to ties.
|
||||
// game; otherwise the order DERIVED from the sprites' layer keys.
|
||||
//
|
||||
// ✅ Checked 2026-08-29 (`examples/paint_order_audit.rs`), because the
|
||||
// previous wording here — "reproduces both measured orders up to ties" —
|
||||
// was unmeasured and stale by one: there are THREE measured orders. The
|
||||
// derived order reproduces the main menu and the developer splash EXACTLY
|
||||
// (0 inverted pairs each) and differs on the title by 8 pairs, all of them
|
||||
// same-layer-key ties, two being total occlusions. Of the port's five
|
||||
// screens only `EXTRAS` rests on a derived order with ties: 15 tied pairs,
|
||||
// 2 overlapping. See docs/re/structures/ui-paint-order-derived-check.md.
|
||||
let order: Vec<usize> =
|
||||
measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle));
|
||||
for &ei in &order {
|
||||
@@ -867,6 +982,13 @@ pub fn compose(
|
||||
continue;
|
||||
}
|
||||
let Some(sprite) = el.sprite.as_ref() else {
|
||||
// Report it, do not just skip it. This arm used to `continue`
|
||||
// silently while the two arms below recorded into `missing`, so when
|
||||
// a name-decoding defect left every menu screen's background
|
||||
// unresolved, `screen render` still said "sprites that did not
|
||||
// resolve: none". A diagnostic with a hole in it is worse than none.
|
||||
// See docs/re/structures/ratc-child-names.md.
|
||||
missing.push(format!("{} (element declares no resolvable sprite)", el.name));
|
||||
continue;
|
||||
};
|
||||
let Some(&(off, size)) = build.sprites.get(sprite) else {
|
||||
@@ -919,8 +1041,11 @@ fn fill_quad(
|
||||
if sw == 0 || sh == 0 {
|
||||
return false;
|
||||
}
|
||||
let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };
|
||||
let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y };
|
||||
// `scale = 0` is COLLAPSED TO NOTHING, not "unset" — see `blit`.
|
||||
let (sx_pct, sy_pct) = (kf.scale_x, kf.scale_y);
|
||||
if sx_pct == 0 || sy_pct == 0 {
|
||||
return false;
|
||||
}
|
||||
let dw = (sw * sx_pct / 100).max(1);
|
||||
let dh = (sh * sy_pct / 100).max(1);
|
||||
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
|
||||
@@ -977,8 +1102,16 @@ fn blit(
|
||||
if sw == 0 || sh == 0 {
|
||||
return;
|
||||
}
|
||||
let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };
|
||||
let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y };
|
||||
// `scale = 0` means COLLAPSED TO NOTHING, not "unset". This used to coerce
|
||||
// 0 → 100 %, which drew a fully-collapsed element at full size. The disc
|
||||
// settles it: of 15 493 elements with a keyframe group, **2 166 have at
|
||||
// least one zero-scale keyframe and not one has zero on every keyframe** —
|
||||
// and 1 762 of them grow back out of it (`ptlogo_eff3.t32` runs 0 % → 200 %).
|
||||
// An "unset" marker that no element ever uses throughout is not a marker.
|
||||
let (sx_pct, sy_pct) = (kf.scale_x, kf.scale_y);
|
||||
if sx_pct == 0 || sy_pct == 0 {
|
||||
return;
|
||||
}
|
||||
let dw = (sw * sx_pct / 100).max(1);
|
||||
let dh = (sh * sy_pct / 100).max(1);
|
||||
// Keep the pivot point fixed as the element scales.
|
||||
@@ -1032,6 +1165,10 @@ fn blit(
|
||||
continue;
|
||||
}
|
||||
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
|
||||
// Straight alpha-over. `T8aD +0x04` bit 0x02 was tested as an
|
||||
// ADDITIVE selector and REFUTED — it moved every metric against the
|
||||
// title capture the wrong way (see the doc comment on
|
||||
// `T8adImage::flags`), so the bit is carried but not acted on.
|
||||
for (k, sc) in [sr, sg, sb].into_iter().enumerate() {
|
||||
let dc = canvas[di + k] as u32;
|
||||
canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8;
|
||||
@@ -1084,6 +1221,9 @@ mod tests {
|
||||
fn kf(x: i32, y: i32, time: u32) -> Keyframe {
|
||||
Keyframe {
|
||||
fade: 0xffff_ffff,
|
||||
rotation_deg: 0,
|
||||
unknown_4: 0,
|
||||
unknown_8: 0,
|
||||
scale_x: 100,
|
||||
scale_y: 100,
|
||||
tint: 0xffff_ffff,
|
||||
@@ -1197,12 +1337,16 @@ mod tests {
|
||||
// leaves the top-left quadrant bare. See
|
||||
// `docs/re/structures/ui-rat-layout.md`.
|
||||
let img = t8ad::T8adImage {
|
||||
flags: 0,
|
||||
width: 640,
|
||||
height: 360,
|
||||
rgba: vec![255u8; 640 * 360 * 4],
|
||||
};
|
||||
let k = Keyframe {
|
||||
fade: 0xffff_ffff,
|
||||
rotation_deg: 0,
|
||||
unknown_4: 0,
|
||||
unknown_8: 0,
|
||||
scale_x: 200,
|
||||
scale_y: 200,
|
||||
tint: 0xffff_ffff,
|
||||
@@ -1230,6 +1374,7 @@ mod tests {
|
||||
// `ptcopyright.t32` is 694x20 at (293,655), and the capture's glyph run
|
||||
// starts at x = 295 — inside that rect, not offset by a pivot.
|
||||
let img = t8ad::T8adImage {
|
||||
flags: 0,
|
||||
width: 694,
|
||||
height: 20,
|
||||
rgba: vec![255u8; 694 * 20 * 4],
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
# `sylph-agent`). They are never printed, never logged, and never passed on a
|
||||
# command line.
|
||||
#
|
||||
# push-work push the current branch
|
||||
# push-work push the current branch, and any annotated tags on it
|
||||
#
|
||||
# --follow-tags publishes ANNOTATED tags reachable from the pushed commits. That
|
||||
# is what makes a pinned decoder state durable: the port depends on commits of
|
||||
# ours by revision, and a commit reachable only from a topic branch is orphaned
|
||||
# by a squash-merge. Lightweight tags are deliberately not pushed.
|
||||
# push-work --dry-run say what it would do
|
||||
set -euo pipefail
|
||||
|
||||
@@ -50,7 +55,17 @@ if [ ! -s "$HOME/.git-credentials" ]; then
|
||||
fi
|
||||
|
||||
# `store` reads the file we mounted; nothing is written back (it is read-only).
|
||||
git config --local credential.helper "store --file=$HOME/.git-credentials"
|
||||
# Applied to THIS COMMAND ONLY, via `-c`, never `git config --local`.
|
||||
#
|
||||
# Writing it to --local config persists it in the repository, and this repo is a
|
||||
# bind mount the host also uses -- so the host's git inherited
|
||||
# `store --file=/sylph-home/re/.git-credentials`, a path that exists only inside
|
||||
# the container, and every host push then failed with
|
||||
# `unable to get credential storage lock: No such file or directory`.
|
||||
#
|
||||
# A tool that configures a shared repository to suit itself breaks every other
|
||||
# user of that repository. Keep it to the invocation.
|
||||
CRED_HELPER="store --file=$HOME/.git-credentials"
|
||||
|
||||
ahead=$(git rev-list --count "origin/$branch..$branch" 2>/dev/null || git rev-list --count HEAD)
|
||||
echo "push-work: $branch — $ahead commit(s) to publish"
|
||||
@@ -63,5 +78,5 @@ fi
|
||||
# --force-with-lease is deliberately NOT offered. If this is rejected as
|
||||
# non-fast-forward, someone else moved the branch: fetch and merge, do not
|
||||
# overwrite.
|
||||
git push --set-upstream origin "$branch"
|
||||
git -c "credential.helper=$CRED_HELPER" push --follow-tags --set-upstream origin "$branch"
|
||||
echo "push-work: pushed $branch"
|
||||
|
||||
@@ -120,3 +120,13 @@ turn ends — but sequential tool calls within a turn are fine.
|
||||
|
||||
The loop runs on a fixed interval set by the harness, so you do **not** need to
|
||||
arm the next wakeup yourself. Spend that attention on the write-up instead.
|
||||
|
||||
**And never stop it.** Do not call `ScheduleWakeup` at all — not to re-pace the
|
||||
loop, not to tidy up, and above all not with `stop`. Ending the loop ends the
|
||||
run: the container exits, and the next iteration never happens. If the cadence
|
||||
is genuinely wrong, say so in your reply and leave it to a human — the interval
|
||||
is set outside this prompt and is not yours to optimise.
|
||||
|
||||
This is not hypothetical. A run ended at 2026-08-29 04:0x with a clean exit 0
|
||||
while the display title read "Loop interval optimization", leaving four files of
|
||||
work uncommitted in the tree.
|
||||
|
||||
@@ -27,26 +27,488 @@ authored version can be deleted.
|
||||
|
||||
| | Question | State | Answer / link |
|
||||
|---|---|---|---|
|
||||
| Q1 | keyframe time unit + ramp shape | ✅ answered | ramp is **linear**; the clock advances **2 units per rendered frame**; working conversion **1 unit = 1/60 s** — [`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md) |
|
||||
| Q2 | which build is which screen state | 🟡 partial | build 4 title, 5 main menu, 6/8/9 submenus, `palogo` splash — unconfirmed against captures |
|
||||
| Q3 | paint order for the six screens | ❔ open | runtime-solved only; declaration table is refuted |
|
||||
| Q4 | button → GamePart | ❔ open | labels are baked into sprites |
|
||||
| Q5 | navigation semantics | ❔ open | |
|
||||
| Q6 | boot sequence + what drives it | 🟡 partial | order observed; the driver is not decoded |
|
||||
| Q7 | transitions | ❔ open | |
|
||||
| Q8 | menu audio bindings | ❔ open | cue table complete, event binding is not |
|
||||
| Q9 | video binding + playback rules | 🟡 partial | `ADV.wmv` is the boot intro; new-game intro unidentified |
|
||||
| Q10 | music-bank sub-wave roles (intro+loop?) | ❔ open | we concatenate blindly today |
|
||||
| S1 | Ready Room go/no-go | ❔ open | probe not run |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| Q6 | boot sequence + what drives it | ✅ answered | sequence **measured** end to end; the driver is **code, not data** — four search spaces closed, so the port **authors** the sequence — [`boot-config-and-gamepart-registry.md`](../re/boot-config-and-gamepart-registry.md) |
|
||||
| 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) |
|
||||
| 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
|
||||
|
||||
* **`GP_TITLE.pak` is the whole title-side tree.** Build 4 is the title with the
|
||||
animating wordmarks, build 5 the five-button main menu, builds 6/8/9 are
|
||||
submenus, and the developer splash is the `palogo` bundle in the same archive.
|
||||
✅ decoded (enumeration), 🟡 the state labels are not yet capture-confirmed.
|
||||
### ⬅ Answers to the port's five asks (2026-08-29)
|
||||
|
||||
* **1 — how to recognise the splash.** ❔ **No content rule exists; you are
|
||||
authoring this.** Design size fails (every extra composable bundle sampled is
|
||||
1280×720, same as every screen) and element count fails (the fragments run
|
||||
2…15 elements, the splash halves have 3 and 7 — the ranges overlap).
|
||||
✅ **But `GP_TITLE` needs no rule.** There, `--all` adds exactly **four**
|
||||
bundles and all four are real screens — no fragments at all — and the `--all`
|
||||
index equals the pak **entry** index 1:1 across all 16, so addressing by entry
|
||||
index does not mean something different from elsewhere.
|
||||
🔴 **And there are TWO splash screens; you have one.** Entries **11/14** are the
|
||||
developer logos (GAME ARTS / SETA / studio anima). Entries **10/13** are the
|
||||
**SQUARE ENIX publisher** wordmark — the *first* thing the boot shows — and you
|
||||
do not have them. The pairs are region twins (`™` on 10, `®` on 13). All four
|
||||
draw every element they declare.
|
||||
[`ui-splash-addressing.md`](../re/ui-splash-addressing.md) ·
|
||||
[render grid](../re/captures/title-builds/splash-both-halves-rendered.png)
|
||||
|
||||
* **2 — the ~0.4 s fade-out is (a)**, and it is bigger than the fade quad.
|
||||
Every element of a screen ends on **exactly one** untimed keyframe — so there
|
||||
is one unknown duration per screen, which rules out (b). That final block is
|
||||
where the screen plays out: the quad goes `a=255` (black) while the buttons,
|
||||
`ptmsg` and the glows go `a=0` and the two frames hold. (c) is refuted by a
|
||||
null test on the capture: a black quad alone keeps the button÷background
|
||||
brightness **ratio constant**, and measured through the fade it falls
|
||||
**6.50 → 1.94, a 3.4× monotonic drop**. So: write one authored constant
|
||||
(~0.4 s / ~24 units) and **play the group to its end on every element** — do
|
||||
not fade a black rectangle over a frozen screen.
|
||||
[`screen-transitions.md`](../re/screen-transitions.md)
|
||||
|
||||
* **3 — focus: your choice is fine, and it is not your bug.** ✅ The focused
|
||||
sprite **completely covers** the base — `f` alpha ≥ base alpha at **100.0 %** of
|
||||
base-visible pixels on three pairs across both languages, once aligned properly
|
||||
(the true offset is **(7,7)**, and at the centre alignment it reads a
|
||||
misleading 78–84 %). Compositing both ways differs by **RMSE 1.1 inside the
|
||||
button rectangle**, max 12/255 on ~25 px — unmeasurable at frame level.
|
||||
🔴 **What you are actually missing is the focus record's SECOND element.**
|
||||
`ptbtn0Nf.rat` declares **two** sprites — `ptbtneff01.t32` (a 42×46 **glowing
|
||||
ring**, focus only) then `ptbtn0Nf.t32` (the bright label) — where the base
|
||||
record declares one. That ring is the marker you say you draw nowhere. ⚠️ Note
|
||||
the small dot-in-circle at each underline's left end is *not* it: that is on
|
||||
every button all the time, part of the base art.
|
||||
[`structures/ui-button-focus-record.md`](../re/structures/ui-button-focus-record.md)
|
||||
|
||||
* **4 — rotation: not mine to decide alone.** Raised with the human; see
|
||||
MISSION. What I can say without a decision: the two sub-questions are not
|
||||
equally open. Rotation is about the **declared pivot** — that anchor is
|
||||
*measured*, not assumed: the title's two `ptloop` sweeps scale 600 %/800 %
|
||||
vertically, where the pivot term is worth 450 and 630 px, and the GPU capture
|
||||
puts both quad centres at y **359.1**/**360.0** against the pivot formula's
|
||||
**360.0**; top-left anchoring predicts 810/990 and centre-as-position 270. So
|
||||
if you draw rotation, rotate about the declared pivot.
|
||||
⚠️ It changes nothing on your five screens **at rest** — they have zero
|
||||
top-level rotations, and the title's two nested ones sit entirely off-screen at
|
||||
rest. [`structures/ui-keyframe-rotation.md`](../re/structures/ui-keyframe-rotation.md)
|
||||
|
||||
* **5 — the capture is not gamma-neutral, and RMSE against it has a floor.**
|
||||
Measured on flat patches (16×16, both images `std < 8`):
|
||||
`capture ≈ 255·(render/255)^γ` with **γ ≈ 1.49** (main menu), **1.49**
|
||||
(`EXTRAS`), **1.34** (title). The chain says this is a ramp **the game
|
||||
installed**, not a capture-path artefact: canary's swap-path gamma stage is a
|
||||
pure 256-entry LUT that defaults to identity, and the game is measured calling
|
||||
`VdGetCurrentDisplayGamma` once at video init. ⚠️ **Reach: the flat patches are
|
||||
almost all dark (render ~0–60), so nothing here constrains midtones or
|
||||
highlights** — which is where γ 1.4 does its visible work. So: yes, there is a
|
||||
floor; a γ ≈ 1.4 darkening gets closer and is **authored**, best applied where
|
||||
it was measured rather than extrapolated. Do not chase RMSE below it.
|
||||
[`structures/ui-render-tone-curve.md`](../re/structures/ui-render-tone-curve.md)
|
||||
|
||||
|
||||
* **`GP_TITLE.pak` is eight screens, each shipped twice — English and Japanese.**
|
||||
Build 4 is the English title art and 7 its Japanese twin; **2/3 are the
|
||||
`PRESS Ⓐ BUTTON` plate, a build of their own** composited over the title and
|
||||
faded in a beat later; 5/8 are the five-button main menu; 6/9 are the `EXTRAS`
|
||||
submenu — the **only** submenu inside this archive. Builds 0/1 and 10/11 are a
|
||||
`DELTASABER / SYLPHEED A.I.` plate that was **never seen running**, in the boot
|
||||
path, any title-side screen, or the attract loop. ✅ measured against live
|
||||
captures for the four English screens the boot path shows;
|
||||
[`ui-title-build-map.md`](../re/ui-title-build-map.md).
|
||||
**Withdrawn:** the earlier "builds 6/8/9 are submenus" — 8 is the Japanese main
|
||||
menu. The other four main-menu buttons leave the archive, and where each one
|
||||
goes is **measured** — see the button-destination bullet below.
|
||||
* **Buttons are identifiable as data.** Element kind `0x3002` = button, `0x0` =
|
||||
decoration, `0x10` = primitive. ✅ decoded.
|
||||
decoration, `0x10` = primitive. ✅ decoded **for the title-side screens**.
|
||||
⚠️ `0x3002` is one member of a `0x3000` family with sub-bits, and it is not the
|
||||
only button kind on the disc: `GP_READY_ROOM`'s 902 bundles contain **zero**
|
||||
`0x3002` and use `0x3000` / `0x3004` / `0x300c` / `0x3008` instead. Nothing in
|
||||
this milestone changes — every screen in scope is `GP_TITLE` — but do not ship
|
||||
`kind == 0x3002` as a general button test.
|
||||
(The kind is the 4th `u32` of the 60-byte declaration entry, at `+40`.)
|
||||
* **The title's settled pose is `rest` — and always pass `--primitives`.**
|
||||
Against a plate-free capture of the real screen, `screen render --build 4
|
||||
--black` edge-correlates **0.9163 at (0,0)**, so the geometry of `rest` is the
|
||||
arrived pose; the timeline is not needed for the title.
|
||||
⚠️ **But without `--primitives` the whole frame is +13.14 too bright** (R +12.35,
|
||||
G +13.58, B +13.48); with them, **+0.55**. The missing element is
|
||||
**`pteff02.prm`**, the 25 % dim, and `--primitives` is off by default. **That is
|
||||
the "washed-out cyan slab"** — a dim that should be there and isn't, not a glow
|
||||
that shouldn't. Because the art is blue-dominant, the shortfall reads cyan.
|
||||
🔴 **A second, separate defect — and it is the "slab".** With the dim in place
|
||||
the residual is localised to one band (y ≈ 112–225): the game draws the logo's
|
||||
`Z` **swoosh thin with a pink/magenta edge**, our render draws it **thick and
|
||||
solid white**. Crop:
|
||||
[`title-swoosh-capture-vs-render.png`](../re/captures/title-builds/title-swoosh-capture-vs-render.png).
|
||||
The elements are `ptlogo_back2.t32`, `ptlogo_back2eff.t32` and
|
||||
`ptlogo_back2eff1…5`. 🟡 Those five are also the group with the **known
|
||||
unsolved paint-order tie-break** (key `0x8083`) — same screen, same elements —
|
||||
but a blend-order swap explains white-instead-of-pink poorly.
|
||||
🔴 **The pivot mismatch is NOT the cause — checked and refuted.** These elements'
|
||||
declared pivots really do belong to the *other language's* sprite
|
||||
(`ptlogo_back2`'s `(500,117)` is exactly half the Japanese 1000×234, not its own
|
||||
English 1118×262; 24 of 109 title elements are off by > 8 px). **But it cannot
|
||||
affect this render**: `blit` sizes a sprite from its **texture**, and applies the
|
||||
pivot only as `kf.x − pivot·(scale−100)/100` — and **all seven swoosh elements
|
||||
are scale `(100,100)` at every keyframe**, so the term is zero.
|
||||
✅ **That formula is now MEASURED, not just implemented (2026-08-28).** The
|
||||
title's two `ptloop` sweeps scale **600 %** and **800 %** vertically, where the
|
||||
pivot term is worth 450 and 630 px, and the GPU capture puts both quad centres
|
||||
at y **359.1** and **360.0** — against the formula's **360.0** for both.
|
||||
Top-left anchoring predicts 810 and 990; treating the position as the centre
|
||||
predicts 270. Horizontally the same term makes the group's `t` solved from
|
||||
**position** agree with `t` solved from **vertex alpha** to **0.33 / 0.65
|
||||
units**, versus ~8 units without it. So: **anchor scaling on the pivot, not on
|
||||
the top-left corner.**
|
||||
🟡 It does *not* prove interpolation is linear — both fields were inverted
|
||||
through the same linear map, so a shared easing curve would cancel. It does
|
||||
show position and alpha ride **one shared parameter**.
|
||||
⚠️ It *would* bite `ptlogo1`/`ptlogo2`, which scale 100 → 150 during the
|
||||
build-in. Not at rest, and not on the swoosh.
|
||||
🔴 **Ruled out for the COLOUR**: every swoosh keyframe's fade is `0x??ffffff`
|
||||
(white RGB, alpha only), no tint is non-white, and the texture decodes
|
||||
*blue*-leaning (175,174,198).
|
||||
🟡 **What is left is the BLEND.** Size and position are the texture's own and
|
||||
match; fade, tint and texture colour are all ruled out. Seven overlapping
|
||||
mostly-transparent sprites (`ptlogo_back2` 5.4 % opaque, its glow 10.3 %, the
|
||||
five `eff` segments 10–23 %, all white or warm) stacked with plain alpha-over
|
||||
saturate to opaque white — which is exactly what we draw, and would read as
|
||||
"thicker" against the game's thin coloured stroke.
|
||||
🟡 **And there is a candidate field for it.** The `T8aD` header word at
|
||||
**`+0x04`** splits the title's sprites exactly along effect-vs-normal:
|
||||
`pteff01`, `pteff03a`, `ptlogo_back2eff1…5`, `ptlogoall_eff`/`_eff2` are
|
||||
**`0x8832`**; `ptlogo1`/`2`, `ptlogo_tm`, `ptbase2`, `ptlogo_back2`,
|
||||
`ptcopyright` and `ptlogo_back2eff` are **`0x8830`**. One bit — **`0x02`**.
|
||||
Disc-wide it is a real independent flag: 19 216 sprites, 18 distinct values,
|
||||
bit `0x02` set in **27.1 %**, and it toggles against otherwise-identical words
|
||||
(`0x8830`/`0x8832`, `0x0830`/`0x0832`, `0x0810`/`0x0812`, `0x0030`/`0x0032`).
|
||||
⚠️ **Correlation only — untested.** Nothing yet shows it *means* additive; the
|
||||
test is to blend bit-`0x02` sprites additively and re-correlate the title
|
||||
against the capture. Note `ptlogo_back2eff` is `0x8830` despite its name, so
|
||||
this is a field and not a naming pattern. ❔ Not diagnosed — but two candidate meanings are now dead: **not** "the name contains `eff`" — and not even the one-way "bit ⇒ `eff` name", which held 10/10 on the title but fails on **2 657 of 4 995** bit-set sprites disc-wide (`P(eff|set) = 0.468`). What survives is a 3.3× association, and the counterexamples are rings, glows and lights — effect-like art without the naming convention and **not** "the element is transient" (`pteff03`/`pteff03a` carry the bit and persist to `t=250`). 🛑 **Parked** — four candidate meanings are now dead (additive blend; `eff` name, both directions; transient element; **premultiplied alpha**, refuted because flagged sprites violate `RGB ≤ A` *more* than unflagged, 55.5 % vs 33.7 %) and none produced a positive account. It blocks nothing — your screens composite at 0.947 correlation against a capture without it. ⚠️ Practical note for an asset pipeline: `T8aD` headers appear in **RATC child order** (18/18 verified on build 4 against decoded dimensions), which is the only sound way to attribute a header to a name when two sprites share a size — and two here do.
|
||||
⚠️ **The rotated draw is NOT the swoosh.** It was identified as the swoosh by
|
||||
elimination; that is **refuted** — its quads span y −209…925 in screen space,
|
||||
the swoosh is a band at y 126…360. ✅ **It is the two `ptloop` sweeps**
|
||||
(`ptloop01.rat` → `pteff03.t32`, `ptloop02.rat` → `pteff03a.t32`), confirmed by
|
||||
edge length: 400 × 1076 and 400 × 1444 against 399×180 at the two elements'
|
||||
**different** declared scales, 600 % (= 1080) and 800 % (= 1440). What stands
|
||||
from the old bullet: the game *does* submit rotated quads this compositor
|
||||
cannot draw, and vertex colours are white.
|
||||
✅ **SOLVED 2026-08-28 by draw capture — it is the GEOMETRY.** The game submits
|
||||
the swoosh as **two rotated parallelograms** (edges `(0.54,−0.56)` and
|
||||
`(0.44,0.79)`, ~45° and ~61°, extending to `y=±1.81` NDC).
|
||||
`ui_layout::blit` draws **axis-aligned rectangles only**, so it blits the sprite
|
||||
upright — right on average, right in position, wrong in shape, which is the
|
||||
measured signature exactly. **A port that blits upright rects will have the same
|
||||
defect.**
|
||||
🔴 Vertex colour is refuted with it: every colour in the capture is
|
||||
`<alpha>FFFFFF`, white RGB.
|
||||
✅ **CLOSED 2026-08-28 — the rotation IS on the disc, at keyframe `+12`.**
|
||||
It is a signed angle in **degrees**, clockwise-positive in screen space
|
||||
(Y down), and `ui_layout::Keyframe` now carries it as `rotation_deg`.
|
||||
Confirmed against the framebuffer, not against our own renderer: the two
|
||||
`ptloop` records declare `+12` = **30** and **−45**, and the GPU capture
|
||||
submits their quads at **+30.26°** and **−45.28°** — magnitude and sign, on two
|
||||
different values. Corroborated separately by shape: `GP_BUNK` entry `117ca14f`
|
||||
holds a group whose `+12` ramps **0 → 360** with position, scale and alpha all
|
||||
constant — a spin in place. Disc-wide `+12` is non-zero in **14.50 %** of
|
||||
83 862 keyframe blocks.
|
||||
⚠️ **Two things the port must know about it.**
|
||||
(1) **Rotation lives in BOTH the top-level table and nested `.rat` leaf
|
||||
records.** ⚠️ I told you one iteration ago that it looked nested-only; that was
|
||||
three archives' worth of pattern and **it is refuted** — `GP_DIALOG` and
|
||||
`GP_DEBRIEFING_PILOTLOG` rotate top-level elements. The actionable half stands:
|
||||
the *title's* rotations are nested, so a composer reading only the declaration
|
||||
table gets zero rotation on exactly the elements that move there.
|
||||
The clearest examples are top-level and show up in
|
||||
`screen info --build 0 --geometry dat/GP_DIALOG.pak`: `pceff03.t32` and
|
||||
`pceff04.t32` ramp `r=` **90 → 30 → 10 → 3 → 0** while their alpha ramps
|
||||
0 → 255 and they slide into place — a swing-in that settles upright; and build
|
||||
6's `pzeff02.t32` ramps **43 → 61 → 75 → 90** while scaling 112 % → 200 % and
|
||||
fading to 0 — a spin-out burst.
|
||||
(2) **`sylpheed-cli screen render` still does not rotate.** The field is
|
||||
decoded, not rendered; `ui_layout::blit` is axis-aligned only. So the reference
|
||||
renderer and the port will *both* draw these upright until a rotating blit
|
||||
exists — and per your own rule, the two of them agreeing about it means
|
||||
nothing.
|
||||
✅ **Checked 2026-08-29: this does NOT affect your five screens at rest.**
|
||||
Title, main menu, `EXTRAS` and both splash halves have **zero** top-level
|
||||
elements with a non-zero rotation. The only rotations on any of them are the
|
||||
title's two nested `ptloop` records (`r = 30` and `−45`), and at rest those sit
|
||||
at `x = 1521` and `x = −839` — a 399-wide sprite entirely off both edges of a
|
||||
1280 screen. So a static composite is unaffected; the caveat applies only if
|
||||
you animate the title's build-in, where the sweeps cross the screen rotated.
|
||||
🟡 The neighbouring words `+4` and `+8` are still unexplained: signed, non-zero
|
||||
in ~4.8 % / 4.6 % of blocks, almost entirely `±180`/`±90`. That distribution
|
||||
looks like a flip flag rather than a free angle, but nothing observed turns on
|
||||
them — **do not transcribe them as X/Y rotation.**
|
||||
[`ui-keyframe-rotation.md`](../re/structures/ui-keyframe-rotation.md)
|
||||
⚠️ The earlier "pink versus white" reading compared two differently-shaped
|
||||
renderings and should be re-checked after geometry, not carried as a separate
|
||||
defect.
|
||||
❔ **Classified: undecodable from the disc, with reach.** Seven candidates
|
||||
eliminated — pivot (inert at scale 100 *and* no measured displacement), `fade`,
|
||||
`tint`, texture colour, additive blend via `+0x04` bit `0x02`, and
|
||||
capture-not-settled (the band is identical from t = 4.0 s to t = 21.5 s). The
|
||||
residual is stable and modest: band mean **+1.83**, edge-corr **0.70** vs ≈ 0.92
|
||||
frame-wide. A next attempt should use a **per-draw GPU capture** of the running
|
||||
guest — not another field — but ⚠️ that capture records prim/indices/shader
|
||||
hashes/**texture bindings**/**vertex attributes** and **no blend state**, so it
|
||||
can test a per-draw *vertex colour* today and would need a Canary change to dump
|
||||
`RB_BLENDCONTROL`. ✅ And the plate-free capture is sound; use it.
|
||||
🔴 **Refuted:** it is *not* that our dim covers the whole frame instead of
|
||||
sitting beneath the UI — the logo reads +2.36 against a background of −0.74, so
|
||||
the paint order is being honoured.
|
||||
|
||||
* **The title's motion, decoded and attributed.** After building in, the *title
|
||||
art* is essentially static — a 22 s capture measures the wordmark region at
|
||||
sd 0.06 and the bottom-right corner at sd 0.003. Two things do move:
|
||||
* ✅ **Two slow light sweeps in build 4.** `ptloop01.rat` (an `opt `-linked
|
||||
`RATC` at `0xbb5966`) sweeps `pteff03.t32` left→right over **450 units =
|
||||
7.5 s**; `ptloop02.rat` sweeps `pteff03a.t32` right→left over **570 units =
|
||||
9.5 s**. Ordinary 40-byte keyframe blocks from `+0x68`, three keyframes each.
|
||||
* ✅ **The `PRESS Ⓐ BUTTON` plate pulses**, and it is the loudest thing on
|
||||
screen — a capture's per-tile amplitude map puts **sd 7.65** in the band
|
||||
x ≈ 318–954, y ≈ 560–672 against **0.06** on the wordmark. It is
|
||||
`ptbtn00f.rat`, the plate's highlight variant (build **2**, not build 4),
|
||||
whose alpha ramps **`0x00 → 0x06 → 0x4a → 0x50` (hold) `→ 0x4a → 0x06 → 0x00`**
|
||||
over **eight** keyframes at `t = 6, 29, 35, 50, 58, 97, 105, ?` — a glow that
|
||||
fades in and back out, **closing on fully transparent**, so it is a complete
|
||||
cycle rather than a one-shot ramp.
|
||||
🟡 Its cycle **length** is not readable, and this is now observed rather than
|
||||
assumed: the eighth block's time slot literally contains the ASCII terminator
|
||||
`end `, so the record ends there and the value does not exist. Declared span is
|
||||
therefore **≥ 105 units = 1.75 s** against a measured **≈ 2.3 s** — which would
|
||||
need a final step of ≈ 33 units. That number is **fitted to the measurement, not
|
||||
read**; the port should take ≈ 2.3 s as measured.
|
||||
⚠️ An earlier version of this bullet said "the title screen loops at ≈ 2.2 s"
|
||||
and attributed it to `ptloop01/02`. Both halves were wrong: it is the **plate**,
|
||||
and it is a different build.
|
||||
* 🟡 **Our composite is brighter than the emulator's frame — measured, and you
|
||||
would be *authoring* if you apply it.** Alignment is exact (best offset
|
||||
dy=0 dx=0, correlation **0.9466**), so only the tone differs. Fitting on 16×16
|
||||
patches that are flat in **both** images: `capture ≈ 255·(render/255)^γ` with
|
||||
γ = **1.491** (main menu), **1.493** (`EXTRAS`), **1.338** (title).
|
||||
⚠️ **Narrow reach.** Those flat patches span only render values ~0–60, where a
|
||||
gamma and a plain scale are nearly indistinguishable — on both menus the errors
|
||||
are 0.28 vs 0.34 and 0.22 vs 0.28. Only the title separates them (1.08 vs
|
||||
9.02). Nothing here constrains midtones or highlights.
|
||||
🔴 The held-out control **failed to discriminate**: the splash's 2 918 flat
|
||||
patches are pure black (render 0–4), so every model scores ≈ 0.
|
||||
🔴 **My "it may be the emulator" caveat is withdrawn.** I said canary applies
|
||||
`kernel_display_gamma_type = 2` (BT.709) on output. It does not — that cvar is
|
||||
a value a **kStub getter reports to the guest** (`VdGetCurrentDisplayGamma`),
|
||||
which the game uses to build its own ramp; canary then applies **the guest's**
|
||||
ramp from the `DC_LUT` registers in the swap path (`apply_gamma_table.ps` /
|
||||
`apply_gamma_pwl.ps`). So there is no emulator post-process to subtract, and
|
||||
any gamma in a capture is one the game installed.
|
||||
✅ **Measured 2026-08-29: the game DOES query the display gamma.** Booted with
|
||||
Kernel logging on (`--log_mask=12 --log_level=3`, which changes nothing about
|
||||
the output), `VdGetCurrentDisplayGamma` is called once at video init, between
|
||||
`VdGetSystemCommandBuffer` and `VdSetDisplayMode` — the moment a ramp-builder
|
||||
would ask. Control in the same log: 359 `VdRetrainEDRAM` lines, so an absent
|
||||
call would have shown.
|
||||
🟡 **That it then writes the ramp is inferred, not observed** — but the chain
|
||||
is closed: canary's swap-path gamma stage is a **pure 256-entry LUT** with no
|
||||
other transfer (`apply_gamma_table.xesli`), and that LUT **defaults to
|
||||
identity** (`CommandProcessor::Initialize`, whose comment says the linear
|
||||
default is "what games set when starting with the sRGB return value"). Identity
|
||||
cannot produce the measured γ ≈ 1.4, so a non-identity ramp was written.
|
||||
⚠️ The weak joint is that this assumes our composite reproduces the *pre-ramp*
|
||||
framebuffer; what carries it is that a systematic ~1.4 across three screens is
|
||||
not the shape of a compositor bug. A fixed sRGB stage does not fit either
|
||||
direction (encode brightens; decode darkens far more).
|
||||
**Practical upshot for you is unchanged:** the darkening is the game's own
|
||||
display ramp, so it belongs in a port as a display profile, not baked in.
|
||||
⚠️ Note the ramp depends on the display type the game is *told*; canary
|
||||
hard-codes TV/BT.709, which on hardware is a console setting. **So this is a
|
||||
display profile, not a fixed property of the game** — reasonable to expose as a
|
||||
setting rather than bake in.
|
||||
[`structures/ui-render-tone-curve.md`](../re/structures/ui-render-tone-curve.md)
|
||||
|
||||
* ✅ **FIXED 2026-08-29 — the dropped background was a name-decoding defect, and
|
||||
`screen render` now draws it.** The reference composites for all five screens
|
||||
changed; regenerate anything you diffed against before that date. Cause: a
|
||||
RATC child's name is stated by an **`opt ` block** immediately before it
|
||||
(`"opt " | BE32 len | name | NUL | 3 bytes | magic`), and our parser instead
|
||||
guessed it from the last printable run of bytes. For this one child the 3
|
||||
trailing bytes are `38 41 58` = `"8AX"`, which beat the real name
|
||||
`pteff05.t32`. **`8AX` was never a name** — earlier text on this page treating
|
||||
it as one was wrong. Disc-wide: 18 002 children, 17 918 already agreed with the
|
||||
`opt ` reading and **24 did not**, every one the same 3-byte-tail failure.
|
||||
Effect on your five screens: mean brightness unmoved, high-frequency detail
|
||||
**×1.15…×1.30** — the same art at twice the resolution, which is exactly what
|
||||
[8AX](../re/structures/ui-8ax-fullres-background.md) said the game draws.
|
||||
⚠️ Both backgrounds are now drawn (`ptbase` upscaled, then the full-res one over
|
||||
it): correct, but wasted fill. Draw only the full-res one, taking its *timing*
|
||||
from `ptbase`'s element, which carries the keyframes.
|
||||
[`structures/ratc-child-names.md`](../re/structures/ratc-child-names.md)
|
||||
✅ **And the fix has no remaining hole.** 60 of the disc's 18 002 RATC children
|
||||
still have no `opt ` block; all 60 are now accounted for and **none is on your
|
||||
screens**. They are the ten frames of the disc's only `.tan` **frame sequence**
|
||||
(`pb_f15_eg_anm.tan`, six language copies of one `GP_READY_ROOM` bundle), where
|
||||
a single `opt ` block names the whole run — so a name-resolution miss is not
|
||||
hiding anything else the way `8AX` was. ⚠️ Two notes if you ever read outside
|
||||
`GP_TITLE`: `ratc::parse` **over-reports** there, listing a `.tan`'s frames as
|
||||
anonymous children; and a RATC bundle names exactly six kinds of resource —
|
||||
`.t32` (14 756), `.rat` (3 311), `.prm` (367), `.tbm` (224), `.sbo` (54),
|
||||
`.tan` (6).
|
||||
[`structures/ratc-tan-frame-sequence.md`](../re/structures/ratc-tan-frame-sequence.md)
|
||||
<details><summary>the original entry, kept because its reasoning still stands</summary>
|
||||
|
||||
🟡 **`screen render` silently drops one full-screen element per screen — and
|
||||
you must NOT simply draw it.** Auditing what the composer omits on your five
|
||||
screens: everything is accounted for (`kind & 0x4` ghost instances, `.prm`
|
||||
primitives, `loop*` animations) except **`pteff04.t32`** on the title and
|
||||
**`pteff05.t32`** on both menus. Those are `kind 0x0`, one keyframe, rest
|
||||
`a = 255`, pivot `(640,360)` — full-screen and opaque.
|
||||
**Cause:** the element declares `pteff05.t32`, but the `T8aD` behind its `opt `
|
||||
link is registered under the name **`8AX`**, so the sprite lookup misses and a
|
||||
silent `continue` drops it.
|
||||
✅ **It does not currently show,** because `ptbase.t32` (640×360, drawn at
|
||||
200 %) is *the same artwork at half resolution* — its 2× upscale differs from
|
||||
`8AX` by mean 2.05, and our background is pixel-identical to `8AX` in every
|
||||
patch sampled.
|
||||
✅ **SETTLED 2026-08-29 — the game draws the full-res `8AX`, so use it.**
|
||||
Previously parked as "needs a per-draw capture"; it did not. The two carry the
|
||||
same art at two resolutions, so what separates them is the detail `8AX` has
|
||||
that an upscale cannot. Correlating the capture's departure-from-upscale
|
||||
against the 8AX-only detail (both first mapped through the measured gamma):
|
||||
main menu **+0.0475** vs controls +0.0032 / −0.0075, title **+0.0634** vs
|
||||
+0.0095 / +0.0086 — **two screens, both 68 % of the theoretical ceiling, 7–15×
|
||||
their matched controls**.
|
||||
**So: resolve the name and draw `8AX` at 1:1.** Upscaling the 640×360 `ptbase`
|
||||
2× is *wrong*, not merely softer. ⚠️ Do not draw **both** — an opaque
|
||||
full-screen layer over an identical one costs fill and hides later changes; and
|
||||
note `ptbase`'s element is the one carrying the keyframes, so you need its
|
||||
timing with `8AX`'s pixels.
|
||||
⚠️ It does not show whether `ptbase` is *also* drawn underneath — the full-res
|
||||
background is
|
||||
~86 % opaque and would hide it either way.
|
||||
[`structures/ui-8ax-fullres-background.md`](../re/structures/ui-8ax-fullres-background.md)
|
||||
</details>
|
||||
|
||||
* ✅ **Paint order: your exposure is two element pairs, on one screen.** We use
|
||||
an order *measured from the running game* where one exists and a derived order
|
||||
(a sort on each sprite's layer key) elsewhere. Checked, rather than assumed:
|
||||
the derived order reproduces the measured one **exactly** on the main menu
|
||||
(0 inverted pairs) and the developer splash (0). On the **title** it differs by
|
||||
8 pairs — **all same-layer-key ties** — and two of those are total occlusions
|
||||
(`back2eff5` is 1133×280 and *fully contains* `back2eff3` and `back2eff4`;
|
||||
derived puts it on top, the game puts it underneath). The title is unaffected
|
||||
in practice because it has a measured order.
|
||||
Per screen: title **measured**, main menu **measured**, developer splash
|
||||
**measured**, publisher splash derived but with **0 ties** (fully determined),
|
||||
and **`EXTRAS` derived with 15 tied pairs of which only 2 overlap**.
|
||||
✅ **That narrows again to ONE, and the capture is consistent with it.** Of the
|
||||
two overlapping pairs, `ptloop01`×`ptloop02` are `loop*` animations that
|
||||
`compose` skips by default, so their tie is unreachable. The remaining pair is
|
||||
`ptframe3`×`ptframe4`, overlapping 102×132 px — and against `live-extras.png`
|
||||
that contested region correlates **+0.9622**, *better* than the whole frame
|
||||
(+0.9440) and inside the range of regions where order cannot matter (+0.8502 /
|
||||
+0.9903). 🟡 Consistent with, not proof — correlation cannot see a swap between
|
||||
locally similar art. **15 → 2 → 1 → consistent** is the whole paint-order risk
|
||||
on your five screens.
|
||||
[`structures/ui-paint-order-derived-check.md`](../re/structures/ui-paint-order-derived-check.md)
|
||||
|
||||
* ✅ **How good are the five screens, actually?** One page with the numbers:
|
||||
[`five-screens-acceptance.md`](../re/five-screens-acceptance.md). Rendered and
|
||||
correlated against the live captures — title **0.9500**, main menu **0.9460**,
|
||||
`EXTRAS` **0.9440**, publisher splash **0.9600**, developer splash **0.9643**,
|
||||
and every one aligns at **exactly dy=0 dx=0** over a ±2 px search, so placement
|
||||
and scale are right and the residual is tone and detail rather than geometry.
|
||||
Every undrawn element is accounted for (ghost instances, `.prm` primitives,
|
||||
`loop*` animations, and the `8AX` name mismatch whose art is on screen anyway) —
|
||||
the counts add up exactly, with nothing unexplained. The residual, ranked: tone
|
||||
(γ≈1.4, the game's own ramp), `8AX` resolution, one `EXTRAS` paint-order tie,
|
||||
and rotation-not-rendered which does not affect these five at rest.
|
||||
⚠️ Reach: static composites at rest against single frames — this says nothing
|
||||
about animation.
|
||||
|
||||
* ✅ **A static composite is only meaningful for a screen that SETTLES — the two
|
||||
splashes are animations.** Measured with its control: suppressing the `_eff`
|
||||
glows takes the **publisher splash 0.9604 → 0.9982** and the **developer splash
|
||||
0.9659 → 0.9980**, while the same edit makes the title −0.002, the main menu
|
||||
**−0.092** and `EXTRAS` **−0.107** worse. The draw log says why — on the
|
||||
developer splash the glows draw on frames 94–115 and the logos on 116–211, so
|
||||
at the captured moment every glow is already finished, including the two with
|
||||
plateaus that `rest_plateau` renders visible.
|
||||
So `rest_plateau` is right where a screen settles and over-draws where it does
|
||||
not. **There is no "resting pose" for the splashes** — they play through and
|
||||
leave, and a static composite of them is a picture of one arbitrary frame
|
||||
(≈0.998 for the frame these captures hold). **Play the timeline for the two
|
||||
splashes; composite statically for title / main menu / `EXTRAS`.**
|
||||
[`structures/ui-resting-pose.md`](../re/structures/ui-resting-pose.md)
|
||||
|
||||
* ❔ **A group's DURATION is in the data; its START TIME is not.** Testing whether
|
||||
the splash timeline *played* reproduces the capture: each element is on screen
|
||||
for its declared span to within 2 % (glows 44 units observed vs 45 declared;
|
||||
logos 192 vs 195). But every glow declares the same times `15,30,45` and every
|
||||
logo the same `15,30,190,194,206,210`, so on one clock they would overlap almost
|
||||
entirely — and they **do not overlap at all**. The glows run frames 94–115, the
|
||||
logos 116–211, strictly sequential.
|
||||
🔴 The obvious candidate is dead: each group header carries an undecoded
|
||||
**lead-in word**, and it is `0x00000000` for all seven elements. Not the
|
||||
keyframe times, not that word, not declaration order, not the RATC child order.
|
||||
✅ **Here is the sequence to author, measured** (1 frame = 1/30 s):
|
||||
publisher `palogo_sqex` **3.00 s+** (a floor — it is already at full alpha on
|
||||
the capture's first frame), a **0.10 s** gap with nothing drawn, then the
|
||||
developer **glows for 0.73 s**, then the developer **logos for 3.20 s**, the
|
||||
glow→logo switch being a single frame boundary with no overlap. Each phase is
|
||||
within 2 % of its element's declared span, so the *durations* come from the
|
||||
bundle and only the *ordering* is authored.
|
||||
⚠️ One capture, one run; and `palogo_anima`/`_eff` get 0 draws in all 214
|
||||
frames, so a third pair's phase is not in this measurement.
|
||||
[`data/splash-phase-timeline.txt`](../re/data/splash-phase-timeline.txt)
|
||||
⚠️ **So you must author the sequencing.** The observed order on the developer
|
||||
splash — both glows, then both logos — is *measured for one screen*, not a
|
||||
decoded rule.
|
||||
✅ **Verified against its own refutation:** across all 235 captured frames,
|
||||
**zero** contain both a glow and a logo; the switch is one clean boundary with
|
||||
two sprites either side.
|
||||
🔴 **And it goes further than start times — a bundle's declared elements are
|
||||
not what gets drawn.** ✅ **And the mechanism is now established, by
|
||||
elimination.** The competing story — "two compositions shown in sequence" —
|
||||
needs a bundle declaring the glows *without* the logos, and no such bundle
|
||||
exists: only four entries in `GP_TITLE` carry `palogo` elements, and each
|
||||
developer entry (11, 14) declares **all six** logos and glows. So whichever
|
||||
bundle was active, only a subset of its elements was drawn at a time.
|
||||
(An earlier texture-base test could not separate the two — it failed its own
|
||||
control, since the publisher splash is a different bundle and shares the base
|
||||
`0x11C30000`, a reused upload slot rather than an identity.) Entry 11 declares *three* logo/glow pairs; only two are ever drawn.
|
||||
`palogo_anima` gets **0 frames** while `palogo_gamearts` gets **95**, from
|
||||
byte-identical keyframe times. (Reach: the capture covers frames 1–214, so
|
||||
"never in the window".) **Compositing every element of a bundle does not
|
||||
reproduce what the game shows over time** — it is right for a static screen
|
||||
that settles, and it is not a timeline.
|
||||
[`structures/ui-group-start-time.md`](../re/structures/ui-group-start-time.md)
|
||||
|
||||
* ✅ **The splash's backdrop is an opaque black `.prm` from the bundle, painted
|
||||
first.** Measured: every frame opens with the clear and then an **untextured
|
||||
1280×720 quad, vertex colour `FF000000` in all 212 frames** — matching
|
||||
`palogo_eff0.prm`'s declaration (`kind 0x10`, pivot 640×360, one keyframe,
|
||||
`a=255`). That is why a splash render needs `--black` rather than the default
|
||||
backdrop. ⚠️ Not a general rule: the main menu's measured order puts one `.prm`
|
||||
at position 4 and the other **last** (the transition fade).
|
||||
✅ **The splash's recorded paint order `[0,2,4,6,1,3,5]` is a real depth
|
||||
order** — read off the runtime child array, and independently backed by the
|
||||
static layer key (glows `0xa100` sort before logos `0xa110`;
|
||||
`paint_order_audit` reports derived == measured, 0 ties). A same-day claim on
|
||||
this page that it was "a temporal sequence, not depth" was **withdrawn**; see
|
||||
the ❌ section in the linked page for why, if you read it before the fix.
|
||||
⚠️ Separately and still true — and it is an **activation** fact, not a
|
||||
paint-order one: the splash's glows and logos are never on screen together
|
||||
(glows f94–115, logos f116–211,
|
||||
[timeline](../re/data/splash-phase-timeline.txt)). Compositing all seven at
|
||||
once reproduces no frame of the real screen. Depth says *what covers what*;
|
||||
it does not say *what is up*.
|
||||
[`structures/ui-prm-primitives.md`](../re/structures/ui-prm-primitives.md)
|
||||
|
||||
* **Menu order is geometric.** Buttons sorted top-to-bottom by resting Y. This is
|
||||
✅ correct for a vertical menu and is **not** a decoded neighbour graph — the
|
||||
disc's real navigation structure is unknown, and `opt ` is *not* a focus link
|
||||
@@ -54,6 +516,28 @@ authored version can be deleted.
|
||||
[`ui-focus-and-effect-elements.md`](../re/structures/ui-focus-and-effect-elements.md)).
|
||||
* **Highlighted states pair by name** — `ptbtn01.rat` ↔ `ptbtn01f.rat`. 🟡 a
|
||||
naming convention that holds for all 54 real pairs, not a decoded field.
|
||||
* **`rest()` was wrong for elements with no exit animation — fixed 2026-08-28.**
|
||||
A trailing run of identical keyframes was always treated as the exit and
|
||||
excluded; on an element that has no exit it *is* the hold, and `rest()` fell
|
||||
back to the element's **first** keyframe — off-position and transparent. Six
|
||||
elements on the main menu were affected, including `ptframe1`/`ptframe2`, the
|
||||
bright circuit bracket around the menu, which both the port's composite **and**
|
||||
`sylpheed-cli screen render` were dropping.
|
||||
The rule now: **a trailing run is the hold exactly when it is visible** (alpha
|
||||
≠ 0). ⚠️ Not the pose-equality test the report proposed — `pgptitle.rat`'s
|
||||
trailing run also matches its last timed keyframe, and adopting that would erase
|
||||
the word PAUSE. Oracle correlation over the bracket region improved
|
||||
**0.9596 → 0.9748**; the PAUSE control is unchanged.
|
||||
✅ **Disc-wide**: over 2 859 bundles / 13 991 elements, `rest` moves for **30**
|
||||
(0.21 %) — **4 invisible → visible, 0 visible → invisible**. Tests green
|
||||
(131 passed).
|
||||
❔ **Back to the port agent:** on the English main menu exactly **two** elements
|
||||
satisfy your pose-equality condition (`ptframe1`/`ptframe2`), so your six span
|
||||
the whole export. If any of the other four have a **transparent** trailing run,
|
||||
this rule leaves them alone on purpose. **Which screens are they on, and does a
|
||||
capture show any of them drawn?** If so the alpha rule is incomplete.
|
||||
This also closes the old ❔ on `ptframe1`/`ptframe2` "resting at alpha 0 but the
|
||||
capture shows the frame plainly".
|
||||
* **The resting pose is the hold**, not the first, last or longest-dwell keyframe;
|
||||
a keyframe is the **start of a ramp**.
|
||||
[`ui-resting-pose.md`](../re/structures/ui-resting-pose.md). ✅
|
||||
@@ -62,39 +546,372 @@ authored version can be deleted.
|
||||
declared 15-unit fade lands on `round(255·k/15)` for all seven of its samples,
|
||||
with `k` stepping 2, 4, 6, 8, 10, 12, 14 on seven consecutive submitted frames.
|
||||
**measured**, not decoded — the disc says `t=30`, it does not say what a `t` is.
|
||||
The seconds conversion (`1 unit = 1/60 s`, so a 30 fps screen) rests on a
|
||||
measured 27.6 present-frames/second and is the one part still worth re-testing;
|
||||
[`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md) names the test.
|
||||
If it turns out the game presents at 60 Hz, every duration halves — nothing
|
||||
else on this page changes.
|
||||
🟡 **But a multi-keyframe element's TIMELINE does not reproduce (2026-08-28).**
|
||||
The linear law and the 2-units-per-frame rate rest on the splash's `_eff`
|
||||
glows, and those are exact. Applying *their* calibration — with no free
|
||||
parameter left — to `palogo_gamearts` in the same bundle and the same frames:
|
||||
the logo is still at `a=255` nine frames after its declared `a=32`, its
|
||||
fade-out runs ~17 frames late, and its declared 80-frame fade-in is never
|
||||
drawn at all (and that is not culling — the same element is submitted down to
|
||||
`a=7` on the way out). Its declared fade-out also spends 12 of 16 units
|
||||
dropping only 23/255 of the alpha; the capture shows no such plateau.
|
||||
🔵 **Followed up, and the candidate is now strongly favoured — but not
|
||||
adopted.** That `+36` holds the *next* pose's time is supported by a
|
||||
calibration-free measurement: the observed full-alpha **hold : fade-out** ratio
|
||||
is 83 : 13 frames = **6.38**, the shifted reading predicts **8.00**, and the
|
||||
current reading predicts **0.25** — off by **26×**. With the glow's 2
|
||||
units/frame fixed and nothing else free, the current reading says the logo
|
||||
holds full alpha for **2.0 frames**; the game holds it for **83**. The shift
|
||||
also makes `rest()`'s plain dwell rule pick the visible hold instead of a
|
||||
fully transparent pose, and removes the decoder's "last block's time is
|
||||
unreadable" special case.
|
||||
~~🔴 **What stops it:** … `GP_TITLE` build 7 … twins should match in
|
||||
brightness …~~ **Withdrawn 2026-08-28.** That render difference is **not**
|
||||
evidence about the time association. It is one element — `ptlogo_eff3.t32`, a
|
||||
transient bloom that grows `0 % → 200 %` at full alpha while rotating, then
|
||||
collapses — and it has **no resting pose at all**. `rest()` falls through to
|
||||
its dwell fallback and returns whichever end of that movement the indexing
|
||||
lands on: the invisible frame as decoded, the 200 % peak shifted. An 896×389
|
||||
sprite at 200 % is larger than the screen, which is the whole 13.1 % and the
|
||||
whole luminance gap. I was comparing a heuristic, not a decode.
|
||||
🔴 **And that is a real defect you should know about:** `rest()`'s dwell rule
|
||||
is unsound *whenever it runs*. A dwell gap is time spent interpolating **from**
|
||||
pose k **to** pose k+1; neither is held unless they are equal — which is a
|
||||
plateau, and the plateau path has already returned by then. **So any element
|
||||
with no two adjacent identical poses has a guessed rest pose**, in our renderer
|
||||
and in anything built from it. Measured disc-wide: **2 305 of 15 493 elements
|
||||
(14.88 %)** — ⚠️ **corrected 2026-08-29 down from a published 3 807 (24.57 %)**,
|
||||
which counted 1 502 *single-keyframe* elements as guesses; those have one pose
|
||||
and it is unambiguously their rest. Of the real 2 305, **195 get a degenerate
|
||||
`scale = 0 %` pose**, and the two
|
||||
candidate rules agree only **50.2 %** of the time.
|
||||
✅ **This does not block you — your exposure is TWO elements, both on the
|
||||
splashes.** The fallback is reached only by an element that is plateau-less
|
||||
*and* multi-keyframe; a single-keyframe element short-circuits. Title, main
|
||||
menu and `EXTRAS` reach it **zero** times, which is why three different
|
||||
fallback rules render them to identical correlations. The publisher and
|
||||
developer splashes reach it once each — and there, `SYLPHEED_REST_RULE=last`
|
||||
(the final keyframe) scores **+0.9982** and **+0.9758** against the current
|
||||
rule's +0.9600 and +0.9643. ⚠️ Measured against single frames of a transient
|
||||
animation, so it fixes which pose matches *those* captures, not which is
|
||||
canonically at rest. Default unchanged — it is better on both screens where it
|
||||
fires and identical on the other three, but it would move 2 305 elements
|
||||
disc-wide on two measurements.
|
||||
✅ **And the picture is now coherent.** Applying the last keyframe to *every*
|
||||
element (not just the plateau-less ones) collapses all five screens — title
|
||||
0.9500→0.6819, main menu 0.9460→0.6416, `EXTRAS` 0.9440→0.5745, and both
|
||||
splashes render **blank**. The reason is the model: a group is **entry → hold →
|
||||
exit**, and the exit is the screen's *dismissal*. A displayed screen is sitting
|
||||
at the **hold**, not at its final pose — so `rest_plateau` is right, and the
|
||||
last keyframe is the *post-exit* state. It is right for a **transient** element
|
||||
precisely because a transient's settled state is "gone". The draw capture agrees
|
||||
independently: on the developer splash the `_eff` glows draw on frames 94–115
|
||||
and the logos on 116–211, so the glows are already over when the logos are up.
|
||||
**Three independent observables — animation timing, static composites and the
|
||||
per-frame draw log — all support: plateau where there is one, last keyframe
|
||||
where there is not.**
|
||||
📊 **Structurally, `last` is defensible for 2 293 of the 2 305 (99.5 %)**: 1 618
|
||||
end invisible (a transient, gone at rest), 675 end at maximum alpha (faded in
|
||||
and stopped — the final pose *is* settled), and only **12** end visible below
|
||||
full alpha, which are genuinely unclear. The dwell rule returns a mid-movement
|
||||
frame by construction. ⚠️ The 1 618 rest on an assumption worth seeing: that
|
||||
such an element's animation has finished by the time the screen settles — shown
|
||||
by the draw log for the two splash glows, unshown for the rest.
|
||||
📊 The disc-wide blast radius, for whoever decides: the rules **differ on 82.3 %**
|
||||
of those 2 305, so "either is fine" is not available — and the current rule
|
||||
returns a **zero-scale** (collapsed, pre-roll) pose for **195** of them against
|
||||
**43** under `last`, a 4.5× reduction in provably-degenerate results. That
|
||||
argues the same way as the captures, from the data's own structure. The single disagreement
|
||||
is `palogo_anima_eff.t32` on the developer splash, and the current answer is
|
||||
the defensible one there — see below. Still worth flagging plateau-less
|
||||
elements in an export rather than silently inheriting our guess; it is one pass
|
||||
over the keyframes.
|
||||
✅ **One concrete part of it is fixed (2026-08-28): `scale = 0` no longer
|
||||
renders at full size.** `blit`/`fill_quad` coerced `scale == 0` to 100 %, so an
|
||||
element collapsed to nothing was drawn full-size. The disc settles the reading:
|
||||
2 166 elements have a zero-scale keyframe, **not one is zero throughout**, and
|
||||
1 762 grow back out of zero — so 0 means collapsed, not "unset". **Renders are
|
||||
24/24 byte-identical** on `GP_TITLE` (all 16 builds), `GP_PAUSE_MENU` and
|
||||
`GP_OPTIONS`, so nothing you rely on moves; the 126 elements the old
|
||||
code actually painted are **all in `GP_READY_ROOM.pak`**, already a no-go.
|
||||
[`structures/ui-rat-layout.md`](../re/structures/ui-rat-layout.md)
|
||||
🔴 **"rest = last keyframe" is refuted** as the fix. The splash has three
|
||||
sibling glows with identical structure and times, differing in one byte
|
||||
(`a=212` vs `a=255` at `t=45`); that rule would make `anima_eff` alone
|
||||
invisible while its two siblings stay lit. The capture agrees weakly — box-mean
|
||||
ratios capture/render are gamearts 0.717, seta 0.723, **anima 0.772**, and a
|
||||
glow we drew that the game does not would put anima *below* its siblings.
|
||||
[`structures/ui-resting-pose.md`](../re/structures/ui-resting-pose.md)
|
||||
🟡 **The shift is still not adopted**, now for a different reason: it flips
|
||||
this element to the visibly wrong answer, so it and a decision about
|
||||
plateau-less elements have to land together, and neither half has a capture to
|
||||
verify against.
|
||||
**Default unchanged**, experiment reachable via `SYLPHEED_KF_TIME_SHIFT=1`.
|
||||
**What this means for you:** the interpolation *law* is settled (linear, 2
|
||||
units/frame); a multi-keyframe group's *timing* is not — do not expect a
|
||||
2-frame hold where the game holds 83.
|
||||
✅ **The seconds conversion is settled: `1 unit = 1/60 s`, a 30 Hz title.** The
|
||||
re-test this page used to name has been run — 300 submitted frames timed on the
|
||||
**idle** title (nothing loading) came out at **28.8 and 28.3 fps**, the same
|
||||
rate as the 27.6 fps measured during the loading splash. The competing 60 Hz
|
||||
reading is excluded: it needs the emulator at 47 % of real time while idling on
|
||||
a screen that costs ~5 draws per frame.
|
||||
A second, independent line agrees — the transition quad is declared black for
|
||||
12 units (0.20 s under this conversion) and a capture measured the pure-black
|
||||
plateau at 0.17–0.23 s
|
||||
([`screen-transitions.md`](../re/screen-transitions.md)).
|
||||
⚠️ Still **measured, not decoded**: no field on the disc says "sixtieths of a
|
||||
second".
|
||||
* **The paint order is derivable from the file.** Each `T8aD` sprite header
|
||||
carries a **`u16` layer key at `+0x0A`** (the upper half of the 32-bit word at
|
||||
`+0x08` is zero in all 21 184 sprites on the disc). Paint order is that key,
|
||||
**stable-sorted** so equal keys keep declaration order; elements with no sprite
|
||||
(`.prm` primitives, `.tbm`) have no key and take an implied one — the backdrop
|
||||
and dim quads sort early, the screen-transition fade (`pteff00.prm`,
|
||||
`pfeff00.prm`) sorts **last**. ✅ decoded. Checked against five paint orders read
|
||||
off the running game, one of them (`GP_SAVE_LOAD`'s slot-list header, 6
|
||||
instances) **exact and independent of the screens the rule was fitted to**, and
|
||||
against a fresh `EXTRAS` capture. It reorders 341 of the disc's 965 builds, so
|
||||
it is not a no-op dressed as a rule.
|
||||
🟡 **The one residual: ties.** Where two elements share a key the game
|
||||
sometimes paints them in an order nothing predicts — eight candidates refuted,
|
||||
including declaration order, RATC child order, keyframe times, resting X/Y and
|
||||
`kind`. Measured cost: on the three screens with ground truth it changes the
|
||||
blend of **one element on one screen** (a title glow). Take the stable sort and
|
||||
accept that.
|
||||
|
||||
* **Ⓑ returns to the title, and that title still works.** Ⓐ on the Ⓑ-returned
|
||||
title opens the main menu — measured, with Ⓐ on the boot title as the control in
|
||||
the same run. (Only the *attract*-returned title is inert, which is an emulator-
|
||||
harness curiosity, not a port concern.)
|
||||
* **Menu movement, measured off the running game.** ⬆⬇ move one item per press
|
||||
and **wrap at both ends** (5-item main menu and 3-item `EXTRAS` both). ⬅➡ do
|
||||
nothing. Ⓑ goes up one level **and restores focus to the item you came from**;
|
||||
Ⓑ on the main menu returns to the title; Ⓑ on the title does nothing. **Initial
|
||||
focus is not stable**: four boots of the same script gave `TUTORIAL`,
|
||||
`TUTORIAL`, `NEW GAME`, `NEW GAME`. Do not hardcode it; pick one and say you
|
||||
picked it. All **measured**, none of it on the disc.
|
||||
* **Each button's destination is measured; its GamePart id is not.**
|
||||
**`NEW GAME` → `DIFFICULTY`** (`EASY`/`NORMAL`/`HARD`/`BACK`, opening on
|
||||
`NORMAL`) **→ `SELECT DATA`** — it does *not* hang; the run then hits the
|
||||
already-documented `sub_823070B0` cache crash, which is not a menu problem.
|
||||
`LOAD GAME` → the save-slot list, `TUTORIAL` → the lesson list, `OPTIONS` →
|
||||
the settings menu, `EXTRAS` → `GP_TITLE` build 6, `EXTRAS ▸ MISSION SELECT` →
|
||||
the stage list. The
|
||||
GamePart ids (`3`, `25`, `8`, `5`, `7`) are the entries of the decoded id table
|
||||
whose **names match the screens seen**; that binding is authored, not measured.
|
||||
|
||||
* **A screen change is a fade through black.** Each screen carries a full-screen
|
||||
black `.prm` quad that paints last (`pteff00.prm` / `pfeff00.prm`) whose
|
||||
keyframe group *is* the transition: black at `T0`, clear by `T1`, clear until
|
||||
`T2`, then back to black on exit. ✅ decoded, with a disc-wide check — and in
|
||||
`GP_TITLE` exactly the six **screen** builds carry it while the six overlays do
|
||||
not. The fade-in length is `T1 − T0` and is read from the file (0.87 s for
|
||||
`EXTRAS`, 0.97 s main menu, 4.08 s title). ❔ **The fade-OUT length is not on
|
||||
the disc** — the last keyframe has no time slot; **measured ~0.4 s**, twice.
|
||||
The black hold measures 0.17–0.23 s. ⚠️ Do not time the fade-in off a capture's
|
||||
brightness: the incoming screen's own element animations dominate it and run
|
||||
much longer than the quad.
|
||||
|
||||
* **The movie manifest names every boot-side video by role.** `dat/tables.pak`
|
||||
entry `0x5b983a08`: `LOGO1`–`LOGO4` → `logo1.wmv`–`logo4.wmv` (**not on the
|
||||
disc** — this is why the splash is a screen), **`ADVERTISE_MOVIE` → `ADV.wmv`**,
|
||||
`STAFF_ROLL` → `SYLPH_HD720p_8M-CBR_2ch.wmv`, **`MS00A` → `S00A.wmv`** (the
|
||||
new-game intro, 93.9 s, with subtitle + `VOICE_S00A` + a text overlay),
|
||||
`MS01A` → `S01A.wmv`. ✅ decoded — and **`S00A.wmv` is now also measured**: matched off the running game at 0.96–1.000 with a strictly monotone playhead over 25 consecutive 0.5 s samples. It starts ~4.5 s after Ⓐ on the save slot.
|
||||
**The boot intro and the attract movie are the SAME asset** — there is no
|
||||
separate boot slot, and 15 of 19 captured attract frames match `ADV.wmv` with a
|
||||
monotonically advancing playhead ending at its full 137 s. One video, not two.
|
||||
✅ The attract movie **plays to its end**; nothing cuts it short.
|
||||
✅ **A movie is skippable with a single Ⓐ.** Measured: one tap ~45 s into the
|
||||
boot brought the title at ~57 s against a ~193 s no-input baseline over three
|
||||
boots, with Canary's own keystroke counter proving exactly one press was
|
||||
delivered — and the skipped-to title is **fully functional** (`PRESS Ⓐ` plate
|
||||
present, Ⓐ opens the main menu). What breaks the boot is *hammering*: 88
|
||||
presses left a permanent black screen. One press is fine.
|
||||
|
||||
* **The menu's sound events are named on the disc.** `tables.pak`'s `SOUNDS`
|
||||
record carries 322 `SE_*` cues, and the low block is the UI vocabulary — named
|
||||
after the *event*: `SE_UI_CURSOR` (2), `SE_UI_DECIDE` (3), `SE_UI_CANSEL` (4),
|
||||
`SE_UI_IMPOSI` (5, the error), `SE_UI_SUB_WIN_OPN`/`_CLS` (8/9),
|
||||
`SE_UI_SPLASH_IN`/`_OUT` (12/13). ✅ decoded, full list in
|
||||
[`data/se-ui-cues.txt`](../re/data/se-ui-cues.txt). They all live in **one**
|
||||
bank — `BANK_SE` is a single field naming `Static.slb`, and 0 of the 322 has
|
||||
its own `FILES` entry.
|
||||
🟡 **Which event fires which cue is a name match**, not a measurement — strong,
|
||||
because these are the authors' own event names, but nobody has watched the game
|
||||
emit cue 2 on a d-pad press. The port is authoring it.
|
||||
✅ **The SE audio IS extractable — by playing it.** (This corrects an earlier
|
||||
"cannot be extracted" on this page.) The disc carries no index: `Static.slb` has
|
||||
**0 `RIFF`/`seek`/`WAVE`** and there is **no XACT container anywhere** (0 ×
|
||||
`XGSF`/`SDBK`/`WBND` in 1.08 GB of `sound.pak`; no `XACT` string in the
|
||||
executable — those extensions are the authoring tool's). But Canary's
|
||||
`--xma_param_probe=true` logs every stream's head bytes, and searching them in
|
||||
`Static.slb` locates the wave exactly:
|
||||
**d-pad move → `0x1ec0` (4 pkts / 8 192 B, 0.533 s); Ⓐ confirm → `0x5d6c0`
|
||||
(6 pkts / 12 288 B, 1.016 s); Ⓑ back → `0x0ec0` (2 pkts / 4 096 B, 0.344 s)** —
|
||||
every cue the five screens need. Move and back reproduce with identical head
|
||||
bytes across **two independent boots**. Each matched at one offset only, and
|
||||
the first two are contiguous
|
||||
(`0x0ec0 + 4096 = 0x1ec0`) — the bank is a packed run of whole 2 048-byte
|
||||
packets with no delimiters, which is why nothing could be scanned for.
|
||||
❔ **⬅ and ➡ play nothing distinct** — no new stream on either, so leave them
|
||||
silent (the probe dedups, so this excludes a *distinct* invalid cue, not a
|
||||
quiet replay of an already-heard one).
|
||||
🟡 The Ⓐ press both confirms and opens a screen, so whether its wave is the cue
|
||||
named `SE_UI_DECIDE` or `SE_UI_SUB_WIN_OPN` is not separated.
|
||||
⚠️ The order is **not** cue-id order, so the index must be observed per cue, not
|
||||
counted. ✅ **The slices decode**: 0.533 s, 0.344 s and 1.016 s of mono 48 kHz
|
||||
audio with the attack-and-decay shape of UI blips, via
|
||||
`tools/re-capture/slb_extract_wave.py` — whose wrapper reproduces a known-good
|
||||
`BGM_001` decode to the same 173.808875 s, so it is verified, not assumed.
|
||||
|
||||
* **The boot sequence is not data-driven — the port authors it.** ❔ Four places
|
||||
were checked and the order is in none: `config.ini`'s `[SYSTEM]` is empty, the
|
||||
movie manifest carries assets not transitions, the requested GamePart id lives
|
||||
**only as a stack argument in flight** (no persistent field, no literal store),
|
||||
and the string `GP_ADVERTISE_DEMO` has **zero xrefs**. A transition is a call
|
||||
with an id argument, chosen by code.
|
||||
🟡 **But the states have names, and the transition uses them.** `sub_821C6458`,
|
||||
the title part's state function, calls **`sub_821CC860(…, "<NAME>", 0)`** with
|
||||
`TITLE_SCREEN`, `TITLE_MENU` and `LOADING` — the three states measured off the
|
||||
game, in the game's own words — and installs the result. So a transition is a
|
||||
call with a **name** argument, which is also why `GP_ADVERTISE_DEMO` has no
|
||||
xrefs: at this level the screen graph is name-keyed, not id-keyed.
|
||||
✅ The argument is now **decoded** at 46 of that function's 48 call sites (28
|
||||
distinct names). ⚠️ It is a **generic name-keyed lookup**, not a screen factory —
|
||||
its arguments include `BG`, `BLACK`, `FADE`, `FILE`, `KEY`, `PAD`, `SOUND`,
|
||||
`GAMMA_RGB`. An earlier version of this page claimed `DIFFICULTY` and
|
||||
`EXTRA_MENU` as corroborated screen names; **neither is ever the argument**, and
|
||||
only `TUTORIAL_MENU` survives.
|
||||
✅ **And the state machine itself is decoded**: `state = this+136`, ten states
|
||||
dispatched through a jump table at `0x821C6498`, with **18 transitions** each a
|
||||
literal `li`/`stw`. States **0, 2, 8** install `TITLE_SCREEN`, `TITLE_MENU`,
|
||||
`LOADING`. `4 → 0` is the only edge back to the title, reached from `2 → 4` —
|
||||
which matches Ⓑ-returns-to-title as measured. Full graph in
|
||||
[`data/title-state-machine.txt`](../re/data/title-state-machine.txt).
|
||||
⚠️ **All of that is ONE PHASE.** `GamePart_Title` dispatches on an outer phase
|
||||
field at `this+132` (five values) before reaching any of it: phase 0 is the
|
||||
**developer splash** (`sub_821C5690`, the same function the corpus fingered
|
||||
independently), phase 4 is the title/menu machine. The ten states and
|
||||
eighteen edges above live inside phase 4 alone.
|
||||
✅ **State 4's edge conditions are an event code** — `sub_821C6458`'s third
|
||||
argument. State 4 is the input-waiting state (reached straight after the menu
|
||||
is installed) and handles 6 of 26 events: **`0` → title**, **`3`, `5`, `8`,
|
||||
`25` → `LOADING`**, `10` → state 5.
|
||||
🟡 What the event *numbers* mean is not decoded — button id, menu row, or
|
||||
message id — so the port still takes the button→destination map from
|
||||
measurement. The one-to-title / four-to-loading shape matches the five-item
|
||||
menu with Ⓑ, but that is a **count-match, not a mapping**. The sequence itself is fully measured:
|
||||
splash → `ADV.wmv` → title + `PRESS Ⓐ` → (idle ~8–10 s → `ADV.wmv` in full →
|
||||
title) → Ⓐ → main menu, with Ⓑ from the main menu returning to the title.
|
||||
|
||||
* **`config.ini` picks the language, and that is what picks the EN/JP build.**
|
||||
The disc's **only** config file (400 bytes, at the root; one `find` over the
|
||||
whole extract). Its `[LANGUAGE]` section maps the console's `XC_LANGUAGE_*`
|
||||
value to `eng`/`jpn`/`deu`/`fra`/`esp`/`ita`, defaulting to `eng` — that code
|
||||
selects `GP_TITLE`'s English or Japanese build and the `<lang>.pak` families.
|
||||
✅ decoded.
|
||||
❔ Its `[SYSTEM]` section — which the file's own comment says holds what the
|
||||
game and every game part share — is **empty**, so the boot *order* is not in
|
||||
disc-side configuration at all.
|
||||
* **Five GameParts are named but never registered.** Pulling every
|
||||
`RegisterToFactory<N, class silph::GamePart_X>` diagnostic string binds **24 of
|
||||
the 29 ids to a C++ class**
|
||||
([`data/gamepart-class-ids.txt`](../re/data/gamepart-class-ids.txt)). Ids `1`,
|
||||
`2`, `16`, `18`, `28` have no registration site — including
|
||||
**`GP_ADVERTISE_DEMO` (1)**, which agrees with the measurement that the attract
|
||||
loop is the *title* replaying `ADV.wmv` rather than a separate part. 🟡 an
|
||||
argument from a diagnostic string, not from the code.
|
||||
Also: **`3` and `4` are both `GamePart_SaveLoad`** — one part, two ids.
|
||||
|
||||
* **The GamePart id table** — 29 entries at `.rdata 0x820A1630`, confirmed by the
|
||||
executable's own registration strings. ✅ This is the screen vocabulary; which
|
||||
button reaches which entry is Q4 and is *not* part of it.
|
||||
* **The logo splash is a screen, not a video.** `logo1`–`logo4` are
|
||||
manifest-bound with no `.wmv` on the disc. ✅
|
||||
* **The logo splash is a screen, not a video, and it renders.** `logo1`–`logo4`
|
||||
are manifest-bound with no `.wmv` on the disc. ✅ The screen is **two** bundles
|
||||
in `GP_TITLE.pak`, each shipped twice: entries **10/13** the white
|
||||
`SQUARE ENIX` publisher logo, entries **11/14** `GAME ARTS`/`SETA`/`studio
|
||||
anima`. ⚠️ They are **invisible to the default `screen list`/`render`** —
|
||||
`is_build` rejects them for having no `.rat` child. Pass **`--all`**, which
|
||||
renumbers `--build`. So the first of the five screens does have a reference
|
||||
composite. ✅ **And a capture**: both halves edge-correlate to their renders at
|
||||
**0.91** and **0.98** at zero shift, each rejected by the other (0.03, −0.03).
|
||||
✅ **Its timing is DECODED, not just measured.** The splash **fades both ways**
|
||||
and the bundles carry the keyframes: `SQUARE ENIX` `[15 30 235 239 251 255]`,
|
||||
developer logos `[15 30 190 194 206 210]`, each with an `_eff` glow child on
|
||||
`[15 30 45]`. Under `1 unit = 1/60 s` that is a 0.25 s ramp in, a **3.42 s**
|
||||
/ **2.67 s** hold, and a **0.33 s** fade out — and a 10 fps capture measures
|
||||
≈ 3.5 s / ≈ 2.4 s holds and **≈ 0.3 s** fade-outs. The visible brightness
|
||||
overshoot on the way in is the `_eff` glow ramping after the logo, not a
|
||||
rendering artifact. Wall-clock: logos ≈ 0.4–4.7 s and ≈ 4.9–8.4 s, then
|
||||
`ADV.wmv` from ≈ 8.9 s. ⚠️ The cyan `SQUARE ENIX` at ≈ 9.5 s is the
|
||||
**movie's** opening, not a third splash screen.
|
||||
* **Sprites carry their own labels.** No font rendering or localisation is needed
|
||||
for this milestone. ✅
|
||||
for this milestone. ✅ — and the localisation is *already baked in*: the
|
||||
Japanese screens are separate builds in the same pak, not a text swap.
|
||||
|
||||
## Facts the port will trip over
|
||||
|
||||
* **`ADV.wmv` is WMV3 video + WMA Pro audio**, 1280×720 at 30 fps, 137 s. Godot 4
|
||||
plays only Ogg Theora natively. How to handle that is the port's decision, not
|
||||
ours — but it is not optional.
|
||||
* **The disc holds 3.3 GB of video.** Only the boot intro and the one new-game
|
||||
intro are in scope.
|
||||
* **The disc holds 3.3 GB of video, and exactly two files are in scope:**
|
||||
`dat/movie/ADV.wmv` (the boot intro *and* the attract loop — one asset) and
|
||||
`dat/movie/S00A.wmv` (the new-game intro, 93.9 s). Named from the movie
|
||||
manifest, ✅ decoded.
|
||||
* **`Static.slb` over-declares its size** by 616 768 bytes — it is the
|
||||
highest-offset entry in `sound.pak` and its size field is an allocation size. A
|
||||
reader must allow a short read there and only there.
|
||||
* **Voice downmixes to mono, music does not.** The left-channel downmix is correct
|
||||
for spoken lines and discards half a music mix.
|
||||
* **A music bank has several sub-waves and we glue them together.** `BGM_001`
|
||||
is 10 KB + 4.47 MB + 4.67 MB, concatenated into one 347 s track. Nobody has
|
||||
established whether those are intro + loop, two variations, or two halves —
|
||||
see Q10. Do not build menu looping on the concatenated track until it is
|
||||
answered.
|
||||
* **A music bank is TWO STEMS THAT PLAY TOGETHER — do not concatenate.**
|
||||
✅ The "10 KB + 4.47 MB + 4.67 MB" reading was wrong: the 10 KB is the bank
|
||||
header, and a bank is exactly **two waves of identical duration** (32/32 banks
|
||||
on the disc). `BGM_001`'s two are **sample-synchronous** — transient
|
||||
correlation peaks at lag 0.00 s over ±5 s, and both stop at the same
|
||||
millisecond, 167.663 s. Wave 1 is quieter, far more L/R-decorrelated and has
|
||||
almost no bass, so it reads as a surround-rear pair or a second intensity
|
||||
layer — 🟡 which of those is unsettled, and `ChannelMask` is `0x0002` on both,
|
||||
so the file will not say. Today's 347 s concatenation plays the piece twice,
|
||||
the second time as a bass-less stem.
|
||||
❔ **And it is not a seamless loop**: `BGM_001` fades out at 167.663 s and is
|
||||
followed by 6.15 s of silence, with no loop-point field identified. A menu loop
|
||||
is authored.
|
||||
✅ **The menu's music is `BGM_103`.** The cue *table* cannot say — its BGM
|
||||
entries are numeric — but `GamePart_Title`'s `sub_821C5580` plays **cue 1103**,
|
||||
and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 B) are
|
||||
byte-for-byte the two streams the XMA probe saw decoding at the main menu.
|
||||
Static code, disc census and runtime all agree. The port does **not** have to
|
||||
choose a track.
|
||||
* **`JNGL_001.slb` does not decode.** One bank in 9 519; its payload is not a whole
|
||||
number of XMA1 packets from any known data offset.
|
||||
|
||||
## What is still open
|
||||
|
||||
Every question above is answered, so this is the honest residue rather than a
|
||||
work queue. **None of it blocks the five screens.** (Q1's seconds conversion was
|
||||
here until 2026-08-28 and is now settled.)
|
||||
|
||||
| | what | why it is stuck |
|
||||
|---|---|---|
|
||||
| 🟡 | **cue NAME → event binding** (Q8) | event→**wave** is measured for move/confirm/back; that the cursor's wave is the cue *named* `SE_UI_CURSOR` is still read off the authors' identifiers |
|
||||
| ❔ | **the other ~319 SE cues** (Q8) | located one at a time by triggering them; only the three the menu needs have been done |
|
||||
| 🟡 | **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 |
|
||||
|
||||
(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
|
||||
`--xma_param_probe`, and using it settled both.)
|
||||
|
||||
⚠️ **A container caveat that bounds all of the above:** the emulator has twice
|
||||
been killed mid-run with no crash line in its own log (at ~50 s and ~145 s), on a
|
||||
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.
|
||||
|
||||
## Reference data
|
||||
|
||||
Committed alongside the findings, so the port can be built without a disc in the
|
||||
|
||||
@@ -75,7 +75,160 @@ alongside it.
|
||||
| **Q8** | **Menu audio.** Which BGM per screen; which cue on move / confirm / back / error. The cue table is complete; the event binding is not | Cue names bound to events, with how you established each |
|
||||
| **Q9** | **Video binding.** Which movie is the boot intro vs the new-game intro; whether playback is skippable and what ends it | Named movies plus the playback rules |
|
||||
| **Q10** | **What are a music bank's sub-waves?** `BGM_001.slb` is three sub-waves — 10 KB, 4.47 MB, 4.67 MB — and we currently **concatenate them blindly** into one 347 s track. Two near-equal halves could be intro + loop, or two variations, or two halves of one piece. A menu that loops its music needs to know which | The role of each sub-wave, established for at least the menu BGM. "Concatenate" is a decision, not a default — right now it is a default nobody chose |
|
||||
| **S1** | **Ready Room probe.** *Gated* — one iteration, then stop | A written go/no-go (see below) |
|
||||
| **S1** | ~~**Ready Room probe.**~~ **DONE 2026-08-28 — [no-go](../re/ready-room-probe.md).** It is 2D and enumerates fine, but the pak is briefing/tactical-map content, not the Ready Room menu | ✅ go/no-go written |
|
||||
|
||||
## 🔴 Emulator-side questions are blocked — the title is not reachable here
|
||||
|
||||
**Status 2026-08-29, instrument-verified.** Two open items need a running menu:
|
||||
the gamma control behind [tone curve](../re/structures/ui-render-tone-curve.md),
|
||||
and separating `8AX` from `ptbase` in
|
||||
[8AX](../re/structures/ui-8ax-fullres-background.md). Both are blocked.
|
||||
|
||||
✅ **The negative is now solid.** A probe that restarts its capture stream every
|
||||
30 s and cross-checks itself against an independent grabber every 60 s
|
||||
(9 checks, 8 agreeing to within 2 %) ran **560 continuous seconds from launch at
|
||||
3.30 fps**: the interactive title's green Ⓐ plate never appeared, while the game
|
||||
rendered throughout. The final frame correlates 0.0145 / −0.0047 / 0.0102 with
|
||||
our title / main-menu / `EXTRAS` renders — attract-movie content.
|
||||
|
||||
❔ **Why is unknown.** `live-title-press-a.png` (753 glyph pixels) proves it was
|
||||
reachable from this container on 2026-08-28. Clearing the shader cache fixed a
|
||||
*black surface* but not this.
|
||||
|
||||
⚠️ Neither blocked item blocks the five menu screens. See
|
||||
[capture-harness-status](../re/capture-harness-status.md) for the full trail,
|
||||
including three earlier "the title never appears" claims that were withdrawn
|
||||
because the instrument was broken each time.
|
||||
|
||||
## 🟡 Needs one more run — a Japanese-locale capture
|
||||
|
||||
Recorded rather than worked around, per "do not improvise around a blocker".
|
||||
|
||||
Two questions have converged on needing **one capture we cannot take**: a running
|
||||
capture of `GP_TITLE` **build 7**, the Japanese title screen.
|
||||
|
||||
* Q1's keyframe-time association. The shifted reading is favoured 26× by a
|
||||
calibration-free measurement, and the only render it changes on the whole disc
|
||||
is build 7 ([`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md)).
|
||||
* What `rest()` should return for a plateau-less element. The one element that
|
||||
discriminates, `ptlogo_eff3.t32`, is also in build 7
|
||||
([`structures/ui-resting-pose.md`](../re/structures/ui-resting-pose.md)).
|
||||
|
||||
**🔴 The "cannot" was wrong, and is withdrawn (2026-08-29).** It is true that
|
||||
`user_language` is only ever `DECLARE_int32` here, with no `DEFINE` and no entry
|
||||
in `xenia-canary.config.toml`, so there is no flag to pass — and passing an
|
||||
unknown one is specifically dangerous, because `run-canary`'s own header records
|
||||
that xenia calls `ShowSimpleMessageBox` from `ParseLaunchArguments` *before*
|
||||
logging starts. But the cvar is not the only route, and I stopped at the first
|
||||
one I checked.
|
||||
|
||||
**The language is persisted, and canary's own file is writable.**
|
||||
`kernel_state.cc` builds `XConfig` over `<storage_root>/xconfig.settings`, which
|
||||
exists here at `/sylph-home/re/.local/share/Xenia/xconfig.settings` (6 680 B).
|
||||
`user.language` is a **big-endian u32 at file offset `0x912`**, located by three
|
||||
independent landmarks rather than guessed:
|
||||
|
||||
| landmark | expected | found |
|
||||
|---|---|---|
|
||||
| `music_volume` (`User+449`) | `0.7f` | BE float at 2727 → `User` base `0x8e6` |
|
||||
| `language` (`User+44`) | `1` = `kEnglish`, the hard-coded default | **1** at `0x912` |
|
||||
| `country` (`User+64`) | United States | `103` at `0x926` |
|
||||
|
||||
`XLanguage::kJapanese = 2` (`xbox.h:307`). Writing 2 there and restoring
|
||||
afterwards is using canary's own persistence, not patching its code.
|
||||
|
||||
**🔴 DIAGNOSED 2026-08-29 — the harness, not the game.** `screenshot` costs
|
||||
**10.8 s while xenia is running** and **0.117 s once it is killed** (92×, at a
|
||||
1-minute load average of 1.80, so it is contention with the emulator). A
|
||||
two-grab polling loop therefore samples every **~41 s**, against a title screen
|
||||
this corpus documents as lasting *a few seconds*. Four runs — two locales, two
|
||||
launch paths, two gamma settings — were all blinking slower than the event.
|
||||
🔴 **…and that diagnosis is itself refuted (same day).** The faster probe was
|
||||
built — 3.98 s → **0.29 s** per sample, 13.7×, control-verified at 753/327 — and
|
||||
at 3.99 fps for **420 continuous seconds (1 674 samples)** the interactive title
|
||||
*still* never appeared. Sampling rate was a real defect and not the cause.
|
||||
✅ So "the interactive title does not appear mid-run without a pad press" is
|
||||
reinstated, now as a dense measurement. ⚠️ Its reach is a **mid-run** window; it
|
||||
says nothing about the boot title.
|
||||
🟡 Leading hypothesis, unconfirmed: the plate appears only in the **boot** title
|
||||
window and the attract loop's title has none — which is precisely what
|
||||
`title_states_capture.sh` was written to test. The experiment is to start the
|
||||
fast probe from t=0, not attach to a run already in progress.
|
||||
See [capture-harness-status](../re/capture-harness-status.md).
|
||||
|
||||
**🟡 Still not settled — two runs, and the reason moved again.**
|
||||
|
||||
*Run 1 (2026-08-28)* reported "title not seen" in 787 s. **That was a broken
|
||||
tool, not the game.** `wait_title.sh` still carried the single-pixel oracle that
|
||||
`is_title.py` was written to replace — a 1280×720 coordinate sampled against the
|
||||
1279×675 game surface, so it always reads the copyright line. Fixed; it now
|
||||
delegates to `is_title.py`.
|
||||
|
||||
*Run 2 (2026-08-29)*, with the working oracle and the profile flag the English
|
||||
captures use, **still did not reach the interactive title.** Not one frame in the
|
||||
run showed a single green-Ⓐ glyph pixel, and content correlation against either
|
||||
build-7 render never exceeded **0.22**. The game sat in the attract movie
|
||||
throughout, polling `XamInputGetKeystrokeEx` (601 calls) — i.e. alive and waiting
|
||||
for input, not hung.
|
||||
|
||||
**✅ The control was run (2026-08-29), and it removes the locale from the
|
||||
picture.** Same flags, same oracle, English locale: **75 samples over 734 s, every
|
||||
one glyph = 0.** The English boot does not present the interactive title either.
|
||||
Canary was alive throughout and polling `XamInputGetKeystrokeEx` (1 801 calls);
|
||||
the frame at 734 s has content but correlates only **0.15** with build 4's render
|
||||
and 0.05 with the main menu, i.e. it is an attract-movie frame, not title art.
|
||||
|
||||
So the Japanese run was **not** failing because of the locale — neither locale
|
||||
reaches the interactive title in ~12 minutes of no pad input. **🔴 Tried the proven path too, and I am stopping this line (2026-08-29).**
|
||||
Run 3 launched exactly the way `boot_menu.sh` does — `DISPLAY=:98`, `--apu=sdl`,
|
||||
`/dev/shm/xenia_*` cleared, the existing profile signed in — and drove
|
||||
`skip_intro.sh`, the detector that is documented to work. It classified **every
|
||||
one of 20 samples over 604 s as "movie"** and waited them all out, then timed
|
||||
out. A direct check at 604 s confirms it was right: zero green-glyph pixels,
|
||||
`screen_id` = other, warm mean (69,53,40), correlation **0.09** with build 7. The
|
||||
game really was playing attract movies for ten minutes.
|
||||
|
||||
So: **three runs, two locales, two launch paths, ~35 minutes of emulator time, no
|
||||
interactive title.** Either the attract loop is far longer than the 600–780 s
|
||||
windows tried, or something has regressed since `live-title-press-a.png` was
|
||||
captured (that one came from a pad-driven boot — commit `88b3ce9`, "booting to
|
||||
the main menu and walking it").
|
||||
|
||||
⚠️ **Not worth more loop iterations.** This capture does not block any of the
|
||||
five menu screens, and it has now cost five. It is written down so a later
|
||||
session with a reason to spend an hour on the boot path can pick it up; the
|
||||
locale mechanism, the fixed oracle and the launch recipe are all in place, so
|
||||
what is left is patience with the attract loop, not tooling.
|
||||
|
||||
The locale is restored to English; `set_console_language.py ja` flips it back in
|
||||
one command.
|
||||
|
||||
⚠️ Neither question blocks the five menu screens. Q1's *interpolation law* is
|
||||
settled and only multi-keyframe absolute timing is open; `rest()` differs from
|
||||
its alternative on **one** element across all five screens, and the current
|
||||
answer there is the defensible one.
|
||||
|
||||
## 🔵 Needs a human decision — rotation (raised 2026-08-29)
|
||||
|
||||
The port agent asks whether it should **render** `rotation_deg` (decoded at
|
||||
keyframe `+12`) when `sylpheed-cli screen render` deliberately does not. Its own
|
||||
framing is the reason this is not mine to settle: if the port rotates and the
|
||||
reference renderer does not, then `verify-screen` reports a large title diff that
|
||||
means *"the port is right"* — a silently inverted signal.
|
||||
|
||||
The RE half is answered and is in HANDOFF: rotation is about the **declared
|
||||
pivot** (measured against a GPU capture, not assumed), and it changes nothing on
|
||||
the five screens **at rest**.
|
||||
|
||||
What needs a decision is which way the divergence gets closed:
|
||||
|
||||
* teach `ui_layout::blit` a rotating path, so the two renderers stay comparable
|
||||
and the diff keeps meaning "someone is wrong" — costs work in the reference
|
||||
renderer, which is otherwise not on the port's critical path; or
|
||||
* let the port render rotation and mark the title as a known-divergent screen in
|
||||
`verify-screen`, accepting a check that no longer guards the title.
|
||||
|
||||
Recorded rather than chosen, per "do not improvise around a blocker".
|
||||
|
||||
## Known unknowns — say so, do not fill them in
|
||||
|
||||
@@ -118,6 +271,35 @@ answer is no-go by definition, not "try harder".
|
||||
**Then stop and write the go/no-go.** Do not start Ready Room work on your own
|
||||
authority.
|
||||
|
||||
## Publishing a decoder state the port pins
|
||||
|
||||
The port's exporter depends on `sylpheed-formats` **by revision**, so a commit of
|
||||
yours becomes part of its build. That creates an obligation that is easy to miss:
|
||||
|
||||
⚠️ **A commit reachable only from an `auto/*` branch is not safe to pin.** If the
|
||||
branch is later deleted, or — worse — **squash-merged**, the object is orphaned.
|
||||
Squash creates *new* commits, so `main` appears to contain the work while the
|
||||
pinned sha becomes unreachable, and the port stops building for anyone doing a
|
||||
fresh checkout. Silently, at their build, long after the moment of breakage.
|
||||
|
||||
**So when you land something the port needs, tag it:**
|
||||
|
||||
```bash
|
||||
git tag -a formats-pin-$(date +%F) -m "what the port gets from this state"
|
||||
push-work # --follow-tags publishes annotated tags with the branch
|
||||
```
|
||||
|
||||
A tag is a permanent ref. It survives branch deletion and squash-merge, it is
|
||||
self-documenting in the port's `Cargo.toml`, and it fails loudly at *fetch* if it
|
||||
ever goes missing rather than silently at build.
|
||||
|
||||
Then tell the port over the message channel that a new pin exists. It bumps
|
||||
deliberately, as its own commit — that is how it stays current without floating,
|
||||
which would only give it staleness it cannot see.
|
||||
|
||||
`formats-pin-2026-08-29` at `7eeae30` is the first, created because the port had
|
||||
already pinned a commit that lived on one topic branch and nowhere else.
|
||||
|
||||
## Handing it over
|
||||
|
||||
[`HANDOFF.md`](HANDOFF.md) is the single page the port agent reads. Keep it
|
||||
|
||||
@@ -19,9 +19,9 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
||||
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
|
||||
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` + `tests/idxd_records_disc.rs` ([container](structures/idxd-container.md)) | **The binary record/index region in front of the string pool is DECODED** (2026-08-25), closing the parser's long-standing "not yet decoded" note. Uniform 16-byte records `{name_hash, name_off, field_begin, field_end}` sorted by hash and binary-searched, then a field count, 12-byte fields `{key, name_off, value_off}` sorted by key, a pool size, and the string pool; the trailing `pool_size == file_len - pool_base` identity makes the layout self-checking. Verified over the **whole disc** with **zero** failures: 7 750/7 750 objects, 190 782/190 782 records reproducing their stored `tag_hash`, 1 271 462/1 271 462 named fields reproducing their key — and `IXUD` is the same container with `ixud_hash`, UTF-16BE and all offsets in **chars** (1 104/1 104 objects, 628 165/628 165 fields). **Field names are stored on disc** — a field's middle word points at its own name — so nothing needs preimage search except the **504** field entries disc-wide that are hash-keyed with no name — which are only **42 distinct keys**, each in 12 places (the page for these was never written — the finding is in this row), and are provably unrecoverable from the hash alone; the other 1 485 073 nameless fields are *positional*, keyed by a literal integer (line slots, movie ids). ⚠️ **Two long-held beliefs WITHDRAWN**: the word at `0x08` is **not a schema hash**, it is record 0's `name_hash` (7 750/7 750) — the header has no type field at all, so an object's kind is known only from the caller that loads it; and the field's middle word is **not** an always-`0xFFFFFFFF` flags word. The first was caught by a test asserting that every movie id names a real record: `1005 -> STAGE10_PHASE01` failed because `tag_hash("STAGE10_PHASE01")` **is** `0x067025B9`, that table's supposed schema id. 🟡 the legacy value-before-key string-pool reader is now known to be an *approximation* of the real table, and every number derived from it is re-checkable but not yet re-checked |
|
||||
| IDXD nameless field keys | ✅/❌ | [idxd-unnamed-keys](structures/idxd-unnamed-keys.md) + [`tools/re-capture/idxd_unnamed_keys.py`](../../tools/re-capture/idxd_unnamed_keys.py) | Census of every field entry whose `name_off` is `0xFFFFFFFF`, disc-wide: **7 750 objects, 2 757 039 field entries, 0 parse failures**, `tag_hash` reproducing **1 271 462/1 271 462** named keys. **7 094 distinct keys are never named — and 7 052 of them are not hashes at all**, but author-assigned element ids (equal to the field's own index in 1 404 924 of 1 485 577 cases; `tag_hash("BGM_001")` is `0xC662435B` while the key valued `BGM_001.slb` is `0x000003E9`). ⚠️ **The "504 hash-keyed nameless fields" figure is 504 ENTRIES, not 504 names** — 42 distinct keys × 6 language copies × 2 records. All 42 are **ISL script-symbol hashes** in `<lang>\script\ID.tbl` (GP_READY_ROOM.pak), the link map built by `PrepareScript`'s "isl script prescanning"; 41 of 42 appear as little-endian call targets inside the `.isb` bytecode, forming a coherent launcher/helper call graph. The hash's own algebra pins the **trailing digits of 30 of the 42 names** (deltas of exactly `+0x01000001` across `stage01..09`, `stage10..16`, `challenge01..06`; `+0x01010000` across `tutorial0101..0601`). ❌ **No name was cracked, and the negative is quantified**: seven attacks up to a 3.5×10⁸ composition space found nothing above the noise floor; exhaustive preimage search recovers `"Stage01"` from its own hash but returns nothing for the real targets at ≤6 characters, and at 7 characters one target already has **1 176** preimages — a 24-bit modulus cannot name an 8+ character identifier uniquely |
|
||||
| XPR2 texture + cubemap | 🟡/✅ | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order ✅ confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3°, *zero* cool pixels) and the game renders the same hull at 9.3° — a red↔blue swap would sit at ≈200°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟡 untested, since a hue comparison cannot see it; cubemap face ordering ❔ |
|
||||
| XPR2 texture + cubemap | 🟡/✅ | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order ✅ confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3°, *zero* cool pixels) and the game renders the same hull at 9.3° — a red↔blue swap would sit at ≈200°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟡 untested, since a hue comparison cannot see it; cubemap face ordering ❔. **2026-08-29, for the UI path only:** our composite is brighter than the emulator's frame by a gamma of **≈1.34–1.49** across three screens ([tone curve](structures/ui-render-tone-curve.md)) — 🟡 measured, not decoded, constrained only over render values ~0–60, and possibly canary's own `kernel_display_gamma_type = 2` (BT.709) output stage rather than the game's |
|
||||
| T8aD 2D texture | ✅ | `sylpheed-formats/src/t8ad.rs` | **100 % of the disc decodes** (19 216/19 216, measured). The "~15 % deferred variants" were a wrong model, not a variant: a surface is a list of **arbitrary sub-rectangles**, each with a 16-byte header of `dst X, dst Y, width, height`, not a 256×256 grid — `0x1c` is the **rectangle count**. Uncovered area stays transparent. **Colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)) |
|
||||
| RATC bundle | ✅ | `sylpheed-formats/src/ratc.rs` | child listing confirmed. **"One level deep" is not a limitation — there is nothing deeper**: 2 859 bundles hold 18 002 children at depth 1 and **0 at depth 2**, with no parse failures. Nested RATC blobs are **leaf records that reference siblings by name** (`opt `, the sprite name): 3 311 leaves, all embedding sibling names, **10 144 of 10 148 references resolve**. The 4 that do not are one dangling asset — `pmbase.rat` → `pmbase.t32` in `GP_STAGE_CLEAR.pak`'s four language builds, and `pmbase.t32` is **on the disc nowhere** |
|
||||
| RATC bundle | ✅ | `sylpheed-formats/src/ratc.rs` | **✅ 2026-08-29: a child's NAME is stated by an `opt ` block** (`"opt " | BE32 len | name | NUL | 3 bytes | magic`), not by the printable bytes before it. We had been guessing it from the last printable run, which is right 17 918 times and **wrong 24**, each time because the 3-byte tail is itself printable — `8AX` (×22) and `'OX` (×2). `8AX` is **not a name**; it hid `pteff05.t32`/`pteff04.t32`, the full-resolution background of all five menu screens, which `compose` then dropped with no diagnostic ([ratc-child-names](structures/ratc-child-names.md)). ✅ 60 of 18 002 children carry no `opt ` block, and all 60 are explained: they are the ten frames of the disc's only `.tan` **frame sequence** (`pb_f15_eg_anm.tan`, six language copies of one `GP_READY_ROOM` bundle, 6 × 10 = 60), where one `opt ` block names the whole run — so `parse` over-reports frames as children there. A census of all **18 718** `opt ` names shows a bundle names exactly six kinds of resource: `.t32` 14 756, `.rat` 3 311, `.prm` 367, `.tbm` 224, `.sbo` 54, `.tan` 6 ([tan-frame-sequence](structures/ratc-tan-frame-sequence.md)). Child listing confirmed. **"One level deep" is not a limitation — there is nothing deeper**: 2 859 bundles hold 18 002 children at depth 1 and **0 at depth 2**, with no parse failures. Nested RATC blobs are **leaf records that reference siblings by name** (`opt `, the sprite name): 3 311 leaves, all embedding sibling names, **✅ 10 148 of 10 148 references resolve, as of 2026-08-29.** The 4 that did not were `pmbase.rat` → `pmbase.t32` in `GP_STAGE_CLEAR.pak`'s four language builds, recorded as "`pmbase.t32` is on the disc nowhere". It was on the disc all along, as the child the printable-run scan named `8AX` — the `opt ` name fix above resolves it in all four builds (3 686 767 B each). A dangling reference that closes itself when an unrelated decode lands is the corroboration that decode wanted |
|
||||
| LSTA sprite list | ✅ | `sylpheed-formats/src/lsta.rs` | A display list of inline elements: **T8aD sprites and `PRMD` primitives**. The `count` at `0x04` is **exact and counts both** — `count == T8aD + PRMD` for **64/64** lists on the disc, which retires the old "a few entries disagree" note (it compared sprites against a total including primitives). **All 1 281 sprite frames decode** after the T8aD rectangle-list fix |
|
||||
| IXUD subtitle + caption text | ✅ | `sylpheed-formats/src/ixud.rs` + `movie_subtitle.rs` ([container](structures/idxd-container.md) · [movie link](movie-subtitle-link.md)) | **The IXUD record/field table is decoded and wired in (2026-08-26)** — `IxudObject` mirrors `IdxdObject`; uniform 16-byte records, 12-byte fields, every offset in **chars**, and the word at `0x08` is record 0's hash, not a schema id. Verified disc-wide: **1104/1104** objects, **1476/1476** records, **628 165/628 165** named fields reproducing their `ixud_hash` (`tests/ixud_records_disc.rs`). **Caption text: 537 → 8800 lines, which is 8800 of 8800 distinct keys.** Two steps — generalising the key parser from `MSG_DEMO_*` to all **eight** families (`ACRO ADAN ADPL BIRD DEMO RHIN TCAF` use `MSG_<FAM>_<id>_<page>_<line>`, `VOICE` alone inserts a family letter) took 537 → 8074; switching from **token adjacency to record fields** took it to 8800. ⚠️ An earlier "1.3 % of the game's text" figure of mine counted *occurrences across blocks* — the honest denominator is **8800 distinct keys**, so the real starting point was 6.1 %. The `DEMO` control shows why the field route matters: token adjacency finds 537 lines there, fields find **541** — it was dropping lines in the one family it was written for. | timed cues. **The movie↔subtitle↔voice link is solved — statically**, and as of 2026-08-25 read from the IDXD **record table** rather than scraped from the string pool: **104 cutscene slots binding 101 distinct movies**, 99 slots / 96 movies with a subtitle, 99 / 96 with a voice track, 22 / 22 with a telop. ⚠️ The previous counts (94 / 83 / 21) were the numbers of **distinct pool strings** — a repeat reference contributes no token, so 13 later `VOICE_D_450..454` references read as "no binding". **All 18 hokyu movies are bound**, not five. 93 of the 94 distinct subtitle members resolve; `SUBTITLE_S12B.tbl` resolves in none of the six languages — a dangling reference on the disc. The ~104 **script ids are no longer ❔**: they are literal positional field keys in `BASE_INFO`, each naming its record, and all 104 resolve. `movie_manifest::parse` now reads the record table; CSV regenerated by `examples/movie_map_csv.rs` |
|
||||
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
|
||||
@@ -30,7 +30,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
||||
| Weapon fields defaulted on disc ⚠️ | ✅/❌ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv). ⚠️ **CORRECTED 2026-08-26: the disc DOES carry a third of them.** Re-read through the [record table](structures/idxd-container.md), **1 514 of the 4 393** rows labelled `defaulted-on-disc` have a value on disc (2 879 genuinely absent) — measured here as an upper bound, since a field is counted when it appears in *any* record of the object; a per-record count gives ~1 448. The old reader could not name a record, so per-record fields read as absent. Spot-checked exactly: `wep_05`/`wep_60` `TriggerShotCount` = **4**, `wep_02` `Power` = **100.0**, `wep_60` `Power` = **1000.0** (refuting the recorded "C band ≈150…500" bracket), `wep_25` `MaximumRange` = **4000.0**, `wep_11/28/36/70` `LoadingCount` = **6/5/5/0**. The runtime capture's numbers all match the disc — what is withdrawn is the premise that it was reaching values the disc lacks. See [weapon-datasheet-runtime](weapon-datasheet-runtime.md) Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check |
|
||||
| Unit (craft/vessel) fields defaulted on disc ⚠️ | ✅/❌ | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. **Re-derived independently 2026-08-13 from the loader's own key strings** (`sub_82341A20`; the field name for each store is a string in the image): **159 fields**, agreeing with this solver on **25 of 25 shared offsets**, verified at **406 values matching the disc and 0 disagreeing** over 11 live objects spanning UNIT and VESSEL — landed as `data/unit_definition_layout.txt` + `sylpheed_formats::unit_layout` + a no-emulator test, with **121 defaulted fields** read out ([live-unit-definitions](live-unit-definitions.md)). ⚠️ **CORRECTED 2026-08-26:** those fields are **on the disc**. The ~30-field player-craft table is on disc at exactly the values the runtime "recovered", spread across the `Generic` / `Shield` / `Mass` / `SE` records — which is why a reader that could not name a record saw them as absent. And **"18 of 23 vessel records are missing at least one of `Size_X/Y/Z/HP`" is false: 0 of 114** objects with a `Generic.Type` (43 Craft + 71 Vessel) miss any of the four. See [unit-struct-runtime](structures/unit-struct-runtime.md) Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — ❌ **the sibling-default rules are WITHDRAWN (2026-08-25)** — `Size_Y` is on disc for **114/114** unit tables and **differs from `Size_X` in 90**; the old reader missed it exactly when the two were equal (a string-pool dedup artefact, cross-tab `seen+equal = 0` for all three pairs), so the rule was re-deriving the condition that hid the field. It also predicts wrong twice — `UN_e104_ADAN_Carrier.DefencePoint` and `UN_e011_ADAN_Attacker_B_HF_Wayne.FCSRange`. Read the record table instead — [values](captures/unit-runtime-fields.csv) |
|
||||
| Arsenal develop economy | ✅/❔ | [arsenal-develop-economy](arsenal-develop-economy.md) + [conditions](captures/arsenal-develop-conditions.csv) | The Arsenal reads `weapon.tbl` (item ids, in the 8-category display order) and `strings.tbl` (names, descriptions, and a **"Conditions to obtain"** block per item) out of `GP_HANGAR_ARSENAL.pak`. All **60** conditions are extracted: gates are stage completion, a predecessor item, or an **ace kill**; costs run 3 000–350 000 P and **20 items are free** once gated. `weapon.tbl`'s first record reproduces the in-game DATA SHEET exactly (Range D / Power E / Speed – / Weight 0.3 = Light / 4000 P) — later records are unreadable from the string pool alone because IDXD **dedupes repeated values**. Used to identify the save blob's index space, now **solved**: the blob follows **`strings.tbl`'s** order — the display order *plus* the cut items only the localisation file lists (`Adhesive Mine B2A`, `Ballista GSH`, …) — pinned by four hand-written probe saves (9 Stiletto, 21 Falcon, 39 Tomahawk, 48 Jamming System) and closing exactly at index 53. `weapon.tbl`'s id list is **not** the index space; that it is also 54 long is a coincidence, and the two agree only to index 32. The retail save's five unexplained owned entries are the cut items, shipped owned and never rendered |
|
||||
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` is decoded — it is a **looping sprite animation**, not a composition. ⚠️ **DEMOTED 2026-08-18** — the declaration table is *not* the paint order: a per-draw capture of the running title screen ([ui-title-paint-order-capture](ui-title-paint-order-capture.md)) paints element 13 first and elements 0/1 late, and the visible screen composites two bundles. The rest of the table's reading stands. Previously claimed: the **screen's draw list is the RATC bundle's own declaration table** (elements in back-to-front order, including the `eff*`/`deli*`/`msg` sprites that have no `.rat`, and excluding focused button variants reached via `opt `); its entry also carries a **parent element index** at `+32`. **A screen is fully reconstructible from its bundle**: the placement region right after the declaration table gives every element a keyframe group (header = element index + keyframe count, then 40-byte blocks of scale/tint/X/Y), including the `.rat`-less sprites — verified 11/11 on the tutorial pause bundle, with `pgp_ttrl_btn10`'s inline (546,288) matching its own record exactly |
|
||||
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` is decoded — it is a **looping sprite animation**, not a composition. ⚠️ **DEMOTED 2026-08-18** — the declaration table is *not* the paint order: a per-draw capture of the running title screen ([ui-title-paint-order-capture](ui-title-paint-order-capture.md)) paints element 13 first and elements 0/1 late, and the visible screen composites two bundles. The rest of the table's reading stands. Previously claimed: the **screen's draw list is the RATC bundle's own declaration table** (elements in back-to-front order, including the `eff*`/`deli*`/`msg` sprites that have no `.rat`, and excluding focused button variants reached via `opt `); its entry also carries a **parent element index** at `+32`. **A screen is fully reconstructible from its bundle**: the placement region right after the declaration table gives every element a keyframe group (header = element index + keyframe count, then 40-byte blocks of scale/tint/X/Y), including the `.rat`-less sprites — verified 11/11 on the tutorial pause bundle, with `pgp_ttrl_btn10`'s inline (546,288) matching its own record exactly. **✅ 2026-08-28: the keyframe block's `+12` is a screen-plane ROTATION in degrees**, clockwise-positive — confirmed against a GPU capture (declared 30 / −45 vs measured +30.26° / −45.28°) and non-zero in 14.50 % of 83 862 blocks disc-wide ([ui-keyframe-rotation](structures/ui-keyframe-rotation.md)). ⚠️ Every rotation on the disc is inside a **nested** `.rat` leaf record, and `ui_layout::blit` is axis-aligned, so the field is decoded but **not rendered**. 🟡 `+4`/`+8` remain unexplained |
|
||||
| UI screen paint order | ✅ | [runtime screen object](structures/ui-screen-runtime.md) + [title capture](ui-title-paint-order-capture.md) | **SOLVED**: the game's screen object keeps a second, reordered list of its elements — the child array at `+0x30` — and that is the paint order, not the declaration table. Read live from guest memory (found by the item vtable `0x820b30b4`) and checked against the draw capture: the seven nameable elements sit at child slots 0, 6, 7, 13, 16, 17, 22, strictly ascending, exactly as captured; it also settles the one pair no static field could order. 🟡 deriving that order from the bundle — what the port needs — is still open |
|
||||
| Scripted input / profile traps | ✅/🟡 | [canary-scripted-input-traps](canary-scripted-input-traps.md) | Why a scripted run appears unable to press Ⓐ: **F10 opens the emulator menu bar**, and any Xenia UI makes `XamInputGetKeystrokeEx` return SUCCESS with an empty keystroke *before* any driver is asked (Canary now logs `[RE-INPUT] … swallowed by IsUIActive`); the title needs a **signed-in profile** (hence `--create_profile_if_none`); and a **FIFO trace consumer that exits stalls the emulator**, which reads exactly like a dead pad. 🟡 The main menu HAS been reached — Ⓐ works, but only intermittently (1 in ~4), which is the open question |
|
||||
| Title-screen guest crash | ✅ | [title-crash-stl-tree](title-crash-stl-tree.md) | The guest throws **`std::out_of_range`** from its cache-manager flush (`sub_823070B0`, an STL map/set erase that builds `'invalid map/set<T> iterator'`); the access violation after it is only the throw **returning**, because this build does not unwind guest EH. Trigger found and controlled: an **incomplete on-disc cache** (`~/.local/share/Xenia/cache/aab216c3`) throws ~100 s into a boot, a complete one never does — 2 runs each way. ❌ `mem_watch`, the handoff's suspect #1, is **eliminated**: cold cache + `--mem_watch=false` throws anyway |
|
||||
@@ -103,6 +103,7 @@ files, which is how the same ground got covered twice.
|
||||
| [`structures/slb-data-offset.md`](structures/slb-data-offset.md) | `.slb` data offset = `(cumulative start of the `.pNN` segment) mod 2048`; the leading bytes are the previous bank's audio, not a header | ✅ CONFIRMED — exact for 8 783/8 783 banks, 0 mismatches; the four values (1392/1468/1600/1728) are the running sums of the five segment sizes mod 2048, which supersedes the earlier `first_riff % 2048` scan heuristic. Decoding at the right offset yields more audio in 85 of 140 sampled banks (median 70×), 54 identical controls. Wave boundaries are exact — `seek` magic at `data_at + declared_size`, **7 620/7 620** — and `Channels` must be read from `RIFF+49` (2.12 % are stereo) |
|
||||
| [`structures/sound-pak-contents.md`](structures/sound-pak-contents.md) | Census of `sound.pak`, and the limit of the leading-region rule | ✅ CONFIRMED, 5 135/5 135 names hash into the TOC, **9 519/9 519** entries accounted for, and a full 4 114-bank manifest (408.3 min of audio) computed from PsuedoBytesPerSec without decoding; ⚠️ leading-region rule holds for 1 571/4 382 eng and 0/5 100 jpn |
|
||||
| [`structures/sound-cue-table.md`](structures/sound-cue-table.md) | The cue index in `tables.pak` — message id -> cue -> sound id -> `.slb` bank | ✅ CONFIRMED, 1 326/1 338 script message ids bind to a bank; SOUNDS and FILES agree on the same 12 absentees, 0 orphan files |
|
||||
| [`structures/bgm-two-stems.md`](structures/bgm-two-stems.md) | A music bank is two stems of one performance, played together | ✅ decoded structure, 32/32 banks two waves of equal duration (4 explained by the leading-region straddle); stems measured sample-synchronous; 🟡 rear-pair vs intensity-layer unsettled; ❔ no bank names a screen |
|
||||
| [`structures/cutscene-message-table.md`](structures/cutscene-message-table.md) | Cutscene dialogue — speaker, portrait, on-screen seconds, audio cue per page | ✅ CONFIRMED, field count = 9·PageCount+2 for all 7 PageCounts, 1 252/1 252 caption keys match, 138 ids close both ways |
|
||||
| [`structures/preset-message-rules.md`](structures/preset-message-rules.md) | The reactive-chatter rule table — 64 events, the (message, Yes/No) pair payload, the `Sperkers` roster | ✅ CONFIRMED, the seven fields read off `sub_82213980`, phase tables merge non-additively (224 variants discarded); 8 firing sites share one entry, Probability is a per-cent roll against rand*100.0; Yes = one-shot line; queue is Priority-ordered and capped at 128; the cooldown is Interval + Fluctuation*rand held in the lingering queue node; the 14 player-facing events are voiced by the player set alone (13/15 Katana tables, 0/129 others) (three are seconds ×60, the Yes/No element is a u32 mask, hence the 32 clamp); MessageCount·2 == positional count in 9 216/9 216; five naming routes union to 144/144 (six names predicted from the speaker roster before hashing, control 0/4); 2 388/2 405 message ids bind to a cue |
|
||||
| [`structures/mission-scoring.md`](structures/mission-scoring.md) | Mission scoring and the S/A/B/C/D rank thresholds — 24 tables x 3 difficulties | ✅ CONFIRMED, one 22-field schema over 72 records; difficulty moves 10 of 22 fields and never the RankScore thresholds; ✅ all 24 named stage\StageParameter_S<NN>.tbl (+ _Tutorial shared by six, + _Test) |
|
||||
@@ -149,6 +150,13 @@ files, which is how the same ground got covered twice.
|
||||
| [`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 |
|
||||
| [`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-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 |
|
||||
|
||||
@@ -117,3 +117,782 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
|
||||
* **Measure animation in submitted frames, not in seconds.** `VdSwap` counts are
|
||||
the guest's own frames, so an emulator at 80 % of real time does not move them;
|
||||
a stopwatch reading does, silently and by an unknown factor.
|
||||
* **~~This game's menus drop d-pad presses shorter than ~0.3 s.~~ WITHDRAWN
|
||||
2026-08-28 — the menu WRAPS, and I had not measured that.** The claim came from
|
||||
reading a cursor that ended up "one item short"; once wrap-around at both ends
|
||||
was measured ([`menu-navigation-semantics.md`](menu-navigation-semantics.md)),
|
||||
every one of those press counts is exactly right — four presses at 0.12 s moved
|
||||
four steps *through the bottom*, which lands one above where a non-wrapping
|
||||
menu would put it. **No press was ever dropped.** The real lesson is the
|
||||
general one: *a step count is only readable once you know the topology*, and I
|
||||
invented a hardware-flakiness story rather than testing the ends of the list.
|
||||
Still true and worth keeping: screenshot after every step and read the cursor,
|
||||
rather than trusting arithmetic over the press count.
|
||||
* **A screenshot taken right after a transition can catch a screen mid-fade.**
|
||||
A grab 2.5 s after Ⓑ returned the game to the title showed the title art with
|
||||
no `PRESS Ⓐ BUTTON` plate; one second later the plate was there. That very
|
||||
nearly went into the corpus as "the returned title has no plate". Sample a
|
||||
changing screen several times before writing down what it does *not* contain.
|
||||
* **Do not identify a menu cursor by label brightness.** The obvious oracle —
|
||||
"the focused label is the brightest row" — fails on this game's menus, because
|
||||
the background art is brighter behind some rows than the highlight is. It
|
||||
confidently named the wrong item on a frame whose ring was plainly elsewhere.
|
||||
Detect the **focus ring** in the gutter left of the labels instead
|
||||
(`tools/re-capture/menu_focus.py`, 254 vs <82 — no threshold tuning needed),
|
||||
and look at the PNG before believing either.
|
||||
* **`screenshot` samples at 0.5 Hz — it cannot time an animation.** Measured:
|
||||
~2 s per grab (an `import` of the root plus an ImageMagick crop). A 0.4 s fade
|
||||
falls entirely between two samples, which is why a 40-frame burst across a
|
||||
screen change looked like an instant cut. For anything timed, record the
|
||||
display instead: `ffmpeg -f x11grab -framerate 30 -video_size <W>x<H> -i
|
||||
:98+<X>,<Y> -t <secs>`, then read per-frame statistics off the file. Take the
|
||||
geometry from `xwininfo -root -tree`, the same way `bin/screenshot` does.
|
||||
* **A screen's brightness curve is not its fade quad.** The incoming screen's own
|
||||
elements animate in *after* the transition quad has cleared, so mean luminance
|
||||
keeps rising long after the fade is over — 1.47 s against a declared 0.97 s on
|
||||
one screen. Time the fade from where the frame is *pure black*, and take the
|
||||
ramp itself from the keyframes.
|
||||
* **A `sound.pak` entry is not a bank.** Entry windows straddle bank boundaries
|
||||
(the leading-region effect), so a naive "walk the RIFFs in this entry" gives
|
||||
the tail of the previous bank, then one of your own, then the *next* bank's
|
||||
header. It looks like a bank with the wrong number of waves. The tell is a
|
||||
`BANK id=` that is not the entry's own number, and the fix is the `seek` packet
|
||||
counts — they pin each wave's join exactly, so a straddle can be realigned
|
||||
rather than thrown away. 4 of 32 BGM banks look like counterexamples until this
|
||||
is applied.
|
||||
* **Do not time a video whose start you did not see.** An attract cycle sampled
|
||||
from partway through measured "≈85 s", which was used to argue it was not the
|
||||
137 s `ADV.wmv`. It was `ADV.wmv`; the sampling began 39 s in. Identify a video
|
||||
by **content** — match frames against the candidates and check the matched
|
||||
playhead advances monotonically — before reasoning about its duration.
|
||||
* **A near-black frame has no signature.** In frame-matching, low-contrast frames
|
||||
drive every correlation toward zero and the ranking becomes noise; one control
|
||||
frame scored 0.000 and another tied the wrong movie at 0.98. Read a dark
|
||||
frame's runner-up as *no evidence*, not as evidence for the runner-up.
|
||||
* **A screen that never changes is not necessarily hung — it may be a menu.** A
|
||||
90-second screenshot loop that presses nothing will report a perfectly healthy
|
||||
`DIFFICULTY` menu as a standing hang, and that reading survived in this corpus
|
||||
for months. Before calling a static screen a hang, *look at it*, and press
|
||||
something.
|
||||
* **Never run `ps -ef` in this container.** Each of the three long-lived
|
||||
processes carries the whole loop prompt as its argv, so the listing is tens of
|
||||
kilobytes of the prompt you already have. Use `pgrep -x <name>` or
|
||||
`ps -o pid=,stat= -C <name>`.
|
||||
* **A file extension is not a format.** `sound.pak` is full of `.slb` banks and
|
||||
names a `.xgs` project, and neither is an XACT container: the whole 1.08 GB
|
||||
archive contains zero `XGSF`, `SDBK` or `WBND` magic, and the executable has no
|
||||
XACT string at all. The names came from the authoring tool and survived into the
|
||||
shipped data. Check for the magic before committing to a parser for the format
|
||||
the extension advertises.
|
||||
* **A log-polling probe must compare a COUNT, not test for presence.** Timing an
|
||||
operation by waiting for its "done" line in a log gives an instant false
|
||||
positive if any earlier run left one there. This produced a confident
|
||||
"39.5 fps" that was really 0.1 s of matching a stale line — and it disagreed
|
||||
with the true 28.5 fps by enough to have flipped a conclusion. Snapshot
|
||||
`log.count(marker)` before starting and wait for it to *increase*.
|
||||
* **This container kills long emulator runs.** Twice in one session the
|
||||
`xenia_canary` process vanished with **no crash line in its own log** — once
|
||||
~50 s into a boot, once ~145 s in at 1.15 GB RSS — while the box sat at
|
||||
~1 GB free with swap exhausted. Budget dynamic experiments to fit in ~2 minutes
|
||||
of guest time, take the measurement early, and never read a disappearance as a
|
||||
guest crash without checking the log tail for one.
|
||||
* **When a frame-match will not resolve, sample denser before doubting the
|
||||
hypothesis.** Identifying a movie from 5-second screenshots gave contrast-23
|
||||
frames, a playhead that would not join up, and a runner-up winning one frame —
|
||||
it looked like weak evidence for the wrong film. The same question sampled every
|
||||
0.5 s (`ffmpeg x11grab` at 10 fps) answered at **0.999 with a strictly monotone
|
||||
playhead**. Sparse sampling does not weaken a signal gracefully; it turns it
|
||||
into noise that looks like a different answer.
|
||||
* **Check what instrumentation the local build already has before declaring a
|
||||
question blocked on tooling.** Two Q8 residuals were written off as needing "an
|
||||
emulator whose audio path can be observed". The emulator in this container
|
||||
already had `xma_param_probe` — visible in the startup CONFIG DUMP the whole
|
||||
time — added by this project for exactly that purpose. Read the cvar dump, or
|
||||
`nm -C <binary> | grep cvars::`, before concluding you cannot measure something.
|
||||
* **"Strings a function references" is not "arguments a function passes".** A
|
||||
loose filter over the strings in a caller produced a tidy-looking screen
|
||||
vocabulary; recovering the actual register argument at each call site removed
|
||||
half of it, including two names that appeared to be corroborated by independent
|
||||
measurement. If the claim is about an argument, simulate the registers — the
|
||||
cheap proxy manufactures agreement.
|
||||
* **A backward scan for `addi rN, …` misses arguments staged through another
|
||||
register.** PPC code often builds a pointer in a scratch register and moves it
|
||||
into place later (`addi r27, r11, X` … `mr r5, r27`). A backward search for the
|
||||
destination register alone found 7 of 48 sites and missed the two known-good
|
||||
ones. Simulate forward over a window instead, tracking `lis`/`addi`/`mr`.
|
||||
* **Check a measurement against the WHOLE reference set, not the part you were
|
||||
reading.** Two observed BGM stream sizes were recorded as matching "no bank's
|
||||
declared waves", which spawned a wrong theory that the decoder gets a window
|
||||
rather than a whole wave. They match `BGM_103` exactly — the check had covered
|
||||
only the `BGM_0xx` rows of the census, because that is the block that had been
|
||||
on screen.
|
||||
* **`instructions.function` is unpopulated for most rows in `sylpheed.db`.** A
|
||||
query keyed on it returns *nothing* rather than erroring — a `bctr` search
|
||||
scoped that way reported "no jump tables" for a function with two known ones.
|
||||
Scope by `address between <start> and <end_address>` instead, and gate any such
|
||||
query on a function whose answer you already know.
|
||||
* **Before testing "state X behaves like this", check that your route actually
|
||||
reaches state X.** A test of "does Ⓐ work in phase 4 state 0" was run by
|
||||
pressing Ⓑ, on the strength of a decoded `4 → state 0` edge — but the same basic
|
||||
block also writes the *phase* field, sending it to phase 2. The test probed the
|
||||
wrong state and produced a confident refutation of a live hypothesis. When a
|
||||
transition sets more than one field, read the whole block, not the one store you
|
||||
were looking for.
|
||||
* **A default filter can hide a whole screen, and the corpus will record it as
|
||||
absent.** `screen list`/`render` filter on `is_build`, which needs a `.rat`
|
||||
child; the developer splash has none, so it never appeared — and a page ended up
|
||||
stating it "cannot be rendered at all". It renders fine under `--all`. When two
|
||||
pages disagree about whether something exists, run the tool both ways before
|
||||
believing either.
|
||||
* **A logo that appears twice may be two different things.** The boot shows
|
||||
`SQUARE ENIX` at ~2 s and again at ~10 s; the first is the splash screen, the
|
||||
second is the intro movie's own opening frame. Reading the capture alone gives
|
||||
"three logo screens". Cross-check a repeat against what is *supposed* to be
|
||||
playing at that moment before counting it as its own screen.
|
||||
* **Do not hard-code an expected element count into a structure scan.** Looking
|
||||
for a `.rat` record's keyframes, I required 29 strictly-increasing times because
|
||||
a header word read `30`. The records hold **three** keyframes in exactly the
|
||||
layout I was testing for, and the over-constrained filter reported the layout as
|
||||
refuted. Scan for the *shape* (here: `0x??ffffff` fade words at a constant
|
||||
stride) and let the count fall out.
|
||||
* **Read the target text before writing a patch pattern for it.** Two iterations
|
||||
running, a scripted edit to `HANDOFF.md` failed its assertion because the
|
||||
paragraph had been rewrapped since the pattern was written — and both times the
|
||||
finding landed in `docs/re/` while the page the port agent reads kept the stale
|
||||
version. The commit's file count is the tell: if it is one short, an edit was
|
||||
silently dropped.
|
||||
* **Two renderers agreeing is not evidence the field is right.** The port's
|
||||
composite and `sylpheed-cli screen render` matched on `main_menu` to 3/255 —
|
||||
and *both* omitted two elements the game draws, because both read one field
|
||||
through one decoder. Agreement between consumers of the same decode measures
|
||||
the decode's determinism, not its truth. Only a framebuffer capture is an
|
||||
independent check. (Reported by the port agent, 2026-08-28, after a capture
|
||||
crop found the missing bracket.)
|
||||
* **`./target/debug/<bin>` is STALE in this container — `CARGO_TARGET_DIR` is
|
||||
redirected.** The Dockerfile sets `CARGO_TARGET_DIR=/sylph-home/re/target-container`
|
||||
so a fresh `cargo build` writes there, while `./target/debug/sylpheed-cli`
|
||||
remains whatever was built on the host, hours old. A decoder fix was verified
|
||||
against the stale binary and produced byte-identical "before" and "after"
|
||||
renders — which reads exactly like "the change had no effect" rather than "you
|
||||
ran the old code". Run the binary cargo actually wrote, or `cargo run`.
|
||||
* **A pivot-equality test conflates rounding with real breakage.** Checking
|
||||
`pivot == texture/2` over `GP_TITLE` flagged 74 % as mismatched, which reads as
|
||||
a systemic decode failure. Split by magnitude it is 17 exact, 54 off by ≤ 1 px
|
||||
(odd dimensions rounding), and only **24 off by > 8 px** — and those 24 are the
|
||||
real finding, concentrated on one element family. Bucket a mismatch by size
|
||||
before reporting a rate.
|
||||
* **I have now broken the "read the target text first" rule three times.** Each
|
||||
time a scripted `HANDOFF.md` edit asserted on a paragraph that had been rewrapped
|
||||
by an earlier edit in the same session, and each time the finding landed in
|
||||
`docs/re/` while the port's page kept the stale text. Writing the rule down was
|
||||
not enough; the working fix is mechanical — `grep`/`sed -n` the target paragraph
|
||||
in the same command that patches it, and check the commit's file count.
|
||||
* **"The nearest header after the name" is not a name→header mapping.** Looking
|
||||
for each sprite's `T8aD` header by searching for its name and taking the next
|
||||
`T8aD` returned *the same header for every sprite* — all the names live together
|
||||
in the declaration table near the top of the bundle, so "next" is always the
|
||||
first one in the file. It failed its own control instantly: different sprites
|
||||
reported identical dimensions. Match on something the header itself carries
|
||||
(here width × height against the decoded PNGs) rather than on proximity.
|
||||
* **`cargo build` passing does not mean `cargo test` compiles.** Adding a field to
|
||||
`T8adImage` built the library fine in 1.48 s — and broke two *test-only* struct
|
||||
literals, so `cargo test` failed to compile with exit 101. A green build on the
|
||||
binary is not a gate for a struct change; run the tests before believing a
|
||||
data-structure edit is safe.
|
||||
* **Detach long background work with `setsid`, or a tool-call timeout kills it.**
|
||||
Three attempts to run the disc-gated test suite in the background died at exit
|
||||
143 with an empty log — the timeout's SIGTERM reached the whole process group.
|
||||
`setsid nohup cargo test … &` survives, and the run then completes across later
|
||||
tool calls. An empty log from a "background" job usually means it was killed,
|
||||
not that it produced nothing.
|
||||
* **Validate a recommendation before leaving it as advice.** Having concluded a
|
||||
question was undecodable from the disc, I named a per-draw GPU capture as the
|
||||
next route "because it reads the blend state". It does not — checking the
|
||||
capture's own source showed it records textures and vertex attributes and no
|
||||
blend registers. A named next step is a claim like any other.
|
||||
* **Name the element from the draw's own coordinates before building on it.** A
|
||||
skewed draw in a title capture was called "the swoosh" because the swoosh is the
|
||||
screen's only diagonal element — an identification by elimination, never checked
|
||||
against the draw's NDC extent. It was wrong: the quads span the full screen and
|
||||
beyond, the swoosh is a 234-pixel band. Five iterations of analysis pointed at
|
||||
the wrong element on the strength of it. Converting the coordinates takes one
|
||||
line and should come first.
|
||||
* **A census over a bundle's top-level table is not a census over the bundle.**
|
||||
The keyframe rotation field read "always zero on this screen" for several
|
||||
iterations because every scan walked `GP_TITLE` build 4's **declaration
|
||||
table**, and the rotated elements are **nested leaf records** reached through
|
||||
an `opt ` link. The bytes were right; the *reach* was wrong, and a negative
|
||||
stated without its reach reads like a fact about the disc. Say which region a
|
||||
negative covers, and check whether the thing you are looking for lives outside
|
||||
it.
|
||||
* **Do not assume 4-byte alignment when scanning raw bundle bytes.** A nested
|
||||
`RATC` blob starts wherever the parent's chunk stream leaves it — `ptloop01.rat`
|
||||
sits at `0xbb5966` — so its 40-byte keyframe blocks are odd-aligned. A scanner
|
||||
that filtered candidates on `%4 == 0` found **0/3** of its own control blocks
|
||||
and under-counted the corpus by **16 341** blocks, all of them nested. It cost
|
||||
nothing to catch, because the control was in the script.
|
||||
* **`pkill -f <pattern>` matches the shell running it.** `pkill -f kfscan.py`
|
||||
from a `bash -c` whose command line contains `kfscan.py` kills its own shell:
|
||||
the tool call returns exit 143/144 and the edit that was queued behind it never
|
||||
runs. The same trap makes `pgrep -f` self-report — a "still running? yes" that
|
||||
was the shell seeing itself, on a job that had already finished. Use
|
||||
`pgrep -x`, or match on a path the current command line does not contain.
|
||||
(This is the second `ps`/`pgrep` entry in this file; the first is about
|
||||
`ps -ef` dumping the loop prompt.)
|
||||
* **Grep a legend and you count the legend.** A sweep for elements with a
|
||||
rotation reported "1 element" in every build on the disc — the header line
|
||||
`a=alpha r=rot°` matched the ` r=` pattern. A uniform count across
|
||||
heterogeneous inputs is the tell. Make the pattern require the *value*
|
||||
(` r=-?[0-9]+`), and sanity-check that a known-negative build reports zero.
|
||||
* **A pattern over the archives you happened to check is not a negative.** I
|
||||
wrote "rotation appears to live only in nested leaf records", correctly scoped
|
||||
to the three paks I had swept — and the sweep refuted it on the fourth, within
|
||||
the hour, because the alphabet had not reached `GP_DIALOG` yet. Scoping the
|
||||
claim was what made it cheap to withdraw, but the better move is to let the
|
||||
sweep finish before writing the sentence at all: a negative that is still
|
||||
running is not a finding, it is a prediction.
|
||||
* **A field that is implemented is not a field that is measured.** The
|
||||
pivot-anchored scale term had been in `blit` for months and reasoned about in
|
||||
the handoff, and no capture had ever exercised it, because every element anyone
|
||||
had looked at was at 100 % scale where the term is identically zero. Look for
|
||||
the case that makes a term *large* — here 600 % and 800 % scale, worth 450 px —
|
||||
and check it there. A term you cannot distinguish from zero has not been
|
||||
verified by any amount of agreement.
|
||||
* **Printing a series' minimum and reading it as its range.** I summarised a
|
||||
captured alpha series as "constant α ≈ 33" and built a contradiction on it —
|
||||
the summary printed `min_alpha` and no maximum, and the series actually ramps
|
||||
34 → 255 → 33. The tell was there in the same table: the column beside it said
|
||||
*14 distinct colours*, which a constant series cannot have. When a summary
|
||||
statistic and a distinct-value count disagree, the summary is wrong.
|
||||
* **Calibrate on one element, test on another.** Fitting a declared ramp to a
|
||||
capture has two free parameters (rate and offset) and will "succeed" against
|
||||
almost anything — my first attempt scored RMS 128/255 and I nearly read the
|
||||
numbers rather than noticing the search could not reach the ramp at all. The
|
||||
version that means something: fix the clock from element A's ramp, check that
|
||||
fix against A's own next landmark, then apply it to element B in the same
|
||||
frames with **nothing left to tune**. That is what turned "the shapes look
|
||||
different" into "still at 255 nine frames after it should read 32".
|
||||
* **Two measurements can both be right and still disagree, when a heuristic sits
|
||||
between them.** A change to the keyframe time association is favoured 26× by an
|
||||
animation measurement and rejected by a static-render comparison. Both are
|
||||
sound: `rest()` picks a pose by *longest dwell*, which is a heuristic layered on
|
||||
top of the times, so moving the times moves its tie-breaks. The mistake would
|
||||
be to treat one as refuting the other and drop the loser. Name what each
|
||||
measurement actually constrains — here *timing* versus *pose selection* — before
|
||||
deciding they conflict.
|
||||
* **Before believing a render diff refutes a decode, find out which element moved.**
|
||||
I reported a 13 % render change as evidence against a keyframe-time hypothesis,
|
||||
on a brightness argument about language twins. One element accounted for all of
|
||||
it, and that element is a transient flash with **no resting pose** — so the
|
||||
difference was our own `rest()` heuristic guessing, and it would have guessed
|
||||
under either reading. A whole-image statistic (pixels changed, mean luminance,
|
||||
correlation) tells you *that* something moved, never *what*, and an argument
|
||||
built on it can be entirely about your own code. Localise the diff to an
|
||||
element and read its keyframes first.
|
||||
* **A fallback that only runs when its precondition fails is not a fallback.**
|
||||
`rest()` picks the longest-dwell keyframe when no plateau exists — but a dwell
|
||||
gap is time spent *moving between* two poses, and the only case where an
|
||||
endpoint is genuinely held is when the two poses are equal, which is exactly
|
||||
the plateau the first path already caught. So the fallback is guaranteed to be
|
||||
guessing every time it is reached. When a heuristic's justification is the same
|
||||
condition that routes around it, it has no justification.
|
||||
* **Siblings adjudicate a rule that a single element cannot.** Deciding what
|
||||
`rest()` should return for an element with no held pose looked like it needed a
|
||||
capture we do not have. It did not: the developer splash carries three glows
|
||||
with identical structure and identical times, differing in one alpha byte, and
|
||||
any rule that makes one of them behave completely differently from the other
|
||||
two is producing an artefact. Parallel constructions in the same bundle are a
|
||||
free control — look for them before concluding a question needs new measurement.
|
||||
* **Backgrounding with `&` in a compound command loses the working directory.**
|
||||
`cd X && cmd &` followed by more work in the same call left the shell reset, and
|
||||
a heredoc two lines later wrote nowhere — while the `echo` after it still
|
||||
printed "written". A success message after a failed redirect is not a success.
|
||||
Use `(setsid nohup … &)` and keep the file-writing in its own call.
|
||||
* **Read the whole function before describing what it does.** I wrote "a
|
||||
zero-scale pose paints a 1-pixel sliver" into a tool's docstring, from the
|
||||
`.max(1)` in the size arithmetic. Two lines above it, a guard coerced
|
||||
`scale == 0` to **100 %**, so the `.max(1)` never saw a zero and the element
|
||||
rendered at *full size* — a much larger bug than the one I described. I had
|
||||
read the lines I went looking for and stopped.
|
||||
* **Never diff a file a background job might still be writing.** A comparison
|
||||
loop over an output directory reported `GP_TITLE` build 6 as changed; the pixel
|
||||
diff was then all zeros, because `cmp` had read a half-written PNG. Compare
|
||||
only after the job signals done, or skip the newest file. A "difference" that
|
||||
vanishes on re-examination is usually a race, not a flake.
|
||||
* **A `screen list` BUILD index is not a pak ENTRY index.** `GP_TITLE` happens to
|
||||
map 1:1 (16 builds, entries 0–15), which is how the assumption survived;
|
||||
`GP_DIALOG` does not — its build 0 is **entry 2**. Indexing a pak directly with
|
||||
a build number silently reads a different bundle and reports a confident wrong
|
||||
answer: a census control asked for the two rotated elements of `GP_DIALOG`
|
||||
build 0 and got zero, from a bundle that genuinely has none. `screen list`
|
||||
prints the mapping; use it rather than assuming identity.
|
||||
* **"There is no flag" is not "there is no way".** I declared a Japanese-locale
|
||||
capture impossible in this container because `user_language` is `DECLARE`d and
|
||||
never `DEFINE`d, so no cvar exists to pass. That was true and not the question.
|
||||
The language is *persisted*: canary builds `XConfig` over
|
||||
`<storage_root>/xconfig.settings`, `SetDefaults()` only supplies the value when
|
||||
the file has none, and the file is writable. One grep further — for where the
|
||||
setting is **stored** rather than where it is **configured** — turned a
|
||||
"blocked, needs a human decision" into a two-line edit. When you conclude a
|
||||
capability is absent, check the persistence layer before writing it up.
|
||||
* **Locate a binary field from struct landmarks, not from a hard-coded offset.**
|
||||
`user.language` was found by scanning for `music_volume`'s `0.7f` (a known
|
||||
default at a known relative offset), then cross-checking `language == 1` and
|
||||
`country == 103` against the emulator's own `SetDefaults()`. Three agreeing
|
||||
landmarks, no offset assumed, and the check re-runs every invocation — so the
|
||||
tool fails loudly if the layout moves instead of silently patching a
|
||||
neighbouring field.
|
||||
* **A stale oracle reports the world, not itself.** A 787 s run concluded "the
|
||||
game never reached the title". It had, repeatedly: `wait_title.sh` was still
|
||||
sampling the single pixel (625,618) that `is_title.py` had already been written
|
||||
to replace — a 1280×720 coordinate against a 1279×675 game surface, always
|
||||
reading the copyright line. The replacement existed *in the same directory*,
|
||||
with a docstring naming the bug. Before believing a negative from a probe,
|
||||
check whether something in the toolkit already supersedes it, and run the probe
|
||||
against a known-positive capture first — `is_title.py` scores 753 on a real
|
||||
title frame and 327 on the main menu, which takes one command to confirm.
|
||||
* **Run the control before theorising about the difference.** A Japanese-locale
|
||||
run failed to reach the interactive title, and I wrote up the open question as
|
||||
"if English reaches it, the difference is the locale". English does not reach
|
||||
it either — 75 samples over 734 s, every one a miss. The locale had nothing to
|
||||
do with it, and two iterations framed the problem around a variable that was
|
||||
never implicated. The control cost one run and would have cost the same one
|
||||
iteration earlier.
|
||||
* **Log every sample, not just the hit.** The first probe printed nothing until
|
||||
it succeeded, so a 787 s failure was indistinguishable from a probe that never
|
||||
ran. Printing the oracle's value each tick turns "no title" into "75 samples,
|
||||
max glyph 0", which is a measurement — and it is what showed the black-screen
|
||||
and attract-movie phases were being sampled correctly all along.
|
||||
* **Know when to stop paying for a non-blocking answer.** A capture that would
|
||||
settle two 🟡 residuals — neither of which blocks the deliverable — absorbed
|
||||
five iterations. Each one produced a real finding (a broken oracle, a wrong
|
||||
"impossible", a locale red herring), so none was wasted, but the *question*
|
||||
never moved. The signal to stop is not "this is hard", it is "the thing this
|
||||
unblocks is not blocking anything". Write down the recipe you built and leave
|
||||
it for a session with a reason to spend the time.
|
||||
* **Audit what a renderer silently SKIPS, not only what it draws.** `compose` has
|
||||
half a dozen `continue` paths, and a sprite-name miss is one of them — an
|
||||
element vanishes with no error, which is exactly how the port agent's missing
|
||||
bracket happened. `screen render` already prints its omissions; reading that
|
||||
list for the five screens that matter took one command and turned up a
|
||||
full-screen opaque element dropped on three of them. Any composer with skip
|
||||
conditions should be asked to name what it skipped before it is trusted.
|
||||
* **An element's declared name is not necessarily its texture's name.** The
|
||||
`opt ` link on `pteff05.t32` leads to a `T8aD` registered as `8AX`, so a
|
||||
lookup by declared name misses. Before concluding a texture is absent, look at
|
||||
the bytes the link actually points at — the name in the declaration table and
|
||||
the name the sprite table keys on can differ.
|
||||
* **A fit whose residual is large for every model is a broken method, not a
|
||||
close call.** Comparing our composite to a capture pixel-wise gave mean abs
|
||||
errors of 10–14 for gamma, for a linear scale, for everything — and a
|
||||
non-monotonic transfer curve (render 96–127 mapping *brighter* than render
|
||||
128–159). The cause was edge misalignment: at correlation 0.947 a bright pixel
|
||||
in one image routinely lands on a dark one in the other. Restricting to patches
|
||||
that are flat in **both** images dropped the residual to 0.2–1.1. When every
|
||||
candidate model fits badly, stop choosing between them and look at what the
|
||||
comparison is actually measuring.
|
||||
* **Say when a control failed to discriminate, rather than reporting it as a
|
||||
pass.** The held-out screen for a tone-curve fit was the developer splash,
|
||||
whose flat regions are pure black — every model scored ≈ 0.00 error there. That
|
||||
is not corroboration; it is a test with no power, and reporting the 0.00 as
|
||||
agreement would have dressed an untested claim as a verified one.
|
||||
* **Time your probe against the thing you are probing for.** Four runs concluded
|
||||
"the game never reaches the title". `screenshot` costs **10.8 s while xenia is
|
||||
running** and **0.117 s once it is killed** — 92× — so a two-grab polling loop
|
||||
samples every ~41 s, against a title screen this corpus documents as lasting a
|
||||
few seconds. The harness was blinking slower than the event. Before believing a
|
||||
negative from a polling loop, measure its interval and compare it to the
|
||||
duration of what you are waiting for; and measure the probe's cost *under the
|
||||
same load as the run*, because idle timing here was off by two orders of
|
||||
magnitude.
|
||||
* **Do not change a display setting and a capture behaviour in the same run.**
|
||||
`kernel_display_gamma_type = 0` brightens the frame, and `skip_intro.sh`
|
||||
classifies movie-vs-static on an *absolute* rmse threshold — so the gamma
|
||||
change biased the very classifier the run depended on. Harness thresholds tuned
|
||||
on one output configuration are not portable to another.
|
||||
* **Fixing the instrument is how you test the explanation that blamed it.** I
|
||||
diagnosed four failed runs as "the probe samples slower than the event",
|
||||
wrote it up, and withdrew three earlier conclusions on that basis. Building the
|
||||
faster probe refuted it: at 100× the sampling density, over 420 unbroken
|
||||
seconds, the event still never occurred. The diagnosis was a real defect that
|
||||
happened not to be the cause — the most seductive kind, because fixing it feels
|
||||
like confirmation. A plausible mechanism that explains a failure is a
|
||||
hypothesis, and the fix is its experiment, not its proof.
|
||||
* **Ask whether the screen is drawing anything before explaining why a feature of
|
||||
it is missing.** Four iterations produced four explanations for "the title is
|
||||
not detected" — a stale oracle, the locale, the sampling rate, a one-shot boot
|
||||
window — and three of them were refuted in turn. Splitting a raw root grab into
|
||||
bands then showed the game surface at **0.08 % non-black, mean 0.07**: it was
|
||||
rendering black. The check costs one command and belongs at the *front* of that
|
||||
sequence, not after the fourth hypothesis. A detector reporting absence is
|
||||
ambiguous between "the feature is not there" and "nothing is there".
|
||||
* **A newly found fault does not retroactively explain older failures.** The
|
||||
black surface is real and reproducible *in the run where it was measured*.
|
||||
Earlier runs in the same session had measurable content (mean 33.1, mean 122.8,
|
||||
non-zero frame-to-frame rmse), so the failure mode changed over the session.
|
||||
Reaching back to re-explain earlier results with the newest cause would have
|
||||
been the fourth over-reach in a row.
|
||||
* **Validating a probe on static test images tests its ANALYSIS, not its
|
||||
ACQUISITION.** The fast probe's counter was controlled against committed
|
||||
captures and matched exactly (753 / 327), which proved the numpy expression
|
||||
right and the frame source untested. The source was the broken half: a
|
||||
long-lived x11grab stream degrades from 3.98 to 1.60 fps and then freezes,
|
||||
repeating one stale frame. The check that catches it is to read the same screen
|
||||
with an **independent grabber at the same moment** — `import` said 125.65 where
|
||||
the stream said 5.21. Do that during a run, not only at design time.
|
||||
* **A dense negative from one instrument is worth less than one cross-check.**
|
||||
"2 391 frames, zero hits" reads as overwhelming and collapses to nothing once
|
||||
the frames may all be the same frame. Sample count is not evidence of coverage
|
||||
unless the samples are known to be independent.
|
||||
* **A probe that cross-checks itself turns "no result" into a result.** Three
|
||||
successive "the title never appears" claims were withdrawn because the
|
||||
instrument was broken each time. The version that finally held prints its own
|
||||
reading beside an independent grabber's every 60 s — 9 checks, 8 agreeing to
|
||||
within 2 % — so the negative arrives with its own evidence that the frames were
|
||||
live. Building the cross-check into the tool costs a few lines and is what
|
||||
separates a measurement from a silence.
|
||||
* **A hedge in a code comment is an unmeasured claim.** `compose` said the
|
||||
derived paint order "reproduces both measured orders up to ties" — a sentence
|
||||
that sounds like a result and was neither measured nor kept current: there were
|
||||
three measured orders by then, not two. Measuring it took one example program
|
||||
and turned a hedge into a bounded number (exact on 4 of 5 bundles; the 5th off
|
||||
by 8 pairs, all ties). Grep your own comments for "up to", "roughly",
|
||||
"essentially" — each one is a claim nobody has checked.
|
||||
* **Count the cases that can actually bite, not the cases that match the
|
||||
pattern.** `EXTRAS` has 15 tied pairs in its derived paint order, which reads
|
||||
alarming. Only **2** of them overlap, and a tie between non-overlapping
|
||||
elements paints identically either way. Reporting 15 would have overstated the
|
||||
risk by 7×; the useful number is the one filtered by whether the difference can
|
||||
reach a pixel.
|
||||
* **An exhaustive field search needs a positive control, or "found nothing" is
|
||||
worthless.** Scanning a header for a field that reproduces a measured ordering
|
||||
returned zero hits — which could equally mean the field is absent or the scan
|
||||
is broken. Running the same scan against a *known* ordering (declaration order)
|
||||
returned **64** hits, proving the scan finds ordering fields when they exist.
|
||||
Only then is the zero a finding. The control costs four extra lines and turns a
|
||||
silence into a negative with reach.
|
||||
* **A stale row on the handoff page is worse than a missing one.** The port was
|
||||
told SE audio was "undecodable from the disc" while the linked doc had already
|
||||
retracted that and located three cues decoding to PCM. The finding landed in
|
||||
`docs/re/` and the page the port reads kept the superseded text — the fourth
|
||||
time in this corpus. Writing the rule down has not worked, so there is now a
|
||||
tool: `tools/re-capture/handoff_lint.py` flags every HANDOFF line that makes a
|
||||
strong negative claim and links a doc containing retraction language. It found
|
||||
this row, and one benign false positive, on its first run.
|
||||
* **Narrow a risk before reporting it, then narrow it again.** "15 tied pairs on
|
||||
`EXTRAS`" became 2 (only overlapping ties can paint differently), then 1 (one
|
||||
of those two is a `loop*` animation the compositor skips), then "consistent
|
||||
with the capture". Each step is a filter that costs one query and changes the
|
||||
number by more than an order of magnitude in total. A raw count is almost never
|
||||
the number a consumer needs.
|
||||
* **Nothing was checking that the docs' cited evidence exists.** A sweep of every
|
||||
relative link under `docs/` found **16 broken**, and two of them were the
|
||||
figures backing the UI layout decode's headline claim — the port's foundation,
|
||||
unreachable from its own page, because a path in `structures/` was written one
|
||||
directory too shallow. Eleven were wrong relative depth with the target present;
|
||||
five name files that do not exist. Evidence that cannot be opened is not
|
||||
evidence, and a link is exactly the kind of thing no one re-reads.
|
||||
`tools/re-capture/doc_link_check.py` now does it, and also flags targets that
|
||||
resolve to a **zero-byte** file — which looks correct in every listing.
|
||||
* **Repair in bulk only when the counts pair.** The fixer rewrote 11 links; the
|
||||
checker went from 1 038 resolving / 16 missing to 1 049 / 5. +11 and −11
|
||||
against 11 edits is the confirmation that the pass did what it said and touched
|
||||
nothing else. A bulk edit without that arithmetic is a hope.
|
||||
* **When two candidates carry the same content, compare their DIFFERENCE against
|
||||
the oracle, not themselves.** `8AX` (1280×720) and `ptbase` (640×360 at 200 %)
|
||||
are the same artwork, so no comparison of either against a capture can separate
|
||||
them — every such test had been read as "inconclusive, needs a per-draw
|
||||
capture". What separates them is `8AX − upscale(ptbase)`: the detail only one
|
||||
of them has. Correlating the capture's residual against *that* answered it
|
||||
statically, on two screens, with matched controls.
|
||||
* **A ratio that saturates at 1 under noise is not a discriminator.** A pixel-pair
|
||||
test cleanly separated upscales (0.00–0.72) from native (0.98) and put the
|
||||
capture at 1.01 — apparently decisive. Additive noise raises both terms of the
|
||||
ratio equally and drives *any* value toward 1, and fitting a noise term showed
|
||||
both hypotheses reproduce the observed numbers. Before believing a ratio, ask
|
||||
what it does as noise grows.
|
||||
* **Read what a cvar DOES before building an experiment around it.** I planned a
|
||||
run to decide whether a measured gamma was "canary's BT.709 output stage",
|
||||
by setting `kernel_display_gamma_type = 0` and re-fitting. The cvar is not an
|
||||
output stage: it is the value a `kStub` **getter returns to the guest**, which
|
||||
the game uses to build its own ramp, and canary applies *that* ramp in the swap
|
||||
path. The experiment could never have isolated a stage that does not exist —
|
||||
and it would have perturbed the capture harness as a side effect. One grep for
|
||||
the cvar's definition replaced a planned emulator run with a better one.
|
||||
* **An absence in a log is only evidence if the log would have shown it.** No
|
||||
gamma lines appear in any run log here — but kernel exports log at Debug and
|
||||
this harness masks Kernel logging entirely (`log_mask = 13`), so their absence
|
||||
is guaranteed regardless of what the game did. Check the logging configuration
|
||||
before reading silence as a result.
|
||||
* **Check what an experiment actually needs before filing it behind a blocker.**
|
||||
The gamma question sat parked behind "needs the emulator to reach a menu" for
|
||||
several iterations. It needed the emulator only to **boot** — video init, and
|
||||
the `VdGetCurrentDisplayGamma` call, happen in the first seconds, long before
|
||||
any title screen. A blocker that stops one experiment does not stop every
|
||||
experiment in the same area, and it is worth re-reading the parked list against
|
||||
what each item really requires rather than against the area it belongs to.
|
||||
* **A default value is evidence.** Whether the game writes a gamma ramp looked
|
||||
like it needed a GPU trace. It mostly did not: canary initialises the ramp
|
||||
table to **identity** and applies it through a shader that is a pure LUT lookup
|
||||
with no other transfer. An unwritten ramp is therefore a no-op, and any
|
||||
non-identity transfer in the output implies a write. Reading what a field holds
|
||||
when nobody has touched it turns "I cannot observe the write" into "the write
|
||||
must have happened" — cheaper than instrumenting, though it stays an inference
|
||||
and should be labelled one.
|
||||
* **Name the weak joint of an inference in the same breath as the conclusion.**
|
||||
The chain above assumes our composite reproduces the pre-ramp framebuffer,
|
||||
which is the one step that could be wrong. Writing that down beside the
|
||||
conclusion — rather than only the supporting facts — is what lets a later
|
||||
reader attack it instead of inheriting it.
|
||||
* **The startup config dump is the config FILE, not the effective flags.** A run
|
||||
passed `--log_mask=12 --log_level=3` and its dump printed `log_mask = 0,
|
||||
log_level = 2` — while Kernel Debug logging was demonstrably on, which is how
|
||||
that run's finding was obtained. So a dump can neither confirm nor refute a
|
||||
command-line override, and reading one as "my flag was ignored" is a mistake I
|
||||
nearly made with `trace_gpu_stream`. Verify a flag by its *effect*, not by the
|
||||
dump. (This does not undo the earlier `user_language` conclusion: absence of a
|
||||
*name* from the dump still shows the cvar is unregistered.)
|
||||
* **`kill -9` destroys anything that finalises on shutdown.** Canary starts a GPU
|
||||
trace at init but only closes it in `GraphicsSystem::Shutdown()`, so the hard
|
||||
kills this session used routinely could never have produced a trace. Before
|
||||
concluding a feature is broken, check whether the way you stop the program is
|
||||
what discards its output.
|
||||
* **Bound a risky experiment from inside, not by watching it.** A boot-time GPU
|
||||
trace on a disk at 95 % could have filled it between two tool calls. The runner
|
||||
carried its own watchdog that killed the emulator the moment the output passed
|
||||
a 2 GiB cap, so the experiment was safe regardless of how coarsely I polled.
|
||||
The watchdog never fired, which is the point — it cost nothing and removed the
|
||||
need to gamble on timing.
|
||||
* **A cvar existing does not mean the feature is compiled in.** `trace_gpu_stream`
|
||||
parses, is documented, sets state, and does nothing: the code that opens the
|
||||
trace sits behind `#if XE_ENABLE_TRACE_WRITER_INSTRUMENTATION == 1`, which
|
||||
`trace_writer.h` ties to `#ifdef NDEBUG` — off in release. Two runs and two
|
||||
wrong hypotheses before reading the `#if`. When a switch produces no effect,
|
||||
follow the code from the switch to the output and look for a compile-time gate
|
||||
before theorising about runtime causes.
|
||||
* **Test a compile-time gate against the binary, with a control.** A string that
|
||||
exists only inside the guarded block (`_stream.xtr`) settles it in one command:
|
||||
0 occurrences in the release binary, **1** in the debug binary. The debug build
|
||||
is the control that proves the test can find the string when it is there —
|
||||
without it, "0 occurrences" is just as consistent with a bad grep.
|
||||
* **`T8aD` headers sit in RATC child order — use that, not the size, to name
|
||||
one.** Attributing a sprite header to a sprite name by matching decoded
|
||||
dimensions fails whenever two sprites share a size, and `GP_TITLE` build 4 has
|
||||
such a pair (`ptlogo_back2eff` / `ptlogo_back2eff5`, both 1133×280) that differ
|
||||
in exactly the flag being studied. Header order matches child order — verified
|
||||
18/18 on that build against the dimensions as an independent check — so
|
||||
ordering resolves what size cannot.
|
||||
* **Identical summary statistics are not identical data.** Two same-sized sprites
|
||||
reported the same %opaque, %clear and mean alpha to one decimal, which reads as
|
||||
"the bundle stores this texture twice". Pixel-comparing them gives max abs diff
|
||||
**21**: two different renditions of one image. Three matching summaries over
|
||||
300 000 pixels is weak evidence of identity; the comparison costs one line.
|
||||
* **A pattern that is perfect on one screen can be near-chance on the disc.** The
|
||||
`0x02` bit matched `eff` names 10/10 on `GP_TITLE` build 4, which is exactly
|
||||
the sample size that makes a rule feel found. Disc-wide it holds 46.8 % of the
|
||||
time — the build's artists simply named their effect sprites consistently. An
|
||||
18-element bundle cannot distinguish a format rule from a local naming habit;
|
||||
before reporting an implication, run it over the corpus, not the example that
|
||||
suggested it.
|
||||
* **When an association survives a refuted implication, the counterexamples are
|
||||
the finding.** The bit is 3.3× enriched for `eff` names and the sprites that
|
||||
break the rule are `pv_loading_ring0`, `pv_loading_light0`–`3`,
|
||||
`px_bunk_line` — rings, glows, lights, thin lines. That the exceptions are all
|
||||
effect-like artwork *without* the naming convention says more about the field
|
||||
than the rule it broke did.
|
||||
* **Report a classifier's lift over its base rate, not its accuracy.** The
|
||||
`0x02` bit's association with bright-RGB/low-alpha art gives a best single
|
||||
threshold of 76.5 % accuracy — which sounds like a finding until you notice
|
||||
that always guessing the majority class scores 64.1 %. The lift is 12 points,
|
||||
the distributions overlap, and it is a tendency rather than a rule. An accuracy
|
||||
quoted without its base rate is not interpretable.
|
||||
* **Park a field after N failed hypotheses, and say what was eliminated.** Four
|
||||
candidate meanings for one flag bit were each refuted at a cost of roughly an
|
||||
iteration apiece, and the bit blocks nothing measurable. Stopping is the right
|
||||
call, but only if the negative space is written down — additive blend, name,
|
||||
lifetime, premultiplied alpha — so the next attempt starts where this one ended
|
||||
rather than at the beginning.
|
||||
* **Nothing was checking that a doc's figures match its committed data.** A
|
||||
number is written once from a run and then lives in prose that gets edited
|
||||
around it; the data file beside it is regenerated independently. All 19
|
||||
headline figures across four censuses do currently agree
|
||||
(`tools/re-capture/doc_figure_check.py`), which is worth knowing rather than
|
||||
assuming — but the checker had to be written **numerically**, because the first
|
||||
version grepped for the doc's formatting (`14 709` with a thin space, `33.7`
|
||||
rounded from `33.66`) and reported almost every figure as a mismatch. A
|
||||
consistency check that fails on formatting will train you to ignore it.
|
||||
* **A detached job you never check can outlive many iterations.** Two
|
||||
`setsid nohup cargo test … &` runs from earlier iterations were still alive
|
||||
**four hours** later, one child at 89 % CPU for 3 h 26 m, holding the load
|
||||
average at 14 on 12 cores. `setsid` was added precisely so a tool-call timeout
|
||||
could not kill them — which also means nothing kills them. Check that a
|
||||
backgrounded run actually exited before launching the next one, and prefer
|
||||
`ps -o etime=` over assuming.
|
||||
* **Know whether your verification gate can terminate.** `build-reborn test`
|
||||
includes a disc test that decodes 166 `.xpr` files (1.4 GB) and had not
|
||||
finished after 3 h 26 m of CPU. Every "green run" from it is partial unless the
|
||||
suite is stated to have terminated — so report the suite count and elapsed
|
||||
state rather than the word "green", which is what I should have been doing all
|
||||
session.
|
||||
* **"Cannot finish" and "takes an hour" are different claims — measure before
|
||||
choosing.** I wrote that the verification gate *cannot terminate*, from having
|
||||
watched it run 3 h 26 m. Timing the work directly: 19 of 166 containers exceed
|
||||
25 s, `Stage_S02` completes in **144 s**, and one full pass is ~45–60 minutes.
|
||||
Nothing hangs. The 3 h 26 m was that hour of work at a load average of 9–14,
|
||||
inflated by my own duplicate runs. A slow thing observed under contention looks
|
||||
like a stuck thing, and the correction matters: an hour-scale gate can be run
|
||||
deliberately, a hung one cannot be run at all.
|
||||
* **Separate the question with evidence from the question tangled with it.** The
|
||||
keyframe-time reading was held back for several iterations by one objection:
|
||||
adopting it changes `rest()` on a single element. But `rest()` reaches the times
|
||||
only through a fallback that is unsound whenever it runs, and the shift does not
|
||||
fix that fallback either — so the objection was never about the times. Timing
|
||||
had three discriminating measurements; pose-selection had a heuristic guessing.
|
||||
When a conclusion is blocked, check whether the blocker is actually evidence
|
||||
about the same thing.
|
||||
* **A predicate over adjacent PAIRS silently misclassifies a one-element list.**
|
||||
"Has a plateau" was implemented as *any two adjacent keyframes share a pose* —
|
||||
which is false for a single-keyframe element, so 1 502 static elements were
|
||||
counted as having a *guessed* rest pose and the published defect rate was 65 %
|
||||
too high. The error only surfaced when acting on it: suppressing those elements
|
||||
dropped the title's correlation by 0.27, because they include the backgrounds.
|
||||
Whenever a rule quantifies over pairs, ask what it says about a list of one.
|
||||
* **Acting on a claim is a better test of it than re-reading it.** The
|
||||
single-keyframe flaw survived a disc-wide census, a write-up and a handoff row.
|
||||
It died the moment the rule was used to change a rendering, because the result
|
||||
was visibly worse. If a measurement implies an action, take the action on
|
||||
something you can score.
|
||||
* **Score a rule where it can actually differ, or you will measure nothing.**
|
||||
Three rest-pose rules rendered builds 4, 5 and 6 to *identical* correlations —
|
||||
not because they agree, but because the code they change is unreachable on
|
||||
those screens. The signal was on the two splashes, the only builds whose
|
||||
elements reach the fallback at all. Identical results across variants is a
|
||||
clue that the variant is not being exercised, not evidence that the choice does
|
||||
not matter.
|
||||
* **An argument from symmetry is a prediction, not a refutation.** I killed the
|
||||
last-keyframe rule because it treats one of three sibling glows differently,
|
||||
which felt like an artefact. Measured, it is the better rule on both screens
|
||||
where it applies. Aesthetic expectations about how authored data "should" look
|
||||
are worth stating as hypotheses and worth nothing as verdicts.
|
||||
* **A blank render is a correlation of NaN, not a low score — notice which.**
|
||||
Applying "rest = last keyframe" to every element made both splashes render with
|
||||
zero variance, so the correlation was undefined rather than poor. Reading the
|
||||
NaN as "the tool failed" would have hidden the result; it *was* the result, and
|
||||
the strongest form of it. Check for degenerate output before treating a missing
|
||||
number as a broken measurement.
|
||||
* **When a model predicts something and the measurement refuses, the model is
|
||||
usually incomplete rather than wrong.** The shifted time reading says when each
|
||||
pose is reached, which looked like it implied the final pose is the resting
|
||||
one. It does not: it says nothing about whether the group is *played to
|
||||
completion* while the screen is still up. The exit is the dismissal, so a
|
||||
displayed screen never reaches its last keyframe. Both claims survive; what was
|
||||
wrong was the step between them that I supplied.
|
||||
* **The control is what turns a helpful edit into a finding.** Suppressing the
|
||||
`_eff` glows raised both splashes from ≈0.96 to ≈0.998 — on its own that reads
|
||||
as "we over-draw glows". Running the same edit on the three screens that settle
|
||||
made them **worse** (−0.002, −0.092, −0.107), which is what makes the result
|
||||
mean something specific: the over-draw is confined to transient screens, where
|
||||
a plateau mid-animation is not evidence the element is on screen at rest. An
|
||||
edit that improves one set of cases is only interesting once you have shown it
|
||||
damages the cases where it should.
|
||||
* **When a model reproduces durations but not positions, the missing piece is an
|
||||
origin, not a rate.** Playing the splash timeline matched every element's
|
||||
on-screen *length* to within 2 % while placing the elements in the wrong part
|
||||
of the run. That pattern names the gap precisely — the rate and the shape are
|
||||
right, so what is absent is a per-group start — and it pointed straight at the
|
||||
one undecoded word in the group header. (Which was zero, but the diagnosis was
|
||||
still what made the next step obvious rather than a search.)
|
||||
* **Two elements with identical data and different outcomes is the strongest
|
||||
possible evidence that the decision is elsewhere.** `palogo_anima` and
|
||||
`palogo_gamearts` carry byte-identical keyframe times; one is drawn for 95
|
||||
frames and the other not at all. No amount of re-reading the placement data can
|
||||
explain that, and the pair is worth more than a survey — when a hypothesis says
|
||||
"the data decides", look for two records that agree and behave differently.
|
||||
* **A shared resource address does not identify the resource's owner.** The draw
|
||||
log's `tex[base=…]` looked like it would say whether two animation phases came
|
||||
from the same bundle. All three splash phases report `0x11C30000` — including
|
||||
the publisher splash, which is certainly a *different* bundle. The address is a
|
||||
reused upload slot. The control was free and sitting in the same table; without
|
||||
it the shared base would have read as proof of a shared bundle.
|
||||
* **State the mechanism as a separate claim from the observation.** "Declared
|
||||
elements are not what gets drawn" is measured and holds. "Because one bundle
|
||||
activates its elements selectively" was an explanation I attached to it, and it
|
||||
is not established. Bundling the two let the weaker claim inherit the stronger
|
||||
one's evidence.
|
||||
* **Ask what the competing hypothesis would REQUIRE on the disc.** Two
|
||||
explanations for the splash's disjoint phases looked separable only by a
|
||||
capture I could not take, and the capture-side test failed its control. The
|
||||
alternative needed a bundle declaring the glows without the logos — a
|
||||
four-entry enumeration showed none exists, and the question closed statically.
|
||||
A hypothesis that predicts an artefact can be killed by looking for the
|
||||
artefact, which is often far cheaper than measuring the behaviour.
|
||||
* **Check what a "measured" value was measured *from* before reasoning about
|
||||
its limits.** Two things that never co-occur have no observable relative order
|
||||
— true, and a real limit on any order read from a *draw capture*. I applied it
|
||||
to the splash's paint order and withdrew it hours later: that vector is a read
|
||||
of the runtime **child array**, which has a definite order whether or not its
|
||||
children are ever drawn together, and the two halves in question also carry
|
||||
distinct static layer keys. Both the provenance and an independent static field
|
||||
said the order was real. The general trap is not the co-occurrence rule, it is
|
||||
applying a source-specific limit to a value from a different source: the word
|
||||
"measured" covers several kinds of evidence, and the page that recorded it
|
||||
usually says which — `ui-screen-runtime.md` said "child slots" in as many
|
||||
words. Read that line before building an argument on top of it.
|
||||
|
||||
* **A heuristic that is right 99.9 % of the time still has a shape to its
|
||||
failures — find it before trusting the field.** RATC child names were read by
|
||||
scanning backwards for the last printable run of bytes. That agrees with the
|
||||
format's own `opt ` declaration on 17 918 of 17 942 children, which is the kind
|
||||
of agreement that stops people looking. The 24 exceptions were not random: all
|
||||
24 are the *same* case, a 3-byte binary tail that happens to be printable ASCII
|
||||
(`8AX`), and one of them was the full-resolution background of every menu
|
||||
screen we care about. Ask what the format *states* before settling for what a
|
||||
scan *infers*, especially when the stated version is already decoded elsewhere
|
||||
in the same file — `opt ` was being read for button focus links the whole time.
|
||||
|
||||
* **A `continue` that silently skips is a defect even when the skip is correct.**
|
||||
`compose` drops an element whose sprite does not resolve. Two arms above it
|
||||
record the name into `missing` first; the `el.sprite.is_none()` arm does not.
|
||||
So a screen lost its background and `screen render` still reported "sprites
|
||||
that did not resolve: none" — the diagnostic was structurally unable to see it.
|
||||
When adding an early-out to a loop that already reports what it discards, make
|
||||
it report through the same channel, or it becomes a place findings go to die.
|
||||
|
||||
* **"It has no name" can mean "it is not a thing that gets named."** Sixty RATC
|
||||
children had no `opt ` name block and the open question was whether the block
|
||||
was absent or merely outside our search window. It was neither: the sixty are
|
||||
*frames*, ten each of six copies of one `.tan` animation, and one `opt ` block
|
||||
names the whole run. The give-away was in the data before any hypothesis was —
|
||||
the distances back to the nearest tag were an exact arithmetic progression
|
||||
(`213 + n·60600`), i.e. ten different records finding the *same* tag. When a
|
||||
negative result's measurements come out evenly spaced, the thing you are
|
||||
counting is probably not the thing the format counts.
|
||||
|
||||
* **This container OOM-kills `slb_leading_segment_disc` under default test
|
||||
parallelism.** It dies with `signal: 9, SIGKILL` and no assertion — eight
|
||||
threads each holding a slice of a ~1.1 GB bank. It is not a regression and not
|
||||
a flake, and it reproduces when run alone. `-- --test-threads=1` passes 8/8 in
|
||||
20 s. Before believing a SIGKILL in this repo, re-run the suite serially;
|
||||
before believing a *pass*, check nothing else heavy was sharing the box.
|
||||
⚠️ And budget for `mesh_consistency_disc`: it takes **22 minutes** (1 318 s
|
||||
measured, serial) and produces no output while it runs, so `build-reborn test`
|
||||
looks hung for a third of an hour. It is not. Two runs were killed for looking
|
||||
stuck before it was timed. If the change under test is not in the mesh path,
|
||||
`build-reborn t -p <crate>` scopes around it — note that `build-reborn test`
|
||||
itself passes `--workspace` and will ignore a `-p`.
|
||||
|
||||
* **Never measure a file another process is still writing.** `ffprobe` on a
|
||||
half-written transcode reported 33 s against a 137 s source — the shape of
|
||||
catastrophic truncation, with no error and no warning, and it nearly became a
|
||||
filed bug. The tell was the mtime and a climbing packet count, not anything in
|
||||
the tool's output. Before comparing an artifact you did not produce, check that
|
||||
whoever produces it has finished; across two agents sharing a repo this is a
|
||||
race, not an edge case.
|
||||
|
||||
* **A difference-signal RMS means nothing until the two are aligned.** Comparing a
|
||||
transcode against its source gave source RMS −25.3 dB and difference RMS
|
||||
−34.2 dB — only ~9 dB down, which looks like a failure. It is inconclusive: a
|
||||
one-sample offset makes the difference nearly as loud as the source. Any such
|
||||
test needs cross-correlation to align first and an agreed downmix, and only then
|
||||
is a pass mark like ">40 dB down" meaningful. Reporting the 9 dB as a result
|
||||
would have been a confident wrong number.
|
||||
|
||||
@@ -35,6 +35,159 @@ neighbourhood, not just the line.
|
||||
|
||||
## Screens, classes and RTTI
|
||||
|
||||
* "the rotated draw in the title's capture is the `Z` swoosh
|
||||
(`ptlogo_back2*`)" → **mine, and refuted.** Its quads span y −209…925 and
|
||||
−292…1012 in screen space; the swoosh is a band at y 126…360.
|
||||
[`ui-title-build-map.md`](ui-title-build-map.md)
|
||||
* "the keyframe words at `+4`/`+8`/`+12` are always zero" → they are non-zero in
|
||||
**4.76 %, 4.62 % and 14.50 % of 83 862** blocks disc-wide, reading as
|
||||
**degrees** (±180, ±90, 120, 360). The original note was a sample artefact.
|
||||
(Superseded figures: an earlier count of 72 287 blocks missed every nested
|
||||
record — see the alignment entry below.)
|
||||
* ~~"those angle fields are where the title's rotated quads come from" → **no** —
|
||||
every `GP_TITLE` build 4 element has all three at zero.~~ → **that refutation
|
||||
was itself wrong, and is withdrawn (2026-08-28).** `+12` *is* exactly where
|
||||
they come from. Build 4's **top-level** elements do all read zero; the rotated
|
||||
quads belong to its two **nested** leaf records, `ptloop01.rat` (`+12` = 30)
|
||||
and `ptloop02.rat` (`+12` = −45), which the census never opened. Measured
|
||||
off the GPU: **+30.26°** and **−45.28°**.
|
||||
[`ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md)
|
||||
* "the rotated draw's element cannot be named from the capture" → refuted; its
|
||||
quads' **edge lengths** name it. 400 × 1076 and 400 × 1444 match `pteff03`
|
||||
399×180 at 600 % and `pteff03a` 399×180 at 800 % — two different heights, both
|
||||
landing. [`ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md)
|
||||
* "a keyframe-block scanner may assume 4-byte alignment" → refuted; it found
|
||||
**0/3** of its own control blocks and under-counted the corpus by 16 341
|
||||
blocks. Nested `RATC` blobs start at odd offsets (`0xbb5966`).
|
||||
* "keyframe rotation lives only in **nested** `.rat` leaf records" → **mine, and
|
||||
refuted within the hour by my own sweep.** It held for the three archives I had
|
||||
checked (`GP_TITLE`, `GP_BUNK`, `GP_CHALLENGE`) and failed on the next:
|
||||
`GP_DIALOG` and `GP_DEBRIEFING_PILOTLOG` rotate **top-level** elements, and
|
||||
those are the clearest examples on the disc.
|
||||
[`ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md)
|
||||
* "the pivot-anchored scale formula `kf.x − pivot·(scale−100)/100` is our
|
||||
renderer's reading, not a measurement" → **now measured.** At the `ptloop`
|
||||
pair's 600 %/800 % scale it predicts both quad centres at y = 360.0 against a
|
||||
captured 359.1/360.0, where top-left anchoring predicts 810/990.
|
||||
[`ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md)
|
||||
* "the game passes a pink per-vertex colour for the title swoosh" → **refuted by
|
||||
draw capture.** Every vertex colour in the capture is `<alpha>FFFFFF`, white RGB.
|
||||
* "the swoosh discrepancy is undecodable" → **solved**: the game submits it as two
|
||||
**rotated parallelograms**; `ui_layout::blit` only does axis-aligned rectangles.
|
||||
[`ui-title-build-map.md`](ui-title-build-map.md)
|
||||
* "the `--log_ui_draws` per-draw capture reads the guest's blend state" →
|
||||
**mine, and wrong.** It records primitive type, index count, index-buffer
|
||||
address, VS/PS ucode hashes, texture bindings and vertex attribute 0 — no
|
||||
`RB_BLENDCONTROL`. It can test vertex colour as-is; blend state needs a Canary
|
||||
change. [`ui-title-build-map.md`](ui-title-build-map.md)
|
||||
* "the plate-free title capture (t ≈ 4.0 s) may be too early to be settled" →
|
||||
**mine, and refuted.** The swoosh band correlates 0.7342 at t = 4.0 s and
|
||||
0.7353 at t = 21.5 s — identical to 0.001 over 17.5 s.
|
||||
* "`T8aD +0x04` bit `0x02` selects an additive blend" → **mine, and refuted.**
|
||||
Blending those sprites additively worsens every measure against the capture.
|
||||
[`ui-title-build-map.md`](ui-title-build-map.md)
|
||||
* "the title logo elements' wrong pivots (off by up to 59 px, authored against the
|
||||
other language's sprite) explain the swoosh rendering too thick" → **mine, and
|
||||
refuted.** `blit` sizes from the texture and applies the pivot only as
|
||||
`pivot·(scale−100)/100`; all seven swoosh elements are scale `(100,100)` at every
|
||||
keyframe, so the term is zero. The mismatch is real but inert here — it would
|
||||
bite `ptlogo1`/`ptlogo2`, which scale to 150 during the build-in.
|
||||
[`ui-title-build-map.md`](ui-title-build-map.md)
|
||||
* "the title screen loops at ≈ 2.2 s" → **mine, and doubly wrong.** The title
|
||||
*art* is near-static (wordmark sd 0.06); the 2.3 s pulse is the
|
||||
**`PRESS Ⓐ BUTTON` plate**, which is a *different build* (2, not 4). Localised
|
||||
by a per-tile amplitude map and decoded in `ptbtn00f.rat`.
|
||||
[`ui-title-build-map.md`](ui-title-build-map.md)
|
||||
* ~~"a `.rat` leaf record's keyframes use the build's 40-byte layout" → they do
|
||||
not~~ — **withdrawn.** They do. The scan that "found nothing" required 29
|
||||
increasing times; the records hold **three**. Decoded in
|
||||
[`ui-title-build-map.md`](ui-title-build-map.md).
|
||||
* "the title's measured ≈ 2.2 s oscillation is the `ptloop01`/`ptloop02`
|
||||
elements" → **mine, and refuted by decoding them.** Their sweeps run 7.5 s and
|
||||
9.5 s; a 22 s capture showed 8 peaks, not 3. The period's source is unidentified.
|
||||
* "the developer splash cannot be rendered by `screen render` at all" → it can,
|
||||
with **`--all`**. It is only invisible to the *default* listing, which filters on
|
||||
`is_build`. [`ui-title-build-map.md`](ui-title-build-map.md)
|
||||
* "the four `GP_TITLE` entries that are not screen builds are unidentified" → they
|
||||
are the **splash**: 10/13 the `SQUARE ENIX` logo, 11/14 the developer logos.
|
||||
* ~~"the boot title is phase 2 and the attract title is phase 4 state 0, and only
|
||||
phase 2 handles Ⓐ" → refuted~~ — **the REFUTATION is withdrawn.** The test
|
||||
assumed Ⓑ lands in phase 4 state 0; the event-0 block actually sets
|
||||
**phase = 2** and state = 0 together, so it probed phase 2. The hypothesis is
|
||||
untested, not dead.
|
||||
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
|
||||
* "any title after the first one refuses input" → too broad. The **Ⓑ-returned
|
||||
title accepts Ⓐ**; only the **attract**-returned title is inert.
|
||||
[`canary-scripted-input-traps.md`](canary-scripted-input-traps.md)
|
||||
* "`sub_821C6458` is `GamePart_Title`'s state machine" → **mine, imprecise.** It
|
||||
is the machine for **phase 4** of a five-way outer dispatch at `this+132`;
|
||||
phase 0 is the splash. The ten states and eighteen edges are phase 4 only.
|
||||
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
|
||||
* "`sub_821CC860` is the game's by-name screen factory" → **mine, and wrong.** Its
|
||||
decoded arguments include `BG`, `BLACK`, `FADE`, `FILE`, `KEY`, `PAD`, `SOUND`,
|
||||
`GAMMA_RGB` — it is a **generic name-keyed lookup**, mostly config.
|
||||
* "`DIFFICULTY` and `EXTRA_MENU` are corroborated screen names" → **mine, and
|
||||
wrong.** Neither appears in `r5` at any of the 48 call sites; they were strings
|
||||
merely referenced by the same functions. Only `TUTORIAL_MENU` survives.
|
||||
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
|
||||
* "the `{func, func, ptr}` triples at `0x820a3b48` are a GamePart state table" →
|
||||
**static-initialiser records trailing the `RegisterToFactory` strings.** The
|
||||
bytes before them are the tail of a diagnostic string and the data column is
|
||||
zero-filled descriptors.
|
||||
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
|
||||
* "the boot sequence is driven by a table the game reads" → it is **not
|
||||
data-driven**; four search spaces closed, transitions are calls with an id
|
||||
argument. [`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
|
||||
* "`config.ini` is a GamePart settings table, so the boot order is in it" → its
|
||||
`[SYSTEM]` section is **empty**; the only populated section is `[LANGUAGE]`.
|
||||
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)
|
||||
* "the attract loop is `GP_ADVERTISE_DEMO` (GamePart 1)" → 🟡 id 1 has **no
|
||||
registration site** in the shipped build; the attract is the title replaying
|
||||
`ADV.wmv`.
|
||||
* "the 29 id-table names are 29 distinct GameParts" → 24 register, and `3`/`4`
|
||||
are the **same class** (`GamePart_SaveLoad`).
|
||||
* "Ⓐ on `NEW GAME` leads to a standing black-screen hang" → it opens
|
||||
**`DIFFICULTY`**, then **`SELECT DATA`**. What looked like a hang was a menu
|
||||
waiting for input that nobody pressed; the crash that follows is the already
|
||||
documented `sub_823070B0` cache throw.
|
||||
[`menu-navigation-semantics.md`](menu-navigation-semantics.md)
|
||||
* "tapping Ⓐ during the boot movie breaks the title" → **one** tap skips the
|
||||
movie cleanly and the title works normally. It is *hammering* (88 presses) that
|
||||
breaks it. [`movie-binding.md`](movie-binding.md)
|
||||
* "the attract movie runs ~85 s, so it is not `ADV.wmv` (137 s)" → **mine, and
|
||||
wrong.** Sampling began 39 s into the movie, so what was timed was its tail.
|
||||
The attract movie **is** `ADV.wmv`, played in full.
|
||||
[`movie-binding.md`](movie-binding.md)
|
||||
* "the boot intro and the attract movie are different videos" → one asset, one
|
||||
manifest slot (`ADVERTISE_MOVIE`).
|
||||
* "the new-game intro is unidentified" → `MS00A` → `S00A.wmv`, decoded from the
|
||||
movie manifest.
|
||||
* "`GP_READY_ROOM.pak` holds the Ready Room screen" → its 317 distinct element
|
||||
names contain **none** of the six visible labels; it is the briefing /
|
||||
tactical-map content behind the `BRIEFINGS` item.
|
||||
[`ready-room-probe.md`](ready-room-probe.md)
|
||||
* "the Ready Room might be 3D with a UI overlay" → it is **2D**; the pak carries
|
||||
zero 3D containers.
|
||||
* "element kind `0x3002` is *the* button kind" → title-side only. All 902
|
||||
`GP_READY_ROOM` bundles have **zero** `0x3002`; that pak uses `0x3000`,
|
||||
`0x3004`, `0x300c`, `0x3008`. `0x3002` is one member of a `0x3000` family.
|
||||
* "the transition between menu screens is a cut" → it is a **fade through
|
||||
black**; a 0.5 Hz screenshot burst simply samples too slowly to see it.
|
||||
[`screen-transitions.md`](screen-transitions.md)
|
||||
* "the main menu's initial focus is fixed" → three boots of one script gave
|
||||
`TUTORIAL`, `TUTORIAL`, `NEW GAME`.
|
||||
* "the title menus drop d-pad presses shorter than ~0.3 s" → **refuted by my
|
||||
own data.** The menu **wraps at both ends**; every press registered, and the
|
||||
"missing" step was the wrap. See [`menu-navigation-semantics.md`](menu-navigation-semantics.md).
|
||||
* "the main menu opens with `NEW GAME` focused" → it opens on **`TUTORIAL`**,
|
||||
2/2 boots (🟡 a third recorded run implies `NEW GAME`, so this is reproducible,
|
||||
not invariant).
|
||||
* "`GP_TITLE` builds 6/8/9 are three submenus" → **8 is the JAPANESE main
|
||||
menu**, and 6/9 are the English and Japanese `EXTRAS` submenu. `GP_TITLE`
|
||||
holds eight screens shipped twice (EN/JP), and exactly one submenu. See
|
||||
[`ui-title-build-map.md`](ui-title-build-map.md).
|
||||
* "the `PRESS Ⓐ BUTTON` plate is a state of the title build" → it is **its own
|
||||
build** (2/3), composited over build 4 and faded in a beat later.
|
||||
* "the RTTI route can name the anonymous classes" → 1 150 vtables: 1 150
|
||||
`ANON_`, 0 `rtti_present`, 0 base classes.
|
||||
* "the sibling vtable methods name the class" → they cannot.
|
||||
@@ -102,6 +255,46 @@ neighbourhood, not just the line.
|
||||
* "a set-difference over file names can see reuse" → it cannot; **join per
|
||||
USER**. Per-pak copies are ×6.
|
||||
|
||||
|
||||
## Audio
|
||||
|
||||
* "which bank the menu plays is not on the disc" → the **cue table** cannot say
|
||||
(all BGM cues are numeric), but the **code** can: `sub_821C5580` plays cue
|
||||
**1103 = `BGM_103`**, and its two declared waves match the two streams the XMA
|
||||
probe saw byte-for-byte.
|
||||
* "the observed BGM stream sizes match no bank's declared waves, so the game hands
|
||||
the decoder a window" → **mine, and wrong** — I checked only the `BGM_0xx` rows.
|
||||
They are `BGM_103`'s two waves exactly; the game hands over the whole wave.
|
||||
|
||||
* "an individual SE cue's audio cannot be extracted" → **mine, and wrong.**
|
||||
`--xma_param_probe=true` logs each stream's head bytes; searching them in
|
||||
`Static.slb` locates the wave exactly. [`menu-audio-cues.md`](menu-audio-cues.md)
|
||||
* "`Static.slb` has no wave boundaries, so its layout is unknown" → it is a
|
||||
**packed run** of whole 2 048-byte XMA packets with no delimiters — the two
|
||||
located cues are contiguous. There is nothing to scan for, by design.
|
||||
|
||||
* "`Pj_Silph.xgs` holds the cue→wave index, so parse XACT" → **no XACT container
|
||||
exists on this disc**: 0 × `XGSF`/`SDBK`/`WBND` in all 1.08 GB of `sound.pak`,
|
||||
and no `XACT`/`.xgs` string in the executable. The extensions are the authoring
|
||||
tool's, not the format's. [`menu-audio-cues.md`](menu-audio-cues.md)
|
||||
|
||||
* "every sound cue resolves to its own `.slb` bank" → the 322 `SE_*` cues do not;
|
||||
**0 of 322** are in `FILES`, and `BANK_SE` puts them all in `Static.slb`.
|
||||
* "`Static.slb` can be split into waves like any other bank" → it holds **0
|
||||
`RIFF`, 0 `seek`, 0 `WAVE`** across all 8 353 472 readable bytes.
|
||||
[`menu-audio-cues.md`](menu-audio-cues.md)
|
||||
|
||||
* "`BGM_001.slb` is three sub-waves (10 KB + 4.47 MB + 4.67 MB)" → the 10 KB is
|
||||
the **bank header**; a bank is **two** waves.
|
||||
* "a music bank's two waves might be intro + loop, two variations, or two halves"
|
||||
→ they are **two stems of one performance, played together** — equal duration
|
||||
in 32/32 banks, and sample-synchronous.
|
||||
[`structures/bgm-two-stems.md`](structures/bgm-two-stems.md)
|
||||
* "`BGM_106`–`BGM_109` break the two-wave rule" → they are the leading-region
|
||||
straddle; realigned across entry boundaries they obey it.
|
||||
* "the cue table names which BGM belongs to which screen" → all 32 BGM cues are
|
||||
numeric (`BGM_001`…`BGM_109`).
|
||||
|
||||
## Units, weapons, effects and assets
|
||||
|
||||
* "`Generic` (394) is the unit datasheet" → refuted.
|
||||
@@ -183,3 +376,244 @@ neighbourhood, not just the line.
|
||||
|
||||
* "every IDXD string value is ASCII" → 6 non-ASCII values of 99 328.
|
||||
* "`文字列` is a dev placeholder" → they are Shift-JIS **type words**.
|
||||
* "the splash `_eff` glows hold a constant α ≈ 33, contradicting their declared
|
||||
255 plateau" → **mine, and refuted within the iteration.** They ramp 34 → 255
|
||||
in exact steps of 34. I had printed the series' minimum and read it as its
|
||||
range. [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)
|
||||
* "the declared keyframe timeline reproduces the captured splash" → **refuted for
|
||||
multi-keyframe elements.** `palogo_gamearts` is still at `a=255` nine frames
|
||||
after its declared `a=32`, and its declared 80-frame fade-in is never drawn.
|
||||
The `_eff` glows do reproduce, exactly — so this is about the group timeline,
|
||||
not about the interpolation law. [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)
|
||||
* "the `_eff` elements' agreement is the whole case for the keyframe-time shift,
|
||||
so it stays a shape argument" → superseded. The **hold duration** is
|
||||
calibration-free and decides it: observed 83 frames of full alpha against a
|
||||
predicted **2.0** as decoded and **80.0** shifted. The shift is nonetheless
|
||||
**not adopted** — it moves `GP_TITLE` build 7 by 13 % of pixels, away from its
|
||||
verified English twin's brightness. [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)
|
||||
* "the `GP_TITLE` build 7 render difference is evidence against the keyframe-time
|
||||
shift" → **mine, and withdrawn.** It is one element, `ptlogo_eff3.t32`, a
|
||||
transient bloom with no resting pose; `rest()`'s dwell fallback returns a
|
||||
different endpoint of the same movement under each reading. The brightness
|
||||
comparison measured our heuristic, not the decode.
|
||||
[`structures/ui-resting-pose.md`](structures/ui-resting-pose.md)
|
||||
* "`rest()`'s longest-dwell fallback picks the pose the element rests at" →
|
||||
**refuted structurally.** A dwell gap is time spent interpolating *between*
|
||||
poses; an endpoint is only held when the two poses are equal, which is a
|
||||
plateau, which the earlier path already returned for. Every element that
|
||||
reaches the fallback has a guessed rest pose.
|
||||
* "`rest()` for a plateau-less element should be the **last keyframe**" → **mine,
|
||||
and refuted.** The developer splash's three sibling glows are structurally
|
||||
identical and differ by one byte (`a=212` vs `a=255` at `t=45`); that rule
|
||||
makes `palogo_anima_eff` alone invisible while `gamearts_eff` and `seta_eff`
|
||||
stay lit. Capture box-mean ratios (0.717 / 0.723 / **0.772**) go the other way
|
||||
too. [`structures/ui-resting-pose.md`](structures/ui-resting-pose.md)
|
||||
* "a keyframe `scale` of 0 means *unset*, so render at 100 %" → **refuted by a
|
||||
disc-wide control.** 2 166 elements have a zero-scale keyframe and **not one is
|
||||
zero on every keyframe**, while 1 762 grow back out of zero (`ptlogo_eff3.t32`
|
||||
runs 0 % → 200 %). Zero means collapsed; the renderer now draws nothing.
|
||||
[`structures/ui-rat-layout.md`](structures/ui-rat-layout.md)
|
||||
* "a Japanese-locale capture is impossible in this container, because canary has
|
||||
no `user_language` cvar" → **mine, and refuted the next iteration.** The cvar
|
||||
really is absent, but the language is persisted in
|
||||
`<storage_root>/xconfig.settings` (`user.language`, BE u32 at file offset
|
||||
`0x912`, located from three struct landmarks) and that file is writable. The
|
||||
capture is still not *taken* — a Japanese run never reached the title in 787 s
|
||||
— but it needs a longer run, not a rebuilt emulator.
|
||||
[`tools/re-capture/set_console_language.py`](../../tools/re-capture/set_console_language.py)
|
||||
* "the Japanese-locale run never reached the title in 787 s" → **the measurement
|
||||
was broken, not the run.** `wait_title.sh` carried the superseded single-pixel
|
||||
oracle. Re-run with `is_title.py`: the game still did not present the
|
||||
interactive title, but that is now a measured statement (zero green-glyph
|
||||
pixels, correlation ≤ 0.22 to either build-7 render) rather than an artefact.
|
||||
* "the Japanese-locale run fails to reach the interactive title *because of the
|
||||
locale*" → **refuted by the English control.** 75 samples over 734 s with the
|
||||
same flags and oracle, every one glyph = 0. Neither locale presents the
|
||||
interactive title without a pad press.
|
||||
* "neither locale reaches the interactive title without a pad press" /
|
||||
"the game sat in the attract loop for 604 s" → **withdrawn as causes.** Both
|
||||
rest on runs whose polling loop sampled every ~41 s, because `screenshot`
|
||||
costs 10.8 s while the emulator runs (0.117 s idle, 92×). A title lasting a few
|
||||
seconds would be missed. The observations stand; the conclusions drawn from
|
||||
them do not. [`capture-harness-status.md`](capture-harness-status.md)
|
||||
* "the boot harness fails because its polling loop samples every ~41 s, slower
|
||||
than the title screen lasts" → **mine, and refuted by my own fix.** The
|
||||
sampling defect was real (3.98 s → 0.29 s per sample, 13.7×, control-verified
|
||||
at 753/327), but a probe running at 3.99 fps for **420 continuous seconds —
|
||||
1 674 samples — still saw zero green-Ⓐ pixels.** Sampling rate was not the
|
||||
cause. [`capture-harness-status.md`](capture-harness-status.md)
|
||||
* ~~"neither locale reaches the interactive title without a pad press"~~ →
|
||||
withdrawn last iteration for want of evidence, now **reinstated as a
|
||||
measurement**: 1 674 dense samples over 420 s, English, zero glyph frames.
|
||||
⚠️ Reach: a mid-run window only; it says nothing about the boot title.
|
||||
* "the PRESS Ⓐ plate appears only in the **boot** title window, which mid-run
|
||||
sampling could never catch" → **mine, and refuted.** The fast probe was
|
||||
attached at t=0: **2 391 frames over 600 s at 3.98 fps from launch**, max glyph
|
||||
0. The plate did not appear at any point in the first ten minutes.
|
||||
[`capture-harness-status.md`](capture-harness-status.md)
|
||||
* "2 391 frames over 600 s from t=0, max glyph 0, therefore the title never
|
||||
appears in the first ten minutes" → **withdrawn: the instrument stalls.** A
|
||||
single long-lived x11grab stream degrades 3.98 → 1.60 fps and then freezes,
|
||||
repeating one stale frame; cross-checked, it read surface mean 5.21 where
|
||||
`import` read 125.65 at the same moment. A dense negative from a frozen stream
|
||||
is not a negative. [`capture-harness-status.md`](capture-harness-status.md)
|
||||
* "the `T8aD` layer key fully determines a screen's paint order" → **refuted, and
|
||||
the remainder is undecodable.** Elements sharing a key are tied; on the title
|
||||
the game paints the five tied `ptlogo_back2eff` glows `1,2,5,3,4` while the
|
||||
declaration table, the RATC child order and **every** field in the `T8aD`
|
||||
header (exhaustive 0x00–0x7f, u8/u16/u32, both directions — 0 matches against
|
||||
64 for the declaration-order control) all give `1,2,3,4,5`.
|
||||
[`ui-paint-order-derived-check.md`](structures/ui-paint-order-derived-check.md)
|
||||
* "SE audio is undecodable from the disc — no XACT container exists anywhere"
|
||||
(as it stood on the **handoff page**) → **stale**: `menu-audio-cues.md` had
|
||||
already retracted it and located three cues in `Static.slb` that decode to PCM.
|
||||
The retraction never reached the row the port agent reads. Handoff row fixed;
|
||||
`tools/re-capture/handoff_lint.py` now checks for this class.
|
||||
* "which of `8AX` and `ptbase` the game draws needs a per-draw capture recording
|
||||
texture base addresses" → **mine, and refuted — it is settled statically.** The
|
||||
two carry the same art at two resolutions, so neither compares usefully against
|
||||
a capture; their *difference* does. Correlating the capture's
|
||||
departure-from-upscale against the 8AX-only detail gives +0.0475 (main menu)
|
||||
and +0.0634 (title), both 68 % of ceiling against matched controls of ≤0.0095.
|
||||
The game draws the full-res `8AX`.
|
||||
[`ui-8ax-fullres-background.md`](structures/ui-8ax-fullres-background.md)
|
||||
* "the pixel-pair ratio shows the capture is native, not an upscale" → **mine,
|
||||
and withdrawn as evidence.** Upscales give 0.00–0.72, native 0.98, capture 1.01
|
||||
— but additive noise pushes any such ratio toward 1, and both "native + noise"
|
||||
and "bilinear upscale + noise" fit the observed values. The conclusion happens
|
||||
to be right; this test does not establish it.
|
||||
* "the render-vs-capture gamma may be canary's own BT.709 output transform, since
|
||||
`kernel_display_gamma_type = 2`" → **mine, and refuted from the source.** That
|
||||
cvar is the value a `kStub` getter (`VdGetCurrentDisplayGamma`) hands the
|
||||
**guest**; the game builds its own ramp from it and canary applies the guest's
|
||||
`DC_LUT` ramp in the swap path. No emulator-side gamma post-process exists to
|
||||
subtract. 🟡 Whether this game installs a ramp at all is still unestablished.
|
||||
[`ui-render-tone-curve.md`](structures/ui-render-tone-curve.md)
|
||||
* "the GPU trace produced nothing because either the CLI flag did not reach the
|
||||
cvar or `BeginTracing()` failed silently" → **both wrong.** The trace writer is
|
||||
**compiled out**: `trace_writer.h` gates it on `#ifdef NDEBUG`, so a release
|
||||
build has no writer at all. Confirmed with a control — the format string
|
||||
`_stream.xtr` appears **once** in the Debug binary and **zero** times in the
|
||||
Release binary `run-canary` actually uses.
|
||||
[`capture-harness-status.md`](capture-harness-status.md)
|
||||
* "the `T8aD` `+0x04` bit `0x02` means the sprite's name contains `eff`" →
|
||||
**refuted, now on evidence.** `ptlogo_back2eff` is an `eff` name with the bit
|
||||
clear; the attribution is confirmed by header-order pairing (18/18 on build 4)
|
||||
rather than by a size match, which cannot separate it from the same-sized
|
||||
`ptlogo_back2eff5`. All 10 bit-set sprites *are* `eff` names, so the
|
||||
implication runs one way only.
|
||||
* "the bit `0x02` marks a transient element" → **refuted.** `pteff03` and
|
||||
`pteff03a` carry the bit and run to `t=250`, ramping to `a=255` and holding.
|
||||
[`ui-paint-order-key.md`](structures/ui-paint-order-key.md)
|
||||
* "bit `0x02` set ⇒ the sprite's name contains `eff`" (the one-way reading that
|
||||
survived the biconditional's refutation) → **mine, and refuted disc-wide the
|
||||
next iteration.** True 10/10 on `GP_TITLE` build 4; over 14 709 sprites it
|
||||
fails on **2 657 of 4 995** bit-set ones. `P(eff|set) = 0.468` against
|
||||
`P(eff|clear) = 0.144` — an association, not an implication.
|
||||
[`ui-paint-order-key.md`](structures/ui-paint-order-key.md)
|
||||
* "`T8aD +0x04` bit `0x02` selects premultiplied alpha" → **refuted.**
|
||||
Premultiplied requires `RGB ≤ A` everywhere; over 170 decoded textures the
|
||||
flagged group violates it on a median **52.5 %** of pixels against **30.2 %**
|
||||
unflagged — both far from premultiplied, and the flagged group *further*.
|
||||
[`ui-paint-order-key.md`](structures/ui-paint-order-key.md)
|
||||
* "`build-reborn test` cannot terminate" → **mine, and too strong; corrected the
|
||||
next iteration.** It is heavy, not hung: 19 of 166 `.xpr` containers exceed
|
||||
25 s, `Stage_S02` completes in **144 s** with `rc = 0`, and one full pass is
|
||||
~45–60 minutes. The 3 h 26 m observed was that work at a load average of 9–14,
|
||||
inflated by my own two duplicate runs.
|
||||
[`test-suite-runtime.md`](test-suite-runtime.md)
|
||||
* "the case for the keyframe-time shift rests on a single element" → **no longer
|
||||
true.** Three elements across two screens discriminate and all favour it:
|
||||
`palogo_gamearts` and `palogo_seta` hold full alpha 83 frames, `palogo_sqex`
|
||||
≥77, against 6–8 predicted by the current reading and 80–102 by the shifted
|
||||
one. The `_eff` glows fit both and argue against neither.
|
||||
[`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)
|
||||
* "an element with no held pose should be drawn as NOTHING rather than at a
|
||||
guessed endpoint" → **mine, and refuted.** Suppressing every plateau-less
|
||||
element and re-correlating against the live captures: title +0.9500 → +0.6839,
|
||||
main menu +0.9460 → +0.9037, `EXTRAS` +0.9440 → +0.9094. Worse on all three.
|
||||
* "`rest()` guesses for 24.57 % of elements (3 807 of 15 493)" → **mine, and
|
||||
overstated by 65 %.** The plateau test marks a **single-keyframe** element as
|
||||
plateau-less because it has no adjacent pair — but its one pose is
|
||||
unambiguously its rest. 1 502 of the 3 807 are those; the genuinely ambiguous
|
||||
population is **2 305 (14.88 %)**.
|
||||
[`ui-resting-pose.md`](structures/ui-resting-pose.md)
|
||||
* "`rest()` for a plateau-less element should be the last keyframe → refuted by
|
||||
the sibling argument" → **that refutation is itself refuted, this time by
|
||||
measurement.** Rendering under the rule and correlating against the live
|
||||
captures: publisher splash +0.9600 → **+0.9982**, developer splash +0.9643 →
|
||||
**+0.9758**. Making `palogo_anima_eff` invisible *improves* the match; the
|
||||
sibling symmetry was my expectation, not evidence.
|
||||
* "the port's exposure to the rest-guessing defect is 14 elements" → **two.** The
|
||||
fallback needs an element to be plateau-less **and** multi-keyframe; title,
|
||||
main menu and `EXTRAS` reach it **zero** times, the two splashes once each.
|
||||
[`ui-resting-pose.md`](structures/ui-resting-pose.md)
|
||||
* "the shifted time reading implies rest = the last keyframe, so the plateau rule
|
||||
can be dropped" → **mine, and refuted by measurement.** Applying it to every
|
||||
element collapses all five screens (title 0.9500→0.6819, main menu
|
||||
0.9460→0.6416, `EXTRAS` 0.9440→0.5745) and renders both splashes **blank**. A
|
||||
group is entry → hold → exit and the exit is the screen's *dismissal*: a
|
||||
displayed screen sits at the hold, not at its final pose.
|
||||
[`ui-resting-pose.md`](structures/ui-resting-pose.md)
|
||||
* "`rest_plateau` renders elements the game has already finished with" (as a
|
||||
general claim) → **narrowed by its control.** It holds on **transient** screens
|
||||
only: suppressing the finished glows takes the two splashes from 0.9604/0.9659
|
||||
to **0.9982/0.9980**, while the same edit costs the title 0.002, the main menu
|
||||
**0.092** and `EXTRAS` **0.107**. A plateau mid-animation is evidence the
|
||||
element is held at that point in the timeline, not that it is on screen once the
|
||||
screen has settled — and where a screen does settle, the rule is right.
|
||||
[`ui-resting-pose.md`](structures/ui-resting-pose.md)
|
||||
* "the group header's undecoded lead-in word carries a per-element start offset"
|
||||
→ **refuted immediately.** It is `0x00000000` for all seven elements of the
|
||||
developer splash — glows and logos alike — while those two families are
|
||||
observed to run sequentially (frames 94–115 and 116–211) despite declaring
|
||||
overlapping times. [`ui-group-start-time.md`](structures/ui-group-start-time.md)
|
||||
* "the splash elements share one clock origin" → **refuted.** Fitting a single
|
||||
origin needs f₀ ≈ 93.5 for `palogo_gamearts_eff` and f₀ ≈ 103 for
|
||||
`palogo_gamearts`, ~19 units apart, and aligning one throws the other off by
|
||||
~9 frames at both ends. Durations match (97.8 %, 98.5 %); starts do not.
|
||||
* "the glows and logos might overlap and my size-grouping merged them" → **tested
|
||||
and refuted.** Across all 235 captured frames, **zero** contain both a glow and
|
||||
a logo; f110–115 draw two glows and f116 onward two logos, with no transition
|
||||
frame. The sequencing is real.
|
||||
* "a bundle's declared elements are what the screen shows" → **refuted.** Entry 11
|
||||
declares three logo/glow pairs and only two are ever drawn — `palogo_anima`
|
||||
gets 0 frames against `palogo_gamearts`'s 95, from byte-identical keyframe
|
||||
times. (Reach: within the capture's frames 1–214.)
|
||||
[`ui-group-start-time.md`](structures/ui-group-start-time.md)
|
||||
* "the glow and logo phases are one bundle with elements selectively activated"
|
||||
→ **mine, and withdrawn as unestablished.** The alternative — two compositions
|
||||
shown in sequence — fits equally. The texture-base test fails its control: the
|
||||
publisher splash is a different bundle and shares the base `0x11C30000`, so
|
||||
that address is a reused upload slot, not a bundle identity. What survives is
|
||||
that declared elements ≠ drawn elements.
|
||||
[`ui-group-start-time.md`](structures/ui-group-start-time.md)
|
||||
* "two compositions shown in sequence" (as the alternative to selective
|
||||
activation) → **refuted statically.** It requires a bundle declaring the glows
|
||||
without the logos; no such bundle exists. Only four `GP_TITLE` entries carry
|
||||
`palogo` elements, and both developer entries declare **all six** logos and
|
||||
glows — so whichever was active, a subset of its elements was drawn at a time.
|
||||
Selective activation is reinstated on evidence.
|
||||
[`ui-group-start-time.md`](structures/ui-group-start-time.md)
|
||||
* ~~"the splash's `measured_paint_order` records a front-to-back depth order" →
|
||||
mis-typed; between its glow and logo halves it records only the temporal order
|
||||
they were seen in."~~ → **that refutation is itself refuted (same day).** The
|
||||
vector is a read of the runtime **child array** (`ui-screen-runtime.md`:
|
||||
"paint order (child slots)"), not of the draw capture, so co-occurrence does
|
||||
not bear on it; and the halves carry *distinct* T8aD layer keys
|
||||
(`0xa100` < `0xa110`, `paint_order_audit`: 0 same-key ties), so the file orders
|
||||
them regardless. The no-overlap measurement was correct; the inference from it
|
||||
was not. What survives: a capture of this screen can only cross-check the order
|
||||
*within* each half. [`ui-prm-primitives.md`](structures/ui-prm-primitives.md)
|
||||
|
||||
* "`8AX` is the name a `T8aD` is registered under" → **it is not a name at all.**
|
||||
It is three bytes of the preceding record's payload (`38 41 58`) that happen to
|
||||
be printable ASCII, which our backwards printable-run scan preferred over the
|
||||
name the format actually states in its `opt ` block. The claim sat in
|
||||
`HANDOFF.md` and `ui-8ax-fullres-background.md` as though `8AX` were a real
|
||||
identifier, and cost every menu screen its full-resolution background.
|
||||
[`ratc-child-names.md`](structures/ratc-child-names.md)
|
||||
* "`pmbase.t32` is on the disc nowhere" (the one dangling asset behind
|
||||
`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)
|
||||
|
||||
583
docs/re/boot-config-and-gamepart-registry.md
Normal file
@@ -0,0 +1,583 @@
|
||||
# What the game reads at boot — `config.ini`, and which GameParts actually exist
|
||||
|
||||
**Status:** ✅ `CONFIRMED` and **decoded** for the language selection. ❔ a bounded
|
||||
**negative** for the boot *order*. 🟡 for what the registration strings imply
|
||||
about the attract loop.
|
||||
|
||||
Contributes to [MISSION Q6](../port/MISSION.md), and firms up Q4's ids.
|
||||
|
||||
## ✅ `config.ini` — the disc's only config, and it selects the language
|
||||
|
||||
`config.ini` sits at the **disc root**, is 400 bytes, and is the **only** `.ini`,
|
||||
`.cfg` or `.txt` anywhere on the disc. Its own header comment (Shift-JIS) names
|
||||
it:
|
||||
|
||||
> `アプリケーション/ゲームパート初期設定テーブル`
|
||||
> — *"Application / GamePart initial settings table"*
|
||||
>
|
||||
> `SYSTEM セクションには、ゲーム及び各ゲームパートで共通にアクセスする情報を記述する`
|
||||
> — *"the SYSTEM section describes information accessed in common by the game and
|
||||
> each game part"*
|
||||
|
||||
```ini
|
||||
[SYSTEM]
|
||||
|
||||
[LANGUAGE]
|
||||
= eng ; default
|
||||
#0x01 = eng ; XC_LANGUAGE_ENGLISH
|
||||
#0x02 = jpn ; XC_LANGUAGE_JAPANESE
|
||||
#0x03 = deu ; XC_LANGUAGE_GERMAN
|
||||
#0x04 = fra ; XC_LANGUAGE_FRENCH
|
||||
#0x05 = esp ; XC_LANGUAGE_SPANISH
|
||||
#0x06 = ita ; XC_LANGUAGE_ITALIAN
|
||||
```
|
||||
|
||||
**This is the mechanism behind the EN/JP screen pairs.** The console's
|
||||
`XC_LANGUAGE_*` setting selects a three-letter code, and that code is what picks
|
||||
`GP_TITLE`'s English or Japanese build ([`ui-title-build-map.md`](ui-title-build-map.md))
|
||||
and the `<lang>.pak` families. Default is `eng`, keyed by the empty line — an
|
||||
unlisted language falls back to English. ✅ decoded. The executable does read the
|
||||
file: the string `config.ini` is at `.rdata 0x82062b44`.
|
||||
|
||||
## ❔ But the boot ORDER is not in it — and this is the whole search space
|
||||
|
||||
`[SYSTEM]` — the section the file's own comment says holds what the game and every
|
||||
game part share — is **empty**. So the file the game itself calls the *GamePart
|
||||
initial settings table* says nothing about which part runs first or what follows
|
||||
what.
|
||||
|
||||
**Reach of the negative:** this is the only config file on the disc (one `find`
|
||||
over the whole extract). The boot order is therefore not in disc-side
|
||||
configuration at all; it is in code, or in a table inside the executable that has
|
||||
not been located.
|
||||
|
||||
## 🟡 `GP_ADVERTISE_DEMO` is never registered — the attract loop is not its own part
|
||||
|
||||
The executable carries one diagnostic string per GamePart registration site
|
||||
(`silph::GamePartTask::RegisterToFactory<N, class silph::GamePart_X>::RegisterToFactory is failed!`)
|
||||
— the same evidence the corpus used to pin the id table
|
||||
([`challenge-mission-gate.md`](challenge-mission-gate.md) §3). Extracting all of
|
||||
them gives **24 of the 29 ids bound to a C++ class**; full list in
|
||||
[`data/gamepart-class-ids.txt`](data/gamepart-class-ids.txt).
|
||||
|
||||
**The five ids with no registration site are `1`, `2`, `16`, `18`, `28`** — in the
|
||||
id table's naming, `GP_ADVERTISE_DEMO`, `GP_SELECT_STORAGE`, `GP_DEMO`,
|
||||
`GP_SELECTOR`, `GP_TEST`.
|
||||
|
||||
That `GP_ADVERTISE_DEMO` (1) is among them matters for Q6, and it agrees with
|
||||
what was measured: the attract loop is **the title screen replaying `ADV.wmv`**
|
||||
([`movie-binding.md`](movie-binding.md)), not a transition into a separate
|
||||
advertise part. The id table names a part the shipped build never registers.
|
||||
|
||||
🟡 **not ✅**, because this is an argument from a *diagnostic string*: no error
|
||||
message for id 1 implies no registration site for id 1. That is how the corpus
|
||||
already reads these strings, and it is sound, but it is not the code.
|
||||
|
||||
### Two bonuses for Q4
|
||||
|
||||
* **`3` and `4` are the same class** — `GamePart_SaveLoad` is registered twice.
|
||||
The id table's separate `GP_LOAD` / `GP_SAVE` names are two ids on one part.
|
||||
* The ids behind the menu buttons now match the executable's own **class** names,
|
||||
not just a list of table names: `GamePart_Options` = 8, `GamePart_Tutorial` =
|
||||
25, `GamePart_Extras` = 5, `GamePart_MissionSelect` = 7,
|
||||
`GamePart_MovieTheater` = 6, `GamePart_Title` = 0. Still a **name match** —
|
||||
screen title ↔ class name — but anchored one level closer to the code.
|
||||
|
||||
## What is still missing for Q6
|
||||
|
||||
The *transitions*. Nothing found so far says "title, then movie, then title" — the
|
||||
manifest gives the boot-side **assets** in play order
|
||||
([`movie-binding.md`](movie-binding.md)), `config.ini` gives the **language**, and
|
||||
the registry gives **which parts exist**. What decides to advance is in
|
||||
`GamePart_Title`'s own code, and reading it is a static PPC job that has not been
|
||||
started.
|
||||
|
||||
## ❔ The transitions are code, not data — the reach of that negative
|
||||
|
||||
Q6's remaining half asked what the game *reads* to decide the boot order. The
|
||||
answer is: **nothing. It is not data-driven.** Four independent places were
|
||||
checked, and the sequence is in none of them.
|
||||
|
||||
| looked in | result |
|
||||
|---|---|
|
||||
| **disc configuration** | `config.ini` is the only config file on the disc, its `[SYSTEM]` section is empty (above) |
|
||||
| **the movie manifest** | carries the boot-side *assets* in play order, and no transitions — [`movie-binding.md`](movie-binding.md) |
|
||||
| **a persistent part-id field** | already refuted: the requested GamePart id exists **only as a stack argument in flight**, with no literal store anywhere — [`challenge-mission-gate.md`](challenge-mission-gate.md) §5 |
|
||||
| **the id table's attract entry** | the string `GP_ADVERTISE_DEMO` at `0x820a1fe8` has **zero xrefs of any kind**; nothing in the code reads it |
|
||||
|
||||
So a transition is a **call with an id argument**, chosen by code. There is no
|
||||
table to read and nothing to poke.
|
||||
|
||||
**Where that code is, as far as it was traced.** The `RegisterToFactory<0, class
|
||||
silph::GamePart_Title>` diagnostic string at `0x820a3d60` is referenced from
|
||||
exactly one place, `sub_8280E148` — the registration site — which also takes the
|
||||
address of **`sub_821C7D98`** (`addi`), the position a factory template puts its
|
||||
creator. 🟡 That identification is by position and convention, not proven;
|
||||
`sub_821C7D98` itself has **0 `.rdata` references**, consistent with a small
|
||||
`new`+ctor thunk rather than the state machine. The substantial function in the
|
||||
same class neighbourhood is `sub_821C6458` (4 460 bytes, has EH, 15 `.rdata`
|
||||
refs), and **reading it has not been attempted**.
|
||||
|
||||
### What this means for the port
|
||||
|
||||
The boot sequence is **authored**, not transcribed — and that is fine, because the
|
||||
sequence itself is measured end to end:
|
||||
|
||||
```
|
||||
developer splash (a RATC screen, not a video)
|
||||
→ ADV.wmv
|
||||
→ title + PRESS Ⓐ ──idle ~8–10 s──> fade to black → ADV.wmv in full → title
|
||||
→ Ⓐ → main menu
|
||||
```
|
||||
|
||||
with the fade-through-black timings in [`screen-transitions.md`](screen-transitions.md)
|
||||
and Ⓑ from the main menu returning to the title.
|
||||
|
||||
|
||||
## 🟡 `sub_821C6458` read — the title's states are NAMED in the executable
|
||||
|
||||
The page above left this function as the named next step: "the substantial
|
||||
function in `GamePart_Title`'s neighbourhood, and **reading it has not been
|
||||
attempted**". It has now been looked at — not disassembled line by line, but
|
||||
characterised, which is enough to sharpen Q6.
|
||||
|
||||
**It is the title part's screen-state function.** 4 460 bytes, has EH, and called
|
||||
from **exactly one** place — `sub_821C7850`, which sits in the same neighbourhood
|
||||
as the creator the registration site points at (`sub_821C7D98`). It makes 33
|
||||
distinct calls.
|
||||
|
||||
**What it names.** Its `.rdata` string references are:
|
||||
|
||||
| string | at |
|
||||
|---|---|
|
||||
| **`TITLE_SCREEN`** | `0x820a3d3c` |
|
||||
| **`TITLE_MENU`** | `0x820a3d30` |
|
||||
| **`LOADING`** | `0x820a214c` |
|
||||
| `BASE_INFO` (×2) | `0x820a20ec` |
|
||||
|
||||
Those are the states measured off the running game, in the game's own words: the
|
||||
title carrying `PRESS Ⓐ BUTTON`, the five-button menu, and a loading state. And
|
||||
`BASE_INFO` is this corpus's own marker for a **screen-config lookup** rather than
|
||||
a table read ([`REFUTED.md`](REFUTED.md), "`BASE_INFO` discriminates
|
||||
screen-config from table-read, 9/9 vs 10/10").
|
||||
|
||||
### The sharper negative
|
||||
|
||||
So the title part **does ask for configuration keyed by `TITLE_SCREEN` and
|
||||
`TITLE_MENU`** — and [`config.ini`](#-configini--the-discs-only-config-and-it-selects-the-language),
|
||||
the disc's only config file, contains **no such sections**. Its only sections are
|
||||
an empty `[SYSTEM]` and `[LANGUAGE]`. The lookups find nothing, and the values are
|
||||
whatever the code defaults to.
|
||||
|
||||
That is a better answer than "the order is not in config": the game *asks the
|
||||
question*, the shipped disc *does not answer it*, and the defaults live in code.
|
||||
For the port it means the state **names** are transcribable even though their
|
||||
contents are not.
|
||||
|
||||
🟡 **Not ✅.** This is a characterisation from string references and call counts,
|
||||
not a read of the control flow. Two self-references (`0x821c6498`, `0x821c6b7c`)
|
||||
inside the function look like jump tables — a switch, which is what a state
|
||||
machine compiles to — but that was **not confirmed**, and nothing here shows which
|
||||
state leads to which.
|
||||
|
||||
### 🔴 Refuted on the way, because it looked like a find
|
||||
|
||||
The words at `0x820a3b48`… resolve as neat `{func, func, ptr}` triples and read
|
||||
convincingly as a state/handler table. **They are not.** The bytes immediately
|
||||
before them are the tail of
|
||||
`…SaveLoad>::RegisterToFactory is failed!`, and the `0x8210c1xx` targets are
|
||||
zero-filled descriptors — static-initialiser records trailing the registration
|
||||
strings, not a dispatch table. A plausible-looking array of function pointers next
|
||||
to relevant strings is not evidence of anything until its neighbours are read.
|
||||
|
||||
|
||||
## 🟡 The title's transition is a screen lookup BY NAME — `sub_821CC860`
|
||||
|
||||
Reading the code around each of the three state names shows the **same four
|
||||
instructions** at all three sites:
|
||||
|
||||
```
|
||||
lwz r29, 20(r30) ; an object off the part
|
||||
lwz r3, 4(...) ; ...
|
||||
bl 0x822F2328
|
||||
lis r11, 0x820A
|
||||
li r6, 0
|
||||
lwz r4, 24(r30)
|
||||
addi r5, r11, 15676 ; "TITLE_SCREEN" (15664 = "TITLE_MENU",
|
||||
lwz r3, 88(r30) ; 8524 = "LOADING")
|
||||
bl 0x821CC860 ; <- lookup(this+88, this+24, NAME, 0)
|
||||
mr r4, r3
|
||||
bl 0x82187B78 ; <- install the result
|
||||
```
|
||||
|
||||
So a title-side transition is **`sub_821CC860(…, "<NAME>", 0)` followed by
|
||||
`sub_82187B78(result)`** — a *string-keyed* screen lookup, then an install. Not a
|
||||
numeric id, not a table index. That also fits `GP_ADVERTISE_DEMO` having zero
|
||||
xrefs: at this level the screen graph is keyed by **name**, not by GamePart id.
|
||||
|
||||
### The candidate name vocabulary
|
||||
|
||||
`sub_821CC860` is called from **28 distinct functions**. Collecting the
|
||||
upper-case identifier strings those callers reference gives 35 names, and they
|
||||
split into two obvious families:
|
||||
|
||||
* **screen/state names** — `TITLE_SCREEN`, `TITLE_MENU`, `LOADING`,
|
||||
**`DIFFICULTY`**, `EXTRA_MENU`, `TUTORIAL_MENU`, `STANDARD_MENU`, `DEBRIEFING`,
|
||||
`CHALLENGE`, `MISSIONS`, `LIVE_BOARD`, `LOCAL_BOARD`, `EXTRA_MENU`;
|
||||
* **config keys** — `TEXT_FONT`, `TEXT_HEIGHT`, `LINE_SPACE`, `GAMMA_RGB`,
|
||||
`GAMMA_WB`, `TEXT_SPEED_PER_LETTER`, `EXIT_VALUE`, `INPUT_DIR`,
|
||||
`MENU_ENABLE_SKIP`/`_DISABLE_SKIP`, `PAUSE_SE`, `JINGLE`, …
|
||||
|
||||
plus `BASE_INFO` in **19 of the 28** callers, which is this corpus's existing
|
||||
marker for a screen-config function.
|
||||
|
||||
**Three of the screen names are independently corroborated by measurement**:
|
||||
`DIFFICULTY` is exactly the screen `NEW GAME` opens, `EXTRA_MENU` matches the
|
||||
`EXTRAS` submenu and `TUTORIAL_MENU` the lesson list — all three measured off the
|
||||
running game in [`menu-navigation-semantics.md`](menu-navigation-semantics.md)
|
||||
before this function was ever looked at.
|
||||
|
||||
⚠️ **Why this is 🟡 and not ✅.** The 35 strings are what those callers
|
||||
*reference*, **not** proven arguments to `sub_821CC860` — the list plainly mixes
|
||||
screen names with config keys, so it is a **candidate vocabulary**, not a decoded
|
||||
one. Confirming it means checking, per call site, which string actually lands in
|
||||
`r5`. That was not done.
|
||||
|
||||
### What is still unread
|
||||
|
||||
Which state leads to which. The three lookups sit in different branches of one
|
||||
function and at least one (`TITLE_MENU`) is guarded by a `cmplwi`/`bne`, but the
|
||||
branch structure was not traced, so the *order* still comes from measurement, not
|
||||
from the code.
|
||||
|
||||
|
||||
## ✅ The argument is now decoded — and it refutes my own corroboration
|
||||
|
||||
The section above listed a **candidate** vocabulary from "upper-case strings the
|
||||
callers reference", flagged 🟡 because those are not proven arguments, and named
|
||||
the check: *confirm per call site which string actually lands in `r5`.* That check
|
||||
has been run, and it fired.
|
||||
|
||||
**Method.** Forward register simulation over the 100 instructions before each of
|
||||
the **48** call sites of `sub_821CC860`, tracking `lis` / `addi` / `subi` / `mr`.
|
||||
Full table in [`data/name-lookup-callsites.txt`](data/name-lookup-callsites.txt).
|
||||
|
||||
**Gated on a control, and the first version failed it.** A backward scan for
|
||||
`addi r5, …` recovered only 7/48 and **missed both title sites I had read by
|
||||
eye** — because there the name goes into `r27` first and reaches `r5` via a later
|
||||
`mr`. The rewritten forward simulation reproduces all three known sites
|
||||
(`TITLE_SCREEN`, `TITLE_MENU`, `LOADING`) and then recovers **46 of 48**.
|
||||
|
||||
### 🔴 `DIFFICULTY` and `EXTRA_MENU` are *not* arguments
|
||||
|
||||
Last section I wrote that three names were "independently corroborated by
|
||||
measurement": `DIFFICULTY`, `EXTRA_MENU`, `TUTORIAL_MENU`. **Only
|
||||
`TUTORIAL_MENU` survives.** `DIFFICULTY` and `EXTRA_MENU` never appear in `r5` at
|
||||
any of the 48 sites — they are strings that merely live in the same functions.
|
||||
The corroboration I claimed was an artifact of the loose filter, and the 🟡 I put
|
||||
on it is exactly what caught it.
|
||||
|
||||
### The 28 names that ARE passed
|
||||
|
||||
```
|
||||
BASE BASE(x3) BG(x3) BLACK(x2) BUTTON DEBRIEFING DETAIL EXTRA FADE(x3)
|
||||
FILE(x5) GAME GAMMA_RGB GAMMA_TITLE INFO KEY LIVE_BOARD(x2) LOADING(x2)
|
||||
LOCAL_BOARD(x2) MENU(x2) MENU_DISABLE_SKIP MESSAGE NEW_ITEM(x2) PAD SOUND
|
||||
TITLE(x2) TITLE_MENU TITLE_SCREEN(x2) TUTORIAL_MENU WINDOW
|
||||
```
|
||||
|
||||
### 🔴 So `sub_821CC860` is not a screen factory
|
||||
|
||||
The previous section called it one. The real argument list says otherwise:
|
||||
`BG`, `BLACK`, `FADE`, `FILE`, `KEY`, `PAD`, `SOUND`, `WINDOW`, `GAMMA_RGB`,
|
||||
`MENU_DISABLE_SKIP` are **not screens**. It is a **generic name-keyed lookup** —
|
||||
a named-entry getter used for config and resources throughout the executable, of
|
||||
which the title part happens to call it with its three state names.
|
||||
|
||||
That weakens nothing about the title finding itself — `sub_821C6458` really does
|
||||
transition by calling this with `TITLE_SCREEN` / `TITLE_MENU` / `LOADING` — but it
|
||||
removes the inference that the 28 names are a screen vocabulary. They are a
|
||||
**lookup-key vocabulary**, mostly config.
|
||||
|
||||
|
||||
## ✅ The title's state machine — decoded
|
||||
|
||||
The last open piece of Q6 was *which state leads to which*. It is a plain
|
||||
`switch` and it is now read.
|
||||
|
||||
```
|
||||
821c6474 lwz r11, 136(r30) ; state = this+0x88
|
||||
821c6478 cmplwi cr6, r11, 0x9 ; ten states, 0..9
|
||||
821c647c bgt cr6, <default>
|
||||
821c6480 lis/addi r12, 0x821C6498 ; jump table
|
||||
821c648c lwzx r0, r12, r0
|
||||
821c6494 bctr
|
||||
```
|
||||
|
||||
⚠️ The table at `0x821C6498` disassembles as ten `lwz r16, N(r28)` instructions.
|
||||
**It is data.** That is the "self-reference" this page flagged two sections ago as
|
||||
*looking* like a jump table — it is one, and the disassembler was decoding its
|
||||
words as code.
|
||||
|
||||
**Three of the ten states install a named screen**: **0 → `TITLE_SCREEN`**,
|
||||
**2 → `TITLE_MENU`**, **8 → `LOADING`**.
|
||||
|
||||
**Eighteen transitions**, every one a literal `li rX, N ; stw rX, 136(r30)`:
|
||||
|
||||
```
|
||||
0 → 1, 2 2 → 4 4 → 0, 5, 8×4 6 → 7, 9, 2 8 → 2
|
||||
1 → 2, 2 3 → 4 5 → 6 7 → 9 9 → (none)
|
||||
```
|
||||
|
||||
Full table with addresses in
|
||||
[`data/title-state-machine.txt`](data/title-state-machine.txt).
|
||||
|
||||
### 🟡 It lines up with what was measured — read as corroboration, not proof
|
||||
|
||||
The boot reaches the title (state 0) and Ⓐ opens the main menu (state 2); the
|
||||
graph has `0 → 2` directly and `0 → 1 → 2`. Ⓑ at the main menu returns to the
|
||||
title, and `4 → 0` is the **only** edge back to state 0, reached from `2 → 4`.
|
||||
Entering a submenu goes through `LOADING` and comes back, and `4 → 8` (four
|
||||
separate sites) then `8 → 2` is exactly that shape.
|
||||
|
||||
Those readings are **mine, matching a graph to observed behaviour** — the
|
||||
conditions on the edges are not decoded, so nothing here proves which input picks
|
||||
which branch.
|
||||
|
||||
### What is still not decoded
|
||||
|
||||
* **the condition on each edge** — which input or event selects it;
|
||||
* **states 1, 3, 5, 6, 7, 9** install no named screen, so what they do is unknown;
|
||||
* **state 3 is never a destination** in this function, so something outside sets
|
||||
it.
|
||||
|
||||
|
||||
## ✅ The edge conditions, for the state that has them — an EVENT CODE
|
||||
|
||||
The previous section left "the condition on each edge" open. For the state that
|
||||
matters it is now read.
|
||||
|
||||
**`sub_821C6458`'s third argument is an event code.** State 4 — reached from
|
||||
`2 → 4`, i.e. immediately after the main menu is installed — is the
|
||||
**input-waiting state**, and it dispatches on that argument:
|
||||
|
||||
```
|
||||
821c6b5c cmplwi cr6, r27, 0x19 ; 26 events, 0..25
|
||||
821c6b60 bgt cr6, <default>
|
||||
821c6b68 addi r12, r12, 27516 ; second jump table, at 0x821c6b7c
|
||||
821c6b78 bctr
|
||||
```
|
||||
|
||||
**Six of the 26 are handled**; the other twenty fall through and change nothing.
|
||||
|
||||
| event | → state | |
|
||||
|---|---|---|
|
||||
| **0** | **0** | `TITLE_SCREEN` — back to the title |
|
||||
| 3 | 8 | `LOADING` |
|
||||
| 5 | 8 | `LOADING` |
|
||||
| 8 | 8 | `LOADING` |
|
||||
| 10 | 5 | — |
|
||||
| 25 | 8 | `LOADING` |
|
||||
|
||||
So last section's edges `4 → 0, 5, 8×4` are each now attributed to a specific
|
||||
event, and the shape of the state graph is complete for the input state.
|
||||
|
||||
### 🟡 A correspondence worth noticing, and not more than that
|
||||
|
||||
The main menu has five items, Ⓑ returns to the title, and this table has **one
|
||||
event to state 0, four to `LOADING`, one elsewhere**. It is tempting to read that
|
||||
as *Ⓑ = event 0, four items load an external archive, `EXTRAS` stays inside
|
||||
`GP_TITLE`* — which would match everything measured.
|
||||
|
||||
**That is a count-match, not a decode.** The event numbers are not named, nothing
|
||||
here shows event 3 is a particular menu row, and state 5 installs no named screen,
|
||||
so the `EXTRAS` half of the story has no support at all. Recorded as an
|
||||
observation so the next person sees the shape; it is **not** a button→event map.
|
||||
|
||||
### What is still not decoded
|
||||
|
||||
* **what the event numbers mean** — button id, menu-item index, or message id;
|
||||
* **conditions on edges out of the other states** (`0 → 1` vs `0 → 2`, `6 → 7/9/2`);
|
||||
* states 1, 3, 5, 6, 7, 9, which install no named screen.
|
||||
|
||||
|
||||
## 🔴 Correcting my own framing: `sub_821C6458` is ONE PHASE, not the whole part
|
||||
|
||||
Chasing where the event code comes from turned up the level above, and it revises
|
||||
what the previous three sections called "the title part's state machine".
|
||||
|
||||
`sub_821C6458` has exactly one caller, `sub_821C7850`, and that caller is itself a
|
||||
dispatcher — on a **second, outer state field**:
|
||||
|
||||
```
|
||||
821c786c lwz r11, 16(r30) ; only runs at all when this+16 == 3
|
||||
821c7874 bne cr6, <exit>
|
||||
821c787c lwz r11, 132(r30) ; phase = this+0x84
|
||||
821c7880 cmplwi cr6, r11, 0x4 ; FIVE phases, 0..4
|
||||
821c789c bctr ; table at 0x821C78A0
|
||||
```
|
||||
|
||||
| phase | handler | |
|
||||
|---|---|---|
|
||||
| 0 | `sub_821C5690` | **the splash** — independently identified as the splash mechanics in the `iterate3E` notes, before any of this |
|
||||
| 1 | inline at `0x821c790c` | |
|
||||
| 2 | `sub_821C5818` | |
|
||||
| 3 | `sub_821C5EC0` | |
|
||||
| **4** | **`sub_821C6458`** | the title/menu machine — `TITLE_SCREEN` / `TITLE_MENU` / `LOADING` |
|
||||
|
||||
So `GamePart_Title` has **two nested state fields**: a phase at `this+132` (five
|
||||
values) selecting which sub-machine runs, and the state at `this+136` (ten values)
|
||||
*inside phase 4*. Everything the previous sections decoded — the ten states, the
|
||||
eighteen edges, the event dispatch — is **phase 4 only**. Phases 0–3 are
|
||||
untouched, and one of them is the developer splash.
|
||||
|
||||
That phase 0 lands on the function the corpus had already fingered as the splash,
|
||||
from a completely different direction, is the useful check here.
|
||||
|
||||
### And the event code is forwarded, not created
|
||||
|
||||
All five phase handlers are called as `(this, r29, r28)` with the *same*
|
||||
arguments, and `r28` is `sub_821C7850`'s own third argument, passed through
|
||||
untouched. So the event vocabulary is defined **at least one level above** this
|
||||
function. Finding what `3`, `5`, `8`, `10`, `25` mean means going up again, and
|
||||
that was not done.
|
||||
|
||||
|
||||
## Going up one more level — three of four callers pass a constant event
|
||||
|
||||
`sub_821C7850` has **four** direct callers. Recovering the `r5` argument at each:
|
||||
|
||||
| caller | event passed |
|
||||
|---|---|
|
||||
| `sub_821C7CB8` | **0** (→ back to the title) |
|
||||
| `sub_821C7BA0` | **0** |
|
||||
| `sub_821C47A0` | **5** (→ `LOADING`) |
|
||||
| `sub_821C5580` | **not constant** — `lwz r5, 4(r27)`, read out of a structure |
|
||||
|
||||
So the vocabulary is still not enumerable from here: the interesting caller reads
|
||||
its event from a field. ❔ What `3`, `8`, `10` and `25` mean remains open.
|
||||
|
||||
**`sub_821C5580` is also where the outer gate is set:**
|
||||
|
||||
```
|
||||
821c5640 li r11, 3
|
||||
821c5644 stw r11, 16(r28) ; this+16 = 3 — the exact value sub_821C7850 tests
|
||||
821c5650 lwz r5, 4(r27) ; event from a struct field
|
||||
821c5658 bl 0x821C7850
|
||||
```
|
||||
|
||||
That answers the "what does `this+16 == 3` gate on" question from the previous
|
||||
section: this function arms it.
|
||||
|
||||
### 🎁 And it names the title's music
|
||||
|
||||
The same function, a few instructions earlier:
|
||||
|
||||
```
|
||||
821c560c li r5, 1103
|
||||
821c5610 li r4, 4
|
||||
821c561c lwz r3, 0(r29)
|
||||
821c5620 bl 0x8217ACF8 ; a sound-play call
|
||||
```
|
||||
|
||||
**1103 is a BGM cue id** — `BGM_103`. That closes an open residual from Q8/Q10
|
||||
("which bank is the menu's music is not on the disc"), and it checks out three
|
||||
ways; see [`structures/bgm-two-stems.md`](structures/bgm-two-stems.md).
|
||||
|
||||
|
||||
## The other phases, characterised — and phase 0 confirms the splash twice over
|
||||
|
||||
Same treatment for the phase handlers that were still unread. Strings each one
|
||||
references, and whether it carries a jump table of its own:
|
||||
|
||||
| phase | handler | size | strings referenced | own switch |
|
||||
|---|---|---|---|---|
|
||||
| 0 | `sub_821C5690` | 380 | **`LOGO`** | none |
|
||||
| 2 | `sub_821C5818` | 1 576 | `BASE_INFO`, **`BUTTON`**, **`TITLE_SCREEN`** | none |
|
||||
| 3 | `sub_821C5EC0` | 1 220 | *(none)* | one, `bctr` at `0x821c5ef8` |
|
||||
| 4 | `sub_821C6458` | 4 460 | `BASE_INFO`, `LOADING`, `TITLE_MENU`, `TITLE_SCREEN` | two (known) |
|
||||
|
||||
**Phase 0 references `LOGO`** — a second, independent confirmation that it is the
|
||||
developer splash. The `iterate3E` notes reached `sub_821C5690` from the guest side
|
||||
and named the splash's `LOGO` items; this reaches the same function from the
|
||||
registration site and finds the same string.
|
||||
|
||||
**Phase 2 draws the title *with* the `PRESS Ⓐ BUTTON` plate** — it references
|
||||
`TITLE_SCREEN` and `BUTTON`, and [`ui-title-build-map.md`](ui-title-build-map.md)
|
||||
established from the archive that the plate is its own build (2/3), composited
|
||||
over the title art.
|
||||
|
||||
### 🟡 A hypothesis for a puzzle this corpus has had open for months
|
||||
|
||||
**The title is installed from two different places**: phase 2, and phase 4's
|
||||
state 0. Same screen, different code.
|
||||
|
||||
[`canary-scripted-input-traps.md`](canary-scripted-input-traps.md) has long
|
||||
recorded, and never explained, that *the title which ends the boot accepts Ⓐ while
|
||||
the title the attract loop returns to accepts nothing* — with the giveaway that a
|
||||
draw capture in each state is identical, 13 quads at the same rects. Two code
|
||||
paths installing the same screen is exactly the shape that would produce that.
|
||||
|
||||
**Candidate:** the boot title is **phase 2**; the attract-returned title is
|
||||
**phase 4, state 0**. Only phase 2 wires up the Ⓐ handling.
|
||||
|
||||
⚠️ **Tested, and dead.** Ⓑ from the main menu is the decoded edge `4 → 0`, so the
|
||||
Ⓑ-returned title *is* phase 4 state 0 — and **Ⓐ works there**, opening the main
|
||||
menu, with Ⓐ on the boot title as the control in the same run. Phase 4 state 0
|
||||
handles Ⓐ, so the two-code-paths explanation is refuted. See
|
||||
[`canary-scripted-input-traps.md`](canary-scripted-input-traps.md), which the
|
||||
test did narrow usefully: only the **attract**-returned title is inert, not every
|
||||
title after the first.
|
||||
|
||||
### ⚠️ One query in this section failed its own control
|
||||
|
||||
Counting `stw rX, 136(r30)` per phase returned **0 for phase 4**, which is known
|
||||
to have 18 — the operand text is `r11, 136(r30)` and the pattern did not allow the
|
||||
space. The per-phase store counts were discarded. The `bctr` half *does* pass its
|
||||
control (phase 4 shows both known tables) and is reported above.
|
||||
|
||||
|
||||
## ✅ The phase transitions — and they invalidate yesterday's test
|
||||
|
||||
Nine stores to the phase field `this+132` across the `GamePart_Title`
|
||||
neighbourhood, attributed to the handler each lives in (control: the same query
|
||||
shape finds 30 stores to `this+136`, ≥ the 18 known in phase 4):
|
||||
|
||||
| store | sets phase | lives in |
|
||||
|---|---|---|
|
||||
| `0x821c4fbc` | 2 | outside the handlers (entry/setup) |
|
||||
| `0x821c4fe0`, `0x821c4fec` | 4 | outside the handlers |
|
||||
| `0x821c5010` | *(not an immediate)* | outside the handlers |
|
||||
| `0x821c592c` | **0** | phase 2 |
|
||||
| `0x821c5b00` | **3** | phase 2 |
|
||||
| `0x821c6370` | **4** | phase 3 |
|
||||
| **`0x821c6e04`** | **2** | **phase 4, in the event-0 block** |
|
||||
| `0x821c7950` | 2 | the dispatcher |
|
||||
|
||||
```
|
||||
entry → 2 2 → 0 (splash) 2 → 3 3 → 4 4 → 2 (event 0)
|
||||
```
|
||||
|
||||
### 🔴 Withdrawing the refutation from the previous iteration
|
||||
|
||||
The event-0 block sets **both** fields, one instruction apart:
|
||||
|
||||
```
|
||||
821c6df8 li r11, 2
|
||||
821c6e00 stw r28, 136(r30) ; state = 0
|
||||
821c6e04 stw r11, 132(r30) ; phase = 2
|
||||
```
|
||||
|
||||
So **Ⓑ from the menu lands in phase 2**, not phase 4 state 0. The previous section
|
||||
tested "Ⓐ on the Ⓑ-returned title" believing it was probing phase 4 state 0 —
|
||||
**it was probing phase 2**, the same phase as the boot title.
|
||||
|
||||
The test therefore says nothing about phase 4 state 0, and the refutation it
|
||||
produced is **withdrawn**. Worse for me and better for the idea: Ⓐ working there
|
||||
is exactly what the hypothesis predicts, since phase 2 is the phase that
|
||||
references `BUTTON`.
|
||||
|
||||
**Status of the hypothesis: untested, and now consistent with two observations**
|
||||
(Ⓐ works on the boot title and on the Ⓑ-returned title, both phase 2). What it
|
||||
still needs is the **attract**-returned title's phase — the one thing no test so
|
||||
far has read.
|
||||
@@ -643,3 +643,58 @@ Two lessons, both cheap:
|
||||
The evidence recovered cleanly once the interference stopped — the interrupted
|
||||
batch's own run 3 had already reached the menu with the same 40 calls / 6
|
||||
`ResolvePath` signature.
|
||||
|
||||
|
||||
## 2026-08-28 — a candidate explanation, from the static side
|
||||
|
||||
This page's oldest open question is **why the attract-returned title accepts no
|
||||
input while the boot title does**, given that a draw capture in each state is
|
||||
identical (13 quads, same rects).
|
||||
|
||||
`GamePart_Title` turns out to dispatch on an outer **phase** field at `this+132`,
|
||||
and **the title screen is installed from two different phases** — phase 2
|
||||
(`sub_821C5818`, which references `TITLE_SCREEN` *and* `BUTTON`) and phase 4's
|
||||
state 0 (`sub_821C6458`). Same screen, different code. See
|
||||
[`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md).
|
||||
|
||||
**Candidate:** the boot title is phase 2; the attract-returned title is phase 4
|
||||
state 0; only one of them wires up Ⓐ.
|
||||
|
||||
### ⚠️ Tested the same day, and the TEST was invalid — see the withdrawal below
|
||||
|
||||
There was a cheaper test than reading `this+132`: the state graph says Ⓑ from the
|
||||
main menu goes `4 → 0`, so **the Ⓑ-returned title *is* phase 4 state 0**. If phase
|
||||
4 state 0 were the inert one, Ⓐ would not work there.
|
||||
|
||||
**It works.** Measured:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| *control* — Ⓐ on the boot title | → main menu ✅ |
|
||||
| Ⓐ on the **Ⓑ-returned** title | → **main menu** ✅ |
|
||||
|
||||
**🔴 Withdrawn.** Reading the event-0 block in full shows it sets **two** fields,
|
||||
one instruction apart: `stw r28, 136(r30)` (state = 0) *and* `stw r11, 132(r30)`
|
||||
with `r11 = 2` — **phase = 2**. So Ⓑ lands in **phase 2**, the same phase as the
|
||||
boot title, and this test never probed phase 4 state 0 at all.
|
||||
|
||||
The refutation is withdrawn. Ⓐ working on the Ⓑ-returned title is exactly what
|
||||
the hypothesis predicts — phase 2 is the phase that references `BUTTON`.
|
||||
|
||||
### ✅ What the test DID establish — the puzzle is narrower than this page says
|
||||
|
||||
(This part stands: it is a measurement, independent of which phase the returned
|
||||
title turns out to be.)
|
||||
|
||||
This page frames the problem as *the boot title accepts Ⓐ, a later title does
|
||||
not*. That is too broad. **The Ⓑ-returned title accepts Ⓐ perfectly well** — a
|
||||
case nobody had tried. Only the **attract-returned** title is inert.
|
||||
|
||||
So whatever makes the title stop responding is tied to the attract cycle
|
||||
specifically — the movie playing and coming back — not to "the title has been
|
||||
shown before". That is a much smaller haystack than the one this page has been
|
||||
pointing at.
|
||||
|
||||
⚠️ Also reproduced in passing: 4 s after Ⓑ the plate is **absent** (169 plate
|
||||
pixels), and it fades in after. A test that samples too early will read the
|
||||
returned title as plate-less, which this corpus has already been caught by once.
|
||||
|
||||
364
docs/re/capture-harness-status.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# 🔴 Why the boot harness stopped reaching the title — `screenshot` costs 10.8 s
|
||||
|
||||
**Status:** ✅ **diagnosed, with a control.** Four consecutive runs on
|
||||
2026-08-28/29 failed to reach the interactive title, across two locales, two
|
||||
launch paths and two display-gamma settings. The cause is none of those.
|
||||
|
||||
## The measurement
|
||||
|
||||
| condition | one `screenshot` call |
|
||||
|---|---|
|
||||
| while Xenia Canary is running | **10.8 s** |
|
||||
| immediately after killing it | **0.117 s** |
|
||||
|
||||
**92×.** The 1-minute load average at the slow measurement was 1.80, so this is
|
||||
contention with the emulator (both go through the same X server), not general
|
||||
system load.
|
||||
|
||||
## Why that breaks the harness
|
||||
|
||||
`skip_intro.sh` takes **two** grabs 0.6 s apart per iteration, plus an
|
||||
`is_title.py` numpy load. Its actual sample timestamps in the last run:
|
||||
|
||||
```
|
||||
57, 95, 177, 218, 238, 298, 333, 380, 426, 471, 515 → intervals
|
||||
38, 82, 41, 20, 30, 30, 35, 47, 46, 45, 44, 43, 21, 19 median 41 s
|
||||
```
|
||||
|
||||
A **41-second** sampling interval against a title screen that the corpus already
|
||||
documents as lasting *"a few seconds"* before auto-returning to the attract loop
|
||||
(`wait_title.sh`'s own header). The harness is not seeing a stuck game; it is
|
||||
blinking slower than the thing it is looking for.
|
||||
|
||||
That is also why runs at 16:43–18:05 the same day succeeded and later ones did
|
||||
not — nothing about the game changed.
|
||||
|
||||
## 🔴 What this retracts
|
||||
|
||||
Three earlier conclusions were built on these runs and are **withdrawn as
|
||||
causes**, though the observations stand:
|
||||
|
||||
* *"the Japanese-locale run never reaches the interactive title"* — it may well
|
||||
have appeared, unsampled.
|
||||
* *"neither locale reaches the interactive title without a pad press"* — the
|
||||
English control shared the same defect, so it controlled for locale but not for
|
||||
the sampling rate.
|
||||
* *"the game sat in the attract loop for 604 s"* — what was observed is that
|
||||
**every one of ~15 samples** landed on movie content, which at a 41 s interval
|
||||
is a much weaker statement than it reads as.
|
||||
|
||||
## ⚠️ A confound I introduced
|
||||
|
||||
Setting `kernel_display_gamma_type = 0` makes the frame substantially brighter
|
||||
(a mid-attract frame measured mean **122.8** against **52.5** and **82.8** on
|
||||
comparable phases at type 2). `skip_intro.sh` classifies movie-vs-static on an
|
||||
**absolute** rmse threshold of 1500 between two grabs, so a brighter output
|
||||
inflates that difference and biases every frame toward "movie". **The capture
|
||||
harness's tuning is coupled to the display settings** — changing gamma and
|
||||
capture behaviour in one run confounds both.
|
||||
|
||||
## 🔴 The fix works — and it REFUTES the diagnosis above
|
||||
|
||||
Built and measured (`tools/re-capture/fast_title_probe.py`): one long-lived
|
||||
`ffmpeg` x11grab stream, raw RGB frames, glyph counted in numpy. No per-sample
|
||||
process startup, no PNG encode, no `convert -crop`.
|
||||
|
||||
| probe | seconds per sample, emulator running |
|
||||
|---|---|
|
||||
| the wrapper `screenshot` | **3.98** |
|
||||
| `import -window root` → PPM | 1.20 |
|
||||
| **long-lived x11grab stream** | **0.29** |
|
||||
|
||||
**13.7× faster**, and the counter is control-verified against the committed
|
||||
frames — it returns **753** on `live-title-press-a.png` and **327** on
|
||||
`live-main-menu.png`, byte-identical to `is_title.py`.
|
||||
|
||||
Then it was pointed at a running game:
|
||||
|
||||
```
|
||||
332 frames in 85.3 s = 3.89 fps; max glyph 0
|
||||
1674 frames in 420.0 s = 3.99 fps; max glyph 0
|
||||
```
|
||||
|
||||
**1 674 consecutive samples over seven unbroken minutes, four per second, and the
|
||||
interactive title never appeared.** So the sampling rate was a real defect and
|
||||
*not* the cause. The hypothesis on this page — that the harness was blinking
|
||||
slower than the event — is **mine, and refuted by my own fix**.
|
||||
|
||||
## What that restores
|
||||
|
||||
Last iteration I withdrew three conclusions on the strength of that hypothesis.
|
||||
The withdrawal was right at the time (15 samples at 41 s intervals cannot support
|
||||
them) and is now **superseded by better evidence**: dense sampling says the
|
||||
interactive title genuinely does not appear in a mid-run window. Reinstated as a
|
||||
**measurement**, with its reach:
|
||||
|
||||
* ✅ over **420 continuous seconds**, English, `gamma_type = 2`, ~13 minutes into
|
||||
a run with no pad input, **zero** frames carried the green Ⓐ glyph.
|
||||
* ⚠️ Reach: this covers a **mid-run** window only. It says nothing about the
|
||||
first minutes of boot.
|
||||
|
||||
## 🟡 The leading hypothesis, not confirmed
|
||||
|
||||
The corpus already suspects the answer. `title_states_capture.sh` exists to test
|
||||
*"whether the interactive one draws `ptbtn00` (the PRESS Ⓐ plate) and the other
|
||||
does not"* — i.e. the title appears **twice**: once at the end of the boot
|
||||
sequence, and again from the attract loop, and only the first may carry the
|
||||
plate. If so, the plate's window is early and one-shot, and no amount of
|
||||
mid-run sampling will ever find it.
|
||||
|
||||
That is consistent with everything measured, and it is **not confirmed**. The
|
||||
test is to start the fast probe *before* the boot title — from t=0 rather than
|
||||
attaching to a run already in progress.
|
||||
|
||||
## The original fix note, kept
|
||||
|
||||
Make the probe cheap enough to sample faster than the title window: grab a small
|
||||
region rather than the full surface, drop the ImageMagick `convert` round trip,
|
||||
or keep the glyph test in one long-lived process instead of re-importing numpy
|
||||
per sample. None of that is done — this page is the diagnosis, and it is what
|
||||
every remaining emulator-side question is waiting on.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🔴 The boot-window hypothesis is refuted too — and the surface is BLACK
|
||||
|
||||
**2026-08-29, third explanation and third refutation.** The named experiment was
|
||||
to attach the fast probe at **t = 0** so the boot title could not be missed.
|
||||
Done, on the default config, English:
|
||||
|
||||
```
|
||||
2391 frames in 600.4 s = 3.98 fps; max glyph 0; hits 0
|
||||
```
|
||||
|
||||
Ten minutes, sampled four times a second **from launch**, and the green Ⓐ glyph
|
||||
never appeared. So "the plate only shows in an early boot window I keep missing"
|
||||
is **mine, and refuted**.
|
||||
|
||||
Then the thing I should have checked first. Splitting the raw root grab by band:
|
||||
|
||||
| band | non-black | mean |
|
||||
|---|---|---|
|
||||
| y 0–44 (the GTK menu bar) | **100 %** | 210.5 |
|
||||
| **y 45–719 (the game surface)** | **0.08 %** | **0.07** |
|
||||
|
||||
**The game is rendering black**, reproducibly across back-to-back samples, while
|
||||
the guest is alive and polling input (`XamInputGetKeystrokeEx` past 1 201 calls)
|
||||
and `MEM-WATCH` keeps reporting. The crop and every pixel oracle were correct;
|
||||
there was nothing on the surface to detect.
|
||||
|
||||
### ⚠️ What this does and does not explain
|
||||
|
||||
**It does not retroactively explain the earlier failures**, and saying so would
|
||||
be the fourth over-reach in a row. Those runs had *content*: run 2 sampled frames
|
||||
at mean 33.1 (warm), run 3's classifier measured real frame-to-frame rmse, and
|
||||
the `gamma_type = 0` run measured mean 122.8. A black surface is **this run's**
|
||||
symptom.
|
||||
|
||||
So the honest reading is that the failure mode **changed** over the session, and
|
||||
the black screen is a new and worse one.
|
||||
|
||||
### 🟡 Hypothesis for the regression, untested
|
||||
|
||||
Canary's shader/pipeline cache at `~/.local/share/Xenia/cache` is **47 MB** and
|
||||
was last written **23:49 on 2026-08-28** — during the failed runs. This session
|
||||
has `kill -9`'d the emulator repeatedly, which can leave a partially written
|
||||
cache. A corrupt pipeline cache is a plausible route to a guest that runs while
|
||||
nothing reaches the screen.
|
||||
|
||||
**The test is one line and one run:** move `cache*` aside and boot again. Not
|
||||
done — it needs a fresh 10-minute run, and this iteration had spent its emulator
|
||||
budget proving the negative above.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 The fast probe STALLS — and that invalidates its own dense negatives
|
||||
|
||||
**2026-08-29.** The instrument built last iteration is unfit for long runs, found
|
||||
by cross-checking it against an independent grabber *while both watched the same
|
||||
screen*.
|
||||
|
||||
A single long-lived `ffmpeg` x11grab stream degrades and then freezes:
|
||||
|
||||
```
|
||||
862 frames in 540.1 s = 1.60 fps (it starts at 3.98 fps)
|
||||
t=450s surface mean 5.21 nonblack 10.1%
|
||||
t=480s surface mean 5.21 nonblack 10.1%
|
||||
t=510s surface mean 5.21 nonblack 10.1%
|
||||
t=540s surface mean 5.21 nonblack 10.1%
|
||||
```
|
||||
|
||||
Four consecutive 30-second marks reporting an identical value. At that same
|
||||
moment an `import` grab of the same display read **surface mean 125.65**, and a
|
||||
*freshly started* ffmpeg stream read **122.43** — agreeing with `import` to 3 %.
|
||||
So the acquisition, not the analysis, was broken: the stream was replaying a
|
||||
stale frame while the screen was 24× brighter.
|
||||
|
||||
🔴 **Therefore the previous section's headline is withdrawn.** "2 391 frames over
|
||||
600 s from t=0, max glyph 0" cannot distinguish *the title never appeared* from
|
||||
*the stream froze early and repeated one frame 2 391 times*. Its 3.98 fps was
|
||||
measured over the first 20 s, before the degradation.
|
||||
|
||||
**Fixed** in `tools/re-capture/fast_title_probe.py`: the stream is torn down and
|
||||
restarted every 30 s. Startup costs ~0.3 s, cheap against the title's window, and
|
||||
it buys a guarantee the frames are live.
|
||||
|
||||
## ✅ Clearing the shader cache restored rendering
|
||||
|
||||
The cache hypothesis from the previous section was tested: `cache`, `cache0`,
|
||||
`cache1` and `cache_host` moved aside (to `/tmp/xenia-cache-aside`, not deleted),
|
||||
then a fresh boot.
|
||||
|
||||
The surface **renders again**, confirmed with the independent grabber:
|
||||
`import` reads surface mean **54.8** and **68.6** on successive samples with 100 %
|
||||
non-black warm content, against **0.07** and 0.08 % non-black in the black run.
|
||||
The probe's own summary agrees where it can be trusted — **773 of 862 frames had
|
||||
> 2 % non-black**.
|
||||
|
||||
🟡 **Supported, not proven.** One run each side, and the emulator was `kill -9`'d
|
||||
many times before the black run, so a corrupt pipeline cache is plausible but a
|
||||
single A/B does not exclude run-to-run variation. The old caches are kept in
|
||||
`/tmp/xenia-cache-aside` if anyone wants to reproduce the failure.
|
||||
|
||||
❔ **Still no title.** Even rendering, `max glyph 0` — but that number now comes
|
||||
from a stalling probe, so it establishes nothing either way.
|
||||
|
||||
---
|
||||
|
||||
## ✅ A negative I can finally stand behind — measured with a self-validating probe
|
||||
|
||||
**2026-08-29.** Three earlier "the title never appears" claims came from
|
||||
instruments that were later found broken (a stale pixel oracle, a 41 s sampling
|
||||
interval, a freezing stream). This run used
|
||||
[`tools/re-capture/title_probe_xchecked.py`](../../tools/re-capture/title_probe_xchecked.py),
|
||||
which restarts its stream every 30 s **and cross-checks itself against an
|
||||
independent `import` grab every 60 s**, printing both numbers.
|
||||
|
||||
```
|
||||
1851 frames in 560.2 s = 3.30 fps
|
||||
cross-checks 9, disagreements 1
|
||||
max glyph 0
|
||||
```
|
||||
|
||||
| t | stream | import | |
|
||||
|---|---|---|---|
|
||||
| 62 s | 6.05 | 0.07 | disagree — a fade, the boot logos are mid-transition |
|
||||
| 123 s | 7.40 | 7.49 | agree |
|
||||
| 183 s | 8.18 | 8.29 | agree |
|
||||
| 243 s | 0.23 | 0.10 | agree |
|
||||
| 311 s | 89.68 | 89.51 | agree |
|
||||
| 371 s | 80.97 | 81.58 | agree |
|
||||
| 426 s | 117.43 | 117.72 | agree |
|
||||
| 487 s | 77.71 | 76.25 | agree |
|
||||
| 546 s | 70.43 | 70.55 | agree |
|
||||
|
||||
Eight of nine agree to within 2 %, the fps held at 3.30 (no collapse to 1.60),
|
||||
and the surface plainly moved through dark and bright phases. **The frames were
|
||||
live and the negative is real:**
|
||||
|
||||
> ✅ **Measured — over 560 continuous seconds from launch, sampled 3.3 times a
|
||||
> second by a cross-validated probe, the interactive title's green Ⓐ plate never
|
||||
> appears, while the game renders throughout.**
|
||||
|
||||
The final frame correlates **0.0145** with our title render, **−0.0047** with the
|
||||
main menu and **0.0102** with `EXTRAS` — it is attract-movie content, not a UI
|
||||
screen at all.
|
||||
|
||||
## ❔ Why, still unknown
|
||||
|
||||
`live-title-press-a.png` is committed and carries 753 glyph pixels, so the title
|
||||
*was* reachable from this container on 2026-08-28. Nothing found so far explains
|
||||
the change. Clearing the shader cache fixed the *black surface* but not this.
|
||||
|
||||
## Where this leaves the emulator-side questions
|
||||
|
||||
The two open items that need a running menu — the gamma control
|
||||
([tone curve](structures/ui-render-tone-curve.md)) and separating `8AX` from
|
||||
`ptbase` ([8AX](structures/ui-8ax-fullres-background.md)) — remain blocked, now
|
||||
on a well-characterised and instrument-verified failure rather than on a
|
||||
suspicion. Neither blocks the five menu screens. **Returning to static work**;
|
||||
the probe is committed for whoever picks this up.
|
||||
|
||||
---
|
||||
|
||||
## ❔ The GPU trace route: attempted, produced nothing, characterised
|
||||
|
||||
**2026-08-29.** To turn the gamma-ramp *inference* into an observation, canary's
|
||||
`trace_gpu_stream` should work — it records gamma ramps as their own command type
|
||||
(`kGammaRamp`, index 11 in `TraceCommandType`). Two bounded runs produced **no
|
||||
trace file at all**: nothing under the prefix, no `.xtr` anywhere, no
|
||||
`scratch/gpu/`.
|
||||
|
||||
Bounded deliberately: the container's disk is at **95 % (50 GiB free)** and a
|
||||
boot-time trace of all GPU packets includes video decode, so the runner carried a
|
||||
watchdog killing the emulator the moment the trace passed a 2 GiB cap. It never
|
||||
fired — there was nothing to cap. Disk was unchanged at 95 % throughout.
|
||||
|
||||
What the attempt did establish:
|
||||
|
||||
* `BeginTracing()` is called at GPU init when the cvar is set
|
||||
(`graphics_system.cc:237`), but `EndTracing()` runs only from
|
||||
`GraphicsSystem::Shutdown()`. **A `kill -9` — which this session has used
|
||||
routinely — can never finalise a trace.** The second run was therefore stopped
|
||||
with `SIGTERM` and exited cleanly. Still no file, so that is not the whole
|
||||
story.
|
||||
* ~~Two candidates remain and were **not** separated: the CLI flag not reaching
|
||||
the cvar, or `BeginTracing()` failing silently.~~
|
||||
|
||||
### ✅ Explained — and it was neither candidate
|
||||
|
||||
**The trace writer is compiled out of the build in use.** Following the code:
|
||||
`BeginTracing()` only sets `trace_state_ = kStreaming` — *"Streaming starts on
|
||||
the next primary buffer execute"* — and the file is opened later, in
|
||||
`ExecutePrimaryBuffer`, inside
|
||||
|
||||
```cpp
|
||||
#if XE_ENABLE_TRACE_WRITER_INSTRUMENTATION == 1
|
||||
```
|
||||
|
||||
which `trace_writer.h` defines as:
|
||||
|
||||
```cpp
|
||||
#ifdef NDEBUG
|
||||
#define XE_ENABLE_TRACE_WRITER_INSTRUMENTATION 0 // release
|
||||
#else
|
||||
#define XE_ENABLE_TRACE_WRITER_INSTRUMENTATION 1 // debug
|
||||
#endif
|
||||
```
|
||||
|
||||
Confirmed in the binaries themselves, with a control. The format string
|
||||
`"{:08X}_stream.xtr"` exists only inside that guard:
|
||||
|
||||
| binary | `_stream.xtr` occurrences |
|
||||
|---|---|
|
||||
| `build/bin/Linux/Release/xenia_canary` | **0** |
|
||||
| `build/bin/Linux/Debug/xenia_canary` | **1** |
|
||||
| `/sylph-home/re/canary-build/.../Release/xenia_canary` — **the one `run-canary` uses** | **0** |
|
||||
|
||||
So `trace_gpu_stream` is a **no-op in this container's emulator**: the cvar
|
||||
parses, `BeginTracing` runs, and nothing can ever open a file. Neither the CLI
|
||||
flag nor `BeginTracing` was at fault, and neither was the `kill -9` — though that
|
||||
would have destroyed the trace too, had one existed.
|
||||
|
||||
🟡 **The route exists but is not cheap.** A Debug build with the writer compiled
|
||||
in is present at `build/bin/Linux/Debug/xenia_canary` (253 MB against Release's
|
||||
18 MB). Running it means a much slower boot and a trace of every GPU packet on a
|
||||
disk at 95 %. Recorded as available rather than attempted — the thing it would
|
||||
confirm (the `DC_LUT` write) is already a well-supported inference, so the cost
|
||||
is out of proportion to the gain.
|
||||
|
||||
## ⚠️ The config dump in a log is the FILE, not the effective command line
|
||||
|
||||
Nearly a wrong conclusion here. The dump printed at startup showed
|
||||
`trace_gpu_stream = false` after I had passed `--trace_gpu_stream=true`, which
|
||||
reads as "the flag was ignored". It is not evidence either way:
|
||||
|
||||
| | passed on the CLI | shown in the dump | actual behaviour |
|
||||
|---|---|---|---|
|
||||
| gamma run | `--log_mask=12 --log_level=3` | `log_mask = 0`, `log_level = 2` | **Kernel Debug logging demonstrably ON** |
|
||||
|
||||
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.
|
||||
53
docs/re/captures/ORACLE-CAPTURES.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# The oracle frames — what to verify a render against
|
||||
|
||||
**These are framebuffer captures of the real game running under Xenia Canary.**
|
||||
They are the reference. `sylpheed-cli screen render` is **not** — Reborn is a GUI
|
||||
explorer and extraction CLI built to check that our *decoding* is right, and it
|
||||
may very well be wrong. Where a render and a capture disagree, the capture wins,
|
||||
and the render is the thing to go and fix.
|
||||
|
||||
⚠️ Two renderers agreeing proves nothing: they share our assumptions. This corpus
|
||||
has been bitten by exactly that three times — the dropped `pteff05` background,
|
||||
the scale-0 rect, and `rest()`. Each was invisible to any render-vs-render diff
|
||||
and visible immediately against a capture.
|
||||
|
||||
## The frames
|
||||
|
||||
All are **1279×675**, top-left aligned, cropped to the game surface by the
|
||||
screenshot tool (the guest renders 1280×720; the missing row/column is the crop,
|
||||
not a scale).
|
||||
|
||||
| screen | capture |
|
||||
|---|---|
|
||||
| publisher splash (SQUARE ENIX) | [`title-builds/live-splash-publisher.png`](title-builds/live-splash-publisher.png) |
|
||||
| developer splash (GAME ARTS / SETA / anima) | [`title-builds/live-splash-developer.png`](title-builds/live-splash-developer.png) |
|
||||
| title, **without** the `PRESS Ⓐ` plate | [`title-builds/live-title-build4-no-plate.png`](title-builds/live-title-build4-no-plate.png) |
|
||||
| title, **with** the plate | [`title-builds/live-title-press-a.png`](title-builds/live-title-press-a.png) |
|
||||
| main menu | [`title-builds/live-main-menu.png`](title-builds/live-main-menu.png) · [`main-menu-oracle.png`](main-menu-oracle.png) |
|
||||
| main menu, **`OPTIONS` focused** | [`title-builds/live-main-menu-options-focused.png`](title-builds/live-main-menu-options-focused.png) |
|
||||
| `EXTRAS` | [`title-builds/live-extras.png`](title-builds/live-extras.png) |
|
||||
| title (alternate) | [`title-screen-oracle.png`](title-screen-oracle.png) |
|
||||
| a screen transition, 13 frames | [`transitions/transition-filmstrip.png`](transitions/transition-filmstrip.png) + [`transition-luminance.csv`](transitions/transition-luminance.csv) |
|
||||
|
||||
The **focused** pair is the useful one for button states: the same screen with a
|
||||
different button highlighted, so the difference isolates what focus changes.
|
||||
|
||||
## ⚠️ Before you compute an RMSE against one
|
||||
|
||||
* **They are not gamma-neutral.** `capture ≈ 255·(render/255)^γ` with γ ≈ 1.34–1.49,
|
||||
and that is a ramp **the game installed**, not a capture-path artefact. So RMSE
|
||||
against these has a floor and chasing it below that floor is chasing the ramp.
|
||||
[`../structures/ui-render-tone-curve.md`](../structures/ui-render-tone-curve.md)
|
||||
* **Geometry is sound**: cross-correlating a render against `live-main-menu.png`
|
||||
over ±6 px puts the best alignment at exactly (0,0), correlation 0.9466. So a
|
||||
positional disagreement is real, not a crop artefact.
|
||||
* **A capture is one moment.** Several of these screens are still animating; the
|
||||
title's two `ptloop` sweeps move continuously. Compare settled poses, or
|
||||
compare regions you know are at rest.
|
||||
|
||||
## What is NOT here
|
||||
|
||||
No capture of the interactive title reached mid-run without a pad press — three
|
||||
runs across two locales and two launch paths never reached it in ~35 minutes.
|
||||
See [`../capture-harness-status.md`](../capture-harness-status.md). And no
|
||||
`GP_READY_ROOM` capture; S1 ruled it out of scope.
|
||||
BIN
docs/re/captures/menu-nav/extras-wrap.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
docs/re/captures/menu-nav/q4-destinations.png
Normal file
|
After Width: | Height: | Size: 634 KiB |
BIN
docs/re/captures/menu-nav/wrap-montage.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/re/captures/newgame-path/newgame-difficulty.png
Normal file
|
After Width: | Height: | Size: 520 KiB |
BIN
docs/re/captures/newgame-path/newgame-selectdata-crash.png
Normal file
|
After Width: | Height: | Size: 435 KiB |
BIN
docs/re/captures/newgame-path/s00a-frame-at-capture-6s.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
docs/re/captures/newgame-path/title-after-single-A-skip.png
Normal file
|
After Width: | Height: | Size: 994 KiB |
BIN
docs/re/captures/ready-room-probe/ready-room-pak-builds.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 143 KiB |
BIN
docs/re/captures/title-builds/live-extras.png
Normal file
|
After Width: | Height: | Size: 645 KiB |
BIN
docs/re/captures/title-builds/live-main-menu-options-focused.png
Normal file
|
After Width: | Height: | Size: 645 KiB |
BIN
docs/re/captures/title-builds/live-main-menu.png
Normal file
|
After Width: | Height: | Size: 644 KiB |
BIN
docs/re/captures/title-builds/live-splash-developer.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/re/captures/title-builds/live-splash-publisher.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/re/captures/title-builds/live-title-build4-no-plate.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/re/captures/title-builds/live-title-press-a.png
Normal file
|
After Width: | Height: | Size: 996 KiB |
BIN
docs/re/captures/title-builds/splash-both-halves-rendered.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/re/captures/title-builds/splash-entries-rendered.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/re/captures/title-builds/title-build-contact-sheet.png
Normal file
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 664 KiB |
@@ -0,0 +1,43 @@
|
||||
# every draw in SUBMISSION ORDER, undeduplicated, frames 1..3
|
||||
# tex dimensions identify the sprite; base is the guest address
|
||||
0 prim=8 indices=3 vs=0x0A6D1DD7767FDF27 ps=0x2E372EA28CC404B7
|
||||
vb=0x15036638 stride=28 attrs=[57@0 38@12 ] fmt0=57 v: [-0.50,-0.50,z=0.00000] [1279.50,-0.50,z=0.00000] [1279.50,719.50,z=0.00000]
|
||||
1 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0BA60000 1280x768 fmt=6]
|
||||
vb=0x15036690 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,-1.00,z=0.00000,col=FFFFFFFF] [-1.00,-1.00,z=0.00000,col=FFFFFFFF]
|
||||
2 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
|
||||
vb=0x150366F0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [0.70,1.58,z=0.00000,col=C3FFFFFF] [1.24,1.02,z=0.00000,col=C3FFFFFF] [0.40,-1.57,z=0.00000,col=C3FFFFFF] [-0.14,-1.02,z=0.00000,col=C3FFFFFF] [-1.29,1.02,z=0.00000,col=B6FFFFFF] [-0.85,1.81,z=0.00000,col=B6FFFFFF] [0.75,-1.02,z=0.00000,col=B6FFFFFF] [0.31,-1.81,z=0.00000,col=B6FFFFFF]
|
||||
3 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B690000 1280x768 fmt=6]
|
||||
vb=0x150367B0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,-1.00,z=0.00000,col=FFFFFFFF] [-1.00,-1.00,z=0.00000,col=FFFFFFFF]
|
||||
4 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B2C0000 1280x768 fmt=6]
|
||||
vb=0x15036810 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.90,0.68,z=0.00000,col=FFFFFFFF] [0.87,0.68,z=0.00000,col=FFFFFFFF] [0.87,-0.10,z=0.00000,col=FFFFFFFF] [-0.90,-0.10,z=0.00000,col=FFFFFFFF] [-0.89,0.65,z=0.00000,col=FFFFFFFF] [0.86,0.65,z=0.00000,col=FFFFFFFF] [0.86,-0.08,z=0.00000,col=FFFFFFFF] [-0.89,-0.08,z=0.00000,col=FFFFFFFF]
|
||||
5 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
|
||||
vb=0x150368D0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.71,0.47,z=0.00000,col=FFFFFFFF] [0.72,0.47,z=0.00000,col=FFFFFFFF] [0.72,0.15,z=0.00000,col=FFFFFFFF] [-0.71,0.15,z=0.00000,col=FFFFFFFF] [0.76,-0.05,z=0.00000,col=FFFFFFFF] [0.82,-0.05,z=0.00000,col=FFFFFFFF] [0.82,-0.10,z=0.00000,col=FFFFFFFF] [0.76,-0.10,z=0.00000,col=FFFFFFFF]
|
||||
6 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0BE30000 1280x768 fmt=6]
|
||||
vb=0x15036990 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.79,0.15,z=0.00000,col=FFFFFFFF] [0.76,0.15,z=0.00000,col=FFFFFFFF] [0.76,-0.14,z=0.00000,col=FFFFFFFF] [-0.79,-0.14,z=0.00000,col=FFFFFFFF]
|
||||
7 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
|
||||
vb=0x150369F0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.54,-0.82,z=0.00000,col=FFFFFFFF] [0.54,-0.82,z=0.00000,col=FFFFFFFF] [0.54,-0.87,z=0.00000,col=FFFFFFFF] [-0.54,-0.87,z=0.00000,col=FFFFFFFF] [-0.40,-0.53,z=0.00000,col=FFFFFFFF] [0.40,-0.53,z=0.00000,col=FFFFFFFF] [0.40,-0.67,z=0.00000,col=FFFFFFFF] [-0.40,-0.67,z=0.00000,col=FFFFFFFF]
|
||||
8 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B2C0000 1280x768 fmt=6]
|
||||
vb=0x15036AB0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.42,-0.49,z=0.00000,col=31FFFFFF] [0.42,-0.49,z=0.00000,col=31FFFFFF] [0.42,-0.70,z=0.00000,col=31FFFFFF] [-0.42,-0.70,z=0.00000,col=31FFFFFF]
|
||||
9 prim=8 indices=3 vs=0x72CBCAA6A7984111 ps=0xE59B2B3DA4AA9008 tex[base=0x10000000 1x1 fmt=26]
|
||||
10 prim=8 indices=3 vs=0x72CBCAA6A7984111 ps=0xE59B2B3DA4AA9008 tex[base=0x10000000 1x1 fmt=26]
|
||||
--- frame 3 ---
|
||||
11 prim=8 indices=3 vs=0x0A6D1DD7767FDF27 ps=0x2E372EA28CC404B7
|
||||
vb=0x15076C38 stride=28 attrs=[57@0 38@12 ] fmt0=57 v: [-0.50,-0.50,z=0.00000] [1279.50,-0.50,z=0.00000] [1279.50,719.50,z=0.00000]
|
||||
12 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0BA60000 1280x768 fmt=6]
|
||||
vb=0x15076C90 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,-1.00,z=0.00000,col=FFFFFFFF] [-1.00,-1.00,z=0.00000,col=FFFFFFFF]
|
||||
13 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
|
||||
vb=0x15076CF0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [0.73,1.58,z=0.00000,col=C5FFFFFF] [1.27,1.02,z=0.00000,col=C5FFFFFF] [0.43,-1.57,z=0.00000,col=C5FFFFFF] [-0.11,-1.02,z=0.00000,col=C5FFFFFF] [-1.32,1.02,z=0.00000,col=B8FFFFFF] [-0.88,1.81,z=0.00000,col=B8FFFFFF] [0.71,-1.02,z=0.00000,col=B8FFFFFF] [0.27,-1.81,z=0.00000,col=B8FFFFFF]
|
||||
14 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B690000 1280x768 fmt=6]
|
||||
vb=0x15076DB0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,1.00,z=0.00000,col=FFFFFFFF] [1.00,-1.00,z=0.00000,col=FFFFFFFF] [-1.00,-1.00,z=0.00000,col=FFFFFFFF]
|
||||
15 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B2C0000 1280x768 fmt=6]
|
||||
vb=0x15076E10 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.90,0.68,z=0.00000,col=FFFFFFFF] [0.87,0.68,z=0.00000,col=FFFFFFFF] [0.87,-0.10,z=0.00000,col=FFFFFFFF] [-0.90,-0.10,z=0.00000,col=FFFFFFFF] [-0.89,0.65,z=0.00000,col=FFFFFFFF] [0.86,0.65,z=0.00000,col=FFFFFFFF] [0.86,-0.08,z=0.00000,col=FFFFFFFF] [-0.89,-0.08,z=0.00000,col=FFFFFFFF]
|
||||
16 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
|
||||
vb=0x15076ED0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.71,0.47,z=0.00000,col=FFFFFFFF] [0.72,0.47,z=0.00000,col=FFFFFFFF] [0.72,0.15,z=0.00000,col=FFFFFFFF] [-0.71,0.15,z=0.00000,col=FFFFFFFF] [0.76,-0.05,z=0.00000,col=FFFFFFFF] [0.82,-0.05,z=0.00000,col=FFFFFFFF] [0.82,-0.10,z=0.00000,col=FFFFFFFF] [0.76,-0.10,z=0.00000,col=FFFFFFFF]
|
||||
17 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0BE30000 1280x768 fmt=6]
|
||||
vb=0x15076F90 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.79,0.15,z=0.00000,col=FFFFFFFF] [0.76,0.15,z=0.00000,col=FFFFFFFF] [0.76,-0.14,z=0.00000,col=FFFFFFFF] [-0.79,-0.14,z=0.00000,col=FFFFFFFF]
|
||||
18 prim=13 indices=8 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x11C80000 1280x768 fmt=6]
|
||||
vb=0x15076FF0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.54,-0.82,z=0.00000,col=FFFFFFFF] [0.54,-0.82,z=0.00000,col=FFFFFFFF] [0.54,-0.87,z=0.00000,col=FFFFFFFF] [-0.54,-0.87,z=0.00000,col=FFFFFFFF] [-0.40,-0.53,z=0.00000,col=FFFFFFFF] [0.40,-0.53,z=0.00000,col=FFFFFFFF] [0.40,-0.67,z=0.00000,col=FFFFFFFF] [-0.40,-0.67,z=0.00000,col=FFFFFFFF]
|
||||
19 prim=13 indices=4 vs=0xE0BAFB4F520FE441 ps=0xE59B2B3DA4AA9008 tex[base=0x0B2C0000 1280x768 fmt=6]
|
||||
vb=0x150770B0 stride=24 attrs=[57@0 6@12 37@16 ] fmt0=57 v: [-0.42,-0.49,z=0.00000,col=1EFFFFFF] [0.42,-0.49,z=0.00000,col=1EFFFFFF] [0.42,-0.70,z=0.00000,col=1EFFFFFF] [-0.42,-0.70,z=0.00000,col=1EFFFFFF]
|
||||
20 prim=8 indices=3 vs=0x72CBCAA6A7984111 ps=0xE59B2B3DA4AA9008 tex[base=0x10000000 1x1 fmt=26]
|
||||
21 prim=8 indices=3 vs=0x72CBCAA6A7984111 ps=0xE59B2B3DA4AA9008 tex[base=0x10000000 1x1 fmt=26]
|
||||
BIN
docs/re/captures/title-builds/title-swoosh-capture-vs-render.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
docs/re/captures/transitions/transition-filmstrip.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
421
docs/re/captures/transitions/transition-luminance.csv
Normal file
@@ -0,0 +1,421 @@
|
||||
frame,seconds,mean_luminance
|
||||
0,0.0000,26.045
|
||||
1,0.0333,26.054
|
||||
2,0.0667,26.051
|
||||
3,0.1000,26.054
|
||||
4,0.1333,26.049
|
||||
5,0.1667,26.054
|
||||
6,0.2000,26.052
|
||||
7,0.2333,26.053
|
||||
8,0.2667,26.046
|
||||
9,0.3000,26.053
|
||||
10,0.3333,26.049
|
||||
11,0.3667,26.052
|
||||
12,0.4000,26.047
|
||||
13,0.4333,26.050
|
||||
14,0.4667,26.048
|
||||
15,0.5000,26.049
|
||||
16,0.5333,26.047
|
||||
17,0.5667,26.049
|
||||
18,0.6000,26.048
|
||||
19,0.6333,26.048
|
||||
20,0.6667,26.045
|
||||
21,0.7000,26.048
|
||||
22,0.7333,26.049
|
||||
23,0.7667,26.048
|
||||
24,0.8000,26.046
|
||||
25,0.8333,26.048
|
||||
26,0.8667,26.048
|
||||
27,0.9000,26.049
|
||||
28,0.9333,26.047
|
||||
29,0.9667,26.048
|
||||
30,1.0000,26.048
|
||||
31,1.0333,26.048
|
||||
32,1.0667,26.047
|
||||
33,1.1000,26.048
|
||||
34,1.1333,26.047
|
||||
35,1.1667,26.047
|
||||
36,1.2000,26.046
|
||||
37,1.2333,26.046
|
||||
38,1.2667,26.046
|
||||
39,1.3000,26.047
|
||||
40,1.3333,26.046
|
||||
41,1.3667,26.047
|
||||
42,1.4000,26.047
|
||||
43,1.4333,26.048
|
||||
44,1.4667,26.047
|
||||
45,1.5000,26.049
|
||||
46,1.5333,26.050
|
||||
47,1.5667,26.052
|
||||
48,1.6000,26.051
|
||||
49,1.6333,26.052
|
||||
50,1.6667,26.052
|
||||
51,1.7000,26.052
|
||||
52,1.7333,26.052
|
||||
53,1.7667,26.055
|
||||
54,1.8000,26.056
|
||||
55,1.8333,26.056
|
||||
56,1.8667,26.055
|
||||
57,1.9000,26.058
|
||||
58,1.9333,26.059
|
||||
59,1.9667,26.059
|
||||
60,2.0000,26.057
|
||||
61,2.0333,26.062
|
||||
62,2.0667,26.022
|
||||
63,2.1000,22.032
|
||||
64,2.1333,21.744
|
||||
65,2.1667,17.909
|
||||
66,2.2000,13.996
|
||||
67,2.2333,13.966
|
||||
68,2.2667,11.113
|
||||
69,2.3000,8.072
|
||||
70,2.3333,7.862
|
||||
71,2.3667,6.270
|
||||
72,2.4000,3.375
|
||||
73,2.4333,1.771
|
||||
74,2.4667,0.020
|
||||
75,2.5000,0.020
|
||||
76,2.5333,0.020
|
||||
77,2.5667,0.020
|
||||
78,2.6000,0.020
|
||||
79,2.6333,0.020
|
||||
80,2.6667,0.020
|
||||
81,2.7000,5.985
|
||||
82,2.7333,5.991
|
||||
83,2.7667,5.990
|
||||
84,2.8000,5.991
|
||||
85,2.8333,5.986
|
||||
86,2.8667,7.432
|
||||
87,2.9000,9.300
|
||||
88,2.9333,9.283
|
||||
89,2.9667,9.484
|
||||
90,3.0000,11.294
|
||||
91,3.0333,11.263
|
||||
92,3.0667,11.606
|
||||
93,3.1000,12.608
|
||||
94,3.1333,12.807
|
||||
95,3.1667,12.790
|
||||
96,3.2000,14.517
|
||||
97,3.2333,18.497
|
||||
98,3.2667,26.935
|
||||
99,3.3000,27.008
|
||||
100,3.3333,27.010
|
||||
101,3.3667,27.039
|
||||
102,3.4000,27.036
|
||||
103,3.4333,27.083
|
||||
104,3.4667,27.239
|
||||
105,3.5000,27.294
|
||||
106,3.5333,27.293
|
||||
107,3.5667,27.294
|
||||
108,3.6000,27.289
|
||||
109,3.6333,27.291
|
||||
110,3.6667,27.290
|
||||
111,3.7000,27.291
|
||||
112,3.7333,27.289
|
||||
113,3.7667,27.292
|
||||
114,3.8000,27.291
|
||||
115,3.8333,27.292
|
||||
116,3.8667,27.291
|
||||
117,3.9000,27.294
|
||||
118,3.9333,27.291
|
||||
119,3.9667,27.293
|
||||
120,4.0000,27.290
|
||||
121,4.0333,27.292
|
||||
122,4.0667,27.291
|
||||
123,4.1000,27.292
|
||||
124,4.1333,27.290
|
||||
125,4.1667,27.293
|
||||
126,4.2000,27.293
|
||||
127,4.2333,27.293
|
||||
128,4.2667,27.292
|
||||
129,4.3000,27.297
|
||||
130,4.3333,27.296
|
||||
131,4.3667,27.297
|
||||
132,4.4000,27.296
|
||||
133,4.4333,27.299
|
||||
134,4.4667,27.300
|
||||
135,4.5000,27.302
|
||||
136,4.5333,27.301
|
||||
137,4.5667,27.304
|
||||
138,4.6000,27.304
|
||||
139,4.6333,27.304
|
||||
140,4.6667,27.302
|
||||
141,4.7000,27.306
|
||||
142,4.7333,27.308
|
||||
143,4.7667,27.309
|
||||
144,4.8000,27.309
|
||||
145,4.8333,27.312
|
||||
146,4.8667,27.311
|
||||
147,4.9000,27.311
|
||||
148,4.9333,27.310
|
||||
149,4.9667,27.317
|
||||
150,5.0000,27.319
|
||||
151,5.0333,27.321
|
||||
152,5.0667,27.322
|
||||
153,5.1000,27.327
|
||||
154,5.1333,27.327
|
||||
155,5.1667,27.328
|
||||
156,5.2000,27.327
|
||||
157,5.2333,27.333
|
||||
158,5.2667,27.334
|
||||
159,5.3000,27.335
|
||||
160,5.3333,27.334
|
||||
161,5.3667,27.344
|
||||
162,5.4000,27.347
|
||||
163,5.4333,27.350
|
||||
164,5.4667,27.351
|
||||
165,5.5000,27.359
|
||||
166,5.5333,27.359
|
||||
167,5.5667,27.360
|
||||
168,5.6000,27.358
|
||||
169,5.6333,27.365
|
||||
170,5.6667,27.366
|
||||
171,5.7000,27.369
|
||||
172,5.7333,27.367
|
||||
173,5.7667,27.383
|
||||
174,5.8000,27.386
|
||||
175,5.8333,27.387
|
||||
176,5.8667,27.386
|
||||
177,5.9000,27.399
|
||||
178,5.9333,27.400
|
||||
179,5.9667,27.402
|
||||
180,6.0000,27.400
|
||||
181,6.0333,27.413
|
||||
182,6.0667,27.414
|
||||
183,6.1000,27.415
|
||||
184,6.1333,27.413
|
||||
185,6.1667,27.432
|
||||
186,6.2000,27.434
|
||||
187,6.2333,27.438
|
||||
188,6.2667,27.436
|
||||
189,6.3000,27.449
|
||||
190,6.3333,27.448
|
||||
191,6.3667,27.451
|
||||
192,6.4000,27.449
|
||||
193,6.4333,27.466
|
||||
194,6.4667,27.471
|
||||
195,6.5000,27.472
|
||||
196,6.5333,27.470
|
||||
197,6.5667,27.480
|
||||
198,6.6000,27.478
|
||||
199,6.6333,27.480
|
||||
200,6.6667,27.476
|
||||
201,6.7000,27.509
|
||||
202,6.7333,27.510
|
||||
203,6.7667,27.516
|
||||
204,6.8000,27.514
|
||||
205,6.8333,27.519
|
||||
206,6.8667,27.519
|
||||
207,6.9000,27.518
|
||||
208,6.9333,27.516
|
||||
209,6.9667,27.538
|
||||
210,7.0000,27.537
|
||||
211,7.0333,27.539
|
||||
212,7.0667,27.538
|
||||
213,7.1000,27.558
|
||||
214,7.1333,27.556
|
||||
215,7.1667,27.558
|
||||
216,7.2000,27.554
|
||||
217,7.2333,27.577
|
||||
218,7.2667,27.576
|
||||
219,7.3000,27.577
|
||||
220,7.3333,27.572
|
||||
221,7.3667,27.596
|
||||
222,7.4000,27.598
|
||||
223,7.4333,27.603
|
||||
224,7.4667,27.597
|
||||
225,7.5000,27.610
|
||||
226,7.5333,27.611
|
||||
227,7.5667,27.613
|
||||
228,7.6000,27.607
|
||||
229,7.6333,27.621
|
||||
230,7.6667,27.621
|
||||
231,7.7000,27.622
|
||||
232,7.7333,27.617
|
||||
233,7.7667,27.624
|
||||
234,7.8000,27.626
|
||||
235,7.8333,27.630
|
||||
236,7.8667,27.622
|
||||
237,7.9000,27.640
|
||||
238,7.9333,27.637
|
||||
239,7.9667,27.639
|
||||
240,8.0000,27.633
|
||||
241,8.0333,27.642
|
||||
242,8.0667,27.639
|
||||
243,8.1000,27.642
|
||||
244,8.1333,27.637
|
||||
245,8.1667,27.658
|
||||
246,8.2000,27.653
|
||||
247,8.2333,27.655
|
||||
248,8.2667,27.649
|
||||
249,8.3000,27.645
|
||||
250,8.3333,27.648
|
||||
251,8.3667,26.704
|
||||
252,8.4000,23.887
|
||||
253,8.4333,23.723
|
||||
254,8.4667,16.471
|
||||
255,8.5000,13.119
|
||||
256,8.5333,13.102
|
||||
257,8.5667,12.118
|
||||
258,8.6000,10.955
|
||||
259,8.6333,10.956
|
||||
260,8.6667,5.365
|
||||
261,8.7000,3.422
|
||||
262,8.7333,3.416
|
||||
263,8.7667,2.820
|
||||
264,8.8000,0.020
|
||||
265,8.8333,0.020
|
||||
266,8.8667,0.020
|
||||
267,8.9000,0.020
|
||||
268,8.9333,0.020
|
||||
269,8.9667,3.267
|
||||
270,9.0000,5.012
|
||||
271,9.0333,5.449
|
||||
272,9.0667,7.339
|
||||
273,9.1000,9.208
|
||||
274,9.1333,10.415
|
||||
275,9.1667,10.418
|
||||
276,9.2000,10.462
|
||||
277,9.2333,10.459
|
||||
278,9.2667,10.569
|
||||
279,9.3000,10.701
|
||||
280,9.3333,10.868
|
||||
281,9.3667,12.924
|
||||
282,9.4000,14.688
|
||||
283,9.4333,14.756
|
||||
284,9.4667,17.184
|
||||
285,9.5000,19.441
|
||||
286,9.5333,19.438
|
||||
287,9.5667,19.492
|
||||
288,9.6000,19.492
|
||||
289,9.6333,19.492
|
||||
290,9.6667,19.508
|
||||
291,9.7000,23.405
|
||||
292,9.7333,23.417
|
||||
293,9.7667,23.414
|
||||
294,9.8000,23.416
|
||||
295,9.8333,23.409
|
||||
296,9.8667,23.467
|
||||
297,9.9000,24.927
|
||||
298,9.9333,24.929
|
||||
299,9.9667,24.926
|
||||
300,10.0000,25.788
|
||||
301,10.0333,25.785
|
||||
302,10.0667,25.789
|
||||
303,10.1000,25.893
|
||||
304,10.1333,25.908
|
||||
305,10.1667,25.905
|
||||
306,10.2000,25.905
|
||||
307,10.2333,25.976
|
||||
308,10.2667,25.986
|
||||
309,10.3000,25.980
|
||||
310,10.3333,25.981
|
||||
311,10.3667,25.976
|
||||
312,10.4000,25.984
|
||||
313,10.4333,26.020
|
||||
314,10.4667,26.022
|
||||
315,10.5000,26.016
|
||||
316,10.5333,26.021
|
||||
317,10.5667,26.018
|
||||
318,10.6000,26.020
|
||||
319,10.6333,26.016
|
||||
320,10.6667,26.021
|
||||
321,10.7000,26.021
|
||||
322,10.7333,26.021
|
||||
323,10.7667,26.019
|
||||
324,10.8000,26.022
|
||||
325,10.8333,26.022
|
||||
326,10.8667,26.022
|
||||
327,10.9000,26.020
|
||||
328,10.9333,26.025
|
||||
329,10.9667,26.025
|
||||
330,11.0000,26.025
|
||||
331,11.0333,26.023
|
||||
332,11.0667,26.029
|
||||
333,11.1000,26.027
|
||||
334,11.1333,26.028
|
||||
335,11.1667,26.024
|
||||
336,11.2000,26.024
|
||||
337,11.2333,26.024
|
||||
338,11.2667,26.024
|
||||
339,11.3000,26.029
|
||||
340,11.3333,26.029
|
||||
341,11.3667,26.027
|
||||
342,11.4000,26.029
|
||||
343,11.4333,26.024
|
||||
344,11.4667,26.027
|
||||
345,11.5000,26.027
|
||||
346,11.5333,26.027
|
||||
347,11.5667,26.025
|
||||
348,11.6000,26.031
|
||||
349,11.6333,26.030
|
||||
350,11.6667,26.033
|
||||
351,11.7000,26.028
|
||||
352,11.7333,26.032
|
||||
353,11.7667,26.032
|
||||
354,11.8000,26.032
|
||||
355,11.8333,26.029
|
||||
356,11.8667,26.034
|
||||
357,11.9000,26.039
|
||||
358,11.9333,26.041
|
||||
359,11.9667,26.041
|
||||
360,12.0000,26.040
|
||||
361,12.0333,26.046
|
||||
362,12.0667,26.047
|
||||
363,12.1000,26.046
|
||||
364,12.1333,26.050
|
||||
365,12.1667,26.051
|
||||
366,12.2000,26.051
|
||||
367,12.2333,26.051
|
||||
368,12.2667,26.051
|
||||
369,12.3000,26.051
|
||||
370,12.3333,26.051
|
||||
371,12.3667,26.051
|
||||
372,12.4000,26.050
|
||||
373,12.4333,26.055
|
||||
374,12.4667,26.056
|
||||
375,12.5000,26.056
|
||||
376,12.5333,26.055
|
||||
377,12.5667,26.056
|
||||
378,12.6000,26.056
|
||||
379,12.6333,26.056
|
||||
380,12.6667,26.055
|
||||
381,12.7000,26.056
|
||||
382,12.7333,26.056
|
||||
383,12.7667,26.056
|
||||
384,12.8000,26.055
|
||||
385,12.8333,26.055
|
||||
386,12.8667,26.056
|
||||
387,12.9000,26.056
|
||||
388,12.9333,26.056
|
||||
389,12.9667,26.056
|
||||
390,13.0000,26.055
|
||||
391,13.0333,26.071
|
||||
392,13.0667,26.072
|
||||
393,13.1000,26.074
|
||||
394,13.1333,26.082
|
||||
395,13.1667,26.083
|
||||
396,13.2000,26.083
|
||||
397,13.2333,26.083
|
||||
398,13.2667,26.083
|
||||
399,13.3000,26.110
|
||||
400,13.3333,26.111
|
||||
401,13.3667,26.114
|
||||
402,13.4000,26.113
|
||||
403,13.4333,26.118
|
||||
404,13.4667,26.119
|
||||
405,13.5000,26.121
|
||||
406,13.5333,26.119
|
||||
407,13.5667,26.140
|
||||
408,13.6000,26.140
|
||||
409,13.6333,26.144
|
||||
410,13.6667,26.142
|
||||
411,13.7000,26.155
|
||||
412,13.7333,26.157
|
||||
413,13.7667,26.158
|
||||
414,13.8000,26.157
|
||||
415,13.8333,26.165
|
||||
416,13.8667,26.165
|
||||
417,13.9000,26.168
|
||||
418,13.9333,26.165
|
||||
419,13.9667,26.165
|
||||
|
BIN
docs/re/captures/ui-layout/button-base-focus-ring-sprites.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/re/captures/ui-layout/extras-composited.png
Normal file
|
After Width: | Height: | Size: 439 KiB |
BIN
docs/re/captures/ui-layout/focus-ring-oracle-vs-sprite.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
33
docs/re/data/attract-frame-match.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
Frame-signature match of captured frames against five candidate movies.
|
||||
32x18 normalised grayscale, movie frames cropped to the 675/720 the game
|
||||
surface shows, 1 fps sampling, best correlation over the whole movie.
|
||||
|
||||
CONTROL - frames captured during the BOOT movie, known to be ADV.wmv:
|
||||
p2.png -> ADV @116s 0.644 (runner-up S13A 0.438)
|
||||
p3.png -> ADV @121s 0.994 (runner-up S03A 0.650)
|
||||
p4.png -> S13A @ 35s 0.984 (runner-up ADV 0.926)
|
||||
p5.png -> ADV @129s 0.933 (runner-up S13A 0.809)
|
||||
p6.png -> ADV @134s 0.992 (runner-up S13A 0.499)
|
||||
p7.png -> S15B @ 88s 0.000 (runner-up S13A 0.000)
|
||||
p8.png -> S13A @ 65s 0.576 (runner-up ADV 0.534)
|
||||
|
||||
TEST - frames captured during the ATTRACT loop, 5 s apart:
|
||||
attract01.png -> ADV @ 39s 0.990 (runner-up S13A 0.487)
|
||||
attract02.png -> ADV @ 45s 0.961 (runner-up S13A 0.411)
|
||||
attract03.png -> S03A @ 37s 0.731 (runner-up ADV 0.422)
|
||||
attract04.png -> ADV @ 56s 0.955 (runner-up S03A 0.532)
|
||||
attract05.png -> ADV @ 62s 0.934 (runner-up S15B 0.245)
|
||||
attract06.png -> S15B @ 65s 0.885 (runner-up ADV 0.743)
|
||||
attract07.png -> ADV @ 75s 0.961 (runner-up S03A 0.355)
|
||||
attract08.png -> ADV @ 80s 0.938 (runner-up S03A 0.753)
|
||||
attract09.png -> ADV @ 85s 0.974 (runner-up S03A 0.484)
|
||||
attract10.png -> ADV @ 92s 0.892 (runner-up S13A 0.754)
|
||||
attract11.png -> S03A @ 24s 0.606 (runner-up ADV 0.588)
|
||||
attract12.png -> ADV @102s 0.994 (runner-up S03A 0.374)
|
||||
attract13.png -> ADV @108s 0.977 (runner-up S13A 0.496)
|
||||
attract14.png -> ADV @113s 0.836 (runner-up S06B 0.464)
|
||||
attract15.png -> ADV @119s 0.958 (runner-up S15B 0.424)
|
||||
attract16.png -> S13A @ 32s 0.996 (runner-up ADV 0.980)
|
||||
attract17.png -> ADV @131s 0.865 (runner-up S13A 0.769)
|
||||
attract18.png -> ADV @137s 0.993 (runner-up S13A 0.502)
|
||||
attract19.png -> S13A @ 65s 0.551 (runner-up ADV 0.534)
|
||||
35
docs/re/data/bgm-wave-census.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
bank waves per-wave (bytes, bytes/s, Hz, seconds)
|
||||
BGM_001.slb 2 4466688 25697 48000 173.82s | 4673536 26887 48000 173.82s EQUAL-LENGTH
|
||||
BGM_002.slb 2 4382720 26376 48000 166.16s | 4667392 28090 48000 166.16s EQUAL-LENGTH
|
||||
BGM_003.slb 2 3555328 26201 48000 135.69s | 3414016 25159 48000 135.70s EQUAL-LENGTH
|
||||
BGM_004.slb 2 6311936 32874 48000 192.00s | 5787648 30143 48000 192.01s EQUAL-LENGTH
|
||||
BGM_005.slb 2 4005888 28013 48000 143.00s | 3917824 27397 48000 143.00s EQUAL-LENGTH
|
||||
BGM_006.slb 2 3946496 26377 48000 149.62s | 3915776 26172 48000 149.62s EQUAL-LENGTH
|
||||
BGM_007.slb 2 7565312 29269 48000 258.48s | 8099840 31337 48000 258.48s EQUAL-LENGTH
|
||||
BGM_008.slb 2 3258368 29947 48000 108.80s | 3397632 31227 48000 108.80s EQUAL-LENGTH
|
||||
BGM_009.slb 2 3819520 25636 48000 148.99s | 3614720 24262 48000 148.99s EQUAL-LENGTH
|
||||
BGM_010.slb 2 4190208 28457 48000 147.25s | 4100096 27845 48000 147.25s EQUAL-LENGTH
|
||||
BGM_011.slb 2 4222976 25232 48000 167.37s | 4311040 25758 48000 167.37s EQUAL-LENGTH
|
||||
BGM_012.slb 2 5562368 31605 48000 176.00s | 5404672 30709 48000 176.00s EQUAL-LENGTH
|
||||
BGM_013.slb 2 6539264 29904 48000 218.68s | 6457344 29530 48000 218.67s EQUAL-LENGTH
|
||||
BGM_014.slb 2 6656000 31438 48000 211.72s | 6948864 32821 48000 211.72s EQUAL-LENGTH
|
||||
BGM_015.slb 2 8017920 29230 48000 274.30s | 7974912 29074 48000 274.30s EQUAL-LENGTH
|
||||
BGM_016.slb 2 3549184 28341 48000 125.23s | 2775040 22159 48000 125.23s EQUAL-LENGTH
|
||||
BGM_017.slb 2 5353472 30690 48000 174.44s | 5062656 29023 48000 174.44s EQUAL-LENGTH
|
||||
BGM_018.slb 2 3620864 26878 48000 134.71s | 2920448 21679 48000 134.71s EQUAL-LENGTH
|
||||
BGM_019.slb 2 6834176 30665 48000 222.87s | 6713344 30123 48000 222.86s EQUAL-LENGTH
|
||||
BGM_020.slb 2 4804608 29592 44100 162.36s | 4675584 28798 44100 162.36s EQUAL-LENGTH
|
||||
BGM_021.slb 2 7780352 28034 44100 277.53s | 7694336 27724 44100 277.53s EQUAL-LENGTH
|
||||
BGM_022.slb 2 5545984 28006 44100 198.03s | 5867520 29630 44100 198.03s EQUAL-LENGTH
|
||||
BGM_023.slb 2 4962304 28001 44100 177.22s | 5044224 28463 44100 177.22s EQUAL-LENGTH
|
||||
BGM_101.slb 2 4800512 27481 48000 174.68s | 2635776 15089 48000 174.68s EQUAL-LENGTH
|
||||
BGM_102.slb 2 1150976 30703 48000 37.49s | 1269760 33872 48000 37.49s EQUAL-LENGTH
|
||||
BGM_103.slb 2 3876864 44181 48000 87.75s | 3930112 44788 48000 87.75s EQUAL-LENGTH
|
||||
BGM_104.slb 2 2672640 31503 48000 84.84s | 2674688 31527 48000 84.84s EQUAL-LENGTH
|
||||
BGM_105.slb 2 3581952 35879 48000 99.83s | 3850240 38566 48000 99.84s EQUAL-LENGTH
|
||||
BGM_106.slb 2 4098048 45389 48000 90.29s | 2383872 36575 48000 65.18s
|
||||
BGM_107.slb 2 2418688 37109 48000 65.18s | 3696640 28676 48000 128.91s
|
||||
BGM_108.slb 2 3696640 28676 48000 128.91s | 2887680 35432 48000 81.50s
|
||||
BGM_109.slb 4 2932736 35985 48000 81.50s | 112640 13377 48000 8.42s | 225280 14228 48000 15.83s | 235520 14795 48000 15.92s
|
||||
|
||||
32 BGM banks; 28 are exactly two waves of equal duration
|
||||
10
docs/re/data/doc-link-audit.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
# tools/re-capture/doc_link_check.py -- 2026-08-29, after the repair pass
|
||||
|
||||
1050 link(s) resolve
|
||||
|
||||
5 MISSING target(s):
|
||||
docs/re/autopilot-knowledge-sources.md -> ../../MEMORY.md
|
||||
docs/re/challenge-mission-gate.md -> ../../../xenia-canary-native/src/xenia/hid/file/file_input_driver.h
|
||||
docs/re/entities-live-roster.md -> ../../MEMORY.md
|
||||
docs/re/mission-freeze-resume-spin.md -> canary-build-verified-env-confound.md
|
||||
docs/re/stage-drift-is-navigation-not-save.md -> structures/weapon-datasheet-runtime.md
|
||||
7
docs/re/data/eff-bit-alpha-test.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
matched 170 decoded textures to a T8aD flag word
|
||||
bit SET n= 61 mean %(RGB>A) 55.52 median 52.52
|
||||
bit clear n=109 mean %(RGB>A) 33.66 median 30.17
|
||||
|
||||
premultiplied would require ~0% for the flagged group -> REFUTED
|
||||
best single-threshold accuracy: 76.5% at %(RGB>A) > 83.5
|
||||
(base rate, always-guess-majority: 64.1%)
|
||||
24
docs/re/data/eff-bit-census.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# tools/re-capture/eff_bit_census.py over dat/GP_*.pak -- 2026-08-29
|
||||
|
||||
sprites with a resolvable preceding name: 14709
|
||||
bit SET & name has 'eff' : 2338
|
||||
bit SET & name lacks 'eff': 2657 <-- counterexamples to 'set => eff'
|
||||
bit clear & name has 'eff' : 1399
|
||||
bit clear & name lacks 'eff': 8315
|
||||
|
||||
P(name has 'eff' | bit set) = 0.468
|
||||
P(name has 'eff' | bit clear) = 0.144
|
||||
|
||||
counterexamples (bit set, no 'eff'):
|
||||
('GP_BUNK.pak', 'px_top_extra.t32', '00008832', '220x126')
|
||||
('GP_BUNK.pak', 'px_bunk_line.t32', '00008832', '663x27')
|
||||
('GP_BUNK.pak', 'px_top_extra.t32', '00008832', '220x126')
|
||||
('GP_BUNK.pak', 'px_bunk_line.t32', '00008832', '663x27')
|
||||
('GP_BUNK.pak', 'pv_loading_line.t32', '00000832', '181x101')
|
||||
('GP_BUNK.pak', 'pv_loading_ring0.t32', '00000832', '142x142')
|
||||
('GP_BUNK.pak', 'pv_loading_light0.t32', '00000832', '29x25')
|
||||
('GP_BUNK.pak', 'pv_loading_light1.t32', '00000832', '30x29')
|
||||
('GP_BUNK.pak', 'pv_loading_light2.t32', '00000832', '29x30')
|
||||
('GP_BUNK.pak', 'pv_loading_light3.t32', '00000832', '25x29')
|
||||
('GP_BUNK.pak', 'pv_loading_ring1.t32', '00000832', '72x72')
|
||||
('GP_BUNK.pak', 'pv_loading_line.t32', '00000832', '181x101')
|
||||
2
docs/re/data/eightax-detail-test.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
main menu: corr +0.0475 controls +0.0032 (shift) -0.0075 (flip) ceiling 0.070 -> 68% of it
|
||||
title: corr +0.0634 controls +0.0095 (shift) +0.0086 (flip) ceiling 0.094 -> 68% of it
|
||||
29
docs/re/data/gamepart-class-ids.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
GamePart ids bound to a C++ class by the executable's own registration strings
|
||||
(24 strings, 24 distinct ids)
|
||||
|
||||
0 GamePart_Title
|
||||
3 GamePart_SaveLoad
|
||||
4 GamePart_SaveLoad
|
||||
5 GamePart_Extras
|
||||
6 GamePart_MovieTheater
|
||||
7 GamePart_MissionSelect
|
||||
8 GamePart_Options
|
||||
9 GamePart_Movie
|
||||
10 GamePart_Bunk
|
||||
11 GamePart_ReadyRoom
|
||||
12 GamePart_Hangar
|
||||
13 GamePart_Arsenal
|
||||
14 GamePart_PilotLog
|
||||
15 GamePart_System
|
||||
17 GamePart_MainGame
|
||||
19 GamePart_PauseMenu
|
||||
20 GamePart_StageClear
|
||||
21 GamePart_MissionLog
|
||||
22 GamePart_GameOver
|
||||
23 GamePart_Debriefing
|
||||
24 GamePart_Dialog
|
||||
25 GamePart_Tutorial
|
||||
26 GamePart_ChallengeMission
|
||||
27 GamePart_Leaderboard
|
||||
|
||||
ids 0..28 with NO registration string: [1, 2, 16, 18, 28]
|
||||
9
docs/re/data/gamma-call-evidence.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
# canary --log_mask=12 --log_level=3 (Kernel logging ON), 2026-08-29
|
||||
# control: the same log carries 359 VdRetrainEDRAM and 358 VdGetSystemCommandBuffer lines,
|
||||
# so a missing call would have been visible.
|
||||
|
||||
F 820006F4 8284E27C 1B9 ( 441) VdGetCurrentDisplayGamma
|
||||
F 8200070C 8284E2BC 1BA ( 442) VdGetCurrentDisplayInformation
|
||||
F 82000710 8284E2CC 1D3 ( 467) VdSetDisplayMode
|
||||
d> F8000008 VdGetSystemCommandBuffer(701CF830, 701CF804)
|
||||
d> F8000008 VdGetCurrentDisplayGamma(701CE1F8(00000000), 701CE1F0(0))
|
||||
51
docs/re/data/kf-angle-census.txt
Normal file
@@ -0,0 +1,51 @@
|
||||
# tools/re-capture/kf_rotation_census.py over dat/GP_*.pak — 2026-08-28
|
||||
# CONTROL must read 3/3; see the tool docstring for why.
|
||||
|
||||
CONTROL ptloop01: 3/3 blocks found, +12 = {30} (want 30)
|
||||
CONTROL ptloop02: 3/3 blocks found, +12 = {-45} (want -45)
|
||||
|
||||
blocks scanned disc-wide: 83862
|
||||
+4: non-zero in 3990 ( 4.76 %)
|
||||
+8: non-zero in 3878 ( 4.62 %)
|
||||
+12: non-zero in 12164 (14.50 %)
|
||||
|
||||
value histogram (non-zero), top 25:
|
||||
+4 = 180 x3880
|
||||
+8 = 180 x3102
|
||||
+12 = 90 x1824
|
||||
+12 = -90 x1176
|
||||
+12 = 360 x1173
|
||||
+12 = 120 x492
|
||||
+12 = 180 x474
|
||||
+12 = -58 x402
|
||||
+12 = 53 x396
|
||||
+12 = -125 x378
|
||||
+12 = -120 x366
|
||||
+12 = -66 x294
|
||||
+12 = 114 x240
|
||||
+8 = 90 x201
|
||||
+12 = 129 x200
|
||||
+12 = -43 x199
|
||||
+12 = 115 x186
|
||||
+12 = -360 x180
|
||||
+8 = -180 x156
|
||||
+12 = 58 x156
|
||||
+8 = 178 x144
|
||||
+12 = -33 x138
|
||||
+12 = 124 x138
|
||||
+12 = -133 x126
|
||||
+12 = 130 x126
|
||||
|
||||
examples:
|
||||
+12 ('GP_BUNK', '117ca14f', '0x73961b', 360)
|
||||
+12 ('GP_BUNK', '117ca14f', '0x7398ac', 360)
|
||||
+12 ('GP_BUNK', '117ca14f', '0x739a93', 360)
|
||||
+12 ('GP_BUNK', '117ca14f', '0x739d24', 360)
|
||||
+4 ('GP_BUNK', '117ca14f', '0x73ae5d', -180)
|
||||
+4 ('GP_BUNK', '117ca14f', '0x73ae85', -180)
|
||||
+4 ('GP_BUNK', '1ff0bcb0', '0x733d68', -180)
|
||||
+4 ('GP_BUNK', '1ff0bcb0', '0x733d90', -180)
|
||||
+8 ('GP_DEBRIEFING_PILOTLOG', '18ab1d66', '0x6f8', 90)
|
||||
+8 ('GP_DEBRIEFING_PILOTLOG', '18ab1d66', '0x770', 90)
|
||||
+8 ('GP_DEBRIEFING_PILOTLOG', '1c8a0129', '0x6f8', 90)
|
||||
+8 ('GP_DEBRIEFING_PILOTLOG', '1c8a0129', '0x770', 90)
|
||||
97
docs/re/data/movie-audio-layouts.csv
Normal file
@@ -0,0 +1,97 @@
|
||||
ADV.wmv,wmapro,48000,6,5.1
|
||||
RT01A.wmv,wmav2,48000,2,unknown
|
||||
RT01B.wmv,wmav2,48000,2,unknown
|
||||
RT01C_1.wmv,wmav2,48000,2,unknown
|
||||
RT01C_2.wmv,wmav2,48000,2,unknown
|
||||
RT02A.wmv,wmav2,48000,2,unknown
|
||||
RT02B.wmv,wmav2,48000,2,unknown
|
||||
RT02C.wmv,wmav2,48000,2,unknown
|
||||
RT02D_1.wmv,wmav2,48000,2,unknown
|
||||
RT02D_2.wmv,wmav2,48000,2,unknown
|
||||
RT03A.wmv,wmav2,48000,2,unknown
|
||||
RT03B.wmv,wmav2,48000,2,unknown
|
||||
RT03C.wmv,wmav2,48000,2,unknown
|
||||
RT03D.wmv,wmav2,48000,2,unknown
|
||||
RT04A.wmv,wmav2,48000,2,unknown
|
||||
RT04B.wmv,wmav2,48000,2,unknown
|
||||
RT05A.wmv,wmav2,48000,2,unknown
|
||||
RT05B.wmv,wmav2,48000,2,unknown
|
||||
RT05C.wmv,wmav2,48000,2,unknown
|
||||
RT06A.wmv,wmav2,48000,2,unknown
|
||||
RT06B.wmv,wmav2,48000,2,unknown
|
||||
RT06C.wmv,wmav2,48000,2,unknown
|
||||
RT06D.wmv,wmav2,48000,2,unknown
|
||||
RT07A.wmv,wmav2,48000,2,unknown
|
||||
RT07B.wmv,wmav2,48000,2,unknown
|
||||
RT07C.wmv,wmav2,48000,2,unknown
|
||||
RT08A.wmv,wmav2,48000,2,unknown
|
||||
RT08B.wmv,wmav2,48000,2,unknown
|
||||
RT08C.wmv,wmav2,48000,2,unknown
|
||||
RT09A.wmv,wmav2,48000,2,unknown
|
||||
RT09B.wmv,wmav2,48000,2,unknown
|
||||
RT09C.wmv,wmav2,48000,2,unknown
|
||||
RT09D.wmv,wmav2,48000,2,unknown
|
||||
RT10A.wmv,wmav2,48000,2,unknown
|
||||
RT10B.wmv,wmav2,48000,2,unknown
|
||||
RT11A.wmv,wmav2,48000,2,unknown
|
||||
RT11B.wmv,wmav2,48000,2,unknown
|
||||
RT11C.wmv,wmav2,48000,2,unknown
|
||||
RT12A.wmv,wmav2,48000,2,unknown
|
||||
RT12B_1.wmv,wmav2,48000,2,unknown
|
||||
RT12B_2.wmv,wmav2,48000,2,unknown
|
||||
RT13A.wmv,wmav2,48000,2,unknown
|
||||
RT13B_1.wmv,wmav2,48000,2,unknown
|
||||
RT13B_2.wmv,wmav2,48000,2,unknown
|
||||
RT14A.wmv,wmav2,48000,2,unknown
|
||||
RT14B.wmv,wmav2,48000,2,unknown
|
||||
RT14C.wmv,wmav2,48000,2,unknown
|
||||
RT15A.wmv,wmav2,48000,2,unknown
|
||||
RT15B.wmv,wmav2,48000,2,unknown
|
||||
RT15C.wmv,wmav2,48000,2,unknown
|
||||
RT16C.wmv,wmav2,48000,2,unknown
|
||||
S00A.wmv,wmapro,48000,6,5.1
|
||||
S01A.wmv,wmapro,48000,6,5.1
|
||||
S02A.wmv,wmapro,48000,6,5.1
|
||||
S02B.wmv,wmapro,48000,6,5.1
|
||||
S02C.wmv,wmapro,48000,6,5.1
|
||||
S03A.wmv,wmapro,48000,6,5.1
|
||||
S04A.wmv,wmapro,48000,6,5.1
|
||||
S04B.wmv,wmapro,48000,6,5.1
|
||||
S05A.wmv,wmapro,48000,6,5.1
|
||||
S06A.wmv,wmapro,48000,6,5.1
|
||||
S06B.wmv,wmapro,48000,6,5.1
|
||||
S07A.wmv,wmapro,48000,6,5.1
|
||||
S07B.wmv,wmapro,48000,6,5.1
|
||||
S09B.wmv,wmapro,48000,6,5.1
|
||||
S10B.wmv,wmapro,48000,6,5.1
|
||||
S11A.wmv,wmapro,48000,6,5.1
|
||||
S11C.wmv,wmapro,48000,6,5.1
|
||||
S12A.wmv,wmapro,48000,6,5.1
|
||||
S12B.wmv,wmapro,48000,6,5.1
|
||||
S12C.wmv,wmapro,48000,6,5.1
|
||||
S13A.wmv,wmapro,48000,6,5.1
|
||||
S13B.wmv,wmapro,48000,6,5.1
|
||||
S14A.wmv,wmapro,48000,6,5.1
|
||||
S15A.wmv,wmapro,48000,6,5.1
|
||||
S15B.wmv,wmapro,48000,6,5.1
|
||||
S15C.wmv,wmapro,48000,6,5.1
|
||||
S16A.wmv,wmapro,48000,6,5.1
|
||||
hokyu_DS_s02A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_DS_s07A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_DS_s07H.wmv,wmav2,48000,2,unknown
|
||||
hokyu_DS_s08A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_DS_s13A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_DS_s14H.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s02A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s02H.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s03A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s03H.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s06A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s06H.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s09A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s09H.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s11A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s14A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s15A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s24A.wmv,wmav2,48000,2,unknown
|
||||
hokyu_LS_s27A.wmv,wmav2,48000,2,unknown
|
||||
|
57
docs/re/data/name-lookup-callsites.txt
Normal file
@@ -0,0 +1,57 @@
|
||||
The r5 (name) argument at every call site of sub_821CC860.
|
||||
Recovered by forward register simulation over the 100 instructions before
|
||||
each call, tracking lis/addi/subi/mr. Gated on three sites read by eye:
|
||||
0x821c6524 TITLE_SCREEN, 0x821c67c8 TITLE_MENU, 0x821c73c8 LOADING -- all three
|
||||
reproduce, so the remaining rows are trusted.
|
||||
|
||||
call site caller r5 name
|
||||
0x82185f14 sub_82185E80 0x820a2160 EXTRA
|
||||
0x821860b4 sub_82186050 0x820a214c LOADING
|
||||
0x82186350 sub_82186270 0x820a2140 NEW_ITEM
|
||||
0x821863f8 sub_82186270 0x820a2140 NEW_ITEM
|
||||
0x8218c094 sub_8218BFE0 0x820a2384 MENU
|
||||
0x8219403c sub_82193648 - (not recovered)
|
||||
0x82196b44 sub_82196A50 0x820a2168 FILE
|
||||
0x82198540 sub_82198440 0x820a2570 BASE
|
||||
0x821988e4 sub_82198808 0x820a270c MESSAGE
|
||||
0x82198a5c sub_82198808 0x820a28ec MENU_DISABLE_SKIP
|
||||
0x8219c3b4 sub_8219C328 0x820a2acc INFO
|
||||
0x821a0b4c sub_821A0AD0 0x820a255c FADE
|
||||
0x821a0b98 sub_821A0AD0 0x820a2ae8 BG
|
||||
0x821a0bdc sub_821A0AD0 0x820a2adc DEBRIEFING
|
||||
0x821a1028 sub_821A0F80 0x820a2ab4 LOCAL_BOARD
|
||||
0x821a113c sub_821A0F80 0x820a255c FADE
|
||||
0x821a1188 sub_821A0F80 0x820a2ae8 BG
|
||||
0x821a129c sub_821A0F80 0x820a2554 TITLE
|
||||
0x821a12e8 sub_821A0F80 0x820a2ad4 BLACK
|
||||
0x821a13b4 sub_821A0F80 0x820a2ab4 LOCAL_BOARD
|
||||
0x821a1a6c sub_821A19B0 0x820a2ac0 LIVE_BOARD
|
||||
0x821a1bd4 sub_821A19B0 0x820a255c FADE
|
||||
0x821a1c20 sub_821A19B0 0x820a2ae8 BG
|
||||
0x821a1d34 sub_821A19B0 0x820a2554 TITLE
|
||||
0x821a1d80 sub_821A19B0 0x820a2ad4 BLACK
|
||||
0x821a1e4c sub_821A19B0 0x820a2ac0 LIVE_BOARD
|
||||
0x821a2b48 sub_821A2A80 0x820a2a74 DETAIL
|
||||
0x821b368c sub_821B34E8 0x820a2570 BASE
|
||||
0x821b3780 sub_821B34E8 0x820a32ac WINDOW
|
||||
0x821b9210 sub_821B9118 0x820a2168 FILE
|
||||
0x821bc60c sub_821BC568 - (not recovered)
|
||||
0x821bca58 sub_821BC978 0x820a2384 MENU
|
||||
0x821be188 sub_821BDF88 0x820a2570 BASE
|
||||
0x821be1ec sub_821BDF88 0x820a38ac TUTORIAL_MENU
|
||||
0x821c1d30 sub_821C1B58 0x820a2168 FILE
|
||||
0x821c2f04 sub_821C2EA0 0x820a2168 FILE
|
||||
0x821c5c88 sub_821C5818 0x820a3d3c TITLE_SCREEN
|
||||
0x821c5d70 sub_821C5818 0x820a339c BUTTON
|
||||
0x821c6524 sub_821C6458 0x820a3d3c TITLE_SCREEN
|
||||
0x821c67c8 sub_821C6458 0x820a3d30 TITLE_MENU
|
||||
0x821c73c8 sub_821C6458 0x820a214c LOADING
|
||||
0x821c9488 sub_821C93C8 0x820a2168 FILE
|
||||
0x822b2e04 sub_822B2D80 0x820ab24c KEY
|
||||
0x822b581c sub_822B5790 0x820ab2a4 GAMMA_TITLE
|
||||
0x822b5914 sub_822B5790 0x820ab298 GAMMA_RGB
|
||||
0x822b71ac sub_822B7128 0x820a99dc GAME
|
||||
0x822b8700 sub_822B8670 0x820a3124 PAD
|
||||
0x822b9ebc sub_822B9E38 0x820a1784 SOUND
|
||||
|
||||
46/48 recovered.
|
||||
23
docs/re/data/order-crosscheck-reach.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
# How much of each recorded paint order its draw capture actually cross-checked.
|
||||
# 2026-08-29, tools/re-capture/order_crosscheck_reach.py
|
||||
#
|
||||
# The three measured_paint_order vectors are reads of the runtime CHILD ARRAY
|
||||
# (ui-screen-runtime.md: "paint order (child slots)"). These captures are the
|
||||
# cross-check, not the source. Quads = indices/4: a single draw can carry
|
||||
# several (the menu's draw 9 has indices=24 = 6 quads), so counting draws
|
||||
# under-reads what is on screen.
|
||||
|
||||
== docs/re/captures/title-screen-draw-order.log
|
||||
frame 1: 8 draws, 12 quads, 5 textures
|
||||
frame 2: 8 draws, 12 quads, 5 textures
|
||||
frame 3: 8 draws, 12 quads, 5 textures
|
||||
textures in EVERY captured frame: 5 of 5
|
||||
== docs/re/captures/title-draw-order-with-alpha.log
|
||||
frame 1: 8 draws, 12 quads, 5 textures
|
||||
frame 2: 8 draws, 12 quads, 5 textures
|
||||
textures in EVERY captured frame: 5 of 5
|
||||
== docs/re/captures/main-menu-draw-order.log
|
||||
frame 0: 9 draws, 16 quads, 5 textures
|
||||
frame 3: 7 draws, 9 quads, 4 textures
|
||||
textures in EVERY captured frame: 4 of 5
|
||||
absent from some frame: ['11C30000']
|
||||
35
docs/re/data/paint-order-audit.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
# cargo run -p sylpheed-formats --example paint_order_audit -- dat/GP_TITLE.pak
|
||||
# 2026-08-29
|
||||
|
||||
1682 | let decl = &decls[0];
|
||||
290 | let mut flush = |base: u32,
|
||||
entry 0 (no measured order) 7 elements, 2 tied pairs, 1 of them OVERLAPPING
|
||||
entry 1 (no measured order) 7 elements, 2 tied pairs, 1 of them OVERLAPPING
|
||||
entry 2 (no measured order) 1 elements, 0 tied pairs, 0 of them OVERLAPPING
|
||||
entry 3 (no measured order) 1 elements, 0 tied pairs, 0 of them OVERLAPPING
|
||||
entry 4 title 24 elements
|
||||
derived == measured : NO
|
||||
inverted pairs : 8 (of which same-layer-key ties: 8)
|
||||
measured: [9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, 22, 23, 21, 8]
|
||||
derived : [9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 16, 17, 18, 0, 1, 2, 3, 4, 5, 7, 22, 23, 21, 8]
|
||||
keys : [32928, 32928, 32928, 32928, 32928, 32928, 32832, 32928, 4294967295, 32768, 4294967295, 32784, 32784, 4294967295, 32899, 32899, 32899, 32899, 32899, 32898, 32897, 33024, 32936, 32937]
|
||||
entry 5 main menu 16 elements
|
||||
derived == measured : YES
|
||||
inverted pairs : 0 (of which same-layer-key ties: 0)
|
||||
entry 6 (no measured order) 18 elements, 15 tied pairs, 2 of them OVERLAPPING
|
||||
entry 7 (no measured order) 30 elements, 37 tied pairs, 16 of them OVERLAPPING
|
||||
entry 8 main menu 16 elements
|
||||
derived == measured : YES
|
||||
inverted pairs : 0 (of which same-layer-key ties: 0)
|
||||
entry 9 (no measured order) 18 elements, 15 tied pairs, 2 of them OVERLAPPING
|
||||
entry 10 (no measured order) 3 elements, 0 tied pairs, 0 of them OVERLAPPING
|
||||
entry 11 splash 7 elements
|
||||
derived == measured : YES
|
||||
inverted pairs : 0 (of which same-layer-key ties: 0)
|
||||
entry 12 (no measured order) 10 elements, 2 tied pairs, 1 of them OVERLAPPING
|
||||
entry 13 (no measured order) 3 elements, 0 tied pairs, 0 of them OVERLAPPING
|
||||
entry 14 splash 7 elements
|
||||
derived == measured : YES
|
||||
inverted pairs : 0 (of which same-layer-key ties: 0)
|
||||
entry 15 (no measured order) 10 elements, 2 tied pairs, 1 of them OVERLAPPING
|
||||
5 build(s) with a measured order were checked
|
||||
25
docs/re/data/plateau-census.txt
Normal file
@@ -0,0 +1,25 @@
|
||||
CONTROL GP_TITLE build 7: fallback elements [8, 13, 14] (want [8, 13, 14])
|
||||
index 8 is 'ptlogo_eff3.t32' (want ptlogo_eff3.t32)
|
||||
|
||||
elements with a keyframe group, disc-wide: 15493
|
||||
no plateau -> rest pose is GUESSED: 3807 (24.57 %)
|
||||
|
||||
most common guessed elements:
|
||||
1002 pb_name_eff.t32
|
||||
90 gbp301i.t32
|
||||
84 pb_warp_eff.t32
|
||||
78 pbb_destroyer.t32
|
||||
70 gbp303i.t32
|
||||
60 pb_w_line.t32
|
||||
54 pbmwindow_eff.t32
|
||||
54 pbfriendly.t32
|
||||
32 pzeff00.prm
|
||||
32 pzeff02.t32
|
||||
32 px_mission_area.rat
|
||||
30 pbr_fighter.t32
|
||||
|
||||
--- the 3807 plateau-less elements, by what each candidate rule returns
|
||||
current rule (longest dwell) returns an INVISIBLE pose : 1711 ( 44.9 %)
|
||||
current rule returns a ZERO-SCALE (degenerate) pose : 195 ( 5.1 %)
|
||||
'rest = last keyframe' returns an INVISIBLE pose : 1618 ( 42.5 %)
|
||||
the two rules agree : 1911 ( 50.2 %)
|
||||
16
docs/re/data/plateauless-endstate.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
# tools/re-capture/plateauless_endstate.py over dat/GP_*.pak -- 2026-08-29
|
||||
|
||||
plateau-less, multi-keyframe, ending VISIBLE: 687
|
||||
alpha never decreases (a fade/slide IN that stops): 472 (68.7 %)
|
||||
ends at its maximum alpha but dips on the way : 203 (29.5 %)
|
||||
ends visible but NOT at maximum alpha : 12 (1.7 %)
|
||||
|
||||
examples of the monotone kind (alpha sequence, then scale pairs):
|
||||
GP_BUNK.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
|
||||
GP_BUNK.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
|
||||
GP_DEBRIEFING_PILOTLOG.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
|
||||
GP_DEBRIEFING_PILOTLOG.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
|
||||
GP_DEBRIEFING_PILOTLOG.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
|
||||
GP_DEBRIEFING_PILOTLOG.pak pjex_eff.rat [0, 255] [(100, 100), (100, 100)]
|
||||
GP_MAIN_GAME_D2D.pak pghud_speed_cut.t32 [0, 255] [(100, 100), (100, 100)]
|
||||
GP_MAIN_GAME_D2D.pak pgmsg_restart_sub.rat [255, 255] [(106, 106), (100, 100)]
|
||||
52
docs/re/data/ratc-child-name-audit.txt
Normal file
@@ -0,0 +1,52 @@
|
||||
# RATC child names: the printable-run scan vs the `opt ` block, all 33 dat/*.pak
|
||||
# 2026-08-29, crates/sylpheed-formats/examples/ratc_child_names.rs
|
||||
#
|
||||
# RUN 1 was taken BEFORE the fix, when ratc::parse still named children by
|
||||
# scanning for the last printable run. It is the evidence for the defect.
|
||||
|
||||
RATC children scanned : 18002
|
||||
with an `opt ` block: 17942
|
||||
scanned name AGREES : 17918
|
||||
scanned name DIFFERS : 24
|
||||
|
||||
distinct disagreements (scanned -> opt), with counts:
|
||||
'OX -> po_keys_win1.t32 x2
|
||||
8AX -> pbbg.t32 x12
|
||||
8AX -> pmbase.t32 x4
|
||||
8AX -> pteff04.t32 x2
|
||||
8AX -> pteff05.t32 x4
|
||||
|
||||
first 20 occurrences:
|
||||
GP_OPTIONS.pak entry 20 'OX -> po_keys_win1.t32
|
||||
GP_OPTIONS.pak entry 22 'OX -> po_keys_win1.t32
|
||||
GP_READY_ROOM.pak entry 132 8AX -> pbbg.t32
|
||||
GP_READY_ROOM.pak entry 141 8AX -> pbbg.t32
|
||||
GP_READY_ROOM.pak entry 211 8AX -> pbbg.t32
|
||||
GP_READY_ROOM.pak entry 219 8AX -> pbbg.t32
|
||||
GP_READY_ROOM.pak entry 233 8AX -> pbbg.t32
|
||||
GP_READY_ROOM.pak entry 234 8AX -> pbbg.t32
|
||||
GP_READY_ROOM.pak entry 264 8AX -> pbbg.t32
|
||||
GP_READY_ROOM.pak entry 265 8AX -> pbbg.t32
|
||||
GP_READY_ROOM.pak entry 967 8AX -> pbbg.t32
|
||||
GP_READY_ROOM.pak entry 999 8AX -> pbbg.t32
|
||||
GP_READY_ROOM.pak entry 1097 8AX -> pbbg.t32
|
||||
GP_READY_ROOM.pak entry 1099 8AX -> pbbg.t32
|
||||
GP_STAGE_CLEAR.pak entry 2 8AX -> pmbase.t32
|
||||
GP_STAGE_CLEAR.pak entry 4 8AX -> pmbase.t32
|
||||
GP_STAGE_CLEAR.pak entry 7 8AX -> pmbase.t32
|
||||
GP_STAGE_CLEAR.pak entry 8 8AX -> pmbase.t32
|
||||
GP_TITLE.pak entry 4 8AX -> pteff04.t32
|
||||
GP_TITLE.pak entry 5 8AX -> pteff05.t32
|
||||
|
||||
# RUN 2, same command AFTER the fix. ratc::parse now prefers the `opt ` name, so
|
||||
# the two readings must agree everywhere -- which is the check that the fix is
|
||||
# complete rather than partial.
|
||||
|
||||
RATC children scanned : 18002
|
||||
with an `opt ` block: 17942
|
||||
scanned name AGREES : 17942
|
||||
scanned name DIFFERS : 0
|
||||
|
||||
distinct disagreements (scanned -> opt), with counts:
|
||||
|
||||
first 20 occurrences:
|
||||
26
docs/re/data/ratc-name-fix-render-effect.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
# Effect on the five port screens of naming RATC children from the `opt `
|
||||
# block. 2026-08-29. `sylpheed-cli screen render --build N GP_TITLE.pak`,
|
||||
# before vs after the ratc.rs change. The newly-resolved element is the
|
||||
# full-resolution background (pteff04.t32 on the title, pteff05.t32 on the
|
||||
# menus), which the old name `8AX` hid.
|
||||
|
||||
build screen mean brightness high-frequency detail
|
||||
4 title 72.77 -> 72.81 8.722 -> 10.062 x1.15 pixels changed >2: 30.0%
|
||||
5 main menu (JP) 42.73 -> 42.74 3.978 -> 5.051 x1.27 pixels changed >2: 20.0%
|
||||
6 EXTRAS (JP) 44.08 -> 44.09 3.953 -> 5.025 x1.27 pixels changed >2: 19.4%
|
||||
8 main menu 41.57 -> 41.58 3.830 -> 4.970 x1.30 pixels changed >2: 21.0%
|
||||
9 EXTRAS 42.99 -> 43.00 3.867 -> 5.000 x1.29 pixels changed >2: 20.2%
|
||||
|
||||
# Brightness unmoved, detail up ~a quarter = the same artwork at twice the
|
||||
# resolution replacing a 2x upscale. New CONTENT would move the mean.
|
||||
|
||||
# Covering check: the background is opaque and full-screen and paints 4th
|
||||
# of 16 on the menu, so it could hide what follows. Standard deviation
|
||||
# inside each element's resting rect (build 8), before vs after:
|
||||
ptbtn01 sd 59.98 -> 60.03
|
||||
ptbtn03 sd 63.40 -> 63.45
|
||||
ptbtn05 sd 66.54 -> 66.58
|
||||
ptframe1 sd 51.16 -> 51.20
|
||||
ptmsg sd 56.11 -> 56.12
|
||||
loop-area sd 50.99 -> 51.04
|
||||
# Nothing is covered.
|
||||
58
docs/re/data/ratc-tan-frame-sequence.txt
Normal file
@@ -0,0 +1,58 @@
|
||||
# The 60 RATC children with no `opt ` block — probe output
|
||||
|
||||
```
|
||||
$ cargo run -p sylpheed-formats --example ratc_optless_children -- $SYLPHEED_DISC/dat/*.pak
|
||||
|
||||
RATC children scanned : 18002
|
||||
with NO accepted `opt ` block: 60
|
||||
... of which are child #0 : 0
|
||||
|
||||
why, by cause:
|
||||
TagBeyondWindow x60
|
||||
|
||||
every occurrence (name is what the FALLBACK scan returned):
|
||||
GP_READY_ROOM.pak entry 26 child 1 TagBeyondWindow(213)
|
||||
24 bytes before the magic: 00 00 00 07 00 00 00 28 00 00 00 08 00 00 00 28 00 00 00 09 00 00 00 28
|
||||
GP_READY_ROOM.pak entry 26 child 2 TagBeyondWindow(60813)
|
||||
24 bytes before the magic: 0a eb 61 00 08 eb 61 00 06 eb 61 00 05 eb 61 00 03 eb 61 00 01 eb 61 00
|
||||
... (the remaining 54 rows are the same five bundles' children 1..10;
|
||||
every distance is 213 + n*60600, i.e. the SAME tag)
|
||||
```
|
||||
|
||||
## Reading bundle 26 directly — the ten are frames of one `.tan`
|
||||
|
||||
```
|
||||
entry 26: 15 children, payload 706609 bytes
|
||||
0 @0x00000444 T8aD size 1933 opt@-40 name='pbf15_energie_generator2.t32'
|
||||
1 @0x00000bd1 T8aD size 60600 opt@-213 name='pb_f15_eg_anm.tan'
|
||||
2 @0x0000f889 T8aD size 60600 opt@-60813 name='pb_f15_eg_anm.tan'
|
||||
3 @0x0001e541 T8aD size 60600 opt@-121413 name='pb_f15_eg_anm.tan'
|
||||
4 @0x0002d1f9 T8aD size 60600 opt@-182013 name='pb_f15_eg_anm.tan'
|
||||
5 @0x0003beb1 T8aD size 60600 opt@-242613 name='pb_f15_eg_anm.tan'
|
||||
6 @0x0004ab69 T8aD size 60600 opt@-303213 name='pb_f15_eg_anm.tan'
|
||||
7 @0x00059821 T8aD size 60600 opt@-363813 name='pb_f15_eg_anm.tan'
|
||||
8 @0x000684d9 T8aD size 60600 opt@-424413 name='pb_f15_eg_anm.tan'
|
||||
9 @0x00077191 T8aD size 60600 opt@-485013 name='pb_f15_eg_anm.tan'
|
||||
10 @0x00085e49 T8aD size 60632 opt@-545613 name='pb_f15_eg_anm.tan'
|
||||
11 @0x00094b21 T8aD size 17523 opt@-32 name='pbf15_pd_inside2.t32'
|
||||
12 @0x00098f94 T8aD size 75068 opt@-35 name='pbenergie_generator.t32'
|
||||
13 @0x000ab4d0 T8aD size 4697 opt@-28 name='pbf15_eg_eff.t32'
|
||||
14 @0x000ac729 RATC size 264 opt@-25 name='pb_s15_eg.rat'
|
||||
```
|
||||
|
||||
## Disc-wide: every `opt ` name, by extension
|
||||
|
||||
```
|
||||
`opt ` blocks disc-wide: 18718
|
||||
|
||||
by extension:
|
||||
.t32 x14756
|
||||
.rat x3311
|
||||
.prm x367
|
||||
.tbm x224
|
||||
.sbo x54
|
||||
.tan x6
|
||||
|
||||
.tan resources with >=1 child: 6
|
||||
GP_READY_ROOM.pak pb_f15_eg_anm.tan frames= 10 sizes=[60600, 60632] x6 bundles
|
||||
```
|
||||
8
docs/re/data/rest-rule-blast-radius.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
# tools/re-capture/rest_rule_blast_radius.py over dat/GP_*.pak -- 2026-08-29
|
||||
|
||||
genuinely ambiguous elements (plateau-less, 2+ keyframes): 2305
|
||||
the two rules AGREE on : 409 (17.7 %)
|
||||
they DIFFER on : 1896 (82.3 %)
|
||||
|
||||
rule dwell : returns an INVISIBLE pose 1711 ( 74.2 %), zero-scale 195 ( 8.5 %)
|
||||
rule last : returns an INVISIBLE pose 1618 ( 70.2 %), zero-scale 43 ( 1.9 %)
|
||||
7
docs/re/data/rotation-top-level-examples.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
# `sylpheed-cli screen info --geometry`, rows with a non-zero rotation (r=).
|
||||
# GP_DIALOG builds 0 and 6; GP_DEBRIEFING_PILOTLOG build 10. 2026-08-28
|
||||
|
||||
4 pceff03.t32 247x281 38x38 NO 8: 630,348 100%,100% a=0 r=90 12: 550,273 100%,100% a=128 r=30 14: 510,235 100%,100% a=192 r=10 16: 490,217 100%,100% a=224 r=3 52: 470,198 100%,100% a=255 58: 470,198 100%,100% a=255 -: 470,198 100%,100% a=0
|
||||
5 pceff04.t32 257x311 478x586 NO 8: 413,77 100%,100% a=0 r=90 12: 483,167 100%,100% a=128 r=30 14: 518,212 100%,100% a=192 r=10 16: 536,234 100%,100% a=224 r=3 52: 553,247 100%,100% a=255 58: 553,247 100%,100% a=255 -: 553,247 100%,100% a=0
|
||||
1 pzeff02.t32 529x519 528x524 NO 197: 376,58 0%,0% a=0 217: 376,58 112%,112% a=255 r=43 232: 376,58 145%,145% a=64 r=61 237: 376,58 173%,173% a=32 r=75 -: 376,58 200%,200% a=0 r=90
|
||||
5 pjeff24a.t32 382x140 610x622 NO -: 335,49 210%,210% a=53 r=90
|
||||
32
docs/re/data/rotation-toplevel-census.txt
Normal file
@@ -0,0 +1,32 @@
|
||||
CONTROL GP_TITLE build 4 (nested rotations only): 0 top-level rotated (want 0)
|
||||
CONTROL GP_DIALOG build 0: 2 top-level rotated (want 2)
|
||||
[4] pceff03.t32 r = [90, 30, 10, 3, 0, 0, 0]
|
||||
[5] pceff04.t32 r = [90, 30, 10, 3, 0, 0, 0]
|
||||
|
||||
top-level elements with a keyframe group, disc-wide: 15493
|
||||
carrying a non-zero rotation: 2152 (13.89 %)
|
||||
|
||||
by archive:
|
||||
1868 GP_READY_ROOM.pak
|
||||
40 GP_DIALOG.pak
|
||||
37 GP_MAIN_GAME_D2D.pak
|
||||
37 GP_MAIN_GAME_E2D.pak
|
||||
37 GP_MAIN_GAME_F2D.pak
|
||||
37 GP_MAIN_GAME_I2D.pak
|
||||
37 GP_MAIN_GAME_J2D.pak
|
||||
37 GP_MAIN_GAME_S2D.pak
|
||||
10 GP_GAMEOVER.pak
|
||||
8 GP_DEBRIEFING_PILOTLOG.pak
|
||||
4 GP_TITLE.pak
|
||||
|
||||
most common rotated element names:
|
||||
444 pbb_destroyer.t32
|
||||
402 pbr_destroyer.t32
|
||||
276 pbr_fighter.t32
|
||||
174 pbb_fighter.t32
|
||||
168 pbb_aircraft.t32
|
||||
84 pb_warp_eff.t32
|
||||
48 pbtab.t32
|
||||
35 gbp012i.t32
|
||||
32 pzeff02.t32
|
||||
30 gbp001i.t32
|
||||
39
docs/re/data/se-cue-runtime-offsets.txt
Normal file
@@ -0,0 +1,39 @@
|
||||
SE cue waves located inside Static.slb by playing them.
|
||||
|
||||
Method: run Canary with --xma_param_probe=true, drive the main menu, and read the
|
||||
per-stream XMA-PARAM lines. Each line carries the stream's first 32 bytes; those
|
||||
bytes are then searched for in Static.slb's 8 353 472 readable bytes.
|
||||
|
||||
event packets bytes channels/rate offset in Static.slb
|
||||
------------------------- ------- ------ -------------- --------------------
|
||||
d-pad move (cursor) 4 8192 mono 48000 Hz 0x1ec0
|
||||
B / back (cancel) 2 4096 mono 48000 Hz 0x0ec0
|
||||
A / confirm (title->menu) 6 12288 mono 48000 Hz 0x5d6c0
|
||||
|
||||
Each head matched at exactly ONE offset. The first two are contiguous:
|
||||
0x0ec0 + 4096 = 0x1ec0.
|
||||
|
||||
Simultaneously at the main menu, two STEREO 48 kHz streams were decoding:
|
||||
1893 packets / 3 876 864 B and 1919 packets / 3 930 112 B. See
|
||||
structures/bgm-two-stems.md -- this is the runtime observation that the two
|
||||
stems of a music bank play at the same time.
|
||||
|
||||
DECODED 2026-08-28 with tools/re-capture/slb_extract_wave.py + ffmpeg's xma1:
|
||||
|
||||
event offset packets decoded rms peak envelope shape
|
||||
---------------- -------- ------- --------- ----- ----- ------------------------
|
||||
d-pad move 0x1ec0 4 0.533 s 2085 29813 sharp attack, monotonic decay
|
||||
B / back 0x0ec0 2 0.344 s 2985 16973 attack, peak in frame 2
|
||||
(pre-input cue) 0x5d6c0 6 1.016 s 4327 32767 peaks in frame 3, long decay
|
||||
|
||||
CONTROL: the same synthesized RIFF wrapper applied to BGM_001.slb's first wave
|
||||
(offset 14336, stereo) decodes to 173.808875 s -- identical to the duration
|
||||
obtained from that bank's own on-disc RIFF header. The wrapper is correct.
|
||||
|
||||
SECOND RUN 2026-08-28 -- independent boot, same probe:
|
||||
* d-pad move and B reproduced with IDENTICAL head bytes and sizes (2 of 2 runs).
|
||||
* The 12288-byte wave fires on the A that advances title -> main menu, together
|
||||
with the two stereo BGM stems (the menu's music starting). Counting from the
|
||||
title rather than the menu is what attributed it.
|
||||
* Left and Right fired NO new stream -- no distinct invalid cue. (Dedup caveat:
|
||||
a press replaying an already-heard wave would also log nothing.)
|
||||
326
docs/re/data/se-ui-cues.txt
Normal file
@@ -0,0 +1,326 @@
|
||||
Every SE cue on the disc, from tables.pak SOUNDS. 322 cues.
|
||||
BANK_SE names the single bank they all live in: Static.slb
|
||||
0 of the 322 has its own entry in FILES (5 135 bank paths).
|
||||
|
||||
1 SE_UI_START
|
||||
2 SE_UI_CURSOR
|
||||
3 SE_UI_DECIDE
|
||||
4 SE_UI_CANSEL
|
||||
5 SE_UI_IMPOSI
|
||||
6 SE_UI_WAIT
|
||||
7 SE_UI_NEXT
|
||||
8 SE_UI_SUB_WIN_OPN
|
||||
9 SE_UI_SUB_WIN_CLS
|
||||
10 SE_UI_ALART_WIN
|
||||
11 SE_UI_PAUSE
|
||||
12 SE_UI_SPLASH_IN
|
||||
13 SE_UI_SPLASH_OUT
|
||||
14 SE_UI_LOAD_CMP
|
||||
15 SE_TUT_BOX_CLEAR
|
||||
16 SE_TUT_UNIT_IN
|
||||
17 SE_UI_WEAPON_PLAN
|
||||
18 SE_UI_WEAPON_CREATE
|
||||
21 SE_HUD_MIS_LOCK
|
||||
22 SE_HUD_LOCK_ALART
|
||||
23 SE_HUD_WP_EMPTY
|
||||
24 SE_HUD_WP_CHARGED
|
||||
25 SE_HUD_DMG_ALARM
|
||||
26 SE_HUD_OH_ALART
|
||||
27 SE_HUD_OVER_HEAT
|
||||
28 SE_HUD_CALL
|
||||
29 SE_HUD_EMERG_CALL
|
||||
30 SE_HUD_MES_END
|
||||
31 SE_HUD_MES_SHAT
|
||||
32 SE_HUD_MIS_SEARCH
|
||||
33 SE_HUD_INVALID
|
||||
34 SE_HUD_MW_CHG
|
||||
35 SE_HUD_TRG_CHG
|
||||
36 SE_HUD_MOD_CHG
|
||||
37 SE_HUD_RADIO_MENU
|
||||
38 SE_HUD_RADIO_ORDER
|
||||
39 SE_HUD_RADIO_CANSEL
|
||||
40 SE_HUD_TM_USE
|
||||
41 SE_HUD_TM_INP
|
||||
42 SE_HUD_TM_COM
|
||||
43 SE_HUD_TM_OK
|
||||
44 SE_HUD_TM_ERROR
|
||||
45 SE_HUD_RADER_MARKER
|
||||
46 SE_HUD_GUIDE
|
||||
47 SE_HUD_SUB_TARGET
|
||||
48 SE_HUD_SP_CHARGE
|
||||
49 SE_HUD_SP_LV_UP
|
||||
50 SE_BR_PASS
|
||||
51 SE_BR_SHAT
|
||||
52 SE_BR_PROGRESS
|
||||
53 SE_BR_DECO_1
|
||||
54 SE_BR_FRAME
|
||||
55 SE_BR_DECO_2
|
||||
56 SE_BR_HELP
|
||||
57 SE_BR_DISP
|
||||
58 SE_BR_COWIN
|
||||
59 SE_BR_WAIT
|
||||
60 SE_BR_NEXT
|
||||
61 SE_BR_FRIENDRY
|
||||
62 SE_BR_ENEMY
|
||||
63 SE_BR_MESS
|
||||
64 SE_BR_POINT
|
||||
65 SE_BR_MARK
|
||||
66 SE_BR_OUT
|
||||
67 SE_BR_MAIN_OP
|
||||
68 SE_BR_MAIN_CLS
|
||||
69 SE_BR_JUMP_OUT
|
||||
70 SE_BR_JUMP_IN
|
||||
71 SE_BR_PROMETEUS
|
||||
72 SE_BR_PLANET_BROKEN
|
||||
73 SE_HUD_SP_RETURN
|
||||
74 SE_HUD_SP_ATACK
|
||||
75 SE_HUD_SP_SLOW
|
||||
76 SE_HUD_AAM_SITE_OPN
|
||||
77 SE_HUD_AAM_SITE_CLS
|
||||
78 SE_HUD_ASM_SITE_OPN
|
||||
79 SE_HUD_ASM_SITE_CLS
|
||||
81 SE_UI_MISSION_START
|
||||
82 SE_UI_MISSION_UPDATE
|
||||
83 SE_UI_MISSION_END
|
||||
101 SE_SW_nbe01_1
|
||||
102 SE_SW_nbe02_1
|
||||
103 SE_SW_nbe03_1
|
||||
104 SE_SW_nla01_1
|
||||
105 SE_SW_nvu01_1
|
||||
106 SE_SW_nvu02_1
|
||||
107 SE_SW_nvu03_1
|
||||
108 SE_SW_nvu04_1
|
||||
109 SE_SW_nsh01_1
|
||||
110 SE_SW_nsh02_1
|
||||
111 SE_SW_nsh03_1
|
||||
112 SE_SW_nsp01_1
|
||||
113 SE_SW_nsp02_1
|
||||
114 SE_SW_nvu01_2
|
||||
115 SE_SW_nvu01_3
|
||||
116 SE_SW_nvu02_2
|
||||
117 SE_SW_nvu02_3
|
||||
118 SE_SW_nvu02_4
|
||||
119 SE_SW_nvu03_2
|
||||
120 SE_SW_nvu03_3
|
||||
121 SE_SW_nvu04_2
|
||||
122 SE_SW_nla02_1
|
||||
123 SE_SW_SPIT_1
|
||||
201 SE_MW_wvu01_1
|
||||
202 SE_MW_wvu02_1
|
||||
203 SE_MW_wvu03_1
|
||||
204 SE_MW_wvu04_1
|
||||
205 SE_MW_wbe01_1
|
||||
206 SE_MW_wbe02_1
|
||||
207 SE_MW_whb01_1
|
||||
208 SE_MW_whb02_1
|
||||
209 SE_MW_wla01_1
|
||||
210 SE_MW_wla02_1
|
||||
211 SE_MW_wla03_1
|
||||
212 SE_MW_wla04_1
|
||||
213 SE_MW_wAA01_1
|
||||
214 SE_MW_wAA02_1
|
||||
215 SE_MW_wAA03_1
|
||||
216 SE_MW_wAA04_1
|
||||
217 SE_MW_wAA04_2
|
||||
218 SE_MW_wAA05_1
|
||||
219 SE_MW_wAA07_1
|
||||
220 SE_MW_wAA0x_2
|
||||
221 SE_MW_wro01_1
|
||||
222 SE_MW_wro02_1
|
||||
223 SE_MW_wro03_1
|
||||
224 SE_MW_wro04_1
|
||||
225 SE_MW_wAS01_1
|
||||
226 SE_MW_wAS02_1
|
||||
227 SE_MW_wAS03_1
|
||||
228 SE_MW_wAS04_1
|
||||
229 SE_MW_wAS05_1
|
||||
230 SE_MW_wAS0x_2
|
||||
231 SE_MW_wAS0x_3
|
||||
232 SE_MW_wto01_1
|
||||
233 SE_MW_wto01_2
|
||||
234 SE_MW_wto02_1
|
||||
235 SE_MW_wto02_2
|
||||
236 SE_MW_wto03_1
|
||||
237 SE_MW_wto03_2
|
||||
238 SE_MW_wto04_1
|
||||
239 SE_MW_wto04_2
|
||||
240 SE_MW_wbo01_1
|
||||
241 SE_MW_wbo02_1
|
||||
242 SE_MW_wbo03_1
|
||||
243 SE_MW_wbo04_1
|
||||
244 SE_MW_wbo05_1
|
||||
245 SE_MW_wbo06_1
|
||||
246 SE_MW_wbo07_1
|
||||
247 SE_MW_wbo08_1
|
||||
248 SE_MW_wbo09_1
|
||||
249 SE_MW_wKE01_1
|
||||
250 SE_MW_wKE02_1
|
||||
251 SE_MW_wKE03_1
|
||||
252 SE_MW_wgu01_1
|
||||
253 SE_MW_wgu02_1
|
||||
254 SE_MW_wgu03_1
|
||||
255 SE_MW_wgu04_1
|
||||
256 SE_MW_wgu05_1
|
||||
257 SE_MW_wgu05_2
|
||||
258 SE_MW_wgu06_1
|
||||
259 SE_MW_wgu07_1
|
||||
260 SE_MW_wsp01_1
|
||||
261 SE_MW_wsp01_2
|
||||
262 SE_MW_wsp01_3
|
||||
263 SE_MW_L3GP_1
|
||||
264 SE_MW_THOR_1
|
||||
301 SE_SPE_mcm01_1
|
||||
302 SE_SPE_mcm02_1
|
||||
304 SE_SPE_mpr01_1
|
||||
351 SE_SWP_BEAM_1
|
||||
352 SE_SWP_RAILGUN_1
|
||||
353 SE_SWP_PCANNON_1
|
||||
354 SE_SWP_LASER_1
|
||||
355 SE_SWP_BVULCAN_1
|
||||
356 SE_SWP_VULCAN_1
|
||||
357 SE_SWP_AAM_1
|
||||
358 SE_SWP_ASM_1
|
||||
359 SE_SWP_BEAM_2
|
||||
401 SE_EXT_OVER_HEAT
|
||||
402 SE_EXT_OH_RECOVER
|
||||
403 SE_EXT_EMPTY
|
||||
404 SE_EXT_EJECT_H
|
||||
405 SE_EXT_EJECT_M
|
||||
406 SE_EXT_EJECT_L
|
||||
407 SE_EXT_EJECT_FAE
|
||||
408 SE_EXT_MGT_OPN
|
||||
409 SE_EXT_MGT_CLS
|
||||
410 SE_EXT_PHOTON_CHG
|
||||
411 SE_EXT_GRAVITY_CHG
|
||||
412 SE_EXT_RECHARGE
|
||||
413 SE_EXT_RECHG_CMP
|
||||
414 SE_EXT_DOUBLER
|
||||
415 SE_EXT_SPIT_CHG
|
||||
501 SE_DS_ENGINE
|
||||
502 SE_DS_POWER_UP
|
||||
503 SE_DS_AFTER_BURNER
|
||||
504 SE_DS_REVERSE
|
||||
505 SE_DS_COCKPIT
|
||||
506 SE_DS_POWER_DOWN
|
||||
507 SE_DS_AB_END
|
||||
508 SE_DS_SIDE
|
||||
509 SE_DS_ROLL
|
||||
511 SE_ENG_ERAN
|
||||
512 SE_ENG_ERAN_Q
|
||||
513 SE_ENG_VINDI
|
||||
514 SE_ENG_DELTA
|
||||
515 SE_ENG_UNE
|
||||
516 SE_ENG_ADAN
|
||||
517 SE_ENG_SUPPLY
|
||||
518 SE_ENG_ATK_S
|
||||
519 SE_ENG_ATK_L
|
||||
551 SE_ENG_ACRO
|
||||
552 SE_ENG_JUN
|
||||
553 SE_ENG_KUCHI
|
||||
554 SE_ENG_OOGATA
|
||||
555 SE_ENG_YUSOU
|
||||
556 SE_ENG_BS
|
||||
557 SE_ENG_AAF
|
||||
558 SE_ENG_ASF
|
||||
559 SE_ENG_CARRIER
|
||||
560 SE_ENG_MISSILE
|
||||
561 SE_ENG_SIDE_S
|
||||
562 SE_ENG_SIDE_L
|
||||
601 SE_EXP_CRAFT
|
||||
602 SE_EXP_EX_CRAFT
|
||||
603 SE_EXP_ATK
|
||||
604 SE_EXP_OUT
|
||||
605 SE_EXP_BS_PART_L
|
||||
606 SE_EXP_BS_PART_M
|
||||
607 SE_EXP_BS_PART_S
|
||||
608 SE_EXP_BS_BREAK_LL
|
||||
609 SE_EXP_BS_BREAK_L
|
||||
610 SE_EXP_BS_BREAK_M
|
||||
611 SE_EXP_BS_BREAK_S
|
||||
612 SE_EXP_BS_BREAK_SS
|
||||
613 SE_EXP_BS_OMEN
|
||||
614 SE_EXP_BS_BOMB
|
||||
615 SE_EXP_MISSILE
|
||||
616 SE_EXP_TCAF_CRUISER
|
||||
617 SE_EXP_MINE
|
||||
651 SE_IMP_BM_DMG
|
||||
652 SE_IMP_BM
|
||||
653 SE_IMP_LS_DMG
|
||||
654 SE_IMP_LS
|
||||
655 SE_IMP_VULCAN
|
||||
656 SE_IMP_SHOTGUN
|
||||
657 SE_IMP_FIRE
|
||||
658 SE_IMP_MISSILE
|
||||
659 SE_IMP_ROCKET
|
||||
660 SE_IMP_SMOKE
|
||||
661 SE_IMP_TORPEDO
|
||||
662 SE_IMP_BOMB
|
||||
663 SE_IMP_KINETIC
|
||||
681 SE_IMP_BULLET
|
||||
683 SE_IMP_BS_BEAM
|
||||
684 SE_IMP_BS_RGUN
|
||||
685 SE_IMP_BS_PCANNON
|
||||
686 SE_IMP_BS_LASER
|
||||
687 SE_IMP_BS_BVULCAN
|
||||
688 SE_IMP_BS_VULCAN
|
||||
689 SE_IMP_BS_AAM
|
||||
690 SE_IMP_BS_ASM
|
||||
691 SE_IMP_GRV
|
||||
701 SE_SHIELD_DOWN
|
||||
702 SE_SHIELD_RECHARGE
|
||||
703 SE_SHIELD_DOWN_BS
|
||||
751 SE_BOSS_CRY
|
||||
752 SE_BOSS_CLOSE
|
||||
753 SE_BOSS_OPEN
|
||||
754 SE_BOSS_STUMBLE
|
||||
755 SE_BOSS_MOMENTS
|
||||
756 SE_BOSS_ATK_A
|
||||
758 SE_BOSS_ATK_C
|
||||
759 SE_BOSS_LASER
|
||||
760 SE_BOSS_HOMING
|
||||
761 SE_BOSS_CHARGE
|
||||
762 SE_BOSS_CORE_CHARGE
|
||||
763 SE_BOSS_SPREAD
|
||||
764 SE_CORE_COLLAPSE
|
||||
781 SE_PROME_AMB_1
|
||||
782 SE_PROME_AMB_2
|
||||
783 SE_PROME_AMB_3
|
||||
784 SE_PROME_FIRE
|
||||
785 SE_ENV_MINE
|
||||
786 SE_HNG_ARM_MV
|
||||
787 SE_HNG_MOUNT
|
||||
788 SE_HNG_UN_MOUNT
|
||||
789 SE_HNG_ARM_UP
|
||||
790 SE_HNG_ARM_DWN
|
||||
791 SE_HNG_MOUNT_2
|
||||
792 SE_HNG_UN_MOUNT_2
|
||||
793 SE_HNG_MOUNT_3
|
||||
794 SE_HNG_UN_MOUNT_3
|
||||
795 SE_HNG_MOUNT_4
|
||||
796 SE_HNG_UN_MOUNT_4
|
||||
807 SE_ETC_JMP_OP
|
||||
808 SE_ETC_JMP_IN
|
||||
809 SE_ETC_JMP_OUT
|
||||
810 SE_ENV_BS_M
|
||||
811 SE_ENV_BS_S
|
||||
812 SE_ENV_BS_L
|
||||
813 SE_ENV_BS_LL
|
||||
814 SE_ENV_CARRIER
|
||||
815 SE_ETC_HP_UNE
|
||||
816 SE_ETC_HP_ADAN
|
||||
817 SE_ETC_HP_ACRO
|
||||
818 SE_ENV_WIND
|
||||
819 SE_ENV_WIND_TURN
|
||||
820 SE_ETC_TURRET_L
|
||||
821 SE_ETC_TURRET_M
|
||||
822 SE_ETC_TURRET_S
|
||||
823 SE_PASS_DS
|
||||
824 SE_PASS_ADAN
|
||||
825 SE_PASS_ERAN
|
||||
826 SE_PASS_PLUS
|
||||
827 SE_PASS_TYPEQ
|
||||
828 SE_PASS_VINDI
|
||||
829 SE_ETC_COLLISION
|
||||
830 SE_ETC_SHIELD_DMG
|
||||
900 SE_LFE_BOMB
|
||||
901 SE_LFE_ENG
|
||||
17
docs/re/data/se-wave-riff-decode.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
# se_wave_riff — the three menu cues, decoded end to end
|
||||
|
||||
$ cargo run -p sylpheed-formats --example se_wave_dump -- /tmp/se
|
||||
|
||||
/tmp/se/move.riff: 8252 bytes (4 packets at 0x1ec0)
|
||||
/tmp/se/back.riff: 4156 bytes (2 packets at 0xec0)
|
||||
/tmp/se/confirm.riff: 12348 bytes (6 packets at 0x5d6c0)
|
||||
refusal path ok: Static.slb: 1048576 packets at 0x1ec0 need 2147483648 bytes, but the bank declares only 8970240 bytes
|
||||
|
||||
$ ffmpeg -i <cue>.riff <cue>.wav # then measure the PCM
|
||||
|
||||
move 48000 Hz mono 0.533 s rms 2084.7 peak 29813 non-quiet 47.5%
|
||||
back 48000 Hz mono 0.344 s rms 2984.7 peak 16973 non-quiet 95.1%
|
||||
confirm 48000 Hz mono 1.016 s rms 4327.0 peak 32767 non-quiet 92.5%
|
||||
Non-silent, plausible envelopes, durations consistent with a UI blip.
|
||||
The refusal path is exercised in the same run: an impossible packet count is
|
||||
rejected rather than returning a short stream.
|
||||
22
docs/re/data/slow-xpr-files.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
# .xpr files whose `mesh info` decode exceeds 25 s (debug build), of 166 scanned
|
||||
# 2026-08-29. Stage_S02 measured at 144 s to completion, rc=0 -- heavy, not hung.
|
||||
|
||||
Hangar.xpr
|
||||
Stage_S02.xpr
|
||||
Stage_S03.xpr
|
||||
Stage_S04.xpr
|
||||
Stage_S06.xpr
|
||||
Stage_S07.xpr
|
||||
Stage_S08.xpr
|
||||
Stage_S09.xpr
|
||||
Stage_S11.xpr
|
||||
Stage_S12.xpr
|
||||
Stage_S13.xpr
|
||||
Stage_S15.xpr
|
||||
Stage_S24.xpr
|
||||
Stage_S25.xpr
|
||||
Stage_S26.xpr
|
||||
Stage_S27.xpr
|
||||
Stage_S28.xpr
|
||||
Stage_S29.xpr
|
||||
ptc_pack.xpr
|
||||
11
docs/re/data/splash-phase-timeline.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
# Measured splash phase timeline -- from docs/re/captures/ui-timing/splash-build-draws.log
|
||||
# 2026-08-29. Frames are VdSwap counts; 1 frame = 1/30 s (2 units/frame, 1 unit = 1/60 s).
|
||||
#
|
||||
# phase frames n seconds start (rel. capture)
|
||||
publisher: SQUARE ENIX logo 1-90 90 3.00+ 0.00 <- floor: already at a=255 on f1
|
||||
(gap, nothing drawn) 91-93 3 0.10 3.00
|
||||
developer: both glows 94-115 22 0.73 3.10
|
||||
developer: both logos 116-211 96 3.20 3.83
|
||||
#
|
||||
# palogo_anima / palogo_anima_eff: 0 draws in the whole 214-frame window.
|
||||
# The glow->logo switch is a single frame boundary (f115 -> f116), no overlap.
|
||||
20
docs/re/data/splash-ramp-check.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
CONTROL — the eff glow's fade-in must be linear at a constant step
|
||||
alphas [34, 68, 102, 136, 170, 204, 238] steps [34, 34, 34, 34, 34, 34]
|
||||
=> 2.000 units/frame; t(f) = 2*f - 171
|
||||
check: declared hold ends t=45 -> predicted frame 108.0; observed last full-alpha frame 107
|
||||
|
||||
LOGO — palogo_gamearts, checked against that calibration
|
||||
observed: first drawn frame 116 at alpha 255; full alpha through 198; fade-out 199..211
|
||||
declared a= 0 at t= 15 -> frame 93.0 observed alpha NOT DRAWN
|
||||
declared a= 0 at t= 30 -> frame 100.5 observed alpha NOT DRAWN
|
||||
declared a=255 at t=190 -> frame 180.5 observed alpha 255
|
||||
declared a=255 at t=194 -> frame 182.5 observed alpha 255
|
||||
declared a=232 at t=206 -> frame 188.5 observed alpha 255
|
||||
declared a= 32 at t=210 -> frame 190.5 observed alpha 255
|
||||
|
||||
SHAPE, independent of any calibration:
|
||||
declared fade-out spans t=194..210 (16 units); of that,
|
||||
12/16 units drop only 23/255 of the alpha (a near-flat leg),
|
||||
4/16 units drop 200/255 (a cliff).
|
||||
captured fade-out: [255, 254, 243, 237, 215, 181, 148, 131, 98, 65, 48, 23, 15, 7]
|
||||
per-frame drops: [1, 11, 6, 22, 34, 33, 17, 33, 33, 17, 25, 8, 8] -> no near-flat leg
|
||||
97
docs/re/data/title-state-machine.txt
Normal file
@@ -0,0 +1,97 @@
|
||||
GamePart_Title has TWO nested state fields.
|
||||
|
||||
OUTER -- sub_821C7850, the part's dispatcher
|
||||
821c786c lwz r11, 16(r30) ; only runs when this+16 == 3
|
||||
821c7870 cmpwi cr6, r11, 3
|
||||
821c7874 bne cr6, <exit>
|
||||
821c787c lwz r11, 132(r30) ; phase = this+0x84
|
||||
821c7880 cmplwi cr6, r11, 0x4 ; five phases, 0..4
|
||||
821c7888 lis/addi r12, 0x821C78A0 ; jump table
|
||||
821c789c bctr
|
||||
|
||||
phase 0 -> sub_821C5690 (the splash -- independently identified as the
|
||||
splash mechanics in the iterate3E notes)
|
||||
phase 1 -> inline block at 0x821c790c
|
||||
phase 2 -> sub_821C5818
|
||||
phase 3 -> sub_821C5EC0
|
||||
phase 4 -> sub_821C6458 (the title/menu machine, below)
|
||||
|
||||
All four call sites forward the SAME three arguments: (this, r29, r28),
|
||||
where r28 is this function's own third argument -- an EVENT CODE passed
|
||||
through unchanged from ITS caller.
|
||||
|
||||
INNER -- sub_821C6458, the title/menu machine (phase 4 only)
|
||||
|
||||
DISPATCH
|
||||
821c6474 lwz r11, 136(r30) ; state = this+0x88
|
||||
821c6478 cmplwi cr6, r11, 0x9 ; 10 states, 0..9
|
||||
821c647c bgt cr6, 0x821C75B8 ; out of range -> default
|
||||
821c6480 lis r12, 0x821C
|
||||
821c6484 addi r12, r12, 25752 ; jump table at 0x821C6498
|
||||
821c6488 slwi r0, r11, 2
|
||||
821c648c lwzx r0, r12, r0
|
||||
821c6490 mtctr r0
|
||||
821c6494 bctr
|
||||
|
||||
NOTE: a disassembler decodes 0x821C6498..0x821C64BC as `lwz r16, N(r28)`
|
||||
instructions. They are the jump TABLE's ten words, not code.
|
||||
|
||||
STATES
|
||||
case 0 -> 0x821c64c0 installs "TITLE_SCREEN"
|
||||
case 1 -> 0x821c65d0
|
||||
case 2 -> 0x821c66e4 installs "TITLE_MENU"
|
||||
case 3 -> 0x821c6b18
|
||||
case 4 -> 0x821c6b5c nested switch, table at 0x821c6b7c (live: 0,3,5,8)
|
||||
case 5 -> 0x821c6fd4
|
||||
case 6 -> 0x821c7028
|
||||
case 7 -> 0x821c72dc
|
||||
case 8 -> 0x821c733c installs "LOADING"
|
||||
case 9 -> 0x821c7558
|
||||
|
||||
TRANSITIONS -- every `li rX,N ; stw rX,136(r30)` in the function
|
||||
from to at
|
||||
0 1 0x821c6598
|
||||
0 2 0x821c65c8
|
||||
1 2 0x821c66a4
|
||||
1 2 0x821c66dc
|
||||
2 4 0x821c6afc
|
||||
3 4 0x821c6b54
|
||||
4 0 0x821c6e00
|
||||
4 5 0x821c6e70
|
||||
4 8 0x821c6ed0
|
||||
4 8 0x821c6f20
|
||||
4 8 0x821c6f7c
|
||||
4 8 0x821c6fcc
|
||||
5 6 0x821c7020
|
||||
6 7 0x821c725c
|
||||
6 9 0x821c72a8
|
||||
6 2 0x821c72d4
|
||||
7 9 0x821c7334
|
||||
8 2 0x821c7548
|
||||
(state 9 stores nothing -- terminal within this function)
|
||||
|
||||
THE CONDITION ON STATE 4's EDGES -- decoded
|
||||
sub_821C6458(this, ?, r5) takes an EVENT CODE in its third argument.
|
||||
State 4 -- the input-waiting state -- dispatches on it:
|
||||
|
||||
821c6b5c cmplwi cr6, r27, 0x19 ; 26 events, 0..25
|
||||
821c6b60 bgt cr6, <default>
|
||||
821c6b68 addi r12, r12, 27516 ; table at 0x821c6b7c
|
||||
821c6b78 bctr
|
||||
|
||||
Only 6 of the 26 are handled; the other 20 fall through with no state change.
|
||||
|
||||
event 0 -> block 0x821c6be4 -> state 0 (TITLE_SCREEN -- back to the title)
|
||||
event 3 -> block 0x821c6e78 -> state 8 (LOADING)
|
||||
event 5 -> block 0x821c6f84 -> state 8 (LOADING)
|
||||
event 8 -> block 0x821c6f34 -> state 8 (LOADING)
|
||||
event 10 -> block 0x821c6e28 -> state 5
|
||||
event 25 -> block 0x821c6ed8 -> state 8 (LOADING)
|
||||
|
||||
WHAT IS NOT DECODED
|
||||
* what the event NUMBERS mean -- they are forwarded unchanged from
|
||||
sub_821C7850's caller, so the vocabulary is defined at least one level up -- button id, menu-item id or message id;
|
||||
* the conditions on edges out of states other than 4;
|
||||
* what states 1, 3, 5, 6, 7 and 9 do (they install no named screen);
|
||||
* state 3 is never a destination here, so something outside this function
|
||||
sets it.
|
||||
24
docs/re/data/zero-scale-census.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# tools/re-capture/zero_scale_census.py -- 2026-08-28
|
||||
|
||||
elements with a keyframe group: 15493
|
||||
at least one keyframe at scale 0: 2166
|
||||
EVERY keyframe at scale 0 (would vanish): 0
|
||||
zero is a transient the element grows out of: 1762
|
||||
rest poses with scale 0%%: 195
|
||||
... of which alpha > 0, so blit paints a 1-pixel sliver: 126
|
||||
... alpha == 0, harmless: 69
|
||||
|
||||
by archive:
|
||||
126 GP_READY_ROOM.pak
|
||||
|
||||
examples (pak, entry, element idx, name, sx, sy, alpha):
|
||||
('GP_READY_ROOM.pak', 0, 2, 'pbacropolis.t32', 0, 100, 255)
|
||||
('GP_READY_ROOM.pak', 0, 6, 'pbFriendly.t32', 0, 100, 255)
|
||||
('GP_READY_ROOM.pak', 0, 10, 'pbFriendly.t32', 0, 100, 255)
|
||||
('GP_READY_ROOM.pak', 0, 14, 'pbFriendly.t32', 0, 100, 255)
|
||||
('GP_READY_ROOM.pak', 7, 2, 'pbacropolis.t32', 0, 100, 255)
|
||||
('GP_READY_ROOM.pak', 7, 6, 'pbFriendly.t32', 0, 100, 255)
|
||||
('GP_READY_ROOM.pak', 7, 10, 'pbFriendly.t32', 0, 100, 255)
|
||||
('GP_READY_ROOM.pak', 7, 14, 'pbFriendly.t32', 0, 100, 255)
|
||||
('GP_READY_ROOM.pak', 74, 2, 'pbr_enemy.t32', 0, 100, 255)
|
||||
('GP_READY_ROOM.pak', 76, 2, 'pbr_enemy.t32', 0, 100, 255)
|
||||
50
docs/re/doc-link-audit.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# ✅ Do the docs' cited artifacts exist? — 11 broken links repaired
|
||||
|
||||
**Status:** ✅ checked mechanically and fixed, with the count verified both ways.
|
||||
|
||||
The brief's rule is *"commit the reference data beside the finding, so the port
|
||||
can be built without a disc in the loop"*. An answer whose evidence is not
|
||||
reachable cannot be used. Nothing had ever checked that.
|
||||
|
||||
[`tools/re-capture/doc_link_check.py`](../../tools/re-capture/doc_link_check.py)
|
||||
walks every markdown file under `docs/`, resolves each relative link, and reports
|
||||
targets that do not exist — and, separately, targets that exist but are **empty**,
|
||||
which is the sneakier failure since a zero-byte file looks fine in any listing.
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| links resolving | 1 038 | **1 049** |
|
||||
| missing targets | **16** | 5 |
|
||||
| empty targets | 0 | 0 |
|
||||
|
||||
**+11 resolving, −11 missing — the two numbers pair exactly**, which is the check
|
||||
that the repair did what it claimed and nothing else.
|
||||
|
||||
## 🔴 Two of them were the evidence for the UI decode itself
|
||||
|
||||
`structures/ui-rat-layout.md` is the layout decode the port is built on. Its two
|
||||
figures — the ones backing *"the tutorial PAUSE menu rebuilds pixel-accurately
|
||||
from its sprites"* and *"the same method reproduces the main menu"* — were
|
||||
written as `captures/ui-layout/…` from a file in `structures/`, one directory
|
||||
too shallow. **The headline evidence for the decode was unreachable from its own
|
||||
document.**
|
||||
|
||||
## What was wrong, and what still is
|
||||
|
||||
Eleven links had the **wrong relative depth** while their targets existed — a
|
||||
missing or surplus `../`, or a missing `structures/`. Those are repaired; each
|
||||
was rewritten only when exactly one candidate path resolved, so nothing was
|
||||
guessed.
|
||||
|
||||
❔ **Five remain genuinely absent** and are left alone rather than invented:
|
||||
|
||||
| doc | target |
|
||||
|---|---|
|
||||
| `autopilot-knowledge-sources.md`, `entities-live-roster.md` | `../../MEMORY.md` — outside the repo |
|
||||
| `challenge-mission-gate.md` | a header in the separate `xenia-canary-native` tree |
|
||||
| `stage-drift-is-navigation-not-save.md` | `structures/weapon-datasheet-runtime.md` — never written |
|
||||
| `mission-freeze-resume-spin.md` | `canary-build-verified-env-confound.md` — never written |
|
||||
|
||||
None is port-relevant: they are mission, entity and emulator-side documents. Two
|
||||
name documents that do not exist, which is a different problem from a bad path
|
||||
and is not something to paper over with a link fix.
|
||||
@@ -79,7 +79,7 @@ looks stuck on the main menu while it is in fact three screens further on.
|
||||
|
||||
## Still open
|
||||
|
||||
* The **world-unit measurement** ([collisionset](collisionset.md)) — positions
|
||||
* The **world-unit measurement** ([collisionset](structures/collisionset.md)) — positions
|
||||
in world units against the HUD's own distance readout. This run reached the
|
||||
mission but the flight HUD was not yet up (green 0.03 %, vs 1.3–1.5 % in
|
||||
flight), so no distance readout was available to compare against.
|
||||
|
||||
63
docs/re/five-screens-acceptance.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# ✅ The five screens, end to end — what each one is actually worth
|
||||
|
||||
**Status:** ✅ measured, 2026-08-29. One page, because the answers for these five
|
||||
screens are otherwise spread across a dozen documents and none of them says how
|
||||
good the result *is*.
|
||||
|
||||
Rendered with `sylpheed-cli screen render` (adding `--all --black` for the two
|
||||
splash halves) and correlated against the committed live captures.
|
||||
|
||||
| screen | build | elements drawn | corr vs capture | alignment |
|
||||
|---|---|---|---|---|
|
||||
| title | 4 | 15 / 24 | **+0.9500** | dy=0 dx=0 |
|
||||
| main menu | 5 | 11 / 16 | **+0.9460** | dy=0 dx=0 |
|
||||
| `EXTRAS` | 6 | 13 / 18 | **+0.9440** | dy=0 dx=0 |
|
||||
| publisher splash | 10 | 2 / 3 | **+0.9600** | dy=0 dx=0 |
|
||||
| developer splash | 11 | 6 / 7 | **+0.9643** | dy=0 dx=0 |
|
||||
|
||||
Every one aligns at **exactly zero offset** — searched ±2 px in both axes — so
|
||||
placement and scale are right and the residual is tone and detail, not geometry.
|
||||
|
||||
## Every omission is accounted for
|
||||
|
||||
The counts are not slack; each undrawn element has a reason already documented:
|
||||
|
||||
| screen | not drawn | why |
|
||||
|---|---|---|
|
||||
| title | `ptlogo1`×2, `ptlogo2`×2 | `kind & 0x4` **ghost instances** — the draw capture shows one quad per wordmark |
|
||||
| | `pteff00.prm`, `pteff02.prm` | `.prm` primitives, off without `--primitives` |
|
||||
| | `ptloop01.rat`, `ptloop02.rat` | `loop*` animations, off without `--animated`; at rest both sit off-screen |
|
||||
| | `pteff04.t32` | the **`8AX` name mismatch** — the art *is* on screen, drawn via `ptbase2` ([8AX](structures/ui-8ax-fullres-background.md)) |
|
||||
| main menu, `EXTRAS` | 2 `.prm` + 2 `loop*` + `pteff05.t32` | same three reasons |
|
||||
| both splashes | `palogo_eff0.prm` | `.prm` primitive |
|
||||
|
||||
4 + 2 + 2 + 1 = 9 on the title; 2 + 2 + 1 = 5 on each menu; 1 on each splash.
|
||||
**Nothing is unexplained.**
|
||||
|
||||
## What the residual is, per screen
|
||||
|
||||
Ranked by what a port would notice:
|
||||
|
||||
1. **Tone.** The capture is darker than the composite by a gamma of ≈1.34–1.49
|
||||
([tone curve](structures/ui-render-tone-curve.md)) — the game's own display
|
||||
ramp, not an emulator artefact. This is the largest single contributor to the
|
||||
0.94–0.96 rather than higher.
|
||||
2. **`8AX` resolution.** The background is drawn from the 640×360 `ptbase`
|
||||
upscaled 2×, where the game uses the native 1280×720 `8AX`
|
||||
([measured](structures/ui-8ax-fullres-background.md), two screens at 68 % of
|
||||
ceiling).
|
||||
3. **Paint-order ties on `EXTRAS` only.** One drawable overlapping tie
|
||||
(`ptframe3`×`ptframe4`), and the capture is consistent with our order
|
||||
([check](structures/ui-paint-order-derived-check.md)). The other four screens
|
||||
use orders measured from the game.
|
||||
4. **Rotation is decoded but not rendered** — and does not affect any of these
|
||||
five at rest ([rotation](structures/ui-keyframe-rotation.md)).
|
||||
|
||||
## Reach
|
||||
|
||||
* These are **static composites at the resting pose** against **single frames**.
|
||||
Nothing here says the animation is right; Q1's interpolation law is settled but
|
||||
a multi-keyframe group's absolute timing is not
|
||||
([time unit](ui-keyframe-time-unit.md)).
|
||||
* Correlation on a whole frame is dominated by large areas. It is a sanity figure,
|
||||
not a per-element check — the per-element work is in the documents linked above.
|
||||
263
docs/re/menu-audio-cues.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Menu audio — the event vocabulary is on the disc; the binding is not
|
||||
|
||||
**Status:** ✅ `CONFIRMED` and **decoded** for the cue vocabulary and the bank
|
||||
that holds it. 🟡 the cue↔event binding is a **name match**, not a measurement.
|
||||
❔ two negatives with their reach: no cue names a screen's music, and the audio
|
||||
for an individual SE cue cannot yet be extracted.
|
||||
|
||||
Answers most of [MISSION Q8](../port/MISSION.md).
|
||||
|
||||
## ✅ The UI cue vocabulary — decoded
|
||||
|
||||
`dat/tables.pak`'s sound table has a `SOUNDS` record of 5 798 cues. 322 are `SE_*`
|
||||
and the **low block is the user-interface vocabulary**, named by the game's
|
||||
authors after the *events*, not after the sounds:
|
||||
|
||||
| id | cue | id | cue |
|
||||
|---|---|---|---|
|
||||
| 1 | `SE_UI_START` | 9 | `SE_UI_SUB_WIN_CLS` |
|
||||
| **2** | **`SE_UI_CURSOR`** | 10 | `SE_UI_ALART_WIN` |
|
||||
| **3** | **`SE_UI_DECIDE`** | 11 | `SE_UI_PAUSE` |
|
||||
| **4** | **`SE_UI_CANSEL`** *(sic)* | 12 | `SE_UI_SPLASH_IN` |
|
||||
| **5** | **`SE_UI_IMPOSI`** | 13 | `SE_UI_SPLASH_OUT` |
|
||||
| 6 | `SE_UI_WAIT` | 14 | `SE_UI_LOAD_CMP` |
|
||||
| 7 | `SE_UI_NEXT` | 17/18 | `SE_UI_WEAPON_PLAN` / `_CREATE` |
|
||||
| 8 | `SE_UI_SUB_WIN_OPN` | 81–83 | `SE_UI_MISSION_START` / `_UPDATE` / `_END` |
|
||||
|
||||
Full 322-cue list in [`data/se-ui-cues.txt`](data/se-ui-cues.txt). The remaining
|
||||
blocks are gameplay: `SE_HUD_*` (21–83), `SE_BR_*` briefing (50–72), `SE_SW_*` /
|
||||
`SE_MW_*` weapons, `SE_DS_*` engine, `SE_EXP_*` explosions, `SE_SHIELD_*`.
|
||||
|
||||
## ✅ They all live in one bank — decoded
|
||||
|
||||
The sound table's `BANK_SE` record is a **single field**: `Static.slb`. And the
|
||||
disc-wide check agrees — **0 of the 322 `SE_*` cues has an entry in `FILES`**, the
|
||||
5 135-path list where every voice, briefing and BGM bank is named. SE is the one
|
||||
family that is not one-cue-one-file.
|
||||
|
||||
`SETTINGS` names the XACT project too: `PATH` = `game:\dat\sound.pak+`,
|
||||
`PARAM` = `Pj_Silph.xgs`.
|
||||
|
||||
## 🟡 The binding: a name match, and I am calling it that
|
||||
|
||||
MISSION asks which cue fires on **move / confirm / back / error**. Read off the
|
||||
names:
|
||||
|
||||
| event | cue |
|
||||
|---|---|
|
||||
| cursor moves | `SE_UI_CURSOR` (2) |
|
||||
| Ⓐ confirm | `SE_UI_DECIDE` (3) |
|
||||
| Ⓑ back | `SE_UI_CANSEL` (4) |
|
||||
| invalid / error | `SE_UI_IMPOSI` (5) |
|
||||
| submenu opens / closes | `SE_UI_SUB_WIN_OPN` / `_CLS` (8/9) |
|
||||
| the splash | `SE_UI_SPLASH_IN` / `_OUT` (12/13) |
|
||||
|
||||
**Nobody has watched the game emit cue 2 on a d-pad press.** This is inference
|
||||
from identifiers, and it belongs in the same box as Q4's GamePart ids — with one
|
||||
honest difference worth stating: these are the *authors' own event names*, chosen
|
||||
to describe when the sound plays, not asset labels I am interpreting. It is a
|
||||
strong name match. It is still a name match, and the port is authoring it.
|
||||
|
||||
Measuring it needs the guest's cue-play call observed with its argument — the
|
||||
same instrumentation Q4's residual wants, and not something this container can do
|
||||
today (it runs `--mute=true` against an SDL dummy device, so there is no audio
|
||||
path to watch either).
|
||||
|
||||
## ✅ Which BGM per screen — the TABLE cannot say, but the CODE can
|
||||
|
||||
All 32 BGM cues are named `BGM_001`…`BGM_109`, and nothing in `SOUNDS`, `FILES` or
|
||||
the bank headers names a screen. That negative stands **for the tables**.
|
||||
|
||||
It does not stand for the executable. `GamePart_Title`'s phase handler
|
||||
`sub_821C5580` contains `li r5, 1103` feeding a sound call — **cue 1103 =
|
||||
`BGM_103`** — and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 bytes)
|
||||
are byte-for-byte the two streams the XMA probe saw decoding at the main menu.
|
||||
Three routes, one answer; see
|
||||
[`structures/bgm-two-stems.md`](structures/bgm-two-stems.md).
|
||||
|
||||
So the per-screen BGM binding is recoverable **per screen, from the code that
|
||||
plays it** — not from any table.
|
||||
|
||||
## ❔ And a new negative: an individual SE's audio is not extractable yet
|
||||
|
||||
`Static.slb` is **8 353 472 readable bytes** (declared 8 970 240 — exactly the
|
||||
616 768 over-declaration the corpus already records), and it contains
|
||||
**0 `RIFF`, 0 `seek`, 0 `WAVE`** — scanned over the whole buffer, so this is not
|
||||
an offset problem. Every other bank on the disc is delimited by `seek` magic at
|
||||
`data_at + declared_size` (7 620/7 620 in
|
||||
[`slb-data-offset.md`](structures/slb-data-offset.md)); `Static.slb` is outside
|
||||
that population. So the route that extracts every voice line and every BGM does
|
||||
**not** give you `SE_UI_CURSOR`'s audio: the cue is named, the bank is named, and
|
||||
the wave inside the bank is not locatable by any boundary marker.
|
||||
|
||||
**The named next step is `Pj_Silph.xgs`** — the XACT project file `SETTINGS`
|
||||
points at, which in XACT is exactly where cue→wave-index lives. It **is** in
|
||||
`sound.pak`: `name_hash("Pj_Silph.xgs")` resolves to TOC index 9454. ⚠️ Its entry
|
||||
is 533 bytes at a 2048-aligned offset and carries no `XGSF` magic — and its region
|
||||
has phase 1728, so those 533 bytes are very likely the *previous* bank's tail
|
||||
rather than its own (the leading-region effect, [`slb-data-offset.md`]
|
||||
(structures/slb-data-offset.md)). Locating its real bytes is the first job, not
|
||||
parsing XACT.
|
||||
|
||||
## For the port
|
||||
|
||||
* the cue **names and ids** are disc facts — use them as the event vocabulary;
|
||||
* which event fires which cue is **authored from the table above**;
|
||||
* which BGM plays on which screen is **authored** — nothing on the disc says;
|
||||
* and the UI **sound effects cannot be exported yet**. That is a gap in the
|
||||
assets, not in the naming.
|
||||
|
||||
## ❔ `Pj_Silph.xgs` is a dead end — retracting the lead, with the reach
|
||||
|
||||
The section above named `Pj_Silph.xgs` as "the named next step", on the reasoning
|
||||
that in XACT the cue→wave index lives in the project file. **That route is dead.**
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| the entry's own bytes | 533 bytes, high entropy, **no `XGSF` magic** |
|
||||
| ±8 KB around the entry in the flat concatenation | no `XGSF`, `SDBK`, `WBND`, `RIFF` or `seek` |
|
||||
| **all of `sound.pak` — 1.08 GB across `.p00`–`.p04`** | **0 × `XGSF`, 0 × `SDBK`, 0 × `WBND`** |
|
||||
| the executable | **0** occurrences of `XGSF`/`XACT`; no string matching `xact`, `.xgs`, `wavebank` or `soundbank` |
|
||||
|
||||
**Control, run first:** the same magic scan over `BGM_001.slb`'s neighbourhood
|
||||
finds `RIFF` exactly where the bank structure says it should be. The scan works;
|
||||
the magic genuinely is not there.
|
||||
|
||||
So the `.xgs` / `.slb` names are **inherited from the authoring tool, not from the
|
||||
shipped format** — nothing on this disc is a parseable XACT container. There is no
|
||||
XACT project file to read, and writing an XACT parser would have been wasted work.
|
||||
|
||||
### Where that leaves the SE audio: undecodable, with reach
|
||||
|
||||
Locating an individual SE cue's wave inside `Static.slb` is **not possible from
|
||||
anything found on the disc**. Looked in:
|
||||
|
||||
* `Static.slb` itself — 8 353 472 readable bytes, **0 `RIFF` / 0 `seek` / 0 `WAVE`**,
|
||||
where every one of the other 7 620 banks is delimited by `seek` magic at
|
||||
`data_at + declared_size` ([`structures/slb-data-offset.md`](structures/slb-data-offset.md));
|
||||
* the XACT project file — absent, as above;
|
||||
* the sound table's five records — `SOUNDS` gives cue→id, `FILES` gives bank
|
||||
paths, `BANK_SE` gives the one bank name, `SETTINGS` gives a path and the
|
||||
(absent) project file, `STAGES` is empty. **No record carries an offset.**
|
||||
|
||||
**What does corroborate the binding:** `Static.slb` is 8 353 472 bytes, and at the
|
||||
bitrates the disc uses elsewhere (25 697–31 000 B/s) that is **269–325 seconds of
|
||||
audio — 0.84 to 1.01 s per cue over 322 cues.** Exactly the shape of a bank of
|
||||
short UI/HUD/weapon effects, which is what `BANK_SE` says it is. The bank is the
|
||||
right bank; only the index into it is missing.
|
||||
|
||||
**If it is ever picked up**, the route is the running game, not the disc: watch
|
||||
the guest hand a wave offset/length to the decoder when a cue fires. That is the
|
||||
same instrumentation the cue↔event binding needs, and the same one this container
|
||||
cannot run.
|
||||
|
||||
## ✅ Retracting "cannot be extracted" — the waves are locatable, by playing them
|
||||
|
||||
The section above concluded that an individual SE cue's audio "cannot be
|
||||
extracted". **That was too strong, and the tool to do it was already in this
|
||||
build.** Canary here carries a cvar `xma_param_probe`, added for exactly this
|
||||
purpose — its own comment says it logs each XMA stream's parameters and head
|
||||
bytes "so raw sound.pak entries can be matched to real decode params".
|
||||
|
||||
Run with `--xma_param_probe=true`, drive the menu, and each newly-decoded stream
|
||||
prints one line with its packet count, channel count, sample rate and **first 32
|
||||
bytes**. Searching those bytes in `Static.slb` finds the wave.
|
||||
|
||||
| event | packets | bytes | format | offset in `Static.slb` |
|
||||
|---|---|---|---|---|
|
||||
| **d-pad move** (cursor) | 4 | 8 192 | mono 48 kHz | **`0x1ec0`** |
|
||||
| **Ⓑ back** (cancel) | 2 | 4 096 | mono 48 kHz | **`0x0ec0`** |
|
||||
| **Ⓐ confirm** (title → main menu) | 6 | 12 288 | mono 48 kHz | **`0x5d6c0`** |
|
||||
|
||||
Each head matched at **exactly one** offset. Reference data in
|
||||
[`data/se-cue-runtime-offsets.txt`](data/se-cue-runtime-offsets.txt).
|
||||
|
||||
**Structurally this also cracks the bank's layout.** The first two are
|
||||
*contiguous* — `0x0ec0 + 4096 = 0x1ec0` — so `Static.slb` is a packed run of
|
||||
XMA waves, each a whole number of 2 048-byte packets, with no delimiter between
|
||||
them. That is why the `seek`-magic scan found nothing: there is nothing to find.
|
||||
A wave is defined only by (offset, packet count), and those live in the game's
|
||||
own tables, not in the bank.
|
||||
|
||||
⚠️ **The order is not cue-id order.** `Ⓑ`'s wave precedes the cursor's in the
|
||||
file, while the name match puts `SE_UI_CURSOR` at id 2 and `SE_UI_CANSEL` at 4.
|
||||
So the index cannot be derived by counting; it has to be observed per cue.
|
||||
|
||||
### What is now measured, and what is still a name match
|
||||
|
||||
* ✅ **measured** — a d-pad move and a Ⓑ press each play a *specific, different*
|
||||
wave, and both waves are located in `Static.slb`. The port can have the audio.
|
||||
* 🟡 **still a name match** — that the cursor's wave *is the cue called*
|
||||
`SE_UI_CURSOR`. The event→wave binding is measured; the event→*name* binding is
|
||||
still read off the authors' identifiers.
|
||||
* ✅ **decoded to PCM** — see below. The cues play.
|
||||
|
||||
### ✅ The waves decode — artifact, not assertion
|
||||
|
||||
[`tools/re-capture/slb_extract_wave.py`](../../tools/re-capture/slb_extract_wave.py)
|
||||
wraps a `(bank, offset, packet count, channels, rate)` slice in a synthesized
|
||||
XMA1 `RIFF` for ffmpeg's `xma1` decoder:
|
||||
|
||||
```
|
||||
slb_extract_wave.py Static.slb 0x1ec0 4 # -> Static_0x1ec0.riff -> 0.533 s
|
||||
```
|
||||
|
||||
| event | offset | packets | decodes to | RMS | peak | envelope |
|
||||
|---|---|---|---|---|---|---|
|
||||
| d-pad move | `0x1ec0` | 4 | **0.533 s** | 2 085 | 29 813 | sharp attack, monotonic decay |
|
||||
| Ⓑ back | `0x0ec0` | 2 | **0.344 s** | 2 985 | 16 973 | attack, peak in frame 2 |
|
||||
| **Ⓐ confirm** | `0x5d6c0` | 6 | **1.016 s** | 4 327 | 32 767 | peaks in frame 3, long decay |
|
||||
|
||||
All three are mono 48 kHz, audible from sample 0, and have the percussive
|
||||
attack-and-decay shape of UI blips — not silence, not noise.
|
||||
|
||||
**Control, run first:** the *same* synthesized wrapper applied to `BGM_001.slb`'s
|
||||
first wave decodes to **173.808875 s** — identical to the duration obtained from
|
||||
that bank's own on-disc `RIFF` header in
|
||||
[`structures/bgm-two-stems.md`](structures/bgm-two-stems.md). The wrapper is not
|
||||
approximately right; it reproduces a known-good decode exactly.
|
||||
|
||||
The bitrates are consistent too: 8 192 B / 0.533 s ≈ 15.4 kB/s, 4 096 / 0.344 ≈
|
||||
11.9 kB/s, 12 288 / 1.016 ≈ 12.1 kB/s — mono at roughly half the stereo BGM rate.
|
||||
|
||||
🟡 **The decoded audio is deliberately not committed.** Three commands regenerate
|
||||
it from the disc, and the corpus keeps measurements and tooling rather than
|
||||
extracted game audio. The offsets, packet counts and this tool are the
|
||||
deliverable.
|
||||
|
||||
## The three cues the milestone needs are bound — 2026-08-28, second run
|
||||
|
||||
A second, independent boot repeated the probe and pinned the one that was still
|
||||
unattributed.
|
||||
|
||||
| event | wave | packets / bytes | runs agreeing |
|
||||
|---|---|---|---|
|
||||
| **d-pad move** | `Static.slb+0x1ec0` | 4 / 8 192 | **2 of 2** — identical head bytes |
|
||||
| **Ⓑ back** | `Static.slb+0x0ec0` | 2 / 4 096 | **2 of 2** — identical head bytes |
|
||||
| **Ⓐ confirm** | `Static.slb+0x5d6c0` | 6 / 12 288 | newly bound this run |
|
||||
|
||||
The `0x5d6c0` wave was previously logged as "played before any input", because
|
||||
the first run only started counting at the main menu. Counting from the **title**
|
||||
instead shows it appears exactly on the Ⓐ that advances title → main menu, along
|
||||
with the two stereo BGM stems (the menu's music starting).
|
||||
|
||||
So move / confirm / back — everything the five-screen milestone needs — are
|
||||
located and decodable.
|
||||
|
||||
🟡 **What that Ⓐ measurement does not separate.** The same press both *confirms*
|
||||
and *opens a screen*, so this wave could be the cue the vocabulary calls
|
||||
`SE_UI_DECIDE` or the one it calls `SE_UI_SUB_WIN_OPN`. The port needs *a* sound
|
||||
on confirm and this is the one the game plays there; which name it carries is not
|
||||
established.
|
||||
|
||||
## ❔ ⬅ and ➡ play nothing distinct
|
||||
|
||||
[`menu-navigation-semantics.md`](menu-navigation-semantics.md) measured that
|
||||
left/right do not move the cursor. They also **fire no new XMA stream** — so
|
||||
there is no distinct "invalid" cue on the main menu, and `SE_UI_IMPOSI` was not
|
||||
observed. The port should leave left/right silent.
|
||||
|
||||
⚠️ **Reach of that negative:** the probe dedups on head bytes, so a press that
|
||||
replayed an *already-heard* wave would log nothing. At that point in the run the
|
||||
cursor, confirm and BGM waves had all been heard. So this excludes a **distinct**
|
||||
invalid cue; it cannot exclude left/right quietly replaying one of those three.
|
||||
136
docs/re/menu-navigation-semantics.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# The title menu — how it moves, and where each button goes
|
||||
|
||||
**Status:** ✅ `CONFIRMED` (**measured**, by driving the running game) for the
|
||||
movement rules and for four of the five main-menu destinations. 🟡 the GamePart
|
||||
*id* behind each destination is a **name match onto the decoded id table**, not a
|
||||
measurement. ❔ `NEW GAME` deliberately untested.
|
||||
|
||||
Answers [MISSION Q5](../port/MISSION.md) and most of Q4. Nothing here is on the
|
||||
disc in any form found so far — the port is **authoring** these rules from this
|
||||
page, not transcribing a field.
|
||||
|
||||
## Q5 — movement
|
||||
|
||||
Two boots via `tools/re-capture/boot_menu.sh`, cursor read off the focus ring
|
||||
with [`tools/re-capture/menu_focus.py`](../../tools/re-capture/menu_focus.py).
|
||||
|
||||
| | behaviour | evidence |
|
||||
|---|---|---|
|
||||
| **initial focus, main menu** | **`TUTORIAL`** — the *middle* item, not the top | 2/2 boots, the first frame after the menu appears |
|
||||
| **initial focus, `EXTRAS`** | `MISSION SELECT` — the top item | [`extras-wrap.png`](captures/menu-nav/extras-wrap.png) |
|
||||
| **up / down** | one item per press, no auto-repeat at the durations tried | |
|
||||
| **wrap at the top** | ⬆ from the first item goes to the **last** | [`wrap-montage.png`](captures/menu-nav/wrap-montage.png), panels 1→2 |
|
||||
| **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** | |
|
||||
|
||||
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.
|
||||
|
||||
**🟡 Initial focus is reproducible but not established as invariant.** Both of my
|
||||
boots opened on `TUTORIAL`, and both used `boot_menu.sh`. The run recorded in
|
||||
[`menu-state-in-memory.md`](menu-state-in-memory.md) reached `EXTRAS` with *four*
|
||||
downs from the main menu, which only works from `NEW GAME`. Either the harness
|
||||
path matters or something persists. **Do not hardcode `TUTORIAL` without
|
||||
re-testing it**; what is solid is that the menu does *not* open on the top item
|
||||
in this harness.
|
||||
|
||||
## Q4 — where each button goes
|
||||
|
||||
Measured by driving: focus the item, press Ⓐ, read the screen's own title.
|
||||
|
||||
| button | screen it opens | evidence | GamePart id |
|
||||
|---|---|---|---|
|
||||
| `NEW GAME` | ❔ **not tested** | — | — |
|
||||
| `LOAD GAME` | the save-slot list, `LOAD GAME` / `Current Storage` | [`q4-destinations.png`](captures/menu-nav/q4-destinations.png) left | 🟡 `3 GP_LOAD` |
|
||||
| `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 ▸ MOVIE THEATER` | ❔ not tested | | 🟡 `6 GP_MOVIE_THEATER` |
|
||||
|
||||
**Say which, as the gate asks.** The *screen* each button opens is **measured** —
|
||||
I pressed the button and read the title off the framebuffer. The **GamePart id is
|
||||
not measured**: it is the entry of the decoded 29-id table at `.rdata 0x820A1630`
|
||||
([`challenge-mission-gate.md`](challenge-mission-gate.md) §3) whose *name* matches
|
||||
the screen I saw. The table is decoded; the *binding* of a button to an entry in
|
||||
it is a name match I made by eye. The port should treat these ids as authored.
|
||||
|
||||
Worth noting that the ids and the paks are not one-to-one: `GP_EXTRAS` is id 5
|
||||
with **no pak of its own** — its artwork is a build inside `GP_TITLE.pak`.
|
||||
|
||||
**❔ `NEW GAME` was deliberately not pressed.** Ⓐ on it leads to a standing
|
||||
black-screen hang that ends the run
|
||||
([`ui-paint-order-third-permutation.md`](ui-paint-order-third-permutation.md)),
|
||||
and this iteration needed the session. It is the one destination still unmeasured.
|
||||
|
||||
### The cheap way to finish this, and to make it a measurement
|
||||
|
||||
`0x828A690C` holds a **live screen id** — `1` title, `3` main menu, `4` extras —
|
||||
and `0x828F38AC` the cursor ([`menu-state-in-memory.md`](menu-state-in-memory.md)).
|
||||
Reading those while pressing Ⓐ turns "the screen said OPTIONS" into a measured
|
||||
transition, and works under `--gpu=null` with no screenshots at all. ⚠️ Note that
|
||||
those values are **not** GamePart ids — `GP_TITLE` is GamePart 0 and `GP_EXTRAS`
|
||||
is 5, but the word reads 1 and 4 — so it is a third enumeration and mapping it to
|
||||
the id table is itself unfinished work.
|
||||
|
||||
## ✅ `NEW GAME` — measured 2026-08-28, and it is not a hang
|
||||
|
||||
The one destination this page could not test has been driven. **Ⓐ on `NEW GAME`
|
||||
does not hang.** It opens two more menus first:
|
||||
|
||||
```
|
||||
NEW GAME -> DIFFICULTY -> SELECT DATA -> guest crash
|
||||
```
|
||||
|
||||
* **`DIFFICULTY`** — `EASY` / `NORMAL` / `HARD` / `BACK`, footer
|
||||
`Ⓐ : OK Ⓑ : Back`, opening focused on **`NORMAL`**
|
||||
([capture](captures/newgame-path/newgame-difficulty.png)). It sat unchanged for
|
||||
90 s with the emulator healthy — a menu waiting for input, which is exactly
|
||||
what "a standing hang" looks like to a screenshot loop that never presses
|
||||
anything.
|
||||
* Ⓐ on `NORMAL` → **`SELECT DATA`**, a save-slot picker headed
|
||||
`Current Storage: Dummy HDD`, prompting to pick a file for the auto-save.
|
||||
* Then the guest throws, and Xenia pauses with `PC: 0x82307128`
|
||||
([capture](captures/newgame-path/newgame-selectdata-crash.png)).
|
||||
|
||||
**That crash is already in the corpus and is not new**: `0x82307128` is inside
|
||||
`sub_823070B0`, the cache-manager STL erase documented in
|
||||
[`title-crash-stl-tree.md`](title-crash-stl-tree.md), whose trigger is an
|
||||
incomplete on-disc shader/code cache — not the menu path. The corpus also already
|
||||
holds `captures/select-data-crash.png`.
|
||||
|
||||
So Q4's last row closes as **measured**:
|
||||
|
||||
| button | screen it opens |
|
||||
|---|---|
|
||||
| `NEW GAME` | **`DIFFICULTY`** (then `SELECT DATA`) |
|
||||
|
||||
🟡 The GamePart ids for these two are unmeasured, like the rest — `24 GP_DIALOG`
|
||||
and `3 GP_LOAD` / `2 GP_SELECT_STORAGE` are name-match candidates and nothing
|
||||
more.
|
||||
|
||||
### Initial focus — a fourth data point, and it still varies
|
||||
|
||||
This boot opened the main menu on **`NEW GAME`**. Running tally across four
|
||||
boots of the same harness: `TUTORIAL`, `TUTORIAL`, `NEW GAME`, `NEW GAME`.
|
||||
Unchanged conclusion: **do not hardcode it.**
|
||||
|
||||
### The `NEW GAME` path completes — the `SELECT DATA` crash is state, not path
|
||||
|
||||
A second run of the same path, 2026-08-28, **did not crash**:
|
||||
|
||||
```
|
||||
NEW GAME → DIFFICULTY → (Ⓐ on NORMAL) → SELECT DATA → (Ⓐ on a slot) → a movie plays
|
||||
```
|
||||
|
||||
The previous run's throw at `PC 0x82307128` is therefore **not inherent to this
|
||||
menu path** — the same six presses got through it. That is consistent with the
|
||||
trigger `title-crash-stl-tree.md` already names (an incomplete on-disc cache) and
|
||||
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.
|
||||
@@ -264,7 +264,7 @@ and the boot never happens.
|
||||
|
||||
A Stage 02 run froze after ~4 minutes of flight (screen still `flight`, not GAME
|
||||
OVER), and `gdb_bt.sh` took backtraces of all **79** threads
|
||||
([`captures/stage02-freeze-gdb-backtraces.txt`](../captures/stage02-freeze-gdb-backtraces.txt)).
|
||||
([`captures/stage02-freeze-gdb-backtraces.txt`](captures/stage02-freeze-gdb-backtraces.txt)).
|
||||
|
||||
**Every single one is in a wait.** Not one thread is executing guest code or
|
||||
sitting in a xenia loop:
|
||||
@@ -394,7 +394,7 @@ and the control are all in place; what is missing is one frozen sample.
|
||||
The fourth run froze **9 seconds** into the watcher's window, in flight
|
||||
(`freeze_watch.sh` confirmed the HUD was still on screen), and the probe built
|
||||
for exactly this moment reported **the healthy-run baseline and nothing else**
|
||||
([`captures/stage02-freeze-stuck-wait-probe.txt`](../captures/stage02-freeze-stuck-wait-probe.txt)):
|
||||
([`captures/stage02-freeze-stuck-wait-probe.txt`](captures/stage02-freeze-stuck-wait-probe.txt)):
|
||||
|
||||
```
|
||||
FROZEN IN FLIGHT at 9s
|
||||
|
||||
178
docs/re/movie-binding.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Which movie plays where — the boot intro, the attract loop, the new-game intro
|
||||
|
||||
**Status:** ✅ `CONFIRMED`. The bindings are **decoded** from the movie manifest,
|
||||
and the attract movie is independently **measured** from captured frames. One
|
||||
part — whether playback is skippable — is 🟡 and the corpus contradicts itself
|
||||
about it; that is written down rather than resolved by assertion.
|
||||
|
||||
Answers [MISSION Q9](../port/MISSION.md), and contributes to Q6.
|
||||
|
||||
## The manifest names the boot-side slots — decoded
|
||||
|
||||
The movie manifest (`dat/tables.pak` entry `0x5b983a08`, schema `0x067025b9`,
|
||||
[`movie_manifest.rs`](../../crates/sylpheed-formats/src/movie_manifest.rs)) is an
|
||||
array of slots whose **key is the semantic role**, with a parallel array of value
|
||||
records in the same order. Its first eight slots are the whole boot-side flow:
|
||||
|
||||
| slot key | movie | also bound |
|
||||
|---|---|---|
|
||||
| `LOGO1` … `LOGO4` | `logo1.wmv` … `logo4.wmv` | — |
|
||||
| **`ADVERTISE_MOVIE`** | **`ADV.wmv`** | `VOICE_ADV` |
|
||||
| `STAFF_ROLL` | `SYLPH_HD720p_8M-CBR_2ch.wmv` | subtitle table |
|
||||
| **`MS00A`** | **`S00A.wmv`** | `SUBTITLE_S00A`, `VOICE_S00A`, a `pwterop_s01a.prt` overlay |
|
||||
| `MS01A` | `S01A.wmv` | `SUBTITLE_S01A`, `VOICE_S01A` |
|
||||
|
||||
The alignment is not in doubt here even though the manifest has valueless slots
|
||||
elsewhere: `logo1..4` zip 1:1 onto `LOGO1..4`, and `MS00A`→`S00A.wmv`,
|
||||
`MS01A`→`S01A.wmv` are the manifest's own anchor rule.
|
||||
|
||||
**The `logo1`–`logo4` slots are bound to `.wmv` files that are not on the disc** —
|
||||
already established, and the reason the developer splash is a screen, not a video.
|
||||
|
||||
## ✅ The boot intro and the attract movie are the same asset
|
||||
|
||||
There is **no separate boot-intro slot**. `ADV.wmv` is bound to
|
||||
`ADVERTISE_MOVIE` — it *is* the advertise movie, and the boot simply plays it
|
||||
first. For the port that means one video, not two.
|
||||
|
||||
### Measured independently, from captured frames
|
||||
|
||||
Frames captured 5 s apart during an attract cycle were matched against five
|
||||
candidate movies by frame signature (32×18 normalised grayscale, movie frames
|
||||
cropped to the 675 of 720 rows the game surface shows, 1 fps sampling).
|
||||
`tools/re-capture/frame_match.py`; full table in
|
||||
[`data/attract-frame-match.txt`](data/attract-frame-match.txt).
|
||||
|
||||
**15 of 19 attract frames matched `ADV.wmv`, and the matched timestamps advance
|
||||
monotonically at the sampling rate** — 39, 45, 56, 62, 75, 80, 85, 92, 102, 108,
|
||||
113, 119, 131, 137 s — ending at 137 s, which is `ADV.wmv`'s full length, after
|
||||
which the title returned. That is not a similarity score, it is a playhead.
|
||||
|
||||
**The control was run first and behaves the same way.** Frames captured during
|
||||
the *boot* movie — known to be `ADV.wmv` — matched `ADV` at 116, 121, 129, 134 s.
|
||||
Its failure mode is worth stating: on near-black frames the correlation collapses
|
||||
(one control frame scored 0.000, another tied `S13A` 0.984 against `ADV` 0.926).
|
||||
Those are exactly the 4 attract frames that did not match. **A dark frame carries
|
||||
no signature; it is not evidence for the runner-up.**
|
||||
|
||||
### 🔴 This corrects an earlier claim of mine
|
||||
|
||||
Two iterations ago I recorded the attract movie as "≈85 s, so probably not
|
||||
`ADV.wmv`'s 137 s". That was wrong, and the mistake was arithmetic on an
|
||||
unobserved start: my sampling began when the movie was already **39 s in**, so
|
||||
what I timed was the *tail* of it, not the whole thing.
|
||||
|
||||
## ✅ The new-game intro is `S00A.wmv`
|
||||
|
||||
Slot `MS00A` — the prologue story intro, 93.87 s, with its own subtitle table,
|
||||
`VOICE_S00A`, and a text overlay. Decoded from the manifest, **not** measured:
|
||||
Ⓐ on `NEW GAME` hangs the emulator
|
||||
([`menu-navigation-semantics.md`](menu-navigation-semantics.md)), so this
|
||||
binding has not been watched happening.
|
||||
|
||||
## 🟡 Skippable — the corpus contradicts itself, and I did not settle it
|
||||
|
||||
* [`canary-scripted-input-traps.md`](canary-scripted-input-traps.md) states
|
||||
plainly that "Ⓐ during a movie skips the movie, every time".
|
||||
* But `boot_menu.sh` / `skip_intro.sh` as they stand **deliberately do not tap
|
||||
during a movie**, logging `movie … -> waiting it out (tapping breaks the
|
||||
title)`, and the same document records run G: 88 Ⓐ presses through the boot
|
||||
left a permanent black screen.
|
||||
|
||||
Both cannot be the whole story. **The test**: boot, tap Ⓐ exactly once well
|
||||
inside the movie, and record whether the title arrives early. One boot, and it
|
||||
was not run this iteration.
|
||||
|
||||
## ✅ What ends the attract movie
|
||||
|
||||
Nothing intervenes: it **plays to its end**. The last matched frame is `ADV` at
|
||||
137 s — the file's full duration — and the title is back on the next sample. So
|
||||
the attract cycle is: title, idle ~8–10 s, fade to black, `ADV.wmv` in full, back
|
||||
to the title (which redraws `PRESS Ⓐ BUTTON`).
|
||||
|
||||
## What this contributes to Q6
|
||||
|
||||
The manifest is **data the game reads** to sequence the boot side: the slot keys
|
||||
are in play order and the key *is* the role. That is not the whole driver — it
|
||||
says what plays, not what decides to advance — but it is the first file-side
|
||||
piece of Q6's second half, and it means the boot-side asset order does not have
|
||||
to be authored.
|
||||
|
||||
## ✅ Skippable — settled 2026-08-28: **one Ⓐ skips the movie**
|
||||
|
||||
The test named above was run, and the answer is unambiguous.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| baseline: title arrives with **no** input | ~193 s, ~196 s, ~193 s (three boots) |
|
||||
| **one Ⓐ tapped at ~45 s into the boot** | **title at ~57 s** |
|
||||
|
||||
The press is provably the cause and provably singular: Canary's own
|
||||
`[RE-INPUT] XamInputGetKeystrokeEx reached the driver` counter went **3 → 4**
|
||||
across the tap, so exactly one keystroke was delivered, and the title arrived
|
||||
~12 s later instead of ~150 s later.
|
||||
|
||||
**And the skipped-to title is fully functional**, which is the part that matters
|
||||
for the harness folklore: it draws the `PRESS Ⓐ BUTTON` plate (2 408 plate pixels
|
||||
by the same probe used elsewhere), and a second Ⓐ opened the main menu normally
|
||||
([`title-after-single-A-skip.png`](captures/newgame-path/title-after-single-A-skip.png)).
|
||||
|
||||
### So what is `skip_intro.sh` protecting against?
|
||||
|
||||
Not a single tap. The failure recorded in
|
||||
[`canary-scripted-input-traps.md`](canary-scripted-input-traps.md) is run G —
|
||||
**88 presses at 4 s intervals through the whole boot** — which left a permanent
|
||||
black screen. Hammering breaks it; one press does exactly what the button is for.
|
||||
The scripts' `waiting it out (tapping breaks the title)` comment is **too broad**,
|
||||
and it costs every scripted boot ~2.5 minutes.
|
||||
|
||||
## ✅ `S00A.wmv` watched — measured 2026-08-28
|
||||
|
||||
The manifest's `MS00A → S00A.wmv` has now been confirmed against the running
|
||||
game. The first attempt, with 5-second screenshots, gave an ambiguous partial
|
||||
playhead; re-run with **`ffmpeg x11grab` at 10 fps** and matched every 0.5 s, it
|
||||
is unambiguous:
|
||||
|
||||
| capture t | `S00A` score @ playhead | `ADV` (runner-up) |
|
||||
|---|---|---|
|
||||
| 5.0 s | **0.998** @ 1.0 s | 0.787 @ 126.5 s |
|
||||
| 6.0 s | **0.999** @ 1.5 s | 0.820 @ 126.5 s |
|
||||
| 7.5 s | **1.000** @ 3.0 s | 0.836 @ 126.5 s |
|
||||
| 9.0 s | **0.999** @ 4.5 s | 0.823 @ 126.5 s |
|
||||
| 12.0 s | **0.998** @ 7.0 s | 0.985 @ 34.5 s |
|
||||
| 16.0 s | **0.999** @ 10.0 s | 0.886 @ 37.5 s |
|
||||
| 17.5 s | **0.967** @ 11.0 s | 0.460 @ 87.0 s |
|
||||
|
||||
Across the 25 consecutive samples from capture 5.0 s to 17.5 s the `S00A`
|
||||
playhead is **strictly non-decreasing** — 1.0, 1.5, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0,
|
||||
4.5, 5.0, 5.5, 5.5, 6.0, 6.5, 7.0, 7.0, 7.5, 8.0, 8.5, 9.0, 9.0, 9.5, 10.0, 10.5,
|
||||
11.0 — advancing at essentially real time, with scores at or above 0.96. **`S00A`
|
||||
is the top match on 23 of the 27 frames that carry signal** (contrast > 35;
|
||||
the other 25 of 52 frames are the transition and are correctly ignored).
|
||||
|
||||
So the new-game intro is `S00A.wmv`: **decoded** from the manifest and now
|
||||
**measured** off the game. It begins ~4.5 s after Ⓐ on the save slot.
|
||||
|
||||
### 🟡 An aside worth recording: `ADV.wmv` reuses `S00A` footage
|
||||
|
||||
In the window `S00A` 5.5–10 s, `ADV` also scores 0.97–0.99 — at its *own*
|
||||
monotonically advancing playhead, 33.5–37.5 s. Two different movies both matching
|
||||
strongly with two consistent playheads is not noise; it reads as **the boot movie
|
||||
being a trailer cut from the story cutscenes**, `S00A` among them. That would
|
||||
explain why the sparse first attempt kept flipping between them. Not investigated
|
||||
further.
|
||||
|
||||
### ⚠️ Sparse sampling was the whole problem
|
||||
|
||||
The earlier attempt sampled every 5 s and produced contrast 23–37 frames, a
|
||||
playhead that would not join up, and one high-contrast frame preferring `ADV`. The
|
||||
same question at 0.5 s spacing answers itself at 0.999. **The movie was never
|
||||
ambiguous; the sampling was.**
|
||||
|
||||
### The run was killed before the mission load
|
||||
|
||||
An earlier run of this path disappeared at ~145 s with **no crash line in its own
|
||||
log** — routine `MEM-WATCH rss=1153MB` to the end — so an external kill, not a
|
||||
guest fault, and the second this session. The `DELTASABER` plates of
|
||||
[`ui-title-build-map.md`](ui-title-build-map.md) need a mission load and were not
|
||||
reached.
|
||||
90
docs/re/ready-room-probe.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# S1 — the Ready Room probe: **no-go**, and not for the reason expected
|
||||
|
||||
**Status:** one iteration, as [MISSION §S1](../port/MISSION.md) specifies. This is
|
||||
the written go/no-go. No Ready Room work has been started.
|
||||
|
||||
## Recommendation: no-go
|
||||
|
||||
Not because it is 3D — **it is not** — and not because the names are
|
||||
unrecoverable. Because the probe found that **`GP_READY_ROOM.pak` does not appear
|
||||
to contain the Ready Room's own menu screen at all**, so the question the mission
|
||||
framed ("only 6 of 1 106 names resolve, is that a week or a quarter?") is aimed at
|
||||
the wrong target. And it is outside the five-screen milestone regardless.
|
||||
|
||||
## What the probe measured
|
||||
|
||||
### ✅ It is 2D. This is not a 3D room with a UI overlay.
|
||||
|
||||
The corpus already holds a framebuffer capture of the running screen,
|
||||
[`captures/ready-room-extra-mode.png`](captures/ready-room-extra-mode.png): a flat
|
||||
menu — a `READY ROOM` wordmark, six labelled items (`TAKE OFF`, `BRIEFINGS`,
|
||||
`HANGAR`, `PILOT LOG`, `OPTIONS`, `MISSION CHANGE`), a focus ring, and an
|
||||
`Ⓐ : OK / Ⓑ : Mission change` footer — in exactly the visual language of the
|
||||
title menus. There is no model viewport. The pak's own entry list carries **zero
|
||||
3D containers** (no `XPR2`, no `XBG7`).
|
||||
|
||||
So the mission's own no-go-by-definition criterion is **not** met. That part is a
|
||||
go.
|
||||
|
||||
### ✅ Enumeration by content works — 60 builds
|
||||
|
||||
`sylpheed-cli screen list` finds **60 screen builds**. The 1 106 entries break
|
||||
down as **902 RATC bundles, 192 IDXD objects, 6 `ttf`, 6 `LSTA`**. The worry that
|
||||
unresolvable *paths* would mean unrenderable *screens* is unfounded here.
|
||||
|
||||
### 🔴 But none of it is the Ready Room menu
|
||||
|
||||
Two independent signals, and they agree.
|
||||
|
||||
**Rendered composites are briefing panels.** Four of the 60 builds — the largest
|
||||
and most element-rich (18, 26, 44 and 9 elements) — composite to a planet
|
||||
information panel, unit markers and direction arrows:
|
||||
[`ready-room-pak-builds.png`](captures/ready-room-probe/ready-room-pak-builds.png).
|
||||
Tactical-map material, not a six-item menu. ⚠️ **4 of 60 sampled** — this alone
|
||||
does not close it.
|
||||
|
||||
**The name census does close it much harder.** The 902 bundles declare **317
|
||||
distinct element names**, all under a `pb*` prefix family:
|
||||
`pbfriendly` (330), `pbacropolis` (168), `pbrhino` (48), `pbsub` (204),
|
||||
`pbtab` (48), `pbcm` (132)… Searching all 317 for any of the six visible labels —
|
||||
`takeoff`, `take_off`, `brief`, `hangar`, `pilot`, `option`, `mission`, `ready` —
|
||||
returns **nothing**. The only `main` hits are `pbmain_btn1..3.t32`,
|
||||
`pbmain_window.t32` and their `_eff` glows, which appear in 9- and 4-element
|
||||
*fragments*, not a screen, and are declared **kind 0** (decoration).
|
||||
|
||||
Read together: this pak is the **briefing / tactical-map** content that sits
|
||||
*behind* the Ready Room's `BRIEFINGS` item, and the Ready Room's own menu lives
|
||||
somewhere else. Note the GamePart table has both `10 GP_BUNK` and
|
||||
`11 GP_READY_ROOM` ([`challenge-mission-gate.md`](challenge-mission-gate.md) §3),
|
||||
and `GP_BUNK.pak` is a separate archive that was not examined here.
|
||||
|
||||
### 🔴 And a caveat that reaches back into the delivered corpus
|
||||
|
||||
**`kind == 0x3002` finds zero buttons in this pak.** All 902 bundles, 0 hits —
|
||||
while `GP_TITLE` has 16. What `GP_READY_ROOM` uses instead:
|
||||
|
||||
| kind | count |
|
||||
|---|---|
|
||||
| `0x3000` | 491 |
|
||||
| `0x3004` | 406 |
|
||||
| `0x300c` | 135 |
|
||||
| `0x3008` | 60 |
|
||||
| `0x3001` | 6 |
|
||||
| `0x3002` | **0** |
|
||||
|
||||
So `0x3002` is one member of a `0x3000` **family** with sub-bits, and "element
|
||||
kind `0x3002` is a button" — a ✅ row in [`HANDOFF.md`](../port/HANDOFF.md) — is
|
||||
sound for the title screens and **must not be applied as an equality test
|
||||
elsewhere**. For the five-screen milestone nothing changes; for anything beyond
|
||||
it, test the family.
|
||||
|
||||
*(The kind field is the 4th `u32` of the 60-byte declaration entry, at `+40`.
|
||||
Calibrated on `GP_TITLE` build 6, where it reads `0x3002` for exactly the three
|
||||
`EXTRAS` buttons and `0x0` for the decorations.)*
|
||||
|
||||
## If it is ever picked up
|
||||
|
||||
The first job is **not** naming this pak's keys. It is finding where the Ready
|
||||
Room's six-button screen actually lives — `GP_BUNK.pak` is the obvious first
|
||||
place to look, and the same content-based `screen list` / name-census pass costs
|
||||
one iteration there.
|
||||
170
docs/re/screen-transitions.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# What happens between two screens — a fade through black, and where its timing lives
|
||||
|
||||
**Status:** ✅ `CONFIRMED`. The quad and its ramp are **decoded** (a keyframe
|
||||
group on the disc, with a disc-wide check); the wall-clock timings are
|
||||
**measured** off the running game at 30 fps. One piece is **undecodable from this
|
||||
field** and is called out below.
|
||||
|
||||
Answers [MISSION Q7](../port/MISSION.md).
|
||||
|
||||
## The mechanism — decoded
|
||||
|
||||
Every title-side screen carries a full-screen untextured primitive that is
|
||||
**black, and paints last**: `pteff00.prm` in `GP_TITLE`, `pfeff00.prm` in
|
||||
`GP_SAVE_LOAD`. That it sorts last was already established
|
||||
([`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md)); what is
|
||||
new here is that its **keyframe group is the transition**.
|
||||
|
||||
The group is always four blocks, and always this shape:
|
||||
|
||||
| block | alpha | meaning |
|
||||
|---|---|---|
|
||||
| 1 | `0xff` at `t = T0` | the screen starts **black** |
|
||||
| 2 | `0x00` at `t = T1` | ramp to fully clear — the screen **fades in** |
|
||||
| 3 | `0x00` at `t = T2` | clear; this is the resting pose, the quad is invisible |
|
||||
| 4 | `0xff`, **no time** | ramp back to black — the screen **fades out** on exit |
|
||||
|
||||
Read with the corpus's rule that a keyframe is the *start* of a ramp
|
||||
([`structures/ui-resting-pose.md`](structures/ui-resting-pose.md)).
|
||||
|
||||
```
|
||||
$ tools/re-capture/fade_quads.py 4 5 6 # GP_TITLE
|
||||
build 4 (title) pteff00.prm t=16 α=255 t=261 α=0 t=269 α=0 t=— α=255
|
||||
build 5 (main menu) pteff00.prm t=12 α=255 t= 70 α=0 t= 80 α=0 t=— α=255
|
||||
build 6 (EXTRAS) pteff00.prm t=12 α=255 t= 64 α=0 t= 74 α=0 t=— α=255
|
||||
```
|
||||
|
||||
Under [Q1](ui-keyframe-time-unit.md)'s `1 unit = 1/60 s`: the screen holds black
|
||||
for **0.20 s**, then fades in over **0.87 s** (`EXTRAS`), **0.97 s** (main menu)
|
||||
or **4.08 s** (the title).
|
||||
|
||||
### ❔ The fade-OUT duration is not in this field
|
||||
|
||||
The fourth block has **no time** — a group's last block stops 4 bytes short and
|
||||
that word is already the next group's element index
|
||||
([`ui_layout.rs`](../../crates/sylpheed-formats/src/ui_layout.rs) documents the
|
||||
packing). So the disc gives the ramp's *target* (black) and not its length. That
|
||||
duration is **measured** below, and the port is authoring it.
|
||||
|
||||
### The disc-wide check, and what it shows about overlays
|
||||
|
||||
Over every `GP_*.pak`, counting bundles that have a `.prm` element **and** ≥8
|
||||
elements (screen-sized rather than a two-element fragment):
|
||||
|
||||
> **40 of 97** carry a 255 → 0 → 255 quad; 56 of the 60 found disc-wide have
|
||||
> exactly 4 keyframes.
|
||||
|
||||
41 % sounds weak until it is read per pak, where it is nearly all-or-nothing:
|
||||
|
||||
| pak | with quad / screen-sized |
|
||||
|---|---|
|
||||
| `GP_MISSION_SELECT`, `GP_MOVIE_THEATER`, `GP_OPTIONS`, `GP_TUTORIAL`, `GP_SYSTEM` | 2/2 each |
|
||||
| `GP_BUNK`, `GP_CHALLENGE` | 6/6 |
|
||||
| `GP_STAGE_CLEAR` | 4/4 |
|
||||
| `GP_SAVE_LOAD` | 10/12 |
|
||||
| **`GP_TITLE`** | **6/12** |
|
||||
| `GP_DIALOG` | 0/133 |
|
||||
| `GP_DEBRIEFING_PILOTLOG` | 4/102 |
|
||||
|
||||
**`GP_TITLE`'s 6 of 12 is the interesting row, and it is not a gap.** The six
|
||||
that carry the quad are exactly the six *screen* builds — title, main menu and
|
||||
`EXTRAS`, English and Japanese. The six that do not are exactly the six
|
||||
**overlays**: the `PRESS Ⓐ BUTTON` plate and the two `DELTASABER` plates
|
||||
([`ui-title-build-map.md`](ui-title-build-map.md)). An overlay composited onto a
|
||||
screen has no transition of its own, so it has no fade quad — which is
|
||||
independent corroboration that those builds are overlays rather than screens.
|
||||
`GP_DIALOG`'s 0/133 says the same thing about dialog boxes.
|
||||
|
||||
## The timing — measured
|
||||
|
||||
Recorded with `ffmpeg -f x11grab -framerate 30` over the game surface, mean
|
||||
frame luminance per frame; raw data in
|
||||
[`captures/transitions/transition-luminance.csv`](captures/transitions/transition-luminance.csv),
|
||||
filmstrip in
|
||||
[`transition-filmstrip.png`](captures/transitions/transition-filmstrip.png).
|
||||
|
||||
| | main menu → `EXTRAS` (Ⓐ) | `EXTRAS` → main menu (Ⓑ) |
|
||||
|---|---|---|
|
||||
| press → first visible change | 0.07 s | 0.37 s |
|
||||
| **fade-out to black** | **0.367 s** | **0.400 s** |
|
||||
| **pure black** (luminance 0.02) | 0.233 s | 0.167 s |
|
||||
| luminance rise until settled | 0.567 s | 1.467 s |
|
||||
|
||||
The fade-out is the number the file cannot give, and it comes out the same both
|
||||
ways: **~0.4 s**, i.e. ~24 units under Q1's rule.
|
||||
|
||||
The black hold is *consistent with* the file's 12 units (0.20 s) but does not
|
||||
confirm it — the plateau spans the tail of the outgoing screen's fade-out and the
|
||||
head of the incoming screen's black, and this measurement cannot separate them.
|
||||
|
||||
### ⚠️ The luminance rise is **not** the quad's ramp
|
||||
|
||||
The two columns differ by 2.6× where the quad's declared ramps differ by only
|
||||
1.12× (58 units vs 52). The filmstrip says why: the background reappears *first*
|
||||
and the labels arrive after it, so what the luminance curve is timing is the
|
||||
incoming screen's **own element animations**, not the fade quad. Quoting 1.47 s
|
||||
as "the main menu's fade" would be wrong. The quad's ramp is decoded; the
|
||||
screen's build-in is a separate, longer thing.
|
||||
|
||||
## For the port
|
||||
|
||||
* a screen change is: **fade the outgoing screen to black over ~0.4 s**, hold
|
||||
black briefly, then **fade the incoming screen in over its own declared ramp**
|
||||
while its elements play their own keyframes;
|
||||
* the fade-in ramp is **read from the file** (`T1 − T0` on the screen's fade
|
||||
quad);
|
||||
* the ~0.4 s fade-out and the black hold are **authored from this page** — the
|
||||
disc does not carry them.
|
||||
|
||||
## ✅ Which quantity the ~0.4 s is — measured 2026-08-29
|
||||
|
||||
Asked by the port: is 0.4 s **(a)** the ramp from the hold to the exit pose, i.e.
|
||||
exactly the missing duration of that untimed keyframe, **(b)** several keyframes'
|
||||
worth, or **(c)** something the game does independently of the group?
|
||||
|
||||
**It is (a)** — and it is bigger than the fade quad. Two facts.
|
||||
|
||||
**1. There is exactly one untimed keyframe, and every element has it.** Reading
|
||||
`screen info --build 5 --geometry` for the main menu, all 16 elements end on a
|
||||
single timeless block; none has two. So there is one unknown duration per screen,
|
||||
not a chain of them — which rules out (b) outright. And that final block is not
|
||||
idle: it is where the screen *plays out*.
|
||||
|
||||
| elements | final untimed block | what it does |
|
||||
|---|---|---|
|
||||
| `pteff00.prm` (the fade quad) | `a = 255` | goes **black** |
|
||||
| `pteff10`, `pteff12`, `ptbtn01…05`, `ptmsg` | `a = 0` | **fade out** |
|
||||
| `ptframe1`, `ptframe2` | `a = 255` | hold, and get covered |
|
||||
| `ptbase`, `pteff05`, `ptloop*`, `pteff02.prm` | single keyframe | hold |
|
||||
|
||||
**2. The capture shows the content fading, not just a black quad arriving.** This
|
||||
has a null hypothesis that discriminates: under (c) — the game blackens the frame
|
||||
independently — every region is scaled by the same `1 − α`, so the **ratio**
|
||||
between a button region and a background region is *constant* through the
|
||||
fade-out. Under (a) it must fall, because the buttons ramp to `a = 0` while the
|
||||
background elements hold at 255 and are only dimmed.
|
||||
|
||||
Measured on [`transition-filmstrip.png`](captures/transitions/transition-filmstrip.png),
|
||||
button column ÷ upper-right background art, frame by frame through the fade-out:
|
||||
|
||||
```
|
||||
frame 0 1 2 3 4 5
|
||||
ratio 6.495 5.574 3.105 2.125 1.935 (black)
|
||||
```
|
||||
|
||||
**A 3.4× monotonic fall.** Constant is refuted. The buttons really are fading
|
||||
independently of the overall dim, exactly as their declared final block says.
|
||||
(The incoming screen runs it in reverse, 2.22 → 3.47 over frames 7–12.)
|
||||
|
||||
⚠️ **Reach.** The filmstrip is downsampled and the "button" region unavoidably
|
||||
contains some background, so the ratio is a direction, not a clean alpha
|
||||
measurement. It refutes the constant-ratio null decisively; it does not by itself
|
||||
pin the 0.4 s to ±0.05 s. And it is measured on **one** transition pair.
|
||||
|
||||
### For the port
|
||||
|
||||
Write **one** authored constant — the duration of the final untimed keyframe,
|
||||
~0.4 s / ~24 units — and **play the group to its end on every element**. Do not
|
||||
model the exit as a black rectangle fading over a frozen screen: the buttons and
|
||||
labels ramp to transparent at the same time, and that difference is visible.
|
||||
|
||||
@@ -146,7 +146,7 @@ the encoding: **1 = not yet deployed, 2 = active, 4 = destroyed**.
|
||||
### ✅ The mission-over branch, observed exactly as disassembled
|
||||
|
||||
The phase ended at 694.9 s, but **the ordinal did not advance** — and the reason
|
||||
is the branch [mission-phase-advance](../mission-phase-advance.md) read out of
|
||||
is the branch [mission-phase-advance](mission-phase-advance.md) read out of
|
||||
`sub_82260710`:
|
||||
|
||||
```
|
||||
|
||||
153
docs/re/structures/bgm-two-stems.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# A music bank is two stems of one performance, played together
|
||||
|
||||
**Status:** ✅ `CONFIRMED` for the structure — **decoded**, with a disc-wide check
|
||||
over all 32 BGM banks. 🟡 for the *role* of the second wave — **measured** by
|
||||
signal analysis, and the two readings that survive are named below. ❔ which bank
|
||||
is the menu's music is **undecodable from the disc**, with the reach stated.
|
||||
|
||||
Answers [MISSION Q10](../../port/MISSION.md). It also **withdraws the premise**: the
|
||||
handoff said `BGM_001.slb` is *three* sub-waves of 10 KB / 4.47 MB / 4.67 MB. The
|
||||
10 KB is the bank **header**, not a wave.
|
||||
|
||||
## The structure — decoded
|
||||
|
||||
```
|
||||
BGM_001.slb (9 178 040 B)
|
||||
0 BANK id=1001 data_size=9175992 hdr = 5 blocks (10240 B) 16bit/2ch
|
||||
10240 RIFF data@14336 size=4466688 (2181 packets) 25697 B/s → 173.82 s
|
||||
4481024 seek 2181 packets
|
||||
4491264 RIFF data@4495360 size=4673536 (2282 packets) 26887 B/s → 173.82 s
|
||||
9168896 seek 2282 packets
|
||||
```
|
||||
|
||||
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
|
||||
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).
|
||||
|
||||
### Disc-wide
|
||||
|
||||
`tools/re-capture/bgm_wave_census.py`, full output in
|
||||
[`../data/bgm-wave-census.txt`](../data/bgm-wave-census.txt):
|
||||
|
||||
> **28 of the 32 BGM entries** read straight off their own pak entry as two waves
|
||||
> of equal duration — equal to 0.01 s, over lengths from 37 s to 277 s.
|
||||
|
||||
The other four — `BGM_106`…`BGM_109` — are **not counterexamples**, they are the
|
||||
known leading-region straddle ([`slb-data-offset.md`](slb-data-offset.md)): those
|
||||
entry windows start mid-bank, so an entry contains the tail of the previous bank,
|
||||
one whole wave of its own, and the `BANK` header of the *next* one.
|
||||
`slb_segment_phase.py bank` shows it directly — the entry named `BGM_107.slb`
|
||||
contains `BANK id=1108`. Realigning across the boundaries with the `seek` packet
|
||||
counts, which pin each join exactly (bank 1107: a `seek` for 1164 packets and a
|
||||
2 383 872-byte wave = 1164 packets), restores the same shape:
|
||||
|
||||
| bank | wave 0 | wave 1 |
|
||||
|---|---|---|
|
||||
| 1107 | 2 383 872 B → 65.18 s | 2 418 688 B → 65.18 s |
|
||||
| 1108 | 3 696 640 B → 128.91 s | 3 696 640 B → 128.91 s |
|
||||
| 1109 | 2 887 680 B → 81.50 s | 2 932 736 B → 81.50 s |
|
||||
|
||||
Bank 1106's wave 0 begins in unclaimed bytes before its entry and is known only
|
||||
by its `seek` count (1 718 packets), so it is 🟡 rather than measured.
|
||||
|
||||
**So: intro + loop is dead, and so is two halves of one piece.** Both would give
|
||||
unequal durations; 32 banks give equal ones.
|
||||
|
||||
## The role of the second wave — measured
|
||||
|
||||
Decoded `BGM_001`'s two waves to PCM and compared them.
|
||||
|
||||
| | wave 0 | wave 1 |
|
||||
|---|---|---|
|
||||
| duration | 173.809 s | 173.809 s |
|
||||
| first sound / last sound | 0.001 s / **167.663 s** | 0.003 s / **167.663 s** |
|
||||
| RMS | 6 824 | 3 961 |
|
||||
| L↔R correlation | 0.61 | **0.14** |
|
||||
| energy below 200 Hz | 16.5 % | **6.7 %** |
|
||||
| energy 1–4 kHz | 31.1 % | 42.5 % |
|
||||
|
||||
**They are sample-synchronous.** Transient-envelope cross-correlation searched
|
||||
over ±5 s peaks at **lag +0.00 s** (0.5624), and both waves stop at the *same
|
||||
millisecond*, 167.663 s. Two takes, two halves or two alternates would not do
|
||||
that. They are two stems of one performance and are meant to sound **at the same
|
||||
time**.
|
||||
|
||||
**Therefore concatenating them is wrong**, and not subtly: it plays the piece
|
||||
twice, the second time as a bass-less secondary stem.
|
||||
|
||||
### 🟡 Which kind of second stem — two readings survive
|
||||
|
||||
* **The rear pair of a 4-channel mix.** Fits every number: quieter, far more
|
||||
decorrelated between L and R, and with the bass managed away to the front pair.
|
||||
* **A second intensity layer** to be mixed in or crossfaded.
|
||||
|
||||
**Not settled, and the obvious field does not settle it.** `ChannelMask` is
|
||||
`0x0002` on *both* waves, and [`sound-slb.md`](sound-slb.md) already records that
|
||||
this game writes meaningless channel metadata (movie voices declare 2 channels
|
||||
over mono content). What would settle it is a runtime observation — whether the
|
||||
game submits both waves to the mixer at once — which needs an emulator with audio
|
||||
this container does not have.
|
||||
|
||||
## ❔ Which bank is the menu's music — undecodable, and here is the reach
|
||||
|
||||
The cue table binds cue names to sound ids and banks
|
||||
([`sound-cue-table.md`](sound-cue-table.md)), and its **32 BGM cues are named
|
||||
`BGM_001` … `BGM_109`** — pure numbers, no `TITLE`, no `MENU`, no semantic name
|
||||
anywhere. Looked in: the `SOUNDS` record (5 798 cues), `FILES` (5 135 bank
|
||||
paths), and the bank headers themselves (a `BANK id` that simply repeats the cue
|
||||
number, `id=1001` for `BGM_001`). Nothing names a screen.
|
||||
|
||||
So the menu↔bank binding has to come from the running game — the same route Q8's
|
||||
event bindings need — and until then the port is **choosing** a track, not
|
||||
transcribing one.
|
||||
|
||||
## What the port needs to know
|
||||
|
||||
* a music bank is **two waves that play together**, not one track and not a
|
||||
sequence — do not concatenate;
|
||||
* both are full length; align them at sample 0;
|
||||
* **the track is not a seamless loop.** `BGM_001` fades out and is followed by
|
||||
**6.15 s of silence** (last sound 167.663 s of 173.809 s; the final second
|
||||
before silence is at RMS 168 against 4 788 at the head). Looping the wave as
|
||||
stored gives a fade-out and six seconds of nothing every cycle. No loop-point
|
||||
field has been identified in the XMA header, so a menu loop is **authored**.
|
||||
|
||||
## ✅ Confirmed at runtime — two stereo streams decode at once
|
||||
|
||||
The page above said the "played together" reading needed "a runtime observation
|
||||
of whether the game submits both waves to the mixer at once", and that this
|
||||
container could not do it. It can: `--xma_param_probe=true` logs every XMA stream
|
||||
the decoder is handed.
|
||||
|
||||
Sitting on the **main menu**, two stereo 48 kHz streams were decoding
|
||||
simultaneously — **1 893 packets / 3 876 864 B** and **1 919 packets /
|
||||
3 930 112 B**. Two stereo streams alive at the same moment on a screen with one
|
||||
piece of music playing is the direct observation this page was missing: **a music
|
||||
bank's two waves are simultaneous, not sequential.** Concatenating them is wrong,
|
||||
now measured as well as inferred.
|
||||
|
||||
🟡 It still does **not** separate the two readings — surround-rear pair versus a
|
||||
second intensity layer — because both predict simultaneity.
|
||||
|
||||
## ✅ The menu's music is `BGM_103` — three independent routes agree
|
||||
|
||||
An earlier version of this section said the two observed byte sizes "do not match
|
||||
any bank's declared wave sizes". **That was wrong** — I had checked only the
|
||||
`BGM_0xx` rows. They match `BGM_103` exactly, and the code names it:
|
||||
|
||||
| route | evidence |
|
||||
|---|---|
|
||||
| **static, code** | `GamePart_Title`'s phase handler `sub_821C5580` does `li r5, 1103` into a sound call (`sub_8217ACF8`, with `r4 = 4`) — **cue 1103 = `BGM_103`** |
|
||||
| **static, disc** | [`../data/bgm-wave-census.txt`](../data/bgm-wave-census.txt): `BGM_103.slb` = two waves of **3 876 864** and **3 930 112** bytes, 87.75 s each |
|
||||
| **runtime** | the XMA probe at the **main menu** saw two stereo 48 kHz streams decoding simultaneously, of **3 876 864** and **3 930 112** bytes |
|
||||
|
||||
Byte-for-byte on both waves. So:
|
||||
|
||||
* **which bank the menu plays is decoded** — `BGM_103`, from the executable, since
|
||||
the cue *table*'s BGM entries are numeric and name no screen;
|
||||
* and the two-stems-play-together finding is now confirmed **on a named bank**,
|
||||
with the runtime stream sizes equal to the bank's declared wave sizes — so the
|
||||
game hands the decoder the whole wave, not a window, which is the opposite of
|
||||
what the earlier note guessed.
|
||||
@@ -82,7 +82,7 @@ names had no text to resolve *to*, and the 100 % figure was unreachable.
|
||||
|
||||
* **Which recording plays.** The caption is the *text*; the voice bank binding
|
||||
is a separate and still-unresolved question — see
|
||||
[voice-bank-leading-region.md](voice-bank-leading-region.md) and the
|
||||
[voice-bank-leading-region.md](../voice-bank-leading-region.md) and the
|
||||
known case of a generic line playing against a specific subtitle.
|
||||
* **Which page a `MSG_DEMO_*` id belongs to.** The `DEMO` family is not called
|
||||
from the stage scripts at all — 78 of its ids are multi-page, so something
|
||||
|
||||
@@ -270,7 +270,7 @@ result of the two.
|
||||
## ✅ 2026-08-23 (third pass) — the counter is at `0xbdb59668` again, and the refutation above is *refined*, not reversed
|
||||
|
||||
Run 4, with the hunt automated end to end
|
||||
([`ob_hunt.py`](../../tools/re-capture/ob_hunt.py) + the HUD reader), produced
|
||||
([`ob_hunt.py`](../../../tools/re-capture/ob_hunt.py) + the HUD reader), produced
|
||||
**exactly one** surviving address:
|
||||
|
||||
```
|
||||
@@ -322,7 +322,7 @@ With the per-entity searches refuted at both word and bit level
|
||||
counter. It sits at `0xbdb59668`, inside the entity-heap window, so the answer is
|
||||
readable directly: sample ±0x200 around it across one transition and keep the
|
||||
words that move **with** it
|
||||
([`ob_neighbourhood.py`](../../tools/re-capture/ob_neighbourhood.py),
|
||||
([`ob_neighbourhood.py`](../../../tools/re-capture/ob_neighbourhood.py),
|
||||
[`captures/ob-counter-neighbourhood-stage02.json`](../captures/ob-counter-neighbourhood-stage02.json)).
|
||||
|
||||
Control first: over an 8-second interval while the counter sat still, **0 of the
|
||||
|
||||
47
docs/re/structures/movie-audio-channels.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# ✅ The disc ships movies in **two** audio profiles — and one of them is 5.1
|
||||
|
||||
**Status:** ✅ `DECODED`, disc-wide (97 of 97 movies probed). Found while checking
|
||||
a transcode, not while looking for it.
|
||||
|
||||
## The split
|
||||
|
||||
```
|
||||
$ ffprobe -select_streams a -show_entries stream=codec_name,sample_rate,channels,channel_layout
|
||||
```
|
||||
|
||||
| profile | count | codec | rate | channels | which |
|
||||
|---|---|---|---|---|---|
|
||||
| **surround** | **28** | `wmapro` | 48 kHz | **6 (5.1)** | `ADV.wmv` + every `S*.wmv` story cutscene |
|
||||
| stereo | 69 | `wmav2` | 48 kHz | 2 | every `RT*.wmv` and `hokyu_*.wmv` |
|
||||
|
||||
Full list in [`data/movie-audio-layouts.csv`](../data/movie-audio-layouts.csv).
|
||||
|
||||
The split is not arbitrary: the **cinematics** are 5.1 and the **in-mission radio
|
||||
chatter** is stereo. `ADV.wmv` (137.714 s) and `S00A.wmv` — the boot/attract intro
|
||||
and the new-game intro, i.e. **both movies the menu milestone needs** — are in the
|
||||
surround group.
|
||||
|
||||
## ⚠️ Why this matters to anything that transcodes
|
||||
|
||||
A single `ffmpeg` command over `dat/movie/` produces **two different kinds of
|
||||
result** and records neither:
|
||||
|
||||
* the 69 stereo files pass through channel-wise unchanged;
|
||||
* the 28 surround files are **downmixed 5.1 → stereo by ffmpeg's default matrix**,
|
||||
folding centre-channel dialogue into L/R at a weighting nobody chose.
|
||||
|
||||
That default is a *content* decision — it sets how loud the dialogue is against
|
||||
the music — and it is **not stable across ffmpeg versions**. A pipeline that wants
|
||||
a reproducible result has to state the downmix explicitly rather than inherit it,
|
||||
and record it beside the command.
|
||||
|
||||
🟡 Whether the game itself plays these in surround, and with what downmix when the
|
||||
console is set to stereo, is **not established here**. This page is about what is
|
||||
on the disc.
|
||||
|
||||
## How it was found, and the trap next to it
|
||||
|
||||
Checking a transcode against its source, `ffprobe` reported the output as 33 s
|
||||
against the source's 137 s — which reads as catastrophic truncation and is not:
|
||||
the file was **being written at that moment**. Measuring a live write produces a
|
||||
confident wrong answer with no error. See [`../METHOD.md`](../METHOD.md).
|
||||
141
docs/re/structures/ratc-child-names.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# A RATC child's name is stated by an `opt ` block, not by the bytes before it
|
||||
|
||||
**Status:** ✅ `DECODED`, with a disc-wide check. This fixes a decoder defect that
|
||||
silently dropped the **full-resolution background from all five menu screens**.
|
||||
|
||||
## The field
|
||||
|
||||
Immediately before nearly every child of a `RATC` bundle — 17 942 of the disc's
|
||||
18 002 — sits an `opt ` block:
|
||||
|
||||
```text
|
||||
"opt " | BE32 length | name | NUL | 3 bytes | <child magic>
|
||||
```
|
||||
|
||||
Two children of the main menu bundle (`GP_TITLE.pak` entry 8), raw:
|
||||
|
||||
```text
|
||||
opt 00 00 00 0a p t b a s e . t 3 2 00 0e 10 a4 T8aD
|
||||
opt 00 00 00 0b p t e f f 0 5 . t 3 2 00 38 41 58 T8aD
|
||||
^^^^^^^^
|
||||
"8AX"
|
||||
```
|
||||
|
||||
This is the **same `opt ` block** `ui_layout::opt_link` already decodes for a
|
||||
button's focus link. Nothing new had to be discovered to read it — only noticed.
|
||||
|
||||
## The defect it fixes
|
||||
|
||||
`ratc::parse` named each child with `name_before`: the last printable run in the
|
||||
96 bytes before the child's magic. That is a heuristic, and it is *usually* right
|
||||
— `ptbase.t32`'s three trailing bytes are `0e 10 a4`, not printable, so the scan
|
||||
walks back to the real name.
|
||||
|
||||
But `pteff05.t32`'s trailing three bytes are `38 41 58`, which is `"8AX"` in
|
||||
ASCII. The scan takes those, the child is registered under the name `8AX`, the
|
||||
element that declares `pteff05.t32` matches nothing in the sprite table, and
|
||||
`compose` drops it through
|
||||
|
||||
```rust
|
||||
let Some(sprite) = el.sprite.as_ref() else { continue };
|
||||
```
|
||||
|
||||
— *before* the arm that records a `missing` sprite. So the screen's background
|
||||
vanished with **no diagnostic at all**: `screen render` reported "all resolved".
|
||||
|
||||
⚠️ `8AX` was carried in `docs/` as though it were a name the game uses — the old
|
||||
text read "the `T8aD` behind its `opt ` link is registered under the name `8AX`".
|
||||
It is not a name. It is three bytes of the preceding record's payload.
|
||||
|
||||
## The disc-wide check
|
||||
|
||||
[`examples/ratc_child_names.rs`](../../../crates/sylpheed-formats/examples/ratc_child_names.rs)
|
||||
compares the two readings for every RATC child in all 33 `dat/*.pak`
|
||||
([data](../data/ratc-child-name-audit.txt)):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| RATC children scanned | **18 002** |
|
||||
| carrying an `opt ` block | 17 942 |
|
||||
| scanned name **agrees** with it | **17 918** |
|
||||
| scanned name **differs** | **24** |
|
||||
|
||||
Every one of the 24 is the same failure: a 3-byte printable tail beating a real
|
||||
name.
|
||||
|
||||
| scanned | actual | count | where |
|
||||
|---|---|---|---|
|
||||
| `8AX` | `pbbg.t32` | 12 | `GP_READY_ROOM` |
|
||||
| `8AX` | `pteff05.t32` | 4 | `GP_TITLE` 5, 6, 8, 9 — the menus |
|
||||
| `8AX` | `pmbase.t32` | 4 | `GP_STAGE_CLEAR` |
|
||||
| `8AX` | `pteff04.t32` | 2 | `GP_TITLE` 4, 7 — the title |
|
||||
| `'OX` | `po_keys_win1.t32` | 2 | `GP_OPTIONS` |
|
||||
|
||||
⚠️ **What I checked about the 24, precisely.** That the recovered name is the one
|
||||
the bundle actually wants is verified for the ten `GP_TITLE` and `GP_STAGE_CLEAR`
|
||||
cases: on the title screens `pteff04.t32`/`pteff05.t32` are *declared elements*
|
||||
that previously resolved to nothing and now resolve, and `pmbase.t32` is the
|
||||
target of `GP_STAGE_CLEAR`'s long-standing dangling reference (below). For the 12
|
||||
`GP_READY_ROOM` (`pbbg.t32`) and 2 `GP_OPTIONS` (`po_keys_win1.t32`) cases I
|
||||
checked only that no element in those bundles is left unresolved afterwards —
|
||||
which is consistent with, not proof of, the same story. None of the 14 is on the
|
||||
five menu screens.
|
||||
|
||||
**The control is the 17 918 the heuristic already got right**: the `opt ` reading
|
||||
reproduces every one of them. A reading that fixed the 24 but disturbed the rest
|
||||
would be a different rule, not this one.
|
||||
|
||||
✅ **Reach — closed 2026-08-29.** 60 children (0.3 %) have **no** `opt ` block
|
||||
within 128 bytes and fall back to the scan. **They are not children.** They are
|
||||
the ten frames of the disc's only `.tan` resource, `pb_f15_eg_anm.tan`, in the
|
||||
six language copies of one `GP_READY_ROOM` bundle — 6 × 10 = 60, the whole
|
||||
population with nothing left over. One `opt ` block names the whole run, which is
|
||||
why nine of the ten find no block of their own. None is on the five menu screens.
|
||||
[`ratc-tan-frame-sequence.md`](ratc-tan-frame-sequence.md)
|
||||
|
||||
## What it changes in the composite
|
||||
|
||||
Resolving the name makes the element resolve, so `compose` now draws it. On all
|
||||
five port screens ([before/after](../data/ratc-name-fix-render-effect.txt)):
|
||||
|
||||
| build | screen | mean brightness | high-frequency detail |
|
||||
|---|---|---|---|
|
||||
| 4 | title | 72.77 → 72.81 | **×1.15** |
|
||||
| 5 | main menu (JP) | 42.73 → 42.74 | **×1.27** |
|
||||
| 6 | `EXTRAS` (JP) | 44.08 → 44.09 | **×1.27** |
|
||||
| 8 | main menu | 41.57 → 41.58 | **×1.30** |
|
||||
| 9 | `EXTRAS` | 42.99 → 43.00 | **×1.29** |
|
||||
|
||||
The brightness is unmoved and the detail is up by a quarter — which is exactly
|
||||
the signature of *the same artwork at twice the resolution* replacing a 2×
|
||||
upscale, and not of new content appearing. That it *should* be the full-res art
|
||||
was settled separately and against the running game, in
|
||||
[`ui-8ax-fullres-background.md`](ui-8ax-fullres-background.md); this page only
|
||||
supplies the name that lets the renderer find it.
|
||||
|
||||
✅ **Nothing is covered.** The background is opaque and full-screen, and on the
|
||||
menu it paints 4th of 16, so the worry is real. Measured at each element's
|
||||
resting rect, before vs after: `ptbtn01` sd 59.98 → 60.03, `ptbtn03` 63.40 →
|
||||
63.45, `ptbtn05` 66.54 → 66.58, `ptframe1` 51.16 → 51.20, `ptmsg` 56.11 → 56.12.
|
||||
Everything survives; only the two `loop*` elements paint beneath it, and those
|
||||
are excluded from the default composite anyway.
|
||||
|
||||
⚠️ **Both backgrounds are now drawn** — `ptbase.t32` upscaled 2×, then the
|
||||
full-res one opaquely over it. Correct output, wasted fill. The port should draw
|
||||
only the full-res one, and take its *timing* from `ptbase`'s element, which is
|
||||
the one carrying the keyframes.
|
||||
|
||||
## What is NOT decoded
|
||||
|
||||
❔ **No declaration word points at the child.** Before reading the bytes I tested
|
||||
whether the 60-byte element declaration indexes the T8aD child table. Its unread
|
||||
words are `+28`, `+36`, `+44` and `+56`; the control is the resolved elements,
|
||||
whose child index is known. On the main menu, of 13 controls the words reproduce
|
||||
the child index **1, 0, 0 and 1** times — and both 1s are the trivial index-0
|
||||
case. There is no pointer; the association is by name, and the name is the `opt `
|
||||
string. [`decl_word_probe.rs`](../../../crates/sylpheed-formats/examples/decl_word_probe.rs)
|
||||
|
||||
✅ Incidental, from the same probe: **`+44` is a button ordinal.** It is `1…5` on
|
||||
exactly the five `ptbtn0N.rat` elements of the main menu, in order, and `−1` on
|
||||
every other element. Not needed for anything open, and recorded rather than
|
||||
chased.
|
||||
120
docs/re/structures/ratc-tan-frame-sequence.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# `.tan` — one name over a run of frames, and the 60 "nameless" children it explains
|
||||
|
||||
**Status:** ✅ `DECODED`, with a disc-wide check. This closes the 🟡 reach caveat
|
||||
left open by [`ratc-child-names.md`](ratc-child-names.md): *"60 children have no
|
||||
`opt ` block within 128 bytes and still fall back to the scan — whether they
|
||||
genuinely lack the block or sit past the search window is not established."*
|
||||
|
||||
**Neither.** They are not children. They are the **ten frames of a single `.tan`
|
||||
resource**, and the one `opt ` block that names the whole run sits up to 545 KB
|
||||
behind the last of them.
|
||||
|
||||
## What was measured
|
||||
|
||||
[`examples/ratc_optless_children.rs`](../../../crates/sylpheed-formats/examples/ratc_optless_children.rs)
|
||||
re-runs `ratc::parse`'s own guards over every child on the disc and reports, for
|
||||
each rejection, *which* guard fired and whether a tag exists further back
|
||||
([data](../data/ratc-tan-frame-sequence.txt)):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| RATC children scanned | 18 002 |
|
||||
| with no accepted `opt ` block | **60** |
|
||||
| of those, rejected by **length**, **gap** or **charset** | **0** |
|
||||
| of those, rejected because the only tag is **beyond the 128-byte window** | **60** |
|
||||
| of those, that are child #0 of their bundle | **0** |
|
||||
| archives involved | **1** — `GP_READY_ROOM.pak` |
|
||||
| bundles involved | **6** — entries 26, 30, 159, 160, 1029, 1050, each exactly 706 609 B |
|
||||
| children involved | **1…10 of each**, never 0 and never 11+ |
|
||||
|
||||
The distances are the tell. Within one bundle they are
|
||||
|
||||
```text
|
||||
213, 60 813, 121 413, 182 013, 242 613, 303 213, 363 813, 424 413, 485 013, 545 613
|
||||
```
|
||||
|
||||
— an exact arithmetic progression, step **60 600**. Ten different children all
|
||||
find the **same** `opt ` tag, because there is only one. Nine of them do not have
|
||||
a block that is merely far away; they have no block.
|
||||
|
||||
## What they are
|
||||
|
||||
Reading bundle 26 directly, without the Rust parser, the 15 "children" resolve:
|
||||
|
||||
| # | offset | kind | size | `opt ` at | name |
|
||||
|---|---|---|---|---|---|
|
||||
| 0 | `0x000444` | T8aD | 1 933 | −40 | `pbf15_energie_generator2.t32` |
|
||||
| **1…9** | `0x000bd1` … | T8aD | **60 600** each | −213 … −485 013 | **`pb_f15_eg_anm.tan`** |
|
||||
| **10** | `0x085e49` | T8aD | **60 632** | −545 613 | **`pb_f15_eg_anm.tan`** |
|
||||
| 11 | `0x094b21` | T8aD | 17 523 | −32 | `pbf15_pd_inside2.t32` |
|
||||
| 12 | `0x098f94` | T8aD | 75 068 | −35 | `pbenergie_generator.t32` |
|
||||
| 13 | `0x0ab4d0` | T8aD | 4 697 | −28 | `pbf15_eg_eff.t32` |
|
||||
| 14 | `0x0ac729` | RATC | 264 | −25 | `pb_s15_eg.rat` |
|
||||
|
||||
So the format is doing something perfectly ordinary that our scan had no concept
|
||||
of: **`.tan` is a frame sequence.** One `opt ` block declares the resource, and
|
||||
its payload is a run of equal-size `T8aD` blocks, one per frame. The name is on
|
||||
the disc and always was. What was missing was the idea that one name can cover
|
||||
more than one block.
|
||||
|
||||
`anm` in `pb_f15_eg_anm` is the authors' own abbreviation, and it agrees.
|
||||
|
||||
## The disc-wide check
|
||||
|
||||
Every `opt ` block in every RATC bundle in all 33 `dat/*.pak`, by the extension
|
||||
it names
|
||||
([`tools/re-capture/ratc_opt_name_census.py`](../../../tools/re-capture/ratc_opt_name_census.py)):
|
||||
|
||||
| extension | count | what it is |
|
||||
|---|---|---|
|
||||
| `.t32` | 14 756 | a `T8aD` sprite |
|
||||
| `.rat` | 3 311 | a nested RATC leaf |
|
||||
| `.prm` | 367 | a primitive |
|
||||
| `.tbm` | 224 | — |
|
||||
| `.sbo` | 54 | — |
|
||||
| **`.tan`** | **6** | **a frame sequence** |
|
||||
| | **18 718** | |
|
||||
|
||||
A RATC bundle names exactly six kinds of resource, and **`.tan` occurs six times
|
||||
on the whole disc** — all of them `pb_f15_eg_anm.tan`, one per language copy of
|
||||
the same bundle, each holding **10 frames**.
|
||||
|
||||
**6 × 10 = 60.** That is the entire population of opt-less children, with nothing
|
||||
left over. The negative is closed, not narrowed.
|
||||
|
||||
## ⚠️ What this says about `ratc::parse`
|
||||
|
||||
The child list **over-reports**. `parse` finds children by scanning for the four
|
||||
child magics, so a `.tan`'s ten frames are listed as ten anonymous children of the
|
||||
bundle rather than as one named resource with ten frames. The disc's "18 002
|
||||
children" is therefore 18 002 *magic-delimited blocks*, of which 60 are frames.
|
||||
|
||||
**Not changed here**, deliberately: nothing in the menu milestone reads a `.tan`,
|
||||
and a rewrite of the child model is a bigger change than the one fact it would
|
||||
buy. Recorded so that a later consumer of `.tan` knows the shape it needs.
|
||||
|
||||
## ❔ Not established
|
||||
|
||||
* **The frame timing.** Ten frames of the same size is a sequence; nothing here
|
||||
shows the rate, whether it loops, or whether the frames are equal-duration. No
|
||||
field was looked for.
|
||||
* **The pixel layout of a 60 600-byte frame.** They decode as `T8aD` like any
|
||||
other sprite as far as the magic goes; their dimensions were not read.
|
||||
* **What `.tbm` and `.sbo` are.** They surfaced from the same census and are
|
||||
recorded above as counts only.
|
||||
* **The two `opt ` totals do not reconcile exactly** and are not forced to.
|
||||
This census counts **18 718** blocks; the Rust audit in
|
||||
[`ratc-child-names.md`](ratc-child-names.md) counts **17 942** children *with* a
|
||||
block. They apply different guards — the Rust one additionally requires the
|
||||
named thing to be one of the four child magics and to follow within 8 bytes,
|
||||
which `.prm` / `.tbm` / `.sbo` (645 blocks) never satisfy. That accounts for
|
||||
most of the 776 difference but not all of it, and the remainder was not chased.
|
||||
Each number is reported as what its own script measured.
|
||||
|
||||
## Scope
|
||||
|
||||
`GP_READY_ROOM.pak` is **out of scope** for the menu milestone ([S1 is a
|
||||
no-go](../ready-room-probe.md)), and `.tan` occurs in no other archive. **None of
|
||||
the five menu screens contains a `.tan`**, so nothing the port draws changes.
|
||||
This closes a caveat on a decode the port *does* depend on, rather than adding a
|
||||
capability.
|
||||
@@ -40,7 +40,7 @@ resolves all twenty-four:
|
||||
| `stage\StageParameter_Test.tbl` | 1 — the developer stage |
|
||||
|
||||
**That is exactly the 24, and it explains 24 against the 29 stage records**
|
||||
([`challenge-mission-gate.md`](challenge-mission-gate.md) counts 29:
|
||||
([`challenge-mission-gate.md`](../challenge-mission-gate.md) counts 29:
|
||||
16 story + 6 tutorial + 6 challenge + `Test`): the six tutorials do not get one
|
||||
table each, they **share a single `_Tutorial` table**, and `_Test` accounts for
|
||||
the last. 16 + 6 + 1 + 1 = 24.
|
||||
|
||||
141
docs/re/structures/ui-8ax-fullres-background.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# ✅ `pteff04` / `pteff05` — the full-resolution background, once dropped as `8AX`
|
||||
|
||||
**Status:** ✅ **RESOLVED 2026-08-29.** This page's two questions are both closed
|
||||
and it is kept for the evidence, not as an open item.
|
||||
|
||||
* *Which of the two backgrounds does the game draw?* — the **full-resolution**
|
||||
one. Measured against a capture; the section below stands unchanged.
|
||||
* *Why did ours drop it?* — because `8AX` **is not a name.** It is three bytes of
|
||||
the preceding record's payload (`38 41 58`) that happen to be printable ASCII,
|
||||
and our backwards printable-run scan preferred them to the name the format
|
||||
states in its `opt ` block. Decoded, with a disc-wide check, in
|
||||
[`ratc-child-names.md`](ratc-child-names.md); `ratc::parse` now reads the
|
||||
stated name and `screen render` draws the background on all five screens.
|
||||
|
||||
⚠️ **Read the rest of this page with that correction in mind.** It was written
|
||||
while `8AX` was believed to be a name the game uses, and says so in several
|
||||
places — "the texture is registered as `8AX`", "the `T8aD` behind its `opt ` link
|
||||
is registered under the name `8AX`". The `opt ` link was never the problem; the
|
||||
`opt ` block was the answer, sitting unread three bytes away.
|
||||
|
||||
<sub>Original status line: 🟡 a real name-resolution gap, currently harmless to
|
||||
look at, and the obvious fix would make the render *worse*. Found by auditing
|
||||
what `screen render` silently omits on the port's five screens.</sub>
|
||||
|
||||
## What `screen render` drops, and why
|
||||
|
||||
`sylpheed-cli screen render` reports its omissions. On the five screens the port
|
||||
needs, every one is accounted for except two:
|
||||
|
||||
| screen | not drawn | reason |
|
||||
|---|---|---|
|
||||
| title (4) | `ptlogo1`×2, `ptlogo2`×2 | `kind & 0x4` ghost instances — the draw capture shows one quad per wordmark |
|
||||
| | `pteff00.prm`, `pteff02.prm` | `.prm` primitives, off without `--primitives` |
|
||||
| | `ptloop01.rat`, `ptloop02.rat` | `loop*` animations, off without `--animated` |
|
||||
| | **`pteff04.t32`** | ⚠️ **unexplained — this page** |
|
||||
| main menu (5), `EXTRAS` (6) | as above, plus **`pteff05.t32`** | ⚠️ same |
|
||||
| splash (10, 11) | `palogo_eff0.prm` | primitive |
|
||||
|
||||
`pteff04`/`pteff05` are `kind = 0x0`, one keyframe, rest **`a = 255`**, and pivot
|
||||
`(640,360)` — i.e. a **full-screen, fully opaque** element. Exactly the shape of
|
||||
the defect the port agent reported (an element the game draws that we omit).
|
||||
|
||||
## The cause: the element's name and its texture's name differ
|
||||
|
||||
Both names occur twice per bundle, the same as `pteff03.t32`, which resolves
|
||||
fine. The difference is what follows the `opt ` link:
|
||||
|
||||
```
|
||||
pteff03.t32 @0x4661a4 opt → T8aD ← resolves
|
||||
pteff05.t32 @0x0e2035 opt → 8AX T8aD ← the sprite is named 8AX
|
||||
```
|
||||
|
||||
Hex at `0x0e2035`:
|
||||
|
||||
```
|
||||
opt .... 70 74 65 66 66 30 35 2e 74 33 32 00 38 41 58 54 38 61 44
|
||||
p t e f f 0 5 . t 3 2 \0 8 A X T 8 a D
|
||||
```
|
||||
|
||||
So the declaration says `pteff05.t32` while the `T8aD` that follows is registered
|
||||
under **`8AX`**. `compose` does `build.sprites.get(sprite)` and, on a miss, hits a
|
||||
silent `continue`. `8AX` is a **1280×720** texture and appears in all six
|
||||
title-family bundles (`a60fcb85`, `a715f485`, `a81c1d85` and the three JP twins);
|
||||
it is a *sprite* in builds 4/5/6 and never an element.
|
||||
|
||||
## ✅ Why it does not currently show
|
||||
|
||||
The same artwork is already on screen. `ptbase.t32` is **640×360** and its
|
||||
element draws at **200 %**, and the background our render produces is
|
||||
pixel-identical to `8AX` in every patch sampled:
|
||||
|
||||
| patch | capture | our render | `8AX` |
|
||||
|---|---|---|---|
|
||||
| top-left | 4.5, 9.4, 19.5 | **17.7, 29.7, 46.7** | **17.7, 29.7, 46.7** |
|
||||
| top-right | 4.4, 9.0, 18.2 | 17.2, 28.2, 43.8 | 17.1, 28.2, 43.8 |
|
||||
| bottom-left | 3.5, 6.0, 11.1 | 13.6, 20.5, 31.2 | 13.6, 20.5, 31.2 |
|
||||
|
||||
Directly: `ptbase` upscaled 2× against `8AX` is **mean abs diff 2.05** (max 80),
|
||||
not byte-identical. They are **the same art at two resolutions** — `8AX` is the
|
||||
full-res copy, `ptbase` the half-res one the element scales back up.
|
||||
|
||||
## ⚠️ So do not "fix" this by drawing it
|
||||
|
||||
Resolving `pteff05 → 8AX` and drawing it *in addition to* `ptbase` would
|
||||
**double-draw the background** — an opaque full-screen layer over an identical
|
||||
one. It would not be visible as a doubling, which is worse: it would silently
|
||||
cost fill and hide any future change to either layer.
|
||||
|
||||
✅ **And that is now the recommendation, not just an option:** use `8AX` at 1:1.
|
||||
The detail test above says the game's background carries 8AX's full-resolution
|
||||
detail, so upscaling the 640×360 `ptbase` 2× is *wrong*, not merely softer.
|
||||
⚠️ Still do not draw **both** — an opaque full-screen layer over an identical one
|
||||
costs fill and hides any later change to either. `ptbase`'s element carries the
|
||||
keyframes, so a consumer needs its timing with `8AX`'s pixels.
|
||||
|
||||
## ✅ Settled statically: the game draws the FULL-RES `8AX`
|
||||
|
||||
This was parked as "needs a per-draw capture". It did not — the two candidates
|
||||
carry the same art at two resolutions, so what separates them is **the detail
|
||||
`8AX` has that an upscale cannot**. Compute `8AX − upscale(ptbase)` and ask
|
||||
whether the live capture contains it.
|
||||
[`tools/re-capture/eightax_detail_test.py`](../../../tools/re-capture/eightax_detail_test.py),
|
||||
output at [`data/eightax-detail-test.txt`](../data/eightax-detail-test.txt).
|
||||
|
||||
Both candidates are first mapped into the capture's tone domain with the measured
|
||||
gamma ([tone curve](ui-render-tone-curve.md)); without that the capture's
|
||||
residual is dominated by the tone difference and the test is blind.
|
||||
|
||||
| screen | corr | control (shift 7 px) | control (flip) | ceiling | % of ceiling |
|
||||
|---|---|---|---|---|---|
|
||||
| main menu | **+0.0475** | +0.0032 | −0.0075 | 0.070 | **68 %** |
|
||||
| title | **+0.0634** | +0.0095 | +0.0086 | 0.094 | **68 %** |
|
||||
|
||||
The "ceiling" is what a clean *"8AX is drawn"* would give, `sd(8AX-only detail) /
|
||||
sd(capture residual)` — the 8AX-only detail is small (sd 1.9–4.1) against
|
||||
everything else that differs (sd 27–43), which is why the absolute correlation is
|
||||
small and why the controls matter more than the magnitude.
|
||||
|
||||
**Two independent screens both land at 68 % of ceiling, 7–15× their matched
|
||||
controls.** The controls preserve the spatial correlation structure and destroy
|
||||
only the alignment, so they are what "no signal" looks like here.
|
||||
|
||||
### 🔴 An earlier, cruder version of this test does NOT support the conclusion
|
||||
|
||||
A pixel-pair test (a 2×-nearest upscale has identical adjacent columns) gave
|
||||
ratio 0.00 for nearest, 0.60–0.72 for bilinear/bicubic/lanczos, 0.98 for native
|
||||
`8AX`, and **1.01 for the capture** — apparently decisive. It is not: additive
|
||||
noise raises both terms equally and pushes *any* ratio toward 1. Fitting a noise
|
||||
term, both "native + noise ≈ 1.2" and "bilinear upscale + noise ≈ 1.8" reproduce
|
||||
the observed numbers. Recorded because the number looks conclusive and is not.
|
||||
|
||||
### ⚠️ What it still does not show
|
||||
|
||||
Whether `ptbase` is **also** drawn underneath. `8AX` is ~86 % opaque and carries
|
||||
the same art, so it would hide it either way. The observable fact is that 8AX's
|
||||
detail reaches the screen.
|
||||
|
||||
## ❔ Not established
|
||||
* The capture is ~4× darker than the render in these patches (4.5 vs 17.7), and
|
||||
not by a constant ratio. That is a separate colour/gamma question, untouched
|
||||
here.
|
||||
203
docs/re/structures/ui-button-focus-record.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# ✅ A focused button is a **two-element record**, and the second element is the ring
|
||||
|
||||
**Status:** 🟡 `DECODED for GP_TITLE`, disc-wide check **not yet run**; the
|
||||
sprite contents and the ring are ✅ `MEASURED` against a live capture. Answers the
|
||||
port's *"focus: drawn over the base element, or instead of it?"*
|
||||
|
||||
⚠️ The element-count field at `+20` is read here on **`GP_TITLE`'s ten button
|
||||
records only** (five buttons × two language bundles). A disc-wide check —
|
||||
"does `+20` equal the number of resource names the leaf embeds, for every `.rat`
|
||||
leaf on the disc?" — is written and was still running when this was committed.
|
||||
Until it lands, treat `+20` as a **strong local reading, not a decoded field**:
|
||||
the *observable* claim below (the focus record carries a second sprite,
|
||||
`ptbtneff01.t32`, and the port must draw it) rests on the embedded names and the
|
||||
capture, not on that word.
|
||||
|
||||
**Short answer: it does not matter, and that is not the bug.** The focused sprite
|
||||
covers the base completely, so over-vs-instead is worth at most 12/255 on ~25
|
||||
pixels. What *is* missing is the focus record's **second element** — a glowing
|
||||
ring that only exists when focused.
|
||||
|
||||
## The record
|
||||
|
||||
A button's base and focused variants are both `.rat` leaves, and they are not the
|
||||
same shape:
|
||||
|
||||
```text
|
||||
ptbtn02.rat 164 B strings: RATC, ptbtn02.t32, opt ptbtn02f.rat
|
||||
ptbtn02f.rat 312 B strings: RATC, ptbtneff01.t32, ptbtn02f.t32, opt ptbtn02b.rat
|
||||
```
|
||||
|
||||
The header says so explicitly. A `.rat` leaf begins:
|
||||
|
||||
```text
|
||||
+0 "RATC"
|
||||
+16 u32 flags 0x00008110 (base) 0x00008112 (focused)
|
||||
+20 u32 ELEMENT COUNT 1 2
|
||||
+32 first element name
|
||||
```
|
||||
|
||||
So the focused record declares **two** elements — `ptbtneff01.t32` first, then
|
||||
`ptbtn0Nf.t32` — where the base declares one. Both `f` records of both language
|
||||
bundles read the same way, and the flag word differs only in bit `0x02`
|
||||
alongside the count.
|
||||
|
||||
⚠️ The `opt ` link on a `.rat` leaf is **not** simply "my focused variant". The
|
||||
chain runs `ptbtn01.rat → ptbtn01f.rat → ptbtn02.rat → ptbtn02f.rat →
|
||||
ptbtn02b.rat`, i.e. it threads base and focused records together in order. The
|
||||
corpus calls it the focus link and for a *base* record that reading works; do not
|
||||
generalise it to the `f` records.
|
||||
|
||||
## The three sprites
|
||||
|
||||
Decoded with `sylpheed-cli pak textures`, shown over a checkerboard in
|
||||
[`button-base-focus-ring-sprites.png`](../captures/ui-layout/button-base-focus-ring-sprites.png):
|
||||
|
||||
| sprite | size | what it is |
|
||||
|---|---|---|
|
||||
| `ptbtn02.t32` | 117×43 | the **dim** label, its underline, and a small dot-in-circle at the underline's left end |
|
||||
| `ptbtn02f.t32` | 130×56 | the **same label, bright and glowing** — a complete replacement, 13 px larger in each axis |
|
||||
| `ptbtneff01.t32` | 42×46 | a **glowing ring**, focus only |
|
||||
|
||||
The small dot-in-circle is on **every** button all the time; the large ring is
|
||||
the focus marker. They are different things and sit side by side on the focused
|
||||
row — visible in
|
||||
[`live-main-menu-options-focused.png`](../captures/title-builds/live-main-menu-options-focused.png)
|
||||
against
|
||||
[`live-main-menu.png`](../captures/title-builds/live-main-menu.png).
|
||||
|
||||
## Over or instead? Measured, and the answer is "unobservable"
|
||||
|
||||
Aligning base and focused by normalised cross-correlation of their alpha masks —
|
||||
the true offset is **(7,7)**, not the (6,6) that centring predicts —
|
||||
|
||||
| pair | base-visible px | `f` alpha ≥ base alpha there |
|
||||
|---|---|---|
|
||||
| `ptbtn02` / `f` (bundle `b58a0fe6`) | 1 444 | **100.0 %** |
|
||||
| `ptbtn01` / `f` (bundle `b58a0fe6`) | 1 898 | **100.0 %** |
|
||||
| `ptbtn02` / `f` (bundle `a715f485`) | 3 720 | **100.0 %** |
|
||||
|
||||
⚠️ **At the centre alignment it reads 78–84 %, and that number is an artefact.**
|
||||
A 1 px shift on strokes this thin manufactures a fifth of a sprite's worth of
|
||||
"the focused art is thinner here". Solve the alignment before trusting a coverage
|
||||
figure.
|
||||
|
||||
Coverage is not the same as hiding, though, because `f` is not fully opaque
|
||||
everywhere. Compositing both ways over the menu's own background colour:
|
||||
|
||||
| pair | max channel difference | px > 8/255 | RMSE over the button rect |
|
||||
|---|---|---|---|
|
||||
| `ptbtn02` | 12.5 | 23 | 1.09 |
|
||||
| `ptbtn01` | 12.2 | 27 | 1.08 |
|
||||
| `ptbtn02` (JP) | 12.2 | 27 | 1.05 |
|
||||
|
||||
So the two hypotheses differ by **~1.1 RMSE inside the button rectangle**, on a
|
||||
couple of dozen pixels — below the ≈ γ 1.4 tone gap
|
||||
([tone curve](ui-render-tone-curve.md)) and far below any frame-level RMSE this
|
||||
corpus can resolve. **Either choice is defensible; neither is measurable.**
|
||||
Replacing is the cheaper one and is what the file's structure suggests, since
|
||||
`ptbtn0Nf.t32` is a whole label rather than an overlay.
|
||||
|
||||
## ✅ Where the ring is placed — DECODED 2026-08-29, no authoring needed
|
||||
|
||||
This page previously said the per-element placement inside a `.rat` leaf was not
|
||||
decoded and that a consumer should eyeball it off a capture. **That was wrong by
|
||||
omission**: a leaf needs no new reader. Its first 32 bytes have the same shape as
|
||||
a bundle header — `"RATC"`, `0x3c` declaration-entry size at `+4`, element count
|
||||
at `+20`, design size `1280x720` at `+24`/`+28` — so `ui_layout::parse_build`
|
||||
reads it **unchanged**.
|
||||
|
||||
**The control is the base record**, whose position is known independently: the
|
||||
parent screen reports `ptbtn01.rat` resting at `(542,162)`, and parsing the leaf
|
||||
on its own returns `ptbtn01.t32` at `(542,162)`. It reproduces all five.
|
||||
|
||||
Positions are **absolute design-space top-left**, not offsets
|
||||
([`examples/rat_leaf_placement.rs`](../../../crates/sylpheed-formats/examples/rat_leaf_placement.rs)):
|
||||
|
||||
| button | base | ring `ptbtneff01.t32` | Δ | label `ptbtn0Nf.t32` | Δ |
|
||||
|---|---|---|---|---|---|
|
||||
| `ptbtn01` | (542,162) | **(500,156)** | (−42,−6) | (535,155) | (−7,−7) |
|
||||
| `ptbtn02` | (542,242) | **(500,236)** | (−42,−6) | (535,235) | (−7,−7) |
|
||||
| `ptbtn03` | (542,322) | **(500,316)** | (−42,−6) | (535,315) | (−7,−7) |
|
||||
| `ptbtn04` | (542,402) | **(500,396)** | (−42,−6) | (535,395) | (−7,−7) |
|
||||
| `ptbtn05` | (542,482) | **(500,476)** | (−42,−6) | (535,475) | (−7,−7) |
|
||||
|
||||
The offset is **uniform**: `(−42,−6)` for the ring and `(−7,−7)` for the label on
|
||||
every button, and identical in the Japanese bundle (pak entry 8).
|
||||
|
||||
⚠️ **Which placement wins — and an earlier version of this page was misleading.**
|
||||
It said "the parent is what `compose` honours; treat the leaf's as the source only
|
||||
for elements the parent does not declare". That is right for a **base** record and
|
||||
wrong for an **`f`** record, because *the parent declares no element for
|
||||
`ptbtn0Nf.rat` at all* — checked, zero of build 5's 16 elements name it. So the
|
||||
`f` record's placement can only come from its own leaf, for **both** its elements,
|
||||
the bright label included.
|
||||
|
||||
The `(−7,−7)` on the label is real and load-bearing: `ptbtn0Nf.t32` is 13 px
|
||||
larger in each axis than the base, and −7 keeps the two **concentric**
|
||||
(`535 + 96/2 = 583` against `542 + 83/2 = 583.5`). Drawing the `f` label at the
|
||||
base element's position would push it 7 px down-right.
|
||||
|
||||
✅ **Checked against the oracle.** Differencing the `OPTIONS`-focused capture
|
||||
against the unfocused one, the changed region is x **505…703**, y **397…446**.
|
||||
The leaf predicts ink starting inside the ring's box at x ≥ 500 (measured 505, a
|
||||
5 px art inset) and the label's right edge near 707 (measured 703); the
|
||||
parent-position reading predicts 714. Both the right edge and the bottom edge
|
||||
favour the leaf by ~7 px. ⚠️ Ink-inset reasoning is soft — the decisive argument
|
||||
is the structural one above, that there is no parent element to inherit from.
|
||||
|
||||
The one thing that *is* duplicated is the **base** record: `ptbtn04`'s parent
|
||||
element rests at y **401** while its own leaf says **402** (and in the JP bundle
|
||||
the leaf says 401 against a parent 401). For a base record the parent is what
|
||||
`compose` honours.
|
||||
|
||||
## ✅ The ring SPINS — and the oracle confirms the game draws the rotation
|
||||
|
||||
The ring's two keyframes differ in exactly one field:
|
||||
|
||||
```text
|
||||
ptbtneff01.t32 kf0 t=120 pos=(500,156) scale=100%,100% a=255 rot=0
|
||||
kf1 t=None pos=(500,156) scale=100%,100% a=255 rot=360
|
||||
```
|
||||
|
||||
Position, scale, alpha and tint are all constant; only `rotation_deg` ramps,
|
||||
**0 → 360**. That is a spin in place — the same shape as the `GP_BUNK` example
|
||||
already recorded in [`ui-keyframe-rotation.md`](ui-keyframe-rotation.md).
|
||||
|
||||
✅ **And it is actually rendered.** In the oracle's `OPTIONS`-focused frame the
|
||||
ring's bright head sits in a completely different angular position from the
|
||||
sprite's own — the game caught it mid-spin:
|
||||
[`focus-ring-oracle-vs-sprite.png`](../captures/ui-layout/focus-ring-oracle-vs-sprite.png)
|
||||
(left: the oracle at the ring's declared box; right: the sprite, unrotated).
|
||||
|
||||
🔴 **Do not quote an angle from this.** A brightest-region centroid puts the
|
||||
displacement near 250°, but the **control refuses it**: rotating the sprite by a
|
||||
known 30/90/180/270° and re-measuring gives errors up to **19.8°**, so the
|
||||
estimator is not trustworthy at that precision. What survives is the part the
|
||||
error bar cannot touch — a ≤20° error cannot manufacture a ~250° displacement, so
|
||||
**the ring is drawn substantially rotated**, and the exact angle is one frame of a
|
||||
continuous spin and not a stable quantity anyway.
|
||||
|
||||
⚠️ **This raises rotation's priority for a port.** It is not a title-only concern
|
||||
that sits off-screen at rest: the main menu's focus marker spins, so a renderer
|
||||
that ignores `rotation_deg` draws a static ring with its highlight in the wrong
|
||||
place, on the screen the player looks at most.
|
||||
|
||||
🟡 The spin's **period** is not established. `t=120` is the first keyframe's time,
|
||||
and what the untimed second one means for a *leaf* — as opposed to a screen, where
|
||||
it is the ~0.4 s exit ramp — was not tested.
|
||||
|
||||
## ⚠️ `screen render --focus` is blind to the ring, and so was this page
|
||||
|
||||
`el.focused` is name-based on **top-level** elements, and a screen's buttons are
|
||||
`.rat` records whose focused twin is not itself a top-level element — so
|
||||
rendering build 5 with and without `--focus` produces an identical image. The
|
||||
reference renderer has the same blind spot the port reported, for the same
|
||||
reason: neither walks into the leaf. Fixing it is a renderer change, not a
|
||||
format question; the format is decoded above.
|
||||
|
||||
## ❔ Not established
|
||||
* **`ptbtn02b.t32`** — a third variant, `b`, exists for button 02 only, same size
|
||||
as the base. Not seen on any capture. Not chased.
|
||||
* Whether a **non-title** archive uses the same two-element convention. Checked
|
||||
on `GP_TITLE` only.
|
||||