formats: check the derived paint order against the screens already verified

The layer-key order was adopted from two measured screens and then applied to
every build on the disc, so it owed a regression check against the screens the
corpus had already validated against the running game.

Rendered the tutorial PAUSE menu and the title main menu both ways and diffed:
3.8 % and 1.1 % of pixels differ, max delta 45/255 and 34/255, and the two
renders are indistinguishable in layout — the change is confined to blends where
translucent sprites overlap. No regression, but which order is more faithful on
those two screens is unsettled and recorded as such.

Adds a corpus-wide test asserting every composite's draw list is strictly
increasing in (layer key, declaration index), streaming one pak at a time so it
does not OOM alongside the other whole-disc tests. It reports the rule's reach:
341 of 965 builds are reordered, and it fails if that share collapses.
This commit is contained in:
Sylpheed RE agent
2026-08-19 05:43:08 +00:00
parent d9ae42dd55
commit deff5a4d6d
3 changed files with 143 additions and 4 deletions

View File

@@ -71,6 +71,31 @@ fn builds(root: &Path) -> Vec<(String, Vec<u8>)> {
out
}
/// Every parseable screen build, handed over **one pak at a time**.
///
/// `builds` holds the whole disc's bundles in memory at once, and four tests in
/// this file want the whole corpus; run together under the default test harness
/// that is enough to get the process OOM-killed. This streams instead.
fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) {
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
for p in &paks {
let name = p.file_name().unwrap().to_string_lossy().to_string();
let Ok(arc) = PakArchive::open(p) else { continue };
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue };
if ui_layout::is_build(&bytes) {
f(&name, &bytes);
}
}
}
}
/// The builds of one pak, in the order `sylpheed-cli screen list` numbers them.
fn pak_builds(root: &Path, pak: &str) -> Vec<Vec<u8>> {
let arc = PakArchive::open(root.join("dat").join(pak)).expect("open pak");
@@ -427,3 +452,66 @@ fn the_derived_order_matches_the_measured_ones_up_to_ties() {
"expected both measured builds; seen = {seen:?} over {checked} matches"
);
}
/// The layer-key order is what every composite on the disc actually paints in,
/// and it is not a no-op dressed up as a discovery.
///
/// Two things are checked corpus-wide, because the derived order was adopted on
/// the strength of **two** measured screens and then applied to all of them:
///
/// * every composite's draw list is non-decreasing in the layer key, so the
/// rule really reaches the whole corpus and a future accidental revert to
/// declaration order fails here rather than silently;
/// * the derived order reorders a substantial share of the disc's builds. If it
/// were a near-no-op the two measured screens would be the only evidence
/// there is, and the rule would deserve much less credit than it has.
#[test]
fn every_composite_paints_in_layer_key_order() {
skip_without_disc!(root);
let (mut total, mut checked, mut reordered) = (0usize, 0usize, 0usize);
for_each_build(&root, |pak, bytes| {
total += 1;
let Some(b) = ui_layout::parse_build(bytes) else {
return;
};
if b.from_fallback {
return;
}
let key = |i: usize| {
(
ui_layout::sprite_layer_key(&b, bytes, &b.elements[i]).unwrap_or(u32::MAX),
i,
)
};
let mut want: Vec<usize> = (0..b.elements.len()).collect();
want.sort_by_key(|&i| key(i));
if want != (0..b.elements.len()).collect::<Vec<_>>() {
reordered += 1;
}
let c = ui_layout::compose(&b, bytes, ComposeOptions::default(), None);
// The two screens read off the running game keep their measured order,
// which agrees with the derived one only up to ties — skip those.
if c.drawn.len() < 2 || b.elements.len() == 24 || b.elements.len() == 7 {
return;
}
checked += 1;
for w in c.drawn.windows(2) {
assert!(
key(w[0]) < key(w[1]),
"{pak}: painted element {} (key {:#x}) before {} (key {:#x}) — the \
composite is no longer in layer-key order",
w[0],
key(w[0]).0,
w[1],
key(w[1]).0
);
}
});
assert!(checked > 100, "only {checked} builds composed 2+ elements");
eprintln!("layer-key order: {reordered}/{total} builds reordered");
assert!(
reordered * 4 > total,
"the derived order reorders only {reordered} of {total} builds — too few \
to carry the weight the write-up puts on it"
);
}

