diff --git a/CLAUDE.md b/CLAUDE.md
index 27efb0c..f3fbf8a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,13 +21,20 @@ Cargo workspace (`resolver = "2"`), four crates under `crates/`:
| Crate | Role |
|-------|------|
-| `ltk_hashdb` | The `.hashdb` format: `mmap` reader (`HashDb`) + streaming writer (`HashDbWriter`), `ExtendedHashDb` overlay |
+| `ltk_hashdb` | The `.hashdb` format: `mmap` reader (`HashDb`) + streaming writer (`HashDbWriter`), `LayeredHashDb` overlay |
| `ltk_mimir_cache` | Shared cache dir, `manifest.json`, versioned publish, update lock, GC, in-process updater (`HashStore`, `HashStore::update`) |
| `ltk_mimir_gen` | Hash-discovery ("hunt") engine - guessers that resolve unknown hashes |
| `ltk_mimir_cli` | The `mimir` binary (`build` / `get` / `verify` / `stats` / `gen` / `update` / `merge` / `bundle`) |
-Docs live in `docs/`: `FORMAT.md` (byte-level spec, format version 1), `CONSUMERS.md`
-(integration API), `BENCHMARKS.md` (frame-size/compression measurements).
+Docs live in `docs/`: `DESIGN.md` (rationale, how the format works, measurements -
+the narrative the README used to carry), `FORMAT.md` (byte-level spec, format version 1),
+`CONSUMERS.md` (integration API), `BENCHMARKS.md` (frame-size/compression measurements),
+`ROADMAP.md` (planned work, in dependency order, with the additive-only constraints that
+govern it).
+
+`README.md` is the usage front door: install, quick start, the API surface with examples,
+and the CLI. Keep its examples true to the current API - they are typechecked by hand, not
+by CI.
## Conventions
diff --git a/Cargo.toml b/Cargo.toml
index ff7e1ec..9b2d690 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,9 +4,9 @@ members = ["crates/*"]
[workspace.package]
edition = "2021"
-license = "MIT OR Apache-2.0"
+license = "Apache-2.0"
repository = "https://github.com/LeagueToolkit/mimir"
-authors = ["League Toolkit contributors"]
+authors = ["Crauzer <0xcrauzer@proton.me>", "League Toolkit contributors"]
[workspace.dependencies]
# Internal crates (path-linked within this workspace; the published ones also
diff --git a/LICENSE-APACHE b/LICENSE
similarity index 100%
rename from LICENSE-APACHE
rename to LICENSE
diff --git a/LICENSE-MIT b/LICENSE-MIT
deleted file mode 100644
index aad79e4..0000000
--- a/LICENSE-MIT
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2026 League Toolkit contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..fb7005c
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,16 @@
+mimir
+Copyright 2026 Crauzer <0xcrauzer@proton.me>
+
+This product includes software developed by Crauzer and the League Toolkit
+contributors.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
diff --git a/README.md b/README.md
index 890a667..32cd3b1 100644
--- a/README.md
+++ b/README.md
@@ -1,158 +1,329 @@
-# mimir
-
-A toolkit for **generating**, **storing**, and **serving** League of Legends
-hash → path tables as a compact, memory-mapped, seekable binary format (`.hashdb`).
-It reimplements and extends CommunityDragon's CDTB `hashes.py` and the
-CommunityDragon/Data txt artifacts.
-
-The `.hashdb` format is general-purpose - an mmap-backed, read-only map from integer keys
-to string values, with nothing League-specific in its layout. League Toolkit distributes
-its own tables in this format under the `.lhdb` extension (identical bytes).
-
-## Why this exists
-
-Almost every League of Legends tool - WAD unpackers, `.bin` inspectors, mod loaders,
-asset browsers - hits the same wall: the game identifies files and fields by **hash**,
-not by name. To show a human-readable path you need a **hash table** that maps each
-hash back to its original string.
-
-Today that table is the CommunityDragon `hashes.*.txt` set: **~348 MB of plain text**,
-which every tool downloads and keeps around. That approach has two costs that compound
-the moment a machine runs more than one of these tools:
-
-- **Memory.** To resolve hashes efficiently a program has to load the whole table into
- an in-memory map. Run three tools that each need the game hashes and you pay for
- **three private copies** of the same hundreds-of-megabytes table, all resident at once.
-- **Startup & distribution.** Every tool ships (or re-downloads) the same giant text
- files and spends time parsing them into a map before it can answer a single query.
-
-**mimir** replaces that text blob with a purpose-built, **read-only** binary format for
-hash storage. The design goals are, in order:
-
-1. **Usable as shipped** - no unpack or full-expansion step; a consumer `mmap`s the file
- and immediately does lookups.
-2. **Small** - the game table drops from ~348 MB of text to roughly **~50 MB** on disk.
-3. **Memory-efficient across processes** - the file is memory-mapped, so the OS page
- cache holds **one** copy that every tool on the machine shares. Resident RAM stays
- low because pages are faulted in lazily and dropped under pressure, and a lookup
- *miss* touches zero string data.
-
-## How it works
-
-A `.hashdb` file is a single logical table laid out for direct, zero-parse use over an
-`mmap`:
-
-- **Sorted key array** - the integer hashes, stored strictly ascending so a lookup is a
- **binary search straight over the mapped bytes**. A miss is decided here and never
- reads any string data.
-- **Parallel offset + length arrays** - for a found key, where its path lives in the arena
- and how long it is.
-- **String arena** - all the path strings concatenated with no separators, compressed as
- a **Zstandard Seekable Format** stream. The seek table means a hit decompresses just the
- **one small frame** that holds its path (default 16 KiB frames), not the whole table -
- so partial, on-demand reads stay cheap. Paths are packed in **lexicographic order** so
- a directory's files land in the same frames, which both compresses far better (~4× vs.
- key order on the real game table) and makes directory-local batch lookups touch fewer
- frames.
-
-The file is immutable once published; updates ship as new versioned files, and a downloaded
-file is treated as untrusted - the header is validated on open and every read bounds-checks
-its own extent. See [`docs/FORMAT.md`](docs/FORMAT.md) for the byte-level specification.
-
-Because it's memory-mapped and read-only, a lazy consumer (say, a mod loader) can open the
-table only when it first needs to resolve a hash, share the page cache with every other
-mimir-backed tool running, and drop the handle to reclaim its (already small) footprint.
-
-## Performance
-
-Real-data measurements against the CommunityDragon `hashes.*.txt` snapshot of
-2026-07-07 (~2.97 M entries across 8 tables). Full tables, methodology, and
-reproduction steps are in [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md).
-
-
-
-
-
-
-The whole corpus drops from **~253 MiB of txt to ~52 MiB** of `.hashdb` - and the
-binary is usable as-shipped: `open` is a header validation plus an `mmap`, with no
-parse or expansion step before the first lookup.
-
-
-
-
-
-
-A hit decompresses exactly one small frame; batched lookups amortize that through
-the reader's frame cache. A **miss is decided by binary search over the raw key
-section and never touches string data** - ~143 ns whether the file is raw or
-compressed, which matters because hash hunting hammers misses.
-
-
-
-
-
-
-Sorting the arena by path packs each directory into the same frames, so the seekable
-arena compresses **~4× better than key order** - beating even a solid, non-seekable
-zstd stream - while making hits faster and directory-local batches frame-coherent.
-
-## Layout
+
-| Crate | Role |
-|-------|------|
-| `ltk_hashdb` | The `.hashdb` format: `mmap` reader (`HashDb`) + streaming writer |
-| `ltk_mimir_cache` | Shared cache dir, manifest, versioned publish, update lock, GC, in-process updater |
-| `ltk_mimir_gen` | Hash-discovery ("hunt") engine for still-unknown hashes |
-| `ltk_mimir_cli` | The `mimir` binary |
+Hash → path tables for League of Legends tooling, stored as a compact, memory-mapped,
+seekable binary format (`.hashdb`). It replaces CommunityDragon's ~348 MB of
+`hashes.*.txt` with a **~52 MiB binary that is usable as shipped** - no parse step before
+the first lookup, and **one copy in the page cache that every tool on the machine
+shares**.
+
+The format itself is general-purpose: a read-only map from integer keys to string values,
+with nothing League-specific in its layout. League Toolkit distributes its own tables
+under the `.lhdb` extension (identical bytes).
+
+
+
+## Install
+
+Nothing is on crates.io yet, so depend on the repository directly:
+
+```toml
+[dependencies]
+ltk_hashdb = { git = "https://github.com/LeagueToolkit/mimir" }
+
+# Only if you want the shared cache and the download-driven updater.
+# `ureq` gives you a blocking fetcher, `reqwest` an async one; both are optional.
+ltk_mimir_cache = { git = "https://github.com/LeagueToolkit/mimir", features = ["ureq"] }
+```
-## Using it
+The CLI:
-As a library - open a table and resolve a hash:
+```sh
+cargo install --git https://github.com/LeagueToolkit/mimir ltk_mimir_cli
+```
+
+## Quick start
+
+Pull the published tables into the shared cache, then resolve a hash out of it:
+
+```sh
+mimir update
+mimir get 0x1234abcd --table game
+```
+
+The same thing from Rust:
```rust
-use ltk_hashdb::HashDb;
+use ltk_mimir_cache::{HashStore, Table};
+
+let store = HashStore::discover()?;
+let db = store.open_shared(Table::Game)?; // mmap + validate header; no parse step
-let db = HashDb::open("game.hashdb")?; // mmap + validate header, lazy
if let Some(path) = db.get(0x1234_5678_9abc_def0) {
println!("{path}");
}
```
-From the CLI:
+The cache lives in the platform data directory - `%LOCALAPPDATA%\LeagueToolkit\hashes` on
+Windows, `$XDG_DATA_HOME/LeagueToolkit/hashes` on Linux, `~/Library/Application
+Support/LeagueToolkit/hashes` on macOS - and `MIMIR_DIR` overrides it.
+
+## Library API
+
+Two crates matter to consumers. `ltk_hashdb` is the format: open a file, resolve hashes.
+`ltk_mimir_cache` is everything around it: where tables live on disk, which version is
+active, and how they get updated. A tool that ships its own tables needs only the first.
+
+### Resolving a hash
+
+`get` returns a **`PathRef`**, which borrows its bytes instead of copying them - out of
+the mapping for a raw arena, out of the cached decompressed frame for a compressed one.
+It derefs to `str`, so it behaves like one:
+
+```rust
+use ltk_hashdb::HashDb;
+
+let db = HashDb::open("game.lhdb")?;
+
+if let Some(path) = db.get(hash) {
+ println!("{path}"); // Display
+ if path.ends_with(".dds") { /* … */ } // Deref
+ let owned: String = path.into_owned(); // copy only when you keep it
+}
+
+// A miss is decided by binary search over the key array and never touches the arena.
+assert!(!db.contains(0xdead_beef));
+```
+
+Hashing a path with the table's own algorithm, so you never have to know which one it is:
+
+```rust
+let hash = db.hash_path("assets/characters/ahri/ahri.bin");
+assert!(db.contains(hash));
+```
+
+### Resolving many hashes
+
+Both forms resolve hits in **arena order** so each compressed frame is decompressed once.
+Use `get_batch` when you want results back in input order, and `for_each_batch` when you
+want them streamed with no intermediate `Vec`:
+
+```rust
+// Collected, in input order.
+for (hash, path) in db.get_batch(&chunk_hashes) {
+ match path {
+ Some(path) => println!("{path}"),
+ None => println!("{hash:016x} (unknown)"),
+ }
+}
+
+// Streamed. Calls arrive in arena order, so the first argument is the input position.
+db.for_each_batch(&chunk_hashes, |i, hash, path| match path {
+ Some(path) => println!("{i}: {path}"),
+ None => println!("{i}: {hash:016x}"),
+});
+```
+
+### Layering tables and adding your own hashes
+
+`LayeredHashDb` puts a writable in-memory overlay over one or more read-only bases.
+Lookups consult the overlay first, then each base in push order; the first hit wins, and
+no base file is ever mutated. This is what WAD consumers want, since chunk resolution
+spans both `game` and `lcu`:
+
+```rust
+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]);
+for (table, e) in &errors {
+ eprintln!("skipping {table:?}: {e}");
+}
+
+// Register a path your mod introduced; it is hashed with the first base's algorithm.
+let hash = db.insert_path("assets/mymod/custom.dds").expect("has a base");
+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.
+
+### Enumerating a table
+
+```rust
+// Streams in arena order (lexicographic path order), one decompress per frame.
+for (hash, path) in db.iter() {
+ println!("{hash:016x} {path}");
+}
+
+// Opt-in resident mode: the whole table as an owned map. Costs the full decompressed
+// size in private memory and forfeits the shared page cache, so reach for it last.
+let map = db.load_all();
+```
+
+### Updating the cache
+
+The crate ships no HTTP client of its own - you hand it a fetcher. `UreqFetch` (feature
+`ureq`) and `ReqwestFetch` (feature `reqwest`, async) cover the common case:
+
+```rust
+use ltk_mimir_cache::{HashStore, ReleaseSource, UpdateOptions, UpdateOutcome, UreqFetch};
+
+let store = HashStore::discover()?;
+let remote = UreqFetch::new(ReleaseSource::github("LeagueToolkit/mimir"));
+
+match store.update(&remote, UpdateOptions::default())? {
+ UpdateOutcome::Completed(report) => println!("installed {:?}", report.installed),
+ UpdateOutcome::Locked => println!("another process is already updating"),
+}
+```
+
+Only tables whose sha256 differs are downloaded. Installs are atomic - versioned files
+land first, the manifest pointer flips last - so a reader sees either the whole old
+version or the whole new one, and readers never take a lock.
+
+### Building your own table
+
+```rust
+use std::fs::File;
+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
+
+writer.insert(hash, "assets/characters/ahri/ahri.bin");
+writer.extend(pairs);
+
+let stats = writer.build(File::create("mine.hashdb")?)?;
+println!("{} entries, {} bytes", stats.entries, stats.file_len);
+```
+
+`Compression::default()` is the publishing configuration: 16 KiB frames at level 19, the
+measured size/latency knee. `Compression::None` writes a raw arena that lookups borrow
+straight out of the mapping.
+
+### API surface
+
+**`HashDb`** - one `.hashdb` file. Cheap to clone; every clone shares the mapping and the
+frame cache. `Send + Sync`.
+
+| Method | |
+|---|---|
+| `open` · `open_bytes` | mmap a file, or open an in-memory image |
+| `options()` | open-time knobs: `frame_cache_bytes(n)`, `0` disables |
+| `get` | resolve a hash → `Option` |
+| `try_get` | as `get`, but a corrupt arena errors instead of reading as a miss |
+| `get_into` | copy into a reusable `String`; holds no frame afterwards |
+| `contains` | membership, never touches the arena |
+| `get_batch` · `for_each_batch` | bulk resolve, collected or streamed |
+| `iter` · `load_all` | enumerate in arena order, or decode into an owned map |
+| `hash_path` | hash a string with this table's algorithm and casing |
+| `verify` · `is_healthy` | full integrity pass; sticky flag set by a failed read |
+| `len` · `key_width` · `hash_kind` · `casing` · `is_compressed` | shape |
+| `downgrade` | a `WeakHashDb` for registries that must not pin the table |
+
+**`LayeredHashDb`** - an overlay over N ordered bases.
+
+| Method | |
+|---|---|
+| `from_bases` · `push_base` | layer read-only tables, highest priority first |
+| `insert` · `insert_path` · `extend` | write to the overlay, shadowing every base |
+| `get` · `contains` · `get_into` | overlay first, then each base in order |
+| `get_batch` · `for_each_batch` | staged bulk resolve; each base sees only the residual |
+| `iter` | every entry, each shadowed key yielded once by the layer that answers it |
+| `bases` · `overlay_len` · `base_len` · `is_healthy` | shape |
+
+**`HashStore`** - the shared cache directory.
+
+| Method | |
+|---|---|
+| `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 |
+| `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 |
+
+**`PathRef`** - a resolved path. `Deref`, plus `as_str`, `is_owned`
+(whether the bytes were copied rather than borrowed), and `into_owned`.
+
+**`HashDbWriter`** - `new` → `hash_kind` / `casing` → `insert` / `extend` → `build`.
+
+## CLI
+
+```
+mimir
+
+build Build a .hashdb table from a txt hash list (lines of ``)
+get Resolve one hash from a .hashdb file or the shared cache
+update Download the latest published tables into the shared cache
+gen Run the hunt engine: discover paths for still-unknown hashes
+merge Sorted dedup merge of CDragon txt hash lists
+bundle Build all tables + manifest from CDragon txt inputs, staged for a GH release
+verify Structural + checksum validation of a .hashdb file
+stats Sizes, entry counts, compression ratio of a .hashdb file
+```
```sh
-# Build a .hashdb table from a CDragon `` txt list
+# Build a table from a CDragon txt list
mimir build --input hashes.game.txt --table game --out game.hashdb
-# Resolve one hash
+# Resolve a hash, from a file or from the shared cache
mimir get 0x1234abcd --file game.hashdb
+mimir get 0x1234abcd --table game
-# Validate a downloaded file (structure + checksum)
+# Keep the shared cache current (--url for a mirror, --dir for a private cache)
+mimir update
+mimir update --force
+
+# Inspect and validate
+mimir stats game.hashdb
mimir verify game.hashdb
```
-See [`docs/CONSUMERS.md`](docs/CONSUMERS.md) for the shared-cache and
-custom-hash-extension APIs.
+## Crates
+
+| Crate | Role |
+|-------|------|
+| `ltk_hashdb` | The `.hashdb` format: `mmap` reader (`HashDb`) + streaming writer |
+| `ltk_mimir_cache` | Shared cache dir, manifest, versioned publish, update lock, GC, in-process updater |
+| `ltk_mimir_gen` | Hash-discovery ("hunt") engine for still-unknown hashes |
+| `ltk_mimir_cli` | The `mimir` binary |
+
+## Documentation
+
+| | |
+|---|---|
+| [`docs/DESIGN.md`](docs/DESIGN.md) | Why this exists, how the format works, what it measures |
+| [`docs/FORMAT.md`](docs/FORMAT.md) | Byte-level specification of `.hashdb`, format version 1 |
+| [`docs/CONSUMERS.md`](docs/CONSUMERS.md) | Integration guide: lookup patterns, threading, custom pipelines |
+| [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md) | Frame-size and compression measurements, with reproduction steps |
+| [`docs/ROADMAP.md`](docs/ROADMAP.md) | Planned work, in dependency order |
## Status
-Early development. The `.hashdb` format, reader/writer, the shared cache, release
-publishing (`mimir bundle` + a scheduled CI job that ships every table as versioned
-`.lhdb` GitHub release assets, rebuilt from the canonical CommunityDragon txt lists), the
+Early development. The format, reader/writer, shared cache, release publishing
+(`mimir bundle` plus a scheduled CI job that ships every table as versioned `.lhdb`
+release assets, rebuilt from the canonical CommunityDragon txt lists), the
download-driven `mimir update` flow, and the hunt engine - including WAD string mining
-(`mimir gen --wad`) - are in place.
+(`mimir gen --wad`) - are all in place.
-## License
+The txt lists stay canonical; the binaries are generated release artifacts, never the
+source of truth.
-Licensed under either of
+## License
-- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
- http://www.apache.org/licenses/LICENSE-2.0)
-- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
+Copyright 2026 Crauzer <0xcrauzer@proton.me>
-at your option.
+Licensed under the Apache License, Version 2.0 ([LICENSE](LICENSE) or
+http://www.apache.org/licenses/LICENSE-2.0). Attribution requirements are in
+[NOTICE](NOTICE).
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
-dual licensed as above, without any additional terms or conditions.
+licensed as above, without any additional terms or conditions.
diff --git a/crates/ltk_hashdb/examples/frame_cache.rs b/crates/ltk_hashdb/examples/frame_cache.rs
new file mode 100644
index 0000000..62cb998
--- /dev/null
+++ b/crates/ltk_hashdb/examples/frame_cache.rs
@@ -0,0 +1,122 @@
+//! Measures what the frame cache is for: point lookups against batched ones.
+//!
+//! A WAD extractor resolves one chunk hash at a time - `ltk_wad`'s `PathResolver` is
+//! point-shaped by construction - but the chunks of any one archive share a path prefix,
+//! so they land in a handful of arena frames. This walks that shape: take a contiguous
+//! run of paths (one "archive"), probe them in hash order (a WAD's table-of-contents
+//! order, which is unrelated to path order), and compare point lookups with the cache on,
+//! with it off, and one `get_batch` over the same keys.
+//!
+//! ```text
+//! cargo run --release -p ltk_hashdb --example frame_cache -- [chunks]
+//! ```
+
+use std::time::Instant;
+
+use ltk_hashdb::HashDb;
+
+fn main() {
+ let mut args = std::env::args_os().skip(1);
+ let Some(path) = args.next() else {
+ eprintln!("usage: frame_cache [chunks]");
+ std::process::exit(2);
+ };
+ let chunks: usize = args
+ .next()
+ .and_then(|n| n.to_str().and_then(|n| n.parse().ok()))
+ .unwrap_or(20_000);
+
+ let db = HashDb::open(&path).expect("open");
+ println!(
+ "{}: {} entries, arena {:.1} MiB raw / {:.1} MiB on disk\n",
+ path.to_string_lossy(),
+ db.len(),
+ db.arena_decompressed_size() as f64 / (1 << 20) as f64,
+ db.arena_compressed_size() as f64 / (1 << 20) as f64,
+ );
+
+ // `iter` yields in arena order, so a window of it is a run of neighbouring paths -
+ // one archive's worth. Probing order is by hash, as a WAD's TOC would give them.
+ let started = Instant::now();
+ let arena_order: Vec = db.iter().map(|(key, _)| key).collect();
+ println!(
+ "full scan: {:?} for {} entries",
+ started.elapsed(),
+ arena_order.len()
+ );
+
+ let window = arena_order.len().min(chunks);
+ let start = (arena_order.len() - window) / 2;
+ let mut keys: Vec = arena_order[start..start + window].to_vec();
+ keys.sort_unstable();
+
+ let cached = HashDb::open(&path).expect("open");
+ let uncached = HashDb::options()
+ .frame_cache_bytes(0)
+ .open(&path)
+ .expect("open");
+
+ // Warm the mapping so the first pass isn't paying for page faults the others skip.
+ let _ = uncached.get(keys[0]);
+
+ let point_uncached = time(|| {
+ for &key in &keys {
+ std::hint::black_box(uncached.get(key));
+ }
+ });
+ let point_cached = time(|| {
+ for &key in &keys {
+ std::hint::black_box(cached.get(key));
+ }
+ });
+ let batched = time(|| {
+ for entry in cached.get_batch(&keys) {
+ std::hint::black_box(entry);
+ }
+ });
+
+ let per = |d: std::time::Duration| d.as_secs_f64() * 1e9 / keys.len() as f64;
+ println!("\n{window} chunks, probed in hash order:");
+ println!(" point, no cache : {:>8.0} ns/lookup", per(point_uncached));
+ println!(" point, cached : {:>8.0} ns/lookup", per(point_cached));
+ println!(" get_batch : {:>8.0} ns/lookup", per(batched));
+ println!(
+ "\n cache speedup : {:>8.1}x point/batch ratio: {:.2}x",
+ point_uncached.as_secs_f64() / point_cached.as_secs_f64(),
+ point_cached.as_secs_f64() / batched.as_secs_f64(),
+ );
+
+ // The other end of the spectrum: keys spread over the whole table, where no cache
+ // of any size holds the working set. This is what the cache must not make slower.
+ let stride = arena_order.len() / window.max(1);
+ let mut scattered: Vec = arena_order.iter().copied().step_by(stride.max(1)).collect();
+ scattered.truncate(window);
+ scattered.sort_unstable();
+
+ let scattered_uncached = time(|| {
+ for &key in &scattered {
+ std::hint::black_box(uncached.get(key));
+ }
+ });
+ let scattered_cached = time(|| {
+ for &key in &scattered {
+ std::hint::black_box(cached.get(key));
+ }
+ });
+
+ println!("\n{} keys spread across the whole table:", scattered.len());
+ println!(
+ " point, no cache : {:>8.0} ns/lookup",
+ scattered_uncached.as_secs_f64() * 1e9 / scattered.len() as f64
+ );
+ println!(
+ " point, cached : {:>8.0} ns/lookup",
+ scattered_cached.as_secs_f64() * 1e9 / scattered.len() as f64
+ );
+}
+
+fn time(mut f: impl FnMut()) -> std::time::Duration {
+ let started = Instant::now();
+ f();
+ started.elapsed()
+}
diff --git a/crates/ltk_hashdb/src/cache.rs b/crates/ltk_hashdb/src/cache.rs
new file mode 100644
index 0000000..f05b214
--- /dev/null
+++ b/crates/ltk_hashdb/src/cache.rs
@@ -0,0 +1,243 @@
+//! Shared, byte-capped store of decompressed arena frames.
+//!
+//! A published table is immutable, so a decompressed frame never goes stale - this is
+//! pure memoisation with nothing to invalidate. Frames are keyed by a dense, contiguous
+//! index, which lets the store be an N-way set-associative table sized once at open:
+//! no map, no eviction list, no per-insert allocation, and one lock per set, so
+//! concurrent readers stay off each other's backs without a sharding decision.
+//!
+//! Buffers from evicted frames are recycled, so a steady-state miss decompresses into a
+//! buffer that already exists instead of allocating a new one.
+
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex, PoisonError};
+
+/// One decompressed arena frame, shared by every path resolved out of it.
+pub(crate) struct Frame(Vec);
+
+impl Frame {
+ pub(crate) fn bytes(&self) -> &[u8] {
+ &self.0
+ }
+}
+
+impl From> for Frame {
+ fn from(bytes: Vec) -> Self {
+ Self(bytes)
+ }
+}
+
+/// Frames per set. Four is enough associativity that the arena's forward-walking
+/// access pattern never thrashes, and small enough that a set scan stays trivial.
+const WAYS: usize = 4;
+
+/// Recycled buffers kept for reuse. Scales with concurrent decompressions, not with
+/// the cache size - a buffer only sits here between one frame's eviction and the next
+/// frame's decompression.
+const SPARE_BUFFERS: usize = 8;
+
+/// A fixed-size set-associative cache of decompressed frames.
+pub(crate) struct FrameCache {
+ /// Empty when caching is disabled; every operation then no-ops.
+ sets: Box<[Mutex<[Slot; WAYS]>]>,
+
+ /// Monotonic access counter; a slot's last value orders eviction within its set.
+ clock: AtomicU64,
+
+ spare: Mutex>>,
+}
+
+#[derive(Default)]
+struct Slot {
+ /// Meaningful only while `frame` is `Some`.
+ index: u32,
+
+ used: u64,
+
+ frame: Option>,
+}
+
+impl FrameCache {
+ /// A cache holding at most `budget` bytes worth of `frame_size` frames.
+ ///
+ /// Never sized beyond the `frames` the file actually has, so a small table costs a
+ /// small table's worth of slots. A zero budget (or a table with no frames at all)
+ /// disables caching outright.
+ pub(crate) fn new(budget: usize, frame_size: usize, frames: usize) -> Self {
+ let wanted = match budget.checked_div(frame_size) {
+ Some(n) => n.min(frames),
+ None => 0,
+ };
+
+ Self {
+ sets: (0..wanted.div_ceil(WAYS))
+ .map(|_| Mutex::new(std::array::from_fn(|_| Slot::default())))
+ .collect(),
+ clock: AtomicU64::new(0),
+ spare: Mutex::new(Vec::new()),
+ }
+ }
+
+ /// How many frames this cache can hold at once; `0` when disabled.
+ pub(crate) fn capacity(&self) -> usize {
+ self.sets.len() * WAYS
+ }
+
+ /// The cached frame `index`, if it is still resident.
+ pub(crate) fn get(&self, index: u32) -> Option> {
+ let mut slots = self
+ .set(index)?
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner);
+ let slot = slots
+ .iter_mut()
+ .find(|slot| slot.frame.is_some() && slot.index == index)?;
+
+ slot.used = self.clock.fetch_add(1, Ordering::Relaxed);
+ slot.frame.clone()
+ }
+
+ /// Publish `frame` under `index`, evicting the coldest frame in its set.
+ pub(crate) fn insert(&self, index: u32, frame: &Arc) {
+ let Some(set) = self.set(index) else { return };
+ let used = self.clock.fetch_add(1, Ordering::Relaxed);
+
+ let evicted = {
+ let mut slots = set.lock().unwrap_or_else(PoisonError::into_inner);
+ // Prefer the slot this frame already occupies (a racing reader decompressed
+ // it too), then an empty one, then the least recently used. `+ 1` keeps an
+ // occupied slot from tying with an empty one at clock zero.
+ let slot = match slots
+ .iter()
+ .position(|slot| slot.frame.is_some() && slot.index == index)
+ {
+ Some(position) => &mut slots[position],
+ None => slots
+ .iter_mut()
+ .min_by_key(|slot| match slot.frame {
+ None => 0,
+ Some(_) => slot.used.saturating_add(1),
+ })
+ .expect("WAYS is nonzero"),
+ };
+
+ slot.index = index;
+ slot.used = used;
+ slot.frame.replace(Arc::clone(frame))
+ };
+
+ if let Some(evicted) = evicted {
+ self.recycle(evicted);
+ }
+ }
+
+ /// A buffer to decompress into, reusing an evicted frame's allocation when there
+ /// is one to reuse.
+ pub(crate) fn take_buffer(&self, capacity: usize) -> Vec {
+ let mut spare = self.spare.lock().unwrap_or_else(PoisonError::into_inner);
+ match spare.pop() {
+ Some(mut buffer) => {
+ buffer.clear();
+ buffer.reserve(capacity);
+ buffer
+ }
+ None => Vec::with_capacity(capacity),
+ }
+ }
+
+ /// Reclaim an evicted frame's buffer, unless a caller still holds a path into it.
+ fn recycle(&self, frame: Arc) {
+ let Some(frame) = Arc::into_inner(frame) else {
+ return;
+ };
+
+ let mut spare = self.spare.lock().unwrap_or_else(PoisonError::into_inner);
+ if spare.len() < SPARE_BUFFERS {
+ let mut buffer = frame.0;
+ buffer.clear();
+ spare.push(buffer);
+ }
+ }
+
+ fn set(&self, index: u32) -> Option<&Mutex<[Slot; WAYS]>> {
+ if self.sets.is_empty() {
+ return None;
+ }
+
+ Some(&self.sets[index as usize % self.sets.len()])
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn frame(byte: u8) -> Arc {
+ Arc::new(Frame::from(vec![byte; 16]))
+ }
+
+ #[test]
+ fn a_zero_budget_disables_caching() {
+ let cache = FrameCache::new(0, 16, 100);
+ assert_eq!(cache.capacity(), 0);
+
+ cache.insert(0, &frame(1));
+ assert!(cache.get(0).is_none());
+ }
+
+ #[test]
+ fn slots_never_exceed_the_frames_in_the_file() {
+ // Budget for 1000 frames, but the file only has 3.
+ let cache = FrameCache::new(16_000, 16, 3);
+ assert_eq!(cache.capacity(), WAYS);
+ }
+
+ #[test]
+ fn frames_round_trip_until_their_set_fills() {
+ // One set: four ways, so the fifth frame mapping to it evicts the coldest.
+ let cache = FrameCache::new(16 * WAYS, 16, WAYS);
+ assert_eq!(cache.sets.len(), 1);
+
+ for i in 0..WAYS as u32 {
+ cache.insert(i, &frame(i as u8));
+ }
+ // Touch 0 so it is no longer the coldest, then force one eviction.
+ assert!(cache.get(0).is_some());
+ cache.insert(99, &frame(99));
+
+ assert!(cache.get(0).is_some(), "recently used frame survived");
+ assert!(cache.get(99).is_some(), "newly inserted frame is resident");
+ assert!(cache.get(1).is_none(), "coldest frame was evicted");
+ }
+
+ #[test]
+ fn evicted_buffers_are_recycled() {
+ let cache = FrameCache::new(16 * WAYS, 16, WAYS);
+ for i in 0..WAYS as u32 + 1 {
+ cache.insert(i, &frame(i as u8));
+ }
+
+ // The evicted frame's allocation came back, so this buffer is not a fresh one.
+ let buffer = cache.take_buffer(16);
+ assert!(buffer.is_empty());
+ assert!(buffer.capacity() >= 16);
+ assert!(
+ cache.spare.lock().unwrap().is_empty(),
+ "buffer was handed out"
+ );
+ }
+
+ /// A frame still held by a caller must not be recycled out from under them.
+ #[test]
+ fn a_held_frame_is_not_recycled() {
+ let cache = FrameCache::new(16 * WAYS, 16, WAYS);
+ let held = frame(0);
+ cache.insert(0, &held);
+ for i in 1..WAYS as u32 + 1 {
+ cache.insert(i, &frame(i as u8));
+ }
+
+ assert_eq!(held.bytes(), &[0u8; 16]);
+ assert!(cache.spare.lock().unwrap().is_empty());
+ }
+}
diff --git a/crates/ltk_hashdb/src/extended.rs b/crates/ltk_hashdb/src/extended.rs
deleted file mode 100644
index 5968d5c..0000000
--- a/crates/ltk_hashdb/src/extended.rs
+++ /dev/null
@@ -1,59 +0,0 @@
-//! A [`HashDb`] plus an in-memory overlay of extra entries.
-
-use std::borrow::Cow;
-use std::collections::HashMap;
-
-use crate::HashDb;
-
-/// A `HashDb` with a mutable in-memory overlay (e.g. runtime mod hashes). The base
-/// file is never mutated; lookups consult the overlay first, then the table.
-pub struct ExtendedHashDb {
- base: HashDb,
- overlay: HashMap>,
-}
-
-impl ExtendedHashDb {
- pub fn new(base: HashDb) -> Self {
- Self {
- base,
- overlay: HashMap::new(),
- }
- }
-
- pub fn insert(&mut self, hash: u64, path: impl Into>) {
- self.overlay.insert(hash, path.into());
- }
-
- /// Hash `path` with the base table's algorithm, insert it, and return the hash -
- /// "register this path" without knowing the algorithm.
- pub fn insert_path(&mut self, path: &str) -> u64 {
- let hash = self.base.hash_path(path);
- self.insert(hash, path);
- hash
- }
-
- pub fn extend<'a>(&mut self, it: impl IntoIterator) {
- self.overlay
- .extend(it.into_iter().map(|(k, p)| (k, Box::from(p))));
- }
-
- /// Overlay first, then the base table.
- pub fn get(&self, hash: u64) -> Option> {
- match self.overlay.get(&hash) {
- Some(path) => Some(Cow::Borrowed(&**path)),
- None => self.base.get(hash),
- }
- }
-
- pub fn contains(&self, hash: u64) -> bool {
- self.overlay.contains_key(&hash) || self.base.contains(hash)
- }
-
- pub fn base(&self) -> &HashDb {
- &self.base
- }
-
- pub fn overlay_len(&self) -> usize {
- self.overlay.len()
- }
-}
diff --git a/crates/ltk_hashdb/src/layered.rs b/crates/ltk_hashdb/src/layered.rs
index 816953c..fd13d84 100644
--- a/crates/ltk_hashdb/src/layered.rs
+++ b/crates/ltk_hashdb/src/layered.rs
@@ -1,9 +1,9 @@
//! An in-memory overlay layered over an ordered list of read-only base tables.
-use std::borrow::Cow;
use std::collections::HashMap;
+use std::fmt;
-use crate::{Casing, HashDb, HashKind, KeyWidth};
+use crate::{Casing, HashDb, HashKind, KeyWidth, PathRef};
/// The key configuration a base hashes under. Every base in a [`LayeredHashDb`]
/// must agree on this triple; a base that diverges can never be hit by a caller's
@@ -15,9 +15,9 @@ fn base_config(db: &HashDb) -> (KeyWidth, HashKind, Casing) {
/// A writable in-memory overlay on top of ordered read-only [`HashDb`] bases.
///
/// Lookups consult the overlay first, then each base in push order; the first hit
-/// wins. Base files are never mutated. This generalises [`ExtendedHashDb`] (one
-/// base) to the N-base case consumers need when several tables (e.g. League's
-/// `game` and `lcu`) sit under one overlay.
+/// wins. Base files are never mutated. One base covers the "table plus my own
+/// runtime hashes" case; several cover the one consumers reach for when a workload
+/// spans more than one table (e.g. League's `game` and `lcu` under one overlay).
///
/// # Base configuration invariant
///
@@ -31,8 +31,6 @@ fn base_config(db: &HashDb) -> (KeyWidth, HashKind, Casing) {
/// [`from_bases`](Self::from_bases) `debug_assert!` this; release builds skip the
/// check. League's `game`/`lcu` tables are uniform (XXH64 / U64 / case-insensitive),
/// so the common path always satisfies it.
-///
-/// [`ExtendedHashDb`]: crate::ExtendedHashDb
#[derive(Default)]
pub struct LayeredHashDb {
overlay: HashMap>,
@@ -107,9 +105,9 @@ impl LayeredHashDb {
}
/// Overlay first, then each base in push order; the first hit wins.
- pub fn get(&self, hash: u64) -> Option> {
+ pub fn get(&self, hash: u64) -> Option> {
if let Some(path) = self.overlay.get(&hash) {
- return Some(Cow::Borrowed(&**path));
+ return Some(PathRef::borrowed(path));
}
self.bases.iter().find_map(|base| base.get(hash))
}
@@ -125,9 +123,9 @@ impl LayeredHashDb {
/// order. This is the payoff over calling [`get`](Self::get) N times.
pub fn get_batch<'a>(
&'a self,
- hashes: &'a [u64],
- ) -> impl Iterator>)> + 'a {
- let mut results: Vec