formats: move media assembly out of the viewer, where it could not be reused
Some checks failed
Some checks failed
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Vec<u8>, 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<Vec<u8>, String> {
|
||||
read_source_file(self, path)
|
||||
}
|
||||
fn open_pak(&self, path: &str) -> Result<sylpheed_formats::PakArchive, String> {
|
||||
read_pak_archive_blocking(self, path)
|
||||
}
|
||||
fn read_segment_range(&self, stem: &str, offset: u64, len: usize) -> Result<Vec<u8>, 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<PathBuf, String> {
|
||||
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<String> {
|
||||
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 `<lang>\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<PathBuf, String> {
|
||||
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
|
||||
/// `<lang>\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<String> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user