diff --git a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs index 8f44e96..e2ee539 100644 --- a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs +++ b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs @@ -744,3 +744,159 @@ fn the_derived_order_puts_every_element_in_the_right_layer_group() { ); eprintln!("layer groups match on all 3 measured screens; {exact} match element-for-element"); } + +/// **Exactly which order disagreements change a pixel** — measured, not assumed. +/// +/// The tie-break within a layer group is unsolved (see +/// `structures/ui-paint-order-key.md`). On the menu and the splash it does not +/// bite: ties there come out in declaration order, which is what the sort gives. +/// The title is the one screen where the game orders a tied group differently, +/// and after the `kind = 0x4` repeat instances are skipped exactly **one** +/// disagreeing pair of drawn elements survives: the game paints `ptlogo_tm` +/// before `ptlogo2`, the sort puts it after. +/// +/// Across all three measured screens there are **3** disagreeing pairs of drawn +/// elements. All 3 have overlapping bounding boxes; **2** actually share opaque +/// pixels, and both are `ptlogo_back2eff5` against a neighbour — the game paints +/// it third in the `0x8083` group (`eff1, eff2, eff5, eff3, eff4`), the sort +/// paints it fifth, and 22 568 / 32 395 blended pixels differ as a result. +/// +/// The third pair, `ptlogo2` vs `ptlogo_tm`, is the instructive one: their rects +/// overlap by two columns (the wordmark decodes 992 px wide and reaches x=1129, +/// the trademark starts at x=1127) but the wordmark is fully transparent there, +/// so it cannot matter. Bounding boxes would have called it a defect; alpha says +/// it is not. That is why this test reads pixels rather than rectangles. +#[test] +fn order_disagreements_that_change_pixels_are_pinned() { + 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, mut pairs, mut boxes) = ([false; 3], 0usize, 0usize); + let mut matters: Vec = Vec::new(); + 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; + // Everything needed to ask "is this pixel opaque here": the decoded + // sprite, its placed rect, and the scale mapping back into it. + struct Drawn { + img: t8ad::T8adImage, + ox: i32, + oy: i32, + dw: i32, + dh: i32, + } + let drawn = |i: usize| -> Option { + let el = &build.elements[i]; + if el.animated || el.focused { + return None; + } + if el.kind & 0x4 != 0 + && build + .elements + .iter() + .any(|o| o.kind & 0x4 == 0 && o.name == el.name) + { + return None; + } + let kf = el.rest()?; + if (kf.fade >> 24) & 0xff == 0 { + return None; // fully faded out — contributes nothing + } + let sprite = el.sprite.as_ref()?; + let &(off, size) = build.sprites.get(sprite)?; + let img = t8ad::parse(&bundle[off..off + size])?; + let (sx, sy) = (kf.scale_x.max(1), kf.scale_y.max(1)); + let (dw, dh) = ((img.width * sx / 100) as i32, (img.height * sy / 100) as i32); + let ox = kf.x - (el.pivot_x as i32 * (sx as i32 - 100)) / 100; + let oy = kf.y - (el.pivot_y as i32 * (sy as i32 - 100)) / 100; + Some(Drawn { img, ox, oy, dw, dh }) + }; + let alpha_at = |d: &Drawn, x: i32, y: i32| -> u8 { + let (cx, cy) = (x - d.ox, y - d.oy); + if cx < 0 || cy < 0 || cx >= d.dw || cy >= d.dh { + return 0; + } + let sxi = ((cx as u32) * d.img.width / d.dw as u32).min(d.img.width - 1); + let syi = ((cy as u32) * d.img.height / d.dh as u32).min(d.img.height - 1); + let idx = ((syi * d.img.width + sxi) * 4 + 3) as usize; + d.img.rgba.get(idx).copied().unwrap_or(0) + }; + let derived = ui_layout::derived_paint_order(&build, &bundle); + let pos = |order: &[usize], i: usize| order.iter().position(|&x| x == i).unwrap(); + for a in 0..*n { + for b in (a + 1)..*n { + if (pos(&derived, a) < pos(&derived, b)) == (pos(measured, a) < pos(measured, b)) + { + continue; + } + let (Some(da), Some(db)) = (drawn(a), drawn(b)) else { + continue; // at least one is not drawn — cannot matter + }; + pairs += 1; + let (x0, y0) = (da.ox.max(db.ox), da.oy.max(db.oy)); + let (x1, y1) = ((da.ox + da.dw).min(db.ox + db.dw), (da.oy + da.dh).min(db.oy + db.dh)); + if x0 >= x1 || y0 >= y1 { + continue; // rects disjoint + } + boxes += 1; + let mut shared = 0usize; + for y in y0..y1 { + for x in x0..x1 { + if alpha_at(&da, x, y) != 0 && alpha_at(&db, x, y) != 0 { + shared += 1; + } + } + } + if shared > 0 { + matters.push(format!( + "{} vs {} ({shared} px)", + build.elements[a].name, build.elements[b].name + )); + } + } + } + } + } + assert!(seen.iter().all(|&b| b), "not every measured screen was found: {seen:?}"); + matters.sort(); + matters.dedup(); + eprintln!( + "{pairs} drawn pair(s) ordered differently from the game, {boxes} with \ + overlapping rects, {} sharing opaque pixels:", + matters.len() + ); + for m in &matters { + eprintln!(" {m}"); + } + // Pinned, so that a change to the sort which makes this WORSE fails here. + // Both survivors are `ptlogo_back2eff5` against its neighbours: the game + // paints it third in the `0x8083` group (eff1, eff2, eff5, eff3, eff4) and + // the sort paints it fifth. Nothing in the file predicts that placement. + assert_eq!( + matters, + vec![ + "ptlogo_back2eff3.t32 vs ptlogo_back2eff5.t32 (22568 px)".to_string(), + "ptlogo_back2eff4.t32 vs ptlogo_back2eff5.t32 (32395 px)".to_string(), + ], + "the set of order disagreements that actually change pixels has moved" + ); +} diff --git a/docs/re/structures/ui-paint-order-key.md b/docs/re/structures/ui-paint-order-key.md index 5169cd1..7fb1823 100644 --- a/docs/re/structures/ui-paint-order-key.md +++ b/docs/re/structures/ui-paint-order-key.md @@ -192,3 +192,68 @@ seen before. drawn, but the capture shows the menu frame plainly. Either the resting rule picks the wrong plateau for them or the frame is drawn by something else. * ❔ The derivation for primitives. Three permutations now, still no rule. + + +## The tie-break: still unsolved, and now measured down to the pixel (2026-08-19) + +With the layer key and the primitives' implied keys in place, the tie-break — +how the game orders elements that **share** a key — is the only thing left +between the derived order and ground truth. Three measured screens now constrain +it. + +**On the menu and the splash it does not bite.** Every tied group there comes out +in declaration order, which is what the stable sort already gives: + +| screen | tied group | measured | +|---|---|---| +| menu | `0x8010` ×2 | 3, 4 | +| menu | `0x8050` ×2 | 6, 7 | +| menu | `0x8110` ×5 | 10, 11, 12, 13, 14 | +| splash | `0xa100` ×3 | 2, 4, 6 | +| splash | `0xa110` ×3 | 1, 3, 5 | + +**The title is the one screen that discriminates**, and nothing predicts it: + +``` +0x8083 ×5 measured 14, 15, 18, 16, 17 (eff1, eff2, eff5, eff3, eff4) +0x80a0 ×7 measured 0, 2, 4, 7, 1, 3, 5 (logo1×3, tm, logo2×3) +``` + +### Refuted + +| candidate | result | +|---|---| +| declaration order | interleaves the logos (`0,1,2,3,4,5,7`); wrong | +| **RATC child order** | groups the logos correctly but puts `tm` last, and leaves `0x8083` in `eff1..eff5`; **exact on the menu and splash, wrong on the title** | +| first keyframe time | `0x8083` times are 52, 56, **62**, 58, 60 — the measured order is not sorted by them | +| resting keyframe time | same shape, same failure | +| resting X or Y | `0x8083` rests at x = 938, 938, 64, 788, 447 — unsorted either way | +| `T8aD` header `+0x00`, `+0x04`, `+0x0c`, `+0x10` | identical within a group, or unsorted | + +Child order deserves a note: it is a *strict improvement* over declaration order +(7 misplaced positions on the title instead of 9, and it recovers the +logo1×3 / logo2×3 grouping), and it is exactly right on the two other screens. +It was **not** adopted, because on the only screen that can tell the two apart it +is still wrong, and a rule that is wrong there buys nothing a stable sort does +not already give. + +### What it costs, exactly + +`order_disagreements_that_change_pixels_are_pinned` measures the damage rather +than assuming it. Across all three measured screens: + +* **3** disagreeing pairs of *drawn* elements (repeat instances and faded-out + elements are skipped, so they cannot count); +* all 3 have overlapping bounding boxes; +* **2** actually share opaque pixels — `ptlogo_back2eff5` against `eff3` + (22 568 px) and against `eff4` (32 395 px). The game paints `eff5` third in the + group, the sort paints it fifth, and those blended glow pixels differ. + +The third pair is the instructive one. `ptlogo2` and `ptlogo_tm` overlap by two +columns — the wordmark decodes 992 px wide and reaches x=1129, the trademark +starts at x=1127 — but the wordmark is **fully transparent** there, so the order +cannot matter. A bounding-box test called this a defect; reading the alpha says +it is not. That is why the test reads pixels. + +So the residual error in the derived order is confined to **one element's blend +on one screen**, and it is pinned: a change that makes it worse fails the test.