port: P6 -- the menu has sound, and the BGM I "chose" was decoded all along

The three Static.slb cues and the menu bed now export to Ogg Vorbis and play.
`sylpheed_formats::media` does the assembly; nothing in port/ has heard of XMA.

Three things this milestone got wrong before it got right, all recorded in
docs/port/DECISIONS.md because the corrections are the useful part:

1. The cue offsets were a Rust `const` in the exporter. They are MEASURED, not
   decoded -- a measured value compiled into the exporter is a measurement
   wearing the costume of a decoded field, and nobody deletes it because nobody
   can see it. They are authored/audio.json now.

2. I picked BGM_001 and wrote a careful `why` calling the choice arbitrary. The
   menu's music is BGM_103, and it is in HANDOFF at 9ca1eb5 -- the exact commit
   BLOCKED.md says that row was reconciled against. Not stale: wrong when
   written. I had summarised a negative without its reach, so "the TABLES cannot
   say which BGM a screen plays" became "it is not on the disc". One word of
   scope was the whole answer, and the export failed only because BGM_001
   without its .slb extension hashes to nothing. That is luck, not design.

3. The comment above the BGM sum argued for unity gain "because halving is a mix
   decision nobody made". It clipped at +1.8 dBFS. 1/n is the smallest constant
   that provably cannot clip -- the same reasoning video.rs already carried for
   its 5.1 downmix, in this repository, unread.

Unsettled and shipped as such: media::sound_bank_riffs returns THREE sub-waves
for BGM_103.slb where HANDOFF Q10's census says exactly two (the third is the
leading headerless region slb.rs emits for the voice path). The exporter sums all
three and writes a manifest warning, because which bytes belong together is the
decoders' question, not this exporter's -- and dropping one would destroy the
evidence, since a corrected export looks exactly like a correct one. Raised with
the Decoder; row in BLOCKED.md.

The gate is a null control, not a peak reading. A master-bus WAV that is
non-silent proves nothing -- the bed alone would look identical. So the same
scripted walk was run with <- in place of <v>, which fires no cue (Q5, measured),
and the difference is one 0.55 s burst at t=1.10 s and silence everywhere else.
The first attempt at that control returned bit-identical zero and I nearly filed
it as "cues never reach the bus": both runs ended at 1.115 s and the first press
lands at 1.17 s. A null result from an instrument that was not running is not a
null result.

Refutation attempt: HANDOFF Q8's three cue durations. They looked attackable --
0.133/0.172/0.169 s per packet, no shared rate -- but an XMA1 packet carries a
variable number of 512-sample frames, and the three come to 50.0/32.3/95.3
frames. Measured off the decoded Ogg: 0.533, 0.344, 1.016 s, every published
digit. SURVIVES, with its reach stated -- it confirms the assembly path and my
transcription, not the event bindings, which only an oracle can retake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WM5XL4HfrHuxz8RiMWdCMC
This commit is contained in:
Sylpheed port agent
2026-08-29 12:29:13 +00:00
parent aebd79a3b9
commit 2ad839460c
11 changed files with 1448 additions and 22 deletions

View File

