This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/crates/sylpheed-formats/src/hash.rs
MechaCat02 5dd526c1de feat(formats,cli): recover entry TOC paths from IDXD identity
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>
2026-07-09 21:56:42 +02:00

153 lines
6.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}