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