Files
Sylpheed/crates/sylpheed-formats/tests/ui_settle_time_disc.rs
sylph-decoder d0735d2c25 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 19:28:27 +00:00

143 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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` (t66238) and `ptlogo_back2`
//! (t80243), then hold for the rest of the screen. `rest()` draws all seven at
//! `a=255` simultaneously, and stacking five extra white glows blows the light
//! arc out to saturation: against the console capture the arc's mean error is
//! 33.22 and 8 581 pixels sit at the clipping level, where the console has 1 459.
//!
//! `UiBuild::settle_time()` recovers the right instant from the disc alone — the
//! midpoint of the longest keyframe-free interval — with no reference to any
//! capture. For this build that is t=198, and posing there takes the arc error to
//! 11.79 and the clipped count to 1 452 against the console's 1 459.
//!
//! Argument, controls and the disc-wide census: `docs/re/structures/ui-settle-time.md`.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
let p = PathBuf::from(p);
if p.join("dat").is_dir() {
return Some(p);
}
}
None
}
/// The case that found the bug, asserted end to end.
#[test]
fn a_flash_is_transparent_at_the_settle_time_and_opaque_at_rest() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let arc = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let bytes = arc.read(&arc.entries()[4]).expect("build 4");
let b = ui_layout::parse_build(&bytes).expect("parse");
let (lo, hi) = b.settle_window().expect("a settle window");
let t = b.settle_time().expect("a settle time");
assert!(hi - lo >= 60, "title's settle window should be a second or more, got {lo}..{hi}");
assert!(lo < t && t < hi, "settle time {t} must lie inside {lo}..{hi}");
let alpha = |k: &ui_layout::Keyframe| k.fade >> 24;
let mut flashes = 0;
for el in &b.elements {
let Some(name) = el.name.strip_prefix("ptlogo_back2eff") else { continue };
// `ptlogo_back2eff.t32` itself holds; only the numbered ones flash.
if !name.starts_with(|c: char| c.is_ascii_digit()) {
continue;
}
flashes += 1;
let rest = el.rest().expect("a rest pose");
let posed = el.pose_at(t).expect("a posed keyframe");
assert_eq!(
alpha(rest),
255,
"{}: rest() is expected to report the FLASH PEAK — that is the bug",
el.name
);
assert_eq!(
alpha(&posed),
0,
"{} flashes once before t110 and must be gone at the settle time {t}",
el.name
);
}
assert_eq!(flashes, 5, "GP_TITLE build 4 has five numbered back2 flashes");
// …while the two that genuinely hold are still opaque there.
for want in ["ptlogo_back2eff.t32", "ptlogo_back2.t32"] {
let el = b.elements.iter().find(|e| e.name == want).expect(want);
assert_eq!(
alpha(&el.pose_at(t).expect("posed")),
255,
"{want} holds across the settle time and must stay opaque"
);
}
}
/// The window is a property of the data, so it must be computable disc-wide
/// without panicking, and must be self-consistent wherever it exists.
#[test]
fn settle_windows_are_self_consistent_disc_wide() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
let (mut with, mut wide) = (0usize, 0usize);
for p in &paks {
let Ok(a) = PakArchive::open(p) else { continue };
for e in a.entries() {
let Ok(by) = a.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
with += 1;
assert!(lo < hi, "an empty window is not a window: {lo}..{hi}");
let t = b.settle_time().expect("a window implies a time");
assert!((lo..=hi).contains(&t), "settle time {t} outside {lo}..{hi}");
// No element may have a keyframe strictly inside the window — that
// is the whole definition, so it is worth asserting rather than
// trusting.
for el in &b.elements {
for k in &el.keyframes {
if let Some(kt) = k.time {
assert!(
kt <= lo || kt >= hi,
"{}: keyframe t={kt} lies inside the settle window {lo}..{hi}",
el.name
);
}
}
}
if hi - lo >= 30 {
wide += 1;
}
}
}
assert!(with > 1000, "expected >1000 bundles with a settle window, got {with}");
eprintln!("{with} bundles have a settle window; {wide} are at least 30 units wide");
}