Expand description
Types for reading and writing League of Legends
property bin files (.bin).
Property bins are hierarchical data structures used throughout League’s game data. They contain objects with typed properties that can reference other objects and external files.
§Quick Start
§Reading a bin file
use std::fs::File;
use ltk_meta::Bin;
let mut file = File::open("data.bin")?;
let tree = Bin::from_reader(&mut file)?;
for (path_hash, object) in &tree.objects {
println!("Object {:08x} has {} properties", path_hash, object.properties.len());
}§Creating a bin file programmatically
use ltk_meta::{property::values, Bin, BinObject};
// Using the builder pattern
let tree = Bin::builder()
.dependency("common.bin")
.object(
BinObject::builder(0x12345678u32, 0xABCDEF00u32)
.property(0x1111, values::I32::new(42))
.property(0x2222, values::String::from("hello"))
.build()
)
.build();
// Or using the simple constructor
let tree = Bin::new(
[BinObject::new(0x1234u32, 0x5678u32)],
["dependency.bin"],
);§Streaming a bin file
Reading everything is the wrong shape for harvesting hashes across thousands of files or
peeking at one header. The stream module mounts a PROP file the way ltk_wad::Wad
mounts an archive and reads only what is asked for:
use std::fs::File;
use ltk_meta::BinStream;
let mut stream = BinStream::mount(File::open("data.bin")?)?;
println!("version {}, {} objects", stream.version(), stream.class_hashes().len());
for entry in stream.entries() {
let entry = entry?;
println!("{:08x}: {:08x}", entry.path_hash, entry.class_hash);
}Descending into an object buffers its declared byte range once and views it in place, so iteration and descent to any depth cost no I/O and materialize nothing:
use std::fs::File;
use ltk_meta::{BinStream, ValueView};
let mut stream = BinStream::mount(File::open("data.bin")?)?;
let mut objects = stream.objects();
while let Some(mut object) = objects.next()? {
for property in object.view()?.properties() {
if let ValueView::String(text) = property?.value_view()? {
println!("{text}");
}
}
}Bin::from_reader is itself BinStream::mount plus BinStream::into_bin, so the eager
tree and the streaming surface are one parser and cannot drift.
§Walking a bin
walk is one traversal for both trees. The walk calls a Visitor once per
node, in pre-order and file order. The visitor answers a Visit that continues,
prunes a property, stops or aborts. The same visitor runs over an owned Bin and over a
BinStream, where the walk materializes nothing.
use ltk_hash::BinHash;
use ltk_meta::{
walk::{Node, TreeValue, Visit, Visitor},
Error,
};
/// Counts nodes and records the address of every `Struct` of one class.
#[derive(Default)]
struct Census {
nodes: usize,
hits: Vec<(BinHash, String)>,
}
impl<'a, V: TreeValue<'a>> Visitor<'a, V> for Census {
type Error = Error;
fn enter_node(&mut self, node: &Node<'_, 'a, V>) -> Result<Visit, Error> {
self.nodes += 1;
if *node.class_hash() == 0x1e6b_a0c4 {
self.hits.push((node.object_hash(), node.trail().to_string()));
}
Ok(Visit::Continue)
}
}
let mut census = Census::default();
bin.walk(&mut census)?;The walk over one object is sequential by contract. Objects are independent, and every view,
node and trail type is Send. A sweep parallelizes across objects: one visitor instance per
worker, reduced at the end. The split is the caller’s. The crate schedules nothing.
let objects: Vec<_> = bin.objects.values().collect();
let workers = std::thread::available_parallelism().map_or(1, |n| n.get());
let per_worker = objects.len().div_ceil(workers).max(1);
let counted = std::thread::scope(|scope| {
let workers: Vec<_> = objects
.chunks(per_worker)
.map(|chunk| {
scope.spawn(move || {
let mut census = Census::default();
for object in chunk {
object.walk(&mut census)?;
}
Ok::<_, Error>(census)
})
})
.collect();
let mut all = Census::default();
for worker in workers {
let census = worker.join().expect("a worker panicked")?;
all.nodes += census.nodes;
all.hits.extend(census.hits);
}
Ok::<_, Error>(all)
})?;
println!("{} nodes, {} hits", counted.nodes, counted.hits.len());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 std::io::Cursor;
use ltk_hash::{BinHash, Hash as _};
use ltk_meta::{
property::values,
walk::{PropertyRefMut, Visit, VisitorMut},
BinDelta, BinStream, Error, PropertyValueEnum,
};
const NAME: BinHash = BinHash(0x0000_0002);
/// Rewrites every `String` under one field as the hash of its text.
#[derive(Default)]
struct Rehash {
changed: usize,
}
impl VisitorMut for Rehash {
type Error = Error;
fn enter_property(&mut self, property: &mut PropertyRefMut<'_>) -> Result<Visit, Error> {
if property.field() != NAME {
return Ok(Visit::Continue);
}
let PropertyValueEnum::String(text) = property.value() else {
return Ok(Visit::Continue);
};
let hash = values::Hash::new(BinHash::hash_str(&text.value));
*property.value_mut() = hash.into();
self.changed += 1;
Ok(Visit::Continue)
}
}
let mut stream = BinStream::mount(Cursor::new(bytes))?;
let mut delta = BinDelta::new();
let mut objects = stream.objects_batch([0x1111_0001u32]);
while let Some(mut object) = objects.next()? {
let mut object = object.read()?;
let mut rehash = Rehash::default();
object.walk_mut(&mut rehash)?;
if rehash.changed > 0 {
delta.replace(object);
}
}
let mut out = Vec::new();
stream.write_patched(&delta, &mut out)?;A handle latched onto the legacy kind numbering refuses the delta. into_bin() and
Bin::to_writer transcode the whole file instead.
§Modifying a bin file
use std::fs::File;
use std::io::Cursor;
use ltk_meta::{Bin, BinObject};
let mut file = File::open("data.bin")?;
let mut tree = Bin::from_reader(&mut file)?;
// Add a new object
tree.add_object(BinObject::new(0x11112222, 0x33334444));
// Remove an object
tree.remove_object(0x55556666);
// Write back
let mut output = Cursor::new(Vec::new());
tree.to_writer(&mut output)?;§Reading an override bin
Some bins are PTCH files: a patch of deletions, extra objects and property patch
records applied over one base bin. They are BinOverride, not Bin.
use std::fs::File;
use ltk_meta::BinOverride;
let mut file = File::open("uiflipped.bin")?;
let patch_bin = BinOverride::from_reader(&mut file)?;
for patch in &patch_bin.patches {
// e.g. `4a47c414 Position.Anchors.Anchor = Vector2`
println!("{:08x} {} = {:?}", patch.object_hash, patch.path, patch.kind());
}§Applying an override bin
A patch only means something over the bin it patches. BinOverride::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. BinOverride::check answers the same question
without touching anything, which is what to ask after a game update.
use std::fs::File;
use ltk_meta::{Bin, BinOverride};
let mut base = Bin::from_reader(&mut File::open("uibase.bin")?)?;
let patch_bin = BinOverride::from_reader(&mut File::open("uiflipped.bin")?)?;
// Does it still fit?
let report = patch_bin.check(&base);
println!("{report}"); // "109 applied (5 inserted), 0 skipped, ..."
if report.is_clean() {
patch_bin.apply(&mut base);
}§Addressing a property by path
PropertyPath is the language a patch record uses to name what it
overrides, and it works on any object: Position.UIRect.Size, Elements[3],
Lookup{"weapon"}.
use std::fs::File;
use ltk_meta::{path::PropertyPath, property::values, Bin};
let mut bin = Bin::from_reader(&mut File::open("uibase.bin")?)?;
let anchor = PropertyPath::new("Position.Anchors.Anchor")?;
println!("{:?}", bin.resolve(0x4a47c414_u32, &anchor)?);
// `patch` follows the client's type rule: the shape has to match, or nothing changes.
bin.patch(
0x4a47c414_u32,
&anchor,
values::Vector2::new(glam::Vec2::new(0.0, 1.0)).into(),
)?;§Merging and diffing bins
Bin::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 combines field by field, and whatever only the base
holds survives. Bin::diff writes the difference as a BinOverride, and names every place a
record carries more than the change in DiffReport::lifted. Applied to its base, the patch
equals the merge.
use std::collections::HashMap;
use ltk_hash::{BinHash, Hash as _};
use ltk_meta::{
property::values,
Bin, BinObject,
property::Kind,
Lift,
};
let lookup = BinHash::hash_str("Lookup");
let names = HashMap::from([(lookup, "Lookup".to_owned())]);
let bin = |entries: &[(u32, i32)]| {
let entries = entries
.iter()
.map(|(key, value)| (values::Hash::new(*key).into(), values::I32::new(*value).into()))
.collect();
let map = values::Map::new(Kind::Hash, Kind::I32, entries).unwrap();
Bin::builder()
.object(BinObject::builder(0x1u32, 0xc1u32).property(lookup, map).build())
.build()
};
let base = bin(&[(1, 10), (2, 20)]);
let edited = bin(&[(2, 21), (3, 30)]);
let mut merged = base.clone();
let report = merged.merge(&edited);
assert_eq!(merged, bin(&[(1, 10), (2, 21), (3, 30)]));
assert_eq!((report.replaced.len(), report.keys_inserted), (1, 1));
// No record inserts a map entry: the map goes whole into one record, key 1 included.
let (patch, report) = base.diff(&edited, &names);
assert!(matches!(report.lifted[..], [Lift::MapInsert { keys: 1, .. }]));
let mut applied = base.clone();
patch.apply(&mut applied);
assert_eq!(applied, merged);§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 std::fs::File;
use ltk_meta::{Bin, BinFile, BinKind, BinOverride};
let mut file = File::open("unknown.bin")?;
match BinFile::from_reader(&mut file)? {
BinFile::Prop(bin) => println!("{} objects", bin.objects.len()),
BinFile::Override(patch_bin) => println!("{} patches", patch_bin.patches.len()),
}
let mut file = File::open("unknown.bin")?;
match BinKind::identify_from_reader(&mut file)? {
BinKind::Prop => { let bin = Bin::from_reader(&mut file)?; }
BinKind::Override => { let patch_bin = BinOverride::from_reader(&mut file)?; }
}Re-exports§
pub use property::Kind as PropertyKind;pub use property::PropertyValueEnum;pub use property::ValueSlot;pub use stream::BatchObjects;pub use stream::BinDelta;pub use stream::BinStream;pub use stream::BinToc;pub use stream::ContainerItems;pub use stream::ContainerView;pub use stream::Entries;pub use stream::LruObjectCache;pub use stream::MapEntries;pub use stream::MapView;pub use stream::NoCache;pub use stream::Numbering;pub use stream::ObjectCache;pub use stream::ObjectEntry;pub use stream::ObjectStream;pub use stream::ObjectView;pub use stream::Objects;pub use stream::OptionalView;pub use stream::Properties;pub use stream::PropertyView;pub use stream::StructView;pub use stream::ValueView;
Modules§
- path
- The property path language:
Position.UIRect.Size,Elements[3],Lookup{"weapon"}. - property
- stream
- Streaming access to
PROPbins: mount a file, read only what is asked for. - traits
- walk
- One traversal over every node of a bin object, driven by a
Visitor, or by aVisitorMutthat edits the owned tree as it goes.
Structs§
- Apply
Report - What laying a
BinOverrideover aBindid, or would do. - Bin
- The complete contents of a League of Legends property bin file.
- BinObject
- A node/object in the bin tree.
- BinOverride
- The contents of a
PTCHbin file: a patch applied over exactly one baseBin. - BinOverride
Builder - A builder for constructing
BinOverrideinstances. - Builder
- A builder for constructing
Bininstances. - Diff
Options - How a diff treats what the edit leaves out.
- Diff
Report - What a diff produced.
- Merge
Report - What a merge did: what it overwrote, and what it added.
- Object
Builder - A builder for constructing
BinObjectinstances. - Property
Patch - One patch record: set the property at
pathinside objectobject_hashtovalue. - Replaced
- One value the edit overwrote.
- Skipped
Patch - One record that did not apply.