Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,10 @@ use ltk_mimir_cache::{HashStore, Table};
let store = HashStore::discover()?;

// Missing tables are reported, not fatal - the tool stays usable and their hashes miss.
let (mut db, errors) = store.open_layered(&[Table::Game, Table::Lcu]);
// The call itself only fails if you ask for tables from different hash universes.
let (mut db, errors) = store.open_layered(&[Table::Game, Table::Lcu])?;
for (table, e) in &errors {
eprintln!("skipping {table:?}: {e}");
eprintln!("skipping {table}: {e}");
}

// Register a path your mod introduced; it is hashed with the first base's algorithm.
Expand All @@ -148,8 +149,10 @@ assert_eq!(db.get(hash).as_deref(), Some("assets/mymod/custom.dds"));

> [!NOTE]
> Every base must agree on key width, hash algorithm, and casing, because lookups take a
> hash the caller already computed and no base re-hashes it. `game` and `lcu` do; the four
> 32-bit `bin*` tables are separate hash universes and must not be layered together.
> hash the caller already computed and no base re-hashes it - `push_base` returns a
> `KeyConfigMismatch` rather than layering one that doesn't. `game` and `lcu` agree; the
> four 32-bit `bin*` tables agree too, and still must not be layered, because they are
> separate hash *universes* - so `open_layered` refuses that set outright.

### Enumerating a table

Expand Down Expand Up @@ -193,7 +196,7 @@ use ltk_hashdb::{Casing, Compression, HashDbWriter, HashKind, KeyWidth};

let mut writer = HashDbWriter::new(KeyWidth::U64, Compression::default())
.hash_kind(HashKind::Xxh64) // recorded, so readers can hash new paths
.casing(Casing::Insensitive); // League tables hash the lowercased path
.casing(Casing::AsciiInsensitive); // League tables hash the ASCII-lowercased path

writer.insert(hash, "assets/characters/ahri/ahri.bin");
writer.extend(pairs);
Expand Down Expand Up @@ -244,15 +247,24 @@ frame cache. `Send + Sync`.
| `discover` · `at` | resolve the platform cache dir, or point at your own |
| `open_shared` | open the active version, reusing a handle this store already has |
| `open` · `open_many` | open a fresh mapping, one table or several |
| `open_layered` | open several into one `LayeredHashDb`, reporting per-table errors |
| `open_layered` | open several of one universe into a `LayeredHashDb`, reporting per-table errors |
| `manifest` · `path_for` | what is installed, and where |
| `update` · `update_async` | compare → download → verify → install → GC |
| `commit` · `gc` · `try_lock_update` | publish versions, sweep old ones, take the lock |

**`Table`** - which logical table, and how it hashes.

| Method | |
|---|---|
| `ALL` · `id` · `Display` · `FromStr` · serde | the stable spellings (`game`, `binentries`, `rst-xxh3`) |
| `key_config` · `key_width` · `hash_kind` · `casing` | how this table's keys were produced |
| `universe` | which hashes it can answer - only same-universe tables may be layered |

**`PathRef`** - a resolved path. `Deref<Target = str>`, plus `as_str`, `is_owned`
(whether the bytes were copied rather than borrowed), and `into_owned`.

**`HashDbWriter`** - `new` → `hash_kind` / `casing` → `insert` / `extend` → `build`.
**`HashDbWriter`** - `new` → `hash_kind` / `casing` → `insert` / `extend` → `build`, or
`with_key_config` when a `Table` already states all three.

## CLI

