Files
Syplheed-Reborn/crates/sylpheed-formats/src/pak.rs
MechaCat02 10425aba8c feat(formats): name font/image inner formats instead of hex
inner_format_label mapped only IDXD/xml/printable-tag magics; fonts and
images fell through to a bare hex label (00010000, 89504E47, …). Recognize
the known binary signatures and return friendly short tags so the pak
browser's format column reads them: sfnt/true/typ1 → "ttf", OTTO → "otf",
ttcf → "ttc", PNG → "png", plus RIFF/DDS. Still-unidentified magics keep the
hex fallback. Shared by the CLI `pak list` and the GUI browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:24:26 +02:00

340 lines
12 KiB
Rust
Raw 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,
})
}
/// 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<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());
}
}