diff --git a/crates/sylpheed-formats/tests/ui_header_time_disc.rs b/crates/sylpheed-formats/tests/ui_header_time_disc.rs new file mode 100644 index 0000000..fc77051 --- /dev/null +++ b/crates/sylpheed-formats/tests/ui_header_time_disc.rs @@ -0,0 +1,143 @@ +//! Is the bundle header's `+0x08` really an animation **duration in frames**? +//! +//! The header sweep found `+0x04` takes only `0x3C0000` and `0x1E0000` — exactly +//! `60.0` and `30.0` in 16.16 — and `+0x08` takes 30 / 1200 / 120 / 60, which +//! *looks* like a frame rate and a length. That reading came from the values +//! alone, and this checks it against something the file states independently: +//! the **keyframe times** in the placement region. +//! +//! If `+0x08` is the length of the bundle's animation, the largest keyframe time +//! in the bundle should never exceed it, and should reach it on bundles that +//! animate all the way through. If instead the times run past it, the reading is +//! wrong and the amber has to come off the other way. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use sylpheed_formats::{pak::PakArchive, ratc, ui_layout}; + +fn disc_root() -> Option { + if let Ok(p) = std::env::var("SYLPHEED_DISC") { + let p = PathBuf::from(p); + if p.join("dat").is_dir() { + return Some(p); + } + } + let default = Path::new( + "/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)", + ); + if default.join("dat").is_dir() { + return Some(default.to_path_buf()); + } + None +} + +fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) { + let mut paks: Vec = std::fs::read_dir(root.join("dat")) + .expect("dat/") + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")) + .collect(); + paks.sort(); + for p in &paks { + let name = p.file_name().unwrap().to_string_lossy().to_string(); + let Ok(arc) = PakArchive::open(p) else { continue }; + for e in arc.entries() { + let Ok(bytes) = arc.read(e) else { continue }; + if ratc::is_ratc(&bytes) { + f(&name, &bytes); + } + } + } +} + +fn be32(b: &[u8], at: usize) -> u32 { + u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) +} + +#[test] +fn header_0x08_against_the_keyframe_times() { + let Some(root) = disc_root() else { + eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)"); + return; + }; + + let (mut animated, mut within, mut exact, mut over) = (0usize, 0, 0, 0); + let mut worst: Vec = Vec::new(); + // How the ratio max_time / header_0x08 is distributed, in tenths. + let mut ratio: HashMap = HashMap::new(); + // Does the rate word co-vary with anything? + let mut by_rate: HashMap = HashMap::new(); // rate -> (bundles, max seen 0x08) + + for_each_build(&root, |pak, bytes| { + if bytes.len() < 0x20 { + return; + } + let Some(build) = ui_layout::parse_build(bytes) else { + return; + }; + if build.from_fallback { + return; + } + let dur = be32(bytes, 0x08); + let rate = be32(bytes, 0x04); + let e = by_rate.entry(rate).or_default(); + e.0 += 1; + e.1 = e.1.max(dur); + + let max_time = build + .elements + .iter() + .flat_map(|el| el.keyframes.iter()) + .filter_map(|k| k.time) + .max(); + let Some(max_time) = max_time else { return }; + if dur == 0 { + return; + } + animated += 1; + if max_time <= dur { + within += 1; + if max_time == dur { + exact += 1; + } + } else { + over += 1; + if worst.len() < 10 { + worst.push(format!("{pak}: max keyframe {max_time} > header {dur}")); + } + } + let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32; + *ratio.entry(r.min(30)).or_default() += 1; + }); + + eprintln!("bundles with keyframe times and a non-zero +0x08: {animated}"); + eprintln!(" max keyframe time <= +0x08: {within} of which EXACTLY equal: {exact}"); + eprintln!(" max keyframe time > +0x08: {over}"); + let mut r: Vec<_> = ratio.iter().collect(); + r.sort(); + eprintln!(" ratio max_time/+0x08 (tenths -> bundles): {r:?}"); + let mut br: Vec<_> = by_rate.iter().collect(); + br.sort(); + eprintln!(" +0x04 rate word -> (bundles, largest +0x08 seen): {br:?}"); + for w in &worst { + eprintln!(" over: {w}"); + } + + assert!(animated > 0, "no animated bundles — the sweep is broken"); + + // MEASURED 2026-08-24. +0x08 bounds the keyframe times in EVERY one of the + // 2313 bundles that have both, and 444 of them reach it exactly. The + // spread-out ratio histogram is what rules out the boring explanation: a + // large unrelated constant would bound everything too, but then the ratios + // would pile up near zero instead of peaking at 1.0. + assert_eq!(over, 0, "a keyframe time runs past the header's +0x08"); + assert!(exact > 400, "the bound is never attained — it may be unrelated"); + let near_one = ratio.get(&10).copied().unwrap_or(0); + let near_zero = ratio.get(&0).copied().unwrap_or(0); + assert!( + near_one > near_zero * 4, + "the max_time/+0x08 ratio does not peak at 1.0 — the bound may be vacuous" + ); +} diff --git a/docs/re/structures/ui-rat-layout.md b/docs/re/structures/ui-rat-layout.md index 402b720..48f048d 100644 --- a/docs/re/structures/ui-rat-layout.md +++ b/docs/re/structures/ui-rat-layout.md @@ -508,7 +508,7 @@ composable bundles with a real declaration table | offset | distinct values | reading | |---|---|---| | `+0x04` | **3** — `0x3C0000` ×2 843, `0x1E0000` ×12, `0x3C0001` ×4 | 🟡 **frame rate in 16.16**: `0x3C0000` is exactly `60.0`, `0x1E0000` exactly `30.0` | -| `+0x08` | 22 — 30, 1200, 120, 60, … | 🟡 a **duration in frames** (0.5 s, 20 s, 2 s, 1 s at 60) | +| `+0x08` | 22 — 30, 1200, 120, 60, … | ✅ the **animation length**, in the same unit as a keyframe's `time` — see below | | `+0x0c` | 170 | ❔ | | `+0x10` | 83 — `0x9400`, `0x9200`, `0x8212`, … | ❔ flags; bit 15 set on **91 %** of all bundles | | `+0x18` | **2** — `1280` ×2 829 | ✅ **design width** | @@ -542,3 +542,40 @@ Element counts over those 2 859 bundles: **min 1, p25 1, median 2, p75 5, p95 23 max 56**, and only **365** carry a full-screen element. The population really is mostly fragments, and the separation is **shape** — or which bundle references which, which the PAK cannot answer directly because its entries are name-hashed. + + +### ✅ `+0x08` is the animation length — checked against the keyframe times + +The rate/duration reading came from the values alone, so it was checked against +something the file states independently: the `time` field of the keyframes in the +placement region (`tests/ui_header_time_disc.rs`). + +Over the **2 313** bundles that have both keyframe times and a non-zero `+0x08`: + +| | | +|---|---| +| largest keyframe time **≤** `+0x08` | **2 313 — every one** | +| largest keyframe time **>** `+0x08` | **0** | +| reaches it **exactly** | **444** | + +and the ratio `max_time / +0x08` peaks at **1.0** (520 bundles) rather than near +zero: + +``` +tenths: 0.0→92 0.1→128 0.2→201 0.3→198 0.4→92 0.5→110 + 0.6→194 0.7→159 0.8→293 0.9→326 1.0→520 +``` + +That histogram is the refutation attempt, and it is why the bound is not vacuous: +a large unrelated constant would also bound every time, but the ratios would then +pile up near zero. They peak at exactly 1. + +**Stated precisely, because the units are a separate claim:** what is proven is +that `+0x08` is the animation's **length in the same unit as the keyframe `time` +field**. That the unit is *frames* still rests on the values themselves +(30 / 60 / 120 / 1200) and on the 16.16 reading of `+0x04`. + +🟡 **`+0x04` stays amber.** The one new piece of evidence is weak but points the +right way: the twelve bundles whose rate word is `0x1E0000` (30.0) have a largest +`+0x08` of **30**, while the 2 843 at `0x3C0000` (60.0) go up to **1 440**. Twelve +bundles is not a demonstration, and nothing here measures a wall-clock duration.