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:
@@ -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
|
||||
|
||||
@@ -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 `<lang>\{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<VoiceClip> {
|
||||
/// 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 `<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 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<VoiceClip> {
|
||||
}
|
||||
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<NN>_<MM>.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<VoiceClip> {
|
||||
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 {
|
||||
// `<lang>\<cat>\VOICE_<SPK>_<NNN>.slb` or `..\VOICE_<movie>.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 `<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]
|
||||
fn rebuilds_riff_from_headerless() {
|
||||
let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET];
|
||||
|
||||
Reference in New Issue
Block a user