diff --git a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs index 89b6695..9d5034c 100644 --- a/crates/sylpheed-formats/tests/ui_paint_order_disc.rs +++ b/crates/sylpheed-formats/tests/ui_paint_order_disc.rs @@ -71,6 +71,31 @@ fn builds(root: &Path) -> Vec<(String, Vec)> { 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 = 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> { 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 = (0..b.elements.len()).collect(); + want.sort_by_key(|&i| key(i)); + if want != (0..b.elements.len()).collect::>() { + 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" + ); +} diff --git a/docs/re/structures/ui-paint-order-key.md b/docs/re/structures/ui-paint-order-key.md index 06d3d62..bec2c8e 100644 --- a/docs/re/structures/ui-paint-order-key.md +++ b/docs/re/structures/ui-paint-order-key.md @@ -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. diff --git a/tools/re-capture/tutorial_launch.sh b/tools/re-capture/tutorial_launch.sh index a934fc2..3bbefee 100755 --- a/tools/re-capture/tutorial_launch.sh +++ b/tools/re-capture/tutorial_launch.sh @@ -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