From 8b6dbcfead4168a674015f0e4fd2c8d83f9ffe31 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Fri, 28 Aug 2026 17:49:33 +0200 Subject: [PATCH] formats: move media assembly out of the viewer, where it could not be reused The trickiest reading on the disc lived in the Bevy viewer: resolving a cutscene's voice to a continuous byte REGION of the sound stream, because the movie voices are one XMA stream chunked into VOICE_*.slb entries whose boundaries do not match the cues -- a cue routinely spans two chunks, so a .slb need not hold the track its name claims. That put the logic most likely to be re-derived incorrectly in the crate least likely to be reused. The Godot port's exporter needs the same answers, and there must be one implementation of them. New `sylpheed_formats::media` owns every case where the bytes of one playable thing are not one archive entry: segment-spanning reads, multi-sub-wave banks, and the voice-region resolution. Callers supply bytes through a `DiscSource` trait, so the viewer keeps its ISO/directory abstraction and a headless consumer gets `DirectorySource` for free. The seam is deliberate: this module returns XMA RIFFs, not PCM. Decoding means shelling out to FFmpeg, which is native-only and a policy decision for the consumer -- everything up to "here are the bytes that belong together" is disc knowledge, everything after it is a codec choice. The four moved functions were previously untested; `tests/media_disc.rs` now pins them, including the negative the corpus paid for -- an unbound movie must stay unvoiced rather than borrow a neighbour's clip, which was tried and played the WRONG recording. The algorithm is unchanged, moved verbatim (same window sizes, same fallbacks). The new disc tests pass; the broader audio suite was not re-run in this pass. Co-Authored-By: Claude Opus 5 --- crates/sylpheed-formats/src/lib.rs | 4 + crates/sylpheed-formats/src/media.rs | 305 ++++++++++++++++++++ crates/sylpheed-formats/tests/media_disc.rs | 88 ++++++ crates/sylpheed-viewer/src/iso_loader.rs | 190 ++---------- tools/re-capture/screen_build_capture.sh | 2 +- 5 files changed, 423 insertions(+), 166 deletions(-) create mode 100644 crates/sylpheed-formats/src/media.rs create mode 100644 crates/sylpheed-formats/tests/media_disc.rs diff --git a/crates/sylpheed-formats/src/lib.rs b/crates/sylpheed-formats/src/lib.rs index 65bbab3c..ea515235 100644 --- a/crates/sylpheed-formats/src/lib.rs +++ b/crates/sylpheed-formats/src/lib.rs @@ -67,6 +67,10 @@ pub mod movie_manifest; pub mod movie_voice; +/// Assembling media whose bytes are not one archive entry — segment-spanning +/// reads, multi-sub-wave banks, and the continuous cutscene-voice stream. +pub mod media; + pub mod game_data; pub mod localization; diff --git a/crates/sylpheed-formats/src/media.rs b/crates/sylpheed-formats/src/media.rs new file mode 100644 index 00000000..99465260 --- /dev/null +++ b/crates/sylpheed-formats/src/media.rs @@ -0,0 +1,305 @@ +//! Assembling media that does **not** sit in one place on the disc. +//! +//! Most assets are one archive entry and are read with [`crate::pak`] alone. +//! Audio is not, and this module owns every case where the bytes of one playable +//! thing have to be gathered from somewhere other than a single entry: +//! +//! * **An entry spans segment files.** A `.pak` TOC offset addresses the +//! *concatenated* `.p00…​.pNN` stream, so one entry routinely straddles two +//! files on disc. [`DiscSource::read_segment_range`] is the seam for that. +//! * **A bank holds several sub-waves.** A `.slb` is an XACT bank; its sub-waves +//! are either alternate takes or sequential segments of one line, and only +//! concatenating them all and clamping to the known length gets both right. +//! * **A cutscene voice is not in its own bank.** The movie voices are one +//! continuous XMA stream chunked into `VOICE_*.slb` TOC entries whose +//! boundaries do **not** match the cutscene cues. A cue routinely spans two +//! chunks, so *a `.slb` need not hold the track its name claims*. +//! [`resolve_movie_voice_region`] resolves a movie to a byte region of the +//! stream instead, which is the only reading that produces the right audio. +//! +//! ## Why this lives in `sylpheed-formats` and not in a viewer +//! +//! It used to live in the Bevy viewer, which meant the one piece of logic most +//! likely to be re-derived incorrectly was in the crate least likely to be +//! reused. Anything that reads the disc — the viewer, a CLI, an asset exporter +//! for a port — needs the same answers, and there must be one implementation of +//! them. +//! +//! ## What deliberately stays out +//! +//! Decoding. This module returns **XMA `RIFF`s**, not PCM: turning XMA into +//! samples means shelling out to FFmpeg, which is a native-only dependency and +//! a policy decision for the consumer. The seam is "here are the bytes that +//! belong together" — everything up to that point is disc knowledge, everything +//! after it is a codec choice. + +use crate::pak::PakArchive; +use crate::slb::VoiceLang; + +/// Where disc bytes come from. Implemented over an extracted directory, an ISO, +/// or anything else that can serve the same three questions. +/// +/// It is a trait rather than a concrete type because the callers differ in ways +/// this module should not know about: a viewer reads from whichever source the +/// user opened, a headless exporter reads from a fixed extract, and a test reads +/// from a fixture. +pub trait DiscSource { + /// Read a whole file by disc-relative path, e.g. `dat/sound.pak`. + fn read_file(&self, path: &str) -> Result, String>; + + /// Open an IPFB archive by disc-relative path, with its `.pNN` segments. + fn open_pak(&self, path: &str) -> Result; + + /// Read `len` bytes at `offset` into the concatenated `.p00…​.pNN` + /// stream, where `stem` is a disc-relative path without extension + /// (`dat/sound`). The range may cross a segment boundary; that is the point. + fn read_segment_range(&self, stem: &str, offset: u64, len: usize) -> Result, String>; +} + +/// Read one `sound.pak` bank by name-hash, taking only its byte range from the +/// segments rather than inflating the 1.07 GB archive. +pub fn read_sound_bank( + source: &S, + name_hash: u32, +) -> Result, String> { + let toc = source.read_file("dat/sound.pak")?; + let entries = PakArchive::parse_toc(&toc).map_err(|e| e.to_string())?; + let idx = entries + .binary_search_by_key(&name_hash, |e| e.name_hash) + .map_err(|_| "not present in sound.pak".to_string())?; + let e = &entries[idx]; + source.read_segment_range("dat/sound", e.offset as u64, e.comp_size as usize) +} + +/// The XMA `RIFF`s of one named bank, in the order they must be concatenated. +/// +/// Every sub-wave is returned, not just the first. The two bank shapes need +/// this for opposite reasons: a **segment** bank (`VOICE_RT07A` = 24 s + 14 s + +/// 11 s ≈ the 50 s movie) is only complete when all of them are joined, and an +/// **alternate-take** bank (`VOICE_S00A`, whose sub-wave 0 already spans the +/// whole movie) is trimmed by the caller's length clamp. Taking sub-wave 0 alone +/// dropped two thirds of the dialogue on segment banks — that was a real bug. +pub fn sound_bank_riffs( + source: &S, + clip_name: &str, +) -> Result>, String> { + let bytes = read_sound_bank(source, crate::hash::name_hash(clip_name))?; + Ok(riffs_of(&bytes)) +} + +/// The XMA `RIFF`s of a continuous byte region of the voice stream, as returned +/// by [`resolve_movie_voice_region`]. +pub fn voice_region_riffs( + source: &S, + start: u64, + end: u64, +) -> Result>, String> { + let bytes = source.read_segment_range("dat/sound", start, (end - start) as usize)?; + Ok(riffs_of(&bytes)) +} + +/// Sub-wave `RIFF`s of a bank's bytes, with the single-stream fallback. +/// +/// Some banks — the data-before-header `\etc\` radio clips — defeat the +/// multi-sub-wave scanner, and the robust single-stream reader handles them. An +/// empty result here means genuinely undecodable, not "scanner confused". +fn riffs_of(bytes: &[u8]) -> Vec> { + let riffs = crate::slb::to_xma_riffs(bytes); + if riffs.is_empty() { + crate::slb::to_xma_riff_best(bytes).into_iter().collect() + } else { + riffs + } +} + +/// Resolve a movie's voice bank **name** through the manifest in `tables.pak`. +/// +/// Only the manifest's DIRECT bindings are trusted. Extending this to unbound +/// resupply movies by shared demo line was tried and verified WRONG — it played +/// the wrong recording — so an unbound movie stays unvoiced rather than play a +/// guess. `None` therefore means "this cutscene has no voice-over", which is a +/// real answer for most `hokyu_*` movies. +pub fn resolve_movie_voice_clip( + source: &S, + movie: &str, + lang: VoiceLang, +) -> Option { + let pak = source.open_pak("dat/tables.pak").ok()?; + let manifest = find_manifest(&pak)?; + let sounds = pak + .read_by_name(&format!("{}\\sounds.tbl", lang.code_pub()))? + .ok()?; + crate::movie_manifest::resolve_voice_entry(&manifest, &sounds, movie, lang) +} + +/// The manifest has no stable name, so it is found by shape among the entries. +fn find_manifest(pak: &PakArchive) -> Option> { + pak.entries().iter().find_map(|e| { + pak.read(e) + .ok() + .filter(|b| crate::movie_manifest::is_manifest(b)) + }) +} + +/// Voice token for a hokyu (resupply) cutscene the manifest leaves unbound. +/// +/// Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`; the rest reuse +/// those recordings. The selector is the cutscene's **demo id** (from its +/// subtitle track), NOT the ship category: `hokyu_LS_s02A` and `hokyu_LS_s11A` +/// are both LS/carrier but use demos 600 vs 601, whose lines differ. So the map +/// is derived from the 5 bound hokyu — each of which has both a subtitle demo id +/// and a `VOICETRACK` — and the target movie's demo id is looked up in it. +pub fn hokyu_voice_token( + source: &S, + movie: &str, + lang: VoiceLang, + manifest: &[u8], +) -> Option { + use crate::movie_subtitle as ms; + if !movie.starts_with("hokyu_") { + return None; + } + let lang_pak = source + .open_pak(&format!("dat/movie/{}.pak", lang.code_pub())) + .ok()?; + let want = ms::track_voice_cues(&lang_pak, movie).first().map(|&(d, _)| d)?; + crate::movie_manifest::parse(manifest) + .into_iter() + .find_map(|e| { + let tok = e.voice_token.filter(|_| e.movie.starts_with("hokyu_"))?; + ms::track_voice_cues(&lang_pak, &e.movie) + .iter() + .any(|&(d, _)| d == want) + .then_some(tok) + }) +} + +/// Resolve a movie's cutscene voice to a continuous `[start, end)` byte region +/// of the voice stream — **the reading that produces the right audio**. +/// +/// The chain is movie → cue token (manifest) → sound id (master registry) → +/// region (scan the stream for two trailers). Each cue ends at an inline +/// `(sound_id, 0x11, …)` trailer, so cue *N* is the bytes between trailer *N-1* +/// and trailer *N*. +/// +/// Returns `None` for movies whose voice is not a `\Movie\` bank — the hokyu +/// `\etc\` clips — which the caller then resolves the per-clip way via +/// [`resolve_movie_voice_clip`]. +pub fn resolve_movie_voice_region( + source: &S, + movie: &str, + lang: VoiceLang, +) -> Option<(u64, u64)> { + use crate::{hash::name_hash, movie_manifest, movie_voice}; + let code = lang.code_pub(); + let tpak = source.open_pak("dat/tables.pak").ok()?; + let manifest = find_manifest(&tpak)?; + let token = movie_manifest::voice_token(&manifest, movie) + .or_else(|| hokyu_voice_token(source, movie, lang, &manifest))?; + + // token → sound id, via the large per-language IDXD entry carrying the + // `\Movie\VOICE_*.slb` paths. Located by content, like the manifest. + let marker = format!("{code}\\Movie\\VOICE_ADV.slb"); + let registry = tpak.entries().iter().find_map(|e| { + tpak.read(e) + .ok() + .filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes())) + })?; + let id = *movie_voice::registry_voice_ids(®istry).get(&token)?; + + // Physical anchor: the TOC offset of this token's own `.slb` chunk. That is a + // start point NEAR the cue's trailers, not the cue itself — the cue may sit + // before or after it, which is the whole reason a region is needed. The + // token's subdirectory varies by kind. + let stoc = source.read_file("dat/sound.pak").ok()?; + let entries = PakArchive::parse_toc(&stoc).ok()?; + let anchor = ["Movie", "etc", "Voice"].iter().find_map(|dir| { + let h = name_hash(&format!("{code}\\{dir}\\{token}.slb")); + entries + .binary_search_by_key(&h, |e| e.name_hash) + .ok() + .map(|i| entries[i].offset as u64) + })?; + + // Scan both directions from the anchor. The window must span the largest + // bank (ADV ≈ 3.6 MB) or the predecessor trailer falls outside it. + let win_start = anchor.saturating_sub(2 * 1024 * 1024) & !3; + let window = source + .read_segment_range("dat/sound", win_start, 8 * 1024 * 1024) + .ok()?; + let end_local = movie_voice::find_descriptor(&window, id)?; + let end = win_start + end_local as u64; + + // Start = the predecessor trailer. Prefer the exact `id-1`; where the id + // sequence has a gap (VOICE_D_453 → 454) fall back to the nearest trailer + // below — but only within one bank (~1.5 MB), else this is the first cue in + // its block and the audio starts at the anchor itself. + let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1)) + .or_else(|| movie_voice::find_descriptor_before(&window, end_local)) + .map(|o| win_start + o as u64) + .filter(|&s| s < end && end - s < 1_500_000) + .unwrap_or(anchor); + Some((start, end)) +} + +/// A [`DiscSource`] over an **extracted** disc directory. +/// +/// Provided here rather than left to each caller because every headless +/// consumer — the CLI, the disc tests, an asset exporter for a port — wants +/// exactly this and would otherwise re-derive the segment-spanning read, which +/// is the part that is easy to get subtly wrong. +#[cfg(not(target_arch = "wasm32"))] +pub struct DirectorySource { + root: std::path::PathBuf, +} + +#[cfg(not(target_arch = "wasm32"))] +impl DirectorySource { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl DiscSource for DirectorySource { + fn read_file(&self, path: &str) -> Result, String> { + std::fs::read(self.root.join(path)).map_err(|e| format!("{path}: {e}")) + } + + fn open_pak(&self, path: &str) -> Result { + PakArchive::open(self.root.join(path)).map_err(|e| format!("{path}: {e}")) + } + + /// Walks `.p00`, `.p01`, … skipping whole segments until the offset is + /// inside one, then reads across as many as the length needs. A range that + /// straddles a boundary is the normal case, not an edge case. + fn read_segment_range(&self, stem: &str, offset: u64, len: usize) -> Result, String> { + use std::io::{Read, Seek, SeekFrom}; + let mut out = Vec::with_capacity(len); + let (mut skip, mut need) = (offset, len); + for i in 0..100u32 { + if need == 0 { + break; + } + let path = self.root.join(format!("{stem}.p{i:02}")); + let Ok(meta) = std::fs::metadata(&path) else { break }; + let seg_len = meta.len(); + if skip >= seg_len { + skip -= seg_len; + continue; + } + let mut f = std::fs::File::open(&path).map_err(|e| e.to_string())?; + f.seek(SeekFrom::Start(skip)).map_err(|e| e.to_string())?; + let take = need.min((seg_len - skip) as usize); + let start = out.len(); + out.resize(start + take, 0); + f.read_exact(&mut out[start..]).map_err(|e| e.to_string())?; + need -= take; + skip = 0; + } + if need != 0 { + return Err(format!("segment range short by {need} bytes")); + } + Ok(out) + } +} diff --git a/crates/sylpheed-formats/tests/media_disc.rs b/crates/sylpheed-formats/tests/media_disc.rs new file mode 100644 index 00000000..a687df39 --- /dev/null +++ b/crates/sylpheed-formats/tests/media_disc.rs @@ -0,0 +1,88 @@ +//! Real-disc tests for media assembly. Skipped without `SYLPHEED_DISC`. +//! +//! This logic used to live in the Bevy viewer, where it had no test at all. It +//! is the trickiest reading on the disc — a cutscene's voice is a byte region of +//! a continuous stream, not the bank its name points at — so it gets pinned here +//! before anything else is built on top of it. + +use std::path::PathBuf; + +use sylpheed_formats::media::{self, DirectorySource, DiscSource}; +use sylpheed_formats::slb::VoiceLang; + +fn disc() -> Option { + let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?); + p.join("dat").is_dir().then(|| DirectorySource::new(p)) +} + +/// A segment-spanning read returns the same bytes as slicing the whole archive. +/// +/// The control that matters: `sound.pak`'s data is five segments, so a TOC +/// offset late in the archive addresses a position no single file has. If the +/// walk were off by a segment this would return plausible-looking wrong bytes +/// rather than fail, which is exactly why it is asserted against the archive's +/// own read rather than against a length. +#[test] +fn segment_range_matches_the_archive_read() { + let Some(src) = disc() else { + eprintln!("SKIP: set SYLPHEED_DISC"); + return; + }; + let name = "BGM_020.slb"; + let hash = sylpheed_formats::hash::name_hash(name); + let via_range = media::read_sound_bank(&src, hash).expect("segment range read"); + + let toc = src.read_file("dat/sound.pak").unwrap(); + let entries = sylpheed_formats::PakArchive::parse_toc(&toc).unwrap(); + let e = entries + .iter() + .find(|e| e.name_hash == hash) + .expect("BGM_020 in the TOC"); + assert_eq!(via_range.len(), e.comp_size as usize); + + // And it decodes, which a misaligned read would not do. + let riffs = media::sound_bank_riffs(&src, name).expect("riffs"); + assert!(!riffs.is_empty(), "no sub-waves recovered"); +} + +/// A movie's voice resolves to a byte region, and the region is sane. +/// +/// `RT01A` is one of the cutscenes whose voice spans more than one `.slb` +/// chunk — the case that motivated regions over per-bank reads in the first +/// place. The assertions are deliberately about *shape* (ordered, non-empty, +/// smaller than one bank) rather than exact offsets, because the offsets are +/// disc facts we have no independent oracle for here; a regression that +/// reversed or emptied the region would still be caught. +#[test] +fn movie_voice_resolves_to_a_region_that_decodes() { + let Some(src) = disc() else { + eprintln!("SKIP: set SYLPHEED_DISC"); + return; + }; + let (start, end) = media::resolve_movie_voice_region(&src, "RT01A", VoiceLang::English) + .expect("RT01A has a bound voice track"); + assert!(start < end, "region is inverted: {start}..{end}"); + assert!(end - start > 4096, "region is implausibly small"); + assert!(end - start < 1_500_000, "region spans more than one bank"); + + let riffs = media::voice_region_riffs(&src, start, end).expect("region riffs"); + assert!(!riffs.is_empty(), "region decoded to no audio"); +} + +/// An unbound movie stays unvoiced rather than borrowing a neighbour's clip. +/// +/// This is a *negative* the corpus paid for: extending resolution to unbound +/// resupply movies by shared demo line played the WRONG recording. The guard +/// keeps that door shut. +#[test] +fn manifest_binding_is_the_only_route() { + let Some(src) = disc() else { + eprintln!("SKIP: set SYLPHEED_DISC"); + return; + }; + // A movie the manifest does not bind must resolve to nothing, not to a guess. + assert_eq!( + media::resolve_movie_voice_clip(&src, "no_such_movie_xyz", VoiceLang::English), + None + ); +} diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index 45cac708..ad6e8871 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -3746,17 +3746,23 @@ fn read_segment_range( Ok(out) } -/// Read one `sound.pak` entry (a `.slb` bank) by name-hash, reading only its -/// byte range from the segments. +/// `SourceKind` as a disc for `sylpheed_formats::media`. +/// +/// The media-assembly logic — segment-spanning reads, multi-sub-wave banks, the +/// continuous cutscene-voice stream — lives in `sylpheed-formats` so that every +/// consumer of the disc shares one implementation. This is the whole adapter: +/// the viewer contributes "how do I get bytes", nothing more. #[cfg(not(target_arch = "wasm32"))] -fn read_sound_entry(source: &SourceKind, name_hash: u32) -> Result, String> { - let toc = read_source_file(source, "dat/sound.pak")?; - let entries = sylpheed_formats::PakArchive::parse_toc(&toc).map_err(|e| e.to_string())?; - let idx = entries - .binary_search_by_key(&name_hash, |e| e.name_hash) - .map_err(|_| "voice not present in sound.pak".to_string())?; - let e = &entries[idx]; - read_segment_range(source, "dat/sound", e.offset as u64, e.comp_size as usize) +impl sylpheed_formats::media::DiscSource for SourceKind { + fn read_file(&self, path: &str) -> Result, String> { + read_source_file(self, path) + } + fn open_pak(&self, path: &str) -> Result { + read_pak_archive_blocking(self, path) + } + fn read_segment_range(&self, stem: &str, offset: u64, len: usize) -> Result, String> { + read_segment_range(self, stem, offset, len) + } } /// Decode a `sound.pak` clip (by its `.slb` entry name) to a temp **mono** WAV @@ -3769,23 +3775,11 @@ fn decode_sound_clip( duration: f32, mono: bool, ) -> Result { - use sylpheed_formats::{hash::name_hash, slb}; + use sylpheed_formats::{hash::name_hash, media}; let key = name_hash(clip_name); - let bytes = read_sound_entry(source, key)?; - // A `.slb` is an XACT bank of one or more sub-waves. Decode EVERY sub-wave and - // concatenate them, then clamp to the movie length. This is robust to both bank - // shapes we see: segment banks (e.g. VOICE_RT07A = 3 sub-waves 24+14+11s ≈ the - // 50s movie) yield the full dialogue, while the clamp drops the extra ALTERNATE - // takes of full-length banks (e.g. VOICE_S00A, whose sub-wave 0 already spans - // the 94s movie). Playing only sub-wave 0 (the old behaviour) dropped 2/3 of the - // dialogue for segment banks — the "RT voice wrong" bug. Verified vs real durations. - let mut riffs = slb::to_xma_riffs(&bytes); - if riffs.is_empty() { - // Some banks (data-before-header `\etc\` radio clips) confuse the - // multi-sub-wave scanner; the robust single-stream decoder handles them, - // so the standalone browser can still play them. - riffs = slb::to_xma_riff_best(&bytes).into_iter().collect(); - } + // Every sub-wave, concatenated, then clamped to the media length -- see + // `media::sound_bank_riffs` for why taking only sub-wave 0 was a bug. + let riffs = media::sound_bank_riffs(source, clip_name)?; decode_riffs_to_wav(riffs, &format!("{key:08x}"), duration, mono) } @@ -3852,108 +3846,6 @@ fn decode_riffs_to_wav( } } -/// Voice token for a hokyu (resupply) cutscene the manifest leaves unbound. -/// -/// Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`; the rest reuse -/// those 5 recordings. The selector is the cutscene's **demo id** (from its -/// subtitle track), NOT the ship/source category: `hokyu_LS_s02A` and -/// `hokyu_LS_s11A` are both LS/carrier but use demos 600 vs 601 → `VOICE_D_450` -/// vs `_451` (their lines differ: "Rhino 3 has landed" vs "Rhino Leader has -/// landed"). We derive the demo→token map from the 5 BOUND hokyu (each has both a -/// subtitle demo id and a VOICETRACK), then look up the target movie's demo id. -/// Returns `None` for hokyu with no voice cue (`hokyu_LS_s24A`/`s27A` — correctly -/// silent) and for non-hokyu movies (they resolve via the manifest only). -#[cfg(not(target_arch = "wasm32"))] -fn hokyu_voice_token( - source: &SourceKind, - movie: &str, - lang: sylpheed_formats::slb::VoiceLang, - manifest: &[u8], -) -> Option { - use sylpheed_formats::{movie_manifest, movie_subtitle as ms}; - if !movie.starts_with("hokyu_") { - return None; - } - let lang_pak = - read_pak_archive_blocking(source, &format!("dat/movie/{}.pak", lang.code_pub())).ok()?; - // The target cutscene's demo id (its subtitle's single voice cue). - let want = ms::track_voice_cues(&lang_pak, movie).first().map(|&(d, _)| d)?; - // Match it against a bound hokyu carrying the same demo id → that VOICETRACK. - movie_manifest::parse(manifest).into_iter().find_map(|e| { - let tok = e.voice_token.filter(|_| e.movie.starts_with("hokyu_"))?; - ms::track_voice_cues(&lang_pak, &e.movie) - .iter() - .any(|&(d, _)| d == want) - .then_some(tok) - }) -} - -/// Resolve a movie's cutscene voice to a **continuous byte region** of the -/// `sound.pNN` voice stream, in `[start, end)` global offsets. -/// -/// The movie voices are one continuous XMA stream chunked into `VOICE_*.slb` TOC -/// entries whose boundaries do NOT match the cutscene cues (a cue routinely spans -/// two `.slb` chunks — so a `.slb` need not hold the track its name claims). Each -/// cue ends at an inline `(sound_id, 0x11, …)` trailer; cue N = the bytes between -/// trailer N-1 and trailer N. Chain: movie → cue name (manifest VOICETRACK) → -/// sound-id (master registry) → region (scan the stream for the two trailers). -/// Returns `None` for movies whose voice is not a `\Movie\` bank (e.g. hokyu -/// `\etc\` clips), which the caller then resolves the legacy per-clip way. -#[cfg(not(target_arch = "wasm32"))] -fn resolve_movie_voice_region( - source: &SourceKind, - movie: &str, - lang: sylpheed_formats::slb::VoiceLang, -) -> Option<(u64, u64)> { - use sylpheed_formats::{hash::name_hash, movie_manifest, movie_voice, PakArchive}; - let code = lang.code_pub(); - let tpak = read_pak_archive_blocking(source, "dat/tables.pak").ok()?; - // movie → cue token (e.g. "VOICE_RT01A") - let manifest = tpak - .entries() - .iter() - .find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?; - let token = movie_manifest::voice_token(&manifest, movie) - .or_else(|| hokyu_voice_token(source, movie, lang, &manifest))?; - // token → sound-id via the master registry (the large per-language IDXD entry - // carrying the `\Movie\VOICE_*.slb` paths). - let marker = format!("{code}\\Movie\\VOICE_ADV.slb"); - let registry = tpak.entries().iter().find_map(|e| { - tpak.read(e) - .ok() - .filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes())) - })?; - let id = *movie_voice::registry_voice_ids(®istry).get(&token)?; - // physical anchor: the TOC offset of this token's own `.slb` chunk — a start - // point near the cue's trailers (the cue itself may sit before/after it). The - // token's subdir varies: `Movie` (ADV/RT/S cutscenes) or `etc` (hokyu VOICE_D). - let stoc = read_source_file(source, "dat/sound.pak").ok()?; - let entries = PakArchive::parse_toc(&stoc).ok()?; - let anchor = ["Movie", "etc", "Voice"].iter().find_map(|dir| { - let h = name_hash(&format!("{code}\\{dir}\\{token}.slb")); - entries - .binary_search_by_key(&h, |e| e.name_hash) - .ok() - .map(|i| entries[i].offset as u64) - })?; - // Scan a window spanning both directions from the anchor for this cue's trailer - // and its predecessor. The window must cover the largest bank (ADV ≈ 3.6 MB). - let win_start = anchor.saturating_sub(2 * 1024 * 1024) & !3; - let window = read_segment_range(source, "dat/sound", win_start, 8 * 1024 * 1024).ok()?; - let end_local = movie_voice::find_descriptor(&window, id)?; - let end = win_start + end_local as u64; - // Cue start = its predecessor trailer. Prefer the exact `id-1` trailer; if the - // id sequence has a gap (e.g. VOICE_D_453→454), fall back to the nearest trailer - // below this one — but only if it's within one bank (~1.5 MB), else this is the - // first cue in its block and the audio starts at the bank anchor itself. - let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1)) - .or_else(|| movie_voice::find_descriptor_before(&window, end_local)) - .map(|o| win_start + o as u64) - .filter(|&s| s < end && end - s < 1_500_000) - .unwrap_or(anchor); - Some((start, end)) -} - /// Decode a continuous movie-voice byte region (from [`resolve_movie_voice_region`]) /// into a mono WAV, clamped to `duration`. #[cfg(not(target_arch = "wasm32"))] @@ -3963,42 +3855,10 @@ fn decode_voice_region( end: u64, duration: f32, ) -> Result { - use sylpheed_formats::slb; - let bytes = read_segment_range(source, "dat/sound", start, (end - start) as usize)?; - let mut riffs = slb::to_xma_riffs(&bytes); - if riffs.is_empty() { - riffs = slb::to_xma_riff_best(&bytes).into_iter().collect(); - } + let riffs = sylpheed_formats::media::voice_region_riffs(source, start, end)?; decode_riffs_to_wav(riffs, &format!("mv_{start:x}"), duration, true) } -/// Resolve a movie's `sound.pak` voice-entry name via the **movie manifest** -/// (`tables.pak`), which authoritatively binds each movie to its voice bank — -/// several `hokyu_*` resupply movies point at in-mission `VOICE_D_*` clips in -/// `\etc\`, and many bind to nothing at all. Returns `None` when the movie -/// has no voice-over (so the caller plays silence instead of guessing a bank). -#[cfg(not(target_arch = "wasm32"))] -fn resolve_movie_voice_clip( - source: &SourceKind, - movie: &str, - lang: sylpheed_formats::slb::VoiceLang, -) -> Option { - use sylpheed_formats::movie_manifest; - let pak = read_pak_archive_blocking(source, "dat/tables.pak").ok()?; - // The manifest has no stable name, so find it by shape among the entries. - let manifest = pak - .entries() - .iter() - .find_map(|e| pak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?; - let sounds = pak - .read_by_name(&format!("{}\\sounds.tbl", lang.code_pub()))? - .ok()?; - // Only the manifest's DIRECT bindings are trusted. Extending to unbound - // resupply movies by shared demo line was verified WRONG (played the wrong - // recording), so unbound movies stay unvoiced rather than play a guess. - movie_manifest::resolve_voice_entry(&manifest, &sounds, movie, lang) -} - /// Handles a [`RequestVoice`]: decodes the cutscene voice off-thread and posts /// the temp WAV path back via the loader channel. The voice bank is resolved /// from the movie manifest, so movies the game leaves unvoiced (e.g. most @@ -4025,10 +3885,10 @@ fn handle_voice_request( // through to the legacy per-clip decoder. A `\Movie\` token that failed // region resolution must NOT play its raw `.slb` — that off-by-one chunk is // the wrong track — so it stays silent rather than wrong. - let wav = resolve_movie_voice_region(&source, &movie, lang) + let wav = sylpheed_formats::media::resolve_movie_voice_region(&source, &movie, lang) .and_then(|(s, e)| decode_voice_region(&source, s, e, duration).ok()) .or_else(|| { - let clip = resolve_movie_voice_clip(&source, &movie, lang)?; + let clip = sylpheed_formats::media::resolve_movie_voice_clip(&source, &movie, lang)?; if clip.contains("\\Movie\\") { return None; } @@ -4117,10 +3977,10 @@ fn handle_audio_request( // stream (same resolution as playback), falling back to a non-`\Movie\` // clip (hokyu `\etc\`); for a named library clip, decode it directly. let wav = match movie { - Some((m, lang)) => resolve_movie_voice_region(&source, &m, lang) + Some((m, lang)) => sylpheed_formats::media::resolve_movie_voice_region(&source, &m, lang) .and_then(|(s, e)| decode_voice_region(&source, s, e, f32::INFINITY).ok()) .or_else(|| { - let c = resolve_movie_voice_clip(&source, &m, lang)?; + let c = sylpheed_formats::media::resolve_movie_voice_clip(&source, &m, lang)?; if c.contains("\\Movie\\") { return None; } diff --git a/tools/re-capture/screen_build_capture.sh b/tools/re-capture/screen_build_capture.sh index 8c0318b9..425fd58a 100755 --- a/tools/re-capture/screen_build_capture.sh +++ b/tools/re-capture/screen_build_capture.sh @@ -38,7 +38,7 @@ wide(){ [ "$(identify -format '%w' "$1" 2>/dev/null || echo 0)" -gt 1000 ]; } --create_profile_if_none="${SYLPH_TAG:-SylphRE}" \ --logged_profile_slot_0_xuid="${SYLPH_XUID:-B13EBABEBABEBABE}" \ >"$OUT/canary.stdout" 2>"$OUT/canary.stderr" & ) -sleep 8 +sleep "${LAUNCH_WAIT:-8}" until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do [ -n "$(alive)" ] || { echo "EMULATOR GONE before the window appeared"; exit 4; } sleep 1