Merge pull request 're: land the F5/F6 title-clock corpus (docs/re, reference data, sylpheed-formats)' (#23) from auto/frame-blend-draw-path into main

Reviewed-on: #23
This commit is contained in:
2026-09-13 11:16:17 +00:00
499 changed files with 78684 additions and 478 deletions

View File

@@ -194,6 +194,36 @@ 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: it omits anything still moving (the
/// title's light sweeps hold off the right edge) and freezes a transient
/// at its PEAK (the title's five two-frame flashes burn forever).
/// ⚠️ This help used to end "Prefer `--settle`". That is WITHDRAWN and was
/// never measured: scored against a live capture of the JP title, settle
/// gives RMSE 40.210 and rest 41.690 — a margin of 1.48 against that
/// instrument's own noise floor of 1.2, which is NOT decisive. `--settle`
/// also has its own failure mode (25.5 % of elements are mid-ramp at their
/// screen's settle instant). Neither is established as better; pick by what
/// you are measuring. See `docs/re/structures/ui-resting-pose.md`.
#[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.
/// ⚠️ That is **38 % of the screen builds this command renders** (185 of
/// 491 carrying two or more keyframe times) and 39 % of the wider set
/// `--all` admits (862 of 2 211), mostly `loop*` fragments. This help used
/// to say "42 % of them" without saying of WHAT: 42 % was 731/1 758 over
/// composable bundles, computed before the keyframe record-layout fix,
/// which times a group's final pose and so admits ~450 bundles that
/// previously had only one timed keyframe. ⚠️ NOT established as better
/// than the resting pose — see the note on `--at`. See
/// `docs/re/structures/ui-settle-time.md`.
#[arg(long)]
settle: bool,
},
}
@@ -361,8 +391,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 {
@@ -596,12 +628,42 @@ 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,
@@ -614,6 +676,7 @@ fn cmd_screen_render(
ComposeOptions::default().backdrop
},
include_primitives: primitives,
at,
},
None,
);
@@ -639,14 +702,35 @@ fn cmd_screen_render(
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(())
}
@@ -776,8 +860,16 @@ fn cmd_audio_info(file: &Path) -> Result<()> {
" 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());

View File

@@ -60,7 +60,7 @@ fn main() {
if rows.is_empty() {
continue;
}
rows.sort_by(|a, b| a.1.cmp(&b.1));
rows.sort_by_key(|r| r.1);
let ys: Vec<i32> = rows.iter().map(|r| r.1).collect();
let gaps: Vec<i32> = ys.windows(2).map(|w| w[1] - w[0]).collect();
if rows.len() == 4

View File

@@ -33,7 +33,7 @@ fn main() {
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for (_, &(o, s)) in &b.records {
for &(o, s) in b.records.values() {
records += 1;
if o + 12 > by.len() || o + s > by.len() {
continue;

View File

@@ -0,0 +1,92 @@
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(" ")
);
}
}
}
}
}
}

View 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 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
);
}
}
}

View File

@@ -0,0 +1,87 @@
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;
}
}

View 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 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});
}
}
}

View File

@@ -0,0 +1,41 @@
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").unwrap());
println!("# Which elements the reference renderer now draws ADDITIVE, per screen.");
println!("# Source: T8aD +0x04 bit 0x02, docs/re/structures/ui-blend-mode-decoded.md.");
println!("# Generated after ui_layout::blit gained an additive path (2026-09-01).");
println!("# Before that change EVERY row below was drawn alpha-over by our renderer,");
println!("# which is why `verify-screen` was structurally incapable on these screens.");
for pak in ["GP_TITLE", "GP_OPTIONS"] {
let Ok(ar) = PakArchive::open(root.join(format!("dat/{pak}.pak"))) else {
continue;
};
let n = ar.entries().len();
for e in 0..n {
let Ok(by) = ar.read(&ar.entries()[e]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let mut add: Vec<&String> = b
.sprites
.keys()
.filter(|s| ui_layout::blend_additive_by_name(&b, &by, s) == Some(true))
.collect();
if add.is_empty() {
continue;
}
add.sort();
println!(
"\n{pak} entry {e} -- {} of {} sprites additive",
add.len(),
b.sprites.len()
);
for s in add {
println!(" {s}");
}
}
}
}

View File

@@ -0,0 +1,61 @@
//! 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 {
""
}
);
}
}

View File

@@ -0,0 +1,44 @@
//! 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
);
}
}

View File

@@ -0,0 +1,44 @@
//! 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}"),
}
}
}

View File

