ltk_wad
Reading, extracting and building League of Legends WAD archives, the .wad.client files that hold the assets of the game.
- A WAD is a table of chunks followed by their bytes.
- Each chunk is one file, compressed on its own with gzip or zstd, or stored as is.
- The archive keeps no file names. The key of a chunk is the xxh64 of its lower-case path, typed as
WadHash. - A name comes back only through a hash table, such as the one CommunityDragon maintains, or out of the
.binfiles inside the archive.
The crate supports reading all versions and writing the latest one, and is part of League Toolkit. The umbrella crate exposes it as league_toolkit::wad behind the wad feature.
[]
= "0.4"
= "0.4" # to hash paths yourself
Contents
- Feature flags
- Reading an archive
- Naming chunks
- Extracting to disk
- Recovering names from the bins
- Building an archive
- Signatures
- Parallel pipelines
Feature flags
| Feature | Default | What it does |
|---|---|---|
zstd |
yes | zstd through the zstd crate, which builds the C library |
ruzstd |
no | zstd in pure Rust, for targets with no C toolchain. Slower, and exclusive with zstd |
rust_backend |
no | ruzstd plus the pure Rust backend of flate2, so nothing in the crate compiles C |
serde |
no | Serialize and Deserialize for Wad, its chunk table and each chunk |
Reading an archive
use File;
use Hash as _;
use ;
Wad::mountreads the header and the chunk table, and keeps the source open. AnyRead + Seekis a source.- The bytes of a chunk come off the source when you ask for them.
load_chunk_rawgives the compressed bytes.decompress_rawor aChunkDecoderturns them into the file.Wadkeeps one decoder, so a run ofload_chunk_decompressedcalls pays for one zstd context.
On WadHash:
WadHash::hash_stris a method of theltk_hash::Hashtrait, which is why the example imports it.- A hash you already hold is
WadHash(value). WadHashparses from its sixteen hex digits withstr::parse, and prints as hex.
Naming chunks
Whatever names the chunks is a PathResolver: resolve(hash) answers the path, or None for a hash it has no name for. These are resolvers:
- every
HashMap<WadHash, String> - a reference, a
Boxor anArcof any resolver NoResolver, which names nothingRecoveredNames, the names read out of the bins, andoverlayers it on top of another resolver
A CommunityDragon hash table is one name per line, as the hash in hex, a space, and the path:
use HashMap;
use WadHash;
let names: = read_to_string?
.lines
.filter_map
.filter_map
.collect;
Extracting to disk
use ;
let mut extractor = new
.with_filter
.with_existing_file_policy
.on_progress;
let report = extractor.extract_all?;
println!;
What the builder takes:
with_filterkeeps the chunks whose path the closure accepts. An unnamed chunk shows as its hash.with_type_filterkeeps only the givenLeagueFileKinds, by what the bytes identify as.with_layout(ExtractLayout::Flat)drops the directories. A second chunk of one name takesname.<hash>.ext.with_existing_file_policy(ExistingFilePolicy::Skip)leaves a file that exists, and counts it.on_progresshears of each chunk once it is done, with the count, the path, the result and the bytes.with_cancel_flagtakes anAtomicBool, and the reader tests it before each chunk.with_workerssets the thread count. The default is the parallelism of the machine, capped at eight.with_name_recoveryreads the bins for the names the resolver lacks. See below.
What it runs:
extract_allextracts every chunk.extract_chunkstakes the hashes of the chunks to extract, and lists the ones the archive lacks underreport.missing.
How it runs:
- The reader walks the archive in order on the calling thread.
- It hands each chunk to a worker over a channel bounded by the worker count, and the worker decompresses and writes it.
- The resolver, the path filter and the progress callback stay on the calling thread, so none of them needs to be
Sync.
Where a chunk lands on disk:
| The chunk | Lands at |
|---|---|
| The resolver names it | Its path, with every directory of it |
| Nothing names it | Its hash as sixteen hex digits, with the extension its bytes identify as |
| Its name has no extension, or is an existing directory | <stem>.ltk.<ext>, or <stem>.ltk when the bytes identify as nothing |
| Its name is too long for the file system | <hash>.<ext> in the output directory itself |
What comes back:
- An
ExtractReportcounts the chunks written, skipped and missing, the bytes, and the chunks by kind. It prints as one line. - A chunk that fails to read, decompress or write fails the extraction with
WadError::Chunk, which wraps the cause with the hash and the path of the chunk.
Recovering names from the bins
The .bin files of League name the assets they use by path. So the names of many chunks a hash table lacks sit inside the archive itself. with_name_recovery() reads them out before the extraction writes anything:
let report = new
.with_name_recovery
.extract_all?;
println!;
A match is exact: a string counts only when its hash is the hash of a chunk the resolver could not name. The pass stays cheap:
- Nothing runs when the resolver names every chunk.
- The scan finds a bin by its name, or by its first bytes, which it decodes from the first compressed block alone.
- It reads the strings out as the length-prefixed runs the format writes, with no parse of the object tree.
- The bins decompress on the worker threads.
On an archive of 30,000 chunks the pass takes tens of milliseconds with a hash table, and under a second with none. NameRecovery runs the same scan on its own, and returns the RecoveredNames.
Building an archive
use ;
use ;
let mut out = new;
default
.with_chunk
.with_chunk
.build_to_writer?;
- The builder writes the chunks in hash order, which the game requires.
- It picks the compression of each chunk by what its bytes identify as, unless
with_force_compressionsays otherwise. with_pathhashes a path for the chunk, andwith_hashtakes a hash you already hold.with_prebuilt_signatureandwith_prebuilt_checksumkeep the header of a mounted archive, for a byte-identical rebuild.
Signatures
Riot signs the chunk table. verify_signature checks the embedded PKCS#1 v1.5 signature over the SHA-256 of the table, and RITO_PKEY is the public key of Riot:
use ;
use RITO_PKEY;
let key = from_public_key_der?;
let = wad.verify_signature?;
Parallel pipelines
load_chunk_raw and decompress_raw split the I/O from the CPU work, so a pipeline reads the archive in order and decompresses wherever it likes:
use ;
let chunks: = wad.chunks.iter.copied.collect;
for chunk in &chunks
Three ways to decode:
decompress_rawdecodes one chunk, and builds a zstd context for it.ChunkDecoderkeeps one zstd context between chunks, the right tool for a thread that decodes many.decompress_prefix, on either, decodes the first bytes of a chunk alone from a prefix of its raw bytes, enough to tell its kind.
License
MIT OR Apache-2.0, at your option.