diff --git a/crates/sylpheed-formats/src/slb.rs b/crates/sylpheed-formats/src/slb.rs index b6db223..63ba45f 100644 --- a/crates/sylpheed-formats/src/slb.rs +++ b/crates/sylpheed-formats/src/slb.rs @@ -79,7 +79,12 @@ pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec { } if i - start >= 6 { if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) { - if s.starts_with(&prefix) && s.ends_with(".slb") && s.contains("VOICE") { + // Every spoken-line category, so the standalone player covers them + // all: in-mission radio (`\Voice\`, `\etc\`) and bound movie voices + // (`\Movie\`) all carry `VOICE_`; mission-briefing lines live in + // `\Briefing\` as `BR_.slb` (no `VOICE` in the name). + let is_voice = s.contains("VOICE") || s.contains("\\Briefing\\"); + if s.starts_with(&prefix) && s.ends_with(".slb") && is_voice { if seen.insert(s.to_string()) { out.push(parse_voice_clip(s)); } @@ -113,8 +118,58 @@ fn parse_voice_clip(name: &str) -> VoiceClip { } } +/// Build one standalone XMA1 `RIFF/WAVE` **per sub-wave** in a `.slb` bank, in +/// on-disc order. A `.slb` is an XACT bank of one or more sub-waves; the sub-waves +/// are EITHER alternate takes (the first is the whole track — e.g. `VOICE_S01A`, +/// `VOICE_ADV`: sub0 ≈ the movie length) OR sequential segments that must be +/// concatenated (e.g. `VOICE_RT07A`: 24s + 14s + 11s ≈ the 50s movie). The caller +/// decodes each to PCM, concatenates in order, and clamps to the movie length — +/// that yields the full track for segment banks while the clamp drops the +/// duplicate takes for alternate-take banks. (Dynamic RE via Canary file-I/O +/// tracing confirmed the movie→voice binding; this fixes the *decode* of `RT*`.) +pub fn to_xma_riffs(slb: &[u8]) -> Vec> { + let mut out = Vec::new(); + if find(slb, b"RIFF", 0).is_none() { + // Headerless single-stream bank. + if let Some(data) = slb.get(HEADERLESS_DATA_OFFSET..) { + if !data.is_empty() { + out.push(build_riff(&synth_xma1_fmt(2, 2, 48000), data)); + } + } + return out; + } + let mut pos = 0usize; + while let Some(ri) = find(slb, b"RIFF", pos) { + // Parse this sub-wave's fmt + data (declared size is honest per sub-wave). + let Some(fi) = find(slb, b"fmt ", ri) else { break }; + let Some(fsz) = le32(slb, fi + 4) else { break }; + let Some(fmt_end) = fi.checked_add(8).and_then(|v| v.checked_add(fsz as usize)) else { + break; + }; + if fmt_end > slb.len() { + break; + } + let Some(di) = find(slb, b"data", fi) else { break }; + let Some(dsz) = le32(slb, di + 4) else { break }; + let Some(ds) = di.checked_add(8) else { break }; + let de = ds + .checked_add(dsz as usize) + .unwrap_or(slb.len()) + .min(slb.len()); + if let Some(data) = slb.get(ds..de) { + if !data.is_empty() { + out.push(build_riff(&slb[fi..fmt_end], data)); + } + } + // Advance past this sub-wave's data to find the next RIFF. + pos = de.max(ri + 4); + } + out +} + /// Build a standalone XMA1 `RIFF/WAVE` from a `.slb` bank, decodable by FFmpeg's -/// `xma1`. Returns `None` if the bank is too small / malformed. +/// `xma1`. Returns `None` if the bank is too small / malformed. This is the +/// FIRST sub-wave only; prefer [`to_xma_riffs`] for correct multi-segment banks. pub fn to_xma_riff(slb: &[u8]) -> Option> { if let Some(ri) = find(slb, b"RIFF", 0) { // RIFF layout: a `.slb` is an XACT bank of one or more sub-waves, each @@ -235,6 +290,40 @@ mod tests { assert_eq!(&riff[dpos + 8..], &[1, 2, 3, 4]); } + #[test] + fn list_voice_clips_covers_movie_radio_and_briefing() { + // The standalone player must enumerate every spoken-line category: bound + // movie voices, in-mission radio (\etc\ + \Voice\), and briefing (\Briefing\, + // whose BR_ names lack "VOICE"). Music (BGM_*) must stay excluded. + let mut tbl = Vec::new(); + for s in [ + "eng\\Movie\\VOICE_S13A.slb", + "eng\\etc\\VOICE_D_450.slb", + "eng\\Voice\\VOICE_ADAN_010.slb", + "eng\\Briefing\\BR01_01.slb", + "eng\\bgm\\BGM_001.slb", + ] { + tbl.extend_from_slice(s.as_bytes()); + tbl.push(0); + } + let names: Vec = list_voice_clips(&tbl, VoiceLang::English) + .into_iter() + .map(|c| c.name) + .collect(); + for want in [ + "eng\\Movie\\VOICE_S13A.slb", + "eng\\etc\\VOICE_D_450.slb", + "eng\\Voice\\VOICE_ADAN_010.slb", + "eng\\Briefing\\BR01_01.slb", + ] { + assert!(names.iter().any(|n| n == want), "missing {want}"); + } + assert!( + !names.iter().any(|n| n.contains("BGM_")), + "music must not be listed as a voice clip" + ); + } + #[test] fn rebuilds_riff_from_headerless() { let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET]; diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index 7e475a3..eb0f2fb 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -1698,6 +1698,10 @@ fn poll_loader_channel( browser.files = files; browser.selected = None; browser.filter.clear(); + // A new game source is loaded — invalidate the voice library so the + // browser re-reads sounds.tbl from THIS source next time it opens + // (otherwise a stale/failed empty result from before load persists). + *voice_lib = VoiceLibrary::default(); info!("Loaded {} files", browser.files.len()); } Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => { @@ -1773,9 +1777,17 @@ fn poll_loader_channel( } } Ok(IsoLoaderMsg::VoiceLibraryLoaded { clips }) => { - voice_lib.clips = clips; - voice_lib.loaded = true; voice_lib.loading = false; + // sounds.tbl always has thousands of clips, so an empty result means + // the read failed (e.g. no game source, or opened only a bare pak). + // Leave `loaded=false` so re-opening the menu retries instead of + // latching an empty list forever. + if clips.is_empty() { + voice_lib.loaded = false; + } else { + voice_lib.clips = clips; + voice_lib.loaded = true; + } } Ok(IsoLoaderMsg::Cancelled) => { iso_state.loading = false; @@ -3128,6 +3140,10 @@ fn decode_sound_clip( use sylpheed_formats::{hash::name_hash, slb}; let key = name_hash(clip_name); let bytes = read_sound_entry(source, key)?; + // Decode the first sub-wave (base behaviour). NOTE: multi-sub-wave assembly + // (segments vs alternate takes vs timecode-placed lines) is UNRESOLVED — a + // naive concat produced the wrong track for RT movies. Pending ground-truth + // voice-playback instrumentation (Canary APU/XMA) before a real fix. let riff = slb::to_xma_riff(&bytes).ok_or("not a decodable .slb")?; let dir = std::env::temp_dir(); @@ -3143,8 +3159,6 @@ fn decode_sound_clip( let status = Command::new("ffmpeg") .args(["-hide_banner", "-y", "-i"]) .arg(&xma_path) - // Downmix to mono using the left channel (holds the full signal for both - // left-only and dual-mono sources), then clamp to the media length. .args(["-af", "pan=mono|c0=c0", "-t"]) .arg(format!("{dur}")) .arg(&out_path)