@@ -81,7 +81,7 @@ fn main() {
}
let mut degen = 0usize;
let (mut agree, mut counted) = (0usize, 0usize);
for t in idx.chunks_exact(3) {
for t in idx.as_chunks::<3>().0 {
let (x, y, z) = (t[0] as usize, t[1] as usize, t[2] as usize);
if x == y || y == z || x == z {
degen += 1;

View File

@@ -0,0 +1,28 @@
//! Dump one BGM bank's waves as RIFF/XMA so they can be decoded and compared
//! against a capture of the running game.
//!
//! cargo run -p sylpheed-formats --example bgm_dump -- BGM_103.slb OUTDIR
use sylpheed_formats::media::{self, DirectorySource};
use sylpheed_formats::slb;
fn main() {
let name = std::env::args()
.nth(1)
.unwrap_or_else(|| "BGM_103.slb".into());
let out = std::env::args().nth(2).expect("OUTDIR");
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 h = sylpheed_formats::hash::name_hash(&name);
let bytes = media::read_sound_bank(&src, h).expect("bank");
println!("{name}: {} B", bytes.len());
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
let p = format!("{out}/{}_{i}.xma", name.trim_end_matches(".slb"));
std::fs::write(&p, r).expect("write");
println!(
" wave {i}: {} B (byte_size {}) -> {p}",
r.len(),
r.len() - 60
);
}
}

View File

@@ -0,0 +1,106 @@
//! Does a screen declare its own OPAQUE BLACK backdrop? Disc-wide.
//!
//! `sylpheed-port` observed that the splash builds declare `palogo_eff0.prm` as a
//! full-screen primitive at t=0 with `fade_argb 0xff000000` -- alpha 255 over RGB
//! 000000 -- and turned it into a candidate predicate: a declared opaque-black
//! backdrop separates STANDALONE screens from COMPOSITED ones. On their sixteen
//! exported screens it splits 12 / 4, with all four exceptions independently known
//! to be composited (the two `press_start` plates, and two loading builds that
//! carry the `pgloading_*` set without its backdrop).
//!
//! That matters because the corpus previously told them "no content rule exists,
//! take the entry index" -- correct for the question asked (recognise the splash),
//! but this is a content rule for a different and useful question. They asked for
//! it to be tested against an archive they do not have. This is that test.
//!
//! CONTROL: it must reproduce the 12/4 split on GP_TITLE's sixteen composable
//! bundles before its disc-wide numbers mean anything.
//!
//! cargo run -p sylpheed-formats --example black_backdrop_predicate
use std::io::Write;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
/// A screen declares its own backdrop if some `.prm` primitive holds
/// `fade == 0xff000000` at t = 0: full alpha over black.
fn has_black_backdrop(b: &ui_layout::UiBuild) -> Option<String> {
for el in &b.elements {
if !el.name.ends_with(".prm") {
continue;
}
if let Some(k) = el.keyframes.iter().find(|k| k.time == Some(0)) {
if k.fade == 0xff00_0000 {
return Some(el.name.clone());
}
}
}
None
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
println!("== CONTROL: GP_TITLE's 16 composable bundles (port reports 12 with, 4 without)");
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let (mut y, mut n) = (0, 0);
for e in 0..16usize {
let Ok(by) = ar.read(&ar.entries()[e]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
match has_black_backdrop(&b) {
Some(nm) => {
y += 1;
println!(" entry {e:>2} YES {nm}")
}
None => {
n += 1;
println!(" entry {e:>2} no")
}
}
}
println!(" -> {y} with, {n} without\n");
println!("== DISC-WIDE, over every screen build");
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 tot, mut with) = (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();
let (mut t, mut w) = (0usize, 0usize);
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
t += 1;
if has_black_backdrop(&b).is_some() {
w += 1
}
}
if t > 0 {
println!("{name:30} {w:4} / {t:<4} declare a black backdrop");
std::io::stdout().flush().ok();
}
tot += t;
with += w;
}
println!(
"\n{with} of {tot} screen builds disc-wide declare an opaque-black backdrop \
({:.1} %)",
100.0 * with as f64 / tot as f64
);
println!("--- END ---");
}

View File

@@ -0,0 +1,107 @@
//! Control for the new public accessor `ui_layout::sprite_blend_additive`.
//!
//! `blend_vs_t8ad_bit` established the field by reading the `T8aD` header inline.
//! The exporter cannot do that — `Element` exposed nothing at `+0x04`, which is
//! why a blend map keyed by SCREEN NAME had to be authored, and why the Japanese
//! menus were being asserted-by-omission to blend differently from the English
//! ones. This checks the accessor the exporter will actually call, against the
//! same 35 oracle rows, so a later refactor cannot silently change the field.
//!
//! cargo run -p sylpheed-formats --example blend_api_check
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
/// (build entry, sprite, measured additive?) — from `data/blend-bit-vs-oracle.txt`,
/// every label an `RB_BLENDCONTROL0` value read out of the guest command stream.
const MEASURED: &[(usize, &str, bool)] = &[
(4, "ptbase2.t32", false),
(4, "ptlogo1.t32", false),
(4, "ptlogo2.t32", false),
(4, "ptlogo_tm.t32", false),
(4, "ptcopyright.t32", false),
(4, "ptlogo_back2.t32", false),
(4, "ptlogo_back2eff.t32", false),
(2, "ptbtn00.t32", false),
(2, "ptbtn00f.t32", true),
(5, "ptbase.t32", false),
(5, "ptmsg.t32", false),
(5, "ptbtn01f.t32", false),
(5, "ptbtneff01.t32", false),
(5, "pteff10.t32", true),
(5, "pteff12.t32", true),
(6, "pteff21.t32", true),
(6, "pteff22.t32", true),
(6, "pteff23.t32", true),
(6, "ptframe3.t32", true),
(6, "ptframe4.t32", true),
(6, "pteff03.t32", true),
(6, "pteff03a.t32", true),
];
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");
let (mut ok, mut bad, mut missing) = (0, 0, 0);
println!(
"{:<7} {:<22} {:<10} {:<10} ",
"entry", "sprite", "expected", "accessor"
);
for e in [2usize, 4, 5, 6] {
let by = ar.read(&ar.entries()[e]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for &(oe, name, additive) in MEASURED {
if oe != e {
continue;
}
// Prefer the Element accessor; fall back to the by-name one for
// focused variants, which are reached through `opt ` and carry no
// top-level element of their own.
let got = b
.elements
.iter()
.find(|x| x.sprite.as_deref() == Some(name))
.and_then(|el| ui_layout::sprite_blend_additive(&b, &by, el))
.or_else(|| ui_layout::blend_additive_by_name(&b, &by, name));
if got.is_none() {
println!("{e:<7} {name:<22} {additive:<10} {:<10} MISSING", "-");
missing += 1;
continue;
}
match got {
Some(g) if g == additive => {
ok += 1;
println!("{e:<7} {name:<22} {additive:<10} {g:<10} OK");
}
other => {
bad += 1;
println!("{e:<7} {name:<22} {additive:<10} {other:?} MISMATCH");
}
}
}
}
println!(
"\n{ok} agree, {bad} mismatched, {missing} not found (of {})",
MEASURED.len()
);
// The control that removes the test's own subject: the accessor must also
// report a MIX. An accessor stuck at one value would pass every `false` row.
let by = ar.read(&ar.entries()[6]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
let add = b
.elements
.iter()
.filter(|e| ui_layout::sprite_blend_additive(&b, &by, e) == Some(true))
.count();
let over = b
.elements
.iter()
.filter(|e| ui_layout::sprite_blend_additive(&b, &by, e) == Some(false))
.count();
println!("control -- entry 6 must report BOTH values: additive={add} alpha-over={over}");
assert!(add > 0 && over > 0, "accessor is not discriminating");
assert_eq!(
bad, 0,
"the public accessor disagrees with the committed oracle"
);
println!("PASS");
}

View File

@@ -0,0 +1,61 @@
//! A PREDICTION, written before the capture that tests it.
//!
//! `blend_vs_t8ad_bit` finds that `T8aD +0x04` bit `0x02` separates additive from
//! alpha-over on all 35 elements whose blend has been measured off the GPU, and
//! that no other bit of the 48-byte header does. That is a fit to three screens.
//!
//! The developer splash (`GP_TITLE` entries 10 and 13) has **never been captured**
//! and is one of the five screens the port ships. This prints what the bit says
//! its elements should be, so the capture can falsify it rather than confirm it.
//!
//! Takes an archive and a build list, so the prediction can be written for any
//! screen -- including one in a DIFFERENT pak, which is the sharper test: the
//! splash predicts alpha-over for both its elements and so can only fail, never
//! discriminate, while a screen with a predicted MIX can do both.
//!
//! cargo run -p sylpheed-formats --example blend_prediction_splash
//! cargo run -p sylpheed-formats --example blend_prediction_splash -- GP_OPTIONS 0 1 2
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 args: Vec<String> = std::env::args().skip(1).collect();
let pak = args
.iter()
.find(|a| a.parse::<usize>().is_err())
.cloned()
.unwrap_or_else(|| "GP_TITLE".to_string());
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
let builds: Vec<usize> = args.iter().filter_map(|a| a.parse().ok()).collect();
let builds = if builds.is_empty() {
(0..ar.entries().len()).collect()
} else {
builds
};
for e in builds {
let Ok(by) = ar.read(&ar.entries()[e]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
println!("=== {pak} entry {e} ===");
let mut names: Vec<&String> = b.sprites.keys().collect();
names.sort();
for n in names {
let (off, size) = b.sprites[n];
let s = &by[off..(off + size).min(by.len())];
if s.len() < 8 || &s[0..4] != b"T8aD" {
continue;
}
let w = u32::from_be_bytes([s[4], s[5], s[6], s[7]]);
println!(
"{n:<26} +0x04 = {w:08X} bit 0x02 {} PREDICT {}",
if w & 2 != 0 { "SET " } else { "clear" },
if w & 2 != 0 { "ADDITIVE" } else { "alpha-over" }
);
}
println!();
}
}

View File

@@ -0,0 +1,298 @@
//! Does `T8aD +0x04` bit `0x02` predict the blend the GAME uses?
//!
//! ⚠️ **`REFUTED.md` kills this claim**: *"`T8aD +0x04` bit `0x02` selects an
//! additive blend" → mine, and refuted. Blending those sprites additively
//! worsens every measure against the capture.* That refutation rests entirely on
//! **our renderer** — it is a claim about our renderer, and the corpus's own rule
//! says so. Since it was written, the blend has been measured off the GPU per
//! draw on three screens (`structures/ui-blend-mode-measured.md`), so the claim
//! can now be tested against the oracle instead of against a render.
//!
//! The labels below are **not** from a render. Every one is a
//! `RB_BLENDCONTROL0` value read out of the guest command stream and attributed
//! to an element by quad size:
//! `data/ui-blend-mode-measured.txt`, `data/ui-blend-title-and-replication.txt`,
//! `data/ui-blend-extras-complete.txt`.
//!
//! cargo run -p sylpheed-formats --example blend_vs_t8ad_bit
use std::collections::BTreeMap;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
/// (build entry, sprite, measured additive?) — the oracle's verdicts, verbatim.
const MEASURED: &[(usize, &str, bool)] = &[
// --- GP_TITLE entry 4 + 2, the live title -------------------------------
(4, "ptbase2.t32", false),
(4, "ptlogo1.t32", false),
(4, "ptlogo2.t32", false),
(4, "ptlogo_tm.t32", false),
(4, "ptcopyright.t32", false),
(4, "ptlogo_back2.t32", false),
(4, "ptlogo_back2eff.t32", false),
(2, "ptbtn00.t32", false),
(2, "ptbtn00f.t32", true),
// --- entry 5, the main menu ---------------------------------------------
(5, "ptbase.t32", false),
(5, "ptmsg.t32", false),
(5, "ptbtn01f.t32", false),
(5, "ptbtneff01.t32", false),
(5, "pteff10.t32", true),
(5, "pteff12.t32", true),
(5, "ptframe1.t32", true),
(5, "ptframe2.t32", true),
(5, "pteff03.t32", true), // the rotated sweep strips, via ptloop01/02
(5, "pteff03a.t32", true),
// --- entry 6, EXTRAS ------------------------------------------------------
(6, "ptbase.t32", false),
(6, "ptmsg2.t32", false),
(6, "pttitle.t32", false),
(6, "ptbtn11f.t32", false),
(6, "ptbtn12.t32", false),
(6, "ptbtn13.t32", false),
(6, "ptbtneff02.t32", false),
(6, "pteff10.t32", true),
(6, "pteff20.t32", true),
(6, "pteff21.t32", true),
(6, "pteff22.t32", true),
(6, "pteff23.t32", true),
(6, "ptframe3.t32", true),
(6, "ptframe4.t32", true),
(6, "pteff03.t32", true),
(6, "pteff03a.t32", true),
];
fn main() {
if std::env::args().any(|a| a == "decl") {
decl_rivals();
return;
}
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");
let mut hdr: BTreeMap<(usize, String), u32> = BTreeMap::new();
for e in [2usize, 4, 5, 6] {
let by = ar.read(&ar.entries()[e]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for (n, &(off, size)) in &b.sprites {
let s = &by[off..(off + size).min(by.len())];
if s.len() < 8 || &s[0..4] != b"T8aD" {
continue;
}
hdr.insert((e, n.clone()), u32::from_be_bytes([s[4], s[5], s[6], s[7]]));
}
}
println!(
"{:<10} {:<22} {:<10} {:>10} measured blend",
"entry", "sprite", "+0x04", "bit 0x02"
);
let (mut tp, mut tn, mut fp, mut fnn, mut missing) = (0, 0, 0, 0, 0);
for &(e, n, additive) in MEASURED {
let Some(&w) = hdr.get(&(e, n.to_string())) else {
println!(
"{e:<10} {n:<22} {:<10} {:>10} {}",
"MISSING",
"-",
if additive { "ADDITIVE" } else { "alpha-over" }
);
missing += 1;
continue;
};
let bit = w & 0x02 != 0;
match (bit, additive) {
(true, true) => tp += 1,
(false, false) => tn += 1,
(true, false) => fp += 1,
(false, true) => fnn += 1,
}
println!(
"{e:<10} {n:<22} {:08X} {:>10} {}{}",
w,
bit,
if additive { "ADDITIVE" } else { "alpha-over" },
if bit == additive {
""
} else {
" <== DISAGREES"
}
);
}
println!("\nbit set & additive {tp}");
println!("bit clear & alpha-over {tn}");
println!("bit set & alpha-over {fp} <- false positives");
println!("bit clear & additive {fnn} <- false negatives");
println!("sprite not found {missing}");
println!(
"\n{}",
if fp == 0 && fnn == 0 && missing == 0 {
"PERFECT PARTITION on every element whose blend was measured."
} else {
"THE BIT DOES NOT PREDICT THE MEASURED BLEND."
}
);
// ── THE CONTROL THAT MATTERS ────────────────────────────────────────────
// A perfect partition is worthless if half the header partitions equally
// well: then the sample is too small to single out a field, and picking
// `+0x04` bit 0x02 out of the tie is the same mistake as picking `+0x08`
// 0x8050 was. So: how many OTHER bits of the first 12 header words separate
// the same 35 elements without error?
let mut rivals: Vec<String> = Vec::new();
let mut words: BTreeMap<(usize, String), Vec<u32>> = BTreeMap::new();
for e in [2usize, 4, 5, 6] {
let by = ar.read(&ar.entries()[e]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for (n, &(off, size)) in &b.sprites {
let s = &by[off..(off + size).min(by.len())];
if s.len() < 48 || &s[0..4] != b"T8aD" {
continue;
}
words.insert(
(e, n.clone()),
(0..12)
.map(|k| {
u32::from_be_bytes([s[k * 4], s[k * 4 + 1], s[k * 4 + 2], s[k * 4 + 3]])
})
.collect(),
);
}
}
for w in 0..12 {
for bit in 0..32 {
let mut ok = true;
let mut set_seen = false;
let mut clear_seen = false;
for &(e, n, additive) in MEASURED {
let Some(v) = words.get(&(e, n.to_string())) else {
ok = false;
break;
};
let on = (v[w] >> bit) & 1 == 1;
if on {
set_seen = true
} else {
clear_seen = true
}
if on != additive {
ok = false;
break;
}
}
// A constant bit trivially "agrees" with nothing; require both sides.
if ok && set_seen && clear_seen {
rivals.push(format!("+0x{:02X} bit {bit} (0x{:X})", w * 4, 1u32 << bit));
}
}
}
println!("\nRIVAL FIELDS — other bits of the first 12 header words that separate");
println!("the same 35 elements with zero errors: {}", rivals.len());
for r in &rivals {
println!(" {r}");
}
if rivals.len() == 1 {
println!(" -> the sample singles out ONE field. Nothing else in the header does it.");
} else {
println!(
" -> the sample does NOT single out a field; {} candidates tie.",
rivals.len()
);
}
}
// ── An integrity check the published decode did NOT do ──────────────────────
// The rival sweep above covers the 48-byte T8aD header. It does NOT cover the
// 60-byte DECLARATION entry, and the earlier declaration hunt was run with
// labels taken from the port's RENDER -- which put pteff10, pteff12, pteff20 and
// pteff21..23 on the alpha-over side, where the oracle says all six are
// additive. So the declaration has never been swept with correct labels, and if
// one of its words also partitions the 35 without error, "the field is the T8aD
// bit" is underdetermined.
//
// Run as: cargo run -p sylpheed-formats --example blend_vs_t8ad_bit -- decl
#[allow(dead_code)]
fn decl_rivals() {
use std::collections::BTreeMap;
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");
const AT: usize = 0x20;
const STRIDE: usize = 60;
let mut decl: BTreeMap<(usize, String), Vec<u32>> = BTreeMap::new();
for e in [2usize, 4, 5, 6] {
let Ok(by) = ar.read(&ar.entries()[e]) else {
continue;
};
if by.len() < 0x18 {
continue;
}
let count = u32::from_be_bytes([by[0x14], by[0x15], by[0x16], by[0x17]]) as usize;
for i in 0..count {
let at = AT + i * STRIDE;
if at + STRIDE > by.len() {
break;
}
let end = by[at..at + 12].iter().position(|&c| c == 0).unwrap_or(12);
let name = String::from_utf8_lossy(&by[at..at + end]).to_string();
decl.insert(
(e, name),
(0..15)
.map(|k| {
u32::from_be_bytes([
by[at + k * 4],
by[at + k * 4 + 1],
by[at + k * 4 + 2],
by[at + k * 4 + 3],
])
})
.collect(),
);
}
}
let mut missing: Vec<String> = Vec::new();
for &(e, n, _) in MEASURED {
if !decl.contains_key(&(e, n.to_string())) {
missing.push(format!("entry {e} {n}"));
}
}
println!("\n=== DECLARATION-ENTRY RIVAL SWEEP ===");
println!(
"measured elements with NO declaration entry of their own: {} of {}",
missing.len(),
MEASURED.len()
);
for m in &missing {
println!(" {m}");
}
if !missing.is_empty() {
println!(" -> no declaration field can select the blend for these, because they");
println!(" have no declaration entry. The header is the only per-sprite home.");
}
let labelled: Vec<&(usize, &str, bool)> = MEASURED
.iter()
.filter(|(e, n, _)| decl.contains_key(&(*e, n.to_string())))
.collect();
let mut rivals = 0;
#[allow(clippy::needless_range_loop)]
for w in 0..15 {
for bit in 0..32 {
let (mut ok, mut s, mut c) = (true, false, false);
for &&(e, n, additive) in &labelled {
let on = (decl[&(e, n.to_string())][w] >> bit) & 1 == 1;
if on {
s = true
} else {
c = true
}
if on != additive {
ok = false;
break;
}
}
if ok && s && c {
println!(" RIVAL: declaration +0x{:02X} bit {bit}", w * 4);
rivals += 1;
}
}
}
println!(
"declaration bits that separate the {} labellable elements: {rivals}",
labelled.len()
);
}

View File

@@ -173,7 +173,7 @@ fn main() {
let (mut cover_exact, mut cover_short, mut idx_equal, mut idx_partial) =
(0usize, 0usize, 0usize, 0usize);
let mut rows: Vec<(usize, String)> = Vec::new();
for (_, (voff, ibs, vcount)) in per_buf.iter() {
for (voff, ibs, vcount) in per_buf.values() {
let batches = ibs.len();
let total: u32 = ibs.iter().map(|i| i.icount).sum();
let lo = ibs.iter().map(|i| i.ibase).min().unwrap() as i64 - base_delta;

View File

@@ -34,7 +34,7 @@ fn main() {
let text = std::fs::read_to_string(log).expect("log");
for d in parse_capture(&text) {
let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0));
if d.ib.map_or(false, |i| i.head_len > 0)
if d.ib.is_some_and(|i| i.head_len > 0)
&& d.pos.len() >= 4
&& seen.insert((log.clone(), d.vbase, k))
{

View File

@@ -183,7 +183,7 @@ fn main() {
None => clusters.push((*s, 1)),
}
}
clusters.sort_by(|x, y| y.1.cmp(&x.1));
clusters.sort_by_key(|x| std::cmp::Reverse(x.1));
let tops: Vec<String> = clusters
.iter()
.filter(|(_, n)| *n >= 3)

View File

@@ -11,6 +11,9 @@
use std::collections::{BTreeMap, HashMap};
use sylpheed_formats::mesh::Xbg7Model;
/// Every place one model name was seen: (container, verts, tris, span).
type Sightings = BTreeMap<String, Vec<(String, usize, usize, [i64; 3])>>;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let list = std::env::args().any(|a| a == "--list");
@@ -23,7 +26,7 @@ fn main() {
files.sort();
// name -> [(container, verts, tris, span)]
let mut seen: BTreeMap<String, Vec<(String, usize, usize, [i64; 3])>> = BTreeMap::new();
let mut seen: Sightings = BTreeMap::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else {
continue;

View File

@@ -0,0 +1,65 @@
//! Does a textured element's declaration carry anything that distinguishes the
//! two FRAME elements from every other element on the main menu?
//!
//! `sylpheed-port` asks for the blend/alpha mode of `ptframe1`/`ptframe2`. Prior
//! work is on `.prm` PRIMITIVES (`ui-prm-blend-mode.md`, undecodable with reach —
//! no field, the declaration words are constant) and on a refuted `T8aD +0x04`
//! bit. Neither covers a `.t32` element's own declaration entry, which is 60
//! bytes and mostly unread.
//!
//! This dumps every declaration entry on the menu and reports, per 4-byte word,
//! whether the two frames share a value that no other element has. A word that
//! separates exactly those two is a candidate; one that does not, is not.
//!
//! cargo run -p sylpheed-formats --example decl_entry_diff
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
const AT: usize = 0x20;
const N: usize = 60;
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");
let by = ar.read(&ar.entries()[5]).expect("entry 5");
let b = ui_layout::parse_build(&by).expect("build");
let names: Vec<String> = b.elements.iter().map(|e| e.name.clone()).collect();
let frames: Vec<usize> = names
.iter()
.enumerate()
.filter(|(_, n)| n.starts_with("ptframe"))
.map(|(i, _)| i)
.collect();
println!("{} elements; frames at indices {:?}", names.len(), frames);
let word = |i: usize, w: usize| -> u32 {
let o = AT + i * N + w * 4;
u32::from_be_bytes([by[o], by[o + 1], by[o + 2], by[o + 3]])
};
println!("\nper-word: does a value separate EXACTLY the two frames?");
for w in 0..N / 4 {
let fv: Vec<u32> = frames.iter().map(|&i| word(i, w)).collect();
let same_in_frames = fv.windows(2).all(|p| p[0] == p[1]);
let others: Vec<u32> = (0..names.len())
.filter(|i| !frames.contains(i))
.map(|i| word(i, w))
.collect();
let unique = same_in_frames && !others.contains(&fv[0]);
let distinct = {
let mut v: Vec<u32> = (0..names.len()).map(|i| word(i, w)).collect();
v.sort_unstable();
v.dedup();
v.len()
};
println!(
" +0x{:02X} frames {:?} distinct values {distinct:2}{}",
w * 4,
fv.iter().map(|v| format!("{v:08X}")).collect::<Vec<_>>(),
if unique {
" <- SEPARATES THE FRAMES"
} else {
""
}
);
}
}

View File

@@ -0,0 +1,45 @@
//! Which elements carry which values at the low-cardinality declaration words?
//!
//! `decl_entry_diff` found nothing separating `ptframe1`/`ptframe2` except their
//! NAME — +0x00 and +0x08 are the name string ("ptfr", ".t32"), so those two hits
//! are a false positive of that test, not a field.
//!
//! The remaining candidates for a per-element mode flag are the words with few
//! distinct values: +0x28 (3) and +0x2C (6). This prints who has what.
//!
//! cargo run -p sylpheed-formats --example decl_flag_words
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
const AT: usize = 0x20;
const N: usize = 60;
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");
let by = ar.read(&ar.entries()[5]).expect("entry 5");
let b = ui_layout::parse_build(&by).expect("build");
let w = |i: usize, off: usize| -> u32 {
let o = AT + i * N + off;
u32::from_be_bytes([by[o], by[o + 1], by[o + 2], by[o + 3]])
};
println!(
"{:<22} {:>10} {:>10} {:>10} {:>10}",
"element", "+0x28", "+0x2C", "+0x34", "kind"
);
for (i, e) in b.elements.iter().enumerate() {
let mark = if e.name.starts_with("ptframe") {
" <- FRAME"
} else {
""
};
println!(
"{:<22} {:>10} {:>10} {:>10} {:>#10x}{mark}",
e.name,
w(i, 0x28),
w(i, 0x2C) as i32,
w(i, 0x34),
e.kind
);
}
}

View File

@@ -124,8 +124,8 @@ fn main() {
}
println!("\n--- schema 0x{schema:08x} {name} ({n_obj} objects) ---");
println!(
"{:<30} {:>5} {:>5} {}",
"KEY", "set", "dflt", "values seen (≤12) | owners defaulting"
"{:<30} {:>5} {:>5} values seen (≤12) | owners defaulting",
"KEY", "set", "dflt"
);
for (k, (n_set, n_def, vals, owners)) in defaulted {
let vv: Vec<&str> = vals.iter().map(|s| s.as_str()).collect();

View File

@@ -0,0 +1,82 @@
//! How often is a screen's design size READ, and how often is it FABRICATED?
//!
//! `ui_layout.rs` scans the `.rat` records for a `(w,h)` at `+0x18`/`+0x1c` and,
//! finding none, falls back to `(DESIGN_W, DESIGN_H)` = 1280x720. Its own comment
//! says "every screen seen is 1280x720, **which is also the fallback**" -- which
//! is precisely the problem: the fabricated value equals the expected one, so no
//! output of the parser can distinguish a read design size from an invented one.
//! The port sizes its screens off this number.
//!
//! This replicates the scan through the public RATC API and counts.
//!
//! cargo run -p sylpheed-formats --example design_size_fallback
use std::io::Write;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn be32(b: &[u8], o: usize) -> u32 {
if o + 4 > b.len() {
return 0;
}
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn main() {
let 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 fell_back, mut nonstd) = (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();
let (mut r, mut f) = (0usize, 0usize);
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) {
continue;
}
let Some(kids) = ratc::parse(&by) else {
continue;
};
// the same predicate ui_layout uses, over the same records
// ⚠️ A first version took EVERY RATC child and failed its control:
// it reported all 965 builds stating a non-1280x720 size, where
// `screen list` prints 1280x720 for every one. `records` in
// ui_layout is the `.rat` children only; a T8aD sprite header read
// at +0x18 is garbage that passes the range test.
let found = kids
.iter()
.filter(|k| k.kind == "RATC" || k.name.ends_with(".rat"))
.find_map(|k| {
let rec = &by[k.offset..(k.offset + k.size).min(by.len())];
let (w, h) = (be32(rec, 0x18), be32(rec, 0x1c));
(w > 0 && h > 0 && w <= 8192 && h <= 8192).then_some((w, h))
});
match found {
Some((w, h)) => {
r += 1;
if (w, h) != (1280, 720) {
nonstd += 1;
println!(" {name} : a build states a NON-standard design size {w}x{h}");
}
}
None => f += 1,
}
}
if r + f > 0 {
println!("{name:30} {r:5} read {f:5} FABRICATED");
std::io::stdout().flush().ok();
}
read += r;
fell_back += f;
}
println!("\n{read} builds state a design size, {fell_back} get the 1280x720 FALLBACK");
println!("{nonstd} builds state something other than 1280x720");
println!("--- END ---");
}

View File

@@ -0,0 +1,59 @@
//! Is `GP_DIALOG` entry 2 the `DLG_SELECT_DIFFICULTY` screen?
//!
//! The image lists `DLG_SELECT_DIFFICULTY` among the `DLG_*` names at
//! `0x820A41BB`, so DIFFICULTY is a DIALOG, not a GamePart screen with its own
//! pak — which is why a search for an 8-record `btn` build in a difficulty-named
//! archive found nothing. `GP_DIALOG` entry 2 carries `pcbtn00`..`03`: four
//! buttons, matching EASY / NORMAL / HARD / BACK.
//!
//! CONTROL: `GP_TITLE` entry 5's five buttons must come back at the rows the disc
//! is independently known to place them (162/242/322/401/482, spacing 80).
//!
//! cargo run -p sylpheed-formats --example dialog_button_rows
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn rows(ar: &PakArchive, entry: usize, what: &str) {
let Ok(by) = ar.read(&ar.entries()[entry]) else {
return;
};
let Some(b) = ui_layout::parse_build(&by) else {
return;
};
let mut v: Vec<(i32, String)> = b
.elements
.iter()
.filter(|e| {
let n = &e.name;
(n.starts_with("pcbtn") || n.starts_with("ptbtn")) && !n.contains('f')
})
.map(|e| {
(
e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32),
e.name.clone(),
)
})
.collect();
v.sort_by_key(|r| r.0);
println!("\n{what} (entry {entry}):");
for (y, n) in &v {
println!(" y {y:5} {n}");
}
if v.len() > 1 {
let sp: Vec<i32> = v.windows(2).map(|w| w[1].0 - w[0].0).collect();
println!(" spacing {sp:?}");
}
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let t = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
rows(
&t,
5,
"CONTROL: GP_TITLE main menu (must be 162/242/322/401/482)",
);
let d = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
rows(&d, 2, "GP_DIALOG candidate for DLG_SELECT_DIFFICULTY");
rows(&d, 3, "GP_DIALOG entry 3 (the pair)");
}

View File

@@ -0,0 +1,70 @@
//! Are the 37 equal-button-count `GP_DIALOG` pairs language pairs, or not?
//!
//! 26 of 65 adjacent pairs differ in BUTTON COUNT — two languages cannot, so those
//! are unrelated dialogs. For the rest my language reading was left UNSUPPORTED
//! rather than refuted, and both agents observed that nothing rewards closing it.
//!
//! A language pair must share its BUTTON NAMES and ROWS exactly (a locale changes
//! glyphs, not layout) and differ only elsewhere. An unrelated pair will differ in
//! button names or rows too.
//!
//! CONTROL: entries 2/3 (identical element sets) must come out as "buttons match".
//!
//! cargo run -p sylpheed-formats --example dialog_pair_37
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn sig(ar: &PakArchive, i: usize) -> Option<(Vec<(String, i32)>, usize)> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
let mut v: Vec<(String, i32)> = b
.elements
.iter()
.filter(|e| e.name.contains("btn"))
.map(|e| {
(
e.name.clone(),
e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32),
)
})
.collect();
v.sort();
let n = v.len();
Some((v, n))
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let (mut match_btn, mut differ_btn, mut ctrl) = (0, 0, false);
let mut examples = 0;
for k in (0..n).step_by(2) {
let (Some((a, na)), Some((b, nb))) = (sig(&ar, k), sig(&ar, k + 1)) else {
continue;
};
if na != nb {
continue; // the 26 already settled
}
if a == b {
match_btn += 1;
if k == 2 {
ctrl = true
}
} else {
differ_btn += 1;
if examples < 5 {
println!(" entries {k:3}/{:<3} buttons DIFFER", k + 1);
println!(" {:?}", a.iter().map(|x| &x.0).collect::<Vec<_>>());
println!(" {:?}", b.iter().map(|x| &x.0).collect::<Vec<_>>());
examples += 1;
}
}
}
println!("\nequal-button-count pairs whose button NAMES+ROWS match : {match_btn}");
println!("equal-button-count pairs whose buttons DIFFER : {differ_btn}");
println!(
"control (entries 2/3 counted as matching): {}",
if ctrl { "PASSED" } else { "FAILED" }
);
}

View File

@@ -0,0 +1,45 @@
//! Do adjacent `GP_DIALOG` entries differ in BUTTON COUNT?
//!
//! I offered an untested reading for why 63 of 65 adjacent pairs have different
//! element sets: dialog text baked into language-specific sprites, so EN/JP
//! entries differ by construction. `sylpheed-port` refuted it with a count — two
//! languages of one dialog cannot differ in how many buttons they have. This
//! re-derives that with my own reader before I record the refutation.
//!
//! cargo run -p sylpheed-formats --example dialog_pair_button_counts
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn btns(ar: &PakArchive, i: usize) -> Option<usize> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().filter(|e| e.name.contains("btn")).count())
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let (mut diff, mut same, mut skip) = (0, 0, 0);
let mut show = 0;
for k in (0..n).step_by(2) {
match (btns(&ar, k), btns(&ar, k + 1)) {
(Some(a), Some(b)) => {
if a != b {
diff += 1;
if show < 6 {
println!(" entries {k:3}/{:<3} button counts {a} vs {b}", k + 1);
show += 1;
}
} else {
same += 1
}
}
_ => skip += 1,
}
}
println!("\nadjacent pairs differing in BUTTON COUNT: {diff}");
println!("adjacent pairs with equal button counts : {same}");
println!("unreadable : {skip}");
println!("\ntwo languages of one dialog cannot differ in button count.");
}

View File

@@ -0,0 +1,51 @@
//! What differs between equal-button-count `GP_DIALOG` adjacent pairs?
//!
//! All 39 share button names and rows. That is consistent with a LANGUAGE PAIR and
//! equally with TWO DIALOGS SHARING A BUTTON TEMPLATE (two yes/no boxes differing
//! only in their message sprite). The difference is in what else differs: a
//! language pair should differ in the SAME slots with locale-marked names.
//!
//! cargo run -p sylpheed-formats --example dialog_pair_diffs
use std::collections::BTreeSet;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn names(ar: &PakArchive, i: usize) -> Option<BTreeSet<String>> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().map(|e| e.name.clone()).collect())
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let mut shown = 0;
for k in (0..n).step_by(2) {
let (Some(a), Some(b)) = (names(&ar, k), names(&ar, k + 1)) else {
continue;
};
if a == b {
continue;
}
let oa: Vec<&String> = a.difference(&b).collect();
let ob: Vec<&String> = b.difference(&a).collect();
// only the equal-button-count ones
let ba = a.iter().filter(|x| x.contains("btn")).count();
let bb = b.iter().filter(|x| x.contains("btn")).count();
if ba != bb {
continue;
}
if shown < 6 {
println!(
"entries {k:3}/{:<3} shared {} only-in-{k}: {:?} only-in-{}: {:?}",
k + 1,
a.intersection(&b).count(),
oa,
k + 1,
ob
);
shown += 1;
}
}
}

