Skip to main content

Crate ltk_meta

Crate ltk_meta 

Source
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 PROP bins: 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 a VisitorMut that edits the owned tree as it goes.

Structs§

ApplyReport
What laying a BinOverride over a Bin did, 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 PTCH bin file: a patch applied over exactly one base Bin.
BinOverrideBuilder
A builder for constructing BinOverride instances.
Builder
A builder for constructing Bin instances.
DiffOptions
How a diff treats what the edit leaves out.
DiffReport
What a diff produced.
MergeReport
What a merge did: what it overwrote, and what it added.
ObjectBuilder
A builder for constructing BinObject instances.
PropertyPatch
One patch record: set the property at path inside object object_hash to value.
Replaced
One value the edit overwrote.
SkippedPatch
One record that did not apply.

Enums§

BinFile
A bin file of either kind.
BinKind
The two kinds of bin file, told apart by their magic.
Error
Lift
A place the record language could not carry what the diff found there.