diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index 2fa859c..1034b3a 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -493,6 +493,51 @@ pub fn compose_build(bundle: &[u8], include_focus: bool) -> Option Option> { + let names: Vec<&str> = build.elements.iter().map(|e| e.name.as_str()).collect(); + // GP_TITLE.pak entry 4 (a60fcb85) — the title screen the game actually runs. + const TITLE: [&str; 24] = [ + "ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32", + "ptlogo2.t32", "pteff01.t32", "ptlogo_tm.t32", "pteff00.prm", "ptbase2.t32", + "pteff04.t32", "ptloop01.rat", "ptloop02.rat", "pteff02.prm", + "ptlogo_back2eff1.t32", "ptlogo_back2eff2.t32", "ptlogo_back2eff3.t32", + "ptlogo_back2eff4.t32", "ptlogo_back2eff5.t32", "ptlogo_back2.t32", + "ptlogo_back2eff.t32", "ptcopyright.t32", "ptlogoall_eff.t32", + "ptlogoall_eff2.t32", + ]; + // GP_TITLE.pak entries 11/14 — the GAME ARTS / SETA / studio anima splash. + const SPLASH: [&str; 7] = [ + "palogo_eff0.prm", "palogo_gamearts.t32", "palogo_gamearts_eff.t32", + "palogo_seta.t32", "palogo_seta_eff.t32", "palogo_anima.t32", + "palogo_anima_eff.t32", + ]; + if names == TITLE { + // background, the rotating pair, the other full-screen layers, the + // back2 glow group, the wordmarks, the copyright, the fade. + return Some(vec![ + 9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, + 22, 23, 21, 8, + ]); + } + if names == SPLASH { + // the full-screen .prm, then all three glows, then the three logos. + return Some(vec![0, 2, 4, 6, 1, 3, 5]); + } + None +} + pub fn compose( build: &UiBuild, bundle: &[u8], @@ -508,7 +553,14 @@ pub fn compose( } let mut drawn = Vec::new(); let mut missing = Vec::new(); - for el in &build.elements { + // Measured paint order when one exists for this build, declaration order + // otherwise — see `measured_paint_order`. + let order: Vec = + measured_paint_order(build).unwrap_or_else(|| (0..build.elements.len()).collect()); + for &ei in &order { + let Some(el) = build.elements.get(ei) else { + continue; + }; if let Some(v) = visible { if !v.get(el.index).copied().unwrap_or(true) { continue; @@ -517,6 +569,16 @@ pub fn compose( if (el.animated && !opts.include_animated) || (el.focused && !opts.include_focus) { continue; } + // `kind = 0x4` elements are the repeated instances of a template — the + // motion-trail ghosts a wordmark leaves while it flies in. They are NOT + // on screen at rest: the title's draw capture shows exactly ONE quad at + // each wordmark's position, though the bundle declares three instances + // of each, and their keyframe groups end at alpha 0. Drawing them at + // their resting keyframe is what put three oversized copies of + // PROJECT SYLPHEED across the composite. + if el.kind & 0x4 != 0 { + continue; + } let Some(kf) = el.rest() else { continue }; let Some(sprite) = el.sprite.as_ref() else { continue; diff --git a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs index f1014dc..129663a 100644 --- a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs +++ b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs @@ -216,3 +216,62 @@ fn scaled_elements_are_a_small_and_mostly_undiscriminating_minority() { "expected thousands of placements, got {total}" ); } + +/// The measured paint order is applied, and the ghost instances are not drawn. +/// +/// Both facts come from the running game, not from the file: +/// +/// * the paint order is the screen object's reordered child list, read out of +/// live guest memory and checked against the draw capture +/// (`docs/re/structures/ui-screen-runtime.md`). For the title build that puts +/// `ptbase2` (declaration index 9) **first**, which declaration order cannot; +/// * the `kind = 0x4` repeat instances are motion-trail ghosts and are absent at +/// rest — the capture shows exactly one quad per wordmark though the bundle +/// declares three instances of each. +#[test] +fn title_composites_in_the_measured_order_without_ghosts() { + skip_without_disc!(root); + // Read ONE pak, not every build on the disc: `builds()` holds them all in + // memory at once, and a fourth test doing that in parallel with the other + // three got the process OOM-killed. + let mut found = false; + for bundle in pak_builds(&root, "GP_TITLE.pak") { + let Some(build) = ui_layout::parse_build(&bundle) else { + continue; + }; + // the title build: 24 elements, and it declares ptbase2 at index 9 + if build.elements.len() != 24 + || build.elements[9].name != "ptbase2.t32" + || build.elements[0].name != "ptlogo1.t32" + { + continue; + } + found = true; + let out = ui_layout::compose(&build, &bundle, ComposeOptions::default(), None); + + // the background is painted FIRST — the whole point of the measured order + assert_eq!( + out.drawn.first().copied(), + Some(9), + "expected ptbase2 (element 9) painted first, got {:?}", + out.drawn.first() + ); + // ... and before both wordmarks, which declaration order would put first + let pos = |i: usize| out.drawn.iter().position(|&d| d == i); + assert!(pos(9) < pos(0), "background must precede ptlogo1"); + assert!(pos(9) < pos(1), "background must precede ptlogo2"); + // the copyright is late, as captured + assert!(pos(21) > pos(0), "copyright must follow the wordmarks"); + + // no kind = 0x4 ghost instance is drawn + for &d in &out.drawn { + assert_eq!( + build.elements[d].kind & 0x4, + 0, + "element {d} ({}) is a kind=0x4 ghost and must not be drawn at rest", + build.elements[d].name + ); + } + } + assert!(found, "the 24-element GP_TITLE build was not found"); +} diff --git a/docs/re/captures/title-composited-measured-order.png b/docs/re/captures/title-composited-measured-order.png new file mode 100644 index 0000000..cf6aa23 Binary files /dev/null and b/docs/re/captures/title-composited-measured-order.png differ