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