Files
Syplheed-Reborn/crates/sylpheed-formats/src/pak.rs
MechaCat02 fbd4550d62 feat(viewer): data pack (.pak / IDXD) browser
Roadmap #3. The explorer can now open IPFB data packs — the game's ship/weapon/
mission definition tables. Selecting a `.pak` shows a master-detail browser:
entry list (hash · inner-format · identity) on the left, the selected IDXD
object's schema + explicit-field property table on the right.

Load path: a `.pak` selection reads the index plus its sibling `.pNN` segments
(ISO reader or extracted dir, probing p00.. until the first gap), assembles via
PakArchive::from_parts, and builds owned PakRow/PakDetail entirely off-thread —
so the UI holds only plain data (no borrow of a PakArchive, WASM-safe). Two
decode caps (64 MiB cumulative, 16 MiB/entry) keep a huge sound.pak from hanging;
over-budget entries show as "(not decoded)".

Wiring mirrors the texture path: new IsoLoaderMsg::PakLoaded → PendingPak staging
→ apply_pak. A shared reset (free GPU handle + clear texture/text/pak previews)
runs in both apply systems, so exactly one viewer is active per selection and the
prior GPU texture is always freed when switching modes.

Reuse over duplication: the CLI's inner_label / idxd_identity move into the
formats crate as pak::inner_format_label and IdxdObject::identity (bodies
verbatim → CLI output unchanged), now shared by CLI and GUI.

PakView registered unconditionally (wasm-safe; population native-only). ViewMode
unchanged — the central panel dispatches on the populated resource. Workspace
builds; 22 formats tests + all crate tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:17:10 +02:00

325 lines
11 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: `IDXD`, `xml`, a printable 4-char tag (RATC, IXUD, LSTA, …), or
/// a hex fallback. 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];
if m == b"IDXD" {
"IDXD".into()
} else if payload.starts_with(b"<?xml") {
"xml".into()
} else if m.iter().all(|&b| b.is_ascii_graphic()) {
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, ttcf, …).
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());
}
}