@@ -14,6 +14,7 @@
# godot --path port -- --menu=extras # ...starting somewhere else
# godot --path port -- --boot --play # boot, then hand over to P5
# godot --path port -- --menu --script=down,down,accept,cancel --shots=/tmp/p5
# godot --path port -- --menu --script=down,accept --audio=/tmp/p6.wav
#
# `--menu` is the P5 mode: the d-pad moves the cursor, (A) opens, (B) goes back.
# `--script` drives the SAME input path with synthetic events -- it does not call
@@ -21,6 +22,14 @@
# nothing about whether a human's press arrives. `--shots` writes one PNG per
# scripted step, after the screen it produced has settled.
#
# `--audio=` records the MASTER BUS to a WAV for the whole run. Neither container
# has a sound card, so "does it actually play?" cannot be answered by listening --
# but it can be answered by measurement, and an `AudioEffectRecord` on Master
# captures the mixed output from inside a headless run with no device at all.
# `docs/port/AUDIO-VERIFICATION.md` §2. The run PRINTS the audio driver it used,
# because "recorded under a dummy driver" is a weaker claim than "heard" and the
# write-up has to be able to say which one it is making.
#
# `--time` is in SECONDS and freezes the timeline there; without it the screen
# animates in real time from t=0. `--pose=rest` draws the export's declared
# resting pose instead of the timeline -- what the reference renderer draws, so
@@ -38,6 +47,7 @@ const DEFAULT_SCREEN := "main_menu"
var view: ScreenView = null
var viewport: SubViewport = null
var audio: MenuAudio = null
func _ready() -> void:
@@ -60,6 +70,21 @@ func _ready() -> void:
if args.has("boot"):
for step: Dictionary in _flow["boot"]:
_sequence.append(step)
# P6. Audio is loaded even for a static `--screen` run: it costs nothing when
# the export has none, and a mode that silently cannot play sound is a mode
# that hides the failure this milestone is about.
audio = MenuAudio.new()
add_child(audio)
if not audio.configure(export_tree):
push_error(audio.error)
get_tree().quit(2)
return
if audio.silent():
print("this export carries no audio -- run the exporter against a disc for P6")
_record_to = args.get("audio", "")
if _record_to != "":
_start_recording()
_film = args.get("film", "")
_shots = args.get("shots", "")
if args.has("script"):
@@ -302,22 +327,35 @@ func _unhandled_input(event: InputEvent) -> void:
# are different lines of code.
pass
elif event.is_action_pressed("ui_accept"):
_menu_activate(_menu.accept(buttons))
_menu_activate(_menu.accept(buttons), "confirm")
elif event.is_action_pressed("ui_cancel"):
_menu_activate(_menu.cancel())
_menu_activate(_menu.cancel(), "back")
func _menu_move(step: int, buttons: Array) -> void:
# MEASURED, HANDOFF Q8 + Q5: the cue fires on a press that MOVES the cursor.
# `move()` returns whether it did, so a press that changes nothing cannot
# click -- which also means left/right stay silent by construction rather
# than by a rule written twice.
if _menu.move(step, buttons):
view.focused_id = _menu.focus()
view.queue_redraw()
audio.play("move")
print(" focus -> %s" % view.focused_id)
## Act on what the flow returned. A destination starts the screen playing itself
## out; the arrival happens in `_process` when the exit ramp is done, so the
## fade is the transition HANDOFF Q7 measured and not a cut.
func _menu_activate(action: Dictionary) -> void:
func _menu_activate(action: Dictionary, cue: String = "") -> void:
# AUTHORED, NOT MEASURED: the cue fires when the press does something, and
# not when nothing is bound to it. Nobody has watched the game take a dead
# press. Silence invents the less of the two -- a sound the game does not
# make is a wrong fact you can hear. `blocked` counts as doing something:
# that destination WAS measured off the running game and is missing from
# this export, not from the game. See port/scripts/menu_audio.gd.
if cue != "" and String(action.get("kind", "none")) != "none":
audio.play(cue)
match String(action.get("kind", "none")):
"enter":
print(" (%s) -> %s" % [action.get("label", ""), action["goto"]])
@@ -342,6 +380,12 @@ func _menu_enter(name: String, fresh: bool) -> void:
_menu.enter(name, view.screen.get("buttons", []))
view.focused_id = _menu.focus()
view.queue_redraw()
# AUTHORED, and the weakest thing in P6: HANDOFF Q10 says nothing on the disc
# names which track a menu plays, so `authored/audio.json` picks one. It
# starts when the menu becomes live and CARRIES ACROSS submenus -- `play_bed`
# is idempotent, because music that restarts every time you press (B) is the
# kind of wrong that reads as "the audio works".
audio.play_bed("main_menu")
print(" menu on %s, focus %s" % [name, _focus_label(view.focused_id)])
if not _script.is_empty() and not _script_started:
_script_started = true
@@ -539,3 +583,63 @@ func _shoot(label: String) -> void:
return
DirAccess.rename_absolute(tmp, path)
print(" shot %s (%s, focus %s)" % [path, _menu.current(), _focus_label(view.focused_id)])
# ── Recording the master bus ──────────────────────────────────────────────────
#
# `docs/port/AUDIO-VERIFICATION.md` §2. This is what closes the loop that file
# opens: comparing an exported Ogg against the disc proves the ASSET is right and
# says nothing about whether the engine ever reached it. A WAV captured off the
# Master bus proves both, and needs no sound card to do it.
#
# It is saved in `_exit_tree` rather than beside each `quit()` because there are
# eight of those and the one that would get missed is an error path -- exactly
# the run whose audio somebody wants to look at.
var _record_to := ""
var _record: AudioEffectRecord = null
func _start_recording() -> void:
var bus := AudioServer.get_bus_index("Master")
_record = AudioEffectRecord.new()
AudioServer.add_bus_effect(bus, _record)
_record.set_recording_active(true)
print("recording the Master bus to %s (audio driver: %s)" % [_record_to, MenuAudio.driver()])
func _exit_tree() -> void:
if _record == null:
return
_record.set_recording_active(false)
var wav := _record.get_recording()
_record = null
if wav == null:
push_error("--audio: the Master bus recorded nothing at all")
return
# Write to a temp name and rename on completion, as everything else in this
# project does: another agent probing a file still being written gets a
# confident wrong duration rather than an error.
#
# ⚠️ The temp name ends in `.wav`, and that is not cosmetic. `save_to_wav`
# APPENDS `.wav` when the path does not already end in it, so `p6.wav.part`
# silently became `p6.wav.part.wav` -- and the rename below then failed to
# find its source and returned an error nobody read, leaving a run that
# printed success beside a file that was not there. This is the same bug the
# exporter's `run_ffmpeg` had in a different dialect: a temp-name convention
# must preserve the extension, because tools dispatch on it.
var tmp := _record_to + ".part.wav"
if wav.save_to_wav(tmp) != OK:
push_error("--audio: cannot write %s" % tmp)
return
var moved := DirAccess.rename_absolute(tmp, _record_to)
if moved != OK:
# Say so rather than print the success line below. A rename that fails
# quietly is worse than one that fails loudly: the caller measures a
# path that does not exist and reads "no such file" as "no audio".
push_error("--audio: wrote %s but could not rename it to %s (%d)"
% [tmp, _record_to, moved])
return
print("recorded %.3f s of Master bus -> %s (driver %s)"
% [float(wav.data.size()) / float(wav.mix_rate * 2 * (2 if wav.stereo else 1)),
_record_to, MenuAudio.driver()])

