From d0735d2c250474bddd04b7779bb76a8c1332bb25 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sat, 29 Aug 2026 19:28:27 +0000 Subject: [PATCH] formats: a settled screen is one instant, not one hold per element `Element::rest()` picks each element's last hold keyframe independently of every other element, so a composite built from it is not the screen at any moment in time -- it is a per-element maximum. For a transient that is exactly wrong: a two-frame flash's last hold IS the flash peak, so it burns forever. GP_TITLE build 4 is the case. `ptlogo_back2eff1`..`eff5` are five staggered two-frame flashes -- one light sweep drawn as five frames, all extinguished by t110 -- that `rest()` draws simultaneously and permanently. Five stacked white glows saturate the light arc behind the logo. The disc names the right instant: the midpoint of the longest interval containing no keyframe of any element. `UiBuild::settle_time()` and `settle_window()`; `screen render --settle` applies it and prints the window, whose width is how much the midpoint is worth. Predicted t=198 from [160,236] BEFORE scoring. Against the console capture, the arc band goes 33.22 -> 11.79 and pixels at the clipping level 8581 -> 1452, where the console has 1459 -- an unfitted statistic. Whole frame 14.07 -> 12.06. Controls at t=100 and t=358 are far worse, and a hand-picked visibility list reaches the identical numbers. `ComposeOptions::at` now poses every element rather than leaves only, which is why the earlier rotation pose scan was flat: it moved the sweeps and never touched the top-level flashes. `at = None` is byte-identical (cmp), the pre-rotation tag renders identically at rest, and the 13 paint-order tests plus the keyframe/focus/opt-link disc tests are green. Also fixes the diagnostic that caused a wrong finding to be sent to the port agent: `not drawn` listed bare names, and a kind-0x4 ghost carries its template's name, so four ghosts printed as `ptlogo1.t32`/`ptlogo2.t32` and read as "the logo is missing". It now prints index, name and reason. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd --- crates/sylpheed-cli/src/main.rs | 78 ++++++++-- .../sylpheed-formats/examples/kf_timeline.rs | 19 +++ crates/sylpheed-formats/examples/loo_band.rs | 26 ++++ .../examples/settle_window.rs | 31 ++++ crates/sylpheed-formats/src/ui_layout.rs | 72 ++++++++- .../tests/ui_settle_time_disc.rs | 142 ++++++++++++++++++ 6 files changed, 352 insertions(+), 16 deletions(-) create mode 100644 crates/sylpheed-formats/examples/kf_timeline.rs create mode 100644 crates/sylpheed-formats/examples/loo_band.rs create mode 100644 crates/sylpheed-formats/examples/settle_window.rs create mode 100644 crates/sylpheed-formats/tests/ui_settle_time_disc.rs diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index cd4fc9dd..d153dff0 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -196,13 +196,22 @@ enum ScreenCommands { #[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 the last *hold* - /// keyframe, which is the settled screen — wrong for anything still - /// moving. The title's two light sweeps hold off the right edge, so a - /// resting composite omits them; `--at 358` puts them where a capture - /// taken mid-sweep has them. - #[arg(long)] + /// pose (60 units = 1 second). The resting pose is each element's last + /// *hold* keyframe, picked independently of every other element — so it + /// is not the screen at any one moment, and it is wrong twice over: it + /// omits anything still moving (the title's light sweeps hold off the + /// right edge), and it freezes a transient at its PEAK (the title's five + /// two-frame flashes burn forever). Prefer `--settle`. + #[arg(long, conflicts_with = "settle")] at: Option, + /// Pose every element at the instant the screen is SETTLED, derived from + /// the disc: the midpoint of the longest interval containing no keyframe + /// of any element. Prints the window it used, whose width is how much the + /// midpoint is worth — a narrow one means the bundle never settles (42 % + /// of them, mostly `loop*` fragments). See + /// `docs/re/structures/ui-settle-time.md`. + #[arg(long)] + settle: bool, }, } @@ -362,8 +371,9 @@ async fn main() -> Result<()> { all, primitives, at, + settle, } => cmd_screen_render( - &pak, &output, build, focus, animated, black, all, primitives, at, + &pak, &output, build, focus, animated, black, all, primitives, at, settle, ), }, Commands::Save { cmd } => match cmd { @@ -591,12 +601,39 @@ fn cmd_screen_render( all: bool, primitives: bool, at: Option, + 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, @@ -632,14 +669,35 @@ fn cmd_screen_render( if !screen.missing.is_empty() { println!(" sprites that did not resolve/decode: {:?}", screen.missing); } - let undrawn: Vec<&str> = b + // 🔴 Report the INDEX, the KIND and WHY, not just the name. A kind-`0x4` + // ghost instance carries its template's name, so a bare name list shows + // `ptlogo1.t32` twice and reads as "the logo is missing" when what is + // skipped is two motion-trail ghosts sitting at alpha 0 off-screen. That + // misreading cost this project a wrong finding sent to another agent. + let undrawn: Vec = 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(()) } diff --git a/crates/sylpheed-formats/examples/kf_timeline.rs b/crates/sylpheed-formats/examples/kf_timeline.rs new file mode 100644 index 00000000..51f073c2 --- /dev/null +++ b/crates/sylpheed-formats/examples/kf_timeline.rs @@ -0,0 +1,19 @@ +use sylpheed_formats::{pak, ui_layout}; +fn main(){ + let mut a=std::env::args().skip(1); + let pk=a.next().unwrap(); let bi:usize=a.next().unwrap().parse().unwrap(); + let ar=pak::PakArchive::open(pk).unwrap(); + let by=ar.read(&ar.entries()[bi]).unwrap(); + let b=ui_layout::parse_build(&by).unwrap(); + for t in a { + let i:usize=t.parse().unwrap(); + let e=&b.elements[i]; + println!("[{i}] {} kind=0x{:x} parent={:?} pivot=({},{}) kfs={}", + e.name, e.kind, e.parent, e.pivot_x, e.pivot_y, e.keyframes.len()); + for k in &e.keyframes { + println!(" t={:<5} a={:<4} xy=({},{}) s={}/{} rot={} u4={} u8={} tint={:08x}", + k.time.map(|v|v as i64).unwrap_or(-1), k.fade>>24, k.x,k.y, + k.scale_x,k.scale_y,k.rotation_deg,k.unknown_4,k.unknown_8,k.tint); + } + } +} diff --git a/crates/sylpheed-formats/examples/loo_band.rs b/crates/sylpheed-formats/examples/loo_band.rs new file mode 100644 index 00000000..4bdc4476 --- /dev/null +++ b/crates/sylpheed-formats/examples/loo_band.rs @@ -0,0 +1,26 @@ +// Leave-one-out over the elements that touch a band, dumping raw RGBA each time. +use sylpheed_formats::{pak, ui_layout}; +fn main(){ + let mut a=std::env::args().skip(1); + let pk=a.next().unwrap(); + let bi:usize=a.next().unwrap().parse().unwrap(); + let dir=a.next().unwrap(); + let ar=pak::PakArchive::open(pk).unwrap(); + let by=ar.read(&ar.entries()[bi]).unwrap(); + let b=ui_layout::parse_build(&by).unwrap(); + let o=ui_layout::ComposeOptions{ backdrop:[0,0,0,255], ..Default::default() }; + let n=b.elements.len(); + let base=ui_layout::compose(&b,&by,o.clone(),None); + std::fs::write(format!("{dir}/base.raw"),&base.rgba).unwrap(); + println!("canvas {}x{} elements {n}", base.width, base.height); + for i in 0..n { + let e=&b.elements[i]; + let Some(kf)=e.rest() else { continue }; + // only bother with elements whose rest pose can touch the band + let mut v=vec![true;n]; v[i]=false; + let c=ui_layout::compose(&b,&by,o.clone(),Some(&v)); + std::fs::write(format!("{dir}/wo-{i}.raw"),&c.rgba).unwrap(); + println!("{i}\t{}\t{}\t({},{})\ta={}", e.name, + e.sprite.clone().unwrap_or_default(), kf.x, kf.y, kf.fade>>24); + } +} diff --git a/crates/sylpheed-formats/examples/settle_window.rs b/crates/sylpheed-formats/examples/settle_window.rs new file mode 100644 index 00000000..96a5b2c6 --- /dev/null +++ b/crates/sylpheed-formats/examples/settle_window.rs @@ -0,0 +1,31 @@ +// The settled screen, computed from the keyframe times alone. +// +// `rest()` picks each element's last HOLD keyframe independently, which is right +// for an element that ends settled and wrong for a transient: a 2-frame flash +// holds at its PEAK, so `rest()` leaves it burning forever. The settled screen is +// instead one INSTANT that every element is posed at, and the instant to pick is +// inside the longest interval during which no element has a keyframe at all. +use sylpheed_formats::{pak, ui_layout}; +fn main(){ + let mut a=std::env::args().skip(1); + let pk=a.next().unwrap(); + let ar=pak::PakArchive::open(&pk).unwrap(); + let only:Option=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=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); + } +} diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index 3cba09e9..abd14cc3 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -194,11 +194,20 @@ impl Element { /// agree — a group that ramps through every frame and never holds. /// The element's pose at keyframe time `t`, linearly interpolated. /// - /// `rest()` returns the last HOLD keyframe, which is the settled screen. That - /// is the wrong pose for anything still moving: the title's light sweeps hold - /// at `x = 1521`, off the right edge, so a resting composite deletes them - /// rather than settling them. A capture taken mid-animation can only be - /// compared against a render posed at the same instant. + /// `rest()` returns the last HOLD keyframe **of one element, chosen + /// independently of every other element**. That is the wrong pose twice over: + /// + /// * for anything still moving — the title's light sweeps hold at `x = 1521`, + /// off the right edge, so a resting composite deletes them rather than + /// settling them; + /// * 🔴 and for anything **transient**. `ptlogo_back2eff1` is a two-frame + /// flash (`a=0` until t52, `255` at t54–56, `0` again by t58); its last + /// hold *is* the flash peak, so `rest()` leaves it burning forever. Five + /// such flashes stack on the title and blow the light arc out to pure + /// white — see `docs/re/structures/ui-settle-time.md`. + /// + /// A settled screen is one INSTANT that every element is posed at, which is + /// what [`UiBuild::settle_time`] computes and `ComposeOptions::at` applies. /// /// The ramp is linear (`docs/re/ui-keyframe-time-unit.md`), and a group /// **holds** at its last keyframe rather than looping, so `t` past the end @@ -988,6 +997,51 @@ fn measured_paint_order(build: &UiBuild) -> Option> { None } +impl UiBuild { + /// The instant at which this screen is *settled*, in keyframe time units. + /// + /// A screen is not settled when each element sits at its own last hold — + /// that is what [`Element::rest`] gives, and it is wrong for a transient + /// (see the note there). It is settled at one shared instant, and the disc + /// says which: gather every keyframe time in the build, and take the + /// **longest interval containing none of them**. Inside that gap nothing has + /// an inflection, so every element is either holding or on a long linear + /// ramp — which is exactly what "the screen has stopped changing" means. + /// + /// Returns the midpoint of that gap, or `None` when the build has fewer than + /// two distinct keyframe times. + /// + /// ⚠️ **Not every bundle has a settled instant.** Disc-wide over the 1 758 + /// composable bundles carrying two or more keyframe times, 30 % have a gap of + /// at least half a second and 42 % have one under 10 units — the latter are + /// mostly `loop*` animation fragments, which are *meant* to be in motion and + /// have no settled pose to find. Check the gap width before trusting the + /// midpoint; `settle_window` returns it. + pub fn settle_time(&self) -> Option { + self.settle_window().map(|(lo, hi)| lo + (hi - lo) / 2) + } + + /// The `[start, end]` of the longest keyframe-free interval — see + /// [`UiBuild::settle_time`]. The width `end - start` is how much confidence + /// the midpoint deserves. + pub fn settle_window(&self) -> Option<(u32, u32)> { + let mut ts: Vec = self + .elements + .iter() + .flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)) + .collect(); + ts.sort_unstable(); + ts.dedup(); + if ts.len() < 2 { + return None; + } + ts.windows(2) + .map(|w| (w[1] - w[0], w[0], w[1])) + .max_by_key(|&(d, _, _)| d) + .map(|(_, lo, hi)| (lo, hi)) + } +} + pub fn compose( build: &UiBuild, bundle: &[u8], @@ -1084,7 +1138,13 @@ pub fn compose_with_order( // capture from 10.92 to 61.74. The leaf is the thing still animating at // that instant, and it runs on its own timeline // (`docs/re/structures/ui-leaf-vs-parent-alpha.md`). - let Some(kf) = el.rest() else { continue }; + let Some(kf) = (match opts.at { + Some(t) => el.pose_at(t), + None => el.rest().cloned(), + }) else { + continue; + }; + let kf = &kf; // An untextured primitive: a solid quad of the keyframe's `fade` colour, // sized by the declared pivot. `kind & 0x10` marks these exactly — see // `docs/re/structures/ui-prm-primitives.md`. They are the screen's diff --git a/crates/sylpheed-formats/tests/ui_settle_time_disc.rs b/crates/sylpheed-formats/tests/ui_settle_time_disc.rs new file mode 100644 index 00000000..dee31caf --- /dev/null +++ b/crates/sylpheed-formats/tests/ui_settle_time_disc.rs @@ -0,0 +1,142 @@ +//! A settled screen is one INSTANT, not one hold per element. +//! +//! `Element::rest()` returns an element's last *hold* keyframe, picked for that +//! element alone. For anything that ends the screen settled that is right. For a +//! **transient** it is exactly wrong: a two-frame flash's last hold is the flash +//! *peak*, so `rest()` leaves it burning for the whole screen. +//! +//! `GP_TITLE` build 4 is the case that found this. `ptlogo_back2eff1` … `eff5` +//! are five staggered flashes — `a=0` until t52, `255` for two frames, `0` again +//! two frames later — that sweep left to right across the logo once and are gone +//! by t110. Two elements, `ptlogo_back2eff` (t66–238) and `ptlogo_back2` +//! (t80–243), then hold for the rest of the screen. `rest()` draws all seven at +//! `a=255` simultaneously, and stacking five extra white glows blows the light +//! arc out to saturation: against the console capture the arc's mean error is +//! 33.22 and 8 581 pixels sit at the clipping level, where the console has 1 459. +//! +//! `UiBuild::settle_time()` recovers the right instant from the disc alone — the +//! midpoint of the longest keyframe-free interval — with no reference to any +//! capture. For this build that is t=198, and posing there takes the arc error to +//! 11.79 and the clipped count to 1 452 against the console's 1 459. +//! +//! Argument, controls and the disc-wide census: `docs/re/structures/ui-settle-time.md`. + +use std::path::PathBuf; + +use sylpheed_formats::{pak::PakArchive, ratc, ui_layout}; + +fn disc_root() -> Option { + if let Ok(p) = std::env::var("SYLPHEED_DISC") { + let p = PathBuf::from(p); + if p.join("dat").is_dir() { + return Some(p); + } + } + None +} + +/// The case that found the bug, asserted end to end. +#[test] +fn a_flash_is_transparent_at_the_settle_time_and_opaque_at_rest() { + let Some(root) = disc_root() else { + eprintln!("SYLPHEED_DISC unset — skipping"); + return; + }; + let arc = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak"); + let bytes = arc.read(&arc.entries()[4]).expect("build 4"); + let b = ui_layout::parse_build(&bytes).expect("parse"); + + let (lo, hi) = b.settle_window().expect("a settle window"); + let t = b.settle_time().expect("a settle time"); + assert!(hi - lo >= 60, "title's settle window should be a second or more, got {lo}..{hi}"); + assert!(lo < t && t < hi, "settle time {t} must lie inside {lo}..{hi}"); + + let alpha = |k: &ui_layout::Keyframe| k.fade >> 24; + let mut flashes = 0; + for el in &b.elements { + let Some(name) = el.name.strip_prefix("ptlogo_back2eff") else { continue }; + // `ptlogo_back2eff.t32` itself holds; only the numbered ones flash. + if !name.starts_with(|c: char| c.is_ascii_digit()) { + continue; + } + flashes += 1; + let rest = el.rest().expect("a rest pose"); + let posed = el.pose_at(t).expect("a posed keyframe"); + assert_eq!( + alpha(rest), + 255, + "{}: rest() is expected to report the FLASH PEAK — that is the bug", + el.name + ); + assert_eq!( + alpha(&posed), + 0, + "{} flashes once before t110 and must be gone at the settle time {t}", + el.name + ); + } + assert_eq!(flashes, 5, "GP_TITLE build 4 has five numbered back2 flashes"); + + // …while the two that genuinely hold are still opaque there. + for want in ["ptlogo_back2eff.t32", "ptlogo_back2.t32"] { + let el = b.elements.iter().find(|e| e.name == want).expect(want); + assert_eq!( + alpha(&el.pose_at(t).expect("posed")), + 255, + "{want} holds across the settle time and must stay opaque" + ); + } +} + +/// The window is a property of the data, so it must be computable disc-wide +/// without panicking, and must be self-consistent wherever it exists. +#[test] +fn settle_windows_are_self_consistent_disc_wide() { + let Some(root) = disc_root() else { + eprintln!("SYLPHEED_DISC unset — skipping"); + return; + }; + let mut paks: Vec = std::fs::read_dir(root.join("dat")) + .expect("dat/") + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")) + .collect(); + paks.sort(); + + let (mut with, mut wide) = (0usize, 0usize); + for p in &paks { + let Ok(a) = PakArchive::open(p) else { continue }; + for e in a.entries() { + let Ok(by) = a.read(e) else { continue }; + if !ratc::is_ratc(&by) { + continue; + } + let Some(b) = ui_layout::parse_build(&by) else { continue }; + let Some((lo, hi)) = b.settle_window() else { continue }; + with += 1; + assert!(lo < hi, "an empty window is not a window: {lo}..{hi}"); + let t = b.settle_time().expect("a window implies a time"); + assert!((lo..=hi).contains(&t), "settle time {t} outside {lo}..{hi}"); + // No element may have a keyframe strictly inside the window — that + // is the whole definition, so it is worth asserting rather than + // trusting. + for el in &b.elements { + for k in &el.keyframes { + if let Some(kt) = k.time { + assert!( + kt <= lo || kt >= hi, + "{}: keyframe t={kt} lies inside the settle window {lo}..{hi}", + el.name + ); + } + } + } + if hi - lo >= 30 { + wide += 1; + } + } + } + assert!(with > 1000, "expected >1000 bundles with a settle window, got {with}"); + eprintln!("{with} bundles have a settle window; {wide} are at least 30 units wide"); +}