diff --git a/crates/ltk_inibin/Cargo.toml b/crates/ltk_inibin/Cargo.toml new file mode 100644 index 00000000..a5c7c722 --- /dev/null +++ b/crates/ltk_inibin/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ltk_inibin" +version = "0.1.0" +edition = "2021" +description = "Inibin (INIBIN v1/v2) parser and writer for League Toolkit" +license = "MIT OR Apache-2.0" +readme = "../../README.md" + +[features] +serde = ["dep:serde", "indexmap/serde"] + +[dependencies] +thiserror = { workspace = true } +byteorder = { workspace = true } +num_enum = { workspace = true } +miette = { workspace = true } +indexmap = { workspace = true } +serde = { workspace = true, optional = true } diff --git a/crates/ltk_inibin/src/error.rs b/crates/ltk_inibin/src/error.rs new file mode 100644 index 00000000..c91b344c --- /dev/null +++ b/crates/ltk_inibin/src/error.rs @@ -0,0 +1,19 @@ +use crate::types::InibinFlags; + +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum InibinError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Empty inibin data")] + Empty, + + #[error("Unknown inibin version: {0}")] + UnknownVersion(u8), + + #[error("Cannot write v1 (old format) entries to binary — convert to v2 storage types first")] + V1WriteNotSupported, + + #[error("Invalid storage type: {0}")] + InvalidStorageType(#[from] num_enum::TryFromPrimitiveError), +} diff --git a/crates/ltk_inibin/src/lib.rs b/crates/ltk_inibin/src/lib.rs new file mode 100644 index 00000000..4214dd6d --- /dev/null +++ b/crates/ltk_inibin/src/lib.rs @@ -0,0 +1,39 @@ +//! `ltk_inibin` — Inibin (INIBIN v1/v2) parser and writer for League Toolkit. +//! +//! Handles `.inibin`, `.troybin`, and `.cfgbin` files — all share the same +//! binary format. Values are stored in typed buckets ([`InibinSet`]) grouped +//! by storage type ([`InibinFlags`]). +//! +//! # Quick start +//! +//! ```no_run +//! let data = std::fs::read("particle.troybin").unwrap(); +//! let file = ltk_inibin::from_slice(&data).unwrap(); +//! +//! // Look up a value by hash +//! if let Some(val) = file.get(0xDEADBEEF) { +//! println!("{val:?}"); +//! } +//! +//! // Write back to binary (v2) +//! let mut output = Vec::new(); +//! ltk_inibin::write(&mut output, &file).unwrap(); +//! ``` + +mod error; +mod reader; +mod types; +mod writer; + +pub use error::InibinError; +pub use types::{InibinFile, InibinFlags, InibinSet, InibinValue}; + +/// Read an inibin binary from a byte slice. +pub fn from_slice(data: &[u8]) -> Result { + reader::from_slice(data) +} + +/// Write an [`InibinFile`] to binary v2 format. +pub fn write(w: &mut W, file: &InibinFile) -> Result<(), InibinError> { + writer::write(w, file) +} diff --git a/crates/ltk_inibin/src/reader.rs b/crates/ltk_inibin/src/reader.rs new file mode 100644 index 00000000..5fdb2586 --- /dev/null +++ b/crates/ltk_inibin/src/reader.rs @@ -0,0 +1,217 @@ +//! Binary reader for INIBIN v1 and v2 files. + +use std::io::{Cursor, Read}; + +use byteorder::{ReadBytesExt, LE}; +use indexmap::IndexMap; + +use crate::error::InibinError; +use crate::types::{InibinFile, InibinFlags, InibinSet, InibinValue}; + +// ── V1 reader ─────────────────────────────────────────────────────────── + +fn sanitize_str(s: &str) -> InibinValue { + if s == "true" { + return InibinValue::Bool(true); + } + if s == "false" { + return InibinValue::Bool(false); + } + + if let Ok(v) = s.parse::() { + if !s.contains('.') && !s.contains('e') && !s.contains('E') { + return InibinValue::I32(v); + } + } + + if let Ok(v) = s.parse::() { + return InibinValue::F32(v); + } + + InibinValue::String(s.to_string()) +} + +fn read_v1(r: &mut R) -> Result { + let mut skip_buf = [0u8; 3]; + r.read_exact(&mut skip_buf)?; + let entry_count = r.read_u32::()? as usize; + let data_count = r.read_u32::()? as usize; + + let mut offsets = Vec::with_capacity(entry_count); + for _ in 0..entry_count { + let h = r.read_u32::()?; + let o = r.read_u32::()? as usize; + offsets.push((h, o)); + } + + let mut data = vec![0u8; data_count]; + r.read_exact(&mut data)?; + + let mut properties = IndexMap::new(); + for &(hash, offset) in &offsets { + let mut o = offset; + let mut s = String::new(); + while o < data.len() && data[o] != 0 { + s.push(data[o] as char); + o += 1; + } + properties.insert(hash, sanitize_str(&s)); + } + + let set = InibinSet::with_properties(InibinFlags::OldFormat, properties); + let mut file = InibinFile::new(); + file.set_version(1); + file.insert_set(set); + Ok(file) +} + +// ── V2 readers ────────────────────────────────────────────────────────── + +fn read_bools(r: &mut R) -> Result { + let num = r.read_u16::()? as usize; + let mut keys = Vec::with_capacity(num); + for _ in 0..num { + keys.push(r.read_u32::()?); + } + let bytes_count = num.div_ceil(8); + let mut bools = vec![0u8; bytes_count]; + r.read_exact(&mut bools)?; + + let mut properties = IndexMap::with_capacity(num); + for (j, &key) in keys.iter().enumerate() { + let bit = (bools[j / 8] >> (j % 8)) & 1; + properties.insert(key, InibinValue::Bool(bit != 0)); + } + Ok(InibinSet::with_properties(InibinFlags::BitList, properties)) +} + +fn read_numbers(r: &mut R, flags: InibinFlags) -> Result { + let num = r.read_u16::()? as usize; + let keys = (0..num).map(|_| r.read_u32::()).collect::>(); // untested + + let mut properties = IndexMap::with_capacity(num); + for &key in &keys { + let value = match flags { + InibinFlags::Int32List | InibinFlags::Int32LongList => { + InibinValue::I32(r.read_i32::()?) + } + InibinFlags::Float32List => InibinValue::F32(r.read_f32::()?), + InibinFlags::FixedPointFloatList => { + InibinValue::FixedPointFloat(r.read_u8()? as f64 * 0.1) + } + InibinFlags::Int16List => InibinValue::I16(r.read_i16::()?), + InibinFlags::Int8List => InibinValue::U8(r.read_u8()?), + InibinFlags::FixedPointFloatListVec3 => { + let a = r.read_u8()? as f64 * 0.1; + let b = r.read_u8()? as f64 * 0.1; + let c = r.read_u8()? as f64 * 0.1; + InibinValue::FixedPointVec3([a, b, c]) + } + InibinFlags::Float32ListVec3 => { + let a = r.read_f32::()?; + let b = r.read_f32::()?; + let c = r.read_f32::()?; + InibinValue::F32Vec3([a, b, c]) + } + InibinFlags::FixedPointFloatListVec2 => { + let a = r.read_u8()? as f64 * 0.1; + let b = r.read_u8()? as f64 * 0.1; + InibinValue::FixedPointVec2([a, b]) + } + InibinFlags::Float32ListVec2 => { + let a = r.read_f32::()?; + let b = r.read_f32::()?; + InibinValue::F32Vec2([a, b]) + } + InibinFlags::FixedPointFloatListVec4 => { + let a = r.read_u8()? as f64 * 0.1; + let b = r.read_u8()? as f64 * 0.1; + let c = r.read_u8()? as f64 * 0.1; + let d = r.read_u8()? as f64 * 0.1; + InibinValue::FixedPointVec4([a, b, c, d]) + } + InibinFlags::Float32ListVec4 => { + let a = r.read_f32::()?; + let b = r.read_f32::()?; + let c = r.read_f32::()?; + let d = r.read_f32::()?; + InibinValue::F32Vec4([a, b, c, d]) + } + _ => unreachable!(), + }; + properties.insert(key, value); + } + Ok(InibinSet::with_properties(flags, properties)) +} + +fn read_strings(r: &mut R, strings_length: usize) -> Result { + let num = r.read_u16::()? as usize; + let mut keys = Vec::with_capacity(num); + for _ in 0..num { + keys.push(r.read_u32::()?); + } + let mut offsets = Vec::with_capacity(num); + for _ in 0..num { + offsets.push(r.read_u16::()? as usize); + } + let mut data = vec![0u8; strings_length]; + r.read_exact(&mut data)?; + + let mut properties = IndexMap::with_capacity(num); + for i in 0..num { + let mut o = offsets[i]; + let mut s = String::new(); + while o < data.len() && data[o] != 0 { + s.push(data[o] as char); + o += 1; + } + properties.insert(keys[i], InibinValue::String(s)); + } + Ok(InibinSet::with_properties( + InibinFlags::StringList, + properties, + )) +} + +fn read_v2(r: &mut R) -> Result { + let strings_length = r.read_u16::()? as usize; + let mut flags = r.read_u16::()?; + if flags == 0 { + flags = r.read_u16::()?; + } + + let mut file = InibinFile::new(); + + for i in 0u8..14 { + if flags & (1 << i) == 0 { + continue; + } + let set = match i { + 5 => read_bools(r)?, + 12 => read_strings(r, strings_length)?, + _ => { + let inibin_flags = InibinFlags::try_from(i)?; + read_numbers(r, inibin_flags)? + } + }; + file.insert_set(set); + } + + Ok(file) +} + +/// Read an inibin binary from a byte slice. +pub fn from_slice(data: &[u8]) -> Result { + if data.is_empty() { + return Err(InibinError::Empty); + } + + let mut cursor = Cursor::new(data); + let version = cursor.read_u8()?; + + match version { + 2 => read_v2(&mut cursor), + 1 => read_v1(&mut cursor), + _ => Err(InibinError::UnknownVersion(version)), + } +} diff --git a/crates/ltk_inibin/src/types.rs b/crates/ltk_inibin/src/types.rs new file mode 100644 index 00000000..ed275b2d --- /dev/null +++ b/crates/ltk_inibin/src/types.rs @@ -0,0 +1,265 @@ +use indexmap::IndexMap; +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +/// Value types inside an inibin file, matching the v2 binary flag bits. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, TryFromPrimitive, IntoPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[repr(u8)] +pub enum InibinFlags { + Int32List = 0, + Float32List = 1, + FixedPointFloatList = 2, + Int16List = 3, + Int8List = 4, + BitList = 5, + FixedPointFloatListVec3 = 6, + Float32ListVec3 = 7, + FixedPointFloatListVec2 = 8, + Float32ListVec2 = 9, + FixedPointFloatListVec4 = 10, + Float32ListVec4 = 11, + StringList = 12, + Int32LongList = 13, + /// Old format (v1) — all values stored as strings in a data block. + OldFormat = 255, +} + +/// A typed value stored in an inibin entry. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum InibinValue { + I32(i32), + F32(f32), + FixedPointFloat(f64), + I16(i16), + U8(u8), + Bool(bool), + FixedPointVec3([f64; 3]), + F32Vec3([f32; 3]), + FixedPointVec2([f64; 2]), + F32Vec2([f32; 2]), + FixedPointVec4([f64; 4]), + F32Vec4([f32; 4]), + String(String), +} + +/// A set of values of a single type inside an [`InibinFile`]. +/// +/// Each set corresponds to one storage-type bucket in the binary format. +/// Properties are keyed by their hash (u32). +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct InibinSet { + flags: InibinFlags, + properties: IndexMap, +} + +impl InibinSet { + /// Create a new empty set for the given type. + pub fn new(flags: InibinFlags) -> Self { + Self { + flags, + properties: IndexMap::new(), + } + } + + /// Create a set with pre-populated properties. + pub fn with_properties(flags: InibinFlags, properties: IndexMap) -> Self { + Self { flags, properties } + } + + /// The storage type of this set. + pub fn flags(&self) -> InibinFlags { + self.flags + } + + /// Get a value by hash. + pub fn get(&self, hash: u32) -> Option<&InibinValue> { + self.properties.get(&hash) + } + + /// Get a mutable reference to a value by hash. + pub fn get_mut(&mut self, hash: u32) -> Option<&mut InibinValue> { + self.properties.get_mut(&hash) + } + + /// Check if a hash exists in this set. + pub fn contains(&self, hash: u32) -> bool { + self.properties.contains_key(&hash) + } + + /// Insert a value. Returns the previous value if the hash already existed. + pub fn insert(&mut self, hash: u32, value: InibinValue) -> Option { + self.properties.insert(hash, value) + } + + /// Remove a value by hash. Returns the removed value. + pub fn remove(&mut self, hash: u32) -> Option { + self.properties.shift_remove(&hash) + } + + /// Number of entries in this set. + pub fn len(&self) -> usize { + self.properties.len() + } + + /// Whether this set is empty. + pub fn is_empty(&self) -> bool { + self.properties.is_empty() + } + + /// Iterate over `(hash, value)` pairs. + pub fn iter(&self) -> indexmap::map::Iter<'_, u32, InibinValue> { + self.properties.iter() + } + + /// Iterate mutably over `(hash, value)` pairs. + pub fn iter_mut(&mut self) -> indexmap::map::IterMut<'_, u32, InibinValue> { + self.properties.iter_mut() + } +} + +impl<'a> IntoIterator for &'a InibinSet { + type Item = (&'a u32, &'a InibinValue); + type IntoIter = indexmap::map::Iter<'a, u32, InibinValue>; + + fn into_iter(self) -> Self::IntoIter { + self.properties.iter() + } +} + +impl<'a> IntoIterator for &'a mut InibinSet { + type Item = (&'a u32, &'a mut InibinValue); + type IntoIter = indexmap::map::IterMut<'a, u32, InibinValue>; + + fn into_iter(self) -> Self::IntoIter { + self.properties.iter_mut() + } +} + +impl IntoIterator for InibinSet { + type Item = (u32, InibinValue); + type IntoIter = indexmap::map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.properties.into_iter() + } +} + +/// Represents a binary ini file (inibin, troybin, cfgbin). +/// +/// Contains sets of values grouped by storage type. Each set holds +/// `hash -> value` pairs of a single type. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct InibinFile { + version: u8, + sets: IndexMap, +} + +impl InibinFile { + /// Create a new empty inibin file. + pub fn new() -> Self { + Self { + version: 2, + sets: IndexMap::new(), + } + } + + /// The format version (1 or 2). + pub fn version(&self) -> u8 { + self.version + } + + /// Set the format version. + pub fn set_version(&mut self, version: u8) { + self.version = version; + } + + /// Get a value by hash, searching all sets. + pub fn get(&self, hash: u32) -> Option<&InibinValue> { + for set in self.sets.values() { + if let Some(val) = set.get(hash) { + return Some(val); + } + } + None + } + + /// Get a value from a specific set type. + pub fn get_from(&self, flags: InibinFlags, hash: u32) -> Option<&InibinValue> { + self.sets.get(&flags).and_then(|s| s.get(hash)) + } + + /// Check if a hash exists in any set. + pub fn contains(&self, hash: u32) -> bool { + self.sets.values().any(|s| s.contains(hash)) + } + + /// Add a value to a specific storage-type bucket. + /// Creates the set if it doesn't exist yet. + /// Returns the previous value if the hash already existed in that set. + pub fn insert_value( + &mut self, + hash: u32, + value: InibinValue, + flags: InibinFlags, + ) -> Option { + let set = self + .sets + .entry(flags) + .or_insert_with(|| InibinSet::new(flags)); + set.insert(hash, value) + } + + /// Remove a value by hash, searching all sets. + pub fn remove(&mut self, hash: u32) -> Option { + for set in self.sets.values_mut() { + if let Some(val) = set.remove(hash) { + return Some(val); + } + } + None + } + + /// Get a reference to a set by type. + pub fn set(&self, flags: InibinFlags) -> Option<&InibinSet> { + self.sets.get(&flags) + } + + /// Get a mutable reference to a set by type. + pub fn set_mut(&mut self, flags: InibinFlags) -> Option<&mut InibinSet> { + self.sets.get_mut(&flags) + } + + /// Insert a complete set. Returns the previous set if one existed for that type. + pub fn insert_set(&mut self, set: InibinSet) -> Option { + self.sets.insert(set.flags(), set) + } + + /// Iterate over all sets. + pub fn sets(&self) -> indexmap::map::Values<'_, InibinFlags, InibinSet> { + self.sets.values() + } + + /// Total number of entries across all sets. + pub fn len(&self) -> usize { + self.sets.values().map(|s| s.len()).sum() + } + + /// Whether the file has no entries. + pub fn is_empty(&self) -> bool { + self.sets.values().all(|s| s.is_empty()) + } + + /// Flat iterator over all `(hash, &value)` pairs across all sets. + pub fn iter(&self) -> impl Iterator { + self.sets.values().flat_map(|s| s.iter()) + } +} + +impl Default for InibinFile { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/ltk_inibin/src/writer.rs b/crates/ltk_inibin/src/writer.rs new file mode 100644 index 00000000..21373f5f --- /dev/null +++ b/crates/ltk_inibin/src/writer.rs @@ -0,0 +1,261 @@ +//! Binary writer for INIBIN v2 files. +//! +//! Serializes an [`InibinFile`] back to the v2 binary format. V1 (old format) +//! sets are skipped since v1 is a legacy read-only format. + +use std::io::Write; + +use byteorder::{WriteBytesExt, LE}; + +use crate::error::InibinError; +use crate::types::{InibinFile, InibinFlags, InibinSet, InibinValue}; + +/// Write an [`InibinFile`] to binary v2 format. +/// +/// Sets with [`InibinFlags::OldFormat`] are skipped — v1 is read-only. +/// Returns [`InibinError::V1WriteNotSupported`] if all sets are old format. +pub fn write(w: &mut W, file: &InibinFile) -> Result<(), InibinError> { + // Check if everything is old format (nothing writable) + let has_v2 = file.sets().any(|s| s.flags() != InibinFlags::OldFormat); + if !has_v2 && !file.is_empty() { + return Err(InibinError::V1WriteNotSupported); + } + + // Compute flags bitmask + let mut flags: u16 = 0; + for set in file.sets() { + if set.is_empty() || set.flags() == InibinFlags::OldFormat { + continue; + } + let bit: u8 = set.flags().into(); + if (bit as usize) < 14 { + flags |= 1 << bit; + } + } + + // Build string pool first (needed for stringsLength header) + let string_pool = file + .set(InibinFlags::StringList) + .filter(|s| !s.is_empty()) + .map(build_string_pool) + .unwrap_or_default(); + let strings_length = string_pool.data.len() as u16; + + // Version byte + w.write_u8(2)?; + // stringsLength (u16 LE) + w.write_u16::(strings_length)?; + // flags (u16 LE) + w.write_u16::(flags)?; + + // Write each block in flag-bit order + for bit in 0u8..14 { + if flags & (1 << bit) == 0 { + continue; + } + let inibin_flags = InibinFlags::try_from(bit)?; + let set = file.set(inibin_flags).unwrap(); + match bit { + 5 => write_bool_block(w, set)?, + 12 => write_string_block(w, set, &string_pool)?, + _ => write_number_block(w, set, inibin_flags)?, + } + } + + Ok(()) +} + +// ── Bool block ────────────────────────────────────────────────────────────── + +fn write_bool_block(w: &mut W, set: &InibinSet) -> Result<(), InibinError> { + let num = set.len(); + w.write_u16::(num as u16)?; + + // Hashes + for (&hash, _) in set.iter() { + w.write_u32::(hash)?; + } + + // Packed booleans + let bytes_count = num.div_ceil(8); + let mut packed = vec![0u8; bytes_count]; + for (j, (_, val)) in set.iter().enumerate() { + let bit = match val { + InibinValue::Bool(b) => *b, + _ => false, + }; + if bit { + packed[j / 8] |= 1 << (j % 8); + } + } + w.write_all(&packed)?; + Ok(()) +} + +// ── Number block ──────────────────────────────────────────────────────────── + +fn write_number_block( + w: &mut W, + set: &InibinSet, + flags: InibinFlags, +) -> Result<(), InibinError> { + w.write_u16::(set.len() as u16)?; + + // Hashes + for (&hash, _) in set.iter() { + w.write_u32::(hash)?; + } + + // Values + for (_, val) in set.iter() { + write_value(w, val, flags)?; + } + Ok(()) +} + +fn write_value( + w: &mut W, + val: &InibinValue, + flags: InibinFlags, +) -> Result<(), InibinError> { + match flags { + InibinFlags::Int32List | InibinFlags::Int32LongList => { + let v = match val { + InibinValue::I32(v) => *v, + _ => 0, + }; + w.write_i32::(v)?; + } + InibinFlags::Float32List => { + let v = match val { + InibinValue::F32(v) => *v, + _ => 0.0, + }; + w.write_f32::(v)?; + } + InibinFlags::FixedPointFloatList => { + let v = match val { + InibinValue::FixedPointFloat(v) => *v, + _ => 0.0, + }; + w.write_u8((v / 0.1).round().clamp(0.0, 255.0) as u8)?; + } + InibinFlags::Int16List => { + let v = match val { + InibinValue::I16(v) => *v, + _ => 0, + }; + w.write_i16::(v)?; + } + InibinFlags::Int8List => { + let v = match val { + InibinValue::U8(v) => *v, + _ => 0, + }; + w.write_u8(v)?; + } + InibinFlags::FixedPointFloatListVec3 => { + let [a, b, c] = match val { + InibinValue::FixedPointVec3(v) => *v, + _ => [0.0; 3], + }; + w.write_u8((a / 0.1).round().clamp(0.0, 255.0) as u8)?; + w.write_u8((b / 0.1).round().clamp(0.0, 255.0) as u8)?; + w.write_u8((c / 0.1).round().clamp(0.0, 255.0) as u8)?; + } + InibinFlags::Float32ListVec3 => { + let [a, b, c] = match val { + InibinValue::F32Vec3(v) => *v, + _ => [0.0; 3], + }; + w.write_f32::(a)?; + w.write_f32::(b)?; + w.write_f32::(c)?; + } + InibinFlags::FixedPointFloatListVec2 => { + let [a, b] = match val { + InibinValue::FixedPointVec2(v) => *v, + _ => [0.0; 2], + }; + w.write_u8((a / 0.1).round().clamp(0.0, 255.0) as u8)?; + w.write_u8((b / 0.1).round().clamp(0.0, 255.0) as u8)?; + } + InibinFlags::Float32ListVec2 => { + let [a, b] = match val { + InibinValue::F32Vec2(v) => *v, + _ => [0.0; 2], + }; + w.write_f32::(a)?; + w.write_f32::(b)?; + } + InibinFlags::FixedPointFloatListVec4 => { + let [a, b, c, d] = match val { + InibinValue::FixedPointVec4(v) => *v, + _ => [0.0; 4], + }; + w.write_u8((a / 0.1).round().clamp(0.0, 255.0) as u8)?; + w.write_u8((b / 0.1).round().clamp(0.0, 255.0) as u8)?; + w.write_u8((c / 0.1).round().clamp(0.0, 255.0) as u8)?; + w.write_u8((d / 0.1).round().clamp(0.0, 255.0) as u8)?; + } + InibinFlags::Float32ListVec4 => { + let [a, b, c, d] = match val { + InibinValue::F32Vec4(v) => *v, + _ => [0.0; 4], + }; + w.write_f32::(a)?; + w.write_f32::(b)?; + w.write_f32::(c)?; + w.write_f32::(d)?; + } + _ => {} + } + Ok(()) +} + +// ── String block ──────────────────────────────────────────────────────────── + +#[derive(Default)] +struct StringPool { + offsets: Vec, + data: Vec, +} + +fn build_string_pool(set: &InibinSet) -> StringPool { + let mut offsets = Vec::with_capacity(set.len()); + let mut data = Vec::new(); + + for (_, val) in set.iter() { + offsets.push(data.len() as u16); + let s = match val { + InibinValue::String(s) => s.as_bytes(), + _ => b"", + }; + data.extend_from_slice(s); + data.push(0); // null terminator + } + + StringPool { offsets, data } +} + +fn write_string_block( + w: &mut W, + set: &InibinSet, + pool: &StringPool, +) -> Result<(), InibinError> { + w.write_u16::(set.len() as u16)?; + + // Hashes + for (&hash, _) in set.iter() { + w.write_u32::(hash)?; + } + + // Offsets (u16 each) + for &offset in &pool.offsets { + w.write_u16::(offset)?; + } + + // String data + w.write_all(&pool.data)?; + Ok(()) +} diff --git a/crates/ltk_inibin/tests/fixtures/slime_environmentminion_idle.troybin b/crates/ltk_inibin/tests/fixtures/slime_environmentminion_idle.troybin new file mode 100644 index 00000000..b2ae9d04 Binary files /dev/null and b/crates/ltk_inibin/tests/fixtures/slime_environmentminion_idle.troybin differ diff --git a/crates/ltk_inibin/tests/round_trip.rs b/crates/ltk_inibin/tests/round_trip.rs new file mode 100644 index 00000000..4a28e593 --- /dev/null +++ b/crates/ltk_inibin/tests/round_trip.rs @@ -0,0 +1,177 @@ +use ltk_inibin::{InibinFile, InibinFlags, InibinValue}; + +const FIXTURE: &[u8] = include_bytes!("fixtures/slime_environmentminion_idle.troybin"); + +#[test] +fn read_real_troybin() { + let file = ltk_inibin::from_slice(FIXTURE).unwrap(); + assert_eq!(file.version(), 2); + assert!(!file.is_empty()); + // Should have multiple sets + assert!(file.sets().count() > 1); +} + +#[test] +fn round_trip_binary() { + let file = ltk_inibin::from_slice(FIXTURE).unwrap(); + let mut buf = Vec::new(); + ltk_inibin::write(&mut buf, &file).unwrap(); + let file2 = ltk_inibin::from_slice(&buf).unwrap(); + assert_eq!(file.len(), file2.len()); + assert_eq!(file.version(), file2.version()); + + // All hashes and values should match + for (hash, val) in file.iter() { + let val2 = file2.get(*hash).expect("hash missing after round-trip"); + assert_eq!(val, val2, "value mismatch for hash {hash:#010X}"); + } +} + +#[test] +fn round_trip_numbers() { + let mut file = InibinFile::new(); + file.add_value(100, InibinValue::I32(42), InibinFlags::Int32List); + file.add_value(200, InibinValue::F32(2.78), InibinFlags::Float32List); + file.add_value( + 300, + InibinValue::F32Vec3([1.0, 2.0, 3.0]), + InibinFlags::Float32ListVec3, + ); + + let mut buf = Vec::new(); + ltk_inibin::write(&mut buf, &file).unwrap(); + let file2 = ltk_inibin::from_slice(&buf).unwrap(); + assert_eq!(file2.len(), 3); + assert_eq!(file2.get(100), Some(&InibinValue::I32(42))); + assert_eq!(file2.get(200), Some(&InibinValue::F32(2.78))); + assert_eq!(file2.get(300), Some(&InibinValue::F32Vec3([1.0, 2.0, 3.0]))); +} + +#[test] +fn round_trip_strings() { + let mut file = InibinFile::new(); + file.add_value( + 400, + InibinValue::String("hello.dds".to_string()), + InibinFlags::StringList, + ); + file.add_value( + 500, + InibinValue::String("world.png".to_string()), + InibinFlags::StringList, + ); + + let mut buf = Vec::new(); + ltk_inibin::write(&mut buf, &file).unwrap(); + let file2 = ltk_inibin::from_slice(&buf).unwrap(); + assert_eq!(file2.len(), 2); + assert_eq!( + file2.get(400), + Some(&InibinValue::String("hello.dds".to_string())) + ); + assert_eq!( + file2.get(500), + Some(&InibinValue::String("world.png".to_string())) + ); +} + +#[test] +fn round_trip_bools() { + let mut file = InibinFile::new(); + file.add_value(600, InibinValue::Bool(true), InibinFlags::BitList); + file.add_value(700, InibinValue::Bool(false), InibinFlags::BitList); + file.add_value(800, InibinValue::Bool(true), InibinFlags::BitList); + + let mut buf = Vec::new(); + ltk_inibin::write(&mut buf, &file).unwrap(); + let file2 = ltk_inibin::from_slice(&buf).unwrap(); + assert_eq!(file2.len(), 3); + assert_eq!(file2.get(600), Some(&InibinValue::Bool(true))); + assert_eq!(file2.get(700), Some(&InibinValue::Bool(false))); + assert_eq!(file2.get(800), Some(&InibinValue::Bool(true))); +} + +#[test] +fn round_trip_mixed() { + let mut file = InibinFile::new(); + file.add_value(10, InibinValue::I32(7), InibinFlags::Int32List); + file.add_value(20, InibinValue::F32(1.5), InibinFlags::Float32List); + file.add_value(30, InibinValue::Bool(true), InibinFlags::BitList); + file.add_value( + 40, + InibinValue::String("test.dds".to_string()), + InibinFlags::StringList, + ); + file.add_value( + 50, + InibinValue::F32Vec2([0.5, 0.6]), + InibinFlags::Float32ListVec2, + ); + + let mut buf = Vec::new(); + ltk_inibin::write(&mut buf, &file).unwrap(); + let file2 = ltk_inibin::from_slice(&buf).unwrap(); + assert_eq!(file2.len(), 5); +} + +#[test] +fn bucket_api_get_set_remove() { + let mut file = InibinFile::new(); + + // Insert + assert!(file + .add_value(1, InibinValue::I32(10), InibinFlags::Int32List) + .is_none()); + assert!(file.contains(1)); + assert_eq!(file.get(1), Some(&InibinValue::I32(10))); + assert_eq!(file.len(), 1); + + // Overwrite returns old value + let old = file.add_value(1, InibinValue::I32(20), InibinFlags::Int32List); + assert_eq!(old, Some(InibinValue::I32(10))); + assert_eq!(file.get(1), Some(&InibinValue::I32(20))); + + // get_from specific set + assert_eq!( + file.get_from(InibinFlags::Int32List, 1), + Some(&InibinValue::I32(20)) + ); + assert_eq!(file.get_from(InibinFlags::Float32List, 1), None); + + // Remove + let removed = file.remove(1); + assert_eq!(removed, Some(InibinValue::I32(20))); + assert!(!file.contains(1)); + assert_eq!(file.len(), 0); +} + +#[test] +fn set_level_api() { + let mut file = InibinFile::new(); + file.add_value(1, InibinValue::I32(10), InibinFlags::Int32List); + file.add_value(2, InibinValue::I32(20), InibinFlags::Int32List); + file.add_value(3, InibinValue::F32(3.0), InibinFlags::Float32List); + + // Access set + let set = file.set(InibinFlags::Int32List).unwrap(); + assert_eq!(set.len(), 2); + assert_eq!(set.flags(), InibinFlags::Int32List); + + // Mutate through set_mut + let set = file.set_mut(InibinFlags::Int32List).unwrap(); + set.insert(4, InibinValue::I32(40)); + assert_eq!(file.len(), 4); +} + +#[test] +fn empty_file() { + let file = InibinFile::new(); + assert!(file.is_empty()); + assert_eq!(file.len(), 0); + assert!(file.get(0).is_none()); + assert!(!file.contains(0)); + + // Writing empty is fine + let mut buf = Vec::new(); + ltk_inibin::write(&mut buf, &file).unwrap(); +}