View File

@@ -0,0 +1,58 @@
//! Are `GP_DIALOG` entries 0/1 and 2/3 a LANGUAGE PAIR or a DUPLICATE?
//!
//! They are the only two adjacent pairs in that archive with identical element
//! sets; every other adjacent pair is two unrelated dialogs. Left open as
//! "untested" — identical element names are equally consistent with a language
//! pair (same layout, different glyphs baked into the textures) and with a
//! byte-for-byte duplicate.
//!
//! The bytes decide it: identical entries are a duplicate; entries that share
//! every element name but differ in payload are a language pair.
//!
//! CONTROL: entries 10/11, known to be two DIFFERENT dialogs (stage 10 vs stage
//! 02), must come out as differing — and by a lot. A comparator that cannot
//! separate two unrelated dialogs cannot judge two similar ones.
//!
//! cargo run -p sylpheed-formats --example dialog_pair_identity
use std::path::PathBuf;
use sylpheed_formats::pak::PakArchive;
fn cmp(ar: &PakArchive, a: usize, b: usize, what: &str) {
let (Ok(x), Ok(y)) = (ar.read(&ar.entries()[a]), ar.read(&ar.entries()[b])) else {
println!("{what}: unreadable");
return;
};
let same_len = x.len() == y.len();
let n = x.len().min(y.len());
let diff = (0..n).filter(|&i| x[i] != y[i]).count();
let first = (0..n).find(|&i| x[i] != y[i]);
println!("{what}");
println!(
" sizes {} / {} ({})",
x.len(),
y.len(),
if same_len { "equal" } else { "DIFFER" }
);
println!(
" differing bytes over the common prefix: {diff} / {n} ({:.2}%)",
100.0 * diff as f64 / n as f64
);
match first {
None if same_len => println!(" => BYTE-IDENTICAL — a duplicate"),
None => println!(" => one is a prefix of the other"),
Some(o) => println!(" => first difference at offset 0x{o:X}"),
}
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
cmp(
&ar,
10,
11,
"CONTROL: entries 10/11 — known two different dialogs",
);
cmp(&ar, 0, 1, "entries 0/1");
cmp(&ar, 2, 3, "entries 2/3 — the DIFFICULTY build");
}

View File

@@ -0,0 +1,58 @@
//! Are `GP_DIALOG`'s 140 entries adjacent EN/JP pairs, one per dialog record?
//!
//! The dialog table has 70 records and the archive has 140 entries. If the
//! pairing is adjacent — (0,1), (2,3), … — then dialog index = entry / 2, and the
//! unbound id-to-entry join becomes an ordering question rather than a search.
//!
//! Test: for each pair, compare the SET of element names. GP_TITLE's EN/JP pairs
//! share their sprite sets exactly except for the title art (4/7), so identical
//! sets are the signature of a language pair.
//!
//! cargo run -p sylpheed-formats --example dialog_pairing
use std::collections::BTreeSet;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn names(ar: &PakArchive, i: usize) -> Option<BTreeSet<String>> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().map(|e| e.name.clone()).collect())
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let (mut adj_same, mut adj_diff, mut skipped) = (0, 0, 0);
for k in (0..n).step_by(2) {
match (names(&ar, k), names(&ar, k + 1)) {
(Some(a), Some(b)) => {
if a == b {
adj_same += 1;
println!(" identical pair: entries {k}/{}", k + 1)
} else {
adj_diff += 1
}
}
_ => skipped += 1,
}
}
println!("ADJACENT pairing (2k, 2k+1): identical {adj_same} differing {adj_diff} unreadable {skipped}");
// rival hypothesis: halves, (i, i+70)
let (mut h_same, mut h_diff, mut h_skip) = (0, 0, 0);
for k in 0..n / 2 {
match (names(&ar, k), names(&ar, k + n / 2)) {
(Some(a), Some(b)) => {
if a == b {
h_same += 1
} else {
h_diff += 1
}
}
_ => h_skip += 1,
}
}
println!(
"HALVES pairing (i, i+70): identical {h_same} differing {h_diff} unreadable {h_skip}"
);
}

View File

@@ -0,0 +1,29 @@
//! How is `GP_DIALOG.pak` organised? Testing whether dialog id maps positionally.
//!
//! The dialog table gives name -> id (70 records, `DLG_SELECT_DIFFICULTY` = 2000)
//! and the disc gives a unique four-button build at GP_DIALOG entries 2/3. Nothing
//! joins them. If the archive were laid out in table order, or in id order, the
//! join would be positional — this checks.
//!
//! cargo run -p sylpheed-formats --example dialog_pak_shape
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 ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let mut builds = 0;
let mut with_btn = 0;
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if let Some(b) = ui_layout::parse_build(&by) {
builds += 1;
if b.elements.iter().any(|el| el.name.contains("btn")) {
with_btn += 1;
}
}
}
println!("entries {n}, parse as builds {builds}, of those with a btn element {with_btn}");
println!("dialog table has 70 records; 70 x 2 (EN/JP) = 140");
}

View File

@@ -5,7 +5,7 @@ fn short(s: &str) -> String {
.trim_start_matches("UnitName_UN_")
.into()
}
fn g<'a>(o: &'a IdxdObject, k: &str) -> String {
fn g(o: &IdxdObject, k: &str) -> String {
o.get_f32(k)
.map(|v| {
if v == v.trunc() {

View File

@@ -1,4 +1,4 @@
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
use sylpheed_formats::{localization::TextIndex, PakArchive};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();

View File

@@ -66,7 +66,7 @@ fn main() {
}
}
let (mut shared, mut inconsistent) = (0usize, 0usize);
for (_, list) in &seen {
for list in seen.values() {
if list.len() < 2 || !list.iter().all(|e| e.1 == list[0].1 && e.2 == list[0].2) {
continue;
}

View File

@@ -0,0 +1,113 @@
//! Every record reachable from an element — leaf **and** `focus_link` — plus a
//! disc-wide census of how many elements have more than one.
//!
//! ⚠️ WHY. I claimed `ptbtn00f`'s peak alpha of 80 was undeclared, having read
//! `ptbtn00.rat` (the leaf, flat 255) and stopped. The pulse is in
//! `ptbtn00f.rat`, the focus record. `focus_link` was already parsed and
//! `ui_layout.rs` already documented it: the format was known and I did not
//! consult it. An absence claim is only as good as its enumeration, so this
//! enumerates rather than asking the reader to remember.
//!
//! cargo run -p sylpheed-formats --example element_records -- GP_TITLE ptbtn00
//! cargo run -p sylpheed-formats --example element_records -- --census
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 argv: Vec<String> = std::env::args().skip(1).collect();
if argv.iter().any(|a| a == "--census") {
let (mut els, mut linked, mut screens_with) = (0usize, 0usize, 0usize);
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
.expect("dat")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
.collect();
paks.sort();
for p in &paks {
let Ok(ar) = PakArchive::open(p) else {
continue;
};
for ent in ar.entries() {
let Ok(by) = ar.read(ent) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let n = b.elements.iter().filter(|e| e.focus_link.is_some()).count();
els += b.elements.len();
linked += n;
if n > 0 {
screens_with += 1
}
}
}
println!("elements disc-wide : {els}");
println!(
"with a focus_link record : {linked} ({:.1}%)",
100.0 * linked as f64 / els as f64
);
println!("builds containing at least 1: {screens_with}");
println!("\nEach of those carries a SECOND record whose keyframes are invisible");
println!("to anyone who looks up the leaf by name and stops.");
return;
}
let pak = argv.first().cloned().unwrap_or_else(|| "GP_TITLE".into());
let want = argv.get(1).cloned();
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
for (i, ent) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(ent) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for el in &b.elements {
if let Some(w) = &want {
if !el.name.starts_with(w.as_str()) {
continue;
}
}
if el.focus_link.is_none() && want.is_none() {
continue;
}
println!("entry {i}: {}", el.name);
let stem = el.name.trim_end_matches(".rat");
for (tag, rec) in [
("leaf", format!("{stem}.rat")),
("focus", el.focus_link.clone().unwrap_or_default()),
] {
if rec.is_empty() {
continue;
}
match b.records.get(rec.as_str()) {
Some(&(lo, ls)) => {
let bytes = &by[lo..(lo + ls).min(by.len())];
let loop_u = ui_layout::loop_length_units(bytes);
let kf: Vec<String> = ui_layout::parse_build(bytes)
.map(|lb| {
lb.elements
.iter()
.map(|e| {
format!(
"{} [{} keys, peak a{}]",
e.name,
e.keyframes.len(),
e.keyframes
.iter()
.map(|k| k.fade >> 24)
.max()
.unwrap_or(0)
)
})
.collect()
})
.unwrap_or_default();
println!(" {tag:<6} {rec:<20} loop {:?} {}", loop_u, kf.join(", "));
}
None => println!(" {tag:<6} {rec:<20} (no such record)"),
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
//! Is `ptbtn11` the TOP item of the `EXTRAS` screen?
//!
//! `sylpheed-port` authors `extras/initial_focus: ptbtn11` and states it is
//! correct under the surviving reading — "a submenu resets to the item it opens
//! on". The oracle shows EXTRAS opening on `MISSION SELECT`, the first of
//! MISSION SELECT / MOVIE THEATER / BACK. So their value is right only if
//! `ptbtn11` is that first item. This checks it against the disc.
//!
//! CONTROL: the same read on the MAIN MENU build, whose five buttons have a known
//! top-to-bottom order (NEW GAME first). If the ordering rule cannot reproduce a
//! known screen it cannot be trusted on an unknown one.
//!
//! cargo run -p sylpheed-formats --example extras_button_order
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn report(ar: &PakArchive, entry: usize, what: &str) {
let Ok(by) = ar.read(&ar.entries()[entry]) else {
return;
};
let Some(b) = ui_layout::parse_build(&by) else {
return;
};
let mut rows: Vec<(i32, String, u32)> = b
.elements
.iter()
.filter(|e| e.name.starts_with("ptbtn") && !e.name.contains('f'))
.map(|e| {
let y = e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32);
(y, e.name.clone(), e.kind)
})
.collect();
rows.sort_by_key(|r| r.0);
println!("\n{what} (entry {entry}) — buttons top to bottom:");
for (y, n, k) in &rows {
println!(" y {y:5} {n:14} kind 0x{k:04x}");
}
if let Some((_, first, _)) = rows.first() {
println!(" => TOP item is {first}");
}
}
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");
report(&ar, 5, "CONTROL: main menu (NEW GAME must be top)");
report(&ar, 6, "EXTRAS");
}

View File

@@ -0,0 +1,69 @@
//! Where does the `DIFFICULTY` screen live?
//!
//! `boot-config-and-gamepart-registry.md` records a count-match — "Ⓑ = event 0,
//! four menu items load an external archive, EXTRAS stays inside GP_TITLE" —
//! explicitly as an observation, not a decode. The disc can test half of it:
//! OPTIONS, LOAD GAME and TUTORIAL have their own paks, and EXTRAS' two items
//! have GP_MISSION_SELECT / GP_MOVIE_THEATER while EXTRAS itself is GP_TITLE
//! entries 6/9. NEW GAME is the fourth, and there is no GP_DIFFICULTY.pak.
//!
//! So: which archive holds a build with EASY / NORMAL / HARD buttons?
//!
//! CONTROL: the same scan must find the EXTRAS build in GP_TITLE, whose location
//! is independently known (entries 6/9, buttons ptbtn11/12/13).
//!
//! cargo run -p sylpheed-formats --example find_difficulty_build
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 dat = root.join("dat");
let mut paks: Vec<_> = std::fs::read_dir(&dat)
.expect("dat")
.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 found_extras = false;
for p in &paks {
let Ok(ar) = PakArchive::open(p) else {
continue;
};
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 btns: Vec<&String> = b
.records
.keys()
.filter(|n| n.starts_with("ptbtn") || n.contains("btn"))
.collect();
if btns.len() != 8 {
continue;
}
let name = p.file_name().unwrap().to_string_lossy();
// CONTROL: the known MAIN MENU build (11 records, so this control is now vacuous) must show up.
if name == "GP_TITLE.pak" && (i == 5 || i == 8) {
found_extras = true;
println!("CONTROL {name} entry {i}: {} button records — the known MAIN MENU build (11 records, so this control is now vacuous)",
btns.len());
}
// any build outside GP_TITLE with a small button set is a candidate
if name != "GP_TITLE.pak" {
let mut names: Vec<String> = btns.iter().map(|s| (*s).clone()).collect();
names.sort();
println!(
" {name:28} entry {i:3} {} buttons {:?}",
btns.len(),
&names[..names.len().min(8)]
);
}
}
}
println!("\ncontrol {} — the known MAIN MENU build (11 records, so this control is now vacuous) was {}found",
if found_extras { "PASSED" } else { "FAILED" },
if found_extras { "" } else { "NOT " });
}

View File

@@ -0,0 +1,68 @@
//! Where is the `DIFFICULTY` screen? Search by NAME, not by structure.
//!
//! A previous pass searched every pak for a build with exactly 8 `btn`-named
//! records, on the assumption that DIFFICULTY's four items (EASY / NORMAL / HARD
//! / BACK) pair with `f` focus variants the way GP_TITLE's screens do. Nothing
//! plausible turned up, and the assumption was mine — recorded as a negative
//! narrower than "not found" (data/gp-title-holds-three-button-screens.txt).
//!
//! This drops the structural assumption and looks for the words instead, across
//! every sprite AND record name in every build on the disc.
//!
//! CONTROL: the same scan must find `ptbtn11` in GP_TITLE — a name whose home is
//! independently known — when asked for it. A name scan that finds nothing
//! proves nothing unless it can find something.
//!
//! cargo run -p sylpheed-formats --example find_difficulty_names
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
const WANTED: &[&str] = &["easy", "normal", "hard", "diff", "level", "rank"];
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
.expect("dat")
.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 control = false;
let mut hits = 0usize;
for p in &paks {
let Ok(ar) = PakArchive::open(p) else {
continue;
};
let pname = p.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 all: Vec<String> = b.sprites.keys().chain(b.records.keys()).cloned().collect();
if pname == "GP_TITLE.pak" && all.iter().any(|n| n.contains("ptbtn11")) {
control = true;
}
let m: Vec<&String> = all
.iter()
.filter(|n| {
let l = n.to_lowercase();
WANTED.iter().any(|w| l.contains(w))
})
.collect();
if !m.is_empty() {
hits += 1;
let mut s: Vec<String> = m.iter().map(|x| (*x).clone()).collect();
s.sort();
s.dedup();
println!(" {pname:28} entry {i:3} {:?}", &s[..s.len().min(6)]);
}
}
}
println!(
"\ncontrol (found ptbtn11 in GP_TITLE): {}",
if control { "PASSED" } else { "FAILED" }
);
println!("{hits} build(s) carried a difficulty-ish name");
}

View File

@@ -0,0 +1,125 @@
//! 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 (1..ID_MAX).contains(&id) && 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(&registry);
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)");
}

View File

@@ -0,0 +1,97 @@
//! 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());
}

