# 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()