Files
Sylpheed/port/scripts/menu_audio.gd
Sylpheed port agent 7132c4a326 port: implement MODDING rule 4, and withdraw a red flag that was my own bad measurement
MODDING.md calls base-and-overrides "a design constraint on the exporter today,
not a milestone to add later". Nothing read `data/mods/` at all -- the directory
has existed since the monorepo merge with a .gitkeep and no code path anywhere.
Eight milestones shipped past it.

ExportTree.resolve() now shadows by path, and every read goes through it:
screens, sprites, cues, the music bed, movies. MenuAudio was reading tree.root
directly and would otherwise have made audio the one asset kind a mod could not
touch, for no reason a modder could have guessed. No manifest, no registration
step -- the path IS the registration, which is the whole of the rule. One tree,
not a stack: layering needs a load order and nobody has asked for one, so
data/mods/README.md says that rather than inventing it.

Every shadowed file is printed as it is read. The first version summarised in
_ready, before any asset had been read, so it always said "nothing shadowed yet"
-- a report structurally incapable of reporting anything, which is worse than
none because it looks like an answer.

data/mods/ was NOT gitignored, and that is a hole in a hard rule: a mod is
usually an edited game asset, and this was the one directory a user is invited
to put modified sprites in and git would have taken them. Now excluded except
the README.

Gate: a synthetic 203x43 magenta PNG (nothing disc-derived) at
data/mods/sprites/title/main_menu/ptbtn01.png changes 8501 pixels in a bounding
box of exactly 203x43 at the button's position, and `check` still passes.

RAISED, NOT RESOLVED: MODDING.md says the tree is data/base/, PORT-MISSION.md §3
and the exporter and .gitignore say export/. Both are mission files and only the
human changes a mission.

REFUTATION on Q3's paint-order key: 2 of 16 screens did not match a stable sort
by layer key -- but that was my test. pgloading_eff00.prm carries NO layer key
(layer_source "none"): a primitive with no sprite header and no implied-name
fallback. I sorted keyless first; the decoders put it last, which is right,
since it is the full-screen black quad and HANDOFF's own sentence is that the
fade quad paints last. Completing the rule to "keyless last" gives 16 of 16.
SURVIVES. Recorded because the published claim does not say where a keyless
element goes and there is one in the archive. Separately the tie-break's reach
looks understated: 105 elements share a layer key across 12 of 16 screens, where
HANDOFF characterises the cost as "one element's blend on one screen".

WITHDRAWN, and it was mine: I filed "the runtime mix has no headroom" in red
twice, off a peak reading. Measured properly it is 43 samples at full scale in
5.9 s and 24 in 98.5 s, longest run 0.25 ms -- the disc's own confirm cue on a
transient, possibly only in the 16-bit save. Nothing changed, deliberately:
attenuating would be an unmeasured level decision of the kind I refused for the
loop point. A peak reading is not a clipping measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WM5XL4HfrHuxz8RiMWdCMC
2026-08-29 13:41:00 +00:00

139 lines
5.5 KiB
GDScript

# 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", []):
# Through the resolver, so a mod can replace a cue or the music bed by
# dropping a file at the same relative path (MODDING rule 4). Reading
# `tree.root` directly here would have made audio the one asset kind a
# mod could not touch, for no reason a modder could have guessed.
var path := tree.resolve(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()