diff --git a/crates/sylpheed-export/examples/voice_chunks.rs b/crates/sylpheed-export/examples/voice_chunks.rs new file mode 100644 index 00000000..06263832 --- /dev/null +++ b/crates/sylpheed-export/examples/voice_chunks.rs @@ -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); + } + } +} diff --git a/crates/sylpheed-export/src/audio.rs b/crates/sylpheed-export/src/audio.rs index 676ecf0c..a1b12322 100644 --- a/crates/sylpheed-export/src/audio.rs +++ b/crates/sylpheed-export/src/audio.rs @@ -420,3 +420,276 @@ pub fn export_bgm( sub_waves: riffs.len(), })) } + +/// One cutscene's voice-over, as `audio/voice/.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_.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( + source: &S, + out: &Path, + movie: &str, + video_duration_s: Option, +) -> Result> { + 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 = 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 = (0..all.len()) + .filter(|&i| (longest - lengths[i]).abs() < 0.001) + .collect(); + let dropped: Vec = (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 = 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 = (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 = ["-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::(), + 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 { + 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 { + 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 { + 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 +} diff --git a/crates/sylpheed-export/src/check.rs b/crates/sylpheed-export/src/check.rs index 99ed1c95..9be3a9ab 100644 --- a/crates/sylpheed-export/src/check.rs +++ b/crates/sylpheed-export/src/check.rs @@ -302,7 +302,7 @@ fn check_audio(root: &Path, m: &Value, errors: &mut Vec) { 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) { // 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(_) => {} diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index 06a40e42..563048ee 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -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)> = 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, diff --git a/crates/sylpheed-export/src/video.rs b/crates/sylpheed-export/src/video.rs index 963eef81..44ca9e95 100644 --- a/crates/sylpheed-export/src/video.rs +++ b/crates/sylpheed-export/src/video.rs @@ -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. diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index 6129533b..9b252f07 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -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. | diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 58e38e3c..41088197 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -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_.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. diff --git a/docs/port/FORMAT.md b/docs/port/FORMAT.md index 15f01419..859a3762 100644 --- a/docs/port/FORMAT.md +++ b/docs/port/FORMAT.md @@ -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 diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index b572004d..32af551e 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -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 diff --git a/port/scripts/menu_audio.gd b/port/scripts/menu_audio.gd index cf91065c..47e335db 100644 --- a/port/scripts/menu_audio.gd +++ b/port/scripts/menu_audio.gd @@ -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 ---------------------------------------------------------