View File

@@ -0,0 +1,99 @@
//! 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
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[0] > t || p[1] > t || p[2] > t)
.count();
let alpha = rgba.as_chunks::<4>().0.iter().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, 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
.as_chunks::<4>()
.0
.iter()
.zip(c.rgba.as_chunks::<4>().0.iter())
.filter(|(x, y)| x != y)
.count();
println!(" exact-RGBA changed pixels between the two orders: {changed}");
}
}
}

View File

@@ -0,0 +1,73 @@
//! 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}");
}

View File

@@ -0,0 +1,117 @@
//! 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()
);
}

View File

@@ -0,0 +1,125 @@
//! 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, None, Some(&with));
let c = ui_layout::compose_with_order(&b, &by, opts, None, Some(&without));
let n = a
.rgba
.as_chunks::<4>()
.0
.iter()
.zip(c.rgba.as_chunks::<4>().0.iter())
.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
.as_chunks::<4>()
.0
.iter()
.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}");
}

View File

@@ -0,0 +1,67 @@
//! Is `GP_DIALOG` 2/3 the ONLY build on the disc with four buttons at 259/329/399/469?
//!
//! Both agents recorded the same reach on the DIFFICULTY identification: entries
//! 2/3 are picked out by button count and geometry, not by a binding from
//! `DLG_SELECT_DIFFICULTY` to a pak entry, so "another four-button dialog with the
//! same rows would be indistinguishable". This tests whether such a rival exists.
//!
//! CONTROL: the scan must find GP_DIALOG 2 and 3 themselves. A rival-search that
//! cannot find the incumbent proves nothing.
//!
//! cargo run -p sylpheed-formats --example four_button_row_rivals
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
const WANT: [i32; 4] = [259, 329, 399, 469];
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
.expect("dat")
.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 incumbent, mut rivals) = (0, 0);
for p in &paks {
let Ok(ar) = PakArchive::open(p) else {
continue;
};
let pname = p.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 mut ys: Vec<i32> = b
.elements
.iter()
.filter(|el| el.name.contains("btn") && !el.name.contains('f'))
.map(|el| el.rest().map(|k| k.y).unwrap_or(el.pivot_y as i32))
.collect();
ys.sort();
ys.dedup();
if ys.len() != 4 {
continue;
}
let close = ys.iter().zip(WANT.iter()).all(|(a, b)| (a - b).abs() <= 6);
if close {
let is_inc = pname == "GP_DIALOG.pak" && (i == 2 || i == 3);
if is_inc {
incumbent += 1
} else {
rivals += 1
}
println!(
" {}{pname:24} entry {i:4} rows {ys:?}",
if is_inc { "INCUMBENT " } else { "RIVAL " }
);
}
}
}
println!(
"\ncontrol: found {incumbent} incumbent build(s) (want 2) — {}",
if incumbent == 2 { "PASSED" } else { "FAILED" }
);
println!("{rivals} rival build(s) elsewhere on the disc");
}

View File

@@ -0,0 +1,83 @@
//! The main menu's sprites, by their ALPHA channel — is `ptframe1`/`ptframe2`'s
//! "no fully-opaque pixel" a property of the artwork, and does any alpha value
//! look like a scale the game expands (e.g. 0..128) rather than 0..255?
//!
//! The port measures both frames as rendering too DARK against the capture, with
//! the shortfall correlating with the BACKGROUND. Two different causes predict
//! that: a background-scaling blend selected in code, or an alpha that is too
//! LOW in our decode. This example tests the second, which is on the disc.
//!
//! cargo run -p sylpheed-formats --example frame_alpha_census
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, t8ad, ui_layout};
fn census(name: &str, img: &t8ad::T8adImage) {
let n = (img.width * img.height) as usize;
let mut hist = [0usize; 256];
for p in 0..n {
hist[img.rgba[p * 4 + 3] as usize] += 1;
}
let zero = hist[0];
let full = hist[255];
let max = (0..256).rev().find(|&a| hist[a] > 0).unwrap_or(0);
let nonzero = n - zero;
// the top five alpha values that actually occur, by population
let mut top: Vec<(usize, usize)> = (1..256)
.map(|a| (hist[a], a))
.filter(|&(c, _)| c > 0)
.collect();
top.sort_unstable_by_key(|a| std::cmp::Reverse(a.0));
let top5: Vec<String> = top
.iter()
.take(5)
.map(|&(c, a)| format!("{a}x{c}"))
.collect();
println!(
"{name:<16} {}x{:<4} px={n:<8} a=0:{:5.1}% a=255:{:5.1}% max={max:<3} \
partial(1..254)/nonzero={:5.1}% top:[{}]",
img.width,
img.height,
100.0 * zero as f64 / n as f64,
100.0 * full as f64 / n as f64,
if nonzero > 0 {
100.0 * (nonzero - full) as f64 / nonzero as f64
} else {
0.0
},
top5.join(" ")
);
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let argv: Vec<String> = std::env::args().skip(1).collect();
let pak = argv
.iter()
.find(|a| a.parse::<usize>().is_err())
.cloned()
.unwrap_or_else(|| "GP_TITLE".to_string());
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
// Builds default to the two the port ships and can be overridden, so the
// same census serves the title (4) and the `PRESS (A)` plate (2).
let args: Vec<usize> = argv.iter().filter_map(|a| a.parse().ok()).collect();
let builds: Vec<usize> = if args.is_empty() { vec![5, 6] } else { args };
for build in builds {
let Ok(by) = ar.read(&ar.entries()[build]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
println!("=== {pak} build {build} ===");
let mut names: Vec<&String> = b.sprites.keys().collect();
names.sort();
for n in names {
let (off, size) = b.sprites[n];
let s = &by[off..(off + size).min(by.len())];
match t8ad::parse(s) {
Some(img) => census(n, &img),
None => println!("{n:<16} (not a T8aD / failed to parse)"),
}
}
}
}

View File

@@ -0,0 +1,79 @@
//! The keyframe record's two unexplained words (`+4`, `+8`) and the fade/tint —
//! do any of them separate the four elements the port measures as rendering too
//! dark (`ptframe1`/`2`, `ptframe3`/`4`) from the ones it measures as accurate?
//!
//! The T8aD header does not: no word and no bit of `+0x04`/`+0x08` puts the four
//! frames on one side and `pteff10` (max alpha 130, wholly semi-transparent, and
//! rendered nearly exact) on the other. The keyframe is the other place a
//! per-element draw mode could live.
//!
//! cargo run -p sylpheed-formats --example frame_keyframe_unknowns
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 ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
println!(
"{:<22} {:>5} {:>3} {:>10} {:>10} {:>8} {:>8} {:>8}",
"element", "build", "kf", "unknown_4", "unknown_8", "fade", "tint", "rot"
);
let mut frame_sets: Vec<(String, i32, i32, u32, u32, i32)> = Vec::new();
let mut other_sets: Vec<(String, i32, i32, u32, u32, i32)> = Vec::new();
for build in [5usize, 6] {
let by = ar.read(&ar.entries()[build]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for e in &b.elements {
for (i, k) in e.keyframes.iter().enumerate() {
println!(
"{:<22} {build:>5} {i:>3} {:>10} {:>10} {:08X} {:08X} {:>8}",
e.name, k.unknown_4, k.unknown_8, k.fade, k.tint, k.rotation_deg
);
let row = (
e.name.clone(),
k.unknown_4,
k.unknown_8,
k.fade,
k.tint,
k.rotation_deg,
);
if e.name.contains("frame") {
frame_sets.push(row)
} else {
other_sets.push(row)
}
}
}
}
println!(
"\nframe keyframes: {} other keyframes: {}",
frame_sets.len(),
other_sets.len()
);
for (label, get) in [
("unknown_4", 0usize),
("unknown_8", 1),
("fade", 2),
("tint", 3),
("rotation", 4),
] {
let val = |r: &(String, i32, i32, u32, u32, i32)| -> i64 {
match get {
0 => r.1 as i64,
1 => r.2 as i64,
2 => r.3 as i64,
3 => r.4 as i64,
_ => r.5 as i64,
}
};
let fv: std::collections::BTreeSet<i64> = frame_sets.iter().map(val).collect();
let ov: std::collections::BTreeSet<i64> = other_sets.iter().map(val).collect();
let only_frames: Vec<&i64> = fv.iter().filter(|v| !ov.contains(v)).collect();
println!(
"{label:<10} frames take {:?} others take {} distinct values; frame-only values: {:?}",
fv,
ov.len(),
only_frames
);
}
}

View File

@@ -0,0 +1,96 @@
//! Which T8aD header word, if any, separates the FOUR elements the port measures
//! as rendering too dark (`ptframe1`/`2` on the main menu, `ptframe3`/`4` on
//! `EXTRAS`) from the elements on the same two screens it measures as accurate?
//!
//! The control that matters: `pteff10` has max alpha 130 and no fully-opaque
//! pixel — the same "wholly semi-transparent" property the port proposed as the
//! reason the frames are special — and it renders nearly exact. So the separator
//! must put `pteff10` on the ACCURATE side.
//!
//! cargo run -p sylpheed-formats --example frame_vs_accurate_words
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 ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let mut rows: Vec<(String, usize, Vec<u32>)> = Vec::new();
for build in [5usize, 6] {
let by = ar.read(&ar.entries()[build]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
let mut names: Vec<&String> = b.sprites.keys().collect();
names.sort();
for n in names {
let (off, size) = b.sprites[n];
let s = &by[off..(off + size).min(by.len())];
if s.len() < 48 || &s[0..4] != b"T8aD" {
continue;
}
let ws: Vec<u32> = (0..12)
.map(|k| u32::from_be_bytes([s[k * 4], s[k * 4 + 1], s[k * 4 + 2], s[k * 4 + 3]]))
.collect();
rows.push((n.clone(), build, ws));
}
}
let is_frame = |n: &str| n.starts_with("ptframe");
println!(
"{:<16} {:>5} +0x04 +0x08 +0x1C +0x2C",
"sprite", "build"
);
for (n, b, w) in &rows {
println!(
"{n:<16} {b:>5} {:08X} {:08X} {:08X} {:08X}{}",
w[1],
w[2],
w[7],
w[11],
if is_frame(n) { " <- TOO DARK" } else { "" }
);
}
println!("\nwords where every ptframe* agrees and NO other sprite takes that value:");
let frames: Vec<&(String, usize, Vec<u32>)> =
rows.iter().filter(|(n, _, _)| is_frame(n)).collect();
let mut any = false;
for k in 0..12 {
let v = frames[0].2[k];
if !frames.iter().all(|r| r.2[k] == v) {
continue;
}
if rows.iter().any(|(n, _, w)| !is_frame(n) && w[k] == v) {
continue;
}
println!(" word {k} (+0x{:02X}) = {v:08X}", k * 4);
any = true;
}
if !any {
println!(" NONE — no header word separates the four frames from the rest");
}
println!(
"\nper-bit check on +0x04 and +0x08 (a bit that is 1 on all frames, 0 on all others):"
);
let mut anyb = false;
for &k in &[1usize, 2] {
for bit in 0..32 {
let on = |v: u32| (v >> bit) & 1 == 1;
if frames.iter().all(|r| on(r.2[k]))
&& rows.iter().all(|(n, _, w)| is_frame(n) || !on(w[k]))
{
println!(" +0x{:02X} bit {bit} (0x{:X})", k * 4, 1u32 << bit);
anyb = true;
}
if frames.iter().all(|r| !on(r.2[k]))
&& rows.iter().all(|(n, _, w)| is_frame(n) || on(w[k]))
{
println!(
" +0x{:02X} bit {bit} (0x{:X}) INVERTED",
k * 4,
1u32 << bit
);
anyb = true;
}
}
}
if !anyb {
println!(" NONE");
}
}

View File

@@ -0,0 +1,32 @@
//! Every button record in `GP_TITLE.pak`, per entry.
//!
//! Testing half of the count-match in boot-config-and-gamepart-registry.md:
//! "four menu items load an external archive, EXTRAS stays inside GP_TITLE".
//! If DIFFICULTY (NEW GAME's destination, EASY/NORMAL/HARD/BACK) is also inside
//! GP_TITLE, then NEW GAME loads nothing external and that reading is wrong.
//!
//! cargo run -p sylpheed-formats --example gp_title_buttons
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 ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
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 mut btns: Vec<String> = b
.records
.keys()
.filter(|n| n.starts_with("ptbtn"))
.cloned()
.collect();
btns.sort();
if btns.is_empty() {
continue;
}
println!("entry {i:2} {:2} button records {:?}", btns.len(), btns);
}
}

