ltk_meta
Reading, editing and writing League of Legends property bins (.bin): the hierarchical, hash-keyed configuration format the game uses for almost all of its data - champions, items, UI, VFX, maps.
Two kinds of file share the .bin extension and this crate reads both:
PROP- a tree of objects with typed properties (Bin).PTCH- an override bin: deletions, extra objects and property patch records applied over a base bin (BinOverride).
Public surface
| Item | What it is |
|---|---|
Bin, BinObject |
The object tree: read (from_reader), build (builder()), edit, write (to_writer) |
BinStream, BinToc, ObjectEntry, BatchObjects |
Streaming access: mount a file, sweep object descriptors, random access by path hash, and whole batches of them at once - without parsing values |
ObjectView, PropertyView, ValueView |
Zero-copy views over one buffered object: iterate, look up and descend to any depth without materializing anything |
ObjectCache, NoCache, LruObjectCache |
The opt-in lookup cache BinStream::cached_object resolves through |
Numbering |
Which property-kind numbering a file is being read under, and whether the legacy latch has flipped |
property::values, PropertyValueEnum, PropertyKind |
One typed value struct per wire kind (I32, String, Vector3, Container, Map, Struct, Embedded, Optional, …) and the enum over them |
ValueSlot |
A mutable handle on one value, carrying the kind its holder pins it to - what resolve_mut hands back |
path::PropertyPath |
Property addressing (Position.Anchors.Anchor, Elements[3], Lookup{"weapon"}), with Bin::resolve, resolve_mut and Bin::patch |
path::ValuePath, path::ValueSegment, path::MapKey, path::FieldNames |
An address for any position inside an object, by hash and by position; its client path and readable form, spelled through a name table |
path::ValueShape |
What a value is - kind, item kind, map key kind, embed class - as the resolver's type rule and the streaming header peek both speak it |
BinOverride, ApplyReport |
PTCH files: read, build, check against / apply onto a base Bin |
MergeReport, Replaced |
Bin::merge: one bin layered over another, and every value it overwrote |
DiffReport, DiffOptions, Lift |
Bin::diff: the difference between two bins as a BinOverride, and every place a record carries more than the change |
BinFile, BinKind |
Reading a .bin when you don't know which kind it is |
traits |
ReadProperty / WriteProperty / PropertyExt (serialized size), for generic code over values |
Error |
One thiserror enum for the whole crate (miette diagnostics included) |
Object names and property names are stored as FNV-1a hashes of the lowercased string - compute them with ltk_hash's fnv1a::hash_lower. Integer literals and hashes convert freely where a name is expected.
Reading a bin
use File;
use Bin;
Streaming a bin
Bin::from_reader parses everything. When you only need the object table - harvesting hashes across thousands of files, or pulling one object's facts out of a big bin - BinStream mounts the file, reads just the header, and skips object bodies by their size fields. Hand mount the bare File; buffering is internal.
use File;
use BinStream;
The entries() sweep populates a BinToc (Clone, serializable with the serde feature) as a side effect, so toc() and object(hash) after a sweep cost no further reads.
Bin::from_reader is itself BinStream::mount plus into_bin(), so the streaming surface and the eager tree are one parser and cannot drift.
Reading inside an object
Descending buffers the object's declared byte range once and views it in place. Iteration, lookup by name hash and descent into nested values are all slice arithmetic from there; nothing decodes until it is touched and nothing allocates until an owned value is asked for.
use File;
use ;
ObjectStream::read() gives the owned BinObject instead, and BinStream::cached_object() resolves through an installed ObjectCache (NoCache by default, LruObjectCache shipped) and hands back an Arc. ObjectStream::byte_range() gives the object's extent in the file, for copying one out verbatim.
Asking what a value is without reading it
A complex value declares its shape in the few header bytes ahead of its body, so a PropertyView can say what it holds - and how many - at skip cost, without descending. shape() returns the same ValueShape the resolver's type rule uses, so "is this a Container[ObjectLink]?" is one comparison rather than a parse.
use File;
use ;
item_count() answers for containers and maps and is None for everything else, an option included - whether an option holds anything is OptionalView::is_some.
Legacy kind numbering
Bins written before WadChunkLink existed number the complex kinds one lower. A handle mounts as Numbering::Current and stays there until it meets a kind byte that only decodes as Numbering::Legacy: that object is re-walked from the bytes already in memory, and the handle is latched for the rest of its life. Nothing is re-read, and a genuinely desynced file can be reinterpreted this way rather than reported as broken - so numbering() is how to tell it happened.
use File;
use ;
A view carries the numbering it was built under, so one handed out before the flip keeps reading the way it started.
Hash widths
A Hash value occupies 4 or 8 bytes in a file. The file does not store the width. The reader resolves the width from the sizes that the file declares. Each reader returns a values::Hash. Its HashValue contains the hash and the width. The writer writes each value with the width of the value.
use ;
use values;
// A 4-byte hash: FNV-1a of the lowercased string.
let narrow = new;
assert_eq!;
// An 8-byte hash. The file does not store the hash function. A class dump contains it.
let material = Hasher ;
let wide = new;
assert_eq!;
assert_eq!;
All Hash items of a container have the same width. All Hash keys of a map have the same width, and all Hash values of a map have the same width. values::Hash::from_reader reads a Hash on its own as 4 bytes. values::Hash::from_reader_with_width reads a Hash of a given width.
Opening many objects at once
object(hash) answers one question per seek. objects_batch takes the whole request up front, so a cold handle resolves it during one forward scan that stops at the last hit, and a warm one visits the rows in offset order. Yield order is file order; missing() reports the hashes the file does not hold.
use File;
use BinStream;
Walking a bin
ltk_meta::walk is one traversal for both trees. The walk calls a Visitor once per node of an object, in pre-order and file order, and once for every property, leaves included, before it descends. The visitor answers a Visit that continues, prunes a property, stops or aborts. The visitor is generic over the tree: the same Census runs over an owned Bin and over a BinStream, where the walk materializes nothing.
use BinHash;
use ;
/// Counts nodes and records the address of every `Struct` of one class.
In parallel. The walk over one object is sequential by contract: one visitor, pre-order, Stop and Skip as ordered decisions. Objects are independent of one another, and every view, node and trail type is Send. A sweep parallelizes across objects with one visitor instance per worker and a reduce at the end. Nothing in the crate schedules this. The split is the caller's.
let objects: = bin.objects.values.collect;
let workers = available_parallelism.map_or;
let per_worker = objects.len.div_ceil.max;
let counted = scope?;
println!;
Across many files the same shape applies one level up: one task per file, each mounting its own BinStream and walking it sequentially. The per-object walk is microseconds. Decompression and I/O are where a sweep spends its time.
Editing and saving
walk::VisitorMut runs the same traversal over an owned object through &mut: a node
callback edits the node's properties, a property callback edits or replaces the value, and the
trail is the read-only walk's. BinDelta holds whole-object edits against a mounted
BinStream, and BinStream::write_patched writes the file with them applied: it copies every
object the delta does not name byte for byte, and encodes only the edited ones.
use ;
use ;
const NAME: BinHash = BinHash;
/// Rewrites every `String` under one field as the hash of its text.
let mut stream = mount?;
let mut delta = new;
let mut objects = stream.objects_batch;
while let Some = objects.next?
let mut out = Vecnew;
stream.write_patched?;
A handle latched onto the legacy kind numbering refuses the delta. into_bin() and
Bin::to_writer transcode the whole file instead.
Creating one programmatically
Build a tree with the builder, or the simple constructor:
use ;
Complex values are validated at construction - container items must share one kind, map keys must be a kind that can key a map - so Map::new and friends return Result:
use ;
Editing and writing back
use File;
use Cursor;
use ;
Round-tripping is a design goal: reading a shipped bin and writing it back reproduces it byte for byte.
Addressing a property by path
PropertyPath is the language a patch record uses to name what it overrides, and it works on any object: . steps into structs and embeds, [n] indexes containers, {key} looks up map entries.
use File;
use ;
resolve_mut is the unchecked way in: it hands back a ValueSlot on whatever is there and applies no type rule, where patch reproduces the client's. The slot carries the kind its holder pins the value to - a container declares its item kind once, ahead of the values, so set refuses a value of another kind there. Editing in place through as_mut needs no check at all, because it cannot change the kind.
use File;
use ;
Override bins (PTCH)
A patch only means something over the bin it patches. apply lays it over one in the order the client does, and reports what it could not apply instead of failing - which is also what the client does with a stale path. check answers the same question without touching anything, which is what to ask after a game update.
use File;
use ;
Merging and diffing
merge layers an edited bin over a base in place: the edit wins at every value it reaches, a map combines key by key, a struct or embed of the same class combines field by field, and whatever only the base holds survives. The report names every value the edit overwrote, and mismatched marks the ones whose shape differs - the mark a type migration leaves on a stale mod.
diff writes the difference as a BinOverride. A record cannot insert a map entry, change a value's shape or address a field with no name. At each of those places the diff writes one record at an ancestor holding the base merged with the edit, and reports a Lift. Applied to its base, the patch equals the merge.
use ;
use ;
use Bin;
Reading a .bin of unknown kind
The extension does not say which kind a file is, so either read it as a BinFile and match on what came back, or ask BinKind::identify_from_reader and call the reader you want - it leaves the magic in place, so the same reader can be handed straight on.
use File;
use BinFile;
Feature flags
serde:Serialize/Deserializeon the tree, values, paths and reports (also enables it on theglammath types they carry).
Related crates
ltk_ritobin: the human-readable text form of these bins, parsing to and from this crate's types.ltk_hash: the FNV-1a and xxhash functions the formats key everything by.ltk_wad: WAD archives, where the game's bins actually live.league-toolkit: umbrella crate that re-exports everything behind feature flags.
License
Licensed under the Apache License, Version 2.0.