Files
Sylpheed/tools/port/check-capture
MechaCat02 c3758e3850 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

263 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# Provenance check for a multichannel capture, BEFORE anybody analyses it.
#
# tools/port/check-capture /path/to/capture.wav
#
# WHY THIS EXISTS. A 6-channel capture of the game's own output was analysed at
# length -- three controls, a drift test, a written-up negative -- and the file
# was corrupt. PulseAudio was remapping between two mismatched channel maps, and
# a 6-channel remap SILENTLY DROPS AND DUPLICATES: right duration, right channel
# count, plausible per-channel levels, no error anywhere. Two of the six channels
# were byte-identical copies of two others and two source channels were simply
# gone.
#
# The Decoder proved it with a control that needs no emulator and no disc: six
# channels each carrying a different tone through the same sink and the same
# `parec` invocation. Channels came back 400 / 3200 / 200 / 800 / 800 / 200 for
# an input of 400 / 800 / 200 / 1600 / 3200 / 6400 -- see
# `docs/re/audio-capture-channel-map-trap.md`. Setting the sink's `channel_map`
# to the guest's own and passing the same map to `parec` returns all six.
#
# THE DETECTABLE SIGNATURE IS AN EXACT DUPLICATE PAIR. Two channels of a real
# surround mix are never byte-identical over 70 s. Levels are not enough to catch
# it -- the corrupt file's per-channel peaks looked entirely reasonable, and it
# was only equal peak AND equal RMS to six decimals that prompted a hash.
#
# This is a NECESSARY check, not a sufficient one: passing it means the capture
# has no duplicated channels, not that it recorded the right thing.
set -euo pipefail
f="${1:?usage: check-capture FILE.wav}"
# Queried one field at a time. A combined `-show_entries` prints two values on
# ONE comma-separated line, and `read -r ch rate dur` then puts "48000,6" in
# `$ch` -- which every later arithmetic test rejects, in a script whose whole
# job is to be trusted about a file.
probe() { ffprobe -v error -select_streams a:0 -show_entries "$1" -of csv=p=0:nk=1 "$f" | head -1; }
ch=$(probe stream=channels)
rate=$(probe stream=sample_rate)
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0:nk=1 "$f" | head -1)
printf '%s: %sch %sHz %.3fs\n' "$f" "$ch" "$rate" "$dur"
# ⚠️ MONO SKIPS THE DUPLICATE TEST AND STILL GETS THE STARVATION ONE. An earlier
# version returned immediately for a single channel, so the mono voice track --
# one of this tool's four controls -- was never actually run through the check it
# was supposed to control. A control that does not execute is not a control.
dupes=0
if [ "$ch" -lt 2 ]; then
echo " single channel -- no duplicate test, starvation still checked"
else
layout=5.1; [ "$ch" = 2 ] && layout=stereo
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
map=""; for i in $(seq 0 $((ch-1))); do map="$map -map [c$i] $tmp/c$i.wav"; done
split=""; for i in $(seq 0 $((ch-1))); do split="$split[c$i]"; done
# shellcheck disable=SC2086
ffmpeg -hide_banner -v error -y -i "$f" \
-filter_complex "channelsplit=channel_layout=$layout$split" $map
declare -a sums
for i in $(seq 0 $((ch-1))); do
s=$(ffmpeg -hide_banner -v error -i "$tmp/c$i.wav" -f md5 - | cut -d= -f2)
peak=$(ffmpeg -hide_banner -v info -i "$tmp/c$i.wav" -af astats -f null - 2>&1 \
| grep -m1 "Peak level dB" | sed 's/.*: //')
sums[i]="$s"
printf ' ch%-2d peak %-12s %s\n' "$i" "$peak" "$s"
done
for i in $(seq 0 $((ch-1))); do
for j in $(seq $((i+1)) $((ch-1))); do
if [ "${sums[i]}" = "${sums[j]}" ]; then
echo " 🔴 ch$i and ch$j are BYTE-IDENTICAL"
dupes=1
fi
done
done
fi
# STARVATION: the second way a capture looks perfect and carries nothing.
#
# A monitor sink advances at WALL-CLOCK rate and substitutes silence whenever the
# producer is late. An emulator running below real time therefore yields a file
# of exactly the right duration, right channel count, no duplicated channels --
# and chopped into fragments with holes punched between them, thousands of times
# over. Envelope correlation against such a file is destroyed by construction:
# what dominates the envelope is the dropout schedule, not the content.
#
# Measured on the capture that prompted this: 35.6 % of frames silent on all six
# channels, 10 482 alternating runs, median burst 13.5 ms and median gap 3.9 ms
# -- a 17.4 ms period, 57 Hz. The Decoder measured the untruncated original at
# 39.3 % and 10 595 runs; the two agree.
#
# THE DISCRIMINATOR IS THE RUN STRUCTURE, NOT THE SILENCE FRACTION. Real audio is
# full of silence -- a voice track is more than half gaps -- but those are TENS of
# runs of HUNDREDS of milliseconds. Dropout chop is THOUSANDS of runs of a few
# milliseconds. So the test is: many short all-channel gaps.
set +e
python3 - "$f" <<'PYEOF'
import array, struct, sys
d = open(sys.argv[1], 'rb').read()
i, fmt, off = 12, None, None
while i + 8 <= len(d):
cid = d[i:i+4]; sz = struct.unpack('<I', d[i+4:i+8])[0]
if cid == b'fmt ': fmt = d[i+8:i+8+sz]
elif cid == b'data':
off, declared = i + 8, sz; break
i += 8 + sz + (sz & 1)
if fmt is None or off is None:
print(" (not a plain WAV -- starvation check skipped)"); raise SystemExit(0)
tag = struct.unpack('<H', fmt[0:2])[0]
ch = struct.unpack('<H', fmt[2:4])[0]; rate = struct.unpack('<I', fmt[4:8])[0]
bits = struct.unpack('<H', fmt[14:16])[0] if len(fmt) >= 16 else 16
# REFUSE A FORMAT THIS CANNOT READ, rather than mis-reading it confidently.
#
# Everything below assumes 16-bit signed. An ALSA `type file` tee writes
# **float32** (`SND_PCM_FORMAT_FLOAT_LE`), and read as s16 it produces a
# plausible-looking file: the Decoder measured one and its only giveaway was
# per-channel peaks alternating EXACTLY -0.00 / -4.82, which is the two halves
# of each float landing in alternate channels. A checker that mis-reads a format
# is worse than one that has no opinion -- it is the shape of every failure this
# tool exists to catch.
#
# tag 1 = PCM, 3 = IEEE float, 0xFFFE = WAVE_FORMAT_EXTENSIBLE.
#
# ⚠️ EXTENSIBLE IS ACCEPTED AT 16 BITS, and the first version of this guard was
# not -- it rejected one of this tool's own controls, a file `ffprobe` correctly
# calls `pcm_s16le`. A format guard that refuses a legitimate capture is the same
# defect as one that mis-reads an illegitimate one, pointing the other way.
# `wBitsPerSample` is what actually decides how the samples are laid out here, so
# it is what the check turns on; a float tee is 32-bit and is still caught.
if tag not in (1, 0xFFFE) or bits != 16:
print(" 🔴 format tag %d, %d-bit -- this tool reads 16-bit PCM only." % (tag, bits))
print(" Read as s16 a float32 tee looks plausible and is not: its tell is")
print(" per-channel peaks alternating exactly, one float split across two")
print(" channels. Convert first: ffmpeg -i in.wav -c:a pcm_s16le out.wav")
raise SystemExit(4)
avail = len(d) - off
if declared == 0 or declared > avail:
# A streaming writer that never patched its header. The file may also be a
# copy taken while it was still being written -- which happened, and made a
# provenance claim wrong.
print(" ⚠️ data chunk declares %d bytes, %d present -- header never patched;"
% (declared, avail))
print(" treat the duration as unverified and check the file is complete.")
n = avail // (2 * ch)
a = array.array('h'); a.frombytes(d[off:off + n * 2 * ch])
sil = bytearray(n)
for f_ in range(n):
b = f_ * ch
if not any(a[b+c] for c in range(ch)): sil[f_] = 1
tot = sum(sil)
# A GAP IS A RUN, NOT A SAMPLE. The first version of this counted every frame
# whose channels were all exactly zero, and real audio crosses zero constantly --
# it scored a clean voice track at 5 947 "gaps" of median 0.0 ms and called it
# starved. The known-good control caught it. Only runs of at least 1 ms (48
# frames at 48 kHz) count: a zero-crossing is one sample, a dropout is hundreds.
MINGAP = max(1, rate // 1000)
runs_s, runs_n = [], []
cur, ln = sil[0], 0
for v in sil:
if v == cur: ln += 1
else:
(runs_s if cur else runs_n).append(ln); cur = v; ln = 1
(runs_s if cur else runs_n).append(ln)
runs_s = [r for r in runs_s if r >= MINGAP]
if not runs_s:
print(" all-channel silence 0.0% -- no gaps at all"); raise SystemExit(0)
rs = sorted(runs_s); med = 1000.0 * rs[len(rs)//2] / rate
secs = n / float(rate)
rate_per_s = len(runs_s) / secs
print(" all-channel silence %.1f%%, %d gap(s) over 1 ms (%.1f/s), median gap %.1f ms"
% (100.0*tot/n, len(runs_s), rate_per_s, med))
# THE THRESHOLD IS SET FROM CONTROLS, and the first two I invented were both
# wrong -- they failed real audio. Measured:
#
# the starved capture 32.9 gaps/s, median 3.9 ms, 35.6 % silent
# a real music+SFX bed 3.3 gaps/s, median 1.4 ms, 1.1 % silent
# a voice track, 53 % pauses 0.03 gaps/s
#
# Real audio does contain short all-zero runs -- a quiet passage in 16-bit is
# genuinely zero for milliseconds -- so neither the gap COUNT nor the median
# length separates them. The RATE does, by an order of magnitude in both
# directions, and 20/s sits between with a 1.6x margin below the bad case and
# 6x above the worst good one.
# TWO NUMBERS, BECAUSE ONE CANNOT SEE THE FAILURE NEXT DOOR.
#
# The first version of this tested the gap RATE alone, at 20/s. The Decoder then
# measured what a LARGER client buffer does, and the relationship is not
# monotonic: raising `PULSE_LATENCY_MSEC` keeps cutting the rate while total
# silence bottoms out and then doubles, because an over-large buffer starves in a
# few enormous holes instead of many small ones. Its 500 ms capture scores
# **1.3 gaps/s -- better than a genuine music bed at 3.3 -- while being 50 %
# silence**, and my bar passed it. Reproduced here on a file I hold: `bigholes`,
# a real bed with 350 ms holes punched in, is 46.3 % silence at 3.2 gaps/s.
#
# That is the same shape as the level table that could not see a duplicated
# channel. One number, blind to the neighbouring failure.
#
# Controls, all four measured here. 🔴 THE FIGURES LIVE IN THE DOC, NOT HERE.
#
# This table used to restate them, and two of the numbers had DRIFTED from
# `AUDIO-VERIFICATION.md`: 53.3 % here against 53.2 % there, in two places each,
# for the same control. Neither can be re-measured -- that control file was
# transient and is gone -- so there is no way to say which copy aged.
#
# That is the mirror of the trap the Decoder named the same day: they lost a
# finding because its only record was a script comment; this lost a digit because
# a finding had TWO records and nothing kept them equal. A number copied into a
# second place will drift from the first, and the drift is invisible because both
# copies look authoritative.
#
# So the doc is the record and this cites it.
#
# real music bed 1.1 % silence, 3.3 gaps/s PASS
# voice track, mono see AUDIO-VERIFICATION.md PASS (real pauses)
# bed with big holes 46.3 % silence, 3.2 gaps/s FAIL
# the starved capture 35.6 % silence, 30.9 gaps/s FAIL
#
# Rate alone cannot separate rows 2 and 3; silence alone cannot separate rows 1
# and 3, nor 2 and 3. The pair does.
if tot / float(n) >= 0.10 and rate_per_s >= 1.0:
print(" 🔴 STARVED: %.1f%% of the file is silent on every channel, in %.1f gaps"
% (100.0 * tot / n, rate_per_s))
print(" per second (median %.1f ms). Real audio is either mostly not" % med)
print(" silent, or silent in a few long stretches -- not both at once.")
raise SystemExit(3)
# ⚠️ THE REGIME THIS TOOL CANNOT JUDGE, said out loud rather than passed
# silently. High silence with FEW gaps is what a real voice track looks like
# (AUDIO-VERIFICATION.md §7 has the figure) and also what an over-buffered
# capture looks like. No
# statistic here separates them, and inventing a bar for a regime I have no
# control in is how the last two bars in this file came to be wrong.
if tot / float(n) >= 0.10:
print(" ⚠️ %.1f%% silent in only %.1f gaps/s -- UNJUDGED. That is the shape of"
% (100.0 * tot / n, rate_per_s))
print(" a real voice track AND of an over-buffered capture, and this tool")
print(" cannot tell them apart. Check it against a known source before")
print(" concluding anything from it.")
PYEOF
starved=$?
set -e
if [ "$starved" = 4 ]; then
# The duplicate test ran (bytes are bytes) but starvation did not. Saying
# "PASS" here would be the tool claiming a check it skipped.
echo "PARTIAL: channels checked, starvation NOT checked -- unreadable sample format."
exit 2
fi
if [ "$starved" = 3 ]; then
echo "FAIL: the recording is starved. A monitor sink advances at wall-clock rate"
echo " and substitutes silence when the producer is late, so this file has"
echo " the right duration and holes punched through the content. Correlation"
echo " against it is meaningless. See docs/port/AUDIO-VERIFICATION.md §7."
exit 1
fi
if [ "$dupes" = 1 ]; then
echo "FAIL: duplicated channels. A surround remap drops and duplicates silently;"
echo " channels are missing from this file. Do not analyse it -- fix the"
echo " sink's channel_map and re-record. See docs/port/AUDIO-VERIFICATION.md."
exit 1
fi
echo "PASS: no duplicated channels. (Necessary, not sufficient -- this says"
echo " nothing about whether the right thing was recorded.)"