View File

@@ -0,0 +1,24 @@
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").unwrap());
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).unwrap();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else {
println!("{i:2} <unreadable>");
continue;
};
let names: Vec<String> = ui_layout::parse_build(&by)
.map(|b| b.sprites.keys().take(2).cloned().collect())
.unwrap_or_default();
let rec: Vec<String> = ui_layout::parse_build(&by)
.map(|b| b.records.keys().take(2).cloned().collect())
.unwrap_or_default();
println!(
"{i:2} {} B sprites {:?} records {:?}",
by.len(),
names,
rec
);
}
}

View File

@@ -0,0 +1,57 @@
//! Is `GP_TITLE.pak` really "8 screens shipped twice, EN/JP"?
//!
//! The Q2 headline says each screen appears twice. The entry dump raised a
//! doubt: entry 11 shows `palogo_gamearts` and entry 14 shows `palogo_seta`,
//! which are different studios, not a language pair. If the two halves of a
//! "pair" declare different sprites, "shipped twice" is the wrong description of
//! at least that pair.
//!
//! CONTROL: a pair known to be a real EN/JP pair must come out as matching. 2/3
//! (the PRESS Ⓐ plate) is byte-identical in size and is the control.
//!
//! cargo run -p sylpheed-formats --example gp_title_pair_check
use std::collections::BTreeSet;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn sprites(ar: &PakArchive, i: usize) -> BTreeSet<String> {
let Ok(by) = ar.read(&ar.entries()[i]) else {
return BTreeSet::new();
};
ui_layout::parse_build(&by)
.map(|b| b.sprites.keys().cloned().collect())
.unwrap_or_default()
}
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 pairs = [
(0, 1, "loading plain"),
(2, 3, "PRESS (A) plate [CONTROL]"),
(4, 7, "title art"),
(5, 8, "main menu"),
(6, 9, "EXTRAS"),
(10, 13, "publisher splash"),
(11, 14, "developer splash"),
(12, 15, "loading dressed"),
];
for (a, b, what) in pairs {
let (sa, sb) = (sprites(&ar, a), sprites(&ar, b));
let only_a: Vec<_> = sa.difference(&sb).cloned().collect();
let only_b: Vec<_> = sb.difference(&sa).cloned().collect();
let shared = sa.intersection(&sb).count();
let verdict = if only_a.is_empty() && only_b.is_empty() {
"IDENTICAL SET"
} else {
"DIFFERS"
};
println!("\n{a:2}/{b:<2} {what:26} {shared:3} shared {verdict}");
if !only_a.is_empty() {
println!(" only in {a}: {only_a:?}");
}
if !only_b.is_empty() {
println!(" only in {b}: {only_b:?}");
}
}
}

View File

@@ -27,7 +27,7 @@ fn score(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> (usize, f32, usize)
let mut degen = 0usize;
let mut agree = 0usize;
let mut counted = 0usize;
for t in idx.chunks_exact(3) {
for t in idx.as_chunks::<3>().0 {
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
if a == b || b == c || a == c {
degen += 1;

View File

@@ -0,0 +1,111 @@
//! Do `ui_layout`'s two IN-RANGE fallbacks ever fire? Counted, disc-wide.
//!
//! An in-range fallback supplies a value that is legitimate, so no output can
//! distinguish it from the real thing and inspection cannot settle it. The only
//! question that has an answer is *how often does it fire*.
//!
//! ui_layout.rs:1681 kf.time.unwrap_or(0) -- 0 is a real keyframe time
//! (pose 0's time IS 0), so a fabricated one is invisible.
//! ui_layout.rs:1010 pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0
//! -- alpha 0 is legitimate, and it makes "no pose here"
//! read as "fully transparent", biasing an occlusion test
//! toward NOT occluded.
//!
//! (ui_layout.rs:973's `unwrap_or(0)` is NOT counted: it is guarded two lines
//! later by `if tmax == 0 { return false; }`, so 0 is handled, not assumed.)
//!
//! cargo run -p sylpheed-formats --example inrange_fallback_count
use std::io::Write;
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 kf, mut untimed, mut builds) = (0u64, 0u64, 0u64);
let (mut queries, mut none_at) = (0u64, 0u64);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else {
continue;
};
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
builds += 1;
// (a) :1681 -- how many poses carry no time?
for el in &b.elements {
for k in &el.keyframes {
kf += 1;
if k.time.is_none() {
untimed += 1
}
}
}
// (b) :1010 -- ask every element for a pose at every time that any
// element declares, which is the set the occlusion test draws from.
let mut times: Vec<u32> = b
.elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
.collect();
times.sort_unstable();
times.dedup();
for el in &b.elements {
for &t in &times {
queries += 1;
if el.pose_at(t).is_none() {
none_at += 1
}
}
}
}
print!(".");
std::io::stdout().flush().ok();
}
println!();
println!("{builds} builds, {kf} keyframes");
println!(":1681 untimed poses (the fallback would fabricate t=0): {untimed}");
println!(":1010 pose_at queries {queries}, of which None (fallback reads a=0): {none_at}");
// NEGATIVE CONTROL. Both counters above report 0, and a zero is the result
// this corpus has learned to distrust most -- it reads clean rather than
// suspicious. So prove the detector CAN see a hit: ask every element for a
// pose at a time no build declares. If pose_at is total, `none_out` is 0 too
// and the 0 above means nothing.
let mut out_queries = 0u64;
let mut none_out = 0u64;
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else {
continue;
};
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for el in &b.elements {
for &t in &[u32::MAX, 1_000_000u32] {
out_queries += 1;
if el.pose_at(t).is_none() {
none_out += 1
}
}
}
}
}
println!("CONTROL pose_at at an undeclared time: {out_queries} queries, {none_out} None");
println!(" (if this is 0 the detector is blind and the 0 above is meaningless)");
println!("--- END ---");
}

View File

@@ -171,7 +171,7 @@ fn main() {
)
})
.collect();
sizes.sort_unstable_by(|a, b| b.0.cmp(&a.0));
sizes.sort_unstable_by_key(|a| std::cmp::Reverse(a.0));
println!("\nlargest resources in {stage}.xpr → drawn in the capture?");
for (v, name) in sizes.iter().take(top_n.min(sizes.len())) {
let n = draw_count.get(v).copied().unwrap_or(0);

View File

@@ -0,0 +1,86 @@
//! 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 std::collections::{BTreeMap, BTreeSet};
use sylpheed_formats::{pak, ui_layout};
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}");
}
}

View File

@@ -0,0 +1,37 @@
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
);
}
}
}

View File

@@ -0,0 +1,103 @@
//! 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 std::collections::BTreeMap;
use sylpheed_formats::{pak, ui_layout};
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}");
}
}
}

View File

@@ -0,0 +1,61 @@
//! What do 0x3002/0x3003 elements actually look like disc-wide?
//! The port's menu-item rule keys on this class; this asks whether the class is
//! uniformly menu-row-shaped or whether it contains other things too.
use std::collections::BTreeMap;
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 names: BTreeMap<String, usize> = BTreeMap::new();
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
.expect("dat")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
.collect();
paks.sort();
for p in &paks {
let Ok(ar) = PakArchive::open(p) else {
continue;
};
for ent in ar.entries() {
let Ok(by) = ar.read(ent) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for el in &b.elements {
if el.kind == 0x3002 || el.kind == 0x3003 {
let stem = el.name.split('.').next().unwrap_or("");
let cls: String = stem
.trim_end_matches(|c: char| c.is_ascii_digit())
.to_string();
*names.entry(cls).or_default() += 1;
}
}
}
}
let total: usize = names.values().sum();
println!(
"0x3002/0x3003 elements disc-wide: {total}, {} name-stems",
names.len()
);
let mut v: Vec<_> = names.into_iter().collect();
v.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
let btn: usize = v
.iter()
.filter(|(k, _)| k.contains("btn"))
.map(|(_, n)| n)
.sum();
println!(
"stems containing \"btn\": {btn} of {total} ({:.1}%)",
100.0 * btn as f64 / total as f64
);
println!("\ntop stems:");
for (k, n) in v.iter().take(14) {
println!(" {n:5} {k}");
}
println!("\nNON-btn stems (the ones a menu-item rule would also claim):");
for (k, n) in v.iter().filter(|(k, _)| !k.contains("btn")).take(12) {
println!(" {n:5} {k}");
}
}

View File

@@ -0,0 +1,71 @@
//! Disc-wide test of one bit: does `kind & 1` mean "this element has a parent"?
//!
//! The port is blocked on what `0x3003` is, having only `0x3002` in its rule.
//! `0x3002` and `0x3003` differ in bit 0 alone, and the struct doc claims bit 0
//! is "has a parent". That is a falsifiable claim over every element on the
//! disc, so it is tested here rather than argued from two screens.
//!
//! cargo run -p sylpheed-formats --example kind_bit0_census
use std::collections::BTreeMap;
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 agree = 0usize;
let mut disagree = 0usize;
let mut kinds: BTreeMap<u32, usize> = BTreeMap::new();
let mut kind_parent: BTreeMap<(u32, bool), usize> = BTreeMap::new();
let mut examples: Vec<String> = Vec::new();
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
.expect("dat")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
.collect();
paks.sort();
for p in &paks {
let Ok(ar) = PakArchive::open(p) else {
continue;
};
for (i, ent) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(ent) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for el in &b.elements {
let has_parent = el.parent.is_some();
let bit0 = el.kind & 1 == 1;
*kinds.entry(el.kind).or_default() += 1;
*kind_parent.entry((el.kind, has_parent)).or_default() += 1;
if bit0 == has_parent {
agree += 1
} else {
disagree += 1;
if examples.len() < 10 {
examples.push(format!(
"{} entry {i} {} kind={:#x} parent={:?}",
p.file_name().unwrap().to_string_lossy(),
el.name,
el.kind,
el.parent
));
}
}
}
}
}
println!("kind&1 == has_parent : agree {agree} DISAGREE {disagree}");
for e in &examples {
println!(" counterexample: {e}");
}
println!("\nkind histogram (count, and how many of each have a parent):");
for (k, n) in &kinds {
let wp = kind_parent.get(&(*k, true)).copied().unwrap_or(0);
println!(
" {k:#06x} n={n:6} with parent {wp:6} without {:6}",
n - wp
);
}
}

View File

@@ -0,0 +1,205 @@
//! Refutation check on `sylpheed-port`'s kind census: *"Every sprite decoration
//! on both screens is `0x0` — `ptframe1`…`ptframe4` included — and every button
//! is `0x3002`."*
//!
//! Their exporter decodes the field independently; this reads it from the other
//! side. The check is deliberately WIDER than their claim in two ways, because a
//! census that only looks where the claim looks cannot fail:
//!
//! * it covers every element, not only `.t32` sprites, so a decoration with an
//! unexpected kind cannot hide behind the word "sprite";
//! * it covers every build of `GP_TITLE`, not the two screens they checked, so
//! the claim's *reach* gets tested and not just its instances.
//!
//! It also cross-checks `kind` against the focus/nav index at `+0x2C`, which is
//! `-1` on anything that cannot take the cursor. That turns a census into a
//! decode: if the two fields agree everywhere, the bit that separates them is
//! identified rather than guessed. Run over EVERY UI pak on the disc, not just
//! `GP_TITLE`, so the claim is disc-wide.
//!
//! cargo run -p sylpheed-formats --example kind_census_five_screens
use std::collections::BTreeMap;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn suffix(n: &str) -> &str {
match n.rfind('.') {
Some(i) => &n[i..],
None => "(none)",
}
}
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");
let n = ar.entries().len();
// kind -> suffix -> count, and the exceptions we care about by name
let mut table: BTreeMap<u32, BTreeMap<String, usize>> = BTreeMap::new();
let mut t32_nonzero: Vec<(usize, String, u32)> = Vec::new();
let mut btn_nonstd: Vec<(usize, String, u32)> = Vec::new();
let mut builds = 0usize;
for e in 0..n {
let by = match ar.read(&ar.entries()[e]) {
Ok(b) => b,
Err(_) => continue,
};
let b = match ui_layout::parse_build(&by) {
Some(b) => b,
None => continue,
};
builds += 1;
for el in &b.elements {
*table
.entry(el.kind)
.or_default()
.entry(suffix(&el.name).to_string())
.or_default() += 1;
if el.name.ends_with(".t32") && !el.name.contains("btn") && el.kind != 0 {
t32_nonzero.push((e, el.name.clone(), el.kind));
}
if el.name.contains("btn") && el.kind != 0x3002 {
btn_nonstd.push((e, el.name.clone(), el.kind));
}
}
}
println!("GP_TITLE: {builds} parseable builds of {n} entries\n");
println!("{:<10} elements by file suffix", "kind");
for (k, m) in &table {
let s: Vec<String> = m.iter().map(|(sfx, c)| format!("{sfx}x{c}")).collect();
println!("0x{k:<8X} {}", s.join(" "));
}
println!(
"\nNON-BUTTON .t32 elements with kind != 0: {}",
t32_nonzero.len()
);
for (e, nm, k) in t32_nonzero.iter().take(30) {
println!(" entry {e:>2} {nm:<24} kind 0x{k:X}");
}
println!("\n*btn* elements with kind != 0x3002: {}", btn_nonstd.len());
for (e, nm, k) in btn_nonstd.iter().take(30) {
println!(" entry {e:>2} {nm:<24} kind 0x{k:X}");
}
// Is `kind` a bitfield, and does bit 0x2 mean "focusable"? The focus/nav
// index at +0x2C is -1 on everything that cannot take the cursor, so the two
// fields cross-check each other. Printed rather than asserted: this is the
// evidence for the reading, not the reading itself.
println!("\nkind vs the focus index at +0x2C (-1 = not focusable), GP_TITLE:");
// +0x2C is not in `Element`, so it is read straight out of the 60-byte
// declaration entry: table at 0x20, 0x14 = count, entry stride 60.
const AT: usize = 0x20;
const STRIDE: usize = 60;
let mut cross: BTreeMap<(u32, i32), usize> = BTreeMap::new();
for e in 0..n {
let by = match ar.read(&ar.entries()[e]) {
Ok(b) => b,
Err(_) => continue,
};
if ui_layout::parse_build(&by).is_none() {
continue;
}
if by.len() < 0x18 {
continue;
}
let count = u32::from_be_bytes([by[0x14], by[0x15], by[0x16], by[0x17]]) as usize;
for i in 0..count {
let at = AT + i * STRIDE;
if at + STRIDE > by.len() {
break;
}
let kind =
u32::from_be_bytes([by[at + 0x28], by[at + 0x29], by[at + 0x2A], by[at + 0x2B]]);
let foc =
i32::from_be_bytes([by[at + 0x2C], by[at + 0x2D], by[at + 0x2E], by[at + 0x2F]]);
*cross
.entry((kind, if foc < 0 { -1 } else { 1 }))
.or_default() += 1;
}
}
for ((k, f), c) in &cross {
println!(
" kind 0x{k:<6X} focus {:<10} {c:>4} elements",
if *f < 0 { "= -1" } else { ">= 0" }
);
}
// ── the same test, every UI pak on the disc ──────────────────────────────
let mut all: BTreeMap<(u32, i32), usize> = BTreeMap::new();
let mut paks = 0usize;
let mut violations: Vec<String> = Vec::new();
let mut dir: Vec<_> = std::fs::read_dir(root.join("dat"))
.expect("dat")
.filter_map(|d| d.ok())
.map(|d| d.path())
.collect();
dir.sort();
for path in dir {
let name = path.file_name().unwrap().to_string_lossy().to_string();
if !name.ends_with(".pak") {
continue;
}
let ar = match PakArchive::open(&path) {
Ok(a) => a,
Err(_) => continue,
};
let mut used = false;
for i in 0..ar.entries().len() {
let by = match ar.read(&ar.entries()[i]) {
Ok(b) => b,
Err(_) => continue,
};
if ui_layout::parse_build(&by).is_none() {
continue;
}
if by.len() < 0x18 {
continue;
}
used = true;
let count = u32::from_be_bytes([by[0x14], by[0x15], by[0x16], by[0x17]]) as usize;
for e in 0..count {
let at = AT + e * STRIDE;
if at + STRIDE > by.len() {
break;
}
let kind = u32::from_be_bytes([
by[at + 0x28],
by[at + 0x29],
by[at + 0x2A],
by[at + 0x2B],
]);
let foc = i32::from_be_bytes([
by[at + 0x2C],
by[at + 0x2D],
by[at + 0x2E],
by[at + 0x2F],
]);
let f = if foc < 0 { -1 } else { 1 };
*all.entry((kind, f)).or_default() += 1;
if ((kind & 0x2) != 0) != (f > 0) {
violations.push(format!(
"{name} entry {i} elem {e}: kind 0x{kind:X} focus {foc}"
));
}
}
}
if used {
paks += 1
}
}
let total: usize = all.values().sum();
println!("\nDISC-WIDE — {paks} UI paks, {total} declaration entries");
println!("{:<12} {:>12} {:>12}", "kind", "focus = -1", "focus >= 0");
let kinds: std::collections::BTreeSet<u32> = all.keys().map(|(k, _)| *k).collect();
for k in kinds {
println!(
"0x{k:<10X} {:>12} {:>12}",
all.get(&(k, -1)).copied().unwrap_or(0),
all.get(&(k, 1)).copied().unwrap_or(0)
);
}
println!("\nHYPOTHESIS: bit 0x2 of kind == (focus index >= 0)");
println!("violations: {} of {total}", violations.len());
for v in violations.iter().take(20) {
println!(" {v}");
}
}

