From c8e69db5041c06b730a92f856e0b050bf90d6b5c Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Fri, 16 Jan 2026 16:25:04 -0800 Subject: [PATCH 1/2] Add codec toolkit and fixed-point helpers --- crates/echo-wasm-abi/src/codec.rs | 215 ++++++++++++++++++++++++++++ crates/echo-wasm-abi/src/lib.rs | 2 + crates/echo-wasm-abi/tests/codec.rs | 27 ++++ crates/warp-core/src/fixed.rs | 63 ++++++++ crates/warp-core/src/lib.rs | 2 + 5 files changed, 309 insertions(+) create mode 100644 crates/echo-wasm-abi/src/codec.rs create mode 100644 crates/echo-wasm-abi/tests/codec.rs create mode 100644 crates/warp-core/src/fixed.rs diff --git a/crates/echo-wasm-abi/src/codec.rs b/crates/echo-wasm-abi/src/codec.rs new file mode 100644 index 00000000..993a7b62 --- /dev/null +++ b/crates/echo-wasm-abi/src/codec.rs @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Minimal deterministic codec helpers (length-prefixed, LE scalars). + +use thiserror::Error; + +/// Errors produced by codec readers. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum CodecError { + /// Attempted to read beyond the end of the buffer. + #[error("buffer too short")] + OutOfBounds, + /// UTF-8 decoding failed. + #[error("invalid utf-8")] + InvalidUtf8, + /// String length exceeded max bound. + #[error("string too long")] + StringTooLong, + /// Length prefix exceeded max bound. + #[error("length too large")] + LengthTooLarge, + /// Enum decoding failed. + #[error("invalid enum value")] + InvalidEnum, +} + +/// Trait for deterministic encoding to bytes. +pub trait Encode { + /// Encode into the provided writer. + fn encode(&self, writer: &mut Writer) -> Result<(), CodecError>; +} + +/// Trait for deterministic decoding from bytes. +pub trait Decode: Sized { + /// Decode from the provided reader. + fn decode(reader: &mut Reader) -> Result; +} + +/// Encode a value into a fresh Vec. +pub fn encode_to_vec(value: &T) -> Result, CodecError> { + let mut writer = Writer::default(); + value.encode(&mut writer)?; + Ok(writer.into_vec()) +} + +/// Decode a value from a byte slice. +pub fn decode_from_bytes(bytes: &[u8]) -> Result { + let mut reader = Reader::new(bytes); + T::decode(&mut reader) +} + +/// Deterministic writer for little-endian scalars and length-prefixed bytes. +#[derive(Debug, Default)] +pub struct Writer { + buf: Vec, +} + +impl Writer { + /// Create a new writer with a pre-allocated capacity. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + Self { buf: Vec::with_capacity(capacity) } + } + + /// Write raw bytes. + pub fn write_bytes(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + + /// Write a single byte. + pub fn write_u8(&mut self, value: u8) { + self.buf.push(value); + } + + /// Write a little-endian u32. + pub fn write_u32_le(&mut self, value: u32) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + /// Write a little-endian u16. + pub fn write_u16_le(&mut self, value: u16) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + /// Write a little-endian i64. + pub fn write_i64_le(&mut self, value: i64) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + /// Write length-prefixed bytes (u32 LE length). + pub fn write_len_prefixed_bytes(&mut self, bytes: &[u8]) -> Result<(), CodecError> { + let len: u32 = bytes.len().try_into().map_err(|_| CodecError::LengthTooLarge)?; + self.write_u32_le(len); + self.write_bytes(bytes); + Ok(()) + } + + /// Write a length-prefixed UTF-8 string with a max bound. + pub fn write_string(&mut self, value: &str, max_len: usize) -> Result<(), CodecError> { + let bytes = value.as_bytes(); + if bytes.len() > max_len { + return Err(CodecError::StringTooLong); + } + self.write_len_prefixed_bytes(bytes) + } + + /// Consume the writer and return the buffer. + #[must_use] + pub fn into_vec(self) -> Vec { + self.buf + } +} + +/// Deterministic reader for little-endian scalars and length-prefixed bytes. +#[derive(Debug)] +pub struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Reader<'a> { + /// Create a reader over the provided byte slice. + #[must_use] + pub fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn take(&mut self, len: usize) -> Result<&'a [u8], CodecError> { + if self.offset + len > self.bytes.len() { + return Err(CodecError::OutOfBounds); + } + let out = &self.bytes[self.offset..self.offset + len]; + self.offset += len; + Ok(out) + } + + /// Read a little-endian u32. + pub fn read_u32_le(&mut self) -> Result { + let chunk = self.take(4)?; + let raw: [u8; 4] = chunk.try_into().map_err(|_| CodecError::OutOfBounds)?; + Ok(u32::from_le_bytes(raw)) + } + + /// Read a single byte. + pub fn read_u8(&mut self) -> Result { + let chunk = self.take(1)?; + Ok(chunk[0]) + } + + /// Read a little-endian u16. + pub fn read_u16_le(&mut self) -> Result { + let chunk = self.take(2)?; + let raw: [u8; 2] = chunk.try_into().map_err(|_| CodecError::OutOfBounds)?; + Ok(u16::from_le_bytes(raw)) + } + + /// Read a little-endian i64. + pub fn read_i64_le(&mut self) -> Result { + let chunk = self.take(8)?; + let raw: [u8; 8] = chunk.try_into().map_err(|_| CodecError::OutOfBounds)?; + Ok(i64::from_le_bytes(raw)) + } + + /// Read a length-prefixed byte slice with a max bound. + pub fn read_len_prefixed_bytes(&mut self, max_len: usize) -> Result<&'a [u8], CodecError> { + let len = self.read_u32_le()? as usize; + if len > max_len { + return Err(CodecError::LengthTooLarge); + } + self.take(len) + } + + /// Read a length-prefixed UTF-8 string with a max bound. + pub fn read_string(&mut self, max_len: usize) -> Result { + let bytes = self.read_len_prefixed_bytes(max_len)?; + std::str::from_utf8(bytes) + .map(|s| s.to_string()) + .map_err(|_| CodecError::InvalidUtf8) + } +} + +/// Q32.32 fixed-point helpers. +#[inline] +#[must_use] +pub fn fx_from_i64(n: i64) -> i64 { + n << 32 +} + +/// Convert f32 to Q32.32 using truncation toward zero. +#[inline] +#[must_use] +pub fn fx_from_f32(value: f32) -> i64 { + let scaled = (value as f64) * ((1u64 << 32) as f64); + if scaled.is_nan() { + 0 + } else if scaled.is_infinite() { + if scaled.is_sign_positive() { i64::MAX } else { i64::MIN } + } else { + scaled.trunc() as i64 + } +} + +/// Convert integer components to Q32.32 raw vector. +#[inline] +#[must_use] +pub fn vec3_fx_from_i64(x: i64, y: i64, z: i64) -> [i64; 3] { + [fx_from_i64(x), fx_from_i64(y), fx_from_i64(z)] +} + +/// Convert f32 components to Q32.32 raw vector using truncation toward zero. +#[inline] +#[must_use] +pub fn vec3_fx_from_f32(x: f32, y: f32, z: f32) -> [i64; 3] { + [fx_from_f32(x), fx_from_f32(y), fx_from_f32(z)] +} diff --git a/crates/echo-wasm-abi/src/lib.rs b/crates/echo-wasm-abi/src/lib.rs index ccde9894..ae99cae6 100644 --- a/crates/echo-wasm-abi/src/lib.rs +++ b/crates/echo-wasm-abi/src/lib.rs @@ -15,6 +15,8 @@ pub use canonical::{CanonError, decode_value, encode_value}; pub mod eintlog; pub use eintlog::*; +pub mod codec; + /// Errors produced by the Intent Envelope parser. #[derive(Debug, PartialEq, Eq)] pub enum EnvelopeError { diff --git a/crates/echo-wasm-abi/tests/codec.rs b/crates/echo-wasm-abi/tests/codec.rs new file mode 100644 index 00000000..75e1d42a --- /dev/null +++ b/crates/echo-wasm-abi/tests/codec.rs @@ -0,0 +1,27 @@ +use echo_wasm_abi::codec::{Reader, Writer, fx_from_i64, fx_from_f32, vec3_fx_from_i64, vec3_fx_from_f32}; + +#[test] +fn codec_round_trip_scalars_and_string() { + let mut w = Writer::with_capacity(64); + w.write_u32_le(42); + w.write_i64_le(-123); + w.write_string("hello", 1024).unwrap(); + let bytes = w.into_vec(); + + let mut r = Reader::new(&bytes); + let a = r.read_u32_le().unwrap(); + let b = r.read_i64_le().unwrap(); + let c = r.read_string(1024).unwrap(); + + assert_eq!(a, 42); + assert_eq!(b, -123); + assert_eq!(c, "hello"); +} + +#[test] +fn codec_fx_helpers() { + assert_eq!(fx_from_i64(1), 1i64 << 32); + assert_eq!(vec3_fx_from_i64(1, -2, 3), [1i64 << 32, -2i64 << 32, 3i64 << 32]); + assert_eq!(fx_from_f32(1.5), (1i64 << 32) + (1i64 << 31)); + assert_eq!(vec3_fx_from_f32(1.0, -2.5, 3.0), [1i64 << 32, -2i64 << 32 - (1i64 << 31), 3i64 << 32]); +} diff --git a/crates/warp-core/src/fixed.rs b/crates/warp-core/src/fixed.rs new file mode 100644 index 00000000..6ffb3a29 --- /dev/null +++ b/crates/warp-core/src/fixed.rs @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Deterministic fixed-point helpers (Q32.32). + +/// Q32.32 fixed-point scalar. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Fx32(i64); + +impl Fx32 { + /// Construct from an integer value (n << 32). + #[must_use] + pub fn from_i64(n: i64) -> Self { + Self(n << 32) + } + + /// Construct directly from raw Q32.32 bits. + #[must_use] + pub fn from_raw(raw: i64) -> Self { + Self(raw) + } + + /// Return the raw Q32.32 representation. + #[must_use] + pub fn raw(self) -> i64 { + self.0 + } +} + +/// 3D vector in Q32.32 fixed-point. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Vec3Fx { + pub x: Fx32, + pub y: Fx32, + pub z: Fx32, +} + +impl Vec3Fx { + /// Construct from integer components (each converted to Q32.32). + #[must_use] + pub fn new_i64(x: i64, y: i64, z: i64) -> Self { + Self { + x: Fx32::from_i64(x), + y: Fx32::from_i64(y), + z: Fx32::from_i64(z), + } + } + + /// Construct from raw Q32.32 components. + #[must_use] + pub fn from_raw(raw: [i64; 3]) -> Self { + Self { + x: Fx32::from_raw(raw[0]), + y: Fx32::from_raw(raw[1]), + z: Fx32::from_raw(raw[2]), + } + } + + /// Return raw Q32.32 components. + #[must_use] + pub fn to_raw(self) -> [i64; 3] { + [self.x.raw(), self.y.raw(), self.z.raw()] + } +} diff --git a/crates/warp-core/src/lib.rs b/crates/warp-core/src/lib.rs index ab6d589f..0ef47d75 100644 --- a/crates/warp-core/src/lib.rs +++ b/crates/warp-core/src/lib.rs @@ -46,6 +46,8 @@ /// Deterministic math subsystem (Vec3, Mat4, Quat, PRNG). pub mod math; +/// Deterministic fixed-point helpers (Q32.32). +pub mod fixed; mod attachment; mod cmd; From 81b6a9f1b660c11535d47442583326e90b0ffa5b Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Sat, 17 Jan 2026 05:41:23 -0800 Subject: [PATCH 2/2] fix(codec): address PR feedback for generic-atoms - Add overflow protection to fx_from_i64 and Fx32::from_i64 (saturate to i64::MIN/MAX for inputs outside i32 range) - Use checked_add in Reader::take to prevent integer overflow - Add rustdoc for codec module in lib.rs - Update CHANGELOG with codec and fixed module additions Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 9 +++++++++ crates/echo-wasm-abi/src/codec.rs | 24 +++++++++++++++++++----- crates/echo-wasm-abi/src/lib.rs | 1 + crates/warp-core/src/fixed.rs | 12 +++++++++++- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf6e3d4..8e590a9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ## Unreleased +- Added `codec` module to `echo-wasm-abi`: + - Deterministic binary codec (`Reader`/`Writer`) for length-prefixed LE scalars + - Q32.32 fixed-point helpers (`fx_from_i64`, `fx_from_f32`, `vec3_fx_from_*`) + - Overflow-safe conversions with saturation for out-of-range inputs + - `Encode`/`Decode` traits for composable serialization +- Added `fixed` module to `warp-core`: + - `Fx32` scalar type for Q32.32 fixed-point arithmetic + - `Vec3Fx` 3D vector type with fixed-point components + - Overflow-safe constructors with range validation - Added WSC (Write-Streaming Columnar) snapshot format to `warp-core`: - Deterministic serialization of WARP graph state with zero-copy mmap deserialization - 8-byte aligned columnar layout for SIMD-friendly access diff --git a/crates/echo-wasm-abi/src/codec.rs b/crates/echo-wasm-abi/src/codec.rs index 5344ee38..2344c46b 100644 --- a/crates/echo-wasm-abi/src/codec.rs +++ b/crates/echo-wasm-abi/src/codec.rs @@ -131,11 +131,15 @@ impl<'a> Reader<'a> { } fn take(&mut self, len: usize) -> Result<&'a [u8], CodecError> { - if self.offset + len > self.bytes.len() { + let end = self + .offset + .checked_add(len) + .ok_or(CodecError::OutOfBounds)?; + if end > self.bytes.len() { return Err(CodecError::OutOfBounds); } - let out = &self.bytes[self.offset..self.offset + len]; - self.offset += len; + let out = &self.bytes[self.offset..end]; + self.offset = end; Ok(out) } @@ -184,11 +188,21 @@ impl<'a> Reader<'a> { } } -/// Q32.32 fixed-point helpers. +/// Convert an integer to Q32.32 fixed-point representation. +/// +/// Valid input range is `i32::MIN..=i32::MAX`. Values outside this range +/// saturate to `i64::MIN` or `i64::MAX` respectively. #[inline] #[must_use] pub fn fx_from_i64(n: i64) -> i64 { - n << 32 + // Q32.32 can only represent integers in i32 range without overflow + if n > i64::from(i32::MAX) { + i64::MAX + } else if n < i64::from(i32::MIN) { + i64::MIN + } else { + n << 32 + } } /// Convert f32 to Q32.32 using truncation toward zero. diff --git a/crates/echo-wasm-abi/src/lib.rs b/crates/echo-wasm-abi/src/lib.rs index ae99cae6..5d4ad4a3 100644 --- a/crates/echo-wasm-abi/src/lib.rs +++ b/crates/echo-wasm-abi/src/lib.rs @@ -15,6 +15,7 @@ pub use canonical::{CanonError, decode_value, encode_value}; pub mod eintlog; pub use eintlog::*; +/// Deterministic binary codec for length-prefixed scalars and Q32.32 fixed-point helpers. pub mod codec; /// Errors produced by the Intent Envelope parser. diff --git a/crates/warp-core/src/fixed.rs b/crates/warp-core/src/fixed.rs index be4e8773..52ae8284 100644 --- a/crates/warp-core/src/fixed.rs +++ b/crates/warp-core/src/fixed.rs @@ -8,9 +8,19 @@ pub struct Fx32(i64); impl Fx32 { /// Construct from an integer value (n << 32). + /// + /// Valid input range is `i32::MIN..=i32::MAX`. Values outside this range + /// saturate to the minimum or maximum representable Q32.32 value. #[must_use] pub fn from_i64(n: i64) -> Self { - Self(n << 32) + // Q32.32 can only represent integers in i32 range without overflow + if n > i64::from(i32::MAX) { + Self(i64::MAX) + } else if n < i64::from(i32::MIN) { + Self(i64::MIN) + } else { + Self(n << 32) + } } /// Construct directly from raw Q32.32 bits.