Files
Sylpheed/tools/port/verify-menu-audio
MechaCat02 a23c321831 port: land the play-tested work, and only that
Takes the port branch up to 77320d5e -- the state the human play-tested on
2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio`
is 366 commits and 938 files, and most of that must not land.

WHAT COMES IN (76 files, all human-confirmed working):
  * the logo splash animation. 08ed3dd1 found it: `pose_at` ASSIGNED the settle
    instant instead of clamping to it, so the splash never animated at all --
    and the same bug manufactured a passing harness result, because the harness
    photographed t past the settle. Confirmed by play-test: "cannot notice any
    obvious difference from the actual game."
  * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad
    binding), stick latched with hysteresis at the game's own 61% digitise
    threshold. This is what made (A), video-skip and Extras work at all.
  * menu navigation and flow, menu audio, the exporter, the authored
    declarations, and 23 verification tools under tools/port/.

WHAT IS DELIBERATELY LEFT ON THE BRANCH:
  * everything after c0ae460a -- the F5/F6 title-timing investigation, whose own
    tip commit calls itself a "hand-off for one-minute human checks". Unchecked
    by definition; it goes through the new review gate like anything else.
  * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested.
  * the F1 repeat mechanism, which its own commit calls "deliberately inert".

WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED:
  545 MB of extracted game content was committed on that branch -- 850 sprite,
  audio and transcoded video files under `export-probe/` and `export-probe2/`,
  plus 246 MB of loose .wav and .tsv at the repo root. This repository's own
  rule, in this file, is "never game content".

  The rule was not missing. It was written, and it was tightened on that very
  branch, with a careful comment explaining why BOTH `export/` and `data/base/`
  had to be listed -- while the exporter was writing to a third name that
  nobody had thought to list. Enumerating names is the thing that failed. So
  the ignore rules now describe the SHAPE: any top-level `export*/`, game media
  by extension, and loose capture output at the root. Verified both ways -- it
  catches all four offenders and ignores nothing currently tracked.

Verified: `cargo check --workspace` clean; all nine GDScript files parse in
project context, with a positive control (an injected syntax error is detected,
3 lines) so the clean result means something. `tools/port/check-all` was NOT
run -- it needs the container, the export tree and a display.
2026-09-04 16:17:14 +02:00

247 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# Does the port actually MAKE SOUND on the P5 walk, and the RIGHT sound?
#
# tools/port/verify-menu-audio # assert
# tools/port/verify-menu-audio --control # can it fail?
#
# 🔴 FOR WEEKS THIS COULD NOT FAIL. It computed the verdict, printed a red line
# when a cue was silent -- and the python had NO EXIT PATH, so it returned 0
# every time while `check-all` registered it `must-pass`. A cue could stop
# sounding and the suite would print the failure and stay green.
#
# That is this project's recurring defect one level up: not an instrument that
# sits below the thing under test, but an instrument that SEES the failure and
# does not report it. Ask of any check: what would this still report if the
# feature were absent -- AND what would it EXIT?
#
# 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}"
CONTROL=0; [ "${1:-}" = "--control" ] && CONTROL=1
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
# 🔴 AND A PER-CUE KNOWN NEGATIVE, because the bed-only control could not settle
# what it was being asked. `move` reported NOT FOUND on three consecutive runs at
# margins 0.109/0.120/0.131 against a 0.15 line that a documented earlier run had
# cleared at 0.185. Two readings fit that -- the cue stopped playing, or the
# threshold sits above the quietest cue's true signal -- and A MARGIN CANNOT
# SEPARATE THEM, because both produce a small number.
#
# So each cue now gets its own negative: the SAME walk, with only that cue's .ogg
# replaced by silence through the mod tree. Silencing a cue that is playing must
# collapse its correlation and leave the other two alone, which is a 3x3 matrix
# with six off-diagonal controls rather than one number to compare against a
# threshold.
for c in move confirm back; do
d="$OUT/sup_$c"; mkdir -p "$d/audio/se"
ffmpeg -v error -f lavfi -i anullsrc=r=44100:cl=stereo \
-t "$(ffprobe -v error -show_entries format=duration -of csv=p=0 export/audio/se/$c.ogg)" \
-c:a libvorbis "$d/audio/se/$c.ogg" -y
SYLPHEED_MODS="$d" run "sup_$c" down,down,accept,cancel,up
grep -q "^mod: audio/se/$c.ogg" "$OUT/sup_$c.log" || {
echo "the $c override was never read -- the matrix below would be meaningless" >&2
exit 2; }
done
# THE CONTROL. Replace the walk with the run that already had `move` silenced, so
# the cue is genuinely missing from the baseline. Silencing it again can then
# remove nothing, the diagonal cannot drop, and the check MUST fail. Built from
# the tool's OWN suppression machinery rather than a second mechanism -- a
# control built a different way tests the control, not the check.
if [ $CONTROL -eq 1 ]; then
cp "$OUT/sup_move.wav" "$OUT/walk.wav"
echo "control: analysing a walk in which \`move\` never sounded"
fi
rc=0
python3 - "$OUT" <<'PYEOF' || rc=$?
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 still means IDENTICAL --
# but aligned to a WHOLE AUDIO BUFFER, because the recording is not
# sample-deterministic across runs and never was.
#
# 🔴 This check compared the two byte streams directly and passed for weeks.
# It then began failing, and the cause is not the port: three IDENTICAL
# invocations produce two distinct outcomes, 1.207438 s and 1.300317 s,
# differing by 0.092879 s = **exactly 4096 samples**, one mixing buffer. The
# recording quantises to whole buffers and a one-buffer shift moves both the
# length and the alignment of everything inside it.
#
# So the old premise -- cross-run bit-determinism -- was never guaranteed. It
# held while the run's timing sat away from a buffer boundary, and a larger
# export (three voice streams instead of one) moved it onto one. A test that
# passes by luck reports the luck running out as a regression in the code.
#
# The fix keeps the strength that mattered: still EXACT equality, still no
# threshold to tune. It only allows the comparison to slide by whole buffers,
# which is the one degree of freedom the recorder actually has.
BUF = 4096
best = None
for k in (0, BUF, -BUF, 2*BUF, -2*BUF):
a, b = (ctrl[k:], noop) if k >= 0 else (ctrl, noop[-k:])
n = min(len(a), len(b))
if n < BUF:
continue
if a[:n].tobytes() == b[:n].tobytes():
best = (k, n)
break
if best:
print("no-op presses vs bed alone : IDENTICAL -- silent (%d samples, %+d buffer shift)"
% (best[1], best[0] // BUF))
else:
n = min(len(ctrl), len(noop))
print("no-op presses vs bed alone : DIFFER at every whole-buffer alignment "
"-- the port sounds a dead press (%d samples)" % 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 = []
tpls = {}
for cue in ("move", "confirm", "back"):
tpl = dec("export/audio/se/%s.ogg" % cue, "%s/%s.raw" % (O, cue))[:int(0.15*SR)]
tpls[cue] = tpl
rw, tw = slide(tpl, walk)
rc, _ = slide(tpl, ctrl)
# 🔴 NO VERDICT ON THIS LINE ANY MORE. It used to print PRESENT/NOT FOUND on
# `margin > 0.15`, and it called `move` NOT FOUND on three consecutive runs at
# 0.109/0.120/0.131 while the cue was DEMONSTRABLY SOUNDING -- silencing its
# .ogg collapses it to the bed floor. The bed-only control is a DIFFERENT RUN,
# so its margin carries every difference between two runs; the threshold that
# once cleared 0.185 was never a property of the cue. The number is still worth
# printing. The verdict now comes from the suppression matrix below.
hit = rw - rc > 0.15
found.append((cue, tw, hit))
print("%-8s walk r=%.3f at %5.2fs | bed-only r=%.3f | margin %+.3f"
% (cue, rw, tw, rc, rw-rc))
# 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.
# 3b. THE SUPPRESSION MATRIX. Row = the cue silenced, column = the template
# searched for. The diagonal is the only cell that should move.
sup = {c: dec("%s/sup_%s.wav" % (O, c), "%s/sup_%s.raw" % (O, c))
for c in ("move", "confirm", "back")}
base = {c: slide(tpls[c], walk)[0] for c in tpls}
print("\nsuppression matrix -- drop in r when one cue's .ogg is silenced")
print(" " + "".join("%9s" % c for c in ("move", "confirm", "back")))
ok = True
for row in ("move", "confirm", "back"):
drops = {col: base[col] - slide(tpls[col], sup[row])[0] for col in ("move", "confirm", "back")}
print(" silence %-6s" % row + "".join("%+9.3f" % drops[c] for c in ("move", "confirm", "back")))
if drops[row] <= 0.05:
ok = False
print(" 🔴 silencing %s did not remove %s -- that cue is NOT SOUNDING" % (row, row))
print(" => %s" % ("all three cues SOUND: silencing each one collapses its own signal"
if ok else "at least one cue is not sounding"))
# 🔴 THE VERDICT EXITS. Everything below this line is REPORTED, not asserted, and
# deliberately so: the no-op-silence line and the cue-order line both carry
# DOCUMENTED cross-run instability (whole-buffer recording shifts; a 0.15 margin
# this file's own comments show going to 0.109 on a sounding cue). Making either
# binding would produce red on correct audio, which is how a suite gets ignored.
# The diagonal has no threshold to drift: silencing a cue either removes its own
# signal or it was never there.
VERDICT_FAILED = not ok
# 🔴 THE VERDICT IS THE DIAGONAL ONLY, and the first version of this asserted the
# off-diagonal too -- "silencing a cue must not move the others". That failed, and
# the material is why: `confirm` lands at 1.12 s and `back` at 1.21 s, 0.09 s apart
# under a 0.15 s template, so the two windows OVERLAP. Silencing `confirm` raises
# `back` by 0.468 because confirm was masking it. That is a fact about two cues the
# game plays 90 ms apart, not a fault, and an assertion that calls it one would
# fail forever on correct audio.
print(" (off-diagonal is MASKING between overlapping cues, not an error --")
print(" confirm at 1.12 s and back at 1.21 s share a 0.15 s window)")
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]))
raise SystemExit(1 if VERDICT_FAILED else 0)
PYEOF
if [ $CONTROL -eq 1 ]; then
if [ $rc -eq 0 ]; then
echo
echo " 🔴 CONTROL FAILED -- the check passed a walk with \`move\` silenced, so it"
echo " cannot detect a cue that stops sounding."
exit 1
fi
echo
echo "the check rejects a run with a cue missing (rc=$rc)"
exit 0
fi
exit $rc