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 <noreply@anthropic.com>
This commit is contained in:
Sylpheed RE agent
2026-08-28 16:28:20 +02:00
parent fb70511242
commit 306a8a5661
5 changed files with 492 additions and 141 deletions

View File

@@ -225,16 +225,34 @@ impl PakArchive {
} }
/// The raw stored bytes for an entry (still `"Z1"`-wrapped / compressed). /// 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> { pub fn stored_bytes(&self, entry: &PakEntry) -> Result<&[u8], PakError> {
let start = entry.offset as usize; let start = entry.offset as usize;
let end = start + entry.comp_size as usize; let end = start + entry.comp_size as usize;
self.data if let Some(b) = self.data.get(start..end) {
.get(start..end) return Ok(b);
.ok_or(PakError::OffsetOutOfRange { }
offset: entry.offset, let is_tail = self.entries.iter().all(|e| e.offset <= entry.offset);
size: entry.comp_size, if is_tail && start < self.data.len() {
data_len: 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 /// Decompress an entry to its raw payload bytes. Handles the `"Z1"` container

View File

@@ -29,13 +29,25 @@ pub const XMA1_PACKET: usize = 2048;
/// Voice language for cutscene audio. Only English and Japanese voice exist on /// Voice language for cutscene audio. Only English and Japanese voice exist on
/// the disc (subtitles cover more languages, voice does not). /// 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 { 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, English,
Japanese, Japanese,
} }
impl VoiceLang { 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 { fn code(self) -> &'static str {
match self { match self {
VoiceLang::English => "eng", VoiceLang::English => "eng",
@@ -67,14 +79,122 @@ pub struct VoiceClip {
pub display: String, pub display: String,
} }
/// Enumerate the voice/dialog clips named in a decompressed `sounds.tbl` (the /// What kind of audio a `sounds.tbl` entry names.
/// IDXD in `tables.pak`). Extracts every `<lang>\{Voice,etc,Movie,Briefing}\…` ///
/// path ending in `.slb` for `lang`, parsed into `(name, speaker, display)`. /// The split is the on-disc path shape, not a guess: the 36 language-independent
pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> { /// banks sit at the table root (`BGM_###.slb`, `JNGL_00#.slb`, `Static.slb`),
/// while everything else is under `<lang>\<dir>\`.
#[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,
/// `<lang>\Voice\` — in-mission radio chatter, by speaker.
Radio,
/// `<lang>\etc\` — the other spoken lines (cutscene dialogue, system).
Dialogue,
/// `<lang>\Movie\VOICE_<movie>.slb` — a cutscene's continuous voice track.
MovieVoice,
/// `<lang>\Briefing\BR<NN>_<MM>.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
/// `<lang>\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 `<lang>\…` 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<AudioEntry> {
let prefix = format!("{}\\", lang.code()); let prefix = format!("{}\\", lang.code());
let mut seen = std::collections::BTreeSet::new(); let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::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; let mut i = 0;
while i < sounds_tbl.len() { while i < sounds_tbl.len() {
let start = i; let start = i;
@@ -83,15 +203,14 @@ pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
} }
if i - start >= 6 { if i - start >= 6 {
if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) { if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) {
// Every spoken-line category, so the standalone player covers them // Take this language's entries plus the root (shared) banks; a
// all: in-mission radio (`\Voice\`, `\etc\`) and bound movie voices // path under the OTHER language would be a table artefact.
// (`\Movie\`) all carry `VOICE_`; mission-briefing lines live in let mine = s.starts_with(&prefix) || !s.contains('\\');
// `\Briefing\` as `BR<NN>_<MM>.slb` (no `VOICE` in the name). if mine && s.ends_with(".slb") && seen.insert(s.to_string()) {
let is_voice = s.contains("VOICE") || s.contains("\\Briefing\\"); out.push(AudioEntry {
if s.starts_with(&prefix) && s.ends_with(".slb") && is_voice { category: AudioCategory::classify(s),
if seen.insert(s.to_string()) { clip: parse_voice_clip(s),
out.push(parse_voice_clip(s)); });
}
} }
} }
} }
@@ -100,6 +219,21 @@ pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
out 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<NN>_<MM>.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<VoiceClip> {
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 { fn parse_voice_clip(name: &str) -> VoiceClip {
// `<lang>\<cat>\VOICE_<SPK>_<NNN>.slb` or `..\VOICE_<movie>.slb`. // `<lang>\<cat>\VOICE_<SPK>_<NNN>.slb` or `..\VOICE_<movie>.slb`.
let stem = name 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 `<lang>\` 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::<Vec<_>>()
};
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] #[test]
fn rebuilds_riff_from_headerless() { fn rebuilds_riff_from_headerless() {
let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET]; let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET];

View File

@@ -465,6 +465,10 @@ pub struct AudioPreview {
pub playing: bool, pub playing: bool,
pub volume: f32, pub volume: f32,
pub loading: bool, 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<String>,
pub seek_request: Option<f32>, pub seek_request: Option<f32>,
/// A freshly-decoded WAV + duration awaiting sink construction. /// A freshly-decoded WAV + duration awaiting sink construction.
pub(crate) pending: Option<(PathBuf, f32)>, pub(crate) pending: Option<(PathBuf, f32)>,
@@ -481,6 +485,7 @@ impl Default for AudioPreview {
playing: false, playing: false,
volume: 0.9, volume: 0.9,
loading: false, loading: false,
error: None,
seek_request: None, seek_request: None,
pending: None, pending: None,
generation: 0, generation: 0,
@@ -498,23 +503,34 @@ pub struct RequestAudio {
pub display: String, pub display: String,
/// When set, resolve the `.slb` from the movie manifest instead of `clip`. /// When set, resolve the `.slb` from the movie manifest instead of `clip`.
pub movie: Option<(String, sylpheed_formats::slb::VoiceLang)>, 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, pub generation: u64,
} }
/// The browseable library of voice-line clips (from `sounds.tbl`), for the /// The browseable library of sound banks named in `sounds.tbl` — music,
/// standalone voice player. Loaded lazily the first time the browser opens. /// 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 `<lang>\sounds.tbl` names a different line set).
#[derive(Resource, Default)] #[derive(Resource, Default)]
pub struct VoiceLibrary { pub struct AudioLibrary {
pub open: bool, pub open: bool,
pub loading: bool, pub loading: bool,
pub loaded: bool, pub loaded: bool,
pub clips: Vec<sylpheed_formats::slb::VoiceClip>, pub entries: Vec<sylpheed_formats::slb::AudioEntry>,
pub filter: String, pub filter: String,
/// Which `<lang>\sounds.tbl` to read. The 36 root banks are shared, so they
/// appear either way; the ~2 4005 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)] #[derive(Event, Default)]
pub struct RequestVoiceLibrary; pub struct RequestAudioLibrary;
// ── Game-data browser (decoded IDXD tables) ──────────────────────────────────── // ── Game-data browser (decoded IDXD tables) ────────────────────────────────────
@@ -946,9 +962,9 @@ enum IsoLoaderMsg {
generation: u64, generation: u64,
wav: Option<(PathBuf, f32)>, wav: Option<(PathBuf, f32)>,
}, },
/// The voice-clip library enumerated from `sounds.tbl`. /// The sound-bank library enumerated from `<lang>\sounds.tbl`.
VoiceLibraryLoaded { AudioLibraryLoaded {
clips: Vec<sylpheed_formats::slb::VoiceClip>, entries: Vec<sylpheed_formats::slb::AudioEntry>,
}, },
/// The assembled-ship catalog for the Ships browser. /// The assembled-ship catalog for the Ships browser.
ShipCatalogLoaded(Vec<ShipRow>), ShipCatalogLoaded(Vec<ShipRow>),
@@ -1180,7 +1196,7 @@ impl Plugin for IsoLoaderPlugin {
.init_resource::<MovieSubtitles>() .init_resource::<MovieSubtitles>()
.init_resource::<MovieVoice>() .init_resource::<MovieVoice>()
.init_resource::<AudioPreview>() .init_resource::<AudioPreview>()
.init_resource::<VoiceLibrary>() .init_resource::<AudioLibrary>()
.init_resource::<GameData>() .init_resource::<GameData>()
.init_resource::<ShipBrowser>() .init_resource::<ShipBrowser>()
.init_resource::<ScreenBrowser>() .init_resource::<ScreenBrowser>()
@@ -1188,7 +1204,7 @@ impl Plugin for IsoLoaderPlugin {
.add_event::<RequestSubtitles>() .add_event::<RequestSubtitles>()
.add_event::<RequestVoice>() .add_event::<RequestVoice>()
.add_event::<RequestAudio>() .add_event::<RequestAudio>()
.add_event::<RequestVoiceLibrary>() .add_event::<RequestAudioLibrary>()
.add_event::<RequestGameData>() .add_event::<RequestGameData>()
.add_event::<RequestShipCatalog>() .add_event::<RequestShipCatalog>()
.add_event::<RequestShipRender>() .add_event::<RequestShipRender>()
@@ -1222,7 +1238,7 @@ impl Plugin for IsoLoaderPlugin {
handle_voice_request, handle_voice_request,
handle_audio_request, handle_audio_request,
advance_audio_playback, advance_audio_playback,
handle_voice_library_request, handle_audio_library_request,
handle_game_data_request, handle_game_data_request,
handle_ship_catalog_request, handle_ship_catalog_request,
handle_ship_render_request, handle_ship_render_request,
@@ -2080,7 +2096,7 @@ fn poll_loader_channel(
mut subtitles: ResMut<MovieSubtitles>, mut subtitles: ResMut<MovieSubtitles>,
mut mvoice: ResMut<MovieVoice>, mut mvoice: ResMut<MovieVoice>,
mut audio_preview: ResMut<AudioPreview>, mut audio_preview: ResMut<AudioPreview>,
mut voice_lib: ResMut<VoiceLibrary>, mut audio_lib: ResMut<AudioLibrary>,
mut game_data: ResMut<GameData>, mut game_data: ResMut<GameData>,
mut ships: ResMut<ShipBrowser>, mut ships: ResMut<ShipBrowser>,
mut screens: ResMut<ScreenBrowser>, mut screens: ResMut<ScreenBrowser>,
@@ -2105,7 +2121,7 @@ fn poll_loader_channel(
// A new game source is loaded — invalidate the voice library so the // A new game source is loaded — invalidate the voice library so the
// browser re-reads sounds.tbl from THIS source next time it opens // browser re-reads sounds.tbl from THIS source next time it opens
// (otherwise a stale/failed empty result from before load persists). // (otherwise a stale/failed empty result from before load persists).
*voice_lib = VoiceLibrary::default(); *audio_lib = AudioLibrary::default();
info!("Loaded {} files", browser.files.len()); info!("Loaded {} files", browser.files.len());
} }
Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => { Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => {
@@ -2178,23 +2194,28 @@ fn poll_loader_channel(
match wav { match wav {
Some((p, dur)) => { Some((p, dur)) => {
audio_preview.name = name; audio_preview.name = name;
audio_preview.error = None;
audio_preview.pending = Some((p, dur)); 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 }) => { Ok(IsoLoaderMsg::AudioLibraryLoaded { entries }) => {
voice_lib.loading = false; audio_lib.loading = false;
// sounds.tbl always has thousands of clips, so an empty result means // sounds.tbl always names thousands of banks, so an empty result
// the read failed (e.g. no game source, or opened only a bare pak). // means the read failed (e.g. no game source, or opened only a bare
// Leave `loaded=false` so re-opening the menu retries instead of // pak). Leave `loaded=false` so re-opening the menu retries instead
// latching an empty list forever. // of latching an empty list forever.
if clips.is_empty() { if entries.is_empty() {
voice_lib.loaded = false; audio_lib.loaded = false;
} else { } else {
voice_lib.clips = clips; audio_lib.entries = entries;
voice_lib.loaded = true; audio_lib.loaded = true;
} }
} }
Ok(IsoLoaderMsg::GameDataLoaded(snap)) => { Ok(IsoLoaderMsg::GameDataLoaded(snap)) => {
@@ -2258,7 +2279,7 @@ fn poll_loader_channel(
saves.loading = false; saves.loading = false;
game_data.loading = false; game_data.loading = false;
ships.loading = false; ships.loading = false;
voice_lib.loading = false; audio_lib.loading = false;
iso_state.error = Some("the asset loader stopped responding".into()); iso_state.error = Some("the asset loader stopped responding".into());
break; break;
} }
@@ -3665,6 +3686,7 @@ fn decode_sound_clip(
source: &SourceKind, source: &SourceKind,
clip_name: &str, clip_name: &str,
duration: f32, duration: f32,
mono: bool,
) -> Result<PathBuf, String> { ) -> Result<PathBuf, String> {
use sylpheed_formats::{hash::name_hash, slb}; use sylpheed_formats::{hash::name_hash, slb};
let key = name_hash(clip_name); let key = name_hash(clip_name);
@@ -3683,15 +3705,25 @@ fn decode_sound_clip(
// so the standalone browser can still play them. // so the standalone browser can still play them.
riffs = slb::to_xma_riff_best(&bytes).into_iter().collect(); 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, /// Decode a set of XMA sub-wave RIFFs into one WAV: concatenate them, optionally
/// downmix to mono (left channel holds the signal for dual-mono sources), and /// downmix to mono, and clamp to `duration` seconds. Shared by the per-`.slb`
/// clamp to `duration` seconds. Shared by the per-`.slb` decoder and the /// decoder and the continuous movie-voice decoder.
/// 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"))] #[cfg(not(target_arch = "wasm32"))]
fn decode_riffs_to_wav(riffs: Vec<Vec<u8>>, tag: &str, duration: f32) -> Result<PathBuf, String> { fn decode_riffs_to_wav(
riffs: Vec<Vec<u8>>,
tag: &str,
duration: f32,
mono: bool,
) -> Result<PathBuf, String> {
if riffs.is_empty() { if riffs.is_empty() {
return Err("no decodable audio".into()); return Err("no decodable audio".into());
} }
@@ -3710,9 +3742,10 @@ fn decode_riffs_to_wav(riffs: Vec<Vec<u8>>, tag: &str, duration: f32) -> Result<
600.0 600.0
}; };
let filter = format!( 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::<String>(), (0..inputs.len()).map(|i| format!("[{i}:a]")).collect::<String>(),
inputs.len(), inputs.len(),
if mono { ",pan=mono|c0=c0" } else { "" },
); );
let mut cmd = Command::new("ffmpeg"); let mut cmd = Command::new("ffmpeg");
cmd.args(["-hide_banner", "-y"]); cmd.args(["-hide_banner", "-y"]);
@@ -3855,7 +3888,7 @@ fn decode_voice_region(
if riffs.is_empty() { if riffs.is_empty() {
riffs = slb::to_xma_riff_best(&bytes).into_iter().collect(); 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** /// 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\\") { if clip.contains("\\Movie\\") {
return None; 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:?}"); info!("[voice] movie={movie:?} gen={generation} -> wav={wav:?}");
let _ = sender.send(IsoLoaderMsg::VoiceLoaded { let _ = sender.send(IsoLoaderMsg::VoiceLoaded {
@@ -3991,10 +4024,11 @@ fn handle_audio_request(
}; };
let source = iso_state.source_kind.clone(); let source = iso_state.source_kind.clone();
let sender = channels.sender.clone(); let sender = channels.sender.clone();
let (clip, display, movie, generation) = ( let (clip, display, movie, mono, generation) = (
req.clip.clone(), req.clip.clone(),
req.display.clone(), req.display.clone(),
req.movie.clone(), req.movie.clone(),
req.mono,
req.generation, req.generation,
); );
std::thread::spawn(move || { std::thread::spawn(move || {
@@ -4009,9 +4043,9 @@ fn handle_audio_request(
if c.contains("\\Movie\\") { if c.contains("\\Movie\\") {
return None; 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))); .and_then(|p| wav_duration(&p).map(|d| (p, d)));
let _ = sender.send(IsoLoaderMsg::AudioLoaded { let _ = sender.send(IsoLoaderMsg::AudioLoaded {
@@ -4786,36 +4820,51 @@ fn build_ship_model(
} }
} }
/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and /// Handles a [`RequestAudioLibrary`]: reads `tables.pak`'s `<lang>\sounds.tbl`
/// enumerates the voice clips off-thread. /// and enumerates every named sound bank off-thread.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn handle_voice_library_request( fn handle_audio_library_request(
mut events: EventReader<RequestVoiceLibrary>, mut events: EventReader<RequestAudioLibrary>,
iso_state: Res<IsoState>, iso_state: Res<IsoState>,
channels: Res<IsoChannels>, channels: Res<IsoChannels>,
mut library: ResMut<AudioLibrary>,
) { ) {
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; 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 source = iso_state.source_kind.clone();
let sender = channels.sender.clone(); let sender = channels.sender.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let clips = (|| -> Result<Vec<sylpheed_formats::slb::VoiceClip>, String> { let entries = (|| -> Result<Vec<sylpheed_formats::slb::AudioEntry>, String> {
let pak = read_pak_archive_blocking(&source, "dat/tables.pak")?; let pak = read_pak_archive_blocking(&source, "dat/tables.pak")?;
let name = format!("{}\\sounds.tbl", lang_code(lang));
let tbl = pak let tbl = pak
.read_by_name("eng\\sounds.tbl") .read_by_name(&name)
.ok_or("sounds.tbl not found")? .ok_or(format!("{name} not found"))?
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(sylpheed_formats::slb::list_voice_clips( Ok(sylpheed_formats::slb::list_audio_entries(&tbl, lang))
&tbl,
sylpheed_formats::slb::VoiceLang::English,
))
})() })()
.unwrap_or_default(); .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 /// Drives the standalone audio player: builds/rebuilds its sink, mirrors
/// play/pause + volume + seeks, advances the clock, and stops at the end. /// play/pause + volume + seeks, advances the clock, and stops at the end.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]

View File

@@ -13,9 +13,9 @@ use crate::iso_loader::{
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview, AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir, MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose, RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose,
RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary, SaveBrowser, RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestAudioLibrary, SaveBrowser,
ScreenBrowser, ShipBrowser, SkyboxPreview, TextPreview, TexturePreview, ScreenBrowser, ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
VideoPreview, VoiceLibrary, IsoLoaderSystemSet, VideoPreview, AudioLibrary, IsoLoaderSystemSet,
}; };
use crate::ViewerState; use crate::ViewerState;
use sylpheed_formats::SubLang; use sylpheed_formats::SubLang;
@@ -148,7 +148,7 @@ struct UiEvents<'w> {
file_selected: EventWriter<'w, FileSelected>, file_selected: EventWriter<'w, FileSelected>,
subtitles: EventWriter<'w, RequestSubtitles>, subtitles: EventWriter<'w, RequestSubtitles>,
audio: EventWriter<'w, RequestAudio>, audio: EventWriter<'w, RequestAudio>,
voice_lib: EventWriter<'w, RequestVoiceLibrary>, audio_lib: EventWriter<'w, RequestAudioLibrary>,
game_data: EventWriter<'w, RequestGameData>, game_data: EventWriter<'w, RequestGameData>,
ships: EventWriter<'w, RequestShipCatalog>, ships: EventWriter<'w, RequestShipCatalog>,
screens: EventWriter<'w, RequestScreenCatalog>, screens: EventWriter<'w, RequestScreenCatalog>,
@@ -170,7 +170,7 @@ fn draw_viewer_ui(
mut subtitles: ResMut<MovieSubtitles>, mut subtitles: ResMut<MovieSubtitles>,
mut movie_voice: ResMut<MovieVoice>, mut movie_voice: ResMut<MovieVoice>,
mut audio: ResMut<AudioPreview>, mut audio: ResMut<AudioPreview>,
mut voice_lib: ResMut<VoiceLibrary>, mut audio_lib: ResMut<AudioLibrary>,
mut events: UiEvents, mut events: UiEvents,
) { ) {
let ctx = contexts.ctx_mut(); let ctx = contexts.ctx_mut();
@@ -202,11 +202,11 @@ fn draw_viewer_ui(
}); });
ui.menu_button("View", |ui| { ui.menu_button("View", |ui| {
if ui.button("🎙 Voice Lines").clicked() { if ui.button("🔊 Audio Library").clicked() {
voice_lib.open = true; audio_lib.open = true;
if !voice_lib.loaded && !voice_lib.loading { if !audio_lib.loaded && !audio_lib.loading {
voice_lib.loading = true; audio_lib.loading = true;
events.voice_lib.send_default(); events.audio_lib.send_default();
} }
ui.close_menu(); ui.close_menu();
} }
@@ -248,98 +248,134 @@ fn draw_viewer_ui(
}); });
// ── Standalone voice-line browser (floating window) ─────────────────── // ── Standalone voice-line browser (floating window) ───────────────────
if voice_lib.open { if audio_lib.open {
let mut open = true; let mut open = true;
egui::Window::new("🎙 Voice Lines") egui::Window::new("🔊 Audio Library")
.default_width(360.0) .default_width(400.0)
.default_height(480.0) .default_height(520.0)
.open(&mut open) .open(&mut open)
.show(ctx, |ui| { .show(ctx, |ui| {
if voice_lib.loading { if audio_lib.loading {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.spinner(); ui.spinner();
ui.label("Reading sounds.tbl…"); ui.label("Reading sounds.tbl…");
}); });
ctx.request_repaint(); ctx.request_repaint();
} else if !voice_lib.loaded { } else if !audio_lib.loaded {
ui.label("Open a game source first."); ui.label("Open a game source first.");
} else { } else {
use sylpheed_formats::slb::VoiceLang;
ui.horizontal(|ui| { 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.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(); let f = audio_lib.filter.to_lowercase();
// Group the thousands of entries as directory → speaker so the // Group category → speaker. The category comes from the path
// list is navigable (e.g. browse `Voice` by character to find a // shape, so the root banks (music/jingles/SFX) get real
// cutscene's radio line). `name` is `<lang>\<dir>\<file>.slb`. // headings instead of the "?" a directory split gave them.
use std::collections::BTreeMap; use std::collections::BTreeMap;
let mut groups: BTreeMap<&str, BTreeMap<&str, Vec<&sylpheed_formats::slb::VoiceClip>>> = type Group<'a> = BTreeMap<&'a str, Vec<&'a sylpheed_formats::slb::AudioEntry>>;
BTreeMap::new(); let mut groups: BTreeMap<
for c in &voice_lib.clips { sylpheed_formats::slb::AudioCategory,
if !f.is_empty() && !c.name.to_lowercase().contains(&f) { Group<'_>,
> = BTreeMap::new();
for e in &audio_lib.entries {
if !f.is_empty() && !e.clip.name.to_lowercase().contains(&f) {
continue; continue;
} }
let dir = c.name.rsplit('\\').nth(1).unwrap_or("?");
groups groups
.entry(dir) .entry(e.category)
.or_default() .or_default()
.entry(c.speaker.as_str()) .entry(e.clip.speaker.as_str())
.or_default() .or_default()
.push(c); .push(e);
} }
let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum(); let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum();
ui.label( ui.label(
egui::RichText::new(format!("{shown} / {} clips", voice_lib.clips.len())) egui::RichText::new(format!(
.weak() "{shown} / {} banks",
.small(), audio_lib.entries.len()
))
.weak()
.small(),
); );
ui.separator(); ui.separator();
let filtering = !f.is_empty(); let filtering = !f.is_empty();
egui::ScrollArea::vertical().show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| {
for (dir, speakers) in &groups { for (cat, speakers) in &groups {
let dtotal: usize = speakers.values().map(Vec::len).sum(); let ctotal: usize = speakers.values().map(Vec::len).sum();
egui::CollapsingHeader::new(format!("📁 {dir} ({dtotal})")) egui::CollapsingHeader::new(format!(
.id_salt(("vdir", *dir)) "{} ({ctotal})",
.default_open(filtering) cat.label()
.show(ui, |ui| { ))
for (speaker, clips) in speakers { .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!( egui::CollapsingHeader::new(format!(
"{speaker} ({})", "{speaker} ({})",
clips.len() entries.len()
)) ))
.id_salt(("vspk", *dir, *speaker)) .id_salt(("aspk", *cat, *speaker))
.default_open(filtering || clips.len() <= 6) .default_open(filtering || entries.len() <= 6)
.show(ui, |ui| { .show(ui, &mut row);
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);
});
}
});
} }
}); }
});
} }
}); });
} }
}); });
voice_lib.open = open; audio_lib.open = open;
} }
// ── Left panel: file browser ────────────────────────────────────────── // ── Left panel: file browser ──────────────────────────────────────────
@@ -481,11 +517,13 @@ fn draw_viewer_ui(
audio.generation = audio.generation.wrapping_add(1); audio.generation = audio.generation.wrapping_add(1);
audio.loading = true; audio.loading = true;
audio.active = true; // show the panel immediately (spinner) audio.active = true; // show the panel immediately (spinner)
audio.error = None;
audio.name = format!("VOICE_{movie}"); audio.name = format!("VOICE_{movie}");
events.audio.send(RequestAudio { events.audio.send(RequestAudio {
clip: String::new(), clip: String::new(),
display: format!("VOICE_{movie}"), display: format!("VOICE_{movie}"),
movie: Some((movie.clone(), movie_voice.lang)), movie: Some((movie.clone(), movie_voice.lang)),
mono: true, // a cutscene voice track
generation: audio.generation, generation: audio.generation,
}); });
} }
@@ -1348,8 +1386,10 @@ fn draw_audio_player(ui: &mut egui::Ui, audio: &mut AudioPreview) {
if audio.loading { if audio.loading {
ui.spinner(); ui.spinner();
ui.label("decoding…"); ui.label("decoding…");
} else if let Some(err) = &audio.error {
ui.colored_label(egui::Color32::from_rgb(224, 86, 122), format!("{err}"));
} else { } else {
ui.label("voice track"); ui.label("sound bank");
} }
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("✖ Close").clicked() { if ui.button("✖ Close").clicked() {

View File

@@ -149,3 +149,63 @@ into those five shapes.
Two incidental facts fall out: **4 banks run at 44 100 Hz** where everything else 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. 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 75555 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.