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:
@@ -493,6 +493,43 @@ pub fn compose_build(bundle: &[u8], include_focus: bool) -> Option<ComposedScree
|
||||
/// `visible`, when given, selects elements by index — the viewer uses it for
|
||||
/// per-element toggles. Elements are drawn in declaration order, which is the
|
||||
/// screen's own back-to-front order.
|
||||
/// The layer key a sprite carries, from the word at `+0x08` of its `T8aD`
|
||||
/// header — the field the paint order sorts by.
|
||||
///
|
||||
/// Measured, not guessed: read in the order the game actually paints them, this
|
||||
/// word is non-decreasing on both screens whose order has been captured, with no
|
||||
/// inversion (`docs/re/structures/ui-paint-order-key.md`). On the developer-logo
|
||||
/// splash it explains the whole permutation — the `_eff` glows carry `0xa100`
|
||||
/// and their base logos `0xa110`, so the glows paint first even though the
|
||||
/// declaration table interleaves them.
|
||||
pub fn sprite_layer_key(build: &UiBuild, bundle: &[u8], el: &Element) -> Option<u32> {
|
||||
let sprite = el.sprite.as_ref()?;
|
||||
let &(off, size) = build.sprites.get(sprite)?;
|
||||
if size < 0x0c {
|
||||
return None;
|
||||
}
|
||||
Some(be32(bundle, off + 8))
|
||||
}
|
||||
|
||||
/// The paint order derived from the bundle: a stable sort of the elements by
|
||||
/// their sprite's layer key.
|
||||
///
|
||||
/// Elements with no sprite (the `.prm` primitives) have no key and keep their
|
||||
/// declaration position among themselves; `compose` skips them anyway, so where
|
||||
/// they land does not affect a composite. Ties keep declaration order — the game
|
||||
/// breaks them some other way, which is unexplained and looks harmless because
|
||||
/// tied elements are same-layer.
|
||||
fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
(
|
||||
sprite_layer_key(build, bundle, &build.elements[i]).unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
/// Paint orders **measured from the running game**, not derived from the file.
|
||||
///
|
||||
/// The bundle does not say what order its elements paint in — the game builds a
|
||||
@@ -555,8 +592,11 @@ pub fn compose(
|
||||
let mut missing = Vec::new();
|
||||
// Measured paint order when one exists for this build, declaration order
|
||||
// otherwise — see `measured_paint_order`.
|
||||
// Measured order when this build is one of the two read off the running
|
||||
// game; otherwise the order DERIVED from the sprites' layer keys, which
|
||||
// reproduces both measured orders up to ties.
|
||||
let order: Vec<usize> =
|
||||
measured_paint_order(build).unwrap_or_else(|| (0..build.elements.len()).collect());
|
||||
measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle));
|
||||
for &ei in &order {
|
||||
let Some(el) = build.elements.get(ei) else {
|
||||
continue;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
BIN
docs/re/captures/mission-select-derived-order.png
Normal file
BIN
docs/re/captures/mission-select-derived-order.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
@@ -64,3 +64,27 @@ port can read directly.
|
||||
* **Two screens is two screens.** A third measured permutation would either
|
||||
promote this to a rule or break it. The cheapest one available is any screen
|
||||
whose object is resident at the same time as the title's.
|
||||
|
||||
|
||||
## Landed in the compositor (2026-08-19)
|
||||
|
||||
`ui_layout::compose` now paints in the **derived** order — a stable sort of the
|
||||
elements by `sprite_layer_key` — for every build except the two whose measured
|
||||
order is hard-coded, which stay as the ground truth they are. Elements with no
|
||||
sprite (the `.prm` primitives) have no key; they keep their declaration position
|
||||
among themselves and `compose` skips them anyway.
|
||||
|
||||
Verified three ways rather than by a green build:
|
||||
|
||||
* a disc-gated test asserts the measured orders are **non-decreasing** in the key
|
||||
and that the composite's key sequence comes out sorted — and it was checked
|
||||
both ways: reading the word from `+0x0c` instead of `+0x08` makes it fail;
|
||||
* the title still composites identically (its measured order is used);
|
||||
* a screen nobody has captured — `GP_MISSION_SELECT` — now composites cleanly
|
||||
([capture](../captures/mission-select-derived-order.png)).
|
||||
|
||||
**Found on the way, and worth its own line:** the developer-logo splash bundle
|
||||
has no `.rat` child, so `ui_layout::is_build` rejects it and the compositor never
|
||||
sees it. Its measured order is therefore inert in practice, and the splash cannot
|
||||
be rendered by `screen render` at all. That is a separate gap in what counts as a
|
||||
"build", not a paint-order question.
|
||||
|
||||
Reference in New Issue
Block a user