formats: move media assembly out of the viewer, where it could not be reused
Some checks failed
CI / Native — ubuntu-latest (push) Failing after 8m42s
CI / WASM — Web (push) Failing after 7m33s
CI / Formatting (push) Failing after 1m15s
CI / Native — windows-latest (push) Has been cancelled
CI / Native — macos-latest (push) Has been cancelled

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:
Sylpheed RE agent
2026-08-28 17:49:33 +02:00
parent 69291a315f
commit 8b6dbcfead
5 changed files with 423 additions and 166 deletions

View File

@@ -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(&registry).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;
}