diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index d40ab024..26568042 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -86,6 +86,24 @@ - Dependency additions MUST be justified — avoid pulling in large dependency trees for trivial functionality. +### VI. Code Style & Formatting + +- Separate distinct code contexts (imports, struct definitions, impl blocks, + helper functions, tests) with a single blank line. +- Within a function, use blank lines to separate logical steps (setup, + processing, result). Do not insert blank lines between tightly coupled + sequential statements. +- Group `use` statements by origin: std first, then external crates, then + internal crate modules, each group separated by a blank line. +- Comments MUST only be added for code with complex or non-obvious behavior. + Do not document self-explanatory code, simple accessors, or trivial logic. +- Section banners (e.g., `// ── Reading ──`) are allowed to visually separate + major logical regions within a file. Keep them consistent in style. +- Prefer short, focused functions over long ones. If a function exceeds ~50 + lines, consider whether it can be decomposed. +- `rustfmt` is authoritative for all brace/indent/whitespace decisions — + do not fight the formatter. + ## Error Handling & Safety - Each crate MUST define its own error type via `thiserror` and a @@ -125,4 +143,4 @@ - Runtime development guidance lives in `CLAUDE.md` at the repository root. -**Version**: 2.0.0 | **Ratified**: 2026-03-07 | **Last Amended**: 2026-03-25 +**Version**: 2.1.0 | **Ratified**: 2026-03-07 | **Last Amended**: 2026-03-26 diff --git a/CLAUDE.md b/CLAUDE.md index 89931405..cc700752 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,9 @@ All crates live under `crates/`. The dependency graph flows upward: **Math**: All vector/matrix types use `glam` (Vec2, Vec3, Vec4, Mat4, Quat). -**Hashing**: WAD paths are XXHash64 (64-bit) of lowercased paths. Bin object/property names are FNV-1a (32-bit) hashes via `ltk_hash::fnv1a::hash_lower()`. +**Hashing**: WAD paths are XXHash64 (64-bit) of lowercased paths. Bin object/property names are FNV-1a (32-bit) hashes via `ltk_hash::fnv1a::hash_lower()`. Inibin keys are SDBM hashes via `ltk_hash::sdbm::hash_inibin_key(section, property)`. + +**I/O utilities**: `ltk_io_ext` provides shared reader/writer extension traits (`ReaderExt`, `WriterExt`) with helpers like `read_str_until_nul()`, `read_padded_string()`, `read_sized_string_u16()`, etc. Format crates MUST use these instead of implementing their own — do not duplicate I/O helpers. ## Crate Layout Convention @@ -82,3 +84,9 @@ Shared dependency versions are declared in the root `Cargo.toml` under `[workspa ## Additional Context The `docs/LTK_GUIDE.md` file contains detailed crate-by-crate API documentation with usage examples, file format references, and hash algorithm details. Consult it for format-specific questions. + +## Active Technologies +- `ltk_inibin`: Rust + `thiserror`, `byteorder`, `ltk_io_ext`, `ltk_hash` (SDBM hashing), `glam` (Vec2/Vec3/Vec4), `bitflags` (ValueFlags), `indexmap` (ordered storage) (001-inibin-crate) + +## Recent Changes +- 001-inibin-crate: Added `ltk_inibin` crate — inibin/troybin parser with all 14 value types, `ValueFlags` bitfield, unified `as_*()` accessors, `sdbm::hash_inibin_key()` convenience diff --git a/crates/league-toolkit/Cargo.toml b/crates/league-toolkit/Cargo.toml index 6fcbf2a9..196832f3 100644 --- a/crates/league-toolkit/Cargo.toml +++ b/crates/league-toolkit/Cargo.toml @@ -13,6 +13,7 @@ readme = "../../README.md" default = [ "anim", "file", + "inibin", "mesh", "meta", "primitives", @@ -25,6 +26,7 @@ serde = ["ltk_wad/serde", "ltk_file/serde", "ltk_meta/serde"] anim = ["dep:ltk_anim"] file = ["dep:ltk_file"] +inibin = ["dep:ltk_inibin"] mesh = ["dep:ltk_mesh"] meta = ["dep:ltk_meta"] primitives = ["dep:ltk_primitives"] @@ -35,6 +37,7 @@ hash = ["dep:ltk_hash"] [dependencies] ltk_anim = { version = "0.3.2", path = "../ltk_anim", optional = true } ltk_file = { version = "0.2.8", path = "../ltk_file", optional = true } +ltk_inibin = { version = "0.1.0", path = "../ltk_inibin", optional = true } ltk_mesh = { version = "0.4.1", path = "../ltk_mesh", optional = true } ltk_meta = { version = "0.4.0", path = "../ltk_meta", optional = true } ltk_primitives = { version = "0.3.2", path = "../ltk_primitives", optional = true } diff --git a/crates/league-toolkit/src/lib.rs b/crates/league-toolkit/src/lib.rs index 4f577afc..43568833 100644 --- a/crates/league-toolkit/src/lib.rs +++ b/crates/league-toolkit/src/lib.rs @@ -21,3 +21,6 @@ pub use ltk_wad as wad; #[cfg(feature = "hash")] pub use ltk_hash as hash; + +#[cfg(feature = "inibin")] +pub use ltk_inibin as inibin; diff --git a/crates/ltk_hash/src/lib.rs b/crates/ltk_hash/src/lib.rs index bbb2f0e2..842a341e 100644 --- a/crates/ltk_hash/src/lib.rs +++ b/crates/ltk_hash/src/lib.rs @@ -1,3 +1,4 @@ //! Other utilities (hashing, etc) pub mod elf; pub mod fnv1a; +pub mod sdbm; diff --git a/crates/ltk_hash/src/sdbm.rs b/crates/ltk_hash/src/sdbm.rs new file mode 100644 index 00000000..3bf84f1f --- /dev/null +++ b/crates/ltk_hash/src/sdbm.rs @@ -0,0 +1,117 @@ +/// Compute SDBM hash of a lowercased string. +pub fn hash_lower(input: impl AsRef) -> u32 { + let mut hash: u32 = 0; + for c in input.as_ref().chars().flat_map(|c| c.to_lowercase()) { + let mut buf = [0u8; 4]; + let encoded = c.encode_utf8(&mut buf); + for &byte in encoded.as_bytes() { + hash = (byte as u32) + .wrapping_add(hash.wrapping_shl(6)) + .wrapping_add(hash.wrapping_shl(16)) + .wrapping_sub(hash); + } + } + hash +} + +/// Compute SDBM hash of an inibin `section*property` key pair (lowercased, `*` delimiter). +/// +/// Convenience wrapper around [`hash_lower_with_delimiter`] with the standard inibin delimiter. +/// +/// ``` +/// # use ltk_hash::sdbm; +/// let key = sdbm::hash_inibin_key("DATA", "AttackRange"); +/// assert_eq!(key, sdbm::hash_lower_with_delimiter("DATA", "AttackRange", '*')); +/// ``` +pub fn hash_inibin_key(section: impl AsRef, property: impl AsRef) -> u32 { + hash_lower_with_delimiter(section, property, '*') +} + +/// Compute SDBM hash of two strings joined by a delimiter, all lowercased. +/// +/// For inibin keys, prefer [`hash_inibin_key`] which defaults the `*` delimiter. +pub fn hash_lower_with_delimiter(a: impl AsRef, b: impl AsRef, delimiter: char) -> u32 { + let mut hash: u32 = 0; + + let chars = a + .as_ref() + .chars() + .chain(std::iter::once(delimiter)) + .chain(b.as_ref().chars()) + .flat_map(|c| c.to_lowercase()); + + for c in chars { + let mut buf = [0u8; 4]; + let encoded = c.encode_utf8(&mut buf); + for &byte in encoded.as_bytes() { + hash = (byte as u32) + .wrapping_add(hash.wrapping_shl(6)) + .wrapping_add(hash.wrapping_shl(16)) + .wrapping_sub(hash); + } + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_lower_basic() { + // SDBM hash of "test" (all lowercase) + let h = hash_lower("test"); + // Verify case-insensitivity + assert_eq!(h, hash_lower("TEST")); + assert_eq!(h, hash_lower("TeSt")); + } + + #[test] + fn test_hash_lower_empty() { + assert_eq!(hash_lower(""), 0); + } + + #[test] + fn test_hash_lower_with_delimiter() { + let h1 = hash_lower_with_delimiter("DATA", "AttackRange", '*'); + let h2 = hash_lower("data*attackrange"); + assert_eq!(h1, h2); + } + + #[test] + fn test_hash_lower_with_delimiter_case_insensitive() { + let h1 = hash_lower_with_delimiter("DATA", "AttackRange", '*'); + let h2 = hash_lower_with_delimiter("data", "attackrange", '*'); + assert_eq!(h1, h2); + } + + #[test] + fn test_hash_inibin_key() { + let h1 = hash_inibin_key("DATA", "AttackRange"); + let h2 = hash_lower_with_delimiter("DATA", "AttackRange", '*'); + assert_eq!(h1, h2); + // Case insensitive + assert_eq!(h1, hash_inibin_key("data", "attackrange")); + } + + #[test] + fn test_hash_lower_accepts_string() { + let s = String::from("test"); + assert_eq!(hash_lower(&s), hash_lower("test")); + assert_eq!(hash_lower(s), hash_lower("test")); + } + + #[test] + fn test_hash_lower_with_delimiter_accepts_string() { + let a = String::from("DATA"); + let b = String::from("AttackRange"); + assert_eq!( + hash_lower_with_delimiter(&a, &b, '*'), + hash_lower_with_delimiter("DATA", "AttackRange", '*') + ); + assert_eq!( + hash_lower_with_delimiter(a, b, '*'), + hash_lower_with_delimiter("DATA", "AttackRange", '*') + ); + } +} diff --git a/crates/ltk_inibin/Cargo.toml b/crates/ltk_inibin/Cargo.toml new file mode 100644 index 00000000..a3d4c448 --- /dev/null +++ b/crates/ltk_inibin/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ltk_inibin" +version = "0.1.0" +edition = "2021" +description = "Inibin/Troybin parser for League Toolkit" +license = "MIT OR Apache-2.0" +readme = "README.md" + +[dependencies] +thiserror = { workspace = true } +byteorder = { workspace = true } +bitflags = { workspace = true } +glam = { workspace = true } +indexmap = { workspace = true } +ltk_io_ext = { version = "0.4.1", path = "../ltk_io_ext" } +ltk_hash = { version = "0.2.5", path = "../ltk_hash/" } + +[dev-dependencies] +approx = { workspace = true } diff --git a/crates/ltk_inibin/README.md b/crates/ltk_inibin/README.md new file mode 100644 index 00000000..dd6898cc --- /dev/null +++ b/crates/ltk_inibin/README.md @@ -0,0 +1,250 @@ +# ltk_inibin + +Rust library for reading, writing, and modifying League of Legends **inibin** and **troybin** binary configuration files. + +Part of the [League Toolkit](https://github.com/LeagueToolkit/league-toolkit) workspace. + +## Overview + +Inibin (`.inibin`) and troybin (`.troybin`) are legacy binary key-value formats used by the League of Legends engine for champion stats, spell data, item properties, particle effects, and map configuration. Both file extensions share the same binary format. + +This crate provides: + +- Parsing of version 1 (legacy, read-only) and version 2 (read+write) inibin files +- Key-based public API for reading, inserting, updating, and deleting values +- Round-trip integrity: parse a file and write it back to produce identical output +- Support for all 14 value set types + +## Installation + +```toml +[dependencies] +ltk_inibin = "0.1" +``` + +Or via the umbrella crate: + +```toml +[dependencies] +league-toolkit = { version = "0.2", features = ["inibin"] } +``` + +## Quick Start + +### Reading an inibin file + +```rust,no_run +use std::fs::File; +use std::io::BufReader; +use ltk_inibin::Inibin; + +let file = File::open("data/characters/annie/annie.inibin").unwrap(); +let mut reader = BufReader::new(file); +let inibin = Inibin::from_reader(&mut reader).unwrap(); + +// Look up a value by its u32 hash key +if let Some(value) = inibin.get(0xABCD1234) { + println!("Value: {:?}", value); +} +``` + +### Hashing section/property keys + +Inibin keys are SDBM hashes of `section*property` (lowercased, `*` as delimiter). Use `ltk_hash::sdbm` to compute them: + +```rust,ignore +use ltk_hash::sdbm; + +let key = sdbm::hash_lower_with_delimiter("DATA", "AttackRange", '*'); +let value = inibin.get(key); +``` + +### Modifying values + +```rust +use ltk_inibin::{Inibin, Value}; + +let mut inibin = Inibin::new(); + +// Insert values of different types +inibin.insert(0x0001, 550.0f32); +inibin.insert(0x0002, 42i32); +inibin.insert(0x0003, "hello"); +inibin.insert(0x0004, 9999999999i64); + +// Remove a value +inibin.remove(0x0001); + +// Update: re-inserting with a different type migrates across buckets +inibin.insert(0x0002, 3.125f32); +``` + +### Writing an inibin file + +```rust,no_run +use std::fs::File; +use std::io::BufWriter; +use ltk_inibin::Inibin; + +# let inibin = Inibin::new(); +let file = File::create("output.inibin").unwrap(); +let mut writer = BufWriter::new(file); +inibin.to_writer(&mut writer).unwrap(); +``` + +### Round-trip + +```rust +use std::io::Cursor; +use ltk_inibin::{Inibin, Value}; + +let mut file = Inibin::new(); +file.insert(0x0001, 42i32); + +let mut buf = Vec::new(); +file.to_writer(&mut buf).unwrap(); + +let mut cursor = Cursor::new(&buf); +let file2 = Inibin::from_reader(&mut cursor).unwrap(); + +assert_eq!(file2.get(0x0001), Some(&Value::I32(42))); +``` + +### Iterating values + +```rust +use ltk_inibin::{Inibin, Value, ValueFlags}; + +let mut inibin = Inibin::new(); +inibin.insert(0x0001, 1i32); +inibin.insert(0x0002, 2.0f32); + +// Iterate all key-value pairs across all buckets +for (key, value) in inibin.iter() { + println!("0x{:08X} = {:?}", key, value); +} + +// Access a specific section +if let Some(int_section) = inibin.section(ValueFlags::INT32_LIST) { + println!("Int32 section has {} entries", int_section.len()); + // Use .keys(), .values(), or .iter() + for key in int_section.keys() { + println!(" key: 0x{:08X}", key); + } +} +``` + +## Binary Format + +### Version 2 (canonical, read+write) + +| Offset | Size | Type | Description | +|--------|------|------|-------------| +| 0 | 1 | `u8` | Version (`2`) | +| 1 | 2 | `u16` LE | String data length | +| 3 | 2 | `u16` LE | Flags bitfield (14 bits) | + +Followed by set data for each flag bit that is set (in bit order 0-13), with StringList (bit 12) always last. + +### Version 1 (legacy, read-only) + +| Offset | Size | Type | Description | +|--------|------|------|-------------| +| 0 | 1 | `u8` | Version (`1`) | +| 1 | 3 | `[u8; 3]` | Padding | +| 4 | 4 | `u32` LE | Value count | +| 8 | 4 | `u32` LE | String data length | + +Followed by `value_count` hash keys (`u32` LE), then a single StringList set. + +### Non-String Set Layout + +Each non-string set is: +1. `u16` LE — value count +2. `count` x `u32` LE — hash keys +3. Value data (format depends on set type) + +### StringList Set Layout + +1. `u16` LE — value count +2. `count` x `u32` LE — hash keys +3. `count` x `u16` LE — string offsets (relative to string data start) +4. Null-terminated ASCII string data + +## Value Set Types + +All 14 types and their corresponding flag bits: + +| Flag | Bit | Type | Rust Variant | Encoding | +|------|-----|------|-------------|----------| +| `INT32_LIST` | 0 | `i32` | `Value::I32` | 4 bytes LE | +| `F32_LIST` | 1 | `f32` | `Value::F32` | 4 bytes LE | +| `U8_LIST` | 2 | `u8` | `Value::U8` | 1 byte raw; `as_f32()` returns `byte * 0.1` (0.0-25.5) | +| `INT16_LIST` | 3 | `i16` | `Value::I16` | 2 bytes LE | +| `INT8_LIST` | 4 | `u8` | `Value::I8` | 1 byte | +| `BIT_LIST` | 5 | `bool` | `Value::Bool` | 8 booleans packed per byte | +| `VEC3_U8_LIST` | 6 | `[u8; 3]` | `Value::Vec3U8` | 3 bytes raw; `as_vec3()` decodes | +| `VEC3_F32_LIST` | 7 | `Vec3` | `Value::Vec3F32` | 3x `f32` LE | +| `VEC2_U8_LIST` | 8 | `[u8; 2]` | `Value::Vec2U8` | 2 bytes raw; `as_vec2()` decodes | +| `VEC2_F32_LIST` | 9 | `Vec2` | `Value::Vec2F32` | 2x `f32` LE | +| `VEC4_U8_LIST` | 10 | `[u8; 4]` | `Value::Vec4U8` | 4 bytes raw; `as_vec4()` decodes | +| `VEC4_F32_LIST` | 11 | `Vec4` | `Value::Vec4F32` | 4x `f32` LE | +| `STRING_LIST` | 12 | `String` | `Value::String` | Null-terminated ASCII with offset table | +| `INT64_LIST` | 13 | `i64` | `Value::I64` | 8 bytes LE | + +### U8 (Fixed-Point Float) Encoding + +The `U8` types (flags 2, 6, 8, 10) store floats as raw bytes. Use the unified `as_*()` accessors to decode: +- `Value::U8(byte)` → `as_f32()` returns `byte * 0.1` (range 0.0 to 25.5) +- `Value::Vec2U8([a, b])` → `as_vec2()` returns `Vec2::new(a * 0.1, b * 0.1)` +- `Value::Vec3U8([a, b, c])` → `as_vec3()` returns `Vec3::new(a * 0.1, b * 0.1, c * 0.1)` +- `Value::Vec4U8([a, b, c, d])` → `as_vec4()` returns `Vec4::new(a * 0.1, b * 0.1, c * 0.1, d * 0.1)` + +These accessors also work on the non-packed variants (`F32`, `Vec2F32`, etc.), returning the value directly. + +### BitList Encoding + +Booleans are packed 8 per byte. For a set with `n` values, `ceil(n / 8)` bytes are read/written. Bits are extracted in order from LSB to MSB within each byte. + +## Architecture + +### Bucket-Based Storage + +Internally, `Inibin` stores data in **sections** — one `Section` per active flag type. Each section holds an `IndexMap` where keys are SDBM hashes. + +The public API is **key-based**: methods like `get`, `insert`, and `remove` search across all buckets transparently. When inserting a value, the library routes it to the correct bucket based on the value's type. If a key already exists in a different-type bucket, it is removed from the old bucket first. + +### Error Handling + +```rust +pub enum Error { + UnsupportedVersion(u8), // Version byte is not 1 or 2 + U8FloatOverflow(f32), // Fixed-point float outside 0.0-25.5 on write + Io(std::io::Error), // Underlying I/O error +} +``` + +### Trait Bounds + +- **Reading**: `from_reader` — requires seeking for StringList offset resolution +- **Writing**: `to_writer` — sequential writes only + +## Hash Algorithm + +Inibin keys use **SDBM** hashing (multiplier 65599) applied to lowercased `section*property` strings: + +``` +hash = 0 +for each byte in lowercase("section*property"): + hash = byte + (hash << 6) + (hash << 16) - hash +``` + +The hash implementation lives in `ltk_hash::sdbm`. Use `hash_lower_with_delimiter(section, property, '*')` to compute keys. + +## Name Resolution + +For resolving hash keys back to human-readable `(section, property)` pairs, see the companion crate [`ltk_inibin_names`](../ltk_inibin_names/). + +## License + +MIT OR Apache-2.0 diff --git a/crates/ltk_inibin/examples/create_inibin.rs b/crates/ltk_inibin/examples/create_inibin.rs new file mode 100644 index 00000000..a4edcf56 --- /dev/null +++ b/crates/ltk_inibin/examples/create_inibin.rs @@ -0,0 +1,121 @@ +//! Create an inibin file from scratch and write it to disk. +//! +//! Demonstrates: +//! - Constructing an empty `Inibin` and inserting all value types +//! - Using `From` convenience for common types (i32, f32, &str, etc.) +//! - Using `Value::*` variants for packed/vector types +//! - Computing hash keys with `ltk_hash::sdbm` +//! - Writing to a file with `to_writer` +//! - Reading back values with `get_as` and `as_*()` unified accessors +//! +//! Usage: cargo run -p ltk_inibin --example create_inibin -- + +use glam::{Vec2, Vec3, Vec4}; +use ltk_inibin::{Inibin, Value}; +use std::{fs::File, io::BufWriter}; + +fn main() { + let path = std::env::args().nth(1).unwrap_or_else(|| { + eprintln!("Usage: create_inibin "); + std::process::exit(1); + }); + + let mut inibin = Inibin::new(); + + // ── Scalar types (From convenience) ────────────────────── + inibin.insert(0x0001, 42i32); + inibin.insert(0x0002, 550.0f32); + inibin.insert(0x0003, "hello world"); + inibin.insert(0x0004, true); + inibin.insert(0x0005, -100i16); + inibin.insert(0x0006, 9999999999i64); + + // ── Packed U8 types (raw bytes, decode with as_*()) ───────── + inibin.insert(0x0100, Value::U8(200)); // as_f32() → 20.0 + inibin.insert(0x0101, Value::Vec2U8([50, 100])); // as_vec2() → (5.0, 10.0) + inibin.insert(0x0102, Value::Vec3U8([10, 20, 30])); // as_vec3() → (1.0, 2.0, 3.0) + inibin.insert(0x0103, Value::Vec4U8([25, 50, 75, 100])); // as_vec4() → (2.5, 5.0, 7.5, 10.0) + + // ── F32 vector types ──────────────────────────────────────── + inibin.insert(0x0200, Value::Vec2F32(Vec2::new(1.5, 2.5))); + inibin.insert(0x0201, Value::Vec3F32(Vec3::new(1.0, 2.0, 3.0))); + inibin.insert(0x0202, Value::Vec4F32(Vec4::new(1.1, 2.2, 3.3, 4.4))); + + // ── Using SDBM hash for real inibin keys ──────────────────── + let attack_range_key = ltk_hash::sdbm::hash_inibin_key("DATA", "AttackRange"); + inibin.insert(attack_range_key, 550.0f32); + + // String keys work too + let name = String::from("DATA"); + let prop = String::from("MoveSpeed"); + let move_speed_key = ltk_hash::sdbm::hash_inibin_key(name, prop); + inibin.insert(move_speed_key, 345.0f32); + + println!("Created inibin with {} entries", inibin.len()); + + // ── Write to file ─────────────────────────────────────────── + let file = File::create(&path).unwrap_or_else(|e| { + eprintln!("Failed to create {path}: {e}"); + std::process::exit(1); + }); + let mut writer = BufWriter::new(file); + inibin.to_writer(&mut writer).unwrap(); + println!("Written to {path}"); + + // ── Reading values back ───────────────────────────────────── + println!(); + println!("Typed access with get_as:"); + println!(" i32: {:?}", inibin.get_as::(0x0001)); + println!(" f32: {:?}", inibin.get_as::(0x0002)); + println!(" string: {:?}", inibin.get_as::<&str>(0x0003)); + println!(" bool: {:?}", inibin.get_as::(0x0004)); + println!(" i16: {:?}", inibin.get_as::(0x0005)); + println!(" i64: {:?}", inibin.get_as::(0x0006)); + + // get_or returns a default on missing key or type mismatch + println!(" missing with default: {}", inibin.get_or(0x9999, 0i32)); + + // ── Unified as_*() accessors for packed/non-packed floats ─── + println!(); + println!("Unified as_*() accessors:"); + + // as_f32() works on both F32 and U8 variants + println!( + " F32 value: {:?}", + inibin.get(0x0002).and_then(|v| v.as_f32()) + ); + println!( + " U8 packed: {:?}", + inibin.get(0x0100).and_then(|v| v.as_f32()) + ); + + // as_vec2() works on both Vec2F32 and Vec2U8 variants + println!( + " Vec2F32 value: {:?}", + inibin.get(0x0200).and_then(|v| v.as_vec2()) + ); + println!( + " Vec2U8 packed: {:?}", + inibin.get(0x0101).and_then(|v| v.as_vec2()) + ); + + // as_vec3() works on both Vec3F32 and Vec3U8 variants + println!( + " Vec3F32 value: {:?}", + inibin.get(0x0201).and_then(|v| v.as_vec3()) + ); + println!( + " Vec3U8 packed: {:?}", + inibin.get(0x0102).and_then(|v| v.as_vec3()) + ); + + // as_vec4() works on both Vec4F32 and Vec4U8 variants + println!( + " Vec4F32 value: {:?}", + inibin.get(0x0202).and_then(|v| v.as_vec4()) + ); + println!( + " Vec4U8 packed: {:?}", + inibin.get(0x0103).and_then(|v| v.as_vec4()) + ); +} diff --git a/crates/ltk_inibin/examples/inspect_inibin.rs b/crates/ltk_inibin/examples/inspect_inibin.rs new file mode 100644 index 00000000..2877d46b --- /dev/null +++ b/crates/ltk_inibin/examples/inspect_inibin.rs @@ -0,0 +1,164 @@ +//! Inspect an inibin file: print summary statistics and query specific keys. +//! +//! Demonstrates: +//! - File summary (entry counts per section type) +//! - Looking up specific keys by SDBM hash +//! - Using `as_*()` unified accessors to decode packed values +//! - Filtering and searching across all entries +//! - Section-level `.keys()` and `.values()` iterators +//! +//! Usage: cargo run -p ltk_inibin --example inspect_inibin -- [section*property ...] + +use ltk_inibin::{Inibin, Value, ValueFlags}; +use std::{fs::File, io::BufReader}; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: inspect_inibin [section*property ...]"); + eprintln!(); + eprintln!("Examples:"); + eprintln!(" inspect_inibin annie.inibin"); + eprintln!(" inspect_inibin annie.inibin DATA*AttackRange DATA*MoveSpeed"); + std::process::exit(1); + } + + let path = &args[1]; + let queries: Vec<&str> = args[2..].iter().map(|s| s.as_str()).collect(); + + // ── Parse ─────────────────────────────────────────────────── + let file = File::open(path).unwrap_or_else(|e| { + eprintln!("Failed to open {path}: {e}"); + std::process::exit(1); + }); + let mut reader = BufReader::new(file); + let inibin = Inibin::from_reader(&mut reader).unwrap_or_else(|e| { + eprintln!("Failed to parse {path}: {e}"); + std::process::exit(1); + }); + + // ── Summary ───────────────────────────────────────────────── + println!("File: {path}"); + println!("Total entries: {}", inibin.len()); + println!(); + + let section_names = [ + (ValueFlags::INT32_LIST, "Int32"), + (ValueFlags::F32_LIST, "Float32"), + (ValueFlags::U8_LIST, "U8 (packed float)"), + (ValueFlags::INT16_LIST, "Int16"), + (ValueFlags::INT8_LIST, "Int8"), + (ValueFlags::BIT_LIST, "Bool"), + (ValueFlags::VEC3_U8_LIST, "Vec3U8"), + (ValueFlags::VEC3_F32_LIST, "Vec3F32"), + (ValueFlags::VEC2_U8_LIST, "Vec2U8"), + (ValueFlags::VEC2_F32_LIST, "Vec2F32"), + (ValueFlags::VEC4_U8_LIST, "Vec4U8"), + (ValueFlags::VEC4_F32_LIST, "Vec4F32"), + (ValueFlags::STRING_LIST, "String"), + (ValueFlags::INT64_LIST, "Int64"), + ]; + + println!("Sections:"); + for (flags, name) in §ion_names { + if let Some(section) = inibin.section(*flags) { + println!(" {name:20} {: >5} entries", section.len()); + } + } + println!(); + + // ── Key queries ───────────────────────────────────────────── + if queries.is_empty() { + println!("Tip: pass section*property pairs as arguments to query specific keys."); + println!(" e.g.: inspect_inibin {path} DATA*AttackRange"); + return; + } + + println!("Queries:"); + for query in &queries { + // Split on '*' to get section and property + let parts: Vec<&str> = query.splitn(2, '*').collect(); + let (section, property) = if parts.len() == 2 { + (parts[0], parts[1]) + } else { + eprintln!(" {query}: invalid format (expected section*property)"); + continue; + }; + + let hash = ltk_hash::sdbm::hash_inibin_key(section, property); + print!(" {query} (0x{hash:08X}): "); + + match inibin.get(hash) { + Some(value) => { + print_decoded(value); + println!(); + } + None => println!("not found"), + } + } + + // ── Find all string values ────────────────────────────────── + if let Some(string_section) = inibin.section(ValueFlags::STRING_LIST) { + println!(); + println!("All string values ({} entries):", string_section.len()); + for (key, value) in string_section.iter() { + if let Value::String(s) = value { + println!(" 0x{key:08X} = {s:?}"); + } + } + } + + // ── Find all float-like values using unified accessors ────── + println!(); + println!("All float-like values (F32 + packed U8):"); + let mut count = 0; + for (key, value) in inibin.iter() { + if let Some(f) = value.as_f32() { + let kind = if matches!(value, Value::U8(_)) { + "packed" + } else { + "f32" + }; + println!(" 0x{key:08X} = {f:.2} ({kind})"); + count += 1; + if count >= 20 { + let remaining = inibin.iter().filter(|(_, v)| v.as_f32().is_some()).count() - 20; + if remaining > 0 { + println!(" ... and {remaining} more"); + } + break; + } + } + } +} + +fn print_decoded(value: &Value) { + match value { + Value::I32(v) => print!("{v} (i32)"), + Value::F32(v) => print!("{v} (f32)"), + Value::U8(v) => print!("{} (u8, decoded: {:.1})", v, value.as_f32().unwrap()), + Value::I16(v) => print!("{v} (i16)"), + Value::I8(v) => print!("{v} (u8 raw)"), + Value::Bool(v) => print!("{v}"), + Value::Vec2U8(_) => { + let d = value.as_vec2().unwrap(); + print!("[{:.1}, {:.1}] (vec2 packed)", d.x, d.y); + } + Value::Vec2F32(v) => print!("[{}, {}] (vec2)", v.x, v.y), + Value::Vec3U8(_) => { + let d = value.as_vec3().unwrap(); + print!("[{:.1}, {:.1}, {:.1}] (vec3 packed)", d.x, d.y, d.z); + } + Value::Vec3F32(v) => print!("[{}, {}, {}] (vec3)", v.x, v.y, v.z), + Value::Vec4U8(_) => { + let d = value.as_vec4().unwrap(); + print!( + "[{:.1}, {:.1}, {:.1}, {:.1}] (vec4 packed)", + d.x, d.y, d.z, d.w + ); + } + Value::Vec4F32(v) => print!("[{}, {}, {}, {}] (vec4)", v.x, v.y, v.z, v.w), + Value::String(v) => print!("{v:?}"), + Value::I64(v) => print!("{v} (i64)"), + } +} diff --git a/crates/ltk_inibin/examples/modify_inibin.rs b/crates/ltk_inibin/examples/modify_inibin.rs new file mode 100644 index 00000000..9924d74f --- /dev/null +++ b/crates/ltk_inibin/examples/modify_inibin.rs @@ -0,0 +1,102 @@ +//! Read an inibin file, modify values, and write the result. +//! +//! Demonstrates: +//! - Reading and modifying an existing inibin file +//! - Inserting, updating, and removing values by hash key +//! - Cross-bucket migration (changing a value's type) +//! - Using `contains_key` and `get_or` for safe access +//! - Section-level access with `section()` and `section_mut()` +//! - Hashing section/property keys with `ltk_hash::sdbm` +//! +//! Usage: cargo run -p ltk_inibin --example modify_inibin -- + +use ltk_inibin::{Inibin, ValueFlags}; +use std::{ + fs::File, + io::{BufReader, BufWriter}, +}; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + eprintln!("Usage: modify_inibin "); + std::process::exit(1); + } + let input_path = &args[1]; + let output_path = &args[2]; + + // ── Read ──────────────────────────────────────────────────── + let file = File::open(input_path).unwrap_or_else(|e| { + eprintln!("Failed to open {input_path}: {e}"); + std::process::exit(1); + }); + let mut reader = BufReader::new(file); + let mut inibin = Inibin::from_reader(&mut reader).unwrap_or_else(|e| { + eprintln!("Failed to parse {input_path}: {e}"); + std::process::exit(1); + }); + + println!("Read {input_path}: {} entries", inibin.len()); + + // ── Insert new values ─────────────────────────────────────── + let key = ltk_hash::sdbm::hash_inibin_key("DATA", "AttackRange"); + let old = inibin.get_or(key, 0.0f32); + inibin.insert(key, 625.0f32); + println!("Updated AttackRange: {old} -> 625.0"); + + // ── Safe access patterns ──────────────────────────────────── + let move_speed_key = ltk_hash::sdbm::hash_inibin_key("DATA", "MoveSpeed"); + if inibin.contains_key(move_speed_key) { + println!("MoveSpeed exists: {:?}", inibin.get(move_speed_key)); + } else { + println!("MoveSpeed not found, inserting default"); + inibin.insert(move_speed_key, 345.0f32); + } + + // get_or returns a default on missing key or type mismatch + let armor: f32 = inibin.get_or(0xDEAD, 30.0f32); + println!("Armor (or default): {armor}"); + + // ── Cross-bucket migration ────────────────────────────────── + // Inserting with a different type automatically moves the entry + inibin.insert(0xFF01, 42i32); + println!("Inserted 0xFF01 as i32"); + inibin.insert(0xFF01, 42.0f32); + println!("Re-inserted 0xFF01 as f32 (migrated from Int32 -> Float32 section)"); + + // Verify it moved + assert!(inibin + .section(ValueFlags::INT32_LIST) + .and_then(|s| s.get(0xFF01)) + .is_none()); + assert!(inibin + .section(ValueFlags::F32_LIST) + .and_then(|s| s.get(0xFF01)) + .is_some()); + + // ── Remove a value ────────────────────────────────────────── + if let Some(removed) = inibin.remove(0xFF01) { + println!("Removed 0xFF01: {removed:?}"); + } + + // ── Section-level inspection ──────────────────────────────── + if let Some(float_section) = inibin.section(ValueFlags::F32_LIST) { + println!("\nFloat32 section: {} entries", float_section.len()); + for (key, value) in float_section.iter().take(5) { + println!(" 0x{key:08X} = {value:?}"); + } + if float_section.len() > 5 { + println!(" ... and {} more", float_section.len() - 5); + } + } + + // ── Write ─────────────────────────────────────────────────── + let file = File::create(output_path).unwrap_or_else(|e| { + eprintln!("Failed to create {output_path}: {e}"); + std::process::exit(1); + }); + let mut writer = BufWriter::new(file); + inibin.to_writer(&mut writer).unwrap(); + + println!("\nWritten {output_path}: {} entries", inibin.len()); +} diff --git a/crates/ltk_inibin/examples/read_inibin.rs b/crates/ltk_inibin/examples/read_inibin.rs new file mode 100644 index 00000000..9dd27d0a --- /dev/null +++ b/crates/ltk_inibin/examples/read_inibin.rs @@ -0,0 +1,109 @@ +//! Read an inibin/troybin file and print all key-value pairs, grouped by section. +//! +//! Demonstrates: +//! - Parsing with `Inibin::from_reader` +//! - Iterating all entries with `inibin.iter()` +//! - Accessing individual sections with `inibin.section()` +//! - Using section `.keys()`, `.values()`, `.iter()` iterators +//! - Using `Value::flags()` to inspect value types +//! - Using unified `as_*()` accessors for decoded output +//! +//! Usage: cargo run -p ltk_inibin --example read_inibin -- + +use ltk_inibin::{Inibin, Value, ValueFlags}; +use std::{fs::File, io::BufReader}; + +fn main() { + let path = std::env::args().nth(1).unwrap_or_else(|| { + eprintln!("Usage: read_inibin "); + std::process::exit(1); + }); + + let file = File::open(&path).unwrap_or_else(|e| { + eprintln!("Failed to open {path}: {e}"); + std::process::exit(1); + }); + + let mut reader = BufReader::new(file); + let inibin = Inibin::from_reader(&mut reader).unwrap_or_else(|e| { + eprintln!("Failed to parse {path}: {e}"); + std::process::exit(1); + }); + + println!("{path}: {} total entries", inibin.len()); + println!(); + + // ── Per-section summary ───────────────────────────────────── + let section_types = [ + (ValueFlags::INT32_LIST, "Int32"), + (ValueFlags::F32_LIST, "Float32"), + (ValueFlags::U8_LIST, "U8 (packed float)"), + (ValueFlags::INT16_LIST, "Int16"), + (ValueFlags::INT8_LIST, "Int8"), + (ValueFlags::BIT_LIST, "BitList (bool)"), + (ValueFlags::VEC3_U8_LIST, "Vec3U8 (packed)"), + (ValueFlags::VEC3_F32_LIST, "Vec3F32"), + (ValueFlags::VEC2_U8_LIST, "Vec2U8 (packed)"), + (ValueFlags::VEC2_F32_LIST, "Vec2F32"), + (ValueFlags::VEC4_U8_LIST, "Vec4U8 (packed)"), + (ValueFlags::VEC4_F32_LIST, "Vec4F32"), + (ValueFlags::STRING_LIST, "String"), + (ValueFlags::INT64_LIST, "Int64"), + ]; + + for (flags, name) in §ion_types { + if let Some(section) = inibin.section(*flags) { + println!("── {name} ({} entries) ──", section.len()); + + for (key, value) in section.iter() { + print!(" 0x{key:08X} = "); + print_value(value); + println!(); + } + println!(); + } + } + + // ── Demonstrate .keys() / .values() ───────────────────────── + if let Some(section) = inibin.section(ValueFlags::F32_LIST) { + println!("── Float32 keys only ──"); + for key in section.keys() { + println!(" 0x{key:08X}"); + } + println!(); + } +} + +fn print_value(value: &Value) { + match value { + Value::I32(v) => print!("{v}"), + Value::F32(v) => print!("{v}"), + Value::U8(v) => print!("{v} (decoded: {:.1})", *v as f32 * 0.1), + Value::I16(v) => print!("{v}"), + Value::I8(v) => print!("{v}"), + Value::Bool(v) => print!("{v}"), + Value::Vec3U8(v) => { + let decoded = value.as_vec3().unwrap(); + print!( + "{v:?} (decoded: [{:.1}, {:.1}, {:.1}])", + decoded.x, decoded.y, decoded.z + ); + } + Value::Vec3F32(v) => print!("[{}, {}, {}]", v.x, v.y, v.z), + Value::Vec2U8(v) => { + let decoded = value.as_vec2().unwrap(); + print!("{v:?} (decoded: [{:.1}, {:.1}])", decoded.x, decoded.y); + } + Value::Vec2F32(v) => print!("[{}, {}]", v.x, v.y), + Value::Vec4U8(v) => { + let decoded = value.as_vec4().unwrap(); + print!( + "{v:?} (decoded: [{:.1}, {:.1}, {:.1}, {:.1}])", + decoded.x, decoded.y, decoded.z, decoded.w + ); + } + Value::Vec4F32(v) => print!("[{}, {}, {}, {}]", v.x, v.y, v.z, v.w), + Value::String(v) => print!("{v:?}"), + Value::I64(v) => print!("{v}"), + } +} diff --git a/crates/ltk_inibin/examples/round_trip.rs b/crates/ltk_inibin/examples/round_trip.rs new file mode 100644 index 00000000..28534715 --- /dev/null +++ b/crates/ltk_inibin/examples/round_trip.rs @@ -0,0 +1,71 @@ +//! Round-trip an inibin file: read -> write -> read, then verify equality. +//! +//! Demonstrates: +//! - Full round-trip workflow (parse → serialize → re-parse → compare) +//! - Writing to an in-memory buffer with `to_writer` +//! - Comparing two `Inibin` instances entry-by-entry +//! +//! Usage: cargo run -p ltk_inibin --example round_trip -- + +use std::{fs::File, io::BufReader}; + +fn main() { + let path = std::env::args().nth(1).unwrap_or_else(|| { + eprintln!("Usage: round_trip "); + std::process::exit(1); + }); + + let file = File::open(&path).unwrap_or_else(|e| { + eprintln!("Failed to open {path}: {e}"); + std::process::exit(1); + }); + + let mut reader = BufReader::new(file); + let original = ltk_inibin::Inibin::from_reader(&mut reader).unwrap_or_else(|e| { + eprintln!("Failed to parse {path}: {e}"); + std::process::exit(1); + }); + + println!("Read {path}: {} entries", original.len()); + + // Write to memory + let mut buf = Vec::new(); + original.to_writer(&mut buf).unwrap(); + println!("Written: {} bytes", buf.len()); + + // Read back + let mut cursor = std::io::Cursor::new(&buf); + let roundtripped = ltk_inibin::Inibin::from_reader(&mut cursor).unwrap(); + println!("Re-read: {} entries", roundtripped.len()); + + // Compare + let mut mismatches = 0; + for (key, value) in original.iter() { + match roundtripped.get(key) { + Some(rt_value) if rt_value == value => {} + Some(rt_value) => { + println!(" MISMATCH 0x{key:08X}: {value:?} vs {rt_value:?}"); + mismatches += 1; + } + None => { + println!(" MISSING 0x{key:08X}: {value:?}"); + mismatches += 1; + } + } + } + + // Check for entries in roundtripped that are not in original + for (key, _) in roundtripped.iter() { + if original.get(key).is_none() { + println!(" EXTRA 0x{key:08X}"); + mismatches += 1; + } + } + + if mismatches == 0 { + println!("Round-trip OK!"); + } else { + println!("{mismatches} mismatches found"); + std::process::exit(1); + } +} diff --git a/crates/ltk_inibin/src/error.rs b/crates/ltk_inibin/src/error.rs new file mode 100644 index 00000000..8d606df4 --- /dev/null +++ b/crates/ltk_inibin/src/error.rs @@ -0,0 +1,13 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("unsupported inibin version: {0}")] + UnsupportedVersion(u8), + + #[error("string data length mismatch: header says {expected}, actual is {actual}")] + StringDataLengthMismatch { expected: u16, actual: u16 }, + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +pub type Result = core::result::Result; diff --git a/crates/ltk_inibin/src/file.rs b/crates/ltk_inibin/src/file.rs new file mode 100644 index 00000000..aeee1eab --- /dev/null +++ b/crates/ltk_inibin/src/file.rs @@ -0,0 +1,386 @@ +use std::io::{Read, Seek, Write}; + +use byteorder::{LittleEndian as LE, ReadBytesExt, WriteBytesExt}; +use indexmap::IndexMap; + +use crate::error::{Error, Result}; +use crate::section::Section; +use crate::value::{FromValue, Value}; +use crate::value_flags::{ValueFlags, NON_STRING_KINDS}; + +/// Top-level inibin/troybin file container. +#[derive(Debug, Clone, PartialEq)] +pub struct Inibin { + pub(crate) sections: IndexMap, +} + +impl Inibin { + pub fn new() -> Self { + Self { + sections: IndexMap::new(), + } + } + + /// Parse an inibin file from a seekable reader. + /// Supports version 1 (legacy) and version 2. + pub fn from_reader(reader: &mut R) -> Result { + let version = reader.read_u8()?; + match version { + 2 => Self::read_v2(reader), + 1 => Self::read_v1(reader), + _ => Err(Error::UnsupportedVersion(version)), + } + } + + fn read_v2(reader: &mut R) -> Result { + let string_data_length = reader.read_u16::()?; + let flags = ValueFlags::from_bits_truncate(reader.read_u16::()?); + let mut sections = IndexMap::new(); + + for &flag in &NON_STRING_KINDS { + if flags.contains(flag) { + let set = Section::read_non_string(reader, flag)?; + sections.insert(flag, set); + } + } + + // StringList is always read last; validate string_data_length from the header + if flags.contains(ValueFlags::STRING_LIST) { + let count_pos = reader.stream_position()?; + let value_count = reader.read_u16::()? as u64; + reader.seek(std::io::SeekFrom::Start(count_pos))?; + + let string_data_offset = count_pos + 2 + value_count * 4 + value_count * 2; + let set = Section::read_string_list(reader, string_data_offset)?; + + let actual = set.string_data_length(); + if actual != string_data_length { + return Err(Error::StringDataLengthMismatch { + expected: string_data_length, + actual, + }); + } + + sections.insert(ValueFlags::STRING_LIST, set); + } + + Ok(Self { sections }) + } + + fn read_v1(reader: &mut R) -> Result { + let mut _padding = [0u8; 3]; + reader.read_exact(&mut _padding)?; + + let value_count = reader.read_u32::()? as usize; + let _string_data_length = reader.read_u32::()?; + + let mut hashes = Vec::with_capacity(value_count); + for _ in 0..value_count { + hashes.push(reader.read_u32::()?); + } + + let offsets_start = reader.stream_position()?; + let string_data_offset = offsets_start + (value_count as u64) * 2; + let set = Section::read_string_list_v1(reader, hashes, string_data_offset)?; + + let mut sections = IndexMap::new(); + sections.insert(ValueFlags::STRING_LIST, set); + + Ok(Self { sections }) + } + + /// Write as version 2 inibin format. + pub fn to_writer(&self, writer: &mut W) -> Result<()> { + let mut flags = ValueFlags::empty(); + for &flag in self.sections.keys() { + flags |= flag; + } + + let string_data_length = self + .sections + .get(&ValueFlags::STRING_LIST) + .map(|s| s.string_data_length()) + .unwrap_or(0); + + // Header + writer.write_u8(2)?; + writer.write_u16::(string_data_length)?; + writer.write_u16::(flags.bits())?; + + // Non-string sets in flag order + for &flag in &NON_STRING_KINDS { + if let Some(set) = self.sections.get(&flag) { + if flag == ValueFlags::BIT_LIST { + set.write_bit_list(writer)?; + } else { + set.write_non_string(writer)?; + } + } + } + + // StringList last + if let Some(set) = self.sections.get(&ValueFlags::STRING_LIST) { + let string_data = set.write_string_list(writer)?; + writer.write_all(&string_data)?; + } + + Ok(()) + } + + // ── Key-based public API ─────────────────────────────────────── + + pub fn get(&self, key: u32) -> Option<&Value> { + for set in self.sections.values() { + if let Some(value) = set.get(key) { + return Some(value); + } + } + None + } + + /// Get a typed value by key, returning `None` on missing key or type mismatch. + /// + /// ``` + /// # use ltk_inibin::{Inibin, Value}; + /// let mut inibin = Inibin::new(); + /// inibin.insert(0x0001, 42i32); + /// inibin.insert(0x0002, "hello"); + /// + /// let v: Option = inibin.get_as(0x0001); + /// assert_eq!(v, Some(42)); + /// + /// let s: Option<&str> = inibin.get_as(0x0002); + /// assert_eq!(s, Some("hello")); + /// + /// // Type mismatch returns None + /// let wrong: Option = inibin.get_as(0x0001); + /// assert_eq!(wrong, None); + /// ``` + pub fn get_as<'a, T: FromValue<'a>>(&'a self, key: u32) -> Option { + self.get(key).and_then(T::from_inibin_value) + } + + /// Get a typed value by key, returning `default` on missing key or type mismatch. + /// + /// ``` + /// # use ltk_inibin::Inibin; + /// let mut inibin = Inibin::new(); + /// inibin.insert(0x0001, 42i32); + /// + /// assert_eq!(inibin.get_or(0x0001, 0i32), 42); + /// assert_eq!(inibin.get_or(0x9999, 0i32), 0); // missing key + /// assert_eq!(inibin.get_or(0x0001, 0.0f32), 0.0); // type mismatch + /// ``` + pub fn get_or<'a, T: FromValue<'a>>(&'a self, key: u32, default: T) -> T { + self.get_as(key).unwrap_or(default) + } + + pub fn contains_key(&self, key: u32) -> bool { + self.get(key).is_some() + } + + /// Insert or update a value, routing to the correct bucket by type. + /// If the key exists in a different-type bucket, removes it first. + /// + /// ``` + /// # use ltk_inibin::Inibin; + /// let mut inibin = Inibin::new(); + /// inibin.insert(0x0001, 42i32); + /// inibin.insert(0x0002, "hello"); + /// ``` + pub fn insert(&mut self, key: u32, value: impl Into) { + let value = value.into(); + let target_flags = value.flags(); + + for (&flag, set) in self.sections.iter_mut() { + if flag != target_flags { + set.remove(key); + } + } + + self.sections + .entry(target_flags) + .or_insert_with(|| Section::new(target_flags)) + .insert(key, value); + } + + pub fn remove(&mut self, key: u32) -> Option { + for set in self.sections.values_mut() { + if let Some(value) = set.remove(key) { + return Some(value); + } + } + None + } + + pub fn len(&self) -> usize { + self.sections.values().map(|s| s.len()).sum() + } + + pub fn is_empty(&self) -> bool { + self.sections.values().all(|s| s.is_empty()) + } + + pub fn iter(&self) -> impl Iterator { + self.sections.values().flat_map(|set| set.iter()) + } + + pub fn section(&self, flags: ValueFlags) -> Option<&Section> { + self.sections.get(&flags) + } + + pub fn section_mut(&mut self, flags: ValueFlags) -> Option<&mut Section> { + self.sections.get_mut(&flags) + } +} + +impl Default for Inibin { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::io::Cursor; + + #[test] + fn test_unsupported_version() { + let data = vec![3u8]; + let mut cursor = Cursor::new(data); + let result = Inibin::from_reader(&mut cursor); + assert!(matches!(result, Err(Error::UnsupportedVersion(3)))); + } + + #[test] + fn test_read_v2_empty() { + let mut data = Vec::new(); + data.push(2); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + + let mut cursor = Cursor::new(data); + let file = Inibin::from_reader(&mut cursor).unwrap(); + assert!(file.sections.is_empty()); + } + + #[test] + fn test_read_v2_with_int32() { + let mut data = Vec::new(); + data.push(2); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&(ValueFlags::INT32_LIST.bits()).to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0x12345678u32.to_le_bytes()); + data.extend_from_slice(&99i32.to_le_bytes()); + + let mut cursor = Cursor::new(data); + let file = Inibin::from_reader(&mut cursor).unwrap(); + + assert_eq!(file.get(0x12345678), Some(&Value::I32(99))); + } + + #[test] + fn test_read_v2_with_string_list() { + let mut data = Vec::new(); + data.push(2); + data.extend_from_slice(&6u16.to_le_bytes()); + data.extend_from_slice(&(ValueFlags::STRING_LIST.bits()).to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0xAABBCCDDu32.to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(b"hello\0"); + + let mut cursor = Cursor::new(data); + let file = Inibin::from_reader(&mut cursor).unwrap(); + + assert_eq!( + file.get(0xAABBCCDD), + Some(&Value::String("hello".to_string())) + ); + } + + #[test] + fn test_read_v1() { + let mut data = Vec::new(); + data.push(1); + data.extend_from_slice(&[0u8; 3]); + data.extend_from_slice(&1u32.to_le_bytes()); + data.extend_from_slice(&4u32.to_le_bytes()); + data.extend_from_slice(&0x11223344u32.to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(b"foo\0"); + + let mut cursor = Cursor::new(data); + let file = Inibin::from_reader(&mut cursor).unwrap(); + + assert_eq!( + file.get(0x11223344), + Some(&Value::String("foo".to_string())) + ); + } + + #[test] + fn test_get_missing_key() { + let file = Inibin::new(); + assert_eq!(file.get(0x12345678), None); + assert!(!file.contains_key(0x12345678)); + } + + #[test] + fn test_insert_and_get() { + let mut file = Inibin::new(); + file.insert(0xABCD, Value::I32(42)); + assert_eq!(file.get(0xABCD), Some(&Value::I32(42))); + assert!(file.contains_key(0xABCD)); + } + + #[test] + fn test_insert_cross_bucket_migration() { + let mut file = Inibin::new(); + file.insert(0xABCD, Value::I32(42)); + assert!(file.section(ValueFlags::INT32_LIST).is_some()); + + file.insert(0xABCD, Value::F32(3.125)); + + assert_eq!(file.get(0xABCD), Some(&Value::F32(3.125))); + assert!(file + .section(ValueFlags::INT32_LIST) + .map(|s| s.get(0xABCD).is_none()) + .unwrap_or(true)); + } + + #[test] + fn test_remove() { + let mut file = Inibin::new(); + file.insert(0xABCD, Value::I32(42)); + + let removed = file.remove(0xABCD); + assert_eq!(removed, Some(Value::I32(42))); + assert!(!file.contains_key(0xABCD)); + } + + #[test] + fn test_round_trip_v2() { + let mut file = Inibin::new(); + file.insert(0x0001, Value::I32(42)); + file.insert(0x0002, Value::F32(3.125)); + file.insert(0x0003, Value::I16(-100)); + file.insert(0x0004, Value::I8(255)); + file.insert(0x0005, Value::String("hello".to_string())); + + let mut buf = Vec::new(); + file.to_writer(&mut buf).unwrap(); + + let mut cursor = Cursor::new(buf); + let file2 = Inibin::from_reader(&mut cursor).unwrap(); + + assert_eq!(file2.get(0x0001), Some(&Value::I32(42))); + assert_eq!(file2.get(0x0002), Some(&Value::F32(3.125))); + assert_eq!(file2.get(0x0003), Some(&Value::I16(-100))); + assert_eq!(file2.get(0x0004), Some(&Value::I8(255))); + assert_eq!(file2.get(0x0005), Some(&Value::String("hello".to_string()))); + } +} diff --git a/crates/ltk_inibin/src/lib.rs b/crates/ltk_inibin/src/lib.rs new file mode 100644 index 00000000..0397ce03 --- /dev/null +++ b/crates/ltk_inibin/src/lib.rs @@ -0,0 +1,26 @@ +//! Inibin/troybin binary configuration file parser for League of Legends. +//! +//! Supports reading (v1 + v2), writing (v2), and modifying all 14 value set types. +//! +//! ``` +//! use ltk_inibin::Inibin; +//! +//! let mut inibin = Inibin::new(); +//! inibin.insert(0x0001, 42i32); +//! inibin.insert(0x0002, "hello"); +//! +//! assert_eq!(inibin.get_as::(0x0001), Some(42)); +//! assert_eq!(inibin.get_or(0x9999, 0i32), 0); +//! ``` + +mod error; +mod file; +mod section; +mod value; +mod value_flags; + +pub use error::{Error, Result}; +pub use file::Inibin; +pub use section::Section; +pub use value::{FromValue, Value}; +pub use value_flags::ValueFlags; diff --git a/crates/ltk_inibin/src/section.rs b/crates/ltk_inibin/src/section.rs new file mode 100644 index 00000000..0a901881 --- /dev/null +++ b/crates/ltk_inibin/src/section.rs @@ -0,0 +1,600 @@ +use std::io::{Read, Seek, SeekFrom, Write}; + +use byteorder::{LittleEndian as LE, ReadBytesExt, WriteBytesExt}; +use glam::{Vec2, Vec3, Vec4}; +use indexmap::IndexMap; +use ltk_io_ext::ReaderExt; + +use crate::error::Result; +use crate::value::Value; +use crate::value_flags::ValueFlags; + +/// A typed bucket of key-value pairs within an inibin file. +#[derive(Debug, Clone, PartialEq)] +pub struct Section { + kind: ValueFlags, + properties: IndexMap, +} + +impl Section { + pub(crate) fn new(kind: ValueFlags) -> Self { + Self { + kind, + properties: IndexMap::new(), + } + } + + // ── Accessors ────────────────────────────────────────────────── + + pub fn get(&self, key: u32) -> Option<&Value> { + self.properties.get(&key) + } + + pub fn insert(&mut self, key: u32, value: Value) { + self.properties.insert(key, value); + } + + pub fn remove(&mut self, key: u32) -> Option { + self.properties.shift_remove(&key) + } + + pub fn len(&self) -> usize { + self.properties.len() + } + + pub fn is_empty(&self) -> bool { + self.properties.is_empty() + } + + pub fn kind(&self) -> ValueFlags { + self.kind + } + + pub fn keys(&self) -> impl Iterator { + self.properties.keys() + } + + pub fn values(&self) -> impl Iterator { + self.properties.values() + } + + pub fn iter(&self) -> impl Iterator { + self.properties.iter().map(|(&k, v)| (k, v)) + } + + // ── Reading ──────────────────────────────────────────────────── + + pub(crate) fn read_non_string(reader: &mut R, kind: ValueFlags) -> Result { + let value_count = reader.read_u16::()? as usize; + let hashes = read_hashes(reader, value_count)?; + let mut properties = IndexMap::with_capacity(value_count); + + match kind { + ValueFlags::INT32_LIST => { + for hash in hashes { + properties.insert(hash, Value::I32(reader.read_i32::()?)); + } + } + ValueFlags::F32_LIST => { + for hash in hashes { + properties.insert(hash, Value::F32(reader.read_f32::()?)); + } + } + ValueFlags::U8_LIST => { + for hash in hashes { + properties.insert(hash, Value::U8(reader.read_u8()?)); + } + } + ValueFlags::INT16_LIST => { + for hash in hashes { + properties.insert(hash, Value::I16(reader.read_i16::()?)); + } + } + ValueFlags::INT8_LIST => { + for hash in hashes { + properties.insert(hash, Value::I8(reader.read_u8()?)); + } + } + ValueFlags::BIT_LIST => { + // 8 booleans packed per byte, extracted LSB to MSB + let mut current_byte: u8 = 0; + for (i, hash) in hashes.into_iter().enumerate() { + if i % 8 == 0 { + current_byte = reader.read_u8()?; + } + let bit = (current_byte >> (i % 8)) & 1 != 0; + properties.insert(hash, Value::Bool(bit)); + } + } + ValueFlags::VEC3_U8_LIST => { + for hash in hashes { + let x = reader.read_u8()?; + let y = reader.read_u8()?; + let z = reader.read_u8()?; + properties.insert(hash, Value::Vec3U8([x, y, z])); + } + } + ValueFlags::VEC3_F32_LIST => { + for hash in hashes { + let x = reader.read_f32::()?; + let y = reader.read_f32::()?; + let z = reader.read_f32::()?; + properties.insert(hash, Value::Vec3F32(Vec3::new(x, y, z))); + } + } + ValueFlags::VEC2_U8_LIST => { + for hash in hashes { + let x = reader.read_u8()?; + let y = reader.read_u8()?; + properties.insert(hash, Value::Vec2U8([x, y])); + } + } + ValueFlags::VEC2_F32_LIST => { + for hash in hashes { + let x = reader.read_f32::()?; + let y = reader.read_f32::()?; + properties.insert(hash, Value::Vec2F32(Vec2::new(x, y))); + } + } + ValueFlags::VEC4_U8_LIST => { + for hash in hashes { + let x = reader.read_u8()?; + let y = reader.read_u8()?; + let z = reader.read_u8()?; + let w = reader.read_u8()?; + properties.insert(hash, Value::Vec4U8([x, y, z, w])); + } + } + ValueFlags::VEC4_F32_LIST => { + for hash in hashes { + let x = reader.read_f32::()?; + let y = reader.read_f32::()?; + let z = reader.read_f32::()?; + let w = reader.read_f32::()?; + properties.insert(hash, Value::Vec4F32(Vec4::new(x, y, z, w))); + } + } + ValueFlags::INT64_LIST => { + for hash in hashes { + properties.insert(hash, Value::I64(reader.read_i64::()?)); + } + } + _ => {} + } + + Ok(Self { kind, properties }) + } + + pub(crate) fn read_string_list( + reader: &mut R, + string_data_offset: u64, + ) -> Result { + let value_count = reader.read_u16::()? as usize; + let hashes = read_hashes(reader, value_count)?; + + let mut offsets = Vec::with_capacity(value_count); + for _ in 0..value_count { + offsets.push(reader.read_u16::()?); + } + + // Seek to each string offset, read null-terminated, then seek back + let mut properties = IndexMap::with_capacity(value_count); + for (i, hash) in hashes.into_iter().enumerate() { + let saved_pos = reader.stream_position()?; + reader.seek(SeekFrom::Start(string_data_offset + offsets[i] as u64))?; + let s = reader.read_str_until_nul()?; + reader.seek(SeekFrom::Start(saved_pos))?; + properties.insert(hash, Value::String(s)); + } + + Ok(Self { + kind: ValueFlags::STRING_LIST, + properties, + }) + } + + /// Version 1 (legacy): hashes are provided externally, not read from the set header. + pub(crate) fn read_string_list_v1( + reader: &mut R, + hashes: Vec, + string_data_offset: u64, + ) -> Result { + let value_count = hashes.len(); + + let mut offsets = Vec::with_capacity(value_count); + for _ in 0..value_count { + offsets.push(reader.read_u16::()?); + } + + let mut properties = IndexMap::with_capacity(value_count); + for (i, hash) in hashes.into_iter().enumerate() { + let saved_pos = reader.stream_position()?; + reader.seek(SeekFrom::Start(string_data_offset + offsets[i] as u64))?; + let s = reader.read_str_until_nul()?; + reader.seek(SeekFrom::Start(saved_pos))?; + properties.insert(hash, Value::String(s)); + } + + Ok(Self { + kind: ValueFlags::STRING_LIST, + properties, + }) + } + + // ── Writing ──────────────────────────────────────────────────── + + pub(crate) fn write_non_string(&self, writer: &mut W) -> Result<()> { + let count = self.properties.len() as u16; + writer.write_u16::(count)?; + + let mut entries: Vec<_> = self.properties.iter().collect(); + entries.sort_by_key(|(&k, _)| k); + + for (&hash, _) in &entries { + writer.write_u32::(hash)?; + } + + for (_, value) in &entries { + match value { + Value::I32(v) => writer.write_i32::(*v)?, + Value::F32(v) => writer.write_f32::(*v)?, + Value::U8(v) => writer.write_u8(*v)?, + Value::I16(v) => writer.write_i16::(*v)?, + Value::I8(v) => writer.write_u8(*v)?, + Value::Bool(_) => unreachable!("Bool values are written via write_bit_list"), + Value::Vec3U8([x, y, z]) => { + writer.write_u8(*x)?; + writer.write_u8(*y)?; + writer.write_u8(*z)?; + } + Value::Vec3F32(v) => { + writer.write_f32::(v.x)?; + writer.write_f32::(v.y)?; + writer.write_f32::(v.z)?; + } + Value::Vec2U8([x, y]) => { + writer.write_u8(*x)?; + writer.write_u8(*y)?; + } + Value::Vec2F32(v) => { + writer.write_f32::(v.x)?; + writer.write_f32::(v.y)?; + } + Value::Vec4U8([x, y, z, w]) => { + writer.write_u8(*x)?; + writer.write_u8(*y)?; + writer.write_u8(*z)?; + writer.write_u8(*w)?; + } + Value::Vec4F32(v) => { + writer.write_f32::(v.x)?; + writer.write_f32::(v.y)?; + writer.write_f32::(v.z)?; + writer.write_f32::(v.w)?; + } + Value::I64(v) => writer.write_i64::(*v)?, + Value::String(_) => {} + } + } + + Ok(()) + } + + /// BitList uses special packing: 8 booleans per byte, LSB to MSB. + pub(crate) fn write_bit_list(&self, writer: &mut W) -> Result<()> { + let count = self.properties.len() as u16; + writer.write_u16::(count)?; + + let mut entries: Vec<_> = self.properties.iter().collect(); + entries.sort_by_key(|(&k, _)| k); + + for (&hash, _) in &entries { + writer.write_u32::(hash)?; + } + + let mut current_byte: u8 = 0; + for (i, (_, value)) in entries.iter().enumerate() { + if let Value::Bool(v) = value { + if *v { + current_byte |= 1 << (i % 8); + } + } + if (i % 8 == 7) || (i == entries.len() - 1) { + writer.write_u8(current_byte)?; + current_byte = 0; + } + } + + Ok(()) + } + + /// Returns the string data bytes separately (written after the offset table). + pub(crate) fn write_string_list(&self, writer: &mut W) -> Result> { + let count = self.properties.len() as u16; + writer.write_u16::(count)?; + + let mut entries: Vec<_> = self.properties.iter().collect(); + entries.sort_by_key(|(&k, _)| k); + + for (&hash, _) in &entries { + writer.write_u32::(hash)?; + } + + let mut string_data = Vec::new(); + let mut offsets = Vec::with_capacity(entries.len()); + + for (_, value) in &entries { + if let Value::String(s) = value { + offsets.push(string_data.len() as u16); + string_data.extend_from_slice(s.as_bytes()); + string_data.push(0); + } + } + + for offset in &offsets { + writer.write_u16::(*offset)?; + } + + Ok(string_data) + } + + pub(crate) fn string_data_length(&self) -> u16 { + let mut len: usize = 0; + for value in self.properties.values() { + if let Value::String(s) = value { + len += s.len() + 1; + } + } + len as u16 + } +} + +// ── Helpers ──────────────────────────────────────────────────────── + +fn read_hashes(reader: &mut R, count: usize) -> Result> { + let mut hashes = Vec::with_capacity(count); + for _ in 0..count { + hashes.push(reader.read_u32::()?); + } + Ok(hashes) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::io::Cursor; + + #[test] + fn test_read_int32_list() { + let mut data = Vec::new(); + data.extend_from_slice(&2u16.to_le_bytes()); + data.extend_from_slice(&0xAAAA0001u32.to_le_bytes()); + data.extend_from_slice(&0xAAAA0002u32.to_le_bytes()); + data.extend_from_slice(&42i32.to_le_bytes()); + data.extend_from_slice(&(-7i32).to_le_bytes()); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::INT32_LIST).unwrap(); + + assert_eq!(set.len(), 2); + assert_eq!(set.get(0xAAAA0001), Some(&Value::I32(42))); + assert_eq!(set.get(0xAAAA0002), Some(&Value::I32(-7))); + } + + #[test] + fn test_read_float32_list() { + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0xBBBB0001u32.to_le_bytes()); + data.extend_from_slice(&3.125f32.to_le_bytes()); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::F32_LIST).unwrap(); + + assert_eq!(set.len(), 1); + if let Some(Value::F32(v)) = set.get(0xBBBB0001) { + approx::assert_relative_eq!(*v, 3.125); + } else { + panic!("Expected F32"); + } + } + + #[test] + fn test_read_u8_list() { + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0xCCCC0001u32.to_le_bytes()); + data.push(100u8); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::U8_LIST).unwrap(); + + assert_eq!(set.get(0xCCCC0001), Some(&Value::U8(100))); + } + + #[test] + fn test_read_int16_list() { + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0xDDDD0001u32.to_le_bytes()); + data.extend_from_slice(&(-123i16).to_le_bytes()); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::INT16_LIST).unwrap(); + + assert_eq!(set.get(0xDDDD0001), Some(&Value::I16(-123))); + } + + #[test] + fn test_read_int8_list() { + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0xEEEE0001u32.to_le_bytes()); + data.push(200u8); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::INT8_LIST).unwrap(); + + assert_eq!(set.get(0xEEEE0001), Some(&Value::I8(200))); + } + + #[test] + fn test_read_bit_list() { + let mut data = Vec::new(); + data.extend_from_slice(&3u16.to_le_bytes()); + data.extend_from_slice(&0x00000001u32.to_le_bytes()); + data.extend_from_slice(&0x00000002u32.to_le_bytes()); + data.extend_from_slice(&0x00000003u32.to_le_bytes()); + data.push(0b00000101u8); // bits: 1,0,1 + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::BIT_LIST).unwrap(); + + assert_eq!(set.get(0x00000001), Some(&Value::Bool(true))); + assert_eq!(set.get(0x00000002), Some(&Value::Bool(false))); + assert_eq!(set.get(0x00000003), Some(&Value::Bool(true))); + } + + #[test] + fn test_read_vec3_f32_list() { + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0x11110001u32.to_le_bytes()); + data.extend_from_slice(&1.0f32.to_le_bytes()); + data.extend_from_slice(&2.0f32.to_le_bytes()); + data.extend_from_slice(&3.0f32.to_le_bytes()); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::VEC3_F32_LIST).unwrap(); + + assert_eq!( + set.get(0x11110001), + Some(&Value::Vec3F32(Vec3::new(1.0, 2.0, 3.0))) + ); + } + + #[test] + fn test_read_vec2_u8_list() { + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0x22220001u32.to_le_bytes()); + data.push(50u8); + data.push(100u8); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::VEC2_U8_LIST).unwrap(); + + assert_eq!(set.get(0x22220001), Some(&Value::Vec2U8([50, 100]))); + } + + #[test] + fn test_read_string_list() { + let mut data = Vec::new(); + data.extend_from_slice(&2u16.to_le_bytes()); + data.extend_from_slice(&0xAA000001u32.to_le_bytes()); + data.extend_from_slice(&0xAA000002u32.to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&6u16.to_le_bytes()); + + let string_data_offset = data.len() as u64; + data.extend_from_slice(b"hello\0world\0"); + + let mut cursor = Cursor::new(data); + let set = Section::read_string_list(&mut cursor, string_data_offset).unwrap(); + + assert_eq!(set.len(), 2); + assert_eq!( + set.get(0xAA000001), + Some(&Value::String("hello".to_string())) + ); + assert_eq!( + set.get(0xAA000002), + Some(&Value::String("world".to_string())) + ); + } + + #[test] + fn test_read_vec2_f32_list() { + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0x33330001u32.to_le_bytes()); + data.extend_from_slice(&4.0f32.to_le_bytes()); + data.extend_from_slice(&5.0f32.to_le_bytes()); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::VEC2_F32_LIST).unwrap(); + + assert_eq!( + set.get(0x33330001), + Some(&Value::Vec2F32(Vec2::new(4.0, 5.0))) + ); + } + + #[test] + fn test_read_vec4_f32_list() { + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0x44440001u32.to_le_bytes()); + data.extend_from_slice(&1.0f32.to_le_bytes()); + data.extend_from_slice(&2.0f32.to_le_bytes()); + data.extend_from_slice(&3.0f32.to_le_bytes()); + data.extend_from_slice(&4.0f32.to_le_bytes()); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::VEC4_F32_LIST).unwrap(); + + assert_eq!( + set.get(0x44440001), + Some(&Value::Vec4F32(Vec4::new(1.0, 2.0, 3.0, 4.0))) + ); + } + + #[test] + fn test_read_vec3_u8_list() { + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0x55550001u32.to_le_bytes()); + data.push(10); + data.push(20); + data.push(30); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::VEC3_U8_LIST).unwrap(); + + assert_eq!(set.get(0x55550001), Some(&Value::Vec3U8([10, 20, 30]))); + } + + #[test] + fn test_read_int64_list() { + let mut data = Vec::new(); + data.extend_from_slice(&2u16.to_le_bytes()); + data.extend_from_slice(&0x77770001u32.to_le_bytes()); + data.extend_from_slice(&0x77770002u32.to_le_bytes()); + data.extend_from_slice(&9999999999i64.to_le_bytes()); + data.extend_from_slice(&(-42i64).to_le_bytes()); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::INT64_LIST).unwrap(); + + assert_eq!(set.len(), 2); + assert_eq!(set.get(0x77770001), Some(&Value::I64(9999999999))); + assert_eq!(set.get(0x77770002), Some(&Value::I64(-42))); + } + + #[test] + fn test_read_vec4_u8_list() { + let mut data = Vec::new(); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0x66660001u32.to_le_bytes()); + data.push(10); + data.push(20); + data.push(30); + data.push(40); + + let mut cursor = Cursor::new(data); + let set = Section::read_non_string(&mut cursor, ValueFlags::VEC4_U8_LIST).unwrap(); + + assert_eq!(set.get(0x66660001), Some(&Value::Vec4U8([10, 20, 30, 40]))); + } +} diff --git a/crates/ltk_inibin/src/value.rs b/crates/ltk_inibin/src/value.rs new file mode 100644 index 00000000..e054b32f --- /dev/null +++ b/crates/ltk_inibin/src/value.rs @@ -0,0 +1,171 @@ +use glam::{Vec2, Vec3, Vec4}; + +use crate::value_flags::ValueFlags; + +/// Typed value stored in an inibin set. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + I32(i32), + F32(f32), + /// Packed float stored as raw byte. Use [`Value::as_f32`] to get the decoded `f32` value (`byte * 0.1`, range 0.0–25.5). + U8(u8), + I16(i16), + I8(u8), + Bool(bool), + /// Packed float triple stored as raw bytes. Use [`Value::as_vec3`] to decode. + Vec3U8([u8; 3]), + Vec3F32(Vec3), + /// Packed float pair stored as raw bytes. Use [`Value::as_vec2`] to decode. + Vec2U8([u8; 2]), + Vec2F32(Vec2), + /// Packed float quad stored as raw bytes. Use [`Value::as_vec4`] to decode. + Vec4U8([u8; 4]), + Vec4F32(Vec4), + String(String), + I64(i64), +} + +impl Value { + /// Returns the [`ValueFlags`] variant this value belongs to. + pub fn flags(&self) -> ValueFlags { + match self { + Value::I32(_) => ValueFlags::INT32_LIST, + Value::F32(_) => ValueFlags::F32_LIST, + Value::U8(_) => ValueFlags::U8_LIST, + Value::I16(_) => ValueFlags::INT16_LIST, + Value::I8(_) => ValueFlags::INT8_LIST, + Value::Bool(_) => ValueFlags::BIT_LIST, + Value::Vec3U8(_) => ValueFlags::VEC3_U8_LIST, + Value::Vec3F32(_) => ValueFlags::VEC3_F32_LIST, + Value::Vec2U8(_) => ValueFlags::VEC2_U8_LIST, + Value::Vec2F32(_) => ValueFlags::VEC2_F32_LIST, + Value::Vec4U8(_) => ValueFlags::VEC4_U8_LIST, + Value::Vec4F32(_) => ValueFlags::VEC4_F32_LIST, + Value::String(_) => ValueFlags::STRING_LIST, + Value::I64(_) => ValueFlags::INT64_LIST, + } + } + + /// Returns the value as `f32`, handling both [`F32`](Value::F32) and packed [`U8`](Value::U8) variants. + /// + /// For `U8`: returns `byte * 0.1` (range 0.0–25.5). + pub fn as_f32(&self) -> Option { + match self { + Value::F32(v) => Some(*v), + Value::U8(v) => Some(*v as f32 * 0.1), + _ => None, + } + } + + /// Returns the value as [`Vec2`], handling both [`Vec2F32`](Value::Vec2F32) and packed [`Vec2U8`](Value::Vec2U8) variants. + pub fn as_vec2(&self) -> Option { + match self { + Value::Vec2F32(v) => Some(*v), + Value::Vec2U8([x, y]) => Some(Vec2::new(*x as f32 * 0.1, *y as f32 * 0.1)), + _ => None, + } + } + + /// Returns the value as [`Vec3`], handling both [`Vec3F32`](Value::Vec3F32) and packed [`Vec3U8`](Value::Vec3U8) variants. + pub fn as_vec3(&self) -> Option { + match self { + Value::Vec3F32(v) => Some(*v), + Value::Vec3U8([x, y, z]) => { + Some(Vec3::new(*x as f32 * 0.1, *y as f32 * 0.1, *z as f32 * 0.1)) + } + _ => None, + } + } + + /// Returns the value as [`Vec4`], handling both [`Vec4F32`](Value::Vec4F32) and packed [`Vec4U8`](Value::Vec4U8) variants. + pub fn as_vec4(&self) -> Option { + match self { + Value::Vec4F32(v) => Some(*v), + Value::Vec4U8([x, y, z, w]) => Some(Vec4::new( + *x as f32 * 0.1, + *y as f32 * 0.1, + *z as f32 * 0.1, + *w as f32 * 0.1, + )), + _ => None, + } + } +} + +// ── FromValue trait + From/extraction impls via macros ───── + +/// Trait for extracting a typed value from an [`Value`] reference. +/// +/// Enables the generic [`Inibin::get_as`](crate::Inibin::get_as) method: +/// ``` +/// # use ltk_inibin::{Inibin, Value}; +/// let mut inibin = Inibin::new(); +/// inibin.insert(0x0001, 42i32); +/// let v: Option = inibin.get_as(0x0001); +/// assert_eq!(v, Some(42)); +/// ``` +pub trait FromValue<'a>: Sized { + /// Try to extract from an [`Value`] reference. Returns `None` on type mismatch. + fn from_inibin_value(value: &'a Value) -> Option; +} + +/// Generates `From<$ty>` and `FromValue` for Copy types with a direct variant mapping. +macro_rules! impl_value_conversion { + ($ty:ty, $variant:ident) => { + impl From<$ty> for Value { + fn from(v: $ty) -> Self { + Value::$variant(v) + } + } + + impl FromValue<'_> for $ty { + fn from_inibin_value(value: &Value) -> Option { + match value { + Value::$variant(v) => Some(*v), + _ => None, + } + } + } + }; +} + +impl_value_conversion!(i32, I32); +impl_value_conversion!(f32, F32); +impl_value_conversion!(i16, I16); +impl_value_conversion!(bool, Bool); +impl_value_conversion!(i64, I64); +impl_value_conversion!(Vec3, Vec3F32); +impl_value_conversion!(Vec2, Vec2F32); +impl_value_conversion!(Vec4, Vec4F32); + +// u8 maps to I8 (raw byte) — not U8 (packed float) +impl FromValue<'_> for u8 { + fn from_inibin_value(value: &Value) -> Option { + match value { + Value::I8(v) => Some(*v), + _ => None, + } + } +} + +// String types need special handling (not Copy) +impl From for Value { + fn from(v: String) -> Self { + Value::String(v) + } +} + +impl From<&str> for Value { + fn from(v: &str) -> Self { + Value::String(v.to_owned()) + } +} + +impl<'a> FromValue<'a> for &'a str { + fn from_inibin_value(value: &'a Value) -> Option { + match value { + Value::String(v) => Some(v), + _ => None, + } + } +} diff --git a/crates/ltk_inibin/src/value_flags.rs b/crates/ltk_inibin/src/value_flags.rs new file mode 100644 index 00000000..c4900d8f --- /dev/null +++ b/crates/ltk_inibin/src/value_flags.rs @@ -0,0 +1,36 @@ +bitflags::bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct ValueFlags: u16 { + const INT32_LIST = 1 << 0; + const F32_LIST = 1 << 1; + const U8_LIST = 1 << 2; + const INT16_LIST = 1 << 3; + const INT8_LIST = 1 << 4; + const BIT_LIST = 1 << 5; + const VEC3_U8_LIST = 1 << 6; + const VEC3_F32_LIST = 1 << 7; + const VEC2_U8_LIST = 1 << 8; + const VEC2_F32_LIST = 1 << 9; + const VEC4_U8_LIST = 1 << 10; + const VEC4_F32_LIST = 1 << 11; + const STRING_LIST = 1 << 12; + const INT64_LIST = 1 << 13; + } +} + +/// Non-string kinds in bit order. STRING_LIST is always read/written last. +pub(crate) const NON_STRING_KINDS: [ValueFlags; 13] = [ + ValueFlags::INT32_LIST, + ValueFlags::F32_LIST, + ValueFlags::U8_LIST, + ValueFlags::INT16_LIST, + ValueFlags::INT8_LIST, + ValueFlags::BIT_LIST, + ValueFlags::VEC3_U8_LIST, + ValueFlags::VEC3_F32_LIST, + ValueFlags::VEC2_U8_LIST, + ValueFlags::VEC2_F32_LIST, + ValueFlags::VEC4_U8_LIST, + ValueFlags::VEC4_F32_LIST, + ValueFlags::INT64_LIST, +]; diff --git a/crates/ltk_inibin/tests/round_trip.rs b/crates/ltk_inibin/tests/round_trip.rs new file mode 100644 index 00000000..b6adaaf1 --- /dev/null +++ b/crates/ltk_inibin/tests/round_trip.rs @@ -0,0 +1,279 @@ +use std::io::Cursor; + +use glam::{Vec2, Vec3, Vec4}; +use ltk_inibin::{Inibin, Value, ValueFlags}; + +// U8 packed floats: value 100 decodes to 10.0 (byte * 0.1) + +/// Round-trip test: construct -> write -> read -> assert equal. +/// Tests all 14 value set types. +#[test] +fn round_trip_all_set_types() { + let mut file = Inibin::new(); + + // Int32List + file.insert(0x0001, Value::I32(42)); + file.insert(0x0002, Value::I32(-999)); + + // F32List + file.insert(0x0101, Value::F32(3.125)); + file.insert(0x0102, Value::F32(-0.5)); + + // U8List (packed float, raw bytes) + file.insert(0x0201, Value::U8(100)); // 100 * 0.1 = 10.0 + file.insert(0x0202, Value::U8(0)); // 0 * 0.1 = 0.0 + file.insert(0x0203, Value::U8(255)); // 255 * 0.1 = 25.5 + + // Int16List + file.insert(0x0301, Value::I16(-12345)); + + // Int8List + file.insert(0x0401, Value::I8(0)); + file.insert(0x0402, Value::I8(255)); + + // BitList + file.insert(0x0501, Value::Bool(true)); + file.insert(0x0502, Value::Bool(false)); + file.insert(0x0503, Value::Bool(true)); + + // Vec3U8List (packed float triple, raw bytes) + file.insert(0x0601, Value::Vec3U8([10, 20, 30])); + + // F32ListVec3 + file.insert(0x0701, Value::Vec3F32(Vec3::new(1.5, 2.5, 3.5))); + + // Vec2U8List (packed float pair, raw bytes) + file.insert(0x0801, Value::Vec2U8([50, 100])); + + // F32ListVec2 + file.insert(0x0901, Value::Vec2F32(Vec2::new(7.7, 8.8))); + + // Vec4U8List (packed float quad, raw bytes) + file.insert(0x0A01, Value::Vec4U8([10, 20, 30, 40])); + + // F32ListVec4 + file.insert(0x0B01, Value::Vec4F32(Vec4::new(1.1, 2.2, 3.3, 4.4))); + + // StringList + file.insert(0x0C01, Value::String("hello".to_string())); + file.insert(0x0C02, Value::String("world".to_string())); + + // Int64List + file.insert(0x0D01, Value::I64(9999999999)); + file.insert(0x0D02, Value::I64(-42)); + + // Write + let mut buf = Vec::new(); + file.to_writer(&mut buf).unwrap(); + + // Read back + let mut cursor = Cursor::new(&buf); + let file2 = Inibin::from_reader(&mut cursor).unwrap(); + + // Verify all values + assert_eq!(file2.get(0x0001), Some(&Value::I32(42))); + assert_eq!(file2.get(0x0002), Some(&Value::I32(-999))); + + assert_eq!(file2.get(0x0101), Some(&Value::F32(3.125))); + assert_eq!(file2.get(0x0102), Some(&Value::F32(-0.5))); + + assert_eq!(file2.get(0x0201), Some(&Value::U8(100))); + assert_eq!(file2.get(0x0202), Some(&Value::U8(0))); + assert_eq!(file2.get(0x0203), Some(&Value::U8(255))); + + assert_eq!(file2.get(0x0301), Some(&Value::I16(-12345))); + + assert_eq!(file2.get(0x0401), Some(&Value::I8(0))); + assert_eq!(file2.get(0x0402), Some(&Value::I8(255))); + + assert_eq!(file2.get(0x0501), Some(&Value::Bool(true))); + assert_eq!(file2.get(0x0502), Some(&Value::Bool(false))); + assert_eq!(file2.get(0x0503), Some(&Value::Bool(true))); + + assert_eq!(file2.get(0x0601), Some(&Value::Vec3U8([10, 20, 30]))); + + assert_eq!( + file2.get(0x0701), + Some(&Value::Vec3F32(Vec3::new(1.5, 2.5, 3.5))) + ); + + assert_eq!(file2.get(0x0801), Some(&Value::Vec2U8([50, 100]))); + + assert_eq!( + file2.get(0x0901), + Some(&Value::Vec2F32(Vec2::new(7.7, 8.8))) + ); + + assert_eq!(file2.get(0x0A01), Some(&Value::Vec4U8([10, 20, 30, 40]))); + + assert_eq!( + file2.get(0x0B01), + Some(&Value::Vec4F32(Vec4::new(1.1, 2.2, 3.3, 4.4))) + ); + + assert_eq!(file2.get(0x0C01), Some(&Value::String("hello".to_string()))); + assert_eq!(file2.get(0x0C02), Some(&Value::String("world".to_string()))); + + assert_eq!(file2.get(0x0D01), Some(&Value::I64(9999999999))); + assert_eq!(file2.get(0x0D02), Some(&Value::I64(-42))); +} + +#[test] +fn round_trip_empty_file() { + let file = Inibin::new(); + + let mut buf = Vec::new(); + file.to_writer(&mut buf).unwrap(); + + let mut cursor = Cursor::new(&buf); + let file2 = Inibin::from_reader(&mut cursor).unwrap(); + + assert_eq!(file2.iter().count(), 0); +} + +#[test] +fn round_trip_bit_list_partial_byte() { + // 3 bools = 1 byte with partial packing + let mut file = Inibin::new(); + file.insert(0x0001, Value::Bool(true)); + file.insert(0x0002, Value::Bool(false)); + file.insert(0x0003, Value::Bool(true)); + + let mut buf = Vec::new(); + file.to_writer(&mut buf).unwrap(); + + let mut cursor = Cursor::new(&buf); + let file2 = Inibin::from_reader(&mut cursor).unwrap(); + + assert_eq!(file2.get(0x0001), Some(&Value::Bool(true))); + assert_eq!(file2.get(0x0002), Some(&Value::Bool(false))); + assert_eq!(file2.get(0x0003), Some(&Value::Bool(true))); +} + +#[test] +fn as_f32_accessor() { + // Packed U8 variants + let val = Value::U8(100); + approx::assert_relative_eq!(val.as_f32().unwrap(), 10.0); + + let val = Value::U8(255); + approx::assert_relative_eq!(val.as_f32().unwrap(), 25.5); + + let val = Value::U8(0); + approx::assert_relative_eq!(val.as_f32().unwrap(), 0.0); + + // Non-packed F32 variant + let val = Value::F32(3.125); + approx::assert_relative_eq!(val.as_f32().unwrap(), 3.125); + + // Non-float variant returns None + assert_eq!(Value::I32(42).as_f32(), None); +} + +#[test] +fn as_vec2_accessor() { + // Packed U8 variant + let val = Value::Vec2U8([50, 100]); + let v = val.as_vec2().unwrap(); + approx::assert_relative_eq!(v.x, 5.0); + approx::assert_relative_eq!(v.y, 10.0); + + // Non-packed F32 variant + let val = Value::Vec2F32(Vec2::new(1.5, 2.5)); + assert_eq!(val.as_vec2(), Some(Vec2::new(1.5, 2.5))); + + // Non-vec2 variant returns None + assert_eq!(Value::I32(42).as_vec2(), None); +} + +#[test] +fn as_vec3_accessor() { + // Packed U8 variant + let val = Value::Vec3U8([10, 20, 30]); + let v = val.as_vec3().unwrap(); + approx::assert_relative_eq!(v.x, 1.0); + approx::assert_relative_eq!(v.y, 2.0); + approx::assert_relative_eq!(v.z, 3.0); + + // Non-packed F32 variant + let val = Value::Vec3F32(Vec3::new(1.5, 2.5, 3.5)); + assert_eq!(val.as_vec3(), Some(Vec3::new(1.5, 2.5, 3.5))); + + // Non-vec3 variant returns None + assert_eq!(Value::I32(42).as_vec3(), None); +} + +#[test] +fn as_vec4_accessor() { + // Packed U8 variant + let val = Value::Vec4U8([10, 20, 30, 40]); + let v = val.as_vec4().unwrap(); + approx::assert_relative_eq!(v.x, 1.0); + approx::assert_relative_eq!(v.y, 2.0); + approx::assert_relative_eq!(v.z, 3.0); + approx::assert_relative_eq!(v.w, 4.0); + + // Non-packed F32 variant + let val = Value::Vec4F32(Vec4::new(1.1, 2.2, 3.3, 4.4)); + assert_eq!(val.as_vec4(), Some(Vec4::new(1.1, 2.2, 3.3, 4.4))); + + // Non-vec4 variant returns None + assert_eq!(Value::I32(42).as_vec4(), None); +} + +#[test] +fn test_set_access() { + let mut file = Inibin::new(); + file.insert(0x0001, Value::I32(1)); + file.insert(0x0002, Value::I32(2)); + file.insert(0x0003, Value::F32(3.0)); + + let int_set = file.section(ValueFlags::INT32_LIST).unwrap(); + assert_eq!(int_set.len(), 2); + assert_eq!(int_set.kind(), ValueFlags::INT32_LIST); + + let float_set = file.section(ValueFlags::F32_LIST).unwrap(); + assert_eq!(float_set.len(), 1); +} + +#[test] +fn round_trip_int64() { + let mut file = Inibin::new(); + file.insert(0x0001, Value::I64(i64::MAX)); + file.insert(0x0002, Value::I64(i64::MIN)); + file.insert(0x0003, Value::I64(0)); + + let mut buf = Vec::new(); + file.to_writer(&mut buf).unwrap(); + + let mut cursor = Cursor::new(&buf); + let file2 = Inibin::from_reader(&mut cursor).unwrap(); + + assert_eq!(file2.get(0x0001), Some(&Value::I64(i64::MAX))); + assert_eq!(file2.get(0x0002), Some(&Value::I64(i64::MIN))); + assert_eq!(file2.get(0x0003), Some(&Value::I64(0))); +} + +#[test] +fn test_int64_cross_bucket_migration() { + let mut file = Inibin::new(); + + // Insert as Int32 first + file.insert(0xABCD, Value::I32(42)); + assert_eq!(file.get(0xABCD), Some(&Value::I32(42))); + + // Re-insert same key as Int64 + file.insert(0xABCD, Value::I64(9999999999)); + assert_eq!(file.get(0xABCD), Some(&Value::I64(9999999999))); + + // Verify it's not in Int32 bucket anymore + assert!(file + .section(ValueFlags::INT32_LIST) + .map(|s| s.get(0xABCD).is_none()) + .unwrap_or(true)); + + // Remove and verify + let removed = file.remove(0xABCD); + assert_eq!(removed, Some(Value::I64(9999999999))); + assert!(!file.contains_key(0xABCD)); +} diff --git a/specs/001-inibin-crate/checklists/requirements.md b/specs/001-inibin-crate/checklists/requirements.md new file mode 100644 index 00000000..4b9ade38 --- /dev/null +++ b/specs/001-inibin-crate/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Inibin File Parser (ltk_inibin) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-25 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- The Assumptions section documents key decisions made: version 1 is read-only, SDBM hashing convention, ASCII string encoding, and hash-to-name resolution being out of scope. diff --git a/specs/001-inibin-crate/contracts/public-api.md b/specs/001-inibin-crate/contracts/public-api.md new file mode 100644 index 00000000..e23b6e45 --- /dev/null +++ b/specs/001-inibin-crate/contracts/public-api.md @@ -0,0 +1,225 @@ +# Public API Contract: ltk_inibin + ltk_inibin_names + +**Phase**: 1 | **Date**: 2026-03-25 | **Updated**: 2026-03-26 + +## ltk_hash additions + +### `ltk_hash::sdbm` + +```rust +/// Compute SDBM hash of a lowercased string. +pub fn hash_lower(input: impl AsRef) -> u32; + +/// Compute SDBM hash of two strings joined by a delimiter, all lowercased. +/// Used for inibin keys: hash_lower_with_delimiter(section, property, '*') +pub fn hash_lower_with_delimiter(a: impl AsRef, b: impl AsRef, delimiter: char) -> u32; +``` + +## ltk_inibin public API + +### Inibin (formerly InibinFile) + +```rust +/// Top-level inibin/troybin file container. +pub struct Inibin { /* bucket-based internal storage */ } + +impl Inibin { + /// Create an empty inibin file. + pub fn new() -> Self; + + /// Parse an inibin file from a seekable reader. + /// Supports version 1 (legacy) and version 2. + pub fn from_reader(reader: &mut R) -> Result; + + /// Write as version 2 inibin format. + pub fn to_writer(&self, writer: &mut W) -> Result<()>; + + /// Get a value by hash key, searching all buckets. + /// Returns None if key not found. + pub fn get(&self, key: u32) -> Option<&Value>; + + /// Get a typed value by hash key. + pub fn get_as<'a, T: FromValue<'a>>(&'a self, key: u32) -> Option; + + /// Get a typed value with default. + pub fn get_or<'a, T: FromValue<'a>>(&'a self, key: u32, default: T) -> T; + + /// Insert or update a value. Routes to the correct bucket by value type. + /// If the key exists in a different-type bucket, removes it first. + pub fn insert(&mut self, key: u32, value: impl Into); + + /// Remove a value by hash key from all buckets. + /// Returns the removed value if found. + pub fn remove(&mut self, key: u32) -> Option; + + /// Check if a key exists in any bucket. + pub fn contains_key(&self, key: u32) -> bool; + + /// Total entry count across all sections. + pub fn len(&self) -> usize; + + /// Whether the inibin has no entries. + pub fn is_empty(&self) -> bool; + + /// Iterate over all key-value pairs across all buckets. + pub fn iter(&self) -> impl Iterator; + + /// Get a reference to a specific section by flag type. + pub fn section(&self, flags: ValueFlags) -> Option<&Section>; + + /// Get a mutable reference to a specific section by flag type. + pub fn section_mut(&mut self, flags: ValueFlags) -> Option<&mut Section>; +} +``` + +### Section (formerly InibinSet) + +```rust +/// A typed bucket of key-value pairs. +pub struct Section { /* properties: IndexMap */ } + +impl Section { + /// Get value by hash key within this section. + pub fn get(&self, key: u32) -> Option<&Value>; + + /// Insert a key-value pair into this section. + pub fn insert(&mut self, key: u32, value: Value); + + /// Remove a key-value pair from this section. + pub fn remove(&mut self, key: u32) -> Option; + + /// Number of entries in this section. + pub fn len(&self) -> usize; + + /// Whether this section is empty. + pub fn is_empty(&self) -> bool; + + /// The flag type of this section. + pub fn kind(&self) -> ValueFlags; + + /// Iterate over hash keys in this section. + pub fn keys(&self) -> impl Iterator; + + /// Iterate over values in this section. + pub fn values(&self) -> impl Iterator; + + /// Iterate over key-value pairs in this section. + pub fn iter(&self) -> impl Iterator; +} +``` + +### ValueFlags (formerly InibinFlags) + +```rust +bitflags! { + /// Bitfield representing inibin value set types. + pub struct ValueFlags: u16 { + const INT32_LIST = 1 << 0; + const F32_LIST = 1 << 1; + const U8_LIST = 1 << 2; + const INT16_LIST = 1 << 3; + const INT8_LIST = 1 << 4; + const BIT_LIST = 1 << 5; + const VEC3_U8_LIST = 1 << 6; + const VEC3_F32_LIST = 1 << 7; + const VEC2_U8_LIST = 1 << 8; + const VEC2_F32_LIST = 1 << 9; + const VEC4_U8_LIST = 1 << 10; + const VEC4_F32_LIST = 1 << 11; + const STRING_LIST = 1 << 12; + const INT64_LIST = 1 << 13; + } +} +``` + +### Value (formerly InibinValue) + +```rust +/// Typed value stored in an inibin section. +pub enum Value { + I32(i32), + F32(f32), + U8(u8), // Raw byte; use as_f32() for packed float conversion + I16(i16), + I8(u8), + Bool(bool), + Vec3U8([u8; 3]), // Raw bytes; use as_vec3() for packed float conversion + Vec3F32(Vec3), + Vec2U8([u8; 2]), // Raw bytes; use as_vec2() for packed float conversion + Vec2F32(Vec2), + Vec4U8([u8; 4]), // Raw bytes; use as_vec4() for packed float conversion + Vec4F32(Vec4), + String(String), + I64(i64), +} + +impl Value { + /// Returns the ValueFlags variant this value belongs to. + pub fn flags(&self) -> ValueFlags; + + /// Returns the value as f32, handling both F32 and packed U8 variants. + pub fn as_f32(&self) -> Option; + + /// Returns the value as Vec2, handling both Vec2F32 and packed Vec2U8 variants. + pub fn as_vec2(&self) -> Option; + + /// Returns the value as Vec3, handling both Vec3F32 and packed Vec3U8 variants. + pub fn as_vec3(&self) -> Option; + + /// Returns the value as Vec4, handling both Vec4F32 and packed Vec4U8 variants. + pub fn as_vec4(&self) -> Option; +} +``` + +### Error types + +```rust +#[derive(Debug, thiserror::Error)] +pub enum InibinError { + #[error("unsupported inibin version: {0}")] + UnsupportedVersion(u8), + + #[error("u8 float overflow: {0} is outside range 0.0-25.5")] + U8FloatOverflow(f32), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +pub type Result = core::result::Result; +``` + +### Crate dependencies + +```toml +[dependencies] +thiserror = { workspace = true } +byteorder = { workspace = true } +bitflags = { workspace = true } +glam = { workspace = true } +ltk_io_ext = { version = "0.4.1", path = "../ltk_io_ext" } +ltk_hash = { version = "0.2.5", path = "../ltk_hash/" } + +[dev-dependencies] +approx = { workspace = true } +``` + +## ltk_inibin_names public API + +### Lookup function + +```rust +/// Look up the human-readable (section, name) pair for an inibin hash key. +/// Returns `None` if the hash is not in the known fixlist. +pub fn lookup(hash: u32) -> Option<(&'static str, &'static str)>; +``` + +### Crate dependencies + +```toml +[dependencies] +phf = { workspace = true } + +[build-dependencies] +phf_codegen = { workspace = true } +``` diff --git a/specs/001-inibin-crate/data-model.md b/specs/001-inibin-crate/data-model.md new file mode 100644 index 00000000..20bb4253 --- /dev/null +++ b/specs/001-inibin-crate/data-model.md @@ -0,0 +1,135 @@ +# Data Model: ltk_inibin + +**Phase**: 1 | **Date**: 2026-03-25 | **Updated**: 2026-03-26 + +## Entities + +### InibinFile + +The top-level container for an inibin/troybin file. + +**Fields**: +- `sections`: Map from `ValueFlags` (single flag value) to `Section` — the bucket-based internal storage + +**Relationships**: Contains zero or more `Section` instances, keyed by flag type. At most 14 sections (one per flag bit). + +**Lifecycle**: +- Created via `from_reader` (parsing) or direct construction +- Modified via key-based insert/update/delete API +- Serialized via `to_writer` (always writes as version 2) + +**Identity**: An InibinFile is a value type — no unique identity beyond its contents. + +### Section (formerly InibinSet) + +A typed collection of key-value pairs within a single bucket. + +**Fields**: +- `kind`: `ValueFlags` — identifies which value type this section holds +- `properties`: `IndexMap` — preserves insertion order + +**Relationships**: Owned by `Inibin`. Each section holds values of exactly one type. + +**Public iterators**: `.keys()`, `.values()`, `.iter()` — idiomatic map-like access. + +**Validation**: +- Hash keys must be unique within a section +- Values must match the section's type constraint +- U8 (fixed-point float) values must be in range 0-255 (raw byte storage) + +### ValueFlags (formerly InibinFlags) + +Bitfield (u16) representing value set types. + +**Values** (14 bits): +- Bit 0: `INT32_LIST` +- Bit 1: `F32_LIST` +- Bit 2: `U8_LIST` +- Bit 3: `INT16_LIST` +- Bit 4: `INT8_LIST` +- Bit 5: `BIT_LIST` +- Bit 6: `VEC3_U8_LIST` +- Bit 7: `VEC3_F32_LIST` +- Bit 8: `VEC2_U8_LIST` +- Bit 9: `VEC2_F32_LIST` +- Bit 10: `VEC4_U8_LIST` +- Bit 11: `VEC4_F32_LIST` +- Bit 12: `STRING_LIST` +- Bit 13: `INT64_LIST` + +**Usage**: In the file header (v2), a combined flags value indicates which sets are present. Internally, each set is keyed by a single flag value. + +### Value (formerly InibinValue) + +Typed value enum representing all possible value types. + +**Variants**: +- `I32(i32)` +- `F32(f32)` +- `U8(u8)` — raw byte storage; `as_f32()` returns `byte * 0.1` +- `I16(i16)` +- `I8(u8)` +- `Bool(bool)` +- `Vec3U8([u8; 3])` — raw bytes; `as_vec3()` returns packed floats +- `Vec3F32(Vec3)` +- `Vec2U8([u8; 2])` — raw bytes; `as_vec2()` returns packed floats +- `Vec2F32(Vec2)` +- `Vec4U8([u8; 4])` — raw bytes; `as_vec4()` returns packed floats +- `Vec4F32(Vec4)` +- `String(String)` +- `I64(i64)` + +**Unified accessors**: `as_f32()`, `as_vec2()`, `as_vec3()`, `as_vec4()` handle both packed (U8-based) and non-packed variants transparently. + +**Mapping**: Each variant corresponds to exactly one `ValueFlags` value. The public API uses this enum for type-safe value access. The library determines which bucket to route to based on the variant. + +### InibinNames (in `ltk_inibin_names`) + +A compile-time static lookup table for resolving hash keys to human-readable names. + +**Fields**: +- `INIBIN_NAMES`: `phf::Map` — maps hash → (section, name) + +**Data Source**: Extracted from lolpytools `inibin_fix.py` `all_inibin_fixlist`. + +**Lifecycle**: Static, immutable, baked into the binary at compile time. + +## Binary Layout + +### Version 2 File Header + +| Offset | Size | Type | Description | +|--------|------|------|-------------| +| 0 | 1 | u8 | Version (== 2) | +| 1 | 2 | u16 LE | String data length | +| 3 | 2 | u16 LE | Flags bitfield | + +Followed by set data for each flag bit set (in order), with StringList last. + +### Version 1 File Header (read-only) + +| Offset | Size | Type | Description | +|--------|------|------|-------------| +| 0 | 1 | u8 | Version (== 1) | +| 1 | 3 | [u8; 3] | Padding | +| 4 | 4 | u32 LE | Value count | +| 8 | 4 | u32 LE | String data length | + +Followed by `value_count` x u32 hash keys, then a single StringList set. + +### Non-String Set Layout + +| Offset | Size | Type | Description | +|--------|------|------|-------------| +| 0 | 2 | u16 LE | Value count | +| 2 | count*4 | [u32 LE] | Hash keys | +| ... | varies | varies | Value data (type-dependent) | + +### StringList Set Layout + +| Offset | Size | Type | Description | +|--------|------|------|-------------| +| 0 | 2 | u16 LE | Value count | +| 2 | count*4 | [u32 LE] | Hash keys | +| ... | count*2 | [u16 LE] | String offsets (relative to string data start) | +| ... | varies | bytes | Null-terminated ASCII string data | diff --git a/specs/001-inibin-crate/plan.md b/specs/001-inibin-crate/plan.md new file mode 100644 index 00000000..ed6b43bd --- /dev/null +++ b/specs/001-inibin-crate/plan.md @@ -0,0 +1,78 @@ +# Implementation Plan: ltk_inibin + +**Branch**: `001-inibin-crate` | **Date**: 2026-03-26 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/001-inibin-crate/spec.md` + +## Summary + +Implement a new `ltk_inibin` crate for parsing, writing, and modifying inibin/troybin binary configuration files. Add SDBM hash algorithm to `ltk_hash::sdbm`. Re-export through the `league-toolkit` umbrella crate behind an `inibin` feature flag. This plan addresses PR #122 review feedback: rename bitfield to `ValueFlags`, add unified `as_*()` accessors on `InibinValue`, use `AsRef` for SDBM functions, and expose `.keys()`/`.values()`/`.iter()` on collection types. + +## Technical Context + +**Language/Version**: Rust (workspace edition, same as other `ltk_*` crates) +**Primary Dependencies**: `thiserror` (errors), `byteorder` (binary I/O), `ltk_io_ext` (reader/writer extensions), `ltk_hash` (SDBM hashing), `glam` (Vec2/Vec3/Vec4 for vector set types), `bitflags` (ValueFlags), `indexmap` (ordered key-value storage) +**Storage**: N/A (in-memory data structures, binary file I/O) +**Testing**: `cargo test` — round-trip tests as primary verification +**Target Platform**: All Rust-supported platforms (no platform-specific code) +**Project Type**: Library (Rust crate within workspace) +**Performance Goals**: N/A — correctness and round-trip integrity are primary goals +**Constraints**: Must follow workspace conventions (`from_reader`/`to_writer`, `thiserror`, workspace deps) +**Scale/Scope**: Single crate (~10 source files), one `ltk_hash` module addition, one umbrella feature flag + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Crate-First Architecture | PASS | New `ltk_inibin` crate under `crates/`, independently compilable, depends on foundation crates only | +| II. Round-Trip Correctness | PASS | Round-trip tests planned (FR-012), `approx` not needed (integer/string formats), `insta` for snapshots if applicable | +| III. Strict CI Quality Gate | PASS | Will pass fmt, clippy -D warnings, and test before merge | +| IV. Idiomatic Rust I/O | PASS | `from_reader(&mut impl Read + Seek)` / `to_writer(&mut impl Write)`, builder not needed (simple struct construction) | +| V. Workspace Dependency Hygiene | PASS | All deps (`indexmap`, `bitflags`, etc.) added at workspace level first | +| Error Handling & Safety | PASS | Own error type via `thiserror`, `Result` alias, no unwrap in lib code | +| Development Workflow | PASS | Feature branch `001-inibin-crate`, conventional commits, PR-based | + +No violations. Gate passes. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-inibin-crate/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── contracts/ # Phase 1 output (public API contracts) +└── tasks.md # Phase 2 output (/speckit.tasks) +``` + +### Source Code (repository root) + +```text +crates/ +├── ltk_hash/ +│ └── src/ +│ ├── sdbm.rs # New: SDBM hash algorithm (AsRef) +│ └── lib.rs # Updated: re-export sdbm module +├── ltk_inibin/ +│ ├── src/ +│ │ ├── lib.rs # Re-exports + module declarations +│ │ ├── error.rs # InibinError enum (thiserror) +│ │ ├── inibin.rs # Inibin top-level container (from_reader/to_writer) +│ │ ├── section.rs # InibinSection — typed set with .keys()/.values()/.iter() +│ │ ├── value.rs # InibinValue enum + unified as_*() accessors +│ │ └── value_flags.rs # ValueFlags bitfield (bitflags) +│ ├── tests/ +│ │ └── round_trip.rs # Round-trip integration tests +│ └── Cargo.toml +└── league-toolkit/ + └── Cargo.toml # Updated: add `inibin` feature flag +``` + +**Structure Decision**: Standard `ltk_*` crate layout following workspace conventions. SDBM hash lives in `ltk_hash` (centralized hashing). No builder pattern needed — direct struct construction suffices for inibin's flat key-value model. + +## Complexity Tracking + +No constitution violations to justify. diff --git a/specs/001-inibin-crate/quickstart.md b/specs/001-inibin-crate/quickstart.md new file mode 100644 index 00000000..e0fb544d --- /dev/null +++ b/specs/001-inibin-crate/quickstart.md @@ -0,0 +1,119 @@ +# Quickstart: ltk_inibin + +**Phase**: 1 | **Date**: 2026-03-25 | **Updated**: 2026-03-26 + +## Reading an inibin file + +```rust +use std::fs::File; +use std::io::BufReader; +use ltk_inibin::Inibin; + +let file = File::open("data/characters/annie/annie.inibin")?; +let mut reader = BufReader::new(file); +let inibin = Inibin::from_reader(&mut reader)?; + +// Look up a value by hash key +if let Some(value) = inibin.get(0xABCD1234) { + println!("Value: {:?}", value); +} +``` + +## Hashing a section/property key + +```rust +use ltk_hash::sdbm; + +// Hash "DATA*AttackRange" to get the lookup key +let key = sdbm::hash_lower_with_delimiter("DATA", "AttackRange", '*'); +let value = inibin.get(key); +``` + +## Modifying values + +```rust +use ltk_inibin::{Inibin, Value}; +use ltk_hash::sdbm; + +let mut inibin = Inibin::from_reader(&mut reader)?; + +// Insert a new float value +let key = sdbm::hash_lower_with_delimiter("DATA", "AttackRange", '*'); +inibin.insert(key, Value::F32(550.0)); + +// Insert an Int64 value +inibin.insert(0x1234, Value::I64(9999999999)); + +// Remove a value +inibin.remove(key); +``` + +## Writing an inibin file + +```rust +use std::fs::File; +use std::io::BufWriter; + +let file = File::create("output.inibin")?; +let mut writer = BufWriter::new(file); +inibin.to_writer(&mut writer)?; +``` + +## Round-trip + +```rust +use std::io::Cursor; + +let inibin = Inibin::from_reader(&mut reader)?; + +let mut buf = Vec::new(); +inibin.to_writer(&mut buf)?; + +let mut cursor = Cursor::new(&buf); +let inibin2 = Inibin::from_reader(&mut cursor)?; +// inibin and inibin2 contain identical data +``` + +## Iterating all values + +```rust +for (key, value) in inibin.iter() { + println!("0x{:08X} = {:?}", key, value); +} +``` + +## Accessing a specific set bucket + +```rust +use ltk_inibin::ValueFlags; + +if let Some(float_section) = inibin.section(ValueFlags::F32_LIST) { + println!("Float section has {} entries", float_section.len()); + // Use .keys(), .values(), or .iter() + for key in float_section.keys() { + println!(" key: 0x{:08X}", key); + } + for value in float_section.values() { + println!(" value: {:?}", value); + } +} +``` + +## Resolving hash keys to names (ltk_inibin_names) + +```rust +use ltk_inibin_names; + +// Look up a known hash key +if let Some((section, name)) = ltk_inibin_names::lookup(0xABCD1234) { + println!("{section}*{name}"); +} + +// Combine with inibin iteration for human-readable output +for (key, value) in inibin.iter() { + match ltk_inibin_names::lookup(key) { + Some((section, name)) => println!("{section}*{name} = {:?}", value), + None => println!("0x{:08X} = {:?}", key, value), + } +} +``` diff --git a/specs/001-inibin-crate/research.md b/specs/001-inibin-crate/research.md new file mode 100644 index 00000000..17256eac --- /dev/null +++ b/specs/001-inibin-crate/research.md @@ -0,0 +1,161 @@ +# Research: ltk_inibin + +**Phase**: 0 | **Date**: 2026-03-25 | **Updated**: 2026-03-26 + +## R-001: Inibin Binary Format + +**Decision**: Follow the C# reference implementation exactly for binary layout. + +**Rationale**: The reference implementation (LeagueToolkit C#) is the authoritative source for the inibin format. The format has two versions with well-defined headers and 14 value set types (13 original + Int64 at flag 13). + +**Format Summary**: + +### Version 2 (canonical) +- `u8` version (== 2) +- `u16` string data length +- `u16` flags bitfield (14 bits, one per set type) +- For each set bit in flags (in order, bits 0-12): read the corresponding InibinSet +- Int64List (bit 13) follows the same non-string set pattern as Int32List +- StringList (bit 12) is read last, using string data length to compute the string data offset + +### Version 1 (legacy, read-only) +- `u8` version (== 1) +- `[u8; 3]` padding +- `u32` value count +- `u32` string data length +- Hashes are read externally (value_count x u32) +- Single StringList set + +### InibinSet (non-string) read order: +1. `u16` value count +2. `value_count` x `u32` hash keys +3. Value data (format depends on set type) + +### InibinSet (StringList) read order: +1. `u16` value count +2. `value_count` x `u32` hash keys +3. `value_count` x `u16` string offsets +4. String data (null-terminated ASCII at computed absolute offsets) + +**Alternatives considered**: None — format is fixed by the game engine. + +## R-002: SDBM Hash Algorithm + +**Decision**: Implement SDBM hash in `ltk_hash::sdbm` module following the existing fnv1a/elf pattern. + +**Rationale**: Inibin keys are SDBM hashes of `section*property` (lowercased, `*` as delimiter). Centralizing in `ltk_hash` keeps all hash algorithms together and makes the function reusable. + +**Algorithm**: +``` +hash = 0 +for each byte in input.to_lowercase(): + hash = byte + (hash << 6) + (hash << 16) - hash +return hash as u32 +``` + +The reference C# uses `Sdbm.HashLowerWithDelimiter(section, property, '*')` which concatenates `section + '*' + property`, lowercases, and hashes. + +**Alternatives considered**: Keeping SDBM internal to ltk_inibin — rejected per clarification to centralize in ltk_hash. + +## R-003: Value Type Encoding + +**Decision**: Use an enum `InibinValue` with 14 variants matching the flag types. + +**Rationale**: Maps directly to the format's 14 set types. Vector types use `glam` (Vec2/Vec3/Vec4) per constitution. Fixed-point floats (U8) are stored as `f32` after conversion (byte * 0.1) for ergonomic access, but validated on write (must be 0.0-25.5). + +**Type mapping**: + +| Flag | Rust Value Type | Read | Write | +|------|----------------|------|-------| +| INT32_LIST | `i32` | read_i32:: | write_i32:: | +| F32_LIST | `f32` | read_f32:: | write_f32:: | +| U8_LIST | `f32` | u8 * 0.1 | validate range, f32 / 0.1 as u8 | +| INT16_LIST | `i16` | read_i16:: | write_i16:: | +| INT8_LIST | `u8` | read_u8 | write_u8 | +| BIT_LIST | `bool` | bit extraction (8 per byte) | bit packing (8 per byte) | +| VEC3_U8_LIST | `Vec3` | 3x u8 * 0.1 | validate, 3x f32/0.1 as u8 | +| VEC3_F32_LIST | `Vec3` | 3x read_f32 | 3x write_f32 | +| VEC2_U8_LIST | `Vec2` | 2x u8 * 0.1 | validate, 2x f32/0.1 as u8 | +| VEC2_F32_LIST | `Vec2` | 2x read_f32 | 2x write_f32 | +| VEC4_U8_LIST | `Vec4` | 4x u8 * 0.1 | validate, 4x f32/0.1 as u8 | +| VEC4_F32_LIST | `Vec4` | 4x read_f32 | 4x write_f32 | +| STRING_LIST | `String` | null-terminated ASCII | null-terminated + offset table | +| INT64_LIST | `i64` | read_i64:: | write_i64:: | + +**Alternatives considered**: Storing fixed-point as raw bytes — accepted in implementation (stores raw `u8`), but with unified `as_*()` accessors that handle conversion transparently. + +**Update (2026-03-26, PR #122 review)**: The actual implementation stores U8 variants as raw `u8` bytes (not `f32`). Unified `as_f32()`, `as_vec2()`, `as_vec3()`, `as_vec4()` accessors handle conversion from both packed (U8) and non-packed (F32) variants transparently. This provides lossless round-trip while still offering ergonomic float access. + +## R-009: ValueKind → ValueFlags Rename (PR #122 review) + +**Decision**: Rename `ValueKind` to `ValueFlags` throughout `ltk_inibin`. +**Rationale**: The type is a `bitflags!` bitfield, not an enum of kinds. `ValueFlags` accurately describes its nature (reviewer feedback from `alanpq`). +**Impact**: Rename in `value_kind.rs` → `value_flags.rs`, update all references in `section.rs`, `file.rs`, `lib.rs`. + +## R-010: SDBM Hash Functions Accept `AsRef` (PR #122 review) + +**Decision**: Change SDBM hash function signatures from `&str` to `impl AsRef`. +**Rationale**: Ergonomic — allows passing `String`, `&str`, `Cow` without `.as_str()` calls. Only applied to SDBM functions; other `ltk_hash` functions unchanged to limit scope. +**Alternatives considered**: Update all `ltk_hash` functions — deferred to separate PR. + +## R-011: Unified `as_*()` Accessors on Value (PR #122 review) + +**Decision**: Replace separate `u8_as_f32()`, `vec2_u8_as_f32()`, etc. with unified `as_f32()`, `as_vec2()`, `as_vec3()`, `as_vec4()`. +**Rationale**: Consumers shouldn't need to know the storage representation. `as_f32()` handles both `F32(v)` → `Some(v)` and `U8(b)` → `Some(b as f32 * 0.1)`. +**Alternatives considered**: Keep separate per-storage accessors — rejected; adds unnecessary cognitive load. + +## R-012: `.keys()` / `.values()` on Section (PR #122 review) + +**Decision**: Add `.keys()` and `.values()` methods to `Section` (`.iter()` already exists). +**Rationale**: Idiomatic Rust for map-like containers. Matches `HashMap`/`IndexMap` conventions. Delegates to `IndexMap::keys()` and `IndexMap::values()`. +**Alternatives considered**: `.iter()` only — rejected per reviewer feedback. + +## R-004: Endianness + +**Decision**: Little-endian for all multi-byte reads/writes. + +**Rationale**: The C# reference uses `BinaryReader` which defaults to little-endian. League of Legends targets x86/x64 which is little-endian. + +**Alternatives considered**: None — format dictates endianness. + +## R-005: Existing Workspace Dependencies + +**Decision**: Use workspace-level dependencies where available. Add `phf` at workspace level for the new `ltk_inibin_names` crate. + +**Rationale**: `thiserror`, `byteorder`, `glam`, `bitflags` already exist at workspace level. `ltk_io_ext` and `ltk_hash` are path dependencies. The `phf` crate (with `phf_codegen` as build dependency) is needed for compile-time perfect hash maps in `ltk_inibin_names` — justified by the thousands of entries in the fixlist where runtime HashMap initialization would be wasteful. + +**Alternatives considered**: `LazyLock` + `HashMap` — rejected per clarification; `phf` gives zero-cost lookups with no runtime initialization. + +## R-006: Reader Trait Bounds + +**Decision**: `from_reader` requires `Read + Seek` per clarification session. + +**Rationale**: StringList reading uses offset-based seeking in the reference implementation. This is consistent with `ltk_wad` which also requires `Read + Seek` for offset-based formats. The `to_writer` method only needs `Write` since it writes sequentially. + +**Alternatives considered**: `Read`-only with buffering — rejected per clarification. + +## R-007: Int64 Support (Flag 13) + +**Decision**: Add Int64List as flag bit 13, full read+write support. + +**Rationale**: The lolpytools reference (`inibin2.py`) documents flag 13 as 64-bit long long (`int64`). While less common than other types, some inibin files in the wild use this type. Full read+write support maintains round-trip integrity, which is a core constitution principle. + +**Format**: Identical to Int32List but with 8-byte values instead of 4. Each entry is `i64` read/written as little-endian. + +**Alternatives considered**: Read-only — rejected per clarification; round-trip integrity requires write support. + +## R-008: Inibin Name Resolution (ltk_inibin_names) + +**Decision**: Separate `ltk_inibin_names` crate with compile-time `phf::Map` for hash→name lookups. + +**Rationale**: The lolpytools `inibin_fix.py` contains thousands of known `(section, name)` mappings. A separate crate keeps `ltk_inibin` lean (no binary size overhead for users who don't need name resolution). The `phf` crate generates a perfect hash map at compile time via `phf_codegen` in `build.rs`, providing O(1) lookups with zero runtime initialization cost. + +**Architecture**: +- `build.rs`: Uses `phf_codegen` to generate a `phf::Map` from the fixlist data +- `src/lib.rs`: Exposes `lookup(hash: u32) -> Option<(&str, &str)>` using the generated map +- Data source: Extracted from lolpytools `inibin_fix.py` `all_inibin_fixlist` + +**Alternatives considered**: +- Inside `ltk_inibin` behind feature flag — rejected per clarification; separate crate preferred +- Runtime HashMap — rejected per clarification; compile-time phf preferred +- External data file — rejected; adds distribution complexity diff --git a/specs/001-inibin-crate/spec.md b/specs/001-inibin-crate/spec.md new file mode 100644 index 00000000..b0f3d0ed --- /dev/null +++ b/specs/001-inibin-crate/spec.md @@ -0,0 +1,161 @@ +# Feature Specification: Inibin File Parser (ltk_inibin) + +**Feature Branch**: `001-inibin-crate` +**Created**: 2026-03-25 +**Status**: Draft +**Input**: User description: "We need to implement a new ltk crate for reading inibin files - https://github.com/LeagueToolkit/league-toolkit/issues/119" + +## Clarifications + +### Session 2026-03-25 + +- Q: What should the public API style be for value access? → A: Key-based public API for read/edit/delete; internal representation stays bucket-based for efficiency. +- Q: Should the parser require Read + Seek or just Read? → A: Read + Seek, consistent with workspace conventions for offset-based formats. +- Q: Where should the SDBM hash implementation live? → A: In `ltk_hash`, centralized alongside FNV-1a and ELF. +- Q: Where should the inibin fixlist (hash→name mapping) live? → A: Separate `ltk_inibin_names` crate, keeping `ltk_inibin` lean for users who only need parsing. +- Q: How should the fixlist data be stored in Rust? → A: Compile-time static map using `phf` (perfect hash function) for zero-cost lookups. +- Q: Should Int64 (flag 13) support reading and writing, or just reading? → A: Full read+write support, maintaining round-trip integrity. + +### Session 2026-03-26 + +- Q: Should `ltk_inibin_names` be included in this PR? → A: No. Descoped to a separate PR — too large for the current scope. This PR covers `ltk_inibin` only (parsing, writing, modification, Int64 support). +- Q: What map type should be used for internal storage? → A: `IndexMap` — preserves insertion order for deterministic iteration and serialization. +- Q: How should packed floats (U8 types) be stored internally? → A: Store raw `u8` byte, provide `as_f32()` accessor returning `byte * 0.1`. Lossless round-trip, validation implicit. +- Q: Should the API use generics for ergonomics? → A: Yes — `From` impls on `InibinValue` for construction + typed getter methods (`get_i32()`, `get_f32()`, etc.) on `Inibin` for extraction. + +### Session 2026-03-26 (PR #122 review) + +- Q: What should the value-type bitfield be named? → A: `ValueFlags` — concise and accurately conveys bitfield semantics. +- Q: Should `InibinValue` provide unified `as_*()` accessors that handle both packed and non-packed variants? → A: Yes — `as_f32()` returns `f32` from both `Float32` and `U8` (packed) variants; same pattern for vec types. +- Q: Should SDBM hash functions accept `AsRef` instead of `&str`? → A: Yes — `AsRef` for SDBM functions only; other `ltk_hash` functions unchanged for now. +- Q: Should section/set types expose `.keys()` and `.values()` iterator methods? → A: Yes — expose `.keys()`, `.values()`, and `.iter()` on all collection types. + +### Session 2026-03-26 (DX) + +- Q: Should there be a convenience function that defaults the `*` delimiter for SDBM inibin key hashing? → A: Yes — `ltk_hash::sdbm::hash_inibin_key(section, property)` centralized in the hash crate, defaults `*` as delimiter. +- Q: Should `ltk_inibin` use the existing `read_str_until_nul` from `ltk_io_ext::ReaderExt` instead of a custom `read_null_terminated_string`? → A: Yes — reuse the workspace's shared I/O extension to avoid duplication. Document reuse of `ltk_io_ext` utilities in CLAUDE.md. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Read Inibin Files (Priority: P1) + +As a developer working with League of Legends legacy files, I want to parse inibin/troybin files into a structured in-memory representation so I can inspect and extract configuration values by their hashed keys. + +**Why this priority**: Reading is the fundamental capability. Without parsing, no other operations are possible. This unlocks inspection, migration, and tooling workflows for legacy game data. + +**Independent Test**: Can be fully tested by providing binary inibin files and verifying that all value sets and their keyed entries are correctly parsed into the expected types and values. + +**Acceptance Scenarios**: + +1. **Given** a valid version 2 inibin file with multiple value sets, **When** the file is parsed, **Then** all sets are correctly identified by their flags and all key-value pairs within each set contain the expected typed values. +2. **Given** a valid version 1 (legacy) inibin file, **When** the file is parsed, **Then** the string list set is correctly read with all key-value pairs intact. +3. **Given** a troybin file (identical format to inibin), **When** the file is parsed, **Then** it produces the same structured output as an equivalent inibin file. + +--- + +### User Story 2 - Access Values by Hash Key (Priority: P1) + +As a developer, I want to look up values from parsed inibin data using hash keys (section/property pairs hashed with SDBM) so I can retrieve specific configuration entries without iterating all sets manually. + +**Why this priority**: Direct key-based access is the primary use case for consumers of inibin data. Without it, the parsed structure has limited utility. + +**Independent Test**: Can be tested by parsing an inibin file and querying specific known hash keys, verifying the returned values match expected data. + +**Acceptance Scenarios**: + +1. **Given** a parsed inibin file, **When** a value is requested by its hash key, **Then** the correct typed value is returned from the appropriate set. +2. **Given** a parsed inibin file, **When** a value is requested by a non-existent hash key, **Then** a None/empty result is returned without error. + +--- + +### User Story 3 - Write Inibin Files (Priority: P2) + +As a developer, I want to write inibin data back to binary format so I can create or modify legacy configuration files for modding or tooling purposes. + +**Why this priority**: Writing enables round-trip workflows (read-modify-write) and creation of new inibin files. This is important but secondary to reading. + +**Independent Test**: Can be tested by constructing an inibin structure in memory, writing it to binary, then re-reading it and verifying all values are preserved (round-trip test). + +**Acceptance Scenarios**: + +1. **Given** an in-memory inibin structure with multiple value sets, **When** written to binary format, **Then** the output is a valid version 2 inibin file that can be re-parsed to produce identical data. +2. **Given** a parsed inibin file, **When** written back to binary and re-parsed, **Then** all values match the original (round-trip integrity). + +--- + +### User Story 4 - Modify Inibin Data (Priority: P2) + +As a developer, I want to insert, remove, and update values in an inibin structure so I can programmatically edit legacy configuration data. + +**Why this priority**: Modification support enables modding workflows and tooling that transforms inibin data. + +**Independent Test**: Can be tested by constructing an inibin structure, performing insertions/removals/updates, then verifying the structure reflects the changes correctly. + +**Acceptance Scenarios**: + +1. **Given** a parsed inibin structure, **When** a new value is added with a specific hash key and type, **Then** it is placed in the correct value set bucket and can be retrieved by key. +2. **Given** a parsed inibin structure with existing entries, **When** an entry is removed by hash key, **Then** it is no longer present in the structure. + +--- + +### Edge Cases + +- What happens when an inibin file has a version byte other than 1 or 2? The parser should return an error indicating an unsupported version. +- What happens when a BitList set has a value count that is not a multiple of 8? The parser should correctly handle partial bytes by reading only the relevant bits. +- What happens when a FixedPointFloat value would overflow its byte range (0-255) during writing? The writer should return an error. +- What happens when string data contains non-ASCII characters? The parser should handle them gracefully or return an error, since the format uses null-terminated ASCII strings. +- What happens when an inibin file is truncated or corrupted? The parser should return a descriptive error rather than panicking. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The library MUST parse version 2 inibin files from a seekable reader (`Read + Seek`), reading the header (version byte, string data length, flags) and all value sets indicated by the flags bitfield. +- **FR-002**: The library MUST parse version 1 (legacy) inibin files, reading the header (version byte, padding, value count, string data length) and the single string list set. +- **FR-003**: The library MUST support all 14 value set types: Int32List, Float32List, U8List, Int16List, Int8List, BitList, Vec3U8List, Vec3F32List, Vec2U8List, Vec2F32List, Vec4U8List, Vec4F32List, StringList, and Int64List (flag 13). +- **FR-004**: The library MUST store parsed data in a bucket-based representation where each value set type maps to a collection of hash-key/value pairs. +- **FR-005**: The library MUST provide a key-based public API that allows users to read values by hash key, searching across all internal buckets transparently. +- **FR-006**: The library MUST provide a key-based public API that allows users to insert, update, and delete values by hash key, with the library routing to the correct internal bucket based on value type. +- **FR-007**: The library MUST write inibin data to binary format as version 2, computing the correct flags bitfield and string data length. +- **FR-008**: The library MUST correctly handle BitList encoding and decoding (8 boolean values packed per byte). +- **FR-009**: The library MUST correctly handle FixedPointFloat encoding and decoding (byte value multiplied by 0.1, range 0.0-25.5). +- **FR-010**: The library MUST correctly handle StringList encoding and decoding (null-terminated ASCII strings with offset-based addressing). +- **FR-011**: The library MUST return descriptive errors for unsupported versions, corrupted data, and invalid operations (e.g., FixedPointFloat overflow). +- **FR-012**: The library MUST support round-trip integrity: parsing a file and writing it back should produce binary-identical output (for version 2 files). +- **FR-013**: The library MUST support Int64 (flag 13, `i64`) values for both reading and writing, following the same pattern as other numeric set types. +- **FR-014**: The SDBM hash functions in `ltk_hash::sdbm` MUST accept `AsRef` for ergonomic use with `String`, `&str`, `Cow`, etc. +- **FR-014a**: `ltk_hash::sdbm` MUST provide a `hash_inibin_key(section, property)` convenience function that defaults the `*` delimiter, equivalent to `hash_lower_with_delimiter(section, property, '*')`. +- **FR-015**: `InibinValue` MUST provide unified `as_f32()`, `as_vec2()`, `as_vec3()`, `as_vec4()` accessors that transparently convert from both packed (U8-based) and non-packed variants. +- **FR-016**: All collection types (sets/sections) MUST expose `.keys()`, `.values()`, and `.iter()` iterator methods following idiomatic Rust map conventions. +- ~~**FR-017**: `ltk_inibin_names` crate~~ — **Descoped** to a separate PR. +- ~~**FR-018**: `ltk_inibin_names` lookup function~~ — **Descoped** to a separate PR. + +### Key Entities + +- **InibinFile**: The top-level container representing a parsed inibin/troybin file. Holds a collection of value sets keyed by their type flag. +- **InibinSet**: A typed collection of key-value pairs where keys are u32 hashes and values are of the type indicated by the set's flag (e.g., i32, f32, string, vector types). Exposes `.keys()`, `.values()`, and `.iter()` iterator methods. +- **ValueFlags** (formerly `InibinFlags`): A bitfield representing the 14 possible value set types present in an inibin file (flags 0-13). +- **InibinValue**: The typed value stored in a set entry (integer, float, u8 fixed-point float, boolean, string, i64, or vector variant). Provides unified `as_*()` accessors (e.g., `as_f32()`, `as_vec2()`) that transparently handle both packed (U8-based) and non-packed variants. +- ~~**InibinNames** (in `ltk_inibin_names`)~~ — **Descoped** to a separate PR. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All 14 value set types (including Int64) can be correctly parsed and written without data loss. +- **SC-002**: Round-trip parsing (read-write-read) produces identical data for all supported value set types. +- **SC-003**: Both version 1 and version 2 inibin files are parseable. +- **SC-004**: Value lookup by hash key returns correct results for all set types. +- **SC-005**: Invalid or corrupted input produces clear error messages rather than panics. +- **SC-006**: The library integrates into the league-toolkit workspace and passes all CI checks (formatting, linting, tests). +- **SC-007**: Unified `as_*()` accessors on `InibinValue` return correct values for both packed and non-packed storage variants. +- ~~**SC-008**: `ltk_inibin_names` hash-to-name resolution~~ — **Descoped** to a separate PR. + +## Assumptions + +- The inibin and troybin formats are binary-identical and can be handled by the same parser without format-specific logic. +- Hash keys use the SDBM hash algorithm (added to `ltk_hash`) applied to lowercased section/property pairs joined by '*' delimiter, consistent with the reference implementation. +- Version 2 is the canonical write format; version 1 is read-only (legacy support). +- The library follows existing workspace conventions: `from_reader`/`to_writer` pattern, `thiserror` error types, and workspace-level dependency management. +- String data in inibin files uses ASCII encoding with null terminators. +- Hash-to-name resolution (`ltk_inibin_names`) is deferred to a separate PR. diff --git a/specs/001-inibin-crate/tasks.md b/specs/001-inibin-crate/tasks.md new file mode 100644 index 00000000..ea6bf892 --- /dev/null +++ b/specs/001-inibin-crate/tasks.md @@ -0,0 +1,160 @@ +# Tasks: ltk_inibin PR #122 Review Fixes + +**Input**: Design documents from `/specs/001-inibin-crate/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/ +**Context**: `ltk_inibin` crate is fully implemented. These tasks address review comments from `alanpq` on PR #122. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup + +**Purpose**: No setup needed — all crates and dependencies already exist. + +(No tasks — crate structure is already in place) + +--- + +## Phase 2: Foundational (Renames & Signature Changes) + +**Purpose**: Rename types and update function signatures that all other changes depend on + +**⚠️ CRITICAL**: Must complete before user story work begins + +- [X] T001 Rename `ValueKind` to `ValueFlags` in `crates/ltk_inibin/src/value_kind.rs` — update the `bitflags!` struct name, `NON_STRING_KINDS` constant, and all doc comments +- [X] T002 Rename file `crates/ltk_inibin/src/value_kind.rs` to `crates/ltk_inibin/src/value_flags.rs` and update the module declaration in `crates/ltk_inibin/src/lib.rs` from `mod value_kind` to `mod value_flags` +- [X] T003 Update `pub use value_kind::ValueKind` to `pub use value_flags::ValueFlags` in `crates/ltk_inibin/src/lib.rs` +- [X] T004 [P] Update all references from `ValueKind` to `ValueFlags` in `crates/ltk_inibin/src/section.rs` +- [X] T005 [P] Update all references from `ValueKind` to `ValueFlags` in `crates/ltk_inibin/src/file.rs` +- [X] T006 [P] Update all references from `ValueKind` to `ValueFlags` in `crates/ltk_inibin/src/value.rs` +- [X] T007 [P] Update all references from `ValueKind` to `ValueFlags` in `crates/ltk_inibin/tests/round_trip.rs` +- [X] T008 [P] Update all references from `ValueKind` to `ValueFlags` in any example files under `crates/ltk_inibin/examples/` +- [X] T009 Change SDBM hash function signatures from `&str` to `impl AsRef` in `crates/ltk_hash/src/sdbm.rs` — update `hash_lower(input: impl AsRef)` and `hash_lower_with_delimiter(a: impl AsRef, b: impl AsRef, delimiter: char)`, adding `.as_ref()` calls on usage sites within the function bodies +- [X] T010 Update SDBM hash tests in `crates/ltk_hash/src/sdbm.rs` to verify both `&str` and `String` arguments work + +**Checkpoint**: `ValueKind` → `ValueFlags` rename complete everywhere. SDBM functions accept `AsRef`. `cargo check` passes. + +--- + +## Phase 3: User Story 1+2 — Unified Value Accessors (Priority: P1) 🎯 MVP + +**Goal**: Replace separate packed-float accessor methods with unified `as_*()` methods that handle both packed (U8-based) and non-packed (F32) variants transparently. + +**Independent Test**: Call `as_f32()` on both `Value::F32(1.5)` and `Value::U8(15)` — both should return `Some(f32)`. Same pattern for vec types. + +### Implementation + +- [X] T011 [US1] Replace `u8_as_f32()` with `as_f32()` in `crates/ltk_inibin/src/value.rs` — return `Some(v)` for `F32(v)`, `Some(b as f32 * 0.1)` for `U8(b)`, `None` for other variants +- [X] T012 [P] [US1] Replace `vec2_u8_as_f32()` with `as_vec2()` in `crates/ltk_inibin/src/value.rs` — return `Some(v)` for `Vec2F32(v)`, `Some(Vec2::new(a*0.1, b*0.1))` for `Vec2U8([a,b])`, `None` for other variants +- [X] T013 [P] [US1] Replace `vec3_u8_as_f32()` with `as_vec3()` in `crates/ltk_inibin/src/value.rs` — return `Some(v)` for `Vec3F32(v)`, `Some(Vec3::new(a*0.1, b*0.1, c*0.1))` for `Vec3U8([a,b,c])`, `None` for other variants +- [X] T014 [P] [US1] Replace `vec4_u8_as_f32()` with `as_vec4()` in `crates/ltk_inibin/src/value.rs` — return `Some(v)` for `Vec4F32(v)`, `Some(Vec4::new(a*0.1, b*0.1, c*0.1, d*0.1))` for `Vec4U8([a,b,c,d])`, `None` for other variants +- [X] T015 [US2] Update all call sites of the old accessor names (`u8_as_f32`, `vec2_u8_as_f32`, etc.) across `crates/ltk_inibin/src/` to use the new unified names (`as_f32`, `as_vec2`, etc.) +- [X] T016 [US2] Update any example files under `crates/ltk_inibin/examples/` that reference old accessor names + +**Checkpoint**: Unified `as_*()` accessors work for both packed and non-packed variants. No references to old method names remain. + +--- + +## Phase 4: User Story 3+4 — Section Iterator Methods (Priority: P2) + +**Goal**: Add `.keys()` and `.values()` iterator methods to `Section` for idiomatic map-like access. + +**Independent Test**: Parse an inibin, get a section, call `.keys()` and `.values()` — verify they return the expected hash keys and values respectively. + +### Implementation + +- [X] T017 [US3] Add `pub fn keys(&self) -> impl Iterator` to `Section` in `crates/ltk_inibin/src/section.rs` — delegate to `self.properties.keys()` +- [X] T018 [US3] Add `pub fn values(&self) -> impl Iterator` to `Section` in `crates/ltk_inibin/src/section.rs` — delegate to `self.properties.values()` +- [X] T019 [US4] Review and remove duplicate helper at approximately line 353 in `crates/ltk_inibin/src/section.rs` — check if existing `ltk_io_ext` or other utility already provides this functionality. If duplicate found, replace with existing helper; if not, leave as-is with a comment + +**Checkpoint**: `Section` exposes `.keys()`, `.values()`, `.iter()`. No duplicate helpers remain. + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +**Purpose**: CI validation, verify round-trip tests still pass, final cleanup + +- [X] T020 Run `cargo fmt -- --check` across workspace +- [X] T021 Run `cargo clippy --all-targets -- -D warnings` across workspace — fix any new warnings from the renames +- [X] T022 Run `cargo test --verbose` across workspace — all existing and new tests must pass +- [X] T023 Verify round-trip test in `crates/ltk_inibin/tests/round_trip.rs` still passes with `ValueFlags` rename +- [X] T024 [P] Update `crates/ltk_inibin/README.md` if it references `ValueKind` or old accessor names + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Foundational (Phase 2)**: No dependencies — start immediately + - T001-T003 must be sequential (rename struct, rename file, update re-export) + - T004-T008 can run in parallel after T001-T003 (updating references in different files) + - T009-T010 can run in parallel with T001-T008 (different crate: `ltk_hash`) +- **US1+2 (Phase 3)**: Depends on Phase 2 (T006 — `ValueFlags` rename in value.rs must be done first) + - T011-T014 can run in parallel (different methods in same file, but independent) + - T015-T016 depend on T011-T014 (need new names to exist before updating call sites) +- **US3+4 (Phase 4)**: Depends on Phase 2 (T004 — `ValueFlags` rename in section.rs must be done first). Can run in parallel with Phase 3. + - T017-T018 can run in parallel (different methods) + - T019 is independent +- **Polish (Phase 5)**: Depends on all prior phases + +### Parallel Opportunities + +- T004, T005, T006, T007, T008 can all run in parallel (different files) +- T009-T010 (`ltk_hash` changes) can run in parallel with all `ltk_inibin` changes +- Phase 3 and Phase 4 can run in parallel (different files: value.rs vs section.rs) +- T012, T013, T014 can run in parallel (independent method implementations) +- T017, T018 can run in parallel (independent methods) + +--- + +## Parallel Example: Phase 2 (Foundational) + +```text +# Sequential first (rename chain): +Task T001: "Rename ValueKind to ValueFlags in value_kind.rs" +Task T002: "Rename file to value_flags.rs, update module declaration" +Task T003: "Update pub use in lib.rs" + +# Then parallel (reference updates across files): +Task T004: "Update ValueKind → ValueFlags in section.rs" +Task T005: "Update ValueKind → ValueFlags in file.rs" +Task T006: "Update ValueKind → ValueFlags in value.rs" +Task T007: "Update ValueKind → ValueFlags in round_trip.rs" +Task T008: "Update ValueKind → ValueFlags in examples/" + +# Parallel with all above (different crate): +Task T009: "AsRef for SDBM hash functions" +Task T010: "Update SDBM tests" +``` + +--- + +## Implementation Strategy + +### MVP First (Rename + Accessors) + +1. Complete Phase 2: Foundational (ValueFlags rename + SDBM AsRef) +2. Complete Phase 3: US1+2 (unified as_*() accessors) +3. **STOP and VALIDATE**: `cargo test` passes, review comments addressed + +### Full Delivery + +1. MVP (above) + Phase 4: US3+4 (.keys()/.values() + helper dedup) +2. Phase 5: Polish (CI gate, README update) + +--- + +## Notes + +- All changes are refactoring/API improvements — no new binary format logic +- The `ValueKind` → `ValueFlags` rename touches many files but is mechanical +- Unified `as_*()` accessors replace existing methods — this is NOT additive, old names are removed +- The duplicate helper check (T019) may result in no change if no duplicate is found +- Round-trip tests are the primary regression gate — must pass after all changes