feat(formats,viewer): movie subtitles, voice decode, and the movie manifest
The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE manifest (the authoritative movie -> subtitle -> voice index). Also flushes several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md. Subtitles (movie_subtitle.rs): - Full movie->track->text chain; join multi-line captions sharing one timing (fixes S13A dropped "Look at it father" line); overlap-safe active_cues(); Latin-1 accents preserved. Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its declared data size (fixes S10-S16 alternate-take garble); list_voice_clips. Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\). Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem). Viewer: manifest-driven voice (movie player toggle + solo button), standalone "Voice Lines" browser, stacked caption overlay. Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc tests; full workspace green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
102
crates/sylpheed-formats/tests/movie_manifest_disc.rs
Normal file
102
crates/sylpheed-formats/tests/movie_manifest_disc.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! Real-disc test for the movie manifest → voice binding. Skipped without
|
||||
//! `SYLPHEED_DISC`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::movie_manifest;
|
||||
use sylpheed_formats::slb::VoiceLang;
|
||||
use sylpheed_formats::PakArchive;
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
|
||||
p.join("dat").is_dir().then_some(p)
|
||||
}
|
||||
|
||||
/// Read the manifest + `eng\sounds.tbl` out of `tables.pak`.
|
||||
fn load_manifest_and_sounds(root: &PathBuf) -> (Vec<u8>, Vec<u8>) {
|
||||
let pak = PakArchive::open(root.join("dat/tables.pak")).unwrap();
|
||||
let manifest = pak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| pak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("movie manifest present in tables.pak");
|
||||
let sounds = pak
|
||||
.read_by_name("eng\\sounds.tbl")
|
||||
.expect("sounds.tbl present")
|
||||
.unwrap();
|
||||
(manifest, sounds)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_and_resolves_movie_voice() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: set SYLPHEED_DISC");
|
||||
return;
|
||||
};
|
||||
let (manifest, sounds) = load_manifest_and_sounds(&root);
|
||||
let entries = movie_manifest::parse(&manifest);
|
||||
assert!(entries.len() > 90, "expected ~101 movies, got {}", entries.len());
|
||||
|
||||
// Standard story movie → VOICE_<movie> in <lang>\Movie\.
|
||||
assert_eq!(
|
||||
movie_manifest::voice_token(&manifest, "S13A").as_deref(),
|
||||
Some("VOICE_S13A")
|
||||
);
|
||||
assert_eq!(
|
||||
movie_manifest::resolve_voice_entry(&manifest, &sounds, "S13A", VoiceLang::English)
|
||||
.as_deref(),
|
||||
Some("eng\\Movie\\VOICE_S13A.slb")
|
||||
);
|
||||
|
||||
// A resupply movie bound to an in-mission radio clip → VOICE_D_* in \etc\.
|
||||
// (This is what a `VOICE_<movie>` guess would miss entirely.)
|
||||
assert_eq!(
|
||||
movie_manifest::voice_token(&manifest, "hokyu_LS_s02A").as_deref(),
|
||||
Some("VOICE_D_450")
|
||||
);
|
||||
assert_eq!(
|
||||
movie_manifest::resolve_voice_entry(
|
||||
&manifest,
|
||||
&sounds,
|
||||
"hokyu_LS_s02A",
|
||||
VoiceLang::English
|
||||
)
|
||||
.as_deref(),
|
||||
Some("eng\\etc\\VOICE_D_450.slb")
|
||||
);
|
||||
|
||||
// A resupply movie the game leaves unbound in the manifest → no DIRECT
|
||||
// binding. (Extending to unbound movies by shared demo line was verified
|
||||
// WRONG against the running game, so we do NOT resolve these.)
|
||||
let e = entries
|
||||
.iter()
|
||||
.find(|e| e.movie == "hokyu_DS_s13A")
|
||||
.expect("hokyu_DS_s13A present in manifest");
|
||||
assert_eq!(e.voice_token, None, "hokyu_DS_s13A has no direct voice binding");
|
||||
assert_eq!(
|
||||
movie_manifest::resolve_voice_entry(&manifest, &sounds, "hokyu_DS_s13A", VoiceLang::English),
|
||||
None
|
||||
);
|
||||
|
||||
// Every resolved voice entry must actually exist in sound.pak.
|
||||
let snd = std::fs::read(root.join("dat/sound.pak")).unwrap();
|
||||
let keys: std::collections::HashSet<u32> = PakArchive::parse_toc(&snd)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e.name_hash)
|
||||
.collect();
|
||||
let mut resolved = 0;
|
||||
let mut missing = Vec::new();
|
||||
for e in &entries {
|
||||
if let Some(full) =
|
||||
movie_manifest::resolve_voice_entry(&manifest, &sounds, &e.movie, VoiceLang::English)
|
||||
{
|
||||
resolved += 1;
|
||||
if !keys.contains(&sylpheed_formats::hash::name_hash(&full)) {
|
||||
missing.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(resolved > 80, "expected 80+ voiced movies, got {resolved}");
|
||||
assert!(missing.is_empty(), "resolved but absent in sound.pak: {missing:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user