//! An opaque full-screen primitive cannot paint above what it would hide. //! //! A keyless primitive has no layer key, and `implied_layer_key` records the //! handful whose position was measured in the running game. For one class the //! file settles it without a measurement: an element covering the screen and //! fully opaque at some instant cannot paint above anything visible then, or the //! screen is blank. Where that set is *every* other element, the position is //! forced to first. //! //! The port found this by contradiction on `build_12`/`build_15`, which its //! renderer composited to solid black at every instant of their declared life. //! //! Argument, census and reach: `docs/re/structures/ui-forced-backdrop.md`. use std::path::PathBuf; use sylpheed_formats::{pak::PakArchive, ratc, ui_layout}; mod common; use common::disc_root; fn build(ar: &PakArchive, i: usize) -> (Vec, ui_layout::UiBuild) { let by = ar.read(&ar.entries()[i]).expect("entry"); let b = ui_layout::parse_build(&by).expect("parse"); (by, b) } fn el<'a>(b: &'a ui_layout::UiBuild, name: &str) -> &'a ui_layout::Element { b.elements.iter().find(|e| e.name == name).expect(name) } /// The two controls are measured orders from the running game. The rule has to /// reproduce one and permit the other, or it is not measuring occlusion. #[test] fn the_rule_reproduces_both_measured_primitives() { let Some(root) = disc_root() else { eprintln!("SYLPHEED_DISC unset — skipping"); return; }; let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak"); // `palogo_eff0.prm` is MEASURED painting first. Named like an overlay, so a // name-based rule gets it wrong; occlusion gets it right. let (_, splash) = build(&ar, 11); assert!( ui_layout::forced_backdrop(&splash, el(&splash, "palogo_eff0.prm")), "the developer splash's backdrop is measured FIRST and must come out forced" ); // `pteff00.prm` is MEASURED painting last. It is opaque only at its screen's // entry and exit, so the rule must NOT force it down. for entry in [4usize, 5] { let (_, b) = build(&ar, entry); assert!( !ui_layout::forced_backdrop(&b, el(&b, "pteff00.prm")), "entry {entry}: pteff00.prm is measured painting LAST and must stay permitted on top" ); } } /// The case that prompted it: the loading screens. #[test] fn the_loading_screens_backdrop_sorts_first() { let Some(root) = disc_root() else { eprintln!("SYLPHEED_DISC unset — skipping"); return; }; let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak"); for entry in [12usize, 15] { let (by, b) = build(&ar, entry); let prim = el(&b, "pgloading_eff00.prm"); assert!(ui_layout::forced_backdrop(&b, prim), "entry {entry}"); let order = ui_layout::derived_paint_order(&b, &by); assert_eq!( order.first().copied(), Some(prim.index), "entry {entry}: the backdrop must be painted first, not last" ); } } /// 🔴 The rule quantifies over "every instant the primitive is opaque" and "every /// element visible then", so both halves depend on where the timeline ends and on /// what an element does after its own last keyframe. **The hold is not a /// convenience: a measured order requires it.** /// /// `palogo_eff0.prm` is a SINGLE keyframe at t=0. If an element counted as *gone* /// after its last keyframe, the splash's backdrop would exist for one instant, no /// other element would be up yet, and the rule would call it free — against the /// order measured in the running game, which paints it first. /// /// Disc-wide the choice decides **72 of 130** verdicts, so this is the load-bearing /// half of the rule. (Using the header's declared `+0x08` as the span instead of /// the elements' maximum changes **0**.) #[test] fn the_hold_after_a_final_keyframe_is_required_by_a_measured_order() { let Some(root) = disc_root() else { eprintln!("SYLPHEED_DISC unset — skipping"); return; }; let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak"); for entry in [10usize, 11] { let (_, b) = build(&ar, entry); let prim = el(&b, "palogo_eff0.prm"); assert_eq!( prim.keyframes.len(), 1, "entry {entry}: the case rests on it being static" ); // With the hold — what `pose_at` does, and what the game does. assert!( ui_layout::forced_backdrop(&b, prim), "entry {entry}: measured painting FIRST, so the rule must force it" ); // Without it, spelled out here rather than imported, so the test states // the counterfactual it is pinning. let tmax = b .elements .iter() .flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)) .max() .unwrap_or(0); let last = |e: &ui_layout::Element| e.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0); let alpha_no_hold = |e: &ui_layout::Element, t: u32| -> u32 { if t > last(e) { 0 } else { e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) } }; let opaque: Vec = (0..=tmax) .filter(|&t| alpha_no_hold(prim, t) == 255) .collect(); let others: Vec<_> = b .elements .iter() .filter(|o| o.index != prim.index) .collect(); let below = others .iter() .filter(|o| opaque.iter().any(|&t| alpha_no_hold(o, t) > 0)) .count(); assert_ne!( below, others.len(), "entry {entry}: without the hold this element would come out FREE — which is \ why the hold is load-bearing rather than incidental" ); } } /// Disc-wide: the rule must fire on a real population and never on something it /// cannot occlude. #[test] fn forced_backdrops_are_full_screen_and_plentiful() { 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 forced, mut prm, mut tbm) = (0usize, 0usize, 0usize); for p in &paks { let Ok(ar) = PakArchive::open(p) else { continue; }; for e in ar.entries() { let Ok(by) = ar.read(e) else { continue }; if !ratc::is_ratc(&by) { continue; } let Some(b) = ui_layout::parse_build(&by) else { continue; }; for element in &b.elements { if !ui_layout::forced_backdrop(&b, element) { continue; } forced += 1; if element.name.ends_with(".prm") { prm += 1 } else if element.name.ends_with(".tbm") { tbm += 1 } // 🔴 Untextured only. This assertion caught the rule's real // limit: applied to `.t32` sprites it claimed 22 of them must // sort first, against their own layer keys — a sprite's element // alpha says nothing about its texture's coverage. assert!( element.sprite.is_none(), "{}: a textured sprite cannot be judged to occlude by element alpha", element.name ); assert!( element.pivot_x * 2 >= b.design_w && element.pivot_y * 2 >= b.design_h, "{}: a quad that does not cover the screen cannot occlude it", element.name ); } } } assert!(forced > 50, "expected a real population, got {forced}"); eprintln!("{forced} keyless primitives have their position forced to first"); // 🔴 Pin the split, so anyone tightening this rule sees what it would cost. // Only the `.prm` half is DECODED: a solid colour quad's fade IS its pixel, so // opacity and coverage are the same fact. Every `.tbm` in the set carries fade // `ffffffff` — a white SOLID quad painted first would make the screen white, so // they are textured, and element alpha does not establish their coverage. // Their verdicts are kept because restricting to `.prm` would send eleven // screens' backgrounds back to last, which is the bug this rule fixed. assert!( prm >= 40 && tbm >= 30, "expected roughly 42 .prm / 38 .tbm forced instances, got {prm} / {tbm} — \ if this moved, re-read the self-refutation section of ui-forced-backdrop.md" ); }