formats: the tie-break, refuted six ways and measured down to the pixel

With the layer key and the primitives' implied keys in place, the tie-break -
how the game orders elements sharing a key - is all that is left between the
derived order and ground truth. Three measured screens now constrain it.

On the menu and the splash every tied group comes out in declaration order,
which is what the stable sort already gives. The title is the only screen that
discriminates, and nothing predicts it: 0x8083 x5 paints eff1, eff2, eff5, eff3,
eff4, and 0x80a0 x7 paints logo1 x3, tm, logo2 x3.

Refuted: declaration order; RATC child order; first keyframe time (52, 56, 62,
58, 60 - the measured order is not sorted by them); resting keyframe time;
resting X or Y (938, 938, 64, 788, 447); and T8aD header words +00, +04, +0c and
+10, which are either identical within a group or unsorted.

Child order is worth its own line: a strict improvement over declaration order
(7 misplaced positions on the title instead of 9, and it recovers the logo
grouping) and exactly right on the other two screens. NOT adopted, because on the
only screen that can tell them apart it is still wrong.

Adds a test that measures what the residual costs instead of assuming it. Of the
3 disagreeing pairs of drawn elements across all three screens, all 3 have
overlapping bounding boxes and 2 share opaque pixels: ptlogo_back2eff5 against
eff3 (22568 px) and eff4 (32395 px). The third pair, ptlogo2 vs ptlogo_tm,
overlaps by two columns and shares NO opaque pixel - the wordmark is transparent
there. A bounding-box test called that a defect; reading the alpha says it is
not, which is why the test reads pixels. The set is pinned, so a change that
makes it worse fails.

15 disc tests green.
This commit is contained in:
Sylpheed RE agent
2026-08-19 09:34:10 +00:00
parent 19f2f562be
commit 0873f47687
2 changed files with 221 additions and 0 deletions

View File

@@ -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<String> = 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<Drawn> {
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"
);
}

View File

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