Files
Syplheed-Reborn/crates/sylpheed-formats/tests/slb_disc.rs
MechaCat02 95de29f290 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>
2026-07-19 20:15:41 +02:00

105 lines
3.9 KiB
Rust

//! Real-disc test: read a movie voice `.slb` out of the segmented `sound.pak`
//! and rebuild a decodable XMA1 RIFF. Skipped without `SYLPHEED_DISC`.
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::PathBuf;
use sylpheed_formats::hash::name_hash;
use sylpheed_formats::slb::{self, 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 `[off, off+size)` from `dat/sound.p00..` (segments concatenated).
fn read_range(root: &PathBuf, mut off: u64, size: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(size);
let mut need = size;
for i in 0..100u32 {
if need == 0 {
break;
}
let seg = root.join(format!("dat/sound.p{i:02}"));
let Ok(meta) = std::fs::metadata(&seg) else { break };
let len = meta.len();
if off >= len {
off -= len;
continue;
}
let mut f = File::open(&seg).unwrap();
f.seek(SeekFrom::Start(off)).unwrap();
let take = need.min((len - off) as usize);
let start = out.len();
out.resize(start + take, 0);
f.read_exact(&mut out[start..]).unwrap();
need -= take;
off = 0;
}
out
}
#[test]
fn reads_and_rebuilds_movie_voice_riff() {
let Some(root) = disc_root() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let toc = std::fs::read(root.join("dat/sound.pak")).unwrap();
let entries = PakArchive::parse_toc(&toc).unwrap();
assert_eq!(entries.len(), 9519);
for movie in ["S00A", "RT07A", "RT02D_1"] {
// RT02D_1 is a headerless variant; S00A/RT07A embed a RIFF.
let key = name_hash(&slb::movie_voice_name(movie, VoiceLang::English));
let idx = entries
.binary_search_by_key(&key, |e| e.name_hash)
.unwrap_or_else(|_| panic!("{movie} voice missing"));
let e = &entries[idx];
let bank = read_range(&root, e.offset as u64, e.comp_size as usize);
let riff = slb::to_xma_riff(&bank).expect("rebuild RIFF");
assert_eq!(&riff[0..4], b"RIFF", "{movie}");
assert_eq!(&riff[8..12], b"WAVE", "{movie}");
// fmt chunk advertises XMA1 (tag 0x0165).
let fpos = riff.windows(4).position(|w| w == b"fmt ").unwrap();
let tag = u16::from_le_bytes([riff[fpos + 8], riff[fpos + 9]]);
assert_eq!(tag, 0x0165, "{movie} should be XMA1");
// data chunk carries the XMA payload.
let dpos = riff.windows(4).position(|w| w == b"data").unwrap();
let dsz = u32::from_le_bytes([
riff[dpos + 4],
riff[dpos + 5],
riff[dpos + 6],
riff[dpos + 7],
]) as usize;
assert!(dsz > 10_000, "{movie} data too small: {dsz}");
assert_eq!(riff.len(), dpos + 8 + dsz, "{movie} data length consistent");
}
}
#[test]
fn enumerates_voice_clips_from_sounds_tbl() {
let Some(root) = disc_root() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let pak = PakArchive::open(root.join("dat/tables.pak")).unwrap();
let tbl = pak.read_by_name("eng\\sounds.tbl").unwrap().unwrap();
let clips = slb::list_voice_clips(&tbl, VoiceLang::English);
assert!(clips.len() > 1000, "expected many voice clips, got {}", clips.len());
// Every clip is an eng voice path present in sound.pak.
let sound = std::fs::read(root.join("dat/sound.pak")).unwrap();
let keys: std::collections::HashSet<u32> =
PakArchive::parse_toc(&sound).unwrap().iter().map(|e| e.name_hash).collect();
let present = clips.iter().filter(|c| keys.contains(&name_hash(&c.name))).count();
assert!(
present as f32 / clips.len() as f32 > 0.95,
"{present}/{} clips resolve in sound.pak",
clips.len()
);
assert!(clips.iter().any(|c| c.speaker == "ADAN"));
}