feat(formats): recover IPFB TOC name-hash; look up entries by path
The pak TOC key was previously an unknown hash (crc32/fnv/djb2/… ruled
out), forcing content-only lookup. Recovered it by static RE of the
retail title:
sub_824609C8 (pak lookup-by-name) dups the requested path, lowercases
it (sub_825F4F90), hashes with sub_82455C78, then binary-searches the
sorted TOC. sub_82455C78 is a per-byte Barrett-reduced polynomial hash
(modulus 0x00FFF9D7, reciprocal 0x80031493): the low 24 bits are the
modular hash, the top byte an 8-bit additive checksum of the bytes.
New `hash` module reproduces it exactly (faithful op sequence, no
textbook %). Verified against the real disc: name_hash("files.tbl") ==
0x83421153 and name_hash("eng\\weapon.tbl") == 0x900C8DCD, both present
in retail TOCs. Add PakArchive::find_by_name / read_by_name and a disc
integration test resolving eng\weapon.tbl by path (→ valid IDXD).
Note: retail paths use backslash separators (eng\weapon.tbl).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
101
crates/sylpheed-formats/src/hash.rs
Normal file
101
crates/sylpheed-formats/src/hash.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user