Files
Sylpheed/crates/sylpheed-formats/tests/ui_paint_order_disc.rs
Fabian Hamm 15d51b30ac test(formats): make $SYLPHEED_DISC an actual control (#16 remedy 3)
Before this commit, `unset SYLPHEED_DISC` did not disable the disc-backed
suites on the machine that has the disc: every `disc_root()` fell back to a
hardcoded absolute path that exists on this box. The env var looked like a
control and was not one. Same for $SYLPHEED_RES3D and $SYLPHEED_ISO.

Replace the duplicated resolvers with one `tests/common/mod.rs`:

  - 17 local `disc_root()` definitions -> 1
  - 7 copies of the skip macro -> 1 (`skip_without_disc!` and siblings)
  - 16 hardcoded absolute paths -> 0 executable ones
    (3 of those were inline in `mesh_disc.rs`, in no resolver at all,
     and 2 were in `examples/`)
  - `corpus_report.rs` now reports on the SAME resolver the suites use,
    instead of a second copy of the logic its own comments flagged as a
    drift risk.

The 17 copies had already drifted into FIVE variants, and they were not all
the same function. `movie_manifest_disc`, `movie_subtitle_disc` and `slb_disc`
honoured $SYLPHEED_DISC and nothing else, while the other 14 fell back. So one
name already meant two things -- a third instance of the shape #16 is about.
The shared helper adopts the env-only behaviour those three already had, rather
than inventing a sixth variant.

Two module docs still described the fallback after it was deleted, which is the
same defect in prose: `texture_disc` claimed "or the default dev path exists"
and `pak_idxd_disc` said "or drop it at the default dev path below". Both now
say what the code does.

Verified both ways on the machine that HAS the corpus, which is the only place
this refactor can be falsified:

  A  env unset  -> "ABSENT -- $SYLPHEED_DISC unset; its suites self-skip"
                   suites=31 passed=209 failed=0 ignored=14, slowest 0.12s
  B  env set    -> "PRESENT via $SYLPHEED_DISC" (all three corpora)
                   suites=31 passed=209 failed=0 ignored=14,
                   slowest 1235.53s (mesh_consistency_disc)

Identical tallies, opposite corpus states, ~10000x apart in wall clock. (A) is
new behaviour -- it was previously unreachable here. (B) proves nothing broke.

`just test-disc` sources `.env` (already gitignored) for the set case. Note the
quoting trap documented there: the corpus paths contain spaces, and an unquoted
`VAR=a b c` parses as "run command `b`", failing silently into ABSENT -- which
looks exactly like a working skip.

Remedy (1) (`#[ignore]` + `--ignored`) is deliberately NOT done here: (3) already
moves the mode from the filesystem into the environment, and `#[ignore]` already
carries three meanings in this directory (corpus-absent, known-failing, bare).
Overloading it a fourth time would re-create the defect.

`cargo fmt --all --check` clean; no new compiler warnings.

Refs #16

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:47:23 +02:00

982 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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},
};
mod common;
use common::skip_without_disc;
/// 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"
);
}
/// A focused-state record is a **pair**, and the unpaired reading was deleting
/// glow layers from most of the disc.
///
/// `compose` skips focused records by default, so whatever this flag matches
/// disappears from every composite. Matching a trailing `f` alone flagged 2 458
/// elements; only 54 of them — all `pgmenu_btnNNf.t32` — have the base element
/// they would be the focused version of. The rest are `_eff` glows that merely
/// end in the same letter, and the draw capture shows them being painted.
#[test]
fn a_focused_state_always_has_the_element_it_is_the_focused_state_of() {
skip_without_disc!(root);
let (mut total, mut flagged, mut eff) = (0usize, 0usize, 0usize);
for_each_build(&root, |pak, bytes| {
let Some(b) = ui_layout::parse_build(bytes) else {
return;
};
let names: Vec<String> = b
.elements
.iter()
.map(|e| e.name.to_ascii_lowercase())
.collect();
for el in &b.elements {
total += 1;
let l = el.name.to_ascii_lowercase();
if l.ends_with("_eff.t32") {
eff += 1;
assert!(
!el.focused,
"{pak}: {} is flagged as a focused state — it is a glow layer",
el.name
);
}
if !el.focused {
continue;
}
flagged += 1;
let (stem, ext) = l.rsplit_once('.').expect("an extension");
let base = format!("{}.{}", &stem[..stem.len() - 1], ext);
assert!(
names.contains(&base),
"{pak}: {} is flagged as a focused state but {base} is not in \
the build — the pairing requirement has been lost",
el.name
);
}
});
assert!(
total > 5000,
"only {total} elements — the sweep did not run"
);
assert!(
eff > 100,
"only {eff} `_eff` elements, expected the disc's glows"
);
eprintln!("focused states: {flagged} of {total} elements; {eff} `_eff` glows kept");
}
/// The developer-logo splash composes — glows and all — even though it has no
/// `.rat` layout child and so is not an `is_build` "screen build".
///
/// It is one of only two screens whose paint order has been read off the running
/// game, and until `is_composable` existed it could not be rendered at all, which
/// made that measurement uncheckable.
#[test]
fn the_developer_logo_splash_composes_with_its_glows() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat").join("GP_TITLE.pak")).expect("GP_TITLE.pak");
let mut seen = 0usize;
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&bytes) else {
continue;
};
let names: Vec<&str> = b.elements.iter().map(|e| e.name.as_str()).collect();
if names
!= [
"palogo_eff0.prm",
"palogo_gamearts.t32",
"palogo_gamearts_eff.t32",
"palogo_seta.t32",
"palogo_seta_eff.t32",
"palogo_anima.t32",
"palogo_anima_eff.t32",
]
{
continue;
}
seen += 1;
assert!(
!ui_layout::is_build(&bytes),
"the splash has gained a .rat child — the reason is_composable exists \
has changed and this test is now testing nothing"
);
assert!(ui_layout::is_composable(&bytes));
let c = ui_layout::compose(&b, &bytes, ComposeOptions::default(), None);
// All six sprites: three logos and the three `_eff` glows behind them.
// The seventh element is a `.prm` primitive with no sprite to draw.
assert_eq!(
c.drawn,
vec![2, 4, 6, 1, 3, 5],
"the splash must paint its glows first, in the order measured off \
the running game"
);
assert!(c.missing.is_empty(), "missing sprites: {:?}", c.missing);
}
assert!(
seen >= 2,
"found {seen} splash bundles, expected the language pair"
);
}
/// Applying the keyframe's `fade` alpha must not gut the corpus.
///
/// It is a modulate, and it can only ever *remove* pixels, so the risk it
/// carries is a screen going blank. Measured: it is a no-op on 4 060 of the
/// 5 200 sprite elements (alpha `0xff`), partial on 453, and hides 687 — which
/// are transient HUD indicators (`pb_emergency`, `pb_refilling`, the target
/// arrows) that should not be lit on a resting screen. **No build is left with
/// nothing visible.**
#[test]
fn applying_the_fade_alpha_blanks_no_screen() {
skip_without_disc!(root);
let (mut sprite_els, mut hidden, mut noop) = (0usize, 0usize, 0usize);
let (mut builds, mut blank) = (0usize, 0usize);
for_each_build(&root, |pak, bytes| {
let Some(b) = ui_layout::parse_build(bytes) else {
return;
};
if b.from_fallback {
return;
}
builds += 1;
let mut visible = 0usize;
for el in &b.elements {
if el.sprite.is_none() {
continue;
}
let Some(k) = el.rest() else { continue };
sprite_els += 1;
match (k.fade >> 24) & 0xff {
0 => hidden += 1,
0xff => {
noop += 1;
visible += 1;
}
_ => visible += 1,
}
}
assert!(
visible > 0 || b.elements.iter().all(|e| e.sprite.is_none()),
"{pak}: every element of this build is hidden by its fade alpha"
);
if visible == 0 {
blank += 1;
}
});
assert!(
sprite_els > 5000,
"only {sprite_els} elements — sweep did not run"
);
assert_eq!(blank, 0, "{blank} of {builds} builds render nothing");
// The modulate must stay overwhelmingly a no-op. If a future change to the
// resting rule pushes many elements onto a ramp frame, this catches it.
assert!(
noop * 4 > sprite_els * 3,
"the fade alpha is a no-op on only {noop}/{sprite_els} elements — the \
resting rule is probably picking ramp frames instead of holds"
);
eprintln!("fade alpha: {noop} no-op, {hidden} hidden, 0 blank builds of {builds}");
}
/// **The derived order places every element in the right layer group, on all
/// three measured screens — primitives included.**
///
/// This is the strong form of the layer-key result. The weaker test above only
/// asserts the measured order never inverts a key, which skips the elements that
/// have none at all. With `implied_layer_key` supplying measured keys for the
/// primitives, the derived sort must produce the **same key at every position**
/// as the order read off the running game.
///
/// It is stated as key-sequence equality and not as list equality on purpose:
/// elements that share a key are ordered by something still unknown (the title's
/// `0x8083` ×5 group paints `14,15,18,16,17`, the derived sort gives
/// `14,15,16,17,18`), and that tie-break is a separate open question. Anything
/// crossing a group boundary is a real regression and fails here.
#[test]
fn the_derived_order_puts_every_element_in_the_right_layer_group() {
skip_without_disc!(root);
let cases: [(usize, &str, &[usize]); 3] = [
(
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]),
(
16,
"pteff00.prm",
&[1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0],
),
];
let arc = PakArchive::open(root.join("dat").join("GP_TITLE.pak")).expect("open pak");
let mut seen = [false; 3];
let mut exact = 0usize;
for e in arc.entries() {
let Ok(bundle) = arc.read(e) else { continue };
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;
}
seen[ci] = true;
let key = |i: usize| {
let el = &build.elements[i];
ui_layout::sprite_layer_key(&build, &bundle, el)
.or_else(|| ui_layout::implied_layer_key(&el.name))
.unwrap_or(u32::MAX)
};
let derived = ui_layout::derived_paint_order(&build, &bundle);
let dk: Vec<u32> = derived.iter().map(|&i| key(i)).collect();
let mk: Vec<u32> = measured.iter().map(|&i| key(i)).collect();
assert_eq!(
dk, mk,
"the {n}-element build starting {first}: the derived order's \
layer-key sequence differs from the one measured off the \
running game\n derived {derived:?}\n measured {measured:?}"
);
if derived == measured.to_vec() {
exact += 1;
}
}
}
assert!(
seen.iter().all(|&b| b),
"not every measured screen was found in GP_TITLE.pak: {seen:?}"
);
eprintln!("layer groups match on all 3 measured screens; {exact} match element-for-element");
}
/// **Exactly which order disagreements change a pixel** — measured, not assumed.
///
/// The tie-break within a layer group is unsolved (see
/// `structures/ui-paint-order-key.md`). On the menu and the splash it does not
/// bite: ties there come out in declaration order, which is what the sort gives.
/// The title is the one screen where the game orders a tied group differently,
/// and after the `kind = 0x4` repeat instances are skipped exactly **one**
/// disagreeing pair of drawn elements survives: the game paints `ptlogo_tm`
/// before `ptlogo2`, the sort puts it after.
///
/// Across all three measured screens there are **3** disagreeing pairs of drawn
/// elements. All 3 have overlapping bounding boxes; **2** actually share opaque
/// pixels, and both are `ptlogo_back2eff5` against a neighbour — the game paints
/// it third in the `0x8083` group (`eff1, eff2, eff5, eff3, eff4`), the sort
/// paints it fifth, and 22 568 / 32 395 blended pixels differ as a result.
///
/// The third pair, `ptlogo2` vs `ptlogo_tm`, is the instructive one: their rects
/// overlap by two columns (the wordmark decodes 992 px wide and reaches x=1129,
/// the trademark starts at x=1127) but the wordmark is fully transparent there,
/// so it cannot matter. Bounding boxes would have called it a defect; alpha says
/// it is not. That is why this test reads pixels rather than rectangles.
#[test]
fn order_disagreements_that_change_pixels_are_pinned() {
skip_without_disc!(root);
let cases: [(usize, &str, &[usize]); 3] = [
(
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]),
(
16,
"pteff00.prm",
&[1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0],
),
];
let arc = PakArchive::open(root.join("dat").join("GP_TITLE.pak")).expect("open pak");
let (mut seen, mut pairs, mut boxes) = ([false; 3], 0usize, 0usize);
let mut matters: Vec<String> = Vec::new();
for e in arc.entries() {
let Ok(bundle) = arc.read(e) else { continue };
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;
}
seen[ci] = true;
// Everything needed to ask "is this pixel opaque here": the decoded
// sprite, its placed rect, and the scale mapping back into it.
struct Drawn {
img: t8ad::T8adImage,
ox: i32,
oy: i32,
dw: i32,
dh: i32,
}
let drawn = |i: usize| -> Option<Drawn> {
let el = &build.elements[i];
if el.animated || el.focused {
return None;
}
if el.kind & 0x4 != 0
&& build
.elements
.iter()
.any(|o| o.kind & 0x4 == 0 && o.name == el.name)
{
return None;
}
let kf = el.rest()?;
if (kf.fade >> 24) & 0xff == 0 {
return None; // fully faded out — contributes nothing
}
let sprite = el.sprite.as_ref()?;
let &(off, size) = build.sprites.get(sprite)?;
let img = t8ad::parse(&bundle[off..off + size])?;
let (sx, sy) = (kf.scale_x.max(1), kf.scale_y.max(1));
let (dw, dh) = (
(img.width * sx / 100) as i32,
(img.height * sy / 100) as i32,
);
let ox = kf.x - (el.pivot_x as i32 * (sx as i32 - 100)) / 100;
let oy = kf.y - (el.pivot_y as i32 * (sy as i32 - 100)) / 100;
Some(Drawn {
img,
ox,
oy,
dw,
dh,
})
};
let alpha_at = |d: &Drawn, x: i32, y: i32| -> u8 {
let (cx, cy) = (x - d.ox, y - d.oy);
if cx < 0 || cy < 0 || cx >= d.dw || cy >= d.dh {
return 0;
}
let sxi = ((cx as u32) * d.img.width / d.dw as u32).min(d.img.width - 1);
let syi = ((cy as u32) * d.img.height / d.dh as u32).min(d.img.height - 1);
let idx = ((syi * d.img.width + sxi) * 4 + 3) as usize;
d.img.rgba.get(idx).copied().unwrap_or(0)
};
let derived = ui_layout::derived_paint_order(&build, &bundle);
let pos = |order: &[usize], i: usize| order.iter().position(|&x| x == i).unwrap();
for a in 0..*n {
for b in (a + 1)..*n {
if (pos(&derived, a) < pos(&derived, b))
== (pos(measured, a) < pos(measured, b))
{
continue;
}
let (Some(da), Some(db)) = (drawn(a), drawn(b)) else {
continue; // at least one is not drawn — cannot matter
};
pairs += 1;
let (x0, y0) = (da.ox.max(db.ox), da.oy.max(db.oy));
let (x1, y1) = (
(da.ox + da.dw).min(db.ox + db.dw),
(da.oy + da.dh).min(db.oy + db.dh),
);
if x0 >= x1 || y0 >= y1 {
continue; // rects disjoint
}
boxes += 1;
let mut shared = 0usize;
for y in y0..y1 {
for x in x0..x1 {
if alpha_at(&da, x, y) != 0 && alpha_at(&db, x, y) != 0 {
shared += 1;
}
}
}
if shared > 0 {
matters.push(format!(
"{} vs {} ({shared} px)",
build.elements[a].name, build.elements[b].name
));
}
}
}
}
}
assert!(
seen.iter().all(|&b| b),
"not every measured screen was found: {seen:?}"
);
matters.sort();
matters.dedup();
eprintln!(
"{pairs} drawn pair(s) ordered differently from the game, {boxes} with \
overlapping rects, {} sharing opaque pixels:",
matters.len()
);
for m in &matters {
eprintln!(" {m}");
}
// Pinned, so that a change to the sort which makes this WORSE fails here.
// Both survivors are `ptlogo_back2eff5` against its neighbours: the game
// paints it third in the `0x8083` group (eff1, eff2, eff5, eff3, eff4) and
// the sort paints it fifth. Nothing in the file predicts that placement.
assert_eq!(
matters,
vec![
"ptlogo_back2eff3.t32 vs ptlogo_back2eff5.t32 (22568 px)".to_string(),
"ptlogo_back2eff4.t32 vs ptlogo_back2eff5.t32 (32395 px)".to_string(),
],
"the set of order disagreements that actually change pixels has moved"
);
}
/// Two more paint orders read off the running game, from `GP_SAVE_LOAD` — and
/// the first **independent confirmation** of the derived rule.
///
/// The three orders the rule was built from all live in `GP_TITLE.pak`. These do
/// not, and one of them the sort gets exactly right without having been told
/// anything about it:
///
/// * the **slot list header** (9 elements) composites `[7,8,0,1,2,3,4,5,6]`,
/// which is what sorting by layer key gives — including **two tied groups**
/// (`0xb102` ×2 and `0xb210` ×5) that both come out in declaration order, and
/// the unkeyed `pfeff00.prm` fade quad last. Exact on all five bundle
/// instances of it.
/// * the **save/load frame** (13 elements) does not, in exactly two ways, and
/// both are already-known open questions rather than new ones: two unkeyed
/// `pfbase.tbm` background elements paint **first** where the sort puts the
/// keyless last, and the `0xb100` group of four paints `10,11,8,12` where
/// declaration order is `8,10,11,12`.
#[test]
fn the_save_load_screens_match_what_the_running_game_paints() {
skip_without_disc!(root);
let header: [usize; 9] = [7, 8, 0, 1, 2, 3, 4, 5, 6];
let frame: [usize; 13] = [0, 1, 2, 3, 4, 5, 6, 7, 10, 11, 8, 12, 9];
let arc = PakArchive::open(root.join("dat").join("GP_SAVE_LOAD.pak")).expect("pak");
let (mut headers, mut frames) = (0usize, 0usize);
for e in arc.entries() {
let Ok(bundle) = arc.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&bundle) else {
continue;
};
let names: Vec<&str> = b.elements.iter().map(|e| e.name.as_str()).collect();
let derived = ui_layout::derived_paint_order(&b, &bundle);
if b.elements.len() == 9 && names[0] == "pftitlebase.t32" && names[6] == "pfeff00.prm" {
headers += 1;
assert_eq!(
derived,
header.to_vec(),
"the slot-list header no longer composites in the order the game paints"
);
}
if b.elements.len() == 13 && names[0] == "pfbase.tbm" {
frames += 1;
// The two known gaps, stated as the measured difference rather than
// asserted away: the sort must at least agree on everything else.
let key = |i: usize| {
let el = &b.elements[i];
ui_layout::sprite_layer_key(&b, &bundle, el)
.or_else(|| ui_layout::implied_layer_key(&el.name))
.unwrap_or(u32::MAX)
};
let dk: Vec<u32> = derived.iter().map(|&i| key(i)).collect();
let mk: Vec<u32> = frame.iter().map(|&i| key(i)).collect();
assert_eq!(
dk, mk,
"the save/load frame's layer-key sequence differs from the game's \
— the only accepted differences are WITHIN a key group\n \
derived {derived:?}\n measured {frame:?}"
);
}
}
assert!(
headers >= 3,
"found {headers} slot-list headers, expected several"
);
assert!(frames >= 1, "found {frames} save/load frames");
eprintln!("GP_SAVE_LOAD: {headers} headers exact, {frames} frames agree by layer group");
}