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

@@ -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;