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:
MechaCat02
2026-07-09 21:27:07 +02:00
parent 84dd806f5b
commit ac340b9219
4 changed files with 145 additions and 4 deletions

View 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 AZ; 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);
}
}

View File

@@ -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;

View File

@@ -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).

View File

@@ -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);
}