Files
Sylpheed/crates/sylpheed-formats/src/media.rs
sylph-decoder 3dbfa320ae formats: drop the 1.5 MB cap that truncated 17 voice regions' first stream
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
2026-08-30 08:55:47 +00:00

379 lines
17 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(&registry).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)
}
}