From 2ebef5ca2035177c4ad15651bbfeb50be9d72afc Mon Sep 17 00:00:00 2001 From: Crauzer <0xcrauzer@proton.me> Date: Thu, 26 Mar 2026 12:21:35 +0100 Subject: [PATCH 1/3] feat(ltk_inibin): implement inibin crate with public api --- .specify/memory/constitution.md | 20 +- CLAUDE.md | 8 + crates/league-toolkit/Cargo.toml | 3 + crates/league-toolkit/src/lib.rs | 3 + crates/ltk_hash/src/lib.rs | 1 + crates/ltk_hash/src/sdbm.rs | 73 +++ crates/ltk_inibin/Cargo.toml | 19 + crates/ltk_inibin/README.md | 242 +++++++ crates/ltk_inibin/examples/create_inibin.rs | 56 ++ crates/ltk_inibin/examples/read_inibin.rs | 30 + crates/ltk_inibin/examples/round_trip.rs | 58 ++ crates/ltk_inibin/src/error.rs | 13 + crates/ltk_inibin/src/file.rs | 386 +++++++++++ crates/ltk_inibin/src/lib.rs | 26 + crates/ltk_inibin/src/section.rs | 603 ++++++++++++++++++ crates/ltk_inibin/src/value.rs | 165 +++++ crates/ltk_inibin/src/value_kind.rs | 36 ++ crates/ltk_inibin/tests/round_trip.rs | 223 +++++++ .../checklists/requirements.md | 35 + .../001-inibin-crate/contracts/public-api.md | 195 ++++++ specs/001-inibin-crate/data-model.md | 131 ++++ specs/001-inibin-crate/plan.md | 90 +++ specs/001-inibin-crate/quickstart.md | 115 ++++ specs/001-inibin-crate/research.md | 135 ++++ specs/001-inibin-crate/spec.md | 144 +++++ specs/001-inibin-crate/tasks.md | 160 +++++ 26 files changed, 2969 insertions(+), 1 deletion(-) create mode 100644 crates/ltk_hash/src/sdbm.rs create mode 100644 crates/ltk_inibin/Cargo.toml create mode 100644 crates/ltk_inibin/README.md create mode 100644 crates/ltk_inibin/examples/create_inibin.rs create mode 100644 crates/ltk_inibin/examples/read_inibin.rs create mode 100644 crates/ltk_inibin/examples/round_trip.rs create mode 100644 crates/ltk_inibin/src/error.rs create mode 100644 crates/ltk_inibin/src/file.rs create mode 100644 crates/ltk_inibin/src/lib.rs create mode 100644 crates/ltk_inibin/src/section.rs create mode 100644 crates/ltk_inibin/src/value.rs create mode 100644 crates/ltk_inibin/src/value_kind.rs create mode 100644 crates/ltk_inibin/tests/round_trip.rs create mode 100644 specs/001-inibin-crate/checklists/requirements.md create mode 100644 specs/001-inibin-crate/contracts/public-api.md create mode 100644 specs/001-inibin-crate/data-model.md create mode 100644 specs/001-inibin-crate/plan.md create mode 100644 specs/001-inibin-crate/quickstart.md create mode 100644 specs/001-inibin-crate/research.md create mode 100644 specs/001-inibin-crate/spec.md create mode 100644 specs/001-inibin-crate/tasks.md 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..c1645c2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,3 +82,11 @@ 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 +- Rust (workspace edition, same as other `ltk_*` crates) + `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` (InibinFlags) (001-inibin-crate) +- N/A (in-memory data structures, binary file I/O) (001-inibin-crate) +- Rust (workspace edition, same as other `ltk_*` crates) + `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` (InibinFlags), `phf`/`phf_codegen` (compile-time hash map for ltk_inibin_names) (001-inibin-crate) + +## Recent Changes +- 001-inibin-crate: Added Rust (workspace edition, same as other `ltk_*` crates) + `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` (InibinFlags) 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..e4918e4f --- /dev/null +++ b/crates/ltk_hash/src/sdbm.rs @@ -0,0 +1,73 @@ +/// Compute SDBM hash of a lowercased string. +pub fn hash_lower(input: &str) -> u32 { + let mut hash: u32 = 0; + for c in input.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 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: &str, b: &str, delimiter: char) -> u32 { + let mut hash: u32 = 0; + + let chars = a + .chars() + .chain(std::iter::once(delimiter)) + .chain(b.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); + } +} 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..0f122683 --- /dev/null +++ b/crates/ltk_inibin/README.md @@ -0,0 +1,242 @@ +# 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::InibinFile; + +let file = File::open("data/characters/annie/annie.inibin").unwrap(); +let mut reader = BufReader::new(file); +let inibin = InibinFile::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::{InibinFile, InibinValue}; + +let mut inibin = InibinFile::new(); + +// Insert values of different types +inibin.insert(0x0001, InibinValue::F32(550.0)); +inibin.insert(0x0002, InibinValue::Int32(42)); +inibin.insert(0x0003, InibinValue::String("hello".to_string())); +inibin.insert(0x0004, InibinValue::Int64(9999999999)); + +// Remove a value +inibin.remove(0x0001); + +// Update: re-inserting with a different type migrates across buckets +inibin.insert(0x0002, InibinValue::F32(3.14)); +``` + +### Writing an inibin file + +```rust,no_run +use std::fs::File; +use std::io::BufWriter; +use ltk_inibin::InibinFile; + +# let inibin = InibinFile::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::{InibinFile, InibinValue}; + +let mut file = InibinFile::new(); +file.insert(0x0001, InibinValue::Int32(42)); + +let mut buf = Vec::new(); +file.to_writer(&mut buf).unwrap(); + +let mut cursor = Cursor::new(&buf); +let file2 = InibinFile::from_reader(&mut cursor).unwrap(); + +assert_eq!(file2.get(0x0001), Some(&InibinValue::Int32(42))); +``` + +### Iterating values + +```rust +use ltk_inibin::{InibinFile, InibinValue, InibinFlags}; + +let mut inibin = InibinFile::new(); +inibin.insert(0x0001, InibinValue::Int32(1)); +inibin.insert(0x0002, InibinValue::F32(2.0)); + +// Iterate all key-value pairs across all buckets +for (key, value) in inibin.iter() { + println!("0x{:08X} = {:?}", key, value); +} + +// Access a specific set bucket +if let Some(int_set) = inibin.set(InibinFlags::INT32_LIST) { + println!("Int32 set has {} entries", int_set.len()); +} +``` + +## 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` | `InibinValue::Int32` | 4 bytes LE | +| `F32_LIST` | 1 | `f32` | `InibinValue::F32` | 4 bytes LE | +| `U8_LIST` | 2 | `f32` | `InibinValue::U8` | 1 byte, `value * 0.1` (range 0.0-25.5) | +| `INT16_LIST` | 3 | `i16` | `InibinValue::Int16` | 2 bytes LE | +| `INT8_LIST` | 4 | `u8` | `InibinValue::Int8` | 1 byte | +| `BIT_LIST` | 5 | `bool` | `InibinValue::Bool` | 8 booleans packed per byte | +| `VEC3_U8_LIST` | 6 | `Vec3` | `InibinValue::Vec3U8` | 3 bytes, each `* 0.1` | +| `VEC3_F32_LIST` | 7 | `Vec3` | `InibinValue::Vec3F32` | 3x `f32` LE | +| `VEC2_U8_LIST` | 8 | `Vec2` | `InibinValue::Vec2U8` | 2 bytes, each `* 0.1` | +| `VEC2_F32_LIST` | 9 | `Vec2` | `InibinValue::Vec2F32` | 2x `f32` LE | +| `VEC4_U8_LIST` | 10 | `Vec4` | `InibinValue::Vec4U8` | 4 bytes, each `* 0.1` | +| `VEC4_F32_LIST` | 11 | `Vec4` | `InibinValue::Vec4F32` | 4x `f32` LE | +| `STRING_LIST` | 12 | `String` | `InibinValue::String` | Null-terminated ASCII with offset table | +| `INT64_LIST` | 13 | `i64` | `InibinValue::Int64` | 8 bytes LE | + +### U8 (Fixed-Point Float) Encoding + +The `U8` types (flags 2, 6, 8, 10) store floats as single bytes scaled by 0.1: +- **Read**: `byte as f32 * 0.1` (range 0.0 to 25.5) +- **Write**: `(value / 0.1).round() as u8` (validated to 0.0-25.5, returns `InibinError::U8FloatOverflow` if out of range) + +### 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, `InibinFile` stores data in **buckets** — one `InibinSet` per active flag type. Each set holds a `HashMap` 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 InibinError { + 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..4c1de798 --- /dev/null +++ b/crates/ltk_inibin/examples/create_inibin.rs @@ -0,0 +1,56 @@ +//! Create an inibin file from scratch and write it to disk. +//! +//! Usage: cargo run -p ltk_inibin --example create_inibin -- + +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(); + + // Insert values using 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); + + // Insert packed float (raw byte, decodes to byte * 0.1) + inibin.insert(0x0007, Value::U8(200)); // 200 * 0.1 = 20.0 + + // Insert vector types + inibin.insert(0x0008, Value::Vec3F32(glam::Vec3::new(1.0, 2.0, 3.0))); + inibin.insert(0x0009, Value::Vec3U8([10, 20, 30])); + + 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}"); + + // Demonstrate generic get_as + println!(); + println!("Reading back 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)); + println!( + " u8 float: {:?}", + inibin.get(0x0007).and_then(|v| v.u8_as_f32()) + ); +} diff --git a/crates/ltk_inibin/examples/read_inibin.rs b/crates/ltk_inibin/examples/read_inibin.rs new file mode 100644 index 00000000..0cce6206 --- /dev/null +++ b/crates/ltk_inibin/examples/read_inibin.rs @@ -0,0 +1,30 @@ +//! Read an inibin/troybin file and print all key-value pairs. +//! +//! Usage: cargo run -p ltk_inibin --example read_inibin -- + +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 = ltk_inibin::Inibin::from_reader(&mut reader).unwrap_or_else(|e| { + eprintln!("Failed to parse {path}: {e}"); + std::process::exit(1); + }); + + println!("{path}: {} entries", inibin.len()); + println!(); + + for (key, value) in inibin.iter() { + println!(" 0x{key:08X} = {value:?}"); + } +} diff --git a/crates/ltk_inibin/examples/round_trip.rs b/crates/ltk_inibin/examples/round_trip.rs new file mode 100644 index 00000000..985be853 --- /dev/null +++ b/crates/ltk_inibin/examples/round_trip.rs @@ -0,0 +1,58 @@ +//! Round-trip an inibin file: read -> write -> read, then verify equality. +//! +//! 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; + } + } + } + + 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..7c02cce2 --- /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_kind::{ValueKind, 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 = ValueKind::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(ValueKind::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(ValueKind::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(ValueKind::STRING_LIST, set); + + Ok(Self { sections }) + } + + /// Write as version 2 inibin format. + pub fn to_writer(&self, writer: &mut W) -> Result<()> { + let mut flags = ValueKind::empty(); + for &flag in self.sections.keys() { + flags |= flag; + } + + let string_data_length = self + .sections + .get(&ValueKind::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 == ValueKind::BIT_LIST { + set.write_bit_list(writer)?; + } else { + set.write_non_string(writer)?; + } + } + } + + // StringList last + if let Some(set) = self.sections.get(&ValueKind::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: ValueKind) -> Option<&Section> { + self.sections.get(&flags) + } + + pub fn section_mut(&mut self, flags: ValueKind) -> 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(&(ValueKind::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(&(ValueKind::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(ValueKind::INT32_LIST).is_some()); + + file.insert(0xABCD, Value::F32(3.125)); + + assert_eq!(file.get(0xABCD), Some(&Value::F32(3.125))); + assert!(file + .section(ValueKind::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..958eea7e --- /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_kind; + +pub use error::{Error, Result}; +pub use file::Inibin; +pub use section::Section; +pub use value::{FromValue, Value}; +pub use value_kind::ValueKind; diff --git a/crates/ltk_inibin/src/section.rs b/crates/ltk_inibin/src/section.rs new file mode 100644 index 00000000..48a46907 --- /dev/null +++ b/crates/ltk_inibin/src/section.rs @@ -0,0 +1,603 @@ +use std::io::{Read, Seek, SeekFrom, Write}; + +use byteorder::{LittleEndian as LE, ReadBytesExt, WriteBytesExt}; +use glam::{Vec2, Vec3, Vec4}; +use indexmap::IndexMap; + +use crate::error::Result; +use crate::value::Value; +use crate::value_kind::ValueKind; + +/// A typed bucket of key-value pairs within an inibin file. +#[derive(Debug, Clone, PartialEq)] +pub struct Section { + kind: ValueKind, + properties: IndexMap, +} + +impl Section { + pub(crate) fn new(kind: ValueKind) -> 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) -> ValueKind { + self.kind + } + + 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: ValueKind) -> 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 { + ValueKind::INT32_LIST => { + for hash in hashes { + properties.insert(hash, Value::I32(reader.read_i32::()?)); + } + } + ValueKind::F32_LIST => { + for hash in hashes { + properties.insert(hash, Value::F32(reader.read_f32::()?)); + } + } + ValueKind::U8_LIST => { + for hash in hashes { + properties.insert(hash, Value::U8(reader.read_u8()?)); + } + } + ValueKind::INT16_LIST => { + for hash in hashes { + properties.insert(hash, Value::I16(reader.read_i16::()?)); + } + } + ValueKind::INT8_LIST => { + for hash in hashes { + properties.insert(hash, Value::I8(reader.read_u8()?)); + } + } + ValueKind::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)); + } + } + ValueKind::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])); + } + } + ValueKind::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))); + } + } + ValueKind::VEC2_U8_LIST => { + for hash in hashes { + let x = reader.read_u8()?; + let y = reader.read_u8()?; + properties.insert(hash, Value::Vec2U8([x, y])); + } + } + ValueKind::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))); + } + } + ValueKind::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])); + } + } + ValueKind::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))); + } + } + ValueKind::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 = read_null_terminated_string(reader)?; + reader.seek(SeekFrom::Start(saved_pos))?; + properties.insert(hash, Value::String(s)); + } + + Ok(Self { + kind: ValueKind::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 = read_null_terminated_string(reader)?; + reader.seek(SeekFrom::Start(saved_pos))?; + properties.insert(hash, Value::String(s)); + } + + Ok(Self { + kind: ValueKind::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) +} + +fn read_null_terminated_string(reader: &mut R) -> Result { + let mut bytes = Vec::new(); + loop { + let byte = reader.read_u8()?; + if byte == 0 { + break; + } + bytes.push(byte); + } + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +#[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, ValueKind::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, ValueKind::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, ValueKind::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, ValueKind::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, ValueKind::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, ValueKind::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, ValueKind::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, ValueKind::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, ValueKind::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, ValueKind::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, ValueKind::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, ValueKind::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, ValueKind::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..abdcacf2 --- /dev/null +++ b/crates/ltk_inibin/src/value.rs @@ -0,0 +1,165 @@ +use glam::{Vec2, Vec3, Vec4}; + +use crate::value_kind::ValueKind; + +/// 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::u8_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::vec3_u8_as_f32`] to decode. + Vec3U8([u8; 3]), + Vec3F32(Vec3), + /// Packed float pair stored as raw bytes. Use [`Value::vec2_u8_as_f32`] to decode. + Vec2U8([u8; 2]), + Vec2F32(Vec2), + /// Packed float quad stored as raw bytes. Use [`Value::vec4_u8_as_f32`] to decode. + Vec4U8([u8; 4]), + Vec4F32(Vec4), + String(String), + I64(i64), +} + +impl Value { + /// Returns the [`ValueKind`] variant this value belongs to. + pub fn flags(&self) -> ValueKind { + match self { + Value::I32(_) => ValueKind::INT32_LIST, + Value::F32(_) => ValueKind::F32_LIST, + Value::U8(_) => ValueKind::U8_LIST, + Value::I16(_) => ValueKind::INT16_LIST, + Value::I8(_) => ValueKind::INT8_LIST, + Value::Bool(_) => ValueKind::BIT_LIST, + Value::Vec3U8(_) => ValueKind::VEC3_U8_LIST, + Value::Vec3F32(_) => ValueKind::VEC3_F32_LIST, + Value::Vec2U8(_) => ValueKind::VEC2_U8_LIST, + Value::Vec2F32(_) => ValueKind::VEC2_F32_LIST, + Value::Vec4U8(_) => ValueKind::VEC4_U8_LIST, + Value::Vec4F32(_) => ValueKind::VEC4_F32_LIST, + Value::String(_) => ValueKind::STRING_LIST, + Value::I64(_) => ValueKind::INT64_LIST, + } + } + + /// Decode a [`U8`](Value::U8) packed float: `byte * 0.1` (range 0.0–25.5). + pub fn u8_as_f32(&self) -> Option { + match self { + Value::U8(v) => Some(*v as f32 * 0.1), + _ => None, + } + } + + /// Decode a [`Vec2U8`](Value::Vec2U8) packed float pair. + pub fn vec2_u8_as_f32(&self) -> Option { + match self { + Value::Vec2U8([x, y]) => Some(Vec2::new(*x as f32 * 0.1, *y as f32 * 0.1)), + _ => None, + } + } + + /// Decode a [`Vec3U8`](Value::Vec3U8) packed float triple. + pub fn vec3_u8_as_f32(&self) -> Option { + match self { + Value::Vec3U8([x, y, z]) => { + Some(Vec3::new(*x as f32 * 0.1, *y as f32 * 0.1, *z as f32 * 0.1)) + } + _ => None, + } + } + + /// Decode a [`Vec4U8`](Value::Vec4U8) packed float quad. + pub fn vec4_u8_as_f32(&self) -> Option { + match self { + 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_kind.rs b/crates/ltk_inibin/src/value_kind.rs new file mode 100644 index 00000000..67724947 --- /dev/null +++ b/crates/ltk_inibin/src/value_kind.rs @@ -0,0 +1,36 @@ +bitflags::bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct ValueKind: 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: [ValueKind; 13] = [ + ValueKind::INT32_LIST, + ValueKind::F32_LIST, + ValueKind::U8_LIST, + ValueKind::INT16_LIST, + ValueKind::INT8_LIST, + ValueKind::BIT_LIST, + ValueKind::VEC3_U8_LIST, + ValueKind::VEC3_F32_LIST, + ValueKind::VEC2_U8_LIST, + ValueKind::VEC2_F32_LIST, + ValueKind::VEC4_U8_LIST, + ValueKind::VEC4_F32_LIST, + ValueKind::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..620641b8 --- /dev/null +++ b/crates/ltk_inibin/tests/round_trip.rs @@ -0,0 +1,223 @@ +use std::io::Cursor; + +use glam::{Vec2, Vec3, Vec4}; +use ltk_inibin::{Inibin, Value, ValueKind}; + +// 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 u8_as_f32_accessor() { + let val = Value::U8(100); + approx::assert_relative_eq!(val.u8_as_f32().unwrap(), 10.0); + + let val = Value::U8(255); + approx::assert_relative_eq!(val.u8_as_f32().unwrap(), 25.5); + + let val = Value::U8(0); + approx::assert_relative_eq!(val.u8_as_f32().unwrap(), 0.0); + + // Non-U8 variant returns None + assert_eq!(Value::I32(42).u8_as_f32(), 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(ValueKind::INT32_LIST).unwrap(); + assert_eq!(int_set.len(), 2); + assert_eq!(int_set.kind(), ValueKind::INT32_LIST); + + let float_set = file.section(ValueKind::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(ValueKind::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..9c248fac --- /dev/null +++ b/specs/001-inibin-crate/contracts/public-api.md @@ -0,0 +1,195 @@ +# Public API Contract: ltk_inibin + ltk_inibin_names + +**Phase**: 1 | **Date**: 2026-03-25 | **Updated**: 2026-03-25 + +## ltk_hash additions + +### `ltk_hash::sdbm` + +```rust +/// Compute SDBM hash of a lowercased string. +pub fn hash_lower(input: &str) -> 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: &str, b: &str, delimiter: char) -> u32; +``` + +## ltk_inibin public API + +### InibinFile + +```rust +/// Top-level inibin/troybin file container. +pub struct InibinFile { /* bucket-based internal storage */ } + +impl InibinFile { + /// 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<&InibinValue>; + + /// 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: InibinValue); + + /// 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; + + /// Iterate over all key-value pairs across all buckets. + pub fn iter(&self) -> impl Iterator; + + /// Get a reference to a specific set by flag type. + pub fn set(&self, flags: InibinFlags) -> Option<&InibinSet>; + + /// Get a mutable reference to a specific set by flag type. + pub fn set_mut(&mut self, flags: InibinFlags) -> Option<&mut InibinSet>; +} +``` + +### InibinSet + +```rust +/// A typed bucket of key-value pairs. +pub struct InibinSet { /* properties map */ } + +impl InibinSet { + /// Get value by hash key within this set. + pub fn get(&self, key: u32) -> Option<&InibinValue>; + + /// Insert a key-value pair into this set. + pub fn insert(&mut self, key: u32, value: InibinValue); + + /// Remove a key-value pair from this set. + pub fn remove(&mut self, key: u32) -> Option; + + /// Number of entries in this set. + pub fn len(&self) -> usize; + + /// Whether this set is empty. + pub fn is_empty(&self) -> bool; + + /// The flag type of this set. + pub fn set_type(&self) -> InibinFlags; + + /// Iterate over key-value pairs in this set. + pub fn iter(&self) -> impl Iterator; +} +``` + +### InibinFlags + +```rust +bitflags! { + /// Bitfield representing inibin value set types. + pub struct InibinFlags: 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; + } +} +``` + +### InibinValue + +```rust +/// Typed value stored in an inibin set. +pub enum InibinValue { + Int32(i32), + F32(f32), + U8(f32), + Int16(i16), + Int8(u8), + Bool(bool), + Vec3U8(Vec3), + Vec3F32(Vec3), + Vec2U8(Vec2), + Vec2F32(Vec2), + Vec4U8(Vec4), + Vec4F32(Vec4), + String(String), + Int64(i64), +} + +impl InibinValue { + /// Returns the InibinFlags variant this value belongs to. + pub fn flags(&self) -> InibinFlags; +} +``` + +### 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..ad2d96d7 --- /dev/null +++ b/specs/001-inibin-crate/data-model.md @@ -0,0 +1,131 @@ +# Data Model: ltk_inibin + +**Phase**: 1 | **Date**: 2026-03-25 | **Updated**: 2026-03-25 + +## Entities + +### InibinFile + +The top-level container for an inibin/troybin file. + +**Fields**: +- `sets`: Map from `InibinFlags` (single flag value) to `InibinSet` — the bucket-based internal storage + +**Relationships**: Contains zero or more `InibinSet` instances, keyed by flag type. At most 14 sets (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. + +### InibinSet + +A typed collection of key-value pairs within a single bucket. + +**Fields**: +- `set_type`: `InibinFlags` — identifies which value type this set holds +- `properties`: Map from `u32` (hash key) to the typed value + +**Relationships**: Owned by `InibinFile`. Each set holds values of exactly one type. + +**Validation**: +- Hash keys must be unique within a set +- Values must match the set's type constraint +- U8 (fixed-point float) values must be in range 0.0-25.5 + +### InibinFlags + +Bitfield enum (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. + +### InibinValue + +Typed value enum representing all possible value types. + +**Variants**: +- `Int32(i32)` +- `F32(f32)` +- `U8(f32)` — stored as f32, validated to 0.0-25.5 range on write +- `Int16(i16)` +- `Int8(u8)` +- `Bool(bool)` +- `Vec3U8(Vec3)` +- `Vec3F32(Vec3)` +- `Vec2U8(Vec2)` +- `Vec2F32(Vec2)` +- `Vec4U8(Vec4)` +- `Vec4F32(Vec4)` +- `String(String)` +- `Int64(i64)` + +**Mapping**: Each variant corresponds to exactly one `InibinFlags` 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..76bb3f75 --- /dev/null +++ b/specs/001-inibin-crate/plan.md @@ -0,0 +1,90 @@ +# Implementation Plan: Inibin File Parser (ltk_inibin + ltk_inibin_names) + +**Branch**: `001-inibin-crate` | **Date**: 2026-03-25 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/001-inibin-crate/spec.md` + +## Summary + +Implement `ltk_inibin` — a Rust crate for reading, writing, and modifying League of Legends inibin/troybin binary files. Supports 14 value set types (including Int64 at flag 13), version 1 (read-only) and version 2 (read+write) formats, with key-based public API and bucket-based internal storage. Additionally, implement `ltk_inibin_names` — a companion crate providing compile-time hash→name resolution from the lolpytools fixlist. + +## 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` (InibinFlags), `phf`/`phf_codegen` (compile-time hash map for ltk_inibin_names) +**Storage**: N/A (in-memory data structures, binary file I/O) +**Testing**: `cargo test`, `approx` for floating-point comparisons +**Target Platform**: All platforms supported by the workspace (no platform-specific code) +**Project Type**: Library (two crates) +**Performance Goals**: Zero-cost name lookups via `phf`; standard binary I/O performance +**Constraints**: Must follow workspace conventions (from_reader/to_writer, thiserror, glam, workspace deps) +**Scale/Scope**: ~14 value types, ~thousands of fixlist entries + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Crate-First Architecture | PASS | Two separate crates: `ltk_inibin` (parser) and `ltk_inibin_names` (name resolution). Both under `crates/`. No circular deps. Umbrella re-exports via feature flags. | +| II. Round-Trip Correctness | PASS | All 14 types support read+write. Round-trip tests required. `approx` for floats. | +| III. Strict CI Quality Gate | PASS | fmt + clippy + test required before merge. | +| IV. Idiomatic Rust I/O | PASS | `from_reader(Read+Seek)` / `to_writer(Write)`. `glam` vectors. | +| V. Workspace Dependency Hygiene | PASS | All existing deps at workspace level. `phf`/`phf_codegen` added at workspace level (justified: thousands of static entries, zero-cost lookups). | +| Error Handling & Safety | PASS | Own error type via thiserror. No unwrap in lib code. | + +**Post-design re-check**: All gates still PASS. + +## 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 +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ └── public-api.md +└── tasks.md # Phase 2 output (/speckit.tasks) +``` + +### Source Code (repository root) + +```text +crates/ltk_hash/ +├── src/ +│ ├── lib.rs # Module declarations (add sdbm) +│ └── sdbm.rs # SDBM hash implementation +└── Cargo.toml + +crates/ltk_inibin/ +├── src/ +│ ├── lib.rs # Re-exports + module declarations +│ ├── error.rs # InibinError + Result +│ ├── file.rs # InibinFile (from_reader, to_writer, CRUD API) +│ ├── flags.rs # InibinFlags bitfield +│ ├── set.rs # InibinSet (per-bucket read/write logic) +│ └── value.rs # InibinValue enum +├── tests/ +│ └── round_trip.rs # Integration round-trip tests +└── Cargo.toml + +crates/ltk_inibin_names/ +├── src/ +│ └── lib.rs # lookup() function + include generated phf map +├── build.rs # phf_codegen: generate hash→name map at compile time +├── data/ +│ └── fixlist.rs # Raw fixlist data (section, name, hash) tuples +└── Cargo.toml + +crates/league-toolkit/ +├── Cargo.toml # Add inibin + inibin-names feature flags +└── src/lib.rs # Re-export ltk_inibin and ltk_inibin_names +``` + +**Structure Decision**: Two new crates under `crates/` following the existing workspace pattern. `ltk_inibin` is the core parser/writer with no dependency on names. `ltk_inibin_names` is a standalone lookup crate using `phf` for compile-time hash maps. Both are re-exported through the umbrella crate behind feature flags. + +## Complexity Tracking + +No constitution violations — table not needed. diff --git a/specs/001-inibin-crate/quickstart.md b/specs/001-inibin-crate/quickstart.md new file mode 100644 index 00000000..254a61ce --- /dev/null +++ b/specs/001-inibin-crate/quickstart.md @@ -0,0 +1,115 @@ +# Quickstart: ltk_inibin + +**Phase**: 1 | **Date**: 2026-03-25 | **Updated**: 2026-03-25 + +## Reading an inibin file + +```rust +use std::fs::File; +use std::io::BufReader; +use ltk_inibin::InibinFile; + +let file = File::open("data/characters/annie/annie.inibin")?; +let mut reader = BufReader::new(file); +let inibin = InibinFile::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::{InibinFile, InibinValue}; +use ltk_hash::sdbm; + +let mut inibin = InibinFile::from_reader(&mut reader)?; + +// Insert a new float value +let key = sdbm::hash_lower_with_delimiter("DATA", "AttackRange", '*'); +inibin.insert(key, InibinValue::F32(550.0)); + +// Insert an Int64 value +inibin.insert(0x1234, InibinValue::Int64(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 = InibinFile::from_reader(&mut reader)?; + +let mut buf = Vec::new(); +inibin.to_writer(&mut buf)?; + +let mut cursor = Cursor::new(&buf); +let inibin2 = InibinFile::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::InibinFlags; + +if let Some(float_set) = inibin.set(InibinFlags::F32_LIST) { + println!("Float set has {} entries", float_set.len()); + for (key, value) in float_set.iter() { + println!(" 0x{:08X} = {:?}", key, 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..b0b1e435 --- /dev/null +++ b/specs/001-inibin-crate/research.md @@ -0,0 +1,135 @@ +# Research: ltk_inibin + +**Phase**: 0 | **Date**: 2026-03-25 | **Updated**: 2026-03-25 + +## 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 — rejected because users expect float access; conversion happens at parse/write boundary. + +## 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..5499d6ac --- /dev/null +++ b/specs/001-inibin-crate/spec.md @@ -0,0 +1,144 @@ +# 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. + +## 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**: `ltk_inibin_names` crate~~ — **Descoped** to a separate PR. +- ~~**FR-015**: `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). +- **InibinFlags**: A bitfield enum 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). +- ~~**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**: `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..1aacfb1a --- /dev/null +++ b/specs/001-inibin-crate/tasks.md @@ -0,0 +1,160 @@ +# Tasks: Inibin Int64 Support + Name Resolution (ltk_inibin_names) + +**Input**: Design documents from `/specs/001-inibin-crate/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/ +**Context**: `ltk_inibin` already exists with 13 value set types. These tasks add Int64 (flag 13) and a new `ltk_inibin_names` crate. + +## 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, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup + +**Purpose**: Add workspace dependencies and create ltk_inibin_names crate skeleton + +- [X] T001 Add `phf` and `phf_codegen` to workspace dependencies in `Cargo.toml` (root) +- [X] T002 Create `crates/ltk_inibin_names/Cargo.toml` with `phf` dependency and `phf_codegen` build-dependency +- [X] T003 Create empty `crates/ltk_inibin_names/src/lib.rs` with module-level doc comment +- [X] T004 Create empty `crates/ltk_inibin_names/build.rs` placeholder + +--- + +## Phase 2: Foundational (Int64 Type Support) + +**Purpose**: Add Int64 flag, value variant, and read/write logic to existing ltk_inibin crate + +**⚠️ CRITICAL**: Must complete before user story validation + +- [X] T005 Add `INT64_LIST = 1 << 13` flag to `InibinFlags` and update `NON_STRING_FLAGS` array in `crates/ltk_inibin/src/flags.rs` +- [X] T006 [P] Add `Int64(i64)` variant to `InibinValue` enum and update `flags()` method in `crates/ltk_inibin/src/value.rs` +- [X] T007 Add Int64List read logic (`read_i64::`) to `InibinSet::read_non_string()` in `crates/ltk_inibin/src/set.rs` +- [X] T008 Add Int64List write logic (`write_i64::`) to `InibinSet::write_non_string()` in `crates/ltk_inibin/src/set.rs` +- [X] T009 Update `InibinFile::read_v2()` to handle flag bit 13 (Int64List) in the read loop in `crates/ltk_inibin/src/file.rs` +- [X] T010 Update `InibinFile::to_writer()` to include Int64List sets in the write loop in `crates/ltk_inibin/src/file.rs` + +**Checkpoint**: Int64 values can be read and written. Existing tests still pass. + +--- + +## Phase 3: User Story 1+2 — Read & Access Int64 Values (Priority: P1) + +**Goal**: Parse inibin files containing Int64 sets and access values by key + +**Independent Test**: Construct an InibinFile with Int64 values, write to bytes, read back, verify values match + +- [X] T011 [P] [US1] Add Int64 unit test to `InibinSet` read tests (test_read_int64_list) in `crates/ltk_inibin/src/set.rs` +- [X] T012 [P] [US2] Add Int64 entries to the `round_trip_all_set_types` test in `crates/ltk_inibin/tests/round_trip.rs` + +**Checkpoint**: Int64 read + key access verified by tests + +--- + +## Phase 4: User Story 3+4 — Write & Modify Int64 Values (Priority: P2) + +**Goal**: Round-trip Int64 values and support insert/remove operations + +**Independent Test**: Insert Int64 values, write, read back, verify round-trip integrity + +- [X] T013 [P] [US3] Add dedicated Int64 round-trip test (round_trip_int64) in `crates/ltk_inibin/tests/round_trip.rs` +- [X] T014 [P] [US4] Add Int64 insert/remove test to verify cross-bucket migration in `crates/ltk_inibin/tests/round_trip.rs` + +**Checkpoint**: Int64 fully integrated — read, write, modify, round-trip all verified + +--- + +## Phase 5: User Story 5 — Hash Key Name Resolution (Priority: P3) + +**Goal**: Provide compile-time hash→(section, name) lookups via `ltk_inibin_names` + +**Independent Test**: Query known hashes from the fixlist and verify correct (section, name) pairs returned; query unknown hash and verify None + +- [X] T015 [US5] Extract fixlist data from lolpytools `inibin_fix.py` into `crates/ltk_inibin_names/data/fixlist.rs` as a Rust array of `(u32, &str, &str)` tuples +- [X] T016 [US5] Implement `build.rs` in `crates/ltk_inibin_names/build.rs` using `phf_codegen` to generate a `phf::Map` from fixlist data +- [X] T017 [US5] Implement `lookup(hash: u32) -> Option<(&'static str, &'static str)>` in `crates/ltk_inibin_names/src/lib.rs` using the generated phf map +- [X] T018 [US5] Add tests for `lookup()` — verify known hashes return correct pairs and unknown hashes return None in `crates/ltk_inibin_names/src/lib.rs` + +**Checkpoint**: Name resolution works for all fixlist entries with zero runtime overhead + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Umbrella integration, CI validation, final cleanup + +- [X] T019 [P] Add `inibin-names` feature flag and `ltk_inibin_names` dependency to `crates/league-toolkit/Cargo.toml` +- [X] T020 [P] Add `#[cfg(feature = "inibin-names")] pub use ltk_inibin_names as inibin_names;` re-export in `crates/league-toolkit/src/lib.rs` +- [X] T021 Run `cargo fmt -- --check` across workspace +- [X] T022 Run `cargo clippy --all-targets -- -D warnings` across workspace +- [X] T023 Run `cargo test --verbose` across workspace — all tests must pass + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 (T001 for workspace deps) +- **US1+2 (Phase 3)**: Depends on Phase 2 (Int64 type support in place) +- **US3+4 (Phase 4)**: Depends on Phase 2 (can run parallel with Phase 3) +- **US5 (Phase 5)**: Depends on Phase 1 only (T002-T004 for crate skeleton) — independent of Phases 2-4 +- **Polish (Phase 6)**: Depends on all prior phases + +### User Story Dependencies + +- **US1+2 (P1)**: Depends on Foundational — Int64 flag/value/read must exist +- **US3+4 (P2)**: Depends on Foundational — Int64 write must exist. Can run parallel with US1+2 +- **US5 (P3)**: Fully independent of US1-4. Only needs crate skeleton from Phase 1 + +### Parallel Opportunities + +- T002, T003, T004 can run in parallel (different files in new crate) +- T005, T006 can run in parallel (different files: flags.rs, value.rs) +- T007, T008 touch same file (set.rs) — must be sequential +- T011, T012 can run in parallel (different test files) +- T013, T014 can run in parallel (same file but independent tests) +- T015 is independent — can start as soon as Phase 1 crate skeleton exists +- T019, T020 can run in parallel (different files) +- Phase 5 (US5) can run entirely in parallel with Phases 3+4 + +--- + +## Parallel Example: Phase 5 (US5) + +```text +# These can run in parallel with Int64 work (Phases 3+4): +Task T015: "Extract fixlist data into crates/ltk_inibin_names/data/fixlist.rs" +Task T016: "Implement build.rs with phf_codegen" (depends on T015) +Task T017: "Implement lookup() in lib.rs" (depends on T016) +Task T018: "Add lookup tests" (depends on T017) +``` + +--- + +## Implementation Strategy + +### MVP First (Int64 Support Only) + +1. Complete Phase 1: Setup (workspace deps) +2. Complete Phase 2: Foundational (Int64 type support) +3. Complete Phase 3: US1+2 (Int64 read + access tests) +4. Complete Phase 4: US3+4 (Int64 write + modify tests) +5. **STOP and VALIDATE**: All existing + new tests pass + +### Full Delivery + +1. MVP (above) + Phase 5: US5 (ltk_inibin_names) +2. Phase 6: Polish (umbrella integration, CI gate) + +--- + +## Notes + +- Existing ltk_inibin code (13 types) is already complete and tested +- Int64 follows the exact same pattern as Int32 but with 8-byte values +- The fixlist extraction from Python to Rust is the largest single task (T015) — thousands of entries +- phf_codegen runs at compile time in build.rs, so the generated map is baked into the binary From 6b7058c4f63fbfc17e2c82f5afdc84e351e8b676 Mon Sep 17 00:00:00 2001 From: Crauzer <0xcrauzer@proton.me> Date: Thu, 26 Mar 2026 14:57:40 +0100 Subject: [PATCH 2/3] refactor(ltk_inibin): Rename types and improve public API - Updated `InibinSet` to `Section` and `InibinFlags` to `ValueFlags` for clarity. - Introduced unified accessors (`as_f32()`, `as_vec2()`, etc.) on `InibinValue` to handle both packed and non-packed variants. - Added `.keys()` and `.values()` methods to `Section` for idiomatic map-like access. - Adjusted SDBM hash functions to accept `AsRef` for improved ergonomics. - Updated documentation and examples to reflect the new naming and API changes. - Ensured all existing tests pass and added new tests for the unified accessors and section methods. --- CLAUDE.md | 6 +- crates/ltk_hash/src/sdbm.rs | 54 +++++- crates/ltk_inibin/README.md | 92 ++++----- crates/ltk_inibin/examples/create_inibin.rs | 92 +++++++-- crates/ltk_inibin/examples/inspect_inibin.rs | 168 +++++++++++++++++ crates/ltk_inibin/examples/modify_inibin.rs | 105 +++++++++++ crates/ltk_inibin/examples/read_inibin.rs | 86 ++++++++- crates/ltk_inibin/examples/round_trip.rs | 13 ++ crates/ltk_inibin/src/file.rs | 32 ++-- crates/ltk_inibin/src/lib.rs | 4 +- crates/ltk_inibin/src/section.rs | 74 ++++---- crates/ltk_inibin/src/value.rs | 64 ++++--- .../src/{value_kind.rs => value_flags.rs} | 30 +-- crates/ltk_inibin/tests/round_trip.rs | 78 ++++++-- .../001-inibin-crate/contracts/public-api.md | 120 +++++++----- specs/001-inibin-crate/data-model.md | 48 ++--- specs/001-inibin-crate/plan.md | 90 ++++----- specs/001-inibin-crate/quickstart.md | 32 ++-- specs/001-inibin-crate/research.md | 30 ++- specs/001-inibin-crate/spec.md | 28 ++- specs/001-inibin-crate/tasks.md | 178 +++++++++--------- 21 files changed, 1021 insertions(+), 403 deletions(-) create mode 100644 crates/ltk_inibin/examples/inspect_inibin.rs create mode 100644 crates/ltk_inibin/examples/modify_inibin.rs rename crates/ltk_inibin/src/{value_kind.rs => value_flags.rs} (60%) diff --git a/CLAUDE.md b/CLAUDE.md index c1645c2c..2b3f9ec8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,9 +84,7 @@ Shared dependency versions are declared in the root `Cargo.toml` under `[workspa 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 -- Rust (workspace edition, same as other `ltk_*` crates) + `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` (InibinFlags) (001-inibin-crate) -- N/A (in-memory data structures, binary file I/O) (001-inibin-crate) -- Rust (workspace edition, same as other `ltk_*` crates) + `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` (InibinFlags), `phf`/`phf_codegen` (compile-time hash map for ltk_inibin_names) (001-inibin-crate) +- `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 Rust (workspace edition, same as other `ltk_*` crates) + `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` (InibinFlags) +- 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/ltk_hash/src/sdbm.rs b/crates/ltk_hash/src/sdbm.rs index e4918e4f..3bf84f1f 100644 --- a/crates/ltk_hash/src/sdbm.rs +++ b/crates/ltk_hash/src/sdbm.rs @@ -1,7 +1,7 @@ /// Compute SDBM hash of a lowercased string. -pub fn hash_lower(input: &str) -> u32 { +pub fn hash_lower(input: impl AsRef) -> u32 { let mut hash: u32 = 0; - for c in input.chars().flat_map(|c| c.to_lowercase()) { + 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() { @@ -14,16 +14,30 @@ pub fn hash_lower(input: &str) -> u32 { 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. /// -/// Used for inibin keys: `hash_lower_with_delimiter(section, property, '*')` -pub fn hash_lower_with_delimiter(a: &str, b: &str, delimiter: char) -> u32 { +/// 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.chars()) + .chain(b.as_ref().chars()) .flat_map(|c| c.to_lowercase()); for c in chars { @@ -70,4 +84,34 @@ mod tests { 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/README.md b/crates/ltk_inibin/README.md index 0f122683..dd6898cc 100644 --- a/crates/ltk_inibin/README.md +++ b/crates/ltk_inibin/README.md @@ -36,11 +36,11 @@ league-toolkit = { version = "0.2", features = ["inibin"] } ```rust,no_run use std::fs::File; use std::io::BufReader; -use ltk_inibin::InibinFile; +use ltk_inibin::Inibin; let file = File::open("data/characters/annie/annie.inibin").unwrap(); let mut reader = BufReader::new(file); -let inibin = InibinFile::from_reader(&mut reader).unwrap(); +let inibin = Inibin::from_reader(&mut reader).unwrap(); // Look up a value by its u32 hash key if let Some(value) = inibin.get(0xABCD1234) { @@ -62,21 +62,21 @@ let value = inibin.get(key); ### Modifying values ```rust -use ltk_inibin::{InibinFile, InibinValue}; +use ltk_inibin::{Inibin, Value}; -let mut inibin = InibinFile::new(); +let mut inibin = Inibin::new(); // Insert values of different types -inibin.insert(0x0001, InibinValue::F32(550.0)); -inibin.insert(0x0002, InibinValue::Int32(42)); -inibin.insert(0x0003, InibinValue::String("hello".to_string())); -inibin.insert(0x0004, InibinValue::Int64(9999999999)); +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, InibinValue::F32(3.14)); +inibin.insert(0x0002, 3.125f32); ``` ### Writing an inibin file @@ -84,9 +84,9 @@ inibin.insert(0x0002, InibinValue::F32(3.14)); ```rust,no_run use std::fs::File; use std::io::BufWriter; -use ltk_inibin::InibinFile; +use ltk_inibin::Inibin; -# let inibin = InibinFile::new(); +# let inibin = Inibin::new(); let file = File::create("output.inibin").unwrap(); let mut writer = BufWriter::new(file); inibin.to_writer(&mut writer).unwrap(); @@ -96,37 +96,41 @@ inibin.to_writer(&mut writer).unwrap(); ```rust use std::io::Cursor; -use ltk_inibin::{InibinFile, InibinValue}; +use ltk_inibin::{Inibin, Value}; -let mut file = InibinFile::new(); -file.insert(0x0001, InibinValue::Int32(42)); +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 = InibinFile::from_reader(&mut cursor).unwrap(); +let file2 = Inibin::from_reader(&mut cursor).unwrap(); -assert_eq!(file2.get(0x0001), Some(&InibinValue::Int32(42))); +assert_eq!(file2.get(0x0001), Some(&Value::I32(42))); ``` ### Iterating values ```rust -use ltk_inibin::{InibinFile, InibinValue, InibinFlags}; +use ltk_inibin::{Inibin, Value, ValueFlags}; -let mut inibin = InibinFile::new(); -inibin.insert(0x0001, InibinValue::Int32(1)); -inibin.insert(0x0002, InibinValue::F32(2.0)); +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 set bucket -if let Some(int_set) = inibin.set(InibinFlags::INT32_LIST) { - println!("Int32 set has {} entries", int_set.len()); +// 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); + } } ``` @@ -173,26 +177,30 @@ All 14 types and their corresponding flag bits: | Flag | Bit | Type | Rust Variant | Encoding | |------|-----|------|-------------|----------| -| `INT32_LIST` | 0 | `i32` | `InibinValue::Int32` | 4 bytes LE | -| `F32_LIST` | 1 | `f32` | `InibinValue::F32` | 4 bytes LE | -| `U8_LIST` | 2 | `f32` | `InibinValue::U8` | 1 byte, `value * 0.1` (range 0.0-25.5) | -| `INT16_LIST` | 3 | `i16` | `InibinValue::Int16` | 2 bytes LE | -| `INT8_LIST` | 4 | `u8` | `InibinValue::Int8` | 1 byte | -| `BIT_LIST` | 5 | `bool` | `InibinValue::Bool` | 8 booleans packed per byte | -| `VEC3_U8_LIST` | 6 | `Vec3` | `InibinValue::Vec3U8` | 3 bytes, each `* 0.1` | -| `VEC3_F32_LIST` | 7 | `Vec3` | `InibinValue::Vec3F32` | 3x `f32` LE | -| `VEC2_U8_LIST` | 8 | `Vec2` | `InibinValue::Vec2U8` | 2 bytes, each `* 0.1` | -| `VEC2_F32_LIST` | 9 | `Vec2` | `InibinValue::Vec2F32` | 2x `f32` LE | -| `VEC4_U8_LIST` | 10 | `Vec4` | `InibinValue::Vec4U8` | 4 bytes, each `* 0.1` | -| `VEC4_F32_LIST` | 11 | `Vec4` | `InibinValue::Vec4F32` | 4x `f32` LE | -| `STRING_LIST` | 12 | `String` | `InibinValue::String` | Null-terminated ASCII with offset table | -| `INT64_LIST` | 13 | `i64` | `InibinValue::Int64` | 8 bytes LE | +| `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 single bytes scaled by 0.1: -- **Read**: `byte as f32 * 0.1` (range 0.0 to 25.5) -- **Write**: `(value / 0.1).round() as u8` (validated to 0.0-25.5, returns `InibinError::U8FloatOverflow` if out of range) +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 @@ -202,14 +210,14 @@ Booleans are packed 8 per byte. For a set with `n` values, `ceil(n / 8)` bytes a ### Bucket-Based Storage -Internally, `InibinFile` stores data in **buckets** — one `InibinSet` per active flag type. Each set holds a `HashMap` where keys are SDBM hashes. +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 InibinError { +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 diff --git a/crates/ltk_inibin/examples/create_inibin.rs b/crates/ltk_inibin/examples/create_inibin.rs index 4c1de798..22bffad0 100644 --- a/crates/ltk_inibin/examples/create_inibin.rs +++ b/crates/ltk_inibin/examples/create_inibin.rs @@ -1,7 +1,16 @@ //! 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}; @@ -13,7 +22,7 @@ fn main() { let mut inibin = Inibin::new(); - // Insert values using From convenience + // ── Scalar types (From convenience) ────────────────────── inibin.insert(0x0001, 42i32); inibin.insert(0x0002, 550.0f32); inibin.insert(0x0003, "hello world"); @@ -21,36 +30,95 @@ fn main() { inibin.insert(0x0005, -100i16); inibin.insert(0x0006, 9999999999i64); - // Insert packed float (raw byte, decodes to byte * 0.1) - inibin.insert(0x0007, Value::U8(200)); // 200 * 0.1 = 20.0 + // ── 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))); - // Insert vector types - inibin.insert(0x0008, Value::Vec3F32(glam::Vec3::new(1.0, 2.0, 3.0))); - inibin.insert(0x0009, Value::Vec3U8([10, 20, 30])); + // ── 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 + // ── 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}"); - // Demonstrate generic get_as + // ── Reading values back ───────────────────────────────────── println!(); - println!("Reading back with get_as:"); + 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!( - " u8 float: {:?}", - inibin.get(0x0007).and_then(|v| v.u8_as_f32()) + " 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..559c9405 --- /dev/null +++ b/crates/ltk_inibin/examples/inspect_inibin.rs @@ -0,0 +1,168 @@ +//! 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..286c7ca0 --- /dev/null +++ b/crates/ltk_inibin/examples/modify_inibin.rs @@ -0,0 +1,105 @@ +//! 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 index 0cce6206..ecf9f843 100644 --- a/crates/ltk_inibin/examples/read_inibin.rs +++ b/crates/ltk_inibin/examples/read_inibin.rs @@ -1,7 +1,16 @@ -//! Read an inibin/troybin file and print all key-value pairs. +//! 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() { @@ -16,15 +25,82 @@ fn main() { }); let mut reader = BufReader::new(file); - let inibin = ltk_inibin::Inibin::from_reader(&mut reader).unwrap_or_else(|e| { + let inibin = Inibin::from_reader(&mut reader).unwrap_or_else(|e| { eprintln!("Failed to parse {path}: {e}"); std::process::exit(1); }); - println!("{path}: {} entries", inibin.len()); + println!("{path}: {} total entries", inibin.len()); println!(); - for (key, value) in inibin.iter() { - println!(" 0x{key:08X} = {value:?}"); + // ── 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 index 985be853..28534715 100644 --- a/crates/ltk_inibin/examples/round_trip.rs +++ b/crates/ltk_inibin/examples/round_trip.rs @@ -1,5 +1,10 @@ //! 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}; @@ -49,6 +54,14 @@ fn main() { } } + // 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 { diff --git a/crates/ltk_inibin/src/file.rs b/crates/ltk_inibin/src/file.rs index 7c02cce2..aeee1eab 100644 --- a/crates/ltk_inibin/src/file.rs +++ b/crates/ltk_inibin/src/file.rs @@ -6,12 +6,12 @@ use indexmap::IndexMap; use crate::error::{Error, Result}; use crate::section::Section; use crate::value::{FromValue, Value}; -use crate::value_kind::{ValueKind, NON_STRING_KINDS}; +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, + pub(crate) sections: IndexMap, } impl Inibin { @@ -34,7 +34,7 @@ impl Inibin { fn read_v2(reader: &mut R) -> Result { let string_data_length = reader.read_u16::()?; - let flags = ValueKind::from_bits_truncate(reader.read_u16::()?); + let flags = ValueFlags::from_bits_truncate(reader.read_u16::()?); let mut sections = IndexMap::new(); for &flag in &NON_STRING_KINDS { @@ -45,7 +45,7 @@ impl Inibin { } // StringList is always read last; validate string_data_length from the header - if flags.contains(ValueKind::STRING_LIST) { + 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))?; @@ -61,7 +61,7 @@ impl Inibin { }); } - sections.insert(ValueKind::STRING_LIST, set); + sections.insert(ValueFlags::STRING_LIST, set); } Ok(Self { sections }) @@ -84,21 +84,21 @@ impl Inibin { let set = Section::read_string_list_v1(reader, hashes, string_data_offset)?; let mut sections = IndexMap::new(); - sections.insert(ValueKind::STRING_LIST, set); + 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 = ValueKind::empty(); + let mut flags = ValueFlags::empty(); for &flag in self.sections.keys() { flags |= flag; } let string_data_length = self .sections - .get(&ValueKind::STRING_LIST) + .get(&ValueFlags::STRING_LIST) .map(|s| s.string_data_length()) .unwrap_or(0); @@ -110,7 +110,7 @@ impl Inibin { // Non-string sets in flag order for &flag in &NON_STRING_KINDS { if let Some(set) = self.sections.get(&flag) { - if flag == ValueKind::BIT_LIST { + if flag == ValueFlags::BIT_LIST { set.write_bit_list(writer)?; } else { set.write_non_string(writer)?; @@ -119,7 +119,7 @@ impl Inibin { } // StringList last - if let Some(set) = self.sections.get(&ValueKind::STRING_LIST) { + if let Some(set) = self.sections.get(&ValueFlags::STRING_LIST) { let string_data = set.write_string_list(writer)?; writer.write_all(&string_data)?; } @@ -225,11 +225,11 @@ impl Inibin { self.sections.values().flat_map(|set| set.iter()) } - pub fn section(&self, flags: ValueKind) -> Option<&Section> { + pub fn section(&self, flags: ValueFlags) -> Option<&Section> { self.sections.get(&flags) } - pub fn section_mut(&mut self, flags: ValueKind) -> Option<&mut Section> { + pub fn section_mut(&mut self, flags: ValueFlags) -> Option<&mut Section> { self.sections.get_mut(&flags) } } @@ -271,7 +271,7 @@ mod tests { let mut data = Vec::new(); data.push(2); data.extend_from_slice(&0u16.to_le_bytes()); - data.extend_from_slice(&(ValueKind::INT32_LIST.bits()).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()); @@ -287,7 +287,7 @@ mod tests { let mut data = Vec::new(); data.push(2); data.extend_from_slice(&6u16.to_le_bytes()); - data.extend_from_slice(&(ValueKind::STRING_LIST.bits()).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()); @@ -341,13 +341,13 @@ mod tests { fn test_insert_cross_bucket_migration() { let mut file = Inibin::new(); file.insert(0xABCD, Value::I32(42)); - assert!(file.section(ValueKind::INT32_LIST).is_some()); + 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(ValueKind::INT32_LIST) + .section(ValueFlags::INT32_LIST) .map(|s| s.get(0xABCD).is_none()) .unwrap_or(true)); } diff --git a/crates/ltk_inibin/src/lib.rs b/crates/ltk_inibin/src/lib.rs index 958eea7e..0397ce03 100644 --- a/crates/ltk_inibin/src/lib.rs +++ b/crates/ltk_inibin/src/lib.rs @@ -17,10 +17,10 @@ mod error; mod file; mod section; mod value; -mod value_kind; +mod value_flags; pub use error::{Error, Result}; pub use file::Inibin; pub use section::Section; pub use value::{FromValue, Value}; -pub use value_kind::ValueKind; +pub use value_flags::ValueFlags; diff --git a/crates/ltk_inibin/src/section.rs b/crates/ltk_inibin/src/section.rs index 48a46907..4d8888a8 100644 --- a/crates/ltk_inibin/src/section.rs +++ b/crates/ltk_inibin/src/section.rs @@ -6,17 +6,17 @@ use indexmap::IndexMap; use crate::error::Result; use crate::value::Value; -use crate::value_kind::ValueKind; +use crate::value_flags::ValueFlags; /// A typed bucket of key-value pairs within an inibin file. #[derive(Debug, Clone, PartialEq)] pub struct Section { - kind: ValueKind, + kind: ValueFlags, properties: IndexMap, } impl Section { - pub(crate) fn new(kind: ValueKind) -> Self { + pub(crate) fn new(kind: ValueFlags) -> Self { Self { kind, properties: IndexMap::new(), @@ -45,48 +45,56 @@ impl Section { self.properties.is_empty() } - pub fn kind(&self) -> ValueKind { + 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: ValueKind) -> Result { + 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 { - ValueKind::INT32_LIST => { + ValueFlags::INT32_LIST => { for hash in hashes { properties.insert(hash, Value::I32(reader.read_i32::()?)); } } - ValueKind::F32_LIST => { + ValueFlags::F32_LIST => { for hash in hashes { properties.insert(hash, Value::F32(reader.read_f32::()?)); } } - ValueKind::U8_LIST => { + ValueFlags::U8_LIST => { for hash in hashes { properties.insert(hash, Value::U8(reader.read_u8()?)); } } - ValueKind::INT16_LIST => { + ValueFlags::INT16_LIST => { for hash in hashes { properties.insert(hash, Value::I16(reader.read_i16::()?)); } } - ValueKind::INT8_LIST => { + ValueFlags::INT8_LIST => { for hash in hashes { properties.insert(hash, Value::I8(reader.read_u8()?)); } } - ValueKind::BIT_LIST => { + 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() { @@ -97,7 +105,7 @@ impl Section { properties.insert(hash, Value::Bool(bit)); } } - ValueKind::VEC3_U8_LIST => { + ValueFlags::VEC3_U8_LIST => { for hash in hashes { let x = reader.read_u8()?; let y = reader.read_u8()?; @@ -105,7 +113,7 @@ impl Section { properties.insert(hash, Value::Vec3U8([x, y, z])); } } - ValueKind::VEC3_F32_LIST => { + ValueFlags::VEC3_F32_LIST => { for hash in hashes { let x = reader.read_f32::()?; let y = reader.read_f32::()?; @@ -113,21 +121,21 @@ impl Section { properties.insert(hash, Value::Vec3F32(Vec3::new(x, y, z))); } } - ValueKind::VEC2_U8_LIST => { + 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])); } } - ValueKind::VEC2_F32_LIST => { + 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))); } } - ValueKind::VEC4_U8_LIST => { + ValueFlags::VEC4_U8_LIST => { for hash in hashes { let x = reader.read_u8()?; let y = reader.read_u8()?; @@ -136,7 +144,7 @@ impl Section { properties.insert(hash, Value::Vec4U8([x, y, z, w])); } } - ValueKind::VEC4_F32_LIST => { + ValueFlags::VEC4_F32_LIST => { for hash in hashes { let x = reader.read_f32::()?; let y = reader.read_f32::()?; @@ -145,7 +153,7 @@ impl Section { properties.insert(hash, Value::Vec4F32(Vec4::new(x, y, z, w))); } } - ValueKind::INT64_LIST => { + ValueFlags::INT64_LIST => { for hash in hashes { properties.insert(hash, Value::I64(reader.read_i64::()?)); } @@ -179,7 +187,7 @@ impl Section { } Ok(Self { - kind: ValueKind::STRING_LIST, + kind: ValueFlags::STRING_LIST, properties, }) } @@ -207,7 +215,7 @@ impl Section { } Ok(Self { - kind: ValueKind::STRING_LIST, + kind: ValueFlags::STRING_LIST, properties, }) } @@ -378,7 +386,7 @@ mod tests { data.extend_from_slice(&(-7i32).to_le_bytes()); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::INT32_LIST).unwrap(); + 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))); @@ -393,7 +401,7 @@ mod tests { data.extend_from_slice(&3.125f32.to_le_bytes()); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::F32_LIST).unwrap(); + 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) { @@ -411,7 +419,7 @@ mod tests { data.push(100u8); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::U8_LIST).unwrap(); + let set = Section::read_non_string(&mut cursor, ValueFlags::U8_LIST).unwrap(); assert_eq!(set.get(0xCCCC0001), Some(&Value::U8(100))); } @@ -424,7 +432,7 @@ mod tests { data.extend_from_slice(&(-123i16).to_le_bytes()); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::INT16_LIST).unwrap(); + let set = Section::read_non_string(&mut cursor, ValueFlags::INT16_LIST).unwrap(); assert_eq!(set.get(0xDDDD0001), Some(&Value::I16(-123))); } @@ -437,7 +445,7 @@ mod tests { data.push(200u8); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::INT8_LIST).unwrap(); + let set = Section::read_non_string(&mut cursor, ValueFlags::INT8_LIST).unwrap(); assert_eq!(set.get(0xEEEE0001), Some(&Value::I8(200))); } @@ -452,7 +460,7 @@ mod tests { data.push(0b00000101u8); // bits: 1,0,1 let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::BIT_LIST).unwrap(); + 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))); @@ -469,7 +477,7 @@ mod tests { data.extend_from_slice(&3.0f32.to_le_bytes()); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::VEC3_F32_LIST).unwrap(); + let set = Section::read_non_string(&mut cursor, ValueFlags::VEC3_F32_LIST).unwrap(); assert_eq!( set.get(0x11110001), @@ -486,7 +494,7 @@ mod tests { data.push(100u8); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::VEC2_U8_LIST).unwrap(); + let set = Section::read_non_string(&mut cursor, ValueFlags::VEC2_U8_LIST).unwrap(); assert_eq!(set.get(0x22220001), Some(&Value::Vec2U8([50, 100]))); } @@ -526,7 +534,7 @@ mod tests { data.extend_from_slice(&5.0f32.to_le_bytes()); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::VEC2_F32_LIST).unwrap(); + let set = Section::read_non_string(&mut cursor, ValueFlags::VEC2_F32_LIST).unwrap(); assert_eq!( set.get(0x33330001), @@ -545,7 +553,7 @@ mod tests { data.extend_from_slice(&4.0f32.to_le_bytes()); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::VEC4_F32_LIST).unwrap(); + let set = Section::read_non_string(&mut cursor, ValueFlags::VEC4_F32_LIST).unwrap(); assert_eq!( set.get(0x44440001), @@ -563,7 +571,7 @@ mod tests { data.push(30); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::VEC3_U8_LIST).unwrap(); + let set = Section::read_non_string(&mut cursor, ValueFlags::VEC3_U8_LIST).unwrap(); assert_eq!(set.get(0x55550001), Some(&Value::Vec3U8([10, 20, 30]))); } @@ -578,7 +586,7 @@ mod tests { data.extend_from_slice(&(-42i64).to_le_bytes()); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::INT64_LIST).unwrap(); + 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))); @@ -596,7 +604,7 @@ mod tests { data.push(40); let mut cursor = Cursor::new(data); - let set = Section::read_non_string(&mut cursor, ValueKind::VEC4_U8_LIST).unwrap(); + 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 index abdcacf2..e054b32f 100644 --- a/crates/ltk_inibin/src/value.rs +++ b/crates/ltk_inibin/src/value.rs @@ -1,24 +1,24 @@ use glam::{Vec2, Vec3, Vec4}; -use crate::value_kind::ValueKind; +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::u8_as_f32`] to get the decoded `f32` value (`byte * 0.1`, range 0.0–25.5). + /// 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::vec3_u8_as_f32`] to decode. + /// 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::vec2_u8_as_f32`] to decode. + /// 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::vec4_u8_as_f32`] to decode. + /// Packed float quad stored as raw bytes. Use [`Value::as_vec4`] to decode. Vec4U8([u8; 4]), Vec4F32(Vec4), String(String), @@ -26,45 +26,50 @@ pub enum Value { } impl Value { - /// Returns the [`ValueKind`] variant this value belongs to. - pub fn flags(&self) -> ValueKind { + /// Returns the [`ValueFlags`] variant this value belongs to. + pub fn flags(&self) -> ValueFlags { match self { - Value::I32(_) => ValueKind::INT32_LIST, - Value::F32(_) => ValueKind::F32_LIST, - Value::U8(_) => ValueKind::U8_LIST, - Value::I16(_) => ValueKind::INT16_LIST, - Value::I8(_) => ValueKind::INT8_LIST, - Value::Bool(_) => ValueKind::BIT_LIST, - Value::Vec3U8(_) => ValueKind::VEC3_U8_LIST, - Value::Vec3F32(_) => ValueKind::VEC3_F32_LIST, - Value::Vec2U8(_) => ValueKind::VEC2_U8_LIST, - Value::Vec2F32(_) => ValueKind::VEC2_F32_LIST, - Value::Vec4U8(_) => ValueKind::VEC4_U8_LIST, - Value::Vec4F32(_) => ValueKind::VEC4_F32_LIST, - Value::String(_) => ValueKind::STRING_LIST, - Value::I64(_) => ValueKind::INT64_LIST, + 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, } } - /// Decode a [`U8`](Value::U8) packed float: `byte * 0.1` (range 0.0–25.5). - pub fn u8_as_f32(&self) -> Option { + /// 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, } } - /// Decode a [`Vec2U8`](Value::Vec2U8) packed float pair. - pub fn vec2_u8_as_f32(&self) -> Option { + /// 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, } } - /// Decode a [`Vec3U8`](Value::Vec3U8) packed float triple. - pub fn vec3_u8_as_f32(&self) -> Option { + /// 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)) } @@ -72,9 +77,10 @@ impl Value { } } - /// Decode a [`Vec4U8`](Value::Vec4U8) packed float quad. - pub fn vec4_u8_as_f32(&self) -> Option { + /// 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, diff --git a/crates/ltk_inibin/src/value_kind.rs b/crates/ltk_inibin/src/value_flags.rs similarity index 60% rename from crates/ltk_inibin/src/value_kind.rs rename to crates/ltk_inibin/src/value_flags.rs index 67724947..c4900d8f 100644 --- a/crates/ltk_inibin/src/value_kind.rs +++ b/crates/ltk_inibin/src/value_flags.rs @@ -1,6 +1,6 @@ bitflags::bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - pub struct ValueKind: u16 { + pub struct ValueFlags: u16 { const INT32_LIST = 1 << 0; const F32_LIST = 1 << 1; const U8_LIST = 1 << 2; @@ -19,18 +19,18 @@ bitflags::bitflags! { } /// Non-string kinds in bit order. STRING_LIST is always read/written last. -pub(crate) const NON_STRING_KINDS: [ValueKind; 13] = [ - ValueKind::INT32_LIST, - ValueKind::F32_LIST, - ValueKind::U8_LIST, - ValueKind::INT16_LIST, - ValueKind::INT8_LIST, - ValueKind::BIT_LIST, - ValueKind::VEC3_U8_LIST, - ValueKind::VEC3_F32_LIST, - ValueKind::VEC2_U8_LIST, - ValueKind::VEC2_F32_LIST, - ValueKind::VEC4_U8_LIST, - ValueKind::VEC4_F32_LIST, - ValueKind::INT64_LIST, +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 index 620641b8..b6adaaf1 100644 --- a/crates/ltk_inibin/tests/round_trip.rs +++ b/crates/ltk_inibin/tests/round_trip.rs @@ -1,7 +1,7 @@ use std::io::Cursor; use glam::{Vec2, Vec3, Vec4}; -use ltk_inibin::{Inibin, Value, ValueKind}; +use ltk_inibin::{Inibin, Value, ValueFlags}; // U8 packed floats: value 100 decodes to 10.0 (byte * 0.1) @@ -151,18 +151,74 @@ fn round_trip_bit_list_partial_byte() { } #[test] -fn u8_as_f32_accessor() { +fn as_f32_accessor() { + // Packed U8 variants let val = Value::U8(100); - approx::assert_relative_eq!(val.u8_as_f32().unwrap(), 10.0); + approx::assert_relative_eq!(val.as_f32().unwrap(), 10.0); let val = Value::U8(255); - approx::assert_relative_eq!(val.u8_as_f32().unwrap(), 25.5); + approx::assert_relative_eq!(val.as_f32().unwrap(), 25.5); let val = Value::U8(0); - approx::assert_relative_eq!(val.u8_as_f32().unwrap(), 0.0); + approx::assert_relative_eq!(val.as_f32().unwrap(), 0.0); - // Non-U8 variant returns None - assert_eq!(Value::I32(42).u8_as_f32(), None); + // 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] @@ -172,11 +228,11 @@ fn test_set_access() { file.insert(0x0002, Value::I32(2)); file.insert(0x0003, Value::F32(3.0)); - let int_set = file.section(ValueKind::INT32_LIST).unwrap(); + let int_set = file.section(ValueFlags::INT32_LIST).unwrap(); assert_eq!(int_set.len(), 2); - assert_eq!(int_set.kind(), ValueKind::INT32_LIST); + assert_eq!(int_set.kind(), ValueFlags::INT32_LIST); - let float_set = file.section(ValueKind::F32_LIST).unwrap(); + let float_set = file.section(ValueFlags::F32_LIST).unwrap(); assert_eq!(float_set.len(), 1); } @@ -212,7 +268,7 @@ fn test_int64_cross_bucket_migration() { // Verify it's not in Int32 bucket anymore assert!(file - .section(ValueKind::INT32_LIST) + .section(ValueFlags::INT32_LIST) .map(|s| s.get(0xABCD).is_none()) .unwrap_or(true)); diff --git a/specs/001-inibin-crate/contracts/public-api.md b/specs/001-inibin-crate/contracts/public-api.md index 9c248fac..e23b6e45 100644 --- a/specs/001-inibin-crate/contracts/public-api.md +++ b/specs/001-inibin-crate/contracts/public-api.md @@ -1,6 +1,6 @@ # Public API Contract: ltk_inibin + ltk_inibin_names -**Phase**: 1 | **Date**: 2026-03-25 | **Updated**: 2026-03-25 +**Phase**: 1 | **Date**: 2026-03-25 | **Updated**: 2026-03-26 ## ltk_hash additions @@ -8,22 +8,22 @@ ```rust /// Compute SDBM hash of a lowercased string. -pub fn hash_lower(input: &str) -> u32; +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: &str, b: &str, delimiter: char) -> u32; +pub fn hash_lower_with_delimiter(a: impl AsRef, b: impl AsRef, delimiter: char) -> u32; ``` ## ltk_inibin public API -### InibinFile +### Inibin (formerly InibinFile) ```rust /// Top-level inibin/troybin file container. -pub struct InibinFile { /* bucket-based internal storage */ } +pub struct Inibin { /* bucket-based internal storage */ } -impl InibinFile { +impl Inibin { /// Create an empty inibin file. pub fn new() -> Self; @@ -36,66 +36,84 @@ impl InibinFile { /// Get a value by hash key, searching all buckets. /// Returns None if key not found. - pub fn get(&self, key: u32) -> Option<&InibinValue>; + 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: InibinValue); + 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; + 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; + pub fn iter(&self) -> impl Iterator; - /// Get a reference to a specific set by flag type. - pub fn set(&self, flags: InibinFlags) -> Option<&InibinSet>; + /// 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 set by flag type. - pub fn set_mut(&mut self, flags: InibinFlags) -> Option<&mut InibinSet>; + /// Get a mutable reference to a specific section by flag type. + pub fn section_mut(&mut self, flags: ValueFlags) -> Option<&mut Section>; } ``` -### InibinSet +### Section (formerly InibinSet) ```rust /// A typed bucket of key-value pairs. -pub struct InibinSet { /* properties map */ } +pub struct Section { /* properties: IndexMap */ } -impl InibinSet { - /// Get value by hash key within this set. - pub fn get(&self, key: u32) -> Option<&InibinValue>; +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 set. - pub fn insert(&mut self, key: u32, value: InibinValue); + /// Insert a key-value pair into this section. + pub fn insert(&mut self, key: u32, value: Value); - /// Remove a key-value pair from this set. - pub fn remove(&mut self, key: u32) -> Option; + /// Remove a key-value pair from this section. + pub fn remove(&mut self, key: u32) -> Option; - /// Number of entries in this set. + /// Number of entries in this section. pub fn len(&self) -> usize; - /// Whether this set is empty. + /// Whether this section is empty. pub fn is_empty(&self) -> bool; - /// The flag type of this set. - pub fn set_type(&self) -> InibinFlags; + /// The flag type of this section. + pub fn kind(&self) -> ValueFlags; - /// Iterate over key-value pairs in this set. - pub fn iter(&self) -> impl Iterator; + /// 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; } ``` -### InibinFlags +### ValueFlags (formerly InibinFlags) ```rust bitflags! { /// Bitfield representing inibin value set types. - pub struct InibinFlags: u16 { + pub struct ValueFlags: u16 { const INT32_LIST = 1 << 0; const F32_LIST = 1 << 1; const U8_LIST = 1 << 2; @@ -114,30 +132,42 @@ bitflags! { } ``` -### InibinValue +### Value (formerly InibinValue) ```rust -/// Typed value stored in an inibin set. -pub enum InibinValue { - Int32(i32), +/// Typed value stored in an inibin section. +pub enum Value { + I32(i32), F32(f32), - U8(f32), - Int16(i16), - Int8(u8), + U8(u8), // Raw byte; use as_f32() for packed float conversion + I16(i16), + I8(u8), Bool(bool), - Vec3U8(Vec3), + Vec3U8([u8; 3]), // Raw bytes; use as_vec3() for packed float conversion Vec3F32(Vec3), - Vec2U8(Vec2), + Vec2U8([u8; 2]), // Raw bytes; use as_vec2() for packed float conversion Vec2F32(Vec2), - Vec4U8(Vec4), + Vec4U8([u8; 4]), // Raw bytes; use as_vec4() for packed float conversion Vec4F32(Vec4), String(String), - Int64(i64), + I64(i64), } -impl InibinValue { - /// Returns the InibinFlags variant this value belongs to. - pub fn flags(&self) -> InibinFlags; +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; } ``` diff --git a/specs/001-inibin-crate/data-model.md b/specs/001-inibin-crate/data-model.md index ad2d96d7..20bb4253 100644 --- a/specs/001-inibin-crate/data-model.md +++ b/specs/001-inibin-crate/data-model.md @@ -1,6 +1,6 @@ # Data Model: ltk_inibin -**Phase**: 1 | **Date**: 2026-03-25 | **Updated**: 2026-03-25 +**Phase**: 1 | **Date**: 2026-03-25 | **Updated**: 2026-03-26 ## Entities @@ -9,9 +9,9 @@ The top-level container for an inibin/troybin file. **Fields**: -- `sets`: Map from `InibinFlags` (single flag value) to `InibinSet` — the bucket-based internal storage +- `sections`: Map from `ValueFlags` (single flag value) to `Section` — the bucket-based internal storage -**Relationships**: Contains zero or more `InibinSet` instances, keyed by flag type. At most 14 sets (one per flag bit). +**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 @@ -20,24 +20,26 @@ The top-level container for an inibin/troybin file. **Identity**: An InibinFile is a value type — no unique identity beyond its contents. -### InibinSet +### Section (formerly InibinSet) A typed collection of key-value pairs within a single bucket. **Fields**: -- `set_type`: `InibinFlags` — identifies which value type this set holds -- `properties`: Map from `u32` (hash key) to the typed value +- `kind`: `ValueFlags` — identifies which value type this section holds +- `properties`: `IndexMap` — preserves insertion order -**Relationships**: Owned by `InibinFile`. Each set holds values of exactly one type. +**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 set -- Values must match the set's type constraint -- U8 (fixed-point float) values must be in range 0.0-25.5 +- 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) -### InibinFlags +### ValueFlags (formerly InibinFlags) -Bitfield enum (u16) representing value set types. +Bitfield (u16) representing value set types. **Values** (14 bits): - Bit 0: `INT32_LIST` @@ -57,27 +59,29 @@ Bitfield enum (u16) representing value set types. **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. -### InibinValue +### Value (formerly InibinValue) Typed value enum representing all possible value types. **Variants**: -- `Int32(i32)` +- `I32(i32)` - `F32(f32)` -- `U8(f32)` — stored as f32, validated to 0.0-25.5 range on write -- `Int16(i16)` -- `Int8(u8)` +- `U8(u8)` — raw byte storage; `as_f32()` returns `byte * 0.1` +- `I16(i16)` +- `I8(u8)` - `Bool(bool)` -- `Vec3U8(Vec3)` +- `Vec3U8([u8; 3])` — raw bytes; `as_vec3()` returns packed floats - `Vec3F32(Vec3)` -- `Vec2U8(Vec2)` +- `Vec2U8([u8; 2])` — raw bytes; `as_vec2()` returns packed floats - `Vec2F32(Vec2)` -- `Vec4U8(Vec4)` +- `Vec4U8([u8; 4])` — raw bytes; `as_vec4()` returns packed floats - `Vec4F32(Vec4)` - `String(String)` -- `Int64(i64)` +- `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 `InibinFlags` value. The public API uses this enum for type-safe value access. The library determines which bucket to route to based on the variant. +**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`) diff --git a/specs/001-inibin-crate/plan.md b/specs/001-inibin-crate/plan.md index 76bb3f75..ed6b43bd 100644 --- a/specs/001-inibin-crate/plan.md +++ b/specs/001-inibin-crate/plan.md @@ -1,23 +1,23 @@ -# Implementation Plan: Inibin File Parser (ltk_inibin + ltk_inibin_names) +# Implementation Plan: ltk_inibin -**Branch**: `001-inibin-crate` | **Date**: 2026-03-25 | **Spec**: [spec.md](spec.md) +**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 `ltk_inibin` — a Rust crate for reading, writing, and modifying League of Legends inibin/troybin binary files. Supports 14 value set types (including Int64 at flag 13), version 1 (read-only) and version 2 (read+write) formats, with key-based public API and bucket-based internal storage. Additionally, implement `ltk_inibin_names` — a companion crate providing compile-time hash→name resolution from the lolpytools fixlist. +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` (InibinFlags), `phf`/`phf_codegen` (compile-time hash map for ltk_inibin_names) +**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`, `approx` for floating-point comparisons -**Target Platform**: All platforms supported by the workspace (no platform-specific code) -**Project Type**: Library (two crates) -**Performance Goals**: Zero-cost name lookups via `phf`; standard binary I/O performance -**Constraints**: Must follow workspace conventions (from_reader/to_writer, thiserror, glam, workspace deps) -**Scale/Scope**: ~14 value types, ~thousands of fixlist entries +**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 @@ -25,14 +25,15 @@ Implement `ltk_inibin` — a Rust crate for reading, writing, and modifying Leag | Principle | Status | Notes | |-----------|--------|-------| -| I. Crate-First Architecture | PASS | Two separate crates: `ltk_inibin` (parser) and `ltk_inibin_names` (name resolution). Both under `crates/`. No circular deps. Umbrella re-exports via feature flags. | -| II. Round-Trip Correctness | PASS | All 14 types support read+write. Round-trip tests required. `approx` for floats. | -| III. Strict CI Quality Gate | PASS | fmt + clippy + test required before merge. | -| IV. Idiomatic Rust I/O | PASS | `from_reader(Read+Seek)` / `to_writer(Write)`. `glam` vectors. | -| V. Workspace Dependency Hygiene | PASS | All existing deps at workspace level. `phf`/`phf_codegen` added at workspace level (justified: thousands of static entries, zero-cost lookups). | -| Error Handling & Safety | PASS | Own error type via thiserror. No unwrap in lib code. | +| 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 | -**Post-design re-check**: All gates still PASS. +No violations. Gate passes. ## Project Structure @@ -43,48 +44,35 @@ specs/001-inibin-crate/ ├── plan.md # This file ├── research.md # Phase 0 output ├── data-model.md # Phase 1 output -├── quickstart.md # Phase 1 output -├── contracts/ # Phase 1 output -│ └── public-api.md +├── contracts/ # Phase 1 output (public API contracts) └── tasks.md # Phase 2 output (/speckit.tasks) ``` ### Source Code (repository root) ```text -crates/ltk_hash/ -├── src/ -│ ├── lib.rs # Module declarations (add sdbm) -│ └── sdbm.rs # SDBM hash implementation -└── Cargo.toml - -crates/ltk_inibin/ -├── src/ -│ ├── lib.rs # Re-exports + module declarations -│ ├── error.rs # InibinError + Result -│ ├── file.rs # InibinFile (from_reader, to_writer, CRUD API) -│ ├── flags.rs # InibinFlags bitfield -│ ├── set.rs # InibinSet (per-bucket read/write logic) -│ └── value.rs # InibinValue enum -├── tests/ -│ └── round_trip.rs # Integration round-trip tests -└── Cargo.toml - -crates/ltk_inibin_names/ -├── src/ -│ └── lib.rs # lookup() function + include generated phf map -├── build.rs # phf_codegen: generate hash→name map at compile time -├── data/ -│ └── fixlist.rs # Raw fixlist data (section, name, hash) tuples -└── Cargo.toml - -crates/league-toolkit/ -├── Cargo.toml # Add inibin + inibin-names feature flags -└── src/lib.rs # Re-export ltk_inibin and ltk_inibin_names +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**: Two new crates under `crates/` following the existing workspace pattern. `ltk_inibin` is the core parser/writer with no dependency on names. `ltk_inibin_names` is a standalone lookup crate using `phf` for compile-time hash maps. Both are re-exported through the umbrella crate behind feature flags. +**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 — table not needed. +No constitution violations to justify. diff --git a/specs/001-inibin-crate/quickstart.md b/specs/001-inibin-crate/quickstart.md index 254a61ce..e0fb544d 100644 --- a/specs/001-inibin-crate/quickstart.md +++ b/specs/001-inibin-crate/quickstart.md @@ -1,17 +1,17 @@ # Quickstart: ltk_inibin -**Phase**: 1 | **Date**: 2026-03-25 | **Updated**: 2026-03-25 +**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::InibinFile; +use ltk_inibin::Inibin; let file = File::open("data/characters/annie/annie.inibin")?; let mut reader = BufReader::new(file); -let inibin = InibinFile::from_reader(&mut reader)?; +let inibin = Inibin::from_reader(&mut reader)?; // Look up a value by hash key if let Some(value) = inibin.get(0xABCD1234) { @@ -32,17 +32,17 @@ let value = inibin.get(key); ## Modifying values ```rust -use ltk_inibin::{InibinFile, InibinValue}; +use ltk_inibin::{Inibin, Value}; use ltk_hash::sdbm; -let mut inibin = InibinFile::from_reader(&mut reader)?; +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, InibinValue::F32(550.0)); +inibin.insert(key, Value::F32(550.0)); // Insert an Int64 value -inibin.insert(0x1234, InibinValue::Int64(9999999999)); +inibin.insert(0x1234, Value::I64(9999999999)); // Remove a value inibin.remove(key); @@ -64,13 +64,13 @@ inibin.to_writer(&mut writer)?; ```rust use std::io::Cursor; -let inibin = InibinFile::from_reader(&mut reader)?; +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 = InibinFile::from_reader(&mut cursor)?; +let inibin2 = Inibin::from_reader(&mut cursor)?; // inibin and inibin2 contain identical data ``` @@ -85,12 +85,16 @@ for (key, value) in inibin.iter() { ## Accessing a specific set bucket ```rust -use ltk_inibin::InibinFlags; +use ltk_inibin::ValueFlags; -if let Some(float_set) = inibin.set(InibinFlags::F32_LIST) { - println!("Float set has {} entries", float_set.len()); - for (key, value) in float_set.iter() { - println!(" 0x{:08X} = {:?}", key, value); +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); } } ``` diff --git a/specs/001-inibin-crate/research.md b/specs/001-inibin-crate/research.md index b0b1e435..17256eac 100644 --- a/specs/001-inibin-crate/research.md +++ b/specs/001-inibin-crate/research.md @@ -1,6 +1,6 @@ # Research: ltk_inibin -**Phase**: 0 | **Date**: 2026-03-25 | **Updated**: 2026-03-25 +**Phase**: 0 | **Date**: 2026-03-25 | **Updated**: 2026-03-26 ## R-001: Inibin Binary Format @@ -82,7 +82,33 @@ The reference C# uses `Sdbm.HashLowerWithDelimiter(section, property, '*')` whic | 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 — rejected because users expect float access; conversion happens at parse/write boundary. +**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 diff --git a/specs/001-inibin-crate/spec.md b/specs/001-inibin-crate/spec.md index 5499d6ac..b28f0579 100644 --- a/specs/001-inibin-crate/spec.md +++ b/specs/001-inibin-crate/spec.md @@ -23,6 +23,17 @@ - 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. + ## User Scenarios & Testing *(mandatory)* ### User Story 1 - Read Inibin Files (Priority: P1) @@ -111,15 +122,19 @@ As a developer, I want to insert, remove, and update values in an inibin structu - **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**: `ltk_inibin_names` crate~~ — **Descoped** to a separate PR. -- ~~**FR-015**: `ltk_inibin_names` lookup function~~ — **Descoped** to a separate PR. +- **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). -- **InibinFlags**: A bitfield enum 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). +- **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)* @@ -132,7 +147,8 @@ As a developer, I want to insert, remove, and update values in an inibin structu - **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**: `ltk_inibin_names` hash-to-name resolution~~ — **Descoped** to a separate PR. +- **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 diff --git a/specs/001-inibin-crate/tasks.md b/specs/001-inibin-crate/tasks.md index 1aacfb1a..ea6bf892 100644 --- a/specs/001-inibin-crate/tasks.md +++ b/specs/001-inibin-crate/tasks.md @@ -1,95 +1,90 @@ -# Tasks: Inibin Int64 Support + Name Resolution (ltk_inibin_names) +# 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` already exists with 13 value set types. These tasks add Int64 (flag 13) and a new `ltk_inibin_names` crate. +**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, US3) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2) - Include exact file paths in descriptions --- ## Phase 1: Setup -**Purpose**: Add workspace dependencies and create ltk_inibin_names crate skeleton +**Purpose**: No setup needed — all crates and dependencies already exist. -- [X] T001 Add `phf` and `phf_codegen` to workspace dependencies in `Cargo.toml` (root) -- [X] T002 Create `crates/ltk_inibin_names/Cargo.toml` with `phf` dependency and `phf_codegen` build-dependency -- [X] T003 Create empty `crates/ltk_inibin_names/src/lib.rs` with module-level doc comment -- [X] T004 Create empty `crates/ltk_inibin_names/build.rs` placeholder +(No tasks — crate structure is already in place) --- -## Phase 2: Foundational (Int64 Type Support) +## Phase 2: Foundational (Renames & Signature Changes) -**Purpose**: Add Int64 flag, value variant, and read/write logic to existing ltk_inibin crate +**Purpose**: Rename types and update function signatures that all other changes depend on -**⚠️ CRITICAL**: Must complete before user story validation +**⚠️ CRITICAL**: Must complete before user story work begins -- [X] T005 Add `INT64_LIST = 1 << 13` flag to `InibinFlags` and update `NON_STRING_FLAGS` array in `crates/ltk_inibin/src/flags.rs` -- [X] T006 [P] Add `Int64(i64)` variant to `InibinValue` enum and update `flags()` method in `crates/ltk_inibin/src/value.rs` -- [X] T007 Add Int64List read logic (`read_i64::`) to `InibinSet::read_non_string()` in `crates/ltk_inibin/src/set.rs` -- [X] T008 Add Int64List write logic (`write_i64::`) to `InibinSet::write_non_string()` in `crates/ltk_inibin/src/set.rs` -- [X] T009 Update `InibinFile::read_v2()` to handle flag bit 13 (Int64List) in the read loop in `crates/ltk_inibin/src/file.rs` -- [X] T010 Update `InibinFile::to_writer()` to include Int64List sets in the write loop in `crates/ltk_inibin/src/file.rs` +- [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**: Int64 values can be read and written. Existing tests still pass. +**Checkpoint**: `ValueKind` → `ValueFlags` rename complete everywhere. SDBM functions accept `AsRef`. `cargo check` passes. --- -## Phase 3: User Story 1+2 — Read & Access Int64 Values (Priority: P1) +## Phase 3: User Story 1+2 — Unified Value Accessors (Priority: P1) 🎯 MVP -**Goal**: Parse inibin files containing Int64 sets and access values by key +**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**: Construct an InibinFile with Int64 values, write to bytes, read back, verify values match +**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. -- [X] T011 [P] [US1] Add Int64 unit test to `InibinSet` read tests (test_read_int64_list) in `crates/ltk_inibin/src/set.rs` -- [X] T012 [P] [US2] Add Int64 entries to the `round_trip_all_set_types` test in `crates/ltk_inibin/tests/round_trip.rs` +### Implementation -**Checkpoint**: Int64 read + key access verified by tests +- [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 ---- - -## Phase 4: User Story 3+4 — Write & Modify Int64 Values (Priority: P2) - -**Goal**: Round-trip Int64 values and support insert/remove operations - -**Independent Test**: Insert Int64 values, write, read back, verify round-trip integrity - -- [X] T013 [P] [US3] Add dedicated Int64 round-trip test (round_trip_int64) in `crates/ltk_inibin/tests/round_trip.rs` -- [X] T014 [P] [US4] Add Int64 insert/remove test to verify cross-bucket migration in `crates/ltk_inibin/tests/round_trip.rs` - -**Checkpoint**: Int64 fully integrated — read, write, modify, round-trip all verified +**Checkpoint**: Unified `as_*()` accessors work for both packed and non-packed variants. No references to old method names remain. --- -## Phase 5: User Story 5 — Hash Key Name Resolution (Priority: P3) +## 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. -**Goal**: Provide compile-time hash→(section, name) lookups via `ltk_inibin_names` +**Independent Test**: Parse an inibin, get a section, call `.keys()` and `.values()` — verify they return the expected hash keys and values respectively. -**Independent Test**: Query known hashes from the fixlist and verify correct (section, name) pairs returned; query unknown hash and verify None +### Implementation -- [X] T015 [US5] Extract fixlist data from lolpytools `inibin_fix.py` into `crates/ltk_inibin_names/data/fixlist.rs` as a Rust array of `(u32, &str, &str)` tuples -- [X] T016 [US5] Implement `build.rs` in `crates/ltk_inibin_names/build.rs` using `phf_codegen` to generate a `phf::Map` from fixlist data -- [X] T017 [US5] Implement `lookup(hash: u32) -> Option<(&'static str, &'static str)>` in `crates/ltk_inibin_names/src/lib.rs` using the generated phf map -- [X] T018 [US5] Add tests for `lookup()` — verify known hashes return correct pairs and unknown hashes return None in `crates/ltk_inibin_names/src/lib.rs` +- [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**: Name resolution works for all fixlist entries with zero runtime overhead +**Checkpoint**: `Section` exposes `.keys()`, `.values()`, `.iter()`. No duplicate helpers remain. --- -## Phase 6: Polish & Cross-Cutting Concerns +## Phase 5: Polish & Cross-Cutting Concerns -**Purpose**: Umbrella integration, CI validation, final cleanup +**Purpose**: CI validation, verify round-trip tests still pass, final cleanup -- [X] T019 [P] Add `inibin-names` feature flag and `ltk_inibin_names` dependency to `crates/league-toolkit/Cargo.toml` -- [X] T020 [P] Add `#[cfg(feature = "inibin-names")] pub use ltk_inibin_names as inibin_names;` re-export in `crates/league-toolkit/src/lib.rs` -- [X] T021 Run `cargo fmt -- --check` across workspace -- [X] T022 Run `cargo clippy --all-targets -- -D warnings` across workspace -- [X] T023 Run `cargo test --verbose` across workspace — all tests must pass +- [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 --- @@ -97,64 +92,69 @@ ### Phase Dependencies -- **Setup (Phase 1)**: No dependencies — start immediately -- **Foundational (Phase 2)**: Depends on Phase 1 (T001 for workspace deps) -- **US1+2 (Phase 3)**: Depends on Phase 2 (Int64 type support in place) -- **US3+4 (Phase 4)**: Depends on Phase 2 (can run parallel with Phase 3) -- **US5 (Phase 5)**: Depends on Phase 1 only (T002-T004 for crate skeleton) — independent of Phases 2-4 -- **Polish (Phase 6)**: Depends on all prior phases - -### User Story Dependencies - -- **US1+2 (P1)**: Depends on Foundational — Int64 flag/value/read must exist -- **US3+4 (P2)**: Depends on Foundational — Int64 write must exist. Can run parallel with US1+2 -- **US5 (P3)**: Fully independent of US1-4. Only needs crate skeleton from Phase 1 +- **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 -- T002, T003, T004 can run in parallel (different files in new crate) -- T005, T006 can run in parallel (different files: flags.rs, value.rs) -- T007, T008 touch same file (set.rs) — must be sequential -- T011, T012 can run in parallel (different test files) -- T013, T014 can run in parallel (same file but independent tests) -- T015 is independent — can start as soon as Phase 1 crate skeleton exists -- T019, T020 can run in parallel (different files) -- Phase 5 (US5) can run entirely in parallel with Phases 3+4 +- 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 5 (US5) +## Parallel Example: Phase 2 (Foundational) ```text -# These can run in parallel with Int64 work (Phases 3+4): -Task T015: "Extract fixlist data into crates/ltk_inibin_names/data/fixlist.rs" -Task T016: "Implement build.rs with phf_codegen" (depends on T015) -Task T017: "Implement lookup() in lib.rs" (depends on T016) -Task T018: "Add lookup tests" (depends on T017) +# 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 (Int64 Support Only) +### MVP First (Rename + Accessors) -1. Complete Phase 1: Setup (workspace deps) -2. Complete Phase 2: Foundational (Int64 type support) -3. Complete Phase 3: US1+2 (Int64 read + access tests) -4. Complete Phase 4: US3+4 (Int64 write + modify tests) -5. **STOP and VALIDATE**: All existing + new tests pass +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 5: US5 (ltk_inibin_names) -2. Phase 6: Polish (umbrella integration, CI gate) +1. MVP (above) + Phase 4: US3+4 (.keys()/.values() + helper dedup) +2. Phase 5: Polish (CI gate, README update) --- ## Notes -- Existing ltk_inibin code (13 types) is already complete and tested -- Int64 follows the exact same pattern as Int32 but with 8-byte values -- The fixlist extraction from Python to Rust is the largest single task (T015) — thousands of entries -- phf_codegen runs at compile time in build.rs, so the generated map is baked into the binary +- 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 From a83b97f4fd2db9d557518ac5a625fa21c500230d Mon Sep 17 00:00:00 2001 From: Crauzer <0xcrauzer@proton.me> Date: Thu, 26 Mar 2026 15:16:57 +0100 Subject: [PATCH 3/3] feat(ltk_inibin): reuse read_str_until_nul from ltk_io_ext and update CLAUDE.md --- CLAUDE.md | 4 +++- crates/ltk_inibin/examples/create_inibin.rs | 5 +---- crates/ltk_inibin/examples/inspect_inibin.rs | 6 +----- crates/ltk_inibin/examples/modify_inibin.rs | 5 +---- crates/ltk_inibin/examples/read_inibin.rs | 5 ++++- crates/ltk_inibin/src/section.rs | 17 +++-------------- specs/001-inibin-crate/spec.md | 1 + 7 files changed, 14 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2b3f9ec8..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 diff --git a/crates/ltk_inibin/examples/create_inibin.rs b/crates/ltk_inibin/examples/create_inibin.rs index 22bffad0..a4edcf56 100644 --- a/crates/ltk_inibin/examples/create_inibin.rs +++ b/crates/ltk_inibin/examples/create_inibin.rs @@ -73,10 +73,7 @@ fn main() { 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) - ); + println!(" missing with default: {}", inibin.get_or(0x9999, 0i32)); // ── Unified as_*() accessors for packed/non-packed floats ─── println!(); diff --git a/crates/ltk_inibin/examples/inspect_inibin.rs b/crates/ltk_inibin/examples/inspect_inibin.rs index 559c9405..2877d46b 100644 --- a/crates/ltk_inibin/examples/inspect_inibin.rs +++ b/crates/ltk_inibin/examples/inspect_inibin.rs @@ -122,11 +122,7 @@ fn main() { 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; + let remaining = inibin.iter().filter(|(_, v)| v.as_f32().is_some()).count() - 20; if remaining > 0 { println!(" ... and {remaining} more"); } diff --git a/crates/ltk_inibin/examples/modify_inibin.rs b/crates/ltk_inibin/examples/modify_inibin.rs index 286c7ca0..9924d74f 100644 --- a/crates/ltk_inibin/examples/modify_inibin.rs +++ b/crates/ltk_inibin/examples/modify_inibin.rs @@ -81,10 +81,7 @@ fn main() { // ── Section-level inspection ──────────────────────────────── if let Some(float_section) = inibin.section(ValueFlags::F32_LIST) { - println!( - "\nFloat32 section: {} entries", - float_section.len() - ); + println!("\nFloat32 section: {} entries", float_section.len()); for (key, value) in float_section.iter().take(5) { println!(" 0x{key:08X} = {value:?}"); } diff --git a/crates/ltk_inibin/examples/read_inibin.rs b/crates/ltk_inibin/examples/read_inibin.rs index ecf9f843..9dd27d0a 100644 --- a/crates/ltk_inibin/examples/read_inibin.rs +++ b/crates/ltk_inibin/examples/read_inibin.rs @@ -84,7 +84,10 @@ fn print_value(value: &Value) { 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); + 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) => { diff --git a/crates/ltk_inibin/src/section.rs b/crates/ltk_inibin/src/section.rs index 4d8888a8..0a901881 100644 --- a/crates/ltk_inibin/src/section.rs +++ b/crates/ltk_inibin/src/section.rs @@ -3,6 +3,7 @@ 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; @@ -181,7 +182,7 @@ impl Section { 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 = read_null_terminated_string(reader)?; + let s = reader.read_str_until_nul()?; reader.seek(SeekFrom::Start(saved_pos))?; properties.insert(hash, Value::String(s)); } @@ -209,7 +210,7 @@ impl Section { 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 = read_null_terminated_string(reader)?; + let s = reader.read_str_until_nul()?; reader.seek(SeekFrom::Start(saved_pos))?; properties.insert(hash, Value::String(s)); } @@ -358,18 +359,6 @@ fn read_hashes(reader: &mut R, count: usize) -> Result> { Ok(hashes) } -fn read_null_terminated_string(reader: &mut R) -> Result { - let mut bytes = Vec::new(); - loop { - let byte = reader.read_u8()?; - if byte == 0 { - break; - } - bytes.push(byte); - } - Ok(String::from_utf8_lossy(&bytes).into_owned()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/specs/001-inibin-crate/spec.md b/specs/001-inibin-crate/spec.md index b28f0579..b0f3d0ed 100644 --- a/specs/001-inibin-crate/spec.md +++ b/specs/001-inibin-crate/spec.md @@ -33,6 +33,7 @@ ### 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)*