`mesh_consistency_disc.rs` had the only conflict: this branch added a `Sightings` type alias where #22 replaced the file's private `disc_root()` with the shared `common::disc_root`. Both kept — they are unrelated edits that happened to land in the same lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
988 lines
40 KiB
Rust
988 lines
40 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},
|
||
};
|
||
|
||
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
|
||
.as_chunks::<4>()
|
||
.0
|
||
.iter()
|
||
.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");
|
||
}
|