Files
Sylpheed/crates/sylpheed-formats/src/pak.rs
Sylpheed RE agent 761e91de96 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>
2026-08-28 16:28:20 +02:00

381 lines
14 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! IPFB archive reader (`*.pak` table-of-contents + `*.p00`/`*.pNN` data segments).
//!
//! Project Sylpheed packs almost all of its data into a custom archive format.
//! Each archive is a pair (or set): a `*.pak` index and one or more `*.pNN`
//! data segments (`.p00`, `.p01`, … concatenated in order).
//!
//! ## `.pak` layout (all fields big-endian)
//!
//! ```text
//! Offset Size Field
//! 0x00 4 Magic: "IPFB"
//! 0x04 4 entry_count
//! 0x08 4 block_size (always 0x800 in retail — the data-offset alignment unit)
//! 0x0C 4 flags (always 0x1000_0000 in retail; purpose unconfirmed)
//! 0x10 12*n TOC: n × { u32 name_hash, u32 offset, u32 comp_size }
//! ```
//!
//! - `name_hash` — the TOC is **sorted ascending by `name_hash`** (binary-searchable).
//! The hash is a custom Barrett-reduced polynomial over the **lowercased** path,
//! recovered from the retail title — see [`crate::hash`]. Look up entries by their
//! original path with [`PakArchive::find_by_name`] / [`PakArchive::read_by_name`]
//! (retail paths use backslash separators, e.g. `eng\weapon.tbl`).
//! - `offset` — byte offset into the **concatenated** `.pNN` segments, `block_size`-aligned.
//! - `comp_size` — stored (compressed) size of the entry.
//!
//! ## Entry payload — the `"Z1"` container
//!
//! Each stored entry is:
//!
//! ```text
//! Offset Size Field
//! 0x00 2 Magic: "Z1"
//! 0x02 4 uncompressed_size (big-endian)
//! 0x06 4 aux (checksum/adler-like; not validated here)
//! 0x0A .. zlib stream (RFC 1950; 0x78 0xDA / 0x9C …)
//! ```
//!
//! [`PakArchive::read`] decompresses this transparently. A handful of entries in
//! some archives are stored raw (no `"Z1"` header); those are returned verbatim.
use std::io::Read;
use std::path::{Path, PathBuf};
use flate2::read::ZlibDecoder;
use thiserror::Error;
/// Magic at the start of every `.pak` index file.
pub const IPFB_MAGIC: [u8; 4] = *b"IPFB";
/// Magic at the start of every compressed entry payload.
pub const Z1_MAGIC: [u8; 2] = *b"Z1";
#[derive(Debug, Error)]
pub enum PakError {
#[error("bad IPFB magic: expected {:?}, got {got:?}", IPFB_MAGIC)]
BadMagic { got: [u8; 4] },
#[error("truncated .pak: need {needed} bytes, have {have}")]
Truncated { needed: usize, have: usize },
#[error("entry offset 0x{offset:x}+{size} runs past segment data ({data_len} bytes)")]
OffsetOutOfRange {
offset: u32,
size: u32,
data_len: usize,
},
#[error("no data segments (.p00, .p01, …) found next to {0}")]
NoSegments(PathBuf),
#[error("zlib decompression failed for entry at 0x{offset:x}: {source}")]
Inflate {
offset: u32,
#[source]
source: std::io::Error,
},
#[error("io error reading {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
}
/// One table-of-contents record.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PakEntry {
/// Custom hash of the entry's original path (unknown preimage — see module docs).
pub name_hash: u32,
/// Byte offset into the concatenated segment data, `block_size`-aligned.
pub offset: u32,
/// Stored (compressed) byte length.
pub comp_size: u32,
}
/// A parsed IPFB archive: its TOC plus the concatenated segment bytes.
pub struct PakArchive {
/// `block_size` header field (data-offset alignment; 0x800 in retail).
pub block_size: u32,
/// `flags` header field (0x1000_0000 in retail).
pub flags: u32,
entries: Vec<PakEntry>,
data: Vec<u8>,
}
impl PakArchive {
/// Open an archive from a `.pak` path, auto-loading the sibling `.p00`/`.pNN`
/// segments (concatenated in numeric order).
pub fn open(pak_path: impl AsRef<Path>) -> Result<Self, PakError> {
let pak_path = pak_path.as_ref();
let index = std::fs::read(pak_path).map_err(|e| PakError::Io {
path: pak_path.display().to_string(),
source: e,
})?;
// Segments share the stem, with extension .p00, .p01, … . Load in order
// until the first gap. `foo.pak` → `foo.p00`, `foo.p01`, …
let base = pak_path.with_extension("");
let mut data = Vec::new();
for i in 0..100u32 {
let seg = base.with_extension(format!("p{i:02}"));
if !seg.exists() {
break;
}
let mut bytes = std::fs::read(&seg).map_err(|e| PakError::Io {
path: seg.display().to_string(),
source: e,
})?;
data.append(&mut bytes);
}
if data.is_empty() {
return Err(PakError::NoSegments(pak_path.to_path_buf()));
}
Self::from_parts(&index, data)
}
/// Parse from already-loaded bytes (the `.pak` index and the concatenated
/// segment data). Useful for tests and WASM, where files arrive over HTTP.
pub fn from_parts(index: &[u8], data: Vec<u8>) -> Result<Self, PakError> {
if index.len() < 16 {
return Err(PakError::Truncated {
needed: 16,
have: index.len(),
});
}
let magic: [u8; 4] = index[0..4].try_into().unwrap();
if magic != IPFB_MAGIC {
return Err(PakError::BadMagic { got: magic });
}
let entry_count = be32(index, 4) as usize;
let block_size = be32(index, 8);
let flags = be32(index, 12);
let toc_len = 16 + entry_count * 12;
if index.len() < toc_len {
return Err(PakError::Truncated {
needed: toc_len,
have: index.len(),
});
}
let mut entries = Vec::with_capacity(entry_count);
for i in 0..entry_count {
let o = 16 + i * 12;
entries.push(PakEntry {
name_hash: be32(index, o),
offset: be32(index, o + 4),
comp_size: be32(index, o + 8),
});
}
Ok(Self {
block_size,
flags,
entries,
data,
})
}
/// Parse **only** the TOC from a `.pak` index, without any segment data.
///
/// For huge archives (`sound.pak` is ~1 GB across `.p00..p04`) this lets a
/// caller locate one entry's `(offset, size)` and read just that byte range
/// from the segments, instead of loading the whole thing into memory. The
/// returned entries are sorted by `name_hash` (binary-searchable).
pub fn parse_toc(index: &[u8]) -> Result<Vec<PakEntry>, PakError> {
// Reuse from_parts' validation with an empty data blob; the entries are
// independent of the data.
Ok(Self::from_parts(index, Vec::new())?.entries)
}
/// A raw slice of the concatenated segment data, by absolute offset.
///
/// Entry windows are **not** wave boundaries in `sound.pak`: a `.slb` wave
/// runs to `data_at + declared_size`, which routinely overruns the entry's
/// own `comp_size` (see docs/re/structures/slb-data-offset.md). Reading the
/// boundary marker therefore needs the flat stream, not the entry slice.
/// Returns `None` if the range falls outside the loaded data.
pub fn data_at(&self, offset: usize, len: usize) -> Option<&[u8]> {
self.data.get(offset..offset.checked_add(len)?)
}
/// All TOC entries, in stored order (ascending `name_hash`).
pub fn entries(&self) -> &[PakEntry] {
&self.entries
}
/// Number of entries.
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the archive has no entries.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Look up an entry by its `name_hash` (binary search; TOC is sorted).
pub fn find(&self, name_hash: u32) -> Option<&PakEntry> {
self.entries
.binary_search_by_key(&name_hash, |e| e.name_hash)
.ok()
.map(|i| &self.entries[i])
}
/// 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;
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
/// (zlib); entries stored without a `"Z1"` header are returned verbatim.
pub fn read(&self, entry: &PakEntry) -> Result<Vec<u8>, PakError> {
let stored = self.stored_bytes(entry)?;
decompress_entry(stored, entry.offset)
}
/// Convenience: [`find`](Self::find) + [`read`](Self::read) by `name_hash`.
pub fn read_by_hash(&self, name_hash: u32) -> Option<Result<Vec<u8>, PakError>> {
self.find(name_hash).map(|e| self.read(e))
}
/// Look up an entry by its original path, hashing it the way the game does
/// ([`crate::hash::name_hash`]). Names are case-insensitive; path separators
/// must be **backslash** to match the on-disc form, e.g. `eng\weapon.tbl`.
pub fn find_by_name(&self, name: &str) -> Option<&PakEntry> {
self.find(crate::hash::name_hash(name))
}
/// Convenience: [`find_by_name`](Self::find_by_name) + [`read`](Self::read).
pub fn read_by_name(&self, name: &str) -> Option<Result<Vec<u8>, PakError>> {
self.find_by_name(name).map(|e| self.read(e))
}
}
/// Decompress a single stored entry payload (the `"Z1"` container, or raw).
pub fn decompress_entry(stored: &[u8], offset_for_err: u32) -> Result<Vec<u8>, PakError> {
if stored.len() >= 10 && stored[0..2] == Z1_MAGIC {
let usize_hint = be32(stored, 2) as usize;
let mut out = Vec::with_capacity(usize_hint.min(64 * 1024 * 1024));
ZlibDecoder::new(&stored[10..])
.read_to_end(&mut out)
.map_err(|e| PakError::Inflate {
offset: offset_for_err,
source: e,
})?;
Ok(out)
} else {
// Rare: entry stored uncompressed.
Ok(stored.to_vec())
}
}
#[inline]
fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
/// Best-effort human label for a decompressed entry's inner format, from its
/// first bytes: a known signature (`IDXD`, fonts, `png`, …), a printable 4-char
/// tag (RATC, IXUD, LSTA, …), or a hex fallback for still-unidentified magics.
/// Shared by the CLI `pak list` and the GUI pack browser.
pub fn inner_format_label(payload: &[u8]) -> String {
if payload.len() < 4 {
return "empty".into();
}
let m = &payload[0..4];
// Known binary/text signatures → friendly short tags (so fonts/images don't
// show as bare hex like `00010000` / `89504E47`).
let known = match m {
b"IDXD" => Some("IDXD"),
b"\x89PNG" => Some("png"),
b"\x00\x01\x00\x00" | b"true" | b"typ1" => Some("ttf"), // sfnt TrueType
b"OTTO" => Some("otf"), // OpenType (CFF)
b"ttcf" => Some("ttc"), // TrueType Collection
b"RIFF" => Some("riff"),
b"DDS " => Some("dds"),
_ if payload.starts_with(b"<?xml") => Some("xml"),
_ => None,
};
if let Some(tag) = known {
return tag.into();
}
if m.iter().all(|&b| b.is_ascii_graphic()) {
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, …).
String::from_utf8_lossy(m).into_owned()
} else {
format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_synthetic_toc() {
// header: IPFB, count=1, block=0x800, flags=0x10000000
let mut idx = Vec::new();
idx.extend_from_slice(&IPFB_MAGIC);
idx.extend_from_slice(&1u32.to_be_bytes());
idx.extend_from_slice(&0x800u32.to_be_bytes());
idx.extend_from_slice(&0x1000_0000u32.to_be_bytes());
// one entry: hash=0xAABBCCDD, offset=0, size=len(payload)
// payload: "Z1" + usize + aux + zlib("hello")
let mut zl = Vec::new();
{
use flate2::{write::ZlibEncoder, Compression};
use std::io::Write;
let mut enc = ZlibEncoder::new(&mut zl, Compression::default());
enc.write_all(b"hello").unwrap();
enc.finish().unwrap();
}
let mut payload = Vec::new();
payload.extend_from_slice(&Z1_MAGIC);
payload.extend_from_slice(&5u32.to_be_bytes()); // uncompressed size
payload.extend_from_slice(&0u32.to_be_bytes()); // aux
payload.extend_from_slice(&zl);
idx.extend_from_slice(&0xAABB_CCDDu32.to_be_bytes());
idx.extend_from_slice(&0u32.to_be_bytes());
idx.extend_from_slice(&(payload.len() as u32).to_be_bytes());
let arc = PakArchive::from_parts(&idx, payload).unwrap();
assert_eq!(arc.len(), 1);
assert_eq!(arc.block_size, 0x800);
let e = arc.find(0xAABB_CCDD).unwrap();
assert_eq!(arc.read(e).unwrap(), b"hello");
assert!(arc.find(0x1234_5678).is_none());
}
}