-
Notifications
You must be signed in to change notification settings - Fork 1
Add codec toolkit and fixed-point helpers #253
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c8e69db
Add codec toolkit and fixed-point helpers
flyingrobots cb68e41
Merge branch 'main' into generic-atoms
flyingrobots 5aa6727
Merge remote-tracking branch 'origin/main' into generic-atoms
flyingrobots 81b6a9f
fix(codec): address PR feedback for generic-atoms
flyingrobots File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,238 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> | ||
| //! 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<Self, CodecError>; | ||
| } | ||
|
|
||
| /// Encode a value into a fresh Vec. | ||
| pub fn encode_to_vec<T: Encode>(value: &T) -> Result<Vec<u8>, 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<T: Decode>(bytes: &[u8]) -> Result<T, CodecError> { | ||
| 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<u8>, | ||
| } | ||
|
|
||
| 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<u8> { | ||
| 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> { | ||
| 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..end]; | ||
| self.offset = end; | ||
| Ok(out) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /// Read a little-endian u32. | ||
| pub fn read_u32_le(&mut self) -> Result<u32, CodecError> { | ||
| 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<u8, CodecError> { | ||
| let chunk = self.take(1)?; | ||
| Ok(chunk[0]) | ||
| } | ||
|
|
||
| /// Read a little-endian u16. | ||
| pub fn read_u16_le(&mut self) -> Result<u16, CodecError> { | ||
| 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<i64, CodecError> { | ||
| 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<String, CodecError> { | ||
| let bytes = self.read_len_prefixed_bytes(max_len)?; | ||
| std::str::from_utf8(bytes) | ||
| .map(|s| s.to_string()) | ||
| .map_err(|_| CodecError::InvalidUtf8) | ||
| } | ||
| } | ||
|
|
||
| /// 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 { | ||
| // 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. | ||
| #[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)] | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> | ||
| //! Codec round-trip tests. | ||
|
|
||
| use echo_wasm_abi::codec::{ | ||
| Reader, Writer, fx_from_f32, fx_from_i64, vec3_fx_from_f32, vec3_fx_from_i64, | ||
| }; | ||
|
|
||
| #[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] | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> | ||
| //! 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). | ||
| /// | ||
| /// 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 { | ||
| // 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. | ||
| #[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 { | ||
| /// X component. | ||
| pub x: Fx32, | ||
| /// Y component. | ||
| pub y: Fx32, | ||
| /// Z component. | ||
| pub z: Fx32, | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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()] | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reject trailing bytes to keep decoding canonical.
decode_from_bytesdecodes a prefix and silently ignores any remaining bytes. That permits multiple encodings for the same value and can hide appended data. For a deterministic codec, this is a correctness and integrity risk. Enforce full consumption or provide a strict variant.🛠️ Proposed fix (add TrailingBytes + strict check)
pub enum CodecError { @@ /// Enum decoding failed. #[error("invalid enum value")] InvalidEnum, + /// Decoded value but leftover bytes remained. + #[error("trailing bytes")] + TrailingBytes, }pub fn decode_from_bytes<T: Decode>(bytes: &[u8]) -> Result<T, CodecError> { let mut reader = Reader::new(bytes); - T::decode(&mut reader) + let value = T::decode(&mut reader)?; + if reader.offset != bytes.len() { + return Err(CodecError::TrailingBytes); + } + Ok(value) }📝 Committable suggestion
🤖 Prompt for AI Agents