//! 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, data: Vec, } 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) -> Result { 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) -> Result { 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, }) } /// 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). 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(), }) } /// 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, 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, 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, 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, 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" 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()); } }