diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index d5ef2dc..2a47ec8 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -667,6 +667,45 @@ pub fn sprite_layer_key(build: &UiBuild, bundle: &[u8], el: &Element) -> Option< Some(be32(bundle, off + 8)) } +/// Layer keys for elements the bundle gives no key for, **measured** from the +/// running game rather than read from a file. +/// +/// A `.prm` primitive has no `T8aD` header and therefore no layer key, and the +/// bundle carries no data for it at all — the menu build declares +/// `pteff00.prm`, `pteff02.prm` and `pteff05.t32` and has **zero** RATC children +/// for any of them. The four unread words of the 60-byte declaration entry are +/// constant across every element (`+28`=0, `+36`=0xffffffff, `+56`=0, and `+44` +/// is a button ordinal 1–5), so the key is not there either. It comes from the +/// game's own code. +/// +/// But it is *consistent*, which is what makes a table honest rather than a +/// fudge. Bracketing each unkeyed element by its measured neighbours' keys: +/// +/// | element | title screen | main menu | splash | +/// |---|---|---|---| +/// | `pteff05.t32` | — | (0x8010, 0x8040) | — | +/// | `pteff04.t32` | (0x8010, 0x8040) | — | — | +/// | `pteff02.prm` | (0x8010, 0x8040) | (0x8010, 0x8040) | — | +/// | `pteff00.prm` | (0x8100, end) | (0x8110, end) | — | +/// | `palogo_eff0.prm` | — | — | (start, 0xa100) | +/// +/// `pteff02.prm` lands in the **same interval on both** screens it appears on; +/// the fade quad is past the maximum on both; the splash backdrop is below the +/// minimum. So these behave exactly like fixed per-name layers. +/// +/// Only names whose position has actually been measured are listed. An unlisted +/// primitive keeps `u32::MAX` and sorts last, which is where `compose` leaves it +/// — and why `ComposeOptions::include_primitives` is still off by default. +pub fn implied_layer_key(name: &str) -> Option { + Some(match name { + "palogo_eff0.prm" => 0x0000, + "pteff04.t32" | "pteff05.t32" => 0x8020, + "pteff02.prm" => 0x8030, + "pteff00.prm" => 0xffff_fffe, + _ => return None, + }) +} + /// The paint order derived from the bundle: a stable sort of the elements by /// their sprite's layer key. /// @@ -675,11 +714,14 @@ pub fn sprite_layer_key(build: &UiBuild, bundle: &[u8], el: &Element) -> Option< /// 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 { +pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec { let mut idx: Vec = (0..build.elements.len()).collect(); idx.sort_by_key(|&i| { + let el = &build.elements[i]; ( - sprite_layer_key(build, bundle, &build.elements[i]).unwrap_or(u32::MAX), + sprite_layer_key(build, bundle, el) + .or_else(|| implied_layer_key(&el.name)) + .unwrap_or(u32::MAX), i, ) }); diff --git a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs index 40ada1b..8f44e96 100644 --- a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs +++ b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs @@ -674,3 +674,73 @@ fn applying_the_fade_alpha_blanks_no_screen() { ); 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 = derived.iter().map(|&i| key(i)).collect(); + let mk: Vec = 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"); +} diff --git a/docs/re/structures/ui-prm-primitives.md b/docs/re/structures/ui-prm-primitives.md index e918588..b6a29ca 100644 --- a/docs/re/structures/ui-prm-primitives.md +++ b/docs/re/structures/ui-prm-primitives.md @@ -116,9 +116,53 @@ The genuinely wiped ones are a different set, wiped by an *opaque* quad. ## What is not settled +## Where a primitive paints — refuted in the file, measured in the game (2026-08-19) + +**🔴 Refuted: the key is not in the bundle.** Two places were checked and both +are empty: + +* the **declaration entry**'s four unread words are constant across every + element of all three measured screens — `+28` = 0, `+36` = `0xffffffff`, + `+56` = 0, and `+44` is a button ordinal (1…5 on the menu's five buttons, + `0xffffffff` everywhere else). No key there. +* the **bundle carries no data at all** for a primitive. The menu build declares + `pteff00.prm`, `pteff02.prm` and `pteff05.t32` and has **zero** RATC children + for any of them — its 34 children are 21 `T8aD` sprites and 13 `.rat` records, + none of them named for a primitive. + +So the layer a primitive draws on comes from the game's own code, not the file. + +**✅ But it is consistent, which makes a per-name table honest.** Bracketing each +unkeyed element between its measured neighbours' keys: + +| element | title screen | main menu | splash | +|---|---|---|---| +| `pteff05.t32` | — | (0x8010, 0x8040) | — | +| `pteff04.t32` | (0x8010, 0x8040) | — | — | +| `pteff02.prm` | **(0x8010, 0x8040)** | **(0x8010, 0x8040)** | — | +| `pteff00.prm` | (0x8100, end) | (0x8110, end) | — | +| `palogo_eff0.prm` | — | — | (start, 0xa100) | + +`pteff02.prm` falls in the **same interval on both** screens it appears on, the +fade quad is past the maximum on both, and the splash backdrop is below the +minimum. `ui_layout::implied_layer_key` records exactly these, and nothing more — +an unlisted primitive keeps `u32::MAX` and still sorts last. + +With it, `derived_paint_order` produces **the same layer-key sequence as the +order read off the running game on all three measured screens**, primitives +included, and matches element-for-element on four of the five bundle instances +(the fifth is the title, which differs only inside its tied groups — a separate +open question). Pinned by +`the_derived_order_puts_every_element_in_the_right_layer_group`. + +This does **not** make `include_primitives` safe by default: the 36 builds that +come out one colour are wiped by `pzeff00.prm` and `pceff00.prm`, whose positions +have never been measured, so they are not in the table. + ## What is not settled -* ❔ **Where a primitive paints.** The blocker, described above. It has no layer +* ❔ **Where an *unmeasured* primitive paints.** The blocker, unchanged for the + ones not in the table. It has no layer key and the two measured screens rule out every constant default. The cheapest next step is a third measured order from a screen that carries a primitive — the `GP_DIALOG` DIFFICULTY box is reachable from the main menu and has exactly