formats: move media assembly out of the viewer, where it could not be reused

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

@@ -67,6 +67,10 @@ pub mod movie_manifest;
pub mod movie_voice;
/// Assembling media whose bytes are not one archive entry — segment-spanning
/// reads, multi-sub-wave banks, and the continuous cutscene-voice stream.
pub mod media;
pub mod game_data;
pub mod localization;

View File

@@ -0,0 +1,305 @@
//! 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(&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 — 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)
}
}

View File

@@ -0,0 +1,88 @@
//! Real-disc tests for media assembly. Skipped without `SYLPHEED_DISC`.
//!
//! This logic used to live in the Bevy viewer, where it had no test at all. It
//! is the trickiest reading on the disc — a cutscene's voice is a byte region of
//! a continuous stream, not the bank its name points at — so it gets pinned here
//! before anything else is built on top of it.
use std::path::PathBuf;
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::VoiceLang;
fn disc() -> Option<DirectorySource> {
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
p.join("dat").is_dir().then(|| DirectorySource::new(p))
}
/// A segment-spanning read returns the same bytes as slicing the whole archive.
///
/// The control that matters: `sound.pak`'s data is five segments, so a TOC
/// offset late in the archive addresses a position no single file has. If the
/// walk were off by a segment this would return plausible-looking wrong bytes
/// rather than fail, which is exactly why it is asserted against the archive's
/// own read rather than against a length.
#[test]
fn segment_range_matches_the_archive_read() {
let Some(src) = disc() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let name = "BGM_020.slb";
let hash = sylpheed_formats::hash::name_hash(name);
let via_range = media::read_sound_bank(&src, hash).expect("segment range read");
let toc = src.read_file("dat/sound.pak").unwrap();
let entries = sylpheed_formats::PakArchive::parse_toc(&toc).unwrap();
let e = entries
.iter()
.find(|e| e.name_hash == hash)
.expect("BGM_020 in the TOC");
assert_eq!(via_range.len(), e.comp_size as usize);
// And it decodes, which a misaligned read would not do.
let riffs = media::sound_bank_riffs(&src, name).expect("riffs");
assert!(!riffs.is_empty(), "no sub-waves recovered");
}
/// A movie's voice resolves to a byte region, and the region is sane.
///
/// `RT01A` is one of the cutscenes whose voice spans more than one `.slb`
/// chunk — the case that motivated regions over per-bank reads in the first
/// place. The assertions are deliberately about *shape* (ordered, non-empty,
/// smaller than one bank) rather than exact offsets, because the offsets are
/// disc facts we have no independent oracle for here; a regression that
/// reversed or emptied the region would still be caught.
#[test]
fn movie_voice_resolves_to_a_region_that_decodes() {
let Some(src) = disc() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let (start, end) = media::resolve_movie_voice_region(&src, "RT01A", VoiceLang::English)
.expect("RT01A has a bound voice track");
assert!(start < end, "region is inverted: {start}..{end}");
assert!(end - start > 4096, "region is implausibly small");
assert!(end - start < 1_500_000, "region spans more than one bank");
let riffs = media::voice_region_riffs(&src, start, end).expect("region riffs");
assert!(!riffs.is_empty(), "region decoded to no audio");
}
/// An unbound movie stays unvoiced rather than borrowing a neighbour's clip.
///
/// This is a *negative* the corpus paid for: extending resolution to unbound
/// resupply movies by shared demo line played the WRONG recording. The guard
/// keeps that door shut.
#[test]
fn manifest_binding_is_the_only_route() {
let Some(src) = disc() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
// A movie the manifest does not bind must resolve to nothing, not to a guess.
assert_eq!(
media::resolve_movie_voice_clip(&src, "no_such_movie_xyz", VoiceLang::English),
None
);
}

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;
}