The manifest has carried the gap for weeks and the runtime printed '+ voice ADV' and nothing else. A reader of manifest.json gets a paragraph; a person LISTENING gets clean dialogue and no way to learn a stream is absent. NEW GAME already announces the screens it jumps over; audio had no equivalent. ManifestAudio gains -- one line naming what is KNOWN missing, absent meaning nothing is known rather than nothing is wrong -- and MenuAudio carries it so _play_video can print it. Verified on the boot's ADV and P7's S00A. The first version of the message was FALSE for one of the two assets: it said 'one is a start-truncated stream', which is ADV's story, where S00A's dropped chunks are digitally silent. Caught by reading the output for both, which I nearly skipped because the ADV line was obviously right. Now states the counts and points at the entry's why. A message generated once from a template but true only for the case it was written against is harder to see than a wrong number -- the sentence is well-formed and confident in both places. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
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()
|