Add hash::recover_toc_name + TOC_NAME_SCHEMES (confirmed path schemes: unit\<ID>.tbl, weapon\<ID>.tbl, message\<ID>.tbl, effect\<ID>.tbl, <name>.tbl) that reproduce an entry's original backslash path from its internal identity string via the recovered name-hash. `pak list` now extracts identifier candidates from each IDXD entry (ID/Name/Model + pool tokens) and prints the resolved path when a scheme matches — e.g. unit\UN_f001_TCAF_DeltaSaber_T.tbl, weapon\Weapon_…_Missile.tbl, message\CharacterCARL.tbl — plus a name-resolved count. 308/1004 resolved on GP_MAIN_GAME_E; the remainder use deeper cross-referenced paths (deferred). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
153 lines
6.3 KiB
Rust
153 lines
6.3 KiB
Rust
//! 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)
|
||
}
|
||
|
||
/// Confirmed TOC key path schemes, as `(prefix, suffix)` around an entry's
|
||
/// internal identity string. The key is `name_hash("<prefix><id><suffix>")`
|
||
/// (backslash separators). Recovered by RE — see `xenia-rs/RE_SYMBOLS.md`.
|
||
///
|
||
/// - `("", ".tbl")` — root manifests / DefTables resource entries (`files.tbl`).
|
||
/// - `("unit\\", ".tbl")` — craft/ship definitions (`unit\UN_f001_…_EX5.tbl`).
|
||
/// - `("weapon\\", ".tbl")` — weapon definitions (`weapon\Weapon_…_Missile.tbl`).
|
||
/// - `("message\\", ".tbl")` — character/dialog entries (`message\CharacterCARL.tbl`).
|
||
/// - `("effect\\", ".tbl")` — effect definitions.
|
||
///
|
||
/// Not every entry type is covered yet (some use deeper, cross-referenced
|
||
/// directory paths, e.g. per-mission dialog); those return `None`.
|
||
pub const TOC_NAME_SCHEMES: &[(&str, &str)] = &[
|
||
("", ".tbl"),
|
||
("unit\\", ".tbl"),
|
||
("weapon\\", ".tbl"),
|
||
("message\\", ".tbl"),
|
||
("effect\\", ".tbl"),
|
||
];
|
||
|
||
/// Try to recover an entry's original path from its TOC `name_hash` and a set of
|
||
/// candidate identity strings (e.g. identifier-like tokens from an IDXD string
|
||
/// pool). Returns the first `<scheme>(id)` whose hash matches, or `None`.
|
||
pub fn recover_toc_name<S: AsRef<str>>(entry_hash: u32, id_candidates: &[S]) -> Option<String> {
|
||
for id in id_candidates {
|
||
let id = id.as_ref();
|
||
for (prefix, suffix) in TOC_NAME_SCHEMES {
|
||
let name = format!("{prefix}{id}{suffix}");
|
||
if name_hash(&name) == entry_hash {
|
||
return Some(name);
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn recovers_names_via_schemes() {
|
||
// Craft entry (unit\<ID>.tbl) and weapon entry, verified on the real disc.
|
||
assert_eq!(
|
||
recover_toc_name(0x7C96_296C, &["UN_f001_TCAF_DeltaSaber_T_EX5"]),
|
||
Some("unit\\UN_f001_TCAF_DeltaSaber_T_EX5.tbl".to_string())
|
||
);
|
||
// First matching candidate wins; non-matching candidates are skipped.
|
||
assert_eq!(
|
||
recover_toc_name(0x7C96_296C, &["nope", "UN_f001_TCAF_DeltaSaber_T_EX5"]),
|
||
Some("unit\\UN_f001_TCAF_DeltaSaber_T_EX5.tbl".to_string())
|
||
);
|
||
assert_eq!(recover_toc_name(0x7C96_296C, &["unrelated"]), None);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|