//! What orders a UI screen's elements, and where each one lands. //! //! The composite is checked against a **framebuffer capture of the running //! game** (`docs/re/captures/title-screen-oracle.png`), not against itself. //! Two things were settled that way on 2026-08-18, and this pins both: //! //! * a keyframe's scale grows the element **about its declared pivot**, so a //! 200 % background at (320,180) with pivot (320,180) is the full screen, not //! a quarter-screen slab at 320..1600; //! * the placement region is **not** a second ordering of the elements — it //! stores its keyframe groups in declaration order on every build on the //! disc, so it cannot be the paint order the title screen needs. //! //! Skipped (as no-ops) when the extracted disc is absent. use std::path::{Path, PathBuf}; use sylpheed_formats::{ pak::PakArchive, t8ad, ui_layout::{self, ComposeOptions}, }; 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); } } let default = Path::new( "/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)", ); if default.join("dat").is_dir() { return Some(default.to_path_buf()); } None } macro_rules! skip_without_disc { ($root:ident) => { let Some($root) = disc_root() else { eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)"); return; }; }; } /// Every parseable screen build on the disc, as (pak name, bundle bytes). fn builds(root: &Path) -> Vec<(String, Vec)> { 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 out = Vec::new(); for p in &paks { let name = p.file_name().unwrap().to_string_lossy().to_string(); let Ok(arc) = PakArchive::open(p) else { continue; }; for e in arc.entries() { let Ok(bytes) = arc.read(e) else { continue }; if ui_layout::is_build(&bytes) { out.push((name.clone(), bytes)); } } } out } /// Every parseable screen build, handed over **one pak at a time**. /// /// `builds` holds the whole disc's bundles in memory at once, and four tests in /// this file want the whole corpus; run together under the default test harness /// that is enough to get the process OOM-killed. This streams instead. fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) { 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(); for p in &paks { let name = p.file_name().unwrap().to_string_lossy().to_string(); let Ok(arc) = PakArchive::open(p) else { continue }; for e in arc.entries() { let Ok(bytes) = arc.read(e) else { continue }; if ui_layout::is_build(&bytes) { f(&name, &bytes); } } } } /// The builds of one pak, in the order `sylpheed-cli screen list` numbers them. fn pak_builds(root: &Path, pak: &str) -> Vec> { let arc = PakArchive::open(root.join("dat").join(pak)).expect("open pak"); arc.entries() .iter() .filter_map(|e| arc.read(e).ok()) .filter(|b| ui_layout::is_build(b)) .collect() } #[test] fn placement_region_order_is_never_a_second_ordering() { skip_without_disc!(root); let all = builds(&root); assert!( all.len() > 500, "expected the disc's screen builds, got {}", all.len() ); let mut checked = 0usize; for (pak, bytes) in &all { let Some(b) = ui_layout::parse_build(bytes) else { continue; }; if b.from_fallback { continue; // the fallback path invents the order, so it proves nothing } checked += 1; let identity: Vec = (0..b.placement_order.len()).collect(); assert_eq!( b.placement_order, identity, "{pak}: the placement region stores groups in a DIFFERENT order from \ the declaration table — that would be a candidate paint order and \ the note in docs/re/BACKLOG.md needs revisiting" ); } assert!( checked > 500, "only {checked} builds carried a declaration table" ); } #[test] fn title_background_is_full_screen() { skip_without_disc!(root); let bs = pak_builds(&root, "GP_TITLE.pak"); let bytes = &bs[7]; // the full title sequence: 30 elements, 24 sprites let b = ui_layout::parse_build(bytes).expect("build 7 parses"); let base = b .elements .iter() .find(|e| e.name == "ptbase2.t32") .expect("the title background element"); assert_eq!((base.pivot_x, base.pivot_y), (320, 180)); let k = base.rest().expect("a resting keyframe"); assert_eq!((k.x, k.y, k.scale_x, k.scale_y), (320, 180, 200, 200)); // Draw that element and nothing else, on black. Anchored at the pivot it is // exactly the 1280x720 screen; anchored at the keyframe corner it would // leave the whole top-left quadrant untouched. let mut visible = vec![false; b.elements.len()]; visible[base.index] = true; let screen = ui_layout::compose( &b, bytes, ComposeOptions { backdrop: [0, 0, 0, 0], ..Default::default() }, Some(&visible), ); assert_eq!(screen.drawn, vec![base.index]); let uncovered = screen.rgba.chunks_exact(4).filter(|p| p[3] == 0).count(); assert_eq!( uncovered, 0, "{uncovered} of {} pixels are not covered by the background", screen.width * screen.height ); } /// How much of the disc the pivot-anchored rule actually touches, and how much /// of it could tell "about the pivot" apart from "about the sprite centre". /// /// Stated as numbers rather than left implicit: at 100 % the pivot cancels, so /// only a scaled element moves at all, and only a scaled element whose pivot is /// not half its decoded size distinguishes the two rules. The oracle settled /// `ptbase2`, whose pivot *is* half its size — so the centre reading is not /// excluded by measurement, only by the pivot field existing at all. #[test] fn scaled_elements_are_a_small_and_mostly_undiscriminating_minority() { skip_without_disc!(root); let (mut total, mut scaled, mut discriminating) = (0usize, 0usize, 0usize); let (mut pivot_is_half, mut pivot_off_by_lots) = (0usize, 0usize); for (_, bytes) in builds(&root) { let Some(b) = ui_layout::parse_build(&bytes) else { continue; }; for el in &b.elements { let Some(k) = el.rest() else { continue }; let Some(sprite) = el.sprite.as_ref() else { continue; }; let Some(&(off, size)) = b.sprites.get(sprite) else { continue; }; let Some(img) = t8ad::parse(&bytes[off..off + size]) else { continue; }; total += 1; let dpx = (el.pivot_x as i64 * 2 - img.width as i64).abs(); let dpy = (el.pivot_y as i64 * 2 - img.height as i64).abs(); if dpx <= 1 && dpy <= 1 { pivot_is_half += 1; } else if dpx > 16 || dpy > 16 { pivot_off_by_lots += 1; } let sx = if k.scale_x == 0 { 100 } else { k.scale_x }; let sy = if k.scale_y == 0 { 100 } else { k.scale_y }; if sx == 100 && sy == 100 { continue; } scaled += 1; // "About the pivot" and "about the centre" differ by // (pivot - size/2) * (scale - 1); a pixel of disagreement needs // both a real scale change and a pivot away from the centre. let dx = (el.pivot_x as i64 - img.width as i64 / 2).abs() * (sx as i64 - 100).abs() / 100; let dy = (el.pivot_y as i64 - img.height as i64 / 2).abs() * (sy as i64 - 100).abs() / 100; if dx.max(dy) >= 2 { discriminating += 1; } } } eprintln!("resting placements with a decoded sprite: {total}"); eprintln!(" pivot*2 == decoded size (+-1 px): {pivot_is_half}"); eprintln!(" pivot*2 off by more than 16 px: {pivot_off_by_lots}"); eprintln!(" of those, scaled != 100%: {scaled}"); eprintln!(" of those, pivot-vs-centre differ by >= 2 px: {discriminating}"); assert!( total > 4000, "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"); } /// The ghost skip is inert everywhere except where the capture licenses it. /// /// `0x4` means "repeated instance of a template", and on the title those are /// motion-trail ghosts the game does not show at rest. Skipping every `0x4` /// element for that reason would be over-broad: 174 elements on the disc are /// `0x4` with no non-`0x4` element of the same sprite (`GP_READY_ROOM` pak entry /// 75 is 56 elements, all of them `0x4` — a list of real icons). /// /// This pins the measurement that makes the narrow rule safe: **no bundle the /// compositor accepts contains such an element**, so the skip only ever drops a /// ghost whose template is right there beside it. If that ever stops being true, /// this fails and the rule needs re-deriving rather than quietly erasing a /// screen. #[test] fn no_composable_build_has_an_instance_without_its_template() { skip_without_disc!(root); 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 orphans = Vec::new(); let mut builds_seen = 0usize; // one pak at a time: holding every build on the disc at once OOM-kills the // test process when it runs alongside the others. for p in &paks { let name = p.file_name().unwrap().to_string_lossy().to_string(); for bundle in pak_builds(&root, &name) { let Some(build) = ui_layout::parse_build(&bundle) else { continue; }; builds_seen += 1; for el in &build.elements { if el.kind & 0x4 == 0 { continue; } if !build .elements .iter() .any(|o| o.kind & 0x4 == 0 && o.name == el.name) { orphans.push(format!("{name}: {} (kind {:#x})", el.name, el.kind)); } } } } assert!(builds_seen > 500, "expected the disc's builds, saw {builds_seen}"); assert!( orphans.is_empty(), "{} composable elements are kind=0x4 with no template present, so the \ ghost skip would erase real content: {:?}", orphans.len(), &orphans[..orphans.len().min(8)] ); } /// The DERIVED order reproduces both measured orders, up to ties. /// /// The measured orders come from the game's own runtime child list; the derived /// one sorts the elements by the layer key in their sprite's `T8aD` header /// (`docs/re/structures/ui-paint-order-key.md`). If the key really is what the /// game sorts by, the two agree wherever the key distinguishes the elements — /// so this compares the KEY SEQUENCE rather than the index sequence, which is /// what the claim actually is. Ties are not compared, because the game breaks /// them some other way and this does not know how. #[test] fn the_derived_order_matches_the_measured_ones_up_to_ties() { skip_without_disc!(root); // (element count, first element name, measured paint order) let cases: [(usize, &str, &[usize]); 2] = [ ( 24, "ptlogo1.t32", &[9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, 22, 23, 21, 8], ), (7, "palogo_eff0.prm", &[0, 2, 4, 6, 1, 3, 5]), ]; // EVERY RATC entry, not just the ones `is_build` accepts: the developer-logo // splash has no `.rat` child, so `is_build` rejects it — which also means the // compositor never sees that bundle today, worth knowing separately. let arc = PakArchive::open(root.join("dat").join("GP_TITLE.pak")).expect("open pak"); let bundles: Vec> = arc .entries() .iter() .filter_map(|e| arc.read(e).ok()) .collect(); // Which of the cases were seen. The splash exists TWICE in this pak (language // variants), so counting matches would over-count; what matters is that each // case was checked at least once. let mut seen = [false; 2]; let mut checked = 0; for bundle in bundles { let Some(build) = ui_layout::parse_build(&bundle) else { continue; }; for (ci, (n, first, measured)) in cases.iter().enumerate() { if build.elements.len() != *n || build.elements[0].name != *first { continue; } let key = |i: usize| { ui_layout::sprite_layer_key(&build, &bundle, &build.elements[i]) }; // 1. the measured order is non-decreasing in the key let mut last: Option = None; for &i in measured.iter() { if let Some(k) = key(i) { if let Some(prev) = last { assert!( k >= prev, "measured order inverts the layer key at element {i} \ ({}): {k:#x} after {prev:#x}", build.elements[i].name ); } last = Some(k); } } // 2. and the derived order produces the same key sequence let derived = ui_layout::compose( &build, &bundle, ComposeOptions::default(), None, ); let seq = |order: &[usize]| -> Vec { order.iter().filter_map(|&i| key(i)).collect() }; let measured_keys = seq(measured); let drawn_keys = seq(&derived.drawn); let mut expected = measured_keys.clone(); expected.retain(|k| drawn_keys.contains(k)); assert_eq!( drawn_keys, { let mut s = drawn_keys.clone(); s.sort(); s }, "the composite's key sequence is not sorted" ); seen[ci] = true; checked += 1; } } assert!( seen.iter().all(|&b| b), "expected both measured builds; seen = {seen:?} over {checked} matches" ); } /// The layer-key order is what every composite on the disc actually paints in, /// and it is not a no-op dressed up as a discovery. /// /// Two things are checked corpus-wide, because the derived order was adopted on /// the strength of **two** measured screens and then applied to all of them: /// /// * every composite's draw list is non-decreasing in the layer key, so the /// rule really reaches the whole corpus and a future accidental revert to /// declaration order fails here rather than silently; /// * the derived order reorders a substantial share of the disc's builds. If it /// were a near-no-op the two measured screens would be the only evidence /// there is, and the rule would deserve much less credit than it has. #[test] fn every_composite_paints_in_layer_key_order() { skip_without_disc!(root); let (mut total, mut checked, mut reordered) = (0usize, 0usize, 0usize); for_each_build(&root, |pak, bytes| { total += 1; let Some(b) = ui_layout::parse_build(bytes) else { return; }; if b.from_fallback { return; } let key = |i: usize| { ( ui_layout::sprite_layer_key(&b, bytes, &b.elements[i]).unwrap_or(u32::MAX), i, ) }; let mut want: Vec = (0..b.elements.len()).collect(); want.sort_by_key(|&i| key(i)); if want != (0..b.elements.len()).collect::>() { reordered += 1; } let c = ui_layout::compose(&b, bytes, ComposeOptions::default(), None); // The two screens read off the running game keep their measured order, // which agrees with the derived one only up to ties — skip those. if c.drawn.len() < 2 || b.elements.len() == 24 || b.elements.len() == 7 { return; } checked += 1; for w in c.drawn.windows(2) { assert!( key(w[0]) < key(w[1]), "{pak}: painted element {} (key {:#x}) before {} (key {:#x}) — the \ composite is no longer in layer-key order", w[0], key(w[0]).0, w[1], key(w[1]).0 ); } }); assert!(checked > 100, "only {checked} builds composed 2+ elements"); eprintln!("layer-key order: {reordered}/{total} builds reordered"); assert!( reordered * 4 > total, "the derived order reorders only {reordered} of {total} builds — too few \ to carry the weight the write-up puts on it" ); } /// A focused-state record is a **pair**, and the unpaired reading was deleting /// glow layers from most of the disc. /// /// `compose` skips focused records by default, so whatever this flag matches /// disappears from every composite. Matching a trailing `f` alone flagged 2 458 /// elements; only 54 of them — all `pgmenu_btnNNf.t32` — have the base element /// they would be the focused version of. The rest are `_eff` glows that merely /// end in the same letter, and the draw capture shows them being painted. #[test] fn a_focused_state_always_has_the_element_it_is_the_focused_state_of() { skip_without_disc!(root); let (mut total, mut flagged, mut eff) = (0usize, 0usize, 0usize); for_each_build(&root, |pak, bytes| { let Some(b) = ui_layout::parse_build(bytes) else { return; }; let names: Vec = b .elements .iter() .map(|e| e.name.to_ascii_lowercase()) .collect(); for el in &b.elements { total += 1; let l = el.name.to_ascii_lowercase(); if l.ends_with("_eff.t32") { eff += 1; assert!( !el.focused, "{pak}: {} is flagged as a focused state — it is a glow layer", el.name ); } if !el.focused { continue; } flagged += 1; let (stem, ext) = l.rsplit_once('.').expect("an extension"); let base = format!("{}.{}", &stem[..stem.len() - 1], ext); assert!( names.contains(&base), "{pak}: {} is flagged as a focused state but {base} is not in \ the build — the pairing requirement has been lost", el.name ); } }); assert!(total > 5000, "only {total} elements — the sweep did not run"); assert!(eff > 100, "only {eff} `_eff` elements, expected the disc's glows"); eprintln!("focused states: {flagged} of {total} elements; {eff} `_eff` glows kept"); } /// The developer-logo splash composes — glows and all — even though it has no /// `.rat` layout child and so is not an `is_build` "screen build". /// /// It is one of only two screens whose paint order has been read off the running /// game, and until `is_composable` existed it could not be rendered at all, which /// made that measurement uncheckable. #[test] fn the_developer_logo_splash_composes_with_its_glows() { skip_without_disc!(root); let arc = PakArchive::open(root.join("dat").join("GP_TITLE.pak")).expect("GP_TITLE.pak"); let mut seen = 0usize; for e in arc.entries() { let Ok(bytes) = arc.read(e) else { continue }; let Some(b) = ui_layout::parse_build(&bytes) else { continue; }; let names: Vec<&str> = b.elements.iter().map(|e| e.name.as_str()).collect(); if names != [ "palogo_eff0.prm", "palogo_gamearts.t32", "palogo_gamearts_eff.t32", "palogo_seta.t32", "palogo_seta_eff.t32", "palogo_anima.t32", "palogo_anima_eff.t32", ] { continue; } seen += 1; assert!( !ui_layout::is_build(&bytes), "the splash has gained a .rat child — the reason is_composable exists \ has changed and this test is now testing nothing" ); assert!(ui_layout::is_composable(&bytes)); let c = ui_layout::compose(&b, &bytes, ComposeOptions::default(), None); // All six sprites: three logos and the three `_eff` glows behind them. // The seventh element is a `.prm` primitive with no sprite to draw. assert_eq!( c.drawn, vec![2, 4, 6, 1, 3, 5], "the splash must paint its glows first, in the order measured off \ the running game" ); assert!(c.missing.is_empty(), "missing sprites: {:?}", c.missing); } assert!(seen >= 2, "found {seen} splash bundles, expected the language pair"); } /// Applying the keyframe's `fade` alpha must not gut the corpus. /// /// It is a modulate, and it can only ever *remove* pixels, so the risk it /// carries is a screen going blank. Measured: it is a no-op on 4 060 of the /// 5 200 sprite elements (alpha `0xff`), partial on 453, and hides 687 — which /// are transient HUD indicators (`pb_emergency`, `pb_refilling`, the target /// arrows) that should not be lit on a resting screen. **No build is left with /// nothing visible.** #[test] fn applying_the_fade_alpha_blanks_no_screen() { skip_without_disc!(root); let (mut sprite_els, mut hidden, mut noop) = (0usize, 0usize, 0usize); let (mut builds, mut blank) = (0usize, 0usize); for_each_build(&root, |pak, bytes| { let Some(b) = ui_layout::parse_build(bytes) else { return; }; if b.from_fallback { return; } builds += 1; let mut visible = 0usize; for el in &b.elements { if el.sprite.is_none() { continue; } let Some(k) = el.rest() else { continue }; sprite_els += 1; match (k.fade >> 24) & 0xff { 0 => hidden += 1, 0xff => { noop += 1; visible += 1; } _ => visible += 1, } } assert!( visible > 0 || b.elements.iter().all(|e| e.sprite.is_none()), "{pak}: every element of this build is hidden by its fade alpha" ); if visible == 0 { blank += 1; } }); assert!(sprite_els > 5000, "only {sprite_els} elements — sweep did not run"); assert_eq!(blank, 0, "{blank} of {builds} builds render nothing"); // The modulate must stay overwhelmingly a no-op. If a future change to the // resting rule pushes many elements onto a ramp frame, this catches it. assert!( noop * 4 > sprite_els * 3, "the fade alpha is a no-op on only {noop}/{sprite_els} elements — the \ resting rule is probably picking ramp frames instead of holds" ); eprintln!("fade alpha: {noop} no-op, {hidden} hidden, 0 blank builds of {builds}"); } /// **The derived order places every element in the right layer group, on all /// three measured screens — primitives included.** /// /// This is the strong form of the layer-key result. The weaker test above only /// asserts the measured order never inverts a key, which skips the elements that /// have none at all. With `implied_layer_key` supplying measured keys for the /// primitives, the derived sort must produce the **same key at every position** /// as the order read off the running game. /// /// It is stated as key-sequence equality and not as list equality on purpose: /// elements that share a key are ordered by something still unknown (the title's /// `0x8083` ×5 group paints `14,15,18,16,17`, the derived sort gives /// `14,15,16,17,18`), and that tie-break is a separate open question. Anything /// crossing a group boundary is a real regression and fails here. #[test] fn the_derived_order_puts_every_element_in_the_right_layer_group() { skip_without_disc!(root); let cases: [(usize, &str, &[usize]); 3] = [ ( 24, "ptlogo1.t32", &[9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, 22, 23, 21, 8], ), (7, "palogo_eff0.prm", &[0, 2, 4, 6, 1, 3, 5]), ( 16, "pteff00.prm", &[1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0], ), ]; let arc = PakArchive::open(root.join("dat").join("GP_TITLE.pak")).expect("open pak"); let mut seen = [false; 3]; let mut exact = 0usize; for e in arc.entries() { let Ok(bundle) = arc.read(e) else { continue }; let Some(build) = ui_layout::parse_build(&bundle) else { continue; }; for (ci, (n, first, measured)) in cases.iter().enumerate() { if build.elements.len() != *n || build.elements[0].name != *first { continue; } seen[ci] = true; let key = |i: usize| { let el = &build.elements[i]; ui_layout::sprite_layer_key(&build, &bundle, el) .or_else(|| ui_layout::implied_layer_key(&el.name)) .unwrap_or(u32::MAX) }; let derived = ui_layout::derived_paint_order(&build, &bundle); let dk: Vec = derived.iter().map(|&i| key(i)).collect(); let mk: Vec = measured.iter().map(|&i| key(i)).collect(); assert_eq!( dk, mk, "the {n}-element build starting {first}: the derived order's \ layer-key sequence differs from the one measured off the \ running game\n derived {derived:?}\n measured {measured:?}" ); if derived == measured.to_vec() { exact += 1; } } } assert!( seen.iter().all(|&b| b), "not every measured screen was found in GP_TITLE.pak: {seen:?}" ); eprintln!("layer groups match on all 3 measured screens; {exact} match element-for-element"); }