Takes the port branch up to77320d5e-- the state the human play-tested on 2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio` is 366 commits and 938 files, and most of that must not land. WHAT COMES IN (76 files, all human-confirmed working): * the logo splash animation.08ed3dd1found it: `pose_at` ASSIGNED the settle instant instead of clamping to it, so the splash never animated at all -- and the same bug manufactured a passing harness result, because the harness photographed t past the settle. Confirmed by play-test: "cannot notice any obvious difference from the actual game." * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad binding), stick latched with hysteresis at the game's own 61% digitise threshold. This is what made (A), video-skip and Extras work at all. * menu navigation and flow, menu audio, the exporter, the authored declarations, and 23 verification tools under tools/port/. WHAT IS DELIBERATELY LEFT ON THE BRANCH: * everything afterc0ae460a-- the F5/F6 title-timing investigation, whose own tip commit calls itself a "hand-off for one-minute human checks". Unchecked by definition; it goes through the new review gate like anything else. * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested. * the F1 repeat mechanism, which its own commit calls "deliberately inert". WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED: 545 MB of extracted game content was committed on that branch -- 850 sprite, audio and transcoded video files under `export-probe/` and `export-probe2/`, plus 246 MB of loose .wav and .tsv at the repo root. This repository's own rule, in this file, is "never game content". The rule was not missing. It was written, and it was tightened on that very branch, with a careful comment explaining why BOTH `export/` and `data/base/` had to be listed -- while the exporter was writing to a third name that nobody had thought to list. Enumerating names is the thing that failed. So the ignore rules now describe the SHAPE: any top-level `export*/`, game media by extension, and loose capture output at the root. Verified both ways -- it catches all four offenders and ignores nothing currently tracked. Verified: `cargo check --workspace` clean; all nine GDScript files parse in project context, with a positive control (an injected syntax error is detected, 3 lines) so the clean result means something. `tools/port/check-all` was NOT run -- it needs the container, the export tree and a display.
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()
|