Files
Sylpheed/crates/sylpheed-formats/tests/ui_paint_order_disc.rs
Sylpheed RE agent 3f34b9c516 formats: derive the paint order from the sprite layer key
compose now sorts elements by the word at +0x08 of their sprite's T8aD header
instead of painting in declaration order, for every build except the two whose
measured order is hard-coded. That word is non-decreasing in the order the game
actually paints both measured screens, so every screen nobody has captured now
gets its layering from the file rather than from the declaration table, which is
provably not the paint order.

Verified with artifacts and both ways, not by a green build: the disc test
asserts the measured orders never invert the key and that the composite's key
sequence is sorted, and reading the word from +0x0c instead makes it fail; the
title composites identically; and GP_MISSION_SELECT — uncaptured — now composites
cleanly, committed as a capture.

Two things recorded rather than smoothed over: ties keep declaration order
because the game breaks them some other way that is not known, and the
developer-logo splash has no .rat child, so is_build rejects it and the
compositor never sees that bundle at all — its measured order is inert in
practice and screen render cannot draw it.
2026-08-19 05:28:05 +00:00

430 lines
17 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}"
);
}
/// 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"
);
}