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.