A snapshot of the non-game files as of0148cb8("port: F5/F6 hand-off -- one-minute human checks, and a refutation attempt that survived", 2026-09-04), the tip of auto/port-p6-audio. The branch was deleted from the server on 2026-09-17 during the consolidation cleanup; issue #7 asks for this work as a reviewable PR, so it is recovered here before the commits are garbage collected. Contents: the 84 files the branch changed relative to its fork pointb305aa4, which is this commit's parent. The tree is therefore 0148cb8's tree with the 854 exported game assets left out -- export-probe/, export-probe2/, three .wav renders of game audio and adv-v2-screenlog.tsv. Game data stays out of git; the exporter regenerates those from the disc. docs/port/DECISIONS.md still refers to them by name. Not recovered: the branch's own 366 commits. Keeping them would make those assets reachable again, so this is one snapshot instead. The original commits stay unreferenced in the server's object store, and in this clone under the local branch archive/port-p6-audio, until either is garbage collected. Refs #7. The OPTIONS work that issue #6 asks for is a subset of this branch, also recovered as recover/options-menu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
219 lines
8.9 KiB
GDScript
219 lines
8.9 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 = {}
|
|
## Movie name -> what that voice export is KNOWN to be missing, from the
|
|
## manifest's `incomplete`. Empty for an asset with no known gap.
|
|
var _voice_gaps: Dictionary = {}
|
|
|
|
## What `movie`'s voice export is known to be missing, or "" if nothing is.
|
|
func incomplete_for(movie: String) -> String:
|
|
return String(_voice_gaps.get(movie, ""))
|
|
|
|
## Movie name -> stream, from the entries of kind `voice`.
|
|
##
|
|
## A cutscene's dialogue is NOT in its `.ogv`. On this disc a movie carries music
|
|
## and effects only and the voice is a separate continuous XMA stream in
|
|
## `sound.pak`, bound by the movie manifest -- so playing a movie means starting
|
|
## two streams together, and a port that plays only the video is silently missing
|
|
## every line of dialogue. That is what a human play-test heard.
|
|
var voices: 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
|
|
"voice":
|
|
# A cutscene's voice-over ends with the cutscene. It is keyed by
|
|
# MOVIE NAME, not by a role: the binding came off the disc's own
|
|
# movie manifest, so unlike the music bed there is nothing
|
|
# authored about which recording belongs to which picture.
|
|
stream.loop = false
|
|
voices[String(entry["name"])] = stream
|
|
# Carried alongside the stream so the runtime can announce a known gap
|
|
# at the moment it plays one. Absent means nothing is KNOWN to be
|
|
# missing -- never that the asset was checked and is complete.
|
|
if entry.has("incomplete"):
|
|
_voice_gaps[String(entry["name"])] = String(entry["incomplete"])
|
|
_:
|
|
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() and voices.is_empty()
|
|
|
|
|
|
# --- The cutscene voice -------------------------------------------------------
|
|
|
|
var _voice: AudioStreamPlayer = null
|
|
|
|
|
|
## Start a movie's dialogue, or do nothing when the export carries none.
|
|
##
|
|
## **No offset, and none is authored.** The voice plays from the video's first
|
|
## frame, so the two streams are started together and nothing here compensates
|
|
## for anything. If they ever drift, that is a fact about the export, not a
|
|
## constant to be tuned in this file.
|
|
##
|
|
## Returns whether a stream was found, so the caller can SAY that a movie is
|
|
## unvoiced rather than leave silence looking like success.
|
|
func play_voice(movie: String) -> bool:
|
|
if not voices.has(movie):
|
|
return false
|
|
if _voice == null:
|
|
_voice = AudioStreamPlayer.new()
|
|
add_child(_voice)
|
|
_voice.stream = voices[movie]
|
|
_voice.play()
|
|
return true
|
|
|
|
|
|
## Stop the dialogue. Called when the movie ends OR is skipped -- a voice that
|
|
## outlived a skipped intro would play over the title screen.
|
|
func stop_voice() -> void:
|
|
if _voice != null:
|
|
_voice.stop()
|
|
|
|
|
|
# --- 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()
|
|
|
|
|
|
## 🔴 DEAD CODE, and that is the finding rather than a tidiness note.
|
|
##
|
|
## Nothing in the port calls this. The bed therefore starts when the main menu
|
|
## goes live and never stops -- through the cutscene, and on to the title after
|
|
## it. Nobody chose that; it is what happens when the only way to stop something
|
|
## is a function no caller remembers.
|
|
##
|
|
## It is the mirror of `ScreenView.skipped`, which was written every frame and
|
|
## read by nobody. One is a fact recorded and never surfaced, the other a
|
|
## capability provided and never used, and both were invisible for the same
|
|
## reason: nothing fails when they are missed.
|
|
##
|
|
## Kept, not deleted. The day a capture says whether the game's menu music ducks
|
|
## under a movie, this is the one line that has to change.
|
|
func stop_bed() -> void:
|
|
if _bed != null:
|
|
_bed.stop()
|
|
_bed_role = ""
|
|
|
|
|
|
## Is the music bed sounding right now? Used by the boot to ANNOUNCE that it is
|
|
## still playing under a movie, rather than to stop it.
|
|
func bed_playing() -> bool:
|
|
return _bed != null and _bed.playing
|
|
|
|
|
|
## 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()
|