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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user