Compare commits
104 Commits
auto/re-ke
...
auto/re-ti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -404,6 +479,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 +507,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;
|
||||
@@ -539,6 +628,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 +903,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 {
|
||||
@@ -919,8 +1019,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 +1080,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 +1143,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 +1199,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 +1315,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 +1352,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],
|
||||
|
||||
@@ -27,26 +27,386 @@ 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.
|
||||
* **`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)
|
||||
|
||||
* 🟡 **`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 — `8AX` is
|
||||
~86 % opaque and would hide it either way.
|
||||
[`structures/ui-8ax-fullres-background.md`](../re/structures/ui-8ax-fullres-background.md)
|
||||
|
||||
* ✅ **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 +414,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 +444,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,138 @@ 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.
|
||||
|
||||
## Known unknowns — say so, do not fill them in
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ 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** |
|
||||
| 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 |
|
||||
@@ -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,724 @@ 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.
|
||||
|
||||
@@ -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,232 @@ 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)
|
||||
|
||||
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.
|
||||
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-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/extras-composited.png
Normal file
|
After Width: | Height: | Size: 439 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)
|
||||
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)]
|
||||
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
|
||||
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.
|
||||
117
docs/re/screen-transitions.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
123
docs/re/structures/ui-8ax-fullres-background.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 🟡 `pteff04` / `pteff05` are dropped — the texture is registered as `8AX`
|
||||
|
||||
**Status:** 🟡 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.
|
||||
|
||||
## 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.
|
||||
212
docs/re/structures/ui-group-start-time.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# ❔ A group's DURATION is in the data; its START TIME is not
|
||||
|
||||
**Status:** ✅ the durations are measured and match; ❔ the start offset is
|
||||
**undecodable from the placement data**, with the obvious candidate refuted.
|
||||
|
||||
Found while testing whether the splash timeline, *played*, reproduces the
|
||||
capture sequence — the last gap in the animation model.
|
||||
|
||||
## The durations are right
|
||||
|
||||
From the 235-frame draw capture of the developer splash, at the settled
|
||||
2 units/frame, under the shifted time reading:
|
||||
|
||||
| element | drawn on frames | = units | declared visible span | match |
|
||||
|---|---|---|---|---|
|
||||
| `palogo_gamearts_eff`, `palogo_seta_eff` | 94–115 (22) | 44 | ~0 … 45 = **45** | **97.8 %** |
|
||||
| `palogo_gamearts`, `palogo_seta` | 116–211 (96) | 192 | 15 … 210 = **195** | **98.5 %** |
|
||||
|
||||
(The logos are `a = 0` until t=15, so they are *drawn* for 15…210; the glows fade
|
||||
in from their untimed first keyframe.) **Each element is on screen for its
|
||||
declared span, to within 2 %.**
|
||||
|
||||
## 🔴 But they do not share a clock origin
|
||||
|
||||
Every glow declares the same times — `15, 30, 45` — and every logo the same
|
||||
`15, 30, 190, 194, 206, 210`. On one clock they would overlap almost entirely:
|
||||
both families start at 15.
|
||||
|
||||
**They do not overlap at all.** The glows run frames **94–115** and the logos
|
||||
**116–211** — strictly sequential, the logos starting the frame after the glows
|
||||
end. Fitting a single origin bears this out: `gamearts_eff` needs f₀ ≈ 93.5 and
|
||||
`gamearts` needs f₀ ≈ 103, about **19 units apart**, and the same edit that
|
||||
aligns one throws the other off by ~9 frames at both ends.
|
||||
|
||||
## 🔴 The obvious candidate is refuted
|
||||
|
||||
`parse_placements` reads each group header as `(element index, keyframe count)`
|
||||
followed by **one undecoded lead-in word**. That is exactly where a per-group
|
||||
start offset would live. It is **`0x00000000` for all seven elements** of the
|
||||
developer splash — glows and logos alike. It carries no offset.
|
||||
|
||||
## ❔ So where does the start come from?
|
||||
|
||||
Not from the keyframe times (identical across each family), not from the group
|
||||
header's spare word (zero), and not from declaration order (`eff0.prm`, gamearts,
|
||||
gamearts_eff, seta, seta_eff, anima, anima_eff — which interleaves logos and
|
||||
glows, while the observed sequencing groups them). The RATC child order does not
|
||||
match the observed order either.
|
||||
|
||||
Reach: three places looked, all negative. What remains is that the sequencing is
|
||||
**driven by code**, consistent with what
|
||||
[boot-config-and-gamepart-registry](../boot-config-and-gamepart-registry.md)
|
||||
already concluded for the boot order — *the driver is code, not data*.
|
||||
|
||||
## ⚠️ What a port needs to know
|
||||
|
||||
A group tells you **how long** an element's animation runs and what it does; it
|
||||
does **not** tell you **when** that animation starts relative to other elements
|
||||
on the same screen. On the developer splash the observed order is: both glows
|
||||
together, then both logos together. That is a **measured** sequence for one
|
||||
screen, not a decoded rule — a port animating a screen has to author the
|
||||
sequencing, and should know it is authoring.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Verified against its own refutation — and it goes further than start times
|
||||
|
||||
**2026-08-29.** Two checks on the "sequential, not simultaneous" reading.
|
||||
|
||||
### The phases really are disjoint
|
||||
|
||||
If the glows and logos ever shared a frame, the sequencing claim would be wrong.
|
||||
Across all 235 captured frames, the number of frames containing **both** a glow
|
||||
and a logo is **zero**. The switch is a single clean boundary:
|
||||
|
||||
```
|
||||
f110–115 1280x720 + 262x108 + 525x90 ← both glows
|
||||
f116–122 1280x720 + 243x86 + 499x72 ← both logos
|
||||
```
|
||||
|
||||
Two sprites either side, no overlap, no transition frame.
|
||||
|
||||
### A third of the bundle is never drawn at all
|
||||
|
||||
Entry 11 declares **three** logo/glow pairs. Only two are ever drawn:
|
||||
|
||||
| sprite | declared | drawn |
|
||||
|---|---|---|
|
||||
| `palogo_gamearts` / `_eff` | 500×71 / 521×91 | **95 / 22 frames** |
|
||||
| `palogo_seta` / `_eff` | 240×89 / 261×110 | **95 / 22 frames** |
|
||||
| `palogo_anima` / `_eff` | 388×136 / 407×156 | **never** |
|
||||
|
||||
…and `palogo_anima` declares the *same* keyframe times as `palogo_gamearts`.
|
||||
⚠️ Reach: the capture covers frames 1–214, so this is "never **within the
|
||||
window**", not "never". The pair may follow after it.
|
||||
|
||||
### What that means for a consumer
|
||||
|
||||
**A bundle is a palette, not a script.** Its elements carry what to draw and for
|
||||
how long; which of them run, and when each starts, is decided outside the
|
||||
placement data — selectively enough that two elements with byte-identical
|
||||
keyframe times get 95 frames and 0 frames in the same run.
|
||||
|
||||
That is the same conclusion the boot-order work reached from the other end
|
||||
(*the driver is code, not data*), now with a per-element measurement behind it.
|
||||
|
||||
⚠️ Concretely: **compositing every element of a bundle does not reproduce what
|
||||
the game shows over time.** It is the right thing for a static screen that
|
||||
settles, and it is not a timeline.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Narrowing "a bundle is a palette" — the mechanism is NOT established
|
||||
|
||||
**2026-08-29.** The glow phase and the logo phase are disjoint, but there are two
|
||||
explanations and I asserted one of them:
|
||||
|
||||
1. **one bundle, selective activation** — the game runs some of entry 11's
|
||||
elements, then others;
|
||||
2. **two bundle-loads in sequence** — the game shows one composition, then
|
||||
another (entries 11 and 14 are twins declaring identical sprites).
|
||||
|
||||
The draw log records each draw's **texture base**, which looked like it would
|
||||
separate them. It does not:
|
||||
|
||||
| phase | frames | texture bases |
|
||||
|---|---|---|
|
||||
| publisher splash | 1–90 | `0x11C30000`, `0x10000000` |
|
||||
| glows | 94–115 | `0x11C30000`, `0x10000000` |
|
||||
| logos | 116–211 | `0x11C30000`, `0x10000000` |
|
||||
|
||||
**The test fails its own control.** The publisher splash is certainly a different
|
||||
bundle from the developer splash — different entry, different artwork — and it
|
||||
uses the *same* base. So `0x11C30000` is a reused upload address, not a bundle
|
||||
identity, and it cannot distinguish hypothesis 1 from hypothesis 2.
|
||||
|
||||
### What survives, and what I withdraw
|
||||
|
||||
✅ **Survives:** a bundle's declared elements are not what gets drawn.
|
||||
`palogo_anima` and `palogo_gamearts` carry byte-identical keyframe times, and in
|
||||
the same run one is drawn for 95 frames and the other for none — whichever of the
|
||||
twins was active declares *both*. The phases are also strictly disjoint (0 frames
|
||||
of overlap in 235).
|
||||
|
||||
🔴 **Withdrawn:** the mechanism. "One bundle, elements activated selectively" was
|
||||
my reading, and the evidence does not choose it over "two compositions shown in
|
||||
sequence". The practical consequence is the same either way — compositing every
|
||||
element of a bundle does not reproduce what the game shows over time — but the
|
||||
*why* is not established and should not have been stated as though it were.
|
||||
|
||||
❔ What would separate them: a per-draw capture recording the **bundle** each draw
|
||||
came from, or a kernel/file-IO log showing whether a second RATC entry is read
|
||||
between frames 115 and 116.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Re-established, by elimination — selective activation is happening
|
||||
|
||||
**2026-08-29.** The alternative can be killed statically, which I had not tried.
|
||||
Hypothesis 2 needs a bundle that declares the **glows without the logos**. There
|
||||
is none. Enumerating every entry in `GP_TITLE` that contains `palogo` elements:
|
||||
|
||||
| entry | elements |
|
||||
|---|---|
|
||||
| 10, 13 (publisher twins) | `palogo_eff0.prm`, `palogo_sqex`, `palogo_sqex_eff` |
|
||||
| 11, 14 (developer twins) | `palogo_eff0.prm`, **all three logos and all three glows** |
|
||||
|
||||
Four entries, and each developer entry declares the **complete set of six**. So
|
||||
whichever bundle was active across frames 94–211 — entry 11, entry 14, or both in
|
||||
turn — it declared the logos *and* the glows, while the game drew two sprites at
|
||||
a time in disjoint phases.
|
||||
|
||||
**⇒ Only a subset of a bundle's elements is drawn at any moment, whatever the
|
||||
bundle-loading story is.** The conclusion does not depend on resolving how many
|
||||
bundles are involved, which is why the texture-base test's failure no longer
|
||||
matters.
|
||||
|
||||
✅ So "a bundle is a palette, not a script" is **reinstated** — this time by
|
||||
eliminating the alternative rather than by assuming it away. The route that
|
||||
worked was not a better capture but asking what the competing hypothesis would
|
||||
*require on the disc*, and finding it absent.
|
||||
|
||||
---
|
||||
|
||||
## ✅ The measured splash sequence — what a port has to author
|
||||
|
||||
Since the sequencing is not in the placement data, here is the measurement, so a
|
||||
port authors from evidence rather than from taste. Frames are VdSwap counts;
|
||||
**1 frame = 1/30 s** (2 units/frame × 1 unit = 1/60 s, both settled in
|
||||
[Q1](../ui-keyframe-time-unit.md)). Data:
|
||||
[`data/splash-phase-timeline.txt`](../data/splash-phase-timeline.txt).
|
||||
|
||||
| phase | frames | n | seconds | starts at |
|
||||
|---|---|---|---|---|
|
||||
| publisher — `palogo_sqex` | 1–90 | 90 | **3.00+** | 0.00 |
|
||||
| *(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 |
|
||||
|
||||
⚠️ **Three limits on these numbers.**
|
||||
|
||||
* The capture opens at frame 1 with `palogo_sqex` **already at `a=255`**, so the
|
||||
publisher phase began before the window. **3.00 s is a floor**, and every
|
||||
"starts at" is relative to the capture, not to boot.
|
||||
* `palogo_anima` and `palogo_anima_eff` get **0 draws** in all 214 frames, so the
|
||||
third logo pair's phase — if there is one — is not in this measurement.
|
||||
* One capture, one run. The glow→logo switch is a single frame boundary with no
|
||||
overlap, which is a strong shape, but the *durations* are one sample each.
|
||||
|
||||
✅ What is solid: the **0.73 s glow phase** and the **3.20 s logo phase** are
|
||||
each within 2 % of their element's declared span, so the durations are the
|
||||
bundle's own and only the sequencing is authored.
|
||||
227
docs/re/structures/ui-keyframe-rotation.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# ✅ The keyframe block's `+12` is a screen-plane ROTATION, in degrees
|
||||
|
||||
**Status:** ✅ `DECODED` — field, disc-wide census, and confirmed against the
|
||||
**framebuffer** (a GPU draw capture), not against our own renderer.
|
||||
🟡 `+4` and `+8` remain unexplained.
|
||||
|
||||
This closes the ⚠️ that stood on the `Keyframe` type: *"they do not explain the
|
||||
title screen — every element of `GP_TITLE` build 4 has all three at zero, while
|
||||
the game demonstrably submits rotated quads there."* That note was wrong about
|
||||
**reach**, not about the bytes it read.
|
||||
|
||||
## The block
|
||||
|
||||
```text
|
||||
+0 u32 ARGB fade colour
|
||||
+4 i32 🟡 unexplained
|
||||
+8 i32 🟡 unexplained
|
||||
+12 i32 ✅ rotation, DEGREES, clockwise-positive in screen space (Y down)
|
||||
+16 u32 scale X, percent
|
||||
+20 u32 scale Y, percent
|
||||
+24 u32 tint
|
||||
+28 i32 X ← signed
|
||||
+32 i32 Y ← signed
|
||||
+36 u32 time (the last block of a group does not own this word)
|
||||
```
|
||||
|
||||
## How it was confirmed: two quads, two different angles, measured
|
||||
|
||||
The live title submits one draw of **two rotated quads** that our renderer does
|
||||
not reproduce ([`title-draw-capture-vertex-colours.log`](../captures/title-builds/title-draw-capture-vertex-colours.log),
|
||||
draw 2). Converting its NDC vertices to screen space — `X = (x+1)·640`,
|
||||
`Y = (1−y)·360` — and taking the quads' **edge lengths** rather than their
|
||||
bounding boxes:
|
||||
|
||||
| quad | size | rotation | centre |
|
||||
|---|---|---|---|
|
||||
| A | 400.1 × 1076.3 | **+30.26°** | (992.0, 359.1) |
|
||||
| B | 400.2 × 1444.5 | **−45.28°** | (467.2, 360.0) |
|
||||
|
||||
Both are true rectangles (adjacent edges perpendicular to < 0.6 %).
|
||||
|
||||
The two elements they belong to are the title's nested leaf records
|
||||
`ptloop01.rat` → `pteff03.t32` and `ptloop02.rat` → `pteff03a.t32`. Their
|
||||
keyframe blocks, at `0xbb5966+0x68` and `0xbb5a82+0x68` in the decompressed
|
||||
build 4:
|
||||
|
||||
| | `ptloop01` | `ptloop02` |
|
||||
|---|---|---|
|
||||
| sprite | `pteff03.t32` 399×180 | `pteff03a.t32` 399×180 |
|
||||
| scale | 100 %, **600 %** → 399 × **1080** | 100 %, **800 %** → 399 × **1440** |
|
||||
| `+12` | **30** | **−45** |
|
||||
| measured | 400.1 × 1076.3 at **+30.26°** | 400.2 × 1444.5 at **−45.28°** |
|
||||
|
||||
Magnitude *and* sign, on **two different values**, from the GPU.
|
||||
|
||||
### Why this is an identification and not a coincidence
|
||||
|
||||
The same capture was first attributed to a different element entirely
|
||||
([REFUTED.md](../REFUTED.md)), so the identification is carried by five
|
||||
independent agreements, not by elimination:
|
||||
|
||||
1. The draw has exactly **two** quads; the screen declares exactly **two**
|
||||
`ptloop` elements.
|
||||
2. Both widths measure **400** against a declared sprite width of **399**.
|
||||
3. The heights are **different numbers that both land**: 1076 ≈ 1080 (600 %) and
|
||||
1444 ≈ 1440 (800 %). A coincidence would have to hit both.
|
||||
4. Between the capture's two frames quad A moves **+19 px right** and quad B
|
||||
moves left — matching each record's own decoded sweep direction
|
||||
(`ptloop01` x: −639 → 1521, `ptloop02` x: 1721 → −839).
|
||||
5. The drawn vertex alphas `0xC3` / `0xB6` both fall inside the declared
|
||||
`0x80` → `0xff` alpha ramps.
|
||||
|
||||
### A second corroboration, by ramp shape rather than by angle
|
||||
|
||||
`GP_BUNK` entry `117ca14f` at `0x7395f3` holds a two-keyframe group in which
|
||||
`+12` ramps **0 → 360** while position, scale and alpha all stay constant. A
|
||||
full-turn ramp that changes nothing else is a spin in place; no non-rotation
|
||||
reading of the field explains it. Disc-wide, `360` occurs 1 173 times and `−360`
|
||||
180 times.
|
||||
|
||||
## ✅ The strongest evidence is `GP_DIALOG`, and it is visible in the CLI
|
||||
|
||||
`sylpheed-cli screen info --build 0 --geometry dat/GP_DIALOG.pak`. These are
|
||||
**top-level** elements, so no hex dump is needed to see them:
|
||||
|
||||
| element | `+12` across the group | what else moves |
|
||||
|---|---|---|
|
||||
| `pceff03.t32` | **90 → 30 → 10 → 3 → 0** | alpha 0 → 128 → 192 → 224 → 255; position slides (630,348) → (470,198) |
|
||||
| `pceff04.t32` | **90 → 30 → 10 → 3 → 0** | the same ramp, mirrored path (413,77) → (553,247) |
|
||||
| `pzeff02.t32` (build 6) | **43 → 61 → 75 → 90** | scale 112 % → 200 %, alpha 255 → 0 |
|
||||
|
||||
The first two are a swing-in that *decays to zero exactly as the element
|
||||
settles*; the third is a burst that spins as it expands and fades. A monotone
|
||||
decay to 0 synchronised with a fade-in, and a spin-out synchronised with an
|
||||
expanding fade-out, are what a rotation field does and are not what any other
|
||||
reading of the word would produce.
|
||||
|
||||
`GP_DEBRIEFING_PILOTLOG` build 10 holds the static case: `pjeff24a.t32`, one
|
||||
keyframe, `r=90` at 210 % scale.
|
||||
|
||||
These four rows are committed verbatim at
|
||||
[`data/rotation-top-level-examples.txt`](../data/rotation-top-level-examples.txt).
|
||||
⚠️ The sweep that found them — every build of every `GP_*.pak`, grepped for a
|
||||
non-zero `r=` — **had not finished** at the time of writing; it was in
|
||||
`GP_DIALOG` build 37 with two archives hit so far. Treat the *count* as
|
||||
incomplete and the *existence* as settled.
|
||||
|
||||
## ✅ Bonus, from the same two quads: the pivot-anchored scale is measured-correct
|
||||
|
||||
`ui_layout::blit` places a scaled element as
|
||||
`ox = kf.x − pivot·(scale−100)/100`, keeping the **pivot** fixed rather than the
|
||||
top-left. That was implemented and reasoned about, never measured — and until now
|
||||
there was nothing to measure it against, because the title's other elements are
|
||||
all at 100 % scale where the term vanishes. The `ptloop` pair scale **600 %** and
|
||||
**800 %** vertically, so the term is worth 450 and 630 px:
|
||||
|
||||
| element | measured centre Y | pivot-anchored | top-left | position-as-centre |
|
||||
|---|---|---|---|---|
|
||||
| `ptloop01` (600 %) | **359.1** | **360.0** ✅ | 810.0 | 270.0 |
|
||||
| `ptloop02` (800 %) | **360.0** | **360.0** ✅ | 990.0 | 270.0 |
|
||||
|
||||
Two different scales, both landing on 360.0. Horizontally the same term is worth
|
||||
199.5 px, and there is an independent cross-check available: solve the group's
|
||||
`t` from the quad's **position**, then from its **vertex alpha**, and see whether
|
||||
the two agree.
|
||||
|
||||
| element | | t from position | t from alpha | Δ |
|
||||
|---|---|---|---|---|
|
||||
| `ptloop01` | with the pivot term | 571.98 | 571.65 | **0.33** |
|
||||
| | without | 579.65 | 571.65 | 8.00 |
|
||||
| `ptloop02` | with the pivot term | 668.92 | 668.27 | **0.65** |
|
||||
| | without | 659.71 | 668.27 | 8.55 |
|
||||
|
||||
🟡 **What this does *not* show is that interpolation is linear.** Both fields
|
||||
were inverted through the same linear map, so a shared easing curve would cancel
|
||||
out and still agree. What it does show is that position and alpha are driven off
|
||||
**one shared parameter** — which is worth knowing on its own, and is why the
|
||||
agreement is a real check on the placement formula rather than a tautology.
|
||||
|
||||
## Disc-wide census
|
||||
|
||||
[`tools/re-capture/kf_rotation_census.py`](../../../tools/re-capture/kf_rotation_census.py),
|
||||
output committed at [`data/kf-angle-census.txt`](../data/kf-angle-census.txt).
|
||||
Over **83 862** keyframe blocks in the `GP_*.pak` UI archives:
|
||||
|
||||
| word | non-zero | dominant values |
|
||||
|---|---|---|
|
||||
| `+4` | 3 990 (**4.76 %**) | `180` (3 880) |
|
||||
| `+8` | 3 878 (**4.62 %**) | `180` (3 102), `90` (201), `−180` (156) |
|
||||
| `+12` | 12 164 (**14.50 %**) | `90`, `−90`, `360`, `120`, `180`, and a long tail of arbitrary angles (`53`, `−58`, `−125`, `114`, `−33`, …) |
|
||||
|
||||
`+12`'s long tail of arbitrary values is itself part of the decode: `+4` and `+8`
|
||||
are almost entirely `±180`/`±90`, which is the signature of a **flip flag**
|
||||
expressed in degrees rather than of a free angle. 🟡 That is a hypothesis about
|
||||
`+4`/`+8`, not a decode — nothing observed turns on them.
|
||||
|
||||
## ⚠️ Reach and limits
|
||||
|
||||
* **`rotation_deg` is decoded but NOT rendered.** `ui_layout`'s blitter draws
|
||||
axis-aligned quads only, so `sylpheed-cli screen render` still paints a
|
||||
rotated element upright. Rotating the blit is a real change to the compositor
|
||||
and was not attempted here. **The title render's residual band is expected to
|
||||
persist until it is.**
|
||||
* 🔴 ~~"Rotation appears to live only in nested `.rat` leaf records."~~
|
||||
**Refuted by my own sweep, within the hour.** It held for `GP_TITLE`,
|
||||
`GP_BUNK` and `GP_CHALLENGE` — the three archives I had checked when I wrote
|
||||
it — and fails on the very next one: `GP_DIALOG` and
|
||||
`GP_DEBRIEFING_PILOTLOG` carry rotation on **top-level** elements, and those
|
||||
are the best evidence on the disc (below). Rotation lives in both places.
|
||||
The half that survives is the one that matters to a consumer: a composer that
|
||||
reads only the declaration table will miss the *title's* rotations, because
|
||||
those particular ones are nested.
|
||||
* **The blocks are not 4-byte aligned.** A nested `RATC` blob can start at an odd
|
||||
offset (`ptloop01.rat` at `0xbb5966`), and its blocks inherit that. Any scanner
|
||||
over raw bundle bytes must not assume alignment — see
|
||||
[METHOD.md](../METHOD.md).
|
||||
* `+4` and `+8` are **not** claimed as X/Y rotation. Three adjacent signed
|
||||
degree-valued words invite that reading; only `+12` has been observed to do
|
||||
anything.
|
||||
|
||||
## ✅ The top-level census, finished (2026-08-28)
|
||||
|
||||
The earlier attempt drove `sylpheed-cli screen info --geometry` in a shell loop
|
||||
and never finished — it decodes every texture per build.
|
||||
[`tools/re-capture/rotation_toplevel_census.py`](../../../tools/re-capture/rotation_toplevel_census.py)
|
||||
walks the top-level placement region the way `ui_layout.rs` does; seconds instead
|
||||
of hours. Output: [`data/rotation-toplevel-census.txt`](../data/rotation-toplevel-census.txt).
|
||||
|
||||
Both controls pass — `GP_TITLE` build 4 reports **0** (its rotations are the
|
||||
nested `ptloop` records) and `GP_DIALOG` build 0 reports the **2** expected
|
||||
`pceff03`/`pceff04` elements.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| top-level elements with a keyframe group | 15 493 |
|
||||
| carrying a non-zero rotation | **2 152 (13.89 %)** |
|
||||
|
||||
By archive: `GP_READY_ROOM` **1 868**, `GP_DIALOG` 40, each of the six
|
||||
`GP_MAIN_GAME_*2D` 37, `GP_GAMEOVER` 10, `GP_DEBRIEFING_PILOTLOG` 8,
|
||||
`GP_TITLE` **4**.
|
||||
|
||||
### Two more corroborations, both free
|
||||
|
||||
**The rotated population is tactical-map ship icons.** The most-rotated names are
|
||||
`pbb_destroyer` (444), `pbr_destroyer` (402), `pbr_fighter` (276),
|
||||
`pbb_fighter` (174), `pbb_aircraft` (168) — map markers, in the archive that
|
||||
holds the tactical map. Rotating a ship icon to its heading is what the field is
|
||||
*for*, and it is the single largest use of it on the disc.
|
||||
|
||||
**`GP_TITLE` entry 7's four rotated elements carry their own control.** Three of
|
||||
them are the Japanese wordmark's pieces, and they settle to upright from
|
||||
**alternating** tilts:
|
||||
|
||||
```
|
||||
ptlogo3a.t32 r = 0, -14, -4, -1, 0, 0, 0, 0
|
||||
ptlogo3b.t32 r = 0, +14, +4, +1, 0, 0, 0, 0
|
||||
ptlogo3c.t32 r = 0, -14, -4, -1, 0, 0, 0, 0
|
||||
```
|
||||
|
||||
Same magnitudes, alternating sign, all decaying to 0 — three sibling elements
|
||||
tilting opposite ways and straightening. A misread field does not produce that.
|
||||
(The fourth is `ptlogo_eff3.t32`, `r = 0, 0, 80, 150` — the transient bloom
|
||||
discussed in [ui-resting-pose](ui-resting-pose.md).)
|
||||
|
||||
⚠️ **This is not subtractable from the byte-scan census.** That one counts
|
||||
*blocks* (12 164 of 83 862 with a non-zero `+12`); this one counts *elements*.
|
||||
The two are different units and the difference is not "the nested population".
|
||||
169
docs/re/structures/ui-paint-order-derived-check.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# ✅ Does the derived paint order reproduce the measured ones? Mostly — and the gap is bounded
|
||||
|
||||
**Status:** ✅ **checked, with numbers.** `compose` uses a paint order *measured
|
||||
from the running game* for the builds that have one and falls back to
|
||||
`derived_paint_order` — a sort on each sprite's layer key — everywhere else. The
|
||||
code's doc comment claimed the derived order "reproduces both measured orders up
|
||||
to ties". That was a hedge with no measurement behind it, and it was stale:
|
||||
there are **three** measured orders now, not two.
|
||||
|
||||
Tool: `cargo run -p sylpheed-formats --example paint_order_audit -- dat/GP_TITLE.pak`.
|
||||
Output committed at [`data/paint-order-audit.txt`](../data/paint-order-audit.txt).
|
||||
|
||||
## The claim holds, and the exception is all ties
|
||||
|
||||
| build | derived == measured | inverted pairs | of which same-key ties |
|
||||
|---|---|---|---|
|
||||
| main menu (entries 5, 8) | **YES** | 0 | — |
|
||||
| developer splash (11, 14) | **YES** | 0 | — |
|
||||
| title (entry 4) | **NO** | 8 | **8 — every one** |
|
||||
|
||||
Four of the five measured bundles reproduce **exactly**. The title's eight
|
||||
disagreements are all between elements that share a layer key, which the sort
|
||||
cannot order and breaks by declaration index.
|
||||
|
||||
## ⚠️ Two of those ties are total occlusions
|
||||
|
||||
The tied family is the five `ptlogo_back2eff` glows, all key `32899`:
|
||||
|
||||
```
|
||||
measured paints: 14, 15, 18, 16, 17
|
||||
derived paints: 14, 15, 16, 17, 18
|
||||
```
|
||||
|
||||
Two pairs flip, and they are not near-misses:
|
||||
|
||||
| pair | overlap | |
|
||||
|---|---|---|
|
||||
| `back2eff5` vs `back2eff3` | 82 824 px² | **100 % of the smaller** |
|
||||
| `back2eff5` vs `back2eff4` | 152 047 px² | **100 % of the smaller** |
|
||||
|
||||
`back2eff5` is 1133×280 and **fully contains** both. Derived paints it on top of
|
||||
two glows it completely covers; the game paints it underneath. So a tie-break by
|
||||
declaration index is not cosmetic — where it is wrong, it can be wrong by a whole
|
||||
layer. ✅ The title is unaffected in practice, because it has a measured order.
|
||||
|
||||
## ✅ The port's actual exposure is two element pairs
|
||||
|
||||
Per build, counting tied pairs and how many of them **overlap** (only those can
|
||||
paint visibly differently):
|
||||
|
||||
| entry | the port's screen | order used | tied pairs | overlapping |
|
||||
|---|---|---|---|---|
|
||||
| 4 | title | **measured** | — | — |
|
||||
| 5 | main menu | **measured** (derived agrees exactly) | 0 | 0 |
|
||||
| **6** | **`EXTRAS`** | **derived** | 15 | **2** |
|
||||
| 10 | publisher splash | derived | **0** | **0** |
|
||||
| 11 | developer splash | **measured** | — | — |
|
||||
|
||||
So of the five screens, **one** rests on an unverified derived order, and its
|
||||
risk is **two overlapping tied pairs** — not the 15 the raw tie count suggests.
|
||||
The publisher splash's derived order is fully determined (no ties at all).
|
||||
|
||||
🟡 For completeness, outside the port's set: entry 7 (the Japanese title) is the
|
||||
worst on the disc at 37 tied pairs, 16 overlapping.
|
||||
|
||||
## Reach
|
||||
|
||||
* This checks the derived order against the orders **measured from the running
|
||||
game**; it is not an independent derivation of what the game does. Where no
|
||||
measured order exists, agreement cannot be checked at all — only the *tie
|
||||
exposure* can, which is what the table above reports.
|
||||
* Overlap uses `pivot × 2` as the element's size (documented as the sprite's own
|
||||
dimensions for a `.t32`) at its resting placement, so scaled or rotated
|
||||
elements are approximated.
|
||||
|
||||
---
|
||||
|
||||
## ❔ The tie-break is undecodable from the bundle — searched, with reach
|
||||
|
||||
The audit above leaves one question: the layer key orders elements, but what
|
||||
orders elements that **share** a key? On the title that tie-break decides two
|
||||
total occlusions, so it is not academic.
|
||||
|
||||
The game paints the five tied `ptlogo_back2eff` glows in the order
|
||||
**eff1, eff2, eff5, eff3, eff4**. Three static structures were searched for
|
||||
anything that reproduces it.
|
||||
|
||||
**1. The declaration table.** Entries 14–18 are *byte-identical* apart from the
|
||||
pivot, which is just half the sprite's own size:
|
||||
|
||||
```
|
||||
14 ptlogo_back2eff1 00000000 ffffffff ffffffff 00000000 ffffffff 0000004e 0000003c 00000000
|
||||
15 ptlogo_back2eff2 00000000 ffffffff ffffffff 00000000 ffffffff 00000074 0000005b 00000000
|
||||
16 ptlogo_back2eff3 00000000 ffffffff ffffffff 00000000 ffffffff 000000aa 0000005b 00000000
|
||||
17 ptlogo_back2eff4 00000000 ffffffff ffffffff 00000000 ffffffff 00000149 0000005b 00000000
|
||||
18 ptlogo_back2eff5 00000000 ffffffff ffffffff 00000000 ffffffff 000001fb 0000007e 00000000
|
||||
```
|
||||
|
||||
**2. The `T8aD` headers.** All five carry identical `+0x04` (`0x8832`) and
|
||||
identical `+0x08`/`+0x0A` (`32899`, the layer key itself). They differ only in
|
||||
position and tile count. Searched **exhaustively**: every offset `0x00–0x7f`, at
|
||||
u8, u16 and u32 width, sorted both ascending and descending —
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| fields sorting to the **measured** order | **0** |
|
||||
| fields sorting to the **declaration** order (control) | **64** |
|
||||
|
||||
The control matters: 64 fields *can* be found that reproduce a known ordering, so
|
||||
the scan is capable of finding an ordering field when one exists. It finds none
|
||||
for the order the game uses.
|
||||
|
||||
**3. The RATC child order** — the bundle's second element list, which is
|
||||
genuinely a different permutation from the declaration table on other screens.
|
||||
For this family it reads `eff1, eff2, eff3, eff4, eff5`: declaration order again.
|
||||
|
||||
### The answer
|
||||
|
||||
❔ **Undecodable from the bundle, with reach.** All three static orderings give
|
||||
`eff1…eff5`; the game gives `eff1, eff2, eff5, eff3, eff4`. That is consistent
|
||||
with what [ui-screen-runtime](ui-screen-runtime.md) already concluded from the
|
||||
other direction — the game builds a **reordered child list at load time** and
|
||||
paints that.
|
||||
|
||||
So Q3 resolves as: the layer key is ✅ **decoded** and orders 4 of the 5 measured
|
||||
bundles exactly; the **tie-break within a key is ❔ undecodable**, and a consumer
|
||||
must either use a measured order or accept declaration index as an arbitrary
|
||||
stand-in.
|
||||
|
||||
---
|
||||
|
||||
## ✅ `EXTRAS`'s exposure narrows to ONE pair — and the capture is consistent with it
|
||||
|
||||
The 15 tied pairs on `EXTRAS` reduce twice.
|
||||
|
||||
**2 of the 15 overlap** (only an overlapping tie can paint differently):
|
||||
|
||||
```
|
||||
[0] ptframe3.t32 x [1] ptframe4.t32 key 32848 overlap 102x132
|
||||
[14] ptloop01.rat x [15] ptloop02.rat key 32784 overlap 400x180
|
||||
```
|
||||
|
||||
**1 of those 2 can be drawn.** `ptloop01`/`ptloop02` are `loop*` sprite
|
||||
animations, which `compose` skips unless `--animated` is passed, so their tie is
|
||||
unreachable in a default render. The real exposure is `ptframe3` × `ptframe4`,
|
||||
overlapping **102 × 132 px** at x 584–686, y 318–450.
|
||||
|
||||
### The capture is consistent with our order
|
||||
|
||||
Correlating our render against
|
||||
[`live-extras.png`](../captures/title-builds/live-extras.png):
|
||||
|
||||
| region | correlation |
|
||||
|---|---|
|
||||
| **the contested overlap** (x 584–686, y 318–450) | **+0.9622** |
|
||||
| `ptframe3` alone, outside the overlap | +0.8502 |
|
||||
| `ptframe4` alone, outside the overlap | +0.9903 |
|
||||
| whole frame | +0.9440 |
|
||||
|
||||
The contested region matches **better than the frame as a whole** and sits inside
|
||||
the range of regions where order *cannot* matter. Were our tie-break wrong, that
|
||||
region should correlate worse than its neighbours.
|
||||
|
||||
🟡 **Consistent with, not proof.** Correlation cannot see a swap if the two
|
||||
frames happen to be locally similar. But it is a real check, and it removes the
|
||||
"entirely unverified" character of the one tie the port can actually hit.
|
||||
|
||||
**So the chain is: 15 tied pairs → 2 overlapping → 1 drawable → consistent with
|
||||
the capture.** That is the whole paint-order risk on the port's five screens.
|
||||
@@ -84,10 +84,16 @@ Verified three ways rather than by a green build:
|
||||
([capture](../captures/mission-select-derived-order.png)).
|
||||
|
||||
**Found on the way, and worth its own line:** the developer-logo splash bundle
|
||||
has no `.rat` child, so `ui_layout::is_build` rejects it and the compositor never
|
||||
sees it. Its measured order is therefore inert in practice, and the splash cannot
|
||||
be rendered by `screen render` at all. That is a separate gap in what counts as a
|
||||
"build", not a paint-order question.
|
||||
has no `.rat` child, so `ui_layout::is_build` rejects it and the **default**
|
||||
`screen render` never sees it.
|
||||
|
||||
⚠️ **Corrected 2026-08-28:** the sentence that used to end this paragraph — "the
|
||||
splash cannot be rendered by `screen render` at all" — is **wrong**. It can:
|
||||
`screen render --all` widens the enumeration past `is_build` and the splash
|
||||
composites fine, both halves.
|
||||
[`../ui-title-build-map.md`](../ui-title-build-map.md) has the renders. What is
|
||||
true is narrower: it is invisible to the *default* listing, so anyone who does not
|
||||
pass `--all` will conclude it is missing.
|
||||
|
||||
## What the change did to the screens that were already verified (2026-08-19)
|
||||
|
||||
@@ -395,3 +401,260 @@ Three published numbers on this page were wrong the same way: each was computed
|
||||
over a population I had not checked was the population in question — the six
|
||||
language copies, then the top-level-only sprites, then the size of that gap. The
|
||||
analysis was never the problem. **Check the sampling frame before the statistic.**
|
||||
|
||||
## `EXTRAS` — the derived order against a capture nobody had taken (2026-08-28)
|
||||
|
||||
The five orders above are all **measured**: the derived rule is checked against a
|
||||
live child list. This one is the other direction — a screen with **no measured
|
||||
order at all**, composited purely from the derived rule and scored against a
|
||||
framebuffer capture taken the same day
|
||||
([capture](../captures/title-builds/live-extras.png),
|
||||
[composite](../captures/ui-layout/extras-composited.png)).
|
||||
|
||||
`GP_TITLE.pak` build 6, 18 elements, two `.prm` primitives:
|
||||
|
||||
```
|
||||
sylpheed-cli screen render --build 6 --black --primitives GP_TITLE.pak extras.png
|
||||
align_to_capture.py live-extras.png extras.png --region 150 100 1150 600
|
||||
```
|
||||
|
||||
| composite | best shift | score |
|
||||
|---|---|---|
|
||||
| **build 6, `--black --primitives`** | **(0, 0)** | **0.9620** |
|
||||
| build 6, no primitives | (0, 0) | 0.9576 |
|
||||
| *control:* main menu vs `main-menu-oracle.png`, same tooling | (0, 0) | 0.9657 |
|
||||
|
||||
The control was run first and reproduces the screen this rule was fitted to, so
|
||||
the harness is known-good before the new screen is scored. `EXTRAS` lands within
|
||||
0.004 of it, at zero shift, with the primitives helping rather than hurting.
|
||||
|
||||
### ⚠️ What this does and does not show
|
||||
|
||||
**It does show** that every other part of the stack transfers to an unfitted
|
||||
screen: element enumeration, resting pose, pivots, `fade` alpha, and that the
|
||||
two primitives land somewhere harmless under the derived `implied_layer_key`.
|
||||
|
||||
**It does not show that the paint order is right.** `align_to_capture.py`
|
||||
correlates *gradient magnitude* — edges — and a paint-order change moves blend
|
||||
values, not edges. The earlier derived-vs-declaration A/B on the pause menu and
|
||||
main menu measured exactly that: 1.1–3.8 % of pixels differing by ≤45/255, in
|
||||
translucent overlaps. A score computed this way cannot see a difference that
|
||||
size, so **0.9620 is evidence for placement and only consistent with the order.**
|
||||
|
||||
**The sharp test, not run.** Composite build 6 twice — the committed derived
|
||||
order, and `compose` temporarily reverted to declaration order — and diff both
|
||||
against the capture over the pixels where they disagree. That needs a rebuild of
|
||||
`sylpheed-formats`, which is why it is named here rather than done; it is the
|
||||
same A/B this page already ran on the two screens that had oracles, and `EXTRAS`
|
||||
would be the first screen where the two orders can be judged against ground
|
||||
truth rather than declared indistinguishable.
|
||||
|
||||
|
||||
## 🟡 A second flag in the same header: `+0x04`, bit `0x02` (2026-08-28)
|
||||
|
||||
This page reads the layer key at `+0x0A`. The word at **`+0x04`** is a different
|
||||
flag word, and on the title screen it splits the sprites exactly along
|
||||
**effect versus normal**:
|
||||
|
||||
| `+0x04` | title sprites |
|
||||
|---|---|
|
||||
| **`0x8832`** | `pteff01`, `pteff03a`, `ptlogo_back2eff1…5`, `ptlogoall_eff`, `ptlogoall_eff2` |
|
||||
| **`0x8830`** | `ptlogo1`, `ptlogo2`, `ptlogo_tm`, `ptbase2`, `ptlogo_back2`, `ptcopyright`, `ptlogo_back2eff` |
|
||||
|
||||
One bit apart: **`0x02`**.
|
||||
|
||||
**Disc-wide** (19 216 `T8aD` sprites, top-level and `RATC`-nested): 18 distinct
|
||||
values, and bit `0x02` is set in **27.1 %**. It varies independently of the rest
|
||||
of the word — `0x8830`/`0x8832`, `0x0830`/`0x0832`, `0x0810`/`0x0812` and
|
||||
`0x0030`/`0x0032` all occur — so it is a genuine flag, not part of a larger
|
||||
enumeration.
|
||||
|
||||
**Why it matters:** the title's swoosh renders opaque white where the game draws
|
||||
a thin coloured stroke, and every other candidate is eliminated — position and
|
||||
size come from the texture and match, `fade` is white-with-alpha, `tint` is white,
|
||||
and the texture itself is blue-leaning
|
||||
([`../ui-title-build-map.md`](../ui-title-build-map.md)). A per-sprite **blend
|
||||
mode** is what is left, and this is the only per-sprite field found that
|
||||
distinguishes the elements involved.
|
||||
|
||||
### 🔴 Tested — it does NOT select an additive blend
|
||||
|
||||
Blending bit-`0x02` sprites additively and re-correlating against the title
|
||||
capture moved **every** measure the wrong way:
|
||||
|
||||
| | alpha-over | additive |
|
||||
|---|---|---|
|
||||
| whole-frame mean diff | **+0.55** | +1.04 |
|
||||
| swoosh-band mean diff | **+1.83** | +3.98 |
|
||||
| swoosh-band edge-corr | **0.6971** | 0.5578 |
|
||||
|
||||
So the bit is real and independent, but **additive is refuted**. `T8adImage` now
|
||||
carries the word as `flags`, documented and *not* acted on.
|
||||
|
||||
⚠️ **What remains is a correlation, not a decode.** The original test was against
|
||||
[`../captures/title-builds/live-title-build4-no-plate.png`](../captures/title-builds/live-title-build4-no-plate.png).
|
||||
Worth noting `ptlogo_back2eff` carries `0x8830` **despite** having `eff` in its
|
||||
name — so the split is the field's, not a naming pattern's. What the bit *does*
|
||||
mean is still unknown.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 The `+0x04` bit `0x02`: two candidate meanings killed, attribution now sound
|
||||
|
||||
**2026-08-29.** This page recorded the bit as a real independent field
|
||||
(set in 27.1 % of 19 216 sprites) with its "additive blend" reading refuted and
|
||||
its meaning ❔ *not diagnosed*, noting that `ptlogo_back2eff` is `0x8830`
|
||||
"despite its name". Two things here: that note rested on a **size match**, and
|
||||
the size is ambiguous.
|
||||
|
||||
### ✅ First, a sound way to attribute a header to a name
|
||||
|
||||
`T8aD` headers appear in the bundle **in RATC child order**. Verified on
|
||||
`GP_TITLE` build 4 by an independent property — each header's decoded
|
||||
dimensions against the dimensions the named child should have:
|
||||
|
||||
**18 of 18 match, 0 mismatches.**
|
||||
|
||||
That matters because two of the eighteen share a size (`ptlogo_back2eff` and
|
||||
`ptlogo_back2eff5`, both 1133×280), so a size-keyed lookup cannot tell them
|
||||
apart — the trap [METHOD.md](../METHOD.md) already records for this corpus.
|
||||
Ordering resolves them: index 12 is `ptlogo_back2eff5` (`0x8832`, bit **set**),
|
||||
index 14 is `ptlogo_back2eff` (`0x8830`, bit **clear**).
|
||||
|
||||
So the documented counterexample is **real and correctly attributed** — now on
|
||||
evidence rather than on a guess between two same-sized sprites.
|
||||
|
||||
### 🔴 Two candidate meanings, both refuted
|
||||
|
||||
| hypothesis | verdict |
|
||||
|---|---|
|
||||
| bit ⟺ the name contains `eff` | **refuted** — `ptlogo_back2eff` is an `eff` name with the bit clear |
|
||||
| bit ⇒ the name contains `eff` (the surviving one-way reading) | **also refuted, disc-wide — see below.** It held 10/10 on build 4 and fails on **2 657 of 4 995** bit-set sprites across the disc |
|
||||
| bit ⟺ the element is transient (gone before the screen settles) | **refuted** — `pteff03`/`pteff03a` carry the bit and run to `t=250`, ramping to `a=255` and holding, i.e. they persist |
|
||||
|
||||
### 🟡 What the exception looks like up close
|
||||
|
||||
`ptlogo_back2eff` and `ptlogo_back2eff5` are the same size and the same artwork,
|
||||
and differ in **two** header words:
|
||||
|
||||
```
|
||||
ptlogo_back2eff5 +0x04 00008832 +0x08 00008083 (layer key 32899, bit SET)
|
||||
ptlogo_back2eff +0x04 00008830 +0x08 00008081 (layer key 32897, bit clear)
|
||||
```
|
||||
|
||||
⚠️ They are **not** duplicates: pixel-compared, `max abs diff 21`. Their alpha
|
||||
summaries are identical to one decimal (4.5 % opaque, 86.7 % clear, mean 19.3),
|
||||
which is precisely why the summaries were not trusted — two different renditions
|
||||
of one image at one size.
|
||||
|
||||
So the bit tracks something that also distinguishes two versions of the same
|
||||
artwork, alongside a different layer key. ❔ **Still not diagnosed**, but the
|
||||
search space is smaller by two and the attribution underneath it is now sound.
|
||||
|
||||
|
||||
### 🔴 The one-way implication is refuted too — build 4 was a local pattern
|
||||
|
||||
Having fixed the attribution, the surviving reading was *"bit set ⇒ the name
|
||||
contains `eff`"*, true 10/10 on `GP_TITLE` build 4. Checked disc-wide with
|
||||
[`tools/re-capture/eff_bit_census.py`](../../../tools/re-capture/eff_bit_census.py)
|
||||
([data](../data/eff-bit-census.txt)) over 14 709 sprites whose preceding name
|
||||
resolves:
|
||||
|
||||
| | count |
|
||||
|---|---|
|
||||
| bit SET & name has `eff` | 2 338 |
|
||||
| **bit SET & name lacks `eff`** | **2 657** |
|
||||
| bit clear & name has `eff` | 1 399 |
|
||||
| bit clear & name lacks `eff` | 8 315 |
|
||||
|
||||
`P(eff | set) = 0.468` against `P(eff | clear) = 0.144`. **The implication fails
|
||||
more often than it holds.** What survives is an *association* — the bit is 3.3×
|
||||
enriched for `eff` names — and one build's 10/10 was a local naming habit, not a
|
||||
rule.
|
||||
|
||||
🟡 The counterexamples are the interesting part: `pv_loading_ring0`,
|
||||
`pv_loading_light0`–`3`, `pv_loading_line`, `px_bunk_line`, `px_top_extra` —
|
||||
rings, glows, lights and thin lines. **Effect-like artwork that does not carry
|
||||
the `eff` naming convention.** Consistent with the bit marking effect sprites by
|
||||
authoring intent rather than by name, which is a description, not a decode.
|
||||
|
||||
⚠️ Name caveat: these names come from the string **immediately preceding** each
|
||||
`T8aD`, validated 17/18 on build 4 against the child order. The one mismatch is
|
||||
the known `pteff04.t32` → registered as `8AX` case, so this is the **element**
|
||||
(`opt `) name, not the sprite's registered name.
|
||||
|
||||
### 🔴 Premultiplied alpha — a fourth candidate, refuted
|
||||
|
||||
A per-sprite flag controlling **premultiplied vs straight alpha** would matter
|
||||
enormously to a port (it changes the blend equation) and has a sharp static
|
||||
signature: a premultiplied texture has `RGB ≤ A` everywhere.
|
||||
[`tools/re-capture/eff_bit_alpha_test.py`](../../../tools/re-capture/eff_bit_alpha_test.py),
|
||||
over the 170 decoded `GP_TITLE` textures that pair to a flag word
|
||||
([data](../data/eff-bit-alpha-test.txt)):
|
||||
|
||||
| group | n | mean %(RGB>A) | median |
|
||||
|---|---|---|---|
|
||||
| bit SET | 61 | **55.5** | 52.5 |
|
||||
| bit clear | 109 | **33.7** | 30.2 |
|
||||
|
||||
Premultiplied requires ~0 % for the flagged group. Both groups are far from it,
|
||||
and the flagged group violates **more** — the opposite of the hypothesis.
|
||||
**Refuted.**
|
||||
|
||||
🟡 What is left is a weak association: flagged sprites carry more bright-RGB /
|
||||
low-alpha pixels, which is what glow art looks like. But the distributions
|
||||
overlap badly — the best single threshold classifies **76.5 %** against a
|
||||
**64.1 %** base rate, a 12-point lift. That is a tendency, not a rule, and
|
||||
nothing to build on.
|
||||
|
||||
### ⚠️ One re-test worth remembering
|
||||
|
||||
"`0x02` selects an additive blend" was refuted by *"blending those sprites
|
||||
additively worsens every measure against the capture"* — but that ran against a
|
||||
title render which has since been fixed twice (the `rest_plateau` bug, and the
|
||||
`8AX` background our composer drops). The refutation may still stand; it was
|
||||
simply measured through a renderer with known other errors. If `ui_layout::blit`
|
||||
ever gains additive blending, it is worth one re-run.
|
||||
|
||||
### 🛑 Parking this field
|
||||
|
||||
Four candidate meanings are now dead — additive blend, `eff` name (both
|
||||
directions), transient element, premultiplied alpha — and none of the four
|
||||
searches produced a positive account. The bit blocks nothing: the port's screens
|
||||
composite at 0.947 correlation against a capture without it. **Parked**, with the
|
||||
search space narrowed and the attribution method (child order, not size) left
|
||||
sound for whoever picks it up.
|
||||
|
||||
## The reach of the draw-capture cross-check (2026-08-29)
|
||||
|
||||
The orders above are read off the **live child list**; the draw captures confirm
|
||||
them. That confirmation is partial, and this is how partial — counted by
|
||||
[`order_crosscheck_reach.py`](../../../tools/re-capture/order_crosscheck_reach.py),
|
||||
data in [`order-crosscheck-reach.txt`](../data/order-crosscheck-reach.txt):
|
||||
|
||||
| screen | captured frames | draws/frame | quads/frame | order length | positions the capture confirms |
|
||||
|---|---|---|---|---|---|
|
||||
| title (entry 4) | 3 + 2 | 8 | 12 | 24 | **7** named in [`ui-screen-runtime.md`](ui-screen-runtime.md) |
|
||||
| main menu (entry 8) | 2 | 9 / 7 | 16 / 9 | 16 | ≤ the richest frame |
|
||||
| splash (11/14) | 235 | — | — | 7 | within each half only |
|
||||
|
||||
Three things this makes explicit, none of which changes an order:
|
||||
|
||||
* **Count quads, not draws.** The menu's draw 9 is `indices=24` — **six** quads
|
||||
batched out of one texture. A per-draw count reads 9 where 16 are on screen,
|
||||
and an earlier pass of this analysis "found" three quads for six declarations
|
||||
that way and briefly concluded elements were missing. They were batched.
|
||||
* **The title capture is stable**: 8 draws / 12 quads / 5 textures, byte-for-byte
|
||||
the same census in every one of the five captured frames across two logs. So
|
||||
everything it shows co-occurs, and the 7 confirmed positions are mutually
|
||||
ordered by observation. The other 17 rest on the child-list read alone — which
|
||||
is the ground truth here, but it is one source, not two. (The census counts 8
|
||||
draws where that page names 7 confirmed elements, so the capture carried a
|
||||
little more than it was read for — not chased, and not a claim either way.)
|
||||
* **The menu capture is not stable**: texture `0x11C30000` is in frame 0 and gone
|
||||
by frame 3. Its order is still confirmed as far as any single frame goes.
|
||||
* **The splash capture cannot cross-check its middle.** Glows (f94–115) and logos
|
||||
(f116–211) never share a frame, so no capture of this screen orders one against
|
||||
the other. The static layer key does (`0xa100` < `0xa110`), which is why the
|
||||
order stands — see the ❌ withdrawn section in
|
||||
[`ui-prm-primitives.md`](ui-prm-primitives.md).
|
||||
|
||||
@@ -174,3 +174,74 @@ have never been measured, so they are not in the table.
|
||||
* ❔ **The 8 non-full-screen ones**, including two with a zero dimension.
|
||||
* 🟡 `kind = 0x3010` (38 elements) is `0x10` plus `0x3000`, the button-record
|
||||
bits — a primitive that is part of a button. Unexamined.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Measured on the splash: the `.prm` paints FIRST, full-screen, opaque black
|
||||
|
||||
**2026-08-29.** This page recorded that where a `.prm` paints "on a screen
|
||||
without a measured order is unsolved". For the developer splash it is now
|
||||
measured, from the 235-frame draw capture.
|
||||
|
||||
Every frame begins with two full-screen draws before any sprite:
|
||||
|
||||
```
|
||||
f120: [1280x720] [1280x720] [499x241] …
|
||||
^clear ^this one
|
||||
```
|
||||
|
||||
The second is **untextured** in all **212** frames, with a constant vertex colour
|
||||
of **`FF000000`** — opaque black. That matches `palogo_eff0.prm`'s declaration
|
||||
exactly:
|
||||
|
||||
| declared | observed |
|
||||
|---|---|
|
||||
| `kind = 0x10` (the primitive marker) | untextured draw |
|
||||
| pivot (640,360) → **1280×720** | 1280×720 quad |
|
||||
| **one** keyframe, `a = 255` | constant across 212 frames |
|
||||
| — | vertex colour `FF000000` |
|
||||
|
||||
So the splash's backdrop is an **opaque black full-screen quad from the bundle
|
||||
itself**, painted behind every sprite — not a clear colour, which is why our
|
||||
splash renders need `--black` to match.
|
||||
|
||||
⚠️ **Not a general rule.** The measured *main menu* order puts `pteff02.prm` at
|
||||
position 4 and `pteff00.prm` **last** — that one is the screen-transition fade.
|
||||
A `.prm` paints where its screen's order says; the splash's happens to be first.
|
||||
|
||||
## ❌ WITHDRAWN (2026-08-29, same day): "the splash's measured order is a sequence, not depth"
|
||||
|
||||
This page briefly claimed that `measured_paint_order`'s `[0, 2, 4, 6, 1, 3, 5]`
|
||||
records, between its glow half and its logo half, the *temporal* order they were
|
||||
seen in rather than a depth order — on the grounds that the two halves never
|
||||
share a frame (0 overlap in 235, which is
|
||||
[measured and still true](../data/splash-phase-timeline.txt): glows f94–115,
|
||||
logos f116–211).
|
||||
|
||||
**The measurement was right and the conclusion was wrong**, on two independent
|
||||
grounds, either of which is fatal:
|
||||
|
||||
* **Wrong source.** The vector was never read off the draw capture. It is a read
|
||||
of the live screen object's **child array** —
|
||||
[`ui-screen-runtime.md`](ui-screen-runtime.md) records it literally as
|
||||
`paint order (child slots): 0 2 4 6 1 3 5`. A child list has a definite order
|
||||
whether or not any two of its children are ever drawn together, so a
|
||||
co-occurrence argument cannot touch it. The capture was the *cross-check*, not
|
||||
the source. That page had already stated the limit honestly for the title
|
||||
build: the `kind = 0x4` instances "draw at α=0 and so never appeared in the
|
||||
capture, but their placement in the list is consistent with the grouping".
|
||||
* **The order is in the file anyway.** `examples/paint_order_audit` on
|
||||
`GP_TITLE.pak` entry 11: `derived == measured : YES`, `inverted pairs: 0`,
|
||||
**`same-layer-key ties: 0`**. The glows and the logos carry *distinct* T8aD
|
||||
layer keys (`0xa100` vs `0xa110` — [key](ui-paint-order-key.md)), so the glow
|
||||
half sorts before the logo half **statically**, with no capture involved. The
|
||||
relationship I called unobservable is decoded.
|
||||
|
||||
What survives is narrower and worth keeping: **a draw capture of this screen
|
||||
cannot cross-check the glow-vs-logo half of the order**, because the two halves
|
||||
never co-occur. The capture validates the order *within* each half only. So the
|
||||
line in [`ui-paint-order-key.md`](ui-paint-order-key.md) — "read off the live
|
||||
child list, checked against a draw capture" — has that reach limit on the splash.
|
||||
The order itself is not in doubt; the *capture's* power to confirm all of it is.
|
||||
|
||||
The ✅ above for element 0 is unaffected: it is a direct draw observation.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
**reassembled from the disc**: the tutorial PAUSE menu rebuilds pixel-accurately from its
|
||||
sprites placed at the coordinates in their `.rat` records — no fitting, no manual nudging.
|
||||
|
||||

