//! 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 } /// 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}" ); }