View File

@@ -0,0 +1,71 @@
//! 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)");
}

View File

@@ -0,0 +1,68 @@
//! 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),
}
}
}

View File

@@ -0,0 +1,45 @@
//! Dump the keyframes of any nested `.rat` leaf by name.
//!
//! cargo run -p sylpheed-formats --example leaf_keyframes -- GP_TITLE ptbtn00.rat 2 3 4
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 argv: Vec<String> = std::env::args().skip(1).collect();
let pak = argv[0].clone();
let rec = argv[1].clone();
let builds: Vec<usize> = argv[2..].iter().filter_map(|a| a.parse().ok()).collect();
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
for e in builds {
let Ok(by) = ar.read(&ar.entries()[e]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let Some(&(lo, ls)) = b.records.get(rec.as_str()) else {
continue;
};
let leaf = &by[lo..(lo + ls).min(by.len())];
println!("=== {pak} entry {e}{rec} (leaf {ls} bytes) ===");
if let Some(u) = ui_layout::loop_length_units(leaf) {
println!(" declared loop: {u} units");
}
if let Some(lb) = ui_layout::parse_build(leaf) {
for el in &lb.elements {
println!(" {}{} keyframes", el.name, el.keyframes.len());
for (i, k) in el.keyframes.iter().enumerate() {
println!(
" kf{i:<2} t={:<5} x={:<6} y={:<6} fade={:08X} (alpha {:3})",
k.time.map(|t| t.to_string()).unwrap_or_else(|| "-".into()),
k.x,
k.y,
k.fade,
k.fade >> 24
);
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
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"),
}
}
}
}

View File

@@ -0,0 +1,36 @@
// 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, 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, 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
);
}
}

View File

@@ -0,0 +1,86 @@
//! Does the `+0x08` falsifier actually identify `+0x08`?
//!
//! `ui-record-loop-length.md` (mine) rests on: an animation cannot restart before
//! its own last pose, so a wrong reading should produce violations, and none exist
//! in 1 781 records. `sylpheed-port` re-ran it at the neighbouring offsets and
//! reports the falsifier ACCEPTS `+0x04` too — meaning it does not discriminate,
//! and the real evidence is the exactness statistic I called a formality.
//!
//! This checks that from my own reader before I correct the page.
//!
//! cargo run -p sylpheed-formats --example loop_length_offset_discriminates
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn be32(b: &[u8], o: usize) -> Option<u32> {
(b.len() >= o + 4).then(|| u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]))
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let dat = root.join("dat");
let mut paks: Vec<_> = std::fs::read_dir(&dat)
.expect("dat")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
.collect();
paks.sort();
// offset -> (records, violations where word < max_t, exact matches)
let mut stat = [(0usize, 0usize, 0usize); 3];
let offsets = [0x04usize, 0x08, 0x0c];
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 };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for (off, size) in b.records.values() {
let rec = &by[*off..(*off + *size).min(by.len())];
if rec.len() < 0x10 || &rec[0..4] != b"RATC" {
continue;
}
let Some(leaf) = ui_layout::parse_build(rec) else {
continue;
};
let max_t = leaf
.elements
.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max();
let Some(max_t) = max_t else { continue };
// sylpheed-port's reconciliation: max_t == 0 makes "does the word
// equal the largest keyframe time?" vacuous, and those records were
// silently in my denominator. Filter them and the counts must meet.
if std::env::var("MEANINGFUL_ONLY").is_ok() && max_t == 0 {
continue;
}
for (i, o) in offsets.iter().enumerate() {
if let Some(w) = be32(rec, *o) {
stat[i].0 += 1;
if w < max_t {
stat[i].1 += 1
}
if w == max_t {
stat[i].2 += 1
}
}
}
}
}
}
println!(
"{:>8} {:>9} {:>12} {:>14}",
"offset", "records", "violations", "exact == max_t"
);
for (i, o) in offsets.iter().enumerate() {
let (n, v, x) = stat[i];
println!(
" +0x{o:02X} {n:>9} {v:>12} ({:>5.1}%) {x:>8} ({:>5.1}%)",
100.0 * v as f64 / n.max(1) as f64,
100.0 * x as f64 / n.max(1) as f64
);
}
}

View File

@@ -0,0 +1,63 @@
//! What elements sit under the port's hot residual tiles on the main menu?
//!
//! `sylpheed-port` mapped the menu's edge residual at 64 px tiles and handed over
//! coordinates without names — the element inventory is this side's. Hot tiles
//! cluster at x 384704, y 64256, hottest at (512,128).
//!
//! ⚠️ Their tiles are in the frame they compare in; this prints DESIGN space, and
//! the two differ by the capture offset (capture_y ≈ 64.8 + 0.992·design_y). Both
//! readings are printed so the mapping is not assumed.
//!
//! cargo run -p sylpheed-formats --example main_menu_element_extents
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 ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let by = ar.read(&ar.entries()[5]).expect("entry 5");
let b = ui_layout::parse_build(&by).expect("build");
println!(
"{:<26} {:>6} {:>6} {:>7} {:>7} sprite",
"element", "x", "y", "pivot_x", "pivot_y"
);
let mut rows: Vec<(i32, i32, String, String)> = b
.elements
.iter()
.map(|e| {
let k = e.rest();
(
k.map(|k| k.x).unwrap_or(0),
k.map(|k| k.y).unwrap_or(0),
e.name.clone(),
e.sprite.clone().unwrap_or_default(),
)
})
.collect();
rows.sort_by_key(|r| (r.1, r.0));
for (x, y, n, s) in &rows {
// flag anything whose rest position lands in the hot band, read both ways
let cap_y = 64.82 + 0.9919 * (*y as f64);
let hot_design = (384..=704).contains(x) && (64..=256).contains(y);
let hot_capture = (384..=704).contains(x) && (64.0..=256.0).contains(&cap_y);
let mark = match (hot_design, hot_capture) {
(true, true) => " <- HOT both readings",
(true, false) => " <- hot in DESIGN space",
(false, true) => " <- hot in CAPTURE space",
_ => "",
};
println!(
"{n:<26} {x:>6} {y:>6} {:>7} {:>7} {s}{mark}",
b.elements
.iter()
.find(|e| &e.name == n)
.map(|e| e.pivot_x)
.unwrap_or(0),
b.elements
.iter()
.find(|e| &e.name == n)
.map(|e| e.pivot_y)
.unwrap_or(0)
);
}
}

View File

@@ -0,0 +1,48 @@
//! 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 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");
}

View File

@@ -0,0 +1,92 @@
//! Where does `screen --build N`'s ORDINAL diverge from the pak ENTRY index?
//!
//! `screen render --build N` takes an ordinal into the filtered build list, not
//! a pak entry. On `GP_TITLE` `[10]` is entry 12, which is how I rendered two
//! loading screens while believing they were the splashes — and every downstream
//! number validated. This enumerates the divergence across the disc so any
//! `--build N` in `docs/` can be checked instead of trusted.
//!
//! Two lists, because `screen list --all` swaps the predicate (`is_composable`
//! for `is_build`) and therefore RENUMBERS: `--build 4` and `--build 4 --all`
//! are not necessarily the same object.
//!
//! cargo run -p sylpheed-formats --example ordinal_entry_map
use std::io::Write;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
/// One decompression pass per entry, both predicates applied to it: reading the
/// archive twice doubled the cost on `GP_READY_ROOM` (902 entries) for nothing.
fn maps(ar: &PakArchive) -> (Vec<usize>, Vec<usize>) {
let (mut d, mut a) = (Vec::new(), Vec::new());
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if ui_layout::is_build(&by) {
d.push(i)
}
if ui_layout::is_composable(&by) {
a.push(i)
}
}
(d, a)
}
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 clean, mut div, mut allshift) = (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();
let (d, a) = maps(&ar);
if d.is_empty() && a.is_empty() {
continue;
}
let bad = d.iter().enumerate().find(|(o, &e)| *o != e).map(|(o, _)| o);
// does `--all` renumber? compare the entry each ordinal resolves to
let shift = (0..d.len().min(a.len())).find(|&o| d[o] != a[o]);
let tag = match bad {
None => {
clean += 1;
format!("{:4} builds ordinal == entry throughout", d.len())
}
Some(o) => {
div += 1;
let t: Vec<String> = d
.iter()
.enumerate()
.skip(o)
.take(5)
.map(|(x, &y)| format!("[{x}]->{y}"))
.collect();
format!(
"{:4} builds 🔴 diverges at ordinal {o}: {}",
d.len(),
t.join(" ")
)
}
};
let s = match shift {
Some(o) => {
allshift += 1;
format!(
" ⚠️ --all renumbers from [{o}]: entry {} -> {}",
d[o], a[o]
)
}
None if a.len() != d.len() => format!(" (--all appends {} more)", a.len() - d.len()),
None => String::new(),
};
println!("{name:30} {tag}{s}");
std::io::stdout().flush().ok();
}
println!("\n{clean} archives ordinal==entry, {div} diverge, {allshift} renumbered by --all");
println!("--- END ---");
}

View File

@@ -17,7 +17,7 @@ fn be16(b: &[u8], at: usize) -> u32 {
/// folded to `max(na, 1-na)` so both authored windings read as ≈1.
fn winding(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> f32 {
let (mut agree, mut n) = (0usize, 0usize);
for t in idx.chunks_exact(3) {
for t in idx.as_chunks::<3>().0 {
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
if a == b || b == c || a == c || a.max(b).max(c) >= pos.len() || a >= nrm.len() {
continue;
@@ -51,7 +51,9 @@ fn winding(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> f32 {
}
fn degenerate(idx: &[u32]) -> usize {
idx.chunks_exact(3)
idx.as_chunks::<3>()
.0
.iter()
.filter(|t| t[0] == t[1] || t[1] == t[2] || t[0] == t[2])
.count()
}

View File

@@ -0,0 +1,65 @@
//! Are `palogo_gamearts_eff` / `palogo_seta_eff` dwell-FALLBACK cases, or PLATEAU
//! cases? The port agent lists them among `GP_TITLE`'s four visible fallback
//! fires; this census listed only `palogo_sqex_eff` and `palogo_anima_eff`.
//!
//! It matters because the two are different defects. A plateau is a pose the
//! element genuinely HOLDS, and `rest_plateau()` returning it is correct. Only the
//! fallback is the unsound path.
//! cargo run -p sylpheed-formats --example palogo_eff_check
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 ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
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 !el.name.starts_with("palogo") || !el.name.contains("eff") {
continue;
}
// A single-keyframe element has no gap to maximise, so neither path
// applies and `rest()` trivially returns the only pose. Excluding it
// here matches the census, which filters `len < 2`.
if el.keyframes.len() < 2 {
continue;
}
let plateau = el.keyframes.windows(2).position(|w| {
w[0].x == w[1].x
&& w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade
});
let ks: Vec<String> = el
.keyframes
.iter()
.map(|k| {
format!(
"{}:a{} {},{} {}%",
k.time.map(|v| v.to_string()).unwrap_or("-".into()),
(k.fade >> 24) & 0xff,
k.x,
k.y,
k.scale_x
)
})
.collect();
let r = el.rest();
println!("e{i:<3} {:24} kf=[{}]", el.name, ks.join(" "));
println!(
" plateau at pair {:?} -> path: {} rest a={} t={:?}",
plateau,
if plateau.is_some() {
"PLATEAU (sound: the pose is held)"
} else {
"DWELL FALLBACK (unsound)"
},
r.map(|k| (k.fade >> 24) & 0xff).unwrap_or(0),
r.and_then(|k| k.time)
);
}
}
}

View File

@@ -0,0 +1,133 @@
//! When an element has MORE THAN ONE plateau, does `rest_plateau()` pick the
//! wrong one — and is that the 21.9 % residual?
//!
//! `rest_vs_settle` found that among elements holding a pose ACROSS the screen's
//! settle instant, `pose_at(settle)` and `rest()` still disagree 21.9 % of the
//! time. I hypothesised that `rest_plateau()` picks the **longest** run (it does —
//! `len >= any_len`), which need not be the run covering the settle instant.
//!
//! ⚠️ **Control**: on elements with exactly ONE plateau that covers the settle
//! instant, the two MUST agree. If they do not, the hypothesis is not the
//! explanation and something else is wrong.
//!
//! cargo run -p sylpheed-formats --example plateau_choice
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 one_cov, mut one_agree) = (0usize, 0usize); // control
let (mut multi_cov, mut multi_agree, mut multi_wrongrun) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else {
continue;
};
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let Some((lo, hi)) = b.settle_window() else {
continue;
};
if hi - lo < 10 {
continue;
}
let st = lo + (hi - lo) / 2;
for el in &b.elements {
let k = &el.keyframes;
if k.len() < 2 {
continue;
}
let same = |a: &ui_layout::Keyframe, c: &ui_layout::Keyframe| {
a.fade == c.fade
&& a.scale_x == c.scale_x
&& a.scale_y == c.scale_y
&& a.tint == c.tint
&& a.x == c.x
&& a.y == c.y
};
// enumerate maximal runs of length >= 2, with their time spans
let mut runs: Vec<(usize, usize)> = Vec::new();
let mut i = 0usize;
while i < k.len() {
let mut j = i;
while j + 1 < k.len() && same(&k[j], &k[j + 1]) {
j += 1
}
if j - i + 1 >= 2 {
runs.push((i, j))
}
i = j + 1;
}
if runs.is_empty() {
continue;
}
let covers = |&(a, c): &(usize, usize)| match (k[a].time, k[c].time) {
(Some(t0), Some(t1)) => t0 <= st && st <= t1,
_ => false,
};
let covering: Vec<_> = runs.iter().filter(|r| covers(r)).collect();
if covering.is_empty() {
continue;
}
let (Some(r), Some(s)) = (el.rest(), el.pose_at(st)) else {
continue;
};
let agree = r.fade == s.fade
&& r.x == s.x
&& r.y == s.y
&& r.scale_x == s.scale_x
&& r.scale_y == s.scale_y;
if runs.len() == 1 {
one_cov += 1;
if agree {
one_agree += 1
}
} else {
multi_cov += 1;
if agree {
multi_agree += 1
} else {
// did rest() land on a run that does NOT cover settle?
let on_covering = covering.iter().any(|&&(a, c)| {
(a..=c).any(|idx| {
let kk = &k[idx];
kk.fade == r.fade
&& kk.x == r.x
&& kk.y == r.y
&& kk.scale_x == r.scale_x
&& kk.scale_y == r.scale_y
})
});
if !on_covering {
multi_wrongrun += 1
}
}
}
}
}
}
println!("CONTROL — exactly ONE plateau, and it covers the settle instant:");
println!(
" {one_cov} elements, rest() and pose_at(settle) agree on {one_agree} ({:.1} %)",
100.0 * one_agree as f64 / one_cov.max(1) as f64
);
println!("\nTEST — MORE THAN ONE plateau, at least one covering the settle instant:");
println!(
" {multi_cov} elements, agree on {multi_agree} ({:.1} %)",
100.0 * multi_agree as f64 / multi_cov.max(1) as f64
);
println!(
" of the {} disagreements, rest() landed on a run that does NOT cover",
multi_cov - multi_agree
);
println!(" the settle instant: {multi_wrongrun}");
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -23,11 +23,11 @@ fn main() {
if tok == &key {
let lo = i.saturating_sub(6);
let hi = (i + 7).min(t.len());
for j in lo..hi {
for (j, tok_j) in t.iter().enumerate().skip(lo).take(hi - lo) {
println!(
" [{j}]{} {:?}",
if j == i { " <-- key" } else { " " },
t[j]
tok_j
);
}
println!();

View File

@@ -0,0 +1,77 @@
//! 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 std::collections::BTreeMap;
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();
// 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(" "));
}
}

View File

@@ -0,0 +1,52 @@
//! 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 std::collections::BTreeMap;
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 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(" "));
}
}

