diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index f7d2c790..60702f49 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -493,6 +493,43 @@ pub fn compose_build(bundle: &[u8], include_focus: bool) -> Option Option { + 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 { + let mut idx: Vec = (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 = - 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; diff --git a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs index c2d573c0..89b66954 100644 --- a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs +++ b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs @@ -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> = 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 = 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 { + 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" + ); +} diff --git a/docs/re/captures/mission-select-derived-order.png b/docs/re/captures/mission-select-derived-order.png new file mode 100644 index 00000000..446c48bc Binary files /dev/null and b/docs/re/captures/mission-select-derived-order.png differ diff --git a/docs/re/structures/ui-paint-order-key.md b/docs/re/structures/ui-paint-order-key.md index 53a47463..06d3d62c 100644 --- a/docs/re/structures/ui-paint-order-key.md +++ b/docs/re/structures/ui-paint-order-key.md @@ -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.