Expand Down
2 changes: 1 addition & 1 deletion crates/ltk_hashdb/examples/bench_real.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ fn build(
) -> (u64, ltk_hashdb::BuildStats) {
let mut w = HashDbWriter::new(key_width, compression)
.hash_kind(hash_kind)
.casing(Casing::Insensitive);
.casing(Casing::AsciiInsensitive);
w.extend(entries.iter().map(|(k, p)| (*k, p.as_str())));
let file = BufWriter::new(File::create(out).expect("create output"));
let stats = w.build(file).expect("build");
Expand Down
21 changes: 21 additions & 0 deletions crates/ltk_hashdb/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

use thiserror::Error;

use crate::KeyConfig;

/// Errors from opening a `.hashdb` file ([`HashDb::open`] / [`HashDb::open_bytes`]):
/// I/O, or the untrusted header/section-bounds validation rejecting the file.
///
Expand Down Expand Up @@ -71,3 +73,22 @@ pub enum BuildError {
#[error("zstd seekable format error")]
Zeekstd(#[from] zeekstd::Error),
}

/// A base rejected by [`LayeredHashDb`] because it does not hash its keys the way
/// the rest of the layer does.
///
/// [`LayeredHashDb`]: crate::LayeredHashDb
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error(
"base {index} is keyed {found}, but the layer is keyed {expected}; a base that hashes differently can never be hit by a caller's precomputed probe"
)]
pub struct KeyConfigMismatch {
/// Position of the rejected base, counting the ones already layered.
pub index: usize,

/// What the layer hashes under - its first base's configuration.
pub expected: KeyConfig,

/// What the rejected base hashes under.
pub found: KeyConfig,
}
163 changes: 127 additions & 36 deletions crates/ltk_hashdb/src/hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,46 @@
//! [`crate::HashDb::hash_path`]. Unit tests pin the case-insensitive results to
//! `ltk_hash`'s `WadHash`/`BinHash` (League paths are ASCII, where they coincide).

use std::fmt;

use xxhash_rust::xxh3::xxh3_64;
use xxhash_rust::xxh64::xxh64;

use crate::KeyWidth;

/// Whether a table's keys hash the path as given or its lowercased form.
/// Whether a table's keys hash the path as given or its ASCII-lowercased form.
///
/// Stored as the `case_insensitive` header flag, orthogonal to [`HashKind`]:
/// the algorithm says *how* the bytes are hashed, the casing says *which* bytes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Casing {
/// Hash the path bytes exactly as given.
#[default]
Sensitive,

/// Lowercase the path before hashing (all League tables). The mapping is
/// Unicode-aware ([`str::to_lowercase`]); on the ASCII paths League ships
/// it reduces to plain `A-Z` → `a-z`, and non-ASCII paths get sensible
/// case-insensitivity for free.
Insensitive,
/// Map `A-Z` to `a-z` before hashing, leaving every other byte alone (all
/// League tables).
///
/// The mapping is deliberately ASCII-only: a byte substitution with no locale,
/// no Unicode tables, and no toolchain drift, so a key computed today still
/// resolves years from now. Bytes outside `A-Z` pass through untouched, so a
/// publisher whose paths are not ASCII should lowercase them however it likes
/// and hash [`Sensitive`](Casing::Sensitive).
AsciiInsensitive,
}

impl fmt::Display for Casing {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Sensitive => "case-sensitive",
Self::AsciiInsensitive => "ascii-case-insensitive",
})
}
}

/// The hash algorithm a table's keys were produced with. The casing rule is
/// recorded separately (see [`Casing`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[repr(u8)]
pub enum HashKind {
/// Not recorded. [`HashKind::hash`] falls back on key width:
Expand Down Expand Up @@ -61,11 +76,10 @@ impl HashKind {
/// Hash `path` with this algorithm under `casing`. `key_width` resolves the
/// [`HashKind::Unspecified`] fallback.
///
/// Insensitive hashing is allocation-free for ASCII paths (all League data):
/// they lowercase into a stack buffer. Only non-ASCII or longer-than-buffer
/// paths pay the full Unicode [`str::to_lowercase`] allocation. This sits on
/// the hunt engine's hot path - millions of candidates per round - where the
/// stack path measures ~2-3× faster than the allocating one.
/// Insensitive hashing is allocation-free for paths up to 512 bytes - every
/// path League ships - which lowercase into a stack buffer; longer ones pay
/// one allocation. This sits on the hunt engine's hot path, millions of
/// candidates per round, where the stack path measures ~2-3× faster.
pub fn hash(self, path: &str, casing: Casing, key_width: KeyWidth) -> u64 {
let kind = match self {
Self::Unspecified => match key_width {
Expand All @@ -75,16 +89,23 @@ impl HashKind {
other => other,
};

// ASCII lowercasing is a per-byte map that leaves every byte of a
// multi-byte sequence alone, so it needs no `is_ascii` guard - only a
// buffer big enough to hold the path.
match casing {
Casing::Sensitive => kind.hash_bytes(path.as_bytes()),
Casing::Insensitive if path.is_ascii() && path.len() <= LOWER_STACK => {
Casing::AsciiInsensitive if path.len() <= LOWER_STACK => {
let mut buf = [0u8; LOWER_STACK];
let lowered = &mut buf[..path.len()];
lowered.copy_from_slice(path.as_bytes());
lowered.make_ascii_lowercase();
kind.hash_bytes(lowered)
}
Casing::Insensitive => kind.hash_bytes(path.to_lowercase().as_bytes()),
Casing::AsciiInsensitive => {
let mut lowered = path.as_bytes().to_vec();
lowered.make_ascii_lowercase();
kind.hash_bytes(&lowered)
}
}
}

Expand All @@ -100,8 +121,67 @@ impl HashKind {
}
}

/// Mixed-case ASCII paths up to this length lowercase on the stack; longer ones
/// fall back to a heap allocation. Real paths max out around 200 bytes.
impl fmt::Display for HashKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Unspecified => "unspecified",
Self::Xxh64 => "xxh64",
Self::Fnv1a32 => "fnv1a32",
Self::Xxh3 => "xxh3",
})
}
}

