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.
518 lines
20 KiB
Rust
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"
|
|
);
|
|
}
|