diff --git a/crates/sylpheed-export/src/screen.rs b/crates/sylpheed-export/src/screen.rs index c808270c..d8457771 100644 --- a/crates/sylpheed-export/src/screen.rs +++ b/crates/sylpheed-export/src/screen.rs @@ -733,7 +733,29 @@ fn forced_backdrop_first(order: Vec, elements: &[Element], design: [u32; .iter() .enumerate() .filter(|(_, e)| { - e.sprite.is_none() + // 🔴 UNTEXTURED SOLID QUAD, tested positively -- NOT merely "has no + // sprite". Those coincide in GP_TITLE and the distinction is still + // the whole point, because the negative test guards a SYMPTOM. + // + // The rule needs the element's alpha to BE its pixels' alpha. That + // is true of a `.prm` solid quad and of nothing else. The Decoder + // found this the expensive way twice: first `.t32` sprites (an + // element's alpha says nothing about a texture that is mostly + // transparent), guarded with "no sprite" -- and then `.tbm`, which + // is 38 of their 80 forced-first verdicts and declares fade + // `ffffffff`. A solid WHITE quad painted first at alpha 255 would + // make the screen white; no screen is white, so a `.tbm`'s white is + // a modulation ON a texture and its element alpha proves nothing + // about coverage either. + // + // "No sprite" would keep admitting a `.tbm` that this exporter + // happens not to emit a sprite for. `role == "primitive"` cannot. + // GP_TITLE has no full-screen `.tbm` at all -- every layerless + // full-screen element here is `.prm` and pure black, checked -- so + // this changes no verdict today and is a guard against a corpus + // that grows. + e.role == "primitive" + && e.sprite.is_none() && e.size.is_some_and(|s| s[0] as u32 >= design[0] && s[1] as u32 >= design[1]) }) .filter(|(i, e)| { diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index d49ef11a..3ba30d48 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -5361,3 +5361,88 @@ framing reaches significance**, which is where both of us landed anyway. `--screen=NAME` render at t=0 both return a **complete, well-formed answer to a different question**. That is why neither looks like an error — nothing inside either view can tell you it is not the view you asked for. + +## P6 gate — sound on the P5 walk, verified, and the tool I nearly shipped instead + +`tools/port/verify-menu-audio`. Until now the evidence that P6's gate was met was +that `audio.play("move")` appears in `boot.gd` — evidence that a *call is +written*, not that a sound reaches the bus. Those differ, and this project has +the case to prove it: the black hold was implemented, called, and emitted nothing +for five milestones. + +It needs no sound card. Godot records the Master bus to a WAV under the Dummy +driver. Three runs on `main_menu`: the walk (`down,down,accept,cancel,up`) and +two controls — `wait`×5 for the bed alone, and `left`×5 for **presses that reach +`_unhandled_input` and are bound to nothing** (Q5: left/right do nothing). + +| check | result | +|---|---| +| a dead press is silent | **bit-identical** to the bed alone, 114 688 samples | +| `move` on the bus | r=0.201 at 1.85 s, bed-only 0.016, margin **+0.185** | +| `confirm` | r=0.945 at 2.14 s, bed-only 0.371, margin **+0.574** | +| `back` | r=0.660 at 2.42 s, bed-only 0.195, margin **+0.465** | +| cue order vs script order | **consistent** | + +The order is the strongest line and it is free: the correlator is never told +where to look, so three different templates landing in script order is three +independent searches agreeing with the log. `move`'s absolute r is low because it +is the quietest cue under the loudest part of the bed; the margin over the same +template against the bed is what carries it. + +🔴 **What it cannot conclude, and must never be read as:** that these are the cues +the *game* plays. That binding is HANDOFF Q8, the Decoder's, and nothing here +re-measures it. This tool cannot tell a correct cue from a confidently wrong one. + +### The instrument I nearly shipped + +The first version counted envelope bursts above a multiple of the bed level. It +reported **4 cues on one run and 0 on the next, from the same script** — its +answer was set by two hand-picked constants, the multiple and a minimum run +length, and the bed level is not constant across a run. I caught it only because +I ran it twice. + +The replacement has no such constant: **the exported cue file is its own +template**, the search covers the whole recording, and the verdict is a margin +over that same template matched against the bed-only control. + +⚠️ Cue *length* is deliberately not asserted. The audible part of a cue is far +shorter than its wave — 0.12–0.38 s against authored 0.344–1.016 s — because the +bed masks the tail. "Elevated for 0.13 s" is a fact about the bed, not the cue, +and I came close to filing that gap as a defect. + +### A check that could not be made independent + +I tried to verify Q8's cue durations against the exported waves. They agree +exactly — 0.533 / 1.016 / 0.344 — **and the agreement is worthless**: the +exporter decodes from Q8's own offsets and packet counts, so the duration is +determined by the claim under test. It confirms the export is faithful to the +authored table, nothing more. Recording it because "I checked and it matched" is +exactly what a correlated instrument feels like from the inside. + +## Their `.tbm` self-refutation does not reach this archive — and it fixes my guard anyway + +The Decoder downgraded 38 of the forced-backdrop rule's 80 verdicts from decoded +to inferred: those elements are `.tbm`, declaring fade `ffffffff`, and a solid +white quad painted first at alpha 255 would make the screen white. No screen is +white, so a `.tbm`'s white is a modulation **on a texture** and its element alpha +says nothing about coverage — the `.t32` mistake one file extension further out. + +✅ **No verdict the port ships is affected.** All six forced elements here are +`palogo_eff0.prm` and `pgloading_eff00.prm`, `role: primitive`, `kind_raw 0x10`, +fade `0xff000000`. They fall in the 42 that stay decoded. And **no layerless +full-screen element anywhere in `GP_TITLE` has a non-black fade** — checked +across all 16 screens, so the downgrade cannot touch this archive. + +But their diagnosis applies to my code regardless: *an element's alpha is not its +texture's opacity, and only an untextured primitive makes the two the same fact*. +My guard was `sprite.is_none()` — a **symptom** test, the same shape as the one +they say fixed their symptom and not their cause. It would keep admitting a +`.tbm` that this exporter happens not to emit a sprite for. + +The guard is now the positive test, `role == "primitive"`. It changes no verdict +today — the six are identical before and after, 16 screens still validate — and +it is correct by construction if the corpus grows. + +⚠️ Not adopted from their message: their reading that the blend question now +narrows to `pbafc.prm`. That is theirs to settle and the port draws no additive +quad either way. diff --git a/tools/port/verify-menu-audio b/tools/port/verify-menu-audio new file mode 100755 index 00000000..c1f0ac2f --- /dev/null +++ b/tools/port/verify-menu-audio @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Does the port actually MAKE SOUND on the P5 walk, and the RIGHT sound? +# +# tools/port/verify-menu-audio +# +# This is the P6 gate check. P6's gate is "sound on the P5 gate", and until this +# existed the only evidence for it was that `audio.play("move")` appears in +# boot.gd -- which is evidence that a call is written, not that a sound reaches +# the Master bus. Those differ: the black hold was implemented, called, and +# emitted nothing for five milestones. +# +# It needs NO SOUND CARD. Godot records the Master bus to a WAV under the Dummy +# driver (docs/port/AUDIO-VERIFICATION.md section 2). +# +# WHAT IT CONCLUDES, and what it must not be read as: +# +# * ✅ that a cue REACHES THE BUS when a press does something; +# * ✅ that a press bound to NOTHING is silent, byte for byte; +# * ✅ that two presses of the same action play the SAME cue; +# * 🔴 NOT that the cue is the one the GAME plays. That binding is HANDOFF Q8, +# measured by the Decoder, and nothing here re-measures it. This tool cannot +# tell a correct cue from a confidently wrong one. +# +# ⚠️ Cue LENGTH is deliberately not asserted. The audible part of a cue is much +# shorter than its wave -- the music bed masks the tail -- so "elevated for +# 0.13 s" is a fact about the bed, not about the cue, and an assertion built on +# it would fail whenever the bed changes. +set -euo pipefail +cd "${PROJECT_DIR:-/work}" +export DISPLAY="${DISPLAY:-:97}" +OUT="${OUT:-${TMPDIR:-/tmp}/verify-menu-audio}" +mkdir -p "$OUT" +[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1 + +run() { # name, script + timeout 300 godot --path port --resolution 1280x720 -- \ + --menu=main_menu "--script=$2" "--audio=$OUT/$1.wav" >"$OUT/$1.log" 2>&1 || true + [ -s "$OUT/$1.wav" ] || { echo "no audio written for $1 -- see $OUT/$1.log" >&2; exit 2; } +} + +# THE WALK, and TWO CONTROLS. The controls are the point: a run that makes noise +# proves nothing on its own, because the music bed makes noise too. +# +# `wait` -- the bed alone, nothing pressed. +# `left` -- five presses that REACH _unhandled_input and are bound to nothing +# (HANDOFF Q5: left/right do nothing). If these differ from `wait`, +# the port is making a sound the game does not. +run walk down,down,accept,cancel,up +run ctrl wait,wait,wait,wait,wait +run noop left,left,left,left,left + +python3 - "$OUT" <<'PYEOF' +import array, math, subprocess, sys +O = sys.argv[1]; SR = 44100 +def dec(src, dst): + subprocess.run(["ffmpeg","-v","error","-i",src,"-f","s16le","-ac","1", + "-ar",str(SR),dst,"-y"], check=True) + a = array.array('h'); a.frombytes(open(dst,'rb').read()); return a +walk = dec(f"{O}/walk.wav", f"{O}/walk.raw") +ctrl = dec(f"{O}/ctrl.wav", f"{O}/ctrl.raw") +noop = dec(f"{O}/noop.wav", f"{O}/noop.raw") + +# 1. A press bound to nothing must be SILENT, and silent means IDENTICAL. +# No threshold: a bar here would be a number nobody measured. +n = min(len(ctrl), len(noop)) +ok_silent = ctrl[:n].tobytes() == noop[:n].tobytes() +print("no-op presses vs bed alone : %s (%d samples)" + % ("IDENTICAL -- silent" if ok_silent else "DIFFER -- the port sounds a dead press", n)) + +# 2. Is the RIGHT CUE on the bus? Match each EXPORTED cue wave against the +# recording by normalised cross-correlation over the whole file. +# +# This replaced a burst-counter that thresholded the envelope at a multiple +# of the bed level. That counter reported 4 cues on one run and 0 on the next +# from the SAME script, because its answer was set by two hand-picked +# constants -- the multiple and a minimum run length -- and the bed level is +# not constant across a run. It was nearly shipped. A tool whose headline +# number moves with its own tuning cannot detect anything. +# +# This has no such constant. The cue file is its own template, the search is +# over the whole recording, and the verdict is a MARGIN over the same +# template matched against the bed-only control. +def slide(tpl, hay, step=16): + t = [float(v) for v in tpl]; bt = math.sqrt(sum(v*v for v in t)) + if bt == 0: return (0.0, 0.0) + best = (-2.0, 0.0) + for i in range(0, len(hay)-len(t), step): + seg = hay[i:i+len(t)] + bs = math.sqrt(sum(float(v)*v for v in seg)) + if bs: + r = sum(a*float(b) for a, b in zip(t, seg))/(bt*bs) + if r > best[0]: best = (r, i/SR) + return best + +found = [] +for cue in ("move", "confirm", "back"): + tpl = dec("export/audio/se/%s.ogg" % cue, "%s/%s.raw" % (O, cue))[:int(0.15*SR)] + rw, tw = slide(tpl, walk) + rc, _ = slide(tpl, ctrl) + hit = rw - rc > 0.15 + found.append((cue, tw, hit)) + print("%-8s walk r=%.3f at %5.2fs | bed-only r=%.3f | margin %+.3f %s" + % (cue, rw, tw, rc, rw-rc, "PRESENT" if hit else "NOT FOUND")) + +# 3. The ORDER is the strongest evidence here and it is free: the correlator is +# never told where to look, so three templates landing in script order -- +# move (step 1) before confirm (step 3) before back (step 4) -- is three +# independent searches agreeing with the log. +times = [t for _, t, hit in found if hit] +print("cue order vs script order : %s" + % ("CONSISTENT" if times == sorted(times) and len(times) == 3 + else "check %s" % [(c, round(t, 2)) for c, t, _ in found])) +PYEOF +echo "artifacts in $OUT"