diff --git a/crates/sylpheed-formats/examples/paint_order_audit.rs b/crates/sylpheed-formats/examples/paint_order_audit.rs new file mode 100644 index 00000000..3b450335 --- /dev/null +++ b/crates/sylpheed-formats/examples/paint_order_audit.rs @@ -0,0 +1,122 @@ +//! Does the DERIVED paint order reproduce the ones measured from the game? +//! +//! `compose` uses a measured order for the three builds that have one and falls +//! back to `derived_paint_order` (a sort on each sprite's layer key) everywhere +//! else. The doc comment claims the derived order "reproduces both measured +//! orders up to ties" — this checks that claim against all three, and says what +//! the ties actually cost. +//! +//! cargo run -p sylpheed-formats --example paint_order_audit -- +use sylpheed_formats::{pak, ui_layout}; + +fn measured(names: &[&str]) -> Option<(&'static str, Vec)> { + const TITLE: [&str; 24] = [ + "ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32", + "ptlogo2.t32", "pteff01.t32", "ptlogo_tm.t32", "pteff00.prm", "ptbase2.t32", + "pteff04.t32", "ptloop01.rat", "ptloop02.rat", "pteff02.prm", + "ptlogo_back2eff1.t32", "ptlogo_back2eff2.t32", "ptlogo_back2eff3.t32", + "ptlogo_back2eff4.t32", "ptlogo_back2eff5.t32", "ptlogo_back2.t32", + "ptlogo_back2eff.t32", "ptcopyright.t32", "ptlogoall_eff.t32", + "ptlogoall_eff2.t32", + ]; + const SPLASH: [&str; 7] = [ + "palogo_eff0.prm", "palogo_gamearts.t32", "palogo_gamearts_eff.t32", + "palogo_seta.t32", "palogo_seta_eff.t32", "palogo_anima.t32", + "palogo_anima_eff.t32", + ]; + const MENU: [&str; 16] = [ + "pteff00.prm", "ptbase.t32", "pteff05.t32", "ptloop01.rat", + "ptloop02.rat", "pteff02.prm", "ptframe1.t32", "ptframe2.t32", + "pteff10.t32", "pteff12.t32", "ptbtn01.rat", "ptbtn02.rat", + "ptbtn03.rat", "ptbtn04.rat", "ptbtn05.rat", "ptmsg.t32", + ]; + if names == TITLE { + return Some(("title", vec![9,11,12,10,13,6,20,19,14,15,18,16,17,0,2,4,7,1,3,5,22,23,21,8])); + } + if names == SPLASH { return Some(("splash", vec![0,2,4,6,1,3,5])); } + if names == MENU { + return Some(("main menu", vec![1,3,4,2,5,8,9,6,7,15,10,11,12,13,14,0])); + } + None +} + +fn main() { + let path = std::env::args().nth(1).expect("usage: paint_order_audit "); + let ar = pak::PakArchive::open(&path).expect("open pak"); + let mut checked = 0; + let entries: Vec<_> = ar.entries().to_vec(); + for (i, e) in entries.iter().enumerate() { + let Ok(bytes) = ar.read(e) else { continue }; + let Some(build) = ui_layout::parse_build(&bytes) else { continue }; + let names: Vec<&str> = build.elements.iter().map(|e| e.name.as_str()).collect(); + // Every build: how exposed is it to tie-breaking? A tie between + // OVERLAPPING elements is where a derived order can go visibly wrong. + let keys_all: Vec = build.elements.iter() + .map(|e| ui_layout::sprite_layer_key(&build, &bytes, e).unwrap_or(u32::MAX)) + .collect(); + let mut tie_pairs = 0; + for a in 0..keys_all.len() { + for b in (a + 1)..keys_all.len() { + if keys_all[a] == keys_all[b] && keys_all[a] != u32::MAX { tie_pairs += 1; } + } + } + // Of the tied pairs, how many OVERLAP? Only those can paint visibly + // differently under an arbitrary tie-break. Rect from the declared + // pivot (= half the sprite for a .t32) at the resting placement. + let rect = |e: &ui_layout::Element| -> Option<(i32,i32,i32,i32)> { + let kf = e.rest()?; + let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32); + if w == 0 || h == 0 { return None; } + Some((kf.x, kf.y, w, h)) + }; + let mut tie_overlap = 0; + for a in 0..keys_all.len() { + for b in (a + 1)..keys_all.len() { + if keys_all[a] != keys_all[b] || keys_all[a] == u32::MAX { continue; } + let (Some(ra), Some(rb)) = (rect(&build.elements[a]), rect(&build.elements[b])) + else { continue }; + let ox = (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0); + let oy = (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1); + if ox > 0 && oy > 0 { tie_overlap += 1; } + } + } + let Some((label, want)) = measured(&names) else { + println!("entry {i:2} (no measured order) {} elements, {tie_pairs} tied pairs, \ +{tie_overlap} of them OVERLAPPING", build.elements.len()); + continue; + }; + checked += 1; + let got = ui_layout::derived_paint_order(&build, &bytes); + let keys: Vec = build.elements.iter() + .map(|e| ui_layout::sprite_layer_key(&build, &bytes, e).unwrap_or(u32::MAX)) + .collect(); + let exact = got == want; + // How many adjacent pairs in the MEASURED order does derived get wrong, + // and of those, how many are between elements sharing a layer key (a + // tie the sort cannot resolve) versus a genuine key-order conflict? + let pos_got: Vec = { + let mut p = vec![0; got.len()]; + for (r, &e) in got.iter().enumerate() { p[e] = r; } + p + }; + let (mut inv, mut tied) = (0, 0); + for a in 0..want.len() { + for b in (a + 1)..want.len() { + let (x, y) = (want[a], want[b]); + if pos_got[x] > pos_got[y] { + inv += 1; + if keys[x] == keys[y] { tied += 1; } + } + } + } + println!("entry {i:2} {label:10} {} elements", want.len()); + println!(" derived == measured : {}", if exact { "YES" } else { "NO" }); + println!(" inverted pairs : {inv} (of which same-layer-key ties: {tied})"); + if !exact { + println!(" measured: {want:?}"); + println!(" derived : {got:?}"); + println!(" keys : {keys:?}"); + } + } + println!("\n{checked} build(s) with a measured order were checked"); +} diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index 17b86b4f..af8d5836 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -874,8 +874,16 @@ pub fn compose( // 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. + // game; otherwise the order DERIVED from the sprites' layer keys. + // + // ✅ Checked 2026-08-29 (`examples/paint_order_audit.rs`), because the + // previous wording here — "reproduces both measured orders up to ties" — + // was unmeasured and stale by one: there are THREE measured orders. The + // derived order reproduces the main menu and the developer splash EXACTLY + // (0 inverted pairs each) and differs on the title by 8 pairs, all of them + // same-layer-key ties, two being total occlusions. Of the port's five + // screens only `EXTRAS` rests on a derived order with ties: 15 tied pairs, + // 2 overlapping. See docs/re/structures/ui-paint-order-derived-check.md. let order: Vec = measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle)); for &ei in &order { diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 8e5686c4..10cc9aac 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -256,6 +256,23 @@ authored version can be deleted. addresses (the two differ in size). [`structures/ui-8ax-fullres-background.md`](../re/structures/ui-8ax-fullres-background.md) +* ✅ **Paint order: your exposure is two element pairs, on one screen.** We use + an order *measured from the running game* where one exists and a derived order + (a sort on each sprite's layer key) elsewhere. Checked, rather than assumed: + the derived order reproduces the measured one **exactly** on the main menu + (0 inverted pairs) and the developer splash (0). On the **title** it differs by + 8 pairs — **all same-layer-key ties** — and two of those are total occlusions + (`back2eff5` is 1133×280 and *fully contains* `back2eff3` and `back2eff4`; + derived puts it on top, the game puts it underneath). The title is unaffected + in practice because it has a measured order. + Per screen: title **measured**, main menu **measured**, developer splash + **measured**, publisher splash derived but with **0 ties** (fully determined), + and **`EXTRAS` derived with 15 tied pairs of which only 2 overlap**. That pair + count is the whole risk — a tie-break by declaration index can be wrong by a + whole layer where it is wrong, so if `EXTRAS` ever looks off, those two pairs + are where to look. + [`structures/ui-paint-order-derived-check.md`](../re/structures/ui-paint-order-derived-check.md) + * **Menu order is geometric.** Buttons sorted top-to-bottom by resting Y. This is ✅ correct for a vertical menu and is **not** a decoded neighbour graph — the disc's real navigation structure is unknown, and `opt ` is *not* a focus link diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index 88a7c9fb..44cd1015 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -551,3 +551,16 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the within 2 % — so the negative arrives with its own evidence that the frames were live. Building the cross-check into the tool costs a few lines and is what separates a measurement from a silence. +* **A hedge in a code comment is an unmeasured claim.** `compose` said the + derived paint order "reproduces both measured orders up to ties" — a sentence + that sounds like a result and was neither measured nor kept current: there were + three measured orders by then, not two. Measuring it took one example program + and turned a hedge into a bounded number (exact on 4 of 5 bundles; the 5th off + by 8 pairs, all ties). Grep your own comments for "up to", "roughly", + "essentially" — each one is a claim nobody has checked. +* **Count the cases that can actually bite, not the cases that match the + pattern.** `EXTRAS` has 15 tied pairs in its derived paint order, which reads + alarming. Only **2** of them overlap, and a tie between non-overlapping + elements paints identically either way. Reporting 15 would have overstated the + risk by 7×; the useful number is the one filtered by whether the difference can + reach a pixel. diff --git a/docs/re/data/paint-order-audit.txt b/docs/re/data/paint-order-audit.txt new file mode 100644 index 00000000..a9cd0c96 --- /dev/null +++ b/docs/re/data/paint-order-audit.txt @@ -0,0 +1,35 @@ +# cargo run -p sylpheed-formats --example paint_order_audit -- dat/GP_TITLE.pak +# 2026-08-29 + +1682 | let decl = &decls[0]; +290 | let mut flush = |base: u32, +entry 0 (no measured order) 7 elements, 2 tied pairs, 1 of them OVERLAPPING +entry 1 (no measured order) 7 elements, 2 tied pairs, 1 of them OVERLAPPING +entry 2 (no measured order) 1 elements, 0 tied pairs, 0 of them OVERLAPPING +entry 3 (no measured order) 1 elements, 0 tied pairs, 0 of them OVERLAPPING +entry 4 title 24 elements + derived == measured : NO + inverted pairs : 8 (of which same-layer-key ties: 8) + measured: [9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, 22, 23, 21, 8] + derived : [9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 16, 17, 18, 0, 1, 2, 3, 4, 5, 7, 22, 23, 21, 8] + keys : [32928, 32928, 32928, 32928, 32928, 32928, 32832, 32928, 4294967295, 32768, 4294967295, 32784, 32784, 4294967295, 32899, 32899, 32899, 32899, 32899, 32898, 32897, 33024, 32936, 32937] +entry 5 main menu 16 elements + derived == measured : YES + inverted pairs : 0 (of which same-layer-key ties: 0) +entry 6 (no measured order) 18 elements, 15 tied pairs, 2 of them OVERLAPPING +entry 7 (no measured order) 30 elements, 37 tied pairs, 16 of them OVERLAPPING +entry 8 main menu 16 elements + derived == measured : YES + inverted pairs : 0 (of which same-layer-key ties: 0) +entry 9 (no measured order) 18 elements, 15 tied pairs, 2 of them OVERLAPPING +entry 10 (no measured order) 3 elements, 0 tied pairs, 0 of them OVERLAPPING +entry 11 splash 7 elements + derived == measured : YES + inverted pairs : 0 (of which same-layer-key ties: 0) +entry 12 (no measured order) 10 elements, 2 tied pairs, 1 of them OVERLAPPING +entry 13 (no measured order) 3 elements, 0 tied pairs, 0 of them OVERLAPPING +entry 14 splash 7 elements + derived == measured : YES + inverted pairs : 0 (of which same-layer-key ties: 0) +entry 15 (no measured order) 10 elements, 2 tied pairs, 1 of them OVERLAPPING +5 build(s) with a measured order were checked diff --git a/docs/re/structures/ui-paint-order-derived-check.md b/docs/re/structures/ui-paint-order-derived-check.md new file mode 100644 index 00000000..8c6e0fd6 --- /dev/null +++ b/docs/re/structures/ui-paint-order-derived-check.md @@ -0,0 +1,74 @@ +# ✅ Does the derived paint order reproduce the measured ones? Mostly — and the gap is bounded + +**Status:** ✅ **checked, with numbers.** `compose` uses a paint order *measured +from the running game* for the builds that have one and falls back to +`derived_paint_order` — a sort on each sprite's layer key — everywhere else. The +code's doc comment claimed the derived order "reproduces both measured orders up +to ties". That was a hedge with no measurement behind it, and it was stale: +there are **three** measured orders now, not two. + +Tool: `cargo run -p sylpheed-formats --example paint_order_audit -- dat/GP_TITLE.pak`. +Output committed at [`data/paint-order-audit.txt`](../data/paint-order-audit.txt). + +## The claim holds, and the exception is all ties + +| build | derived == measured | inverted pairs | of which same-key ties | +|---|---|---|---| +| main menu (entries 5, 8) | **YES** | 0 | — | +| developer splash (11, 14) | **YES** | 0 | — | +| title (entry 4) | **NO** | 8 | **8 — every one** | + +Four of the five measured bundles reproduce **exactly**. The title's eight +disagreements are all between elements that share a layer key, which the sort +cannot order and breaks by declaration index. + +## ⚠️ Two of those ties are total occlusions + +The tied family is the five `ptlogo_back2eff` glows, all key `32899`: + +``` +measured paints: 14, 15, 18, 16, 17 +derived paints: 14, 15, 16, 17, 18 +``` + +Two pairs flip, and they are not near-misses: + +| pair | overlap | | +|---|---|---| +| `back2eff5` vs `back2eff3` | 82 824 px² | **100 % of the smaller** | +| `back2eff5` vs `back2eff4` | 152 047 px² | **100 % of the smaller** | + +`back2eff5` is 1133×280 and **fully contains** both. Derived paints it on top of +two glows it completely covers; the game paints it underneath. So a tie-break by +declaration index is not cosmetic — where it is wrong, it can be wrong by a whole +layer. ✅ The title is unaffected in practice, because it has a measured order. + +## ✅ The port's actual exposure is two element pairs + +Per build, counting tied pairs and how many of them **overlap** (only those can +paint visibly differently): + +| entry | the port's screen | order used | tied pairs | overlapping | +|---|---|---|---|---| +| 4 | title | **measured** | — | — | +| 5 | main menu | **measured** (derived agrees exactly) | 0 | 0 | +| **6** | **`EXTRAS`** | **derived** | 15 | **2** | +| 10 | publisher splash | derived | **0** | **0** | +| 11 | developer splash | **measured** | — | — | + +So of the five screens, **one** rests on an unverified derived order, and its +risk is **two overlapping tied pairs** — not the 15 the raw tie count suggests. +The publisher splash's derived order is fully determined (no ties at all). + +🟡 For completeness, outside the port's set: entry 7 (the Japanese title) is the +worst on the disc at 37 tied pairs, 16 overlapping. + +## Reach + +* This checks the derived order against the orders **measured from the running + game**; it is not an independent derivation of what the game does. Where no + measured order exists, agreement cannot be checked at all — only the *tie + exposure* can, which is what the table above reports. +* Overlap uses `pivot × 2` as the element's size (documented as the sprite's own + dimensions for a `.t32`) at its resting placement, so scaled or rotated + elements are approximated.