View File

@@ -0,0 +1,95 @@
//! 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}");
}
}

View File

@@ -0,0 +1,138 @@
//! 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}")
);
}
}
}
}

View File

@@ -0,0 +1,144 @@
//! 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 || (el.pivot_y * 2) < b.design_h {
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}")
}
}

View File

@@ -0,0 +1,92 @@
//! The FULL extent of the two title sweep leaves, across their whole cycle.
//!
//! `ptloop_leaf_sweep_at.rs` samples t=340..540 — a window chosen to compare two
//! competing fits — so it never showed how far the leaves travel. That gap let a
//! claim stand that `ptloop01/02` "do not free-run", measured over the PARENT's
//! 200x90 rect, which is a pivot anchor the leaf spends almost no time inside.
//! `sylpheed-port` reports x tracks of -639..1521 and -839..1721 from their
//! export; this checks that against the disc.
//!
//! cargo run -p sylpheed-formats --example ptloop_leaf_extent
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 ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for entry in [4usize, 5, 7] {
let Ok(by) = ar.read(&ar.entries()[entry]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
println!("\n######## GP_TITLE entry {entry} ########");
for parent in ["ptloop01", "ptloop02"] {
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else {
println!(" {parent}: not present in this build");
continue;
};
let Some(&(off, size)) = b.records.get(&el.name) else {
println!(" {}: no nested record", el.name);
continue;
};
let span = u32::from_be_bytes(by[off + 8..off + 12].try_into().unwrap());
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else {
println!(" {}: leaf will not parse", el.name);
continue;
};
println!(
" {} parent rest ({},{}) nested cycle span {span}",
el.name,
el.keyframes.last().map(|k| k.x).unwrap_or(0),
el.keyframes.last().map(|k| k.y).unwrap_or(0)
);
for le in &lb.elements {
let (mut lo, mut hi) = (i64::MAX, i64::MIN);
let (mut sxs, mut sys) = (Vec::new(), Vec::new());
for t in 0..=span {
if let Some(k) = le.pose_at(t) {
lo = lo.min(k.x as i64);
hi = hi.max(k.x as i64);
if !sxs.contains(&k.scale_x) {
sxs.push(k.scale_x)
}
if !sys.contains(&k.scale_y) {
sys.push(k.scale_y)
}
}
}
// A CYCLE LENGTH IS NOT A MOTION DURATION. Find the last t at
// which x still changes: sylpheed-port reports the final segment
// HOLDS, which would make px/unit larger than cycle-based maths.
let mut last_move = 0u32;
let mut prev = None;
for t in 0..=span {
if let Some(k) = le.pose_at(t) {
if prev.is_some_and(|p| p != k.x) {
last_move = t
}
prev = Some(k.x);
}
}
let w = (le.pivot_x * 2) as i64;
println!(
" leaf {:<12} pivot {}x{} quad w={w} x track {lo} .. {hi} \
(centre {} .. {}) scale_x {:?} scale_y {:?}",
le.name,
le.pivot_x,
le.pivot_y,
lo + le.pivot_x as i64,
hi + le.pivot_x as i64,
sxs,
sys
);
println!(" motion ends at t={last_move} of a {span}-unit cycle -> {:.3} px/unit over the MOVING span (vs {:.3} over the cycle)",
(hi - lo) as f64 / last_move.max(1) as f64,
(hi - lo) as f64 / span as f64);
}
}
}
println!("--- END ---");
}

View File

@@ -0,0 +1,49 @@
//! The sweep leaves' RAW keyframes, so a segment rate can be checked not assumed.
//!
//! `sylpheed-port` reports `pteff03` as +4.0000 px/unit over t 0..150 and +4.0000
//! again over 150..540 -- perfectly linear -- against `pteff03a` at -4.0667 then
//! -4.0625. That asymmetry is what makes their "inversion" observation sharp: my
//! linearity gate fails on the leaf whose source is exactly straight. It is their
//! number from their export, so it is worth deriving independently.
//!
//! cargo run -p sylpheed-formats --example ptloop_leaf_keyframes
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 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 {
continue;
};
let Some(&(off, size)) = b.records.get(&el.name) else {
continue;
};
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else {
continue;
};
for le in &lb.elements {
println!("\n== {} -> leaf {}", el.name, le.name);
let ks: Vec<_> = le.keyframes.iter().collect();
for w in ks.windows(2) {
let (a, c) = (w[0], w[1]);
match (a.time, c.time) {
(Some(t0), Some(t1)) if t1 > t0 => println!(
" t {t0:>4} -> {t1:<4} x {:>6} -> {:<6} = {:+.4} px/unit",
a.x,
c.x,
(c.x - a.x) as f64 / (t1 - t0) as f64
),
_ => println!(
" t {:?} -> {:?} x {} -> {} (no rate)",
a.time, c.time, a.x, c.x
),
}
}
}
}
println!("--- END ---");
}

View File

@@ -0,0 +1,94 @@
//! 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 {
""
}
);
}
}
}
}

View File

@@ -0,0 +1,43 @@
//! The PARENT elements that host the sweep leaves — `ptloop01`/`ptloop02` as
//! they are declared in the title build itself, not in their nested records.
//!
//! The leaves' own alpha is non-zero at their t=0 (`pteff03` declares 255), yet
//! the capture shows the sweep fading in from ~8. So the gate is the parent's
//! alpha, and this prints it.
//!
//! cargo run -p sylpheed-formats --example ptloop_parent_keyframes -- GP_TITLE 5
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 argv: Vec<String> = std::env::args().skip(1).collect();
let pak = argv
.iter()
.find(|a| a.parse::<usize>().is_err())
.cloned()
.unwrap_or_else(|| "GP_TITLE".to_string());
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
let builds: Vec<usize> = argv.iter().filter_map(|a| a.parse().ok()).collect();
for e in if builds.is_empty() {
vec![5usize]
} else {
builds
} {
let Ok(by) = ar.read(&ar.entries()[e]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
println!("=== {pak} entry {e}: {} elements ===", b.elements.len());
for el in &b.elements {
for (i, k) in el.keyframes.iter().enumerate() {
println!(" {:<20} kf{i:<2} t={:<5} x={:<6} y={:<6} sx={:<4} sy={:<4} fade={:08X} (alpha {:3})",
el.name,
k.time.map(|t| t.to_string()).unwrap_or_else(|| "-".into()),
k.x, k.y, k.scale_x, k.scale_y, k.fade, k.fade >> 24);
}
}
}
}

View File

@@ -106,7 +106,7 @@ fn main() {
if let Some(out) = std::env::args().nth(2) {
// Dependency-free PPM (P6, RGB — alpha already composited over the backdrop).
let mut buf = format!("P6\n{} {}\n255\n", screen.width, screen.height).into_bytes();
for px in screen.rgba.chunks_exact(4) {
for px in screen.rgba.as_chunks::<4>().0 {
buf.extend_from_slice(&px[..3]);
}
std::fs::write(&out, buf).unwrap();

View File

@@ -0,0 +1,113 @@
//! 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 std::collections::BTreeMap;
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 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}");
}
}
}

View File

@@ -0,0 +1,92 @@
//! Verify the newly-public `ui_layout::loop_length_units` against the disc.
//!
//! `sylpheed-port` reads a record's `+0x08` itself, guarded on the RATC magic,
//! because the field was exposed on no public ref at all — example, test and
//! `docs/re/` only. This checks the public function reproduces the numbers the
//! finding was written from before the port depends on it.
//!
//! CONTROL FIRST: the function must return `None` for a non-RATC slice and for a
//! slice too short to hold the field. An accessor that returns a number for
//! anything cannot be trusted to return the right one.
//!
//! cargo run -p sylpheed-formats --example record_loop_length_api
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
// ---- controls -------------------------------------------------------
assert_eq!(
ui_layout::loop_length_units(b"NOTR\x00\x00\x00\x00\x00\x00\x00\x78"),
None,
"control FAILED: accepted a non-RATC slice"
);
assert_eq!(
ui_layout::loop_length_units(b"RATC\x00\x00"),
None,
"control FAILED: accepted a slice too short for +0x08"
);
assert_eq!(
ui_layout::loop_length_units(b"RATC\x00\x00\x00\x00\x00\x00\x00\x78"),
Some(120),
"control FAILED: did not read +0x08 big-endian"
);
println!("controls pass: rejects non-RATC, rejects short, reads BE at +0x08");
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");
// The records the finding names, with their published values.
let expect: &[(&str, u32)] = &[
("ptbtn00f.rat", 120),
("ptloop01.rat", 600),
("ptloop02.rat", 720),
];
let mut seen = 0usize;
let (mut recs, mut viol) = (0usize, 0usize);
for (ei, 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 (name, (off, size)) in &b.records {
let rec = &by[*off..(*off + *size).min(by.len())];
let Some(len) = ui_layout::loop_length_units(rec) else {
continue;
};
recs += 1;
// the disc-wide invariant the finding rests on
let largest = ui_layout::parse_build(rec)
.map(|l| {
l.elements
.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max()
.unwrap_or(0)
})
.unwrap_or(0);
if len < largest {
viol += 1;
}
for (want_name, want) in expect {
if name == want_name && seen < 16 {
seen += 1;
let ok = if len == *want { "OK" } else { "MISMATCH" };
println!(
" entry {ei:2} {name:16} +0x08 = {len:4} \
(published {want}) largest kf {largest:4} {ok}"
);
assert_eq!(len, *want, "{name} disagrees with the published value");
}
}
}
}
println!(
"\n{recs} records read through the public fn; \
{viol} violate +0x08 >= largest keyframe time"
);
assert!(
seen > 0,
"found none of the named records — the check proved nothing"
);
}

View File

@@ -0,0 +1,99 @@
//! Does "1 697 fallback fires return a visible pose" survive being said out loud?
//!
//! `rest-fallback-census.txt` reports that of 2 305 elements where the dwell
//! fallback decides, 1 697 rest at `alpha > 0`. It was written as if that number
//! were the defect. **It is only a defect where the element is a transient.** An
//! element that genuinely ends visible and stays visible SHOULD rest visible, and
//! the fallback happening to be the path that got there is not an error.
//!
//! The port agent hit the mirror image of this: it counted a screen's own exit
//! ramp as the end of an element's visibility, so `ptmsg` — the main menu's
//! permanent footer — came out as "a 2-unit flash". The story collapsed when
//! said aloud. This asks the same question of my number.
//!
//! Split the 1 697 by what the element's LAST keyframe does:
//! * last alpha > 0 -> the element ends visible; resting visible is right
//! * last alpha == 0 -> it fades out; a visible rest is a transient's peak
//!
//! cargo run -p sylpheed-formats --example rest_fallback_audit
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 fires, mut vis, mut ends_visible, mut ends_zero, mut at_peak) = (0, 0, 0, 0, 0);
// ⚠️ The port agent's exit-ramp finding applies to THIS split too: if a
// screen's exit ramp drives every element to a=0, then "last keyframe a=0"
// says nothing about the element being a transient. Measure it on ALL
// elements before using it on the 1 697.
let (mut all_el, mut all_end_zero) = (0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else {
continue;
};
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for el in &b.elements {
if el.keyframes.len() < 2 {
continue;
}
all_el += 1;
if (el.keyframes.last().unwrap().fade >> 24) & 0xff == 0 {
all_end_zero += 1
}
if el.keyframes.windows(2).any(|w| {
w[0].x == w[1].x
&& w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade
}) {
continue;
}
fires += 1;
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
if a == 0 {
continue;
}
vis += 1;
let last = (el.keyframes.last().unwrap().fade >> 24) & 0xff;
if last > 0 {
ends_visible += 1
} else {
ends_zero += 1
}
let peak = el
.keyframes
.iter()
.map(|k| (k.fade >> 24) & 0xff)
.max()
.unwrap_or(0);
if a == peak {
at_peak += 1
}
}
}
}
println!("fallback fires {fires}");
println!(" of those, rest alpha > 0 {vis}");
println!(" element's LAST keyframe alpha > 0 {ends_visible} <- ends visible; resting visible is CORRECT");
println!(" element's LAST keyframe alpha = 0 {ends_zero} <- fades out; a visible rest is a transient's peak");
println!(" rest alpha == the element's MAX {at_peak}");
println!("\nCONTROL on the split itself — is 'ends at a=0' near-universal?");
println!(" all elements with >= 2 keyframes {all_el}");
println!(
" of those, last keyframe alpha = 0 {all_end_zero} ({:.1} %)",
100.0 * all_end_zero as f64 / all_el as f64
);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -0,0 +1,91 @@
//! When the resting-pose DWELL FALLBACK actually runs, does it pick a visible pose?
//!
//! `ui-resting-pose.md` argues the fallback is structurally unsound — the gap it
//! maximises is time spent *interpolating*, so neither endpoint is held. Its one
//! worked example, `GP_TITLE` build 7's `ptlogo_eff3.t32`, **no longer
//! discriminates**: under the corrected keyframe-record layout the longest gap
//! moved from `61→103` to `0→46`, and both ends of that are `a = 0`. The page's
//! listing still shows the stale parser's trailing `-`.
//!
//! Losing the example is not the same as closing the question, so: disc-wide, how
//! often does the fallback fire, and when it does, does it return something the
//! player would see? An element resting at `a = 0` is harmless whichever end the
//! rule lands on.
//!
//! cargo run -p sylpheed-formats --example rest_fallback_census
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 elements, mut plateau, mut fallback, mut fb_visible) = (0usize, 0, 0, 0);
let mut worst: Vec<(u32, String, String)> = Vec::new();
let mut per_pak: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
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 el.keyframes.len() < 2 {
continue;
}
elements += 1;
// a plateau is two ADJACENT poses that are equal — the same test
// the plateau path makes before the fallback can run
let has_plateau = el.keyframes.windows(2).any(|w| {
w[0].x == w[1].x
&& w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade
});
if has_plateau {
plateau += 1;
continue;
}
fallback += 1;
per_pak.entry(name.clone()).or_default().0 += 1;
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
if a > 0 {
fb_visible += 1;
per_pak.entry(name.clone()).or_default().1 += 1;
worst.push((a, name.clone(), format!("e{i}/{}", el.name)));
}
}
}
}
println!(
"POPULATION: {elements} elements with >= 2 keyframes, over {} archives",
paks.len()
);
println!("COVERAGE: {plateau} have a plateau (fallback never runs)");
println!(" {fallback} have NONE -> the dwell fallback decides");
println!(" {fb_visible} of those rest at alpha > 0 -- i.e. VISIBLE\n");
println!("PER ARCHIVE — fallback fires / of those, rests VISIBLE:");
let mut rows: Vec<_> = per_pak.into_iter().collect();
rows.sort_by_key(|a| std::cmp::Reverse(a.1 .1));
for (pak, (fires, vis)) in &rows {
println!(" {pak:34} {fires:5} fires {vis:5} visible");
}
println!();
worst.sort_by_key(|a| std::cmp::Reverse(a.0));
for (a, pak, el) in worst.iter().take(6) {
println!(" a={a:3} {pak} {el}");
}
println!("\n--- END OF CENSUS (if this line is missing, the run did not finish) ---");
}

View File

@@ -0,0 +1,49 @@
//! Which `GP_TITLE` elements does the resting-pose dwell fallback decide, and does
//! it hand back a visible pose? The disc-wide census says 5 fires / 4 visible here.
//! cargo run -p sylpheed-formats --example rest_fallback_title
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 ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
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 el.keyframes.len() < 2 {
continue;
}
if el.keyframes.windows(2).any(|w| {
w[0].x == w[1].x
&& w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade
}) {
continue;
}
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
let ks: Vec<String> = el
.keyframes
.iter()
.map(|k| {
format!(
"{}:a{}",
k.time.map(|v| v.to_string()).unwrap_or("-".into()),
(k.fade >> 24) & 0xff
)
})
.collect();
println!(
"entry {i:2} {:24} rest a={a:3} t={:?} [{}]{}",
el.name,
r.time,
ks.join(" "),
if a > 0 { " <== VISIBLE" } else { "" }
);
}
}
}

