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>
306 lines
13 KiB
Rust
306 lines
13 KiB
Rust
//! 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<Vec<u8>, String>;
|
||
|
||
/// Open an IPFB archive by disc-relative path, with its `.pNN` segments.
|
||
fn open_pak(&self, path: &str) -> Result<PakArchive, String>;
|
||
|
||
/// Read `len` bytes at `offset` into the concatenated `<stem>.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<Vec<u8>, 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<S: DiscSource + ?Sized>(
|
||
source: &S,
|
||
name_hash: u32,
|
||
) -> Result<Vec<u8>, 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<S: DiscSource + ?Sized>(
|
||
source: &S,
|
||
clip_name: &str,
|
||
) -> Result<Vec<Vec<u8>>, 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<S: DiscSource + ?Sized>(
|
||
source: &S,
|
||
start: u64,
|
||
end: u64,
|
||
) -> Result<Vec<Vec<u8>>, 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<Vec<u8>> {
|
||
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<S: DiscSource + ?Sized>(
|
||
source: &S,
|
||
movie: &str,
|
||
lang: VoiceLang,
|
||
) -> Option<String> {
|
||
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<Vec<u8>> {
|
||
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<S: DiscSource + ?Sized>(
|
||
source: &S,
|
||
movie: &str,
|
||
lang: VoiceLang,
|
||
manifest: &[u8],
|
||
) -> Option<String> {
|
||
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<S: DiscSource + ?Sized>(
|
||
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
|
||
// `<lang>\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<std::path::PathBuf>) -> Self {
|
||
Self { root: root.into() }
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
impl DiscSource for DirectorySource {
|
||
fn read_file(&self, path: &str) -> Result<Vec<u8>, String> {
|
||
std::fs::read(self.root.join(path)).map_err(|e| format!("{path}: {e}"))
|
||
}
|
||
|
||
fn open_pak(&self, path: &str) -> Result<PakArchive, String> {
|
||
PakArchive::open(self.root.join(path)).map_err(|e| format!("{path}: {e}"))
|
||
}
|
||
|
||
/// Walks `<stem>.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<Vec<u8>, 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)
|
||
}
|
||
}
|