Files
Sylpheed/crates/sylpheed-formats/tests/ui_paint_order_disc.rs
Sylpheed RE agent 705d9a37ba formats/cli: the bundle's other orderings, and a guard that neither is a paint order
The title screen needs an order that puts element 13 (`ptbase2.t32`, the
full-screen background) behind elements 0-5 (the wordmarks) — the capture shows
the wordmarks on top, so the declaration table is not it. Two other orderings
the bundle carries were the cheap candidates, and both are now dead:

* the **placement region** stores a keyframe group per element with an explicit
  element index, so it could be a second ordering. It is not — it equals the
  declaration order on every build on the disc. `UiBuild::placement_order`
  exposes it and `placement_region_order_is_never_a_second_ordering` pins it, so
  the refutation stays checkable instead of remembered.
* the **RATC child order** is the declaration order with the `.prm` elements
  absent — strictly less information, and no place to put the background other
  than where the table already puts it.

`screen info --geometry` prints both, plus each element's decoded sprite size
beside `pivot*2` and every keyframe's scale/position/time — the numbers a
placement hypothesis has to be tested against, and how the pivot/scale rule in
the previous commit was found.

`title_background_is_full_screen` pins that rule against the disc rather than a
synthetic sprite. `scaled_elements_are_a_small_and_mostly_undiscriminating_minority`
reports the scope honestly: 865 of 5 130 resting placements are scaled at all,
and only 213 of those could tell "about the pivot" from "about the sprite
centre" — which the capture did *not* settle, because `ptbase2`'s pivot is its
centre. It also counts how far `pivot*2` is from the decoded size disc-wide
(2 521 agree, 1 884 are off by more than 16 px), which demotes the "pivot is
exactly half the texture" result to a property of the tutorial bundle.
2026-08-18 16:32:40 +00:00

219 lines
7.9 KiB
Rust

//! 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<PathBuf> {
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<u8>)> {
let mut paks: Vec<PathBuf> = 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<Vec<u8>> {
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<usize> = (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}"
);
}