View File

@@ -88,3 +88,48 @@ has no `.rat` child, so `ui_layout::is_build` rejects it and the compositor neve
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.
## What the change did to the screens that were already verified (2026-08-19)
The derived order is applied to **every** build on the disc on the strength of
two measured screens, so the first thing owed to it is a check of what it did to
the screens the corpus had already validated against the running game. Two exist:
the tutorial PAUSE menu and the title main menu
(`../captures/ui-layout/pause-tutorial-real-vs-rebuilt.png`).
Rendered both ways — `compose` as committed, then with `compose` temporarily
reverted to declaration order — and diffed:
| screen | pixels differing | RMSE | max per-channel delta |
|---|---|---|---|
| tutorial PAUSE | 35 162 / 921 600 (3.8 %) | 0.52 % | 45 / 255 |
| title main menu | 9 911 / 921 600 (1.1 %) | 0.36 % | 34 / 255 |
**No layout regression.** Side by side the two renders are indistinguishable:
every panel, label and glyph is in the same place at the same size. What moved is
confined to pixels where translucent sprites overlap — the glows around PAUSE,
the OBJECTIVE / DEFEAT CONDITION / HINT header bars, the menu underlines — i.e.
the order the blends compose in, which is exactly what a paint-order change is
supposed to touch and nothing else.
🟡 **Which of the two is more faithful on these two screens is NOT settled.** A
difference of ≤45/255 on a few per cent of pixels is not decidable against the
committed side-by-side oracle, and there is no fresh framebuffer capture of
either screen to diff at that magnitude. The derived order is kept because it is
the rule measured off the game on the two screens where the order *is* known, not
because it was shown to be better here. If a capture of the PAUSE menu is ever
taken, this is the first thing to check it against.
### Corpus-wide, and not a no-op
A disc-gated test (`every_composite_paints_in_layer_key_order`) composes every
build on the disc and asserts the draw list is strictly increasing in
`(layer key, declaration index)`. It also counts how far the rule reaches:
> **341 of 965 builds (35 %) are reordered** by it.
That matters for how much credit the rule gets. Had it been a near-no-op, the two
measured screens would be the entire evidence base; instead a third of the disc's
screens now composite in an order no capture has checked. The test asserts the
share stays above a quarter, so a future change that quietly collapses the rule
back to declaration order fails here instead of passing silently.

View File

@@ -20,7 +20,7 @@ alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $
for attempt in $(seq 1 "${ATTEMPTS:-4}"); do
echo "=== attempt $attempt"
pkill -9 -x xenia_canary 2>/dev/null; sleep 3
( cd "$OUT" && nohup run-canary --mem_watch=false \
( cd "$OUT" && nohup run-canary --mem_watch=false ${EXTRA_FLAGS:-} \
--logged_profile_slot_0_xuid=B13EBABEBABEBABE \
>"$OUT/canary.stdout" 2>&1 & )
sleep 8
@@ -49,12 +49,18 @@ python3 "$SD/pad.py" dpad down 0.08; sleep 1.2
shot "$OUT/menu-on-tutorial.png"
python3 "$SD/pad.py" tap A 0.3
for i in $(seq 1 24); do
# TUTORIAL leads to DIFFICULTY (A picks NORMAL) and then SELECT DATA, where the
# cache-flush crash fires. Keep pressing A through those and report what happens,
# with the crash count, because "did it survive the throw" is the question.
for i in $(seq 1 20); do
sleep 8; s="$(screen)"
shot "$OUT/after-$(printf '%02d' "$i").png"
echo " t+$((i*8))s $s"
[ "$s" = "flight" ] && { echo "IN FLIGHT"; break; }
crashes=$(grep -c "CRASH DUMP" "$OUT/canary.stdout" 2>/dev/null || echo 0)
echo " t+$((i*8))s $s crashes=$crashes"
case $i in 2|5|9) echo " -> A"; python3 "$SD/pad.py" tap A 0.3 ;; esac
[ "$s" = "flight" ] && [ "$crashes" = "0" ] && { echo "IN FLIGHT"; break; }
done
echo "final crash dumps: $(grep -c 'CRASH DUMP' "$OUT/canary.stdout" 2>/dev/null || echo 0)"
echo "TUTORIAL LAUNCH ATTEMPT DONE (emulator left running)"
exit 0
done