diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index c5635b03..a32193df 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -137,6 +137,12 @@ enum ScreenCommands { /// Which build (index into `screen list`); default = the largest #[arg(long)] build: Option, + /// Also print the bundle's other orderings and, per element, the decoded + /// sprite size beside the declared pivot and every keyframe's + /// scale/position/time — what a placement or paint-order hypothesis has + /// to be tested against. + #[arg(long)] + geometry: bool, }, /// Composite one build to a PNG — the headless self-verify for the viewer Render { @@ -301,7 +307,11 @@ async fn main() -> Result<()> { }, Commands::Screen { cmd } => match cmd { ScreenCommands::List { pak } => cmd_screen_list(&pak), - ScreenCommands::Info { pak, build } => cmd_screen_info(&pak, build), + ScreenCommands::Info { + pak, + build, + geometry, + } => cmd_screen_info(&pak, build, geometry), ScreenCommands::Render { pak, output, @@ -379,7 +389,7 @@ fn cmd_screen_list(pak: &Path) -> Result<()> { Ok(()) } -fn cmd_screen_info(pak: &Path, want: Option) -> Result<()> { +fn cmd_screen_info(pak: &Path, want: Option, geometry: bool) -> Result<()> { use sylpheed_formats::ui_layout; let builds = screen_builds(pak)?; let idx = pick_build(&builds, want)?; @@ -436,9 +446,82 @@ fn cmd_screen_info(pak: &Path, want: Option) -> Result<()> { println!("{:<3} {:<30} → focus {link}", "", ""); } } + if geometry { + print_geometry(&b, bytes); + } Ok(()) } +/// The two other orderings the bundle carries, and each element's real drawn +/// size. Both were needed to settle the title screen's paint order — see +/// `docs/re/BACKLOG.md`. +fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) { + println!(); + let identity: Vec = (0..b.placement_order.len()).collect(); + println!( + "placement-region group order: {:?}{}", + b.placement_order, + if b.placement_order == identity { + " (== declaration order)" + } else { + " (DIFFERS from declaration order)" + } + ); + if let Some(kids) = sylpheed_formats::ratc::parse(bytes) { + println!( + "RATC child order: {:?}", + kids.iter() + .map(|c| format!("{}:{}", c.kind, c.name)) + .collect::>() + ); + } + println!(); + println!("geometry — decoded sprite size vs the declared pivot, and every keyframe"); + println!( + "{:<3} {:<26} {:>11} {:>11} {:>5} keyframes t: x,y sx%,sy% a=fade-alpha", + "#", "sprite", "decoded", "pivot*2", "same" + ); + for el in &b.elements { + let dims = el + .sprite + .as_ref() + .and_then(|s| b.sprites.get(s)) + .and_then(|&(off, size)| sylpheed_formats::t8ad::parse(&bytes[off..off + size])) + .map(|img| (img.width, img.height)); + let pv = (el.pivot_x * 2, el.pivot_y * 2); + let same = match dims { + Some(d) if d == pv => "yes", + Some(_) => "NO", + None => "-", + }; + let kfs = el + .keyframes + .iter() + .map(|f| { + format!( + "{}: {},{} {}%,{}% a={}", + f.time.map(|v| v.to_string()).unwrap_or_else(|| "-".into()), + f.x, + f.y, + f.scale_x, + f.scale_y, + (f.fade >> 24) & 0xff + ) + }) + .collect::>() + .join(" "); + println!( + "{:<3} {:<26} {:>11} {:>11} {:>5} {kfs}", + el.index, + el.sprite.as_deref().unwrap_or("—"), + dims.map(|(w, h)| format!("{w}x{h}")) + .unwrap_or_else(|| "—".into()), + format!("{}x{}", pv.0, pv.1), + same, + ); + } +} + fn cmd_screen_render( pak: &Path, output: &Path, diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index bad0918a..2fa859c4 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -169,6 +169,14 @@ pub struct UiBuild { /// by scanning `.rat` records instead — placements are then per-record and /// `.rat`-less elements are missing. pub from_fallback: bool, + /// Element indices in the order the placement region stores their keyframe + /// groups. Each group names its element explicitly, so this *could* be a + /// second, independent ordering — and therefore a candidate for the paint + /// order the title screen needs. It is not: it equals the declaration order + /// on every build on the disc. Kept, with + /// `placement_region_order_is_never_a_second_ordering` guarding it, so the + /// refutation stays checkable rather than remembered. + pub placement_order: Vec, } /// Whether `bundle` is a RATC screen build (has at least one `.rat` layout child). @@ -254,8 +262,9 @@ fn parse_decls(bundle: &[u8]) -> Option> { /// Read the placement region that follows the declaration table, filling in each /// element's keyframe group. -fn parse_placements(bundle: &[u8], elements: &mut [Element]) { +fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec { let count = elements.len(); + let mut order = Vec::with_capacity(count); let mut pos = DECL_TABLE_AT + count * DECL_ENTRY; for _ in 0..count { if pos + 8 > bundle.len() { @@ -290,8 +299,10 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) { }); } elements[idx].keyframes = group; + order.push(idx); pos = group_end; } + order } /// Parse a build bundle into its elements and sprite table. @@ -308,15 +319,19 @@ pub fn parse_build(bundle: &[u8]) -> Option { } } - let (mut elements, from_fallback) = match parse_decls(bundle) { + let (mut elements, from_fallback, placement_order) = match parse_decls(bundle) { Some(mut els) => { - parse_placements(bundle, &mut els); - (els, false) + let order = parse_placements(bundle, &mut els); + (els, false, order) } // No usable declaration table: recover what the `.rat` records alone can // say. Elements without a record (eff*/deli*/msg) are then missing, so // callers are told via `from_fallback`. - None => (fallback_elements(bundle, &records), true), + None => { + let els = fallback_elements(bundle, &records); + let order = (0..els.len()).collect(); + (els, true, order) + } }; // Resolve each element to the sprite it draws, and pick up its focus link. @@ -355,6 +370,7 @@ pub fn parse_build(bundle: &[u8]) -> Option { sprites, context_hint, from_fallback, + placement_order, }) } diff --git a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs new file mode 100644 index 00000000..f1014dcf --- /dev/null +++ b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs @@ -0,0 +1,218 @@ +//! 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}" + ); +}