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:
@@ -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;
|
||||
|
||||
305
crates/sylpheed-formats/src/media.rs
Normal file
305
crates/sylpheed-formats/src/media.rs
Normal 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(®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)
|
||||
}
|
||||
}
|
||||
88
crates/sylpheed-formats/tests/media_disc.rs
Normal file
88
crates/sylpheed-formats/tests/media_disc.rs
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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(®istry).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;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ wide(){ [ "$(identify -format '%w' "$1" 2>/dev/null || echo 0)" -gt 1000 ]; }
|
||||
--create_profile_if_none="${SYLPH_TAG:-SylphRE}" \
|
||||
--logged_profile_slot_0_xuid="${SYLPH_XUID:-B13EBABEBABEBABE}" \
|
||||
>"$OUT/canary.stdout" 2>"$OUT/canary.stderr" & )
|
||||
sleep 8
|
||||
sleep "${LAUNCH_WAIT:-8}"
|
||||
until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do
|
||||
[ -n "$(alive)" ] || { echo "EMULATOR GONE before the window appeared"; exit 4; }
|
||||
sleep 1
|
||||
|
||||
Reference in New Issue
Block a user