Files
Sylpheed/crates/sylpheed-formats/tests/ui_paint_order_disc.rs
Sylpheed RE agent 4e9600ddeb formats: check the derived paint order against the screens already verified
The layer-key order was adopted from two measured screens and then applied to
every build on the disc, so it owed a regression check against the screens the
corpus had already validated against the running game.

Rendered the tutorial PAUSE menu and the title main menu both ways and diffed:
3.8 % and 1.1 % of pixels differ, max delta 45/255 and 34/255, and the two
renders are indistinguishable in layout — the change is confined to blends where
translucent sprites overlap. No regression, but which order is more faithful on
those two screens is unsettled and recorded as such.

Adds a corpus-wide test asserting every composite's draw list is strictly
increasing in (layer key, declaration index), streaming one pak at a time so it
does not OOM alongside the other whole-disc tests. It reports the rule's reach:
341 of 965 builds are reordered, and it fails if that share collapses.
2026-08-19 05:43:08 +00:00

518 lines
20 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
}
/// 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<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();
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<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}"
);
}
/// 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::path::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 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<Vec<u8>> = 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<u32> = 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<u32> {
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<usize> = (0..b.elements.len()).collect();
want.sort_by_key(|&i| key(i));
if want != (0..b.elements.len()).collect::<Vec<_>>() {
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"
);
}