port: the intro had no dialogue because the voice is a separate asset, and I concatenated it wrongly first
A human play-test heard music under the boot intro and no voices. The obvious reading -- the 5.1 fold dropped the centre channel -- is wrong. `ADV.wmv` carries music and effects only; a cutscene's voice is a separate continuous XMA stream in `sound.pak`, bound to the movie by the manifest in `tables.pak`. Nothing was dropped. The exporter had never been asked for it, so every fidelity measurement in AUDIO-VERIFICATION.md would have come back clean. `audio::export_voice` resolves it with `media::resolve_movie_voice_region` and never by filename: `RT01A`'s voice lives inside `VOICE_ADV.slb`, so a name match is correct on exactly the two movies this port would have spot-checked. Decoded, not authored -- so it runs outside the `authored/audio.json` block. THE FIRST VERSION CONCATENATED THE REGION'S CHUNKS AND WAS WRONG. It produced 359 s of dialogue for a 137 s movie. Decoding and timing each chunk shows two of them equal to six decimals and each spanning the whole movie -- HANDOFF Q10's decoded two-stem shape on a second asset kind -- so they are summed at 1/n. The error was visible only because the first version recorded the decoded length against the movie's instead of clamping to it; the clamp `media`'s own doc comment invites, and which `sylpheed-viewer` applies, would have produced a file of exactly the right duration containing the wrong audio. The dropped leading chunk matches no duration in its region and is NOT closed here. It is the same signature as `BGM_103`'s third sub-wave, already open in BLOCKED.md, now corroborated on an independent asset kind. Raised with the Decoder; the manifest names every chunk dropped and its length. Also in this commit, and separable: * `--skip-at=SECONDS` -- `--script` structurally cannot press during a movie, because `_script_settled` waits while `_player != null`. That is why "does (A) skip the intro" had been read out of the source rather than measured. * MISSION section 6 pins a 5.1->stereo matrix and this exporter has shipped a different one since P4 -- the same weighting, 7.65 dB quieter -- and said so nowhere. Re-measured with the right instrument (float decode, whole file, count the samples that would clamp, not a peak reading): the pinned matrix puts ADV at +4.26 dBFS on 4406 samples, while S00A never clips. So the pin overloads one movie and the constant is over-broad for the other. NOT changed -- the level of a mix is what section 6 reserves to a human. The export now carries a warning with the numbers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
This commit is contained in:
41
crates/sylpheed-export/examples/voice_chunks.rs
Normal file
41
crates/sylpheed-export/examples/voice_chunks.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Throwaway probe: how long is each region chunk of a movie's voice?
|
||||
//!
|
||||
//! The question it answers is whether the chunks of a resolved voice region are
|
||||
//! CONSECUTIVE SEGMENTS (concatenate them) or ALTERNATE TAKES (chunk 0 is the
|
||||
//! whole track). Getting that backwards plays the dialogue three times over.
|
||||
use std::process::Command;
|
||||
use sylpheed_formats::{media, slb::VoiceLang};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let src = media::DirectorySource::new(&disc);
|
||||
for movie in ["ADV", "S00A", "RT01A"] {
|
||||
let Some((s, e)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)
|
||||
else {
|
||||
println!("{movie}: no region");
|
||||
continue;
|
||||
};
|
||||
let riffs = media::voice_region_riffs(&src, s, e).expect("riffs");
|
||||
println!("{movie}: region [{s}, {e}) = {} bytes, {} chunk(s)", e - s, riffs.len());
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let p = std::env::temp_dir().join(format!("vc_{movie}_{i}.xma.wav"));
|
||||
std::fs::write(&p, r).unwrap();
|
||||
// XMA declares no duration, so DECODE it and measure the result.
|
||||
let w = std::env::temp_dir().join(format!("vc_{movie}_{i}.wav"));
|
||||
let _ = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
.arg(&p)
|
||||
.arg(&w)
|
||||
.output();
|
||||
let out = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"])
|
||||
.arg(&w)
|
||||
.output()
|
||||
.unwrap();
|
||||
let dur = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let _ = std::fs::remove_file(&w);
|
||||
println!(" chunk {i}: {} bytes -> {dur} s", r.len());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,3 +420,276 @@ pub fn export_bgm<S: DiscSource + ?Sized>(
|
||||
sub_waves: riffs.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// One cutscene's voice-over, as `audio/voice/<movie>.ogg`.
|
||||
///
|
||||
/// ## Why this is a separate file from the movie at all
|
||||
///
|
||||
/// A human play-test heard music under the intro and no dialogue, and the
|
||||
/// obvious reading — "the transcode dropped a channel" — is wrong. `ADV.wmv`
|
||||
/// genuinely carries **music and effects only**. On this disc a cutscene's voice
|
||||
/// is a *different asset*: one continuous XMA stream in `sound.pak`, bound to
|
||||
/// the movie by the movie manifest in `tables.pak` (`ADV` → `VOICE_ADV`). It was
|
||||
/// not dropped by [`crate::video`]; it was never exported, because nothing here
|
||||
/// asked for it.
|
||||
///
|
||||
/// ## The binding is resolved, never matched by name
|
||||
///
|
||||
/// `sylpheed_formats::media::resolve_movie_voice_region` walks
|
||||
/// movie → cue token → sound id → byte region. It is the only route taken here,
|
||||
/// and the reason is that the cheap route looks correct on exactly the movies a
|
||||
/// person would check first. Measured on the retail disc:
|
||||
///
|
||||
/// | movie | region | inside the bank named after it? |
|
||||
/// |---|---|---|
|
||||
/// | `ADV` | 433 930 240…437 044 592 | yes |
|
||||
/// | `S00A` | 452 798 464…455 499 120 | yes |
|
||||
/// | `RT01A` | 437 044 592…437 345 648 | **no — it is inside `VOICE_ADV.slb`** |
|
||||
///
|
||||
/// So `VOICE_<movie>.slb` is a name that happens to hold the right audio twice
|
||||
/// out of three, and the two it gets right are the two in this port's scope.
|
||||
/// Reading the bank by name would have shipped, verified clean, and been wrong
|
||||
/// for the radio cutscenes the moment anybody extended the export.
|
||||
///
|
||||
/// ## What a `None` means, and what it must not become
|
||||
///
|
||||
/// A movie whose region does not resolve is **genuinely unvoiced** — that is a
|
||||
/// real answer for most `hokyu_*` resupply cutscenes, and the corpus already
|
||||
/// paid for the alternative: resolving unbound movies through a shared demo line
|
||||
/// played the *wrong recording*. Nothing is substituted. A `\Movie\` token that
|
||||
/// resolved a clip but not a region stays silent for the same reason
|
||||
/// `sylpheed-viewer` keeps it silent: its raw `.slb` is off by one chunk, so it
|
||||
/// is not this movie's dialogue.
|
||||
///
|
||||
/// ## Three choices made here, and the reason each is not a guess
|
||||
///
|
||||
/// * **One file per movie** (MODDING rule 1), and the region's `RIFF`s are
|
||||
/// **summed, not concatenated** — the reverse of what this function did when
|
||||
/// it was first written an hour earlier. Concatenating produced a 359 s voice
|
||||
/// track for a 137 s movie.
|
||||
///
|
||||
/// ## The chunks are stems, and only a measurement showed it
|
||||
///
|
||||
/// A resolved region decodes to several chunks, and the two readings — segments
|
||||
/// to join end to end, or stems to mix — look identical in the bytes. Decoded
|
||||
/// and timed against the movies' own lengths:
|
||||
///
|
||||
/// | movie | movie | chunk 0 | chunk 1 | chunk 2 |
|
||||
/// |---|---|---|---|---|
|
||||
/// | `ADV` | 137.437 s | 84.553 | **137.324** | **137.324** |
|
||||
/// | `S00A` | 93.779 s | 68.072 | **93.694** | **93.694** |
|
||||
/// | `RT01A` | — | 0.009 | **34.034** | — |
|
||||
///
|
||||
/// Chunks 1 and 2 are **equal to six decimals and each span the whole movie**.
|
||||
/// That is exactly HANDOFF Q10's decoded shape — *a bank is two stems of one
|
||||
/// performance, played together; do not concatenate* — arriving on a different
|
||||
/// asset kind, which is why they are summed at `1/n` like [`export_bgm`]'s.
|
||||
///
|
||||
/// ⚠️ **Chunk 0 is dropped, and what it is remains an open decoding question.**
|
||||
/// Its duration matches nothing: 84.6 s under a 137 s movie, 9 ms under
|
||||
/// `RT01A`. `docs/re/REFUTED.md` records `slb.rs`'s `to_xma_riffs` hybrid branch
|
||||
/// emitting a **leading headerless packet region** ahead of the real `RIFF`
|
||||
/// waves, and `docs/port/BLOCKED.md` already carries that as an open row against
|
||||
/// `BGM_103`, where `media` likewise returns three sub-waves where Q10's census
|
||||
/// says two. This is the **same signature on a second, independent asset kind** —
|
||||
/// corroboration, not proof. So the rule here is stated in terms of what was
|
||||
/// measured — *keep the longest duration and everything that ties with it* — and
|
||||
/// every dropped chunk is named in the manifest rather than quietly discarded.
|
||||
/// * **Mono**, with the fold chosen from the stream's own declared channel
|
||||
/// count rather than by passing `-ac 1` and hoping. A voice track that is
|
||||
/// already mono is passed through untouched.
|
||||
/// * **No sync offset.** The voice plays from the video's first frame, so the
|
||||
/// runtime needs no delay and none is authored. The decoded length is
|
||||
/// recorded beside the movie's own length in the manifest so a disagreement
|
||||
/// is visible rather than absorbed.
|
||||
pub fn export_voice<S: DiscSource + ?Sized>(
|
||||
source: &S,
|
||||
out: &Path,
|
||||
movie: &str,
|
||||
video_duration_s: Option<f32>,
|
||||
) -> Result<Option<Exported>> {
|
||||
use sylpheed_formats::slb::VoiceLang;
|
||||
|
||||
// English only: MISSION §7 puts localisation beyond English out of scope.
|
||||
// The language is a parameter of the resolution, not of the file layout, so
|
||||
// adding Japanese later is a second call and a second file, not a re-think.
|
||||
let lang = VoiceLang::English;
|
||||
let region = media::resolve_movie_voice_region(source, movie, lang);
|
||||
let Some((start, end)) = region else {
|
||||
return Ok(None);
|
||||
};
|
||||
let riffs = media::voice_region_riffs(source, start, end)
|
||||
.map_err(anyhow::Error::msg)
|
||||
.with_context(|| format!("decode the {movie} voice region"))?;
|
||||
if riffs.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let dir = out.join("audio/voice");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let mut all = Vec::new();
|
||||
for (i, riff) in riffs.iter().enumerate() {
|
||||
all.push(stage_riff(&dir, &format!("{movie}.{i}"), riff)?);
|
||||
}
|
||||
|
||||
// Classify before mixing. XMA declares no duration, so each chunk is decoded
|
||||
// and timed -- the only way to tell a stem from the leading region, and the
|
||||
// measurement that showed concatenation to be wrong here.
|
||||
let lengths: Vec<f32> = all.iter().map(|p| decoded_seconds(p).unwrap_or(0.0)).collect();
|
||||
let longest = lengths.iter().cloned().fold(0.0f32, f32::max);
|
||||
// A tie at 1 ms. The two stems agree to six decimals and the chunk that is
|
||||
// not one of them misses by tens of seconds, so nothing sits near this
|
||||
// bound: it separates the measured cases without being a tuned threshold.
|
||||
let keep: Vec<usize> = (0..all.len())
|
||||
.filter(|&i| (longest - lengths[i]).abs() < 0.001)
|
||||
.collect();
|
||||
let dropped: Vec<String> = (0..all.len())
|
||||
.filter(|i| !keep.contains(i))
|
||||
.map(|i| format!("chunk {i} ({:.3} s, {} B)", lengths[i], riffs[i].len()))
|
||||
.collect();
|
||||
let staged: Vec<PathBuf> = keep.iter().map(|&i| all[i].clone()).collect();
|
||||
|
||||
// The fold is chosen from what the stream declares, because `pan` silently
|
||||
// ignores a channel the input does not have -- so a stereo matrix applied to
|
||||
// a mono voice track is not an error, it is a 6 dB attenuation nobody sees.
|
||||
let channels = probe_channels(&staged[0]).unwrap_or(1);
|
||||
let fold = match channels {
|
||||
0 | 1 => String::new(),
|
||||
n => {
|
||||
let g = 1.0 / n as f64;
|
||||
let terms: Vec<String> = (0..n).map(|c| format!("{g:.6}*c{c}")).collect();
|
||||
format!(",pan=mono|c0={}", terms.join("+"))
|
||||
}
|
||||
};
|
||||
|
||||
let ogg = dir.join(format!("{movie}.ogg"));
|
||||
let mut argv: Vec<String> = ["-hide_banner", "-loglevel", "error", "-y"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
for s in &staged {
|
||||
argv.push("-i".into());
|
||||
argv.push(s.display().to_string());
|
||||
}
|
||||
// SUMMED at 1/n, with the coefficient written out rather than left to
|
||||
// `amix`'s `normalize=1` default, so it appears in the manifest's command
|
||||
// line. Same reasoning as `export_bgm`: a default is a decision nobody made
|
||||
// and it can move under an ffmpeg upgrade.
|
||||
let filter = format!(
|
||||
"{}amix=inputs={n}:normalize=0,volume={:.6}{fold}[a]",
|
||||
(0..staged.len()).map(|i| format!("[{i}:a]")).collect::<String>(),
|
||||
1.0 / staged.len() as f64,
|
||||
n = staged.len(),
|
||||
);
|
||||
argv.push("-filter_complex".into());
|
||||
argv.push(filter);
|
||||
argv.push("-map".into());
|
||||
argv.push("[a]".into());
|
||||
argv.extend(
|
||||
["-c:a", "libvorbis", "-q:a", VORBIS_Q, &ogg.display().to_string()]
|
||||
.iter()
|
||||
.map(|s| s.to_string()),
|
||||
);
|
||||
let command = format!("ffmpeg {}", argv.join(" "));
|
||||
run_ffmpeg(&argv, &ogg)?;
|
||||
let (peak, dur) = measure(&ogg);
|
||||
for s in &all {
|
||||
std::fs::remove_file(s).ok();
|
||||
}
|
||||
|
||||
let against = match (dur, video_duration_s) {
|
||||
(Some(d), Some(v)) => format!(
|
||||
" Decoded length {d:.3} s against the movie's {v:.3} s (delta {:+.3} s); \
|
||||
NOT trimmed to fit -- a clamp would hide a resolution error, and the \
|
||||
runtime stops the voice when the video ends.",
|
||||
d - v
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
Ok(Some(Exported {
|
||||
name: movie.to_string(),
|
||||
file: format!("audio/voice/{movie}.ogg"),
|
||||
command,
|
||||
why: format!(
|
||||
"DECODED, not authored: the movie manifest in tables.pak binds {movie} to a \
|
||||
voice cue, and sylpheed_formats::media::resolve_movie_voice_region walks \
|
||||
movie -> token -> sound id -> byte region [{start}, {end}) of the continuous \
|
||||
voice stream. NOT matched by filename: RT01A's voice lives inside \
|
||||
VOICE_ADV.slb, so the name is right for this movie by luck and wrong for \
|
||||
others. Of {} region chunk(s), {} were SUMMED at 1/{} -- they are \
|
||||
equal-duration and each spans the whole movie, which is HANDOFF Q10's decoded \
|
||||
two-stem shape, so joining them end to end would play the dialogue twice.{} \
|
||||
Folded to mono from {channels} channel(s).{against}",
|
||||
riffs.len(),
|
||||
staged.len(),
|
||||
staged.len(),
|
||||
if dropped.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" DROPPED, matching no duration in this region and OPEN as a decoding \
|
||||
question -- the same signature as BGM_103's third sub-wave, see \
|
||||
docs/port/BLOCKED.md: {}.",
|
||||
dropped.join(", ")
|
||||
)
|
||||
}
|
||||
),
|
||||
peak_dbfs: peak,
|
||||
duration_s: dur,
|
||||
kind: "voice",
|
||||
name_match: None,
|
||||
loop_mode: None,
|
||||
sub_waves: riffs.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// How many channels a staged `RIFF` declares, per ffprobe.
|
||||
fn probe_channels(path: &Path) -> Option<u8> {
|
||||
let out = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v", "error", "-select_streams", "a:0",
|
||||
"-show_entries", "stream=channels", "-of", "csv=p=0",
|
||||
])
|
||||
.arg(path)
|
||||
.output()
|
||||
.ok()?;
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
|
||||
}
|
||||
|
||||
/// Seconds a finished media file runs, per ffprobe.
|
||||
///
|
||||
/// Exposed so the caller can hand [`export_voice`] the movie's own length: the
|
||||
/// voice is a separate asset with no shared container to agree with, so the only
|
||||
/// way a resolution error shows up is a length that does not match the picture.
|
||||
pub fn probe_duration(path: &Path) -> Option<f32> {
|
||||
let out = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0",
|
||||
])
|
||||
.arg(path)
|
||||
.output()
|
||||
.ok()?;
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
|
||||
}
|
||||
|
||||
/// Seconds one staged XMA `RIFF` decodes to.
|
||||
///
|
||||
/// XMA carries no duration in its header, so this decodes the chunk to PCM and
|
||||
/// measures the result. That is expensive and it is the only instrument that can
|
||||
/// tell a stem from the leading region: `ffprobe` on the `RIFF` itself returns
|
||||
/// `N/A`, which a caller that trusted it would read as zero.
|
||||
fn decoded_seconds(riff: &Path) -> Option<f32> {
|
||||
let wav = riff.with_extension("probe.wav");
|
||||
let ok = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
.arg(riff)
|
||||
.arg(&wav)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
let out = if ok { probe_duration(&wav) } else { None };
|
||||
let _ = std::fs::remove_file(&wav);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ fn check_audio(root: &Path, m: &Value, errors: &mut Vec<String>) {
|
||||
for a in audio {
|
||||
let name = a.get("name").and_then(Value::as_str).unwrap_or("?");
|
||||
let kind = a.get("kind").and_then(Value::as_str).unwrap_or("");
|
||||
if !matches!(kind, "se" | "bgm") {
|
||||
if !matches!(kind, "se" | "bgm" | "voice") {
|
||||
errors.push(format!(
|
||||
"manifest.json: audio `{name}` has kind {kind:?}, which a consumer cannot dispatch on"
|
||||
));
|
||||
@@ -340,10 +340,13 @@ fn check_audio(root: &Path, m: &Value, errors: &mut Vec<String>) {
|
||||
// the overshoot distribution across a corpus of cues, and if a cue
|
||||
// ever trips this the right response is that measurement, not a
|
||||
// looser bound.
|
||||
Some(p) if kind == "bgm" && p >= 0.0 => errors.push(format!(
|
||||
// `voice` joins `bgm` on the strict side of this bound for the same
|
||||
// reason: it is a sum of stems this exporter produced, not a single
|
||||
// wave taken off the disc, so a peak at full scale is our arithmetic.
|
||||
Some(p) if matches!(kind, "bgm" | "voice") && p >= 0.0 => errors.push(format!(
|
||||
"{file}: peak is {p:.1} dBFS -- a SUM we produced clips"
|
||||
)),
|
||||
Some(p) if kind != "bgm" && p > 1.0 => errors.push(format!(
|
||||
Some(p) if !matches!(kind, "bgm" | "voice") && p > 1.0 => errors.push(format!(
|
||||
"{file}: peak is {p:.1} dBFS -- too far over full scale to be decode overshoot"
|
||||
)),
|
||||
Some(_) => {}
|
||||
|
||||
@@ -304,10 +304,24 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
|
||||
// MISSION §6: the boot intro and the one new-game intro only.
|
||||
let mut videos = Vec::new();
|
||||
let mut movie_lengths: Vec<(&'static str, Option<f32>)> = Vec::new();
|
||||
// 🔴 The export deviates from a HUMAN decision, and until this warning
|
||||
// existed nobody could tell. MISSION §6 pins the 5.1 fold; `video.rs` ships
|
||||
// that matrix scaled by 0.4142, i.e. 7.65 dB quieter. The deviation is
|
||||
// justified for one of the two movies and over-broad for the other, and
|
||||
// which of the three options to take is not the exporter's call -- so it is
|
||||
// reported on every run rather than left in a doc comment nobody opens.
|
||||
if video::MOVIES.iter().any(|m| disc.join(m.src).exists()) {
|
||||
warnings.push(
|
||||
"video/*.ogv: the 5.1->stereo fold is NOT the matrix MISSION §6 pins. §6 fixes it at FL = 1.0*FL + 0.707*FC + 0.707*BL (a human decision, 2026-08-29); this export ships that matrix scaled by 0.4142 -- same weighting, 7.65 dB quieter. Measured over the whole of both movies, float-decoded so nothing is pre-clamped: under the PINNED matrix ADV peaks at +4.26 dBFS with 4406 samples at or over full scale (1874 more than 1 dB over, longest clamped run 0.333 ms), while S00A peaks at -1.34 dBFS and never clips. So the pin overloads ADV and this constant is over-broad for S00A; the smallest single scalar under which neither clamps is 1/1.6339 = 0.612. NOT changed on the exporter's own authority -- the level of a mix is what §6 reserves to a human. See docs/port/DECISIONS.md."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
for m in video::MOVIES {
|
||||
match video::transcode(disc, out, m)? {
|
||||
Some(t) => {
|
||||
println!(" video {} -> {}", m.src, t.file);
|
||||
movie_lengths.push((m.stem, audio::probe_duration(&out.join(&t.file))));
|
||||
videos.push(ManifestVideo {
|
||||
name: t.name,
|
||||
file: t.file,
|
||||
@@ -381,6 +395,35 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// The cutscene voices are DERIVED, not authored, so this runs outside the
|
||||
// `authored/audio.json` block above: the binding comes off the disc (the
|
||||
// movie manifest in `tables.pak`), and an export with no authored audio
|
||||
// should still carry the dialogue for the movies it ships.
|
||||
//
|
||||
// A movie that resolves to no region is genuinely unvoiced and gets a
|
||||
// warning rather than a substitute -- for both movies in scope this port
|
||||
// expects a region, so a warning here is a real signal and not noise.
|
||||
{
|
||||
let source = media::DirectorySource::new(disc);
|
||||
for (stem, len) in &movie_lengths {
|
||||
match audio::export_voice(&source, out, stem, *len)? {
|
||||
Some(a) => {
|
||||
println!(
|
||||
" voice {:<8} -> {} ({}, {} region chunk(s))",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a),
|
||||
a.sub_waves
|
||||
);
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
None => warnings.push(format!(
|
||||
"movie `{stem}`: the movie manifest binds it to no voice region, so no dialogue was exported. That is a real answer for an unvoiced cutscene -- nothing is substituted, because resolving an unbound movie through a shared demo line was measured to play the WRONG recording."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
format: "sylpheed.manifest/1",
|
||||
exporter: EXPORTER,
|
||||
|
||||
@@ -63,8 +63,34 @@ pub const MOVIES: &[Movie] = &[
|
||||
/// coefficient rounding — and peak and mean levels agree to 0.1 dB. ffmpeg's
|
||||
/// default *is* this matrix; the point is that the manifest now says so.
|
||||
///
|
||||
/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is
|
||||
/// why the normalisation is here rather than the textbook coefficients.
|
||||
/// # 🔴 This is NOT the matrix MISSION §6 pins, and that was never said out loud
|
||||
///
|
||||
/// MISSION §6 records a **human decision of 2026-08-29** fixing the fold at
|
||||
/// `FL = 1.0·FL + 0.707·FC + 0.707·BL` (plus 7.1 terms a 5.1 source does not
|
||||
/// have). This constant is that matrix scaled by 0.4142 — the same relative
|
||||
/// weighting, **7.65 dB quieter** — and until now nothing in the code, the
|
||||
/// manifest or the docs said so. Recording the command you ran does not disclose
|
||||
/// that it is not the command you were given.
|
||||
///
|
||||
/// The original justification for the deviation was *"the unnormalised form
|
||||
/// clips: peak 0.0 dBFS"*, and that is a peak reading — the instrument
|
||||
/// `docs/port/BLOCKED.md` records this port declaring unfit for the clipping
|
||||
/// question, because one sample at full scale and two seconds of square wave
|
||||
/// give the same number. Re-measured properly (float decode, whole file, count
|
||||
/// the samples that would clamp):
|
||||
///
|
||||
/// | | peak | ≥ full scale | > +1 dB over | longest run |
|
||||
/// |---|---|---|---|---|
|
||||
/// | `ADV`, MISSION §6 | **+4.26 dBFS** | 4 406 / 13 187 900 | 1 874 | 0.333 ms |
|
||||
/// | `S00A`, MISSION §6 | −1.34 dBFS | **0** | 0 | — |
|
||||
///
|
||||
/// So the pin really does overload `ADV` — and this constant is over-broad,
|
||||
/// because `S00A` never needed it. The smallest single scalar under which
|
||||
/// neither clamps is `1/1.6339 = 0.612`, +3.39 dB on today.
|
||||
///
|
||||
/// **Not changed here.** The level of a mix is what §6 reserves to a human
|
||||
/// (*"adjust it deliberately, as a commit"*), so the export carries a warning
|
||||
/// with these numbers instead. See `docs/port/DECISIONS.md`.
|
||||
const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL |FR=0.4142*FR+0.2929*FC+0.2929*BR";
|
||||
|
||||
/// How many audio channels the source declares.
|
||||
|
||||
@@ -86,6 +86,21 @@ git log -1 --format=%h -- docs/port/HANDOFF.md # newer than 9ca1eb5? re-reconc
|
||||
|
||||
## Still open — these block work
|
||||
|
||||
**Re-checked at the voice export**, `HEAD` = `3a4c6ac` (merged with `origin/main`
|
||||
at `1b1a4df`). `git log -1 --format=%h -- docs/port/HANDOFF.md` still answers
|
||||
**`9ca1eb5`** — HANDOFF has not moved in four milestones. The second-half check,
|
||||
`git log --oneline 9ca1eb5..HEAD -- docs/re/`, lists four commits, of which
|
||||
`3491d30` (*"the disc ships movies in TWO audio profiles, and 28 of them are
|
||||
5.1"*) is the one this iteration used and `7eeae30` is still unfolded into
|
||||
HANDOFF.
|
||||
|
||||
| Milestone | Needs | HANDOFF | State |
|
||||
|---|---|---|---|
|
||||
| P4/P7 — the intro's dialogue | ~~why the intro has no voices~~ | Q9 | ✅ **answered and TAKEN 2026-08-29, and the obvious diagnosis was wrong.** Not a transcode fault: `ADV.wmv` carries music and effects only, and a cutscene's voice is a *separate* continuous XMA stream in `sound.pak` bound by the movie manifest. `audio::export_voice` now resolves it with `media::resolve_movie_voice_region` — never by filename, because `RT01A`'s voice lives inside `VOICE_ADV.slb` and a name match is right on exactly the two movies this port would have spot-checked. Region chunks are **concatenated** (one continuous stream), not summed. This is **decoded, nothing authored**. |
|
||||
| P4/P7 — the movie downmix | **is the exporter allowed to ship a matrix MISSION §6 did not pin?** | — | 🔴 **with the HUMAN, not the Decoder, and now visible for the first time.** §6 pins the 5.1 fold as a human decision of 2026-08-29; `video.rs` has shipped that matrix scaled by **0.4142** since P4 — same weighting, **7.65 dB quieter** — and said so nowhere. Re-measured this iteration with the right instrument (float decode, whole file, count the samples that would clamp, not a peak reading): under the **pinned** matrix `ADV` peaks at **+4.26 dBFS** with **4 406** samples at or over full scale and 1 874 more than 1 dB over, while `S00A` peaks at −1.34 dBFS and **never clips**. So the pin overloads one movie and the exporter's constant is over-broad for the other. Smallest single scalar under which neither clamps: **0.612**, +3.39 dB on today. **Not changed** — the level of a mix is what §6 reserves. The export now carries a manifest warning with these numbers. |
|
||||
| P4 — is an attract movie skippable at all? | **does the real game let Ⓐ end `ADV`, or does it play through?** | Q9 | 🔴 **a human play-test reports Ⓐ does not skip the port's intro, and the port could not tell which bug that is.** It is *implemented*, not assumed: `authored/flow.json` carries `skippable: true` with a `why` citing Q9 as measured (title at 57 s against a 193 s baseline), and `boot.gd` `_unhandled_input` acts on it. What did not exist was any way to **test** it: `--script` structurally cannot press during a movie, because `_script_settled` waits while `_player != null`. `--skip-at=SECONDS` was added this iteration to close that hole. ⚠️ Two different questions sit behind the one symptom, and only the first is mine: (a) does the synthetic press reach `_unhandled_input` — measurable here; (b) does the **game** permit skipping an attract movie — `INDEX.md` still marks skippability 🟡 and only a capture settles it. If (b) is no, the port's skip path is deleted rather than debugged. Asked 2026-08-29. |
|
||||
| ~~P5 — a real submenu cycle~~ | ~~is any submenu reachable without a new archive?~~ | Q2/Q4 | ✅ **already shipped at P5, and one premise of the ask is refuted by this repo.** `ptbtn05` (EXTRAS) → screen `extras` (entries 6/9), and `extras`' `on_cancel` returns to `main_menu` with focus restored — a full main-menu → submenu → back cycle, in `GP_TITLE`, live since P5. ⚠️ **Build 8 is not a submenu.** It is `main_menu_jp`, the Japanese five-button main menu; `authored/screen_names.json` records that an earlier reading called 8 a submenu and that HANDOFF Q2 **withdrew it** against a capture. That coordinates identical to build 5 mean a language twin rather than a second menu is exactly the inference the port is not allowed to make on layout similarity — in either direction. The other four main-menu items really are blocked: `GP_SAVE_LOAD`, `GP_OPTIONS`, `GP_MISSION_SELECT` and the `DIFFICULTY`/`TUTORIAL_MENU` builds are not in this archive. |
|
||||
|
||||
| Milestone | Needs | HANDOFF | State |
|
||||
|---|---|---|---|
|
||||
| ~~P6 audio~~ | ~~which cue fires on move / confirm / back~~ | Q8 | ✅ **answered 2026-08-28** — the RE agent retracted "cannot be extracted". The waves are located in `Static.slb` by playing them: **move `0x1ec0`** (8 192 B, 0.533 s), **confirm `0x5d6c0`** (12 288 B, 1.016 s), **back `0x0ec0`** (4 096 B, 0.344 s), and ⬅➡ play nothing. Move and back reproduce across two boots. 🟡 that the cursor's wave is the cue *named* `SE_UI_CURSOR` is still a name match, and Ⓐ's wave is not separated between `SE_UI_DECIDE` and `SE_UI_SUB_WIN_OPN`. P6 can now export real audio; the exporter has to grow an SE path. **✅ TAKEN at P6, 2026-08-29.** The three offsets now live in `authored/audio.json` `se.*` — *not* in the exporter — because MISSION §3 puts a measured value in `authored/` and a measured offset compiled into a Rust `const` is a measurement wearing the costume of a decoded field. `sylpheed_formats::media::se_wave_riff` does the assembly. |
|
||||
|
||||
@@ -2365,3 +2365,188 @@ Two enumerations of the same archive differ by exactly the four bundles the port
|
||||
had to add an allow-list to reach. That is the sharpest possible demonstration of
|
||||
why the exporter switched, and it has now nearly caused the error it switched to
|
||||
prevent. Reported; the names are still the RE agent's to give.
|
||||
|
||||
## The intro's missing dialogue was an export gap, not a transcode bug, 2026-08-29
|
||||
|
||||
A human play-test heard music under the boot intro and no voices. The obvious
|
||||
reading is that the 5.1→stereo fold dropped the centre channel, and it is wrong.
|
||||
|
||||
**`ADV.wmv` carries music and effects only.** On this disc a cutscene's voice is
|
||||
a *separate asset*: one continuous XMA stream in `sound.pak`, bound to the movie
|
||||
by the manifest in `tables.pak` (`ADV` → `VOICETRACK = VOICE_ADV`). Nothing was
|
||||
dropped — `grep -rn voice crates/sylpheed-export/src/` returned nothing, because
|
||||
the exporter had never been asked for it. The transcode was correct the whole
|
||||
time, which is why every measurement on it passed.
|
||||
|
||||
That is worth stating plainly because the failure *looked* exactly like a codec
|
||||
bug, and `docs/port/AUDIO-VERIFICATION.md` is full of ways to measure a
|
||||
transcode against its source. Every one of them would have come back clean.
|
||||
|
||||
### The binding is resolved, and must never be matched by name
|
||||
|
||||
`audio::export_voice` takes exactly one route:
|
||||
`media::resolve_movie_voice_region(source, movie, VoiceLang::English)`, which
|
||||
walks movie → cue token (manifest) → sound id (registry) → a `[start, end)` byte
|
||||
region of the continuous stream. The cheap route — read `VOICE_<movie>.slb` —
|
||||
was not taken, and the reason is a measurement:
|
||||
|
||||
| movie | resolved region | inside the bank named after it? |
|
||||
|---|---|---|
|
||||
| `ADV` | 433 930 240…437 044 592 | yes |
|
||||
| `S00A` | 452 798 464…455 499 120 | yes |
|
||||
| `RT01A` | 437 044 592…437 345 648 | **no — it is inside `VOICE_ADV.slb`** |
|
||||
|
||||
⚠️ **Name-matching is correct on exactly the two movies this port ships, and
|
||||
wrong on the radio cutscenes.** It would have exported clean, verified clean
|
||||
against both in-scope movies, and returned the wrong recording the moment
|
||||
anybody widened the export. This is the failure mode MISSION §2 names — one
|
||||
playable thing is not one archive entry — in its most convincing disguise: the
|
||||
spot-checks a person would actually run are the ones it passes.
|
||||
|
||||
### Three choices, and why none is a guess
|
||||
|
||||
* **One file per movie**, per MODDING rule 1, and the region's chunks are
|
||||
**summed** — see the correction below, because the first version of this
|
||||
paragraph said the opposite and was wrong.
|
||||
* **Mono**, folded from the stream's **own declared channel count**, probed with
|
||||
`ffprobe` rather than assumed. This is not pedantry: `pan` silently ignores a
|
||||
channel the input does not have — measured this iteration on the 5.1 fold
|
||||
below, where `FLC`/`FRC`/`SL`/`SR` vanished with no warning at all — so a
|
||||
stereo matrix applied to a mono voice track is not an error, it is a −6 dB
|
||||
attenuation that nothing reports. A track that is already mono is passed
|
||||
through untouched.
|
||||
* **No sync offset, and no length clamp.** The voice plays from the video's
|
||||
first frame, so nothing is authored. The decoded length is recorded in the
|
||||
manifest *beside the movie's own length* rather than trimmed to it: the voice
|
||||
has no shared container to disagree with, so a length mismatch is the only
|
||||
symptom a resolution error would ever show, and clamping would delete it. That
|
||||
decision is the reason the error below was caught in the same hour it was made.
|
||||
|
||||
### Correction, within the hour — the chunks are stems, and I had concatenated them
|
||||
|
||||
The first version of `export_voice` joined the region's chunks end to end and
|
||||
produced **359.201 s of voice for a 137.437 s movie**, and **255.460 s for a
|
||||
93.779 s one**. Both ratios sit near 3, and both regions decode to 3 chunks.
|
||||
|
||||
The manifest said so on the first run, because the length was recorded against
|
||||
the movie's instead of being clamped to it. A clamp — which is what
|
||||
`sylpheed-viewer` does, and what `media`'s own doc comment invites with *"trimmed
|
||||
by the caller's length clamp"* — would have produced a file of exactly the right
|
||||
duration containing the wrong audio, and every check in
|
||||
`docs/port/AUDIO-VERIFICATION.md` would have passed it.
|
||||
|
||||
Decoding each chunk and timing it (`crates/sylpheed-export/examples/voice_chunks.rs`):
|
||||
|
||||
| movie | movie length | chunk 0 | chunk 1 | chunk 2 |
|
||||
|---|---|---|---|---|
|
||||
| `ADV` | 137.437 s | 84.553 | **137.324** | **137.324** |
|
||||
| `S00A` | 93.779 s | 68.072 | **93.694** | **93.694** |
|
||||
| `RT01A` | — | 0.009 | **34.034** | — |
|
||||
|
||||
Chunks 1 and 2 are **equal to six decimals and each span the whole movie**. That
|
||||
is HANDOFF Q10's decoded shape — *two stems of one performance, played together;
|
||||
do not concatenate* — showing up on a second asset kind. They are summed at
|
||||
`1/n`, exactly as `export_bgm` sums a music bank.
|
||||
|
||||
⚠️ **Chunk 0 is dropped and its status is open.** Its duration matches nothing:
|
||||
84.6 s under a 137 s movie, 9 ms under `RT01A`. `docs/re/REFUTED.md` records
|
||||
`to_xma_riffs`'s hybrid branch emitting a **leading headerless packet region**
|
||||
ahead of the real `RIFF` waves, and `docs/port/BLOCKED.md` already carries that
|
||||
as an open row against `BGM_103`, where `media` returns three sub-waves against a
|
||||
census of two. **This is the same signature on an independent asset kind** — good
|
||||
corroboration, not proof, and the port is not entitled to close it. So the
|
||||
selection rule is written in terms of the measurement (*keep the longest
|
||||
duration and everything tying with it*), and every dropped chunk is named in the
|
||||
manifest with its length.
|
||||
|
||||
This is the media-assembly trap MISSION §2 names, and it caught me: I wrote a
|
||||
doc comment asserting concatenation, gave the reason, and had it wrong. What
|
||||
saved it was refusing to clamp — the one decision in the first version that was
|
||||
made for the right reason.
|
||||
|
||||
### What a `None` means
|
||||
|
||||
A movie whose region does not resolve is **genuinely unvoiced** — the honest
|
||||
answer for most `hokyu_*` resupply cutscenes — and gets a manifest warning, not
|
||||
a substitute. The corpus already paid for the alternative: resolving unbound
|
||||
movies through a shared demo line played the *wrong recording*.
|
||||
|
||||
This is **decoded, not authored**, so it runs outside the `authored/audio.json`
|
||||
block in `main.rs`. Nothing new goes in `authored/`; there is nothing here we
|
||||
decided.
|
||||
|
||||
## Refutation, of my own exporter — MISSION §6 pins a downmix matrix, and the exporter ships a different one
|
||||
|
||||
**The claim under test is the port's**, not another agent's, and it has been in
|
||||
`video.rs` since P4: that the 5.1 fold is normalised by
|
||||
`1/(1 + √½ + √½) = 0.4142` because *"the unnormalised form was measured too and
|
||||
**clips**: peak 0.0 dBFS."*
|
||||
|
||||
That sentence rests on a peak reading. `docs/port/BLOCKED.md` records this port
|
||||
withdrawing a 🔴 runtime-clipping flag on precisely the grounds that **a peak
|
||||
reading is not a clipping measurement** — one sample at full scale and two
|
||||
seconds of square wave give the same number. So the justification for deviating
|
||||
from a matrix a human pinned was produced by an instrument this port has already
|
||||
declared unfit for the question.
|
||||
|
||||
### Measured properly, over the whole of both movies
|
||||
|
||||
Decoded to 32-bit float so nothing is pre-clamped, then counted: samples at or
|
||||
over full scale, how many exceed it by more than 1 dB, and the longest
|
||||
consecutive run.
|
||||
|
||||
| | peak | RMS | ≥ full scale | > +1 dB | longest run |
|
||||
|---|---|---|---|---|---|
|
||||
| `ADV`, MISSION §6 matrix | **+4.26 dBFS** | −14.55 | **4 406** / 13 187 900 | 1 874 | 16 samples (0.333 ms) |
|
||||
| `ADV`, exporter's matrix | −3.39 dBFS | −22.21 | 0 | 0 | — |
|
||||
| `S00A`, MISSION §6 matrix | **−1.34 dBFS** | −18.73 | **0** | 0 | — |
|
||||
| `S00A`, exporter's matrix | −8.99 dBFS | −26.39 | 0 | 0 | — |
|
||||
|
||||
**The claim survives, and the reasoning behind it does not.** The pinned matrix
|
||||
genuinely overloads `ADV`: not one stray sample but 4 406 of them, 1 874 more
|
||||
than a full dB over, wanting 4.26 dB more headroom than the container has. That
|
||||
is a different animal from the 43 samples and 0.25 ms transient I withdrew a flag
|
||||
over, and the number that separates them is the **magnitude**, not the count.
|
||||
|
||||
But the same table refutes the *scope* of the fix. **`S00A` never clips under the
|
||||
pinned matrix** — it peaks at −1.34 dBFS. The exporter attenuates it by 7.65 dB
|
||||
to solve a problem it does not have, because 0.4142 is derived from a theoretical
|
||||
worst case (every channel correlated at full scale at once) that neither movie
|
||||
comes near.
|
||||
|
||||
### Control, before believing any of it
|
||||
|
||||
The pinned matrix names `FLC`, `FRC`, `SL` and `SR`, and a 5.1 source has none of
|
||||
them. ffmpeg neither errors nor warns — measured at `-loglevel warning`, the
|
||||
output was empty. So the literal string was decoded alongside its three-term 5.1
|
||||
reduction (`FL = 1.0·FL + 0.707·FC + 0.707·BL`) and the two outputs compared:
|
||||
**bit-identical**, 52 751 600 bytes. The reduction is what runs, and it is the
|
||||
matrix §6 intends. *That silence is itself the trap the mono fold above guards
|
||||
against.*
|
||||
|
||||
### Not changed, and deliberately so
|
||||
|
||||
MISSION §6 is a **human decision of 2026-08-29**, and the level of a mix is
|
||||
exactly the kind of thing §6 reserves — *"adjust it deliberately, as a commit"*.
|
||||
Three options, and choosing between them is not mine:
|
||||
|
||||
1. **Keep the pin.** `ADV` clamps on 4 406 samples. Rejected on the measurement.
|
||||
2. **Keep the exporter's 0.4142.** Preserves the two movies' relative loudness
|
||||
exactly, costs 7.65 dB, and is safe by construction for any movie a modder
|
||||
drops in.
|
||||
3. **One measured constant, `1/1.6339 = 0.612`.** The smallest single scalar
|
||||
under which no in-scope movie clamps: +3.39 dB over today, still one constant
|
||||
so relative loudness is untouched. Tuned to two files, but the exporter's own
|
||||
`check` refuses any export whose peak reaches 0 dBFS, so a third movie that
|
||||
needed more headroom would fail loudly rather than clamp quietly.
|
||||
|
||||
Per-file normalisation is **not** on that list: it would put `ADV` 4.26 dB below
|
||||
`S00A` and change how two cutscenes sit against each other and against the menu
|
||||
bed, which is an aesthetic decision with nothing measured behind it.
|
||||
|
||||
What changes today is only that the deviation is **visible**: `video.rs` now
|
||||
cites MISSION §6 by name and says it departs from it, and the export carries a
|
||||
manifest warning with these numbers. Before this, a reader of the manifest could
|
||||
not tell that a pinned human decision had been overridden at all — the command
|
||||
line was recorded faithfully, and recording the command you ran does not disclose
|
||||
that it is not the command you were given.
|
||||
|
||||
@@ -339,13 +339,27 @@ not done its job.
|
||||
|
||||
| field | |
|
||||
|---|---|
|
||||
| `kind` | `se` or `bgm`. The runtime dispatches on it, so it is a field rather than a prefix on `name` that a consumer would have to parse |
|
||||
| `name` | the **role**, not the disc asset: `move`, `confirm`, `back`, `main_menu`. Which bank plays a role is authored and expected to change; a rename on the disc side must not be a change to the Godot project |
|
||||
| `kind` | `se`, `bgm` or `voice`. The runtime dispatches on it, so it is a field rather than a prefix on `name` that a consumer would have to parse |
|
||||
| `name` | the **role**, not the disc asset: `move`, `confirm`, `back`, `main_menu`. Which bank plays a role is authored and expected to change; a rename on the disc side must not be a change to the Godot project. ⚠️ **`voice` is the exception and keys by MOVIE NAME** (`ADV`, `S00A`), because there is no role to name: the binding of recording to picture came off the disc's own movie manifest, so unlike a music bed nothing about it was chosen |
|
||||
| `peak_dbfs` | measured off the finished file. **Required.** Silence is the audio failure that looks like success — right duration, right channel count, right size, full of zeroes — and clipping is the other one, which the BGM can produce because it is a sum of two stems at unity gain. `sylpheed-export check` refuses a tree whose peak is ≤ −90 dBFS or ≥ 0 dBFS |
|
||||
| `duration_s` | measured off the finished file, so that a claim about a cue's length can be checked against the finding that produced it |
|
||||
| `name_match` | the game's own cue identifier **guessed by name**. Absent means nobody claimed one — never that the binding is unknown. The binding is the measured part; the name is not |
|
||||
| `loop_mode` | what the runtime does at the end of the file, where that was authored. Absent on a cue: a cue ends |
|
||||
|
||||
**A `voice` entry is a cutscene's dialogue, and it is a separate file on
|
||||
purpose.** On this disc a movie's `.wmv` carries music and effects only; the
|
||||
voice is a byte region of one continuous XMA stream in `sound.pak`, bound by the
|
||||
movie manifest. A consumer plays the two together, **from the same instant** —
|
||||
there is no offset and none is authored. A movie with no `voice` entry is
|
||||
genuinely unvoiced, which is the honest answer for most `hokyu_*` cutscenes;
|
||||
nothing is substituted, and the manifest carries a warning naming the movie.
|
||||
|
||||
⚠️ The `why` on a `voice` entry names every region chunk the exporter **dropped**
|
||||
and its measured length. That is not commentary: which chunks of a region are the
|
||||
track is an open decoding question (see `docs/port/BLOCKED.md`), and a consumer
|
||||
reading a shorter file than it expected should be able to see what was left out
|
||||
rather than infer it.
|
||||
|
||||
## Changes from v2
|
||||
|
||||
v2 was written before the keyframe's `+12` was decoded and before anyone could
|
||||
|
||||
@@ -120,6 +120,7 @@ func _ready() -> void:
|
||||
_shots = args.get("shots", "")
|
||||
if args.has("script"):
|
||||
_script = args["script"].split(",", false)
|
||||
_skip_at = float(args.get("skip-at", "0"))
|
||||
# P5. `--play` boots first and hands over on the title; `--menu` starts on a
|
||||
# screen directly, which is what makes an unattended run cheap -- it does not
|
||||
# sit through 137 s of intro to press a d-pad.
|
||||
@@ -242,6 +243,9 @@ var _play := false
|
||||
var _pending: Variant = null
|
||||
var _script: PackedStringArray = PackedStringArray()
|
||||
var _shots := ""
|
||||
## `--skip-at=SECONDS`: when to send a synthetic (A) during a movie, or 0.
|
||||
var _skip_at := 0.0
|
||||
var _skip_sent := false
|
||||
var _script_started := false
|
||||
var _sequence: Array[Dictionary] = []
|
||||
var _player: VideoStreamPlayer = null
|
||||
@@ -262,6 +266,20 @@ func _process(delta: float) -> void:
|
||||
_overlay_process(delta)
|
||||
|
||||
if _player != null:
|
||||
# `--skip-at=SECONDS` presses (A) at a wall-clock moment DURING a movie,
|
||||
# which `--script` structurally cannot do: `_script_settled` waits while
|
||||
# `_player != null`, so a scripted walk only ever starts after the movie
|
||||
# has ended. That gap is why "does (A) skip the intro" had been read out
|
||||
# of the source rather than measured, and a human play-test then found
|
||||
# it not working.
|
||||
#
|
||||
# It goes through `Input.parse_input_event`, like `_press` -- the wiring
|
||||
# between a press and `_unhandled_input` is the thing under test, so a
|
||||
# direct call to `_video_finished` would prove nothing.
|
||||
if _skip_at > 0.0 and _elapsed >= _skip_at and not _skip_sent:
|
||||
_skip_sent = true
|
||||
print(" --skip-at: pressing (A) at %.2f s" % _elapsed)
|
||||
_press("ui_accept")
|
||||
return
|
||||
|
||||
# A menu transition. This is checked BEFORE the boot sequence and outside
|
||||
@@ -367,6 +385,17 @@ func _play_video(name: String, skippable: bool) -> void:
|
||||
await get_tree().process_frame
|
||||
_player.finished.connect(_video_finished)
|
||||
_player.play()
|
||||
# The dialogue is a SECOND stream, started with the picture. `ADV.wmv` and
|
||||
# `S00A.wmv` carry music and effects only; the voice is a separate asset the
|
||||
# exporter resolves off the movie manifest. Started after `play()` and in the
|
||||
# same frame, because the offset between them is zero and adding a wait here
|
||||
# would be authoring a sync constant nobody measured.
|
||||
if audio.play_voice(name):
|
||||
print(" + voice %s" % name)
|
||||
else:
|
||||
# Said out loud: silence is the audio failure that looks like success,
|
||||
# and "this cutscene is unvoiced" is a real answer for most of the disc.
|
||||
print(" no voice track for %s in this export" % name)
|
||||
|
||||
|
||||
var _skippable := false
|
||||
@@ -374,6 +403,9 @@ var _skippable := false
|
||||
|
||||
func _video_finished() -> void:
|
||||
print(" video ended at %.2f s" % _elapsed)
|
||||
# Before anything else: a voice that outlived a skipped intro would play on
|
||||
# over the title screen, which is the sort of bug that sounds like a feature.
|
||||
audio.stop_voice()
|
||||
_player.queue_free()
|
||||
_player = null
|
||||
# A movie the MENU started (P7) returns to an authored screen; a movie the
|
||||
|
||||
@@ -24,6 +24,14 @@ extends Node
|
||||
var cues: Dictionary = {}
|
||||
## Role -> {stream, loop}, from the entries of kind `bgm`.
|
||||
var beds: Dictionary = {}
|
||||
## 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
|
||||
@@ -71,6 +79,13 @@ func configure(tree: ExportTree) -> bool:
|
||||
# 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
|
||||
_:
|
||||
push_warning("manifest audio entry %s has kind %s, which this build does not play"
|
||||
% [entry.get("name", "?"), entry.get("kind", "?")])
|
||||
@@ -79,7 +94,39 @@ func configure(tree: ExportTree) -> bool:
|
||||
|
||||
## 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()
|
||||
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 ---------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user