From 761e91de96e72556555098c5c2e9ff0c7e3bcf17 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Fri, 28 Aug 2026 16:28:20 +0200 Subject: [PATCH] viewer: open the whole sound bank, not just the voice half The library enumerator kept only names containing VOICE or \Briefing\, and read eng\sounds.tbl unconditionally. So the Explorer could reach 4382 of the 9519 banks in sound.pak: no music, no jingles, no sound effects, and no Japanese voice at all -- roughly half the disc's audio had no route to the UI. `slb::list_audio_entries` now returns every named bank with the category its path implies (Music / Jingles / Sound effects / Radio / Dialogue / Movie voice / Briefing). `list_voice_clips` is that, restricted to the spoken categories, so its existing test still guards the old behaviour. The 36 root banks carry no language component and appear whichever table is read; the window gets an English/Japanese switch that re-reads the other sounds.tbl, since the table name IS the selector. Two defects the decode found, both recorded in docs/re/structures/sound-pak-contents.md: * `Static.slb` -- the SFX bank -- declares 616768 bytes more than sound.p04 holds. Not our extraction: p04 matches the ISO's own directory record, and a sweep of every pak on the disc finds this one entry over-running and no other. It is the highest-offset entry, so its comp_size is an allocation size. A short read is now allowed for the tail entry ONLY; any other overrun stays an error, because clamping it would hide real damage behind a half-decoded asset. The bank went from unreadable to 514 s of audio. * the left-channel downmix was applied to everything. Right for voice (mono content however stored), wrong for music (a real stereo mix, half of it discarded). The caller now decides from the category. 35 of the 36 shared banks decode; JNGL_001 does not, and says so in the player instead of the panel silently closing. Its payload is not a whole number of XMA1 packets from any known data offset, so it is likely not a plain headerless stream -- written up rather than papered over. Co-Authored-By: Claude Opus 5 --- crates/sylpheed-formats/src/pak.rs | 32 +++- crates/sylpheed-formats/src/slb.rs | 214 +++++++++++++++++++++-- crates/sylpheed-viewer/src/iso_loader.rs | 151 ++++++++++------ crates/sylpheed-viewer/src/ui.rs | 176 ++++++++++++------- docs/re/structures/sound-pak-contents.md | 60 +++++++ 5 files changed, 492 insertions(+), 141 deletions(-) diff --git a/crates/sylpheed-formats/src/pak.rs b/crates/sylpheed-formats/src/pak.rs index 110e6ecd..5129d4b8 100644 --- a/crates/sylpheed-formats/src/pak.rs +++ b/crates/sylpheed-formats/src/pak.rs @@ -225,16 +225,34 @@ impl PakArchive { } /// The raw stored bytes for an entry (still `"Z1"`-wrapped / compressed). + /// + /// One entry on the retail disc declares more bytes than the segments hold: + /// `sound.pak`'s `Static.slb` (the SFX bank) claims 8 970 240 bytes at the + /// highest offset in the archive, 616 768 past the end of `sound.p04`. It is + /// not corruption and it is not our extraction — `sound.p04` is byte-for-byte + /// the size the ISO's own directory record gives, and a sweep of **every** + /// `.pak` on the disc finds this one entry and no other. So the last entry's + /// `comp_size` is an allocation size, not a stored size. + /// + /// A short read is therefore allowed **only** for the highest-offset entry, + /// which is the shape the evidence supports. Any other overrun is still an + /// error: that would be real damage, and clamping it would hide the damage + /// behind a half-decoded asset. pub fn stored_bytes(&self, entry: &PakEntry) -> Result<&[u8], PakError> { let start = entry.offset as usize; let end = start + entry.comp_size as usize; - self.data - .get(start..end) - .ok_or(PakError::OffsetOutOfRange { - offset: entry.offset, - size: entry.comp_size, - data_len: self.data.len(), - }) + if let Some(b) = self.data.get(start..end) { + return Ok(b); + } + let is_tail = self.entries.iter().all(|e| e.offset <= entry.offset); + if is_tail && start < self.data.len() { + return Ok(&self.data[start..]); + } + Err(PakError::OffsetOutOfRange { + offset: entry.offset, + size: entry.comp_size, + data_len: self.data.len(), + }) } /// Decompress an entry to its raw payload bytes. Handles the `"Z1"` container diff --git a/crates/sylpheed-formats/src/slb.rs b/crates/sylpheed-formats/src/slb.rs index 9a16770a..1c6bf399 100644 --- a/crates/sylpheed-formats/src/slb.rs +++ b/crates/sylpheed-formats/src/slb.rs @@ -29,13 +29,25 @@ pub const XMA1_PACKET: usize = 2048; /// Voice language for cutscene audio. Only English and Japanese voice exist on /// the disc (subtitles cover more languages, voice does not). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum VoiceLang { + /// The default only because the disc's own default audio track is English; + /// nothing else about the code should assume it. + #[default] English, Japanese, } impl VoiceLang { + pub const ALL: [VoiceLang; 2] = [VoiceLang::English, VoiceLang::Japanese]; + + pub fn label(self) -> &'static str { + match self { + VoiceLang::English => "English", + VoiceLang::Japanese => "Japanese", + } + } + fn code(self) -> &'static str { match self { VoiceLang::English => "eng", @@ -67,14 +79,122 @@ pub struct VoiceClip { pub display: String, } -/// Enumerate the voice/dialog clips named in a decompressed `sounds.tbl` (the -/// IDXD in `tables.pak`). Extracts every `\{Voice,etc,Movie,Briefing}\…` -/// path ending in `.slb` for `lang`, parsed into `(name, speaker, display)`. -pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec { +/// What kind of audio a `sounds.tbl` entry names. +/// +/// The split is the on-disc path shape, not a guess: the 36 language-independent +/// banks sit at the table root (`BGM_###.slb`, `JNGL_00#.slb`, `Static.slb`), +/// while everything else is under `\\`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AudioCategory { + /// `BGM_###.slb` — 32 music tracks, language-independent. + Music, + /// `JNGL_00#.slb` — 3 short jingles (mission clear / fail stings). + Jingle, + /// `Static.slb` — the sound-effect bank, one 9 MB multi-wave bank. + Sfx, + /// `\Voice\` — in-mission radio chatter, by speaker. + Radio, + /// `\etc\` — the other spoken lines (cutscene dialogue, system). + Dialogue, + /// `\Movie\VOICE_.slb` — a cutscene's continuous voice track. + MovieVoice, + /// `\Briefing\BR_.slb` — mission briefing lines. + Briefing, + /// A `.slb` whose path matched no known shape. + Other, +} + +impl AudioCategory { + pub const ALL: [AudioCategory; 8] = [ + AudioCategory::Music, + AudioCategory::Jingle, + AudioCategory::Sfx, + AudioCategory::Radio, + AudioCategory::Dialogue, + AudioCategory::MovieVoice, + AudioCategory::Briefing, + AudioCategory::Other, + ]; + + pub fn label(self) -> &'static str { + match self { + AudioCategory::Music => "Music", + AudioCategory::Jingle => "Jingles", + AudioCategory::Sfx => "Sound effects", + AudioCategory::Radio => "Radio", + AudioCategory::Dialogue => "Dialogue", + AudioCategory::MovieVoice => "Movie voice", + AudioCategory::Briefing => "Briefing", + AudioCategory::Other => "Other", + } + } + + /// True for the categories that are spoken lines — the set + /// [`list_voice_clips`] returns. + pub fn is_voice(self) -> bool { + matches!( + self, + AudioCategory::Radio + | AudioCategory::Dialogue + | AudioCategory::MovieVoice + | AudioCategory::Briefing + ) + } + + /// True when the bank is language-independent, so it appears whichever + /// `\sounds.tbl` is read. + pub fn is_shared(self) -> bool { + matches!( + self, + AudioCategory::Music | AudioCategory::Jingle | AudioCategory::Sfx + ) + } + + fn classify(name: &str) -> AudioCategory { + let leaf = name.rsplit('\\').next().unwrap_or(name); + if !name.contains('\\') { + return if leaf.starts_with("BGM_") { + AudioCategory::Music + } else if leaf.starts_with("JNGL_") { + AudioCategory::Jingle + } else if leaf.eq_ignore_ascii_case("Static.slb") { + AudioCategory::Sfx + } else { + AudioCategory::Other + }; + } + match name.rsplit('\\').nth(1) { + Some("Voice") => AudioCategory::Radio, + Some("etc") => AudioCategory::Dialogue, + Some("Movie") => AudioCategory::MovieVoice, + Some("Briefing") => AudioCategory::Briefing, + _ => AudioCategory::Other, + } + } +} + +/// One playable bank named in `sounds.tbl`, with the category its path implies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AudioEntry { + pub clip: VoiceClip, + pub category: AudioCategory, +} + +/// Enumerate **every** `.slb` bank named in a decompressed `sounds.tbl` (the +/// IDXD in `tables.pak`): the language-independent music/jingle/SFX banks at +/// the table root, plus every `\…` spoken line. +/// +/// Measured on the retail disc: `eng\sounds.tbl` names 4 418 banks (36 shared + +/// 2 382 Radio + 1 821 Dialogue + 101 Briefing + 78 Movie voice) and +/// `jpn\sounds.tbl` names 5 136 (the same 36 shared + 5 100 Japanese lines). +/// Every one of the 36 shared names resolves to a `sound.pak` TOC entry under +/// [`crate::hash::name_hash`], which is the check that they are real banks and +/// not stale table text. +pub fn list_audio_entries(sounds_tbl: &[u8], lang: VoiceLang) -> Vec { let prefix = format!("{}\\", lang.code()); let mut seen = std::collections::BTreeSet::new(); let mut out = Vec::new(); - // Scan for printable-ASCII runs; keep those that look like a voice path. + // Scan for printable-ASCII runs; keep those that name a `.slb`. let mut i = 0; while i < sounds_tbl.len() { let start = i; @@ -83,15 +203,14 @@ 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]) { - // 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)); - } + // Take this language's entries plus the root (shared) banks; a + // path under the OTHER language would be a table artefact. + let mine = s.starts_with(&prefix) || !s.contains('\\'); + if mine && s.ends_with(".slb") && seen.insert(s.to_string()) { + out.push(AudioEntry { + category: AudioCategory::classify(s), + clip: parse_voice_clip(s), + }); } } } @@ -100,6 +219,21 @@ pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec { out } +/// Enumerate just the spoken-line clips — [`list_audio_entries`] restricted to +/// [`AudioCategory::is_voice`]. +/// +/// In-mission radio (`\Voice\`, `\etc\`) and bound movie voices (`\Movie\`) all +/// carry `VOICE_`; mission-briefing lines live in `\Briefing\` as +/// `BR_.slb` and carry no `VOICE` at all, which is why the category — +/// i.e. the directory — decides this and not the filename. +pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec { + list_audio_entries(sounds_tbl, lang) + .into_iter() + .filter(|e| e.category.is_voice()) + .map(|e| e.clip) + .collect() +} + fn parse_voice_clip(name: &str) -> VoiceClip { // `\\VOICE__.slb` or `..\VOICE_.slb`. let stem = name @@ -525,6 +659,56 @@ mod tests { ); } + #[test] + fn list_audio_entries_categorises_root_banks_and_keeps_them_language_shared() { + // The three root banks carry no language component, so BOTH sounds.tbl + // files name them; a language filter that only accepted `\` would + // silently drop all the music, which is what it used to do. + let mut tbl = Vec::new(); + for s in [ + "BGM_001.slb", + "JNGL_002.slb", + "Static.slb", + "eng\\Voice\\VOICE_ADAN_010.slb", + "eng\\etc\\VOICE_D_450.slb", + "eng\\Movie\\VOICE_S13A.slb", + "eng\\Briefing\\BR01_01.slb", + ] { + tbl.extend_from_slice(s.as_bytes()); + tbl.push(0); + } + let by = |lang| { + list_audio_entries(&tbl, lang) + .into_iter() + .map(|e| (e.clip.name, e.category)) + .collect::>() + }; + let eng = by(VoiceLang::English); + let want = [ + ("BGM_001.slb", AudioCategory::Music), + ("JNGL_002.slb", AudioCategory::Jingle), + ("Static.slb", AudioCategory::Sfx), + ("eng\\Voice\\VOICE_ADAN_010.slb", AudioCategory::Radio), + ("eng\\etc\\VOICE_D_450.slb", AudioCategory::Dialogue), + ("eng\\Movie\\VOICE_S13A.slb", AudioCategory::MovieVoice), + ("eng\\Briefing\\BR01_01.slb", AudioCategory::Briefing), + ]; + assert_eq!(eng.len(), want.len()); + for (n, c) in want { + assert!( + eng.iter().any(|(en, ec)| en == n && *ec == c), + "{n} not categorised as {c:?}" + ); + } + // Reading the Japanese table yields the shared banks and none of the + // English lines. + let jpn = by(VoiceLang::Japanese); + assert_eq!(jpn.len(), 3, "only the shared banks: {jpn:?}"); + assert!(jpn.iter().all(|(_, c)| c.is_shared())); + // And the voice view is exactly the non-shared half. + assert_eq!(list_voice_clips(&tbl, VoiceLang::English).len(), 4); + } + #[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 7fc64d9f..b1827bec 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -465,6 +465,10 @@ pub struct AudioPreview { pub playing: bool, pub volume: f32, pub loading: bool, + /// Set when a bank did not decode. Shown in the player instead of the panel + /// vanishing: one bank on the disc (`JNGL_001.slb`) genuinely does not decode, + /// and a player that silently closes is indistinguishable from a misclick. + pub error: Option, pub seek_request: Option, /// A freshly-decoded WAV + duration awaiting sink construction. pub(crate) pending: Option<(PathBuf, f32)>, @@ -481,6 +485,7 @@ impl Default for AudioPreview { playing: false, volume: 0.9, loading: false, + error: None, seek_request: None, pending: None, generation: 0, @@ -498,23 +503,34 @@ pub struct RequestAudio { pub display: String, /// When set, resolve the `.slb` from the movie manifest instead of `clip`. pub movie: Option<(String, sylpheed_formats::slb::VoiceLang)>, + /// Downmix to the left channel. True for spoken lines (mono content however + /// it is stored); false for music, whose two channels are a real mix. + pub mono: bool, pub generation: u64, } -/// The browseable library of voice-line clips (from `sounds.tbl`), for the -/// standalone voice player. Loaded lazily the first time the browser opens. +/// The browseable library of sound banks named in `sounds.tbl` — music, +/// jingles, the SFX bank and every spoken line — for the standalone player. +/// Loaded lazily the first time the browser opens, and re-loaded when the +/// language changes (each `\sounds.tbl` names a different line set). #[derive(Resource, Default)] -pub struct VoiceLibrary { +pub struct AudioLibrary { pub open: bool, pub loading: bool, pub loaded: bool, - pub clips: Vec, + pub entries: Vec, pub filter: String, + /// Which `\sounds.tbl` to read. The 36 root banks are shared, so they + /// appear either way; the ~2 400–5 100 spoken lines do not. + pub lang: sylpheed_formats::slb::VoiceLang, + /// Set when the language changes; consumed by the loader like + /// [`ScreenBrowser::rescan`], for the same B0002 reason. + pub reload: bool, } -/// Ask the loader to enumerate the voice library from `sounds.tbl`. +/// Ask the loader to enumerate the audio library from `sounds.tbl`. #[derive(Event, Default)] -pub struct RequestVoiceLibrary; +pub struct RequestAudioLibrary; // ── Game-data browser (decoded IDXD tables) ──────────────────────────────────── @@ -946,9 +962,9 @@ enum IsoLoaderMsg { generation: u64, wav: Option<(PathBuf, f32)>, }, - /// The voice-clip library enumerated from `sounds.tbl`. - VoiceLibraryLoaded { - clips: Vec, + /// The sound-bank library enumerated from `\sounds.tbl`. + AudioLibraryLoaded { + entries: Vec, }, /// The assembled-ship catalog for the Ships browser. ShipCatalogLoaded(Vec), @@ -1180,7 +1196,7 @@ impl Plugin for IsoLoaderPlugin { .init_resource::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -1188,7 +1204,7 @@ impl Plugin for IsoLoaderPlugin { .add_event::() .add_event::() .add_event::() - .add_event::() + .add_event::() .add_event::() .add_event::() .add_event::() @@ -1222,7 +1238,7 @@ impl Plugin for IsoLoaderPlugin { handle_voice_request, handle_audio_request, advance_audio_playback, - handle_voice_library_request, + handle_audio_library_request, handle_game_data_request, handle_ship_catalog_request, handle_ship_render_request, @@ -2080,7 +2096,7 @@ fn poll_loader_channel( mut subtitles: ResMut, mut mvoice: ResMut, mut audio_preview: ResMut, - mut voice_lib: ResMut, + mut audio_lib: ResMut, mut game_data: ResMut, mut ships: ResMut, mut screens: ResMut, @@ -2105,7 +2121,7 @@ fn poll_loader_channel( // 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(); + *audio_lib = AudioLibrary::default(); info!("Loaded {} files", browser.files.len()); } Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => { @@ -2178,23 +2194,28 @@ fn poll_loader_channel( match wav { Some((p, dur)) => { audio_preview.name = name; + audio_preview.error = None; audio_preview.pending = Some((p, dur)); } - None => audio_preview.active = false, + None => { + audio_preview.name = name; + audio_preview.error = + Some("this bank did not decode to any audio".into()); + } } } } - Ok(IsoLoaderMsg::VoiceLibraryLoaded { clips }) => { - 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; + Ok(IsoLoaderMsg::AudioLibraryLoaded { entries }) => { + audio_lib.loading = false; + // sounds.tbl always names thousands of banks, 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 entries.is_empty() { + audio_lib.loaded = false; } else { - voice_lib.clips = clips; - voice_lib.loaded = true; + audio_lib.entries = entries; + audio_lib.loaded = true; } } Ok(IsoLoaderMsg::GameDataLoaded(snap)) => { @@ -2258,7 +2279,7 @@ fn poll_loader_channel( saves.loading = false; game_data.loading = false; ships.loading = false; - voice_lib.loading = false; + audio_lib.loading = false; iso_state.error = Some("the asset loader stopped responding".into()); break; } @@ -3665,6 +3686,7 @@ fn decode_sound_clip( source: &SourceKind, clip_name: &str, duration: f32, + mono: bool, ) -> Result { use sylpheed_formats::{hash::name_hash, slb}; let key = name_hash(clip_name); @@ -3683,15 +3705,25 @@ fn decode_sound_clip( // so the standalone browser can still play them. riffs = slb::to_xma_riff_best(&bytes).into_iter().collect(); } - decode_riffs_to_wav(riffs, &format!("{key:08x}"), duration) + decode_riffs_to_wav(riffs, &format!("{key:08x}"), duration, mono) } -/// Decode a set of XMA sub-wave RIFFs into a single mono WAV: concatenate them, -/// downmix to mono (left channel holds the signal for dual-mono sources), and -/// clamp to `duration` seconds. Shared by the per-`.slb` decoder and the -/// continuous movie-voice decoder. +/// Decode a set of XMA sub-wave RIFFs into one WAV: concatenate them, optionally +/// downmix to mono, and clamp to `duration` seconds. Shared by the per-`.slb` +/// decoder and the continuous movie-voice decoder. +/// +/// `mono` takes the LEFT channel only. That is right for **voice**, where the +/// content is mono however it is stored (some clips put the signal in the left +/// channel alone, others duplicate L=R) -- and wrong for **music**, where the two +/// channels are a real stereo mix and taking one throws half of it away. So the +/// caller decides from the bank's category rather than this guessing per-sample. #[cfg(not(target_arch = "wasm32"))] -fn decode_riffs_to_wav(riffs: Vec>, tag: &str, duration: f32) -> Result { +fn decode_riffs_to_wav( + riffs: Vec>, + tag: &str, + duration: f32, + mono: bool, +) -> Result { if riffs.is_empty() { return Err("no decodable audio".into()); } @@ -3710,9 +3742,10 @@ fn decode_riffs_to_wav(riffs: Vec>, tag: &str, duration: f32) -> Result< 600.0 }; let filter = format!( - "{}concat=n={}:v=0:a=1,pan=mono|c0=c0[a]", + "{}concat=n={}:v=0:a=1{}[a]", (0..inputs.len()).map(|i| format!("[{i}:a]")).collect::(), inputs.len(), + if mono { ",pan=mono|c0=c0" } else { "" }, ); let mut cmd = Command::new("ffmpeg"); cmd.args(["-hide_banner", "-y"]); @@ -3855,7 +3888,7 @@ fn decode_voice_region( if riffs.is_empty() { riffs = slb::to_xma_riff_best(&bytes).into_iter().collect(); } - decode_riffs_to_wav(riffs, &format!("mv_{start:x}"), duration) + decode_riffs_to_wav(riffs, &format!("mv_{start:x}"), duration, true) } /// Resolve a movie's `sound.pak` voice-entry name via the **movie manifest** @@ -3918,7 +3951,7 @@ fn handle_voice_request( if clip.contains("\\Movie\\") { return None; } - decode_sound_clip(&source, &clip, duration).ok() + decode_sound_clip(&source, &clip, duration, true).ok() }); info!("[voice] movie={movie:?} gen={generation} -> wav={wav:?}"); let _ = sender.send(IsoLoaderMsg::VoiceLoaded { @@ -3991,10 +4024,11 @@ fn handle_audio_request( }; let source = iso_state.source_kind.clone(); let sender = channels.sender.clone(); - let (clip, display, movie, generation) = ( + let (clip, display, movie, mono, generation) = ( req.clip.clone(), req.display.clone(), req.movie.clone(), + req.mono, req.generation, ); std::thread::spawn(move || { @@ -4009,9 +4043,9 @@ fn handle_audio_request( if c.contains("\\Movie\\") { return None; } - decode_sound_clip(&source, &c, f32::INFINITY).ok() + decode_sound_clip(&source, &c, f32::INFINITY, true).ok() }), - None => decode_sound_clip(&source, &clip, f32::INFINITY).ok(), + None => decode_sound_clip(&source, &clip, f32::INFINITY, mono).ok(), } .and_then(|p| wav_duration(&p).map(|d| (p, d))); let _ = sender.send(IsoLoaderMsg::AudioLoaded { @@ -4786,36 +4820,51 @@ fn build_ship_model( } } -/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and -/// enumerates the voice clips off-thread. +/// Handles a [`RequestAudioLibrary`]: reads `tables.pak`'s `\sounds.tbl` +/// and enumerates every named sound bank off-thread. #[cfg(not(target_arch = "wasm32"))] -fn handle_voice_library_request( - mut events: EventReader, +fn handle_audio_library_request( + mut events: EventReader, iso_state: Res, channels: Res, + mut library: ResMut, ) { - if events.read().next().is_none() { + // Either the View-menu event, or the in-window language switch's flag. + let reload = library.reload; + if events.read().next().is_none() && !reload { return; } + library.reload = false; + if library.loading { + return; + } + library.loading = true; + // The table name IS the language selector — there is no language field + // inside it, so switching languages means reading the other file. + let lang = library.lang; let source = iso_state.source_kind.clone(); let sender = channels.sender.clone(); std::thread::spawn(move || { - let clips = (|| -> Result, String> { + let entries = (|| -> Result, String> { let pak = read_pak_archive_blocking(&source, "dat/tables.pak")?; + let name = format!("{}\\sounds.tbl", lang_code(lang)); let tbl = pak - .read_by_name("eng\\sounds.tbl") - .ok_or("sounds.tbl not found")? + .read_by_name(&name) + .ok_or(format!("{name} not found"))? .map_err(|e| e.to_string())?; - Ok(sylpheed_formats::slb::list_voice_clips( - &tbl, - sylpheed_formats::slb::VoiceLang::English, - )) + Ok(sylpheed_formats::slb::list_audio_entries(&tbl, lang)) })() .unwrap_or_default(); - let _ = sender.send(IsoLoaderMsg::VoiceLibraryLoaded { clips }); + let _ = sender.send(IsoLoaderMsg::AudioLibraryLoaded { entries }); }); } +/// `eng` / `jpn` — the `tables.pak` and `sound.pak` path prefix for a language. +#[cfg(not(target_arch = "wasm32"))] +fn lang_code(lang: sylpheed_formats::slb::VoiceLang) -> &'static str { + lang.code_pub() +} + /// Drives the standalone audio player: builds/rebuilds its sink, mirrors /// play/pause + volume + seeks, advances the clock, and stops at the end. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 84c721f1..398a3285 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -13,9 +13,9 @@ use crate::iso_loader::{ AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview, MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir, RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose, - RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary, SaveBrowser, + RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestAudioLibrary, SaveBrowser, ScreenBrowser, ShipBrowser, SkyboxPreview, TextPreview, TexturePreview, - VideoPreview, VoiceLibrary, IsoLoaderSystemSet, + VideoPreview, AudioLibrary, IsoLoaderSystemSet, }; use crate::ViewerState; use sylpheed_formats::SubLang; @@ -148,7 +148,7 @@ struct UiEvents<'w> { file_selected: EventWriter<'w, FileSelected>, subtitles: EventWriter<'w, RequestSubtitles>, audio: EventWriter<'w, RequestAudio>, - voice_lib: EventWriter<'w, RequestVoiceLibrary>, + audio_lib: EventWriter<'w, RequestAudioLibrary>, game_data: EventWriter<'w, RequestGameData>, ships: EventWriter<'w, RequestShipCatalog>, screens: EventWriter<'w, RequestScreenCatalog>, @@ -170,7 +170,7 @@ fn draw_viewer_ui( mut subtitles: ResMut, mut movie_voice: ResMut, mut audio: ResMut, - mut voice_lib: ResMut, + mut audio_lib: ResMut, mut events: UiEvents, ) { let ctx = contexts.ctx_mut(); @@ -202,11 +202,11 @@ fn draw_viewer_ui( }); ui.menu_button("View", |ui| { - if ui.button("🎙 Voice Lines…").clicked() { - voice_lib.open = true; - if !voice_lib.loaded && !voice_lib.loading { - voice_lib.loading = true; - events.voice_lib.send_default(); + if ui.button("🔊 Audio Library…").clicked() { + audio_lib.open = true; + if !audio_lib.loaded && !audio_lib.loading { + audio_lib.loading = true; + events.audio_lib.send_default(); } ui.close_menu(); } @@ -248,98 +248,134 @@ fn draw_viewer_ui( }); // ── Standalone voice-line browser (floating window) ─────────────────── - if voice_lib.open { + if audio_lib.open { let mut open = true; - egui::Window::new("🎙 Voice Lines") - .default_width(360.0) - .default_height(480.0) + egui::Window::new("🔊 Audio Library") + .default_width(400.0) + .default_height(520.0) .open(&mut open) .show(ctx, |ui| { - if voice_lib.loading { + if audio_lib.loading { ui.horizontal(|ui| { ui.spinner(); ui.label("Reading sounds.tbl…"); }); ctx.request_repaint(); - } else if !voice_lib.loaded { + } else if !audio_lib.loaded { ui.label("Open a game source first."); } else { + use sylpheed_formats::slb::VoiceLang; ui.horizontal(|ui| { + ui.label("Voice:"); + // The table name IS the selector: there is no language + // field inside sounds.tbl, so switching re-reads the + // other file. Music/jingles/SFX are shared and stay. + for lang in VoiceLang::ALL { + if ui + .selectable_label(audio_lib.lang == lang, lang.label()) + .clicked() + && audio_lib.lang != lang + { + audio_lib.lang = lang; + audio_lib.loaded = false; + audio_lib.entries.clear(); + audio_lib.reload = true; + } + } + ui.separator(); ui.label("Filter:"); - ui.text_edit_singleline(&mut voice_lib.filter); + ui.text_edit_singleline(&mut audio_lib.filter); }); - let f = voice_lib.filter.to_lowercase(); - // Group the thousands of entries as directory → speaker so the - // list is navigable (e.g. browse `Voice` by character to find a - // cutscene's radio line). `name` is `\\.slb`. + let f = audio_lib.filter.to_lowercase(); + // Group category → speaker. The category comes from the path + // shape, so the root banks (music/jingles/SFX) get real + // headings instead of the "?" a directory split gave them. use std::collections::BTreeMap; - let mut groups: BTreeMap<&str, BTreeMap<&str, Vec<&sylpheed_formats::slb::VoiceClip>>> = - BTreeMap::new(); - for c in &voice_lib.clips { - if !f.is_empty() && !c.name.to_lowercase().contains(&f) { + type Group<'a> = BTreeMap<&'a str, Vec<&'a sylpheed_formats::slb::AudioEntry>>; + let mut groups: BTreeMap< + sylpheed_formats::slb::AudioCategory, + Group<'_>, + > = BTreeMap::new(); + for e in &audio_lib.entries { + if !f.is_empty() && !e.clip.name.to_lowercase().contains(&f) { continue; } - let dir = c.name.rsplit('\\').nth(1).unwrap_or("?"); groups - .entry(dir) + .entry(e.category) .or_default() - .entry(c.speaker.as_str()) + .entry(e.clip.speaker.as_str()) .or_default() - .push(c); + .push(e); } let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum(); ui.label( - egui::RichText::new(format!("{shown} / {} clips", voice_lib.clips.len())) - .weak() - .small(), + egui::RichText::new(format!( + "{shown} / {} banks", + audio_lib.entries.len() + )) + .weak() + .small(), ); ui.separator(); let filtering = !f.is_empty(); egui::ScrollArea::vertical().show(ui, |ui| { - for (dir, speakers) in &groups { - let dtotal: usize = speakers.values().map(Vec::len).sum(); - egui::CollapsingHeader::new(format!("📁 {dir} ({dtotal})")) - .id_salt(("vdir", *dir)) - .default_open(filtering) - .show(ui, |ui| { - for (speaker, clips) in speakers { + for (cat, speakers) in &groups { + let ctotal: usize = speakers.values().map(Vec::len).sum(); + egui::CollapsingHeader::new(format!( + "{} ({ctotal})", + cat.label() + )) + .id_salt(("acat", *cat)) + .default_open(filtering || ctotal <= 40) + .show(ui, |ui| { + for (speaker, entries) in speakers { + // A single-bank group (Static.slb) would be a + // pointless nested header. + let flat = speakers.len() == 1 || entries.len() == 1; + let mut row = |ui: &mut egui::Ui| { + for e in entries { + ui.horizontal(|ui| { + if ui + .button("▶") + .on_hover_text(&e.clip.name) + .clicked() + { + audio.generation = + audio.generation.wrapping_add(1); + audio.loading = true; + audio.active = true; + audio.error = None; + audio.name = e.clip.display.clone(); + events.audio.send(RequestAudio { + clip: e.clip.name.clone(), + display: e.clip.display.clone(), + movie: None, + mono: e.category.is_voice(), + generation: audio.generation, + }); + } + ui.label(&e.clip.display); + }); + } + }; + if flat { + row(ui); + } else { egui::CollapsingHeader::new(format!( "{speaker} ({})", - clips.len() + entries.len() )) - .id_salt(("vspk", *dir, *speaker)) - .default_open(filtering || clips.len() <= 6) - .show(ui, |ui| { - for c in clips { - ui.horizontal(|ui| { - if ui - .button("▶") - .on_hover_text(&c.name) - .clicked() - { - audio.generation = - audio.generation.wrapping_add(1); - audio.loading = true; - audio.active = true; - audio.name = c.display.clone(); - events.audio.send(RequestAudio { - clip: c.name.clone(), - display: c.display.clone(), - movie: None, - generation: audio.generation, - }); - } - ui.label(&c.display); - }); - } - }); + .id_salt(("aspk", *cat, *speaker)) + .default_open(filtering || entries.len() <= 6) + .show(ui, &mut row); } - }); + } + }); } }); } }); - voice_lib.open = open; + audio_lib.open = open; } // ── Left panel: file browser ────────────────────────────────────────── @@ -481,11 +517,13 @@ fn draw_viewer_ui( audio.generation = audio.generation.wrapping_add(1); audio.loading = true; audio.active = true; // show the panel immediately (spinner) + audio.error = None; audio.name = format!("VOICE_{movie}"); events.audio.send(RequestAudio { clip: String::new(), display: format!("VOICE_{movie}"), movie: Some((movie.clone(), movie_voice.lang)), + mono: true, // a cutscene voice track generation: audio.generation, }); } @@ -1348,8 +1386,10 @@ fn draw_audio_player(ui: &mut egui::Ui, audio: &mut AudioPreview) { if audio.loading { ui.spinner(); ui.label("decoding…"); + } else if let Some(err) = &audio.error { + ui.colored_label(egui::Color32::from_rgb(224, 86, 122), format!("⚠ {err}")); } else { - ui.label("voice track"); + ui.label("sound bank"); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("✖ Close").clicked() { diff --git a/docs/re/structures/sound-pak-contents.md b/docs/re/structures/sound-pak-contents.md index 911c2455..71a007e7 100644 --- a/docs/re/structures/sound-pak-contents.md +++ b/docs/re/structures/sound-pak-contents.md @@ -149,3 +149,63 @@ into those five shapes. Two incidental facts fall out: **4 banks run at 44 100 Hz** where everything else is 48 000, and the `BGM_*` tracks are the stereo ones. + +## The 36 shared banks, decoded end-to-end (2026-08-28) + +The census above is arithmetic — durations from the `seek` table, no decoding. +This section is the decode itself, for the 36 language-independent banks +(32 `BGM_*` + 3 `JNGL_*` + `Static.slb`), because they were the ones no viewer +had ever played: the library enumerator kept only names containing `VOICE` or +`\Briefing\`, so every one of them was filtered out before it could be tried. + +**35 of 36 decode to plausible audio**, and the shapes agree with the census: +the 32 `BGM_*` come out 75–555 s and **stereo**, matching "the `BGM_*` tracks +are the stereo ones"; `JNGL_002` decodes to 33.89 s against the manifest's +predicted 33.97 s. + +Two things the census could not have caught, both found by decoding: + +### `Static.slb`'s TOC entry over-declares its size + +The SFX bank sits at the **highest offset in the archive** and claims +8 970 240 bytes — **616 768 past the end of `sound.p04`**. It is not our +extraction: `sound.p04` is byte-for-byte the size the ISO's own directory record +gives. A sweep of **every `.pak` on the disc** finds this one entry over-running +and no other, so the last entry's `comp_size` is an allocation size rather than a +stored size. + +`PakArchive::stored_bytes` therefore allows a short read **only** for the +highest-offset entry. Any other overrun is still an error — that would be real +damage, and clamping it would hide the damage behind a half-decoded asset. With +the short read, `Static.slb` decodes to **514 s of mono**; before it, the SFX +bank could not be read at all. + +### `JNGL_001.slb` does not decode — and that is a real gap, not a filter + +It is one of the headerless banks (no `RIFF`), so it was already outside the +4 114-bank manifest above. Decoding it yields **0.01 s** — one frame, the +signature this corpus already records for a wrong channel count. But the usual +fixes do not apply: + +* it is not a channel-count error the `RIFF+49` rule can repair, because there is + no `RIFF` to read the count from; +* its payload is **not a whole number of 2048-byte XMA1 packets** from any of the + four `DATA_OFFSET_CANDIDATES`, which a headerless XMA1 stream must be. + +So `JNGL_001` is probably not a plain headerless XMA1 stream at all. The other +headerless root bank, `Static.slb`, decodes fine at 514 s, so the headerless path +is not broken in general — this is one bank in 9 519. It is listed in the viewer +and reports that it did not decode, rather than being hidden. + +⚠️ Note on the offsets: `to_xma_riffs` still *scans* for the headerless data +offset, while [slb-data-offset.md](slb-data-offset.md) establishes the exact rule +(the cumulative `.pNN` segment start mod 2048, 8 783/8 783). Wiring the exact +rule into the decoder is open, and is the first thing to try on `JNGL_001`. + +### Downmix is a per-category decision, not a constant + +The decoder took the left channel unconditionally. That is right for **voice**, +whose content is mono however it is stored (some clips put the signal in the left +channel alone, others duplicate L=R) — and wrong for **music**, where the two +channels are a real stereo mix and taking one throws half of it away. The caller +now decides from the bank's category.