|
||||

|
||||
|
||||
*Left: the running game (Canary screenshot). Right: rebuilt from `GP_PAUSE_MENU.pak` alone.
|
||||
The remaining differences are the animated frame/glow sprites (`*eff*`) that were not
|
||||
@@ -157,7 +157,7 @@ game, twice, in that order — the records were never fitted to the picture.
|
||||
The same method run against `GP_TITLE.pak` reproduces the **main menu**, which is a
|
||||
different screen with a different item count and a different pitch:
|
||||
|
||||

|
||||

|
||||
|
||||
`ptbtn01..05.rat` give X = 542 for all five and Y = 162 / 242 / 322 / 402 / 482 — an
|
||||
**80 px** pitch, where the pause menu used 70. Measured against the screenshot, the sprite
|
||||
@@ -695,4 +695,64 @@ rule, and the kind of moderate split that would be easy to over-read.
|
||||
rather than untried, and the field is at least correctly *sized*. Assigning
|
||||
meanings almost certainly needs the game observed with individual bundles
|
||||
loaded, not more static correlation — every property visible in the file has now
|
||||
been tried.
|
||||
been tried.
|
||||
|
||||
## A leaf record's last keyframe time is the chunk terminator (2026-08-28)
|
||||
|
||||
The build placement region's "a group's data stops 4 bytes short of its final
|
||||
block's time slot — that word is already the next group's element index" has a
|
||||
second form in **leaf `.rat` records**, where there is no next group.
|
||||
|
||||
`ptbtn00f.rat` (`GP_TITLE` build 2, `RATC` at `0x04115e`) holds 8 keyframe blocks
|
||||
of the ordinary 40-byte layout starting at `+0x68`. The eighth block's time slot
|
||||
at `+0x1a4` contains **`end `** — the record's ASCII terminator. So the last
|
||||
keyframe's time is not merely unread, it is **not present**, and any animation's
|
||||
full cycle length has to come from measurement.
|
||||
|
||||
❔ The word at `+0x004` is **not** the keyframe count: `0x003c0000` (60) with 8
|
||||
keyframes here, `0x001e0000` (30) with 3 in `ptloop01.rat`/`ptloop02.rat`.
|
||||
|
||||
## ✅ `scale = 0` means COLLAPSED, not "unset" (2026-08-28)
|
||||
|
||||
`ui_layout::blit` and `fill_quad` both used to open with
|
||||
|
||||
```rust
|
||||
let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };
|
||||
```
|
||||
|
||||
so an element whose pose is *collapsed to nothing* was drawn at **full size**.
|
||||
(The `.max(1)` two lines below never saw a zero — see [METHOD.md](../METHOD.md).)
|
||||
|
||||
### The control, run before the change
|
||||
|
||||
If `0` meant "unset", some element would use it throughout. Over the disc
|
||||
([`data/zero-scale-census.txt`](../data/zero-scale-census.txt)):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| elements with a keyframe group | 15 493 |
|
||||
| at least one keyframe at scale 0 | 2 166 |
|
||||
| **every** keyframe at scale 0 | **0** |
|
||||
| zero is a transient the element grows back out of | **1 762** |
|
||||
|
||||
Not one element on the disc is zero-scale throughout, and 1 762 grow out of zero
|
||||
— `ptlogo_eff3.t32` runs `0 % → 200 %`. **An "unset" marker that nothing ever
|
||||
uses throughout is not a marker.** Zero means zero.
|
||||
|
||||
### The fix, and its reach
|
||||
|
||||
Both functions now return without drawing when either scale is 0.
|
||||
|
||||
* **Renders: 24 of 24 byte-identical** across `GP_TITLE` (all 16 builds),
|
||||
`GP_PAUSE_MENU` and `GP_OPTIONS`. The change is additive
|
||||
on every screen the port needs.
|
||||
* 195 elements have a guessed rest pose at scale 0; **126 of those had a non-zero
|
||||
alpha**, so the old code painted them at full size.
|
||||
* ⚠️ **All 126 are in `GP_READY_ROOM.pak`** — the archive
|
||||
[S1](../ready-room-probe.md) already declared a no-go. They are tactical-map
|
||||
sprites (`pbacropolis`, `pbFriendly`, `pbr_enemy`) at `sx=0, sy=100`, i.e.
|
||||
mid-horizontal-wipe, which the old coercion drew full-width.
|
||||
|
||||
🟡 So this is a **real correctness fix with no visible effect on the menu port**.
|
||||
It matters for anything that later reads `GP_READY_ROOM`, and it removes a case
|
||||
where the renderer contradicted the format.
|
||||
|
||||
185
docs/re/structures/ui-render-tone-curve.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 🟡 Our composite is brighter than the emulator's frame — measured, not decoded
|
||||
|
||||
**Status:** 🟡 **measured, with a narrow reach and a live confound.** Closes an
|
||||
observation left dangling by
|
||||
[ui-8ax-fullres-background](ui-8ax-fullres-background.md) ("the capture is ~4×
|
||||
darker than the render"), and puts a number on the ❔ that
|
||||
[INDEX](../INDEX.md)'s texture row already carried: *"exact fidelity
|
||||
(gamma/sRGB curve, premultiplied alpha, per-channel scale) is untested, since a
|
||||
hue comparison cannot see it."*
|
||||
|
||||
⚠️ **This is not a decode.** A port applying it is authoring a value.
|
||||
|
||||
## First: the geometry is right
|
||||
|
||||
Cross-correlating `live-main-menu.png` against our render over ±6 px finds the
|
||||
best alignment at exactly **dy = 0, dx = 0**, correlation **0.9466**. So the
|
||||
composite is in the right place at the right size and only the *tone* differs.
|
||||
(The capture is 1279×675 and top-aligned; that is the screenshot tool's crop.)
|
||||
|
||||
## 🔴 The first two methods were wrong, and both failed visibly
|
||||
|
||||
* **Three dark patches** gave "capture ≈ 4× darker". Over the whole frame the
|
||||
best linear scale is **0.914**. Three patches from one region are not a
|
||||
transfer curve.
|
||||
* **A pixel-wise fit** over 854 685 pixels produced a non-monotonic transfer
|
||||
(render 96–127 → capture *143*, brighter than render 128–159 → 132). That is
|
||||
the signature of **edge misalignment**, not of a tone curve: at a 0.947
|
||||
correlation a bright render pixel routinely lands on a dark capture pixel.
|
||||
Mean abs error was 10–14 for every model, which is the tell that none of them
|
||||
fit.
|
||||
|
||||
Both are recorded because the second is the interesting failure — a fit whose
|
||||
*residual* is large everywhere is not a model to choose between, it is a method
|
||||
to throw away.
|
||||
|
||||
## The method that works: flat patches only
|
||||
|
||||
16×16 patches where **both** images have `std < 8`, so local edges cannot
|
||||
contribute. The threshold is not arbitrary — at `std < 3` there are **zero**
|
||||
patches, and the count runs 0 / 83 / 404 / 1055 / 1788 for `std <`
|
||||
3 / 5 / 8 / 12 / 20.
|
||||
|
||||
| screen | flat patches | gamma exponent | mean abs err | best linear | its err |
|
||||
|---|---|---|---|---|---|
|
||||
| main menu | 404 | **1.491** | 0.28 | 0.276 | 0.34 |
|
||||
| `EXTRAS` | 382 | **1.493** | 0.22 | 0.273 | 0.28 |
|
||||
| title | 506 | **1.338** | 1.08 | 0.842 | 9.02 |
|
||||
|
||||
So `capture ≈ 255·(render/255)^γ` with **γ ≈ 1.34 – 1.49**.
|
||||
|
||||
## ⚠️ The reach — and it is narrow
|
||||
|
||||
* **The flat patches are almost all dark**: render values ~0–60. Over that range
|
||||
a gamma and a linear scale are nearly indistinguishable — on the two menus the
|
||||
errors are 0.28 vs 0.34 and 0.22 vs 0.28, which decides nothing. **Only the
|
||||
title separates them** (1.08 vs 9.02), because its flat regions reach ~60.
|
||||
* **The held-out control could not test it.** Running the same fit on the
|
||||
developer splash gives 2 918 flat patches whose render range is **0–4** — pure
|
||||
black. Every model scores ≈ 0.00 there. That is a control that failed to
|
||||
discriminate, not a control that passed.
|
||||
* **Nothing here constrains midtones or highlights**, which is exactly where a
|
||||
γ = 1.4 curve does its visible work.
|
||||
|
||||
## 🔴 The confound as I stated it is REFUTED — canary applies no gamma of its own
|
||||
|
||||
I wrote that "canary applies its own output transform: `kernel_display_gamma_type
|
||||
= 2` — BT.709". **That is not what the cvar does.** Reading the source:
|
||||
|
||||
```cpp
|
||||
void VdGetCurrentDisplayGamma_entry(lpdword_t type_ptr, lpfloat_t power_ptr) {
|
||||
// 1 - sRGB. 2 - TV (BT.709). 3 - use the power written to *power_ptr.
|
||||
// Anything else - linear.
|
||||
// Used in D3D SetGammaRamp/SetPWLGamma to adjust the ramp for the display.
|
||||
*type_ptr = cvars::kernel_display_gamma_type;
|
||||
...
|
||||
```
|
||||
|
||||
It is a **getter the guest calls** (`xboxkrnl_video.cc`, exported `kStub`). The
|
||||
cvar is a value *reported to the game*, which then builds its own ramp. The
|
||||
emulator's role is downstream and faithful:
|
||||
|
||||
* the guest writes its ramp to the `DC_LUT` registers;
|
||||
* `command_processor.cc` reads them into `gamma_ramp_256_entry_table_`;
|
||||
* the **swap** (present) path applies them —
|
||||
`swap_apply_gamma_pipeline_layout`, with `apply_gamma_table.ps` /
|
||||
`apply_gamma_pwl.ps` compiled in.
|
||||
|
||||
So there is no emulator-side BT.709 post-process to subtract. Any gamma in the
|
||||
captured frame is a ramp **the game installed**.
|
||||
|
||||
### 🟡 What that does and does not settle
|
||||
|
||||
✅ The stated confound is gone: the measured exponent is not an emulator artefact
|
||||
bolted onto the game's output.
|
||||
|
||||
✅ **The game DOES query the display gamma — measured 2026-08-29.** Booted with
|
||||
`--log_mask=12 --log_level=3` (Kernel logging on, Cpu/Gpu off), which changes
|
||||
nothing about the output. `VdGetCurrentDisplayGamma` is called **once at video
|
||||
init**, between the command-buffer setup and `VdSetDisplayMode`:
|
||||
|
||||
```
|
||||
d> F8000008 VdGetSystemCommandBuffer(701CF830, 701CF804)
|
||||
d> F8000008 VdGetCurrentDisplayGamma(701CE1F8(00000000), 701CE1F0(0))
|
||||
d> F8000008 VdSetDisplayMode(40000000)
|
||||
d> F8000008 VdGetCurrentDisplayInformation(701CF110)
|
||||
```
|
||||
|
||||
**The control is in the same log:** 359 `VdRetrainEDRAM` and 358
|
||||
`VdGetSystemCommandBuffer` lines, so an absent call would have been visible.
|
||||
Evidence in [`data/gamma-call-evidence.txt`](../data/gamma-call-evidence.txt).
|
||||
|
||||
Per the export's own comment the returned type is "used in D3D
|
||||
SetGammaRamp/SetPWLGamma", so the game asks the question a ramp-builder asks, at
|
||||
the moment one would ask it.
|
||||
|
||||
### 🟡 The ramp write: inferred from a closed chain, not directly observed
|
||||
|
||||
The write is a GPU register operation (`XE_GPU_REG_DC_LUT_RW_INDEX` in
|
||||
`CommandProcessor::WriteRegister`), invisible to kernel logging and unlogged in
|
||||
any case. But two facts from the source close the reasoning:
|
||||
|
||||
**1. The swap-path gamma stage is a pure LUT — nothing else.**
|
||||
`apply_gamma_table.xesli` is the whole transform:
|
||||
|
||||
```
|
||||
uint3 apply_gamma_input = uint3(texel_fetch(source, pixel).rgb * 255.0 + 0.5);
|
||||
apply_gamma_output.r = texel_fetch_buffer(xe_apply_gamma_ramp, input.r).b;
|
||||
… .g = …(input.g).g; … .b = …(input.b).r;
|
||||
```
|
||||
|
||||
An index into a 256-entry table. No sRGB encode, no second transfer function.
|
||||
|
||||
**2. The table defaults to IDENTITY.** `CommandProcessor::Initialize` fills it
|
||||
with `value = i * 0x3FF / 0xFF`, and its own comment says so: *"Initialize the
|
||||
gamma ramps to their default (linear) values — taken from what games set when
|
||||
starting with the sRGB (return value 1) `VdGetCurrentDisplayGamma`."* An unwritten
|
||||
ramp is a no-op.
|
||||
|
||||
So: the only transform is a LUT; the LUT is identity unless the guest writes it;
|
||||
the game queries the display gamma at init (measured above); and the captured
|
||||
frame differs from our composite by γ ≈ 1.34–1.49, which identity cannot produce.
|
||||
**⇒ the guest wrote a non-identity ramp.**
|
||||
|
||||
⚠️ **This is an inference, and here is its weak joint.** It assumes our composite
|
||||
faithfully reproduces the *pre-ramp* framebuffer, which it does not exactly — our
|
||||
renderer has its own inaccuracies. What makes it hold up is the *shape*: a
|
||||
systematic exponent near 1.4, fitted on flat patches, consistent across three
|
||||
screens, is not the signature of a compositor bug.
|
||||
|
||||
A fixed sRGB stage elsewhere in the presenter is the obvious alternative and does
|
||||
not fit: an sRGB **encode** (≈ `^0.45`) brightens, and we measured darkening; an
|
||||
sRGB **decode** (`^2.2`) darkens far more than 1.4.
|
||||
|
||||
✅ **Direct observation is still available and cheap**, and needs the emulator only
|
||||
to boot: a GPU trace records gamma ramps as their own command type
|
||||
(`TraceWriter::WriteGammaRamp`), or one log line at
|
||||
`XE_GPU_REG_DC_LUT_RW_INDEX` would settle it outright. Not done.
|
||||
|
||||
⚠️ And the ramp a game builds depends on the display type it is *told*. Canary
|
||||
hard-codes `2` (TV/BT.709); on hardware that is the console's display setting. So
|
||||
the exponent is display-profile dependent **by design**, not a fixed property of
|
||||
the game.
|
||||
|
||||
### ✅ The corrected next experiment
|
||||
|
||||
My planned run — set `kernel_display_gamma_type = 0` and re-fit — was the wrong
|
||||
design: it changes what the *guest* is told and therefore which ramp the *game*
|
||||
builds, so it could never isolate an emulator stage that does not exist. It also
|
||||
perturbs the capture harness, because `skip_intro.sh` classifies movie-vs-static
|
||||
on an **absolute** rmse threshold and a brighter frame biases it (see
|
||||
[capture-harness-status](../capture-harness-status.md)).
|
||||
|
||||
The right run changes nothing about the output: boot with `LOG_MASK=12
|
||||
LOG_LEVEL=3` (both are needed — kernel calls log at Debug) and look for
|
||||
`VdGetCurrentDisplayGamma` and the `DC_LUT` writes. Same frames, no perturbation.
|
||||
✅ **Run, and it answered the first half** — see above. ⚠️ And note it needed the
|
||||
emulator only to **boot**, not to reach a menu: video init happens in the first
|
||||
seconds. This had been parked behind the title-screen blocker for no reason.
|
||||
|
||||
## What a port should do with this
|
||||
|
||||
Treat it as **authored**, not transcribed. If the goal is to match the emulator —
|
||||
which is what every capture in this corpus is — a γ ≈ 1.4 darkening of the
|
||||
composite gets closer, and is best applied where it was measured (the dark
|
||||
background), not extrapolated to the whole range on this evidence.
|
||||
@@ -153,3 +153,500 @@ invisible — in the literal sense — until `fade` is applied.
|
||||
0 and never hold at a visible value, so the plateau rule rests them at alpha 0
|
||||
— invisible. That is *probably* right for a settled screen, but the capture
|
||||
cannot confirm it while `fade` is unapplied.
|
||||
|
||||
|
||||
## 🔴 `rest_plateau` is wrong for elements with no exit animation (2026-08-28)
|
||||
|
||||
Reported by the port agent with a capture that proves it, and it affects
|
||||
`sylpheed-cli screen render` too — this is not only a port concern.
|
||||
|
||||
`rest_plateau` drops a trailing run of identical keyframes because that run is
|
||||
normally the **exit** animation. On an element that has **no exit**, the trailing
|
||||
run *is* the hold, and dropping it puts the element back at its **first**
|
||||
keyframe — off-position and transparent.
|
||||
|
||||
**The condition that identifies these exactly** (no false positives across the
|
||||
port's whole export):
|
||||
|
||||
> the final untimed keyframe has the same pose as the last timed one
|
||||
|
||||
**Six elements** on `main_menu` match it and `rest()` misses all six. The
|
||||
visible cost: on `main_menu` the timeline render and the `rest` render differ in
|
||||
exactly one region — **400 × 470 at (440,108)**, the bounding box of `ptframe1`
|
||||
and `ptframe2` and nothing else. That is the **bright circuit bracket around the
|
||||
menu**, plainly present in
|
||||
[`../captures/main-menu-oracle.png`](../captures/main-menu-oracle.png) and absent
|
||||
from the `rest` render. Cropping the same region from the capture and from both
|
||||
renders puts the ring and its elbow trace pixel-aligned with the game's in the
|
||||
timeline render.
|
||||
|
||||
This also explains a long-standing ❔ on
|
||||
[`ui-paint-order-key.md`](ui-paint-order-key.md): *"`ptframe1`/`ptframe2` rest at
|
||||
`0x00ffffff` (alpha 0) and are therefore not drawn, but the capture shows the menu
|
||||
frame plainly."* Same two elements, same cause — now identified.
|
||||
|
||||
## ✅ Fixed 2026-08-28 — but the condition is the ALPHA, not the pose
|
||||
|
||||
The report's proposed test — *"the final untimed keyframe has the same pose as the
|
||||
last timed one"* — **misfires**, and on the exact case the exclusion was written
|
||||
for. `pgptitle.rat`'s last two keyframes are also identical:
|
||||
|
||||
```
|
||||
pgptitle.rat kf4: fade=0x00ffffff pos=(220,69) t=30
|
||||
kf5: fade=0x00ffffff pos=(220,69) t=None <- same pose
|
||||
```
|
||||
|
||||
Adopting it as stated would erase the word PAUSE again. What separates the two is
|
||||
**visibility**:
|
||||
|
||||
| | trailing run | alpha | is it the hold? |
|
||||
|---|---|---|---|
|
||||
| `ptframe1`/`ptframe2` (main menu) | 3 × `0xffffffff` at (440,108) | `0xff` | **yes** — no exit animation |
|
||||
| `pgptitle` (pause menu) | 2 × `0x00ffffff` | `0x00` | no — it is the fade-out |
|
||||
|
||||
An exit fades the element out, so its last keyframe is transparent; an element
|
||||
with no exit ends on the pose you can see. **So a trailing run is the hold exactly
|
||||
when it is visible**, and that is what `rest_plateau` now tests.
|
||||
|
||||
### Verified against a capture, not against another renderer
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| `ptframe1` rest | `(620,108) t=16` → **`(440,108) t=62`** |
|
||||
| bracket region draws | mean 61.74 → **62.79** |
|
||||
| **oracle correlation** over that region vs [`main-menu-oracle.png`](../captures/main-menu-oracle.png) | **0.9596 → 0.9748** |
|
||||
| pixels changed, whole frame | 10 082, bounding box **x 440–839, y 108–577** — exactly the 400 × 470 at (440,108) the report predicted |
|
||||
| **regression control**: PAUSE wordmark, 3 pause builds | **unchanged** (2833 / 2858 / 2833 bright px) |
|
||||
|
||||
✅ **`cargo test -p sylpheed-formats` with `SYLPHEED_DISC` set: 131 passed, 0
|
||||
failed** across 6 binaries including the disc-gated ones. (One pre-existing
|
||||
`ignored` — the XBG7 shared-resource test — unrelated.)
|
||||
|
||||
### ✅ The disc-wide check
|
||||
|
||||
Both rules reimplemented over the parsed keyframes of **every** `RATC` bundle on
|
||||
the disc — 2 859 bundles, 13 991 elements with ≥ 2 keyframes:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| elements whose `rest` moves | **30** (0.21 %) |
|
||||
| invisible → visible | **4** |
|
||||
| **visible → invisible** | **0** — the safety property |
|
||||
|
||||
The change is surgical and it never hides something that was being drawn. The
|
||||
remaining 26 move `rest` between two *visible* poses (position, not visibility).
|
||||
|
||||
The 4 revealed are `ptframe1.t32` and `ptframe2.t32` in `GP_TITLE` **entry 5 and
|
||||
entry 8** — the same two elements, once per language build.
|
||||
|
||||
### 🟡 Reconciling with the report's "six elements"
|
||||
|
||||
On the **English main menu exactly two** elements satisfy the report's condition
|
||||
(last two keyframes identical), and both are `ptframe1`/`ptframe2`. So the six
|
||||
must span its whole 12-screen export, not that one screen — consistent with its
|
||||
own observation that the timeline and `rest` differ *"in exactly one region …
|
||||
the bounding box of `ptframe1`/`ptframe2` and nothing else"*.
|
||||
|
||||
❔ **Open, and it matters:** if any of the other four have a **transparent**
|
||||
trailing run, this rule deliberately leaves them alone — that exclusion is what
|
||||
protects the word PAUSE. If a capture shows one of them drawn, the alpha rule is
|
||||
incomplete and needs a third discriminator. **Which screens are they on?**
|
||||
|
||||
---
|
||||
|
||||
## 🔴 The dwell fallback is unsound whenever it actually runs (2026-08-28)
|
||||
|
||||
`rest()` tries `rest_plateau()` first and, failing that, picks the keyframe with
|
||||
the longest **dwell** — the largest gap `t[k+1] − t[k]`. That rule is not sound,
|
||||
and the reason is structural rather than a tuning problem.
|
||||
|
||||
A gap between `t[k]` and `t[k+1]` is time the element spends **interpolating from
|
||||
pose `k` to pose `k+1`**. Neither pose is *held* during it — unless the two poses
|
||||
are equal, which is exactly a plateau, and the plateau path has already handled
|
||||
that case and returned. **So by the time the fallback runs, it is guaranteed that
|
||||
no pose is held, and the rule is choosing an endpoint of a movement.**
|
||||
|
||||
### The element that exposed it
|
||||
|
||||
`GP_TITLE` build 7, `ptlogo_eff3.t32` — a transient bloom:
|
||||
|
||||
```
|
||||
46: (98,42) 100%,100% a=0
|
||||
61: (108,72) 0%,0% a=0
|
||||
103: (108,72) 200%,200% a=255 r=80
|
||||
-: (108,72) 0%,0% a=0 r=150
|
||||
```
|
||||
|
||||
No two adjacent poses are equal, so there is no plateau. The longest gap is
|
||||
`61 → 103` (42 units), during which the sprite grows from nothing to **200 %** at
|
||||
full alpha while rotating 80°, then collapses again. The rule returns whichever
|
||||
end of that movement the indexing lands on:
|
||||
|
||||
| | returned "rest pose" |
|
||||
|---|---|
|
||||
| as decoded | `(108,72) 0%,0% a=0` — invisible |
|
||||
| with `SYLPHEED_KF_TIME_SHIFT=1` | `(108,72) 200%,200% a=255` — the peak |
|
||||
|
||||
An 896×389 sprite at 200 % scale is 1792×778 — larger than the screen. Painting
|
||||
it permanently is what made build 7's render 13.1 % different and 4.9 luminance
|
||||
units brighter.
|
||||
|
||||
**The element has no resting pose.** It is a flash; after it plays there is
|
||||
nothing. Neither answer is *derived* — one of them is merely harmless.
|
||||
|
||||
### 🔴 What this retracts
|
||||
|
||||
Last iteration I reported the build 7 render difference as evidence **against**
|
||||
the keyframe-time shift, on the reasoning that language twins should match in
|
||||
brightness. **Withdrawn.** The difference is not about the time association at
|
||||
all: it is the dwell fallback guessing, and it would guess on this element under
|
||||
any reading of the times. The brightness comparison was measuring a heuristic,
|
||||
not a decode.
|
||||
|
||||
What that leaves: the case *for* the shift (a factor of 26 on the hold:fade-out
|
||||
ratio, [`ui-keyframe-time-unit.md`](../ui-keyframe-time-unit.md)) is no longer
|
||||
opposed by render evidence — 10 of 11 builds are byte-identical and the 11th
|
||||
differs only through an unsound heuristic.
|
||||
|
||||
🟡 **It is still not adopted**, for a different reason than before: adopting it
|
||||
would flip this element to the visibly wrong answer, so the shift and a decision
|
||||
about what `rest()` should do for plateau-less elements have to land together,
|
||||
and the second half has no capture to verify against either.
|
||||
|
||||
### ⚠️ What the port should take from this
|
||||
|
||||
Any element whose keyframes contain **no two adjacent identical poses** has a
|
||||
`rest()` result that is guessed, not decoded — in our renderer and in anything
|
||||
built from it. That is a property a consumer can test for itself in one pass over
|
||||
the keyframes, and it is worth flagging in an export rather than silently
|
||||
inheriting our guess.
|
||||
|
||||
## The size of the defect, and why it does not block the menu port
|
||||
|
||||
[`tools/re-capture/plateau_census.py`](../../../tools/re-capture/plateau_census.py)
|
||||
walks every `GP_*.pak` placement region directly (the CLI route decodes every
|
||||
texture and is far too slow for a disc-wide pass). Its control reproduces
|
||||
`GP_TITLE` build 7's three fallback elements and names `ptlogo_eff3.t32` among
|
||||
them before it counts anything. Output: [`data/plateau-census.txt`](../data/plateau-census.txt).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| elements with a keyframe group, disc-wide | **15 493** |
|
||||
| no plateau | 3 807 (24.57 %) — ⚠️ **overstated, see the correction below** |
|
||||
| …of those, **single-keyframe** (trivially at rest, not a guess) | **1 502** |
|
||||
| **genuinely ambiguous** (2+ keyframes, no plateau) | **2 305 (14.88 %)** |
|
||||
| of those, current rule returns an **invisible** pose | 1 711 (44.9 %) |
|
||||
| of those, current rule returns a **zero-scale** pose | **195 (5.1 %)** |
|
||||
| the two candidate rules **agree** | 1 911 (50.2 %) |
|
||||
|
||||
A returned pose with `scale = 0 %` is not a pose at all, and 195 elements get
|
||||
one. Disc-wide the choice of rule is not cosmetic: the candidates agree only half
|
||||
the time.
|
||||
|
||||
### ✅ But on the five screens the port needs, the exposure is one element
|
||||
|
||||
| screen | plateau-less | rules differ |
|
||||
|---|---|---|
|
||||
| main menu (entry 5) | 5 / 16 | 0 |
|
||||
| `EXTRAS` (entry 6) | 5 / 18 | 0 |
|
||||
| title (entry 4) | 2 / 24 | 0 |
|
||||
| developer splash (entry 11) | 2 / 7 | **1** |
|
||||
|
||||
Thirteen of the fourteen affected elements get the same answer either way. The
|
||||
one disagreement is `palogo_anima_eff.t32`.
|
||||
|
||||
### 🔴 And "rest = last keyframe" is refuted on it
|
||||
|
||||
That was the alternative I named last iteration. The developer splash carries
|
||||
**three sibling glows**, identical in structure and in every time:
|
||||
|
||||
```
|
||||
palogo_gamearts_eff 15:a=0 30:a=255 45:a=255 -:a=0 → plateau → visible
|
||||
palogo_seta_eff 15:a=0 30:a=255 45:a=255 -:a=0 → plateau → visible
|
||||
palogo_anima_eff 15:a=0 30:a=255 45:a=212 -:a=0 → no plateau
|
||||
```
|
||||
|
||||
They differ in **one byte** — `212` where the others have `255`. Under "last
|
||||
keyframe", `anima_eff` alone goes invisible while its two siblings stay lit. A
|
||||
rule that makes one of three parallel elements behave differently because of a
|
||||
single alpha count is producing an artefact, not a decode.
|
||||
|
||||
The capture agrees weakly. Comparing box means in
|
||||
[`live-splash-developer.png`](../captures/title-builds/live-splash-developer.png)
|
||||
against our render (the screenshot is 1279×675, top-aligned, so only ratios are
|
||||
comparable): gamearts **0.717**, seta **0.723**, anima **0.772**. If our render
|
||||
were adding a glow the game does not draw, anima's ratio would sit *below* its
|
||||
siblings'. It sits above.
|
||||
|
||||
### 🟡 Where this leaves it
|
||||
|
||||
The **defect** is established and measured: `rest()` guesses for **14.88 %** of
|
||||
elements disc-wide and returns a degenerate zero-scale pose for 195 of them.
|
||||
The **fix** is not decided — "last keyframe" is refuted, and the current rule
|
||||
survives on the only captured element that discriminates. ⚠️ For the menu port
|
||||
specifically this is **not a blocker**: one element on one screen, and our
|
||||
current answer for it is the defensible one.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🔴 The 24.57 % was overstated, and my proposed fix is refuted
|
||||
|
||||
**2026-08-29.** Two corrections, both from one experiment.
|
||||
|
||||
### The prediction: draw nothing 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** (title),
|
||||
**8/16** (main menu), **12/18** (`EXTRAS`), **2/3** and **6/7** (splashes). So
|
||||
the screen "as seen" is the *hold*, which is why `rest_plateau` is the right
|
||||
primary rule and why "rest = the last keyframe" would empty every screen.
|
||||
|
||||
That suggested a fix: an element with **no** hold has no representative pose, so
|
||||
draw nothing rather than guess an endpoint. Tested via `compose`'s `visible` mask
|
||||
(`examples/plateauless_suppression.rs`), correlated against the live captures:
|
||||
|
||||
| screen | as-is | plateau-less suppressed | Δ |
|
||||
|---|---|---|---|
|
||||
| title | +0.9500 | +0.6839 | **−0.2661** |
|
||||
| main menu | +0.9460 | +0.9037 | −0.0423 |
|
||||
| `EXTRAS` | +0.9440 | +0.9094 | −0.0346 |
|
||||
|
||||
**Refuted, decisively, on all three.**
|
||||
|
||||
### Why — and it invalidates the headline number
|
||||
|
||||
An element with a **single keyframe** has no *adjacent pair*, so the plateau test
|
||||
marks it plateau-less. But a single-keyframe element is not animated at all: its
|
||||
one pose *is* its rest, unambiguously. Suppressing those removes backgrounds and
|
||||
full-screen layers, which is where the title's −0.27 comes from.
|
||||
|
||||
The same flaw is in the census this page published:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| no plateau (as published) | 3 807 (24.57 %) |
|
||||
| …of which **single-keyframe** | **1 502** — trivially at rest |
|
||||
| **genuinely ambiguous** | **2 305 (14.88 %)** |
|
||||
|
||||
So the guessed-rest population is **2 305, not 3 807** — the published figure
|
||||
overstated it by **65 %**. The defect is real and smaller than reported.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Where the fallback actually fires — and "last keyframe" wins there
|
||||
|
||||
**2026-08-29.** Two things fell out of scoring rules by rendering instead of
|
||||
arguing about them.
|
||||
|
||||
### The port's exposure is TWO elements, not fourteen
|
||||
|
||||
The guessing fallback is reached only by an element that is plateau-less **and**
|
||||
has more than one keyframe — a single-keyframe element short-circuits at
|
||||
`match len { 1 => first }`. Per screen:
|
||||
|
||||
| screen | elements | plateau-less | **reach the fallback** |
|
||||
|---|---|---|---|
|
||||
| title (4) | 24 | 2 | **0** |
|
||||
| main menu (5) | 16 | 5 | **0** |
|
||||
| `EXTRAS` (6) | 18 | 5 | **0** |
|
||||
| publisher splash (10) | 3 | 2 | **1** |
|
||||
| developer splash (11) | 7 | 2 | **1** |
|
||||
|
||||
On the three screens a port cares most about, **`rest()` never guesses at all**.
|
||||
That is why three different fallback rules render builds 4, 5 and 6 to *identical*
|
||||
correlations (+0.9500 / +0.9460 / +0.9440 for every rule) — the code is
|
||||
unreachable there.
|
||||
|
||||
### On the two splashes, the last keyframe is markedly better
|
||||
|
||||
Rendering under each rule (`SYLPHEED_REST_RULE=last|maxalpha`, default off) and
|
||||
correlating against the live captures:
|
||||
|
||||
| splash | dwell (current) | **last** | maxalpha |
|
||||
|---|---|---|---|
|
||||
| publisher | +0.9600 | **+0.9982** | +0.9600 |
|
||||
| developer | +0.9643 | **+0.9758** | +0.9643 |
|
||||
|
||||
`maxalpha` is indistinguishable from the current rule; **`last` gains +0.038 and
|
||||
+0.012**, and 0.9982 is very close to exact.
|
||||
|
||||
### 🔴 This refutes my own refutation
|
||||
|
||||
I had killed "rest = the last keyframe" by argument: on the developer splash it
|
||||
makes `palogo_anima_eff` invisible while its two siblings stay lit, which looked
|
||||
like an artefact. **The capture says otherwise** — making it invisible is what
|
||||
improves the match. The sibling symmetry was my expectation, not evidence.
|
||||
|
||||
⚠️ **The caveat that keeps this honest:** both captures are *single frames of a
|
||||
transient animation*. They fix which pose matches **these reference frames**, not
|
||||
which pose is canonically "at rest" — a grab a second earlier would show the
|
||||
glows. What the measurement supports is: for reproducing the committed captures,
|
||||
use the last keyframe.
|
||||
|
||||
🟡 **Default unchanged.** The rule is better on both screens where it fires and
|
||||
provably identical on the other three, but it would alter the rest pose of
|
||||
**2 305** elements disc-wide and two of them have been measured. Reachable via
|
||||
`SYLPHEED_REST_RULE=last` for anyone who wants to take that further.
|
||||
|
||||
✅ Unaffected by the earlier correction: all **195** zero-scale rest poses are
|
||||
inside the genuinely-ambiguous 2 305 — none is a single-keyframe element.
|
||||
|
||||
### The disc-wide blast radius — measurable even though the rule is not scorable there
|
||||
|
||||
Only two ambiguous elements sit on a screen with a live capture, so the rule
|
||||
cannot be *scored* disc-wide. What can be quantified is what changing it would
|
||||
do ([`tools/re-capture/rest_rule_blast_radius.py`](../../../tools/re-capture/rest_rule_blast_radius.py),
|
||||
[data](../data/rest-rule-blast-radius.txt)):
|
||||
|
||||
| | dwell (current) | `last` |
|
||||
|---|---|---|
|
||||
| the two rules **differ** | **1 896 of 2 305 (82.3 %)** | |
|
||||
| returns an **invisible** pose | 1 711 (74.2 %) | 1 618 (70.2 %) |
|
||||
| returns a **zero-scale** pose | **195 (8.5 %)** | **43 (1.9 %)** |
|
||||
|
||||
Two things worth reading off this.
|
||||
|
||||
**It is not a marginal choice.** The rules disagree on 82 % of the affected
|
||||
elements, so "either is fine" is not available.
|
||||
|
||||
**The current rule produces 4.5× more degenerate poses.** A zero-scale pose is
|
||||
*collapsed to nothing* ([rat layout](ui-rat-layout.md)) — an element's **pre-roll**,
|
||||
before it has grown in. A pre-roll is definitionally not a rest, so 195 elements
|
||||
currently rest at a frame the element is only passing through, against 43 under
|
||||
`last`. That is an argument from the data's own structure rather than from the
|
||||
two captures, and it points the same way they do.
|
||||
|
||||
🟡 It remains **indirect**: fewer degenerate results is not the same as more
|
||||
correct results, and `last` still returns an invisible pose 70 % of the time —
|
||||
which for a transient element may well be right, and for a persistent one would
|
||||
be wrong. The default stays put.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Why `rest_plateau` is right, and why `last` is right only for a transient
|
||||
|
||||
**2026-08-29.** The shifted keyframe-time reading
|
||||
([time unit](../ui-keyframe-time-unit.md)) appears to predict something simple:
|
||||
the final pose is reached at a definite time and nothing follows, so "rest"
|
||||
should just be the last keyframe, with no heuristic at all. Tested
|
||||
(`SYLPHEED_REST_RULE=lastall`, bypassing the plateau rule for **every** element):
|
||||
|
||||
| screen | current | `lastall` | Δ |
|
||||
|---|---|---|---|
|
||||
| title | +0.9500 | +0.6819 | **−0.2681** |
|
||||
| main menu | +0.9460 | +0.6416 | **−0.3044** |
|
||||
| `EXTRAS` | +0.9440 | +0.5745 | **−0.3695** |
|
||||
| publisher splash | +0.9600 | **blank** | — |
|
||||
| developer splash | +0.9643 | **blank** | — |
|
||||
|
||||
The splashes render *entirely empty* — zero variance, so the correlation is not
|
||||
even defined. **Refuted.**
|
||||
|
||||
### What the failure shows
|
||||
|
||||
A group's keyframes describe **entry → hold → exit**, and the exit is the
|
||||
screen's *dismissal*. While a screen is on display it has not reached its last
|
||||
keyframe; it is sitting at the hold, and the exit plays only when the screen
|
||||
leaves. That is why:
|
||||
|
||||
* `rest_plateau` is the correct primary rule — the hold is where the screen
|
||||
lives, and it is what a capture of a displayed screen shows;
|
||||
* the last keyframe is the **post-exit** state, correct only at the moment the
|
||||
screen is already gone — which is why applying it everywhere blanks the
|
||||
splashes;
|
||||
* and it does **not** contradict the shifted time reading. That reading says when
|
||||
each pose is reached; it says nothing about the group being played to
|
||||
completion while the screen is still up.
|
||||
|
||||
### And it explains why `last` wins for the plateau-less two
|
||||
|
||||
An element with **no hold** is a transient: it flashes and is over. At any
|
||||
settled moment it is *gone*, and "gone" is its last keyframe. That is not a
|
||||
general rule about rest — it is what a transient's settled state *is*.
|
||||
|
||||
The draw capture says exactly this independently: on the developer splash the
|
||||
`_eff` glows are drawn on frames **94–115** and the logos on **116–211**. The
|
||||
glows are already finished when the logos are up, so a capture of the logo
|
||||
moment must show them invisible — which is what `SYLPHEED_REST_RULE=last`
|
||||
produces, and why it scored +0.9982 and +0.9758.
|
||||
|
||||
✅ So the picture is coherent across three independent observables — animation
|
||||
timing, static composites, and the per-frame draw log — and the rule it supports
|
||||
is: **plateau where there is one, last keyframe where there is not.**
|
||||
|
||||
### ✅ The structural case for `last`, disc-wide — 2 293 of 2 305
|
||||
|
||||
Only two ambiguous elements could be *scored* against a capture. But the
|
||||
entry → hold → exit model makes a prediction that can be checked on all 2 305:
|
||||
what does each element's **final** keyframe look like?
|
||||
([`tools/re-capture/plateauless_endstate.py`](../../../tools/re-capture/plateauless_endstate.py),
|
||||
[data](../data/plateauless-endstate.txt))
|
||||
|
||||
| final keyframe | count | is `last` the right answer? |
|
||||
|---|---|---|
|
||||
| **invisible** (`a = 0`) | **1 618** | the element is a transient; at any settled moment it is *gone* — ✅ model-consistent, and confirmed by the draw log for the two observed |
|
||||
| **visible, at maximum alpha** | **675** | it faded in and stopped — the final pose *is* the settled one ✅ |
|
||||
| visible, **below** maximum alpha | **12** | ❔ genuinely unclear |
|
||||
|
||||
Of the 687 that end visible, **472 have monotonically non-decreasing alpha** — a
|
||||
plain fade-in that stops, `[0, 255]` over two keyframes in the commonest case
|
||||
(`pjex_eff.rat`, `pghud_speed_cut.t32`) — and another 203 end at their maximum
|
||||
after dipping. Only **12 elements in 2 305** end visible at less than full alpha.
|
||||
|
||||
So `last` is **structurally defensible for 2 293 of 2 305 (99.5 %)**, against a
|
||||
dwell rule that returns a mid-movement frame by construction. It is *observed*
|
||||
correct for 2, *structural* for 675, *model-consistent* for 1 618, and unclear
|
||||
for 12.
|
||||
|
||||
⚠️ The 1 618 carry the weight of an assumption, and it should be visible: that a
|
||||
plateau-less element's animation has finished by the time the screen is settled.
|
||||
The draw log establishes this for the two splash glows (drawn on frames 94–115,
|
||||
with the logos on 116–211) and nothing establishes it for the rest.
|
||||
|
||||
---
|
||||
|
||||
## ✅ A static "rest" composite is only meaningful for a screen that SETTLES
|
||||
|
||||
**2026-08-29.** The model's sharpest prediction, tested with its control.
|
||||
|
||||
The draw log says that on the developer splash the `_eff` glows are drawn on
|
||||
frames **94–115** and the logos on **116–211** — so at the moment the reference
|
||||
capture was taken, *every* glow is already finished, including the two that have
|
||||
plateaus and which `rest_plateau` therefore renders visible. Suppressing them
|
||||
should improve the match. Suppressing them on a screen that genuinely settles
|
||||
should make it worse.
|
||||
|
||||
| screen | as-is | `eff` elements suppressed | Δ |
|
||||
|---|---|---|---|
|
||||
| **publisher splash** | +0.9604 | **+0.9982** | **+0.0377** |
|
||||
| **developer splash** | +0.9659 | **+0.9980** | **+0.0321** |
|
||||
| title *(control)* | +0.9500 | +0.9480 | −0.0020 |
|
||||
| main menu *(control)* | +0.9460 | +0.8544 | **−0.0916** |
|
||||
| `EXTRAS` *(control)* | +0.9440 | +0.8370 | **−0.1070** |
|
||||
|
||||
Both splashes jump to **≈0.998**; all three persistent screens get worse. The
|
||||
control is what makes this a finding rather than a coincidence — the same edit
|
||||
helps on exactly the screens the model says it should and hurts on exactly the
|
||||
ones it says it should not.
|
||||
|
||||
### What this settles
|
||||
|
||||
**`rest_plateau` is not over-drawing in general — it over-draws on transient
|
||||
screens.** A plateau in the middle of an animation is evidence the element is
|
||||
held *at that point in the timeline*, not that it is on screen when the screen
|
||||
has settled. On a screen that settles, the held pose is the settled pose and the
|
||||
rule is right (the controls). On a screen that plays through and leaves, there is
|
||||
no settled moment at all, and the reference capture is a picture of one arbitrary
|
||||
frame.
|
||||
|
||||
⚠️ **So the honest answer to "what is the rest pose of a transient screen" is:
|
||||
there isn't one.** The splashes are animations. A static composite of them can
|
||||
match a chosen frame — ≈0.998 for the frame these captures hold — but "the
|
||||
resting pose" is a question the format does not answer because the screen never
|
||||
rests.
|
||||
|
||||
✅ For the port: **play the timeline for the splashes**, which the settled
|
||||
keyframe timing now supports ([time unit](../ui-keyframe-time-unit.md)). Use a
|
||||
static composite for the title, main menu and `EXTRAS`, where the screen does
|
||||
settle and `rest_plateau` is measurably right.
|
||||
|
||||
@@ -507,7 +507,7 @@ run scan that builds the candidate list — which does not emit a start for thes
|
||||
resources' real vertex buffers. That is where the remaining 51 live.
|
||||
|
||||
The ignored test
|
||||
[`mesh_consistency_disc.rs`](../../crates/sylpheed-formats/tests/mesh_consistency_disc.rs)
|
||||
[`mesh_consistency_disc.rs`](../../../crates/sylpheed-formats/tests/mesh_consistency_disc.rs)
|
||||
still asserts the target state and now records 63 rather than 125; the remaining
|
||||
cases are where the neighbourhood is itself wrong or absent.
|
||||
|
||||
|
||||
78
docs/re/test-suite-runtime.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 🟡 `build-reborn test` takes about an hour — and I left two runaways
|
||||
|
||||
**Status:** ✅ measured. Two facts, one of them my own mess.
|
||||
|
||||
## The gate the brief names does not complete
|
||||
|
||||
`build-reborn test` (`cargo test --workspace` with `SYLPHEED_DISC` wired up) is
|
||||
the verification gate. It contains a disc test that decodes **every `.xpr` in
|
||||
`hidden/resource3d` — 166 files, 1.4 GB** — through the full `Xbg7Model`
|
||||
anchoring path:
|
||||
|
||||
```rust
|
||||
#[test] // NOT #[ignore]d
|
||||
fn twin_pairs_do_not_share_a_buffer() {
|
||||
…for f in &files { … Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) … }
|
||||
```
|
||||
|
||||
Measured: one instance accumulated **3 h 26 m of CPU at 89 %** without finishing.
|
||||
|
||||
### 🔴 "Cannot terminate" was too strong — corrected
|
||||
|
||||
It terminates; it is merely heavy, and I said the stronger thing before measuring
|
||||
it. Timing `sylpheed-cli mesh info` on each of the 166 files with a 25 s cap
|
||||
([`data/slow-xpr-files.txt`](data/slow-xpr-files.txt)):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| files scanned | 166 |
|
||||
| exceeding 25 s | **19** — `Hangar`, 17 `Stage_*`, `ptc_pack` |
|
||||
| `Stage_S02` timed to completion | **144 s**, `rc = 0` |
|
||||
|
||||
So nothing hangs. Nineteen heavy containers at roughly two minutes each is
|
||||
**~45–60 minutes** for one pass, before the 147 fast ones. The observed 3 h 26 m
|
||||
was that work running at a **load average of 9–14** — inflated by my own two
|
||||
duplicate runs competing with each other and with this one. The runaways did not
|
||||
merely coexist with the slowness; they multiplied it.
|
||||
|
||||
⚠️ The practical conclusion is unchanged and only the wording softens: an
|
||||
hour-scale suite is not an iteration-scale gate.
|
||||
Its sibling in the same file, `shared_resources_decode_identically_in_every_container`,
|
||||
walks the same 166 files and *is* `#[ignore]`d (as known-failing), so the binary's
|
||||
cost is easy to underestimate from a glance at the file.
|
||||
|
||||
⚠️ **Consequence for anyone using this gate:** every "green run" reported in this
|
||||
corpus from a workspace test is necessarily **partial** unless it explicitly says
|
||||
the suite terminated. `cargo test -p sylpheed-formats` reaches ~131 tests in a few
|
||||
minutes and then sits in this one; the workspace run is the same picture. Quote
|
||||
the suite count and the elapsed state, not the word "green".
|
||||
|
||||
🟡 Not proposed here: `#[ignore]`-ing it, or bounding it to a sample of the 166.
|
||||
Both change what the suite asserts, which is the project's call and not a
|
||||
side-effect of an audit.
|
||||
|
||||
## 🔴 And I left two of them running for four hours
|
||||
|
||||
Two `cargo test -p sylpheed-formats` runs launched detached in earlier iterations
|
||||
never exited, because they were sitting in this test. At the moment they were
|
||||
found:
|
||||
|
||||
| pid | elapsed | child | CPU time | %CPU |
|
||||
|---|---|---|---|---|
|
||||
| 103375 | 4 h 12 m | `mesh_consistency_disc` | **3 h 26 m** | 89.3 |
|
||||
| 99965 | 4 h 39 m | `pak_idxd_disc` | 1 h 16 m | 93.8 |
|
||||
|
||||
Load average **14.18** on 12 cores. Killed; load fell to **9.68** within a
|
||||
minute, and the legitimately-running workspace suite was checked first and left
|
||||
alone.
|
||||
|
||||
### 🔴 What this does NOT explain
|
||||
|
||||
It is tempting to pin the session's emulator troubles on this, and the
|
||||
measurement says no. `screenshot` cost **0.49 s** with both runaways live and the
|
||||
emulator stopped, against the **10.8 s** measured earlier with the emulator
|
||||
running. So the 92× figure in
|
||||
[capture-harness-status](capture-harness-status.md) really was emulator
|
||||
contention, and the runaways were a background tax on top — not the cause. The
|
||||
black surface and the unreachable title stand as measured, with their own
|
||||
controls.
|
||||
@@ -109,14 +109,46 @@ fit are:
|
||||
t = 16 … 269) is then **4.2 s**, and the main menu build (build 5, t = 12 … 80)
|
||||
**1.1 s**.
|
||||
|
||||
**The reach of this negative:** the present rate was measured *during the boot
|
||||
splash*, where the guest is also streaming from the ISO, so it is a lower bound on
|
||||
the emulator's speed and cannot by itself exclude the 60 Hz reading. What would
|
||||
settle it is the same 300-frame timing taken on the **idle title screen**, where
|
||||
nothing is loading — if that also comes out near 28 fps the game is 30 Hz and the
|
||||
unit is 1/60 s; if it doubles to ~55 fps the game is 60 Hz and every duration on
|
||||
this page halves. Until then the port should treat **2 units per rendered frame**
|
||||
as the measured fact and `1/60 s` as the working conversion.
|
||||
**The reach of this negative** *(as written before the test below — kept for the
|
||||
reasoning)*: the present rate was measured *during the boot splash*, where the
|
||||
guest is also streaming from the ISO, so it is a lower bound on the emulator's
|
||||
speed and cannot by itself exclude the 60 Hz reading. What would settle it is the
|
||||
same 300-frame timing taken on the **idle title screen**, where nothing is
|
||||
loading — if that also comes out near 28 fps the game is 30 Hz and the unit is
|
||||
1/60 s; if it doubles to ~55 fps the game is 60 Hz and every duration on this page
|
||||
halves.
|
||||
|
||||
## ✅ That test was run — 2026-08-28. The game is 30 Hz; `1 unit = 1/60 s`
|
||||
|
||||
Reached the title with a single Ⓐ to skip the intro
|
||||
([`movie-binding.md`](movie-binding.md)), let it settle 12 s so nothing was
|
||||
loading, then armed the bounded 300-frame capture and timed it to its own
|
||||
`[UI-CAP] done` line:
|
||||
|
||||
| | 300 frames in | rate |
|
||||
|---|---|---|
|
||||
| idle title, trial 1 | 10.40 s | **28.8 fps** |
|
||||
| idle title, trial 2 | *arm produced no capture* | — |
|
||||
| idle title, trial 3 | 10.60 s | **28.3 fps** |
|
||||
| *(prior)* boot splash | 10.87 s | 27.6 fps |
|
||||
|
||||
**~28.5 fps on an idle title — the same rate as the loading splash.** By this
|
||||
page's own criterion that settles it: the title presents at **30 Hz**, the
|
||||
timeline ticks at **60 units/second**, and **`1 unit = 1/60 s`**.
|
||||
|
||||
**The 60 Hz reading is now excluded, not merely disfavoured.** It would require
|
||||
the emulator to be running at 47 % of real time *while sitting idle on a static
|
||||
title* — and the capture says that screen costs **1 526 draws over 300 frames,
|
||||
about 5 draws per frame**. Nothing there is expensive enough to halve the
|
||||
emulator's speed, and the splash and the idle title returning the same rate is
|
||||
exactly what a constant ~95 %-of-real-time emulator looks like.
|
||||
|
||||
So the durations on this page stand as written: title build 4 ≈ **4.2 s**, main
|
||||
menu build 5 ≈ **1.1 s**, and `EXTRAS` build 6's declared fade-in ≈ **0.87 s**.
|
||||
|
||||
⚠️ Still **measured, not decoded** — no field on the disc says "sixtieths of a
|
||||
second". What changed is that the measurement now has an idle-state control and
|
||||
the competing reading is ruled out.
|
||||
|
||||
## What this does not say
|
||||
|
||||
@@ -129,3 +161,232 @@ as the measured fact and `1/60 s` as the working conversion.
|
||||
`_sqex_eff` absence is explainable (the capture joined the SQUARE ENIX logo
|
||||
mid-hold); the missing studio-anima logo is not, and is left as an open
|
||||
observation for Q2 rather than a claim.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Open: the `palogo_*` LOGO elements do not play their declared timeline
|
||||
|
||||
**Added 2026-08-28.** Everything above stands — it rests on the `_eff` glows, and
|
||||
they reproduce exactly. What follows is a **different element in the same bundle
|
||||
and the same capture**, and it does not.
|
||||
|
||||
Reproduce with
|
||||
[`tools/re-capture/splash_ramp_check.py`](../../tools/re-capture/splash_ramp_check.py);
|
||||
output committed at [`data/splash-ramp-check.txt`](data/splash-ramp-check.txt).
|
||||
|
||||
### Why this is a test and not a fit
|
||||
|
||||
The clock is calibrated on `palogo_gamearts_eff` — its declared 15-unit fade-in
|
||||
`0@15 → 255@30` against its captured alphas 34, 68, 102, 136, 170, 204, 238, a
|
||||
constant step of exactly 34. That gives `t = 2f − 171`, and the calibration
|
||||
checks itself: the glow's declared hold ends at `t=45`, predicted **frame 108.0**,
|
||||
and the observed last full-alpha frame is **107**.
|
||||
|
||||
That calibration is then applied to `palogo_gamearts` — same bundle, same frames,
|
||||
**no free parameter left**:
|
||||
|
||||
| declared | predicted frame | observed alpha |
|
||||
|---|---|---|
|
||||
| `a=0` at `t=15` | 93.0 | not drawn |
|
||||
| `a=0` at `t=30` | 100.5 | not drawn |
|
||||
| `a=255` at `t=190` | 180.5 | 255 |
|
||||
| `a=232` at `t=206` | 188.5 | **255** |
|
||||
| `a=32` at `t=210` | 190.5 | **255** |
|
||||
|
||||
The logo is still at full alpha nine frames after it should have been at `a=32`.
|
||||
Its fade-out actually runs frames **199–211**, some 17 frames late, and its
|
||||
declared 80-frame fade-in (`t=30→190`) is **not drawn at all** — the element's
|
||||
first appearance, frame 116, is already at 255.
|
||||
|
||||
⚠️ "Not drawn" is not a culling artefact: the same element is submitted all the
|
||||
way down to `a=7` on the way out, so low-alpha quads plainly do reach the GPU.
|
||||
|
||||
### The shape mismatch, which needs no calibration at all
|
||||
|
||||
The declared fade-out spends **12 of its 16 units** dropping only **23/255** of
|
||||
the alpha — a near-flat leg — then **200/255** in the remaining 4. The captured
|
||||
per-frame drops are `1, 11, 6, 22, 34, 33, 17, 33, 33, 17, 25, 8, 8`. There is no
|
||||
near-flat leg.
|
||||
|
||||
### 🟡 A candidate, offered as one and NOT adopted
|
||||
|
||||
If the word at `+36` were the time of the **next** keyframe rather than of its own
|
||||
block, the logo's fade-out would read `255@190 → 232@194 → 32@206 → 0@210`:
|
||||
slow, fast, slow — which is the captured shape. Fitting both readings to the
|
||||
captured fade-out gives RMS alpha error **4.05** shifted against **12.13** as
|
||||
decoded, on two elements independently (`gamearts` and `sqex`).
|
||||
|
||||
It also removes a special case. The decoder currently notes that a group's data
|
||||
"stops 4 bytes short of its final block's time slot", so the last block's time is
|
||||
unreadable. Under the shifted reading the last block simply *has no successor*,
|
||||
so it has no time word — the same bytes, no special case.
|
||||
|
||||
**Not adopted, for three reasons:**
|
||||
|
||||
1. It does not explain the missing fade-in, which is the larger anomaly.
|
||||
2. It does not fix the 17-frame lateness of the fade-out.
|
||||
3. The `_eff` elements **do not discriminate** between the two readings — I
|
||||
checked: with four blocks the shift merely relabels which phase is which, and
|
||||
both reproduce the observed fade-in / hold / fade-out. So the entire case for
|
||||
the shift rests on one element's fade-out shape.
|
||||
|
||||
**The decoder is unchanged.** 🟡 What a port should take from this: the
|
||||
interpolation *law* is settled (linear, 2 units per frame), and the *group
|
||||
timeline* for multi-keyframe elements is not.
|
||||
|
||||
### 🔴 One thing I got wrong in the course of this
|
||||
|
||||
I first reported the `_eff` glows as holding "a constant α ≈ 33" and read that as
|
||||
contradicting the declared 255 plateau. They do no such thing — they ramp 34 →
|
||||
255 in exact steps of 34. I had printed only the series' **minimum** and read it
|
||||
as its range. Withdrawn; the trap is in [METHOD.md](METHOD.md).
|
||||
|
||||
### ✅ Settled the next iteration: the hold duration decides it
|
||||
|
||||
The section above left the candidate unadopted because its whole case rested on
|
||||
one fade-out's *shape*. There is a much blunter measurement in the same capture,
|
||||
and it needs **no calibration at all** — just the ratio of two observed spans:
|
||||
|
||||
| | full-alpha hold : fade-out | ratio |
|
||||
|---|---|---|
|
||||
| **observed** | 83 frames : 13 frames | **6.38** |
|
||||
| as decoded | 4 units : 16 units | 0.25 — **off by 26×** |
|
||||
| `+36` = the NEXT pose's time | 160 units : 20 units | **8.00** |
|
||||
|
||||
With the glow's 2 units/frame fixed and nothing else free, the current reading
|
||||
predicts `palogo_gamearts` holds full alpha for **2.0 frames**. The capture holds
|
||||
it for **83**. The shifted reading predicts **80.0**.
|
||||
|
||||
That is no longer a shape argument, and it is not a fit — it is a factor of 26.
|
||||
|
||||
### A fifth argument, from the corpus's own behaviour
|
||||
|
||||
`Element::rest()` tries `rest_plateau()` first and falls back to picking the
|
||||
keyframe with the longest **dwell**. Run that fallback on `palogo_gamearts`:
|
||||
|
||||
| reading | dwell winner |
|
||||
|---|---|
|
||||
| as decoded | **`a=0`** (dwell 160) — a fully transparent pose, for a logo |
|
||||
| shifted | **`a=255`** (dwell 160) — the visible hold |
|
||||
|
||||
Under the current reading the plain dwell rule picks an invisible pose for a
|
||||
publisher logo, and only the `rest_plateau` special case rescues the render. That
|
||||
special case has been repeatedly troublesome — it is the one the port agent
|
||||
reported a bug in on 2026-08-28. Under the shifted reading the dwell rule is
|
||||
simply correct on its own.
|
||||
|
||||
### What the shift is, exactly
|
||||
|
||||
Blocks `0…n−1`; `W[k]` is the word at block `k`'s `+36`. `W[n−1]` lies outside the
|
||||
group, because a group owns `frames·40 − 4` bytes.
|
||||
|
||||
* **as decoded:** `kf[k].time = W[k]`; the last keyframe has no time, as a special
|
||||
case for the missing word.
|
||||
* **shifted:** `kf[k].time = W[k−1]`; the **first** keyframe has no time, and
|
||||
`kf[n−1]` takes `W[n−2]` — which exists. Nothing is missing and nothing is
|
||||
special-cased: `W[k]` is simply *the time at which pose `k+1` is reached*.
|
||||
|
||||
Gated by `SYLPHEED_KF_TIME_SHIFT=1`, default unchanged. See below for what
|
||||
adopting it would cost.
|
||||
|
||||
### 🔴 …and what it costs — which is why the default is UNCHANGED
|
||||
|
||||
Rendering every build of six UI paks under both readings, and byte-comparing:
|
||||
|
||||
| | result |
|
||||
|---|---|
|
||||
| builds rendered identically | 10 of 11 compared |
|
||||
| builds that changed | **`GP_TITLE` build 7** — 13.1 % of pixels |
|
||||
|
||||
Build 7 is the **Japanese twin of build 4**, and build 4 — the one verified
|
||||
against a live capture — is byte-identical under both readings. So the one build
|
||||
the shift moves is the one with no capture to adjudicate it. The available proxy
|
||||
says the shift makes it *worse*:
|
||||
|
||||
| build 7 rendered | corr vs verified build 4 | mean luminance |
|
||||
|---|---|---|
|
||||
| as decoded | 0.6206 | **70.94** (build 4: 71.41) |
|
||||
| shifted | 0.6201 | 76.32 |
|
||||
|
||||
Two language twins are the same artwork with different text, so their brightness
|
||||
should match closely. As decoded it matches to **0.5**; shifted it is **4.9**
|
||||
brighter. Correlation does not separate them (0.6206 vs 0.6201) — the luminance
|
||||
does.
|
||||
|
||||
### The honest position
|
||||
|
||||
These two results are about **different things**, and both are real:
|
||||
|
||||
* For **animation timing** — what the port actually needs to play a screen — the
|
||||
shifted reading is favoured by a factor of 26 on a calibration-free
|
||||
measurement, and the current reading predicts a 2-frame hold where the game
|
||||
holds 83.
|
||||
* For **resting-pose selection** — what `screen render` does — the current
|
||||
reading matches the EN/JP twin brightness and the shifted one does not.
|
||||
|
||||
They can both be true: `rest()`'s dwell fallback is a heuristic layered *on top
|
||||
of* the times, and moving the times moves its tie-breaks. Adopting the shift
|
||||
would mean revisiting that heuristic in the same change, and there is no capture
|
||||
of build 7 to verify the result against.
|
||||
|
||||
**So the default stays as decoded.** 🟡 The port should treat a multi-keyframe
|
||||
group's *timing* as unverified — and specifically should not expect a
|
||||
2-frame hold where the game holds 83 — while `rest()` and `screen render` remain
|
||||
as they are.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Replicated: three elements, two screens, and the shifted reading wins every time
|
||||
|
||||
**2026-08-29.** The case for reading `+36` as *"the time the NEXT pose is
|
||||
reached"* rested on one element's fade-out shape, then on one element's hold
|
||||
duration. Both splash halves supply more, and they all say the same thing.
|
||||
|
||||
Phase durations in frames (2 units/frame, from the `_eff` glow ramp):
|
||||
|
||||
| element | screen | observed hold at `a=255` | as decoded | shifted |
|
||||
|---|---|---|---|---|
|
||||
| `palogo_gamearts` | developer splash | **83 f** | 8 f | **80 f** |
|
||||
| `palogo_seta` | developer splash | **83 f** | 6 f | **80 f** |
|
||||
| `palogo_sqex` | publisher splash | **≥ 77 f** * | 6 f | **102 f** |
|
||||
|
||||
\* the capture opens mid-hold at frame 1, so 77 is a floor, not the length.
|
||||
|
||||
The two readings predict **opposite structures** for these elements. On
|
||||
`palogo_gamearts`, as decoded: `hold 8f, in 80f, hold 2f, out 6f, out 2f` —
|
||||
an eighty-frame *fade-in* and a two-frame hold. Shifted: `in 8f, hold 80f,
|
||||
out 2f, out 6f, out 2f` — an eight-frame fade-in and an eighty-frame *hold*.
|
||||
The capture shows a **83-frame hold** and no fade-in at all.
|
||||
|
||||
### And the elements that cannot discriminate are not contradicted
|
||||
|
||||
`palogo_gamearts_eff` observed `in 7f, hold 7f, out 8f`. As decoded it reads
|
||||
`in 8f, hold 8f`; shifted it reads `hold 8f, out 8f`. Both fit — with only four
|
||||
blocks the shift relabels which phase is which without changing any duration. So
|
||||
the glows, which are what Q1's linear law was measured on, **do not argue against
|
||||
the shift**; they simply say nothing about it.
|
||||
|
||||
### 🟡 Why the decoder's default is still unchanged
|
||||
|
||||
The one thing that opposes the shift is `Element::rest()` on `ptlogo_eff3.t32`,
|
||||
where the shifted reading makes the longest-dwell fallback return the bloom's
|
||||
200 % peak instead of an invisible frame. But that fallback is **unsound whenever
|
||||
it runs** ([resting pose](structures/ui-resting-pose.md)) — it returns an
|
||||
endpoint of a movement, and neither endpoint is held. Checked: the shift does not
|
||||
fix it either, so this is not a case of two readings disagreeing about the times.
|
||||
It is a heuristic guessing, in both.
|
||||
|
||||
**So the two questions are separable, and only one of them has evidence.** The
|
||||
times govern animation; `rest()` picks a static pose through a rule that consults
|
||||
them only after its own precondition has failed.
|
||||
|
||||
### ⚠️ What a port should do
|
||||
|
||||
For **animation timing**, read `+36` as the *next* pose's time: three elements
|
||||
across two screens, each off by an order of magnitude under the other reading.
|
||||
For **static composites**, nothing changes — `screen render` is unaffected, and
|
||||
the five screens' correlations stand ([acceptance](five-screens-acceptance.md)).
|
||||
|
||||
🟡 Classified **measured, not decoded**: this is three elements in one game
|
||||
screen family, not a disc-wide field check, and our own decoder still defaults to
|
||||
the other reading behind `SYLPHEED_KF_TIME_SHIFT=1`.
|
||||
|
||||
739
docs/re/ui-title-build-map.md
Normal file
@@ -0,0 +1,739 @@
|
||||
# Which `GP_TITLE` build is which screen — measured against the running game
|
||||
|
||||
**Status:** ✅ `CONFIRMED` for the four screens the boot path actually shows
|
||||
(title art, `PRESS Ⓐ BUTTON`, main menu, `EXTRAS`); 🟡 `PROBABLE` for their
|
||||
Japanese twins; ❔ open for the two `DELTASABER` plates.
|
||||
|
||||
Answers [MISSION Q2](../port/MISSION.md). The previous statement — *"build 4
|
||||
title, 5 main menu, 6/8/9 submenus"* — is **partly wrong** and is withdrawn:
|
||||
build 8 is the **Japanese main menu**, not a submenu, and `GP_TITLE` holds
|
||||
exactly **one** submenu (`EXTRAS`), in two languages.
|
||||
|
||||
## The archive is eight screens, each shipped twice
|
||||
|
||||
`GP_TITLE.pak` has 16 entries; `sylpheed-cli screen list` calls 12 of them
|
||||
composable builds. The 16 fall into **eight pairs**, and each pair's two members
|
||||
have near-identical sizes and name hashes that differ by a constant — one
|
||||
character of the name apart:
|
||||
|
||||
| entry | hash | bytes | build idx | pair |
|
||||
|---|---|---|---|---|
|
||||
| 0 | `01a2db9c` | 483 958 | 0 | A |
|
||||
| 1 | `0ff0b8a8` | 483 958 | 1 | A |
|
||||
| 2 | `285d8849` | 267 014 | 2 | B |
|
||||
| 3 | `369773cb` | 267 014 | 3 | B |
|
||||
| 4 | `a60fcb85` | 12 278 666 | 4 | C |
|
||||
| 7 | `b483e6e6` | 13 363 328 | 7 | C |
|
||||
| 5 | `a715f485` | 6 977 437 | 5 | D |
|
||||
| 8 | `b58a0fe6` | 6 931 653 | 8 | D |
|
||||
| 6 | `a81c1d85` | 6 549 126 | 6 | E |
|
||||
| 9 | `b69038e6` | 6 548 438 | 9 | E |
|
||||
| 10 | `cdba806e` | 426 473 | — | F |
|
||||
| 13 | `db2ea1f8` | 423 333 | — | F |
|
||||
| 11 | `cec0a96e` | 999 643 | — | G |
|
||||
| 14 | `dc34caf8` | 999 643 | — | G |
|
||||
| 12 | `cf2a8ccd` | 1 774 639 | 10 | H |
|
||||
| 15 | `dd56d99b` | 1 774 639 | 11 | H |
|
||||
|
||||
For the three pairs whose members differ visibly — C, D, E — **the difference is
|
||||
English versus Japanese**: build 7 is the title art with the katakana subtitle
|
||||
プロジェクト シルフィード, build 8 the main menu reading 新規 / ロード /
|
||||
チュートリアル / オプション / エクストラ, build 9 the `EXTRAS` submenu as
|
||||
エクストラ. That is the whole of the language split we can see. In pairs A, B and
|
||||
H the two members render **byte-identical PNGs** — the archive still carries two
|
||||
copies, but the artwork does not change with the language.
|
||||
|
||||
Pairs F and G are the four entries `screen list` does *not* classify as builds by
|
||||
default. **They are the developer splash**, and they render — see below.
|
||||
|
||||
## The map
|
||||
|
||||
Contact sheet of every render:
|
||||
[`captures/title-builds/title-build-contact-sheet.png`](captures/title-builds/title-build-contact-sheet.png).
|
||||
|
||||
| build | what it is | confirmed how |
|
||||
|---|---|---|
|
||||
| 0, 1 | a `DELTASABER / SYLPHEED A.I.` plate low-left on black, no background | ❔ **not observed running.** Never seen in the boot path, the main menu, `EXTRAS` or `MISSION SELECT` |
|
||||
| **2**, 3 | the `PRESS Ⓐ BUTTON` plate — **an overlay build of its own**, not a state of build 4 | ✅ seen composited over build 4 on the live title, at the same rect our render puts it |
|
||||
| **4** | title art, English (`PROJECT SYLPHEED`, ™, `(C)2006,2007 SQUARE ENIX`) | ✅ [`live-title-press-a.png`](captures/title-builds/live-title-press-a.png) |
|
||||
| 7 | the same, Japanese | 🟡 renders as the JP twin of build 4; the container runs an English locale, so it was not seen |
|
||||
| **5** | main menu, English: NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS, footer `Ⓐ : OK` | ✅ [`live-main-menu.png`](captures/title-builds/live-main-menu.png) — element for element |
|
||||
| 8 | the same, Japanese | 🟡 as above |
|
||||
| **6** | the `EXTRAS` submenu, English: MISSION SELECT / MOVIE THEATER / BACK, footer `Ⓐ : OK Ⓑ : Back` | ✅ [`live-extras.png`](captures/title-builds/live-extras.png) |
|
||||
| 9 | the same, Japanese | 🟡 as above |
|
||||
| 10, 11 | the same `DELTASABER` plate as build 0, over a dark circuit-line background | ❔ **not observed running** |
|
||||
|
||||
## ✅ The splash — the four "non-build" entries, rendered
|
||||
|
||||
`screen list --all` widens the enumeration to every composable bundle and shows
|
||||
all **16** entries. The four that the default listing drops are the two halves of
|
||||
the developer splash, each shipped twice:
|
||||
|
||||
| entry | elements / sprites | what it is |
|
||||
|---|---|---|
|
||||
| **10, 13** | 3 / 2 | the white **`SQUARE ENIX`** publisher logo |
|
||||
| **11, 14** | 7 / 6 | **`GAME ARTS` / `SETA` / `studio anima`** — the developer logos |
|
||||
|
||||
[`splash-entries-rendered.png`](captures/title-builds/splash-entries-rendered.png).
|
||||
Rendered with `screen render --all --primitives --black`. Entry 11's seven
|
||||
elements are the three logos, their three `_eff` glows and the
|
||||
`palogo_eff0.prm` black backdrop — exactly the composition
|
||||
[`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md) measured
|
||||
for the splash.
|
||||
|
||||
### ✅ Confirmed against the running game — 2026-08-28
|
||||
|
||||
The 🟡 above ("no framebuffer capture to diff against") is closed. Recording the
|
||||
boot from the moment the window appears, at 2 fps, catches the splash before the
|
||||
movie:
|
||||
[`live-splash-publisher.png`](captures/title-builds/live-splash-publisher.png),
|
||||
[`live-splash-developer.png`](captures/title-builds/live-splash-developer.png).
|
||||
|
||||
Edge-correlated against the renders, **with the other half as a negative
|
||||
control**:
|
||||
|
||||
| capture | vs entry 10 | vs entry 11 |
|
||||
|---|---|---|
|
||||
| publisher frame (t ≈ 2.0 s) | **0.9146** @ (0,0) | 0.027 ✗ |
|
||||
| developer frame (t ≈ 6.0 s) | −0.033 ✗ | **0.9792** @ (0,0) |
|
||||
|
||||
Both halves match their own render at zero shift and are firmly rejected by the
|
||||
other. The `13`/`14` twins score 0.876 / 0.966 — near-identical artwork, so this
|
||||
test cannot tell a pair apart, only a screen from a different screen.
|
||||
|
||||
### ✅ The splash's timing is DECODED, and the capture confirms it
|
||||
|
||||
Re-recorded at **10 fps** (the 2 fps pass below was ±0.5 s and could not see
|
||||
ramps at all). The splash **fades, both ways** — it does not cut — and the
|
||||
bundle's own keyframes say so:
|
||||
|
||||
```
|
||||
$ sylpheed-cli screen info --all --build 10 GP_TITLE.pak
|
||||
1 palogo_sqex.t32 7 kf [15 30 235 239 251 255 -]
|
||||
2 palogo_sqex_eff.t32 4 kf [15 30 45 -] (the glow, child of 1)
|
||||
|
||||
$ ... --build 11
|
||||
1 palogo_gamearts.t32 7 kf [15 30 190 194 206 210 -] (seta, anima identical)
|
||||
2 palogo_gamearts_eff 4 kf [15 30 45 -]
|
||||
```
|
||||
|
||||
Under Q1's `1 unit = 1/60 s`:
|
||||
|
||||
| | declared | measured at 10 fps |
|
||||
|---|---|---|
|
||||
| `SQUARE ENIX` ramp in | `15 → 30` = 0.25 s | rise 0.4 → 0.8 s |
|
||||
| `SQUARE ENIX` hold | `30 → 235` = **3.42 s** | ≈ 3.5 s (0.8 → 4.3 s) |
|
||||
| `SQUARE ENIX` fade out | `235 → 255` = **0.33 s** | **≈ 0.3 s** (4.4 → 4.7 s) |
|
||||
| developer hold | `30 → 190` = **2.67 s** | ≈ 2.4 s (5.6 → 8.0 s) |
|
||||
| developer fade out | `190 → 210` = **0.33 s** | **≈ 0.3 s** (8.1 → 8.4 s) |
|
||||
|
||||
The offset between declared and measured start is ≈ 0.35 s, which is simply that
|
||||
the recording's `t = 0` is when the *window* appears, not when the guest starts
|
||||
drawing. Everything downstream of that lines up.
|
||||
|
||||
**The overshoot is the glow.** The measured rise peaks (6.21) at 0.8 s and settles
|
||||
back (5.39) by 1.1 s, which looks like a bloom. It is the `_eff` child element:
|
||||
its keyframes are `15 → 30 → 45`, so it ramps in *after* the logo and then back
|
||||
down, while the logo itself holds. Decoded, not a rendering artifact.
|
||||
|
||||
So the port can **read** the splash's timing off the disc rather than author it —
|
||||
the first screen's animation is not a measurement it has to trust.
|
||||
|
||||
### The wall-clock sequence, for orientation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `SQUARE ENIX` on screen | **≈ 0.5 → 4.0 s** after the window appears |
|
||||
| black | ≈ 4.5 s |
|
||||
| `GAME ARTS` / `SETA` / `studio anima` | **≈ 5.0 → 7.5 s** |
|
||||
| black | ≈ 9.0 s |
|
||||
| `ADV.wmv` begins | **≈ 9.5 s** |
|
||||
|
||||
⚠️ The frames at ≈ 9.5–12.5 s show `SQUARE ENIX` again in **cyan**. That is not a
|
||||
third splash — it is the intro movie's own opening, which the milestone-2 notes
|
||||
describe as "white SQUARE ENIX + cyan glow + red diamonds". A capture-only reading
|
||||
would have recorded a third logo screen that does not exist.
|
||||
|
||||
**So the first of the five screens now has a reference composite, a framebuffer
|
||||
capture, and its on-screen durations.**
|
||||
|
||||
**Reach of the two negatives.** Builds 0/1 and 10/11 were looked for in: the whole
|
||||
boot sequence (a 5 s-cadence filmstrip from launch to the title, ~190 s), the
|
||||
title, the main menu, the `EXTRAS` submenu, the `LOAD GAME` slot list, the
|
||||
transition into `MISSION SELECT` (a 40-frame burst), and the attract cycle. They
|
||||
appear in none of them. The obvious remaining candidate is a *long* load — a
|
||||
mission launch — which is out of this objective's scope; they are most likely a
|
||||
loading/AI-chatter plate. That is a hypothesis, not a result.
|
||||
|
||||
## Three things the captures settle beyond the map
|
||||
|
||||
**The live title is two builds composited.** Build 4 draws the art; build 2 draws
|
||||
`PRESS Ⓐ BUTTON` on top, and it **fades in a beat later** — a screenshot taken
|
||||
2.5 s after arriving at the title has the art and no plate, one taken ~1 s later
|
||||
has both. The port must treat the plate as its own timed element.
|
||||
|
||||
**The attract-loop title carries the plate too.** After ~8–10 s idle the title
|
||||
fades to black, a full-motion video plays for ~85 s, and the title comes back —
|
||||
*with* `PRESS Ⓐ BUTTON`
|
||||
([`live-attract-title-press-a-band.png`](captures/title-builds/live-attract-title-press-a-band.png)).
|
||||
This is consistent with, and adds nothing to, the draw-quad comparison in
|
||||
[`canary-scripted-input-traps.md`](canary-scripted-input-traps.md): the plate is
|
||||
not the tell that distinguishes the boot title from the attract title.
|
||||
|
||||
**`EXTRAS` is the only main-menu destination inside `GP_TITLE`.** Ⓐ on `EXTRAS`
|
||||
opens build 6 — measured. The other four destinations leave the archive: Ⓐ on
|
||||
`LOAD GAME` opened a `LOAD GAME` slot list, and Ⓐ on `MISSION SELECT` inside
|
||||
`EXTRAS` opened a `MISSION SELECT` screen, neither of which is a `GP_TITLE`
|
||||
build. `dat/` carries `GP_SAVE_LOAD.pak`, `GP_TUTORIAL.pak`, `GP_OPTIONS.pak`,
|
||||
`GP_MISSION_SELECT.pak` and `GP_MOVIE_THEATER.pak`; **that those are the archives
|
||||
behind the other four buttons is an inference from the names, not a measurement.**
|
||||
|
||||
## How to reproduce
|
||||
|
||||
```bash
|
||||
sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_TITLE.pak"
|
||||
sylpheed-cli screen render --build 6 "$SYLPHEED_DISC/dat/GP_TITLE.pak" /tmp/b6.png
|
||||
tools/re-capture/boot_menu.sh q2 # boots to the main menu, cursor on NEW GAME
|
||||
tools/re-capture/pad.py dpad down 0.35 # x4 -> EXTRAS
|
||||
tools/re-capture/pad.py tap A 0.30
|
||||
```
|
||||
|
||||
Mind the d-pad hold — see [`METHOD.md`](METHOD.md).
|
||||
|
||||
## ✅ The title is not a still image — it loops, ≈ 2.2 s
|
||||
|
||||
Recording the title at 10 fps for 22 s shows the screen **never settles**. After
|
||||
the build-in it oscillates by about ±0.5 in mean luminance, continuously:
|
||||
|
||||
```
|
||||
peaks at 5.6 7.8 10.0 12.7 15.2 17.1 19.2 21.3 s
|
||||
intervals 2.2 2.2 2.7 2.5 1.9 2.1 2.1
|
||||
mean 2.24 s
|
||||
```
|
||||
|
||||
**measured**, n = 7 intervals, spread 1.9–2.7 s — peak-picking a low-amplitude
|
||||
signal is coarse, so read it as **≈ 2.2 s ± 0.4**, not a precise period.
|
||||
|
||||
The mechanism is already decoded: build 4 declares **`ptloop01.rat` /
|
||||
`ptloop02.rat`**, and `loop*.rat` is a **looping sprite animation** rather than a
|
||||
composition ([`INDEX.md`](INDEX.md), UI screen layout row). So the title carries a
|
||||
looping element by construction; what is new is that it runs at ≈ 2.2 s and never
|
||||
stops.
|
||||
|
||||
### ✅ The loop records ARE decoded — and they are not what I measured
|
||||
|
||||
`ptloop01.rat` is an **`opt `-linked leaf record**: in build 4 the chunk `opt `
|
||||
(size `0x0c`) carries the name, immediately followed by a `RATC` blob at
|
||||
**`0xbb5966`** (`ptloop02.rat` the same at `0xbb5a82`). Its pivot fields read
|
||||
`200`/`90`, matching `screen info`'s `pivot (200,90)` — the right blob.
|
||||
|
||||
**The keyframes use the ordinary 40-byte block layout**, starting at `+0x68`:
|
||||
|
||||
| | `ptloop01` → `pteff03.t32` | `ptloop02` → `pteff03a.t32` |
|
||||
|---|---|---|
|
||||
| kf0 | `t=150` x=**−639** α=`ff` | `t=150` x=**1721** α=`00` |
|
||||
| kf1 | `t=540` x=−39 α=`80` | `t=630` x=1111 α=`80` |
|
||||
| kf2 | `t=600` x=**1521** α=`ff` | `t=720` x=**−839** α=`ff` |
|
||||
| scale | 100 × 600 | 100 × 800 |
|
||||
| span | 150 → 600 = **450 units = 7.5 s** | 150 → 720 = **570 units = 9.5 s** |
|
||||
|
||||
Y is constant at 270 and X runs off one edge to the other, so these are
|
||||
**horizontal light sweeps** — `loop01` left → right, `loop02` right → left.
|
||||
|
||||
### 🔴 Two things I wrote last iteration are wrong
|
||||
|
||||
**1. "A leaf record's keyframes are not in the build's 40-byte layout."**
|
||||
Withdrawn — they are, exactly. The scan that "found nothing" demanded **29**
|
||||
strictly-increasing times because I read the word at `+0x004` (`0x001e0000`) as a
|
||||
keyframe count. These records hold **three** keyframes. A filter that hard-codes
|
||||
the expected count rejects the right structure; whatever the `30` is, it is not
|
||||
the number of keyframes here.
|
||||
|
||||
**2. "The ≈ 2.2 s oscillation is the `ptloop` elements."** Withdrawn — I asserted
|
||||
the link because build 4 declares those elements, not because anything showed it.
|
||||
The decoded sweeps run **7.5 s and 9.5 s**. A 22 s capture would show ~3 peaks
|
||||
from a 7.5 s cycle; it showed **8**. So the loops are *not* what the oscillation
|
||||
measured.
|
||||
|
||||
### ✅ Identified: it is the `PRESS Ⓐ BUTTON` plate pulsing
|
||||
|
||||
A per-tile amplitude map over the capture (8 × 6 grid, 18 s after build-in)
|
||||
localises the 2.3 s period precisely:
|
||||
|
||||
| region | sd | dominant lag |
|
||||
|---|---|---|
|
||||
| band x ≈ 318–954, y ≈ 560–672 | **7.65** | **2.3 s** |
|
||||
| wordmark centre | 0.06 | — |
|
||||
| bottom-right corner | 0.003 | — |
|
||||
|
||||
That band is the `PRESS Ⓐ BUTTON` plate's rest position (`ptbtn00.rat`, rest
|
||||
`(383,550)`, pivot `(256,25)`). It is **build 2**, composited over the title — not
|
||||
build 4.
|
||||
|
||||
Decoding `ptbtn00f.rat`, the plate's highlight variant, gives the pulse directly:
|
||||
|
||||
| kf | t | alpha |
|
||||
|---|---|---|
|
||||
| 0 | 6 | `0x00` |
|
||||
| 1 | 29 | `0x06` |
|
||||
| 2 | 35 | `0x4a` |
|
||||
| 3 | 50 | `0x50` |
|
||||
| 4 | 58 | `0x50` (hold) |
|
||||
| 5 | 97 | `0x4a` |
|
||||
| 6 | 105 | `0x06` |
|
||||
|
||||
A glow that fades in to `0x50` and back out — exactly a "press start" pulse.
|
||||
|
||||
There is an **eighth** keyframe: fade `0x00ffffff` at the same position — the glow
|
||||
returns to **fully transparent**, so this is a closed cycle, not a one-shot ramp.
|
||||
|
||||
🟡 **The cycle length is still not readable — and now that is an observation.**
|
||||
The eighth block's time slot contains the four bytes `end `, the record's ASCII
|
||||
terminator: the record simply stops there and the value does not exist. So the
|
||||
corpus's "a group's last block has no time of its own" rule holds here in a second
|
||||
form — not the next group's index, but the chunk terminator.
|
||||
|
||||
Declared span is **≥ 105 units = 1.75 s**; measured **≈ 2.3 s** (≈ 138 units),
|
||||
which would need a final step of ≈ 33 units. **That 33 is fitted to the
|
||||
measurement, not read from the file**, and is recorded only so nobody re-derives
|
||||
it as if it were a decode.
|
||||
|
||||
❔ **The word at `+0x004` is not a keyframe count.** It reads `0x003c0000` (60)
|
||||
here with 8 keyframes, and `0x001e0000` (30) in the loop records with 3. Whatever
|
||||
it is, it is not the count, and it is not decoded.
|
||||
|
||||
### ❔ The declared 4.08 s build-in was not tested
|
||||
|
||||
That was the intent of this recording and it did not work. The title was reached
|
||||
by **skipping the movie with Ⓐ**, which cuts to black and brings the title up on a
|
||||
path that may not be the normal one; and the visible rise (≈ 2.7 s, from t ≈ 0.8
|
||||
to 3.5) is a *luminance* curve, which
|
||||
[`screen-transitions.md`](screen-transitions.md) already establishes is **not**
|
||||
the fade quad's ramp. So ≈ 2.7 s neither confirms nor contradicts build 4's
|
||||
declared `16 → 261` (4.08 s); the two are not measuring the same thing.
|
||||
|
||||
Testing it properly needs the title reached **without** a skip, and a way to
|
||||
separate the quad from the elements — neither of which this recording had.
|
||||
|
||||
|
||||
## ✅ For the port: build 4 alone, and groups HOLD rather than loop
|
||||
|
||||
Both answers the port agent asked for, in one recording.
|
||||
|
||||
### The capture — the title with no `PRESS Ⓐ` plate over it
|
||||
|
||||
Ⓑ from the main menu returns to the title and the plate fades in **a beat later**,
|
||||
which opens a clean window. Recorded at 20 fps from the press:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| title art appears | t ≈ 1.10 s |
|
||||
| builds in | 1.10 → 3.70 s (mean 41.8 → 63.2) |
|
||||
| **settled, still no plate** | **3.70 → 5.00 s** |
|
||||
| plate arrives | t ≈ 5.10 s (band jumps 282 → 3 755 bright px) |
|
||||
|
||||
[`live-title-build4-no-plate.png`](captures/title-builds/live-title-build4-no-plate.png)
|
||||
is t = 4.0 s — build 4, settled, unobstructed. That is the reference for the
|
||||
washed-out cyan glow slab the port reports drawing and the game not having.
|
||||
|
||||
### ✅ A keyframe group HOLDS at its last keyframe — it does not loop
|
||||
|
||||
This follows from the decoded sweeps plus a measurement, and the two agree:
|
||||
|
||||
* `ptloop01.rat`'s final keyframe puts `pteff03.t32` at **x = 1521** and
|
||||
`ptloop02.rat`'s puts `pteff03a.t32` at **x = −839** — both **off-screen** on a
|
||||
1280-wide design. A group that holds therefore parks both sweep sprites out of
|
||||
view and nothing moves after the build-in.
|
||||
* Measured: over 18 s of settled title the centre tiles sit at **sd ≤ 0.01**
|
||||
(per-tile map above). A looping group would recross the screen every **7.5 s**
|
||||
and be unmissable.
|
||||
|
||||
So the name `loop*.rat` is misleading — in this build the records animate **once**
|
||||
during the build-in and then rest off-screen. 🟡 This is about *these* groups on
|
||||
*this* screen; nothing here says no group anywhere loops.
|
||||
|
||||
## ✅ The title's settled pose is `rest`, and the "washed-out slab" is a MISSING dim
|
||||
|
||||
The port agent could not decide whether `rest` or its played-out timeline is the
|
||||
title's arrived pose (they disagree by 142–247/255), and separately reported a
|
||||
*"washed-out cyan glow slab over the title logo that the running game doesn't
|
||||
have"*. The plate-free capture settles both.
|
||||
|
||||
`sylpheed-cli screen render --build 4 --black`, edge-correlated against
|
||||
[`live-title-build4-no-plate.png`](captures/title-builds/live-title-build4-no-plate.png)
|
||||
— a capture of the real screen, so this is an independent oracle and not one
|
||||
renderer checking another:
|
||||
|
||||
> **0.9163 at shift (0,0)**, and per band 0.92 / 0.78 / 0.93.
|
||||
|
||||
**Geometry is right**, so `rest` is the arrived pose for the title.
|
||||
|
||||
### The slab is the 25 % dim, absent — not a glow, present
|
||||
|
||||
`rest` alone is **uniformly too bright**, and the excess leans cyan:
|
||||
|
||||
| render | mean (render − capture) | R | G | B |
|
||||
|---|---|---|---|---|
|
||||
| `--black` | **+13.14** | +12.35 | +13.58 | +13.48 |
|
||||
| `--black --primitives` | **+0.55** | +1.30 | +0.89 | −0.54 |
|
||||
|
||||
Drawing the `.prm` primitives collapses the excess to nothing. The element is
|
||||
**`pteff02.prm`**, the 25 % dim quad (build 4, rest at `t=46`, fade `0x40` = 64 =
|
||||
25 %) — and `--primitives` is **off by default**.
|
||||
|
||||
So the "washed-out cyan slab" is not something being drawn that shouldn't be. It
|
||||
is the **dim that should be drawn and isn't**: without it every pixel sits ~13
|
||||
high, and because the title art is blue-dominant the shortfall reads as a cyan
|
||||
wash. ⚠️ Any consumer of `screen render` that omits `--primitives` on this screen
|
||||
gets it.
|
||||
|
||||
### 🟡 The residual: right on average, not right per pixel
|
||||
|
||||
With primitives the *mean* is essentially exact (+0.55) but pixel agreement is
|
||||
slightly **worse** — edge-correlation 0.9163 → **0.9066**, and pixels differing by
|
||||
> 20 rise 108 051 → 162 636. So the dim's average contribution is right while its
|
||||
application is not exactly the game's (blend mode or per-region alpha). Not
|
||||
diagnosed.
|
||||
|
||||
### 🔴 The real defect: the logo swoosh is drawn white and thick
|
||||
|
||||
With the dim in place, the residual is **not uniform** — it is a dark patch beside
|
||||
a bright one in one band:
|
||||
|
||||
```
|
||||
row1 (y 112-225): -2.0 +3.0 -8.8 -38.6 -17.6 +16.2 +33.8 +24.4
|
||||
```
|
||||
|
||||
Cropping that band from capture and render
|
||||
([`title-swoosh-capture-vs-render.png`](captures/title-builds/title-swoosh-capture-vs-render.png))
|
||||
shows it plainly: the game draws the logo's `Z` **swoosh thin, with a
|
||||
pink/magenta edge**; our render draws it **thick and solid white**. Too bright to
|
||||
its right, too dark where the game's thin stroke actually falls.
|
||||
|
||||
**This — not the missing dim — is the port agent's "washed-out slab over the title
|
||||
logo".** The dim explains a *uniform* +13; the slab is this.
|
||||
|
||||
The elements are `ptlogo_back2.t32` (rest `(71,126)`, pivot `(500,117)` — a
|
||||
1000 × 234 diagonal), its glow `ptlogo_back2eff.t32`, and the five
|
||||
`ptlogo_back2eff1…5` segments at y ≈ 117–194 — exactly the band that disagrees.
|
||||
|
||||
🟡 **A connection worth chasing, not a diagnosis.** Those five segments are the
|
||||
group whose **paint-order tie-break is the known unsolved residual**
|
||||
([`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md)): they
|
||||
share key `0x8083`, the game paints them `14,15,18,16,17`, the stable sort paints
|
||||
`14,15,16,17,18`, and the measured cost is "`ptlogo_back2eff5` against `eff3`
|
||||
(22 568 px) and against `eff4` (32 395 px)". **Same screen, same elements.**
|
||||
|
||||
❔ But a blend-order swap is a poor explanation for *white instead of pink* — that
|
||||
looks like a tint or blend-mode problem, so I would expect a second cause.
|
||||
|
||||
### 🟡 A quantified cause for the GEOMETRY: the pivot belongs to the other language
|
||||
|
||||
The fade and tint fields are **not** the culprit — every keyframe of every swoosh
|
||||
element carries `0x??ffffff`, white RGB with only alpha varying, and no tint is
|
||||
anything but `0xffffffff`. Nor is the texture pink: `ptlogo_back2.t32` decodes
|
||||
**blue**-leaning (175, 174, 198) and its glow warm (255, 253, 234).
|
||||
|
||||
What *is* wrong is the **pivot**. Checking every `GP_TITLE` element whose texture
|
||||
we decode (109 of 178) against the rule *pivot = texture ÷ 2*:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| exact | 17 |
|
||||
| off by ≤ 1 px (rounding) | 54 |
|
||||
| off by ≤ 8 px | 14 |
|
||||
| **off by > 8 px** | **24** |
|
||||
|
||||
and the gross ones are **concentrated on the title's logo elements**:
|
||||
|
||||
```
|
||||
a60fcb85 ptlogo_back2.t32 pivot (500,117) texture 1118x262 -> implies (559,131) off 59.0
|
||||
a60fcb85 ptlogo_back2eff.t32 pivot (507,126) texture 1133x280 -> implies (566,140) off 59.5
|
||||
a60fcb85 ptlogo2.t32 pivot (449, 46) texture 992x104 -> implies (496, 52) off 47.0
|
||||
b483e6e6 ptlogo1.t32 pivot (451, 50) texture 822x100 -> implies (411, 50) off 40.0
|
||||
```
|
||||
|
||||
**Why:** build 4's `ptlogo_back2` pivot `(500,117)` is *exactly* half of the
|
||||
**Japanese** texture (1000 × 234), not its own English one (1118 × 262). The
|
||||
layout record is authored once and shared while the `.t32` sprites are swapped per
|
||||
language — the effect
|
||||
[`structures/ui-rat-layout.md`](structures/ui-rat-layout.md) warns about in
|
||||
general, here measured on the screen where the render disagrees with the game.
|
||||
Note it cuts **both ways**: the Japanese build's `ptlogo1` is off by 40 px too.
|
||||
|
||||
A 59 px offset on a 1118 px sprite is the right order to produce "too thick and
|
||||
extending too far right", which is what the crop shows.
|
||||
|
||||
### 🔴 …and that candidate is refuted
|
||||
|
||||
Checked, as promised. `ui_layout`'s `blit` takes the drawn size from the
|
||||
**texture** (`img.width`/`img.height`), and uses the pivot only for the
|
||||
scale anchor:
|
||||
|
||||
```rust
|
||||
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
|
||||
```
|
||||
|
||||
At `sx_pct == 100` that term is **zero**. And **every one of the seven swoosh
|
||||
elements is scale `(100,100)` at every keyframe** — `ptlogo_back2`,
|
||||
`ptlogo_back2eff` and `ptlogo_back2eff1…5` all report a single scale. So the
|
||||
pivot mismatch, real as it is in the data, **cannot** move or resize the swoosh in
|
||||
our render.
|
||||
|
||||
⚠️ It is not harmless everywhere: `ptlogo1`/`ptlogo2` run scales
|
||||
`100 → 101 → 103 → 112 → 150` during the build-in, so there the wrong pivot *does*
|
||||
displace them — during the animation, not at rest.
|
||||
|
||||
### 🟡 So the swoosh defect is a BLEND problem, by elimination
|
||||
|
||||
Position and size are the texture's own and are right; fade is white-with-alpha;
|
||||
tint is white; the texture is blue-leaning, not pink. What remains is how the
|
||||
seven overlapping sprites are combined — `ptlogo_back2` is only **5.4 % opaque**,
|
||||
its glow 10.3 %, the five `eff` segments 10–23 %, all white or warm. Stacked with
|
||||
plain alpha-over they saturate toward opaque white, which is what we draw and
|
||||
would read as "thicker" beside the game's thin coloured stroke. ❔ Not diagnosed —
|
||||
no blend mode has been identified in the data.
|
||||
|
||||
### 🔴 Refuted on the way
|
||||
|
||||
The obvious guess — *our dim is applied over the whole frame instead of beneath
|
||||
the UI, where its layer key puts it* — is **wrong**. If it were, the logo would
|
||||
render too dark; it reads **+2.36** against a background of **−0.74**. The
|
||||
compositor honours the paint order here.
|
||||
|
||||
|
||||
## 🔴 The swoosh is not displaced either — and the residual is restated
|
||||
|
||||
Two more candidates eliminated, and the residual is smaller than earlier sections
|
||||
implied.
|
||||
|
||||
**Not a displacement.** Shifting the render's swoosh band over ±80 px × ±8 px and
|
||||
re-correlating peaks **sharply at (0, 0)** — 0.7342, falling to 0.22 at ±24 px and
|
||||
0.10 at ±48. The swoosh is where it should be.
|
||||
|
||||
**Not additive blending.** See
|
||||
[`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md): every
|
||||
measure worsens.
|
||||
|
||||
**And the residual, restated with the current best render** (`--black
|
||||
--primitives`, `rest()` fixed):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| whole-frame mean diff | **+0.55** |
|
||||
| swoosh-band mean diff | **+1.83** |
|
||||
| swoosh-band edge-correlation | **0.6971** (vs ≈ 0.92 frame-wide) |
|
||||
|
||||
⚠️ Earlier sections quoted band tiles at **+16 … +34**. Those were measured on a
|
||||
render **without** `--primitives`. With the dim drawn the band's *average* is
|
||||
nearly right; what is wrong is its **structure** — the tiles run −38.6 then +33.8
|
||||
across the band and cancel. So the defect is neither brightness, nor position, nor
|
||||
additive blending: it is a shape difference in one band, and it is **not
|
||||
diagnosed**. Six candidates eliminated: pivot (twice — inert at scale 100, and no
|
||||
measured displacement), `fade`, `tint`, texture colour, additive blend.
|
||||
|
||||
### 🔴 The capture IS settled — my own caveat, tested and withdrawn
|
||||
|
||||
The previous version of this section worried that the plate-free capture, at
|
||||
t ≈ 4.0 s, might be too early: elements have keyframes to t = 600 (10 s), and
|
||||
"settled" had been judged from mean luminance, which cannot see a thin sprite
|
||||
still moving.
|
||||
|
||||
The plate sits at y ≈ 550–600, **disjoint** from the swoosh band at y 112–225, so
|
||||
a *late* capture works even with the plate present. Correlating the render's band
|
||||
against the same band at several ages of the screen:
|
||||
|
||||
| capture | band edge-corr | band mean |
|
||||
|---|---|---|
|
||||
| t = 4.0 s (plate-free) | **0.7342** | 126.63 |
|
||||
| t = 6.0 s | 0.7351 | 126.97 |
|
||||
| t = 12.0 s | 0.7352 | 126.98 |
|
||||
| t = 18.0 s | 0.7353 | 126.98 |
|
||||
| t = 21.5 s | 0.7353 | 126.98 |
|
||||
|
||||
Identical to within 0.001 over 17.5 s. **The band is settled by t = 4.0 s**, the
|
||||
capture handed to the port agent is sound, and the caveat is withdrawn. It also
|
||||
corroborates that the groups **hold**: nothing crosses that band in 22 s.
|
||||
|
||||
## The swoosh — SOLVED by draw capture (see below); the elimination trail kept
|
||||
|
||||
Seven candidates eliminated, none confirmed:
|
||||
|
||||
| candidate | verdict |
|
||||
|---|---|
|
||||
| element **pivot** (off by 59 px, authored for the other language) | inert — `blit` sizes from the texture and applies the pivot only when scale ≠ 100; all seven elements are scale `(100,100)` |
|
||||
| **displacement** | none — shifting ±80 × ±8 px peaks sharply at (0,0), 0.734 → 0.22 at ±24 px |
|
||||
| **`fade`** | `0x??ffffff` on every keyframe: white RGB, alpha only |
|
||||
| **`tint`** | `0xffffffff` throughout |
|
||||
| **texture colour** | blue-leaning (175,174,198); the glow warm — neither pink |
|
||||
| **additive blend** via `T8aD +0x04` bit `0x02` | refuted: every measure worsens |
|
||||
| **capture not settled** | refuted above |
|
||||
|
||||
The residual is stable and modest: band mean **+1.83**, band edge-correlation
|
||||
**0.6971–0.735** against ≈ 0.92 frame-wide. Real, persistent, and **not located in
|
||||
any field this project can read from the disc**.
|
||||
|
||||
**Where a next attempt should start, and it is not another field** — but check
|
||||
what the tool actually records first, because the obvious phrasing of this is
|
||||
wrong.
|
||||
|
||||
⚠️ **The per-draw capture does NOT record blend state.** Reading
|
||||
`command_processor.cc`, each captured draw carries: primitive type, index count,
|
||||
index-buffer address, vertex- and pixel-shader `ucode_data_hash`, the pixel
|
||||
shader's **texture bindings** (base, dimensions, format), and **vertex attribute
|
||||
0 of binding 0**. There is no `RB_BLENDCONTROL` / `RB_COLORCONTROL` dump. An
|
||||
earlier version of this section claimed the capture "reads the actual blend
|
||||
state"; it does not.
|
||||
|
||||
So the route splits:
|
||||
|
||||
* ✅ **Testable today, no code change** — whether the game passes a **vertex
|
||||
colour** for those draws. The capture dumps vertex attributes, and a pink vertex
|
||||
colour would explain white-versus-pink directly.
|
||||
* ❔ **Needs a Canary change** — the blend mode itself, which means adding an
|
||||
`RB_BLENDCONTROL` dump to the same capture path.
|
||||
|
||||
Either way it is instrumentation of the running guest, not another field in the
|
||||
file.
|
||||
|
||||
|
||||
## ✅ SOLVED — the game draws the swoosh as ROTATED QUADS, which our blit cannot
|
||||
|
||||
The route named in the previous section was run: `--ui_draw_capture_frames=3`,
|
||||
armed with F10 on the settled title.
|
||||
[`title-draw-capture-vertex-colours.log`](captures/title-builds/title-draw-capture-vertex-colours.log)
|
||||
is the capture, 22 draws over 3 frames.
|
||||
|
||||
### 🔴 First, the vertex-colour hypothesis dies
|
||||
|
||||
Every vertex colour in the entire capture is `<alpha>FFFFFF` — **white RGB**, only
|
||||
the alpha varying: `FFFFFFFF`, `C5FFFFFF`, `C3FFFFFF`, `B8FFFFFF`, `B6FFFFFF`,
|
||||
`31FFFFFF`, `1EFFFFFF`. The game passes no colour. That was the eighth candidate.
|
||||
|
||||
### ✅ And the ninth is the answer — it is the GEOMETRY
|
||||
|
||||
Draw 2 submits **two parallelograms, neither axis-aligned**:
|
||||
|
||||
| quad | corners (NDC) | edge `v0→v1` | axis-aligned? |
|
||||
|---|---|---|---|
|
||||
| A | `(0.70,1.58) (1.24,1.02) (0.40,−1.57) (−0.14,−1.02)` | `(0.54, −0.56)` | **no** |
|
||||
| B | `(−1.29,1.02) (−0.85,1.81) (0.75,−1.02) (0.31,−1.81)` | `(0.44, 0.79)` | **no** |
|
||||
|
||||
Both verified parallelograms (opposite edges equal to 0.01), both **rotated** —
|
||||
roughly 45° and 61° — and both extending to `y = ±1.81`, well beyond the screen.
|
||||
That is the diagonal `Z` stroke.
|
||||
|
||||
**Our compositor cannot draw that.** `ui_layout::blit` walks destination rows and
|
||||
columns of an **axis-aligned rectangle** (`for row in 0..dh { for col in 0..dw`),
|
||||
sampling the source by a straight ratio. It has no rotation. So the swoosh is
|
||||
blitted upright where the game draws it skewed — which is exactly the observed
|
||||
signature: **right on average (+1.83), right in position (peak at (0,0)), wrong in
|
||||
structure (edge-corr 0.70)**, dark on one side of the true stroke and bright on
|
||||
the other.
|
||||
|
||||
### ❔ And a real gap in the decoded format
|
||||
|
||||
The keyframe carries `fade`, `scale_x`, `scale_y`, `tint`, `x`, `y`, `time`
|
||||
([`ui_layout.rs`](../../crates/sylpheed-formats/src/ui_layout.rs)) — **no
|
||||
rotation**. So where the game's rotation comes from is **not decoded**: either a
|
||||
field not yet identified, or the element is positioned by code rather than by its
|
||||
keyframes. That is the open question this leaves.
|
||||
|
||||
⚠️ **The "pink versus white" reading is now suspect.** It was a visual comparison
|
||||
of two differently-*shaped* renderings. Whether any colour difference survives
|
||||
correct geometry is **untested**, and should be re-checked rather than carried
|
||||
forward as a separate defect.
|
||||
|
||||
## 🟡 Three angle fields found — and they are NOT the title's rotation
|
||||
|
||||
The obvious place to look for the rotation was the keyframe block's three words
|
||||
at `+4`, `+8`, `+12`, which `ui_layout.rs` documented as `0`.
|
||||
|
||||
**They are not zero.** Over **72 287** keyframe blocks disc-wide:
|
||||
|
||||
| word | non-zero | commonest values (signed) |
|
||||
|---|---|---|
|
||||
| `+4` | **4.81 %** | `180`, `−180`, `22`, `90` |
|
||||
| `+8` | **4.56 %** | `180`, `90`, `−180`, `178` |
|
||||
| `+12` | **15.82 %** | `90`, `−90`, `120`, `−58` |
|
||||
|
||||
Values clustering on ±180, ±90, 120 read as **degrees**, and three of them suggests
|
||||
rotation about three axes. 🟡 That reading is **not tied to an observed rotation** —
|
||||
it is the shape of the numbers, nothing more. The doc comment is corrected either
|
||||
way: "0 on every frame seen" was a sample artefact.
|
||||
|
||||
### ✅ They *do* explain this screen — the earlier negative was wrong about reach
|
||||
|
||||
~~**Every element of `GP_TITLE` build 4 has all three at zero** — checked element
|
||||
by element. So the title's rotation comes from outside the keyframe data.~~
|
||||
**Withdrawn (2026-08-28).** That check walked build 4's **top-level declaration
|
||||
table**. The rotated quads belong to its two **nested leaf records**, and there
|
||||
`+12` reads **30** and **−45** — against a measured **+30.26°** and **−45.28°**.
|
||||
The bytes were read correctly; the *region* was too small. See
|
||||
[`structures/ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md).
|
||||
|
||||
⚠️ **One thing I should not have stated flatly:** that the skewed draw *is* the
|
||||
swoosh. It is the only skewed geometry in the capture and the swoosh is the only
|
||||
diagonal element on the screen, so the inference is reasonable — but it was not
|
||||
confirmed by matching the draw's texture or screen position to that element, and
|
||||
should be.
|
||||
|
||||
## 🔴 The skewed draw is NOT the swoosh — my identification was wrong
|
||||
|
||||
Converting draw 2's quads from NDC to screen space settles it:
|
||||
|
||||
| quad | screen corners | bbox |
|
||||
|---|---|---|
|
||||
| A | (1088,−209) (1434,−7) (896,925) (550,727) | x 550…1434, **y −209…925** |
|
||||
| B | (−186,−7) (96,−292) (1120,727) (838,1012) | x −186…1120, **y −292…1012** |
|
||||
|
||||
These span the **full screen height and well beyond it**. The swoosh
|
||||
(`ptlogo_back2`, rest `(71,126)`, 1118 × 262) is a **band** at y 126…360. Draw 2
|
||||
is not it.
|
||||
|
||||
## ✅ It is the two `ptloop` sweeps — confirmed
|
||||
|
||||
The bounding box was the wrong measurement; the quads are rotated, so what
|
||||
identifies them is their **edge lengths**:
|
||||
|
||||
| quad | size | rotation | centre |
|
||||
|---|---|---|---|
|
||||
| A | 400.1 × 1076.3 | **+30.26°** | (992.0, 359.1) |
|
||||
| B | 400.2 × 1444.5 | **−45.28°** | (467.2, 360.0) |
|
||||
|
||||
| quad | element | sprite × declared scale |
|
||||
|---|---|---|
|
||||
| A | `ptloop01.rat` | `pteff03.t32` 399×180 @ 100 %,**600 %** = 399 × **1080** |
|
||||
| B | `ptloop02.rat` | `pteff03a.t32` 399×180 @ 100 %,**800 %** = 399 × **1440** |
|
||||
|
||||
Five independent agreements, listed in
|
||||
[`structures/ui-keyframe-rotation.md`](structures/ui-keyframe-rotation.md): the
|
||||
count (two quads, two `ptloop` elements), both widths (400 vs 399), **both
|
||||
heights, which are different numbers that both land**, the direction each quad
|
||||
moves between the capture's two frames (matching each record's own sweep
|
||||
direction), and the vertex alphas falling inside the declared ramps.
|
||||
|
||||
The known-positives in the same capture pass the same test: draw 5 measures
|
||||
915 × 115 (`ptlogo1.t32` 919×113), draw 7 measures 691 × 18 (`ptcopyright.t32`
|
||||
694×20) and 512 × 50 (`ptbtn00.t32` 513×50).
|
||||
|
||||
### ✅ And "a keyframe group holds" survives
|
||||
|
||||
The consequence I flagged conditionally last iteration resolves the *other* way.
|
||||
Our renderer parks these two sweeps at their final keyframe (`x = 1521` and
|
||||
`−839`, both off-screen); the game draws them across the screen. That is not a
|
||||
contradiction — the capture caught them **mid-sweep**: the quad centres, x = 992
|
||||
and x = 467, both fall inside the decoded `t = 150…600` / `150…720` travel, the
|
||||
alphas are mid-ramp, and the sprites move in the decoded direction between
|
||||
frames. The capture is of the **build-in**, not of the settled screen, and the
|
||||
18 s stillness measurement (sd ≤ 0.01) still says the groups stop. No change to
|
||||
[the holds-not-loops finding](#-a-keyframe-group-holds-at-its-last-keyframe--it-does-not-loop).
|
||||
|
||||
### What this costs
|
||||
|
||||
Five iterations of swoosh work — the additive-blend test, the pivot analysis, the
|
||||
vertex-colour capture — were built on "the skewed draw is the swoosh", an
|
||||
identification made by *elimination on one screen* and never checked against the
|
||||
draw's own coordinates. The eliminations themselves stand (they were measured
|
||||
against the capture, not against the identification), but the chain of reasoning
|
||||
that pointed at `ptlogo_back2*` did not.
|
||||
30
tools/re-capture/bgm_wave_census.py
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Every BGM bank's sub-wave structure: count, bytes, PsuedoBytesPerSec, seconds.
|
||||
|
||||
Static, disc-only. See docs/re/structures/bgm-two-stems.md."""
|
||||
import sys, struct
|
||||
sys.path.insert(0,"/work/Syplheed-Reborn/tools/re-capture")
|
||||
exec(open("/work/Syplheed-Reborn/tools/re-capture/slb_segment_phase.py").read().split("def cmd_phases")[0])
|
||||
pak=Pak("/work/sylph_extract/dat/sound.pak")
|
||||
def bps(b, riff_at):
|
||||
# PsuedoBytesPerSec at RIFF+0x20 (u32 LE), SampleRate at +0x24
|
||||
return struct.unpack_from("<I", b, riff_at+0x20)[0], struct.unpack_from("<I", b, riff_at+0x24)[0]
|
||||
rows=[]
|
||||
for n in list(range(1,60))+list(range(101,120)):
|
||||
name=f"BGM_{n:03d}.slb"
|
||||
i=pak.find(name)
|
||||
if i is None: continue
|
||||
b=pak.read(i); ws=waves(b)
|
||||
ds=[]
|
||||
for w in ws:
|
||||
pb,sr=bps(b,w["at"])
|
||||
ds.append((w["data_size"], pb, sr, w["data_size"]/pb if pb else 0))
|
||||
rows.append((name,len(ws),ds))
|
||||
print(f"{'bank':14s} {'waves':>5s} per-wave (bytes, bytes/s, Hz, seconds)")
|
||||
same=0
|
||||
for name,k,ds in rows:
|
||||
txt=" | ".join(f"{d:8d} {pb:6d} {sr:5d} {sec:7.2f}s" for d,pb,sr,sec in ds)
|
||||
eq = len(ds)==2 and abs(ds[0][3]-ds[1][3])<0.05
|
||||
same += eq
|
||||
print(f"{name:14s} {k:5d} {txt} {'EQUAL-LENGTH' if eq else ''}")
|
||||
print(f"\n{len(rows)} BGM banks; {same} are exactly two waves of equal duration")
|
||||
48
tools/re-capture/doc_figure_check.py
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Do the headline figures in a doc match its committed reference data?
|
||||
|
||||
Numbers drift between a run and the prose written about it, and nothing was
|
||||
checking. This compares the two NUMERICALLY, which matters: a naive string
|
||||
grep reports every figure as a mismatch, because the data files write `14709`
|
||||
and the docs write `14 709` (thin space) and round `33.66` to `33.7`. That
|
||||
false-positive run is why this is a script and not a grep.
|
||||
|
||||
doc_figure_check.py # runs the built-in case list
|
||||
"""
|
||||
import re, sys
|
||||
|
||||
CASES = [
|
||||
("eff-bit-census.txt", "structures/ui-paint-order-key.md",
|
||||
[14709, 2338, 2657, 1399, 8315, 0.468, 0.144]),
|
||||
("plateau-census.txt", "structures/ui-resting-pose.md",
|
||||
[15493, 3807, 24.57, 50.2]),
|
||||
("rotation-toplevel-census.txt", "structures/ui-keyframe-rotation.md",
|
||||
[2152, 13.89]),
|
||||
("eff-bit-alpha-test.txt", "structures/ui-paint-order-key.md",
|
||||
[55.52, 33.66, 52.52, 30.17, 76.5, 64.1]),
|
||||
]
|
||||
NUM = re.compile(r"\d[\d ,]*\.?\d*")
|
||||
|
||||
def nums(text):
|
||||
out = set()
|
||||
for m in NUM.finditer(text):
|
||||
try: out.add(float(re.sub(r"[ ,]", "", m.group())))
|
||||
except ValueError: pass
|
||||
return out
|
||||
|
||||
def main():
|
||||
bad = 0
|
||||
for dfile, mfile, figs in CASES:
|
||||
D = nums(open(f"docs/re/data/{dfile}", encoding="utf-8").read())
|
||||
M = nums(open(f"docs/re/{mfile}", encoding="utf-8").read())
|
||||
for f in figs:
|
||||
in_d = any(abs(f - x) < 0.011 for x in D)
|
||||
in_m = any(abs(f - x) < 0.051 for x in M) # the doc may round
|
||||
if not (in_d and in_m):
|
||||
bad += 1
|
||||
print(f" CHECK {dfile} / {mfile}: {f} data:{in_d} doc:{in_m}")
|
||||
print(f"{sum(len(c[2]) for c in CASES)} figures checked, {bad} to look at")
|
||||
return 1 if bad else 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
52
tools/re-capture/doc_link_check.py
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Do the files the docs cite actually exist in the repo?
|
||||
|
||||
An answer whose evidence is not committed cannot be used by anyone without a
|
||||
disc and an emulator, which is the whole point of the reference data. This walks
|
||||
every markdown file under docs/ and resolves each relative link, reporting the
|
||||
ones that point at nothing.
|
||||
|
||||
Skips external links (http, mailto) and pure anchors. Reports missing targets
|
||||
and, separately, committed-but-EMPTY files, which are the sneakier failure --
|
||||
a link that resolves to a zero-byte file looks fine in every listing.
|
||||
|
||||
doc_link_check.py [docs-root]
|
||||
"""
|
||||
import os, re, sys
|
||||
|
||||
ROOT = sys.argv[1] if len(sys.argv) > 1 else "docs"
|
||||
LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
||||
|
||||
missing, empty, ok = [], [], 0
|
||||
for dirpath, _dirs, files in os.walk(ROOT):
|
||||
for f in files:
|
||||
if not f.endswith(".md"):
|
||||
continue
|
||||
src = os.path.join(dirpath, f)
|
||||
try:
|
||||
body = open(src, encoding="utf-8").read()
|
||||
except Exception:
|
||||
continue
|
||||
for target in LINK.findall(body):
|
||||
if target.startswith(("http://", "https://", "mailto:", "#")):
|
||||
continue
|
||||
path = os.path.normpath(os.path.join(dirpath, target.split("#")[0]))
|
||||
if not path:
|
||||
continue
|
||||
if not os.path.exists(path):
|
||||
missing.append((src, target))
|
||||
elif os.path.isfile(path) and os.path.getsize(path) == 0:
|
||||
empty.append((src, target))
|
||||
else:
|
||||
ok += 1
|
||||
|
||||
print(f"{ok} link(s) resolve")
|
||||
if missing:
|
||||
print(f"\n{len(missing)} MISSING target(s):")
|
||||
for s, t in sorted(missing):
|
||||
print(f" {s} -> {t}")
|
||||
if empty:
|
||||
print(f"\n{len(empty)} link(s) resolve to an EMPTY file:")
|
||||
for s, t in sorted(empty):
|
||||
print(f" {s} -> {t}")
|
||||
sys.exit(1 if (missing or empty) else 0)
|
||||
58
tools/re-capture/eff_bit_alpha_test.py
Executable file
@@ -0,0 +1,58 @@
|
||||
"""Does bit 0x02 separate sprites by their RGB-vs-alpha content?
|
||||
|
||||
Premultiplied alpha predicts RGB <= A everywhere for the flagged group. Tested
|
||||
below and refuted. What remains is a description: how often RGB exceeds A, which
|
||||
is the signature of glow art (bright colour carried at low alpha).
|
||||
|
||||
Bit comes from the T8aD header; the name from the string immediately preceding
|
||||
it (validated 17/18 on build 4 against the RATC child order).
|
||||
"""
|
||||
import struct, zlib, glob, re, os
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
NAME = re.compile(rb'[A-Za-z0-9_.]{2,31}\x00')
|
||||
base = "/work/sylph_extract/dat/GP_TITLE"
|
||||
stub = open(base + ".pak", "rb").read()
|
||||
n = struct.unpack_from(">I", stub, 4)[0]
|
||||
blob = b"".join(open(s, "rb").read() for s in sorted(glob.glob(base + ".p[0-9][0-9]")))
|
||||
flags = {} # (entry_hash, name, w, h) -> flags
|
||||
for i in range(n):
|
||||
h_, off, sz = struct.unpack_from(">III", stub, 0x10 + 12 * i)
|
||||
st = blob[off:off + sz]
|
||||
if len(st) < 10: continue
|
||||
try: d = zlib.decompress(st[10:]) if st[:2] == b"Z1" else st
|
||||
except Exception: continue
|
||||
for m in re.finditer(b"T8aD", d):
|
||||
o = m.start()
|
||||
try:
|
||||
fl = struct.unpack_from(">I", d, o + 4)[0]
|
||||
w = struct.unpack_from(">I", d, o + 0x14)[0]
|
||||
hh = struct.unpack_from(">I", d, o + 0x18)[0]
|
||||
except Exception: continue
|
||||
if not (0 < w <= 4096 and 0 < hh <= 4096): continue
|
||||
ms = list(NAME.finditer(d[max(0, o - 64):o]))
|
||||
if not ms: continue
|
||||
flags[(f"{h_:08x}", ms[-1].group()[:-1].decode("latin1"), w, hh)] = fl
|
||||
rows = []
|
||||
for f in sorted(glob.glob("/tmp/tex/*.png")):
|
||||
b = os.path.basename(f)
|
||||
m = re.match(r"([0-9a-f]{8})_(.+)_(\d+)x(\d+)\.png$", b)
|
||||
if not m: continue
|
||||
key = (m.group(1), m.group(2), int(m.group(3)), int(m.group(4)))
|
||||
fl = flags.get(key)
|
||||
if fl is None: continue
|
||||
a = np.asarray(Image.open(f).convert("RGBA")).astype(int)
|
||||
rgb = a[:, :, :3].max(axis=2); al = a[:, :, 3]
|
||||
rows.append((bool(fl & 2), 100 * float((rgb > al).mean()), m.group(2)))
|
||||
s = [r[1] for r in rows if r[0]]; c = [r[1] for r in rows if not r[0]]
|
||||
print(f"matched {len(rows)} decoded textures to a T8aD flag word")
|
||||
print(f" bit SET n={len(s):3d} mean %(RGB>A) {np.mean(s):6.2f} median {np.median(s):6.2f}")
|
||||
print(f" bit clear n={len(c):3d} mean %(RGB>A) {np.mean(c):6.2f} median {np.median(c):6.2f}")
|
||||
print(f"\n premultiplied would require ~0% for the flagged group -> REFUTED")
|
||||
# separability: what threshold best splits them, and how well?
|
||||
best = (0, None)
|
||||
for t in np.arange(0, 100, 0.5):
|
||||
acc = (sum(x > t for x in s) + sum(x <= t for x in c)) / len(rows)
|
||||
if acc > best[0]: best = (acc, t)
|
||||
print(f" best single-threshold accuracy: {100*best[0]:.1f}% at %(RGB>A) > {best[1]}")
|
||||
print(f" (base rate, always-guess-majority: {100*max(len(s),len(c))/len(rows):.1f}%)")
|
||||
60
tools/re-capture/eff_bit_census.py
Executable file
@@ -0,0 +1,60 @@
|
||||
"""Disc-wide: does T8aD flag bit 0x02 imply an `eff` name?
|
||||
|
||||
Names come from the NAME IMMEDIATELY PRECEDING each T8aD chunk -- validated
|
||||
17/18 on GP_TITLE build 4 against the known RATC child order. The single
|
||||
mismatch is the documented `pteff04.t32` -> registered as `8AX` case, i.e. the
|
||||
preceding name is the ELEMENT's (opt) name and the child list is the SPRITE's.
|
||||
This test uses the element name and says so.
|
||||
"""
|
||||
import struct, zlib, glob, os, re, collections
|
||||
NAME = re.compile(rb'[A-Za-z0-9_.]{2,31}\x00')
|
||||
|
||||
def entries(base):
|
||||
stub = open(base + ".pak", "rb").read()
|
||||
if stub[:4] != b"IPFB": return
|
||||
n = struct.unpack_from(">I", stub, 4)[0]
|
||||
segs = sorted(glob.glob(base + ".p[0-9][0-9]"))
|
||||
if not segs: return
|
||||
blob = b"".join(open(s, "rb").read() for s in segs)
|
||||
for i in range(n):
|
||||
h, off, sz = struct.unpack_from(">III", stub, 0x10 + 12 * i)
|
||||
st = blob[off:off + sz]
|
||||
if len(st) < 10: continue
|
||||
try: yield (zlib.decompress(st[10:]) if st[:2] == b"Z1" else st)
|
||||
except Exception: continue
|
||||
|
||||
set_eff = set_noneff = clear_eff = clear_noneff = 0
|
||||
examples = []
|
||||
for pak in sorted(glob.glob("/work/sylph_extract/dat/GP_*.pak")):
|
||||
for d in entries(pak[:-4]):
|
||||
for m in re.finditer(b"T8aD", d):
|
||||
o = m.start()
|
||||
try:
|
||||
fl = struct.unpack_from(">I", d, o + 4)[0]
|
||||
w = struct.unpack_from(">I", d, o + 0x14)[0]
|
||||
h = struct.unpack_from(">I", d, o + 0x18)[0]
|
||||
except Exception: continue
|
||||
if not (0 < w <= 4096 and 0 < h <= 4096): continue
|
||||
ms = list(NAME.finditer(d[max(0, o - 64):o]))
|
||||
if not ms: continue
|
||||
nm = ms[-1].group()[:-1].decode("latin1")
|
||||
eff = "eff" in nm.lower()
|
||||
if fl & 2:
|
||||
if eff: set_eff += 1
|
||||
else:
|
||||
set_noneff += 1
|
||||
if len(examples) < 12: examples.append((os.path.basename(pak), nm, f"{fl:08x}", f"{w}x{h}"))
|
||||
else:
|
||||
clear_eff += 1 if eff else 0
|
||||
clear_noneff += 0 if eff else 1
|
||||
tot = set_eff + set_noneff + clear_eff + clear_noneff
|
||||
print(f"sprites with a resolvable preceding name: {tot}")
|
||||
print(f" bit SET & name has 'eff' : {set_eff}")
|
||||
print(f" bit SET & name lacks 'eff': {set_noneff} <-- counterexamples to 'set => eff'")
|
||||
print(f" bit clear & name has 'eff' : {clear_eff}")
|
||||
print(f" bit clear & name lacks 'eff': {clear_noneff}")
|
||||
if tot: print(f"\n P(name has 'eff' | bit set) = {set_eff/max(set_eff+set_noneff,1):.3f}")
|
||||
if tot: print(f" P(name has 'eff' | bit clear) = {clear_eff/max(clear_eff+clear_noneff,1):.3f}")
|
||||
if examples:
|
||||
print("\n counterexamples (bit set, no 'eff'):")
|
||||
for e in examples: print(" ", e)
|
||||
49
tools/re-capture/eightax_detail_test.py
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does the running game draw the full-res `8AX` or the 640x360 `ptbase` upscaled?
|
||||
|
||||
Both carry the SAME artwork at two resolutions, so no pixel comparison of the
|
||||
backgrounds can separate them. What CAN: the detail 8AX has and an upscale does
|
||||
not. Compute `8AX - upscale(ptbase)` -- the 8AX-only detail -- and ask whether
|
||||
the live capture contains it.
|
||||
|
||||
Both candidates are first mapped into the capture's tone domain with the
|
||||
measured gamma (docs/re/structures/ui-render-tone-curve.md); without that, the
|
||||
capture's residual is dominated by the tone difference and the test is blind.
|
||||
|
||||
CONTROLS are the point: the same correlation with the 8AX residual shifted 7 px
|
||||
and flipped. Those preserve the spatial correlation structure while destroying
|
||||
the alignment, so they are what "no signal" looks like here.
|
||||
"""
|
||||
import sys
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
CASES = [
|
||||
("main menu", 1.491, "a715f485_8AX_1280x720.png", "a715f485_ptbase.t32_640x360.png",
|
||||
"docs/re/captures/title-builds/live-main-menu.png"),
|
||||
("title", 1.338, "a60fcb85_8AX_1280x720.png", "a60fcb85_ptbase2.t32_640x360.png",
|
||||
"docs/re/captures/title-builds/live-title-press-a.png"),
|
||||
]
|
||||
TEX = sys.argv[1] if len(sys.argv) > 1 else "/tmp/tex"
|
||||
Y0, Y1, X0, X1 = 40, 300, 20, 600 # background, away from menu text
|
||||
|
||||
def main():
|
||||
for label, gamma, ax_png, pb_png, cap_png in CASES:
|
||||
ax = np.asarray(Image.open(f"{TEX}/{ax_png}").convert("L")).astype(float)
|
||||
pb = np.asarray(Image.open(f"{TEX}/{pb_png}").convert("L")
|
||||
.resize((1280, 720), Image.BILINEAR)).astype(float)
|
||||
cap = np.asarray(Image.open(cap_png).convert("L")).astype(float)
|
||||
h, w = cap.shape
|
||||
ax, pb = ax[:h, :w], pb[:h, :w]
|
||||
g = lambda a: 255 * np.power(np.clip(a, 0, 255) / 255.0, gamma)
|
||||
axg, pbg = g(ax), g(pb)
|
||||
E = (axg - pbg)[Y0:Y1, X0:X1] # detail only 8AX has
|
||||
C = (cap - pbg)[Y0:Y1, X0:X1] # how the capture departs from the upscale
|
||||
cc = lambda a, b: float(np.corrcoef(a.ravel(), b.ravel())[0, 1])
|
||||
ceiling = E.std() / C.std()
|
||||
r = cc(C, E)
|
||||
print(f"{label}: corr {r:+.4f} controls {cc(C[:, :-7], E[:, 7:]):+.4f} (shift) "
|
||||
f"{cc(C, E[::-1]):+.4f} (flip) ceiling {ceiling:.3f} -> {100*r/ceiling:.0f}% of it")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
52
tools/re-capture/fade_quads.py
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump every `.prm` primitive's keyframe group (alpha + time) for a UI build.
|
||||
|
||||
The screen-transition fade lives here -- see docs/re/screen-transitions.md.
|
||||
Usage: PAK=<pak> fade_quads.py [build...] (default: GP_TITLE, builds 2 4 5 6)"""
|
||||
import struct, sys, glob, os, zlib
|
||||
sys.path.insert(0, "/work/Syplheed-Reborn/tools/re-capture")
|
||||
src = open("/work/Syplheed-Reborn/tools/re-capture/regn_decode.py").read()
|
||||
exec(src.split("# ── POF0")[0])
|
||||
|
||||
DECL_AT, DECL_ENTRY, KF = 0x20, 60, 40
|
||||
def be32(b,o): return struct.unpack_from(">I", b, o)[0]
|
||||
|
||||
def parse(bundle):
|
||||
n = be32(bundle, 0x14)
|
||||
names=[]
|
||||
for i in range(n):
|
||||
o = DECL_AT + i*DECL_ENTRY
|
||||
names.append(bundle[o:o+28].split(b"\0")[0].decode("ascii","replace"))
|
||||
groups={}
|
||||
pos = DECL_AT + n*DECL_ENTRY
|
||||
for _ in range(n):
|
||||
if pos+8 > len(bundle): break
|
||||
idx = be32(bundle,pos); frames = be32(bundle,pos+4)
|
||||
if idx>=n or frames==0 or frames>4096: break
|
||||
first = pos+12; end = first + frames*KF - 4
|
||||
g=[]
|
||||
for k in range(frames):
|
||||
blk = first + k*KF
|
||||
if blk+36 > len(bundle) or blk+36 > end: break
|
||||
g.append(dict(fade=be32(bundle,blk), sx=be32(bundle,blk+16), sy=be32(bundle,blk+20),
|
||||
x=struct.unpack_from(">i",bundle,blk+28)[0], y=struct.unpack_from(">i",bundle,blk+32)[0],
|
||||
t=(be32(bundle,blk+36) if blk+40<=end else None)))
|
||||
groups[idx]=g; pos=end
|
||||
return names, groups
|
||||
|
||||
pak = os.environ.get("PAK", "/work/sylph_extract/dat/GP_TITLE.pak")
|
||||
E = pak_entries(pak)
|
||||
E = [b for h,b in E]
|
||||
# build index -> pak entry index, from `screen list`: 0..9 then 12, 15
|
||||
BUILDS = {0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,10:12,11:15}
|
||||
want = [int(a) for a in sys.argv[1:]] or [2,4,5,6]
|
||||
for b in want:
|
||||
names, groups = parse(E[BUILDS[b]])
|
||||
print(f"=== build {b} ===")
|
||||
for i,nm in enumerate(names):
|
||||
if not nm.endswith(".prm"): continue
|
||||
g = groups.get(i, [])
|
||||
print(f" [{i}] {nm} {len(g)} kf")
|
||||
for k in g:
|
||||
a=(k['fade']>>24)&0xff
|
||||
print(f" t={str(k['t']):>5} fade=0x{k['fade']:08x} (alpha {a:3d}) scale={k['sx']}x{k['sy']} pos=({k['x']},{k['y']})")
|
||||
52
tools/re-capture/fast_title_probe.py
Executable file
@@ -0,0 +1,52 @@
|
||||
"""Fast title probe: a PERIODICALLY RESTARTED x11grab stream + glyph count.
|
||||
|
||||
🔴 DO NOT run one x11grab stream for the whole session. Measured 2026-08-29: a
|
||||
single long-lived stream degrades from 3.98 fps to 1.60 fps and then FREEZES,
|
||||
reporting a stale frame indefinitely. Cross-checked at one moment against an
|
||||
independent `import` grab: the stream said surface mean 5.21 while `import` said
|
||||
125.65, and the stream repeated that same value for four consecutive 30 s marks.
|
||||
Any negative result from a stalled stream is worthless -- an earlier "2391
|
||||
frames, zero hits" claim came from exactly that failure.
|
||||
|
||||
So the stream is torn down and restarted every RESTART_S seconds. Startup costs
|
||||
~0.3 s, which is cheap against the title's few-second window and buys a
|
||||
guarantee that the frames are live.
|
||||
|
||||
Replaces the two-`screenshot` polling loop, which costs ~4-11 s per grab while
|
||||
xenia runs and so samples every ~41 s -- slower than the title screen lasts.
|
||||
Counter is byte-identical to is_title.py (controlled: 753 on the committed title
|
||||
capture, 327 on the main menu).
|
||||
"""
|
||||
import subprocess, sys, time
|
||||
import numpy as np
|
||||
W,H = 1280,720
|
||||
LIMIT = float(sys.argv[1]) if len(sys.argv)>1 else 120
|
||||
NEED = int(sys.argv[2]) if len(sys.argv)>2 else 400
|
||||
RESTART_S = 30 # tear down and restart the stream this often
|
||||
def _open():
|
||||
return subprocess.Popen(["ffmpeg","-loglevel","error","-f","x11grab","-draw_mouse","0",
|
||||
"-video_size",f"{W}x{H}","-i",":98","-r","4","-f","rawvideo","-pix_fmt","rgb24","-"],
|
||||
stdout=subprocess.PIPE, bufsize=W*H*3*2)
|
||||
p=_open()
|
||||
n=W*H*3; t0=time.time(); frames=0; best=0; hits=0; seg=time.time()
|
||||
while time.time()-t0 < LIMIT:
|
||||
if time.time()-seg > RESTART_S: # a stalled stream reports stale frames forever
|
||||
p.kill(); p=_open(); seg=time.time()
|
||||
buf=p.stdout.read(n)
|
||||
if len(buf)<n:
|
||||
p.kill(); p=_open(); seg=time.time(); continue
|
||||
a=np.frombuffer(buf,np.uint8).reshape(H,W,3).astype(int)
|
||||
r,g,b=a[:,:,0],a[:,:,1],a[:,:,2]
|
||||
c=int(((g>130)&(g-r>45)&(g-b>45)).sum())
|
||||
frames+=1
|
||||
if c>best:
|
||||
best=c
|
||||
if c>=NEED:
|
||||
from PIL import Image
|
||||
Image.fromarray(a.astype(np.uint8)).save("/sylph-home/re/shots/fast-title.png")
|
||||
if c>=NEED:
|
||||
hits+=1
|
||||
if hits==1: print(f"TITLE at t={time.time()-t0:.1f}s glyph={c} (frame {frames})", flush=True)
|
||||
p.kill()
|
||||
dt=time.time()-t0
|
||||
print(f"{frames} frames in {dt:.1f}s = {frames/dt:.2f} fps; max glyph {best}; hits {hits}")
|
||||
21
tools/re-capture/frame_match.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Match a captured game frame against candidate .wmv files by frame signature.
|
||||
|
||||
Static + previously-captured frames. See docs/re/movie-binding.md."""
|
||||
import numpy as np, subprocess, sys, os
|
||||
from PIL import Image
|
||||
W,H=32,18
|
||||
def sig_img(path, crop_bottom=False):
|
||||
im=Image.open(path).convert("L")
|
||||
if crop_bottom: # movie frame -> what the capture can see
|
||||
w,h=im.size; im=im.crop((0,0,w,int(h*675/720)))
|
||||
a=np.asarray(im.resize((W,H)),dtype=np.float32).ravel()
|
||||
return (a-a.mean())/(a.std()+1e-6)
|
||||
def movie_sigs(path, fps=1.0):
|
||||
out=f"/tmp/q9/fr_{os.path.basename(path).replace('.wmv','')}"
|
||||
os.makedirs(out, exist_ok=True)
|
||||
if not os.listdir(out):
|
||||
subprocess.run(["ffmpeg","-v","error","-i",path,"-vf",f"fps={fps},scale=160:90",
|
||||
"-y",f"{out}/f%05d.png"],check=True)
|
||||
fs=sorted(os.listdir(out))
|
||||
return [(int(f[1:6]), sig_img(os.path.join(out,f), crop_bottom=True)) for f in fs]
|
||||
53
tools/re-capture/handoff_lint.py
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does HANDOFF.md still assert something its own linked doc has retracted?
|
||||
|
||||
This has now happened four times: a finding lands in docs/re/ and the page the
|
||||
port agent actually reads keeps the superseded text. Once it was worse than
|
||||
stale -- the port was told SE audio was "undecodable from the disc" while three
|
||||
cues were located and decoding to PCM in the linked file.
|
||||
|
||||
For every HANDOFF line that makes a STRONG NEGATIVE claim and links to a doc,
|
||||
check whether that doc contains retraction language. A hit is not proof the row
|
||||
is wrong -- docs retract other things too -- it is a row to read.
|
||||
|
||||
handoff_lint.py [docs/port/HANDOFF.md]
|
||||
"""
|
||||
import os, re, sys
|
||||
|
||||
HANDOFF = sys.argv[1] if len(sys.argv) > 1 else "docs/port/HANDOFF.md"
|
||||
# Resolve links the way markdown does: relative to the FILE, not to a guessed
|
||||
# repo root. The first version joined a guessed root and reported every existing
|
||||
# doc as missing -- which is the tool failing its own control, and is why the
|
||||
# "missing doc" branch prints rather than being silently skipped.
|
||||
BASE = os.path.dirname(os.path.abspath(HANDOFF))
|
||||
|
||||
NEGATIVE = re.compile(r"undecodable|cannot be|can not be|impossible|no .{0,24}exists|"
|
||||
r"does not exist|not extractable|unreachable", re.I)
|
||||
RETRACT = re.compile(r"retract|withdraw|was too strong|that was wrong|superseded|"
|
||||
r"refuted\b.{0,40}\bmine|no longer", re.I)
|
||||
LINK = re.compile(r"\]\(\.\./re/([^)]+)\)")
|
||||
|
||||
def main():
|
||||
text = open(HANDOFF, encoding="utf-8").read().splitlines()
|
||||
flagged = checked = 0
|
||||
for n, line in enumerate(text, 1):
|
||||
if not NEGATIVE.search(line):
|
||||
continue
|
||||
for rel in LINK.findall(line):
|
||||
doc = os.path.normpath(os.path.join(BASE, "..", "re", rel.split("#")[0]))
|
||||
if not os.path.exists(doc):
|
||||
print(f" L{n}: link to a MISSING doc: {rel}")
|
||||
flagged += 1
|
||||
continue
|
||||
checked += 1
|
||||
body = open(doc, encoding="utf-8").read()
|
||||
hits = sorted({m.group(0).lower() for m in RETRACT.finditer(body)})
|
||||
if hits:
|
||||
flagged += 1
|
||||
claim = NEGATIVE.search(line).group(0)
|
||||
print(f" L{n}: claims {claim!r}; linked {rel} contains {hits}")
|
||||
print(f"\n{checked} negative claim(s) with links checked, {flagged} to read")
|
||||
return 1 if flagged else 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
88
tools/re-capture/kf_rotation_census.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Structural census of the keyframe block's angle words (+4/+8/+12), disc-wide.
|
||||
|
||||
`+12` is the screen-plane ROTATION in degrees (see
|
||||
docs/re/structures/ui-keyframe-rotation.md). This is the disc-wide check behind
|
||||
that decode, and it exists because the FIRST version of it was wrong in a way
|
||||
that hid the very blocks the decode rests on.
|
||||
|
||||
⚠️ The blocks are **not 4-byte aligned**. A nested leaf record's `RATC` blob can
|
||||
start at an odd offset (`ptloop01.rat` sits at 0xbb5966), so its keyframe blocks
|
||||
inherit that alignment. An earlier scan filtered candidates on `%4 == 0`, found
|
||||
0/3 of its own control blocks, and under-counted the corpus by 16 341 blocks —
|
||||
every one of them inside a nested record. The CONTROL below is not decoration:
|
||||
it must print 3/3 with +12 = {30} and {-45} or the numbers mean nothing.
|
||||
|
||||
A keyframe block is 40 bytes: fade(ARGB) | w1 w2 w3 | sx sy | tint | x y | t.
|
||||
The filter keys on SHAPE, not on a count: >=2 consecutive blocks whose fade is
|
||||
0x??ffffff, whose tint is 0xffffffff, and whose scale words are 1..4000.
|
||||
"""
|
||||
import struct, zlib, glob, os, sys, collections, re
|
||||
|
||||
def entries(base):
|
||||
stub=open(base+'.pak','rb').read()
|
||||
if stub[:4]!=b'IPFB': return
|
||||
n=struct.unpack_from('>I',stub,4)[0]
|
||||
segs=sorted(glob.glob(base+'.p[0-9][0-9]'))
|
||||
if not segs: return
|
||||
blob=b''.join(open(s,'rb').read() for s in segs)
|
||||
for i in range(n):
|
||||
h,off,sz=struct.unpack_from('>III',stub,0x10+12*i)
|
||||
st=blob[off:off+sz]
|
||||
if len(st)<10: continue
|
||||
try: yield h,(zlib.decompress(st[10:]) if st[:2]==b'Z1' else st)
|
||||
except Exception: continue
|
||||
|
||||
def blocks(d):
|
||||
"""Yield offsets of the first block of each run of >=2 keyframe blocks."""
|
||||
n=len(d)
|
||||
U=lambda p: struct.unpack_from('>I',d,p)[0]
|
||||
seen=set()
|
||||
cands=sorted({m.start()-24 for m in re.finditer(b'\xff\xff\xff\xff',d)
|
||||
if m.start()>=24})
|
||||
for o in cands:
|
||||
if o in seen or o+80>n: continue
|
||||
if (U(o)&0x00ffffff)==0x00ffffff and U(o+24)==0xffffffff \
|
||||
and 0<U(o+16)<=4000 and 0<U(o+20)<=4000:
|
||||
k=0
|
||||
while o+40*(k+1)<=n and (U(o+40*k)&0x00ffffff)==0x00ffffff \
|
||||
and U(o+40*k+24)==0xffffffff \
|
||||
and 0<U(o+40*k+16)<=4000 and 0<U(o+40*k+20)<=4000:
|
||||
k+=1
|
||||
if k>=2:
|
||||
for j in range(k):
|
||||
seen.add(o+40*j); yield o+40*j
|
||||
|
||||
S=lambda d,p: struct.unpack_from('>i',d,p)[0]
|
||||
|
||||
# --- control: the two known ptloop blocks must be found, with 30 / -45 ---
|
||||
d4=open('/tmp/build4.bin','rb').read()
|
||||
found={o for o in blocks(d4)}
|
||||
for name,base,want in [("ptloop01",0xbb5966,30),("ptloop02",0xbb5a82,-45)]:
|
||||
hits=[b for b in (base+0x68+40*k for k in range(3)) if b in found]
|
||||
vals={S(d4,b+12) for b in hits}
|
||||
print(f"CONTROL {name}: {len(hits)}/3 blocks found, +12 = {vals} (want {want})")
|
||||
if not all(True for _ in [0]): sys.exit(1)
|
||||
|
||||
hist=collections.Counter(); nz=collections.Counter(); total=0
|
||||
examples=collections.defaultdict(list)
|
||||
for pak in sorted(glob.glob('/work/sylph_extract/dat/GP_*.pak')):
|
||||
base=pak[:-4]
|
||||
for h,d in entries(base):
|
||||
if b'RATC' not in d[:4] and d[:4]!=b'RATC': pass
|
||||
for o in blocks(d):
|
||||
total+=1
|
||||
for lbl,off in (("+4",4),("+8",8),("+12",12)):
|
||||
v=S(d,o+off)
|
||||
if v!=0:
|
||||
nz[lbl]+=1
|
||||
hist[(lbl,v)]+=1
|
||||
if len(examples[lbl])<8: examples[lbl].append((os.path.basename(base),f"{h:08x}",hex(o),v))
|
||||
print(f"\nblocks scanned disc-wide: {total}")
|
||||
for lbl in ("+4","+8","+12"):
|
||||
print(f" {lbl}: non-zero in {nz[lbl]:6d} ({100*nz[lbl]/max(total,1):5.2f} %)")
|
||||
print("\nvalue histogram (non-zero), top 25:")
|
||||
for (lbl,v),c in hist.most_common(25):
|
||||
print(f" {lbl} = {v:>8} x{c}")
|
||||
print("\nexamples:")
|
||||
for lbl,ex in examples.items():
|
||||
for e in ex[:4]: print(" ",lbl,e)
|
||||
11
tools/re-capture/menu_focus.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import sys
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
names=["NEW GAME","LOAD GAME","TUTORIAL","OPTIONS","EXTRAS"]
|
||||
ys=[166,241,315,390,465]
|
||||
for p in sys.argv[1:]:
|
||||
a=np.asarray(Image.open(p).convert("L"),dtype=float)
|
||||
# the focus RING sits in the gutter left of the label; nothing else is bright there
|
||||
v=[a[y-20:y+20,500:542].max() for y in ys]
|
||||
i=int(np.argmax(v))
|
||||
print(f"{p.split('/')[-1]:26s} -> {names[i]:10s} " + " ".join(f"{x:5.0f}" for x in v))
|
||||