Compare commits
122 Commits
human/r1-r
...
formats-pi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dbfa320ae | ||
|
|
fb6095ae3a | ||
|
|
3e8235ddbd | ||
|
|
8c71520ab8 | ||
|
|
59f5bf1b59 | ||
|
|
232ae0fa9c | ||
|
|
ef4ea8fe86 | ||
|
|
6f87ecc4db | ||
|
|
e0de5c6f22 | ||
|
|
6072d216a8 | ||
|
|
b6f0cf3fb2 | ||
|
|
3c004845fa | ||
|
|
ec06c50bf6 | ||
|
|
8dfa0ebac5 | ||
|
|
15d9037774 | ||
|
|
59f3ac6846 | ||
|
|
bec7f02c0a | ||
|
|
f8abb56bc4 | ||
|
|
aa9b7ef340 | ||
|
|
304ce9efaa | ||
|
|
e94e203a71 | ||
|
|
6d7bc87b0f | ||
|
|
159778faf1 | ||
|
|
29c99e1b3e | ||
|
|
1f6e07598e | ||
|
|
7a5f7b886a | ||
|
|
6c8ab5db94 | ||
|
|
acaacc36e0 | ||
|
|
78328c5022 | ||
|
|
cf91ad1dcc | ||
|
|
cfcda5501c | ||
|
|
47faeaa7a9 | ||
|
|
1a2f4fdc2d | ||
|
|
69bc4cd1cd | ||
|
|
311bd16ad8 | ||
|
|
d98c8214cc | ||
|
|
4c74e579a0 | ||
|
|
d4a08ad194 | ||
|
|
7b5a4aa041 | ||
|
|
38b80302b7 | ||
|
|
d0735d2c25 | ||
|
|
37af2055e3 | ||
|
|
81f42f9041 | ||
|
|
21a2c649e6 | ||
|
|
ce5505872b | ||
|
|
f2a74efe2d | ||
|
|
1f9609c37f | ||
|
|
258e1e63f7 | ||
|
|
f15c3f516d | ||
|
|
a1adb17d35 | ||
|
|
5da1a837fa | ||
|
|
14a7897c8f | ||
|
|
2f1c561d76 | ||
|
|
5a4428ec67 | ||
|
|
3bd1a61ea9 | ||
|
|
c5fe6a460c | ||
|
|
8b4dcccb67 | ||
|
|
b7930e6616 | ||
|
|
2a7ac9f859 | ||
|
|
fbe4ba5526 | ||
|
|
a13dd42c2d | ||
|
|
9e66d6a82c | ||
|
|
5fb64f3d31 | ||
|
|
6f25a6a0d0 | ||
|
|
4f77ab26d7 | ||
|
|
eaa578421f | ||
|
|
371cd0d610 | ||
|
|
3d5b5bae13 | ||
|
|
e8afbc8632 | ||
|
|
b56d0e1fb6 | ||
|
|
55cdcac61b | ||
|
|
02a99bc3e9 | ||
|
|
5938d3b1fc | ||
|
|
3a5f215179 | ||
|
|
c05c8e1719 | ||
|
|
b07f8984e3 | ||
|
|
05d278173d | ||
|
|
b047788d37 | ||
|
|
930ab1016d | ||
|
|
e2c599fa72 | ||
|
|
105a1f65e4 | ||
|
|
a8a828644a | ||
|
|
4362ea21b7 | ||
|
|
062e17bf34 | ||
|
|
a98a66190e | ||
|
|
9f34829776 | ||
|
|
b82f75979e | ||
|
|
63573d2b50 | ||
|
|
6011512334 | ||
|
|
cf3b60180e | ||
|
|
3f945808d6 | ||
|
|
432fb7450b | ||
|
|
de2fe4a110 | ||
|
|
3e7a258a9a | ||
|
|
b40f18eeb6 | ||
|
|
fb0755d190 | ||
|
|
884822ebe3 | ||
|
|
a0eb509cec | ||
|
|
6c05b72099 | ||
|
|
406517cc68 | ||
|
|
fe49c70da1 | ||
|
|
d71a74f938 | ||
|
|
2e04e24ce9 | ||
|
|
3e4864b7fa | ||
|
|
43f61a8d93 | ||
|
|
5744f379b2 | ||
|
|
b7104c6668 | ||
|
|
aafd7c1b6f | ||
|
|
14fced07b6 | ||
|
|
f24304248c | ||
|
|
f34d24941d | ||
|
|
5fcc89be55 | ||
|
|
3ee1a25f47 | ||
|
|
dd4f30a79f | ||
|
|
06c32a0fd4 | ||
|
|
7a4a74f8d7 | ||
|
|
724e06b134 | ||
|
|
e2d2dd34f0 | ||
|
|
9f34e6f7b6 | ||
|
|
c88f5e87a9 | ||
|
|
6f4b4d8b4c | ||
|
|
3db09a3806 |
@@ -195,6 +195,23 @@ enum ScreenCommands {
|
||||
/// `--build`**, which is why it is a flag and not the default.
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
/// Pose every element at this KEYFRAME TIME instead of at its resting
|
||||
/// pose (60 units = 1 second). The resting pose is each element's last
|
||||
/// *hold* keyframe, picked independently of every other element — so it
|
||||
/// is not the screen at any one moment, and it is wrong twice over: it
|
||||
/// omits anything still moving (the title's light sweeps hold off the
|
||||
/// right edge), and it freezes a transient at its PEAK (the title's five
|
||||
/// two-frame flashes burn forever). Prefer `--settle`.
|
||||
#[arg(long, conflicts_with = "settle")]
|
||||
at: Option<u32>,
|
||||
/// Pose every element at the instant the screen is SETTLED, derived from
|
||||
/// the disc: the midpoint of the longest interval containing no keyframe
|
||||
/// of any element. Prints the window it used, whose width is how much the
|
||||
/// midpoint is worth — a narrow one means the bundle never settles (42 %
|
||||
/// of them, mostly `loop*` fragments). See
|
||||
/// `docs/re/structures/ui-settle-time.md`.
|
||||
#[arg(long)]
|
||||
settle: bool,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -353,8 +370,10 @@ async fn main() -> Result<()> {
|
||||
black,
|
||||
all,
|
||||
primitives,
|
||||
at,
|
||||
settle,
|
||||
} => cmd_screen_render(
|
||||
&pak, &output, build, focus, animated, black, all, primitives,
|
||||
&pak, &output, build, focus, animated, black, all, primitives, at, settle,
|
||||
),
|
||||
},
|
||||
Commands::Save { cmd } => match cmd {
|
||||
@@ -581,12 +600,40 @@ fn cmd_screen_render(
|
||||
black: bool,
|
||||
all: bool,
|
||||
primitives: bool,
|
||||
at: Option<u32>,
|
||||
settle: bool,
|
||||
) -> Result<()> {
|
||||
use sylpheed_formats::ui_layout::{self, ComposeOptions};
|
||||
let builds = screen_builds(pak, all)?;
|
||||
let idx = pick_build(&builds, want)?;
|
||||
let bytes = &builds[idx].1;
|
||||
let b = ui_layout::parse_build(bytes).context("build did not parse")?;
|
||||
let at = if settle {
|
||||
match (b.settle_window(), b.settle_time()) {
|
||||
(Some((lo, hi)), Some(t)) => {
|
||||
// Report the width, not just the answer. A 4-unit window and a
|
||||
// 190-unit one give the same kind of number and mean entirely
|
||||
// different things.
|
||||
println!(
|
||||
"settle window [{lo}, {hi}] = {} units ({:.2} s) -> posing at t={t}{}",
|
||||
hi - lo,
|
||||
(hi - lo) as f64 / 60.0,
|
||||
if hi - lo < 30 {
|
||||
" ⚠️ narrow — this bundle may never settle"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
Some(t)
|
||||
}
|
||||
_ => {
|
||||
println!("no settle window (fewer than two distinct keyframe times) — using rest()");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
at
|
||||
};
|
||||
let screen = ui_layout::compose(
|
||||
&b,
|
||||
bytes,
|
||||
@@ -599,6 +646,7 @@ fn cmd_screen_render(
|
||||
ComposeOptions::default().backdrop
|
||||
},
|
||||
include_primitives: primitives,
|
||||
at,
|
||||
},
|
||||
None,
|
||||
);
|
||||
@@ -621,14 +669,35 @@ fn cmd_screen_render(
|
||||
if !screen.missing.is_empty() {
|
||||
println!(" sprites that did not resolve/decode: {:?}", screen.missing);
|
||||
}
|
||||
let undrawn: Vec<&str> = b
|
||||
// 🔴 Report the INDEX, the KIND and WHY, not just the name. A kind-`0x4`
|
||||
// ghost instance carries its template's name, so a bare name list shows
|
||||
// `ptlogo1.t32` twice and reads as "the logo is missing" when what is
|
||||
// skipped is two motion-trail ghosts sitting at alpha 0 off-screen. That
|
||||
// misreading cost this project a wrong finding sent to another agent.
|
||||
let undrawn: Vec<String> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| !screen.drawn.contains(&e.index))
|
||||
.map(|e| e.name.as_str())
|
||||
.map(|e| {
|
||||
let why = if e.name.ends_with(".prm") {
|
||||
"untextured primitive, needs --primitives"
|
||||
} else if e.name.ends_with(".rat") {
|
||||
"animation, needs --animated"
|
||||
} else if e.kind == 0x4 {
|
||||
"kind 0x4 ghost instance"
|
||||
} else if e.rest().map(|k| k.fade >> 24) == Some(0) {
|
||||
"transparent at its pose"
|
||||
} else {
|
||||
"no reason established"
|
||||
};
|
||||
format!("[{}] {} ({why})", e.index, e.name)
|
||||
})
|
||||
.collect();
|
||||
if !undrawn.is_empty() {
|
||||
println!(" not drawn ({}): {undrawn:?}", undrawn.len());
|
||||
println!(" not drawn ({}):", undrawn.len());
|
||||
for u in &undrawn {
|
||||
println!(" {u}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -744,8 +813,16 @@ fn cmd_audio_info(file: &Path) -> Result<()> {
|
||||
println!(" Channels : {}", opt(info.channels.map(|c| c.to_string())));
|
||||
println!(" Sample rate: {}", opt(info.sample_rate.map(|r| format!("{r} Hz"))));
|
||||
println!(" Bit depth : {}", opt(info.bits_per_sample.map(|b| format!("{b}-bit"))));
|
||||
if let Some(b) = info.avg_bytes_per_sec {
|
||||
println!(" Byte rate : {} B/s (declared)", b.to_string().yellow());
|
||||
}
|
||||
if let Some(d) = info.duration_secs {
|
||||
println!(" Duration : {d:.2} s");
|
||||
let how = if info.codec == sylpheed_formats::AudioCodec::Xma {
|
||||
" (from the declared byte rate, not decoded)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(" Duration : {d:.2} s{how}");
|
||||
}
|
||||
if let Some(p) = info.xma_packets {
|
||||
println!(" XMA packets: {} (2048 B each)", p.to_string().yellow());
|
||||
|
||||
38
crates/sylpheed-formats/examples/_all2.rs
Normal file
38
crates/sylpheed-formats/examples/_all2.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap(); let i:usize=a.next().unwrap().parse().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
println!("entry {i}: {} elements, {} records, {} sprites",
|
||||
b.elements.len(), b.records.len(), b.sprites.len());
|
||||
let mut rk:Vec<&String>=b.records.keys().collect(); rk.sort();
|
||||
println!(" records: {:?}", rk);
|
||||
for (rn,&(o,sz)) in &b.records {
|
||||
if o+sz>by.len() { continue }
|
||||
let Some(lb)=ui_layout::parse_build(&by[o..o+sz]) else { continue };
|
||||
println!(" RECORD {rn}: {} elements", lb.elements.len());
|
||||
for le in &lb.elements {
|
||||
let lts:Vec<String>=le.keyframes.iter()
|
||||
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
|
||||
println!(" [{}] {:<22} kind=0x{:<6x} {}", le.index, le.name, le.kind, lts.join(" "));
|
||||
}
|
||||
}
|
||||
for e in &b.elements {
|
||||
let ts:Vec<String>=e.keyframes.iter()
|
||||
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
|
||||
println!(" [{}] {:<24} kind=0x{:<6x} {}", e.index, e.name, e.kind, ts.join(" "));
|
||||
if let Some(&(o,s))=b.records.get(&e.name) {
|
||||
if o+s<=by.len() {
|
||||
if let Some(lb)=ui_layout::parse_build(&by[o..o+s]) {
|
||||
for le in &lb.elements {
|
||||
let lts:Vec<String>=le.keyframes.iter()
|
||||
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
|
||||
println!(" leaf {:<20} kind=0x{:<6x} {}", le.name, le.kind, lts.join(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
crates/sylpheed-formats/examples/_keys.rs
Normal file
21
crates/sylpheed-formats/examples/_keys.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
for t in a {
|
||||
let i:usize=t.parse().unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
println!("=== entry {i} ===");
|
||||
let order=ui_layout::derived_paint_order(&b,&by);
|
||||
for e in &b.elements {
|
||||
let k=ui_layout::sprite_layer_key(&b,&by,e);
|
||||
let pos=order.iter().position(|&x|x==e.index);
|
||||
println!(" [{}] {:<24} kind=0x{:<5x} sprite={:<24} key={:<12} paint#{:?}",
|
||||
e.index, e.name, e.kind,
|
||||
e.sprite.clone().unwrap_or_else(||"<none>".into()),
|
||||
k.map(|v|format!("0x{v:08x}")).unwrap_or_else(||"NONE".into()), pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
crates/sylpheed-formats/examples/_pbafc.rs
Normal file
39
crates/sylpheed-formats/examples/_pbafc.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
fn main(){
|
||||
let root=std::env::var("SYLPHEED_DISC").unwrap_or_else(|_|"/disc".into());
|
||||
let ar=pak::PakArchive::open(format!("{root}/dat/GP_READY_ROOM.pak")).unwrap();
|
||||
for (i,e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by)=ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
let Some(p)=b.elements.iter().find(|el|el.name=="pbafc.prm") else { continue };
|
||||
println!("=== entry {i}: {} elements ===", b.elements.len());
|
||||
println!(" pbafc.prm pivot=({},{}) -> {}x{}", p.pivot_x,p.pivot_y,p.pivot_x*2,p.pivot_y*2);
|
||||
for k in &p.keyframes {
|
||||
println!(" t={:<5} fade={:08x} a={:<4} xy=({},{}) s={}/{}",
|
||||
k.time.map(|v|v as i64).unwrap_or(-1), k.fade, k.fade>>24, k.x,k.y,k.scale_x,k.scale_y);
|
||||
}
|
||||
// what does it cover, and is anything visible while it is opaque?
|
||||
let rest=p.rest().unwrap();
|
||||
let (px,py,pw,ph)=(rest.x, rest.y, (p.pivot_x*2) as i32, (p.pivot_y*2) as i32);
|
||||
println!(" its rect at rest: ({px},{py}) {pw}x{ph}");
|
||||
let tmax=b.elements.iter().flat_map(|e|e.keyframes.iter().filter_map(|k|k.time)).max().unwrap_or(0);
|
||||
let op:Vec<u32>=(0..=tmax).filter(|&t|p.pose_at(t).map(|k|k.fade>>24)==Some(255)).collect();
|
||||
println!(" opaque at {} instants (t={:?}..{:?}) of 0..{tmax}", op.len(), op.first(), op.last());
|
||||
let mut cov=0; let mut vis=0;
|
||||
for o in &b.elements {
|
||||
if o.index==p.index { continue }
|
||||
let Some(ok)=o.rest() else { continue };
|
||||
let (ow,oh)=((o.pivot_x*2) as i32,(o.pivot_y*2) as i32);
|
||||
let overlap=(px+pw).min(ok.x+ow)-px.max(ok.x)>0 && (py+ph).min(ok.y+oh)-py.max(ok.y)>0;
|
||||
if !overlap { continue }
|
||||
cov+=1;
|
||||
if op.iter().any(|&t| o.pose_at(t).map(|k|k.fade>>24).unwrap_or(0)>0) {
|
||||
vis+=1;
|
||||
if vis<=6 { println!(" covered AND visible while opaque: {}", o.name); }
|
||||
}
|
||||
}
|
||||
println!(" elements its rect covers: {cov}; visible while it is opaque: {vis}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
27
crates/sylpheed-formats/examples/_rechdr.rs
Normal file
27
crates/sylpheed-formats/examples/_rechdr.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
for t in a {
|
||||
let i:usize=t.parse().unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
let top=u32::from_be_bytes(by[8..12].try_into().unwrap());
|
||||
println!("entry {i:2} TOP +08 = {top}");
|
||||
let mut ks:Vec<&String>=b.records.keys().collect(); ks.sort();
|
||||
for rn in ks {
|
||||
let &(o,s)=b.records.get(rn).unwrap();
|
||||
if o+16>by.len() { continue }
|
||||
let magic=&by[o..o+4];
|
||||
let h4=u32::from_be_bytes(by[o+4..o+8].try_into().unwrap());
|
||||
let h8=u32::from_be_bytes(by[o+8..o+12].try_into().unwrap());
|
||||
let maxt=ui_layout::parse_build(&by[o..o+s])
|
||||
.map(|lb|lb.elements.iter().flat_map(|e|e.keyframes.iter()
|
||||
.filter_map(|k|k.time)).max().unwrap_or(0)).unwrap_or(0);
|
||||
println!(" {rn:<24} magic={:?} +04={:08x}({:.1}) +08={h8:<6} max keyframe t={maxt} ratio={:.4}",
|
||||
String::from_utf8_lossy(magic), h4, h4 as f64/65536.0,
|
||||
if maxt>0 {h8 as f64/maxt as f64} else {0.0});
|
||||
}
|
||||
}
|
||||
}
|
||||
53
crates/sylpheed-formats/examples/adv_region_extend.rs
Normal file
53
crates/sylpheed-formats/examples/adv_region_extend.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! Does `resolve_movie_voice_region` start LATE, and by exactly how much?
|
||||
//!
|
||||
//! The port agent's arithmetic: the running decoder's three `ADV` XMA contexts sum
|
||||
//! to **3 584 000** payload bytes, but the resolved voice region is **3 114 352** —
|
||||
//! 15 % too small to hold them. One of the two spans is not what the other thinks
|
||||
//! it is, and the disc side is this crate's.
|
||||
//!
|
||||
//! The gap is exact. `ctx0` declares **632** packets (1 294 336 B); the leading
|
||||
//! chunk the resolver yields has **394** (806 912 B). The difference is **238
|
||||
//! packets = 487 424 B**, a whole number of packets — which is what a start offset
|
||||
//! looks like, not corruption.
|
||||
//!
|
||||
//! So: walk the region start backwards and report where `to_xma_riffs` first
|
||||
//! reproduces the decoder's own three sizes. The probe's byte_sizes are the
|
||||
//! control — this is not free to fit, it either lands on them or it does not.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example adv_region_extend
|
||||
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
/// What the running decoder reported (docs/re/structures/voice-three-streams-are-concurrent.md).
|
||||
const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456];
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let (start, end) =
|
||||
media::resolve_movie_voice_region(&src, "ADV", VoiceLang::English).expect("region");
|
||||
println!("resolver says {start}..{end} ({} B)", end - start);
|
||||
println!("decoder wants {:?} = {} B payload\n", WANT, WANT.iter().sum::<usize>());
|
||||
|
||||
for back_packets in [0usize, 100, 200, 237, 238, 239, 300, 400] {
|
||||
let back = (back_packets * 2048) as u64;
|
||||
if back > start {
|
||||
continue;
|
||||
}
|
||||
let s = start - back;
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", s, (end - s) as usize) else {
|
||||
println!("-{back_packets:4} packets: unreadable");
|
||||
continue;
|
||||
};
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
let sizes: Vec<usize> = riffs.iter().map(|r| r.len() - 60).collect();
|
||||
let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b);
|
||||
println!(
|
||||
"-{back_packets:4} packets (start {s}): {} chunk(s) {:?}{}",
|
||||
riffs.len(),
|
||||
sizes,
|
||||
if hit { " <== MATCHES THE DECODER" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
crates/sylpheed-formats/examples/adv_voice_dump.rs
Normal file
41
crates/sylpheed-formats/examples/adv_voice_dump.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Dump `ADV`'s voice chunks as RIFF/XMA, so each can be decoded and identified.
|
||||
//!
|
||||
//! `intro-audio-decomposed.md` measured that the intro's output is the movie's own
|
||||
//! WMA Pro 5.1 track at 0.600 **plus** three streams occupying a front pair, a
|
||||
//! centre (with a silent partner) and a rear pair. What it could **not** say is
|
||||
//! *which* stream sits where — the assignment there is by position, not content.
|
||||
//! The port needs that to weight a positional downmix.
|
||||
//!
|
||||
//! This writes the chunks out so they can be decoded (ffmpeg has `xma2`) and
|
||||
//! correlated against the per-channel residuals.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example adv_voice_dump -- OUTDIR [MOVIE]
|
||||
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
fn main() {
|
||||
let out = std::env::args().nth(1).expect("OUTDIR");
|
||||
let movie = std::env::args().nth(2).unwrap_or_else(|| "ADV".into());
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
std::fs::create_dir_all(&out).expect("outdir");
|
||||
|
||||
let (start, end) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
|
||||
.expect("voice region");
|
||||
let bytes = src
|
||||
.read_segment_range("dat/sound", start, (end - start) as usize)
|
||||
.expect("region");
|
||||
println!("{movie}: region {start}..{end} = {} B", end - start);
|
||||
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
println!("{} RIFF chunk(s)", riffs.len());
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let p = format!("{out}/{movie}_{i}.xma");
|
||||
std::fs::write(&p, r).expect("write");
|
||||
// the probe reports `byte_size` = RIFF total - 60; print both so the
|
||||
// dump can be tied to a specific XMA context by its own number
|
||||
println!(" chunk {i}: {} B byte_size-equivalent {} -> {p}",
|
||||
r.len(), r.len() as i64 - 60);
|
||||
}
|
||||
}
|
||||
40
crates/sylpheed-formats/examples/bank_streams.rs
Normal file
40
crates/sylpheed-formats/examples/bank_streams.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! List a sound bank's streams and their declared rates.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example bank_streams -- <disc> BGM_102.slb …
|
||||
use sylpheed_formats::media::{self, DirectorySource};
|
||||
use sylpheed_formats::{hash::name_hash, slb};
|
||||
|
||||
fn main() {
|
||||
let mut a = std::env::args().skip(1);
|
||||
let disc = a.next().expect("usage: bank_streams <disc> NAME.slb…");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
for name in a {
|
||||
let h = name_hash(&name);
|
||||
match media::read_sound_bank(&src, h) {
|
||||
Ok(bytes) => {
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
println!(
|
||||
"{name} (hash {h:08x}, {} B on disc) header {:?} -> {} stream(s)",
|
||||
bytes.len(),
|
||||
slb::bank_header_len(&bytes),
|
||||
riffs.len()
|
||||
);
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let rate = if r.len() >= 0x28 {
|
||||
u32::from_le_bytes(r[0x20..0x24].try_into().unwrap())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let payload = r.len() - 60;
|
||||
println!(
|
||||
" stream {i}: payload {payload} B ({} packets) declared {rate} B/s \
|
||||
=> {:.3} s",
|
||||
payload / 2048,
|
||||
if rate > 0 { payload as f64 / rate as f64 } else { 0.0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
123
crates/sylpheed-formats/examples/find_stream_by_size.rs
Normal file
123
crates/sylpheed-formats/examples/find_stream_by_size.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! Which cue owns an XMA stream of a given payload size?
|
||||
//!
|
||||
//! A boot with `--xma_param_probe` logs each decoded stream's `byte_size`. Three
|
||||
//! of the five on the take-2 `ADV` boot are that movie's own streams; two —
|
||||
//! 1 150 976 and 1 269 760 B — belong to something unidentified. The probe gives
|
||||
//! a size and nothing else, so the disc has to be asked which cue has a stream
|
||||
//! that long.
|
||||
//!
|
||||
//! Searches every inter-descriptor span of the continuous voice stream, and
|
||||
//! every `sound.pak` entry, for a stream whose payload matches.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example find_stream_by_size -- <disc> <bytes>…
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::{slb, PakArchive};
|
||||
|
||||
const DESC_MARK: u32 = 0x11;
|
||||
const DESC_REPEAT: usize = 0x800;
|
||||
const ID_MAX: u32 = 0x1_0000;
|
||||
|
||||
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
let mut out = Vec::new();
|
||||
if buf.len() < DESC_REPEAT + 8 {
|
||||
return out;
|
||||
}
|
||||
let end = buf.len() - (DESC_REPEAT + 4);
|
||||
let mut o = 0;
|
||||
while o <= end {
|
||||
let id = be(o);
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
out.push((o, id));
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let disc = args.next().expect("usage: find_stream_by_size <disc> <bytes>…");
|
||||
let wanted: Vec<usize> = args.filter_map(|a| a.parse().ok()).collect();
|
||||
assert!(!wanted.is_empty(), "give at least one payload size");
|
||||
// A `to_xma_riffs` chunk is the payload plus a 60-byte RIFF wrapper.
|
||||
let want_riff: Vec<usize> = wanted.iter().map(|w| w + 60).collect();
|
||||
println!("looking for payloads {wanted:?} (riff sizes {want_riff:?})\n");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
|
||||
// --- 1. the continuous movie-voice stream, span by span
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let marker = "eng\\Movie\\VOICE_ADV.slb";
|
||||
let registry = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
})
|
||||
.expect("registry");
|
||||
let ids = sylpheed_formats::movie_voice::registry_voice_ids(®istry);
|
||||
let name_of: std::collections::HashMap<u32, String> =
|
||||
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
|
||||
let win_start: u64 = 421_739_888 & !3;
|
||||
let buf = src
|
||||
.read_segment_range("dat/sound", win_start, 116_300_000)
|
||||
.expect("window");
|
||||
let descs = all_descriptors(&buf);
|
||||
println!("voice stream: {} descriptors", descs.len());
|
||||
let mut hits = 0;
|
||||
for w in descs.windows(2) {
|
||||
let (a, b) = (w[0].0, w[1].0);
|
||||
if b <= a || b - a < 4096 {
|
||||
continue;
|
||||
}
|
||||
for (i, r) in slb::to_xma_riffs(&buf[a..b]).iter().enumerate() {
|
||||
if want_riff.contains(&r.len()) {
|
||||
let name = name_of
|
||||
.get(&w[1].1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("id{}", w[1].1));
|
||||
println!(
|
||||
" ✅ cue {name} (id {}) stream {i}: payload {} B",
|
||||
w[1].1,
|
||||
r.len() - 60
|
||||
);
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(" {hits} hit(s) in the voice stream\n");
|
||||
|
||||
// --- 2. every sound.pak entry
|
||||
let stoc = src.read_file("dat/sound.pak").expect("sound.pak toc");
|
||||
let entries = PakArchive::parse_toc(&stoc).expect("toc");
|
||||
println!("sound.pak: {} entries", entries.len());
|
||||
let mut phits = 0;
|
||||
let mut scanned = 0usize;
|
||||
for e in &entries {
|
||||
// Only entries big enough to hold the target.
|
||||
let need = wanted.iter().copied().min().unwrap_or(0) as u32;
|
||||
if e.comp_size < need {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", e.offset as u64, e.comp_size as usize)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
scanned += 1;
|
||||
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
|
||||
if want_riff.contains(&r.len()) {
|
||||
println!(
|
||||
" ✅ sound.pak entry hash {:08x} offset {} size {} — stream {i}: payload {} B",
|
||||
e.name_hash,
|
||||
e.offset,
|
||||
e.comp_size,
|
||||
r.len() - 60
|
||||
);
|
||||
phits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(" scanned {scanned} entries large enough; {phits} hit(s)");
|
||||
}
|
||||
65
crates/sylpheed-formats/examples/focus_alpha_census.rs
Normal file
65
crates/sylpheed-formats/examples/focus_alpha_census.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! Which focus records have a VARYING alpha — disc-wide, not export-wide?
|
||||
//!
|
||||
//! `rest()` returns an element's last hold keyframe. For a constant-alpha
|
||||
//! element that is harmless. For one that pulses it returns the PEAK, which is
|
||||
//! the `ui-settle-time.md` pathology: the plate's `ptbtn00f` ramps 0→80→0 and
|
||||
//! `rest()` reports 80, its maximum.
|
||||
//!
|
||||
//! The port censused this over its own export (34 records, 2 varying) and
|
||||
//! concluded there is nothing to fix. That conclusion is only as wide as the
|
||||
//! export. This asks the same question of the whole disc.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut n_rec, mut n_elem, mut varying) = (0usize, 0usize, 0usize);
|
||||
let (mut at_peak, mut mid_ramp) = (0usize, 0usize);
|
||||
let mut by_pak: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
let mut hits: Vec<String> = Vec::new();
|
||||
for p in &paks {
|
||||
let pn = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for (ei, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (rn, &(o, s)) in &b.records {
|
||||
// A focus record is one whose name is another record's plus `f`.
|
||||
let Some(stem) = rn.strip_suffix("f.rat") else { continue };
|
||||
if !b.records.contains_key(&format!("{stem}.rat")) { continue }
|
||||
if o + s > by.len() { continue }
|
||||
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
|
||||
n_rec += 1;
|
||||
for el in &lb.elements {
|
||||
if el.keyframes.is_empty() { continue }
|
||||
n_elem += 1;
|
||||
let a: Vec<u32> = el.keyframes.iter().map(|k| k.fade >> 24).collect();
|
||||
let (lo, hi) = (*a.iter().min().unwrap(), *a.iter().max().unwrap());
|
||||
if lo == hi { continue }
|
||||
varying += 1;
|
||||
let rest = el.rest().map(|k| k.fade >> 24).unwrap_or(0);
|
||||
if rest == hi { at_peak += 1 } else { mid_ramp += 1 }
|
||||
*by_pak.entry(pn.clone()).or_default() += 1;
|
||||
hits.push(format!(
|
||||
"{pn} [{ei}] {rn}::{} alpha {lo}..{hi} rest()={rest}{}",
|
||||
el.name, if rest == hi { " 🔴 == PEAK" } else { "" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("focus records disc-wide : {n_rec}");
|
||||
println!(" their timed elements : {n_elem}");
|
||||
println!(" with a VARYING alpha : {varying}");
|
||||
println!(" of which rest() == the PEAK : {at_peak} <- burns bright forever");
|
||||
println!(" of which rest() is MID-RAMP : {mid_ramp} <- neither extreme; looks plausible");
|
||||
println!("\nby pak:");
|
||||
for (k, v) in &by_pak { println!(" {k:<34} {v}") }
|
||||
println!("\nevery varying one:");
|
||||
hits.sort(); hits.dedup();
|
||||
for h in &hits { println!(" {h}") }
|
||||
println!("\n({} distinct)", hits.len());
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Reconcile two ink counts for one screen that were never counting the same pixels.
|
||||
//!
|
||||
//! The port agent double-witnessed the pixel-cost claim in Godot — a renderer
|
||||
//! sharing no code with `compose` — and got `GP_TITLE` entry 12 at **59 530 px**
|
||||
//! ink above threshold 0 and **48 368** above 1. This crate reported **49 771**.
|
||||
//! Neither is wrong; the question is which convention each was using, and on a
|
||||
//! mostly-dark frame the answer moves thousands of pixels.
|
||||
//!
|
||||
//! So: count the same composite every way, and print the family. Whichever row
|
||||
//! the port's numbers land in is the convention, and then the two renderers can be
|
||||
//! compared on purpose rather than by coincidence.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_ink_thresholds
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use ui_layout::ComposeOptions;
|
||||
|
||||
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
let el = &build.elements[i];
|
||||
(
|
||||
ui_layout::sprite_layer_key(build, bundle, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name))
|
||||
.unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
fn counts(rgba: &[u8], t: u8) -> (usize, usize) {
|
||||
let rgb = rgba
|
||||
.chunks_exact(4)
|
||||
.filter(|p| p[0] > t || p[1] > t || p[2] > t)
|
||||
.count();
|
||||
let alpha = rgba.chunks_exact(4).filter(|p| p[3] > t).count();
|
||||
(rgb, alpha)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
|
||||
for entry in [12usize, 15] {
|
||||
let by = ar.read(&ar.entries()[entry]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("parse");
|
||||
for (label, opts) in [
|
||||
(
|
||||
"primitives on (what the cost run used)",
|
||||
ComposeOptions {
|
||||
include_primitives: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"primitives+focus+animated",
|
||||
ComposeOptions {
|
||||
include_primitives: true,
|
||||
include_focus: true,
|
||||
include_animated: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
let with = ui_layout::derived_paint_order(&b, &by);
|
||||
let without = order_without_rule(&b, &by);
|
||||
let a = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&with));
|
||||
let c = ui_layout::compose_with_order(&b, &by, opts, None, Some(&without));
|
||||
println!("\n== GP_TITLE entry {entry} — {label} ({}x{})", a.width, a.height);
|
||||
println!(" threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT");
|
||||
for t in [0u8, 1, 2, 4, 8, 16] {
|
||||
let (r1, a1) = counts(&a.rgba, t);
|
||||
let (r0, a0) = counts(&c.rgba, t);
|
||||
println!(" >{t:<8} | {r1:>16} | {a1:>14} | {r0:>13} | {a0:>11}");
|
||||
}
|
||||
let changed = a
|
||||
.rgba
|
||||
.chunks_exact(4)
|
||||
.zip(c.rgba.chunks_exact(4))
|
||||
.filter(|(x, y)| x != y)
|
||||
.count();
|
||||
println!(" exact-RGBA changed pixels between the two orders: {changed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Of the forced instances the rule merely CONFIRMS, how many have a key READ
|
||||
//! FROM THE FILE, and how many an IMPLIED key that is itself a measurement?
|
||||
//!
|
||||
//! `forced_backdrop_necessity.rs` asked only whether an element had *a* key,
|
||||
//! collapsing `sprite_layer_key` (a `u16` read out of the `T8aD` header — decoded)
|
||||
//! with `implied_layer_key` (this crate's per-name table of positions **measured
|
||||
//! in the running game**). For counting whether the rule moves anything that is
|
||||
//! the right question. For describing what a confirmation is *made of*, it is not:
|
||||
//! "the file already settles it" and "another measurement already settles it" are
|
||||
//! different claims, and a reader who sees "own key" will take the first.
|
||||
//!
|
||||
//! Raised by the port agent 2026-08-30.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_key_source
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let (mut read, mut implied, mut none) = (0usize, 0usize, 0usize);
|
||||
println!("# archive entry element key_source key");
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
let name = pak.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else {
|
||||
continue;
|
||||
};
|
||||
for el in &b.elements {
|
||||
if !ui_layout::forced_backdrop(&b, el) {
|
||||
continue;
|
||||
}
|
||||
let (src, key) = match ui_layout::sprite_layer_key(&b, &by, el) {
|
||||
Some(k) => {
|
||||
read += 1;
|
||||
("read_T8aD", Some(k))
|
||||
}
|
||||
None => match ui_layout::implied_layer_key(&el.name) {
|
||||
Some(k) => {
|
||||
implied += 1;
|
||||
("implied_MEASURED", Some(k))
|
||||
}
|
||||
None => {
|
||||
none += 1;
|
||||
("none", None)
|
||||
}
|
||||
},
|
||||
};
|
||||
println!(
|
||||
" {name} {i} {} {src} {}",
|
||||
el.name,
|
||||
key.map(|k| format!("0x{k:08X}")).unwrap_or("-".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\n# forced instances by key source:");
|
||||
println!("# read from the T8aD header (decoded): {read}");
|
||||
println!("# implied — this crate's MEASURED name table: {implied}");
|
||||
println!("# none — only forced_backdrop can speak: {none}");
|
||||
}
|
||||
114
crates/sylpheed-formats/examples/forced_backdrop_necessity.rs
Normal file
114
crates/sylpheed-formats/examples/forced_backdrop_necessity.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
//! Which screens does `forced_backdrop` DECIDE, and which does it merely agree with?
|
||||
//!
|
||||
//! Every check this corpus has run on the rule measured its **stability** — that
|
||||
//! no verdict moved when something else changed. That is a different property
|
||||
//! from **necessity**: an element whose position is already fixed by a read or an
|
||||
//! implied key is confirmed by the rule, not decided by it.
|
||||
//!
|
||||
//! So: compute `derived_paint_order` with the rule, and again with the
|
||||
//! `forced_backdrop` fallback removed, and report every entry whose order moves.
|
||||
//! Where nothing moves, the rule is decorative on that screen; where it moves,
|
||||
//! the rule is the only thing holding the order up.
|
||||
//!
|
||||
//! Raised by the port agent 2026-08-30. Reach note in
|
||||
//! `docs/re/structures/ui-forced-backdrop.md`.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_necessity -- [pak...]
|
||||
//!
|
||||
//! 🔴 With no argument this used to default to `GP_TITLE` alone, so a bare run
|
||||
//! reported **6 instances, not 80** — a thirteenth of the census, printed in the
|
||||
//! same format and reading like the whole thing. The port agent hit it and nearly
|
||||
//! filed the discrepancy back at me. It now walks every `dat/*.pak` by default and
|
||||
//! says on stderr how many archives it opened, because "I ran your instrument" has
|
||||
//! to mean the same thing to both of us.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
let el = &build.elements[i];
|
||||
(
|
||||
ui_layout::sprite_layer_key(build, bundle, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name))
|
||||
.unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let (mut total_decides, mut total_agrees) = (0usize, 0usize);
|
||||
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if paks.is_empty() {
|
||||
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
all.sort();
|
||||
paks = all;
|
||||
}
|
||||
eprintln!("# scanning {} archive(s)", paks.len());
|
||||
for path in &paks {
|
||||
let ar = PakArchive::open(path).expect("pak");
|
||||
println!("# {}", path.display());
|
||||
println!("# entry forced decides elements note");
|
||||
|
||||
let mut decides = Vec::new();
|
||||
let mut agrees = Vec::new();
|
||||
#[allow(unused)]
|
||||
let _ = (&decides, &agrees);
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else {
|
||||
continue;
|
||||
};
|
||||
let with = ui_layout::derived_paint_order(&b, &by);
|
||||
let without = order_without_rule(&b, &by);
|
||||
|
||||
// Which elements does the rule fire on, and of those, which have no key
|
||||
// of their own to fall back on?
|
||||
let mut forced = Vec::new();
|
||||
let mut keyless = Vec::new();
|
||||
for el in &b.elements {
|
||||
if !ui_layout::forced_backdrop(&b, el) {
|
||||
continue;
|
||||
}
|
||||
forced.push(el.name.clone());
|
||||
let own = ui_layout::sprite_layer_key(&b, &by, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name));
|
||||
if own.is_none() {
|
||||
keyless.push(el.name.clone());
|
||||
}
|
||||
}
|
||||
if forced.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let moved = with != without;
|
||||
if moved {
|
||||
decides.push(i);
|
||||
} else {
|
||||
agrees.push(i);
|
||||
}
|
||||
println!(
|
||||
" {i:>5} {:>6} {:>7} {:>8} forced=[{}] keyless=[{}]",
|
||||
forced.len(),
|
||||
if moved { "YES" } else { "no" },
|
||||
b.elements.len(),
|
||||
forced.join(","),
|
||||
keyless.join(","),
|
||||
);
|
||||
}
|
||||
println!("# rule DECIDES the order on entries {decides:?}");
|
||||
println!("# rule merely AGREES on entries {agrees:?}\n");
|
||||
total_decides += decides.len();
|
||||
total_agrees += agrees.len();
|
||||
}
|
||||
println!("# TOTAL over {} archive(s): {total_decides} deciding entries, \
|
||||
{total_agrees} agreeing", paks.len());
|
||||
}
|
||||
119
crates/sylpheed-formats/examples/forced_backdrop_pixel_cost.rs
Normal file
119
crates/sylpheed-formats/examples/forced_backdrop_pixel_cost.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
//! What does `forced_backdrop` cost IN PIXELS on the screens it decides?
|
||||
//!
|
||||
//! `forced_backdrop_necessity.rs` answers "does the derived ORDER move", which is
|
||||
//! a property of the sort. The port agent then pointed out — correctly — that its
|
||||
//! re-run of that probe was **my code executed twice**, not a second witness, so
|
||||
//! the disc-wide 62 has one measurement behind it and only `GP_TITLE` has two.
|
||||
//!
|
||||
//! This does not fix that (it is still this crate), but it moves the question to a
|
||||
//! **different layer**: render each deciding build twice, once in the order
|
||||
//! `compose` derives and once with the `forced_backdrop` fallback removed, and
|
||||
//! count the pixels that differ. "The order moved" and "the picture moved" are not
|
||||
//! the same claim, and the second is the one anybody cares about — the tie-break
|
||||
//! work already found overlapping reorders that cost exactly zero pixels.
|
||||
//!
|
||||
//! Each entry carries its own CONTROL: the pixel count of the composite itself.
|
||||
//! If a build renders empty, its zero means the instrument saw nothing, not that
|
||||
//! the rule is free.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_pixel_cost -- [pak...]
|
||||
//!
|
||||
//! With no argument it walks **every `dat/*.pak`** — the necessity probe defaulted
|
||||
//! to `GP_TITLE`, which made a bare run report a thirteenth of the census and read
|
||||
//! like the whole thing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use ui_layout::ComposeOptions;
|
||||
|
||||
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
let el = &build.elements[i];
|
||||
(
|
||||
ui_layout::sprite_layer_key(build, bundle, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name))
|
||||
.unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if paks.is_empty() {
|
||||
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
all.sort();
|
||||
paks = all;
|
||||
}
|
||||
eprintln!("# scanning {} archive(s)", paks.len());
|
||||
|
||||
let opts = ComposeOptions {
|
||||
include_primitives: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
println!("# archive entry element changed_px total_px ink_px(control) pct");
|
||||
let (mut decided, mut zero_cost, mut blind) = (0usize, 0usize, 0usize);
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
let name = pak.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else {
|
||||
continue;
|
||||
};
|
||||
let with = ui_layout::derived_paint_order(&b, &by);
|
||||
let without = order_without_rule(&b, &by);
|
||||
if with == without {
|
||||
continue;
|
||||
}
|
||||
let forced: Vec<&str> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|el| ui_layout::forced_backdrop(&b, el))
|
||||
.map(|el| el.name.as_str())
|
||||
.collect();
|
||||
|
||||
let a = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&with));
|
||||
let c = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&without));
|
||||
let n = a
|
||||
.rgba
|
||||
.chunks_exact(4)
|
||||
.zip(c.rgba.chunks_exact(4))
|
||||
.filter(|(x, y)| x != y)
|
||||
.count();
|
||||
// Control: does this build put any ink down at all, against the bare
|
||||
// backdrop? A build that renders to nothing cannot show a reorder.
|
||||
let ink = a
|
||||
.rgba
|
||||
.chunks_exact(4)
|
||||
.filter(|p| p[..3] != [0, 0, 0])
|
||||
.count();
|
||||
let total = a.rgba.len() / 4;
|
||||
|
||||
decided += 1;
|
||||
if ink == 0 {
|
||||
blind += 1;
|
||||
} else if n == 0 {
|
||||
zero_cost += 1;
|
||||
}
|
||||
println!(
|
||||
" {name} {i} {} {n} {total} {ink} {:.2}%",
|
||||
forced.join(","),
|
||||
100.0 * n as f64 / total as f64
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("\n# builds whose ORDER the rule decides: {decided}");
|
||||
println!("# of those, costing ZERO pixels: {zero_cost}");
|
||||
println!("# of those, BLIND (build renders no ink, control fails): {blind}");
|
||||
}
|
||||
72
crates/sylpheed-formats/examples/kf_flip_test.rs
Normal file
72
crates/sylpheed-formats/examples/kf_flip_test.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Do `+4` / `+8` = 180 mean MIRROR?
|
||||
//!
|
||||
//! The disc-wide census shows `+4` and `+8` are dominated by 180 and ±90, while
|
||||
//! `+12` (the decoded screen-plane rotation) takes 157 distinct values including
|
||||
//! odd ones. That shape says flips rather than free rotation.
|
||||
//!
|
||||
//! Structural test, no renderer involved: if 180 means "mirror", then the same
|
||||
//! sprite should appear both with the field 0 and with it 180 **within one
|
||||
//! build** -- a mirrored pair. Free-rotation semantics predicts no such pairing.
|
||||
//!
|
||||
//! CONTROL: the same search run on `+12`, which is decoded as a real rotation
|
||||
//! and should NOT show a 0/180 pairing pattern of the same strength.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("<disc>/dat");
|
||||
let mut paks: Vec<_> = std::fs::read_dir(&dir).expect("dir")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false)).collect();
|
||||
paks.sort();
|
||||
|
||||
// field -> (pak, entry, sprite) -> set of values seen
|
||||
let mut seen: [BTreeMap<(String, usize, String), BTreeSet<i32>>; 3] =
|
||||
[BTreeMap::new(), BTreeMap::new(), BTreeMap::new()];
|
||||
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
let pn = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
|
||||
let mut groups: Vec<(String, Vec<ui_layout::Keyframe>)> =
|
||||
b.elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect();
|
||||
for el in &b.elements {
|
||||
if let Some(&(off, size)) = b.records.get(&el.name) {
|
||||
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
for le in &lb.elements {
|
||||
groups.push((le.name.clone(), le.keyframes.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (nm, ks) in groups {
|
||||
for k in &ks {
|
||||
let key = (pn.clone(), i, nm.clone());
|
||||
seen[0].entry(key.clone()).or_default().insert(k.unknown_4);
|
||||
seen[1].entry(key.clone()).or_default().insert(k.unknown_8);
|
||||
seen[2].entry(key).or_default().insert(k.rotation_deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (idx, label) in [(0, "+4"), (1, "+8"), (2, "+12 (rotation, CONTROL)")] {
|
||||
let m = &seen[idx];
|
||||
let mut pair_0_180 = 0usize; // a sprite seen at BOTH 0 and 180 in one build
|
||||
let mut only_180 = 0usize;
|
||||
let mut multi = 0usize; // more than two distinct values
|
||||
for v in m.values() {
|
||||
if v.len() > 2 { multi += 1; }
|
||||
let has0 = v.contains(&0);
|
||||
let has180 = v.contains(&180) || v.contains(&-180);
|
||||
if has0 && has180 { pair_0_180 += 1; }
|
||||
else if has180 && !has0 { only_180 += 1; }
|
||||
}
|
||||
println!("{label}: {} sprite-instances", m.len());
|
||||
println!(" both 0 and ±180 in one build : {pair_0_180}");
|
||||
println!(" ±180 without any 0 : {only_180}");
|
||||
println!(" more than 2 distinct values : {multi}");
|
||||
}
|
||||
}
|
||||
19
crates/sylpheed-formats/examples/kf_timeline.rs
Normal file
19
crates/sylpheed-formats/examples/kf_timeline.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap(); let bi:usize=a.next().unwrap().parse().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[bi]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
for t in a {
|
||||
let i:usize=t.parse().unwrap();
|
||||
let e=&b.elements[i];
|
||||
println!("[{i}] {} kind=0x{:x} parent={:?} pivot=({},{}) kfs={}",
|
||||
e.name, e.kind, e.parent, e.pivot_x, e.pivot_y, e.keyframes.len());
|
||||
for k in &e.keyframes {
|
||||
println!(" t={:<5} a={:<4} xy=({},{}) s={}/{} rot={} u4={} u8={} tint={:08x}",
|
||||
k.time.map(|v|v as i64).unwrap_or(-1), k.fade>>24, k.x,k.y,
|
||||
k.scale_x,k.scale_y,k.rotation_deg,k.unknown_4,k.unknown_8,k.tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
crates/sylpheed-formats/examples/kf_unknown_census.rs
Normal file
83
crates/sylpheed-formats/examples/kf_unknown_census.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! What are the keyframe block's `+4` and `+8`?
|
||||
//!
|
||||
//! `+12` is decoded as a screen-plane rotation in degrees. `+4` and `+8` sit
|
||||
//! immediately before it and are carried but unexplained; one standing 🟡
|
||||
//! reading is that the three together are rotations about three axes, "not tied
|
||||
//! to an observed rotation". This censuses them across every UI pak on the disc
|
||||
//! so the reading can be argued with rather than assumed.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("usage: <disc>/dat");
|
||||
let mut paks: Vec<_> = std::fs::read_dir(&dir)
|
||||
.expect("dir")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let mut h4: BTreeMap<i32, usize> = BTreeMap::new();
|
||||
let mut h8: BTreeMap<i32, usize> = BTreeMap::new();
|
||||
let mut h12: BTreeMap<i32, usize> = BTreeMap::new();
|
||||
let mut both_nz: Vec<String> = Vec::new();
|
||||
let (mut kfs, mut builds) = (0usize, 0usize);
|
||||
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
|
||||
builds += 1;
|
||||
// parents and leaves alike
|
||||
let mut groups: Vec<(String, Vec<ui_layout::Keyframe>)> = b
|
||||
.elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect();
|
||||
for el in &b.elements {
|
||||
if let Some(&(off, size)) = b.records.get(&el.name) {
|
||||
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
for le in &lb.elements {
|
||||
groups.push((format!("{}->{}", el.name, le.name), le.keyframes.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (nm, ks) in groups {
|
||||
for k in &ks {
|
||||
kfs += 1;
|
||||
*h4.entry(k.unknown_4).or_default() += 1;
|
||||
*h8.entry(k.unknown_8).or_default() += 1;
|
||||
*h12.entry(k.rotation_deg).or_default() += 1;
|
||||
if k.unknown_4 != 0 || k.unknown_8 != 0 {
|
||||
both_nz.push(format!("{name} e{i} {nm} +4={} +8={} +12={}",
|
||||
k.unknown_4, k.unknown_8, k.rotation_deg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{builds} builds, {kfs} keyframes (parents + leaves)\n");
|
||||
for (nm, h) in [("+4", &h4), ("+8", &h8), ("+12 (rotation)", &h12)] {
|
||||
let nz: usize = h.iter().filter(|(k, _)| **k != 0).map(|(_, v)| *v).sum();
|
||||
println!("{nm}: {} distinct values, {} non-zero keyframes ({:.4}%)",
|
||||
h.len(), nz, 100.0 * nz as f64 / kfs as f64);
|
||||
let mut top: Vec<_> = h.iter().filter(|(k, _)| **k != 0).collect();
|
||||
top.sort_by_key(|(_, v)| std::cmp::Reverse(**v));
|
||||
for (k, v) in top.iter().take(6) { println!(" {k:>8} x{v}"); }
|
||||
}
|
||||
println!("\nkeyframes with a non-zero +4 or +8: {}", both_nz.len());
|
||||
// Per-pak, so a reader can ask whether a pak they have a CAPTURE of is
|
||||
// among them -- which decides whether the field is testable at all.
|
||||
let mut per: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for l in &both_nz {
|
||||
let pak = l.split_whitespace().next().unwrap_or("?").to_string();
|
||||
*per.entry(pak).or_default() += 1;
|
||||
}
|
||||
println!(" by pak:");
|
||||
for (k, v) in &per { println!(" {k:<28} {v}"); }
|
||||
println!(" paks with NONE: (any UI pak not listed above)");
|
||||
if let Ok(f) = std::env::var("KF_SHOW") {
|
||||
println!("\n all lines for {f}:");
|
||||
for l in both_nz.iter().filter(|l| l.starts_with(&f)) { println!(" {l}"); }
|
||||
}
|
||||
}
|
||||
56
crates/sylpheed-formats/examples/leaf_alpha_compose.rs
Normal file
56
crates/sylpheed-formats/examples/leaf_alpha_compose.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! How do a parent record's alpha ramp and its nested `.rat` leaf's compose?
|
||||
//!
|
||||
//! The port emits both but will not draw the leaf without knowing the rule --
|
||||
//! rightly, since drawing on a guess trades a visible 1.82 % error for an
|
||||
//! invisible wrong one. There is an oracle for this: the GPU draw capture
|
||||
//! records **vertex colours**, and on the title's `ptloop` draw they are
|
||||
//! `C3FFFFFF` and `B6FFFFFF` -- alpha **195** and **182**, not 255. So the game's
|
||||
//! composed alpha is observable, and a candidate rule either predicts those two
|
||||
//! numbers or does not.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example leaf_alpha_compose -- <GP_TITLE.pak>
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
|
||||
fn alpha_of(kf: &ui_layout::Keyframe) -> u32 {
|
||||
// The keyframe block's +0 is an ARGB fade colour; alpha is its high byte.
|
||||
kf.fade >> 24
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: <pak>");
|
||||
let ar = pak::PakArchive::open(&path).expect("open");
|
||||
let e = &ar.entries()[4]; // GP_TITLE entry 4 = the English title
|
||||
let bytes = ar.read(e).expect("read");
|
||||
let build = ui_layout::parse_build(&bytes).expect("build");
|
||||
|
||||
for name in ["ptloop01.rat", "ptloop02.rat"] {
|
||||
println!("\n=== {name} ===");
|
||||
if let Some(el) = build.elements.iter().find(|x| x.name == name) {
|
||||
println!(" PARENT keyframes (t, alpha, scale, rot, x,y):");
|
||||
for k in &el.keyframes {
|
||||
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})",
|
||||
k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y);
|
||||
}
|
||||
}
|
||||
match build.records.get(name) {
|
||||
Some(&(off, size)) => {
|
||||
let leaf = &bytes[off..off + size];
|
||||
match ui_layout::parse_build(leaf) {
|
||||
Some(lb) => {
|
||||
for le in &lb.elements {
|
||||
println!(" LEAF element {:?}:", le.name);
|
||||
for k in &le.keyframes {
|
||||
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})",
|
||||
k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y,
|
||||
k.rotation_deg, k.x, k.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => println!(" leaf did not parse"),
|
||||
}
|
||||
}
|
||||
None => println!(" no leaf record"),
|
||||
}
|
||||
}
|
||||
println!("\nOBSERVED in the draw capture: quad A alpha 195 (0xC3), quad B alpha 182 (0xB6)");
|
||||
}
|
||||
42
crates/sylpheed-formats/examples/leaf_dump.rs
Normal file
42
crates/sylpheed-formats/examples/leaf_dump.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! Dump a record's parent keyframes and its nested `.rat` leaf's, for any entry.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example leaf_dump -- <pak> <entry> <name>
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
|
||||
fn a(k: &ui_layout::Keyframe) -> u32 { k.fade >> 24 }
|
||||
fn t(k: &ui_layout::Keyframe) -> i64 { k.time.map(|v| v as i64).unwrap_or(-1) }
|
||||
|
||||
fn show(tag: &str, els: &[ui_layout::Element]) {
|
||||
for e in els {
|
||||
println!(" {tag} {:?} pivot=({},{}) kind={:#x}", e.name, e.pivot_x, e.pivot_y, e.kind);
|
||||
for k in &e.keyframes {
|
||||
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} pos=({},{}) tint={:#010x}",
|
||||
t(k), a(k), k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y, k.tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut it = std::env::args().skip(1);
|
||||
let path = it.next().expect("pak");
|
||||
let entry: usize = it.next().expect("entry").parse().expect("entry");
|
||||
let want = it.next().expect("name");
|
||||
let ar = pak::PakArchive::open(&path).expect("open");
|
||||
let bytes = ar.read(&ar.entries()[entry]).expect("read");
|
||||
let b = ui_layout::parse_build(&bytes).expect("build");
|
||||
println!("entry {entry}: {} elements, design {}x{}", b.elements.len(), b.design_w, b.design_h);
|
||||
let els: Vec<_> = b.elements.iter().filter(|e| e.name.contains(&want)).cloned().collect();
|
||||
show("PARENT", &els);
|
||||
for e in &els {
|
||||
match b.records.get(&e.name) {
|
||||
Some(&(off, size)) => {
|
||||
println!(" -- leaf of {:?}: {size} B at {off}", e.name);
|
||||
match ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
Some(lb) => show(" LEAF", &lb.elements),
|
||||
None => println!(" leaf did not parse"),
|
||||
}
|
||||
}
|
||||
None => println!(" -- {:?} has NO leaf record", e.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
20
crates/sylpheed-formats/examples/leafdbg.rs
Normal file
20
crates/sylpheed-formats/examples/leafdbg.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap();
|
||||
let by=ar.read(&ar.entries()[4]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
for n in ["ptloop01.rat","ptloop02.rat"] {
|
||||
let el=b.elements.iter().find(|e| e.name==n).unwrap();
|
||||
println!("{n}: kind={:#x} animated={} rec={:?}", el.kind, el.animated,
|
||||
b.records.get(n).map(|&(o,s)|(o,s)));
|
||||
if let Some(&(o,s))=b.records.get(n) {
|
||||
match ui_layout::parse_build(&by[o..o+s]) {
|
||||
Some(lb)=>for le in &lb.elements {
|
||||
println!(" leaf {:?} sprite={:?} in_sprites={}", le.name, le.sprite,
|
||||
le.sprite.as_ref().map(|x| b.sprites.contains_key(x)).unwrap_or(false));
|
||||
},
|
||||
None=>println!(" leaf parse FAILED"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
crates/sylpheed-formats/examples/loo_band.rs
Normal file
26
crates/sylpheed-formats/examples/loo_band.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
// Leave-one-out over the elements that touch a band, dumping raw RGBA each time.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let bi:usize=a.next().unwrap().parse().unwrap();
|
||||
let dir=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[bi]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
let o=ui_layout::ComposeOptions{ backdrop:[0,0,0,255], ..Default::default() };
|
||||
let n=b.elements.len();
|
||||
let base=ui_layout::compose(&b,&by,o.clone(),None);
|
||||
std::fs::write(format!("{dir}/base.raw"),&base.rgba).unwrap();
|
||||
println!("canvas {}x{} elements {n}", base.width, base.height);
|
||||
for i in 0..n {
|
||||
let e=&b.elements[i];
|
||||
let Some(kf)=e.rest() else { continue };
|
||||
// only bother with elements whose rest pose can touch the band
|
||||
let mut v=vec![true;n]; v[i]=false;
|
||||
let c=ui_layout::compose(&b,&by,o.clone(),Some(&v));
|
||||
std::fs::write(format!("{dir}/wo-{i}.raw"),&c.rgba).unwrap();
|
||||
println!("{i}\t{}\t{}\t({},{})\ta={}", e.name,
|
||||
e.sprite.clone().unwrap_or_default(), kf.x, kf.y, kf.fade>>24);
|
||||
}
|
||||
}
|
||||
46
crates/sylpheed-formats/examples/name_from_hash.rs
Normal file
46
crates/sylpheed-formats/examples/name_from_hash.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Recover a `sound.pak` TOC name from its hash by generating candidates.
|
||||
//!
|
||||
//! The hash is a Barrett-reduction over the uppercased path, so it cannot be
|
||||
//! inverted — but the naming is regular enough to enumerate. Tries the shapes
|
||||
//! this disc actually uses for sound entries.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example name_from_hash -- <hex-hash>…
|
||||
use sylpheed_formats::hash::name_hash;
|
||||
|
||||
fn main() {
|
||||
let wanted: Vec<u32> = std::env::args()
|
||||
.skip(1)
|
||||
.filter_map(|a| u32::from_str_radix(a.trim_start_matches("0x"), 16).ok())
|
||||
.collect();
|
||||
assert!(!wanted.is_empty(), "give hashes in hex");
|
||||
let langs = ["eng", "jpn", ""];
|
||||
let dirs = ["", "Movie", "etc", "Voice", "Sound", "BGM", "bgm", "se", "SE"];
|
||||
let mut tried = 0usize;
|
||||
let mut check = |name: String, tried: &mut usize| {
|
||||
*tried += 1;
|
||||
let h = name_hash(&name);
|
||||
if wanted.contains(&h) {
|
||||
println!(" ✅ {h:08x} {name}");
|
||||
}
|
||||
};
|
||||
for l in langs {
|
||||
for d in dirs {
|
||||
let pre = match (l.is_empty(), d.is_empty()) {
|
||||
(true, true) => String::new(),
|
||||
(true, false) => format!("{d}\\"),
|
||||
(false, true) => format!("{l}\\"),
|
||||
(false, false) => format!("{l}\\{d}\\"),
|
||||
};
|
||||
for stem in ["BGM", "bgm", "JNGL", "jngl", "SE", "Static", "VOICE"] {
|
||||
for n in 0..1200u32 {
|
||||
check(format!("{pre}{stem}_{n:03}.slb"), &mut tried);
|
||||
check(format!("{pre}{stem}{n:03}.slb"), &mut tried);
|
||||
}
|
||||
}
|
||||
for bare in ["Static.slb", "static.slb", "SE.slb", "BGM.slb"] {
|
||||
check(format!("{pre}{bare}"), &mut tried);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("tried {tried} candidate names");
|
||||
}
|
||||
50
crates/sylpheed-formats/examples/prm_alpha_census.rs
Normal file
50
crates/sylpheed-formats/examples/prm_alpha_census.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Does a primitive's alpha AT t=0 predict the layer it paints on?
|
||||
//!
|
||||
//! `implied_layer_key` is a measured per-name table. The four entries in it, read
|
||||
//! against their own keyframes, suggest a rule derived from the file instead:
|
||||
//!
|
||||
//! * `palogo_eff0.prm` measured FIRST (0x0000) -- alpha at t=0 = ?
|
||||
//! * `pfbase.tbm` measured FIRST (0x0000) -- alpha at t=0 = ?
|
||||
//! * `pteff02.prm` measured MIDDLE (0x8030) -- alpha at t=0 = ?
|
||||
//! * `pteff00.prm` measured LAST -- alpha at t=0 = ?
|
||||
//!
|
||||
//! ⚠️ The rule was invented AFTER seeing three of those answers, so it is fitted
|
||||
//! on them and only `pfbase.tbm` is out of sample. This prints all four plus a
|
||||
//! disc-wide census, so the fit and its reach are visible together.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
// name -> (count, set of t=0 alphas, set of "starts at max" flags)
|
||||
let mut byname: BTreeMap<String, (usize, BTreeMap<u32, usize>)> = BTreeMap::new();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for el in &b.elements {
|
||||
// primitives and the keyless: anything with no layer key
|
||||
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
|
||||
let Some(k0) = el.keyframes.first() else { continue };
|
||||
let a0 = k0.fade >> 24;
|
||||
let ent = byname.entry(el.name.clone()).or_default();
|
||||
ent.0 += 1;
|
||||
*ent.1.entry(a0).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{:>28} {:>7} alpha at t=0 (count)", "keyless element", "n");
|
||||
let known = ["palogo_eff0.prm", "pfbase.tbm", "pteff02.prm", "pteff00.prm"];
|
||||
for (n, (c, a)) in &byname {
|
||||
let tag = if known.contains(&n.as_str()) { " <- IN THE MEASURED TABLE" } else { "" };
|
||||
if *c < 4 && tag.is_empty() { continue }
|
||||
let al: Vec<String> = a.iter().map(|(k, v)| format!("{k}x{v}")).collect();
|
||||
println!(" {n:>26} {c:>7} {}{tag}", al.join(" "));
|
||||
}
|
||||
}
|
||||
37
crates/sylpheed-formats/examples/prm_colour_census.rs
Normal file
37
crates/sylpheed-formats/examples/prm_colour_census.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! What COLOUR is a primitive, and does any of them only make sense additively?
|
||||
//!
|
||||
//! `ui-prm-primitives.md` leaves blend mode open, and `forced_backdrop` assumes
|
||||
//! straight alpha-over. A quad whose ARGB would tint the whole screen a colour no
|
||||
//! screen shows is evidence against alpha-over for that quad.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
fn main(){
|
||||
let root=std::env::var("SYLPHEED_DISC").unwrap_or_else(|_|"/disc".into());
|
||||
let mut paks:Vec<_>=std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e|e.path())
|
||||
.filter(|p|p.extension().and_then(|s|s.to_str())==Some("pak")).collect();
|
||||
paks.sort();
|
||||
let mut m:BTreeMap<String,BTreeMap<String,usize>>=BTreeMap::new();
|
||||
for p in &paks {
|
||||
let Ok(ar)=pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by)=ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
for el in &b.elements {
|
||||
if el.sprite.is_some() { continue }
|
||||
for k in &el.keyframes {
|
||||
*m.entry(el.name.clone()).or_default()
|
||||
.entry(format!("{:08x}", k.fade)).or_default()+=1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{:>26} fade ARGB values (count)", "keyless element");
|
||||
for (n,v) in &m {
|
||||
let tot:usize=v.values().sum();
|
||||
if tot<4 { continue }
|
||||
let s:Vec<String>=v.iter().map(|(k,c)|format!("{k}x{c}")).collect();
|
||||
println!(" {n:>24} {}", s.join(" "));
|
||||
}
|
||||
}
|
||||
58
crates/sylpheed-formats/examples/prm_forced_first.rs
Normal file
58
crates/sylpheed-formats/examples/prm_forced_first.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Which keyless primitives have their paint position FORCED by occlusion?
|
||||
//!
|
||||
//! An opaque full-screen quad must sort below every element visible at any
|
||||
//! instant it is opaque. Where that set is *every* other element, its position is
|
||||
//! forced to first — derived from the file, not analogised from a neighbour.
|
||||
//!
|
||||
//! Controls, both measured in the running game and both reproduced here:
|
||||
//! * `palogo_eff0.prm` is measured painting FIRST — and comes out forced first.
|
||||
//! * `pteff00.prm` is measured painting LAST — and is forced below only a
|
||||
//! handful, so the constraint permits it on top.
|
||||
//!
|
||||
//! ⚠️ Assumes straight alpha-over blending. Blend mode is ❔ in
|
||||
//! `ui-prm-primitives.md`; an additive quad at alpha 255 would not occlude.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut forced, mut partial, mut free) = (0usize, 0usize, 0usize);
|
||||
let mut names: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0);
|
||||
if tmax == 0 { continue }
|
||||
for el in &b.elements {
|
||||
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
|
||||
// full-screen only: a quad that does not cover cannot occlude
|
||||
if el.pivot_x * 2 < 1280 || el.pivot_y * 2 < 720 { continue }
|
||||
let op: Vec<u32> = (0..=tmax)
|
||||
.filter(|&t| el.pose_at(t).map(|k| k.fade >> 24) == Some(255)).collect();
|
||||
if op.is_empty() { continue }
|
||||
let others: Vec<&ui_layout::Element> =
|
||||
b.elements.iter().filter(|o| o.index != el.index).collect();
|
||||
if others.is_empty() { continue }
|
||||
let below = others.iter().filter(|o|
|
||||
op.iter().any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)).count();
|
||||
let ent = names.entry(el.name.clone()).or_default();
|
||||
ent.1 += 1;
|
||||
if below == others.len() { forced += 1; ent.0 += 1 }
|
||||
else if below > 0 { partial += 1 } else { free += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("keyless FULL-SCREEN primitives with an opaque interval:");
|
||||
println!(" position FORCED FIRST (below every other element) : {forced}");
|
||||
println!(" forced below SOME but not all : {partial}");
|
||||
println!(" occludes nothing : {free}");
|
||||
println!("\nby name — instances forced first / total:");
|
||||
for (n, (f, t)) in &names { println!(" {n:>24} {f:>4} / {t}"); }
|
||||
}
|
||||
73
crates/sylpheed-formats/examples/prm_occlusion_check.rs
Normal file
73
crates/sylpheed-formats/examples/prm_occlusion_check.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
//! An OPAQUE full-screen primitive cannot paint on top of elements that are
|
||||
//! visible at the same time — the screen would be blank.
|
||||
//!
|
||||
//! That is a constraint read off the file, not a preference. For each keyless
|
||||
//! primitive this computes the interval over which it is opaque, and the interval
|
||||
//! over which any OTHER element is visible, and reports the overlap.
|
||||
//!
|
||||
//! The falsifier: `pteff00.prm` is MEASURED painting last on the title and the
|
||||
//! main menu. If any of its instances is opaque while content is up, the
|
||||
//! constraint is wrong and this whole line is dead.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
/// Interval(s) where alpha >= `thr`, sampled at every half unit.
|
||||
fn opaque_span(el: &ui_layout::Element, thr: u32, tmax: u32) -> Vec<(f64, f64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<f64> = None;
|
||||
let mut t = 0.0;
|
||||
while t <= tmax as f64 {
|
||||
let a = el.pose_at(t as u32).map(|k| k.fade >> 24).unwrap_or(0);
|
||||
if a >= thr {
|
||||
if cur.is_none() { cur = Some(t) }
|
||||
} else if let Some(s) = cur.take() {
|
||||
out.push((s, t));
|
||||
}
|
||||
t += 0.5;
|
||||
}
|
||||
if let Some(s) = cur { out.push((s, tmax as f64)) }
|
||||
out
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let want = ["pteff00.prm", "pgloading_eff00.prm", "palogo_eff0.prm",
|
||||
"pfbase.tbm", "pteff02.prm", "pzeff00.prm", "pceff00.prm", "pdeff00.prm"];
|
||||
println!("{:>22} {:>5} {:>16} {:>18} overlap", "primitive", "entry", "opaque while", "content visible");
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for (ei, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0);
|
||||
if tmax == 0 { continue }
|
||||
for el in &b.elements {
|
||||
if !want.contains(&el.name.as_str()) { continue }
|
||||
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
|
||||
let op = opaque_span(el, 255, tmax);
|
||||
if op.is_empty() { continue }
|
||||
// when is any OTHER element visible?
|
||||
let mut cmin = f64::MAX; let mut cmax = f64::MIN;
|
||||
for o in &b.elements {
|
||||
if o.index == el.index { continue }
|
||||
for (s, t) in opaque_span(o, 1, tmax) { cmin = cmin.min(s); cmax = cmax.max(t) }
|
||||
}
|
||||
if cmin > cmax { continue }
|
||||
// overlap of the primitive's opaque span with the content span
|
||||
let ov: f64 = op.iter()
|
||||
.map(|&(s, t)| (t.min(cmax) - s.max(cmin)).max(0.0)).sum();
|
||||
let opd: String = op.iter().map(|&(s,t)| format!("{s:.0}-{t:.0}")).collect::<Vec<_>>().join(",");
|
||||
let flag = if ov > 2.0 { " 🔴 CANNOT BE ON TOP" } else { "" };
|
||||
println!("{:>22} {:>5} {:>16} {:>18} {ov:6.1}{flag}",
|
||||
el.name, format!("{}:{}", p.file_name().unwrap().to_string_lossy()
|
||||
.trim_end_matches(".pak").trim_start_matches("GP_"), ei),
|
||||
opd, format!("{cmin:.0}-{cmax:.0}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
crates/sylpheed-formats/examples/prm_span_sensitivity.rs
Normal file
82
crates/sylpheed-formats/examples/prm_span_sensitivity.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! Does `forced_backdrop`'s verdict depend on how the screen's timeline ENDS?
|
||||
//!
|
||||
//! The rule quantifies over "every instant the primitive is opaque" and "every
|
||||
//! element visible then", so both halves depend on where the timeline stops and
|
||||
//! on what an element does after its own last keyframe. The port asked, and it is
|
||||
//! the right question: a verdict that flips with the convention is not a decode.
|
||||
//!
|
||||
//! Four conventions, all applied to the same disc:
|
||||
//! A span = max keyframe time over all elements; elements HOLD their last pose
|
||||
//! (what `forced_backdrop` does, and what the port implements)
|
||||
//! B span = the primitive's OWN last keyframe time; elements hold
|
||||
//! C span = the bundle header `+0x08` (the declared length); elements hold
|
||||
//! D span = max keyframe time; an element is GONE after its own last keyframe
|
||||
//!
|
||||
//! D is the one worth the most: it is the assumption the port flagged as "doing
|
||||
//! real work", and it strictly shrinks the visible set, so it can only turn
|
||||
//! `forced` into `not forced`.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn last_t(el: &ui_layout::Element) -> u32 {
|
||||
el.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn forced(b: &ui_layout::UiBuild, el: &ui_layout::Element, tmax: u32, hold: bool) -> Option<bool> {
|
||||
if el.sprite.is_some() { return None }
|
||||
if (el.pivot_x * 2) < b.design_w as u32 || (el.pivot_y * 2) < b.design_h as u32 { return None }
|
||||
if tmax == 0 { return None }
|
||||
let alpha = |e: &ui_layout::Element, t: u32| -> u32 {
|
||||
if !hold && t > last_t(e) { return 0 }
|
||||
e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0)
|
||||
};
|
||||
let op: Vec<u32> = (0..=tmax).filter(|&t| alpha(el, t) == 255).collect();
|
||||
if op.is_empty() { return None }
|
||||
let others: Vec<&ui_layout::Element> = b.elements.iter().filter(|o| o.index != el.index).collect();
|
||||
if others.is_empty() { return None }
|
||||
let below = others.iter().filter(|o| op.iter().any(|&t| alpha(o, t) > 0)).count();
|
||||
Some(below == others.len())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut n, mut a_true) = (0usize, 0usize);
|
||||
let mut flips = [0usize; 3];
|
||||
let mut examples: Vec<String> = Vec::new();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for (ei, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let tall = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0);
|
||||
let hdr = if by.len() >= 12 { u32::from_be_bytes(by[8..12].try_into().unwrap()) } else { 0 };
|
||||
for el in &b.elements {
|
||||
let Some(va) = forced(&b, el, tall, true) else { continue };
|
||||
n += 1; if va { a_true += 1 }
|
||||
for (k, vb) in [forced(&b, el, last_t(el), true),
|
||||
forced(&b, el, hdr, true),
|
||||
forced(&b, el, tall, false)].into_iter().enumerate() {
|
||||
if vb != Some(va) {
|
||||
flips[k] += 1;
|
||||
if k == 2 && examples.len() < 6 {
|
||||
examples.push(format!("{}:{} {} A={va} D={vb:?}",
|
||||
p.file_name().unwrap().to_string_lossy(), ei, el.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("keyless full-screen primitives with an opaque interval: {n}");
|
||||
println!(" convention A (span = all elements' max, hold) -> forced first: {a_true}\n");
|
||||
println!(" verdicts that CHANGE under:");
|
||||
println!(" B span = the primitive's own last keyframe : {}", flips[0]);
|
||||
println!(" C span = the header's declared length +0x08 : {}", flips[1]);
|
||||
println!(" D elements GONE after their last keyframe : {}", flips[2]);
|
||||
for e in &examples { println!(" {e}") }
|
||||
}
|
||||
91
crates/sylpheed-formats/examples/ptloop_leaf_sweep_at.rs
Normal file
91
crates/sylpheed-formats/examples/ptloop_leaf_sweep_at.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! Where are the title's two light-sweep quads at a given time — and is a
|
||||
//! best-fit against a framebuffer PNG even measuring their position?
|
||||
//!
|
||||
//! `ui-leaf-vs-parent-alpha.md` solves the sweep instant as **t = 357.7** from a
|
||||
//! GPU per-draw capture: quad centre x measured off the submitted vertex buffer,
|
||||
//! which is a position measurement at 4 px/unit. The port agent separately
|
||||
//! best-fits the same leaf against `live-title-build4-no-plate.png` and gets
|
||||
//! **~400 units**, and asked whether the two used the same capture.
|
||||
//!
|
||||
//! Before comparing the numbers, check whether the second method can see what it
|
||||
//! claims to measure. A fit that is minimised by the quad being OFF-SCREEN is
|
||||
//! minimised by absence, and would return "best" at whatever time draws least —
|
||||
//! the same shape as the `.tbm` control that could not fail.
|
||||
//!
|
||||
//! So: print the leaves' own x, alpha, and on-screen overlap across the window.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example ptloop_leaf_sweep_at
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
const SCREEN_W: i64 = 1280;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
let by = ar.read(&ar.entries()[4]).expect("entry 4");
|
||||
let b = ui_layout::parse_build(&by).expect("parse");
|
||||
|
||||
for parent in ["ptloop01", "ptloop02"] {
|
||||
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else {
|
||||
eprintln!("no element {parent}");
|
||||
continue;
|
||||
};
|
||||
let Some(&(off, size)) = b.records.get(&el.name) else {
|
||||
eprintln!("{}: no nested record", el.name);
|
||||
continue;
|
||||
};
|
||||
// `ui-record-loop-length.md`: a nested record's header `+0x08` is its
|
||||
// CYCLE LENGTH, and its keyframes need not fill it. This is why two
|
||||
// captures of the same settled title do not share a sweep phase.
|
||||
let loop_len = u32::from_be_bytes(by[off + 8..off + 12].try_into().unwrap());
|
||||
println!("\n-- {} nested record: loop length (+0x08) = {loop_len}", el.name);
|
||||
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else {
|
||||
eprintln!("{}: leaf will not parse", el.name);
|
||||
continue;
|
||||
};
|
||||
for le in &lb.elements {
|
||||
let w = (le.pivot_x * 2) as i64;
|
||||
println!(
|
||||
"\n== {} -> leaf {} (sprite {:?}, pivot {}x{}, {} keyframes, last t={:?})",
|
||||
el.name,
|
||||
le.name,
|
||||
le.sprite,
|
||||
le.pivot_x,
|
||||
le.pivot_y,
|
||||
le.keyframes.len(),
|
||||
le.keyframes.last().map(|k| k.time)
|
||||
);
|
||||
// ⚠️ A keyframe's `x` is the quad's LEFT edge, not its centre. The
|
||||
// draw-capture fit is quoted in CENTRES, so compare `centre`, which is
|
||||
// `x + pivot_x`. Printing `x` under a "centre" heading is how a
|
||||
// 200-px offset gets into a comparison unnoticed.
|
||||
println!(" t | x | centre | a | on-screen px of a {w}px-wide quad");
|
||||
for t in [
|
||||
340u32, 350, 355, 357, 358, 360, 370, 380, 390, 395, 400, 405, 410, 420, 440, 480,
|
||||
540,
|
||||
] {
|
||||
let Some(k) = le.pose_at(t) else {
|
||||
println!(" {t:>4} | (no pose)");
|
||||
continue;
|
||||
};
|
||||
let x = k.x as i64;
|
||||
let a = k.fade >> 24;
|
||||
let l = x;
|
||||
let r = l + w;
|
||||
let vis = (r.min(SCREEN_W) - l.max(0)).max(0);
|
||||
println!(
|
||||
" {t:>4} | {x:>4} | {:>6} | {a:>3} | {vis:>5} px {}",
|
||||
x + le.pivot_x as i64,
|
||||
if vis == 0 {
|
||||
"*** ENTIRELY OFF SCREEN ***"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
crates/sylpheed-formats/examples/record_loop_length.rs
Normal file
69
crates/sylpheed-formats/examples/record_loop_length.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! Is a nested record's header `+0x08` its LOOP LENGTH — and do its keyframes
|
||||
//! have to fill it?
|
||||
//!
|
||||
//! A `*f` focus record animates forever while its button is focused, so
|
||||
//! something must say where the cycle restarts. The keyframes cannot: the plate's
|
||||
//! `ptbtn00f` runs 0→80→0 over 105 units, and looping at 105 gives a period 15 %
|
||||
//! short of every measurement of the real thing.
|
||||
//!
|
||||
//! Each record is itself a RATC bundle with its own header, and `+0x08` is a
|
||||
//! frame count. If it is the loop length then it must never be LESS than the
|
||||
//! record's largest keyframe time — an animation cannot restart before its own
|
||||
//! last pose — and it may be more, which is a hold at the final pose.
|
||||
//!
|
||||
//! Two controls, both of which a wrong reading fails:
|
||||
//! * `+0x08 < max keyframe time` must never happen. That is the falsifier.
|
||||
//! * The distribution must not be trivial: if every record had exactly
|
||||
//! `+0x08 == max t`, the field would carry nothing and "loop length" would be
|
||||
//! an unfalsifiable relabelling of the keyframes.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize);
|
||||
let mut hold_hist: BTreeMap<i64, usize> = BTreeMap::new();
|
||||
let mut worst: Vec<(i64, String)> = Vec::new();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (rn, &(o, s)) in &b.records {
|
||||
if o + 12 > by.len() || o + s > by.len() { continue }
|
||||
if &by[o..o + 4] != b"RATC" { continue }
|
||||
let len = u32::from_be_bytes(by[o + 8..o + 12].try_into().unwrap()) as i64;
|
||||
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
|
||||
let maxt = lb.elements.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0) as i64;
|
||||
if maxt == 0 { continue } // a static record declares no cycle
|
||||
total += 1;
|
||||
let slack = len - maxt;
|
||||
*hold_hist.entry(slack).or_default() += 1;
|
||||
if slack == 0 { exact += 1 } else if slack > 0 { holds += 1 } else {
|
||||
violations += 1;
|
||||
if worst.len() < 12 {
|
||||
worst.push((slack, format!("{}:{rn} len={len} maxt={maxt}",
|
||||
p.file_name().unwrap().to_string_lossy())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("nested records with timed keyframes : {total}");
|
||||
println!(" +08 == max keyframe time (exact) : {exact} ({:.1}%)", 100.0*exact as f64/total as f64);
|
||||
println!(" +08 > max keyframe time (a hold) : {holds} ({:.1}%)", 100.0*holds as f64/total as f64);
|
||||
println!(" +08 < max keyframe time 🔴 : {violations} ({:.2}%) <- the falsifier",
|
||||
100.0*violations as f64/total as f64);
|
||||
println!("\nslack (+08 - max t) distribution, most common first:");
|
||||
let mut h: Vec<_> = hold_hist.iter().collect();
|
||||
h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n));
|
||||
for (k, n) in h.iter().take(14) { println!(" slack {k:>6} : {n}"); }
|
||||
if !worst.is_empty() { println!("\nviolations:"); for (s, w) in &worst { println!(" {s:>6} {w}"); } }
|
||||
}
|
||||
48
crates/sylpheed-formats/examples/scale_census.rs
Normal file
48
crates/sylpheed-formats/examples/scale_census.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! Every scale value on the disc's UI, parents AND nested leaves.
|
||||
//!
|
||||
//! `DECISIONS.md` records `ptlogo_eff2` at 125 % as "the single drawn element in
|
||||
//! the whole export at a scale that is not a whole multiple of 100 %". That
|
||||
//! census was over parents only -- leaves were never opened. This opens them.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("pak");
|
||||
let ar = pak::PakArchive::open(&path).expect("open");
|
||||
let mut hist: BTreeMap<(u32, u32), Vec<String>> = BTreeMap::new();
|
||||
let mut leaves_opened = 0usize;
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
|
||||
for el in &b.elements {
|
||||
for k in &el.keyframes {
|
||||
hist.entry((k.scale_x, k.scale_y))
|
||||
.or_default()
|
||||
.push(format!("e{i}/{}", el.name));
|
||||
}
|
||||
if let Some(&(off, size)) = b.records.get(&el.name) {
|
||||
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
leaves_opened += 1;
|
||||
for le in &lb.elements {
|
||||
for k in &le.keyframes {
|
||||
hist.entry((k.scale_x, k.scale_y))
|
||||
.or_default()
|
||||
.push(format!("e{i}/{}->LEAF/{}", el.name, le.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{leaves_opened} leaves opened\n");
|
||||
println!("{:>12} {:>7} examples", "scale", "count");
|
||||
for (k, v) in &hist {
|
||||
let mut ex: Vec<&String> = v.iter().collect();
|
||||
ex.sort(); ex.dedup();
|
||||
let odd = k.0 % 100 != 0 || k.1 % 100 != 0;
|
||||
println!("{}{:>5},{:<5} {:>7} {}", if odd { "* " } else { " " },
|
||||
k.0, k.1, v.len(),
|
||||
ex.iter().take(3).map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
|
||||
}
|
||||
println!("\n* = not a whole multiple of 100%");
|
||||
}
|
||||
31
crates/sylpheed-formats/examples/settle_window.rs
Normal file
31
crates/sylpheed-formats/examples/settle_window.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
// The settled screen, computed from the keyframe times alone.
|
||||
//
|
||||
// `rest()` picks each element's last HOLD keyframe independently, which is right
|
||||
// for an element that ends settled and wrong for a transient: a 2-frame flash
|
||||
// holds at its PEAK, so `rest()` leaves it burning forever. The settled screen is
|
||||
// instead one INSTANT that every element is posed at, and the instant to pick is
|
||||
// inside the longest interval during which no element has a keyframe at all.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(&pk).unwrap();
|
||||
let only:Option<usize>=a.next().and_then(|s|s.parse().ok());
|
||||
for (i,e) in ar.entries().iter().enumerate() {
|
||||
if let Some(o)=only { if o!=i { continue } }
|
||||
let Ok(by)=ar.read(e) else { continue };
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
if b.elements.len()<2 { continue }
|
||||
let mut ts:Vec<u32>=b.elements.iter().flat_map(|el|
|
||||
el.keyframes.iter().filter_map(|k|k.time)).collect();
|
||||
if ts.len()<2 { continue }
|
||||
ts.sort_unstable(); ts.dedup();
|
||||
// longest gap between consecutive keyframe times
|
||||
let (mut best,mut lo,mut hi)=(0u32,0u32,0u32);
|
||||
for w in ts.windows(2) {
|
||||
if w[1]-w[0] > best { best=w[1]-w[0]; lo=w[0]; hi=w[1]; }
|
||||
}
|
||||
println!("{:>4} {:<28} times {:>3} span {:>4} settle window [{lo},{hi}] = {best} units ({:.2}s) -> t={}",
|
||||
i, format!("{:08x}",e.name_hash), ts.len(), ts.last().unwrap(), best as f64/60.0, lo+best/2);
|
||||
}
|
||||
}
|
||||
12
crates/sylpheed-formats/examples/sprite_dims.rs
Normal file
12
crates/sylpheed-formats/examples/sprite_dims.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use sylpheed_formats::{pak, ui_layout, t8ad};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap(); let i:usize=a.next().unwrap().parse().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
let mut v:Vec<(String,u32,u32)>=b.sprites.iter().filter_map(|(n,&(o,s))|{
|
||||
let im=t8ad::parse(&by[o..o+s])?; Some((n.clone(),im.width,im.height))}).collect();
|
||||
v.sort();
|
||||
for (n,w,h) in v { println!(" {w:>5} x {h:<5} {n}"); }
|
||||
}
|
||||
9
crates/sylpheed-formats/examples/sprite_of.rs
Normal file
9
crates/sylpheed-formats/examples/sprite_of.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap();
|
||||
let b=ui_layout::parse_build(&ar.read(&ar.entries()[4]).unwrap()).unwrap();
|
||||
for el in b.elements.iter().filter(|e| e.name.contains("ptloop")||e.name.contains("ptbtn")) {
|
||||
println!("{:<18} sprite={:?} has_leaf={}", el.name, el.sprite,
|
||||
b.records.contains_key(&el.name));
|
||||
}
|
||||
}
|
||||
332
crates/sylpheed-formats/examples/tie_break_pixel_cost.rs
Normal file
332
crates/sylpheed-formats/examples/tie_break_pixel_cost.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
//! What does the unknown paint-order TIE-BREAK actually cost, in pixels?
|
||||
//!
|
||||
//! `ui-paint-order-derived-check.md` bounds *where* a wrong tie-break could
|
||||
//! show — 24 overlapping tied pairs across `GP_TITLE` — and says outright that
|
||||
//! nobody has measured how many of them change a pixel. Overlap is an upper
|
||||
//! bound: two elements can overlap and still composite identically in either
|
||||
//! order, if either is transparent where they meet.
|
||||
//!
|
||||
//! This renders each screen twice — once in the order `compose` derives, once
|
||||
//! with one tied pair swapped — and counts the pixels that differ. Same-key
|
||||
//! elements are contiguous in the derived order (a stable sort by `(key, i)`),
|
||||
//! so swapping two of them paints nothing else in between: the diff is the
|
||||
//! tie-break's cost and nothing else.
|
||||
//!
|
||||
//! Every entry also runs a CONTROL: a swap of two OVERLAPPING elements with
|
||||
//! DIFFERENT keys, i.e. a pair whose order the game is known to care about. If
|
||||
//! the control diff is zero the instrument cannot see a reorder on this screen
|
||||
//! and its zeros mean nothing.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example tie_break_pixel_cost -- <GP_TITLE.pak>
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use ui_layout::{ComposeOptions, UiBuild};
|
||||
|
||||
/// Pixels that differ, and the largest per-channel difference.
|
||||
fn diff(a: &[u8], b: &[u8]) -> (usize, u8) {
|
||||
let (mut n, mut worst) = (0usize, 0u8);
|
||||
for (pa, pb) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
|
||||
if pa != pb {
|
||||
n += 1;
|
||||
for k in 0..4 {
|
||||
worst = worst.max(pa[k].abs_diff(pb[k]));
|
||||
}
|
||||
}
|
||||
}
|
||||
(n, worst)
|
||||
}
|
||||
|
||||
/// Where does element `ei` actually put ink? Render with it and without it;
|
||||
/// the pixels that move are the ones it paints. This is what turns a bare
|
||||
/// "0 px differ" into an explained one: bounding boxes can overlap while the
|
||||
/// sprites inside them never touch the same pixel.
|
||||
fn ink_mask(
|
||||
build: &UiBuild,
|
||||
bundle: &[u8],
|
||||
opts: ComposeOptions,
|
||||
order: &[usize],
|
||||
base: &[u8],
|
||||
ei: usize,
|
||||
) -> Vec<bool> {
|
||||
let n_el = build.elements.iter().map(|e| e.index).max().unwrap_or(0) + 1;
|
||||
let mut vis = vec![true; n_el.max(build.elements.len())];
|
||||
vis[build.elements[ei].index] = false;
|
||||
let without = ui_layout::compose_with_order(build, bundle, opts, Some(&vis), Some(order));
|
||||
base.chunks_exact(4)
|
||||
.zip(without.rgba.chunks_exact(4))
|
||||
.map(|(x, y)| x != y)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn swapped(order: &[usize], a: usize, b: usize) -> Vec<usize> {
|
||||
let mut o = order.to_vec();
|
||||
let (pa, pb) = (
|
||||
o.iter().position(|&e| e == a).unwrap(),
|
||||
o.iter().position(|&e| e == b).unwrap(),
|
||||
);
|
||||
o.swap(pa, pb);
|
||||
o
|
||||
}
|
||||
|
||||
/// The element's on-screen rect, the same approximation the tie census uses:
|
||||
/// the declared pivot doubled, placed at the keyframe. `at` selects the pose —
|
||||
/// `None` is `rest()`, which is where the original census was computed.
|
||||
///
|
||||
/// 🔴 An element that is TRANSPARENT at the chosen pose gets no rect at all. A
|
||||
/// tie involving something invisible cannot cost a pixel, and counting it as an
|
||||
/// overlap is what made the original census an upper bound rather than a cost.
|
||||
fn rect(e: &ui_layout::Element, at: Option<u32>) -> Option<(i32, i32, i32, i32)> {
|
||||
let kf = match at {
|
||||
Some(t) => e.pose_at(t)?,
|
||||
None => e.rest()?.clone(),
|
||||
};
|
||||
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
|
||||
return None;
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
fn overlaps(a: &ui_layout::Element, b: &ui_layout::Element, at: Option<u32>) -> bool {
|
||||
let (Some(ra), Some(rb)) = (rect(a, at), rect(b, at)) else {
|
||||
return false;
|
||||
};
|
||||
(ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
|
||||
&& (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1) > 0
|
||||
}
|
||||
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
opts: ComposeOptions,
|
||||
}
|
||||
|
||||
fn cases() -> Vec<Case> {
|
||||
vec![
|
||||
Case {
|
||||
name: "default (what `screen render` draws)",
|
||||
opts: ComposeOptions {
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
Case {
|
||||
name: "everything on (focus+animated+primitives)",
|
||||
opts: ComposeOptions {
|
||||
include_focus: true,
|
||||
include_animated: true,
|
||||
include_primitives: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
// 🔴 The two cases above pose at `rest()`, which is each element's last
|
||||
// hold picked independently — so they draw transients that the settled
|
||||
// screen does not have (`docs/re/structures/ui-settle-time.md`). A tie
|
||||
// between two elements that are transparent at the settle time cannot
|
||||
// cost a pixel on the screen the player sees, however much their rects
|
||||
// overlap at rest. `at` is filled in per entry.
|
||||
Case {
|
||||
name: "AT THE SETTLE TIME (what the player sees)",
|
||||
opts: ComposeOptions {
|
||||
backdrop: [0, 0, 0, 255],
|
||||
at: Some(0), // replaced per entry
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn tied_overlapping_pairs(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Vec<(usize, usize, u32)> {
|
||||
let keys: Vec<u32> = build
|
||||
.elements
|
||||
.iter()
|
||||
.map(|e| ui_layout::sprite_layer_key(build, bytes, e).unwrap_or(u32::MAX))
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
for a in 0..keys.len() {
|
||||
for b in (a + 1)..keys.len() {
|
||||
if keys[a] != keys[b] || keys[a] == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
if overlaps(&build.elements[a], &build.elements[b], at) {
|
||||
out.push((a, b, keys[a]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A pair the game's own order DOES separate: overlapping, different keys.
|
||||
/// Used as the control — swapping it must move pixels.
|
||||
fn control_pair(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Option<(usize, usize)> {
|
||||
let keys: Vec<u32> = build
|
||||
.elements
|
||||
.iter()
|
||||
.map(|e| ui_layout::sprite_layer_key(build, bytes, e).unwrap_or(u32::MAX))
|
||||
.collect();
|
||||
let mut best: Option<(i64, usize, usize)> = None;
|
||||
for a in 0..keys.len() {
|
||||
for b in (a + 1)..keys.len() {
|
||||
if keys[a] == keys[b] || keys[a] == u32::MAX || keys[b] == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
if !overlaps(&build.elements[a], &build.elements[b], at) {
|
||||
continue;
|
||||
}
|
||||
let (ra, rb) = (rect(&build.elements[a], at)?, rect(&build.elements[b], at)?);
|
||||
let ox = ((ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0)) as i64;
|
||||
let oy = ((ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1)) as i64;
|
||||
let area = ox * oy;
|
||||
if best.map_or(true, |(x, _, _)| area > x) {
|
||||
best = Some((area, a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, a, b)| (a, b))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args()
|
||||
.nth(1)
|
||||
.expect("usage: tie_break_pixel_cost <pak>");
|
||||
let ar = pak::PakArchive::open(&path).expect("open pak");
|
||||
let entries: Vec<_> = ar.entries().to_vec();
|
||||
println!("# tie-break pixel cost — {path}\n");
|
||||
let mut totals = (0usize, 0usize, 0usize); // pairs, changed-a-pixel, controls-dead
|
||||
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;
|
||||
};
|
||||
// The census pairs are still computed at rest, so the report can say
|
||||
// how many of THOSE survive posing at the settle time.
|
||||
let pairs_at_rest = tied_overlapping_pairs(&build, &bytes, None);
|
||||
if pairs_at_rest.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let settle = build.settle_time();
|
||||
println!(
|
||||
"entry {i:2} {} elements {} overlapping tied pair(s) at rest{}",
|
||||
build.elements.len(),
|
||||
pairs_at_rest.len(),
|
||||
match (settle, build.settle_window()) {
|
||||
(Some(t), Some((lo, hi))) => format!(" settle t={t} (window {} units)", hi - lo),
|
||||
_ => " NO SETTLE WINDOW".to_string(),
|
||||
}
|
||||
);
|
||||
let derived = ui_layout::derived_paint_order(&build, &bytes);
|
||||
for mut c in cases() {
|
||||
// The settle case is a no-op on a bundle that never settles.
|
||||
if c.opts.at.is_some() {
|
||||
match settle {
|
||||
Some(t) => c.opts.at = Some(t),
|
||||
None => {
|
||||
println!(" [{}] SKIPPED: no settle window", c.name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let pairs = tied_overlapping_pairs(&build, &bytes, c.opts.at);
|
||||
if pairs.len() != pairs_at_rest.len() {
|
||||
println!(
|
||||
" [{}] 🔴 {} of the {} tied pairs are GONE at this pose (an element is \
|
||||
transparent or collapsed there) — they cannot cost a pixel",
|
||||
c.name,
|
||||
pairs_at_rest.len() - pairs.len(),
|
||||
pairs_at_rest.len()
|
||||
);
|
||||
}
|
||||
let base = ui_layout::compose_with_order(&build, &bytes, c.opts, None, Some(&derived));
|
||||
let drawn: std::collections::HashSet<usize> = base.drawn.iter().copied().collect();
|
||||
// Control first. An instrument that cannot see a reorder it is
|
||||
// supposed to see makes every zero below meaningless.
|
||||
let ctrl = match control_pair(&build, &bytes, c.opts.at) {
|
||||
Some((a, b)) if drawn.contains(&a) && drawn.contains(&b) => {
|
||||
let alt = ui_layout::compose_with_order(
|
||||
&build,
|
||||
&bytes,
|
||||
c.opts,
|
||||
None,
|
||||
Some(&swapped(&derived, a, b)),
|
||||
);
|
||||
let (n, w) = diff(&base.rgba, &alt.rgba);
|
||||
Some((a, b, n, w))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match ctrl {
|
||||
Some((a, b, n, w)) if n > 0 => println!(
|
||||
" [{}] CONTROL ok: swapping [{a}] {} x [{b}] {} moves {n} px (max Δ {w})",
|
||||
c.name, build.elements[a].name, build.elements[b].name
|
||||
),
|
||||
Some((a, b, _, _)) => {
|
||||
totals.2 += 1;
|
||||
println!(
|
||||
" [{}] CONTROL DEAD: swapping [{a}] {} x [{b}] {} changes NOTHING — \
|
||||
zeros below are uninterpretable",
|
||||
c.name, build.elements[a].name, build.elements[b].name
|
||||
)
|
||||
}
|
||||
None => {
|
||||
totals.2 += 1;
|
||||
println!(
|
||||
" [{}] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn",
|
||||
c.name
|
||||
)
|
||||
}
|
||||
}
|
||||
for &(a, b, key) in &pairs {
|
||||
let both_drawn = drawn.contains(&a) && drawn.contains(&b);
|
||||
if !both_drawn {
|
||||
println!(
|
||||
" [{}] [{a}] {} x [{b}] {} (key {key}): NOT BOTH DRAWN — unreachable here",
|
||||
c.name, build.elements[a].name, build.elements[b].name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let alt = ui_layout::compose_with_order(
|
||||
&build,
|
||||
&bytes,
|
||||
c.opts,
|
||||
None,
|
||||
Some(&swapped(&derived, a, b)),
|
||||
);
|
||||
let (n, w) = diff(&base.rgba, &alt.rgba);
|
||||
let total = (base.width as usize) * (base.height as usize);
|
||||
// Explain the number: how many pixels do the two BOTH paint on?
|
||||
// A zero with a large shared-ink count is a real "order does
|
||||
// not matter here"; a zero with no shared ink means the
|
||||
// bounding boxes overlapped and the sprites did not.
|
||||
let ma = ink_mask(&build, &bytes, c.opts, &derived, &base.rgba, a);
|
||||
let mb = ink_mask(&build, &bytes, c.opts, &derived, &base.rgba, b);
|
||||
let shared = ma.iter().zip(&mb).filter(|(x, y)| **x && **y).count();
|
||||
let (ia, ib) = (
|
||||
ma.iter().filter(|x| **x).count(),
|
||||
mb.iter().filter(|x| **x).count(),
|
||||
);
|
||||
println!(
|
||||
" [{}] [{a}] {} x [{b}] {} (key {key}): {n} px differ ({:.4}% of frame), \
|
||||
max Δ {w} | ink {ia} / {ib} px, shared {shared} px",
|
||||
c.name,
|
||||
build.elements[a].name,
|
||||
build.elements[b].name,
|
||||
100.0 * n as f64 / total as f64
|
||||
);
|
||||
if c.name.starts_with("default") {
|
||||
totals.0 += 1;
|
||||
if n > 0 {
|
||||
totals.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
println!(
|
||||
"default-options summary: {} of {} overlapping tied pairs change at least one pixel; \
|
||||
{} dead/unavailable controls",
|
||||
totals.1, totals.0, totals.2
|
||||
);
|
||||
}
|
||||
76
crates/sylpheed-formats/examples/tie_cost_over_time.rs
Normal file
76
crates/sylpheed-formats/examples/tie_cost_over_time.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! How many tied pairs can cost a pixel, as a function of TIME?
|
||||
//!
|
||||
//! `tie_break_pixel_cost` answers "at one pose". That leaves the answer looking
|
||||
//! like it might be a knife-edge: pick a different instant and the count could
|
||||
//! jump. This sweeps every keyframe time in the bundle and reports, per entry,
|
||||
//! how many same-key pairs are simultaneously **opaque, non-collapsed and
|
||||
//! overlapping** — the pairs whose order could possibly matter at that instant.
|
||||
//!
|
||||
//! The instrument's control is built in: the count at t=0 (nothing has faded in)
|
||||
//! and the count at rest must bracket it, and an entry whose count is flat at
|
||||
//! zero for the whole sweep would be suspicious rather than reassuring — so the
|
||||
//! peak is printed too.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use ui_layout::UiBuild;
|
||||
|
||||
fn rect(e: &ui_layout::Element, t: u32) -> Option<(i32, i32, i32, i32)> {
|
||||
let kf = e.pose_at(t)?;
|
||||
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
|
||||
return None;
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
fn live_pairs(b: &UiBuild, bytes: &[u8], keys: &[u32], t: u32) -> usize {
|
||||
let mut n = 0;
|
||||
for a in 0..keys.len() {
|
||||
for c in (a + 1)..keys.len() {
|
||||
if keys[a] != keys[c] || keys[a] == u32::MAX { continue }
|
||||
let (Some(ra), Some(rb)) = (rect(&b.elements[a], t), rect(&b.elements[c], t)) else { continue };
|
||||
if (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
|
||||
&& (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1) > 0 { n += 1 }
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: tie_cost_over_time <pak>");
|
||||
let ar = pak::PakArchive::open(&path).expect("open pak");
|
||||
println!("# tied pairs that could cost a pixel, over time — {path}\n");
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let keys: Vec<u32> = b.elements.iter()
|
||||
.map(|el| ui_layout::sprite_layer_key(&b, &by, el).unwrap_or(u32::MAX)).collect();
|
||||
let mut ts: Vec<u32> = b.elements.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).collect();
|
||||
ts.sort_unstable(); ts.dedup();
|
||||
if ts.len() < 2 { continue }
|
||||
let last = *ts.last().unwrap();
|
||||
// sample every keyframe time AND every midpoint between them
|
||||
let mut samples: Vec<u32> = ts.clone();
|
||||
for w in ts.windows(2) { samples.push(w[0] + (w[1] - w[0]) / 2); }
|
||||
samples.sort_unstable(); samples.dedup();
|
||||
let counts: Vec<(u32, usize)> =
|
||||
samples.iter().map(|&t| (t, live_pairs(&b, &by, &keys, t))).collect();
|
||||
let peak = counts.iter().map(|&(_, n)| n).max().unwrap_or(0);
|
||||
if peak == 0 { continue }
|
||||
let Some((lo, hi)) = b.settle_window() else { continue };
|
||||
let st = b.settle_time().unwrap();
|
||||
let at_settle = live_pairs(&b, &by, &keys, st);
|
||||
// the whole plateau, not just its midpoint
|
||||
let plateau: Vec<usize> =
|
||||
(lo..=hi).step_by(((hi - lo).max(1) / 8).max(1) as usize)
|
||||
.map(|t| live_pairs(&b, &by, &keys, t)).collect();
|
||||
let pmax = plateau.iter().copied().max().unwrap_or(0);
|
||||
println!("entry {i:2} peak {peak} live pair(s) over t=0..{last} \
|
||||
settle window [{lo},{hi}] at t={st}: {at_settle} ACROSS THE WHOLE WINDOW: max {pmax}");
|
||||
let busy: Vec<String> = counts.iter().filter(|&&(_, n)| n > 0)
|
||||
.map(|&(t, n)| format!("t{t}:{n}")).collect();
|
||||
if busy.len() <= 24 { println!(" live only at {}", busy.join(" ")); }
|
||||
else { println!(" live at {} of {} sampled instants", busy.len(), counts.len()); }
|
||||
}
|
||||
}
|
||||
71
crates/sylpheed-formats/examples/voice_region_cap_sweep.rs
Normal file
71
crates/sylpheed-formats/examples/voice_region_cap_sweep.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Is raising the start filter's 1.5 MB cap safe, disc-wide?
|
||||
//!
|
||||
//! `voice_region_fix_test.rs` shows that for `ADV` the predecessor start recovers
|
||||
//! the decoder's own three byte_sizes exactly. But the cap exists to protect a
|
||||
//! case: the code says *"only within one bank (~1.5 MB), else this is the first cue
|
||||
//! in its block and the audio starts at the anchor itself"*. Raising it blindly
|
||||
//! could pull a **previous asset's** streams into the region.
|
||||
//!
|
||||
//! So compare, per movie: the chunk list the resolver gives today against the one
|
||||
//! the predecessor start gives. A safe change makes the FIRST chunk bigger and
|
||||
//! leaves the rest identical. An unsafe one adds leading chunks.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_cap_sweep
|
||||
|
||||
use sylpheed_formats::hash::name_hash;
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
use sylpheed_formats::{movie_manifest, movie_voice};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let code = VoiceLang::English.code_pub();
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak.entries().iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
|
||||
let registry = tpak.entries().iter().find_map(|e| {
|
||||
tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
}).expect("registry");
|
||||
let ids = movie_voice::registry_voice_ids(®istry);
|
||||
let stoc = src.read_file("dat/sound.pak").expect("sound.pak");
|
||||
let entries = PakArchive::parse_toc(&stoc).expect("toc");
|
||||
|
||||
let (mut same, mut grew, mut extra, mut skip) = (0, 0, 0, 0);
|
||||
for m in movie_manifest::parse(&manifest) {
|
||||
let movie = m.movie;
|
||||
let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { skip += 1; continue };
|
||||
let Some(&id) = ids.get(&token) else { skip += 1; continue };
|
||||
let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| {
|
||||
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
|
||||
entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64)
|
||||
}) else { skip += 1; continue };
|
||||
let win_start = anchor.saturating_sub(2*1024*1024) & !3;
|
||||
let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { skip += 1; continue };
|
||||
let Some(end_local) = movie_voice::find_descriptor(&window, id) else { skip += 1; continue };
|
||||
let end = win_start + end_local as u64;
|
||||
let cand = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
|
||||
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
|
||||
.map(|o| win_start + o as u64)
|
||||
.filter(|&s| s < end);
|
||||
let today = cand.filter(|&s| end - s < 1_500_000).unwrap_or(anchor);
|
||||
let Some(proposed) = cand else { skip += 1; continue };
|
||||
if today == proposed { same += 1; continue }
|
||||
let sizes = |s: u64| -> Vec<usize> {
|
||||
src.read_segment_range("dat/sound", s, (end - s) as usize)
|
||||
.map(|b| slb::to_xma_riffs(&b).iter().map(|r| r.len() - 60).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (a, b) = (sizes(today), sizes(proposed));
|
||||
let tail_same = a.len() == b.len() && a.iter().skip(1).eq(b.iter().skip(1));
|
||||
let verdict = if a.len() == b.len() && tail_same && b[0] > a[0] {
|
||||
grew += 1; "first chunk GREW, tail identical"
|
||||
} else if b.len() > a.len() { extra += 1; "EXTRA leading chunks" }
|
||||
else { extra += 1; "changed otherwise" };
|
||||
println!("{movie:10} today {a:?}\n{:10} prop {b:?} {verdict}", "");
|
||||
}
|
||||
println!("\nunchanged {same} fixed-cleanly {grew} would-break {extra} skipped {skip}");
|
||||
}
|
||||
97
crates/sylpheed-formats/examples/voice_region_chunks.rs
Normal file
97
crates/sylpheed-formats/examples/voice_region_chunks.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
//! What ARE the chunks a movie-voice region decodes to?
|
||||
//!
|
||||
//! The port reports a resolved voice region decoding to **three** chunks — two
|
||||
//! of equal duration each spanning the whole movie, and a leading one that
|
||||
//! "matches nothing" — and notes that this is the same 2+1 signature the BGM
|
||||
//! banks showed before `bank_header_len` attributed the extra to a bank header.
|
||||
//! Two different asset kinds with one signature is worth checking, because if
|
||||
//! the same explanation applies then `bank_header_len` is incomplete, and if it
|
||||
//! does not then the leading chunk is something we are discarding.
|
||||
//!
|
||||
//! `slb.rs`'s own doc comment already predicts the answer and disagrees with
|
||||
//! "drop it": the header signature fires on 28 entries, all music banks, with
|
||||
//! "zero false positives on the 7 993 mid-bank windows, WHERE THE LEADING
|
||||
//! REGION IS REAL". A voice region is a mid-bank window by construction —
|
||||
//! `resolve_movie_voice_region` starts it at the PREDECESSOR cue's trailer.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_chunks -- <disc-dir>
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc dir"));
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
|
||||
// Disc-wide, not the four movies that motivated the question: every movie
|
||||
// the manifest binds a voice to.
|
||||
let movies: Vec<String> = {
|
||||
use sylpheed_formats::movie_manifest;
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("movie manifest");
|
||||
movie_manifest::parse(&manifest)
|
||||
.into_iter()
|
||||
.map(|m| m.movie)
|
||||
.collect()
|
||||
};
|
||||
println!("{} movies in the manifest\n", movies.len());
|
||||
let mut census: std::collections::BTreeMap<usize, usize> = Default::default();
|
||||
let (mut with_header, mut with_leading, mut no_leading) = (0, 0, 0);
|
||||
for movie in movies.iter().map(|s| s.as_str()) {
|
||||
let Some((start, end)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let len = end - start;
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", start, len as usize) else {
|
||||
println!("{movie:8} region {start}..{end} unreadable");
|
||||
continue;
|
||||
};
|
||||
// Where does the first RIFF sit? Everything before it is the leading
|
||||
// headerless packet region.
|
||||
let first_riff = bytes
|
||||
.windows(4)
|
||||
.position(|w| w == b"RIFF")
|
||||
.map(|p| p as i64)
|
||||
.unwrap_or(-1);
|
||||
let hdr = slb::bank_header_len(&bytes);
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
let kind = if first_riff <= 0 {
|
||||
no_leading += 1;
|
||||
"no leading region".to_string()
|
||||
} else if hdr == Some(first_riff as usize) {
|
||||
with_header += 1;
|
||||
format!("BANK HEADER ({first_riff} B = {} packets exactly)", first_riff / 2048)
|
||||
} else {
|
||||
with_leading += 1;
|
||||
*census.entry(first_riff as usize % 2048).or_default() += 1;
|
||||
format!(
|
||||
"leading STREAM ({first_riff} B = {} packets + {} B)",
|
||||
first_riff / 2048,
|
||||
first_riff % 2048
|
||||
)
|
||||
};
|
||||
println!(
|
||||
"{movie:10} {start:12}..{end:12} {len:9} B chunks {} {kind}",
|
||||
riffs.len()
|
||||
);
|
||||
if let Ok(dir) = std::env::var("VOICE_CHUNK_DUMP") {
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let _ = std::fs::write(format!("{dir}/{movie}-chunk{i}.wav"), r);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\n{with_header} region(s) open with a BANK HEADER (bank_header_len fires)\n\
|
||||
{with_leading} open with a leading STREAM\n{no_leading} start at a RIFF"
|
||||
);
|
||||
println!("leading-stream length mod 2048, i.e. the derived data offset:");
|
||||
for (rem, n) in &census {
|
||||
println!(" {rem:5} B x{n}");
|
||||
}
|
||||
}
|
||||
40
crates/sylpheed-formats/examples/voice_region_fix_test.rs
Normal file
40
crates/sylpheed-formats/examples/voice_region_fix_test.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Would keeping the predecessor (instead of falling back to `anchor`) recover
|
||||
//! the streams the running decoder actually decodes?
|
||||
//!
|
||||
//! `voice_region_start_why.rs` shows the failing branch: the start filter
|
||||
//! `end - s < 1_500_000` rejects `ADV`'s predecessor because its span is 3.6 MB,
|
||||
//! so `start` falls back to `anchor` — a TOC offset, not a stream boundary.
|
||||
//!
|
||||
//! This does NOT patch the resolver. It asks the one question that decides whether
|
||||
//! raising that cap is the fix: **from the predecessor, does `to_xma_riffs` return
|
||||
//! the decoder's own byte_sizes?** For `ADV` those are known, so this is a test and
|
||||
//! not a fit.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_fix_test
|
||||
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb;
|
||||
|
||||
const ADV_PRED: u64 = 433_425_776;
|
||||
const ADV_ANCHOR: u64 = 433_930_240;
|
||||
const ADV_END: u64 = 437_044_592;
|
||||
/// What the running decoder reported.
|
||||
const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456];
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
for (label, start) in [("anchor (today)", ADV_ANCHOR), ("predecessor (proposed)", ADV_PRED)] {
|
||||
let bytes = src
|
||||
.read_segment_range("dat/sound", start, (ADV_END - start) as usize)
|
||||
.expect("region");
|
||||
let sizes: Vec<usize> = slb::to_xma_riffs(&bytes).iter().map(|r| r.len() - 60).collect();
|
||||
let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b);
|
||||
println!(
|
||||
"{label:24} start {start} span {:>9} -> {:?}{}",
|
||||
ADV_END - start,
|
||||
sizes,
|
||||
if hit { " <== MATCHES THE DECODER" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
63
crates/sylpheed-formats/examples/voice_region_start_audit.rs
Normal file
63
crates/sylpheed-formats/examples/voice_region_start_audit.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Does `resolve_movie_voice_region` start inside the first stream, disc-wide?
|
||||
//!
|
||||
//! Verified on `ADV`: the resolver starts **238 packets (487 424 B) late**, and
|
||||
//! extending the span by exactly that reproduces the running decoder's three
|
||||
//! byte_sizes to the byte. The decoder is the ground truth there, but it exists
|
||||
//! for one movie only — so this asks a structural question instead.
|
||||
//!
|
||||
//! **If the region began at a stream boundary, stepping the start backwards would
|
||||
//! immediately expose the PREVIOUS asset's chunks.** If it began mid-stream, the
|
||||
//! first chunk instead *grows*, packet for packet, until the real boundary. The
|
||||
//! number of packets it grows for is the clip.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_start_audit
|
||||
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::movie_manifest;
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let movies: Vec<String> = movie_manifest::parse(&manifest)
|
||||
.into_iter()
|
||||
.map(|m| m.movie)
|
||||
.collect();
|
||||
|
||||
println!("{:10} {:>8} {:>12} {:>10} {}", "movie", "chunks", "first chunk", "clip pkts", "verdict");
|
||||
let (mut clipped, mut clean, mut skipped) = (0, 0, 0);
|
||||
for movie in movies {
|
||||
let Some((start, end)) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
|
||||
else { skipped += 1; continue };
|
||||
// ONE read of the region plus a lead-in, then slide inside it. Re-reading
|
||||
// several MB per step made this too slow to finish at all.
|
||||
const LEAD: u64 = 600 * 2048;
|
||||
let lead = LEAD.min(start);
|
||||
let buf = match src.read_segment_range("dat/sound", start - lead, (end - (start - lead)) as usize) {
|
||||
Ok(b) => b, Err(_) => { skipped += 1; continue }
|
||||
};
|
||||
let at = |back: usize| -> Vec<Vec<u8>> {
|
||||
let off = lead as usize - back * 2048;
|
||||
slb::to_xma_riffs(&buf[off..])
|
||||
};
|
||||
let base_riffs = at(0);
|
||||
if base_riffs.is_empty() { skipped += 1; continue }
|
||||
let n0 = base_riffs.len();
|
||||
let first0 = base_riffs[0].len() - 60;
|
||||
let mut clip = 0usize;
|
||||
for k in 1..=(lead as usize / 2048) {
|
||||
if at(k).len() != n0 { break }
|
||||
clip = k;
|
||||
}
|
||||
let verdict = if clip == 0 { clean += 1; "starts at a boundary" }
|
||||
else { clipped += 1; "STARTS MID-STREAM" };
|
||||
println!("{movie:10} {n0:>8} {first0:>12} {clip:>10} {verdict}");
|
||||
}
|
||||
println!("\nclipped {clipped} clean {clean} skipped {skipped}");
|
||||
}
|
||||
70
crates/sylpheed-formats/examples/voice_region_start_why.rs
Normal file
70
crates/sylpheed-formats/examples/voice_region_start_why.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! WHY does `resolve_movie_voice_region` start inside the first stream?
|
||||
//!
|
||||
//! [`voice-region-starts-late.md`] establishes that it does — 238 packets late for
|
||||
//! `ADV`, 8 of 10 multichannel regions disc-wide — but not why, and a fix guessed
|
||||
//! from one movie would be worse than a documented defect. This reproduces the
|
||||
//! resolver's own steps and prints each candidate, so the failing branch is visible
|
||||
//! rather than inferred.
|
||||
//!
|
||||
//! The suspicion the code itself raises: the start is filtered by
|
||||
//! `end - s < 1_500_000` — "only within one bank" — and `ADV`'s region has to span
|
||||
//! **3.6 MB**. If that filter rejects the real predecessor, `start` silently falls
|
||||
//! back to `anchor`, which is a TOC offset and not a stream boundary at all.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_start_why
|
||||
|
||||
use sylpheed_formats::hash::name_hash;
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
use sylpheed_formats::slb::VoiceLang;
|
||||
use sylpheed_formats::{movie_manifest, movie_voice};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let code = VoiceLang::English.code_pub();
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak
|
||||
.entries().iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
|
||||
let registry = tpak.entries().iter().find_map(|e| {
|
||||
tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
}).expect("registry");
|
||||
let ids = movie_voice::registry_voice_ids(®istry);
|
||||
let stoc = src.read_file("dat/sound.pak").expect("sound.pak");
|
||||
let entries = PakArchive::parse_toc(&stoc).expect("toc");
|
||||
|
||||
println!("{:10} {:>6} {:>12} {:>12} {:>12} {:>10} {:>9} {}",
|
||||
"movie","id","anchor","pred(id-1)","pred(before)","span","chosen","note");
|
||||
for m in movie_manifest::parse(&manifest) {
|
||||
let movie = m.movie;
|
||||
let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { continue };
|
||||
let Some(&id) = ids.get(&token) else { continue };
|
||||
let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| {
|
||||
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
|
||||
entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64)
|
||||
}) else { continue };
|
||||
let win_start = anchor.saturating_sub(2*1024*1024) & !3;
|
||||
let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { continue };
|
||||
let Some(end_local) = movie_voice::find_descriptor(&window, id) else { continue };
|
||||
let end = win_start + end_local as u64;
|
||||
let p1 = movie_voice::find_descriptor(&window, id.wrapping_sub(1)).map(|o| win_start + o as u64);
|
||||
let pb = movie_voice::find_descriptor_before(&window, end_local).map(|o| win_start + o as u64);
|
||||
let cand = p1.or(pb);
|
||||
// the resolver's own filter
|
||||
let kept = cand.filter(|&s| s < end && end - s < 1_500_000);
|
||||
let chosen = kept.unwrap_or(anchor);
|
||||
let span = cand.map(|s| end.saturating_sub(s)).unwrap_or(0);
|
||||
let note = match (cand, kept) {
|
||||
(Some(_), None) => "REJECTED by the 1.5 MB filter -> fell back to anchor",
|
||||
(Some(_), Some(_)) => "predecessor kept",
|
||||
(None, _) => "no predecessor found -> anchor",
|
||||
};
|
||||
println!("{movie:10} {id:>6} {anchor:>12} {:>12} {:>12} {span:>10} {:>9} {note}",
|
||||
p1.map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
pb.map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
if chosen == anchor { "anchor" } else { "pred" });
|
||||
}
|
||||
}
|
||||
205
crates/sylpheed-formats/examples/voice_stream_cue_map.rs
Normal file
205
crates/sylpheed-formats/examples/voice_stream_cue_map.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! Who owns the bytes in front of a movie-voice region's first `RIFF`?
|
||||
//!
|
||||
//! [`voice-region-leading-chunk.md`] left one thing open: the leading chunk of
|
||||
//! the 17 stream-opening regions is real XMA audio that **no other movie-voice
|
||||
//! region claims** — but the census only enumerated the 95 movie cues, while the
|
||||
//! same continuous stream also carries the in-mission `VOICE_D_*` lines. The
|
||||
//! leading hypothesis was that the bytes belong to one of those, and the port
|
||||
//! pointed out that the byte-span test already written settles it *without
|
||||
//! anyone listening* if the enumeration is widened.
|
||||
//!
|
||||
//! So this widens it the whole way: rather than resolving cues one at a time
|
||||
//! through the manifest, scan the stream itself for **every** trailer descriptor
|
||||
//! — the `(id: u32be, 0x11, …)` pair whose id repeats at `+0x800`, which
|
||||
//! `movie_voice` documents as the end of a cue's audio. Cue N's audio is
|
||||
//! `[descriptor(N-1) .. descriptor(N)]`, so the full descriptor list IS the
|
||||
//! complete cue partition of the stream, movie and mission alike.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_stream_cue_map -- <disc-dir>
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::VoiceLang;
|
||||
|
||||
const DESC_MARK: u32 = 0x11;
|
||||
const DESC_REPEAT: usize = 0x800;
|
||||
const ID_MAX: u32 = 0x1_0000;
|
||||
|
||||
/// Every trailer descriptor in `buf`, as `(offset, id)`.
|
||||
///
|
||||
/// Same predicate `movie_voice::find_descriptor` uses — the id-repeat at +0x800
|
||||
/// is what makes a false match inside XMA audio ~2^-64.
|
||||
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
let mut out = Vec::new();
|
||||
if buf.len() < DESC_REPEAT + 8 {
|
||||
return out;
|
||||
}
|
||||
let end = buf.len() - (DESC_REPEAT + 4);
|
||||
let mut o = 0;
|
||||
while o <= end {
|
||||
let id = be(o);
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
out.push((o, id));
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc dir"));
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
|
||||
// The cue-name -> id registry, so a descriptor id can be named.
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let marker = "eng\\Movie\\VOICE_ADV.slb";
|
||||
let registry = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
})
|
||||
.expect("voice registry");
|
||||
let ids = sylpheed_formats::movie_voice::registry_voice_ids(®istry);
|
||||
let name_of: std::collections::HashMap<u32, String> =
|
||||
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
|
||||
println!("registry: {} cue names, {} distinct ids", ids.len(), name_of.len());
|
||||
|
||||
// The 17 regions that open with a headerless stream, from the manifest.
|
||||
let movies: Vec<String> = {
|
||||
use sylpheed_formats::movie_manifest;
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
movie_manifest::parse(&manifest).into_iter().map(|m| m.movie).collect()
|
||||
};
|
||||
let mut regions = Vec::new();
|
||||
for m in &movies {
|
||||
if let Some((s, e)) = media::resolve_movie_voice_region(&src, m, VoiceLang::English) {
|
||||
regions.push((m.clone(), s, e));
|
||||
}
|
||||
}
|
||||
let lo = regions.iter().map(|r| r.1).min().unwrap();
|
||||
let hi = regions.iter().map(|r| r.2).max().unwrap();
|
||||
// Scan a window covering every region, with margin for cues either side.
|
||||
let win_start = lo.saturating_sub(8 * 1024 * 1024) & !3;
|
||||
let win_len = (hi - win_start + 8 * 1024 * 1024) as usize;
|
||||
println!("scanning dat/sound {win_start}..{} ({:.1} MB)", win_start + win_len as u64,
|
||||
win_len as f64 / 1e6);
|
||||
let buf = src
|
||||
.read_segment_range("dat/sound", win_start, win_len)
|
||||
.expect("stream window");
|
||||
let descs = all_descriptors(&buf);
|
||||
println!("{} trailer descriptors found\n", descs.len());
|
||||
|
||||
let named = descs.iter().filter(|(_, id)| name_of.contains_key(id)).count();
|
||||
println!(" of those, {named} carry an id the registry names, {} do not\n",
|
||||
descs.len() - named);
|
||||
|
||||
// For each stream-opening region, name the cue that OWNS the leading span:
|
||||
// the cue whose [prev_desc .. desc] interval contains it.
|
||||
println!("leading span -> owning cue\n");
|
||||
let mut verdicts: std::collections::BTreeMap<&str, usize> = Default::default();
|
||||
for (m, s, e) in ®ions {
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", *s, (e - s) as usize) else { continue };
|
||||
let Some(fr) = bytes.windows(4).position(|w| w == b"RIFF") else { continue };
|
||||
if fr == 0 || sylpheed_formats::slb::bank_header_len(&bytes) == Some(fr) {
|
||||
continue; // bank-header case, not ours
|
||||
}
|
||||
let (a, b) = (*s, *s + fr as u64); // the leading span, global offsets
|
||||
// Descriptors bracketing the MIDDLE of the leading span.
|
||||
let mid = (a + b) / 2;
|
||||
let mid_local = (mid - win_start) as usize;
|
||||
let before = descs.iter().rev().find(|(o, _)| (*o as u64) < mid_local as u64);
|
||||
let after = descs.iter().find(|(o, _)| *o >= mid_local);
|
||||
let owner = after.map(|(_, id)| *id);
|
||||
let owner_name = owner
|
||||
.and_then(|id| name_of.get(&id).cloned())
|
||||
.unwrap_or_else(|| owner.map(|i| format!("<unnamed id {i}>")).unwrap_or("<none>".into()));
|
||||
let kind = if owner_name.starts_with("VOICE_D_") {
|
||||
"MISSION line"
|
||||
} else if owner_name.starts_with("VOICE_") {
|
||||
"movie cue"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
*verdicts.entry(kind).or_default() += 1;
|
||||
println!(
|
||||
" {m:8} lead {:8} B bracketed by desc@{:?} .. desc@{:?} owner {owner_name} [{kind}]",
|
||||
b - a,
|
||||
before.map(|(o, i)| (*o as u64 + win_start, *i)),
|
||||
after.map(|(o, i)| (*o as u64 + win_start, *i)),
|
||||
);
|
||||
}
|
||||
println!("\nverdicts: {verdicts:?}");
|
||||
|
||||
// WHY do exactly these 17 open mid-cue? `resolve_movie_voice_region` takes
|
||||
// the predecessor trailer as the region start, but guards it with
|
||||
// `end - start < 1_500_000` and falls back to the .slb TOC anchor when that
|
||||
// fails. If the guard is the cause, then the stream-opening regions are
|
||||
// exactly the cues whose true span exceeds the guard.
|
||||
println!("\ncue span vs the 1.5 MB guard, and what the region actually starts at:\n");
|
||||
let (mut over, mut under, mut over_is_stream, mut under_is_stream) = (0, 0, 0, 0);
|
||||
for (m, s, e) in ®ions {
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", *s, (e - s) as usize) else { continue };
|
||||
let fr = bytes.windows(4).position(|w| w == b"RIFF");
|
||||
let is_stream = matches!(fr, Some(f) if f > 0
|
||||
&& sylpheed_formats::slb::bank_header_len(&bytes) != Some(f));
|
||||
// The true predecessor trailer for this cue, from the full descriptor list.
|
||||
let end_local = (*e - win_start) as usize;
|
||||
let prev = descs.iter().rev().find(|(o, _)| *o < end_local).map(|(o, _)| *o as u64 + win_start);
|
||||
let Some(prev) = prev else { continue };
|
||||
let span = e - prev;
|
||||
let guarded = span >= 1_500_000;
|
||||
if guarded { over += 1; if is_stream { over_is_stream += 1 } }
|
||||
else { under += 1; if is_stream { under_is_stream += 1 } }
|
||||
if is_stream {
|
||||
println!(
|
||||
" {m:8} true cue span {span:8} B (> guard: {guarded}) region starts at {s}, \
|
||||
true start {prev} -> {} B of the cue's own audio is OUTSIDE the region",
|
||||
s.saturating_sub(prev)
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("\ncues over the 1.5 MB guard: {over}, of which stream-opening: {over_is_stream}");
|
||||
println!("cues under the guard: {under}, of which stream-opening: {under_is_stream}");
|
||||
|
||||
// How many streams is ONE cue stored as? The port measured that a region's
|
||||
// leading chunk is the TAIL of its first full-length chunk, i.e. the cue is
|
||||
// re-presented. Structurally that predicts a fixed number of stream starts
|
||||
// inside a cue's TRUE span [desc(N-1) .. desc(N)] -- which is measurable
|
||||
// from the bytes alone, with no decoder.
|
||||
println!("\nstream starts inside each cue's TRUE span (desc(N-1)..desc(N)):\n");
|
||||
let mut hist: std::collections::BTreeMap<usize, usize> = Default::default();
|
||||
let mut hist_long: std::collections::BTreeMap<usize, usize> = Default::default();
|
||||
for w in descs.windows(2) {
|
||||
let (a, b) = (w[0].0, w[1].0);
|
||||
if b <= a || b - a < 4096 {
|
||||
continue;
|
||||
}
|
||||
let span = &buf[a..b];
|
||||
// A stream start is a RIFF; plus the run before the first one, when it
|
||||
// is not a bank header, is itself a stream.
|
||||
let riffs = span
|
||||
.windows(4)
|
||||
.enumerate()
|
||||
.filter(|(_, w)| *w == b"RIFF")
|
||||
.count();
|
||||
let lead_is_stream = match span.windows(4).position(|w| w == b"RIFF") {
|
||||
Some(f) if f > 0 => sylpheed_formats::slb::bank_header_len(span) != Some(f),
|
||||
_ => false,
|
||||
};
|
||||
let streams = riffs + usize::from(lead_is_stream);
|
||||
*hist.entry(streams).or_default() += 1;
|
||||
if b - a >= 1_500_000 {
|
||||
*hist_long.entry(streams).or_default() += 1;
|
||||
}
|
||||
}
|
||||
println!(" all inter-descriptor spans: {hist:?}");
|
||||
println!(" spans >= 1.5 MB (the long cues): {hist_long:?}");
|
||||
}
|
||||
122
crates/sylpheed-formats/examples/voice_three_stream_sizes.rs
Normal file
122
crates/sylpheed-formats/examples/voice_three_stream_sizes.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Are the three streams of a voice cue in a CONSISTENT size relationship?
|
||||
//!
|
||||
//! The port selected a voice presentation on this argument: `ADV` chunk 1 is
|
||||
//! mono-in-stereo (channel 2 digitally silent) and chunk 2 is dual-mono (both
|
||||
//! channels identical), so chunk 2's extra bytes encode a duplicate channel
|
||||
//! rather than fidelity — which would explain its higher declared
|
||||
//! `PsuedoBytesPerSec` without appealing to encode quality.
|
||||
//!
|
||||
//! That is a claim about the *encoding*, and it makes a structural prediction:
|
||||
//! if stream 3 is always "the same take with its channel duplicated", it should
|
||||
//! sit in a consistent size ratio to stream 2 across every 3-stream cue on the
|
||||
//! disc. If the ratio scatters — or if some third streams are tiny — then the
|
||||
//! observation is about `ADV`, not about the format.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_three_stream_sizes -- <disc>
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb;
|
||||
|
||||
const DESC_MARK: u32 = 0x11;
|
||||
const DESC_REPEAT: usize = 0x800;
|
||||
const ID_MAX: u32 = 0x1_0000;
|
||||
|
||||
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
let mut out = Vec::new();
|
||||
if buf.len() < DESC_REPEAT + 8 {
|
||||
return out;
|
||||
}
|
||||
let end = buf.len() - (DESC_REPEAT + 4);
|
||||
let mut o = 0;
|
||||
while o <= end {
|
||||
let id = be(o);
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
out.push((o, id));
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The declared `PsuedoBytesPerSec` at `fmt +0x20` of a RIFF chunk, if present.
|
||||
fn declared_rate(riff: &[u8]) -> Option<u32> {
|
||||
if riff.len() < 0x28 || &riff[0..4] != b"RIFF" {
|
||||
return None;
|
||||
}
|
||||
Some(u32::from_le_bytes(riff[0x20..0x24].try_into().ok()?))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc"));
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let marker = "eng\\Movie\\VOICE_ADV.slb";
|
||||
let registry = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
})
|
||||
.expect("registry");
|
||||
let ids = sylpheed_formats::movie_voice::registry_voice_ids(®istry);
|
||||
let name_of: std::collections::HashMap<u32, String> =
|
||||
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
|
||||
|
||||
// Same window the cue map uses.
|
||||
let win_start: u64 = 421_739_888 & !3;
|
||||
let win_len: usize = 116_300_000;
|
||||
let buf = src
|
||||
.read_segment_range("dat/sound", win_start, win_len)
|
||||
.expect("window");
|
||||
let descs = all_descriptors(&buf);
|
||||
|
||||
println!("{:<14} {:>10} {:>10} {:>10} {:>7} {:>9} {:>9}",
|
||||
"cue", "stream1", "stream2", "stream3", "s3/s2", "rate2", "rate3");
|
||||
let mut ratios: Vec<f64> = Vec::new();
|
||||
let mut tiny = 0;
|
||||
for w in descs.windows(2) {
|
||||
let (a, b) = (w[0].0, w[1].0);
|
||||
if b <= a || b - a < 4096 {
|
||||
continue;
|
||||
}
|
||||
let span = &buf[a..b];
|
||||
let riffs = slb::to_xma_riffs(span);
|
||||
if riffs.len() != 3 {
|
||||
continue;
|
||||
}
|
||||
let name = name_of
|
||||
.get(&w[1].1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("id{}", w[1].1));
|
||||
let (s1, s2, s3) = (riffs[0].len(), riffs[1].len(), riffs[2].len());
|
||||
let r = s3 as f64 / s2 as f64;
|
||||
ratios.push(r);
|
||||
if r < 0.5 {
|
||||
tiny += 1;
|
||||
}
|
||||
println!(
|
||||
"{:<14} {s1:>10} {s2:>10} {s3:>10} {r:>7.4} {:>9} {:>9}",
|
||||
name.trim_start_matches("VOICE_"),
|
||||
declared_rate(&riffs[1]).map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
declared_rate(&riffs[2]).map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
);
|
||||
}
|
||||
ratios.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let n = ratios.len();
|
||||
println!("\n{n} three-stream cues");
|
||||
if n > 0 {
|
||||
let mean = ratios.iter().sum::<f64>() / n as f64;
|
||||
let var = ratios.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
|
||||
println!(
|
||||
" stream3/stream2 ratio: min {:.4} median {:.4} max {:.4} mean {:.4} sd {:.4}",
|
||||
ratios[0], ratios[n / 2], ratios[n - 1], mean, var.sqrt()
|
||||
);
|
||||
println!(" cues where stream 3 is less than HALF of stream 2: {tiny}");
|
||||
let near = ratios.iter().filter(|r| (**r - 1.0).abs() < 0.15).count();
|
||||
println!(" cues where stream 3 is within 15% of stream 2: {near} of {n}");
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,14 @@ pub struct AudioInfo {
|
||||
pub size_bytes: usize,
|
||||
/// 2048-byte XMA packet count, for XMA/raw-XMA streams.
|
||||
pub xma_packets: Option<u32>,
|
||||
/// The stream's **declared** average bytes per second.
|
||||
///
|
||||
/// For XMA1 this is `XMASTREAMFORMAT::PsuedoBytesPerSec`. It is what makes a
|
||||
/// duration available for a codec we cannot decode: `data_bytes / this`
|
||||
/// agreed with an independently decoded duration to **0.01 %** on the two
|
||||
/// movie voices it was checked against
|
||||
/// (`docs/re/structures/voice-region-leading-chunk.md`).
|
||||
pub avg_bytes_per_sec: Option<u32>,
|
||||
}
|
||||
|
||||
impl AudioInfo {
|
||||
@@ -110,6 +118,7 @@ impl AudioInfo {
|
||||
duration_secs: None,
|
||||
size_bytes: size,
|
||||
xma_packets: None,
|
||||
avg_bytes_per_sec: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +190,7 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
|
||||
|
||||
let mut pos = 12;
|
||||
let (mut tag, mut channels, mut rate, mut bits) = (0u16, 0u16, 0u32, 0u16);
|
||||
let mut avg_bps = 0u32;
|
||||
let mut data_bytes: Option<u64> = None;
|
||||
let mut have_fmt = false;
|
||||
|
||||
@@ -191,13 +201,37 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
|
||||
match id {
|
||||
b"fmt " if body + 16 <= bytes.len() => {
|
||||
tag = le16(body);
|
||||
channels = le16(body + 2);
|
||||
rate = le32(body + 4);
|
||||
bits = le16(body + 14);
|
||||
// WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's first
|
||||
// two bytes, right after cbSize (+2) → +24 from the fmt body.
|
||||
if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() {
|
||||
tag = le16(body + 24);
|
||||
// 🔴 XMA1 is NOT a WAVEFORMATEX. Reading it as one is where
|
||||
// `audio info` got "16 channels, 4310 Hz, 2-bit" from the
|
||||
// movie voices: 16 is `wBitsPerSample` read as channels, and
|
||||
// 4310 is `wEncodeOptions` (0x10d6) read as a sample rate.
|
||||
//
|
||||
// XMA1 carries `XMAWAVEFORMAT`, then one `XMASTREAMFORMAT` per
|
||||
// stream (xenia-canary `src/xenia/apu/xma_context.h`,
|
||||
// cross-checked against the disc's own movie-voice headers):
|
||||
//
|
||||
// +0 wFormatTag +2 wBitsPerSample +4 wEncodeOptions
|
||||
// +6 wLargestSkip +8 wNumStreams +10 bLoopCount (u8)
|
||||
// +11 bStreamCount (u8)
|
||||
// +12 PsuedoBytesPerSec +16 SampleRate +20 LoopStart
|
||||
// +24 LoopEnd +28 SubframeData (u8) +29 Channels (u8)
|
||||
// +30 ChannelMask
|
||||
if tag == WAVE_FORMAT_XMA && body + 32 <= bytes.len() {
|
||||
bits = le16(body + 2);
|
||||
avg_bps = le32(body + 12);
|
||||
rate = le32(body + 16);
|
||||
channels = bytes[body + 29] as u16;
|
||||
} else {
|
||||
channels = le16(body + 2);
|
||||
rate = le32(body + 4);
|
||||
bits = le16(body + 14);
|
||||
avg_bps = le32(body + 8);
|
||||
// WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's
|
||||
// first two bytes, right after cbSize (+2) → +24 from the
|
||||
// fmt body.
|
||||
if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() {
|
||||
tag = le16(body + 24);
|
||||
}
|
||||
}
|
||||
have_fmt = true;
|
||||
}
|
||||
@@ -222,6 +256,15 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
|
||||
info.channels = Some(channels).filter(|&c| c > 0);
|
||||
info.sample_rate = Some(rate).filter(|&r| r > 0);
|
||||
info.bits_per_sample = Some(bits).filter(|&b| b > 0);
|
||||
info.avg_bytes_per_sec = Some(avg_bps).filter(|&b| b > 0);
|
||||
|
||||
// A declared byte rate gives a duration for a codec we cannot decode. Only
|
||||
// for XMA1, where the field is `PsuedoBytesPerSec` and means exactly this.
|
||||
if codec == AudioCodec::Xma && avg_bps > 0 {
|
||||
if let Some(d) = data_bytes {
|
||||
info.duration_secs = Some(d as f32 / avg_bps as f32);
|
||||
}
|
||||
}
|
||||
|
||||
match codec {
|
||||
AudioCodec::Pcm | AudioCodec::PcmFloat => {
|
||||
@@ -356,6 +399,58 @@ mod tests {
|
||||
assert!((audio.samples[2] - 0.99997).abs() < 1e-3); // 32767/32768
|
||||
}
|
||||
|
||||
/// XMA1 is not a `WAVEFORMATEX`, and reading it as one produced nonsense.
|
||||
///
|
||||
/// The bytes here are the real `fmt ` chunk of `ADV`'s first movie-voice
|
||||
/// presentation, copied off the disc. Read as a `WAVEFORMATEX` it reports
|
||||
/// **16 channels, 4310 Hz, 2-bit** — 16 is `wBitsPerSample`, 4310 is
|
||||
/// `wEncodeOptions` (`0x10d6`). Read as an `XMAWAVEFORMAT` it reports 2
|
||||
/// channels, 48 kHz, 16-bit, 8142 B/s.
|
||||
///
|
||||
/// The duration is the part worth guarding: this crate has no XMA decoder,
|
||||
/// and `data_bytes / PsuedoBytesPerSec` is the only route to one. It agrees
|
||||
/// with an independently decoded 137.324 s to **0.02 %**.
|
||||
#[test]
|
||||
fn xma1_fmt_is_not_a_waveformatex() {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(b"RIFF");
|
||||
v.extend_from_slice(&0u32.to_le_bytes());
|
||||
v.extend_from_slice(b"WAVE");
|
||||
v.extend_from_slice(b"fmt ");
|
||||
v.extend_from_slice(&32u32.to_le_bytes());
|
||||
// XMAWAVEFORMAT, exactly as it appears on the disc.
|
||||
v.extend_from_slice(&[
|
||||
0x65, 0x01, // wFormatTag = 0x0165 (XMA1)
|
||||
0x10, 0x00, // wBitsPerSample = 16
|
||||
0xd6, 0x10, // wEncodeOptions = 0x10d6 <- was misread as the rate
|
||||
0x00, 0x00, // wLargestSkip
|
||||
0x01, 0x00, // wNumStreams
|
||||
0x00, // bLoopCount
|
||||
0x02, // bStreamCount
|
||||
0xce, 0x1f, 0x00, 0x00, // PsuedoBytesPerSec = 8142
|
||||
0x80, 0xbb, 0x00, 0x00, // SampleRate = 48000
|
||||
0x00, 0x00, 0x00, 0x00, // LoopStart
|
||||
0x00, 0x00, 0x00, 0x00, // LoopEnd
|
||||
0x00, // SubframeData
|
||||
0x02, // Channels = 2 <- was read from +2 as 16
|
||||
0x02, 0x00, // ChannelMask
|
||||
]);
|
||||
v.extend_from_slice(b"data");
|
||||
v.extend_from_slice(&1_118_208u32.to_le_bytes());
|
||||
|
||||
let info = AudioInfo::probe(&v);
|
||||
assert_eq!(info.codec, AudioCodec::Xma);
|
||||
assert_eq!(info.channels, Some(2), "channels came from wBitsPerSample");
|
||||
assert_eq!(info.sample_rate, Some(48_000), "rate came from wEncodeOptions");
|
||||
assert_eq!(info.bits_per_sample, Some(16));
|
||||
assert_eq!(info.avg_bytes_per_sec, Some(8142));
|
||||
let d = info.duration_secs.expect("duration from the declared byte rate");
|
||||
assert!(
|
||||
(d - 137.324).abs() < 0.05,
|
||||
"declared-rate duration {d} should match the decoded 137.324 s"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_xma2_riff_reports_metadata_not_decode() {
|
||||
// Minimal RIFF/WAVE with an XMA2 fmt tag.
|
||||
|
||||
@@ -288,12 +288,29 @@ pub fn resolve_movie_voice_region<S: DiscSource + ?Sized>(
|
||||
|
||||
// Start = the predecessor trailer. Prefer the exact `id-1`; where the id
|
||||
// sequence has a gap (VOICE_D_453 → 454) fall back to the nearest trailer
|
||||
// below — but only within one bank (~1.5 MB), else this is the first cue in
|
||||
// its block and the audio starts at the anchor itself.
|
||||
// below.
|
||||
//
|
||||
// 🔴 There used to be a second condition here — `end - s < 1_500_000`, "only
|
||||
// within one bank, else this is the first cue in its block and the audio
|
||||
// starts at the anchor itself". **It was wrong, and it silently truncated the
|
||||
// first stream of every region larger than 1.5 MB.** `anchor` is a TOC offset,
|
||||
// not a stream boundary, so the fallback started mid-packet-run: `ADV` began
|
||||
// **238 packets (487 424 B) into its own first stream**, and a consumer then
|
||||
// saw a leading chunk that "matched nothing" and dropped 62 % of a real stream.
|
||||
//
|
||||
// Ground truth is the running decoder, which reports `ADV`'s three contexts as
|
||||
// 1 294 336 / 1 118 208 / 1 171 456 (`--xma_param_probe`). With the cap gone the
|
||||
// region reproduces all three exactly; with it, the first is 806 912.
|
||||
//
|
||||
// Disc-wide over the 95 manifest movies that resolve: **17 regions fixed, 78
|
||||
// unchanged, 0 changed in any other way** — in every one of the 17 the first
|
||||
// chunk grows and the remaining chunks are byte-identical, which is what a
|
||||
// corrected start looks like and what pulling in a neighbouring asset does not.
|
||||
// `docs/re/structures/voice-region-starts-late.md`.
|
||||
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
|
||||
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
|
||||
.map(|o| win_start + o as u64)
|
||||
.filter(|&s| s < end && end - s < 1_500_000)
|
||||
.filter(|&s| s < end)
|
||||
.unwrap_or(anchor);
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
@@ -377,6 +377,39 @@ pub fn leading_data_offset(first_riff: usize) -> usize {
|
||||
first_riff % XMA1_PACKET
|
||||
}
|
||||
|
||||
/// Length of the **bank header** when an entry begins with one, in bytes.
|
||||
///
|
||||
/// A music bank opens with a header the header itself sizes: big-endian, the
|
||||
/// 2048-byte block size sits at `+0x18`, the bank id is repeated at `+0x00` and
|
||||
/// `+0x20`, and `+0x24` is the header's length **in blocks** (5, i.e. 10 240 B,
|
||||
/// on every music bank on this disc).
|
||||
///
|
||||
/// This exists because [`leading_data_offset`] derives a leading packet stream's
|
||||
/// start as `first_riff % XMA1_PACKET`, which is only correct when the header is
|
||||
/// SMALLER than one packet. A music bank's header is exactly five packets, so
|
||||
/// the modulus returns 0 and the whole header was being emitted as a sub-wave —
|
||||
/// a third "stem" on a bank the corpus documents as two
|
||||
/// (`docs/re/structures/bgm-two-stems.md`).
|
||||
///
|
||||
/// Disc-wide over `sound.pak`'s 9 519 entries the signature fires on **28**, all
|
||||
/// of them music banks (ids 1001–1023, 1101–1105), and on every one of the 28
|
||||
/// the declared header ends **exactly** at the first `RIFF` — so no bank on this
|
||||
/// disc has both a header at offset 0 and a leading packet stream. Zero false
|
||||
/// positives on the 7 993 mid-bank windows, where the leading region IS real.
|
||||
pub fn bank_header_len(slb: &[u8]) -> Option<usize> {
|
||||
if slb.len() < 0x38 {
|
||||
return None;
|
||||
}
|
||||
if slb[0x18..0x1c] != [0x00, 0x00, 0x08, 0x00] {
|
||||
return None;
|
||||
}
|
||||
if slb[0x00..0x04] != slb[0x20..0x24] {
|
||||
return None;
|
||||
}
|
||||
let blocks = u32::from_be_bytes(slb[0x24..0x28].try_into().ok()?) as usize;
|
||||
blocks.checked_mul(XMA1_PACKET)
|
||||
}
|
||||
|
||||
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let first_riff = find(slb, b"RIFF", 0);
|
||||
@@ -422,7 +455,15 @@ pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
|
||||
// bound to `VOICE_D_453`/`454`, i.e. precisely the broken ones — and
|
||||
// ≤0.25 s to 66 of the rest. Callers clamp to the movie length anyway.
|
||||
if let Some(ri) = first_riff {
|
||||
let start = leading_data_offset(ri);
|
||||
// A bank that carries its OWN header at offset 0 states how long it is,
|
||||
// and on this disc that header always runs right up to the first `RIFF`
|
||||
// — so there is no leading packet stream at all. Without this the
|
||||
// modulus below returns 0 for a 5-packet header and the header itself is
|
||||
// emitted as a sub-wave: `BGM_103.slb` came back as THREE waves against a
|
||||
// census, an executable reference and a runtime XMA probe that all say
|
||||
// two. It decodes to 0.009 s of PCM (the same chain returns 87.744 s for
|
||||
// the bank's real wave 0), and it is 99.1 % zero bytes.
|
||||
let start = bank_header_len(slb).unwrap_or_else(|| leading_data_offset(ri));
|
||||
if ri > start {
|
||||
if let Some(data) = slb.get(start..ri) {
|
||||
if data.iter().any(|b| *b != 0) {
|
||||
|
||||
@@ -111,12 +111,22 @@ pub struct Keyframe {
|
||||
/// starts are negative.
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
/// Keyframe time, or `None` for the group's **last** frame.
|
||||
/// The time at which this pose is reached.
|
||||
///
|
||||
/// A group's data stops 4 bytes short of its final block's time slot — that
|
||||
/// word is already the next group's element index. Reading it anyway is
|
||||
/// where a stray `time = 1869640736` comes from, and it silently corrupts
|
||||
/// the max-dwell pick in [`Element::rest`].
|
||||
/// ✅ Always `Some` since 2026-08-29. A placement group is an 8-byte header
|
||||
/// followed by `frames` records of `{ u32 time; 36-byte pose }`, so the time
|
||||
/// word **precedes** the pose it belongs to. Our block window starts at the
|
||||
/// pose, so pose `k`'s time is the previous stride's `+36` word, and pose
|
||||
/// 0's is the group's lead-in word at `header + 8`.
|
||||
///
|
||||
/// ⚠️ The old reading took `+36` as *this* pose's time. That left the final
|
||||
/// pose — the end of every fade-out — untimed, and it is where the "a
|
||||
/// group's data stops 4 bytes short of its final block's time slot" note and
|
||||
/// the stray `time = 1869640736` both came from. There is no short group and
|
||||
/// no missing word; the association was off by one.
|
||||
/// See `docs/re/ui-keyframe-record-layout.md`.
|
||||
///
|
||||
/// `Option` is retained for the `SYLPHEED_KF_TIME_LEGACY=1` escape hatch.
|
||||
pub time: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -182,6 +192,77 @@ 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.
|
||||
/// The element's pose at keyframe time `t`, linearly interpolated.
|
||||
///
|
||||
/// `rest()` returns the last HOLD keyframe **of one element, chosen
|
||||
/// independently of every other element**. That is the wrong pose twice over:
|
||||
///
|
||||
/// * for anything still moving — the title's light sweeps hold at `x = 1521`,
|
||||
/// off the right edge, so a resting composite deletes them rather than
|
||||
/// settling them;
|
||||
/// * 🔴 and for anything **transient**. `ptlogo_back2eff1` is a two-frame
|
||||
/// flash (`a=0` until t52, `255` at t54–56, `0` again by t58); its last
|
||||
/// hold *is* the flash peak, so `rest()` leaves it burning forever. Five
|
||||
/// such flashes stack on the title and blow the light arc out to pure
|
||||
/// white — see `docs/re/structures/ui-settle-time.md`.
|
||||
///
|
||||
/// A settled screen is one INSTANT that every element is posed at, which is
|
||||
/// what [`UiBuild::settle_time`] computes and `ComposeOptions::at` applies.
|
||||
///
|
||||
/// The ramp is linear (`docs/re/ui-keyframe-time-unit.md`), and a group
|
||||
/// **holds** at its last keyframe rather than looping, so `t` past the end
|
||||
/// clamps.
|
||||
pub fn pose_at(&self, t: u32) -> Option<Keyframe> {
|
||||
let ks = &self.keyframes;
|
||||
if ks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let timed: Vec<(u32, &Keyframe)> =
|
||||
ks.iter().filter_map(|k| k.time.map(|tt| (tt, k))).collect();
|
||||
if timed.is_empty() {
|
||||
return Some(ks[ks.len() - 1].clone());
|
||||
}
|
||||
if t <= timed[0].0 {
|
||||
return Some(timed[0].1.clone());
|
||||
}
|
||||
if t >= timed[timed.len() - 1].0 {
|
||||
return Some(timed[timed.len() - 1].1.clone());
|
||||
}
|
||||
for w in timed.windows(2) {
|
||||
let ((t0, a), (t1, b)) = (w[0], w[1]);
|
||||
if t >= t0 && t <= t1 {
|
||||
if t1 == t0 {
|
||||
return Some(b.clone());
|
||||
}
|
||||
let f = (t - t0) as f64 / (t1 - t0) as f64;
|
||||
let li = |x: i32, y: i32| x + ((y - x) as f64 * f).round() as i32;
|
||||
let lu = |x: u32, y: u32| (x as f64 + (y as f64 - x as f64) * f).round() as u32;
|
||||
// ARGB / RGBA words interpolate per BYTE, not as integers.
|
||||
let lc = |x: u32, y: u32| {
|
||||
let mut o = 0u32;
|
||||
for sh in [24, 16, 8, 0] {
|
||||
let (cx, cy) = ((x >> sh) & 0xff, (y >> sh) & 0xff);
|
||||
o |= (lu(cx, cy) & 0xff) << sh;
|
||||
}
|
||||
o
|
||||
};
|
||||
return Some(Keyframe {
|
||||
fade: lc(a.fade, b.fade),
|
||||
rotation_deg: li(a.rotation_deg, b.rotation_deg),
|
||||
unknown_4: li(a.unknown_4, b.unknown_4),
|
||||
unknown_8: li(a.unknown_8, b.unknown_8),
|
||||
scale_x: lu(a.scale_x, b.scale_x),
|
||||
scale_y: lu(a.scale_y, b.scale_y),
|
||||
tint: lc(a.tint, b.tint),
|
||||
x: li(a.x, b.x),
|
||||
y: li(a.y, b.y),
|
||||
time: Some(t),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(timed[timed.len() - 1].1.clone())
|
||||
}
|
||||
|
||||
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
|
||||
@@ -493,8 +574,10 @@ fn mark_focused_states(elements: &mut [Element]) {
|
||||
/// Read the placement region that follows the declaration table, filling in each
|
||||
/// element's keyframe group.
|
||||
fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||||
// Experiment gate, default off; see the `time` field below.
|
||||
let shift_times = std::env::var("SYLPHEED_KF_TIME_SHIFT").as_deref() == Ok("1");
|
||||
// Escape hatch for the pre-2026-08-29 reading, which mis-associated every
|
||||
// keyframe time by one slot and could not time a group's final pose at all.
|
||||
// See `docs/re/ui-keyframe-record-layout.md`.
|
||||
let legacy_times = std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1");
|
||||
let count = elements.len();
|
||||
let mut order = Vec::with_capacity(count);
|
||||
let mut pos = DECL_TABLE_AT + count * DECL_ENTRY;
|
||||
@@ -507,7 +590,13 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||||
if idx >= count || frames == 0 || frames > 4096 {
|
||||
break;
|
||||
}
|
||||
// Group header is (index, count) then one lead-in word; blocks follow.
|
||||
// The region is `frames` records of 40 bytes, each `{ u32 time; 36-byte
|
||||
// pose }`, after an 8-byte header — so the word at `pos + 8` is the
|
||||
// FIRST pose's time, and each 40-byte stride's `+36` word is the time of
|
||||
// the pose that follows it. Our block window is offset 4 bytes into the
|
||||
// record (it starts at the pose), which is why the pose field offsets
|
||||
// below are right while the times were off by one.
|
||||
let first_time = be32(bundle, pos + 8);
|
||||
let first = pos + 12;
|
||||
// The region is packed so that the next group's header sits 4 bytes
|
||||
// inside the last block — i.e. the group owns `frames * 40 - 4` bytes of
|
||||
@@ -529,16 +618,17 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||||
tint: be32(bundle, blk + 24),
|
||||
x: be32(bundle, blk + 28) as i32,
|
||||
y: be32(bundle, blk + 32) as i32,
|
||||
// Only a block wholly inside the group carries a time.
|
||||
//
|
||||
// ⚠️ Which block a time word BELONGS TO is under test — see
|
||||
// `docs/re/ui-keyframe-time-unit.md`. Set `SYLPHEED_KF_TIME_SHIFT=1`
|
||||
// to read `W[k-1]` as block `k`'s time ("the word is the time the
|
||||
// NEXT pose is reached") instead of `W[k]`. Default is unchanged.
|
||||
time: if shift_times {
|
||||
(k >= 1).then(|| be32(bundle, blk - KEYFRAME + 36))
|
||||
} else {
|
||||
// Pose `k`'s time is the word that PRECEDES it: the group's
|
||||
// lead-in word for `k == 0`, and the previous stride's `+36`
|
||||
// otherwise. Every pose is timed; nothing is missing and nothing
|
||||
// is special-cased. Checked disc-wide — see
|
||||
// `docs/re/ui-keyframe-record-layout.md`.
|
||||
time: if legacy_times {
|
||||
(blk + 40 <= group_end).then(|| be32(bundle, blk + 36))
|
||||
} else if k == 0 {
|
||||
Some(first_time)
|
||||
} else {
|
||||
Some(be32(bundle, blk - KEYFRAME + 36))
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -724,6 +814,12 @@ pub struct ComposeOptions {
|
||||
/// Left on with the derived order, an opaque black quad sorts last and wipes
|
||||
/// 32 of `GP_DIALOG`'s builds.
|
||||
pub include_primitives: bool,
|
||||
/// Pose every element at this keyframe time instead of at its resting pose.
|
||||
///
|
||||
/// `None` keeps the settled composite, which is what every existing caller
|
||||
/// wants. A capture taken mid-animation needs the render posed at the same
|
||||
/// instant — see [`Element::pose_at`].
|
||||
pub at: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for ComposeOptions {
|
||||
@@ -733,6 +829,7 @@ impl Default for ComposeOptions {
|
||||
include_animated: false,
|
||||
backdrop: [14, 14, 20, 255],
|
||||
include_primitives: false,
|
||||
at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,6 +922,99 @@ pub fn implied_layer_key(name: &str) -> Option<u32> {
|
||||
/// they land does not affect a composite. Ties keep declaration order — the game
|
||||
/// breaks them some other way, which is unexplained and looks harmless because
|
||||
/// tied elements are same-layer.
|
||||
/// Is this element an opaque full-screen quad that **must** sort below everything?
|
||||
///
|
||||
/// A keyless primitive has no layer key and the game's own code decides where it
|
||||
/// paints ([`implied_layer_key`] records the names measured in the running game).
|
||||
/// For one whole class of them the file settles it without a measurement: an
|
||||
/// element that covers the screen and is **fully opaque** at some instant cannot
|
||||
/// paint above anything visible at that instant, or the screen would be blank.
|
||||
/// Where the elements visible during its opaque span are *all* of them, its
|
||||
/// position is forced to first.
|
||||
///
|
||||
/// Two controls, both measured in the running game and both reproduced by this
|
||||
/// rule rather than assumed by it:
|
||||
///
|
||||
/// * `palogo_eff0.prm` is measured painting **first** — and comes out forced
|
||||
/// first (opaque for 211 instants, below 6 of 6). A rule keyed on the *name*
|
||||
/// would get this wrong: it is named like an overlay.
|
||||
/// * `pteff00.prm` is measured painting **last** — and is forced below only 3 of
|
||||
/// 23 elements on the title, because it is opaque for 2 instants at the screen's
|
||||
/// entry and exit, so the rule permits it on top.
|
||||
///
|
||||
/// Disc-wide: 80 instances forced first, 50 constrained but not forced, 0
|
||||
/// unconstrained. It also explains the 36 dialog builds that composite to one
|
||||
/// colour — `pzeff00.prm` is forced first in 32 of 32 instances.
|
||||
///
|
||||
/// ⚠️ Assumes straight alpha-over blending. Blend mode is an open question in
|
||||
/// `docs/re/structures/ui-prm-primitives.md`; an *additive* quad at alpha 255
|
||||
/// would not occlude, and this rule would then be placing it wrongly.
|
||||
pub fn forced_backdrop(build: &UiBuild, el: &Element) -> bool {
|
||||
// 🔴 Untextured primitives only. A `.t32` sprite's ELEMENT alpha being 255
|
||||
// says nothing about whether its texture covers the screen — most of it may
|
||||
// be transparent, so it occludes nothing. Applied without this guard the
|
||||
// rule claims 22 textured sprites must sort first, against their own layer
|
||||
// keys: `pneff01.t32` (key 0xd850, paints #8 of 13) and `pbfriendly.t32`
|
||||
// (key 0x9230, #17 of 49). Those disagreements are the rule being wrong,
|
||||
// not the keys.
|
||||
if el.sprite.is_some() {
|
||||
return false;
|
||||
}
|
||||
// The element must have a declared size at all; coverage itself is tested
|
||||
// per instant below, against the SCALED size.
|
||||
if el.pivot_x == 0 || el.pivot_y == 0 {
|
||||
return false;
|
||||
}
|
||||
let tmax = build
|
||||
.elements
|
||||
.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if tmax == 0 {
|
||||
return false;
|
||||
}
|
||||
// An instant counts only where the element is BOTH fully opaque AND actually
|
||||
// covering — tested together, because both animate on the same ramp.
|
||||
//
|
||||
// ⚠️ Coverage is the SCALED size, not the declared one, and the test must be
|
||||
// two-sided. A quad scaled down does not cover what its pivot suggests —
|
||||
// `pbafc.prm` declares 844x600 and draws ~17x18 at 2 %/3 %. And a quad scaled
|
||||
// *up* can cover from a smaller declared size, so rejecting on the declared
|
||||
// size would replace one error with its mirror. Checked before adopting:
|
||||
// across 921 keyless elements, **0** cover the screen only via scale, so the
|
||||
// mirror case does not occur on this disc — the per-instant test is in
|
||||
// because it does not need that to stay true.
|
||||
let (dw, dh) = ((el.pivot_x * 2) as u64, (el.pivot_y * 2) as u64);
|
||||
let opaque: Vec<u32> = (0..=tmax)
|
||||
.filter(|&t| {
|
||||
el.pose_at(t).map_or(false, |k| {
|
||||
k.fade >> 24 == 255
|
||||
&& dw * k.scale_x as u64 / 100 >= build.design_w as u64
|
||||
&& dh * k.scale_y as u64 / 100 >= build.design_h as u64
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if opaque.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut others = 0usize;
|
||||
let mut occluded = 0usize;
|
||||
for o in &build.elements {
|
||||
if o.index == el.index {
|
||||
continue;
|
||||
}
|
||||
others += 1;
|
||||
if opaque
|
||||
.iter()
|
||||
.any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)
|
||||
{
|
||||
occluded += 1;
|
||||
}
|
||||
}
|
||||
others > 0 && occluded == others
|
||||
}
|
||||
|
||||
pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
@@ -832,6 +1022,9 @@ pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
(
|
||||
sprite_layer_key(build, bundle, el)
|
||||
.or_else(|| implied_layer_key(&el.name))
|
||||
// An opaque full-screen quad that covers every other element
|
||||
// while it is opaque cannot be on top — see `forced_backdrop`.
|
||||
.or_else(|| forced_backdrop(build, el).then_some(0))
|
||||
.unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
@@ -900,11 +1093,73 @@ fn measured_paint_order(build: &UiBuild) -> Option<Vec<usize>> {
|
||||
None
|
||||
}
|
||||
|
||||
impl UiBuild {
|
||||
/// The instant at which this screen is *settled*, in keyframe time units.
|
||||
///
|
||||
/// A screen is not settled when each element sits at its own last hold —
|
||||
/// that is what [`Element::rest`] gives, and it is wrong for a transient
|
||||
/// (see the note there). It is settled at one shared instant, and the disc
|
||||
/// says which: gather every keyframe time in the build, and take the
|
||||
/// **longest interval containing none of them**. Inside that gap nothing has
|
||||
/// an inflection, so every element is either holding or on a long linear
|
||||
/// ramp — which is exactly what "the screen has stopped changing" means.
|
||||
///
|
||||
/// Returns the midpoint of that gap, or `None` when the build has fewer than
|
||||
/// two distinct keyframe times.
|
||||
///
|
||||
/// ⚠️ **Not every bundle has a settled instant.** Disc-wide over the 1 758
|
||||
/// composable bundles carrying two or more keyframe times, 30 % have a gap of
|
||||
/// at least half a second and 42 % have one under 10 units — the latter are
|
||||
/// mostly `loop*` animation fragments, which are *meant* to be in motion and
|
||||
/// have no settled pose to find. Check the gap width before trusting the
|
||||
/// midpoint; `settle_window` returns it.
|
||||
pub fn settle_time(&self) -> Option<u32> {
|
||||
self.settle_window().map(|(lo, hi)| lo + (hi - lo) / 2)
|
||||
}
|
||||
|
||||
/// The `[start, end]` of the longest keyframe-free interval — see
|
||||
/// [`UiBuild::settle_time`]. The width `end - start` is how much confidence
|
||||
/// the midpoint deserves.
|
||||
pub fn settle_window(&self) -> Option<(u32, u32)> {
|
||||
let mut ts: Vec<u32> = self
|
||||
.elements
|
||||
.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
|
||||
.collect();
|
||||
ts.sort_unstable();
|
||||
ts.dedup();
|
||||
if ts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
ts.windows(2)
|
||||
.map(|w| (w[1] - w[0], w[0], w[1]))
|
||||
.max_by_key(|&(d, _, _)| d)
|
||||
.map(|(_, lo, hi)| (lo, hi))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compose(
|
||||
build: &UiBuild,
|
||||
bundle: &[u8],
|
||||
opts: ComposeOptions,
|
||||
visible: Option<&[bool]>,
|
||||
) -> ComposedScreen {
|
||||
compose_with_order(build, bundle, opts, visible, None)
|
||||
}
|
||||
|
||||
/// `compose`, with the paint order supplied by the caller.
|
||||
///
|
||||
/// The only reason this exists is to *measure* what a paint order costs: render
|
||||
/// a screen twice, once with the order `compose` would pick and once with two
|
||||
/// elements swapped, and diff the pixels. `order` is a permutation of element
|
||||
/// indices, first painted first; `None` means "whatever `compose` would use".
|
||||
/// Nothing in the normal render path passes anything but `None`.
|
||||
pub fn compose_with_order(
|
||||
build: &UiBuild,
|
||||
bundle: &[u8],
|
||||
opts: ComposeOptions,
|
||||
visible: Option<&[bool]>,
|
||||
order_override: Option<&[usize]>,
|
||||
) -> ComposedScreen {
|
||||
let (w, h) = (build.design_w, build.design_h);
|
||||
// A dim backdrop stands in for the PRMD dim-quad + the live 3D scene behind
|
||||
@@ -928,8 +1183,10 @@ pub fn compose(
|
||||
// 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));
|
||||
let order: Vec<usize> = match order_override {
|
||||
Some(o) => o.to_vec(),
|
||||
None => measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle)),
|
||||
};
|
||||
for &ei in &order {
|
||||
let Some(el) = build.elements.get(ei) else {
|
||||
continue;
|
||||
@@ -967,7 +1224,23 @@ pub fn compose(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(kf) = el.rest() else { continue };
|
||||
// 🔴 `at` poses LEAVES ONLY, never the top-level elements.
|
||||
//
|
||||
// Posing everything at one global time was tried and is wrong: a
|
||||
// top-level group's final keyframes are its **exit ramp** — the fade-out
|
||||
// played when the screen leaves — and `rest()` deliberately stops at the
|
||||
// last *hold* keyframe before it. Posing the title at t=358 walked every
|
||||
// parent into its exit and drove the render's disagreement with the
|
||||
// capture from 10.92 to 61.74. The leaf is the thing still animating at
|
||||
// that instant, and it runs on its own timeline
|
||||
// (`docs/re/structures/ui-leaf-vs-parent-alpha.md`).
|
||||
let Some(kf) = (match opts.at {
|
||||
Some(t) => el.pose_at(t),
|
||||
None => el.rest().cloned(),
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let kf = &kf;
|
||||
// An untextured primitive: a solid quad of the keyframe's `fade` colour,
|
||||
// sized by the declared pivot. `kind & 0x10` marks these exactly — see
|
||||
// `docs/re/structures/ui-prm-primitives.md`. They are the screen's
|
||||
@@ -999,6 +1272,65 @@ pub fn compose(
|
||||
missing.push(sprite.clone());
|
||||
continue;
|
||||
};
|
||||
// A nested `.rat` leaf sometimes carries the geometry while the parent
|
||||
// carries none — the title's light sweeps are the case: the parent sits
|
||||
// fixed at (441,270) scale 100 %, and the leaf holds the 600 %/800 %
|
||||
// scale, the +30°/−45° rotation and the whole sweep. Drawing the parent
|
||||
// put the sprite upright in the middle of the screen.
|
||||
//
|
||||
// ⚠️ NOT a blanket rule. A button's base record has a leaf that
|
||||
// DUPLICATES it, and there the parent wins
|
||||
// (`docs/re/structures/ui-button-focus-record.md`). The discriminator is
|
||||
// which record actually carries geometry, so the leaf is used only when
|
||||
// its pose genuinely differs — see `ui-leaf-vs-parent-alpha.md`.
|
||||
let leaf = build.records.get(&el.name).and_then(|&(lo, ls)| {
|
||||
if lo + ls > bundle.len() {
|
||||
return None;
|
||||
}
|
||||
let lb = parse_build(&bundle[lo..lo + ls])?;
|
||||
let pose = |e: &Element| match opts.at {
|
||||
Some(t) => e.pose_at(t),
|
||||
None => e.rest().cloned(),
|
||||
};
|
||||
let differs = lb.elements.iter().any(|le| {
|
||||
pose(le).map_or(false, |lk| {
|
||||
lk.rotation_deg != 0 || lk.scale_x != kf.scale_x || lk.scale_y != kf.scale_y
|
||||
})
|
||||
});
|
||||
if differs { Some(lb) } else { None }
|
||||
});
|
||||
if let Some(lb) = leaf {
|
||||
let mut any = false;
|
||||
for le in &lb.elements {
|
||||
let lk = match opts.at {
|
||||
Some(t) => le.pose_at(t),
|
||||
None => le.rest().cloned(),
|
||||
};
|
||||
let Some(lk) = lk else { continue };
|
||||
// A leaf element resolves no sprite of its own: sprite names are
|
||||
// resolved against the bundle a build was parsed from, and a leaf
|
||||
// is parsed from its own slice. Its NAME is the sprite name, and
|
||||
// the sprite itself lives in the PARENT bundle's table.
|
||||
let lsp = le.sprite.clone().unwrap_or_else(|| le.name.clone());
|
||||
let Some(&(so, ss)) = build.sprites.get(&lsp) else { continue };
|
||||
let Some(limg) = t8ad::parse(&bundle[so..so + ss]) else { continue };
|
||||
// 🔴 Only count it as drawn if it CAN draw. `blit` returns early
|
||||
// on a zero scale — "collapsed to nothing", not "unset" — so
|
||||
// setting the flag unconditionally would let a scale-0 leaf
|
||||
// suppress its parent and blank the element outright.
|
||||
// `pgloading_loop5`'s leaf is scale (0,0), and scale-0 is one of
|
||||
// the failures this corpus is already named for.
|
||||
if lk.scale_x == 0 || lk.scale_y == 0 {
|
||||
continue;
|
||||
}
|
||||
blit(&mut canvas, w, h, &limg, &lk, le.pivot_x, le.pivot_y);
|
||||
any = true;
|
||||
}
|
||||
if any {
|
||||
drawn.push(el.index);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y);
|
||||
drawn.push(el.index);
|
||||
}
|
||||
@@ -1117,6 +1449,11 @@ fn blit(
|
||||
// Keep the pivot point fixed as the element scales.
|
||||
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
|
||||
let oy = kf.y - (pivot_y as i32 * (sy_pct as i32 - 100)) / 100;
|
||||
// The pivot's ABSOLUTE position is invariant under scale, which is the whole
|
||||
// point of the two lines above: at 100 % `ox = kf.x` so the pivot sits at
|
||||
// `kf.x + pivot_x`; at 200 % `ox = kf.x - pivot_x` and the pivot sits at
|
||||
// `ox + 2·pivot_x`, the same place. So rotation turns about it.
|
||||
let (pax, pay) = (kf.x + pivot_x as i32, kf.y + pivot_y as i32);
|
||||
// Two modulate colours multiply into one: `tint` (RGBA, and `0xffffffff` on
|
||||
// essentially every keyframe seen) and `fade` (**ARGB** — the high byte is
|
||||
// the alpha that ramps, the low 24 bits a colour multiply that is `0xffffff`
|
||||
@@ -1135,6 +1472,73 @@ fn blit(
|
||||
((kf.tint >> 8) & 0xff) * fb / 255,
|
||||
(kf.tint & 0xff) * fa / 255,
|
||||
);
|
||||
// ---- rotated path -------------------------------------------------------
|
||||
// `+12` is a screen-plane rotation in DEGREES, clockwise-positive with Y
|
||||
// down (`docs/re/structures/ui-keyframe-rotation.md`). The game submits
|
||||
// rotated quads for it; this used to draw them axis-aligned, which put the
|
||||
// title's two light sweeps upright instead of at +30° / −45° and left at
|
||||
// least two thirds of that screen's disagreement with the capture
|
||||
// (`title-residual-tone-vs-geometry.md`).
|
||||
//
|
||||
// Zero rotation keeps the original forward-mapped path byte for byte, so
|
||||
// the screens that do not rotate cannot regress. A rotated element is drawn
|
||||
// by INVERSE mapping instead: forward-mapping a rotation leaves gaps.
|
||||
let rot = ((kf.rotation_deg % 360) + 360) % 360;
|
||||
if rot != 0 {
|
||||
let th = (rot as f64).to_radians();
|
||||
let (cs, sn) = (th.cos(), th.sin());
|
||||
// Axis-aligned bounds of the rotated destination rect.
|
||||
let corners = [
|
||||
(ox, oy),
|
||||
(ox + dw as i32, oy),
|
||||
(ox + dw as i32, oy + dh as i32),
|
||||
(ox, oy + dh as i32),
|
||||
];
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||||
for (cx, cy) in corners {
|
||||
let (rx, ry) = ((cx - pax) as f64, (cy - pay) as f64);
|
||||
let px = pax as f64 + rx * cs - ry * sn;
|
||||
let py = pay as f64 + rx * sn + ry * cs;
|
||||
x0 = x0.min(px.floor() as i32);
|
||||
y0 = y0.min(py.floor() as i32);
|
||||
x1 = x1.max(px.ceil() as i32);
|
||||
y1 = y1.max(py.ceil() as i32);
|
||||
}
|
||||
for ty in y0.max(0)..=y1.min(ch as i32 - 1) {
|
||||
for tx in x0.max(0)..=x1.min(cw as i32 - 1) {
|
||||
// Rotate the destination pixel BACK to find its source pixel.
|
||||
let (rx, ry) = ((tx - pax) as f64 + 0.5, (ty - pay) as f64 + 0.5);
|
||||
let ux = rx * cs + ry * sn;
|
||||
let uy = -rx * sn + ry * cs;
|
||||
let dx = ux + (pax - ox) as f64;
|
||||
let dy = uy + (pay - oy) as f64;
|
||||
if dx < 0.0 || dy < 0.0 || dx >= dw as f64 || dy >= dh as f64 {
|
||||
continue;
|
||||
}
|
||||
let sxi = ((dx as u32) * sw / dw).min(sw - 1);
|
||||
let syi = ((dy as u32) * sh / dh).min(sh - 1);
|
||||
let si = ((syi * sw + sxi) * 4) as usize;
|
||||
if si + 3 >= img.rgba.len() {
|
||||
continue;
|
||||
}
|
||||
let sr = img.rgba[si] as u32 * tr / 255;
|
||||
let sg = img.rgba[si + 1] as u32 * tg / 255;
|
||||
let sb = img.rgba[si + 2] as u32 * tb / 255;
|
||||
let sa = img.rgba[si + 3] as u32 * ta / 255;
|
||||
if sa == 0 {
|
||||
continue;
|
||||
}
|
||||
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
|
||||
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;
|
||||
}
|
||||
canvas[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ---- unrotated path (unchanged) -----------------------------------------
|
||||
for row in 0..dh {
|
||||
let ty = oy + row as i32;
|
||||
if ty < 0 {
|
||||
@@ -1182,6 +1586,70 @@ fn blit(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A solid opaque rectangle sprite, for exercising `blit` geometry.
|
||||
fn solid(w: u32, h: u32) -> t8ad::T8adImage {
|
||||
t8ad::T8adImage { width: w, height: h, rgba: vec![255u8; (w * h * 4) as usize],
|
||||
flags: 0 }
|
||||
}
|
||||
fn kf_at(x: i32, y: i32, rot: i32) -> Keyframe {
|
||||
Keyframe { fade: 0xff_ff_ff_ff, rotation_deg: rot, unknown_4: 0, unknown_8: 0,
|
||||
scale_x: 100, scale_y: 100, tint: 0xffff_ffff, x, y, time: Some(0) }
|
||||
}
|
||||
fn draw(img: &t8ad::T8adImage, kf: &Keyframe, px: u32, py: u32) -> Vec<u8> {
|
||||
let mut c = vec![0u8; 64 * 64 * 4];
|
||||
blit(&mut c, 64, 64, img, kf, px, py);
|
||||
c
|
||||
}
|
||||
fn covered(c: &[u8]) -> Vec<(i32, i32)> {
|
||||
let mut v = Vec::new();
|
||||
for y in 0..64 { for x in 0..64 {
|
||||
if c[((y * 64 + x) * 4 + 3) as usize] != 0 { v.push((x as i32, y as i32)); }
|
||||
}}
|
||||
v
|
||||
}
|
||||
|
||||
/// 🔴 CONTROL for the rotated path. An estimator that is wrong on a known
|
||||
/// angle cannot be trusted on an unknown one, so the rotated blit is pinned
|
||||
/// against angles whose answer is arithmetic rather than measured.
|
||||
#[test]
|
||||
fn rotation_control_known_angles() {
|
||||
let img = solid(10, 4);
|
||||
// pivot at the sprite's centre, so rotation turns in place
|
||||
let (px, py) = (5u32, 2u32);
|
||||
let base = draw(&img, &kf_at(20, 30, 0), px, py);
|
||||
|
||||
// 0° and 360° must be identical to the unrotated path, byte for byte:
|
||||
// the fast path must be exactly the old behaviour.
|
||||
assert_eq!(base, draw(&img, &kf_at(20, 30, 360), px, py),
|
||||
"360 degrees must equal the unrotated path exactly");
|
||||
|
||||
// 90° must turn a 10x4 into a 4x10 about the same centre.
|
||||
let r90 = covered(&draw(&img, &kf_at(20, 30, 90), px, py));
|
||||
let b = covered(&base);
|
||||
let bw = b.iter().map(|p| p.0).max().unwrap() - b.iter().map(|p| p.0).min().unwrap();
|
||||
let bh = b.iter().map(|p| p.1).max().unwrap() - b.iter().map(|p| p.1).min().unwrap();
|
||||
let rw = r90.iter().map(|p| p.0).max().unwrap() - r90.iter().map(|p| p.0).min().unwrap();
|
||||
let rh = r90.iter().map(|p| p.1).max().unwrap() - r90.iter().map(|p| p.1).min().unwrap();
|
||||
assert_eq!((bw, bh), (9, 3), "unrotated extent");
|
||||
assert_eq!((rw, rh), (3, 9), "90 degrees must swap the extents");
|
||||
|
||||
// The covered area must be conserved to a few percent -- a rotation that
|
||||
// loses or invents pixels is the forward-mapping bug this path avoids.
|
||||
let (a0, a90) = (b.len() as f64, r90.len() as f64);
|
||||
assert!((a0 - a90).abs() / a0 < 0.15,
|
||||
"area changed too much under rotation: {a0} -> {a90}");
|
||||
|
||||
// And the centroid must stay on the pivot.
|
||||
let cen = |v: &Vec<(i32, i32)>| {
|
||||
let n = v.len() as f64;
|
||||
(v.iter().map(|p| p.0 as f64).sum::<f64>() / n,
|
||||
v.iter().map(|p| p.1 as f64).sum::<f64>() / n)
|
||||
};
|
||||
let (c0, c9) = (cen(&b), cen(&r90));
|
||||
assert!((c0.0 - c9.0).abs() < 1.0 && (c0.1 - c9.1).abs() < 1.0,
|
||||
"rotation moved the centroid: {c0:?} -> {c9:?}");
|
||||
}
|
||||
|
||||
/// A synthetic build bundle: RATC magic, entry count at 0x14, a declaration
|
||||
/// table at 0x20, then a placement region.
|
||||
fn synth_build(decls: &[(&str, u32, u32, u32, u32)], groups: &[(usize, Vec<Keyframe>)]) -> Vec<u8> {
|
||||
|
||||
@@ -86,3 +86,39 @@ fn manifest_binding_is_the_only_route() {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// The resolved `ADV` voice region must contain **all three** streams the running
|
||||
/// decoder decodes — not a truncated first one.
|
||||
///
|
||||
/// Ground truth is the emulator, not this crate: booting with `--xma_param_probe`
|
||||
/// reports three XMA contexts with `byte_size` 1 294 336 / 1 118 208 / 1 171 456
|
||||
/// (`docs/re/structures/voice-three-streams-are-concurrent.md`). Until 2026-08-30
|
||||
/// the resolver's start filter capped a region at 1.5 MB, `ADV`'s span is 3.6 MB,
|
||||
/// so the start fell back to `anchor` — a TOC offset, 238 packets into the first
|
||||
/// stream — and this returned 806 912 for the first chunk.
|
||||
///
|
||||
/// This is a regression test against an EXTERNAL measurement, which is the only
|
||||
/// kind that can catch the class of bug it was written for: every internal check
|
||||
/// passed happily while a third of a stream was missing.
|
||||
#[test]
|
||||
fn adv_voice_region_holds_all_three_decoded_streams() {
|
||||
let Some(src) = disc() else {
|
||||
eprintln!("SKIP: set SYLPHEED_DISC");
|
||||
return;
|
||||
};
|
||||
let (start, end) = media::resolve_movie_voice_region(&src, "ADV", VoiceLang::English)
|
||||
.expect("ADV voice region");
|
||||
let bytes = src
|
||||
.read_segment_range("dat/sound", start, (end - start) as usize)
|
||||
.expect("region bytes");
|
||||
let sizes: Vec<usize> = sylpheed_formats::slb::to_xma_riffs(&bytes)
|
||||
.iter()
|
||||
.map(|r| r.len() - 60)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sizes,
|
||||
vec![1_294_336, 1_118_208, 1_171_456],
|
||||
"the region must reproduce the RUNNING DECODER's byte_sizes; \
|
||||
a first chunk of 806912 means the start filter has come back"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,3 +269,62 @@ fn a_waves_declared_size_is_confirmed_by_the_next_seek() {
|
||||
assert!(checked >= 30, "expected banks to check, got {checked}");
|
||||
eprintln!("wave-boundary identity held for {checked} banks");
|
||||
}
|
||||
|
||||
/// A **music** bank has no leading segment — the bytes before its first `RIFF`
|
||||
/// are the bank header, and emitting them made `BGM_103` look like three stems.
|
||||
///
|
||||
/// The header sizes itself (`+0x24`, in 2048-byte blocks), and on every bank on
|
||||
/// this disc that size lands exactly on the first `RIFF`. So the guard is not a
|
||||
/// heuristic and has no threshold: if a bank states a header, believe it.
|
||||
#[test]
|
||||
fn a_bank_that_states_its_own_header_has_no_leading_segment() {
|
||||
skip_without_disc!(root);
|
||||
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
|
||||
let mut with_header = 0usize;
|
||||
let mut mid_bank = 0usize;
|
||||
// Peek at the 56-byte header through the archive's flat data rather than
|
||||
// decompressing 9 519 entries: `sound.pak` stores them uncompressed, and a
|
||||
// full read of all of them is several GB (it OOM-killed the test runner).
|
||||
for entry in snd.entries() {
|
||||
let Some(head) = snd.data_at(entry.offset as usize, 0x38) else { continue };
|
||||
match slb::bank_header_len(head) {
|
||||
Some(h) => {
|
||||
let b = snd.read(entry).expect("read a bank that states a header");
|
||||
let ri = b.windows(4).position(|w| w == b"RIFF").expect("has a RIFF");
|
||||
// Declared header ends exactly at the first RIFF: no gap, so
|
||||
// nothing before it can be a packet stream.
|
||||
assert_eq!(h, ri, "a bank header that does not end at its first RIFF");
|
||||
with_header += 1;
|
||||
}
|
||||
None => mid_bank += 1,
|
||||
}
|
||||
}
|
||||
// 28 music banks (ids 1001-1023, 1101-1105); the rest are mid-bank windows,
|
||||
// where the leading region IS real and must keep being emitted.
|
||||
assert_eq!(with_header, 28, "banks stating their own header at offset 0");
|
||||
assert!(mid_bank > 9000, "mid-bank windows, got {mid_bank}");
|
||||
eprintln!("{with_header} banks state a header; {mid_bank} mid-bank windows");
|
||||
}
|
||||
|
||||
/// The regression itself: the menu's music bank is **two** sub-waves, and they
|
||||
/// are the two the corpus names — matching the executable's `BGM_103` and the
|
||||
/// two streams the runtime XMA probe saw at the main menu.
|
||||
#[test]
|
||||
fn the_menu_music_bank_is_exactly_two_sub_waves() {
|
||||
skip_without_disc!(root);
|
||||
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
|
||||
for (name, sizes) in [
|
||||
("BGM_103.slb", [3_876_864usize, 3_930_112]),
|
||||
("BGM_001.slb", [4_466_688, 4_673_536]),
|
||||
] {
|
||||
let entry = snd.find_by_name(name).expect("bank present");
|
||||
let b = snd.read(entry).expect("read");
|
||||
let riffs = slb::to_xma_riffs(&b);
|
||||
assert_eq!(riffs.len(), 2, "{name}: sub-wave count");
|
||||
for (r, want) in riffs.iter().zip(sizes) {
|
||||
let di = r.windows(4).position(|w| w == b"data").expect("data chunk");
|
||||
let got = u32::from_le_bytes(r[di + 4..di + 8].try_into().unwrap()) as usize;
|
||||
assert_eq!(got, want, "{name}: sub-wave payload size");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
197
crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs
Normal file
197
crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! An opaque full-screen primitive cannot paint above what it would hide.
|
||||
//!
|
||||
//! A keyless primitive has no layer key, and `implied_layer_key` records the
|
||||
//! handful whose position was measured in the running game. For one class the
|
||||
//! file settles it without a measurement: an element covering the screen and
|
||||
//! fully opaque at some instant cannot paint above anything visible then, or the
|
||||
//! screen is blank. Where that set is *every* other element, the position is
|
||||
//! forced to first.
|
||||
//!
|
||||
//! The port found this by contradiction on `build_12`/`build_15`, which its
|
||||
//! renderer composited to solid black at every instant of their declared life.
|
||||
//!
|
||||
//! Argument, census and reach: `docs/re/structures/ui-forced-backdrop.md`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
|
||||
p.join("dat").is_dir().then_some(p)
|
||||
}
|
||||
|
||||
fn build(ar: &PakArchive, i: usize) -> (Vec<u8>, ui_layout::UiBuild) {
|
||||
let by = ar.read(&ar.entries()[i]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("parse");
|
||||
(by, b)
|
||||
}
|
||||
|
||||
fn el<'a>(b: &'a ui_layout::UiBuild, name: &str) -> &'a ui_layout::Element {
|
||||
b.elements.iter().find(|e| e.name == name).expect(name)
|
||||
}
|
||||
|
||||
/// The two controls are measured orders from the running game. The rule has to
|
||||
/// reproduce one and permit the other, or it is not measuring occlusion.
|
||||
#[test]
|
||||
fn the_rule_reproduces_both_measured_primitives() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
|
||||
// `palogo_eff0.prm` is MEASURED painting first. Named like an overlay, so a
|
||||
// name-based rule gets it wrong; occlusion gets it right.
|
||||
let (_, splash) = build(&ar, 11);
|
||||
assert!(
|
||||
ui_layout::forced_backdrop(&splash, el(&splash, "palogo_eff0.prm")),
|
||||
"the developer splash's backdrop is measured FIRST and must come out forced"
|
||||
);
|
||||
|
||||
// `pteff00.prm` is MEASURED painting last. It is opaque only at its screen's
|
||||
// entry and exit, so the rule must NOT force it down.
|
||||
for entry in [4usize, 5] {
|
||||
let (_, b) = build(&ar, entry);
|
||||
assert!(
|
||||
!ui_layout::forced_backdrop(&b, el(&b, "pteff00.prm")),
|
||||
"entry {entry}: pteff00.prm is measured painting LAST and must stay permitted on top"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The case that prompted it: the loading screens.
|
||||
#[test]
|
||||
fn the_loading_screens_backdrop_sorts_first() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
for entry in [12usize, 15] {
|
||||
let (by, b) = build(&ar, entry);
|
||||
let prim = el(&b, "pgloading_eff00.prm");
|
||||
assert!(ui_layout::forced_backdrop(&b, prim), "entry {entry}");
|
||||
let order = ui_layout::derived_paint_order(&b, &by);
|
||||
assert_eq!(
|
||||
order.first().copied(),
|
||||
Some(prim.index),
|
||||
"entry {entry}: the backdrop must be painted first, not last"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 The rule quantifies over "every instant the primitive is opaque" and "every
|
||||
/// element visible then", so both halves depend on where the timeline ends and on
|
||||
/// what an element does after its own last keyframe. **The hold is not a
|
||||
/// convenience: a measured order requires it.**
|
||||
///
|
||||
/// `palogo_eff0.prm` is a SINGLE keyframe at t=0. If an element counted as *gone*
|
||||
/// after its last keyframe, the splash's backdrop would exist for one instant, no
|
||||
/// other element would be up yet, and the rule would call it free — against the
|
||||
/// order measured in the running game, which paints it first.
|
||||
///
|
||||
/// Disc-wide the choice decides **72 of 130** verdicts, so this is the load-bearing
|
||||
/// half of the rule. (Using the header's declared `+0x08` as the span instead of
|
||||
/// the elements' maximum changes **0**.)
|
||||
#[test]
|
||||
fn the_hold_after_a_final_keyframe_is_required_by_a_measured_order() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
for entry in [10usize, 11] {
|
||||
let (_, b) = build(&ar, entry);
|
||||
let prim = el(&b, "palogo_eff0.prm");
|
||||
assert_eq!(prim.keyframes.len(), 1, "entry {entry}: the case rests on it being static");
|
||||
|
||||
// With the hold — what `pose_at` does, and what the game does.
|
||||
assert!(
|
||||
ui_layout::forced_backdrop(&b, prim),
|
||||
"entry {entry}: measured painting FIRST, so the rule must force it"
|
||||
);
|
||||
|
||||
// Without it, spelled out here rather than imported, so the test states
|
||||
// the counterfactual it is pinning.
|
||||
let tmax = b.elements.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).max().unwrap_or(0);
|
||||
let last = |e: &ui_layout::Element| e.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0);
|
||||
let alpha_no_hold = |e: &ui_layout::Element, t: u32| -> u32 {
|
||||
if t > last(e) { 0 } else { e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) }
|
||||
};
|
||||
let opaque: Vec<u32> = (0..=tmax).filter(|&t| alpha_no_hold(prim, t) == 255).collect();
|
||||
let others: Vec<_> = b.elements.iter().filter(|o| o.index != prim.index).collect();
|
||||
let below = others.iter()
|
||||
.filter(|o| opaque.iter().any(|&t| alpha_no_hold(o, t) > 0)).count();
|
||||
assert_ne!(
|
||||
below, others.len(),
|
||||
"entry {entry}: without the hold this element would come out FREE — which is \
|
||||
why the hold is load-bearing rather than incidental"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Disc-wide: the rule must fire on a real population and never on something it
|
||||
/// cannot occlude.
|
||||
#[test]
|
||||
fn forced_backdrops_are_full_screen_and_plentiful() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
let (mut forced, mut prm, mut tbm) = (0usize, 0usize, 0usize);
|
||||
for p in &paks {
|
||||
let Ok(ar) = PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for element in &b.elements {
|
||||
if !ui_layout::forced_backdrop(&b, element) {
|
||||
continue;
|
||||
}
|
||||
forced += 1;
|
||||
if element.name.ends_with(".prm") { prm += 1 }
|
||||
else if element.name.ends_with(".tbm") { tbm += 1 }
|
||||
// 🔴 Untextured only. This assertion caught the rule's real
|
||||
// limit: applied to `.t32` sprites it claimed 22 of them must
|
||||
// sort first, against their own layer keys — a sprite's element
|
||||
// alpha says nothing about its texture's coverage.
|
||||
assert!(
|
||||
element.sprite.is_none(),
|
||||
"{}: a textured sprite cannot be judged to occlude by element alpha",
|
||||
element.name
|
||||
);
|
||||
assert!(
|
||||
element.pivot_x * 2 >= b.design_w as u32
|
||||
&& element.pivot_y * 2 >= b.design_h as u32,
|
||||
"{}: a quad that does not cover the screen cannot occlude it",
|
||||
element.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(forced > 50, "expected a real population, got {forced}");
|
||||
eprintln!("{forced} keyless primitives have their position forced to first");
|
||||
|
||||
// 🔴 Pin the split, so anyone tightening this rule sees what it would cost.
|
||||
// Only the `.prm` half is DECODED: a solid colour quad's fade IS its pixel, so
|
||||
// opacity and coverage are the same fact. Every `.tbm` in the set carries fade
|
||||
// `ffffffff` — a white SOLID quad painted first would make the screen white, so
|
||||
// they are textured, and element alpha does not establish their coverage.
|
||||
// Their verdicts are kept because restricting to `.prm` would send eleven
|
||||
// screens' backgrounds back to last, which is the bug this rule fixed.
|
||||
assert!(prm >= 40 && tbm >= 30,
|
||||
"expected roughly 42 .prm / 38 .tbm forced instances, got {prm} / {tbm} — \
|
||||
if this moved, re-read the self-refutation section of ui-forced-backdrop.md");
|
||||
}
|
||||
@@ -120,8 +120,17 @@ fn header_0x08_against_the_keyframe_times() {
|
||||
worst.push(format!("{pak}: max keyframe {max_time} > header {dur}"));
|
||||
}
|
||||
}
|
||||
let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32;
|
||||
*ratio.entry(r.min(30)).or_default() += 1;
|
||||
// ⚠️ Bundles whose every group is a single static pose contribute
|
||||
// `max_time == 0` and say nothing about whether `+0x08` is a length.
|
||||
// Before 2026-08-29 they were invisible here, because the old keyframe
|
||||
// time reading left a one-frame group's only pose untimed; the corrected
|
||||
// record layout (`docs/re/ui-keyframe-record-layout.md`) gives it the
|
||||
// group's lead-in time, which is 0. They are excluded rather than
|
||||
// allowed to swamp the histogram's zero bucket — 546 of them do.
|
||||
if max_time > 0 {
|
||||
let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32;
|
||||
*ratio.entry(r.min(30)).or_default() += 1;
|
||||
}
|
||||
});
|
||||
|
||||
eprintln!("bundles with keyframe times and a non-zero +0x08: {animated}");
|
||||
@@ -142,11 +151,18 @@ fn header_0x08_against_the_keyframe_times() {
|
||||
|
||||
assert!(animated > 0, "no animated bundles — the sweep is broken");
|
||||
|
||||
// MEASURED 2026-08-24. +0x08 bounds the keyframe times in EVERY one of the
|
||||
// 2313 bundles that have both, and 444 of them reach it exactly. The
|
||||
// MEASURED 2026-08-24, re-measured 2026-08-29 under the corrected keyframe
|
||||
// record layout. +0x08 bounds the keyframe times in EVERY one of the 2 859
|
||||
// bundles that have both (2 313 before the correction, which could not time
|
||||
// a group's final pose at all), and 444 of them reach it exactly. The
|
||||
// spread-out ratio histogram is what rules out the boring explanation: a
|
||||
// large unrelated constant would bound everything too, but then the ratios
|
||||
// would pile up near zero instead of peaking at 1.0.
|
||||
//
|
||||
// ✅ The correction STRENGTHENS this result rather than weakening it: 546
|
||||
// more bundles now carry a readable last-pose time, and `over` is still 0 —
|
||||
// i.e. the newly-visible times, which are the LATEST in every group, still
|
||||
// do not run past the header's.
|
||||
assert_eq!(over, 0, "a keyframe time runs past the header's +0x08");
|
||||
assert!(exact > 400, "the bound is never attained — it may be unrelated");
|
||||
let near_one = ratio.get(&10).copied().unwrap_or(0);
|
||||
|
||||
204
crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs
Normal file
204
crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! A placement group's time word **precedes** the pose it belongs to.
|
||||
//!
|
||||
//! A group is an 8-byte header `{u32 element_index, u32 frame_count}` followed
|
||||
//! by `frame_count` records of 40 bytes, each `{u32 time; 36-byte pose}`. The
|
||||
//! parser's block window starts at the *pose*, four bytes into the record, so
|
||||
//! the word at a block's `+36` is the time of the pose that FOLLOWS it, and the
|
||||
//! first pose's time is the group's lead-in word at `header + 8`.
|
||||
//!
|
||||
//! The old reading took `+36` as the block's own time. That is off by one, and
|
||||
//! it is where two long-standing oddities came from: the group looked four bytes
|
||||
//! short, and the final pose — the end of every fade-out — carried no time.
|
||||
//!
|
||||
//! Full argument and disc-wide census: `docs/re/ui-keyframe-record-layout.md`.
|
||||
//!
|
||||
//! Two checks here, both disc-wide:
|
||||
//!
|
||||
//! 1. **Every pose is timed, and the times are non-decreasing.** Under the old
|
||||
//! reading the last pose has no time at all, so this cannot even be asked.
|
||||
//! 2. **A monotone alpha ramp of three or more segments runs at a constant
|
||||
//! rate.** Interpolation between keyframes is linear
|
||||
//! (`docs/re/ui-keyframe-time-unit.md`), so a correct time assignment makes
|
||||
//! multi-keyframe ramps come out at a constant d(alpha)/d(time). The old
|
||||
//! reading achieves this on **zero** ramps on the whole disc.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
||||
let p = PathBuf::from(p);
|
||||
if p.join("dat").is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let default = Path::new(
|
||||
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
|
||||
);
|
||||
if default.join("dat").is_dir() {
|
||||
return Some(default.to_path_buf());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) {
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
for p in &paks {
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
let Ok(arc) = PakArchive::open(p) else { continue };
|
||||
for e in arc.entries() {
|
||||
let Ok(bytes) = arc.read(e) else { continue };
|
||||
if ratc::is_ratc(&bytes) {
|
||||
f(&name, &bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximal runs of strictly monotone alpha with at least `min_seg` segments.
|
||||
fn monotone_ramps(alphas: &[i32], min_seg: usize) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let (mut i, n) = (0usize, alphas.len());
|
||||
while i + 1 < n {
|
||||
if alphas[i] == alphas[i + 1] {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let up = alphas[i + 1] > alphas[i];
|
||||
let mut j = i + 1;
|
||||
while j + 1 < n && ((alphas[j + 1] > alphas[j]) == up) && alphas[j + 1] != alphas[j] {
|
||||
j += 1;
|
||||
}
|
||||
if j - i >= min_seg {
|
||||
out.push((i, j));
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn constant_rate(times: &[u32], alphas: &[i32], a: usize, b: usize) -> Option<bool> {
|
||||
let mut rates = Vec::new();
|
||||
for k in a..b {
|
||||
let dt = times[k + 1].checked_sub(times[k])?;
|
||||
if dt == 0 {
|
||||
return None;
|
||||
}
|
||||
rates.push((alphas[k + 1] - alphas[k]).unsigned_abs() as f64 / dt as f64);
|
||||
}
|
||||
let mean = rates.iter().sum::<f64>() / rates.len() as f64;
|
||||
if mean == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let worst = rates.iter().map(|r| (r - mean).abs()).fold(0.0, f64::max);
|
||||
Some(worst / mean <= 0.06)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_pose_is_timed_and_the_times_are_ordered() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||
return;
|
||||
};
|
||||
if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") {
|
||||
eprintln!("SKIP: the legacy time reading is selected");
|
||||
return;
|
||||
}
|
||||
|
||||
let (mut groups, mut untimed, mut out_of_order) = (0usize, 0usize, 0usize);
|
||||
let mut examples: Vec<String> = Vec::new();
|
||||
for_each_build(&root, |pak, bytes| {
|
||||
let Some(build) = ui_layout::parse_build(bytes) else {
|
||||
return;
|
||||
};
|
||||
if build.from_fallback {
|
||||
return;
|
||||
}
|
||||
for el in &build.elements {
|
||||
if el.keyframes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
groups += 1;
|
||||
if el.keyframes.iter().any(|k| k.time.is_none()) {
|
||||
untimed += 1;
|
||||
if examples.len() < 8 {
|
||||
examples.push(format!("{pak}: {} has an untimed pose", el.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let t: Vec<u32> = el.keyframes.iter().map(|k| k.time.unwrap()).collect();
|
||||
if t.windows(2).any(|w| w[1] < w[0]) {
|
||||
out_of_order += 1;
|
||||
if examples.len() < 8 {
|
||||
examples.push(format!("{pak}: {} times {t:?} descend", el.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
eprintln!("placement groups: {groups}; untimed: {untimed}; out of order: {out_of_order}");
|
||||
for e in &examples {
|
||||
eprintln!(" {e}");
|
||||
}
|
||||
assert!(groups > 10_000, "expected the whole disc, saw {groups} groups");
|
||||
assert_eq!(untimed, 0, "every pose must carry a time");
|
||||
assert_eq!(out_of_order, 0, "keyframe times must not descend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_segment_alpha_ramps_run_at_a_constant_rate() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||
return;
|
||||
};
|
||||
if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") {
|
||||
eprintln!("SKIP: the legacy time reading is selected");
|
||||
return;
|
||||
}
|
||||
|
||||
let (mut ramps, mut constant) = (0usize, 0usize);
|
||||
for_each_build(&root, |_pak, bytes| {
|
||||
let Some(build) = ui_layout::parse_build(bytes) else {
|
||||
return;
|
||||
};
|
||||
if build.from_fallback {
|
||||
return;
|
||||
}
|
||||
for el in &build.elements {
|
||||
if el.keyframes.iter().any(|k| k.time.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let t: Vec<u32> = el.keyframes.iter().map(|k| k.time.unwrap()).collect();
|
||||
let a: Vec<i32> = el
|
||||
.keyframes
|
||||
.iter()
|
||||
.map(|k| ((k.fade >> 24) & 0xff) as i32)
|
||||
.collect();
|
||||
for (lo, hi) in monotone_ramps(&a, 3) {
|
||||
if let Some(ok) = constant_rate(&t, &a, lo, hi) {
|
||||
ramps += 1;
|
||||
constant += usize::from(ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let share = 100.0 * constant as f64 / ramps as f64;
|
||||
eprintln!("multi-segment alpha ramps: {constant}/{ramps} at a constant rate ({share:.1}%)");
|
||||
assert!(ramps > 500, "expected the whole disc, saw {ramps} ramps");
|
||||
// The old reading scores 0 of 1042. Half is a floor, not a target: the rest
|
||||
// are genuinely shaped ramps, authored with keyframes that are not evenly
|
||||
// spaced. Anything near zero means the time assignment has slipped again.
|
||||
assert!(
|
||||
share > 45.0,
|
||||
"only {share:.1}% of ramps run at a constant rate — the time \
|
||||
association has probably slipped (the old off-by-one scored 0%)"
|
||||
);
|
||||
}
|
||||
121
crates/sylpheed-formats/tests/ui_record_loop_length_disc.rs
Normal file
121
crates/sylpheed-formats/tests/ui_record_loop_length_disc.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! A nested record's header `+0x08` is its LOOP LENGTH, and its keyframes need
|
||||
//! not fill it.
|
||||
//!
|
||||
//! A `*f` focus record animates for as long as its button is focused, so
|
||||
//! something has to say where the cycle restarts. The keyframes cannot: the
|
||||
//! `PRESS Ⓐ` plate's `ptbtn00f` ramps 0→80→0 over **105** units, and a 105-unit
|
||||
//! period is 15 % short of every measurement of the real thing.
|
||||
//!
|
||||
//! Each record is itself a RATC bundle with its own header. `+0x08` is a frame
|
||||
//! count, and if it is the loop length it must never be **less** than the
|
||||
//! record's largest keyframe time — an animation cannot restart before its own
|
||||
//! last pose. Disc-wide that holds 1 781 times out of 1 781, and 7.7 % of records
|
||||
//! declare *more*, which is a hold at the final pose before the cycle repeats.
|
||||
//!
|
||||
//! `ptbtn00f` is one of those: 105 units of ramp inside a **120**-unit cycle, so
|
||||
//! it rests dark for 15 units between pulses.
|
||||
//!
|
||||
//! Argument, the falsification test against the measured period, and the census:
|
||||
//! `docs/re/structures/ui-record-loop-length.md`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
|
||||
p.join("dat").is_dir().then_some(p)
|
||||
}
|
||||
|
||||
/// Read a nested record's declared length and its largest keyframe time.
|
||||
fn record_len_and_maxt(bundle: &[u8], off: usize, size: usize) -> Option<(i64, i64)> {
|
||||
if off + 12 > bundle.len() || off + size > bundle.len() || &bundle[off..off + 4] != b"RATC" {
|
||||
return None;
|
||||
}
|
||||
let len = u32::from_be_bytes(bundle[off + 8..off + 12].try_into().ok()?) as i64;
|
||||
let lb = ui_layout::parse_build(&bundle[off..off + size])?;
|
||||
let maxt = lb
|
||||
.elements
|
||||
.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max()? as i64;
|
||||
(maxt > 0).then_some((len, maxt))
|
||||
}
|
||||
|
||||
/// The falsifier: a loop cannot restart before its own last keyframe.
|
||||
#[test]
|
||||
fn a_records_declared_length_is_never_shorter_than_its_keyframes() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let (mut total, mut exact, mut holds) = (0usize, 0usize, 0usize);
|
||||
for p in &paks {
|
||||
let Ok(ar) = PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (rn, &(o, s)) in &b.records {
|
||||
let Some((len, maxt)) = record_len_and_maxt(&by, o, s) else { continue };
|
||||
total += 1;
|
||||
assert!(
|
||||
len >= maxt,
|
||||
"{}:{rn} declares a {len}-unit cycle but has a keyframe at t={maxt} — \
|
||||
a loop cannot restart before its own last pose",
|
||||
p.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
if len == maxt { exact += 1 } else { holds += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(total > 1500, "expected >1500 timed nested records, got {total}");
|
||||
// The field must carry information. If every record declared exactly its own
|
||||
// last keyframe time, "loop length" would be an unfalsifiable relabelling.
|
||||
assert!(
|
||||
holds > 50,
|
||||
"only {holds} of {total} records declare a hold — the field would be carrying nothing"
|
||||
);
|
||||
eprintln!("{total} records: {exact} exact, {holds} with a hold before the cycle repeats");
|
||||
}
|
||||
|
||||
/// The case that motivated it, pinned by name.
|
||||
#[test]
|
||||
fn the_press_a_plate_glow_holds_dark_for_fifteen_units() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
let by = ar.read(&ar.entries()[2]).expect("entry 2 — the PRESS A plate");
|
||||
let b = ui_layout::parse_build(&by).expect("parse");
|
||||
let &(o, s) = b.records.get("ptbtn00f.rat").expect("the focus record");
|
||||
let (len, maxt) = record_len_and_maxt(&by, o, s).expect("a timed record");
|
||||
assert_eq!(maxt, 105, "the glow's ramp ends at t=105");
|
||||
assert_eq!(len, 120, "but the cycle is 120 units");
|
||||
assert_eq!(len - maxt, 15, "so it holds dark for 15 units between pulses");
|
||||
|
||||
// The five main-menu focus records fill their cycle exactly — the contrast
|
||||
// that shows the slack is a property of this record, not of the format.
|
||||
let menu = ar.read(&ar.entries()[5]).expect("entry 5 — the main menu");
|
||||
let mb = ui_layout::parse_build(&menu).expect("parse");
|
||||
let mut checked = 0;
|
||||
for n in 1..=5 {
|
||||
let name = format!("ptbtn0{n}f.rat");
|
||||
let Some(&(mo, ms)) = mb.records.get(&name) else { continue };
|
||||
let (l, m) = record_len_and_maxt(&menu, mo, ms).expect("timed");
|
||||
assert_eq!((l, m), (120, 120), "{name} should fill its 120-unit cycle exactly");
|
||||
checked += 1;
|
||||
}
|
||||
assert_eq!(checked, 5, "expected five main-menu focus records");
|
||||
}
|
||||
142
crates/sylpheed-formats/tests/ui_settle_time_disc.rs
Normal file
142
crates/sylpheed-formats/tests/ui_settle_time_disc.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! A settled screen is one INSTANT, not one hold per element.
|
||||
//!
|
||||
//! `Element::rest()` returns an element's last *hold* keyframe, picked for that
|
||||
//! element alone. For anything that ends the screen settled that is right. For a
|
||||
//! **transient** it is exactly wrong: a two-frame flash's last hold is the flash
|
||||
//! *peak*, so `rest()` leaves it burning for the whole screen.
|
||||
//!
|
||||
//! `GP_TITLE` build 4 is the case that found this. `ptlogo_back2eff1` … `eff5`
|
||||
//! are five staggered flashes — `a=0` until t52, `255` for two frames, `0` again
|
||||
//! two frames later — that sweep left to right across the logo once and are gone
|
||||
//! by t110. Two elements, `ptlogo_back2eff` (t66–238) and `ptlogo_back2`
|
||||
//! (t80–243), then hold for the rest of the screen. `rest()` draws all seven at
|
||||
//! `a=255` simultaneously, and stacking five extra white glows blows the light
|
||||
//! arc out to saturation: against the console capture the arc's mean error is
|
||||
//! 33.22 and 8 581 pixels sit at the clipping level, where the console has 1 459.
|
||||
//!
|
||||
//! `UiBuild::settle_time()` recovers the right instant from the disc alone — the
|
||||
//! midpoint of the longest keyframe-free interval — with no reference to any
|
||||
//! capture. For this build that is t=198, and posing there takes the arc error to
|
||||
//! 11.79 and the clipped count to 1 452 against the console's 1 459.
|
||||
//!
|
||||
//! Argument, controls and the disc-wide census: `docs/re/structures/ui-settle-time.md`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
||||
let p = PathBuf::from(p);
|
||||
if p.join("dat").is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The case that found the bug, asserted end to end.
|
||||
#[test]
|
||||
fn a_flash_is_transparent_at_the_settle_time_and_opaque_at_rest() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let arc = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
let bytes = arc.read(&arc.entries()[4]).expect("build 4");
|
||||
let b = ui_layout::parse_build(&bytes).expect("parse");
|
||||
|
||||
let (lo, hi) = b.settle_window().expect("a settle window");
|
||||
let t = b.settle_time().expect("a settle time");
|
||||
assert!(hi - lo >= 60, "title's settle window should be a second or more, got {lo}..{hi}");
|
||||
assert!(lo < t && t < hi, "settle time {t} must lie inside {lo}..{hi}");
|
||||
|
||||
let alpha = |k: &ui_layout::Keyframe| k.fade >> 24;
|
||||
let mut flashes = 0;
|
||||
for el in &b.elements {
|
||||
let Some(name) = el.name.strip_prefix("ptlogo_back2eff") else { continue };
|
||||
// `ptlogo_back2eff.t32` itself holds; only the numbered ones flash.
|
||||
if !name.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
flashes += 1;
|
||||
let rest = el.rest().expect("a rest pose");
|
||||
let posed = el.pose_at(t).expect("a posed keyframe");
|
||||
assert_eq!(
|
||||
alpha(rest),
|
||||
255,
|
||||
"{}: rest() is expected to report the FLASH PEAK — that is the bug",
|
||||
el.name
|
||||
);
|
||||
assert_eq!(
|
||||
alpha(&posed),
|
||||
0,
|
||||
"{} flashes once before t110 and must be gone at the settle time {t}",
|
||||
el.name
|
||||
);
|
||||
}
|
||||
assert_eq!(flashes, 5, "GP_TITLE build 4 has five numbered back2 flashes");
|
||||
|
||||
// …while the two that genuinely hold are still opaque there.
|
||||
for want in ["ptlogo_back2eff.t32", "ptlogo_back2.t32"] {
|
||||
let el = b.elements.iter().find(|e| e.name == want).expect(want);
|
||||
assert_eq!(
|
||||
alpha(&el.pose_at(t).expect("posed")),
|
||||
255,
|
||||
"{want} holds across the settle time and must stay opaque"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The window is a property of the data, so it must be computable disc-wide
|
||||
/// without panicking, and must be self-consistent wherever it exists.
|
||||
#[test]
|
||||
fn settle_windows_are_self_consistent_disc_wide() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let (mut with, mut wide) = (0usize, 0usize);
|
||||
for p in &paks {
|
||||
let Ok(a) = PakArchive::open(p) else { continue };
|
||||
for e in a.entries() {
|
||||
let Ok(by) = a.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let Some((lo, hi)) = b.settle_window() else { continue };
|
||||
with += 1;
|
||||
assert!(lo < hi, "an empty window is not a window: {lo}..{hi}");
|
||||
let t = b.settle_time().expect("a window implies a time");
|
||||
assert!((lo..=hi).contains(&t), "settle time {t} outside {lo}..{hi}");
|
||||
// No element may have a keyframe strictly inside the window — that
|
||||
// is the whole definition, so it is worth asserting rather than
|
||||
// trusting.
|
||||
for el in &b.elements {
|
||||
for k in &el.keyframes {
|
||||
if let Some(kt) = k.time {
|
||||
assert!(
|
||||
kt <= lo || kt >= hi,
|
||||
"{}: keyframe t={kt} lies inside the settle window {lo}..{hi}",
|
||||
el.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if hi - lo >= 30 {
|
||||
wide += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(with > 1000, "expected >1000 bundles with a settle window, got {with}");
|
||||
eprintln!("{with} bundles have a settle window; {wide} are at least 30 units wide");
|
||||
}
|
||||
@@ -12,6 +12,17 @@ already went wrong once.
|
||||
* **The toolchain is real.** `tools/re-capture/rebuild_canary.sh` exists because
|
||||
the old box had no cmake/ninja/clang and only runtime sonames, so it hand-
|
||||
relinked object files. **Do not use it here.** Use `build-canary`.
|
||||
🔴 **But `build-canary` does not work in this container as it stands
|
||||
(2026-08-29).** It builds `${PROJECT_DIR:-/work}/xenia-canary`, which **does
|
||||
not exist here** — the Canary source is at **`/canary`** (`$XENIA_SRC`). The
|
||||
warm 235 MB tree at `/sylph-home/re/canary-build` is configured with
|
||||
`CMAKE_HOME_DIRECTORY=/work/xenia-canary`, also missing, and its
|
||||
`build-Release.ninja` carries **no per-file rules** — it wants to re-run CMake
|
||||
first, which would fail on the absent source root. **So any Canary change is a
|
||||
full reconfigure against `/canary` plus a full compile**, not an incremental
|
||||
one. Budget for that before starting: `SYLPH_JOBS=4`, and this box has been
|
||||
sitting at **~700 MB free** with a documented history of full-parallel builds
|
||||
OOM-killing the host.
|
||||
* **numpy and Pillow are installed.** `entities2.py`, `flight_probe.py` and the
|
||||
image oracles work. Their absence used to look like a logic bug.
|
||||
|
||||
@@ -40,6 +51,64 @@ python3 tools/re-capture/gmem.py find hex:820af844 400
|
||||
```
|
||||
|
||||
* **One emulator at a time.** `run-canary` enforces it with a lockfile.
|
||||
* 🔴 **`run-canary` is SILENT TWICE OVER, and that defeats `audio-capture`.**
|
||||
Line 82 is `export SDL_AUDIODRIVER="${SDL_AUDIODRIVER:-dummy}"`, and its
|
||||
header explains why: `--apu=nop` stalls the guest in the intro movie, so the
|
||||
SDL driver against a *dummy* device is what lets the title advance. The
|
||||
comment's premise — "there is no PulseAudio here" — **stopped being true when
|
||||
`tools/audio-capture` landed**, and it starts a daemon on demand.
|
||||
So a capture through the null sink records **pure silence**, at the right
|
||||
length, with a perfectly healthy-looking run behind it. To actually record the
|
||||
game:
|
||||
|
||||
⚠️ **And that is only the first of TWO layers.** `run-canary` also passes
|
||||
**`--mute=true`** on its own command line (line 98). With the driver fixed and
|
||||
the mute left alone, Canary attaches a healthy 6-channel stream to the sink,
|
||||
holds it at 100 % volume for the whole run — and emits silence. Both have to go:
|
||||
|
||||
```bash
|
||||
audio-capture start # or load a null sink yourself
|
||||
PULSE_SINK=cap SDL_AUDIODRIVER=pulseaudio \
|
||||
run-canary --mute=false … # `"$@"` is last, so this wins
|
||||
```
|
||||
|
||||
Record at the monitor's real format, too — `parec` defaults to stereo/44.1 kHz
|
||||
and will silently resample a 6-channel monitor:
|
||||
`parec -d cap.monitor --channels=6 --rate=48000 --format=s16le`.
|
||||
|
||||
🔴 **And even with both mutes off, a PulseAudio-monitor capture is not
|
||||
faithful — use the ALSA tee instead.** A null sink's *monitor* is sampled on a
|
||||
wall clock and **invents silence** whenever the client is late, so a capture
|
||||
through it is 39 % holes that the game never emitted. `PULSE_LATENCY_MSEC`
|
||||
only trades gap count against gap size and never wins.
|
||||
✅ **The working route is `--apu=alsa` with an ALSA `file` tee in front of a
|
||||
paced slave** — full recipe, controls and three configuration traps in
|
||||
[`audio-capture-alsa-file-tee.md`](../re/audio-capture-alsa-file-tee.md).
|
||||
* 🔴 **A BARE ALSA `file` tee WILL FILL THE DISK — always run a size guard.**
|
||||
Xenia's ALSA writer thread pads silence whenever its ring is empty
|
||||
(`alsa_audio_driver.cc:359`), so against a device that never blocks it
|
||||
free-runs: measured at **~250× real time, 7.34 GB in 50 seconds**. The slave
|
||||
must pace — `slave.pcm { type pulse }` — and the capture loop should abort
|
||||
above ~3× real time. The next person to try a bare tee hits this in the first
|
||||
minute.
|
||||
* ✅ **And use `--gpu=null` for an audio capture.** It is what takes the guest
|
||||
from 0.70× to **0.96×** real time, which stops Xenia padding at all: 0.31 %
|
||||
silence and 0.01 gaps/s, against 9.98 % / 8.37 rendered. ⚠️ No video, so
|
||||
screen-based provenance is unavailable (use the XMA probe), and `--gpu=null`
|
||||
runs here die at ~70 s with `PM4_DRAW_INDX: Failed in backend`.
|
||||
|
||||
⚠️ **Do not read Canary's 6-channel PulseAudio stream as evidence the GAME is
|
||||
5.1.** `pactl` will show `float32le 6ch 48000Hz`, channel-mapped to a full 5.1
|
||||
layout, on any title. That is `AudioDriver::kFrameChannelsDefault = 6`, a
|
||||
hardcoded constant — the code path actually used
|
||||
(`SDLAudioSystem::CreateDriver(index, semaphore, &driver)`) constructs
|
||||
`SDLAudioDriver(semaphore)` and takes every default. The *format* is Xenia's;
|
||||
only the *content* of those six channels is the guest's.
|
||||
|
||||
⚠️ **Check `pactl list sink-inputs` before trusting a recording.** If it is
|
||||
empty, Canary never attached and you are recording zeroes; the sink also sits
|
||||
at `IDLE`. `audio-capture run` warns on a `-inf` peak afterwards, which is the
|
||||
backstop — but a live check fails in seconds instead of after the whole run.
|
||||
* Boot is slow cold, ~25 s once the shader/code caches are warm — so a
|
||||
launch-and-dump fits in a single call.
|
||||
* **Screens: classify by whole-image statistics** (`screen_id.py`), not named
|
||||
@@ -49,7 +118,19 @@ python3 tools/re-capture/gmem.py find hex:820af844 400
|
||||
## Verifying your own work
|
||||
|
||||
* Reborn's disc-gated tests **self-skip** without `SYLPHEED_DISC`. A green run
|
||||
with it unset means almost nothing. `build-reborn test` wires it up for you.
|
||||
with it unset means almost nothing.
|
||||
🔴 **But `build-reborn` does not work in this container (2026-08-29).** Line 15
|
||||
is `SRC="${PROJECT_DIR:-/work}/Syplheed-Reborn"` — note the transposed letters —
|
||||
and no such directory exists; the workspace is at **`/work`** itself. It fails
|
||||
immediately with `cd: /work/Syplheed-Reborn: No such file or directory`, so the
|
||||
documented way to run the disc-gated tests is broken.
|
||||
✅ **Run them directly instead**, setting the variable yourself:
|
||||
```bash
|
||||
SYLPHEED_DISC=/disc cargo test -p sylpheed-formats --test <name>
|
||||
```
|
||||
⚠️ This is the **second** wrapper in this container pointing at a source root
|
||||
that does not exist — `build-canary` has the same defect. Check a wrapper's
|
||||
`SRC` before trusting that a green or a failure came from your code.
|
||||
* Prefer a headless self-verify over "it compiles": `sylpheed-cli mesh render`,
|
||||
`screen render`, `save info` all produce checkable artifacts.
|
||||
* A Bevy system-parameter conflict is invisible to the type checker and panics
|
||||
@@ -125,3 +206,41 @@ XEX decrypt + LZX decompress, and the disassembly-to-database step — belongs i
|
||||
`crates/sylpheed-formats`.** Until then, every static finding rests on an
|
||||
artefact this project cannot rebuild, and that is a real gap in the corpus rather
|
||||
than a convenience.
|
||||
|
||||
## 🔴 Pressing Ⓐ on the title faults the guest — and the fault fills the disk
|
||||
|
||||
Three attempts to capture the main menu on 2026-08-29 ended the same way. Every
|
||||
run that tapped Ⓐ **on the title** faulted; every run that tapped nothing there
|
||||
completed and produced its capture.
|
||||
|
||||
| run | input on the title | outcome |
|
||||
|---|---|---|
|
||||
| 1 | Ⓐ, then Ⓐ again on the transition | guest fault, **519 MB** of register dump |
|
||||
| 2 | one Ⓐ | drifted to a `flight` classification, 97 MB |
|
||||
| 3 | one Ⓐ | guest fault, **223 MB** of register dump |
|
||||
| 4–6 | none (`NOTAP=1`) | all completed normally |
|
||||
|
||||
This is the crash `ui_draw_capture.sh`'s own header records from 2026-08-18 — "a
|
||||
stray A there sends the guest into the save-data probe". ⚠️ The corpus's existing
|
||||
menu measurements (Q4, Q5, the focus ring) were taken by some route that survived
|
||||
this; what differs has not been found. **Menu-side dynamic RE is blocked until it
|
||||
is.**
|
||||
|
||||
⚠️ **A guest fault writes an UNBOUNDED register dump to stdout.** Xenia runs with
|
||||
`break_on_unimplemented_instructions = true`, and the dump is `vN = [...]` / `rN =
|
||||
...` lines at roughly 100 MB per 30 s. The filesystem here sits at **91 %**. Any
|
||||
scripted run that presses a button must watch `canary.stdout` and kill on growth —
|
||||
`ls -la` on it before trusting a long run.
|
||||
|
||||
📌 Two knobs added to `ui_draw_capture.sh` for boot-side work: `GRACE=1` (the fixed
|
||||
8 s wait before arming means an `ARM=early` capture otherwise misses both splashes,
|
||||
which run at ~1.2–9.5 s of guest time) and `NOTAP=1` (no input at all — the movie
|
||||
tap fires on "the screen changed a lot", which is also true of a fading splash).
|
||||
|
||||
### ⚠️ `ARM=early` loses its F10 about 40 % of the time
|
||||
|
||||
Five `ui_draw_capture.sh ARM=early` runs on 2026-08-29: **two logged `ARMED EARLY`
|
||||
and produced no `xenia_re_ui_draws_NN.log` at all.** The keypress goes to the
|
||||
window and is silently lost — nothing in the session log distinguishes a run that
|
||||
armed from one that did not, so **check the log file exists before spending the
|
||||
run**, and treat a repeat measurement as needing more attempts than samples.
|
||||
|
||||
@@ -7,8 +7,9 @@ and wants to reach a mission — or who needs to script that journey.
|
||||
Internal names (`ptbtn03`, `GP_LOAD`, build numbers) appear only as footnotes,
|
||||
because they are how *we* find things, not what the game shows anyone.
|
||||
|
||||
**Status:** skeleton. Most of it is ❔ and is *meant* to be — this page exists to
|
||||
be filled in by playing, not to look finished.
|
||||
**Status:** filling in. §1–§4 now carry what the committed oracle frames actually
|
||||
show; what is still ❔ is what no capture answers. This page exists to be filled
|
||||
in by playing, not to look finished.
|
||||
|
||||
> ## ⚠️ Fill this in from the real game
|
||||
>
|
||||
@@ -28,10 +29,29 @@ Confidence: ✅ seen in a capture · 🟡 inferred · ❔ unknown.
|
||||
|
||||
| # | What you see | What you do | What happens |
|
||||
|---|---|---|---|
|
||||
| 1 | Publisher and developer logos on black | nothing | plays through 🟡 |
|
||||
| 2 | The opening cinematic | ❔ can it be skipped, and with which button? | ends into the title 🟡 |
|
||||
| 3 | **Title screen** — the wordmark animates in, then a prompt | press **Ⓐ** | goes to the main menu ✅ |
|
||||
| 4 | **Main menu** | — | see §2 |
|
||||
| 1 | **SQUARE ENIX** in white on black, the two dots in red, `™` after it ✅ | nothing | fades on to the next logo |
|
||||
| 2 | **GAME ARTS**, **SETA** and **studio anima** stacked on black ✅ | nothing | fades on into the cinematic |
|
||||
| 3 | The opening cinematic | **Ⓐ** skips it ✅ | ends into the title |
|
||||
| 4 | **Title screen** — the wordmark appears **first, with no prompt**; `PRESS Ⓐ BUTTON` fades in **2.13 s** later, above the 2006/2007 Square Enix copyright line, and then pulses about every 2.2 s ✅ | press **Ⓐ** | goes to the main menu ✅ |
|
||||
| 5 | **Main menu** | — | see §2 |
|
||||
|
||||
✅ **The order is publisher then developer, confirmed in three cold boots
|
||||
(2026-08-29)** — `SQUARE ENIX` for ~4.3 s, a ~0.25 s black hold, then
|
||||
`GAME ARTS` / `SETA` / `studio anima` for ~3.5 s, and both dwells are declared on
|
||||
the disc (240 and 195 keyframe units). ⚠️ There is a **third** SQUARE ENIX
|
||||
wordmark about ten seconds in — bloomed, below centre — and it is the opening
|
||||
card of the intro movie, not a splash.
|
||||
[the three frames side by side](../re/captures/boot-order/splash-order-two-runs.png) ·
|
||||
[`boot-order-and-splash-dwell.md`](../re/boot-order-and-splash-dwell.md)
|
||||
|
||||
Both logo screens are **still pictures the game draws**, not video — neither is a
|
||||
`.wmv` on the disc. Captures:
|
||||
[publisher](../re/captures/title-builds/live-splash-publisher.png) ·
|
||||
[developer](../re/captures/title-builds/live-splash-developer.png) ·
|
||||
[title](../re/captures/title-builds/live-title-press-a.png).
|
||||
|
||||
⚠️ **One Ⓐ skips the cinematic**, and it is worth a lot of time: the title
|
||||
arrived at **57 s** with the skip against **193 s** without it ✅.
|
||||
|
||||
⚠️ **The title screen has two states that look identical.** The one that ends
|
||||
the boot accepts Ⓐ. The one the attract loop returns to, after the game has sat
|
||||
@@ -43,6 +63,21 @@ boot.
|
||||
⚠️ **The title is not input-ready for about ten seconds** after it appears ✅.
|
||||
And even then Ⓐ registers roughly half the time, with nothing yet found that
|
||||
predicts which ✅ — budget retries.
|
||||
🔴 **Refutation attempt, 2026-08-29 — both halves of that came out wrong on the
|
||||
runs I could test.** Two boots, Ⓐ pressed **7.29 s** and **7.28 s** after the
|
||||
title art settled (5.15 s and 5.15 s after the prompt appeared): **accepted both
|
||||
times, first press, no retry**, and each went straight on to the main menu. Ⓑ on
|
||||
the menu was then also accepted first press, both runs.
|
||||
⚠️ Reach: **n = 2**, so "half the time" is only made unlikely (2/2 has p ≈ 0.25
|
||||
under it), not excluded — but *"not input-ready for about ten seconds"* is
|
||||
contradicted outright, because 7.3 s worked twice. Keep the retry budget; drop
|
||||
the ten-second wait. Evidence:
|
||||
[run 1](../re/data/plate-timing-run1.tsv) · [run 2](../re/data/plate-timing-run2.tsv) ·
|
||||
[`title-plate-delay-measured.md`](../re/title-plate-delay-measured.md).
|
||||
|
||||
⚠️ **The prompt takes 2.13 s to arrive, measured twice (2.138 s / 2.132 s).**
|
||||
Timed from the moment the wordmark stops animating, not from the moment it first
|
||||
appears — the build-in itself varies by half a second between runs.
|
||||
|
||||
---
|
||||
|
||||
@@ -51,18 +86,57 @@ predicts which ✅ — budget retries.
|
||||
Five options in a vertical stack, roughly centred, with a highlighted state on
|
||||
the focused one.
|
||||
|
||||
> ✅ **The focused option carries a small ring to the left of its label, and the
|
||||
> ring turns — continuously, about once every 2.2 s.** It has a bright head, so
|
||||
> you can see it go round. It is the **only** thing moving on this screen once it
|
||||
> has settled: the labels, the bracket and the footer are all completely still
|
||||
> (temporal std exactly 0.000 over 20 s). Ⓑ
|
||||
> [five frames, 4 s apart](../re/captures/focus-ring/ring-single-frames-4s-apart.png) ·
|
||||
> [the measurement](../re/focus-ring-spin-measured.md)
|
||||
|
||||
| position | label | what it opens |
|
||||
|---|---|---|
|
||||
| 1 | ❔ | ❔ |
|
||||
| 2 | ❔ | ❔ |
|
||||
| 3 | ❔ | ❔ |
|
||||
| 4 | ❔ | ❔ |
|
||||
| 5 | ❔ | ❔ |
|
||||
| 1 | **NEW GAME** | a **DIFFICULTY** prompt, then **SELECT DATA** ✅ |
|
||||
| 2 | **LOAD GAME** | the save-slot list ✅ |
|
||||
| 3 | **TUTORIAL** | the lesson list ✅ |
|
||||
| 4 | **OPTIONS** | the settings menu ✅ |
|
||||
| 5 | **EXTRAS** | a three-item submenu ✅ |
|
||||
|
||||
**To fill in, by looking:** read the five labels off the screen and say what each
|
||||
one leads to. ❔ Which item is focused when the menu opens · ❔ does the cursor
|
||||
wrap from the last item back to the first · ❔ does left/right do anything ·
|
||||
❔ what B does here — back to the title, or nothing.
|
||||
Read off [`live-main-menu.png`](../re/captures/title-builds/live-main-menu.png);
|
||||
destinations off
|
||||
[`q4-destinations.png`](../re/captures/menu-nav/q4-destinations.png) and
|
||||
[`newgame-difficulty.png`](../re/captures/newgame-path/newgame-difficulty.png).
|
||||
|
||||
The screen is the title art gone dim, with the wordmark ghosted behind the list
|
||||
and a bracket of glowing rule-lines drawn around it. The focused item is bright
|
||||
white with a **spinning ring** to its left; the others are dim blue. Every item
|
||||
carries a small dot-in-circle at the left end of its underline — that is on all
|
||||
five all the time and is *not* the cursor.
|
||||
|
||||
**Moving around ✅**
|
||||
|
||||
| you press | what happens |
|
||||
|---|---|
|
||||
| ⬆ / ⬇ | one item, and it **wraps** at both ends |
|
||||
| ⬅ / ➡ | nothing |
|
||||
| Ⓐ | opens the focused item |
|
||||
| Ⓑ | 🟡 back to the title — see the warning below |
|
||||
|
||||
❔ **Which item is focused when the menu opens is not fixed.** Four boots of the
|
||||
same harness opened on `TUTORIAL`, `TUTORIAL`, `NEW GAME`, `NEW GAME`. Do not
|
||||
assume the top item, and do not assume the middle one either.
|
||||
|
||||
> ⚠️ **The main menu is the one screen whose footer does not offer Ⓑ.** It reads
|
||||
> `⊙ : Select Ⓐ : OK` — every submenu adds `Ⓑ : Back`. Measured: **zero**
|
||||
> red-Ⓑ glyph pixels anywhere in the frame, on two captures, with the same
|
||||
> detector finding the glyph on `EXTRAS` and `DIFFICULTY` ✅.
|
||||
> ✅ **But Ⓑ does leave it, and the objection that stood here is refuted
|
||||
> (2026-08-29).** This page used to say the title "returns on its own after
|
||||
> ~8–10 s idle", so an observer could not tell Ⓑ from the timer. That timer
|
||||
> belongs to the **title**, not to this screen: the main menu was held untouched
|
||||
> for **≥ 60 s** and never moved. Ⓑ is delivered and is the only input in ≥ 100 s
|
||||
> before the return, so the ordering is measured — the *latency* is not
|
||||
> ([the measurement](../re/menu-idle-and-b-2026-08-29.md)).
|
||||
|
||||
*Internals: `GP_TITLE.pak` build 5; buttons `ptbtn01`–`ptbtn05` top to bottom.*
|
||||
|
||||
@@ -73,26 +147,82 @@ wrap from the last item back to the first · ❔ does left/right do anything ·
|
||||
One section each, in the shape of §2: what is on screen, what the cursor does,
|
||||
what each choice leads to, and what a wrong choice shows you.
|
||||
|
||||
### Continue / Load ❔
|
||||
❔ How saves are listed · ❔ what an empty slot looks like · ❔ the confirmation
|
||||
prompt and where the cursor starts.
|
||||
### New game ✅
|
||||
Ⓐ on `NEW GAME` does **not** start a mission. It opens **DIFFICULTY** —
|
||||
`EASY` / `NORMAL` / `HARD` / `BACK`, opening focused on **NORMAL** ✅ — and Ⓐ
|
||||
there opens **SELECT DATA**, a save-slot picker headed
|
||||
`Current Storage: Dummy HDD` that asks you to choose a file for the auto-save.
|
||||
Pick one and a movie plays ✅.
|
||||
[DIFFICULTY](../re/captures/difficulty-screen.png)
|
||||
|
||||
### Load game ✅
|
||||
A vertical list of numbered slots, **8 rows visible**, scrolling as a carousel —
|
||||
one capture shows the order `19, 20, 01, 02, 03, 04` with `01` focused, so the
|
||||
list runs past the end and back round to the start ✅. Each row shows
|
||||
`Difficulty`, `Flight Time` and `Clear Ratio`; a **Details** panel to the right
|
||||
gives `STAGE`, `Game Status`, `Points` and `Times Cleared`, and an empty slot
|
||||
leaves every one of those blank ✅. `Current Storage: Dummy HDD` sits along the
|
||||
top.
|
||||
|
||||
Its footer offers more than the other menus:
|
||||
`⊙ : Select Ⓐ : OK Ⓑ : Back Ⓧ : Delete Ⓨ : Select Storage` ✅.
|
||||
[capture](../re/captures/menu-nav/q4-destinations.png) (left panel)
|
||||
|
||||
❔ Still open: the overwrite / delete confirmation, and where its cursor starts.
|
||||
|
||||
Known: `title → LOAD GAME → slot 01 → YES → READY ROOM → TAKE OFF` reaches
|
||||
flight ✅.
|
||||
|
||||
### Options ❔
|
||||
❔ Which settings exist, what each ranges over, how a change is applied and
|
||||
whether it needs confirming.
|
||||
### Tutorial ✅
|
||||
A list of lessons in two headed groups, with a one-line description shown on the
|
||||
left for whichever is focused ✅ — e.g. `BASIC CONTROLS` reads
|
||||
*"Learn how to move and attack"*. Opens focused on the first entry.
|
||||
|
||||
### Extras ❔
|
||||
❔ What is in it — a movie theatre, a gallery, records? ❔ what is locked at the
|
||||
start and what unlocks it.
|
||||
| group | lessons |
|
||||
|---|---|
|
||||
| **Level 1** | `BASIC CONTROLS`, `HEADS-UP DISPLAY`, `RADAR` |
|
||||
| **Level 2** | `SUPPLY AND SPECIAL MOVES`, `RADIO ORDERS`, `ADVANCED CONTROLS` |
|
||||
| — | `BACK` |
|
||||
|
||||
### Mission select ❔
|
||||
⚠️ **Stage select would not move**: sixteen d-pad presses never left Stage 01 ✅.
|
||||
Whether that is because only one stage was unlocked, or because the list is
|
||||
driven some other way, is unknown — worth settling early, since a scripted run
|
||||
has to get past it.
|
||||
[capture](../re/captures/menu-nav/q4-destinations.png) (middle panel)
|
||||
|
||||
### Options ✅ (one level in)
|
||||
`GAME SETTINGS` · `CONTROL SETTINGS` · `SOUND SETTINGS` · `SCREEN SETTINGS` ·
|
||||
`BACK`, opening focused on the first ✅.
|
||||
[capture](../re/captures/menu-nav/q4-destinations.png) (right panel)
|
||||
|
||||
❔ Still open: what is inside each of the four, what each setting ranges over, and
|
||||
whether a change needs confirming.
|
||||
|
||||
### Extras ✅
|
||||
Three items: `MISSION SELECT` · `MOVIE THEATER` · `BACK`, opening focused on
|
||||
`MISSION SELECT` ✅. The cursor wraps here too — it is a menu rule, not a
|
||||
per-screen one ✅.
|
||||
[capture](../re/captures/title-builds/live-extras.png)
|
||||
|
||||
❔ `MOVIE THEATER` has never been opened.
|
||||
|
||||
### Mission select ✅ — and the "stuck cursor" is explained
|
||||
The stage list on the left (**8 rows visible of 16**, with a scrollbar), a detail
|
||||
panel showing the stage's name, a picture, `High Score` and `Best Time`, and a
|
||||
**Wide Area Space Map** on the right with the named systems on it. The chosen
|
||||
difficulty is printed top-right. Footer:
|
||||
`⊙ : Select Ⓐ : OK Ⓑ : Back Ⓨ : Difficulty` ✅.
|
||||
|
||||
⚠️ **"Stage select would not move" — sixteen d-pad presses never left Stage 01 —
|
||||
is now explained: the other fifteen stages were LOCKED** ✅. A locked row is
|
||||
drawn *dimmer than an unfocused one*: measured, the labels sit at three distinct
|
||||
brightnesses — focused **254**, unlocked **183**, locked **104** — and on a save
|
||||
with the story unlocked the same rows read 183, with the cursor able to reach
|
||||
**Stage16** at the bottom of the scrolled list.
|
||||
[the measurement](../re/menu-navigation-semantics.md#-mission-select-the-cursor-was-stuck-because-the-stages-were-locked) ·
|
||||
[locked](../re/captures/mission-select-stage01-only.png) ·
|
||||
[unlocked](../re/captures/mission-select-all-story-unlocked.png) ·
|
||||
[at Stage16](../re/captures/mission-select-ends-at-stage16.png)
|
||||
|
||||
So: if you are scripting a run, **check what the save has unlocked** before
|
||||
concluding the list is broken. ❔ Whether the list wraps past Stage16, and
|
||||
whether a locked row is skipped or simply unreachable, is not settled.
|
||||
|
||||
### Briefing and Ready Room ❔
|
||||
❔ What you read, what you choose, and what finally launches the mission.
|
||||
|
||||
2001
docs/port/HANDOFF.md
2001
docs/port/HANDOFF.md
File diff suppressed because it is too large
Load Diff
@@ -136,6 +136,7 @@ files, which is how the same ground got covered twice.
|
||||
| [`structures/stage-mission-tables.md`](structures/stage-mission-tables.md) | The stage table set — phases, routes, sub-objectives and AI parameters | ✅ the table set and how the stage record reaches it, validated across; **`AIParams` disc-wide: 23 objects, one shared 34-profile roster (782 records), loader `sub_8233C368`; `Type`→field-count holds except the two `_Test` templates** |
|
||||
| [`structures/texture-color-k8888.md`](structures/texture-color-k8888.md) | Texture colour interpretation — `k_8_8_8_8` (32bpp UI/HUD textures) | — |
|
||||
| [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) | What a keyframe time is worth, and what shape the ramp has | ✅ CONFIRMED from the running game's own draw stream — the ramp is **linear** (a declared 15-unit fade lands on `round(255·k/15)` for all seven samples) and the animation clock advances **2 time units per submitted frame**. 🟡 the seconds conversion (`1 unit = 1/60 s`) rests on a measured 27.6 present-frames/second |
|
||||
| [`ui-keyframe-record-layout.md`](ui-keyframe-record-layout.md) | A keyframe's time word comes **before** its pose — the placement record, decoded | ✅ CONFIRMED, **decoded**. A group is an 8-byte header then `frames` records of `{u32 time; 36-byte pose}`, so the time precedes the pose; the group's lead-in word at `header+8` is pose 0's time and **every** pose is timed. Disc-wide over 13 991 groups in 33 archives, each test with a control: lead-in prepended is non-decreasing **13 991/13 991**; a non-zero lead-in is strictly below the next time **5 058/5 058** (control 70.9 %); a multi-segment alpha ramp runs at a constant `dα/dt` **857/1 540** against **0/1 042** under the old reading. 🔴 Retires two long-standing corpus claims — *"a group's data stops 4 bytes short of its final block's time slot"* and *"the last keyframe carries no time"* — both of which were this off-by-one. Adoption is free: all 12 `GP_TITLE` builds render byte-identically, and over 217 builds only two elements pick a different `rest()` pose, both between equally invisible ones. ❔ the executable's own parser was **not** found (the 40/60 stride query is weak, not negative) |
|
||||
| [`structures/ui-composable-bundles.md`](structures/ui-composable-bundles.md) | A screen build is not the only thing `compose` can draw | ✅ CONFIRMED by measurement over the disc, with the artifact to |
|
||||
| [`structures/ui-focus-and-effect-elements.md`](structures/ui-focus-and-effect-elements.md) | `_eff` glow layers are not focused-state records | ✅ CONFIRMED by measurement over all 965 screen builds on the disc, |
|
||||
| [`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md) | The paint order comes from a layer key in the T8aD sprite header | ✅ CONFIRMED on both screens whose paint order has been measured — |
|
||||
@@ -147,17 +148,36 @@ files, which is how the same ground got covered twice.
|
||||
| [`structures/unit-struct-runtime.md`](structures/unit-struct-runtime.md) | Runtime `Unit` struct (craft / vessel definitions) — read from live guest memory | — |
|
||||
| [`structures/weapon-struct-runtime.md`](structures/weapon-struct-runtime.md) | Runtime `Weapon` / `Shell` structs — read from live guest memory | — |
|
||||
| [`structures/xbg7-mesh.md`](structures/xbg7-mesh.md) | XBG7 — mesh geometry (inside XPR2 model containers) | — |
|
||||
| [`capture-harness-status.md`](capture-harness-status.md) | Why the harness stops reaching the title — and the two instruments that could not see the disc | ✅ **the disc is BACK** (2026-08-29, container replaced at 11:07:38): `/disc` is a real 6.2 GB read-only mount and `screen list` returns 12 builds. The "no disc" section is withdrawn — and its two instruments were blind either way: `find / -xdev` cannot cross into a bind mount on another device, and `sylph-doctor` only ever looks under `/work`. Earlier sections: `screenshot` costs 10.8 s under xenia (92×), and `trace_gpu_stream` is a no-op in the Release build |
|
||||
| [`title-crash-stl-tree.md`](title-crash-stl-tree.md) | The title-screen crash is an STL `map`/`set` erase on a bad iterator | ✅ CONFIRMED — the guest throws std::out_of_range from an STL |
|
||||
| [`ui-paint-order-third-permutation.md`](ui-paint-order-third-permutation.md) | A third measured paint order — tool built and validated, screen not reached | ✅ the reader works and is CONFIRMED against both previously |
|
||||
| [`ui-quad-class-foothold.md`](ui-quad-class-foothold.md) | The guest's UI quad class — a foothold found from the capture's vertex layout | 🟡 PROBABLE for the identification below (it is a static read, but |
|
||||
| [`menu-navigation-semantics.md`](menu-navigation-semantics.md) | The title menu — how it moves, and where each button goes | ✅ measured: wraps both ends, Ⓑ restores focus, ⬅➡ inert; 4 of 5 destinations driven. 🟡 GamePart id is a name match, ❔ `NEW GAME` untested |
|
||||
| [`menu-navigation-semantics.md`](menu-navigation-semantics.md) | The title menu — how it moves, and where each button goes | ✅ measured: wraps both ends, Ⓑ restores focus, ⬅➡ inert; all 5 destinations driven. 🟡 GamePart id is a name match. 🟡 **Ⓑ leaving the MAIN menu downgraded 2026-08-29** — uncited, and the main menu is the only screen whose footer omits Ⓑ (0 glyph px in frame vs 514/518 elsewhere). ✅ **MISSION SELECT's stuck cursor was a LOCKED stage list** — labels have three brightnesses, locked 104 / unfocused 183 / focused 254 |
|
||||
| [`screen-transitions.md`](screen-transitions.md) | Between two screens — a fade through black, and where its timing lives | ✅ the fade quad's keyframe group is decoded (disc-wide: per-pak all-or-nothing; `GP_TITLE` = the 6 screens, not the 6 overlays); the ~0.4 s fade-OUT is measured, not on the disc |
|
||||
| [`menu-audio-cues.md`](menu-audio-cues.md) | Menu audio — the event vocabulary is on the disc, the binding is not | ✅ `SE_UI_*` cue names/ids decoded and `BANK_SE`→`Static.slb` (0/322 in FILES); 🟡 event binding is a name match; ❔ `Static.slb` has no wave boundaries, so SE audio is not extractable |
|
||||
| [`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md) | What the game reads at boot — `config.ini`, and which GameParts exist | ✅ `config.ini` selects the language (the disc's only config); ❔ its `[SYSTEM]` is empty so the boot order is not in config; 🟡 24/29 ids bind to a class, `GP_ADVERTISE_DEMO` is never registered |
|
||||
| [`movie-binding.md`](movie-binding.md) | Which movie plays where — boot intro, attract loop, new-game intro | ✅ decoded from the movie manifest (`ADVERTISE_MOVIE`→`ADV.wmv`, `MS00A`→`S00A.wmv`); attract identity confirmed independently by frame matching; 🟡 skippability unsettled |
|
||||
| [`ready-room-probe.md`](ready-room-probe.md) | S1 — the Ready Room probe: no-go, and not for the reason expected | ✅ it is 2D and enumerates (60 builds), but the pak is briefing/tactical-map content; and `kind == 0x3002` finds 0 buttons there |
|
||||
| [`ui-title-build-map.md`](ui-title-build-map.md) | Which `GP_TITLE` build is which screen state | ✅ CONFIRMED for title / `PRESS Ⓐ` / main menu / `EXTRAS` against live captures; the archive is 8 screens × EN/JP, and "6/8/9 are submenus" is withdrawn |
|
||||
| [`ui-title-build-map.md`](ui-title-build-map.md) | Which `GP_TITLE` build is which screen state | ✅ CONFIRMED for title / `PRESS Ⓐ` / main menu / `EXTRAS` against live captures; the archive is 8 screens × EN/JP, and "6/8/9 are submenus" is withdrawn ✅ **2026-08-29: the two "unidentified `DELTASABER` plates" are the LOADING screen** — builds 0/1 the plain variant, 10/11 the dressed one, decoded from their `pgloading_*` element names, and the executable (`sub_821C4EB0`, bytes checked in the image) names exactly five title-side screens: `TITLE_SCREEN`, `BUTTON`, `TITLE_MENU`, `LOADING`, `LOADING2`. 🟡 which loading bundle takes which of the two names is undecided. 🟡 the English member of a pair is the one in the first half of `GP_TITLE.p00` — 8/8 structurally, 3/3 where a capture can check it. |
|
||||
| [`ui-title-paint-order-capture.md`](ui-title-paint-order-capture.md) | The title screen's paint order, measured from the guest's draw submissions | ✅ CONFIRMED — the order in which the running game paints the title |
|
||||
| [`upstream-baseline.md`](upstream-baseline.md) | A stock-upstream baseline runs Stage 02 crash-free | ✅ CONFIRMED — upstream canary_experimental + only the pad |
|
||||
| [`weapon-datasheet-runtime.md`](weapon-datasheet-runtime.md) | Weapon DATA SHEET — runtime capture (Route B) | 🟡 first dynamic capture, 2026-07-28. The Arsenal's Gallery Mode panel is a |
|
||||
| [`xpr2-colour-check.md`](xpr2-colour-check.md) | XPR2 colours: channel order ✅ confirmed against the running game | — |
|
||||
| [`focus-ring-spin-measured.md`](focus-ring-spin-measured.md) | The main menu's focus ring spins continuously — and how fast | ✅ **measured**: period **2.177 s** over 9 revolutions (8 evenly spaced autocorrelation peaks) = 120 units = 60 frames = 2.00 s at 30 Hz. A pulse is excluded — annulus total conserved to 0.4 % while per-bin brightness swings by 24. ✅ the ring is the **only** moving thing on the settled main menu (std exactly 0.000 elsewhere). 🔴 no angle is quoted: the angular estimator FAILED its own control (30° → 0°) |
|
||||
| [`structures/slb-bank-header-not-a-wave.md`](structures/slb-bank-header-not-a-wave.md) | Why a music bank read as THREE sub-waves when the census says two | ✅ **decoded**: the third is the **bank header**, emitted by our own reader. `to_xma_riffs`'s hybrid branch derives a leading packet stream's start as `first_riff % 2048`, which is right only for a header shorter than one packet; a music bank's header is exactly **5 packets (10 240 B)**, so the modulus gave 0 and the whole header came back as sub-wave 0. The header states its own length at `+0x24` in blocks. Disc-wide over 9 519 `sound.pak` entries: **28** match the header signature at offset 0 (ids 1001–1023, 1101–1105), **28/28** end exactly at the first `RIFF`, **0** have a gap, **0** false positives — so a header at offset 0 and a leading packet stream never coexist. Decode control, same chain, same bank: the emitted region gives **0.009 s** against **87.744 s** for the real wave 0. Corroborated by the runtime XMA probe, which saw exactly two streams at the main menu. Fixed + 2 regression tests; the `VOICE_D_453` recovery is untouched (10/10 green) |
|
||||
| [`title-plate-delay-measured.md`](title-plate-delay-measured.md) | How long the boot title shows build 4 before the `PRESS Ⓐ` plate | ✅ **decoded after a refutation**: build 2 and build 4 run on **one clock started together**, and the plate's own `ptbtn00` reaches `a=255` at `t=238`; the last build-in ramp ends at `t=118`, so the interval is a declared **120 units = 2.000 s**. 🔴 The instruction that shipped first — "wait 2.13 s after build 4 settles" — was **refuted by the port** with disc arithmetic and is corrected in place; 🔴 `rest.t` is **not** when a screen settles (it is the last hold keyframe before the exit: `ptlogo1` rests at `t=251` and stops moving at `t=42`). ⚠️ The wall-clock 2.13 s is 6.7 % long because Canary presents at **28.06 / 28.14 fps** against a nominal 30, matching the corpus's independent **28.5 fps**; author the 120 units. ✅ **measured**, two independent boots: **2.138 s** and **2.132 s** from the frame build 4 settles (glyph = its no-plate 154, motion → 0). Agreeing to **6 ms**. So the boot title's end state is **not** plate-free and a compositor must draw **two builds at once**. ⚠️ Measure from *settled*, not from first pixels — "first drawn → plate" is 3.78 s vs 4.26 s across the same two runs, because the build-in animation's own duration varies with emulator frame pacing. Plate pulse re-measured at 2.12/2.19/2.34/2.31 s (mean 2.24), replicating the corpus's ≈2.3 s. ✅ black hold between screens bracketed at **0.14–0.30 s**, consistent with the declared 12 units. 🔴 the Ⓐ→menu latency is still **not** available: both runs freeze one frame for ~1.4 s at surface mean **26.626** — agreeing between runs to 1e-6, and reproduced with stream restarts disabled — which is a guest **load stall**, not the capture path. Probe: 8.7 ms/frame, 7.97/7.98 fps against a requested 8, controls 9/9 + 4/4 |
|
||||
| [`menu-idle-and-b-2026-08-29.md`](menu-idle-and-b-2026-08-29.md) | The main menu does not idle back to the title — and four durations that were a pipeline | ✅ **refuted**: no self-return in **≥ 60 s** untouched; the ~8–10 s idle belongs to the **title**. 🟡 Ⓑ→title ordering measured, latency not. 🔴 `classify_array` at **1503 ms/frame** drained an 8 fps stream at 0.64 fps and manufactured four latencies (24.66 s / 15.58 s / 25.60 s / 20.26 s) — all withdrawn; a backlog preserves ordering and destroys durations |
|
||||
| [`structures/ui-settle-time.md`](structures/ui-settle-time.md) | Which instant a "settled screen" composite depicts | ✅ **decoded**: a settled screen is **one instant every element is posed at**, and the disc names it — the midpoint of the **longest keyframe-free interval** in the build (`UiBuild::settle_time` / `settle_window`). 🔴 `rest()` is *not* that: it picks each element's last hold **independently**, so a two-frame flash holds at its **peak** and burns forever. `GP_TITLE` build 4 has five staggered flashes (`ptlogo_back2eff1`…`eff5`, all extinguished by t110) that `rest()` draws simultaneously and permanently, saturating the light arc. Predicted t=198 from `[160,236]` **before scoring**: arc band **33.22 → 11.79**, clipped pixels **8 581 → 1 452** against the console's **1 459** (an unfitted statistic), whole frame 14.07 → 12.06; controls at t=100 and t=358 are far worse, and a hand-picked visibility list reaches the identical 12.06/11.79/1 452. Controls: `at=None` byte-identical (`cmp`), pre- and post-rotation tags both 14.07, 13 paint-order tests green. ⚠️ **Reach**: of 1 758 bundles with ≥2 keyframe times only **30 %** have a window ≥ 30 units and **42 %** under 10 — mostly `loop*` fragments that never settle; check the width. 🔴 Withdraws two claims in [`ui-rotation-implemented.md`](structures/ui-rotation-implemented.md) — its "Flat. No minimum." (`at` posed **leaves only**) and its "Reborn does not draw `ptlogo1`/`ptlogo2`" (both **are** drawn; only kind-`0x4` ghosts are skipped, and hiding the real ones makes the error *worse* by +5.20/+7.47). ❔ its **10.92** baseline is unreproducible — 14.07 at both tags |
|
||||
| [`structures/ui-tie-break-cost-at-settle.md`](structures/ui-tie-break-cost-at-settle.md) | What the unknown paint-order tie-break costs, in pixels | ✅ **decoded**, closing the open half of Q3: at the settled instant the tie-break costs **at most 1 px at Δ1**, on the **Japanese title only** (`ptlogo2`×`ptlogo_tm`, 5 px shared ink); **exactly 0 px on all five port screens**. The earlier 24-pair bound was a `rest()` count — and 10 of the title's 11 tied pairs are between `ptlogo_back2eff1`…`eff5`, five transient flashes that are **transparent** on the settled screen ([`ui-settle-time.md`](structures/ui-settle-time.md)). Live pairs at settle: entry 4 → **1**, entry 7 → **2**, the four loading bundles → **0**. ✅ Not a knife-edge — sweeping every keyframe time and midpoint, the count is **flat across the whole settle window**, and the loading bundles' tie is live only at t17–t33. ✅ Controls: an overlapping *different*-key swap moves 25 310 / 268 698 / ~765 000 px on the entries reporting zero; zeros are explained by shared-ink counts (the `ptframe` pairs share **0 px** of ink). ⚠️ Entries 0/1/12/15 have **no live control** — their zeros rest on keyframe data, not a render. 🟡 Refutation attempt on the corpus's "24 pairs": **survives** as a rest-pose bound, 16/16 on entry 7. ❔ *Why* ties order as they do is still unknown — and now worth one pixel |
|
||||
| [`structures/ui-record-loop-length.md`](structures/ui-record-loop-length.md) | Where a looping record's cycle restarts — and the `PRESS Ⓐ` plate's real period | ✅ **decoded**: a nested record is itself a RATC bundle and its header **`+0x08` is the loop length**; its keyframes need not fill it, and the slack is a hold at the final pose. Disc-wide over **1 781** timed nested records: 92.3 % declare exactly their last keyframe time, **7.7 % declare more**, and **0 declare less** — the falsifier (a cycle cannot restart before its own last pose) never fires. 🔴 **The plate's `ptbtn00f` is 105 units of ramp inside a 120-unit cycle, so it holds dark for 15 units** — the port was shipping **105**, and the answer is **120**. ✅ Falsification test against the running game, using a pacing factor measured *independently* on the focus ring (declared 120 → **2.177 s**, factor **1.0885**): to reach the corpus's measured 2.12–2.34 s, 105 units needs a factor of **1.211–1.337** (🔴 excludes the ring's) while 120 needs **1.060–1.170** (✅ contains it). Different elements, different bundles, separate runs — tied only by both declaring 120. ⚠️ Says where a cycle *ends*, not which records cycle. ❔ the **top-level** `+0x08` (300 on every `GP_TITLE` entry, elements ending at 244–269) is a different question, untouched |
|
||||
| [`structures/ui-focus-record-pulse-census.md`](structures/ui-focus-record-pulse-census.md) | Every focus record whose glow pulses, and where `rest()` puts it | ✅ **decoded**, disc-wide: **1 130** focus records, **2 664** timed elements, **210 with a varying alpha** — of which **202** have `rest()` == the **peak** (burns bright forever) and **8** land **mid-ramp**. By pak: `PILOTLOG` 116, `MOVIE_THEATER` 54, `HANGAR_ARSENAL` 30, `LEADERBOARD` 8, **`GP_TITLE` 2**. 🟡 Bounds rather than refutes the port's "34 in the export, 2 varying, nothing to fix" — correct, and correct *because* `GP_TITLE` has 2; the pathology sits in the screens a wider port needs next. 🔴 The 8 mid-ramp ones are the worse mode: `py_ranking_btn01f` swings 255→127→255 and `rest()` returns **244**, neither extreme, which looks entirely plausible and nothing reports it. ✅ Control: `ptbtn01f` is genuinely constant (255 throughout) and is **not** flagged; two hits verified keyframe by keyframe. ⚠️ A pulsing element has no resting pose — the question is malformed, not mis-answered; `pose_at(t)` inside the record's declared cycle ([`ui-record-loop-length.md`](structures/ui-record-loop-length.md)) is the only well-formed query. ⚠️ 210 is a **floor**: focus records are matched by the `Xf.rat` name rule, and varying scale/rotation/position is not counted |
|
||||
| [`structures/ui-title-buildin-measured.md`](structures/ui-title-buildin-measured.md) | The title's build-in and the plate glow, read out of the guest's own draw stream | ✅ **measured** (Canary, `ARM=early` draw capture): the decoded *mechanism* is observed, not just its end state. **The five flashes fire in a six-frame window and are absent from all 155 other sampled frames**; `ptlogo_back2eff1` is drawn in exactly 2 frames at **t = 54.0** against a decoded peak of **t54–56**, and `ptlogo1` first appears at **t = 42.2** against a decoded **t42** — with units/frame taken from the **glow's period alone**, a different element. The two holders (`ptlogo_back2eff`, `ptlogo_back2`) are continuous from frame 134. ✅ The glow's per-vertex colour alpha IS its fade alpha: **observed range 0…80 against a decoded peak of 80**, exact and unfitted; **period 51.158 presented frames** over 20 cycle starts; fitting the decoded ramp gives RMS **13.16** against **38.18 reversed** (2.9×), so the asymmetry is real and correctly directed. Structure: the settled title is 10–11 draws naming no sprite — which is why arming at the title sees nothing. ⚠️ Frame **107** is a 27-draw spike between the movie's last frame and the title's first; calling it "the composite" was an **over-read** — it binds **no texture** and only 4 of its 27 draws log geometry. The second title entry has no such frame. ⚠️ The two entries are the same animation at **different sampling phases** (only 4 of 46 aligned frames match), which is what makes the `eff3` result robust. 🔴🔴 **RETRACTED — the game DOES draw `ptlogo_back2eff3`, and all five flashes fire in both entries in the declared stagger** (`eff3` at frames 133–134 / 5957–5958, i.e. t=60.1 and 62.3, inside its declared t∈(58,64)). The absence was an **instrument artefact**: a draw batches several quads (`indices=8` is two) and the log dumps only the first 8 vertices, so min/max over a line **merges** them — and because the wipe is right-aligned, `eff3` (788…1196) lies entirely inside `eff4` (447…1196), making the union *exactly* `eff4`'s extent. The merged box matched `eff4` to 1 px. 🔴 Three explanations had been "ruled out" and all three were aimed at the wrong failure — notably the invisible-draw check counted draws with **no** geometry, where the hiding place was **partial** geometry. Superseded text follows: ~~three alternative explanations tested and failed: *phase* (its window is **6 units** against a **2.23-unit** step, so it cannot be missed — frames 133/134 sit at t=60.1/62.3 inside it and draw `eff2` and `eff4` instead), *an unlogged draw* (exactly 2 blind draws/frame, always the same full-screen-triangle shader, present when no wipe is active), and *a bad position guess* (dropping position entirely, **zero** quads anywhere have a width within ±30 of 408; the spectrum jumps 262 → 748). Draw counts across both entries: eff1 **4**, eff2 **3**, eff3 **0**, eff4 **6**.~~ (all from the merged-box parse, and wrong) 🔴 **The port draws `eff3` at t=60–62 and the console does not.** ❔ Why is not established — nothing in its element record differs from its neighbours. ⚠️ An earlier "sub-frame phase" explanation and the advice that drawing all five "shows more sweep than the console" are both **withdrawn**. ⚠️ What a frame-by-frame build-in comparison *will* show is disagreement about which flash lands in which frame — 2 units/submitted frame against this run's 2.231 units/presented frame — and neither side is wrong. 🔴 **Trap:** matching a bound texture's dimensions to a sprite fails both ways — it missed every flash *and* read the intro movie's 640×360 YUV planes as `ptbase2`. ✅ A regression of five events' observed frames against their declared times (residuals ≤0.9 frames) recovers the intercept at frame **106.1** when the composite spike, not in the fit, is frame **107**. ⚠️ Per-vertex alpha = fade alpha holds for the **glow** and does not generalise — `eff4` reads 255/127/254 on consecutive frames. ❔ Frame rate not recorded, so nothing is in seconds; the glow's period implies a **114**-unit cycle against a declared 120, unexplained; `eff5` vs `ptlogo_back2eff` not separated |
|
||||
| [`structures/boot-splash-gap-measured.md`](structures/boot-splash-gap-measured.md) | The black gap between the two boot splashes | ✅ **measured** in the guest's **draw stream**, which separates true black from a fade tail where luminance cannot: the publisher's last sprite is frame 125 (alpha 7), then **frames 126–129 submit NO sprite quad at all**, then the developer fades in at alpha 34. **The gap is 4 presented frames.** Converted with the disc as its own clock — `palogo_sqex` declares alpha≥1 for **239.8 units** and is drawn in **105** frames → **2.284 units/frame** (the title capture independently gave 2.231) — that is **~9.1 units ≈ 0.152 s**, against the **12** the port authored; ⚠️ and the true black is *shorter*, since both boundary frames still carry picture. 🔴 **RETRACTED**: "the developer splash is ONE composited 525×259 quad" — the same batching artefact. It draws three logos and three glows as separate quads in one `indices=24` call; the 525×259 was `gamearts_eff` merged with `seta_eff`. The port refuted it with arithmetic (a 259-tall box cannot hold logos spanning y 164…585) before I checked. ⚠️ The gap measurement is unaffected — those glows are the developer splash's first draw. ❌ Not declared on the disc: `palogo_eff0.prm` is a single static keyframe, and the top-level `+0x08` is a **family constant** (300 / 60) whose slack ranges 12–226 units. ❔ The executable is **not** looked at — named, not claimed. 🔴 The instrument was perturbing the measurement: the capture script taps Ⓐ on "screen changed a lot", which is also true of a fading splash — it tapped through the publisher and the developer never appeared. `GRACE=1` and `NOTAP=1` knobs added |
|
||||
| [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) | Where a keyless primitive paints, when the file forces it | ✅ **decoded**, partly closing `ui-prm-primitives.md`'s standing blocker: **an element covering the screen and fully opaque at some instant cannot paint above anything visible then**, and where that set is *every* other element its position is **forced first**. Disc-wide **80** instances forced, 50 constrained but not forced, 0 unconstrained. ✅ **Two controls, both measured orders from the running game**: it reproduces `palogo_eff0.prm` = FIRST (opaque 211 instants, below 6/6) — which a **name**-based rule gets wrong, since it is named like an overlay — and permits `pteff00.prm` on top (opaque 2 instants, below 3/23), which is where it is measured. ✅ Answers the port's `build_12`/`build_15` blank-screen contradiction: `pgloading_eff00.prm` is forced first, 4/4. ✅ Explains 36 builds the corpus recorded as "one colour" with no cause — `pzeff00.prm` forced first 32/32, so **our own sort wiped them**. 🔴 The rule's limit was found by its own test failing: applied to `.t32` sprites it claimed 22 must sort first against their own keys (`pneff01` 0xd850 at #8/13, `pbfriendly` 0x9230 at #17/49) — a sprite's *element* alpha says nothing about its *texture*'s coverage, so it is now restricted to untextured primitives. ⚠️ Assumes straight alpha-over; blend mode is still ❔. ⚠️ A lower bound, not an ordering. ⚠️ No new oracle run — the controls are prior measurements |
|
||||
| [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) *(span sensitivity)* | How much of the forced-backdrop rule rests on the timeline convention | ✅ **decoded**: the span is `0..=max keyframe time over every element`, and an element **holds** its final pose — decoded, not assumed ([`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md), [`ui-record-loop-length.md`](structures/ui-record-loop-length.md)). Sensitivity over the 130 keyless full-screen primitives with an opaque interval: using the header's declared **`+0x08`** instead changes **0** verdicts (interchangeable); using the primitive's **own** last keyframe changes **72**; counting elements **gone** after their last keyframe changes **72**. 🔴 So the hold decides **55 %** of verdicts — and dropping it is **refuted by a measured order**: `palogo_eff0.prm` is a single keyframe at t=0, so without the hold it is opaque for one instant, nothing else is up, and the rule calls it *free* against a game measured painting it first. ✅ The verdicts that matter are convention-independent — `pgloading_eff00.prm` is FIRST under all four, `pteff00.prm` FREE under all four. ⚠️ The port's **256 vs 211** was a **bundle mismatch, not a definitional one**: `palogo_eff0.prm` runs to t=255 on the publisher splash (entries 10/13) and t=210 on the developer (11/14) |
|
||||
| [`structures/ui-clock-freezes-at-settle.md`](structures/ui-clock-freezes-at-settle.md) | The top-level clock stops at the settle point — observed in the running game | ✅ **measured**: `GP_TITLE` build 4 declares `t = 0…269`, about 120 presented frames at this run's pacing, and the dwell lasted **~1 100**. `ptcopyright` declares alpha≥1 for **106 units** (t=138…244) and is **drawn for 1 050 frames**; `ptlogo1` declares an exit at t=264 and is drawn for 1 095. Both vanish within three frames of the dwell ending. **The clock advances through the build-in, stops inside the settle window `[160,236]`, and holds; the exit ramp plays when the screen leaves, not on a timer** — [`ui-settle-time.md`](structures/ui-settle-time.md)'s decode observed from the other side. A nested record keeps looping on its own clock throughout. 🔴 **This closes the 114-vs-120 gap, and it was my arithmetic**: 2.231 units/frame was regressed over *build-in* events (the only stretch the top-level clock advances) and applied to a period measured during the freeze — two different clocks. The declared **120** was never in doubt from the calibration-free dark-fraction test. ✅ The 51.158-frame period is now confirmed by a **second independent estimator** (autocorrelation, lag 51 with harmonics at 102/154) — ⚠️ whose first version **failed its control**, returning 48, because it indexed by sample position where the log's frame numbers have gaps. ❔ The **sweeps'** period stays unmeasured: the same validated estimator disagrees between two dwells of one screen (515 vs 452 frames). 🔴 **Blocker: a single Ⓐ on the title faults the guest** — 3 attempts, 2 register dumps of 223 MB and 519 MB, against 3 no-input runs that all completed; bounds menu-side dynamic RE here, and any scripted button press needs a `canary.stdout` size guard |
|
||||
| [`structures/boot-splash-dwells-are-declared.md`](structures/boot-splash-dwells-are-declared.md) | How long each boot splash is shown | ✅ **decoded**: the dwells are the bundles' own declared timelines — publisher **t=0…255 = 4.250 s**, developer **t=0…210 = 3.500 s** at 60 units/s. The corpus's independent screenshot timing over 3 cold boots gives 4.30/4.60/4.37 and **3.51/3.50/3.37** — the developer agreeing to **1.1 %**, two of its three runs to 0.3 %. 🔴 **Wall clock is the wrong unit to author**: a fresh no-input boot measured the same two dwells at **5.10–5.61 s** and 3.83–4.30 s, 15–20 % longer than both the declared values and the corpus's runs, on the same disc — so a seconds figure is one run's emulator pacing. Boundaries from the draw stream: publisher wordmark frames 6–119, **3 frames with no sprite drawn**, developer glows 123, wordmarks 140–209, intro video 216. 🔴 **The frame→wall-clock instrument resolves to one BUFFER FLUSH, not one frame** — 69 of 125 samples showed no advance and the rest jumped 7–15 frames, making the apparent rate swing 0.0164–0.0316 s/frame; frames 119 and 123 fall in one burst, so the inter-splash gap is **not separable** by it. Quoted as brackets; sub-flush estimates withdrawn before reporting. ⚠️ `palogo_anima` never appears — almost certainly the 8-vertex cap (7 elements batched, 2 logged), the same trap as the `eff3` false negative, so it is named not reported. ❔ the publisher's 4.1 % error vs the developer's 1.1 % is unexplained |
|
||||
| [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) *(colour census + self-refutation)* | What colour a keyless element is, and which forced verdicts the argument actually supports | ✅ **decoded, disc-wide**: every full-screen `*eff00*` **primitive** is **pure black** at its various alphas (`ff000000`, `7f000000`, `40000000`, `b2000000`, `cc000000`, `d4000000`, `00000000`) — exactly an alpha-over dim or fade, and an *additive* black quad would be a no-op nobody would author. The **only** non-black primitive on the disc is `pbafc.prm`, RGB `00e8e0` cyan at alphas to `ff`, and it is **844×600, not full-screen**, so outside the backdrop rule's geometry guard — ❔ it is now the sole additive candidate. 🔴 **Self-refutation: of the 80 forced-first instances only 42 are `.prm`; 38 are `.tbm` carrying fade `ffffffff`.** A *solid* white quad painted first would make the screen white and no screen is white, so a `.tbm` is a white **modulation on a texture** — and element alpha does not establish its coverage. That is the `.t32` error one extension further out: I had fixed the symptom (`el.sprite.is_some()`) not the cause, **an element's alpha is not its texture's opacity, and only an untextured primitive makes the two the same fact**. So 42 verdicts stay **decoded**, 38 drop to 🟡 (still almost certainly right — all named `*base*`, full-screen, and `pfbase.tbm`'s first position is *measured* — but on a name-and-role argument this page elsewhere calls the weaker kind). ⚠️ Code deliberately unchanged: restricting to `.prm` would send eleven screens' backgrounds back to last, the blank-screen bug the rule fixed. Split pinned by a test |
|
||||
| [`structures/ui-prm-blend-mode.md`](structures/ui-prm-blend-mode.md) | Whether a primitive blends additively or alpha-over | ❔ **undecodable, with reach** — but the consequence is closed. Looked in **the bundle** (no field: the declaration words are constant and a primitive has no RATC child at all), **the colour census** (every full-screen `*eff00*` primitive is **pure black**; the only non-black primitive on the disc is `pbafc.prm`, cyan `00e8e0`), **the occlusion constraint** (inapplicable — `pbafc.prm` strobes 255/124 every 2 units, travels, and is scaled **2 %×3 %**, so it draws ~**17×18 px**, not its declared 844×600), and **the oracle** (`GP_READY_ROOM` is a recorded no-go and gameplay needs the Ⓐ that faults the guest). ✅ **Why it stopped mattering:** for a *black* quad the hypotheses differ only in whether it hides what is beneath — drawn **first** it is correct under **both**, drawn **last** only under additive. So `forced_backdrop`'s verdict is robust to the open question, and the port's original "layerless sorts last" was wrong under alpha-over and merely pointless under additive. ⚠️ This is not evidence *for* alpha-over. 🔴 The investigation found `forced_backdrop` judged coverage from the **pivot alone**, ignoring scale; checked first, **all 80 forced instances are at 100 %**, so no verdict moved and the added guard is defensive |
|
||||
| [`structures/title-a-press-fault.md`](structures/title-a-press-fault.md) | Why a single Ⓐ on the title faults the guest — the blocker on all menu-side dynamic RE | ✅ **SOLVED 2026-08-30, and it is the emulator, not the game.** Xenia returns `X_ERROR_SUCCESS` with a *zeroed* keystroke on every `XamInputGetKeystrokeEx` while a XAM dialog is up (`xam_input.cc:197`, upstream); the game's pump is an **unbounded** `while (GetKeystrokeEx()==SUCCESS) queue.push_back()`, so it queued **8 388 608** empty keystrokes, grew its vector to 64 MB, asked for 128 MB, got a failed allocation back **unchecked** and copied off the top of the guest thread stack. ✅ **The number is the argument**: the Canary counter reports **8 388 601** swallowed calls at the last report before the crash, the dump's `r29` says the vector held **8 388 608** — two independent instruments, 7 apart, inside the 600-call reporting granularity. No new boot: the failing run's 326 MB log was still on disk. 🔴 **RETRACTED — "`r9` is a wild pointer above 4 GB"**. Xenia prints `si_addr`, a *host* address, and the guest is mapped at `0x100000000`: `0x1701D0000 − 0x100000000 = 0x701D0000`, which **is** `r9` in the dump — an ordinary guest heap address on an uncommitted page. Subtract `0x100000000` from every `Access Violation … at 0x1________` before reading it. ✅ **Decoded code path**, image-checked with **0 mismatches** over 586 instructions: `sub_824574C0` the input-manager singleton at `0x828F3888`, `sub_82457038` the keystroke pump, `sub_82457780` its `vector<X_INPUT_KEYSTROKE>` insert-with-grow. ✅ **It explains the earlier successes**: whether a XAM dialog is up is *emulator* state, so "reproduced 4/4" and "Q4/Q5 pressed Ⓐ fine" were both always true. 🟡 **Which** dialog is still open — `XamShowDeviceSelectorUI` is ruled out (`storage_selection_dialog = false` takes the headless path), `XamShowSigninUI` / `XamShowMessageBoxUIEx` are not; the settling experiment is one log line per `is_xam_dialog_present_.store(true)` site, not another blind boot. 🟡 Three untried routes out: dismiss the dialog, `--headless`, or return `X_ERROR_EMPTY` from the swallow. ✅ `frame_clock.sh`'s 300 MB guard killed the run as designed — keep it |
|
||||
| [`structures/plate-pulse-measured.md`](structures/plate-pulse-measured.md) | Does the `PRESS Ⓐ` plate stay up, pulse, or blink once? | ✅ **measured** — it **PULSES**, continuously and without decay, on a title held with **no input**: two windows in one boot, 58 s and 57 s, ~23 cycles each, periods **2.530 / 2.540 s** agreeing to 0.4 %. ⚠️ **It never goes off** — the plate-absent floor is **159** green pixels (the title art's own, from `live-title-build4-no-plate.png`) and the pulse bottoms at **714**, 4.5× that. So the port's "flash and nothing after", reasoned from `ptbtn00` expiring at t=244, is wrong; `ptbtn00f`'s 120-unit cycle is what runs. 🔴 **Two estimators, one misspecified**: mid-crossings replicate to 0.4 %, a single-sinusoid fit does not (2.553 vs 2.413) because the waveform is fast-rise/slow-decay — and its own r² of 0.468/0.228 is the tell. Both were controlled on synthetics at 2.24/2.55/3.10 s laid on the real timestamps and recovered all three exactly. 🟡 wall-clock is **13 % longer** than the corpus's earlier 2.24 s mean — same declared 120 units, different pacing (×1.27 vs ×1.12), so **author the units**. ⚠️ Reach: one boot; does not distinguish the boot title from an attract-loop title; the glyph count is a thresholded pixel count and **not** an alpha, so no duty cycle can be read off it |
|
||||
|
||||
@@ -26,6 +26,26 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
|
||||
## Inference
|
||||
|
||||
* **Never conclude from ONE sample.**
|
||||
* **⚠️ The specific observation and the general rule read identically on the
|
||||
page — and the general one is what the next reader uses.** This cost five
|
||||
corrections across two agents in two days, and none of them was carelessness
|
||||
about the measurement; every underlying observation was true of the asset
|
||||
actually looked at. The failure is reaching for the general form in the same
|
||||
breath as the specific one:
|
||||
* "the two chunks are two stems of one performance" — true of a *music bank*,
|
||||
written as a fact about voice, where one of the two is digital silence;
|
||||
* "the extra bytes are a duplicated channel, not fidelity" — true of `ADV`,
|
||||
and the size ratio it implies runs 0.0778 to 2.9163 across the disc;
|
||||
* "everything the sequencer paces off `rest.t` is late" — true of the *title*,
|
||||
and false of the screens actually checked;
|
||||
* "a three-stream cue is a movie cue" — mine, and `BIRD_224` is neither;
|
||||
* "take the highest-rate, highest-gain stream" — mine, and on `ADV` those two
|
||||
criteria select *different* streams.
|
||||
|
||||
**The counter is cheap and it is always the same one: run the census before
|
||||
writing the rule.** A ratio that is tight over 28 assets is a format fact; a
|
||||
ratio that scatters 37× was one asset wearing a rule's clothing. Where the
|
||||
census cannot be run, write the specific sentence and *say* it is specific.
|
||||
* **A law proved on one population is a hypothesis on the next.**
|
||||
* **Finding one exception does not imply a family.**
|
||||
* **Consistency is not proof. A suggestive coincidence is a coincidence until
|
||||
@@ -38,6 +58,39 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
|
||||
* **My own last-turn result is a hypothesis too.**
|
||||
* **A global partition can understate a per-owner one.**
|
||||
* **A residual is measured against a population — name it.**
|
||||
* 🔴 **An insensitive observable fails TWICE, and the second way is worse.**
|
||||
Two bugs in one exchange, one cause:
|
||||
1. A leaf-composition rule was checked against **alpha**, which moves ~0.3
|
||||
levels per keyframe unit — so a one-keyframe association error barely
|
||||
shifted it and the rule **looked confirmed**. The same span moved `x` by
|
||||
**1 560 px**.
|
||||
2. Fitting `t` from that same alpha then **manufactured an 11.5 px position
|
||||
residual that did not exist**, and sent the consumer hunting a
|
||||
pivot/rotation mechanism to explain it. One byte of alpha quantisation is
|
||||
worth 1.5–1.9 keyframe units, i.e. 6–8 px of sweep.
|
||||
**Solve on the fastest-moving field; check the slow one. Never the reverse.**
|
||||
⚠️ The second failure is the more expensive: not-falsifying leaves you falsely
|
||||
reassured, but **inventing a residual sends you looking for a mechanism**.
|
||||
* ⚠️ **A stated reach is a boundary, not a hedge — do not extrapolate past it.**
|
||||
`ui-render-tone-curve.md` fitted γ ≈ 1.34–1.49 on **dark flat patches** and
|
||||
wrote "nothing constrains midtones or highlights". Used above that range the
|
||||
model is simply wrong: binned by level, the exponent falls monotonically and
|
||||
**crosses 1.0 near render ≈ 40**, so above it the capture is *brighter* than
|
||||
the render and no single exponent can express the curve. The page had already
|
||||
said where it stopped being true; the error was reading past the sentence.
|
||||
✅ **The fix was not a better fit — it was printing the curve instead of a
|
||||
scalar**, so it can be argued with. A scalar hides its own domain.
|
||||
* ⚠️ **Normalising? Divide by how many inputs CARRY SIGNAL, not how many there
|
||||
are.** The port hit this three times in one pipeline, each invisible to every
|
||||
check except a level measurement, and each the same mistake:
|
||||
a digitally silent *chunk* counted in a voice sum; a digitally silent
|
||||
*channel* counted in a mono fold (−5.94 dB); a digitally silent *sub-wave* —
|
||||
the 10 240-byte bank header, wrapped to 10 300 B — counted as a third stem in
|
||||
a music sum, putting every real stem at 1/3 instead of 1/2 (**−3.52 dB on all
|
||||
menu music, shipping for two iterations**). This corpus's own census said
|
||||
those banks hold **two** waves; the exporter's divisor said three. **A count
|
||||
that disagrees with a census is the count that is wrong**, and the symptom is
|
||||
never a crash — it is everything being quietly a few dB down.
|
||||
|
||||
## Searching and tooling
|
||||
|
||||
@@ -89,6 +142,32 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
|
||||
* **Raw grep cannot see inside compressed pak entries.**
|
||||
* **Commit messages go in a file** (`git commit -F`); a literal `|` in a table
|
||||
cell needs escaping; `git log --all -- <path>` can hang.
|
||||
* 🔴 **Never clamp a value before something compares it.** A focus detector
|
||||
printed a degenerate `margin=12359888888.89`, so it was capped at 999 to keep
|
||||
the output readable. That cap ran *before* the vote-sorting step, so two
|
||||
different votes compared **equal**, the stable sort kept the wrong one, and a
|
||||
correct `NEW GAME` became an out-of-range index and a refusal — which aborted a
|
||||
seven-minute driven boot. The measurement was right the whole time; a cosmetic
|
||||
fix changed a decision. **Clamp at the point of display, never upstream of a
|
||||
comparison that depends on the value.**
|
||||
* **`pkill -f PATTERN` / `pgrep -f PATTERN` match YOUR OWN command line.** Hit
|
||||
twice in one session: `pkill -9 -f adv_audio_cap.sh` killed the shell that ran
|
||||
it, and an `until ! pgrep -f "probe.py --run"` loop never exited because the
|
||||
loop's own command line contained the pattern. Kill by process name
|
||||
(`ps -o pid= -C xenia_canary`) or exclude self; a wait-loop that greps for its
|
||||
own text waits forever and looks like the job hanging.
|
||||
* **"Build 10" of a pak is ambiguous — always say which index space.**
|
||||
`sylpheed-cli screen list GP_TITLE.pak` reports **12** builds and numbers them
|
||||
0–11; `screen list --all` reports **16** and numbers them 0–15. Only under
|
||||
`--all` does the ordinal equal the pak entry. Without it, ordinal 10 is pak
|
||||
entry **12** and ordinal 11 is entry **15** — so "builds 10/11 are the loading
|
||||
screen" and "entries 12/15 are the loading screen" are the same true statement,
|
||||
while "**entries** 10/11 are the loading screen" is false: those are the
|
||||
publisher (`palogo_sqex`) and developer (`palogo_gamearts`/`seta`/`anima`)
|
||||
splashes. This cost a wrong line in HANDOFF that the port caught, and it would
|
||||
have validated silently because the port's `screen_names.json` is keyed by
|
||||
entry. **Write `entry N`, not `build N`, whenever the number leaves this
|
||||
repository.**
|
||||
|
||||
## Runtime / emulator
|
||||
|
||||
@@ -96,6 +175,37 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
|
||||
* **"Animating" is not "still in a mission".**
|
||||
* **Dedup entity enumerations by position value.**
|
||||
* **Do not diagnose timing or liveness under gdb.** `ps %cpu` is cumulative.
|
||||
* **The `PRESS Ⓐ` glyph counter false-positives on the attract movie by 13×.**
|
||||
`title_timing_probe.py`'s plate detector thresholds a green-glyph pixel count
|
||||
at 400, and its control checks two committed movie frames that both score 0.
|
||||
A real boot disagrees: in one 100 s attract window, **17 frames scored ≥ 400
|
||||
and the peak was 5 393** — the movie has green content in the plate region.
|
||||
The probe is safe *because its state machine will not look at the glyph until
|
||||
the content classifier has already said `title_*`*, not because the threshold
|
||||
discriminates. ⚠️ **`glyph()` alone is not a plate detector**; a two-frame
|
||||
control over a 3½-minute movie is not a control over that movie.
|
||||
* 🔴 **`screen_id.py` cannot see a plate-less title, and calls DIFFICULTY a
|
||||
menu.** Both reproduce on committed reference frames:
|
||||
|
||||
| frame | `screen_id.py` says | should be |
|
||||
|---|---|---|
|
||||
| `live-title-build4-no-plate.png` | **`other`** | title |
|
||||
| `live-title-press-a.png` | `title` | title |
|
||||
| `difficulty-screen.png` | **`menu`** | not the main menu |
|
||||
|
||||
It thresholds on **green** (0.0009 with the plate vs 0.0002 without), so it
|
||||
recognises a title only once `PRESS Ⓐ` has faded in — and this corpus's own
|
||||
finding is that **the boot title shows build 4 FIRST, plate-less**, for ~2.25 s.
|
||||
⚠️ **Any harness that waits for `title` from it can sit through a visible title
|
||||
and report nothing** — that is what happened on an `S00A` drive here, 396 s of
|
||||
`other` with two spurious `menu` hits, on a run whose audio proved the guest was
|
||||
healthy throughout. `newgame_path.sh`, `nav_probe.sh` and `boot_menu.sh` all
|
||||
gate on this.
|
||||
✅ The zncc-against-committed-frames classifier used for the settle-time screen
|
||||
log does not have either defect: 6/6 including both movie frames and
|
||||
`difficulty-screen` as negatives, at a 0.85 threshold. ⚠️ At 0.60 it *also*
|
||||
called `difficulty-screen` a menu (0.632) — the threshold is doing real work
|
||||
and must be controlled, not chosen.
|
||||
* **Classify screens by whole-image statistics, not named pixels** — a named
|
||||
pixel is only valid while the image sits at a known place, and nothing errors
|
||||
when it moves.
|
||||
@@ -114,6 +224,25 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
|
||||
about how a screen is built or animated has to be armed *before* it exists.
|
||||
Re-arming every few seconds and keeping every log tiles the approach: each F10
|
||||
opens a new numbered file and closes the previous one complete.
|
||||
* **A capture stream that opens N seconds after launch will report the boot in
|
||||
the wrong order, and nothing errors.** `data/boot-timeline-2026-08-29.tsv`
|
||||
opens on the *developer* splash and labels the publisher one 6 s later, which
|
||||
reads as `dev → pub` and is the opposite of the boot. The stream had attached
|
||||
~7.7 s in and missed the publisher entirely. **The tell was in the file**:
|
||||
its first twelve rows are byte-identical to four decimals — one held frame
|
||||
sampled twelve times, i.e. the probe joined a screen already in progress rather
|
||||
than watching it arrive. If `t = 0` is not the launch, say so in the file; if
|
||||
the first rows do not *change*, you did not see the beginning.
|
||||
([`boot-order-and-splash-dwell.md`](boot-order-and-splash-dwell.md))
|
||||
* **`ADV.wmv` opens with its own SQUARE ENIX card, and it scores 0.75 against the
|
||||
publisher splash.** A correlation classifier keyed on
|
||||
`live-splash-publisher.png` therefore fires **twice** per boot, ~10 s apart,
|
||||
and the second one is a movie frame. Discriminators that work: the real splash
|
||||
is *perfectly still* (identical frame statistics for seconds) and scores
|
||||
0.93–0.94; the movie card drifts continuously and never passes 0.76 — and its
|
||||
wordmark is bloomed and below centre where the splash's is sharp and centred.
|
||||
**A threshold that both a screen and a movie frame clear is not a classifier**;
|
||||
look at the frame.
|
||||
* **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.
|
||||
@@ -896,3 +1025,293 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
|
||||
test needs cross-correlation to align first and an agreed downmix, and only then
|
||||
is a pass mark like ">40 dB down" meaningful. Reporting the 9 dB as a result
|
||||
would have been a confident wrong number.
|
||||
|
||||
## A shared `CARGO_TARGET_DIR` makes a worktree build replace the binary you run
|
||||
|
||||
`CARGO_TARGET_DIR=/sylph-home/re/target-container` is set for the whole container,
|
||||
so **every checkout shares one target directory**. Build anything in a
|
||||
`git worktree` — the obvious way to render from an old tag as a control — and the
|
||||
binary at `$CARGO_TARGET_DIR/release/` is now the *other* checkout's. Cargo then
|
||||
considers your main tree fresh and does not rebuild it.
|
||||
|
||||
It cost three renders here that silently used a CLI with no `--at` flag, and the
|
||||
only reason it was caught is that the missing flag was a hard error. **A stale
|
||||
binary that merely produces slightly different numbers would have been believed.**
|
||||
|
||||
After any worktree build, `touch` a source file and rebuild before measuring
|
||||
anything — and prefer building the control's binary to an explicit
|
||||
`--target-dir` of its own.
|
||||
|
||||
## `rest()` is one element's last hold, not the settled screen
|
||||
|
||||
`Element::rest()` picks each element's last **hold** keyframe *independently of
|
||||
every other element*, so a composite built from it is not the screen at any moment
|
||||
in time — it is a per-element maximum. For a transient this is exactly wrong: a
|
||||
two-frame flash's last hold is the flash **peak**, so `rest()` leaves it burning
|
||||
forever.
|
||||
|
||||
Five such flashes stack on the title and saturate the light arc; the band's error
|
||||
against the console was 33.22, and 8 581 pixels sat at the clipping level where
|
||||
the console has 1 459. Posing every element at one shared instant instead — the
|
||||
midpoint of the longest keyframe-free interval — takes those to 11.79 and 1 452.
|
||||
|
||||
The general trap: **an aggregate computed per-element is not a state of the
|
||||
system.** Ask what instant a composite claims to depict, and check that every
|
||||
element was asked the same question. See
|
||||
[`structures/ui-settle-time.md`](structures/ui-settle-time.md).
|
||||
|
||||
## A 2D draw's identity is its geometry, not its bound texture
|
||||
|
||||
The title's sprites **sample large shared texture pages**, so the texture bound to
|
||||
a draw identifies a page and not an element. Matching a bound texture's dimensions
|
||||
against a decoded sprite's fails silently in both directions, and one pass here
|
||||
did both at once:
|
||||
|
||||
* **false negative** — "none of the five flash sprites is ever drawn". They are
|
||||
drawn; they simply never appear as their own texture.
|
||||
* **false positive** — "`ptbase2` (640×360) and `pteff04` (1280×720) are drawn in
|
||||
frames 75–105". Those frames are the **intro movie**, whose YUV planes and
|
||||
target happen to be 640×360 and 1280×720.
|
||||
|
||||
Re-run against the **quad's vertex rect** in design space and every element
|
||||
appears where the disc says it should. Canary's own capture code already carries
|
||||
this warning in a comment, and the corpus had already recorded that the settled
|
||||
title binds only 1280×768 pages — both were there to be read first.
|
||||
|
||||
The general shape: **a coincidence of size is not an identification.** Before
|
||||
matching on one attribute, ask what else in the frame shares it.
|
||||
|
||||
## A batched draw merges quads, and the merge can be invisible
|
||||
|
||||
A GPU draw can carry several quads — `indices=4` is one, `indices=8` two,
|
||||
`indices=24` six — and the UI draw log dumps **only the first 8 vertices**. Taking
|
||||
min/max over a log line's whole vertex list therefore silently *merges* quads into
|
||||
one bounding box.
|
||||
|
||||
This produced two wrong findings in one session, one of them reported to another
|
||||
agent with three alternative explanations "ruled out":
|
||||
|
||||
* **`ptlogo_back2eff3` "is never drawn by the game".** It is batched with
|
||||
`ptlogo_back2eff4`, and because the wipe family is right-aligned, `eff3`
|
||||
(788…1196) lies **entirely inside** `eff4` (447…1196). The union is *exactly*
|
||||
`eff4`'s extent — so the merged box matched `eff4` to 1 px, `eff3` vanished, and
|
||||
nothing looked wrong.
|
||||
* **"the developer splash is one composited quad."** `gamearts_eff` and
|
||||
`seta_eff` merged into a box that was read as the bounding box of three logos —
|
||||
which it could not have been, since it was 259 px tall and they span 421.
|
||||
|
||||
**Why the checks failed.** Three hypotheses were tested and refuted — sampling
|
||||
phase, a draw with no geometry logged, a bad position guess. All three were aimed
|
||||
at the wrong failure. In particular the "invisible draw" check counted draws with
|
||||
**no** geometry line; the hiding place was draws with **partial** geometry, which
|
||||
was never looked for.
|
||||
|
||||
> 🔴 **Refuting three wrong hypotheses is not evidence for a fourth.** The
|
||||
> confidence gained from "I ruled out everything I could think of" is worth
|
||||
> exactly as much as the list was complete, and a list of failure modes assembled
|
||||
> by the person who built the instrument is the least likely to contain that
|
||||
> instrument's own blind spot.
|
||||
|
||||
Parse vertices in groups of four, one per quad, and **compare the logged quad
|
||||
count against `indices / 4`** — `tools/re-capture/quads_per_frame.py` does both and
|
||||
warns on the shortfall.
|
||||
|
||||
⚠️ A related tell that was present and ignored: a merged box carries the *first*
|
||||
quad's vertex colour, which made one element's alpha read 255 / 127 / 254 on
|
||||
consecutive frames. That non-monotonicity was noticed, written down as "the
|
||||
vertex-alpha identity does not generalise", and not chased. **An anomaly you
|
||||
explain away is cheaper to chase than to re-derive later.**
|
||||
|
||||
## Count the batch, not the quads the log happened to print
|
||||
|
||||
The UI draw log caps its vertex dump at **8 vertices — two quads** — while a draw
|
||||
may batch many more (`indices=24` is six). Two consequences, and the second is the
|
||||
one that bites:
|
||||
|
||||
* a bounding box taken across a line's vertices **merges** quads (already recorded
|
||||
above, the `eff3` false negative);
|
||||
* **which** elements appear in the log is the *first two in the batch*, and that
|
||||
set changes as elements fade. On the boot's developer splash the three glows
|
||||
occupy the prefix until t=45; the three wordmarks are invisible to the log until
|
||||
the glows stop being submitted. Read naively this says "the wordmarks are first
|
||||
drawn at frame 140", which is the logging prefix shifting and not the game.
|
||||
|
||||
That produced two splash spans 7.9 % apart on one boot of one guest — a quantity
|
||||
that must be one number. **The fix costs nothing: `indices / 4` is how many quads
|
||||
the draw actually holds, and the cap cannot touch it.** Its transitions land
|
||||
exactly where the declared count of elements with alpha > 0 changes, which makes
|
||||
them free calibration points.
|
||||
|
||||
> The general form: **when an instrument truncates, the surviving sample is not
|
||||
> random — it is the first N, and what falls in the first N is itself a moving
|
||||
> function of the thing you are measuring.** A truncated view looks like a
|
||||
> complete view of a smaller set.
|
||||
|
||||
## Before calling a failure unexplained, grep the corpus for its *symptom*
|
||||
|
||||
`title-a-press-fault.md` spent a session recording that a single Ⓐ faults the guest
|
||||
4/4, and closed with *"it does not explain how Q4/Q5 pressed Ⓐ successfully; what
|
||||
differs is unfound."*
|
||||
|
||||
**It was found, and written down twice, before that page existed.**
|
||||
|
||||
* [`canary-scripted-input-traps.md`](canary-scripted-input-traps.md) §3: *"With no
|
||||
profile, Ⓐ **is** handled: the guest calls `XamShowSigninUI` and Xenia pops its
|
||||
Sign In dialog"* — with a committed capture.
|
||||
* `tools/re-capture/boot_menu.sh`'s header, which explains the swallow **and quotes
|
||||
the 8.4 million figure**, and is why that launcher passes
|
||||
`--logged_profile_slot_0_xuid`.
|
||||
|
||||
The fault page searched for the *cause* it had hypothesised — an unimplemented
|
||||
instruction, then a wild pointer — and never searched for its own *symptom*, which
|
||||
would have hit both immediately.
|
||||
|
||||
⚠️ **Two lessons, and the second is the expensive one:**
|
||||
|
||||
1. **Grep for the symptom, not the theory.** "Ⓐ", "signin", "IsUIActive" were all in
|
||||
the tree.
|
||||
2. 🔴 **Knowledge in a script header is invisible to the document that needs it.**
|
||||
`boot_menu.sh` had the mechanism and the magnitude, and no `docs/re/` page linked
|
||||
to it. A tool comment is a fine place to explain a flag and a **bad** place to be
|
||||
the only record of a finding. If a script comment is carrying a measurement, that
|
||||
measurement belongs in `docs/re/` with the script pointing at it.
|
||||
|
||||
What the later session did add was the **join** — that this known input blackout is
|
||||
what drives the guest's unbounded keystroke queue into a failed 128 MB allocation —
|
||||
plus the guest code path and a host-vs-guest address retraction. A join between two
|
||||
recorded facts is a real finding; but it is much cheaper when neither fact has to be
|
||||
rediscovered.
|
||||
|
||||
## …and its mirror: a finding with TWO records and nothing keeping them equal
|
||||
|
||||
The section above is about a measurement whose only record was a script comment, so
|
||||
the document that needed it could not see it. The port agent ran the same audit
|
||||
against its own tree and found the **opposite** failure, which is worth pairing here
|
||||
because the fix for one is the cause of the other.
|
||||
|
||||
Its voice-verification control was recorded in **two** places — a tool's control
|
||||
table and a prose document — and they had drifted: **53.3 %** in the tool, **53.2 %**
|
||||
in the doc, twice each. The control file was transient and is gone, so neither copy
|
||||
can be re-measured and there is no way to tell which is right.
|
||||
|
||||
⚠️ **Both copies look authoritative.** That is the whole problem: a single record
|
||||
that is hard to find announces itself as missing the moment you look; two records
|
||||
that disagree announce nothing at all, and a reader takes whichever they opened.
|
||||
|
||||
**So the rule is not "write it down twice".** It is:
|
||||
|
||||
* **one record, in `docs/re/`**, for anything that is a measurement;
|
||||
* **everything else cites it** — a tool comment says *why the flag is there* and
|
||||
links to the page, and never restates the number;
|
||||
* if a number must appear in two places, one of them has to be **generated** from
|
||||
the other, not typed.
|
||||
|
||||
The port fixed its case by deleting the duplicate rather than picking a winner,
|
||||
which is right: with the evidence gone, choosing between 53.2 and 53.3 would have
|
||||
been authoring a measurement.
|
||||
|
||||
## A pixel figure without its region and its threshold is not checkable
|
||||
|
||||
`plate-pulse-measured.md` published 159 / 714 / 1520 as the plate-absent floor and
|
||||
the pulse's two levels. The port agent holds the same capture, tried to reproduce
|
||||
the floor, and got 3–5× at every threshold it tried — because the page named
|
||||
neither the **region** (whole 1280×720 frame, not a plate crop) nor the
|
||||
**predicate** (`(g>130) & (g−r>45) & (g−b>45)`, a three-channel test, not
|
||||
`green > N`).
|
||||
|
||||
⚠️ **This is worse than an obviously incomplete number.** A figure with no stated
|
||||
method reads as checkable, so a reader spends real effort failing to reproduce it
|
||||
and then has to decide whether the disagreement is theirs or yours.
|
||||
|
||||
And writing the method down immediately exposed a defect the prose had hidden: the
|
||||
floor came from a **1279×675** capture while the pulse came from **1280×720**
|
||||
frames — different crops, silently compared. The fix was a same-run, same-geometry
|
||||
floor that was in the series all along.
|
||||
|
||||
**So:** every pixel count states its region and its predicate, and a comparison
|
||||
between two counts states that they share a geometry. If they do not, that is a
|
||||
finding about the comparison, not a detail.
|
||||
|
||||
## A fix that overshoots leaves no symptom until something else needs the part it disabled
|
||||
|
||||
From the port agent, and it generalises past its own case. Its static-overlay path
|
||||
was **frozen at the overlay's arrival** — a fix for a different bug that reached too
|
||||
far and stopped the overlay's clock entirely. Nothing noticed for a week, because
|
||||
nothing needed that clock to advance. The plate pulse is what finally gave it
|
||||
something to be wrong about.
|
||||
|
||||
⚠️ **An over-broad fix does not fail; it goes quiet.** The class of bug to look for
|
||||
is not "this is broken" but "this has been correct-by-inactivity since the day
|
||||
somebody disabled it". When a fix works by *stopping* something rather than
|
||||
correcting it, that is the moment to write down what has been stopped.
|
||||
|
||||
## A detector that can fire on a single frame will fire on the wrong one
|
||||
|
||||
The Ⓐ A/B's first pair was **void**, and the reason is worth more than the result.
|
||||
The "wait for the title" step tested one frame against a glyph threshold. The intro
|
||||
movie throws green flashes of **1 298…5 433** lasting under a second, which clears
|
||||
any threshold the title also clears — so both legs pressed Ⓐ into the movie, about
|
||||
**6 s before the title appeared**.
|
||||
|
||||
🔴 **What makes this dangerous is that it looked like it ran.** The presses were
|
||||
real and had a real effect: each skipped the rest of the movie, which is exactly
|
||||
what the corpus documents Ⓐ doing to a movie. Both legs then reported zero swallow
|
||||
and zero crashes — a clean, symmetric, entirely meaningless result. **A void test
|
||||
that appears to have run is worse than one that errors**, because nothing prompts
|
||||
you to look.
|
||||
|
||||
It is the same shape `is_title.py` already records for `screen_id.py`, which called
|
||||
the SQUARE ENIX logo "title" 151 s into a boot and spent `skip_intro`'s one press
|
||||
there. The corpus has now paid for this twice.
|
||||
|
||||
**The rule: a screen detector matches a *signature over time*, never a single
|
||||
frame.** The fixed version requires 12 consecutive samples inside a band the movie
|
||||
overshoots — and, crucially, it was **replayed against the void runs' own recorded
|
||||
series as its control**, where it declines the flash at 84.8 / 85.5 s and fires at
|
||||
93.9 / 94.7 s. A broken run's data is the cheapest possible control for its
|
||||
replacement; keep the series.
|
||||
|
||||
## A demand for reproducibility can surface a defect that is not the one demanded
|
||||
|
||||
The port agent challenged this corpus's pulse figures as unverifiable — it had the
|
||||
capture and could not reproduce the numbers. The literal answer was small: name the
|
||||
predicate, and its counts then matched **exactly**.
|
||||
|
||||
But writing the method down is what exposed the actual defect: the floor came from a
|
||||
**1279×675** capture and the pulse from **1280×720** frames, silently compared
|
||||
across geometries. Nobody was looking for that.
|
||||
|
||||
⚠️ **And both sides were wrong at once.** The challenger's counts were the wrong
|
||||
measurement (single-channel, plate-crop) *and* the published figure had a real flaw.
|
||||
"One of us must be right" was never the shape of it — which is worth remembering
|
||||
before spending a round arguing about which.
|
||||
|
||||
## A rule learned from a burn generalises to cases that LOOK like the burn, not to cases that share its mechanism
|
||||
|
||||
Contributed by the port agent, and it is the sharpest thing either of us has put in
|
||||
this file.
|
||||
|
||||
This file already carries **two** divisor bugs, both the same shape: a silent input
|
||||
sitting in a divisor and attenuating real signal. The lesson taken from them was
|
||||
roughly *"be suspicious of dividing by N"*. So when the intro's three streams had to
|
||||
be combined, the port summed at **unity** — and its own checker rejected the tree at
|
||||
**+2.62 dBFS**.
|
||||
|
||||
🔴 **The precedent did not transfer, and the surface shape is why it looked like it
|
||||
would.** Both cases are "several streams, one output". But:
|
||||
|
||||
* a BGM bank's two waves are **stems of one signal** — parts that were split apart
|
||||
and must be added back;
|
||||
* the intro's three streams are **positions in a field** — a stereo downmix weights
|
||||
them 0.4142 / 0.2929 / 0.2929, which **sum to one whatever the assignment**, so
|
||||
the total is fixed even when the placement is unknown.
|
||||
|
||||
Divide-by-N is neither right nor wrong in itself. It depends on whether the inputs
|
||||
are parts of one *signal* or parts of one *field*, and nothing in the phrase
|
||||
"several streams, one output" distinguishes those.
|
||||
|
||||
⚠️ **The general failure**: a rule extracted from a specific burn tends to be indexed
|
||||
by *what the burn looked like* rather than by *why it happened*. It then fires on the
|
||||
next thing with the same silhouette — and, worse, feels well-earned while doing it.
|
||||
When reaching for a past lesson, state the mechanism it turned on and check that
|
||||
mechanism is present, not the resemblance.
|
||||
|
||||
@@ -617,3 +617,58 @@ neighbourhood, not just the line.
|
||||
`10 144 of 10 148 references resolve`) → **withdrawn; it is on the disc.** It is
|
||||
the `GP_STAGE_CLEAR` child the same scan named `8AX`. With the name decoded the
|
||||
count is **10 148 of 10 148**. [`ratc-child-names.md`](structures/ratc-child-names.md)
|
||||
|
||||
## UI timing (2026-08-29)
|
||||
|
||||
* "a screen has SETTLED at its `rest.t`" → **refuted.** `rest.t` is the last
|
||||
*hold* keyframe before the exit, not the end of motion. Build 4's `ptlogo1`
|
||||
rests at `t=251` and stops moving at **`t=42`**; the title's visible build-in
|
||||
ends at `t≈118`, where `pteff01`, `pteff02.prm` and `ptlogoall_eff` end their
|
||||
ramps together. Believing `rest.t` put a port's plate 3.97 s late —
|
||||
[`title-plate-delay-measured.md`](title-plate-delay-measured.md).
|
||||
* "the `PRESS Ⓐ` plate is composited a measured 2.13 s after the title settles,
|
||||
and the port should author that" → **the measurement stands, the instruction
|
||||
was refuted by the port.** Build 2 has a keyframe group of its own; both builds
|
||||
run on **one clock started together** and the plate's declared `t=238` supplies
|
||||
the timing, so nothing is authored. `238 − 118 = 120 units = 2.000 s`, of which
|
||||
2.13 s was a wall-clock reading stretched by Canary presenting at ~28.1 fps.
|
||||
⚠️ General lesson: **a wall-clock duration off this emulator is ~6 % long**, so
|
||||
a measured interval that lands near a round number of units probably *is* that
|
||||
number of units.
|
||||
* "a music bank has three sub-waves" → **refuted; it was our reader.** The third
|
||||
is the bank header, emitted because `to_xma_riffs` derived a leading packet
|
||||
stream's start as `first_riff % 2048` — valid only for a header shorter than
|
||||
one packet. 28/28 disc-wide —
|
||||
[`structures/slb-bank-header-not-a-wave.md`](structures/slb-bank-header-not-a-wave.md).
|
||||
|
||||
## The oracle harness and the container (2026-08-29)
|
||||
|
||||
* "the decoder container has no disc" → **refuted the same day.** The container
|
||||
was replaced and `/disc` is a real 6.2 GB read-only mount. Worse, both
|
||||
instruments behind the claim were blind to the answer either way:
|
||||
`find / -xdev` **cannot cross** into a bind mount on another device, and
|
||||
`sylph-doctor` only checks `/work` and never `$SYLPHEED_DISC`. "sylph-doctor
|
||||
agrees" was two instruments sharing one blind spot.
|
||||
→ To test for the disc, ask the variable that names it:
|
||||
`sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_TITLE.pak"`.
|
||||
* "the main menu returns to the title on its own after ~8–10 s idle" → **refuted.**
|
||||
The menu sat untouched for **≥ 60 s** without moving (correlation never leaving
|
||||
0.9245–0.9249). The ~8–10 s idle is real but belongs to the **title**. This was
|
||||
the only reason "Ⓑ leaves the main menu" was classed as authored.
|
||||
* "whole-image statistics (green / white / mean) can tell the title from the
|
||||
attract movie" → **refuted.** A frame of `ADV.wmv` with a bright green laser
|
||||
reads green 0.0018 / white 0.086 / mean (53,67,76) — the title's numbers. A
|
||||
probe built on it tapped Ⓐ into the movie and waited 120 s for a menu that was
|
||||
never coming. → Correlate against a committed capture instead, and keep movie
|
||||
frames as the negative controls.
|
||||
* "a 360-bin angular cross-correlation can measure the focus ring's rotation
|
||||
angle" → **refuted by its own control**: a synthetic **30°** rotation of a live
|
||||
frame came back as **0°** (peak 0.596), while 90/180/270° came back exactly
|
||||
(peak 1.000) — it only resolves exact pixel permutations. No angle was quoted;
|
||||
the spin was established from brightness conservation instead.
|
||||
* "a latency read off a classified `x11grab` stream is a duration" → **refuted.**
|
||||
At 1503 ms per classification against an 8 fps stream the consumer ran at
|
||||
0.64 fps, so frames were stale and increasingly so. Four "durations" died with
|
||||
it. The tell was that a screen transition, a button press and a plate fade all
|
||||
came out at ~20–25 s. → A backlog **preserves ordering and destroys
|
||||
durations**; check consumed-fps against requested-fps before quoting a time.
|
||||
|
||||
230
docs/re/audio-capture-alsa-file-tee.md
Normal file
230
docs/re/audio-capture-alsa-file-tee.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# ✅ Capturing the emulator's audio: the ALSA `file` tee, and why PulseAudio's monitor cannot do it
|
||||
|
||||
**Classification: measured**, on the capture chain. Supersedes the tuning advice
|
||||
in [`audio-capture-channel-map-trap.md`](audio-capture-channel-map-trap.md),
|
||||
which chased the wrong subsystem.
|
||||
|
||||
## The root cause of every bad capture so far
|
||||
|
||||
A PulseAudio null sink's **monitor is sampled on a wall clock**. When the client
|
||||
is late, PulseAudio does not wait — it **emits silence to keep its own
|
||||
timeline**. So the 39.3 % silence measured in the take-2 capture was never audio
|
||||
that went missing; it was silence PulseAudio *invented*.
|
||||
|
||||
That is why `PULSE_LATENCY_MSEC` produced a non-monotonic curve (39.3 % → 15.6 %
|
||||
→ 50.1 % silence at 5.3 / 200 / 500 ms) and never won: **the buffer size trades
|
||||
gap count against gap size, and no setting escapes a clock the capture point
|
||||
does not share.** The instrument was wrong, not mistuned.
|
||||
|
||||
**ALSA's `file` plugin has no clock at all.** It tees exactly what the client
|
||||
writes. A slow producer yields a *shorter file*, not a gap-riddled one — turning
|
||||
a data-loss problem into a time-base problem, which is the right trade when the
|
||||
question is "is the correct audio playing".
|
||||
|
||||
## ✅ Control — 6 distinct tones, byte-exact
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| source | 12.000 s, 6 channels at 400 / 800 / 200 / 1600 / 3200 / 6400 Hz |
|
||||
| captured | **12.000 s**, **0.00 % silence**, **0 gaps**, no duplicate channels |
|
||||
|
||||
⚠️ **Channel order is ALSA's, not WAV's.** Captured channel *i* holds source
|
||||
channel `[0,1,4,5,2,3]` — i.e. `FL FR BL BR FC LFE` where the WAV file had
|
||||
`FL FR FC LFE BL BR`. Deterministic, invertible, and **not** data loss; do not
|
||||
mistake it for the remap corruption documented in the companion page.
|
||||
|
||||
## The three configuration traps, in the order they bite
|
||||
|
||||
1. **`ALSA_CONFIG_PATH` REPLACES the entire ALSA config.** Without
|
||||
`</usr/share/alsa/alsa.conf>` the named `null` device is undefined and the
|
||||
client fails with `Input/output error`.
|
||||
2. 🔴 **But with that include, overriding `pcm.!default` silently does not
|
||||
take** — no error, no file, the client runs happily to completion. Both the
|
||||
full inline form and the `pcm.!default "name"` alias failed this way.
|
||||
**The fix is to drop the include** and declare the slave with an *inline
|
||||
plugin type* (`{ type null }`, `{ type pulse }`), which needs no named
|
||||
reference. Xenia hardcodes `snd_pcm_open(..., "default", ...)`
|
||||
(`alsa_audio_driver.cc:150`), so `default` **must** be the tee — a named
|
||||
device is not reachable.
|
||||
3. **`| head -N` kills the producer.** An `ffmpeg … | head -3` SIGPIPEs ffmpeg
|
||||
before it writes, and the symptom is "no file" with no error — indisting-
|
||||
uishable from a broken config.
|
||||
|
||||
## 🔴 And the reason a bare `file` tee is NOT enough for Xenia
|
||||
|
||||
Xenia's ALSA driver runs a writer thread that **pads silence whenever its ring
|
||||
buffer is empty** (`alsa_audio_driver.cc:359`). Against a device that never
|
||||
blocks, `snd_pcm_avail_update` always reports space, so the thread spins:
|
||||
|
||||
> **Measured: ~250× real time — 7.34 GB, 12 746 s of nominal audio, in ~50 s of
|
||||
> wall clock**, nearly all of it driver-generated silence. It was killed and the
|
||||
> file deleted; it would have filled the disk.
|
||||
|
||||
⚠️ This is exactly the limit the route's proposer flagged — it had been verified
|
||||
with `ffmpeg` as the client, which is self-paced, and **not** with Canary, which
|
||||
pads.
|
||||
|
||||
## ✅ The configuration that satisfies both constraints
|
||||
|
||||
**Tee in front of a paced slave.** The file plugin captures what the client
|
||||
writes; the slave supplies the clock that stops the driver free-running. The
|
||||
wall-clock silence-insertion then happens *downstream of the capture point*
|
||||
rather than inside it.
|
||||
|
||||
```
|
||||
# NO include: `type pulse` is an inline plugin type, and the include is what
|
||||
# makes a pcm.!default override fail to take.
|
||||
pcm.!default {
|
||||
type file
|
||||
slave.pcm { type pulse }
|
||||
file "/path/to/capture.raw"
|
||||
format raw
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
ALSA_CONFIG_PATH=…/asound-tee-pulse.conf PULSE_SINK=cap \
|
||||
run-canary --apu=alsa --mute=false … # "$@" is last, so both win
|
||||
ffmpeg -f s16le -ar 48000 -ac 6 -i capture.raw out.wav
|
||||
```
|
||||
|
||||
✅ Control through this exact config: **12.000 s, 0.00 % silence, 0 gaps.**
|
||||
|
||||
⚠️ **`--apu=alsa` is a third option neither agent had tried.** The note that
|
||||
`--apu=nop` stalls the guest in the intro movie still stands and is why
|
||||
`--mute=true` was there; it says nothing about the ALSA backend.
|
||||
|
||||
## ✅ Measured on Canary — and the residual silence changes meaning
|
||||
|
||||
150 s boot, `--apu=alsa --mute=false`, tee in front of the paced pulse slave.
|
||||
⚠️ **Xenia's ALSA driver is `SND_PCM_FORMAT_FLOAT_LE`** (`alsa_audio_driver.cc:173`)
|
||||
and its log confirms `ALSA initialized: 48000 Hz, 6 channels (output: 6),
|
||||
period: 512, buffer: 2048`. The raw tee is therefore **float32, 6 channels** —
|
||||
reading it as `s16` produces a plausible-looking file with a giveaway signature:
|
||||
peaks alternating exactly `−0.00 / −4.82 / −0.00 / −4.82 / −0.00 / −4.82`, which
|
||||
is the two halves of each float landing in alternate "channels".
|
||||
|
||||
| capture route | silence | gaps/s | notes |
|
||||
|---|---|---|---|
|
||||
| PulseAudio monitor, xenia default (~5.3 ms) | 39.3 % | 30.5 | |
|
||||
| PulseAudio monitor, `PULSE_LATENCY_MSEC=200` | 15.6 % | 3.5 | |
|
||||
| PulseAudio monitor, `PULSE_LATENCY_MSEC=500` | 50.1 % | 1.3 | |
|
||||
| **ALSA tee → paced pulse slave** | **9.98 %** | 8.37 | 106.2 s captured over ~151 s wall = **0.70× real time** |
|
||||
|
||||
**The file is short rather than gap-riddled, which is the intended trade** — and
|
||||
six distinct channels, no duplicates, sensible peaks (−4.41 / −3.96 / −4.41 /
|
||||
−11.65 / −8.09 / −6.53 dBFS).
|
||||
|
||||
🔴 **But the residual ~10 % silence is NOT removed, and its meaning has changed.**
|
||||
It is no longer invented by PulseAudio's monitor — the tee records exactly what
|
||||
Xenia wrote, and **Xenia wrote silence**, because its writer thread pads whenever
|
||||
the guest has not filled the ring (`alsa_audio_driver.cc:359`). So:
|
||||
|
||||
* ✅ the capture is now **faithful** — every sample in it is a sample the
|
||||
emulator emitted;
|
||||
* ❔ the emulator is still emitting padding, because the guest runs at ~0.7× real
|
||||
time here, and **no capture method can remove that**. Fixing it needs the guest
|
||||
to keep up, or a change to the driver's padding behaviour.
|
||||
|
||||
⚠️ **So this is a 3.9× improvement in silence and a change of attribution, not a
|
||||
clean capture.** At 9.98 % / 8.37 gaps/s it sits right on the port's "≥10 %
|
||||
silent *and* ≥1 gap/s" fail bar. **Do not treat it as an oracle without saying
|
||||
which side of that line it fell on.**
|
||||
|
||||
## ✅ SOLVED — `--gpu=null` removes the residual, and the capture is clean
|
||||
|
||||
The residual padding was the guest running at **0.70× real time**, and the
|
||||
dominant load is **llvmpipe software rendering** — which an *audio* capture does
|
||||
not need at all.
|
||||
|
||||
| configuration | silence | gaps/s |
|
||||
|---|---|---|
|
||||
| PulseAudio monitor, xenia default | 39.3 % | 30.5 |
|
||||
| ALSA tee → paced slave, rendered (llvmpipe) | 9.98 % | 8.37 |
|
||||
| **ALSA tee → paced slave, `--gpu=null`** | **0.31 %** | **0.01** |
|
||||
|
||||
**One gap in 67.7 s.** Six distinct channels, no duplicates, peaks −5.15 / −4.55
|
||||
/ −4.47 / −11.65 / −6.73 / −6.40 dBFS. For scale, the port's *genuine music bed*
|
||||
control measures 1.1 % silence at 3.3 gaps/s — **this capture is cleaner than
|
||||
their known-good reference.**
|
||||
|
||||
✅ **Control that the run is still comparable:** `ADV`'s three XMA contexts
|
||||
(1 294 336 / 1 118 208 / 1 171 456) appear in the `--gpu=null` log, so the
|
||||
movie's voice is decoding exactly as in a rendered boot. That is also better
|
||||
provenance for an *audio* question than screenshots were — it evidences the thing
|
||||
being recorded rather than what was on screen.
|
||||
|
||||
### The full working recipe
|
||||
|
||||
```bash
|
||||
MAP=front-left,front-right,front-center,lfe,rear-left,rear-right
|
||||
pactl load-module module-null-sink sink_name=cap channels=6 channel_map=$MAP
|
||||
# asound.conf: NO include; tee in front of a PACED slave
|
||||
# pcm.!default { type file slave.pcm { type pulse } file "…" format raw }
|
||||
ALSA_CONFIG_PATH=…/asound.conf PULSE_SINK=cap \
|
||||
run-canary --apu=alsa --mute=false --gpu=null …
|
||||
ffmpeg -f f32le -ar 48000 -ac 6 -i capture.raw out.wav # float32, not s16
|
||||
```
|
||||
|
||||
⚠️ `--gpu=null` means **no video**, so screen-based provenance is unavailable —
|
||||
use the XMA probe instead. And it is only appropriate when the question is about
|
||||
audio; it changes what the guest is doing.
|
||||
|
||||
## ✅ The distinction that makes even an imperfect tee capture usable
|
||||
|
||||
Contributed by the port, and it is sharper than the framing this page had:
|
||||
|
||||
* **PulseAudio's monitor SUBSTITUTES.** Audio that existed is *replaced* by
|
||||
silence to keep the wall clock. Information is destroyed, and deleting the
|
||||
holes cannot recover it — it only compresses time unevenly.
|
||||
* **Xenia's padding is ADDITIVE.** The silence is *inserted between* samples the
|
||||
guest emitted. **Nothing is lost.** Every real sample is present and in order.
|
||||
|
||||
So **stripping all-channel-zero runs from an ALSA-tee capture is exact, not a
|
||||
repair** — it returns a contiguous stream of everything the guest produced. That
|
||||
means even the 0.70×-real-time rendered capture (9.98 % padded) is usable for
|
||||
correlation, where none of the PulseAudio-monitor captures ever were, however
|
||||
they were tuned.
|
||||
|
||||
✅ **VERIFIED 2026-08-29.** The port controlled it rather than relying on the
|
||||
reasoning: a real music+SFX bed (137.37 s, carrying **454 genuine zero runs of
|
||||
its own**, which is what makes it an honest control) had **1 149 holes inserted
|
||||
at 8.37 gaps/s to +9.9 % length** — matching the measured ALSA profile — then
|
||||
stripped:
|
||||
|
||||
| | r | lag | margin |
|
||||
|---|---|---|---|
|
||||
| original vs itself (ceiling) | 1.000 | 0.0 s | +0.141 |
|
||||
| **padded** vs original | **0.436** | −12.2 s | **+0.006** |
|
||||
| **stripped** vs original | **1.000** | **0.0 s** | **+0.142** |
|
||||
|
||||
Two things beyond the yes:
|
||||
|
||||
* ✅ **It runs the inference forwards.** Padding at this profile puts correlation
|
||||
squarely in the known-absent regime (margin +0.006) on a file whose contents
|
||||
are controlled — so the earlier captures were unusable *for the reason claimed*
|
||||
rather than for some other one. Until now that was reasoning backwards from a
|
||||
failure to a cause.
|
||||
* ✅ **Only ONE side needs stripping.** The stripped capture matches the
|
||||
**unstripped** source at the ceiling, so a capture needs no preprocessing at
|
||||
all before being handed over — no shared step for two parties to get out of
|
||||
sync on.
|
||||
|
||||
🔴 **And the danger, which is the part to repeat:** stripping removes *genuine*
|
||||
silence too and cannot tell the two apart. It is **exact on additive ALSA
|
||||
padding and vandalism on a PulseAudio monitor capture**, where the silence
|
||||
replaced real audio. On mostly-silent material the genuine runs would cost
|
||||
something measurable — here they totalled 0.71 s in 137 s and cost nothing.
|
||||
⚠️ **Running it on the wrong artefact would look like it worked.**
|
||||
|
||||
⚠️ **Consequence for `check-capture`:** its silence/gap-rate rule was built when
|
||||
only *damage* existed, and **cannot distinguish genuine emulator padding from
|
||||
capture damage.** A FAIL on an ALSA-tee capture is a statement about the
|
||||
recording path, not about the file's usability.
|
||||
|
||||
## Consequences for verification
|
||||
|
||||
🔴 **Short file becomes the failure mode**, so a capture check needs an
|
||||
**expected-duration** test alongside silence fraction and gap rate. And a
|
||||
**runaway guard** is not optional: abort if the file exceeds ~3× real time, or
|
||||
one misconfiguration writes 7 GB before anyone looks.
|
||||
308
docs/re/audio-capture-channel-map-trap.md
Normal file
308
docs/re/audio-capture-channel-map-trap.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# 🔴 A 6-channel PulseAudio capture SCRAMBLES AND DUPLICATES channels unless the maps match
|
||||
|
||||
**Classification: measured**, on the capture chain itself rather than on the
|
||||
game. Recorded because a capture taken with this defect was handed to the port
|
||||
as evidence, cost them a full controlled analysis, and the negative they
|
||||
correctly reported was **my instrument, not the guest**.
|
||||
|
||||
## What happened
|
||||
|
||||
`adv-game-output-6ch.wav` was captured from Canary through a PulseAudio null
|
||||
sink and shared as "what the game emits during `ADV`". The port could not match
|
||||
it against **anything** — the `ADV` bed, any of the three XMA voice streams,
|
||||
`BGM_103`, `S00A` — with best-vs-runner-up margins of 0.001–0.016 everywhere,
|
||||
i.e. plateaux rather than peaks. They controlled that three ways (their
|
||||
instrument finds `bed` vs `bed` at r=1.000 margin +0.115; their `.ogv` reference
|
||||
matches the disc's `.wmv` at r=1.000 margin +0.114; and drift was excluded by
|
||||
windowed lags scattering across the movie). They also observed that **capture
|
||||
channels 3 and 6 were byte-identical, same MD5**.
|
||||
|
||||
That duplicate pair is the tell, and it is reproducible without the emulator.
|
||||
|
||||
## The control I should have run first
|
||||
|
||||
Six channels, each a **different** tone, so any reorder, drop or duplication
|
||||
shows up as a wrong frequency. Played to the sink with `paplay`, recorded from
|
||||
its monitor with the same `parec` invocation the `ADV` capture used.
|
||||
|
||||
**Sink map `FL,FR,RL,RR,FC,LFE`, i.e. NOT the stream's map — the original setup:**
|
||||
|
||||
| channel | expected | captured | |
|
||||
|---|---|---|---|
|
||||
| 0 | 400 Hz | 400 Hz | ok |
|
||||
| 1 | 800 Hz | **3200 Hz** | wrong |
|
||||
| 2 | 200 Hz | 200 Hz | ok |
|
||||
| 3 | 1600 Hz | **800 Hz** | wrong |
|
||||
| 4 | 3200 Hz | **800 Hz** | wrong |
|
||||
| 5 | 6400 Hz | **200 Hz** | wrong |
|
||||
|
||||
`ch2 == ch5`, **byte-identical** — the same artefact the port found. The 6400 Hz
|
||||
and 1600 Hz channels are **gone entirely**, replaced by duplicates.
|
||||
|
||||
**Sink map made identical to Canary's own stream map**
|
||||
(`front-left,front-right,front-center,lfe,rear-left,rear-right`), and the same
|
||||
map given to `parec` explicitly:
|
||||
|
||||
| channel | expected | captured |
|
||||
|---|---|---|
|
||||
| 0–5 | 400 / 800 / 200 / 1600 / 3200 / 6400 | **400 / 800 / 200 / 1600 / 3200 / 6400** |
|
||||
|
||||
No duplicates. **CONTROL PASSED.**
|
||||
|
||||
## 🔴 And a LEVEL CHECK cannot see this failure — by construction
|
||||
|
||||
The port made this point while building a checker for it, and it refutes
|
||||
something written above.
|
||||
|
||||
In the known-bad control, **all six channels report a peak of −18.063656 dB,
|
||||
identical to six decimals, while the file contains three duplicate pairs.** Equal
|
||||
tone amplitudes make the peak table uniform no matter how the channels are
|
||||
permuted or duplicated — and on *real* content the peaks simply differ from each
|
||||
other, which looks equally healthy. Either way the table is uninformative.
|
||||
|
||||
⚠️ So "the WAV has plausible per-channel levels" was not weak evidence that the
|
||||
capture was sound; it was **no** evidence, and this page said otherwise. The
|
||||
per-channel peak table is the natural thing to eyeball after a capture and it is
|
||||
**blind to remap corruption**. What detects it is hashing each channel and
|
||||
comparing — the port's `tools/port/check-capture`, controlled in both directions
|
||||
(six distinct tones → PASS; this page's known-bad pattern → FAIL naming all four
|
||||
pairs; the withdrawn capture → FAIL on `ch2 == ch5`).
|
||||
|
||||
## The rule
|
||||
|
||||
⚠️ **A null sink whose `channel_map` differs from the client's makes PulseAudio
|
||||
remap, and a 6-channel remap silently loses channels and duplicates others.**
|
||||
There is no error, no warning, and the WAV has the right length, the right
|
||||
channel count and plausible per-channel levels. Set the sink's map to the
|
||||
client's, and pass the same map to `parec`:
|
||||
|
||||
```bash
|
||||
MAP=front-left,front-right,front-center,lfe,rear-left,rear-right
|
||||
pactl load-module module-null-sink sink_name=cap channels=6 channel_map=$MAP
|
||||
parec -d cap.monitor --channels=6 --rate=48000 --format=s16le --channel-map=$MAP …
|
||||
```
|
||||
|
||||
## 🔴 What this withdraws
|
||||
|
||||
* **`adv-game-output-6ch.wav` is withdrawn as evidence.** Its channels are
|
||||
scrambled and one pair is a duplicate. Nothing should be concluded from it,
|
||||
in either direction — it is not evidence that the game emits something
|
||||
unexpected, and the port's inability to match it is fully explained.
|
||||
* **"All six channels carry signal"** — withdrawn. One of the six was a copy of
|
||||
another.
|
||||
* **"The surround and LFE channels are not zero, which a stereo guest padded
|
||||
into a 6-channel frame would give"** — withdrawn. It was offered as weak
|
||||
support for the 5.1 reading of a voice cue's three streams
|
||||
([`voice-three-streams-are-concurrent.md`](structures/voice-three-streams-are-concurrent.md)),
|
||||
and it is worth nothing. The port said a duplicated channel is not an
|
||||
independent one, and they were right before this control existed.
|
||||
|
||||
✅ **Unaffected:** the three-XMA-context concurrency result. That is read from
|
||||
the emulator's own log, not from the audio path, and it reproduced on two
|
||||
independent boots.
|
||||
|
||||
## The lesson, in the form it should have been applied
|
||||
|
||||
The corpus's own rule is *run your instrument through a control first*. Here the
|
||||
control needed no emulator, no disc and 30 seconds: **play a known signal through
|
||||
the capture chain and check that it comes back.** It was not run, an artefact was
|
||||
published, and the person who found the defect was the one who could not see the
|
||||
instrument. ⚠️ **A capture is an instrument, not just an output** — the same
|
||||
scrutiny a parser or an estimator gets.
|
||||
|
||||
---
|
||||
|
||||
## ✅ The capture that passes — recipe, and how it proves itself
|
||||
|
||||
Take 2, 2026-08-29. Verified with the port's independent
|
||||
`tools/port/check-capture` (six distinct channel MD5s → PASS) **before** being
|
||||
shared, deliberately using their tool rather than the hand that made the file.
|
||||
|
||||
```bash
|
||||
MAP=front-left,front-right,front-center,lfe,rear-left,rear-right # Canary's own
|
||||
pactl load-module module-null-sink sink_name=cap channels=6 channel_map=$MAP
|
||||
parec -d cap.monitor --channels=6 --rate=48000 --format=s16le \
|
||||
--channel-map=$MAP --file-format=wav out.wav & # recorder FIRST
|
||||
PULSE_SINK=cap SDL_AUDIODRIVER=pulseaudio \
|
||||
run-canary --mute=false … # both mutes off
|
||||
```
|
||||
|
||||
Two properties make it self-checking, and both were the port's asks:
|
||||
|
||||
* **The recorder starts before the emulator**, so WAV `t=0` precedes process
|
||||
launch and the movie cannot fall outside the window by accident.
|
||||
* **A screenshot every ~11 s, keyed to the recording's own clock.** Classified
|
||||
against the committed references afterwards, this run reads `movie/other` for
|
||||
**t = 10 … 251** and then `title_noplate` at **t = 262** (r = +0.998),
|
||||
`title_plate` at 277/289. So the 253 s of audio sits wholly inside the movie,
|
||||
with the title arriving just after it ends. **A miss would now be diagnosable
|
||||
instead of ambiguous** — which is the whole difference from take 1.
|
||||
|
||||
⚠️ It is still the **full mix** — the movie's own WMA bed plus the voice streams.
|
||||
Nothing at this boundary separates them.
|
||||
|
||||
## ❔ New, unexplained: this run decoded FIVE XMA streams, not three
|
||||
|
||||
`--xma_param_probe` on the take-2 boot logs five distinct `byte_size` values:
|
||||
`ADV`'s three (**1 294 336 / 1 118 208 / 1 171 456**) plus **1 150 976** and
|
||||
**1 269 760**. The extra pair belongs to some other cue and is unidentified —
|
||||
they are not `BGM_103`'s two waves (3 876 864 / 3 930 112). A *pair* is the shape
|
||||
[`bgm-two-stems`](structures/bgm-two-stems.md) documents for music banks, so a
|
||||
second bank is the first guess and it is untested.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 TAKE 2 IS ALSO UNUSABLE — the sink is being STARVED, 39.3 % digital silence
|
||||
|
||||
Take 2 passed the duplicate-channel check and carried a verified screen log, and
|
||||
the port still could not find either the movie's WMA bed or the cutscene voice in
|
||||
it — this time with a **calibrated** correlator (they had retracted their first
|
||||
one: it scored 0.415 hunting a bed inside a synthetic mix that certainly
|
||||
contained it, so it could not have found the target even when present). Their
|
||||
rebuilt instrument passes both directions, and their negative on take 2 stands.
|
||||
|
||||
They named the two readings: *the capture path is still losing the guest's mix*,
|
||||
or *the guest is not emitting these sources*. ⚠️ They flagged the second as
|
||||
landing on them hard — if the game never plays the `.wmv`'s WMA track, the port's
|
||||
intro audio has been wrong since P4.
|
||||
|
||||
**It is the first, and the capture says so on its face.** Take 2, measured
|
||||
directly:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| frames that are digital silence on **all six** channels | **6 557 892 / 16 680 453 = 39.3 %** |
|
||||
| non-silent runs | **10 595**, median **13.60 ms**, longest 1.19 s |
|
||||
| silent runs | **10 596**, median **3.94 ms** |
|
||||
| burst + gap period | **≈17.5 ms → 57 Hz**, duty cycle **60.7 %** |
|
||||
|
||||
The recording is chopped into ~13 ms fragments separated by ~4 ms holes, ten
|
||||
thousand times over. That is a **starved sink** — PulseAudio filling underruns
|
||||
with silence because the guest is not keeping the driver fed — and it destroys
|
||||
envelope correlation *by construction*: the envelope is dominated by a 57 Hz chop
|
||||
that has nothing to do with the content.
|
||||
|
||||
✅ **So the port's alarming hypothesis is NOT supported by this capture.** Nothing
|
||||
here says the game fails to play the movie's audio track. What it says is that
|
||||
**this capture cannot answer the question either way**, and the earlier
|
||||
autocorrelation hint pointed the same way — the file's strongest periodicity is
|
||||
at **5.2 s** (r = 0.449), not at `BGM_102`'s 37.487 s loop, and 5.2 s is a beat of
|
||||
the dropout schedule rather than anything musical. (Estimator controlled: it
|
||||
recovers a synthetic 37.487 s loop as **37.480 s**, and scores non-repeating noise
|
||||
at r = 0.019.)
|
||||
|
||||
### Why the sink starves — and 🔴 why "it cannot be fixed by configuration" was WRONG
|
||||
|
||||
`parec` reads a sink **monitor**, which advances at wall-clock rate and
|
||||
substitutes silence whenever nothing is written. **And every sink in this
|
||||
container is a null sink**, because there is no audio hardware at all — no
|
||||
`/proc/asound/cards`, no `/dev/snd`, no `/etc/asound.conf` — so PulseAudio's
|
||||
stock `default.pa:109` `module-always-sink` supplies one, whose stated purpose is
|
||||
to "make sure we always have a sink around, **even if it is a null sink**". A
|
||||
null sink has no hardware clock: it is driven on a timer, and anything the client
|
||||
fails to write in time becomes silence in the monitor.
|
||||
|
||||
From that I concluded the route "cannot be fixed by configuration" and that only
|
||||
an in-emulator tap would work. **That was wrong, and it was wrong because I
|
||||
assumed the holes meant the guest was running below real time without testing the
|
||||
alternative** — that the *client buffer* is simply too small. Xenia asks SDL for
|
||||
`channel_samples_ = 256`, i.e. **5.33 ms** at 6 channels, and `daemon.conf` here
|
||||
is stock with no fragment tuning at all.
|
||||
|
||||
`PULSE_LATENCY_MSEC` overrides what SDL's PulseAudio backend requests. Measured,
|
||||
same title, same sink, same `parec` invocation:
|
||||
|
||||
| client buffer | duration | **silence** | gaps/s | median gap |
|
||||
|---|---|---|---|---|
|
||||
| xenia default (~5.3 ms) | 347.5 s | 39.3 % | 30.5 | 3.94 ms |
|
||||
| **`PULSE_LATENCY_MSEC=200`** (114.7 ms reported) | 88.0 s | **15.6 %** | 3.5 | 37.33 ms |
|
||||
| `PULSE_LATENCY_MSEC=500` | 87.9 s | **50.1 %** | **1.3** | 346.67 ms |
|
||||
|
||||
**200 ms is 2.5× better than the default. 500 ms is worse than either.** The
|
||||
relationship is **not monotonic**: raising the buffer keeps cutting the gap
|
||||
*rate* (30.5 → 3.5 → 1.3) while the *total silence* bottoms out at 200 ms and
|
||||
then doubles, because an over-large buffer starves in a few enormous holes
|
||||
instead of many small ones — a 346 ms median gap at 500 ms against 37 ms at 200.
|
||||
|
||||
🔴 **And that is a warning about the metric, not just the setting.** The port's
|
||||
`check-capture` bar is **20 gaps/s**, derived from good controls (starved 32.9,
|
||||
genuine music bed 3.3, voice track 0.03). The 500 ms file scores **1.3 gaps/s —
|
||||
better than a real music bed — while being 50 % silence.** A gap-*rate* test
|
||||
alone would pass the worst capture of the three. It needs a **total-silence**
|
||||
companion, and this is the same shape as the defect that made a level table
|
||||
useless: one number that cannot see the failure mode next door.
|
||||
|
||||
⚠️ **Not yet a clean bill of health.** The two runs are not like-for-like: 88 s
|
||||
against 347 s, and the short one covers the splash logos, where silence between
|
||||
cards is real. What is established is the *direction and scale* — the dropouts
|
||||
were substantially a **client-buffer** problem, not proof that the guest runs
|
||||
below real time.
|
||||
|
||||
✅ **Consequence: the in-emulator tap may not be needed.** The capture route is
|
||||
worth retrying at a raised latency before anyone spends a session on a Canary
|
||||
rebuild.
|
||||
|
||||
❔ **The route that would work is an internal tap**, and it does not exist yet.
|
||||
`SDLAudioDriver::SubmitFrame(float* frame)`
|
||||
(`/canary/src/xenia/apu/sdl/sdl_audio_driver.cc`) receives exactly `frame_size_`
|
||||
bytes — `sizeof(float) × frame_channels_ × channel_samples_` — of the guest's own
|
||||
frame, in guest order, with **no wall clock in the loop**. A cvar-gated WAV
|
||||
writer there is the same shape as `xma_param_probe`: additive, default-off,
|
||||
read-only. It would produce a gap-free recording however slowly the emulator
|
||||
runs, because it records what the guest *produced* rather than what a device
|
||||
*consumed*.
|
||||
|
||||
🔴 **Blocked on the build, not on the change — and this is a container fact
|
||||
worth knowing before anyone plans around it.** `build-canary` builds
|
||||
`${PROJECT_DIR:-/work}/xenia-canary`, which does not exist here; the source is at
|
||||
`/canary`. The warm 235 MB tree at `/sylph-home/re/canary-build` is configured
|
||||
with `CMAKE_HOME_DIRECTORY=/work/xenia-canary` — also missing — and its
|
||||
`build-Release.ninja` has no per-file rules, so it re-runs CMake first and that
|
||||
reconfigure fails on the absent root. **Any Canary change is therefore a full
|
||||
reconfigure plus a full compile**, at `SYLPH_JOBS=4` on a box sitting at ~700 MB
|
||||
free with a documented history of parallel builds OOM-killing the host.
|
||||
|
||||
**Not attempted, deliberately** — that is a whole session's risk for one probe,
|
||||
and the rule here is not to improvise around a blocker. Recorded so the next
|
||||
session can decide with the cost in front of it rather than discovering it
|
||||
halfway through a build.
|
||||
|
||||
## 🔴 And a provenance number I got wrong
|
||||
|
||||
I told the port take 2 was **253.3 s**. The file is **318.5 s**, and the full
|
||||
recording on disk is 349 s. I read `ffprobe` *while the recorder was still
|
||||
writing*, quoted the partial length, and copied the file before it finished — so
|
||||
the shared artefact is itself a truncation of the run.
|
||||
|
||||
The corrected provenance, from the same screen log: movie/other **t = 10 … 251**,
|
||||
`title_noplate` at **262**, `title_plate` **277 … 318**, back to movie/other at
|
||||
**329** (the documented title idle timeout). ⚠️ So the shared file **includes the
|
||||
title screen**, which contradicts what I told them — I had said it sat wholly
|
||||
inside the movie window.
|
||||
|
||||
**A length in a provenance claim must be read from the finished artefact.**
|
||||
Measuring a file that is still being written is the same class of error as
|
||||
reading a level table that cannot see the defect.
|
||||
|
||||
🔴 **And it is worse than "truncated" — the shared file declares itself EMPTY.**
|
||||
`parec` writes the WAV header with zero sizes and only patches them on a clean
|
||||
exit, so a copy taken mid-recording has:
|
||||
|
||||
| field | shared artefact | the finished local recording |
|
||||
|---|---|---|
|
||||
| `RIFF` size | **8** | 200 165 472 |
|
||||
| `data` size | **0** | 200 165 436 |
|
||||
| actual bytes | 183 478 556 | 200 165 480 |
|
||||
|
||||
Python's `wave` module **refuses to open it** (`fmt chunk and/or data chunk
|
||||
missing`). `ffmpeg` and `ffprobe` recover by scanning, report a plausible
|
||||
duration, and that is exactly why the defect went unnoticed — **the lenient
|
||||
reader hid it from me and the strict one would have caught it instantly.**
|
||||
|
||||
⚠️ **Attribution of the starvation numbers, corrected.** The 39.3 % / 16 680 453
|
||||
frames / 10 595 runs above were measured on the **finished local recording**
|
||||
(347.5 s), not on the artefact that was shared (318.5 s). The port measured the
|
||||
shared copy independently and got **35.6 % / 15 289 876 frames / 10 482 runs**;
|
||||
median burst 13.5 ms vs 13.6, gap 3.9 vs 3.9, period 17.4 ms vs ≈17.5. The
|
||||
diagnosis is unaffected — both files are starved — but **a number must say which
|
||||
artefact it came from**, and these did not.
|
||||
104
docs/re/bgm-102-decoded-during-boot.md
Normal file
104
docs/re/bgm-102-decoded-during-boot.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# ✅ The two unexplained XMA streams are `BGM_102.slb` — and the corpus's `BGM_103` sizes survive a check
|
||||
|
||||
**Classification: decoded** for the identification (the bank, plus a disc-wide
|
||||
search); **measured** for the fact that it was decoded during a boot.
|
||||
|
||||
Closes the ❔ left by the take-2 audio capture, where
|
||||
[`audio-capture-channel-map-trap.md`](audio-capture-channel-map-trap.md) recorded
|
||||
that `--xma_param_probe` logged **five** distinct streams on one boot when only
|
||||
`ADV`'s three were accounted for.
|
||||
|
||||
## The identification
|
||||
|
||||
The probe gives a `byte_size` and nothing else, so the disc was asked which cue
|
||||
owns a stream that long. Both unexplained sizes are whole packet counts —
|
||||
1 150 976 = 562 packets, 1 269 760 = 620 — and
|
||||
`--example find_stream_by_size` searched every inter-descriptor span of the
|
||||
continuous voice stream **and** every `sound.pak` entry large enough:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| hits in the movie-voice stream | **0** |
|
||||
| hits in `sound.pak` | one entry carrying **both**: hash `9799c546` |
|
||||
|
||||
One entry holding both sizes is the two-stem shape, not a coincidence of two
|
||||
separate matches. The hash recovers by candidate enumeration
|
||||
(`--example name_from_hash`) to **`BGM_102.slb`**.
|
||||
|
||||
```
|
||||
BGM_102.slb 2 445 760 B on disc, header 10 240 -> 2 streams
|
||||
stream 0: 1 150 976 B (562 packets) declared 30 703 B/s => 37.487 s
|
||||
stream 1: 1 269 760 B (620 packets) declared 33 872 B/s => 37.487 s
|
||||
```
|
||||
|
||||
✅ So the boot's five streams were **`ADV`'s three voice streams plus one music
|
||||
bank's two stems**, and nothing is unaccounted for.
|
||||
|
||||
## 🟡 What it does NOT establish: which screen it belongs to
|
||||
|
||||
The capture window ran from process launch to **t = 253 s**, and its screen log
|
||||
reads movie/attract throughout, with the title arriving at t = 262 s — *after*
|
||||
the recording ended. So `BGM_102` was decoded somewhere inside a
|
||||
launch-to-just-before-title window.
|
||||
|
||||
⚠️ **That is not enough to call it the attract music.** The probe fires on *first
|
||||
decode* and its log lines carry a thread id, not a timestamp, so nothing here
|
||||
says *when* in those 253 s it started — and a title BGM being decoded moments
|
||||
before the title appears is exactly as consistent. The numbering makes that a
|
||||
live hypothesis rather than a remote one: the corpus already has the **main
|
||||
menu** on cue **1103** → `BGM_103`, so **1102** sitting one below it is at least
|
||||
suggestive of the title.
|
||||
|
||||
**The experiment that would settle it** is cheap and is not done: put a
|
||||
wall-clock timestamp on the probe line (or bound the run so it stops before the
|
||||
title) and compare against the screen log the capture already produces.
|
||||
|
||||
## 🟢 Refutation attempt — HANDOFF's `BGM_103` wave sizes. It SURVIVED.
|
||||
|
||||
HANDOFF asserts the menu's music is `BGM_103` partly on *"`BGM_103.slb`'s two
|
||||
declared waves (3 876 864 / 3 930 112 B)"*. Read off the disc:
|
||||
|
||||
```
|
||||
BGM_103.slb 7 841 292 B, header 10 240 -> 2 streams
|
||||
stream 0: 3 876 864 B declared 44 181 B/s => 87.750 s
|
||||
stream 1: 3 930 112 B declared 44 788 B/s => 87.749 s
|
||||
```
|
||||
|
||||
**Exact, both.** The claim stands unchanged.
|
||||
|
||||
## ✅ And a third route to "two stems of identical duration"
|
||||
|
||||
[`bgm-two-stems`](structures/bgm-two-stems.md) established equal duration by
|
||||
decoding. The XMA1 `PsuedoBytesPerSec` fix
|
||||
([`voice-region-leading-chunk.md`](structures/voice-region-leading-chunk.md))
|
||||
gives the same answer from the header alone, on three banks:
|
||||
|
||||
| bank | stem 0 | stem 1 |
|
||||
|---|---|---|
|
||||
| `BGM_102` | 37.487 s | 37.487 s |
|
||||
| `BGM_103` | 87.750 s | 87.749 s |
|
||||
| `BGM_001` | 173.821 s | 173.821 s |
|
||||
|
||||
🔴 **An explanation I gave here was wrong and is withdrawn (2026-08-29).** It
|
||||
said `BGM_001`'s declared **173.821 s** disagreed with a decoded **167.663 s**,
|
||||
and that "declared covers the encoded stream including its trailing silence; the
|
||||
decoded figure is where the audio stops". **There is no disagreement to explain.**
|
||||
A full decode of `BGM_001` yields **173.809 s of PCM** — the 167.663 s is where
|
||||
the music *fades out*, measured from the audio, and the stream continues silent
|
||||
to its declared end **inside** that decode. Declared and decoded agree.
|
||||
|
||||
✅ **The declared-rate method is now cross-checked on three banks against
|
||||
independent decodes**, and it is better than the first version of this page
|
||||
claimed:
|
||||
|
||||
| bank | declared | decoded | agreement |
|
||||
|---|---|---|---|
|
||||
| `BGM_103` | 87.750 / 87.749 s | **87.744 s** | 5–6 ms |
|
||||
| `BGM_102` | 37.487 s | **37.482 s** | 5 ms |
|
||||
| `BGM_001` | 173.821 s | **173.809 s** | 12 ms |
|
||||
|
||||
⚠️ The conclusion that survives unchanged is the useful one: **trust it for
|
||||
lengths, not for musical boundaries.** A bank's declared length includes whatever
|
||||
silence the encode carries, so it is not a loop point — that has to be measured
|
||||
from the audio, and for `BGM_001` that is 167.663 s, 6.1 s before the stream
|
||||
ends.
|
||||
125
docs/re/boot-order-and-splash-dwell.md
Normal file
125
docs/re/boot-order-and-splash-dwell.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# The boot's first ten seconds: publisher, then developer — and the movie has a logo card too
|
||||
|
||||
**Status:** ✅ `CONFIRMED`. The **order** is measured in three independent cold
|
||||
boots and confirmed **by eye**, not only by a correlation. The **dwells** are
|
||||
**decoded** — they are on the disc, and the running game reproduces them to
|
||||
within the emulator's own frame pacing.
|
||||
|
||||
Raised by the port agent against `data/boot-timeline-2026-08-29.tsv`, whose
|
||||
`label` column runs `splash_dev` *before* `splash_pub` — the opposite of what
|
||||
`authored/flow.json` carries. If that ordering were real it would be a boot-order
|
||||
bug in the port. **It is not real, and this page says why.**
|
||||
|
||||
## The order: publisher first
|
||||
|
||||

|
||||
|
||||
*Brightened ×6 — these frames have a surface mean of ≈5/255. Top-left and
|
||||
top-right are the two splashes; the bottom row is what comes next and is NOT a
|
||||
splash.*
|
||||
|
||||
| run | `SQUARE ENIX` (publisher) | `GAME ARTS`/`SETA`/`studio anima` (developer) |
|
||||
|---|---|---|
|
||||
| 1 | 3.046 → 7.343 s | 7.683 → 11.191 s |
|
||||
| 2 | 1.180 → 5.784 s | 6.051 → 9.554 s |
|
||||
| 3 | 1.192 → 5.562 s | 5.929 → 9.295 s |
|
||||
|
||||
Three cold boots, `t = 0` at process launch, a ~0.2–0.3 s black hold between
|
||||
them. **Publisher first, every time**, and the top row of the contact sheet is
|
||||
what the two segments actually show. `docs/game/navigation.md` §1 and
|
||||
[`ui-title-build-map.md`](ui-title-build-map.md) stand.
|
||||
|
||||
## Why the committed TSV says otherwise — the probe attached late
|
||||
|
||||
`data/boot-timeline-2026-08-29.tsv` opens at `t = 0.692` with twelve
|
||||
**byte-identical** rows: mean `5.642`, `splash_dev` `+0.8707`, `splash_pub`
|
||||
`+0.0294`, to four decimals. Twelve identical samples over 1.27 s are one
|
||||
observation of a held screen, not twelve.
|
||||
|
||||
Those exact numbers appear in my run 1 at **8.42 – 10.94 s** — same mean, same
|
||||
two correlations, same four decimals. So that capture's `t = 0` is roughly
|
||||
**7.7 s into the guest's boot**: the publisher splash had already been and gone
|
||||
before the stream opened, and the developer splash was simply the first thing the
|
||||
probe ever saw.
|
||||
|
||||
**The label column is right about what each frame is. It is wrong about what came
|
||||
before the file starts.** Nothing in the TSV is retracted; its *reach* is.
|
||||
|
||||
## The trap that makes this worse: `ADV.wmv` opens with a SQUARE ENIX card
|
||||
|
||||
After the developer splash there is a black hold and then a screen that scores
|
||||
**0.59 – 0.75** against `live-splash-publisher.png` — above the classifier's
|
||||
threshold, so it is labelled `splash_pub` a second time. In all three runs:
|
||||
|
||||
| run | second "splash_pub" |
|
||||
|---|---|
|
||||
| 1 | 17.793 → 20.301 s |
|
||||
| 2 | 16.930 → 20.664 s |
|
||||
| 3 | 14.669 → 17.308 s |
|
||||
|
||||
The bottom row of the contact sheet is that screen. It is a **blurred, bloomed
|
||||
SQUARE ENIX wordmark, lower in the frame** — the intro movie's own opening title
|
||||
card, i.e. `ADV.wmv` ([`movie-binding.md`](movie-binding.md)) already playing.
|
||||
The real splash's wordmark is sharp and centred; the movie's is soft and sits
|
||||
below centre.
|
||||
|
||||
⚠️ **So `splash_pub` is not a safe label once the movie has started.** A boot
|
||||
classifier keyed on `live-splash-publisher.png` will fire twice per boot. The
|
||||
discriminators that do work: the real splash holds *perfectly still* (identical
|
||||
frame statistics to four decimals for seconds at a time) and scores **0.93–0.94**;
|
||||
the movie card drifts continuously and never exceeds **0.76**.
|
||||
|
||||
## The dwells are on the disc — do not author them
|
||||
|
||||
Both splash bundles declare their whole life. Read with the corrected keyframe
|
||||
record layout ([`ui-keyframe-record-layout.md`](ui-keyframe-record-layout.md))
|
||||
and Q1's `1 unit = 1/60 s`:
|
||||
|
||||
| | declared | visible span | measured (run 1 / 2 / 3) |
|
||||
|---|---|---|---|
|
||||
| publisher, `palogo_sqex.t32` | α `0@15 → 255@30 → 255@235 → 232@239 → 32@251 → 0@255` | `15 → 255` = 240 u = **4.000 s** | 4.297 / 4.604 / 4.370 s |
|
||||
| developer, `palogo_gamearts.t32` (`seta`, `anima` identical) | α `0@15 → 255@30 → 255@190 → 232@194 → 32@206 → 0@210` | `15 → 210` = 195 u = **3.250 s** | 3.508 / 3.503 / 3.366 s |
|
||||
|
||||
Measured ÷ declared, over all six spans: **1.074, 1.151, 1.093, 1.079, 1.078,
|
||||
1.036** — mean **1.085**. A 30 Hz timeline stretched by 8.5 % is the game
|
||||
presenting at **27.6 fps**, which is the rate this corpus has measured
|
||||
independently three times (27.6 on the boot splash, 28.3 and 28.8 on the idle
|
||||
title — [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)).
|
||||
|
||||
✅ **Classified decoded.** The port reads **240 units** for the publisher and
|
||||
**195 units** for the developer and authors nothing.
|
||||
|
||||
⚠️ **Reach of the ±:** a correlation crossing a threshold during a fade is not a
|
||||
sharp edge, so an individual span is good to roughly ±0.2 s. The developer's
|
||||
first two runs agree to **5 ms**, which is the instrument at its best; run 2's
|
||||
publisher span (4.604 s) is the outlier and its onset at 1.180 s is early enough
|
||||
to be a stream still filling. The *order* does not depend on any of this.
|
||||
|
||||
## Method
|
||||
|
||||
`tools/re-capture/boot_timeline_probe.py`, whose `--control` was run first and
|
||||
passed **11/11** on the content classifier and **4/4** on the plate detector,
|
||||
including `live-splash-publisher.png → splash_pub` and
|
||||
`live-splash-developer.png → splash_dev` with each rejecting the other at
|
||||
**0.035**. So a 0.87 reading against the developer reference is a real match and
|
||||
not an artefact of two dark images: the two references are both dark and they do
|
||||
not correlate with each other.
|
||||
|
||||
Both splash references are themselves committed captures whose identity is
|
||||
independent of any correlation — `live-splash-publisher.png` was matched to
|
||||
`GP_TITLE` entry 10, whose elements are literally named `palogo_sqex`, and
|
||||
`live-splash-developer.png` to entry 11, `palogo_gamearts` / `palogo_seta` /
|
||||
`palogo_anima`.
|
||||
|
||||
Boots were cold: `/dev/shm/xenia_*` cleared, no pad input at any point.
|
||||
|
||||
## What this does not say
|
||||
|
||||
* Nothing here is about the **movie's** length or the attract loop; the runs were
|
||||
cut at 26–45 s. That half is in `data/boot-timeline-2026-08-29.tsv`, whose
|
||||
*intervals* are unaffected by the attach offset even though its absolute `t` is.
|
||||
* The 0.2–0.3 s black hold between the two splashes was not separately timed
|
||||
against the declared `palogo_eff0.prm` backdrop; it is consistent with the
|
||||
0.14–0.30 s black hold already measured between screens
|
||||
([`title-plate-delay-measured.md`](title-plate-delay-measured.md)) but that is
|
||||
a consistency note, not a measurement of this particular gap.
|
||||
168
docs/re/boot-settle-times-measured.md
Normal file
168
docs/re/boot-settle-times-measured.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# ✅ `settle_time()` — when each boot screen actually arrives, measured
|
||||
|
||||
**Classification: measured.** None of this is on the disc as a settle time. The
|
||||
disc declares *keyframes*; when a screen visibly arrives is a property of the
|
||||
running game, and the port authors these numbers from this page.
|
||||
|
||||
Answers the port's standing ask: its boot sequencer paces every screen off
|
||||
`rest.t`, which is the **last hold keyframe** and not when a screen arrives — it
|
||||
holds the title for 4.350 s where the build-in is over at about 2 s.
|
||||
|
||||
**Run:** one cold boot, 2026-08-29. Container had **no Xenia storage root at
|
||||
all**, so this is a fresh profile (`--create_profile_if_none`) with no shader
|
||||
cache — the slowest case, deliberately.
|
||||
Frame log committed at [`data/boot-settle-run1.tsv`](data/boot-settle-run1.tsv);
|
||||
analysis `tools/re-capture/settle_analyse.py`.
|
||||
|
||||
## The instrument was controlled first, and then caught being wrong
|
||||
|
||||
`title_timing_probe.py --control` passed **9/9** on the content classifier —
|
||||
including the movie-frame and `difficulty-screen` negatives — and **4/4** on the
|
||||
plate detector, before the run. The run itself sampled **2 107 frames in 263.7 s
|
||||
= 7.99 fps against a requested 8**, with an independent one-shot grab every 20 s
|
||||
agreeing with the stream to under 1 grey level. So this is not the backlog
|
||||
failure mode that cost this corpus four withdrawn durations.
|
||||
|
||||
🔴 **And the probe's own `title_static` mark is still biased early — do not use
|
||||
it for a duration.** It fires when the classifier first labels a frame
|
||||
`title_*` *and* motion is low, which happens **during the crossfade out of the
|
||||
attract movie, before the wordmark has drawn**. In this run it fired at
|
||||
`t=242.655` while the green-glyph count was still **0**; the title art does not
|
||||
reach its steady state until `243.595`. Any `title_static → plate` figure from
|
||||
this probe is therefore inflated by about a second.
|
||||
|
||||
## The landmarks, taken from content rather than from the probe's marks
|
||||
|
||||
The robust landmark is the **glyph plateau**: the title art alone scores a steady
|
||||
`glyph = 154` (the corpus's `live-title-build4-no-plate.png` scores 159), and the
|
||||
plate takes it past 700.
|
||||
|
||||
| landmark | t (s) | how |
|
||||
|---|---|---|
|
||||
| attract movie ends, first title ink | 243.362 | glyph leaves 0 |
|
||||
| **title art fully drawn** | **243.595** | glyph reaches its steady 154 |
|
||||
| **`PRESS Ⓐ` plate on** | **245.842** | glyph crosses 400 → 771 |
|
||||
| Ⓐ pressed | 250.983 | |
|
||||
| ⚠️ guest load stall | 251.601 – 253.135 | 13 byte-identical frames |
|
||||
| **main menu arrives** | **254.707** | classifier |
|
||||
| **main menu settled** | **255.238** | motion below a run-calibrated floor |
|
||||
| Ⓑ pressed | 262.864 | |
|
||||
| **title back** | **263.346** | |
|
||||
|
||||
### What the port should author
|
||||
|
||||
| | measured | ⚠️ |
|
||||
|---|---|---|
|
||||
| title build-in (first ink → fully drawn) | **0.23 s** | from first ink; **1.63 s** from the first frame the classifier calls `title_*`, which is where the crossfade starts |
|
||||
| **title settled → plate on** | **2.247 s** | matches the disc's declared **120 units** |
|
||||
| plate pulse period | **≈2.37 s** | trough-to-trough, 248.098 → 250.467 |
|
||||
| **main menu build-in** | **0.531 s** | |
|
||||
| **Ⓑ → title** | **0.482 s** | |
|
||||
| Ⓐ → menu | 3.763 s | 🔴 **do not author** — contains a 1.53 s emulator load stall, below |
|
||||
|
||||
🔴 **`rest.t` is confirmed to be the wrong landmark.** The title's `rest.t` is 251
|
||||
units = 4.183 s; its art is finished at ~2 s and the plate is on at 2.25 s. A
|
||||
sequencer pacing off `rest.t` holds the title roughly twice as long as the game
|
||||
does.
|
||||
|
||||
## 🟢 A refutation attempt that FAILED — the corpus's 2.13 s plate delay survives
|
||||
|
||||
The probe's own marks gave a plate delay of **3.203 s** (`title_static` 242.655 →
|
||||
`plate` 245.858), against the corpus's two committed runs at **2.138 / 2.132 s**
|
||||
and a declared 120 units ≈ 2.0 s. A 50 % disagreement, from a cold-cache boot, is
|
||||
exactly where you would expect the corpus to be wrong.
|
||||
|
||||
**It is not. The instrument was** — but 🔴 **one of the two arguments I gave for
|
||||
that is withdrawn (2026-08-29).**
|
||||
|
||||
* 🔴 **WITHDRAWN — "the presentation rate is not depressed in this run".** This
|
||||
said the plate **pulse period** acts as an internal clock and measured
|
||||
**2.369 s** against the corpus's ≈2.3 s. Re-examined, that estimate rests on
|
||||
**one interval between two distinct troughs**, at a 125 ms sample interval, so
|
||||
its uncertainty is **±0.177 s (±6.7 %)** — and trough-picking on a noisy
|
||||
plateau is fragile enough that re-running it gave **2.628 s** rather than
|
||||
2.369, because an adjacent local minimum had been taken as a separate trough.
|
||||
Against the corpus's ≈2.24 s that is **+17.3 %**, about 2σ. So the pulse
|
||||
period does **not** show the run running at normal speed; it is simply too
|
||||
weak to show anything, and **it cannot resolve a real-time factor below ~7 %
|
||||
at all**. It should never have carried the argument.
|
||||
* ✅ **The conclusion survives on the other leg, which is the sound one.**
|
||||
Re-measured from content rather than from the probe's mark, steady title art
|
||||
(`243.595`) → plate on (`245.842`) is **2.247 s**, and that agrees with the
|
||||
corpus's three independent readings — 2.13 / 2.132 / 2.138 — to within the
|
||||
±0.125 s sample interval. Both of its landmarks are sharp content transitions
|
||||
(a glyph plateau, and a glyph crossing), unlike a trough on a noisy plateau.
|
||||
A 17 % slowdown would have put this at 2.49 s; it did not.
|
||||
|
||||
⚠️ **What that leaves open, and the port authors from these numbers:** this run
|
||||
carries an unmeasured real-time factor somewhere under ~7 %, because nothing in
|
||||
it was precise enough to pin one. The plate delay is anchored by agreement with
|
||||
three prior runs; **the menu build-in (0.531 s) and Ⓑ→title (0.482 s) are not
|
||||
anchored by anything**, and a few per cent of emulator slowdown sits inside them
|
||||
undetected. They were already flagged as one-run figures; this is the second
|
||||
reason to treat them as provisional.
|
||||
|
||||
✅ So the 2.13 s stands, the 120-unit reading stands, and the 3.203 s is
|
||||
`title_static` firing during a crossfade. Recorded because a failed refutation is
|
||||
worth as much as a successful one, and because the next person to read the
|
||||
probe's `title_static` will otherwise repeat it.
|
||||
|
||||
## 🟢 And the load stall reproduces — third independent run, cold cache
|
||||
|
||||
[`title-plate-delay-measured.md`](title-plate-delay-measured.md) records a frozen
|
||||
frame on the Ⓐ path in two runs: **14 frames (1.53 s)** and **12 frames
|
||||
(1.39 s)**, both at surface mean **26.626**, and concludes any Ⓐ→menu figure from
|
||||
this harness is an emulator load time rather than a game constant.
|
||||
|
||||
This run is a third: **13 frames, 251.601 – 253.135 = 1.53 s, surface mean
|
||||
26.631**, labelled `title_plate` throughout.
|
||||
|
||||
✅ The claim survives, and is now stronger in a way the earlier runs could not
|
||||
show: this boot had **no shader cache at all**, so the stall is not a warm-cache
|
||||
artefact. ⚠️ One honest qualification — the earlier note says its two runs agreed
|
||||
"to six decimals"; mine agrees only to three (26.631 vs 26.626), so the mean is
|
||||
reproducible but not byte-identical across all three.
|
||||
|
||||
## Reach
|
||||
|
||||
* **One run.** The durations above are one cold boot. The two that are
|
||||
cross-checked against independent evidence — the plate delay, against the
|
||||
corpus's two runs and the disc's 120 units; the load stall, against two prior
|
||||
runs — are the ones to lean on. The menu build-in (0.531 s) and Ⓑ→title
|
||||
(0.482 s) rest on **this run alone**.
|
||||
🟢 **The re-take was offered and declined, 2026-08-29**: the port authors
|
||||
neither number, is already within ~0.1 s of both from the disc's own
|
||||
keyframes, and asked that no emulator time be spent on its account. Authoring a
|
||||
one-run measurement over a decoded value would gain nothing measurable. Left
|
||||
provisional deliberately rather than for want of a run.
|
||||
* **Sampling is 8 fps**, so every landmark carries ±0.125 s, and the guest's own
|
||||
presentation rate cannot be measured from it — 8 fps is far below the ~28 fps
|
||||
the game presents at, so every sample is a distinct guest frame and repeats
|
||||
only appear when the guest itself stalls.
|
||||
* The splash dwells are **not** re-measured here; they are already in
|
||||
[`boot-order-and-splash-dwell.md`](boot-order-and-splash-dwell.md).
|
||||
|
||||
---
|
||||
|
||||
## 🟢 The consumer's own red flag was larger than this measurement supports
|
||||
|
||||
Recorded because it is the outcome of the measurement and it went the other way
|
||||
from what the port expected.
|
||||
|
||||
The port's standing 🔴 read: *"`rest.t` is the wrong landmark, therefore
|
||||
everything the sequencer paces off it is late."* **The premise is confirmed here
|
||||
and the consequence is not.** Measuring the port the same way this page measures
|
||||
the game — visible span, per-frame greyscale mean — its publisher wordmark runs
|
||||
4.25 s against three cold boots at 4.297 / 4.604 / 4.370, and its developer logos
|
||||
3.50 s against 3.508 / 3.503 / 3.366.
|
||||
|
||||
⚠️ **The discrepancy it was about to chase was the plate-delay trap in a second
|
||||
place.** It had been comparing *arrival-to-arrival* transition timestamps against
|
||||
*visible spans*; those differ by the exit ramp plus the black hold, about 0.6 s,
|
||||
which was the whole of it — the same shape as timing the title from where it
|
||||
stops animating rather than from where it first appears.
|
||||
|
||||
✅ So the generalisation this page supports is narrower than "the sequencer is
|
||||
late": **`rest.t` is the wrong landmark for the title specifically**, where it
|
||||
overstates by 4.183 s against ~2 s. Whether any *other* screen is mis-paced does
|
||||
not follow from it and was not measured here.
|
||||
@@ -1,3 +1,62 @@
|
||||
# ✅ WITHDRAWN 2026-08-29 (later the same day) — the interactive title IS reachable here, twice, with no pad input
|
||||
|
||||
**This banner supersedes everything below it about the title being unreachable,
|
||||
and it supersedes the 🔴 "Emulator-side questions are blocked" section of
|
||||
[MISSION](../port/MISSION.md).** Everything below is kept because the harness
|
||||
defects it diagnoses were real and the fixes are in use; what it concluded about
|
||||
the *game* is now refuted by measurement.
|
||||
|
||||
**Two consecutive boots reached the interactive title, with the `PRESS Ⓐ BUTTON`
|
||||
plate, without a single pad press before it:**
|
||||
|
||||
| | run 1 | run 2 |
|
||||
|---|---|---|
|
||||
| plate on screen at | **205.4 s** into the probe | **218.4 s** |
|
||||
| pad input before that | **none** | **none** |
|
||||
| Ⓐ then reached the main menu | ✅ | ✅ |
|
||||
| Ⓑ then returned to the title | ✅ | ✅ |
|
||||
|
||||
Full per-frame traces, 8 fps, 1783 and 1886 frames:
|
||||
[`data/plate-timing-run1.tsv`](data/plate-timing-run1.tsv) ·
|
||||
[`data/plate-timing-run2.tsv`](data/plate-timing-run2.tsv). The measurement they
|
||||
were taken for is [`title-plate-delay-measured.md`](title-plate-delay-measured.md).
|
||||
|
||||
So the standing negative — "three runs, two locales, two launch paths, ~35
|
||||
minutes of emulator time, no interactive title" — does not hold in this
|
||||
container today. **The attract loop is simply passed through in ~3.5 minutes and
|
||||
the title follows.**
|
||||
|
||||
## ❔ What changed is NOT established, and I am not going to guess it
|
||||
|
||||
What is different about this container, stated as facts rather than as a cause:
|
||||
|
||||
* it came up with **no Xenia storage root at all** — no
|
||||
`~/.local/share/Xenia`, so no profile, no `xconfig.settings`, and no shader
|
||||
cache. The earlier runs signed in a profile that already existed.
|
||||
* run 1 therefore had to create one, with canary's own
|
||||
`--create_profile_if_none=Decoder`. Run 2 signed in the profile run 1 made
|
||||
(`B13EBABEBABEBABE`).
|
||||
* the launch was otherwise `boot_menu.sh`'s, minus `skip_intro.sh` — this
|
||||
measurement had to leave the title untouched, so nothing tapped Ⓐ at all.
|
||||
|
||||
⚠️ **A cold profile is a correlation across two runs, not a cause.** It is
|
||||
written down so the next session can test it directly (delete the storage root,
|
||||
boot, compare) instead of re-deriving that the title is reachable.
|
||||
|
||||
## 🔵 What this unblocks
|
||||
|
||||
* the **Japanese-locale capture** that MISSION parks as "🟡 needs one more run":
|
||||
the mechanism (`set_console_language.py ja`, `user.language` at file offset
|
||||
`0x912`) is in place, and the reason it was parked — *the title never
|
||||
appears* — is gone. ⚠️ Note the storage root is new, so `xconfig.settings` has
|
||||
been recreated and the byte offset should be re-located by its three landmarks
|
||||
rather than assumed.
|
||||
* the two items MISSION lists as emulator-blocked: the gamma control behind
|
||||
[tone curve](structures/ui-render-tone-curve.md), and separating `8AX` from
|
||||
`ptbase` in [8AX](structures/ui-8ax-fullres-background.md).
|
||||
|
||||
---
|
||||
|
||||
# 🔴 Why the boot harness stopped reaching the title — `screenshot` costs 10.8 s
|
||||
|
||||
**Status:** ✅ **diagnosed, with a control.** Four consecutive runs on
|
||||
@@ -362,3 +421,173 @@ The gamma run's flags plainly took effect — that run is where
|
||||
`VdGetCurrentDisplayGamma` was captured — while its dump showed the file's
|
||||
values. So the dump reflects the config file and cannot confirm or refute a
|
||||
command-line override.
|
||||
|
||||
---
|
||||
|
||||
# ✅ 2026-08-29 (later) — the disc is back, and the section below is withdrawn as CURRENT status
|
||||
|
||||
Kept for its history, not as a live claim. The container was replaced: PID 1
|
||||
here started at **11:07:38 UTC**, 25 minutes after commit `b9aca6a` wrote the
|
||||
section below at 10:42, and the replacement has the disc mounted.
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| `/proc/mounts` | `/dev/sda2 /disc ext4 ro,relatime` — a real bind mount |
|
||||
| device | `/disc` is device **2050**; `/` is device **92** |
|
||||
| size | 6.2 GB, 74 entries under `dat/`, `default.xex` = 3 497 984 B |
|
||||
| ISO | `/iso/game.iso`, 7 835 492 352 B |
|
||||
| end to end | `sylpheed-cli screen list /disc/dat/GP_TITLE.pak` → 12 builds, element/sprite counts matching the committed build map |
|
||||
|
||||
⚠️ **Two instruments would have said "no disc" either way, and both are still
|
||||
in place.** This is the reusable lesson, and it is worth more than the
|
||||
resolved incident:
|
||||
|
||||
* **`find / -xdev` cannot see `/disc`.** `-xdev` refuses to cross a filesystem
|
||||
boundary; `/disc` is on a different device from `/`. The withdrawn section's
|
||||
headline measurement — "no ISO, no `default.xex`, no `GP_TITLE.pak` anywhere"
|
||||
— is what that command returns **whether or not the disc is mounted**. It had
|
||||
no reach over the question it was used to answer.
|
||||
* **`sylph-doctor` never checks `$SYLPHEED_DISC`.** Its two disc lines are
|
||||
`find /work -maxdepth 2 -iname '*.iso'` and `[ -d /work/sylph_extract/dat ]`
|
||||
(lines 79–82). With the disc at `/disc` it reports "no ISO under /work" and
|
||||
"no extracted disc — Reborn disc tests will SKIP" — as it does right now,
|
||||
against a working disc. "`sylph-doctor` agrees" was two instruments sharing
|
||||
one blind spot, not corroboration.
|
||||
|
||||
**To check for the disc, ask the variable that names it**: `ls "$SYLPHEED_DISC/dat"`,
|
||||
or `sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_TITLE.pak"`, which fails
|
||||
loudly and cheaply.
|
||||
|
||||
# 🔴 2026-08-29 — the disc is not in the decoder container at all *(WITHDRAWN — see the section immediately above)*
|
||||
|
||||
**Status:** ✅ **diagnosed, root-caused in the launcher.** This supersedes every
|
||||
"the emulator did not reach the title" entry above as the *current* reason the
|
||||
oracle is unavailable: there is no game to run.
|
||||
|
||||
## The measurement
|
||||
|
||||
| looked for | result |
|
||||
|---|---|
|
||||
| `find / -xdev -iname '*.iso'` | **0** |
|
||||
| `find / -xdev -iname 'default.xex'` | **0** |
|
||||
| `find / -xdev -iname 'GP_TITLE.pak'` | **0** |
|
||||
| `$SYLPHEED_DISC` | **empty** |
|
||||
| `/work/sylph_extract` | does not exist |
|
||||
| `/exchange/files` | **empty** |
|
||||
|
||||
`sylph-doctor` agrees and says so in its own words:
|
||||
|
||||
```
|
||||
── project ──
|
||||
✖ /work/xenia-canary not mounted
|
||||
✖ /work/Syplheed-Reborn not mounted
|
||||
! no ISO under /work — run-canary needs SYLPH_ISO
|
||||
! no extracted disc — Reborn disc tests will SKIP
|
||||
```
|
||||
|
||||
Everything else is healthy: `xenia_canary` is built and present, display `:98`
|
||||
is up, `screenshot` works, Vulkan (llvmpipe) enumerates, cargo and the python
|
||||
stack are fine. **The emulator has no disc to boot.**
|
||||
|
||||
## The cause — the volume migration, and a mount nobody replaced
|
||||
|
||||
Before [`06676d3`](#) the launcher bind-mounted the human's working tree:
|
||||
|
||||
```
|
||||
-v "$PROJECT:$PROJECT"
|
||||
-v "$PROJECT:/work"
|
||||
```
|
||||
|
||||
The ISO and `sylph_extract/` live in that tree, so the disc arrived **incidentally
|
||||
with the repository mount**, and `run-canary`'s `find "$PROJECT_DIR" -maxdepth 2
|
||||
-iname '*.iso'` found it.
|
||||
|
||||
`06676d3` replaced that with the agent's own clone in a named volume —
|
||||
|
||||
```
|
||||
-v "sylpheed-decoder-repo:/work"
|
||||
```
|
||||
|
||||
— which is the right fix for the collision class it was written for, and it
|
||||
removed the disc along with the working tree. **Nothing was added to replace
|
||||
it.** The launcher still forwards
|
||||
|
||||
```
|
||||
[ -n "${SYLPH_ISO:-}" ] && _out+=(-e "SYLPH_ISO=$SYLPH_ISO")
|
||||
```
|
||||
|
||||
but that is an **environment variable with no bind mount behind it** — it names a
|
||||
host path that does not exist inside the container, so it cannot help.
|
||||
|
||||
**The port container does not have this bug.** `docker/port/sylph-port` mounts
|
||||
the disc explicitly:
|
||||
|
||||
```
|
||||
_out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
|
||||
```
|
||||
|
||||
So the one container that *owns* the disc and the oracle is the one container
|
||||
without them.
|
||||
|
||||
## Reach of the negative
|
||||
|
||||
Whole-filesystem, single pass, `-xdev` per mount, three independent names (the
|
||||
ISO, the executable, a pak the corpus names constantly). The exchange volume is
|
||||
empty, so the disc is not arriving by `share` either. This is not "I looked in
|
||||
the usual place".
|
||||
|
||||
## What it blocks — everything disc-side and everything dynamic
|
||||
|
||||
* the **oracle** — no boot, no capture, no `run-canary`;
|
||||
* every `sylpheed-cli` invocation that names a pak — `screen list`, `screen info`,
|
||||
`screen render`, `pak textures`;
|
||||
* `build-reborn test` — the disc-gated tests self-skip, and per MISSION a green
|
||||
run then means almost nothing. (`build-reborn` is *also* pointing at
|
||||
`/work/Syplheed-Reborn`, a path the monorepo no longer has.)
|
||||
* **static RE of the executable** — the XEX is on the disc, so the whole
|
||||
PPC-disassembly route is shut too, not just the dynamic one.
|
||||
|
||||
## What it does not block
|
||||
|
||||
The committed corpus. `docs/re/captures/` is 99 MB of oracle frames and
|
||||
`docs/re/data/` 2.5 MB of extracted tables, both in git — enough to re-measure
|
||||
against captures, which is what this iteration did instead.
|
||||
|
||||
## 🔵 For the human — the one-line fix
|
||||
|
||||
Add a disc mount to `docker/decoder/sylph-decoder`, the way `sylph-port` already
|
||||
has one:
|
||||
|
||||
```bash
|
||||
[ -d "$DISC" ] && _out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
|
||||
[ -f "$SYLPH_ISO" ] && _out+=(-v "$SYLPH_ISO:/disc.iso:ro" -e "SYLPH_ISO=/disc.iso")
|
||||
```
|
||||
|
||||
Recorded rather than worked around, per *do not improvise around a blocker* —
|
||||
and **not attempted**, because the launcher runs on the host and this container
|
||||
cannot restart itself.
|
||||
|
||||
⚠️ `sylph-doctor` reports the missing ISO as `!` (a warning) rather than `✖`. For
|
||||
the decoder that is not a warning: it is the difference between having an oracle
|
||||
and not having one.
|
||||
|
||||
### A second, smaller consequence of the same migration — no git identity
|
||||
|
||||
`git commit` in a fresh decoder container fails with *"Author identity
|
||||
unknown"*: nothing in the image, the entrypoint or `sylph-decoder` sets
|
||||
`user.name` / `user.email`, and the old bind mount used to bring the human's
|
||||
`.git/config` along with the tree.
|
||||
|
||||
Set locally, per iteration if the volume is recreated:
|
||||
|
||||
```bash
|
||||
git config --local user.name "sylph-decoder"
|
||||
git config --local user.email "fabian@diekaulbachs.de"
|
||||
```
|
||||
|
||||
⚠️ `push-work`'s header warns at length against `git config --local`, because
|
||||
the credential helper it wrote there leaked a container-only path onto the host.
|
||||
**That warning no longer applies to identity**: `/work` is a private named
|
||||
volume now, not a shared bind mount, so nothing written to its `.git/config`
|
||||
can reach a host checkout. The credential helper is still applied per-invocation
|
||||
with `-c`, and should stay that way.
|
||||
|
||||
2733
docs/re/data/a-press-ab-legs.txt
Normal file
2733
docs/re/data/a-press-ab-legs.txt
Normal file
File diff suppressed because it is too large
Load Diff
138
docs/re/data/a-press-fault-log-extract.txt
Normal file
138
docs/re/data/a-press-fault-log-extract.txt
Normal file
@@ -0,0 +1,138 @@
|
||||
# Extract from the Ⓐ-press fault run's xenia.log (2026-08-29)
|
||||
#
|
||||
# Source: /sylph-home/re/canary-build/bin/Linux/Release/xenia.log, 326 921 343 bytes,
|
||||
# mtime 2026-08-29 22:15. 32 356 '==== CRASH DUMP ====' blocks. This file is the
|
||||
# part that carries the diagnosis; the whole log is not committed (326 MB).
|
||||
#
|
||||
# ---- 1. the three real Ⓐ keystrokes, then the swallow begins (log lines 1180-1262)
|
||||
|
||||
i> 0100000C [UI-CAP] writing xenia_re_ui_draws_01.log (from frame 0)
|
||||
!> 0001278F MEM-WATCH rss=590MB (peak 590MB) vsz=19185MB malloc_inuse=313MB mmap=90MB cache_deque=40 cache_list=40
|
||||
!> 0001278F MEM-WATCH rss=590MB (peak 590MB) vsz=19189MB malloc_inuse=313MB mmap=90MB cache_deque=40 cache_list=40
|
||||
i> F8000008 [file-pad] #3 buttons=1000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
|
||||
i> F8000008 [file-pad] keystroke vk=5800 down
|
||||
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0001 (call flags 00000003)
|
||||
i> F8000008 [file-pad] #4 buttons=0000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
|
||||
i> F8000008 [file-pad] keystroke vk=5800 up
|
||||
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0002 (call flags 00000003)
|
||||
!> 0001278F MEM-WATCH rss=594MB (peak 594MB) vsz=19189MB malloc_inuse=314MB mmap=90MB cache_deque=40 cache_list=40
|
||||
!> 0001278F MEM-WATCH rss=595MB (peak 595MB) vsz=19189MB malloc_inuse=313MB mmap=90MB cache_deque=40 cache_list=40
|
||||
i> F8000008 XThreadF80000B8 (1A) Stack: 70880000-70900000
|
||||
K> F80000B8 XThread::Execute thid 26 (handle=F80000B8, 'XThread76FFE6C0 (F80000B8)', native=76FFE6C0)
|
||||
F> F80000B8 HostPathDevice::ResolvePath(\aab216c3\5\c10eae6)
|
||||
F> F80000B8 HostPathDevice::ResolvePath(\aab216c3\5)
|
||||
F> F8000084 DiscImageDevice::ResolvePath(\dat)
|
||||
F> F8000008 DiscImageDevice::ResolvePath(\dat\movie)
|
||||
F> F8000008 DiscImageDevice::ResolvePath(\dat\movie)
|
||||
i> F8000008 XThreadF8000154 (1B) Stack: 70880000-70890000
|
||||
i> F8000008 XThreadF8000158 (1C) Stack: 708B0000-708C0000
|
||||
i> F8000008 XThreadF800015C (1D) Stack: 708E0000-708F0000
|
||||
i> F8000008 XThreadF8000160 (1E) Stack: 70910000-70920000
|
||||
K> F8000158 XThread::Execute thid 28 (handle=F8000158, 'XThread6FFFF6C0 (F8000158)', native=6FFFF6C0)
|
||||
K> F8000154 XThread::Execute thid 27 (handle=F8000154, 'XThread75FFD6C0 (F8000154)', native=75FFD6C0)
|
||||
K> F800015C XThread::Execute thid 29 (handle=F800015C, 'XThread6EFFE6C0 (F800015C)', native=6EFFE6C0)
|
||||
!> 0001278F MEM-WATCH rss=660MB (peak 660MB) vsz=19473MB malloc_inuse=324MB mmap=90MB cache_deque=40 cache_list=40
|
||||
!> 0001278F MEM-WATCH rss=661MB (peak 661MB) vsz=19473MB malloc_inuse=324MB mmap=90MB cache_deque=40 cache_list=40
|
||||
!> 0001278F MEM-WATCH rss=661MB (peak 661MB) vsz=19473MB malloc_inuse=324MB mmap=90MB cache_deque=40 cache_list=40
|
||||
i> F8000008 [file-pad] #5 buttons=1000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
|
||||
i> F8000008 [file-pad] keystroke vk=5800 down
|
||||
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0001 (call flags 00000003)
|
||||
i> F8000008 [file-pad] #6 buttons=0000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
|
||||
i> F8000008 [file-pad] keystroke vk=5800 up
|
||||
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0002 (call flags 00000003)
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F8000154
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F8000158
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F800015C
|
||||
K> F8000160 XThread::Execute thid 30 (handle=F8000160, 'XThread6DFFD6C0 (F8000160)', native=6DFFD6C0)
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F8000154
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F8000158
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F800015C
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
|
||||
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
|
||||
!> F8000008 BaseHeap::Release failed because address is not a region start: addr=1E4B0E00 heap_base=00000000 page=124080 owning_region_start=1C220000 region_page_count=14976 state=03
|
||||
!> F8000008 PhysicalHeap::Release failed due to parent heap failure
|
||||
!> F8000008 BaseHeap::Release failed because address is not a region start: addr=1E7A8F00 heap_base=00000000 page=124840 owning_region_start=1C220000 region_page_count=14976 state=03
|
||||
!> F8000008 PhysicalHeap::Release failed due to parent heap failure
|
||||
F> F8000084 DiscImageDevice::ResolvePath(\dat)
|
||||
!> 0001278F MEM-WATCH rss=830MB (peak 830MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40
|
||||
!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40
|
||||
!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40
|
||||
!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40
|
||||
!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40
|
||||
!> 0001278F MEM-WATCH rss=832MB (peak 832MB) vsz=19501MB malloc_inuse=348MB mmap=90MB cache_deque=40 cache_list=40
|
||||
!> 0001278F MEM-WATCH rss=832MB (peak 832MB) vsz=19501MB malloc_inuse=348MB mmap=90MB cache_deque=40 cache_list=40
|
||||
i> F8000008 [file-pad] #7 buttons=1000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
|
||||
i> F8000008 [file-pad] keystroke vk=5800 down
|
||||
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0001 (call flags 00000003)
|
||||
i> F8000008 XThreadF80000D4 (1F) Stack: 70880000-70900000
|
||||
K> F80000D4 XThread::Execute thid 31 (handle=F80000D4, 'XThread9BFFF6C0 (F80000D4)', native=9BFFF6C0)
|
||||
F> F80000D4 HostPathDevice::ResolvePath(\aab216c3\a\c7e701e)
|
||||
F> F80000D4 HostPathDevice::ResolvePath(\aab216c3\a)
|
||||
F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\e\b80b1a0)
|
||||
F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\e)
|
||||
F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\c\6dea48b)
|
||||
F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\c)
|
||||
i> F8000008 [file-pad] #8 buttons=0000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
|
||||
i> F8000008 [file-pad] keystroke vk=5800 up
|
||||
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0002 (call flags 00000003)
|
||||
!> 0001278F MEM-WATCH rss=833MB (peak 833MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=41
|
||||
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 1 so far)
|
||||
!> F8000008 BaseHeap::Release failed because address is not a region start: addr=1DA98C80 heap_base=00000000 page=121496 owning_region_start=1C220000 region_page_count=14976 state=03
|
||||
!> F8000008 PhysicalHeap::Release failed due to parent heap failure
|
||||
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 601 so far)
|
||||
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 1201 so far)
|
||||
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 1801 so far)
|
||||
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 2401 so far)
|
||||
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 3001 so far)
|
||||
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 3601 so far)
|
||||
|
||||
# ---- 2. the last swallow report before the first crash dump (log line <15243)
|
||||
15236:w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 8388001 so far)
|
||||
15237:w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 8388601 so far)
|
||||
# total 'swallowed' report lines before the first crash dump:
|
||||
13982
|
||||
# they are emitted every 600th call, so swallowed calls ~= 600 x that count
|
||||
|
||||
# ---- 3. the first crash dump's GPRs (log line 15243+)
|
||||
!> F8000008 ==== CRASH DUMP ====
|
||||
Thread ID (Host: 0xEEFFE6C0 / Guest: 0x00000006)
|
||||
Thread Handle: 0xF8000008
|
||||
PC: 0x824578A0
|
||||
Access Violation: write at 0x00000001701D0000
|
||||
Registers:
|
||||
r0 = 0000000000000000
|
||||
r1 = 00000000701CF7B0
|
||||
r2 = 0000000020000000
|
||||
r3 = 00000000701CF5F0
|
||||
r4 = 0000000000000000
|
||||
r5 = 0000000000000000
|
||||
r6 = 0000000000000000
|
||||
r7 = 00000000A3AC0000
|
||||
r8 = 00000000701D0008
|
||||
r9 = 00000000701D0000
|
||||
r10 = 0000000000000000
|
||||
r11 = 00000000A3AC0A18
|
||||
r12 = 0000000082457864
|
||||
r13 = 000000003001E000
|
||||
r14 = 0000000000000000
|
||||
r15 = 0000000000000000
|
||||
r16 = 0000000000000000
|
||||
r17 = 0000000000000000
|
||||
r18 = 00000000BCE24BFC
|
||||
r19 = 0000000000000001
|
||||
r20 = 0000000000000000
|
||||
r21 = FFFFFFFFFFFFFFFF
|
||||
r22 = 00000000BC65D540
|
||||
r23 = FFFFFFFF828F3844
|
||||
r24 = 000000000000052F
|
||||
r25 = FFFFFFFF828E0000
|
||||
r26 = 0000000000800001
|
||||
r27 = 0000000001000000
|
||||
r28 = 00000000701CF898
|
||||
r29 = 0000000000800000
|
||||
r30 = FFFFFFFF828F38CC
|
||||
r31 = 00000000A7AC0000
|
||||
46
docs/re/data/adv-stream-assignment.txt
Normal file
46
docs/re/data/adv-stream-assignment.txt
Normal file
@@ -0,0 +1,46 @@
|
||||
# Which ADV voice stream sits where -- the assignment, and how it was settled.
|
||||
#
|
||||
# 2026-08-30. Chunks dumped by examples/adv_voice_dump.rs from the resolved
|
||||
# movie voice region (dat/sound 433930240..437044592), decoded with ffmpeg's
|
||||
# xma decoder to f32le 48 kHz stereo.
|
||||
#
|
||||
# chunk bytes byte_size probe ctx dur L rms R rms L/R r R silent
|
||||
# 0 806972 806912 ctx0 TAIL 84.55s -24.79 -24.81 +0.932 53.1%
|
||||
# 1 1118268 1118208 ctx1 137.32s -20.33 -inf +0.000 100.0%
|
||||
# 2 1171516 1171456 ctx2 137.32s -30.67 -30.68 +0.962 53.6%
|
||||
#
|
||||
# ctx0's full byte_size is 1294336; the region resolver starts at the
|
||||
# predecessor cue's trailer, so chunk 0 is its clipped tail (62 %).
|
||||
#
|
||||
# 🔴 WHAT DID NOT WORK: envelope correlation cannot discriminate.
|
||||
# Every residual channel shares the dialogue's activity timing, so a
|
||||
# per-pair lag search returns 0.86-0.95 for EVERY chunk against EVERY
|
||||
# channel. Recorded because it looks like a strong result and is not.
|
||||
#
|
||||
# 🔴 Sample-level correlation also fails: the chunks do not start with the
|
||||
# movie and the XMA decode's framing offset is unknown, so r ~ 0.
|
||||
#
|
||||
# ✅ WHAT SETTLES IT: level, under the SAME 0.600 gain the bed uses.
|
||||
#
|
||||
# chunk level x0.600 nearest residuals (|error| dB)
|
||||
# 0L -24.79 -29.23 FL 0.05 FR 0.06 FC 3.96
|
||||
# 0R -24.81 -29.25 FL 0.03 FR 0.04 FC 3.98
|
||||
# 1L -20.33 -24.77 FC 0.50 FL 4.51 FR 4.52
|
||||
# 2L -30.67 -35.11 BL 0.35 BR 0.38 FR 5.82
|
||||
# 2R -30.68 -35.12 BL 0.34 BR 0.37 FR 5.83
|
||||
#
|
||||
# Ratio test, immune to any worry about chunk 0 being clipped:
|
||||
# chunk0L - chunk2L = +5.88 dB ; FL - BL = +6.18 dB -> agree to 0.30 dB
|
||||
# swapped, the ratio would be wrong by 11.76 dB
|
||||
#
|
||||
# Structural confirmation: chunk 1 is the ONLY chunk with a digitally silent
|
||||
# channel (R, 100 %), and LFE is the ONLY output channel with an empty
|
||||
# residual (-115.73 dBFS). One-to-one.
|
||||
#
|
||||
# Internal L/R correlation also tracks:
|
||||
# chunk0 +0.932 <-> FL/FR residual +0.918
|
||||
# chunk2 +0.962 <-> BL/BR residual +0.929
|
||||
#
|
||||
# ==> ctx0 (1294336) -> FL, FR
|
||||
# ==> ctx1 (1118208) -> FC, LFE silent
|
||||
# ==> ctx2 (1171456) -> BL, BR
|
||||
2136
docs/re/data/boot-settle-run1.tsv
Normal file
2136
docs/re/data/boot-settle-run1.tsv
Normal file
File diff suppressed because it is too large
Load Diff
234
docs/re/data/boot-splash-gap-draws.csv
Normal file
234
docs/re/data/boot-splash-gap-draws.csv
Normal file
@@ -0,0 +1,234 @@
|
||||
# Every sprite quad submitted during the boot splashes, from the guest's
|
||||
# own draw stream. Capture: ui_draw_capture.sh GRACE=1 NOTAP=1 ARM=early,
|
||||
# 2026-08-29. Full-screen quads (w>=1250) are omitted.
|
||||
# frames 126..129 carry NO sprite quad at all -- that is the black gap.
|
||||
frame,x,y,w,h,vertex_alpha
|
||||
2,301,317,685,90,119
|
||||
3,301,317,685,90,153
|
||||
4,301,317,685,90,187
|
||||
6,301,317,685,90,246
|
||||
7,301,317,685,90,240
|
||||
9,301,317,685,90,229
|
||||
10,301,317,685,90,223
|
||||
11,301,317,685,90,220
|
||||
12,301,317,685,90,214
|
||||
13,301,317,685,90,211
|
||||
14,301,317,685,90,183
|
||||
15,301,317,685,90,155
|
||||
16,301,317,685,90,127
|
||||
17,301,317,685,90,98
|
||||
18,301,317,685,90,70
|
||||
19,301,317,685,90,56
|
||||
20,301,317,685,90,28
|
||||
21,307,331,666,65,255
|
||||
22,307,331,666,65,255
|
||||
23,307,331,666,65,255
|
||||
24,307,331,666,65,255
|
||||
25,307,331,666,65,255
|
||||
26,307,331,666,65,255
|
||||
27,307,331,666,65,255
|
||||
28,307,331,666,65,255
|
||||
29,307,331,666,65,255
|
||||
30,307,331,666,65,255
|
||||
31,307,331,666,65,255
|
||||
32,307,331,666,65,255
|
||||
34,307,331,666,65,255
|
||||
35,307,331,666,65,255
|
||||
37,307,331,666,65,255
|
||||
38,307,331,666,65,255
|
||||
39,307,331,666,65,255
|
||||
40,307,331,666,65,255
|
||||
41,307,331,666,65,255
|
||||
42,307,331,666,65,255
|
||||
43,307,331,666,65,255
|
||||
44,307,331,666,65,255
|
||||
45,307,331,666,65,255
|
||||
46,307,331,666,65,255
|
||||
47,307,331,666,65,255
|
||||
48,307,331,666,65,255
|
||||
49,307,331,666,65,255
|
||||
50,307,331,666,65,255
|
||||
51,307,331,666,65,255
|
||||
52,307,331,666,65,255
|
||||
53,307,331,666,65,255
|
||||
54,307,331,666,65,255
|
||||
55,307,331,666,65,255
|
||||
56,307,331,666,65,255
|
||||
57,307,331,666,65,255
|
||||
58,307,331,666,65,255
|
||||
59,307,331,666,65,255
|
||||
60,307,331,666,65,255
|
||||
61,307,331,666,65,255
|
||||
62,307,331,666,65,255
|
||||
63,307,331,666,65,255
|
||||
64,307,331,666,65,255
|
||||
65,307,331,666,65,255
|
||||
66,307,331,666,65,255
|
||||
67,307,331,666,65,255
|
||||
68,307,331,666,65,255
|
||||
69,307,331,666,65,255
|
||||
70,307,331,666,65,255
|
||||
71,307,331,666,65,255
|
||||
72,307,331,666,65,255
|
||||
73,307,331,666,65,255
|
||||
74,307,331,666,65,255
|
||||
75,307,331,666,65,255
|
||||
76,307,331,666,65,255
|
||||
77,307,331,666,65,255
|
||||
78,307,331,666,65,255
|
||||
79,307,331,666,65,255
|
||||
80,307,331,666,65,255
|
||||
81,307,331,666,65,255
|
||||
82,307,331,666,65,255
|
||||
83,307,331,666,65,255
|
||||
84,307,331,666,65,255
|
||||
85,307,331,666,65,255
|
||||
86,307,331,666,65,255
|
||||
87,307,331,666,65,255
|
||||
88,307,331,666,65,255
|
||||
89,307,331,666,65,255
|
||||
90,307,331,666,65,255
|
||||
91,307,331,666,65,255
|
||||
92,307,331,666,65,255
|
||||
93,307,331,666,65,255
|
||||
94,307,331,666,65,255
|
||||
95,307,331,666,65,255
|
||||
96,307,331,666,65,255
|
||||
97,307,331,666,65,255
|
||||
98,307,331,666,65,255
|
||||
99,307,331,666,65,255
|
||||
100,307,331,666,65,255
|
||||
102,307,331,666,65,255
|
||||
103,307,331,666,65,255
|
||||
105,307,331,666,65,255
|
||||
106,307,331,666,65,255
|
||||
107,307,331,666,65,255
|
||||
108,307,331,666,65,255
|
||||
109,307,331,666,65,255
|
||||
110,307,331,666,65,255
|
||||
111,307,331,666,65,255
|
||||
112,307,331,666,65,255
|
||||
113,307,331,666,65,249
|
||||
114,307,331,666,65,243
|
||||
115,307,331,666,65,231
|
||||
116,307,331,666,65,198
|
||||
117,307,331,666,65,181
|
||||
118,307,331,666,65,165
|
||||
119,307,331,666,65,148
|
||||
120,307,331,666,65,115
|
||||
121,307,331,666,65,81
|
||||
122,307,331,666,65,65
|
||||
123,307,331,666,65,31
|
||||
124,307,331,666,65,15
|
||||
125,307,331,666,65,7
|
||||
130,378,155,525,259,34
|
||||
131,378,155,525,259,51
|
||||
132,378,155,525,259,85
|
||||
133,378,155,525,259,119
|
||||
134,378,155,525,259,136
|
||||
135,378,155,525,259,153
|
||||
136,378,155,525,259,170
|
||||
137,378,155,525,259,221
|
||||
138,378,155,525,259,255
|
||||
139,378,155,525,259,255
|
||||
140,378,155,525,259,255
|
||||
141,378,155,525,259,255
|
||||
142,378,155,525,259,255
|
||||
143,378,155,525,259,255
|
||||
144,378,155,525,259,255
|
||||
145,378,155,525,259,255
|
||||
146,378,155,525,259,254
|
||||
147,378,155,525,259,220
|
||||
148,378,155,525,259,186
|
||||
149,378,155,525,259,152
|
||||
151,378,155,525,259,84
|
||||
152,378,155,525,259,50
|
||||
153,378,155,525,259,33
|
||||
154,390,162,499,241,255
|
||||
155,390,162,499,241,255
|
||||
156,390,162,499,241,255
|
||||
157,390,162,499,241,255
|
||||
158,390,162,499,241,255
|
||||
159,390,162,499,241,255
|
||||
160,390,162,499,241,255
|
||||
161,390,162,499,241,255
|
||||
162,390,162,499,241,255
|
||||
163,390,162,499,241,255
|
||||
164,390,162,499,241,255
|
||||
165,390,162,499,241,255
|
||||
166,390,162,499,241,255
|
||||
167,390,162,499,241,255
|
||||
168,390,162,499,241,255
|
||||
169,390,162,499,241,255
|
||||
170,390,162,499,241,255
|
||||
171,390,162,499,241,255
|
||||
172,390,162,499,241,255
|
||||
173,390,162,499,241,255
|
||||
174,390,162,499,241,255
|
||||
176,390,162,499,241,255
|
||||
177,390,162,499,241,255
|
||||
179,390,162,499,241,255
|
||||
180,390,162,499,241,255
|
||||
181,390,162,499,241,255
|
||||
183,390,162,499,241,255
|
||||
184,390,162,499,241,255
|
||||
185,390,162,499,241,255
|
||||
186,390,162,499,241,255
|
||||
187,390,162,499,241,255
|
||||
188,390,162,499,241,255
|
||||
189,390,162,499,241,255
|
||||
190,390,162,499,241,255
|
||||
191,390,162,499,241,255
|
||||
192,390,162,499,241,255
|
||||
193,390,162,499,241,255
|
||||
194,390,162,499,241,255
|
||||
195,390,162,499,241,255
|
||||
196,390,162,499,241,255
|
||||
197,390,162,499,241,255
|
||||
198,390,162,499,241,255
|
||||
199,390,162,499,241,255
|
||||
200,390,162,499,241,255
|
||||
201,390,162,499,241,255
|
||||
202,390,162,499,241,255
|
||||
203,390,162,499,241,255
|
||||
204,390,162,499,241,255
|
||||
205,390,162,499,241,255
|
||||
206,390,162,499,241,255
|
||||
207,390,162,499,241,255
|
||||
208,390,162,499,241,255
|
||||
209,390,162,499,241,255
|
||||
210,390,162,499,241,255
|
||||
211,390,162,499,241,255
|
||||
212,390,162,499,241,255
|
||||
213,390,162,499,241,255
|
||||
214,390,162,499,241,255
|
||||
215,390,162,499,241,255
|
||||
216,390,162,499,241,255
|
||||
217,390,162,499,241,255
|
||||
218,390,162,499,241,255
|
||||
219,390,162,499,241,255
|
||||
221,390,162,499,241,255
|
||||
223,390,162,499,241,255
|
||||
224,390,162,499,241,255
|
||||
225,390,162,499,241,255
|
||||
226,390,162,499,241,255
|
||||
227,390,162,499,241,255
|
||||
228,390,162,499,241,255
|
||||
229,390,162,499,241,255
|
||||
230,390,162,499,241,255
|
||||
231,390,162,499,241,255
|
||||
232,390,162,499,241,255
|
||||
233,390,162,499,241,255
|
||||
234,390,162,499,241,255
|
||||
235,390,162,499,241,255
|
||||
236,390,162,499,241,254
|
||||
237,390,162,499,241,243
|
||||
238,390,162,499,241,231
|
||||
239,390,162,499,241,198
|
||||
240,390,162,499,241,165
|
||||
241,390,162,499,241,131
|
||||
242,390,162,499,241,115
|
||||
243,390,162,499,241,81
|
||||
244,390,162,499,241,48
|
||||
245,390,162,499,241,23
|
||||
246,390,162,499,241,7
|
||||
|
4192
docs/re/data/boot-timeline-2026-08-29.tsv
Normal file
4192
docs/re/data/boot-timeline-2026-08-29.tsv
Normal file
File diff suppressed because it is too large
Load Diff
226
docs/re/data/focus-record-alpha-census.txt
Normal file
226
docs/re/data/focus-record-alpha-census.txt
Normal file
@@ -0,0 +1,226 @@
|
||||
focus records disc-wide : 1130
|
||||
their timed elements : 2664
|
||||
with a VARYING alpha : 210
|
||||
of which rest() == the PEAK : 202 <- burns bright forever
|
||||
of which rest() is MID-RAMP : 8 <- neither extreme; looks plausible
|
||||
|
||||
by pak:
|
||||
GP_DEBRIEFING_PILOTLOG.pak 116
|
||||
GP_HANGAR_ARSENAL.pak 30
|
||||
GP_LEADERBOARD.pak 8
|
||||
GP_MOVIE_THEATER.pak 54
|
||||
GP_TITLE.pak 2
|
||||
|
||||
every varying one:
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn01f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn02f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn03f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn04f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn05f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn06f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn07f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn08f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn09f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn10f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn11f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn12f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn13f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn14f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn15f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn16f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn17f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn18f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn19f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn20f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn21f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn22f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn23f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn24f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn01f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn02f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn03f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn04f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn05f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn06f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn07f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn08f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn09f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn10f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn11f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn12f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn13f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn14f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn15f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn16f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn17f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn18f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn19f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn20f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn21f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn22f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn23f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn24f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [136] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [136] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [136] pl_main_btn2f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [136] pl_main_btn3f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [166] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [166] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [166] pl_main_btn2f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [166] pl_main_btn3f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [22] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [22] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [22] pl_main_btn2f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [22] pl_main_btn3f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [24] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [24] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn01f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn02f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn03f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn04f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn05f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn06f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn07f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn08f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn09f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn10f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn11f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn12f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn13f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn14f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn15f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn16f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn17f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn18f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn19f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn20f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn21f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn22f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn23f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn24f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [43] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [43] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [43] pl_main_btn2f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [43] pl_main_btn3f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [53] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [53] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn01f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn02f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn03f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn04f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn05f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn06f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn07f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn08f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn09f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn10f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn11f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn12f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn13f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn14f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn15f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn16f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn17f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn18f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn19f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn20f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn21f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn22f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn23f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn24f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [129] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [129] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [129] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [129] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [167] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [167] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [167] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [167] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [239] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [239] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [239] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [239] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [24] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [253] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [253] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [253] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [253] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [31] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [42] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [43] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [455] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [455] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [455] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [455] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [458] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [458] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [458] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [458] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [60] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_HANGAR_ARSENAL.pak [61] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_LEADERBOARD.pak [11] py_ranking_btn01f.rat::py_ranking_btn01f.t32 alpha 127..255 rest()=244
|
||||
GP_LEADERBOARD.pak [11] py_ranking_btn02f.rat::py_ranking_btn02f.t32 alpha 127..255 rest()=244
|
||||
GP_LEADERBOARD.pak [11] py_ranking_btn03f.rat::py_ranking_btn03f.t32 alpha 127..255 rest()=244
|
||||
GP_LEADERBOARD.pak [11] py_ranking_btn04f.rat::py_ranking_btn04f.t32 alpha 127..255 rest()=244
|
||||
GP_LEADERBOARD.pak [6] py_ranking_btn01f.rat::py_ranking_btn01f.t32 alpha 127..255 rest()=244
|
||||
GP_LEADERBOARD.pak [6] py_ranking_btn02f.rat::py_ranking_btn02f.t32 alpha 127..255 rest()=244
|
||||
GP_LEADERBOARD.pak [6] py_ranking_btn03f.rat::py_ranking_btn03f.t32 alpha 127..255 rest()=244
|
||||
GP_LEADERBOARD.pak [6] py_ranking_btn04f.rat::py_ranking_btn04f.t32 alpha 127..255 rest()=244
|
||||
GP_MOVIE_THEATER.pak [10] px_movie_tn030f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [11] px_movie_tn130f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [12] px_movie_tn040f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [13] px_movie_tn022f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [14] px_movie_tn121f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [15] px_movie_tn131f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [16] px_movie_tn041f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [17] px_movie_tn140f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [18] px_movie_tn050f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [19] px_movie_tn122f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [20] px_movie_tn150f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [21] px_movie_tn060f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [22] px_movie_tn151f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [23] px_movie_tn061f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [24] px_movie_tn160f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [25] px_movie_tn070f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [26] px_movie_tn152f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [27] px_movie_tn071f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [28] px_movie_tn090f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [29] px_movie_tn000f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [2] px_movie_tn000f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [30] px_movie_tn100f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [31] px_movie_tn010f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [32] px_movie_tn110f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [33] px_movie_tn020f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [34] px_movie_tn111f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [35] px_movie_tn021f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [36] px_movie_tn120f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [37] px_movie_tn030f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [38] px_movie_tn130f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [39] px_movie_tn040f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [3] px_movie_tn100f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [40] px_movie_tn022f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [41] px_movie_tn121f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [42] px_movie_tn131f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [43] px_movie_tn041f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [44] px_movie_tn140f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [45] px_movie_tn050f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [46] px_movie_tn122f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [47] px_movie_tn150f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [48] px_movie_tn060f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [49] px_movie_tn151f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [4] px_movie_tn010f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [50] px_movie_tn061f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [51] px_movie_tn160f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [52] px_movie_tn070f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [53] px_movie_tn152f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [54] px_movie_tn071f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [55] px_movie_tn090f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [5] px_movie_tn110f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [6] px_movie_tn020f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [7] px_movie_tn111f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [8] px_movie_tn021f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_MOVIE_THEATER.pak [9] px_movie_tn120f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
|
||||
GP_TITLE.pak [2] ptbtn00f.rat::ptbtn00f.t32 alpha 0..80 rest()=80 🔴 == PEAK
|
||||
GP_TITLE.pak [3] ptbtn00f.rat::ptbtn00f.t32 alpha 0..80 rest()=80 🔴 == PEAK
|
||||
|
||||
(210 distinct)
|
||||
BIN
docs/re/data/focus-ring-period-corr.npy
Normal file
BIN
docs/re/data/focus-ring-period-corr.npy
Normal file
Binary file not shown.
61
docs/re/data/forced-backdrop-ink-thresholds.txt
Normal file
61
docs/re/data/forced-backdrop-ink-thresholds.txt
Normal file
@@ -0,0 +1,61 @@
|
||||
# Reconciling two ink counts for GP_TITLE entries 12/15 that were never
|
||||
# counting the same pixels.
|
||||
#
|
||||
# Produced by: cargo run -p sylpheed-formats --example forced_backdrop_ink_thresholds
|
||||
# 2026-08-30, SYLPHEED_DISC=/disc, 1280x720, black backdrop.
|
||||
#
|
||||
# The port agent's Godot second witness for entry 12 (build_12, --pose=rest):
|
||||
# >0 59 530 px >1 48 368 px without the rule: 0 at both
|
||||
#
|
||||
# This crate, same entry, primitives-on (the convention the cost run used):
|
||||
# >0 49 771 px >1 48 043 px without the rule: 0 at every threshold
|
||||
#
|
||||
# CONCLUSION: the >1 counts agree to 0.68 % (325 px). The >0 counts differ by
|
||||
# 16 %. So the disagreement lives entirely in pixels whose value is exactly 1 --
|
||||
# a 1-LSB artefact of a different sampler, not a different set of inked pixels.
|
||||
# >0 is NOT portable between these two renderers on a mostly-dark frame; >1 is.
|
||||
#
|
||||
# Note also: our reported 49 771 was never an ink THRESHOLD figure. It is exact
|
||||
# RGBA inequality between the two paint orders, which over a black backdrop
|
||||
# coincides with ink>0 -- so it belongs against the port's 59 530, not its 48 368.
|
||||
#
|
||||
|
||||
== GP_TITLE entry 12 — primitives on (what the cost run used) (1280x720)
|
||||
threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT
|
||||
>0 | 49771 | 921600 | 0 | 921600
|
||||
>1 | 48043 | 921600 | 0 | 921600
|
||||
>2 | 44884 | 921600 | 0 | 921600
|
||||
>4 | 41946 | 921600 | 0 | 921600
|
||||
>8 | 38409 | 921600 | 0 | 921600
|
||||
>16 | 32760 | 921600 | 0 | 921600
|
||||
exact-RGBA changed pixels between the two orders: 49771
|
||||
|
||||
== GP_TITLE entry 12 — primitives+focus+animated (1280x720)
|
||||
threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT
|
||||
>0 | 54968 | 921600 | 0 | 921600
|
||||
>1 | 52058 | 921600 | 0 | 921600
|
||||
>2 | 48171 | 921600 | 0 | 921600
|
||||
>4 | 44396 | 921600 | 0 | 921600
|
||||
>8 | 40064 | 921600 | 0 | 921600
|
||||
>16 | 33791 | 921600 | 0 | 921600
|
||||
exact-RGBA changed pixels between the two orders: 54968
|
||||
|
||||
== GP_TITLE entry 15 — primitives on (what the cost run used) (1280x720)
|
||||
threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT
|
||||
>0 | 49771 | 921600 | 0 | 921600
|
||||
>1 | 48043 | 921600 | 0 | 921600
|
||||
>2 | 44884 | 921600 | 0 | 921600
|
||||
>4 | 41946 | 921600 | 0 | 921600
|
||||
>8 | 38409 | 921600 | 0 | 921600
|
||||
>16 | 32760 | 921600 | 0 | 921600
|
||||
exact-RGBA changed pixels between the two orders: 49771
|
||||
|
||||
== GP_TITLE entry 15 — primitives+focus+animated (1280x720)
|
||||
threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT
|
||||
>0 | 54968 | 921600 | 0 | 921600
|
||||
>1 | 52058 | 921600 | 0 | 921600
|
||||
>2 | 48171 | 921600 | 0 | 921600
|
||||
>4 | 44396 | 921600 | 0 | 921600
|
||||
>8 | 40064 | 921600 | 0 | 921600
|
||||
>16 | 33791 | 921600 | 0 | 921600
|
||||
exact-RGBA changed pixels between the two orders: 54968
|
||||
100
docs/re/data/forced-backdrop-key-source.txt
Normal file
100
docs/re/data/forced-backdrop-key-source.txt
Normal file
@@ -0,0 +1,100 @@
|
||||
# What kind of key does each of the 80 forced instances have?
|
||||
#
|
||||
# Produced by: cargo run -p sylpheed-formats --example forced_backdrop_key_source
|
||||
# 2026-08-30, SYLPHEED_DISC=/disc, every dat/*.pak.
|
||||
#
|
||||
# forced_backdrop_necessity.rs collapsed sprite_layer_key (a u16 READ from the
|
||||
# T8aD header -- decoded) with implied_layer_key (this crate's per-name table of
|
||||
# positions MEASURED in the running game). Raised by the port agent; splitting
|
||||
# them gives a stronger result than either of us stated.
|
||||
#
|
||||
# read from the T8aD header: 0 <-- NOT ONE, anywhere on the disc
|
||||
# implied (measured): 14 10x pfbase.tbm, 4x palogo_eff0.prm
|
||||
# nothing at all: 66 62 the rule decides, 4 inert
|
||||
#
|
||||
# archive entry element key_source key
|
||||
GP_BUNK.pak 0 px_bunk_base.tbm none -
|
||||
GP_BUNK.pak 2 px_bunk_base.tbm none -
|
||||
GP_BUNK.pak 4 pvbase.tbm none -
|
||||
GP_BUNK.pak 6 pvbase.tbm none -
|
||||
GP_DEBRIEFING_PILOTLOG.pak 118 px_deb_base.tbm none -
|
||||
GP_DEBRIEFING_PILOTLOG.pak 130 px_deb_base.tbm none -
|
||||
GP_DEBRIEFING_PILOTLOG.pak 131 pjbgbase2.tbm none -
|
||||
GP_DEBRIEFING_PILOTLOG.pak 134 px_deb_base.tbm none -
|
||||
GP_DEBRIEFING_PILOTLOG.pak 150 pjbgbase2.tbm none -
|
||||
GP_DEBRIEFING_PILOTLOG.pak 165 px_deb_base.tbm none -
|
||||
GP_DIALOG.pak 2 pcbase.tbm none -
|
||||
GP_DIALOG.pak 3 pcbase.tbm none -
|
||||
GP_DIALOG.pak 9 pzeff00.prm none -
|
||||
GP_DIALOG.pak 10 pzeff00.prm none -
|
||||
GP_DIALOG.pak 11 pzeff00.prm none -
|
||||
GP_DIALOG.pak 12 pzeff00.prm none -
|
||||
GP_DIALOG.pak 13 pzeff00.prm none -
|
||||
GP_DIALOG.pak 14 pzeff00.prm none -
|
||||
GP_DIALOG.pak 15 pzeff00.prm none -
|
||||
GP_DIALOG.pak 16 pzeff00.prm none -
|
||||
GP_DIALOG.pak 17 pzeff00.prm none -
|
||||
GP_DIALOG.pak 18 pzeff00.prm none -
|
||||
GP_DIALOG.pak 19 pzeff00.prm none -
|
||||
GP_DIALOG.pak 20 pzeff00.prm none -
|
||||
GP_DIALOG.pak 21 pzeff00.prm none -
|
||||
GP_DIALOG.pak 22 pzeff00.prm none -
|
||||
GP_DIALOG.pak 23 pzeff00.prm none -
|
||||
GP_DIALOG.pak 24 pzeff00.prm none -
|
||||
GP_DIALOG.pak 25 pzeff00.prm none -
|
||||
GP_DIALOG.pak 26 pzeff00.prm none -
|
||||
GP_DIALOG.pak 28 pzeff00.prm none -
|
||||
GP_DIALOG.pak 29 pzeff00.prm none -
|
||||
GP_DIALOG.pak 30 pzeff00.prm none -
|
||||
GP_DIALOG.pak 31 pzeff00.prm none -
|
||||
GP_DIALOG.pak 32 pzeff00.prm none -
|
||||
GP_DIALOG.pak 33 pzeff00.prm none -
|
||||
GP_DIALOG.pak 34 pzeff00.prm none -
|
||||
GP_DIALOG.pak 35 pzeff00.prm none -
|
||||
GP_DIALOG.pak 36 pzeff00.prm none -
|
||||
GP_DIALOG.pak 37 pzeff00.prm none -
|
||||
GP_DIALOG.pak 38 pzeff00.prm none -
|
||||
GP_DIALOG.pak 39 pzeff00.prm none -
|
||||
GP_DIALOG.pak 40 pzeff00.prm none -
|
||||
GP_DIALOG.pak 41 pzeff00.prm none -
|
||||
GP_DIALOG.pak 86 esrb_base.prm none -
|
||||
GP_DIALOG.pak 130 esrb_base.prm none -
|
||||
GP_GAMEOVER.pak 4 pnbase.tbm none -
|
||||
GP_GAMEOVER.pak 7 pnbase.tbm none -
|
||||
GP_MISSION_SELECT.pak 3 px_mission_base.tbm none -
|
||||
GP_MISSION_SELECT.pak 5 px_mission_base.tbm none -
|
||||
GP_MOVIE_THEATER.pak 0 px_movie_base.tbm none -
|
||||
GP_MOVIE_THEATER.pak 1 px_movie_base.tbm none -
|
||||
GP_OPTIONS.pak 0 po_menu_base.tbm none -
|
||||
GP_OPTIONS.pak 0 po_menu_base.tbm none -
|
||||
GP_OPTIONS.pak 2 po_menu_base.tbm none -
|
||||
GP_OPTIONS.pak 2 po_menu_base.tbm none -
|
||||
GP_SAVE_LOAD.pak 37 pfbase.tbm implied_MEASURED 0x00000000
|
||||
GP_SAVE_LOAD.pak 37 pfbase.tbm implied_MEASURED 0x00000000
|
||||
GP_SAVE_LOAD.pak 40 pfbase.tbm implied_MEASURED 0x00000000
|
||||
GP_SAVE_LOAD.pak 40 pfbase.tbm implied_MEASURED 0x00000000
|
||||
GP_SAVE_LOAD.pak 46 pgloading_eff00.prm none -
|
||||
GP_SAVE_LOAD.pak 58 pfbase.tbm implied_MEASURED 0x00000000
|
||||
GP_SAVE_LOAD.pak 69 pgloading_eff00.prm none -
|
||||
GP_SAVE_LOAD.pak 78 pfbase.tbm implied_MEASURED 0x00000000
|
||||
GP_SAVE_LOAD.pak 89 px_replay_base.tbm none -
|
||||
GP_SAVE_LOAD.pak 93 pfbase.tbm implied_MEASURED 0x00000000
|
||||
GP_SAVE_LOAD.pak 93 pfbase.tbm implied_MEASURED 0x00000000
|
||||
GP_SAVE_LOAD.pak 98 px_replay_base.tbm none -
|
||||
GP_SAVE_LOAD.pak 99 pfbase.tbm implied_MEASURED 0x00000000
|
||||
GP_SAVE_LOAD.pak 99 pfbase.tbm implied_MEASURED 0x00000000
|
||||
GP_SYSTEM.pak 0 pqbase.tbm none -
|
||||
GP_SYSTEM.pak 1 pqbase.tbm none -
|
||||
GP_TITLE.pak 10 palogo_eff0.prm implied_MEASURED 0x00000000
|
||||
GP_TITLE.pak 11 palogo_eff0.prm implied_MEASURED 0x00000000
|
||||
GP_TITLE.pak 12 pgloading_eff00.prm none -
|
||||
GP_TITLE.pak 13 palogo_eff0.prm implied_MEASURED 0x00000000
|
||||
GP_TITLE.pak 14 palogo_eff0.prm implied_MEASURED 0x00000000
|
||||
GP_TITLE.pak 15 pgloading_eff00.prm none -
|
||||
GP_TUTORIAL.pak 0 pubase.tbm none -
|
||||
GP_TUTORIAL.pak 1 pubase.tbm none -
|
||||
|
||||
# forced instances by key source:
|
||||
# read from the T8aD header (decoded): 0
|
||||
# implied — this crate's MEASURED name table: 14
|
||||
# none — only forced_backdrop can speak: 66
|
||||
253
docs/re/data/forced-backdrop-necessity.txt
Normal file
253
docs/re/data/forced-backdrop-necessity.txt
Normal file
@@ -0,0 +1,253 @@
|
||||
# Does forced_backdrop DECIDE a screen's order, or merely AGREE with it?
|
||||
#
|
||||
# Produced by: cargo run -p sylpheed-formats --example forced_backdrop_necessity -- <pak>
|
||||
# over every dat/*.pak, 2026-08-30. SYLPHEED_DISC=/disc.
|
||||
#
|
||||
# 'decides' = derived_paint_order() differs from the same sort with the
|
||||
# forced_backdrop fallback removed. 'no' = the element's own read or implied
|
||||
# key already puts it there, OR every element on the screen is forced so the
|
||||
# declaration-index tie-break gives the same order either way.
|
||||
#
|
||||
# TOTALS: 80 forced instances = 62 decides + 18 agrees.
|
||||
# The 80 reproduces the census in ui-forced-backdrop.md exactly.
|
||||
# Every one of the 62 deciders is keyless; no keyed element is ever moved.
|
||||
#
|
||||
# /disc/dat/GP_BUNK.pak
|
||||
# entry forced decides elements note
|
||||
0 1 YES 21 forced=[px_bunk_base.tbm] keyless=[px_bunk_base.tbm]
|
||||
2 1 YES 21 forced=[px_bunk_base.tbm] keyless=[px_bunk_base.tbm]
|
||||
4 1 YES 19 forced=[pvbase.tbm] keyless=[pvbase.tbm]
|
||||
6 1 YES 19 forced=[pvbase.tbm] keyless=[pvbase.tbm]
|
||||
|
||||
# rule DECIDES the order on entries [0, 2, 4, 6]
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_CHALLENGE.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_DEBRIEFING_PILOTLOG.pak
|
||||
# entry forced decides elements note
|
||||
118 1 YES 6 forced=[px_deb_base.tbm] keyless=[px_deb_base.tbm]
|
||||
130 1 YES 6 forced=[px_deb_base.tbm] keyless=[px_deb_base.tbm]
|
||||
131 1 YES 13 forced=[pjbgbase2.tbm] keyless=[pjbgbase2.tbm]
|
||||
134 1 YES 10 forced=[px_deb_base.tbm] keyless=[px_deb_base.tbm]
|
||||
150 1 YES 13 forced=[pjbgbase2.tbm] keyless=[pjbgbase2.tbm]
|
||||
165 1 YES 10 forced=[px_deb_base.tbm] keyless=[px_deb_base.tbm]
|
||||
|
||||
# rule DECIDES the order on entries [118, 130, 131, 134, 150, 165]
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_DIALOG.pak
|
||||
# entry forced decides elements note
|
||||
2 1 YES 15 forced=[pcbase.tbm] keyless=[pcbase.tbm]
|
||||
3 1 YES 15 forced=[pcbase.tbm] keyless=[pcbase.tbm]
|
||||
9 1 YES 34 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
10 1 YES 46 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
11 1 YES 38 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
12 1 YES 32 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
13 1 YES 32 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
14 1 YES 34 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
15 1 YES 26 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
16 1 YES 20 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
17 1 YES 30 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
18 1 YES 38 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
19 1 YES 30 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
20 1 YES 36 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
21 1 YES 38 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
22 1 YES 46 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
23 1 YES 34 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
24 1 YES 36 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
25 1 YES 14 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
26 1 YES 16 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
28 1 YES 14 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
29 1 YES 16 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
30 1 YES 18 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
31 1 YES 18 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
32 1 YES 10 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
33 1 YES 16 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
34 1 YES 14 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
35 1 YES 18 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
36 1 YES 16 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
37 1 YES 18 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
38 1 YES 26 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
39 1 YES 20 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
40 1 YES 18 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
41 1 YES 20 forced=[pzeff00.prm] keyless=[pzeff00.prm]
|
||||
86 1 YES 2 forced=[esrb_base.prm] keyless=[esrb_base.prm]
|
||||
130 1 YES 2 forced=[esrb_base.prm] keyless=[esrb_base.prm]
|
||||
|
||||
# rule DECIDES the order on entries [2, 3, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 86, 130]
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_GAMEOVER.pak
|
||||
# entry forced decides elements note
|
||||
4 1 YES 13 forced=[pnbase.tbm] keyless=[pnbase.tbm]
|
||||
7 1 YES 13 forced=[pnbase.tbm] keyless=[pnbase.tbm]
|
||||
|
||||
# rule DECIDES the order on entries [4, 7]
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_HANGAR_ARSENAL.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_LEADERBOARD.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_D.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_D2D.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_E.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_E2D.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_F.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_F2D.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_I.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_I2D.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_J.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_J2D.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_S.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MAIN_GAME_S2D.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MISSION_LOG.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MISSION_SELECT.pak
|
||||
# entry forced decides elements note
|
||||
3 1 YES 15 forced=[px_mission_base.tbm] keyless=[px_mission_base.tbm]
|
||||
5 1 YES 15 forced=[px_mission_base.tbm] keyless=[px_mission_base.tbm]
|
||||
|
||||
# rule DECIDES the order on entries [3, 5]
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_MOVIE_THEATER.pak
|
||||
# entry forced decides elements note
|
||||
0 1 YES 12 forced=[px_movie_base.tbm] keyless=[px_movie_base.tbm]
|
||||
1 1 YES 12 forced=[px_movie_base.tbm] keyless=[px_movie_base.tbm]
|
||||
|
||||
# rule DECIDES the order on entries [0, 1]
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_OPTIONS.pak
|
||||
# entry forced decides elements note
|
||||
0 2 no 2 forced=[po_menu_base.tbm,po_menu_base.tbm] keyless=[po_menu_base.tbm,po_menu_base.tbm]
|
||||
2 2 no 2 forced=[po_menu_base.tbm,po_menu_base.tbm] keyless=[po_menu_base.tbm,po_menu_base.tbm]
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries [0, 2]
|
||||
# /disc/dat/GP_PAUSE_MENU.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_READY_ROOM.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_SAVE_LOAD.pak
|
||||
# entry forced decides elements note
|
||||
37 2 no 13 forced=[pfbase.tbm,pfbase.tbm] keyless=[]
|
||||
40 2 no 13 forced=[pfbase.tbm,pfbase.tbm] keyless=[]
|
||||
46 1 YES 10 forced=[pgloading_eff00.prm] keyless=[pgloading_eff00.prm]
|
||||
58 1 no 9 forced=[pfbase.tbm] keyless=[]
|
||||
69 1 YES 10 forced=[pgloading_eff00.prm] keyless=[pgloading_eff00.prm]
|
||||
78 1 no 9 forced=[pfbase.tbm] keyless=[]
|
||||
89 1 YES 7 forced=[px_replay_base.tbm] keyless=[px_replay_base.tbm]
|
||||
93 2 no 13 forced=[pfbase.tbm,pfbase.tbm] keyless=[]
|
||||
98 1 YES 7 forced=[px_replay_base.tbm] keyless=[px_replay_base.tbm]
|
||||
99 2 no 13 forced=[pfbase.tbm,pfbase.tbm] keyless=[]
|
||||
|
||||
# rule DECIDES the order on entries [46, 69, 89, 98]
|
||||
# rule merely AGREES on entries [37, 40, 58, 78, 93, 99]
|
||||
# /disc/dat/GP_STAGE_CLEAR.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_SYSTEM.pak
|
||||
# entry forced decides elements note
|
||||
0 1 YES 21 forced=[pqbase.tbm] keyless=[pqbase.tbm]
|
||||
1 1 YES 21 forced=[pqbase.tbm] keyless=[pqbase.tbm]
|
||||
|
||||
# rule DECIDES the order on entries [0, 1]
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/GP_TITLE.pak
|
||||
# entry forced decides elements note
|
||||
10 1 no 3 forced=[palogo_eff0.prm] keyless=[]
|
||||
11 1 no 7 forced=[palogo_eff0.prm] keyless=[]
|
||||
12 1 YES 10 forced=[pgloading_eff00.prm] keyless=[pgloading_eff00.prm]
|
||||
13 1 no 3 forced=[palogo_eff0.prm] keyless=[]
|
||||
14 1 no 7 forced=[palogo_eff0.prm] keyless=[]
|
||||
15 1 YES 10 forced=[pgloading_eff00.prm] keyless=[pgloading_eff00.prm]
|
||||
|
||||
# rule DECIDES the order on entries [12, 15]
|
||||
# rule merely AGREES on entries [10, 11, 13, 14]
|
||||
# /disc/dat/GP_TUTORIAL.pak
|
||||
# entry forced decides elements note
|
||||
0 1 YES 18 forced=[pubase.tbm] keyless=[pubase.tbm]
|
||||
1 1 YES 18 forced=[pubase.tbm] keyless=[pubase.tbm]
|
||||
|
||||
# rule DECIDES the order on entries [0, 1]
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/fonts.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/sound.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
# /disc/dat/tables.pak
|
||||
# entry forced decides elements note
|
||||
|
||||
# rule DECIDES the order on entries []
|
||||
# rule merely AGREES on entries []
|
||||
83
docs/re/data/forced-backdrop-pixel-cost.txt
Normal file
83
docs/re/data/forced-backdrop-pixel-cost.txt
Normal file
@@ -0,0 +1,83 @@
|
||||
# What forced_backdrop costs IN PIXELS on the 62 builds whose order it decides.
|
||||
#
|
||||
# Produced by: cargo run -p sylpheed-formats --example forced_backdrop_pixel_cost
|
||||
# (no argument = every dat/*.pak), 2026-08-30, SYLPHEED_DISC=/disc.
|
||||
# Each build rendered twice at 1280x720 with include_primitives=true and a
|
||||
# black backdrop: once in derived_paint_order(), once with the
|
||||
# forced_backdrop fallback removed. changed_px is the diff.
|
||||
#
|
||||
# RESULT, and it splits perfectly along the element kind:
|
||||
# 38 .prm deciders: changed_px > 0, and changed_px == ink_px in ALL 38.
|
||||
# Without the rule the screen composites to PURE BLACK.
|
||||
# 24 .tbm deciders: changed_px == 0 in all 24 -- but see the caveat, this
|
||||
# is our compositor drawing no pixels for a .tbm at all,
|
||||
# NOT the rule being free. The control below did not
|
||||
# catch it and was the wrong control.
|
||||
#
|
||||
# archive entry element changed_px total_px ink_px(control) pct
|
||||
GP_BUNK.pak 0 px_bunk_base.tbm 0 921600 75487 0.00%
|
||||
GP_BUNK.pak 2 px_bunk_base.tbm 0 921600 70372 0.00%
|
||||
GP_BUNK.pak 4 pvbase.tbm 0 921600 41137 0.00%
|
||||
GP_BUNK.pak 6 pvbase.tbm 0 921600 34773 0.00%
|
||||
GP_DEBRIEFING_PILOTLOG.pak 118 px_deb_base.tbm 0 921600 25812 0.00%
|
||||
GP_DEBRIEFING_PILOTLOG.pak 130 px_deb_base.tbm 0 921600 25812 0.00%
|
||||
GP_DEBRIEFING_PILOTLOG.pak 131 pjbgbase2.tbm 0 921600 33329 0.00%
|
||||
GP_DEBRIEFING_PILOTLOG.pak 134 px_deb_base.tbm 0 921600 44147 0.00%
|
||||
GP_DEBRIEFING_PILOTLOG.pak 150 pjbgbase2.tbm 0 921600 34401 0.00%
|
||||
GP_DEBRIEFING_PILOTLOG.pak 165 px_deb_base.tbm 0 921600 42487 0.00%
|
||||
GP_DIALOG.pak 2 pcbase.tbm 0 921600 646626 0.00%
|
||||
GP_DIALOG.pak 3 pcbase.tbm 0 921600 646626 0.00%
|
||||
GP_DIALOG.pak 9 pzeff00.prm 867195 921600 867195 94.10%
|
||||
GP_DIALOG.pak 10 pzeff00.prm 869598 921600 869598 94.36%
|
||||
GP_DIALOG.pak 11 pzeff00.prm 868329 921600 868329 94.22%
|
||||
GP_DIALOG.pak 12 pzeff00.prm 867629 921600 867629 94.14%
|
||||
GP_DIALOG.pak 13 pzeff00.prm 867713 921600 867713 94.15%
|
||||
GP_DIALOG.pak 14 pzeff00.prm 868023 921600 868023 94.19%
|
||||
GP_DIALOG.pak 15 pzeff00.prm 867294 921600 867294 94.11%
|
||||
GP_DIALOG.pak 16 pzeff00.prm 865326 921600 865326 93.89%
|
||||
GP_DIALOG.pak 17 pzeff00.prm 867276 921600 867276 94.11%
|
||||
GP_DIALOG.pak 18 pzeff00.prm 868899 921600 868899 94.28%
|
||||
GP_DIALOG.pak 19 pzeff00.prm 866752 921600 866752 94.05%
|
||||
GP_DIALOG.pak 20 pzeff00.prm 868266 921600 868266 94.21%
|
||||
GP_DIALOG.pak 21 pzeff00.prm 868388 921600 868388 94.23%
|
||||
GP_DIALOG.pak 22 pzeff00.prm 870053 921600 870053 94.41%
|
||||
GP_DIALOG.pak 23 pzeff00.prm 867982 921600 867982 94.18%
|
||||
GP_DIALOG.pak 24 pzeff00.prm 868386 921600 868386 94.23%
|
||||
GP_DIALOG.pak 25 pzeff00.prm 865233 921600 865233 93.88%
|
||||
GP_DIALOG.pak 26 pzeff00.prm 865525 921600 865525 93.92%
|
||||
GP_DIALOG.pak 28 pzeff00.prm 865233 921600 865233 93.88%
|
||||
GP_DIALOG.pak 29 pzeff00.prm 865500 921600 865500 93.91%
|
||||
GP_DIALOG.pak 30 pzeff00.prm 866183 921600 866183 93.99%
|
||||
GP_DIALOG.pak 31 pzeff00.prm 866233 921600 866233 93.99%
|
||||
GP_DIALOG.pak 32 pzeff00.prm 865192 921600 865192 93.88%
|
||||
GP_DIALOG.pak 33 pzeff00.prm 865538 921600 865538 93.92%
|
||||
GP_DIALOG.pak 34 pzeff00.prm 865233 921600 865233 93.88%
|
||||
GP_DIALOG.pak 35 pzeff00.prm 866079 921600 866079 93.98%
|
||||
GP_DIALOG.pak 36 pzeff00.prm 865508 921600 865508 93.91%
|
||||
GP_DIALOG.pak 37 pzeff00.prm 866218 921600 866218 93.99%
|
||||
GP_DIALOG.pak 38 pzeff00.prm 868259 921600 868259 94.21%
|
||||
GP_DIALOG.pak 39 pzeff00.prm 866804 921600 866804 94.05%
|
||||
GP_DIALOG.pak 40 pzeff00.prm 866062 921600 866062 93.97%
|
||||
GP_DIALOG.pak 41 pzeff00.prm 866792 921600 866792 94.05%
|
||||
GP_DIALOG.pak 86 esrb_base.prm 6388 921600 6388 0.69%
|
||||
GP_DIALOG.pak 130 esrb_base.prm 6388 921600 6388 0.69%
|
||||
GP_GAMEOVER.pak 4 pnbase.tbm 0 921600 921600 0.00%
|
||||
GP_GAMEOVER.pak 7 pnbase.tbm 0 921600 921600 0.00%
|
||||
GP_MISSION_SELECT.pak 3 px_mission_base.tbm 0 921600 661678 0.00%
|
||||
GP_MISSION_SELECT.pak 5 px_mission_base.tbm 0 921600 659464 0.00%
|
||||
GP_MOVIE_THEATER.pak 0 px_movie_base.tbm 0 921600 32871 0.00%
|
||||
GP_MOVIE_THEATER.pak 1 px_movie_base.tbm 0 921600 27726 0.00%
|
||||
GP_SAVE_LOAD.pak 46 pgloading_eff00.prm 49771 921600 49771 5.40%
|
||||
GP_SAVE_LOAD.pak 69 pgloading_eff00.prm 49771 921600 49771 5.40%
|
||||
GP_SAVE_LOAD.pak 89 px_replay_base.tbm 0 921600 269421 0.00%
|
||||
GP_SAVE_LOAD.pak 98 px_replay_base.tbm 0 921600 269421 0.00%
|
||||
GP_SYSTEM.pak 0 pqbase.tbm 0 921600 828253 0.00%
|
||||
GP_SYSTEM.pak 1 pqbase.tbm 0 921600 828199 0.00%
|
||||
GP_TITLE.pak 12 pgloading_eff00.prm 49771 921600 49771 5.40%
|
||||
GP_TITLE.pak 15 pgloading_eff00.prm 49771 921600 49771 5.40%
|
||||
GP_TUTORIAL.pak 0 pubase.tbm 0 921600 92946 0.00%
|
||||
GP_TUTORIAL.pak 1 pubase.tbm 0 921600 92879 0.00%
|
||||
|
||||
# builds whose ORDER the rule decides: 62
|
||||
# of those, costing ZERO pixels: 24
|
||||
# of those, BLIND (build renders no ink, control fails): 0
|
||||
34
docs/re/data/intro-audio-channel-census.txt
Normal file
34
docs/re/data/intro-audio-channel-census.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
# The game's own audio output over the boot intro (Q9/#4 groundwork).
|
||||
#
|
||||
# 2026-08-30. Recipe: docs/re/audio-capture-alsa-file-tee.md, exactly.
|
||||
# ARGV: run-canary --apu=alsa --mute=false --gpu=null --xma_param_probe=true
|
||||
# ALSA file tee in front of a paced pulse slave; 6ch float32 @ 48 kHz.
|
||||
# Raw is 170 MB and is NOT committed -- sent via share.
|
||||
#
|
||||
# PROVENANCE (better than a screenshot for an audio question): the XMA probe
|
||||
# logged ADV's three contexts byte-exact against the disc --
|
||||
# ctx0 packets=632 byte_size=1294336
|
||||
# ctx1 packets=546 byte_size=1118208
|
||||
# ctx2 packets=572 byte_size=1171456
|
||||
# then two more (1150976 / 1269760) = the documented BGM_102 pair.
|
||||
#
|
||||
# CAPTURE QUALITY: 0.15-0.16 % silence on five channels, against the
|
||||
# 0.31 % the recipe page records for its clean --gpu=null run.
|
||||
#
|
||||
# ALSA CHANNEL ORDER: captured i holds source [0,1,4,5,2,3], i.e. the
|
||||
# labels below are FL FR BL BR FC LFE. Deterministic, not data loss.
|
||||
#
|
||||
frames 7105024 = 148.02 s, 6ch float32 @ 48 kHz
|
||||
|
||||
ch name peak dBFS rms dBFS %silent
|
||||
0 FL -3.65 -22.26 0.16
|
||||
1 FR -2.22 -20.89 0.15
|
||||
2 BL -4.41 -24.77 0.15
|
||||
3 BR -11.65 -36.06 82.18
|
||||
4 FC -4.57 -24.81 0.16
|
||||
5 LFE -4.66 -24.38 0.16
|
||||
|
||||
pairwise |r| > 0.5:
|
||||
ch0(FL) vs ch1(FR): r=+0.6996
|
||||
ch0(FL) vs ch4(FC): r=+0.5202
|
||||
ch1(FR) vs ch5(LFE): r=+0.5639
|
||||
42
docs/re/data/intro-audio-decomposition.txt
Normal file
42
docs/re/data/intro-audio-decomposition.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
# What the game emits over the boot intro, decomposed against the movie's own track.
|
||||
#
|
||||
# 2026-08-30. Capture: docs/re/structures/intro-audio-output-census.md
|
||||
# Reference: ffmpeg -i /disc/dat/movie/ADV.wmv -map 0:a:0 -f f32le -ar 48000 -ac 6
|
||||
#
|
||||
# ADV.wmv carries ONE audio stream: wmapro, 48000 Hz, 5.1, 384 kb/s. Not XMA.
|
||||
#
|
||||
# ALIGNMENT (energy envelope, 100 Hz, summed over channels = permutation-invariant)
|
||||
# movie begins +6.63 s into the capture; envelope r = 0.7692
|
||||
# control: peak 0.7692, 99.9th pct 0.6241, median -0.0009
|
||||
# refined by sample-level correlation to +224 samples, r = 0.900
|
||||
#
|
||||
# CHANNEL MAP -- measured, not assumed. Every row's max is a distinct movie
|
||||
# channel, i.e. a genuine permutation, and it is the IDENTITY:
|
||||
# cap ch0->FL +0.899 ch1->FR +0.935 ch2->FC +0.185
|
||||
# cap ch3->LFE +1.000 ch4->BL +0.960 ch5->BR +0.971
|
||||
# 🔴 The ALSA order [0,1,4,5,2,3] documented in audio-capture-alsa-file-tee.md
|
||||
# does NOT apply to this capture. See the doc for what that corrected.
|
||||
#
|
||||
# DECOMPOSITION capture = 0.600 x movie + residual (80 s from movie t=20 s)
|
||||
# ch gain r cap rms resid rms resid/cap
|
||||
# FL 0.600 +0.905 -21.86 -29.28 -7.43 dB
|
||||
# FR 0.600 +0.935 -20.28 -29.29 -9.02 dB
|
||||
# FC 0.597 +0.146 -25.18 -25.27 -0.09 dB <- movie explains NOTHING
|
||||
# LFE 0.600 +1.000 -43.66 -115.73 -72.06 dB <- exact to precision
|
||||
# BL 0.600 +0.956 -24.89 -35.46 -10.58 dB
|
||||
# BR 0.600 +0.964 -24.02 -35.49 -11.47 dB
|
||||
#
|
||||
# RESIDUAL STRUCTURE -- residual vs residual correlation
|
||||
# FL FR FC LFE BL BR
|
||||
# FL +1.000 +0.918 +0.017 +0.001 +0.009 +0.004
|
||||
# FR +0.918 +1.000 +0.034 +0.001 +0.015 +0.014
|
||||
# FC +0.017 +0.034 +1.000 +0.000 +0.385 +0.378
|
||||
# LFE +0.001 +0.001 +0.000 +1.000 +0.000 -0.000
|
||||
# BL +0.009 +0.015 +0.385 +0.000 +1.000 +0.929
|
||||
# BR +0.004 +0.014 +0.378 -0.000 +0.929 +1.000
|
||||
#
|
||||
# Three coherent groups: a FRONT pair (0.918), a REAR pair (0.929), and a
|
||||
# CENTRE whose partner LFE is empty. That is three stereo streams in 5.1.
|
||||
#
|
||||
# FC residual 100 ms frame levels: median -53.9 dB, p90 -19.9 dB,
|
||||
# dynamic range 34.0 dB -- bursty, not steady noise.
|
||||
14
docs/re/data/kf-record-census.txt
Normal file
14
docs/re/data/kf-record-census.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
paks scanned : 33
|
||||
placement groups : 13991
|
||||
|
||||
A. lead-in prepended to the shifted times is non-decreasing
|
||||
13991/13991 = 100.000%
|
||||
|
||||
B. non-zero lead-in is strictly less than the next time
|
||||
5058/5058 = 100.000%
|
||||
control (another group's lead-in, same bundle): 35837/50580 = 70.852%
|
||||
gap to the next time, most common: [(10, 2076), (1, 2022), (30, 116), (40, 80), (12, 78), (90, 78), (149, 78), (20, 78)]
|
||||
|
||||
C. constant d(alpha)/d(time) across a multi-segment ramp
|
||||
corrected (time precedes pose): 857/1540 = 55.649%
|
||||
old (+36 is own time) : 0/1042 = 0.000%
|
||||
40
docs/re/data/kf-unknown-4-8-census.txt
Normal file
40
docs/re/data/kf-unknown-4-8-census.txt
Normal file
@@ -0,0 +1,40 @@
|
||||
2859 builds, 90347 keyframes (parents + leaves)
|
||||
|
||||
+4: 12 distinct values, 4289 non-zero keyframes (4.7473%)
|
||||
180 x4200
|
||||
-180 x24
|
||||
22 x24
|
||||
90 x18
|
||||
-45 x8
|
||||
60 x8
|
||||
+8: 11 distinct values, 4064 non-zero keyframes (4.4982%)
|
||||
180 x3288
|
||||
90 x201
|
||||
-180 x156
|
||||
178 x144
|
||||
45 x99
|
||||
23 x95
|
||||
+12 (rotation): 157 distinct values, 12520 non-zero keyframes (13.8577%)
|
||||
90 x1968
|
||||
-90 x1260
|
||||
180 x608
|
||||
120 x540
|
||||
-58 x426
|
||||
53 x408
|
||||
|
||||
keyframes with a non-zero +4 or +8: 6345
|
||||
GP_BUNK.pak e1 pjnet_bg.rat->pjnet_base.t32 +4=180 +8=0 +12=0
|
||||
GP_BUNK.pak e1 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0
|
||||
GP_BUNK.pak e1 pjnet_bg.rat->pjnet_loop2.rat +4=180 +8=0 +12=0
|
||||
GP_BUNK.pak e1 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0
|
||||
GP_BUNK.pak e3 pjnet_bg.rat->pjnet_base.t32 +4=180 +8=0 +12=0
|
||||
GP_BUNK.pak e3 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0
|
||||
GP_BUNK.pak e3 pjnet_bg.rat->pjnet_loop2.rat +4=180 +8=0 +12=0
|
||||
GP_BUNK.pak e3 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0
|
||||
GP_BUNK.pak e4 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=0
|
||||
GP_BUNK.pak e4 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=360
|
||||
GP_BUNK.pak e6 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=0
|
||||
GP_BUNK.pak e6 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=360
|
||||
GP_CHALLENGE.pak e71 pjnet_bg.rat->pjnet_base.t32 +4=180 +8=0 +12=0
|
||||
GP_CHALLENGE.pak e71 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0
|
||||
GP_CHALLENGE.pak e71 pjnet_bg.rat->pjnet_loop2.rat +4=180 +8=0 +12=0
|
||||
125
docs/re/data/paint-order-tie-pixel-cost.txt
Normal file
125
docs/re/data/paint-order-tie-pixel-cost.txt
Normal file
@@ -0,0 +1,125 @@
|
||||
# tie-break pixel cost — /disc/dat/GP_TITLE.pak
|
||||
|
||||
entry 0 7 elements 1 overlapping tied pair(s)
|
||||
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
[default (what `screen render` draws)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [4] pgloading_loop4.rat x [6] pgloading_eff02.t32 moves 3654 px (max Δ 23)
|
||||
[everything on (focus+animated+primitives)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1610 px differ (0.1747% of frame), max Δ 1 | ink 29173 / 21017 px, shared 3139 px
|
||||
|
||||
entry 1 7 elements 1 overlapping tied pair(s)
|
||||
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
[default (what `screen render` draws)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [4] pgloading_loop4.rat x [6] pgloading_eff02.t32 moves 3654 px (max Δ 23)
|
||||
[everything on (focus+animated+primitives)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1610 px differ (0.1747% of frame), max Δ 1 | ink 29173 / 21017 px, shared 3139 px
|
||||
|
||||
entry 4 24 elements 13 overlapping tied pair(s)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [10] pteff04.t32 x [18] ptlogo_back2eff5.t32 moves 36305 px (max Δ 254)
|
||||
[default (what `screen render` draws)] [2] ptlogo1.t32 x [4] ptlogo1.t32 (key 32928): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [3] ptlogo2.t32 x [5] ptlogo2.t32 (key 32928): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [11] ptloop01.rat x [12] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [15] ptlogo_back2eff2.t32 (key 32899): 295 px differ (0.0320% of frame), max Δ 1 | ink 2483 / 6547 px, shared 2483 px
|
||||
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 861 px differ (0.0934% of frame), max Δ 2 | ink 2483 / 9698 px, shared 2483 px
|
||||
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1555 px differ (0.1687% of frame), max Δ 2 | ink 2483 / 13926 px, shared 2483 px
|
||||
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6645 px differ (0.7210% of frame), max Δ 3 | ink 2483 / 22834 px, shared 2398 px
|
||||
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 420 px differ (0.0456% of frame), max Δ 1 | ink 6547 / 9698 px, shared 6547 px
|
||||
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1209 px differ (0.1312% of frame), max Δ 2 | ink 6547 / 13926 px, shared 6547 px
|
||||
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6641 px differ (0.7206% of frame), max Δ 2 | ink 6547 / 22834 px, shared 6360 px
|
||||
[default (what `screen render` draws)] [16] ptlogo_back2eff3.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 584 px differ (0.0634% of frame), max Δ 1 | ink 9698 / 13926 px, shared 9698 px
|
||||
[default (what `screen render` draws)] [16] ptlogo_back2eff3.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6390 px differ (0.6934% of frame), max Δ 2 | ink 9698 / 22834 px, shared 9462 px
|
||||
[default (what `screen render` draws)] [17] ptlogo_back2eff4.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 5516 px differ (0.5985% of frame), max Δ 1 | ink 13926 / 22834 px, shared 13480 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [10] pteff04.t32 x [18] ptlogo_back2eff5.t32 moves 860461 px (max Δ 254)
|
||||
[everything on (focus+animated+primitives)] [2] ptlogo1.t32 x [4] ptlogo1.t32 (key 32928): NOT BOTH DRAWN — unreachable here
|
||||
[everything on (focus+animated+primitives)] [3] ptlogo2.t32 x [5] ptlogo2.t32 (key 32928): NOT BOTH DRAWN — unreachable here
|
||||
[everything on (focus+animated+primitives)] [11] ptloop01.rat x [12] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [15] ptlogo_back2eff2.t32 (key 32899): 280 px differ (0.0304% of frame), max Δ 1 | ink 2516 / 6589 px, shared 2516 px
|
||||
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 811 px differ (0.0880% of frame), max Δ 2 | ink 2516 / 9754 px, shared 2516 px
|
||||
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1527 px differ (0.1657% of frame), max Δ 2 | ink 2516 / 14072 px, shared 2516 px
|
||||
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6586 px differ (0.7146% of frame), max Δ 3 | ink 2516 / 22970 px, shared 2419 px
|
||||
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 395 px differ (0.0429% of frame), max Δ 1 | ink 6589 / 9754 px, shared 6589 px
|
||||
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1204 px differ (0.1306% of frame), max Δ 2 | ink 6589 / 14072 px, shared 6589 px
|
||||
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6567 px differ (0.7126% of frame), max Δ 2 | ink 6589 / 22970 px, shared 6397 px
|
||||
[everything on (focus+animated+primitives)] [16] ptlogo_back2eff3.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 599 px differ (0.0650% of frame), max Δ 1 | ink 9754 / 14072 px, shared 9754 px
|
||||
[everything on (focus+animated+primitives)] [16] ptlogo_back2eff3.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6333 px differ (0.6872% of frame), max Δ 2 | ink 9754 / 22970 px, shared 9512 px
|
||||
[everything on (focus+animated+primitives)] [17] ptlogo_back2eff4.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 5427 px differ (0.5889% of frame), max Δ 1 | ink 14072 / 22970 px, shared 13620 px
|
||||
|
||||
entry 5 16 elements 2 overlapping tied pair(s)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 764030 px (max Δ 67)
|
||||
[default (what `screen render` draws)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4783 / 5297 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 725164 px (max Δ 50)
|
||||
[everything on (focus+animated+primitives)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4781 / 5305 px, shared 0 px
|
||||
|
||||
entry 6 18 elements 2 overlapping tied pair(s)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 761600 px (max Δ 67)
|
||||
[default (what `screen render` draws)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
|
||||
[default (what `screen render` draws)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 721144 px (max Δ 50)
|
||||
[everything on (focus+animated+primitives)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
|
||||
entry 7 30 elements 16 overlapping tied pair(s)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [8] ptlogo_eff3.t32 x [14] pteff04.t32 moves 240308 px (max Δ 225)
|
||||
[default (what `screen render` draws)] [1] ptlogo2.t32 x [11] ptlogo_tm.t32 (key 32928): 1 px differ (0.0001% of frame), max Δ 1 | ink 58790 / 1062 px, shared 5 px
|
||||
[default (what `screen render` draws)] [2] ptlogo1.t32 x [4] ptlogo1.t32 (key 32928): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [3] ptlogo2.t32 x [5] ptlogo2.t32 (key 32928): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [7] ptlogo_eff2.rat x [23] ptlogo_back2.t32 (key 32898): 67 px differ (0.0073% of frame), max Δ 1 | ink 74167 / 723 px, shared 8 px
|
||||
[default (what `screen render` draws)] [8] ptlogo_eff3.t32 x [24] ptlogo_back2eff.t32 (key 32897): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 14507 px, shared 0 px
|
||||
[default (what `screen render` draws)] [15] ptloop01.rat x [16] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [19] ptlogo_back2eff2.t32 (key 32899): 211 px differ (0.0229% of frame), max Δ 1 | ink 2124 / 4909 px, shared 2124 px
|
||||
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 538 px differ (0.0584% of frame), max Δ 2 | ink 2124 / 7538 px, shared 2124 px
|
||||
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 941 px differ (0.1021% of frame), max Δ 2 | ink 2124 / 9413 px, shared 2124 px
|
||||
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1061 px differ (0.1151% of frame), max Δ 2 | ink 2124 / 16198 px, shared 2124 px
|
||||
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 216 px differ (0.0234% of frame), max Δ 1 | ink 4909 / 7538 px, shared 4909 px
|
||||
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 663 px differ (0.0719% of frame), max Δ 2 | ink 4909 / 9413 px, shared 4909 px
|
||||
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 847 px differ (0.0919% of frame), max Δ 2 | ink 4909 / 16198 px, shared 4909 px
|
||||
[default (what `screen render` draws)] [20] ptlogo_back2eff3.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 330 px differ (0.0358% of frame), max Δ 1 | ink 7538 / 9413 px, shared 7538 px
|
||||
[default (what `screen render` draws)] [20] ptlogo_back2eff3.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 526 px differ (0.0571% of frame), max Δ 2 | ink 7538 / 16198 px, shared 7538 px
|
||||
[default (what `screen render` draws)] [21] ptlogo_back2eff4.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 9 px differ (0.0010% of frame), max Δ 1 | ink 9413 / 16198 px, shared 9413 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [8] ptlogo_eff3.t32 x [14] pteff04.t32 moves 825048 px (max Δ 225)
|
||||
[everything on (focus+animated+primitives)] [1] ptlogo2.t32 x [11] ptlogo_tm.t32 (key 32928): 3 px differ (0.0003% of frame), max Δ 1 | ink 58742 / 1062 px, shared 5 px
|
||||
[everything on (focus+animated+primitives)] [2] ptlogo1.t32 x [4] ptlogo1.t32 (key 32928): NOT BOTH DRAWN — unreachable here
|
||||
[everything on (focus+animated+primitives)] [3] ptlogo2.t32 x [5] ptlogo2.t32 (key 32928): NOT BOTH DRAWN — unreachable here
|
||||
[everything on (focus+animated+primitives)] [7] ptlogo_eff2.rat x [23] ptlogo_back2.t32 (key 32898): 67 px differ (0.0073% of frame), max Δ 1 | ink 73690 / 729 px, shared 7 px
|
||||
[everything on (focus+animated+primitives)] [8] ptlogo_eff3.t32 x [24] ptlogo_back2eff.t32 (key 32897): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 14531 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [15] ptloop01.rat x [16] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [19] ptlogo_back2eff2.t32 (key 32899): 193 px differ (0.0209% of frame), max Δ 1 | ink 2137 / 4917 px, shared 2137 px
|
||||
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 511 px differ (0.0554% of frame), max Δ 2 | ink 2137 / 7552 px, shared 2137 px
|
||||
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 931 px differ (0.1010% of frame), max Δ 2 | ink 2137 / 9422 px, shared 2137 px
|
||||
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1034 px differ (0.1122% of frame), max Δ 3 | ink 2137 / 16223 px, shared 2137 px
|
||||
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 233 px differ (0.0253% of frame), max Δ 1 | ink 4917 / 7552 px, shared 4917 px
|
||||
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 668 px differ (0.0725% of frame), max Δ 2 | ink 4917 / 9422 px, shared 4917 px
|
||||
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 841 px differ (0.0913% of frame), max Δ 2 | ink 4917 / 16223 px, shared 4917 px
|
||||
[everything on (focus+animated+primitives)] [20] ptlogo_back2eff3.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 319 px differ (0.0346% of frame), max Δ 1 | ink 7552 / 9422 px, shared 7552 px
|
||||
[everything on (focus+animated+primitives)] [20] ptlogo_back2eff3.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 539 px differ (0.0585% of frame), max Δ 2 | ink 7552 / 16223 px, shared 7552 px
|
||||
[everything on (focus+animated+primitives)] [21] ptlogo_back2eff4.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 6 px differ (0.0007% of frame), max Δ 1 | ink 9422 / 16223 px, shared 9422 px
|
||||
|
||||
entry 8 16 elements 2 overlapping tied pair(s)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 771479 px (max Δ 66)
|
||||
[default (what `screen render` draws)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4778 / 5302 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 733320 px (max Δ 50)
|
||||
[everything on (focus+animated+primitives)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4782 / 5309 px, shared 0 px
|
||||
|
||||
entry 9 18 elements 2 overlapping tied pair(s)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 768159 px (max Δ 66)
|
||||
[default (what `screen render` draws)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
|
||||
[default (what `screen render` draws)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 729480 px (max Δ 50)
|
||||
[everything on (focus+animated+primitives)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
|
||||
entry 12 10 elements 1 overlapping tied pair(s)
|
||||
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
[default (what `screen render` draws)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
|
||||
[everything on (focus+animated+primitives)] CONTROL DEAD: swapping [6] pgloading_loop5.rat x [7] pgloading_baseeff.t32 changes NOTHING — zeros below are uninterpretable
|
||||
[everything on (focus+animated+primitives)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
|
||||
entry 15 10 elements 1 overlapping tied pair(s)
|
||||
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
[default (what `screen render` draws)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
|
||||
[everything on (focus+animated+primitives)] CONTROL DEAD: swapping [6] pgloading_loop5.rat x [7] pgloading_baseeff.t32 changes NOTHING — zeros below are uninterpretable
|
||||
[everything on (focus+animated+primitives)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
|
||||
default-options summary: 26 of 31 overlapping tied pairs change at least one pixel; 6 dead/unavailable controls
|
||||
55
docs/re/data/paint-order-ties-gp_title.txt
Normal file
55
docs/re/data/paint-order-ties-gp_title.txt
Normal file
@@ -0,0 +1,55 @@
|
||||
entry 0 (no measured order) 7 elements, 2 tied pairs, 1 of them OVERLAPPING
|
||||
overlapping tie: [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 key 49408 rect (202, 528, 332, 144) / (74, 518, 188, 186) overlap 60x144
|
||||
entry 1 (no measured order) 7 elements, 2 tied pairs, 1 of them OVERLAPPING
|
||||
overlapping tie: [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 key 49408 rect (202, 528, 332, 144) / (74, 518, 188, 186) overlap 60x144
|
||||
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, 32800, 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
|
||||
overlapping tie: [0] ptframe3.t32 x [1] ptframe4.t32 key 32848 rect (440, 230, 246, 220) / (584, 318, 256, 210) overlap 102x132
|
||||
overlapping tie: [14] ptloop01.rat x [15] ptloop02.rat key 32784 rect (441, 270, 400, 180) / (441, 270, 400, 180) overlap 400x180
|
||||
entry 7 (no measured order) 30 elements, 37 tied pairs, 16 of them OVERLAPPING
|
||||
overlapping tie: [1] ptlogo2.t32 x [11] ptlogo_tm.t32 key 32928 rect (193, 335, 898, 92) / (1073, 392, 44, 28) overlap 18x28
|
||||
overlapping tie: [2] ptlogo1.t32 x [4] ptlogo1.t32 key 32928 rect (-65, 33, 902, 100) / (-65, 33, 902, 100) overlap 902x100
|
||||
overlapping tie: [3] ptlogo2.t32 x [5] ptlogo2.t32 key 32928 rect (493, 535, 898, 92) / (493, 535, 898, 92) overlap 898x92
|
||||
overlapping tie: [7] ptlogo_eff2.rat x [23] ptlogo_back2.t32 key 32898 rect (412, 96, 338, 338) / (134, 173, 1000, 234) overlap 338x234
|
||||
overlapping tie: [8] ptlogo_eff3.t32 x [24] ptlogo_back2eff.t32 key 32897 rect (98, 42, 946, 386) / (127, 164, 1014, 252) overlap 917x252
|
||||
overlapping tie: [15] ptloop01.rat x [16] ptloop02.rat key 32784 rect (441, 270, 400, 180) / (441, 270, 400, 180) overlap 400x180
|
||||
overlapping tie: [18] ptlogo_back2eff1.t32 x [19] ptlogo_back2eff2.t32 key 32899 rect (910, 227, 156, 120) / (910, 164, 232, 182) overlap 156x119
|
||||
overlapping tie: [18] ptlogo_back2eff1.t32 x [20] ptlogo_back2eff3.t32 key 32899 rect (910, 227, 156, 120) / (802, 164, 340, 182) overlap 156x119
|
||||
overlapping tie: [18] ptlogo_back2eff1.t32 x [21] ptlogo_back2eff4.t32 key 32899 rect (910, 227, 156, 120) / (483, 164, 658, 182) overlap 156x119
|
||||
overlapping tie: [18] ptlogo_back2eff1.t32 x [22] ptlogo_back2eff5.t32 key 32899 rect (910, 227, 156, 120) / (127, 164, 1014, 252) overlap 156x120
|
||||
overlapping tie: [19] ptlogo_back2eff2.t32 x [20] ptlogo_back2eff3.t32 key 32899 rect (910, 164, 232, 182) / (802, 164, 340, 182) overlap 232x182
|
||||
overlapping tie: [19] ptlogo_back2eff2.t32 x [21] ptlogo_back2eff4.t32 key 32899 rect (910, 164, 232, 182) / (483, 164, 658, 182) overlap 231x182
|
||||
overlapping tie: [19] ptlogo_back2eff2.t32 x [22] ptlogo_back2eff5.t32 key 32899 rect (910, 164, 232, 182) / (127, 164, 1014, 252) overlap 231x182
|
||||
overlapping tie: [20] ptlogo_back2eff3.t32 x [21] ptlogo_back2eff4.t32 key 32899 rect (802, 164, 340, 182) / (483, 164, 658, 182) overlap 339x182
|
||||
overlapping tie: [20] ptlogo_back2eff3.t32 x [22] ptlogo_back2eff5.t32 key 32899 rect (802, 164, 340, 182) / (127, 164, 1014, 252) overlap 339x182
|
||||
overlapping tie: [21] ptlogo_back2eff4.t32 x [22] ptlogo_back2eff5.t32 key 32899 rect (483, 164, 658, 182) / (127, 164, 1014, 252) overlap 658x182
|
||||
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
|
||||
overlapping tie: [0] ptframe3.t32 x [1] ptframe4.t32 key 32848 rect (440, 230, 246, 220) / (584, 318, 256, 210) overlap 102x132
|
||||
overlapping tie: [14] ptloop01.rat x [15] ptloop02.rat key 32784 rect (441, 270, 400, 180) / (441, 270, 400, 180) overlap 400x180
|
||||
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
|
||||
overlapping tie: [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 key 49408 rect (202, 528, 332, 144) / (74, 518, 188, 186) overlap 60x144
|
||||
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
|
||||
overlapping tie: [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 key 49408 rect (202, 528, 332, 144) / (74, 518, 188, 186) overlap 60x144
|
||||
|
||||
5 build(s) with a measured order were checked
|
||||
3063
docs/re/data/plate-pulse-timeseries.txt
Normal file
3063
docs/re/data/plate-pulse-timeseries.txt
Normal file
File diff suppressed because it is too large
Load Diff
1809
docs/re/data/plate-timing-run1.tsv
Normal file
1809
docs/re/data/plate-timing-run1.tsv
Normal file
File diff suppressed because it is too large
Load Diff
1912
docs/re/data/plate-timing-run2.tsv
Normal file
1912
docs/re/data/plate-timing-run2.tsv
Normal file
File diff suppressed because it is too large
Load Diff
4171
docs/re/data/present-rate-controls-2026-08-29.json
Normal file
4171
docs/re/data/present-rate-controls-2026-08-29.json
Normal file
File diff suppressed because it is too large
Load Diff
62
docs/re/data/ptloop-leaf-sweep-positions.txt
Normal file
62
docs/re/data/ptloop-leaf-sweep-positions.txt
Normal file
@@ -0,0 +1,62 @@
|
||||
# The title's two light-sweep leaves: position, alpha and on-screen extent
|
||||
# across the window where the draw-capture fit (t=357.7) and the port's
|
||||
# PNG fit (~400) disagree.
|
||||
#
|
||||
# Produced by: cargo run -p sylpheed-formats --example ptloop_leaf_sweep_at
|
||||
# 2026-08-30, SYLPHEED_DISC=/disc, GP_TITLE entry 4.
|
||||
#
|
||||
# CONTROL: at t=355 this reproduces ui-leaf-vs-parent-alpha.md's published
|
||||
# centres exactly -- 981 and 478. The probe is reading the same leaves.
|
||||
#
|
||||
# centre = keyframe x + pivot_x. The keyframe x is the quad's LEFT edge;
|
||||
# the draw-capture fit is quoted in centres.
|
||||
#
|
||||
# At t=357.7: A centre 991.8, B centre 467.2 (measured: 992.0 / 467.2)
|
||||
# At t=400: A centre 1161, B centre 295 -- +169.0 and -172.2 px off
|
||||
#
|
||||
# The two nested records cycle at DIFFERENT lengths, 600 and 720.
|
||||
#
|
||||
|
||||
-- ptloop01.rat nested record: loop length (+0x08) = 600
|
||||
|
||||
== ptloop01.rat -> leaf pteff03.t32 (sprite None, pivot 200x90, 4 keyframes, last t=Some(Some(600)))
|
||||
t | x | centre | a | on-screen px of a 400px-wide quad
|
||||
340 | 721 | 921 | 190 | 400 px
|
||||
350 | 761 | 961 | 193 | 400 px
|
||||
355 | 781 | 981 | 195 | 400 px
|
||||
357 | 789 | 989 | 195 | 400 px
|
||||
358 | 793 | 993 | 196 | 400 px
|
||||
360 | 801 | 1001 | 196 | 400 px
|
||||
370 | 841 | 1041 | 200 | 400 px
|
||||
380 | 881 | 1081 | 203 | 399 px
|
||||
390 | 921 | 1121 | 206 | 359 px
|
||||
395 | 941 | 1141 | 208 | 339 px
|
||||
400 | 961 | 1161 | 209 | 319 px
|
||||
405 | 981 | 1181 | 211 | 299 px
|
||||
410 | 1001 | 1201 | 213 | 279 px
|
||||
420 | 1041 | 1241 | 216 | 239 px
|
||||
440 | 1121 | 1321 | 222 | 159 px
|
||||
480 | 1281 | 1481 | 235 | 0 px *** ENTIRELY OFF SCREEN ***
|
||||
540 | 1521 | 1721 | 255 | 0 px *** ENTIRELY OFF SCREEN ***
|
||||
|
||||
-- ptloop02.rat nested record: loop length (+0x08) = 720
|
||||
|
||||
== ptloop02.rat -> leaf pteff03a.t32 (sprite None, pivot 200x90, 4 keyframes, last t=Some(Some(720)))
|
||||
t | x | centre | a | on-screen px of a 400px-wide quad
|
||||
340 | 339 | 539 | 178 | 400 px
|
||||
350 | 298 | 498 | 181 | 400 px
|
||||
355 | 278 | 478 | 182 | 400 px
|
||||
357 | 270 | 470 | 183 | 400 px
|
||||
358 | 266 | 466 | 183 | 400 px
|
||||
360 | 258 | 458 | 184 | 400 px
|
||||
370 | 217 | 417 | 186 | 400 px
|
||||
380 | 177 | 377 | 189 | 400 px
|
||||
390 | 136 | 336 | 192 | 400 px
|
||||
395 | 116 | 316 | 193 | 400 px
|
||||
400 | 95 | 295 | 194 | 400 px
|
||||
405 | 75 | 275 | 195 | 400 px
|
||||
410 | 55 | 255 | 197 | 400 px
|
||||
420 | 14 | 214 | 199 | 400 px
|
||||
440 | -67 | 133 | 205 | 333 px
|
||||
480 | -230 | -30 | 215 | 170 px
|
||||
540 | -473 | -273 | 231 | 0 px *** ENTIRELY OFF SCREEN ***
|
||||
20
docs/re/data/record-loop-length-census.txt
Normal file
20
docs/re/data/record-loop-length-census.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
nested records with timed keyframes : 1781
|
||||
+08 == max keyframe time (exact) : 1643 (92.3%)
|
||||
+08 > max keyframe time (a hold) : 138 (7.7%)
|
||||
+08 < max keyframe time 🔴 : 0 (0.00%) <- the falsifier
|
||||
|
||||
slack (+08 - max t) distribution, most common first:
|
||||
slack 0 : 1643
|
||||
slack 40 : 32
|
||||
slack 10 : 20
|
||||
slack 4 : 13
|
||||
slack 54 : 12
|
||||
slack 30 : 8
|
||||
slack 1 : 6
|
||||
slack 6 : 6
|
||||
slack 9 : 6
|
||||
slack 36 : 6
|
||||
slack 405 : 6
|
||||
slack 16 : 5
|
||||
slack 58 : 5
|
||||
slack 80 : 5
|
||||
22
docs/re/data/tie-break-live-over-time-gp_title.txt
Normal file
22
docs/re/data/tie-break-live-over-time-gp_title.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
# tied pairs that could cost a pixel, over time — /disc/dat/GP_TITLE.pak
|
||||
|
||||
entry 0 peak 1 live pair(s) over t=0..40 settle window [34,38] at t=36: 0 ACROSS THE WHOLE WINDOW: max 0
|
||||
live only at t17:1 t18:1 t19:1 t20:1 t21:1 t22:1 t23:1 t24:1 t25:1 t26:1 t27:1 t28:1 t30:1 t32:1 t33:1
|
||||
entry 1 peak 1 live pair(s) over t=0..40 settle window [34,38] at t=36: 0 ACROSS THE WHOLE WINDOW: max 0
|
||||
live only at t17:1 t18:1 t19:1 t20:1 t21:1 t22:1 t23:1 t24:1 t25:1 t26:1 t27:1 t28:1 t30:1 t32:1 t33:1
|
||||
entry 4 peak 6 live pair(s) over t=0..269 settle window [160,236] at t=198: 1 ACROSS THE WHOLE WINDOW: max 1
|
||||
live at 62 of 88 sampled instants
|
||||
entry 5 peak 2 live pair(s) over t=0..80 settle window [44,56] at t=50: 2 ACROSS THE WHOLE WINDOW: max 2
|
||||
live at 47 of 47 sampled instants
|
||||
entry 6 peak 2 live pair(s) over t=0..74 settle window [38,50] at t=44: 2 ACROSS THE WHOLE WINDOW: max 2
|
||||
live at 46 of 46 sampled instants
|
||||
entry 7 peak 6 live pair(s) over t=0..269 settle window [190,236] at t=213: 2 ACROSS THE WHOLE WINDOW: max 2
|
||||
live at 92 of 117 sampled instants
|
||||
entry 8 peak 2 live pair(s) over t=0..80 settle window [44,56] at t=50: 2 ACROSS THE WHOLE WINDOW: max 2
|
||||
live at 47 of 47 sampled instants
|
||||
entry 9 peak 2 live pair(s) over t=0..74 settle window [38,50] at t=44: 2 ACROSS THE WHOLE WINDOW: max 2
|
||||
live at 46 of 46 sampled instants
|
||||
entry 12 peak 1 live pair(s) over t=0..48 settle window [40,48] at t=44: 0 ACROSS THE WHOLE WINDOW: max 0
|
||||
live only at t17:1 t18:1 t19:1 t20:1 t21:1 t22:1 t23:1 t24:1 t25:1 t26:1 t27:1 t28:1 t30:1 t32:1 t33:1
|
||||
entry 15 peak 1 live pair(s) over t=0..48 settle window [40,48] at t=44: 0 ACROSS THE WHOLE WINDOW: max 0
|
||||
live only at t17:1 t18:1 t19:1 t20:1 t21:1 t22:1 t23:1 t24:1 t25:1 t26:1 t27:1 t28:1 t30:1 t32:1 t33:1
|
||||
142
docs/re/data/tie-break-pixel-cost-gp_title.txt
Normal file
142
docs/re/data/tie-break-pixel-cost-gp_title.txt
Normal file
@@ -0,0 +1,142 @@
|
||||
# tie-break pixel cost — /disc/dat/GP_TITLE.pak
|
||||
|
||||
entry 0 7 elements 1 overlapping tied pair(s) at rest settle t=36 (window 4 units)
|
||||
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
[default (what `screen render` draws)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [4] pgloading_loop4.rat x [6] pgloading_eff02.t32 moves 3654 px (max Δ 23)
|
||||
[everything on (focus+animated+primitives)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1610 px differ (0.1747% of frame), max Δ 1 | ink 29173 / 21017 px, shared 3139 px
|
||||
[AT THE SETTLE TIME (what the player sees)] 🔴 1 of the 1 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
|
||||
[AT THE SETTLE TIME (what the player sees)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
|
||||
entry 1 7 elements 1 overlapping tied pair(s) at rest settle t=36 (window 4 units)
|
||||
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
[default (what `screen render` draws)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [4] pgloading_loop4.rat x [6] pgloading_eff02.t32 moves 3654 px (max Δ 23)
|
||||
[everything on (focus+animated+primitives)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1610 px differ (0.1747% of frame), max Δ 1 | ink 29173 / 21017 px, shared 3139 px
|
||||
[AT THE SETTLE TIME (what the player sees)] 🔴 1 of the 1 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
|
||||
[AT THE SETTLE TIME (what the player sees)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
|
||||
entry 4 24 elements 11 overlapping tied pair(s) at rest settle t=198 (window 76 units)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [10] pteff04.t32 x [18] ptlogo_back2eff5.t32 moves 36305 px (max Δ 254)
|
||||
[default (what `screen render` draws)] [11] ptloop01.rat x [12] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [15] ptlogo_back2eff2.t32 (key 32899): 295 px differ (0.0320% of frame), max Δ 1 | ink 2483 / 6547 px, shared 2483 px
|
||||
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 861 px differ (0.0934% of frame), max Δ 2 | ink 2483 / 9698 px, shared 2483 px
|
||||
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1555 px differ (0.1687% of frame), max Δ 2 | ink 2483 / 13926 px, shared 2483 px
|
||||
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6645 px differ (0.7210% of frame), max Δ 3 | ink 2483 / 22834 px, shared 2398 px
|
||||
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 420 px differ (0.0456% of frame), max Δ 1 | ink 6547 / 9698 px, shared 6547 px
|
||||
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1209 px differ (0.1312% of frame), max Δ 2 | ink 6547 / 13926 px, shared 6547 px
|
||||
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6641 px differ (0.7206% of frame), max Δ 2 | ink 6547 / 22834 px, shared 6360 px
|
||||
[default (what `screen render` draws)] [16] ptlogo_back2eff3.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 584 px differ (0.0634% of frame), max Δ 1 | ink 9698 / 13926 px, shared 9698 px
|
||||
[default (what `screen render` draws)] [16] ptlogo_back2eff3.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6390 px differ (0.6934% of frame), max Δ 2 | ink 9698 / 22834 px, shared 9462 px
|
||||
[default (what `screen render` draws)] [17] ptlogo_back2eff4.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 5516 px differ (0.5985% of frame), max Δ 1 | ink 13926 / 22834 px, shared 13480 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [10] pteff04.t32 x [18] ptlogo_back2eff5.t32 moves 860461 px (max Δ 254)
|
||||
[everything on (focus+animated+primitives)] [11] ptloop01.rat x [12] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [15] ptlogo_back2eff2.t32 (key 32899): 280 px differ (0.0304% of frame), max Δ 1 | ink 2516 / 6589 px, shared 2516 px
|
||||
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 811 px differ (0.0880% of frame), max Δ 2 | ink 2516 / 9754 px, shared 2516 px
|
||||
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1527 px differ (0.1657% of frame), max Δ 2 | ink 2516 / 14072 px, shared 2516 px
|
||||
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6586 px differ (0.7146% of frame), max Δ 3 | ink 2516 / 22970 px, shared 2419 px
|
||||
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 395 px differ (0.0429% of frame), max Δ 1 | ink 6589 / 9754 px, shared 6589 px
|
||||
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1204 px differ (0.1306% of frame), max Δ 2 | ink 6589 / 14072 px, shared 6589 px
|
||||
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6567 px differ (0.7126% of frame), max Δ 2 | ink 6589 / 22970 px, shared 6397 px
|
||||
[everything on (focus+animated+primitives)] [16] ptlogo_back2eff3.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 599 px differ (0.0650% of frame), max Δ 1 | ink 9754 / 14072 px, shared 9754 px
|
||||
[everything on (focus+animated+primitives)] [16] ptlogo_back2eff3.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6333 px differ (0.6872% of frame), max Δ 2 | ink 9754 / 22970 px, shared 9512 px
|
||||
[everything on (focus+animated+primitives)] [17] ptlogo_back2eff4.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 5427 px differ (0.5889% of frame), max Δ 1 | ink 14072 / 22970 px, shared 13620 px
|
||||
[AT THE SETTLE TIME (what the player sees)] 🔴 10 of the 11 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
|
||||
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [10] pteff04.t32 x [20] ptlogo_back2eff.t32 moves 25310 px (max Δ 243)
|
||||
[AT THE SETTLE TIME (what the player sees)] [11] ptloop01.rat x [12] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
|
||||
entry 5 16 elements 2 overlapping tied pair(s) at rest settle t=50 (window 12 units)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 764030 px (max Δ 67)
|
||||
[default (what `screen render` draws)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4783 / 5297 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 725164 px (max Δ 50)
|
||||
[everything on (focus+animated+primitives)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4781 / 5305 px, shared 0 px
|
||||
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 765778 px (max Δ 67)
|
||||
[AT THE SETTLE TIME (what the player sees)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[AT THE SETTLE TIME (what the player sees)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4783 / 5297 px, shared 0 px
|
||||
|
||||
entry 6 18 elements 2 overlapping tied pair(s) at rest settle t=44 (window 12 units)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 761600 px (max Δ 67)
|
||||
[default (what `screen render` draws)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
|
||||
[default (what `screen render` draws)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 721144 px (max Δ 50)
|
||||
[everything on (focus+animated+primitives)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 764104 px (max Δ 67)
|
||||
[AT THE SETTLE TIME (what the player sees)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
|
||||
[AT THE SETTLE TIME (what the player sees)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
|
||||
entry 7 30 elements 13 overlapping tied pair(s) at rest settle t=213 (window 46 units)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [14] pteff04.t32 x [22] ptlogo_back2eff5.t32 moves 278139 px (max Δ 248)
|
||||
[default (what `screen render` draws)] [1] ptlogo2.t32 x [11] ptlogo_tm.t32 (key 32928): 1 px differ (0.0001% of frame), max Δ 1 | ink 58773 / 1062 px, shared 5 px
|
||||
[default (what `screen render` draws)] [7] ptlogo_eff2.rat x [23] ptlogo_back2.t32 (key 32898): 1 px differ (0.0001% of frame), max Δ 1 | ink 47839 / 659 px, shared 3 px
|
||||
[default (what `screen render` draws)] [15] ptloop01.rat x [16] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [19] ptlogo_back2eff2.t32 (key 32899): 211 px differ (0.0229% of frame), max Δ 1 | ink 2124 / 4909 px, shared 2124 px
|
||||
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 538 px differ (0.0584% of frame), max Δ 2 | ink 2124 / 7538 px, shared 2124 px
|
||||
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 941 px differ (0.1021% of frame), max Δ 2 | ink 2124 / 10876 px, shared 2124 px
|
||||
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1237 px differ (0.1342% of frame), max Δ 2 | ink 2124 / 17678 px, shared 2124 px
|
||||
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 216 px differ (0.0234% of frame), max Δ 1 | ink 4909 / 7538 px, shared 4909 px
|
||||
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 663 px differ (0.0719% of frame), max Δ 2 | ink 4909 / 10876 px, shared 4909 px
|
||||
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1023 px differ (0.1110% of frame), max Δ 2 | ink 4909 / 17678 px, shared 4909 px
|
||||
[default (what `screen render` draws)] [20] ptlogo_back2eff3.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 330 px differ (0.0358% of frame), max Δ 1 | ink 7538 / 10876 px, shared 7538 px
|
||||
[default (what `screen render` draws)] [20] ptlogo_back2eff3.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 702 px differ (0.0762% of frame), max Δ 2 | ink 7538 / 17678 px, shared 7538 px
|
||||
[default (what `screen render` draws)] [21] ptlogo_back2eff4.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 185 px differ (0.0201% of frame), max Δ 1 | ink 10876 / 17678 px, shared 10876 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [14] pteff04.t32 x [22] ptlogo_back2eff5.t32 moves 862138 px (max Δ 248)
|
||||
[everything on (focus+animated+primitives)] [1] ptlogo2.t32 x [11] ptlogo_tm.t32 (key 32928): 3 px differ (0.0003% of frame), max Δ 1 | ink 58727 / 1062 px, shared 5 px
|
||||
[everything on (focus+animated+primitives)] [7] ptlogo_eff2.rat x [23] ptlogo_back2.t32 (key 32898): 1 px differ (0.0001% of frame), max Δ 1 | ink 47217 / 664 px, shared 3 px
|
||||
[everything on (focus+animated+primitives)] [15] ptloop01.rat x [16] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [19] ptlogo_back2eff2.t32 (key 32899): 193 px differ (0.0209% of frame), max Δ 1 | ink 2137 / 4917 px, shared 2137 px
|
||||
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 511 px differ (0.0554% of frame), max Δ 2 | ink 2137 / 7552 px, shared 2137 px
|
||||
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 931 px differ (0.1010% of frame), max Δ 2 | ink 2137 / 10892 px, shared 2137 px
|
||||
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1215 px differ (0.1318% of frame), max Δ 3 | ink 2137 / 17707 px, shared 2137 px
|
||||
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 233 px differ (0.0253% of frame), max Δ 1 | ink 4917 / 7552 px, shared 4917 px
|
||||
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 668 px differ (0.0725% of frame), max Δ 2 | ink 4917 / 10892 px, shared 4917 px
|
||||
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1022 px differ (0.1109% of frame), max Δ 2 | ink 4917 / 17707 px, shared 4917 px
|
||||
[everything on (focus+animated+primitives)] [20] ptlogo_back2eff3.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 319 px differ (0.0346% of frame), max Δ 1 | ink 7552 / 10892 px, shared 7552 px
|
||||
[everything on (focus+animated+primitives)] [20] ptlogo_back2eff3.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 720 px differ (0.0781% of frame), max Δ 2 | ink 7552 / 17707 px, shared 7552 px
|
||||
[everything on (focus+animated+primitives)] [21] ptlogo_back2eff4.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 187 px differ (0.0203% of frame), max Δ 1 | ink 10892 / 17707 px, shared 10892 px
|
||||
[AT THE SETTLE TIME (what the player sees)] 🔴 11 of the 13 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
|
||||
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [14] pteff04.t32 x [24] ptlogo_back2eff.t32 moves 268698 px (max Δ 247)
|
||||
[AT THE SETTLE TIME (what the player sees)] [1] ptlogo2.t32 x [11] ptlogo_tm.t32 (key 32928): 1 px differ (0.0001% of frame), max Δ 1 | ink 58770 / 1062 px, shared 5 px
|
||||
[AT THE SETTLE TIME (what the player sees)] [15] ptloop01.rat x [16] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
|
||||
entry 8 16 elements 2 overlapping tied pair(s) at rest settle t=50 (window 12 units)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 771479 px (max Δ 66)
|
||||
[default (what `screen render` draws)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[default (what `screen render` draws)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4778 / 5302 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 733320 px (max Δ 50)
|
||||
[everything on (focus+animated+primitives)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4782 / 5309 px, shared 0 px
|
||||
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 773431 px (max Δ 66)
|
||||
[AT THE SETTLE TIME (what the player sees)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[AT THE SETTLE TIME (what the player sees)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4778 / 5302 px, shared 0 px
|
||||
|
||||
entry 9 18 elements 2 overlapping tied pair(s) at rest settle t=44 (window 12 units)
|
||||
[default (what `screen render` draws)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 768159 px (max Δ 66)
|
||||
[default (what `screen render` draws)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
|
||||
[default (what `screen render` draws)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
[everything on (focus+animated+primitives)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 729480 px (max Δ 50)
|
||||
[everything on (focus+animated+primitives)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
|
||||
[everything on (focus+animated+primitives)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 771048 px (max Δ 66)
|
||||
[AT THE SETTLE TIME (what the player sees)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
|
||||
[AT THE SETTLE TIME (what the player sees)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
|
||||
|
||||
entry 12 10 elements 1 overlapping tied pair(s) at rest settle t=44 (window 8 units)
|
||||
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
[default (what `screen render` draws)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
|
||||
[everything on (focus+animated+primitives)] CONTROL DEAD: swapping [6] pgloading_loop5.rat x [7] pgloading_baseeff.t32 changes NOTHING — zeros below are uninterpretable
|
||||
[everything on (focus+animated+primitives)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[AT THE SETTLE TIME (what the player sees)] 🔴 1 of the 1 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
|
||||
[AT THE SETTLE TIME (what the player sees)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
|
||||
entry 15 10 elements 1 overlapping tied pair(s) at rest settle t=44 (window 8 units)
|
||||
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
[default (what `screen render` draws)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
|
||||
[everything on (focus+animated+primitives)] CONTROL DEAD: swapping [6] pgloading_loop5.rat x [7] pgloading_baseeff.t32 changes NOTHING — zeros below are uninterpretable
|
||||
[everything on (focus+animated+primitives)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
|
||||
[AT THE SETTLE TIME (what the player sees)] 🔴 1 of the 1 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
|
||||
[AT THE SETTLE TIME (what the player sees)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
|
||||
|
||||
default-options summary: 26 of 30 overlapping tied pairs change at least one pixel; 10 dead/unavailable controls
|
||||
3184
docs/re/data/title-glow-alpha-per-frame.csv
Normal file
3184
docs/re/data/title-glow-alpha-per-frame.csv
Normal file
File diff suppressed because it is too large
Load Diff
30
docs/re/data/ui-scale-census-with-leaves.txt
Normal file
30
docs/re/data/ui-scale-census-with-leaves.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
45 leaves opened
|
||||
|
||||
scale count examples
|
||||
0,0 12 e0/pgloading_loop4.rat, e1/pgloading_loop4.rat, e12/pgloading_loop4.rat
|
||||
0,100 4 e0/pgloading_line.t32, e1/pgloading_line.t32, e12/pgloading_line.t32
|
||||
* 75,75 4 e0/pgloading_loop4.rat, e1/pgloading_loop4.rat, e12/pgloading_loop4.rat
|
||||
* 75,100 4 e0/pgloading_line.t32, e1/pgloading_line.t32, e12/pgloading_line.t32
|
||||
* 96,96 4 e0/pgloading_loop4.rat, e1/pgloading_loop4.rat, e12/pgloading_loop4.rat
|
||||
* 96,100 4 e0/pgloading_line.t32, e1/pgloading_line.t32, e12/pgloading_line.t32
|
||||
* 99,99 4 e0/pgloading_loop4.rat, e1/pgloading_loop4.rat, e12/pgloading_loop4.rat
|
||||
* 99,100 4 e0/pgloading_line.t32, e1/pgloading_line.t32, e12/pgloading_line.t32
|
||||
100,100 759 e0/pgloading_eff01.t32, e0/pgloading_eff02.t32, e0/pgloading_line.t32
|
||||
100,600 24 e4/ptloop01.rat->LEAF/pteff03.t32, e5/ptloop01.rat->LEAF/pteff03.t32, e6/ptloop01.rat->LEAF/pteff03.t32
|
||||
100,800 24 e4/ptloop02.rat->LEAF/pteff03a.t32, e5/ptloop02.rat->LEAF/pteff03a.t32, e6/ptloop02.rat->LEAF/pteff03a.t32
|
||||
* 101,101 12 e4/ptlogo1.t32, e4/ptlogo2.t32, e7/ptlogo1.t32
|
||||
* 103,103 12 e4/ptlogo1.t32, e4/ptlogo2.t32, e7/ptlogo1.t32
|
||||
* 112,112 12 e4/ptlogo1.t32, e4/ptlogo2.t32, e7/ptlogo1.t32
|
||||
* 125,125 2 e7/ptlogo_eff2.rat
|
||||
* 150,150 28 e0/pgloading_loop1.rat, e1/pgloading_loop1.rat, e12/pgloading_loop1.rat
|
||||
200,200 46 e12/pgloading_baseeff.t32, e15/pgloading_baseeff.t32, e4/ptbase2.t32
|
||||
200,500 20 e5/pteff10.t32, e6/pteff10.t32, e8/pteff10.t32
|
||||
* 204,208 1 e4/ptlogoall_eff.t32
|
||||
* 210,220 1 e4/ptlogoall_eff.t32
|
||||
* 250,250 2 e12/pgloading_loop5.rat->LEAF/pgloading_ring.t32, e15/pgloading_loop5.rat->LEAF/pgloading_ring.t32
|
||||
300,100 2 e6/pteff21.t32, e9/pteff21.t32
|
||||
400,400 5 e4/ptlogoall_eff2.t32
|
||||
800,800 2 e12/pgloading_loop5.rat->LEAF/pgloading_ring.t32, e15/pgloading_loop5.rat->LEAF/pgloading_ring.t32
|
||||
1000,1000 4 e12/pgloading_loop5.rat->LEAF/pgloading_ring.t32, e15/pgloading_loop5.rat->LEAF/pgloading_ring.t32
|
||||
|
||||
* = not a whole multiple of 100%
|
||||
36
docs/re/data/voice-region-cap-sweep.txt
Normal file
36
docs/re/data/voice-region-cap-sweep.txt
Normal file
@@ -0,0 +1,36 @@
|
||||
ADV today [806912, 1118208, 1171456]
|
||||
prop [1294336, 1118208, 1171456] first chunk GREW, tail identical
|
||||
S00A today [1323008, 1263616, 98304]
|
||||
prop [1810432, 1263616, 98304] first chunk GREW, tail identical
|
||||
S01A today [1153024, 1390592, 1552384]
|
||||
prop [1640448, 1390592, 1552384] first chunk GREW, tail identical
|
||||
S02B today [430080, 505856, 866304]
|
||||
prop [919552, 505856, 866304] first chunk GREW, tail identical
|
||||
S02C today [1867776, 1349632, 2390016]
|
||||
prop [2353152, 1349632, 2390016] first chunk GREW, tail identical
|
||||
S03A today [251904, 540672, 739328]
|
||||
prop [741376, 540672, 739328] first chunk GREW, tail identical
|
||||
S04B today [555008, 870400, 991232]
|
||||
prop [1044480, 870400, 991232] first chunk GREW, tail identical
|
||||
S06A today [350208, 294912, 860160]
|
||||
prop [839680, 294912, 860160] first chunk GREW, tail identical
|
||||
S06B today [606208, 874496, 1449984]
|
||||
prop [1093632, 874496, 1449984] first chunk GREW, tail identical
|
||||
S07A today [503808, 473088, 1040384]
|
||||
prop [993280, 473088, 1040384] first chunk GREW, tail identical
|
||||
S09B today [176128, 571392, 843776]
|
||||
prop [665600, 571392, 843776] first chunk GREW, tail identical
|
||||
S11C today [741376, 1075200, 1107968]
|
||||
prop [1228800, 1075200, 1107968] first chunk GREW, tail identical
|
||||
S12C today [1843200, 1273856, 2502656]
|
||||
prop [2328576, 1273856, 2502656] first chunk GREW, tail identical
|
||||
S13A today [401408, 585728, 962560]
|
||||
prop [890880, 585728, 962560] first chunk GREW, tail identical
|
||||
S14A today [1816576, 1634304, 2541568]
|
||||
prop [2301952, 1634304, 2541568] first chunk GREW, tail identical
|
||||
S15A today [253952, 618496, 1122304]
|
||||
prop [743424, 618496, 1122304] first chunk GREW, tail identical
|
||||
S15C today [929792, 1101824, 1384448]
|
||||
prop [1417216, 1101824, 1384448] first chunk GREW, tail identical
|
||||
|
||||
unchanged 78 fixed-cleanly 17 would-break 0 skipped 9
|
||||
103
docs/re/data/voice-region-chunk-census.txt
Normal file
103
docs/re/data/voice-region-chunk-census.txt
Normal file
@@ -0,0 +1,103 @@
|
||||
104 movies in the manifest
|
||||
|
||||
ADV 433930240.. 437044592 3114352 B chunks 3 leading STREAM (808304 B = 394 packets + 1392 B)
|
||||
S00A 452798464.. 455499120 2700656 B chunks 3 leading STREAM (1324400 B = 646 packets + 1392 B)
|
||||
S01A 456003584.. 460117360 4113776 B chunks 3 leading STREAM (1154416 B = 563 packets + 1392 B)
|
||||
RT01A 437044592.. 437345648 301056 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT01B 437345648.. 437712240 366592 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT01C_1 437712240.. 438080880 368640 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT01C_2 438080880.. 438451568 370688 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S02A 460117360.. 461518192 1400832 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S02B 462022656.. 463838576 1815920 B chunks 3 leading STREAM (431472 B = 210 packets + 1392 B)
|
||||
S02C 464343040.. 469970288 5627248 B chunks 3 leading STREAM (1869168 B = 912 packets + 1392 B)
|
||||
RT02A 438451568.. 438789488 337920 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT02B 438789488.. 439192944 403456 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT02C 439192944.. 439645552 452608 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT02D_1 439645552.. 439852400 206848 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT02D_2 439852400.. 440112496 260096 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s02A 430128496.. 430196080 67584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s02H 430335344.. 430390640 55296 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S03A 470474752.. 472020336 1545584 B chunks 3 leading STREAM (253296 B = 123 packets + 1392 B)
|
||||
RT03A 440112496.. 440448368 335872 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT03B 440448368.. 440730992 282624 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT03C 440730992.. 441144688 413696 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT03D 441144688.. 441773424 628736 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s03A 430128496.. 430196080 67584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s03H 430335344.. 430390640 55296 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S04B 480507904.. 482938224 2430320 B chunks 3 leading STREAM (556400 B = 271 packets + 1392 B)
|
||||
RT04A 441773424.. 442019184 245760 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT04B 442019184.. 442254704 235520 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_DS_s02A 430265712.. 430335344 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S05A 482938224.. 483433840 495616 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT05A 442254704.. 442578288 323584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT05B 442578288.. 442938736 360448 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT05C 442938736.. 443143536 204800 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s02A 430128496.. 430196080 67584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S06A 483938304.. 485457264 1518960 B chunks 3 leading STREAM (351600 B = 171 packets + 1392 B)
|
||||
S06B 485961728.. 488908144 2946416 B chunks 3 leading STREAM (607600 B = 296 packets + 1392 B)
|
||||
RT06A 443143536.. 443460976 317440 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT06B 443460976.. 443802992 342016 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT06C 443802992.. 444046704 243712 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT06D 444046704.. 444183920 137216 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s06A 430128496.. 430196080 67584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s06H 430335344.. 430390640 55296 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S07A 489412608.. 491443568 2030960 B chunks 3 leading STREAM (505200 B = 246 packets + 1392 B)
|
||||
S07B 491443568.. 491634032 190464 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT07A 444183920.. 444706160 522240 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT07B 444706160.. 444980592 274432 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT07C 444980592.. 445130096 149504 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_DS_s07A 430265712.. 430335344 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_DS_s07H 430390640.. 430464368 73728 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT08A 445130096.. 445326704 196608 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT08B 445326704.. 445517168 190464 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT08C 445517168.. 445676912 159744 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_DS_s08A 430265712.. 430335344 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S09B 492138496.. 493743472 1604976 B chunks 3 leading STREAM (177520 B = 86 packets + 1392 B)
|
||||
RT09A 445676912.. 446000496 323584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT09B 446000496.. 446485872 485376 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT09C 446485872.. 446748016 262144 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT09D 446748016.. 447149424 401408 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s09A 430196080.. 430265712 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s09H 430335344.. 430390640 55296 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT10A 447149424.. 447272304 122880 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT10B 447272304.. 447356272 83968 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S11A 501900656.. 502173040 272384 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S11C 502677504.. 505619824 2942320 B chunks 3 leading STREAM (742768 B = 362 packets + 1392 B)
|
||||
RT11A 447356272.. 447776112 419840 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT11B 447776112.. 447880560 104448 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT11C 447880560.. 448097648 217088 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s11A 430196080.. 430265712 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S12A 505619824.. 506191216 571392 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S12B 506191216.. 506262896 71680 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S12C 506767360.. 512406896 5639536 B chunks 3 leading STREAM (1844592 B = 900 packets + 1392 B)
|
||||
RT12A 448097648.. 448374128 276480 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT12B_1 448374128.. 448544112 169984 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT12B_2 448544112.. 448767344 223232 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_DS_s07A 430265712.. 430335344 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_DS_s07H 430390640.. 430464368 73728 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S13A 512911360.. 514874736 1963376 B chunks 3 leading STREAM (402800 B = 196 packets + 1392 B)
|
||||
S13B 514874736.. 515278192 403456 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT13A 448767344.. 449002864 235520 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT13B_1 449002864.. 449099120 96256 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT13B_2 449099120.. 449340784 241664 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_DS_s13A 430265712.. 430335344 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S14A 515782656.. 521794928 6012272 B chunks 3 leading STREAM (1817968 B = 887 packets + 1392 B)
|
||||
RT14A 449340784.. 449721712 380928 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT14B 449721712.. 450033008 311296 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT14C 450033008.. 450280816 247808 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_DS_s14H 430390640.. 430464368 73728 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S15A 522299392.. 524309872 2010480 B chunks 3 leading STREAM (255344 B = 124 packets + 1392 B)
|
||||
S15B 524309872.. 525626736 1316864 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
|
||||
S15C 526131200.. 529565040 3433840 B chunks 3 leading STREAM (931184 B = 454 packets + 1392 B)
|
||||
RT15A 450280816.. 450985328 704512 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT15B 450985328.. 451282288 296960 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
RT15C 451282288.. 451614064 331776 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s15A 430196080.. 430265712 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s24A 430196080.. 430265712 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
hokyu_LS_s27A 430196080.. 430265712 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
|
||||
|
||||
78 region(s) open with a BANK HEADER (bank_header_len fires)
|
||||
17 open with a leading STREAM
|
||||
0 start at a RIFF
|
||||
leading-stream length mod 2048, i.e. the derived data offset:
|
||||
1392 B x17
|
||||
63
docs/re/data/voice-region-start-audit.txt
Normal file
63
docs/re/data/voice-region-start-audit.txt
Normal file
@@ -0,0 +1,63 @@
|
||||
movie chunks first chunk clip pkts verdict
|
||||
ADV 3 806912 243 STARTS MID-STREAM
|
||||
S00A 3 1323008 243 STARTS MID-STREAM
|
||||
S01A 3 1153024 243 STARTS MID-STREAM
|
||||
RT01A 1 284672 0 starts at a boundary
|
||||
RT01B 1 350208 0 starts at a boundary
|
||||
RT01C_1 1 352256 0 starts at a boundary
|
||||
RT01C_2 1 354304 0 starts at a boundary
|
||||
S02A 3 665600 0 starts at a boundary
|
||||
S02B 3 430080 243 STARTS MID-STREAM
|
||||
S02C 3 1867776 243 STARTS MID-STREAM
|
||||
RT02A 1 321536 0 starts at a boundary
|
||||
RT02B 1 387072 0 starts at a boundary
|
||||
RT02C 1 436224 0 starts at a boundary
|
||||
RT02D_1 1 190464 0 starts at a boundary
|
||||
RT02D_2 1 243712 0 starts at a boundary
|
||||
hokyu_LS_s02A 1 51200 0 starts at a boundary
|
||||
hokyu_LS_s02H 1 38912 0 starts at a boundary
|
||||
S03A 3 251904 243 STARTS MID-STREAM
|
||||
RT03A 1 319488 0 starts at a boundary
|
||||
RT03B 1 266240 0 starts at a boundary
|
||||
RT03C 1 397312 0 starts at a boundary
|
||||
RT03D 1 612352 0 starts at a boundary
|
||||
hokyu_LS_s03A 1 51200 0 starts at a boundary
|
||||
hokyu_LS_s03H 1 38912 0 starts at a boundary
|
||||
S04B 3 555008 243 STARTS MID-STREAM
|
||||
RT04A 1 229376 0 starts at a boundary
|
||||
RT04B 1 219136 0 starts at a boundary
|
||||
hokyu_DS_s02A 1 53248 0 starts at a boundary
|
||||
S05A 3 161792 0 starts at a boundary
|
||||
RT05A 1 307200 0 starts at a boundary
|
||||
RT05B 1 344064 0 starts at a boundary
|
||||
RT05C 1 188416 0 starts at a boundary
|
||||
hokyu_LS_s02A 1 51200 0 starts at a boundary
|
||||
S06A 3 350208 243 STARTS MID-STREAM
|
||||
S06B 3 606208 243 STARTS MID-STREAM
|
||||
RT06A 1 301056 0 starts at a boundary
|
||||
RT06B 1 325632 0 starts at a boundary
|
||||
RT06C 1 227328 0 starts at a boundary
|
||||
RT06D 1 120832 0 starts at a boundary
|
||||
hokyu_LS_s06A 1 51200 0 starts at a boundary
|
||||
hokyu_LS_s06H 1 38912 0 starts at a boundary
|
||||
S07A 3 503808 243 STARTS MID-STREAM
|
||||
S07B 3 53248 0 starts at a boundary
|
||||
RT07A 1 505856 0 starts at a boundary
|
||||
RT07B 1 258048 0 starts at a boundary
|
||||
RT07C 1 133120 0 starts at a boundary
|
||||
hokyu_DS_s07A 1 53248 0 starts at a boundary
|
||||
hokyu_DS_s07H 1 57344 0 starts at a boundary
|
||||
RT08A 1 180224 0 starts at a boundary
|
||||
RT08B 1 174080 0 starts at a boundary
|
||||
RT08C 1 143360 0 starts at a boundary
|
||||
hokyu_DS_s08A 1 53248 0 starts at a boundary
|
||||
S09B 3 176128 243 STARTS MID-STREAM
|
||||
RT09A 1 307200 0 starts at a boundary
|
||||
RT09B 1 468992 0 starts at a boundary
|
||||
RT09C 1 245760 0 starts at a boundary
|
||||
RT09D 1 385024 0 starts at a boundary
|
||||
hokyu_LS_s09A 1 53248 0 starts at a boundary
|
||||
hokyu_LS_s09H 1 38912 0 starts at a boundary
|
||||
RT10A 1 106496 0 starts at a boundary
|
||||
RT10B 1 67584 0 starts at a boundary
|
||||
S11A 3 81920 0 starts at a boundary
|
||||
75
docs/re/data/voice-region-start-clip.txt
Normal file
75
docs/re/data/voice-region-start-clip.txt
Normal file
@@ -0,0 +1,75 @@
|
||||
# resolve_movie_voice_region starts 238 packets LATE for ADV.
|
||||
#
|
||||
# 2026-08-30. Raised by the port agent, whose arithmetic is the whole reason
|
||||
# this was found: the running decoder's three ADV contexts sum to 3 584 000
|
||||
# payload bytes, but the resolved region is 3 114 352 -- 15 % too small to
|
||||
# hold them. One of the two spans was wrong, and it was the disc side.
|
||||
#
|
||||
# The gap is a WHOLE NUMBER OF PACKETS, which is what a start offset looks
|
||||
# like and corruption does not:
|
||||
# ctx0 declares 632 packets = 1 294 336 B
|
||||
# the resolver's leading chunk has 394 packets = 806 912 B
|
||||
# difference 238 packets = 487 424 B
|
||||
#
|
||||
# GROUND TRUTH is the running decoder's own byte_sizes. This is not free to
|
||||
# fit: the span either lands on all three or it does not.
|
||||
#
|
||||
resolver says 433930240..437044592 (3114352 B)
|
||||
decoder wants [1294336, 1118208, 1171456] = 3584000 B payload
|
||||
|
||||
- 0 packets (start 433930240): 3 chunk(s) [806912, 1118208, 1171456]
|
||||
- 100 packets (start 433725440): 3 chunk(s) [1011712, 1118208, 1171456]
|
||||
- 200 packets (start 433520640): 3 chunk(s) [1216512, 1118208, 1171456]
|
||||
- 237 packets (start 433444864): 3 chunk(s) [1292288, 1118208, 1171456]
|
||||
- 238 packets (start 433442816): 3 chunk(s) [1294336, 1118208, 1171456] <== MATCHES THE DECODER
|
||||
- 239 packets (start 433440768): 3 chunk(s) [1296384, 1118208, 1171456]
|
||||
- 300 packets (start 433315840): 5 chunk(s) [57344, 45056, 1294336, 1118208, 1171456]
|
||||
- 400 packets (start 433111040): 8 chunk(s) [59392, 59392, 47104, 47104, 45056, 1294336, 1118208, 1171456]
|
||||
|
||||
# -238 is a real boundary, not the end of a sweep: at -300 and -400 the
|
||||
# PREVIOUS asset's chunks appear (57344, 45056, ...) while the three ADV
|
||||
# sizes stay exactly stable. The stream starts at -238 and something else
|
||||
# ends just before it.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DISC-WIDE (examples/voice_region_start_audit.rs, full table in
|
||||
# voice-region-start-audit.txt)
|
||||
#
|
||||
# 1-chunk regions: 24 of 24 start at a chunk boundary
|
||||
# 3-chunk regions: 8 of 10 START MID-STREAM
|
||||
#
|
||||
# So the defect is specific to the three-stream (multichannel) voice regions.
|
||||
#
|
||||
# ⚠️ The audit's "243" column is an UPPER BOUND on the clip, not the clip. Its
|
||||
# stopping rule is "step back until the chunk COUNT changes", and to_xma_riffs
|
||||
# will happily absorb a few packets of the PREVIOUS asset into the first chunk
|
||||
# before that happens -- for ADV it reports 243 where the decoder-verified
|
||||
# answer is 238. Only ADV has external ground truth.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHY (examples/voice_region_start_why.rs), and the FIX (cap_sweep)
|
||||
#
|
||||
# The resolver picks start = the predecessor cue's trailer, then filters it with
|
||||
# .filter(|&s| s < end && end - s < 1_500_000)
|
||||
# "only within one bank (~1.5 MB), else this is the first cue in its block and
|
||||
# the audio starts at the anchor itself".
|
||||
#
|
||||
# ADV's predecessor sits 3 618 816 B before `end`. The filter REJECTS it, and the
|
||||
# start falls back to `anchor` -- a TOC offset, not a stream boundary:
|
||||
#
|
||||
# movie id anchor pred(before) span chosen
|
||||
# ADV 1600 433930240 433425776 3618816 anchor <- REJECTED
|
||||
#
|
||||
# predecessor 433425776 + 17 040 B of descriptor/padding = 433442816,
|
||||
# which is exactly the -238 packet start measured against the decoder.
|
||||
#
|
||||
# 17 of 95 resolving movies hit this. Reading from the predecessor instead:
|
||||
#
|
||||
# anchor (today) start 433930240 -> [806912, 1118208, 1171456]
|
||||
# predecessor (proposed) start 433425776 -> [1294336, 1118208, 1171456] MATCHES
|
||||
#
|
||||
# DISC-WIDE CONSEQUENCE of dropping the cap (voice-region-cap-sweep.txt):
|
||||
# unchanged 78 fixed-cleanly 17 would-break 0 skipped 9
|
||||
# In all 17 the first chunk GROWS and every later chunk is byte-identical --
|
||||
# which is what a corrected start looks like, and what pulling in a neighbouring
|
||||
# asset does not.
|
||||
55
docs/re/data/voice-stream-cue-map.txt
Normal file
55
docs/re/data/voice-stream-cue-map.txt
Normal file
@@ -0,0 +1,55 @@
|
||||
registry: 4280 cue names, 4280 distinct ids
|
||||
scanning dat/sound 421739888..537953648 (116.2 MB)
|
||||
287 trailer descriptors found
|
||||
|
||||
of those, 287 carry an id the registry names, 0 do not
|
||||
|
||||
leading span -> owning cue
|
||||
|
||||
ADV lead 808304 B bracketed by desc@Some((433425776, 1528)) .. desc@Some((437044592, 1600)) owner VOICE_ADV [movie cue]
|
||||
S00A lead 1324400 B bracketed by desc@Some((452294000, 1500)) .. desc@Some((455499120, 1501)) owner VOICE_S00A [movie cue]
|
||||
S01A lead 1154416 B bracketed by desc@Some((455499120, 1501)) .. desc@Some((460117360, 1502)) owner VOICE_S01A [movie cue]
|
||||
S02B lead 431472 B bracketed by desc@Some((461518192, 1503)) .. desc@Some((463838576, 1504)) owner VOICE_S02B [movie cue]
|
||||
S02C lead 1869168 B bracketed by desc@Some((463838576, 1504)) .. desc@Some((469970288, 1506)) owner VOICE_S02C [movie cue]
|
||||
S03A lead 253296 B bracketed by desc@Some((469970288, 1506)) .. desc@Some((472020336, 1507)) owner VOICE_S03A [movie cue]
|
||||
S04B lead 556400 B bracketed by desc@Some((480003440, 1508)) .. desc@Some((482938224, 1509)) owner VOICE_S04B [movie cue]
|
||||
S06A lead 351600 B bracketed by desc@Some((483433840, 1510)) .. desc@Some((485457264, 1511)) owner VOICE_S06A [movie cue]
|
||||
S06B lead 607600 B bracketed by desc@Some((485457264, 1511)) .. desc@Some((488908144, 1512)) owner VOICE_S06B [movie cue]
|
||||
S07A lead 505200 B bracketed by desc@Some((488908144, 1512)) .. desc@Some((491443568, 1513)) owner VOICE_S07A [movie cue]
|
||||
S09B lead 177520 B bracketed by desc@Some((491634032, 1514)) .. desc@Some((493743472, 1515)) owner VOICE_S09B [movie cue]
|
||||
S11C lead 742768 B bracketed by desc@Some((502173040, 1517)) .. desc@Some((505619824, 1518)) owner VOICE_S11C [movie cue]
|
||||
S12C lead 1844592 B bracketed by desc@Some((506262896, 1520)) .. desc@Some((512406896, 1521)) owner VOICE_S12C [movie cue]
|
||||
S13A lead 402800 B bracketed by desc@Some((512406896, 1521)) .. desc@Some((514874736, 1522)) owner VOICE_S13A [movie cue]
|
||||
S14A lead 1817968 B bracketed by desc@Some((515278192, 1523)) .. desc@Some((521794928, 1524)) owner VOICE_S14A [movie cue]
|
||||
S15A lead 255344 B bracketed by desc@Some((521794928, 1524)) .. desc@Some((524309872, 1525)) owner VOICE_S15A [movie cue]
|
||||
S15C lead 931184 B bracketed by desc@Some((525626736, 1526)) .. desc@Some((529565040, 1527)) owner VOICE_S15C [movie cue]
|
||||
|
||||
verdicts: {"movie cue": 17}
|
||||
|
||||
cue span vs the 1.5 MB guard, and what the region actually starts at:
|
||||
|
||||
ADV true cue span 3618816 B (> guard: true) region starts at 433930240, true start 433425776 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S00A true cue span 3205120 B (> guard: true) region starts at 452798464, true start 452294000 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S01A true cue span 4618240 B (> guard: true) region starts at 456003584, true start 455499120 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S02B true cue span 2320384 B (> guard: true) region starts at 462022656, true start 461518192 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S02C true cue span 6131712 B (> guard: true) region starts at 464343040, true start 463838576 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S03A true cue span 2050048 B (> guard: true) region starts at 470474752, true start 469970288 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S04B true cue span 2934784 B (> guard: true) region starts at 480507904, true start 480003440 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S06A true cue span 2023424 B (> guard: true) region starts at 483938304, true start 483433840 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S06B true cue span 3450880 B (> guard: true) region starts at 485961728, true start 485457264 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S07A true cue span 2535424 B (> guard: true) region starts at 489412608, true start 488908144 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S09B true cue span 2109440 B (> guard: true) region starts at 492138496, true start 491634032 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S11C true cue span 3446784 B (> guard: true) region starts at 502677504, true start 502173040 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S12C true cue span 6144000 B (> guard: true) region starts at 506767360, true start 506262896 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S13A true cue span 2467840 B (> guard: true) region starts at 512911360, true start 512406896 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S14A true cue span 6516736 B (> guard: true) region starts at 515782656, true start 515278192 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S15A true cue span 2514944 B (> guard: true) region starts at 522299392, true start 521794928 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
S15C true cue span 3938304 B (> guard: true) region starts at 526131200, true start 525626736 -> 504464 B of the cue's own audio is OUTSIDE the region
|
||||
|
||||
cues over the 1.5 MB guard: 17, of which stream-opening: 17
|
||||
cues under the guard: 78, of which stream-opening: 0
|
||||
|
||||
stream starts inside each cue's TRUE span (desc(N-1)..desc(N)):
|
||||
|
||||
all inter-descriptor spans: {1: 258, 3: 28}
|
||||
spans >= 1.5 MB (the long cues): {3: 20}
|
||||
34
docs/re/data/voice-three-stream-sizes.txt
Normal file
34
docs/re/data/voice-three-stream-sizes.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
cue stream1 stream2 stream3 s3/s2 rate2 rate3
|
||||
ADV 1294396 1118268 1171516 1.0476 8142 8530
|
||||
S00A 1810492 1263676 98364 0.0778 13485 1049
|
||||
S01A 1640508 1390652 1552444 1.1163 6952 7760
|
||||
S02A 665660 356412 350268 0.9828 6673 6558
|
||||
S02B 919612 505916 866364 1.7125 4825 8263
|
||||
S02C 2353212 1349692 2390076 1.7708 10547 18677
|
||||
S03A 741436 540732 739388 1.3674 6549 8955
|
||||
S04A 2775100 2486332 2680892 1.0783 9688 10446
|
||||
S04B 1044540 870460 991292 1.1388 9361 10661
|
||||
S05A 161852 147516 157756 1.0694 4447 4756
|
||||
S06A 839740 294972 860220 2.9163 5661 16513
|
||||
S06B 1093692 874556 1450044 1.6580 10678 17706
|
||||
S07A 993340 473148 1040444 2.1990 6810 14977
|
||||
S07B 53308 55356 53308 0.9630 3114 2999
|
||||
S09B 665660 571452 843836 1.4767 5033 7432
|
||||
S10B 2904124 2564156 2648124 1.0327 6629 6846
|
||||
S11A 81980 81980 79932 0.9750 1842 1796
|
||||
S11C 1228860 1075260 1108028 1.0305 5594 5765
|
||||
S12A 196668 135228 211004 1.5604 7938 12388
|
||||
S12B 14396 14396 14396 1.0000 1072 1072
|
||||
S12C 2328636 1273916 2502716 1.9646 7215 14176
|
||||
S13A 890940 585788 962620 1.6433 6990 11487
|
||||
S13B 131132 116796 127036 1.0877 7488 8145
|
||||
S14A 2302012 1634364 2541628 1.5551 8211 12770
|
||||
S15A 743484 618556 1122364 1.8145 5985 10861
|
||||
S15B 475196 348220 464956 1.3352 3968 5299
|
||||
S15C 1417276 1101884 1384508 1.2565 5988 7524
|
||||
BIRD_224 2050108 1607740 1978428 1.2306 2387 2937
|
||||
|
||||
28 three-stream cues
|
||||
stream3/stream2 ratio: min 0.0778 median 1.2565 max 2.9163 mean 1.3593 sd 0.5057
|
||||
cues where stream 3 is less than HALF of stream 2: 1
|
||||
cues where stream 3 is within 15% of stream 2: 12 of 28
|
||||
4
docs/re/data/voice-three-streams-runtime.txt
Normal file
4
docs/re/data/voice-three-streams-runtime.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
w> 01000014 XMA-PROBE active (ctx 0 first decode) — cvar parsed OK
|
||||
w> 01000014 XMA-PARAM ctx=0 buf=0 ptr=0x13544000 read_off=32 stereo=1 channels=2 rate_id=3 rate=48000 packets=632 byte_size=1294336 sig_off=1024 head=080000000095fc01c001020408c01f7f0004081023007dfc001020408c01f7f0 sig=004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004
|
||||
w> 01000014 XMA-PARAM ctx=1 buf=0 ptr=0x13682000 read_off=32 stereo=1 channels=2 rate_id=3 rate=48000 packets=546 byte_size=1118208 sig_off=1024 head=080000000095fc01c001020408c01f7f0004081023007dfc001020408c01f7f0 sig=004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004
|
||||
w> 01000014 XMA-PARAM ctx=2 buf=0 ptr=0x13795000 read_off=32 stereo=1 channels=2 rate_id=3 rate=48000 packets=572 byte_size=1171456 sig_off=1024 head=080000000095fc01c001020408c01f7f0004081023007dfc001020408c01f7f0 sig=004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user