formats: derive the paint order from the sprite layer key

compose now sorts elements by the word at +0x08 of their sprite's T8aD header
instead of painting in declaration order, for every build except the two whose
measured order is hard-coded. That word is non-decreasing in the order the game
actually paints both measured screens, so every screen nobody has captured now
gets its layering from the file rather than from the declaration table, which is
provably not the paint order.

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

Two things recorded rather than smoothed over: ties keep declaration order
because the game breaks them some other way that is not known, and the
developer-logo splash has no .rat child, so is_build rejects it and the
compositor never sees that bundle at all — its measured order is inert in
practice and screen render cannot draw it.
This commit is contained in:
Sylpheed RE agent
2026-08-19 05:28:05 +00:00
parent aa1f49633e
commit d9ae42dd55
4 changed files with 159 additions and 1 deletions

View File

@@ -333,3 +333,97 @@ fn no_composable_build_has_an_instance_without_its_template() {
&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"
);
}