/// How a table's keys were produced: key width, hash algorithm, casing rule.
///
/// The three only ever mean something together - a `u64` probe is answerable by a
/// table only when all three of them agree - so they travel as one value.
/// [`HashDb::key_config`](crate::HashDb::key_config) reports a table's, and
/// [`LayeredHashDb`](crate::LayeredHashDb) requires every base to share one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct KeyConfig {
key_width: KeyWidth,
hash_kind: HashKind,
casing: Casing,
}

impl KeyConfig {
/// A configuration from its three parts.
pub const fn new(key_width: KeyWidth, hash_kind: HashKind, casing: Casing) -> Self {
Self {
key_width,
hash_kind,
casing,
}
}

pub const fn key_width(self) -> KeyWidth {
self.key_width
}

pub const fn hash_kind(self) -> HashKind {
self.hash_kind
}

pub const fn casing(self) -> Casing {
self.casing
}

/// Hash `path` the way this table's keys were produced.
pub fn hash(self, path: &str) -> u64 {
self.hash_kind.hash(path, self.casing, self.key_width)
}
}

impl fmt::Display for KeyConfig {
/// `u64/xxh64/ascii-case-insensitive`.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}/{}", self.key_width, self.hash_kind, self.casing)
}
}

/// Mixed-case paths up to this length lowercase on the stack; longer ones fall
/// back to a heap allocation. Real paths max out around 200 bytes.
const LOWER_STACK: usize = 512;

