Files
Syplheed-Reborn/crates/sylpheed-formats/src/pak.rs
MechaCat02 95de29f290 feat(formats,viewer): movie subtitles, voice decode, and the movie manifest
The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE
manifest (the authoritative movie -> subtitle -> voice index). Also flushes
several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship
decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md.

Subtitles (movie_subtitle.rs):
- Full movie->track->text chain; join multi-line captions sharing one timing
  (fixes S13A dropped "Look at it father" line); overlap-safe active_cues();
  Latin-1 accents preserved.

Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its
declared data size (fixes S10-S16 alternate-take garble); list_voice_clips.

Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real
movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\).
Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the
demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game
and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem).

Viewer: manifest-driven voice (movie player toggle + solo button), standalone
"Voice Lines" browser, stacked caption overlay.

Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc
tests; full workspace green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:15:41 +02:00

352 lines
13 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,
})
}
/// 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)
}
/// 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());
}
}