diff --git a/crates/sylpheed-formats/src/hash.rs b/crates/sylpheed-formats/src/hash.rs new file mode 100644 index 0000000..3f8b4e5 --- /dev/null +++ b/crates/sylpheed-formats/src/hash.rs @@ -0,0 +1,101 @@ +//! IPFB TOC name-hash — the algorithm the game uses to key archive entries. +//! +//! Recovered by static RE of the retail title (`default.xex`): the pak +//! lookup-by-name routine `sub_824609C8` duplicates the requested path, +//! lowercases it (`sub_825F4F90`, ASCII `A`–`Z` → `+0x20`), hashes the +//! lowercased bytes with `sub_82455C78`, then binary-searches the sorted TOC +//! for the resulting 32-bit key. +//! +//! `sub_82455C78` is a per-byte Barrett-reduced polynomial hash: +//! +//! ```text +//! A = 0 ; B = 0 +//! for each (sign-extended) byte c of the lowercased name: +//! A = ((A << 8) + c) // 32-bit +//! A = A - (((A * 0x8003_1493) >> 32) rol 9 & 0x1FF) * 0x00FF_F9D7 // A mod 0x00FF_F9D7 +//! B = B + c +//! hash = ((B & 0xFF) << 24) | (A & 0x00FF_FFFF) +//! ``` +//! +//! i.e. the low 24 bits are a modular polynomial hash and the top byte is an +//! 8-bit additive checksum of the bytes. The reduction constant `0x8003_1493` +//! is the reciprocal of the modulus `0x00FF_F9D7` used by the `mulhwu`/`mullw` +//! Barrett step; there is **no** trailing conditional subtract, so the value is +//! defined by the exact op sequence (faithfully reproduced below), not by a +//! textbook `%`. +//! +//! Verified against the real disc: `name_hash("files.tbl") == 0x8342_1153` +//! and `name_hash("eng\\weapon.tbl") == 0x900C_8DCD`, both of which are present +//! in the retail TOCs. + +/// Barrett modulus used by the low-24-bit polynomial hash. +const MODULUS: u32 = 0x00FF_F9D7; +/// Reciprocal of [`MODULUS`] used by the `mulhwu` Barrett step. +const RECIP: u32 = 0x8003_1493; + +/// Hash already-lowercased bytes with the raw `sub_82455C78` algorithm. +/// +/// Callers normally want [`name_hash`], which lowercases first (the game always +/// lowercases the path before hashing). This lower-level entry point exists for +/// tests and for inputs that are known to already be lowercase. +pub fn name_hash_raw(bytes: &[u8]) -> u32 { + let mut a: u32 = 0; + let mut b: u32 = 0; + for &byte in bytes { + // `extsb` — the guest sign-extends each byte before use. + let c = byte as i8 as i32 as u32; + // A = (A << 8) + c (the guest rotates then masks off the top byte, + // but A < MODULUS < 2^24 always, so this is exactly a logical `<< 8`). + a = (a.rotate_left(8) & 0xFFFF_FF00).wrapping_add(c); + b = b.wrapping_add(c); + // A -= high9(A * RECIP) * MODULUS (Barrett reduction, no final fixup) + let hi = ((a as u64 * RECIP as u64) >> 32) as u32; + let q = hi.rotate_left(9) & 0x1FF; + a = a.wrapping_sub(q.wrapping_mul(MODULUS)); + } + ((b << 24) & 0xFF00_0000) | (a & 0x00FF_FFFF) +} + +/// Compute the IPFB TOC name-hash for a path, matching the game exactly. +/// +/// ASCII `A`–`Z` are lowercased first (as the game does). Path separators are +/// significant and must match the on-disc form — retail names use **backslash** +/// (`eng\weapon.tbl`), not forward slash. +pub fn name_hash(name: &str) -> u32 { + // ASCII lowercase, byte for byte (the guest only maps A–Z; other bytes pass + // through untouched, including any high-bit bytes, which are then sign-extended). + let mut bytes = name.as_bytes().to_vec(); + for byte in &mut bytes { + if (b'A'..=b'Z').contains(byte) { + *byte += 0x20; + } + } + name_hash_raw(&bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_known_disc_hashes() { + // Confirmed present in retail TOCs (see module docs). + assert_eq!(name_hash("files.tbl"), 0x8342_1153); + assert_eq!(name_hash("eng\\weapon.tbl"), 0x900C_8DCD); + assert_eq!(name_hash("eng\\strings.tbl"), 0x10C8_0B87); + assert_eq!(name_hash("jpn\\weapon.tbl"), 0x9E85_FEFF); + } + + #[test] + fn lowercases_like_the_game() { + assert_eq!(name_hash("FILES.TBL"), name_hash("files.tbl")); + assert_eq!(name_hash("Eng\\Weapon.TBL"), name_hash("eng\\weapon.tbl")); + } + + #[test] + fn empty_name_is_zero() { + // sub_82455C78 returns its input unchanged for empty strings; with A=B=0 + // the composed value is 0. + assert_eq!(name_hash(""), 0); + } +} diff --git a/crates/sylpheed-formats/src/lib.rs b/crates/sylpheed-formats/src/lib.rs index a6320b5..cddb4f0 100644 --- a/crates/sylpheed-formats/src/lib.rs +++ b/crates/sylpheed-formats/src/lib.rs @@ -16,6 +16,9 @@ pub mod texture; pub mod vfs; +// IPFB TOC name-hash (path → 32-bit key), recovered from the retail title +pub mod hash; + // IPFB archive (`*.pak` index + `*.pNN` segments) reader pub mod pak; diff --git a/crates/sylpheed-formats/src/pak.rs b/crates/sylpheed-formats/src/pak.rs index cc25d8a..bc02007 100644 --- a/crates/sylpheed-formats/src/pak.rs +++ b/crates/sylpheed-formats/src/pak.rs @@ -16,10 +16,10 @@ //! ``` //! //! - `name_hash` — the TOC is **sorted ascending by `name_hash`** (binary-searchable). -//! The hash is a **custom/unknown algorithm** (crc32/fnv/djb2/sdbm/elf/jenkins all -//! ruled out), so entries cannot yet be looked up by their original string path. -//! In practice most payloads are self-describing (see [`crate::idxd`]), so lookup -//! by content is usually preferable anyway. +//! 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. //! @@ -225,6 +225,18 @@ impl PakArchive { 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). diff --git a/crates/sylpheed-formats/tests/pak_idxd_disc.rs b/crates/sylpheed-formats/tests/pak_idxd_disc.rs index 7731a18..100582d 100644 --- a/crates/sylpheed-formats/tests/pak_idxd_disc.rs +++ b/crates/sylpheed-formats/tests/pak_idxd_disc.rs @@ -97,3 +97,28 @@ fn dsaber_missile_weapon_stats() { assert_eq!(obj.get_i64("TriggerShotCount"), Some(12)); assert_eq!(obj.get_f32("MaximumRange"), Some(10000.0)); } + +/// The recovered TOC name-hash resolves real entries by their original path. +#[test] +fn name_lookup_resolves_weapon_tbl() { + skip_without_disc!(root); + let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap(); + + // eng\weapon.tbl is present and hashes to the retail TOC key. + let entry = arc + .find_by_name("eng\\weapon.tbl") + .expect("eng\\weapon.tbl must resolve by name"); + assert_eq!(entry.name_hash, 0x900C_8DCD); + + // Case-insensitive, matching the game's lowercasing. + assert_eq!( + arc.find_by_name("ENG\\Weapon.TBL").map(|e| e.name_hash), + Some(0x900C_8DCD) + ); + + // The payload decompresses to a self-describing IDXD table. + let bytes = arc.read_by_name("eng\\weapon.tbl").unwrap().unwrap(); + assert_eq!(&bytes[..4], b"IDXD"); + let obj = IdxdObject::parse(&bytes).expect("weapon.tbl parses as IDXD"); + assert!(obj.count > 0); +}