The cause, and the fix, with a disc-wide check. resolve_movie_voice_region picks start = the predecessor cue's trailer, then filtered it with 'end - s < 1_500_000' -- 'only within one bank'. ADV's predecessor sits 3 618 816 B before end, so the filter rejected it and start fell back to anchor, which is a TOC offset and not a stream boundary. That explains the shape of the defect exactly: it strikes regions larger than 1.5 MB, which is why the three-stream multichannel regions are hit and single-stream ones never are. 17 of 95 resolving movies took the fallback. ADV's predecessor trailer at 433 425 776 plus 17 040 B of descriptor and padding is 433 442 816 -- the -238-packet start measured against the decoder, to the byte. Dropping the cap: unchanged 78, fixed cleanly 17, changed in any other way ZERO. In all 17 the only difference is a larger first chunk with every later chunk byte-identical, which is what a corrected start looks like and what pulling in a neighbouring asset does not. Regression test pinned to the RUNNING DECODER's byte_sizes rather than to this crate's own output. That is the point of it: every internal check passed happily while a third of a stream was missing, so only an external number could have caught this class of bug. sylpheed-formats: 136 tests pass, 0 fail (the one still running at commit time is an unrelated long mesh test). Exact clips for the other 16 are not independently verified -- the sweep is strong but ADV is the only one with a decoder measurement behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
379 lines
17 KiB
Rust
379 lines
17 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))
|
||
}
|
||
|
||
/// One sound-effect wave out of a **delimiter-less** bank, as a decodable `RIFF`.
|
||
///
|
||
/// `Static.slb` — where the menu's cues live — has no `RIFF`, no `seek` chunk and
|
||
/// no XACT container: it is a packed run of whole 2048-byte XMA1 packets. So
|
||
/// [`sound_bank_riffs`] finds nothing to split on, and a wave is defined *only*
|
||
/// by `(offset, packet_count)`. Both come from the running game, not from the
|
||
/// file: launch Canary with `--xma_param_probe=true`, trigger the sound, and the
|
||
/// log prints the stream's packet count and first 32 bytes; searching those bytes
|
||
/// in the bank gives the offset. ⚠️ The file order is **not** cue-id order, so the
|
||
/// index cannot be counted out — see `docs/re/menu-audio-cues.md`.
|
||
///
|
||
/// The three cues a menu needs, all mono 48 kHz:
|
||
///
|
||
/// | event | offset | packets |
|
||
/// |---|---|---|
|
||
/// | d-pad move | `0x1ec0` | 4 |
|
||
/// | Ⓑ back | `0x0ec0` | 2 |
|
||
/// | Ⓐ confirm | `0x5d6c0` | 6 |
|
||
///
|
||
/// Returns an error rather than a short stream if the bank does not actually
|
||
/// hold `packet_count` whole packets at `offset` — a truncated XMA stream decodes
|
||
/// to plausible-sounding garbage, which is the failure worth refusing.
|
||
pub fn se_wave_riff<S: DiscSource + ?Sized>(
|
||
source: &S,
|
||
bank: &str,
|
||
offset: usize,
|
||
packet_count: usize,
|
||
channels: u8,
|
||
rate: u32,
|
||
) -> Result<Vec<u8>, String> {
|
||
let len = packet_count * crate::slb::XMA1_PACKET;
|
||
// Read only the packets asked for, not the whole bank. That is not just an
|
||
// efficiency point: `Static.slb` is 8.97 MB and is the ONE entry in
|
||
// `sound.pak` whose declared extent runs past the end of the extracted
|
||
// segments (by 616 768 B), so reading it whole fails outright on a disc
|
||
// extraction that is short at the tail. Every cue we need sits in the first
|
||
// few hundred KB. See `docs/re/menu-audio-cues.md`.
|
||
let toc = source.read_file("dat/sound.pak")?;
|
||
let entries = PakArchive::parse_toc(&toc).map_err(|e| e.to_string())?;
|
||
let hash = crate::hash::name_hash(bank);
|
||
let idx = entries
|
||
.binary_search_by_key(&hash, |e| e.name_hash)
|
||
.map_err(|_| format!("{bank}: not present in sound.pak"))?;
|
||
let e = &entries[idx];
|
||
if offset + len > e.comp_size as usize {
|
||
return Err(format!(
|
||
"{bank}: {packet_count} packets at {offset:#x} need {len} bytes, \
|
||
but the bank declares only {} bytes",
|
||
e.comp_size
|
||
));
|
||
}
|
||
let packets =
|
||
source.read_segment_range("dat/sound", e.offset as u64 + offset as u64, len)?;
|
||
Ok(crate::slb::xma1_wave_riff(&packets, channels, rate))
|
||
}
|
||
|
||
/// 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.
|
||
//
|
||
// 🔴 There used to be a second condition here — `end - s < 1_500_000`, "only
|
||
// within one bank, else this is the first cue in its block and the audio
|
||
// starts at the anchor itself". **It was wrong, and it silently truncated the
|
||
// first stream of every region larger than 1.5 MB.** `anchor` is a TOC offset,
|
||
// not a stream boundary, so the fallback started mid-packet-run: `ADV` began
|
||
// **238 packets (487 424 B) into its own first stream**, and a consumer then
|
||
// saw a leading chunk that "matched nothing" and dropped 62 % of a real stream.
|
||
//
|
||
// Ground truth is the running decoder, which reports `ADV`'s three contexts as
|
||
// 1 294 336 / 1 118 208 / 1 171 456 (`--xma_param_probe`). With the cap gone the
|
||
// region reproduces all three exactly; with it, the first is 806 912.
|
||
//
|
||
// Disc-wide over the 95 manifest movies that resolve: **17 regions fixed, 78
|
||
// unchanged, 0 changed in any other way** — in every one of the 17 the first
|
||
// chunk grows and the remaining chunks are byte-identical, which is what a
|
||
// corrected start looks like and what pulling in a neighbouring asset does not.
|
||
// `docs/re/structures/voice-region-starts-late.md`.
|
||
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)
|
||
.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)
|
||
}
|
||
}
|