View File

@@ -0,0 +1,62 @@
//! The resting SCALE of each element, so a drawn quad's size can be predicted.
//!
//! One additive draw on both the main menu and `EXTRAS` measures 819.2 x 720 px
//! and matches no sprite at 1x or 2x. Scale is the missing factor: the keyframe
//! carries scale_x / scale_y in percent, and a sprite drawn at 200 % x 500 % is
//! nothing like its stored size.
//!
//! cargo run -p sylpheed-formats --example rest_scale_of -- 5 6 4
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 argv: Vec<String> = std::env::args().skip(1).collect();
let pak = argv
.iter()
.find(|a| a.parse::<usize>().is_err())
.cloned()
.unwrap_or_else(|| "GP_TITLE".to_string());
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
let builds: Vec<usize> = argv.iter().filter_map(|a| a.parse().ok()).collect();
for build in if builds.is_empty() {
vec![5usize, 6]
} else {
builds
} {
let Ok(by) = ar.read(&ar.entries()[build]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
println!("=== {pak} entry {build} ===");
println!(
"{:<22} {:>10} {:>7} {:>7} {:>12} at 1x/2x of pivot*2",
"element", "pivot(w,h)", "sx%", "sy%", "drawn px"
);
for e in &b.elements {
let k = match e.rest() {
Some(k) => k,
None => continue,
};
let w = e.pivot_x * 2;
let h = e.pivot_y * 2;
let dw = w as f64 * k.scale_x as f64 / 100.0;
let dh = h as f64 * k.scale_y as f64 / 100.0;
println!(
"{:<22} {:>4},{:<5} {:>7} {:>7} {:>6.1}x{:<5.1} {:>6.1}x{:<5.1}",
e.name,
w,
h,
k.scale_x,
k.scale_y,
dw,
dh,
dw * 2.0,
dh * 2.0
);
}
println!();
}
}

View File

@@ -0,0 +1,134 @@
//! Should the settled pose come from each element's `rest()`, or from the
//! **screen's** settle instant?
//!
//! Three iterations have measured how badly `rest()`'s dwell fallback behaves —
//! 2 305 elements where it decides, 1 457 of them handed the element's *maximum*
//! alpha, and by construction none of those poses is held. What has been missing
//! is a proposal.
//!
//! `UiBuild::settle_time()` already exists: the midpoint of the longest
//! keyframe-free interval **across the whole build**. That is the port agent's
//! "re-key on the screen's span rather than the element's", and its shipped path
//! poses `pose_at(hold)` and agrees with every capture it holds at 0.01 %.
//!
//! ⚠️ **Control first.** On elements where `rest()` is already sound — the plateau
//! path, a pose the element genuinely holds — `pose_at(settle)` must AGREE. If it
//! disagrees there, it is not a better rule, it is a different one.
//!
//! ⚠️ `ui-settle-time.md` records that 42 % of bundles have a settle window under
//! 10 units and never settle at all. Bundles are split on that here rather than
//! averaged over.
//!
//! cargo run -p sylpheed-formats --example rest_vs_settle
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();
// control population (plateau) and test population (fallback), each split by
// whether the bundle settles at all
let (mut ctl_n, mut ctl_cov, mut ctl_agree) = (0usize, 0usize, 0usize);
let (mut fb_n, mut fb_rest_vis, mut fb_settle_vis) = (0usize, 0usize, 0usize);
let mut narrow = 0usize;
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else {
continue;
};
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let Some((lo, hi)) = b.settle_window() else {
continue;
};
if hi - lo < 10 {
narrow += 1;
continue;
} // this bundle never settles
let st = lo + (hi - lo) / 2;
for el in &b.elements {
if el.keyframes.len() < 2 {
continue;
}
let plateau = el.keyframes.windows(2).any(|w| {
w[0].x == w[1].x
&& w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade
});
let (Some(r), Some(s)) = (el.rest(), el.pose_at(st)) else {
continue;
};
let (ra, sa) = ((r.fade >> 24) & 0xff, (s.fade >> 24) & 0xff);
if plateau {
// ⚠️ The first version of this control compared EVERY plateau
// element and got 46.6 % agreement — then I asked what that
// means physically. `rest()` finds *a* held pose; many
// elements hold one during the build-in and then move on.
// `pose_at(settle)` asks what is on screen WHEN THE SCREEN HAS
// SETTLED. Those are different questions, so disagreement
// proves nothing. The fair control is the subset where the
// held interval actually CONTAINS the settle instant.
ctl_n += 1;
let covers = el.keyframes.windows(2).any(|w| {
let held = w[0].x == w[1].x
&& w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade;
match (w[0].time, w[1].time) {
(Some(a), Some(bb)) => held && a <= st && st <= bb,
_ => false,
}
});
if covers {
ctl_cov += 1;
if ra == sa
&& r.x == s.x
&& r.y == s.y
&& r.scale_x == s.scale_x
&& r.scale_y == s.scale_y
{
ctl_agree += 1
}
}
} else {
fb_n += 1;
if ra > 0 {
fb_rest_vis += 1
}
if sa > 0 {
fb_settle_vis += 1
}
}
}
}
}
println!("bundles skipped as never-settling (window < 10 units): {narrow}\n");
println!("CONTROL — elements where rest() takes the SOUND plateau path:");
println!(" {ctl_n} plateau elements in settling bundles");
println!(" {ctl_cov} of them HOLD ACROSS the settle instant — the fair control");
println!(
" pose_at(settle) agrees with rest() on {ctl_agree} of those ({:.1} %)",
100.0 * ctl_agree as f64 / ctl_cov.max(1) as f64
);
println!("\nTEST — elements where the unsound dwell fallback decides:");
println!(" {fb_n} elements");
println!(
" rest() returns a VISIBLE pose on {fb_rest_vis} ({:.1} %)",
100.0 * fb_rest_vis as f64 / fb_n.max(1) as f64
);
println!(
" pose_at(settle) returns a VISIBLE pose on {fb_settle_vis} ({:.1} %)",
100.0 * fb_settle_vis as f64 / fb_n.max(1) as f64
);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -0,0 +1,60 @@
//! 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 std::collections::BTreeMap;
use sylpheed_formats::{pak, ui_layout};
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%");
}

View File

@@ -0,0 +1,111 @@
//! How often does posing at the SCREEN's settle instant catch an element
//! mid-ramp? The adversarial census of my own proposal.
//!
//! The port agent found `ptmsg` — the main menu's footer — at alpha **127.5 of
//! 255** at that screen's settle instant, because the longest keyframe-free
//! interval ends exactly as the footer starts to arrive. `screen render --settle`
//! already prints "⚠️ narrow — this bundle may never settle" there: the window is
//! **12 units**.
//!
//! ⚠️ **And my `rest_vs_settle` filter was too permissive**: it dropped bundles
//! with a window under 10 units, so a 12-unit window passed while the tool itself
//! was flagging it. This splits by width instead of picking one cutoff.
//!
//! "Mid-ramp" = at the settle instant the element sits strictly inside an interval
//! whose two endpoint poses DIFFER — it is interpolating, not held.
//!
//! cargo run -p sylpheed-formats --example settle_midramp_census
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();
// buckets by settle-window width
let edges = [0u32, 10, 20, 30, 60, u32::MAX];
let names = ["< 10", "1019", "2029", "3059", ">= 60"];
let mut els = [0usize; 5];
let mut mid = [0usize; 5];
let mut bundles = [0usize; 5];
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else {
continue;
};
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let Some((lo, hi)) = b.settle_window() else {
continue;
};
let w = hi - lo;
let bi = edges
.windows(2)
.position(|p| w >= p[0] && w < p[1])
.unwrap_or(4);
bundles[bi] += 1;
let st = lo + w / 2;
for el in &b.elements {
let k = &el.keyframes;
if k.len() < 2 {
continue;
}
els[bi] += 1;
// the interval containing the settle instant
let mut interpolating = false;
for pair in k.windows(2) {
if let (Some(t0), Some(t1)) = (pair[0].time, pair[1].time) {
if t0 <= st && st <= t1 && t0 != t1 {
let same = pair[0].fade == pair[1].fade
&& pair[0].x == pair[1].x
&& pair[0].y == pair[1].y
&& pair[0].scale_x == pair[1].scale_x
&& pair[0].scale_y == pair[1].scale_y;
if !same && st != t0 && st != t1 {
interpolating = true
}
break;
}
}
}
if interpolating {
mid[bi] += 1
}
}
}
}
println!(
"{:8}{:>10}{:>10}{:>12}{:>10}",
"window", "bundles", "elements", "mid-ramp", "share"
);
for i in 0..5 {
if els[i] == 0 {
continue;
}
println!(
"{:8}{:>10}{:>10}{:>12}{:>9.1}%",
names[i],
bundles[i],
els[i],
mid[i],
100.0 * mid[i] as f64 / els[i] as f64
);
}
let te: usize = els.iter().sum();
let tm: usize = mid.iter().sum();
println!(
"{:8}{:>10}{:>10}{:>12}{:>9.1}%",
"ALL",
bundles.iter().sum::<usize>(),
te,
tm,
100.0 * tm as f64 / te as f64
);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -0,0 +1,80 @@
//! What share of bundles have a NARROW settle window -- and of WHICH bundles?
//!
//! `screen render --settle`'s help says "a narrow one means the bundle never
//! settles (42 % of them, mostly `loop*` fragments)". The 42 % is correct and is
//! stated precisely in ui-settle-time.md: 731 of **1 758 composable bundles
//! carrying two or more keyframe times**. But inside `screen render`, "them"
//! reads as the bundles you would render -- the SCREEN BUILDS -- which is a
//! different and much smaller population. This computes both.
//!
//! cargo run -p sylpheed-formats --example settle_narrow_rate
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();
// (with >=2 keyframe times, narrow) for each population
let (mut b2, mut bn) = (0usize, 0usize); // screen builds (is_build)
let (mut c2, mut cn) = (0usize, 0usize); // composable (is_composable)
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else {
continue;
};
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let is_b = ui_layout::is_build(&by);
let is_c = ui_layout::is_composable(&by);
if !is_b && !is_c {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
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 narrow = match b.settle_window() {
Some((lo, hi)) => hi - lo < 10,
None => true,
};
if is_b {
b2 += 1;
if narrow {
bn += 1
}
}
if is_c {
c2 += 1;
if narrow {
cn += 1
}
}
}
}
println!("population n narrow (<10 u) share");
println!("SCREEN BUILDS (is_build, what `screen render` renders by default)");
println!(
" {b2:5} {bn:9} {:.0} %",
100.0 * bn as f64 / b2.max(1) as f64
);
println!("COMPOSABLE bundles (is_composable, what --all admits)");
println!(
" {c2:5} {cn:9} {:.0} %",
100.0 * cn as f64 / c2.max(1) as f64
);
println!("\nui-settle-time.md quotes 731 / 1758 = 42 % over composable bundles.");
println!("--- END ---");
}

View File

@@ -0,0 +1,49 @@
// 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);
}
}

View File

@@ -0,0 +1,39 @@
//! Where does `settle_window()`'s answer come from? The port agent recomputes the
//! publisher splash's widest keyframe-free gap as 190 units; `--settle` reports 8.
//! One of the two readings is wrong and the file settles it.
//! cargo run -p sylpheed-formats --example settle_window_check -- <build>
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let b: usize = std::env::args()
.nth(1)
.unwrap_or("10".into())
.parse()
.unwrap();
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");
let by = ar.read(&ar.entries()[b]).expect("entry");
let build = ui_layout::parse_build(&by).expect("parse");
println!("entry {b}: {} elements", build.elements.len());
for el in &build.elements {
let ts: Vec<String> = el
.keyframes
.iter()
.map(|k| k.time.map(|v| v.to_string()).unwrap_or("-".into()))
.collect();
println!(" {:26} [{}]", el.name, ts.join(" "));
}
let mut ts: Vec<u32> = build
.elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
.collect();
ts.sort_unstable();
ts.dedup();
println!("\nunion of all element keyframe times: {ts:?}");
let gaps: Vec<(u32, u32, u32)> = ts.windows(2).map(|w| (w[1] - w[0], w[0], w[1])).collect();
let mut g = gaps.clone();
g.sort_by_key(|a| std::cmp::Reverse(a.0));
println!("widest gaps: {:?}", &g[..g.len().min(4)]);
println!("settle_window() reports {:?}", build.settle_window());
}

View File

@@ -38,7 +38,7 @@ fn main() {
hi[k] = hi[k].max(v[k]);
}
}
for t in sub.indices.chunks_exact(3) {
for t in sub.indices.as_chunks::<3>().0 {
tris.push([w[t[0] as usize], w[t[1] as usize], w[t[2] as usize]]);
}
}

View File

@@ -61,7 +61,7 @@ fn main() {
.or_default()
.push((m.name.clone(), thin));
}
for (id, parts) in &by_ship {
for parts in by_ship.values() {
if parts.len() < 3 {
continue; // no meaningful median
}

View File

@@ -18,7 +18,7 @@ fn main() {
};
has_riff += 1;
if ri > slb::HEADERLESS_DATA_OFFSET
&& (ri - slb::HEADERLESS_DATA_OFFSET) % slb::XMA1_PACKET == 0
&& (ri - slb::HEADERLESS_DATA_OFFSET).is_multiple_of(slb::XMA1_PACKET)
{
hybrid += 1;
if b[slb::HEADERLESS_DATA_OFFSET..ri].iter().any(|x| *x != 0) {

View File

@@ -0,0 +1,93 @@
//! F2 — is a per-cue or per-bus GAIN on the disc?
//!
//! The port has no gain value anywhere in its export: `confirm` peaks at
//! 0.0 dBFS and sits 3 dB above the music. A cue record commonly carries a
//! volume beside its wave index. This asks the disc directly rather than
//! choosing a number.
//!
//! Method: dump every token of every `tables.pak` object whose tokens mention
//! SOUND/BANK/SE/BGM, so a gain field would appear as a token if one exists.
//! ⚠️ A NEGATIVE here is only as good as its coverage, so this prints the token
//! count per object and does not filter — a field missed by a filter would read
//! exactly like a field that is not there.
//!
//! cargo run --release -p sylpheed-formats --example sound_cue_fields -- $SYLPHEED_DISC
use sylpheed_formats::{idxd::IdxdObject, pak::PakArchive};
fn main() {
let a: Vec<String> = std::env::args().collect();
let root = a
.get(1)
.cloned()
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").unwrap());
let arc = PakArchive::open(format!("{root}/dat/tables.pak")).unwrap();
// Anything a gain would plausibly be called, plus the audio nouns.
const GAINY: &[&str] = &["VOL", "GAIN", "LEVEL", "DB", "ATTEN", "AMP", "MIX", "LOUD"];
let mut audio_objs = 0usize;
let mut gain_hits: Vec<(usize, String)> = Vec::new();
for (i, e) in arc.entries().iter().enumerate() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
let t = o.tokens();
let up: Vec<String> = t.iter().map(|s| s.to_uppercase()).collect();
let is_audio = up.iter().any(|s| {
s.contains("SOUND")
|| s.contains("BANK_")
|| s.starts_with("SE_")
|| s.starts_with("BGM_")
});
if !is_audio {
continue;
}
audio_objs += 1;
println!(
"audio object #{i}: schema {:08x}, {} tokens",
o.schema_hash,
t.len()
);
for (j, tok) in up.iter().enumerate() {
if GAINY.iter().any(|g| tok.contains(g)) {
gain_hits.push((i, t[j].clone()));
}
}
}
println!("\naudio-bearing objects examined: {audio_objs}");
println!("tokens matching {GAINY:?}: {}", gain_hits.len());
for (i, tok) in &gain_hits {
println!(" object #{i}: {tok}");
}
if gain_hits.is_empty() {
println!("\n==> NO gain-like token in any audio object of tables.pak.");
}
// CONTROL: the search must be able to FIND a token when one is present.
// Without this, "no hits" is indistinguishable from a broken matcher.
let mut ctrl = 0usize;
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
ctrl += o
.tokens()
.iter()
.filter(|s| s.to_uppercase().contains("SE_UI"))
.count();
}
println!(
"\nCONTROL — the same matcher looking for a token known to exist (\"SE_UI\"): {ctrl} hits"
);
println!(
" {}",
if ctrl > 0 {
"PASS: the matcher finds tokens that are there"
} else {
"FAIL: matcher is broken, the negative above means nothing"
}
);
}

Some files were not shown because too many files have changed in this diff Show More