/// FNV-1a 32 over raw bytes (`ltk_hash::BinHash` only exposes a lowercasing form).
Expand All @@ -123,31 +203,42 @@ mod tests {
fn insensitive_matches_ltk_hash() {
let p = "DATA/Characters/Aatrox/Aatrox.bin";
assert_eq!(
HashKind::Xxh64.hash(p, Casing::Insensitive, KeyWidth::U64),
HashKind::Xxh64.hash(p, Casing::AsciiInsensitive, KeyWidth::U64),
*WadHash::hash_str(p)
);
assert_eq!(
HashKind::Fnv1a32.hash(p, Casing::Insensitive, KeyWidth::U32),
HashKind::Fnv1a32.hash(p, Casing::AsciiInsensitive, KeyWidth::U32),
*BinHash::hash_str(p) as u64
);
// Known FNV-1a-lower vector (from ltk_hash's own tests).
assert_eq!(
HashKind::Fnv1a32.hash("TEST", Casing::Insensitive, KeyWidth::U32),
HashKind::Fnv1a32.hash("TEST", Casing::AsciiInsensitive, KeyWidth::U32),
0xafd071e5
);
}

#[test]
fn insensitive_lowercases_before_hashing() {
for kind in [HashKind::Xxh64, HashKind::Fnv1a32, HashKind::Xxh3] {
let a = kind.hash("ASSETS/Foo.DDS", Casing::Insensitive, KeyWidth::U64);
let b = kind.hash("assets/foo.dds", Casing::Insensitive, KeyWidth::U64);
let a = kind.hash("ASSETS/Foo.DDS", Casing::AsciiInsensitive, KeyWidth::U64);
let b = kind.hash("assets/foo.dds", Casing::AsciiInsensitive, KeyWidth::U64);
assert_eq!(a, b, "{kind:?}");
}
}

/// The rule is ASCII-only on purpose: non-ASCII case pairs stay distinct
/// rather than tracking a Unicode table that can move under a published file.
#[test]
fn insensitive_leaves_non_ascii_alone() {
for kind in [HashKind::Xxh64, HashKind::Fnv1a32, HashKind::Xxh3] {
let upper = kind.hash("assets/É.dds", Casing::AsciiInsensitive, KeyWidth::U64);
let lower = kind.hash("assets/é.dds", Casing::AsciiInsensitive, KeyWidth::U64);
assert_ne!(upper, lower, "{kind:?}");

// The lowercasing is Unicode-aware, not ASCII-only.
let a = kind.hash("assets/É.dds", Casing::Insensitive, KeyWidth::U64);
let b = kind.hash("assets/é.dds", Casing::Insensitive, KeyWidth::U64);
assert_eq!(a, b, "{kind:?} (unicode)");
// A non-ASCII byte must not disturb the ASCII part of the same path.
let a = kind.hash("É/Foo.DDS", Casing::AsciiInsensitive, KeyWidth::U64);
let b = kind.hash("É/foo.dds", Casing::AsciiInsensitive, KeyWidth::U64);
assert_eq!(a, b, "{kind:?}");
}
}

Expand All @@ -168,7 +259,7 @@ mod tests {
for kind in [HashKind::Xxh64, HashKind::Fnv1a32, HashKind::Xxh3] {
assert_eq!(
kind.hash(p, Casing::Sensitive, KeyWidth::U64),
kind.hash(p, Casing::Insensitive, KeyWidth::U64),
kind.hash(p, Casing::AsciiInsensitive, KeyWidth::U64),
"{kind:?}"
);
}
Expand All @@ -179,19 +270,19 @@ mod tests {
);
}

/// By definition `Insensitive` must equal lowercase-then-`Sensitive`; pin the
/// stack-buffer / heap fast paths (and the buffer boundary) to it.
/// By definition `AsciiInsensitive` must equal ascii-lowercase-then-`Sensitive`;
/// pin the stack-buffer and heap paths (and the buffer boundary) to it.
#[test]
fn insensitive_fast_paths_match_reference() {
fn insensitive_paths_match_reference() {
let long_mixed = "A".repeat(600) + "/File.DDS";
let mut cases = vec![
String::new(),
"a".into(),
"assets/foo.dds".into(), // ASCII: stack buffer
"assets/foo.dds".into(), // stack buffer
"ASSETS/Foo.DDS".into(),
"ässets/FÖÖ.dds".into(), // non-ASCII: heap
"ässets/FÖÖ.dds".into(), // non-ASCII: only the ASCII bytes move
"É".into(),
long_mixed, // ASCII past the stack buffer: heap
long_mixed, // past the stack buffer: heap
];
for len in [511, 512, 513] {
cases.push("A".repeat(len)); // exactly around the stack-buffer boundary
Expand All @@ -200,8 +291,8 @@ mod tests {
for kind in [HashKind::Xxh64, HashKind::Fnv1a32, HashKind::Xxh3] {
for path in &cases {
assert_eq!(
kind.hash(path, Casing::Insensitive, KeyWidth::U64),
kind.hash(&path.to_lowercase(), Casing::Sensitive, KeyWidth::U64),
kind.hash(path, Casing::AsciiInsensitive, KeyWidth::U64),
kind.hash(&path.to_ascii_lowercase(), Casing::Sensitive, KeyWidth::U64),
"{kind:?} {path:?}"
);
}
Expand All @@ -211,7 +302,7 @@ mod tests {
#[test]
fn unspecified_falls_back_on_key_width() {
let p = "data/characters/aatrox/aatrox.bin";
for casing in [Casing::Sensitive, Casing::Insensitive] {
for casing in [Casing::Sensitive, Casing::AsciiInsensitive] {
assert_eq!(
HashKind::Unspecified.hash(p, casing, KeyWidth::U64),
HashKind::Xxh64.hash(p, casing, KeyWidth::U64)
Expand Down
9 changes: 7 additions & 2 deletions crates/ltk_hashdb/src/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ pub const HEADER_SIZE: usize = 80;
/// Header flag: the arena is a zeekstd seekable stream rather than raw bytes.
pub(crate) const FLAG_ARENA_COMPRESSED: u8 = 1 << 0;

/// Header flag: the keys hash the lowercased path ([`Casing::Insensitive`]).
/// Header flag: the keys hash the ASCII-lowercased path
/// ([`Casing::AsciiInsensitive`]).
///
/// Should a Unicode-aware rule ever be wanted, it gets its own value in the
/// reserved byte at offset 14 rather than a second flag bit - unknown flag bits
/// are rejected, unknown reserved bytes are not.
pub(crate) const FLAG_CASE_INSENSITIVE: u8 = 1 << 1;

const KNOWN_FLAGS: u8 = FLAG_ARENA_COMPRESSED | FLAG_CASE_INSENSITIVE;
Expand Down Expand Up @@ -75,7 +80,7 @@ impl Header {

pub fn casing(&self) -> Casing {
if self.flags & FLAG_CASE_INSENSITIVE != 0 {
Casing::Insensitive
Casing::AsciiInsensitive
} else {
Casing::Sensitive
}
Expand Down
Loading
Loading