134
port/scripts/menu_audio.gd Normal file
View File

@@ -0,0 +1,134 @@
# The menu's sound: three cues and one music bed.
#
# EVERYTHING THIS CLASS PLAYS IS AUTHORED OR MEASURED, and the two are not the
# same. `authored/audio.json` carries the distinction and the exporter copies it
# into `manifest.json` alongside each file, so a reader of the export tree sees
# it without having to find this project:
#
# * WHICH WAVE a menu event plays was MEASURED off the running game (HANDOFF
# Q8) -- it is on the disc in no findable form. `Static.slb` has no RIFF, no
# seek chunk and no container.
# * WHICH TRACK the menu plays is CHOSEN. HANDOFF Q10 is a negative: all 32
# banks are named BGM_001..BGM_109 and nothing on the disc says which one a
# menu uses.
# * WHEN a cue fires is authored here, and §"When a cue fires" below says
# exactly which parts of that nobody has watched the game do.
#
# The wall (MISSION §2): this class reads **Ogg Vorbis**. It has never heard of
# XMA, of `sound.pak` or of `Static.slb`, and it must not learn. The exporter
# converts; the runtime plays.
class_name MenuAudio
extends Node
## Cue name -> stream, from `manifest.json`'s `audio` entries of kind `se`.
var cues: Dictionary = {}
## Role -> {stream, loop}, from the entries of kind `bgm`.
var beds: Dictionary = {}
var error: String = ""
## One player per cue name, so a move and a confirm can overlap rather than
## cutting each other off. Three cues is not worth a pool.
var _players: Dictionary = {}
var _bed: AudioStreamPlayer = null
var _bed_role := ""
## Load every audio entry the manifest declares.
##
## Missing audio is NOT an error and does not stop a run: every milestone before
## P6 exported none, and `--menu` must stay usable against one of those trees.
## A cue that is listed but unreadable IS an error, because that is a broken
## export rather than an old one.
func configure(tree: ExportTree) -> bool:
var manifest := tree.manifest()
if manifest.is_empty():
error = tree.error
return false
for entry: Dictionary in manifest.get("audio", []):
var path := tree.root.path_join(String(entry.get("file", "")))
var stream := AudioStreamOggVorbis.load_from_file(path)
if stream == null:
error = "manifest lists audio %s but %s is not a readable Ogg Vorbis file" \
% [entry.get("name", "?"), path]
return false
match String(entry.get("kind", "")):
"se":
# A cue ends. Nothing measured says otherwise, and a looping
# cue would be a bug you hear rather than one you read.
stream.loop = false
cues[String(entry["name"])] = stream
"bgm":
# AUTHORED, and audibly imperfect on purpose. HANDOFF Q10: no
# loop-point field has been identified, so `restart` replays
# from sample 0 -- the listener hears the track's own fade-out
# and its trailing silence before the music returns. Trimming to
# the fade would sound better and would INVENT a loop point,
# which is worse: an invented one is indistinguishable from a
# decoded one a month later. See authored/audio.json loop_why.
stream.loop = String(entry.get("loop_mode", "")) == "restart"
beds[String(entry["name"])] = stream
_:
push_warning("manifest audio entry %s has kind %s, which this build does not play"
% [entry.get("name", "?"), entry.get("kind", "?")])
return true
## True when this export carries no audio at all -- an export taken before P6.
func silent() -> bool:
return cues.is_empty() and beds.is_empty()
# --- When a cue fires ---------------------------------------------------------
#
# MEASURED (HANDOFF Q5 + Q8): a d-pad press that MOVES the cursor plays the move
# cue, and left/right play nothing at all. `MenuFlow.move()` returns whether the
# cursor actually moved for exactly this reason, so a press at the end of a
# non-wrapping list cannot click.
#
# NOT MEASURED, and authored here: whether Ⓐ or Ⓑ click when nothing is bound to
# them. Nobody has watched the game take a dead press. This class stays silent in
# that case, which is the choice that invents the least -- a sound the game does
# not make is a wrong fact you can hear, whereas a missing one is a gap. Ask the
# RE agent before relying on it either way.
func play(cue: String) -> void:
if not cues.has(cue):
return
if not _players.has(cue):
var p := AudioStreamPlayer.new()
p.stream = cues[cue]
add_child(p)
_players[cue] = p
(_players[cue] as AudioStreamPlayer).play()
## Start the music bed for a role, or do nothing if it is already playing.
##
## Idempotent because the menu re-enters screens constantly -- Ⓑ back to the main
## menu must not restart the music, and a bed that restarts on every navigation
## is the kind of wrong that reads as "the audio works".
func play_bed(role: String) -> void:
if not beds.has(role) or _bed_role == role:
return
if _bed == null:
_bed = AudioStreamPlayer.new()
add_child(_bed)
_bed.stream = beds[role]
_bed_role = role
_bed.play()
func stop_bed() -> void:
if _bed != null:
_bed.stop()
_bed_role = ""
## What the audio server is actually doing, for a run's write-up.
##
## `docs/port/AUDIO-VERIFICATION.md`: "recorded under a dummy driver" is a
## weaker claim than "heard", and the difference matters -- so the claim is
## printed by the run that makes it rather than assumed by the person reading it.
static func driver() -> String:
return AudioServer.get_driver_name()

View File

@@ -0,0 +1 @@
uid://badw3pulb0xpt