From 4eed8efed092417a1a2758a088ec7277b67fa3bc Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 20:17:02 +0200 Subject: [PATCH] =?UTF-8?q?Bin=C3=A4re=20Kodierung=20f=C3=BCr=20den=20Kern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writer und Reader für Befehle, Snapshots und Aufzeichnungen. Beschädigte Eingaben führen zu einem Fehler statt zu einem Absturz. Co-Authored-By: Claude Opus 5.5 --- core/src/codec.rs | 169 ++++++++++++++++++++++++++++++++++++++++++++ core/src/lib.rs | 2 + core/tests/codec.rs | 78 ++++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 core/src/codec.rs create mode 100644 core/tests/codec.rs diff --git a/core/src/codec.rs b/core/src/codec.rs new file mode 100644 index 0000000..66c6d85 --- /dev/null +++ b/core/src/codec.rs @@ -0,0 +1,169 @@ +//! Minimal little-endian binary encoding for commands, snapshots and +//! recordings. + +use std::fmt; + +/// Error returned when bytes cannot be decoded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DecodeError { + /// The input ended before the value was complete. + UnexpectedEnd, + /// The input contained a value that is not allowed at this position. + InvalidValue(&'static str), + /// The input contained bytes after the last expected value. + TrailingBytes, +} + +impl fmt::Display for DecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DecodeError::UnexpectedEnd => write!(f, "unexpected end of input"), + DecodeError::InvalidValue(what) => write!(f, "invalid value: {what}"), + DecodeError::TrailingBytes => write!(f, "trailing bytes after the last value"), + } + } +} + +impl std::error::Error for DecodeError {} + +/// Appends values to a growing byte buffer. +#[derive(Debug, Default)] +pub struct Writer { + buf: Vec, +} + +impl Writer { + pub fn new() -> Self { + Self::default() + } + + pub fn u8(&mut self, value: u8) { + self.buf.push(value); + } + + pub fn u16(&mut self, value: u16) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn u32(&mut self, value: u32) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn u64(&mut self, value: u64) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn i32(&mut self, value: i32) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn bool(&mut self, value: bool) { + self.buf.push(u8::from(value)); + } + + /// Writes bytes as they are, without a length. + pub fn raw(&mut self, value: &[u8]) { + self.buf.extend_from_slice(value); + } + + /// Writes a `u32` length followed by the bytes. + pub fn bytes(&mut self, value: &[u8]) { + let len = u32::try_from(value.len()).expect("length fits in u32"); + self.u32(len); + self.buf.extend_from_slice(value); + } + + /// Writes a string as UTF-8 with a `u32` length in front. + pub fn str(&mut self, value: &str) { + self.bytes(value.as_bytes()); + } + + pub fn into_bytes(self) -> Vec { + self.buf + } +} + +/// Reads values from a byte slice in the order they were written. +#[derive(Debug)] +pub struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + pub fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + /// Number of bytes that have not been read yet. + pub fn remaining(&self) -> usize { + self.buf.len() - self.pos + } + + /// Reads exactly `len` bytes. + pub fn raw(&mut self, len: usize) -> Result<&'a [u8], DecodeError> { + if len > self.remaining() { + return Err(DecodeError::UnexpectedEnd); + } + let slice = &self.buf[self.pos..self.pos + len]; + self.pos += len; + Ok(slice) + } + + fn array(&mut self) -> Result<[u8; N], DecodeError> { + let mut out = [0u8; N]; + out.copy_from_slice(self.raw(N)?); + Ok(out) + } + + pub fn u8(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + + pub fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.array()?)) + } + + pub fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.array()?)) + } + + pub fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.array()?)) + } + + pub fn i32(&mut self) -> Result { + Ok(i32::from_le_bytes(self.array()?)) + } + + pub fn bool(&mut self) -> Result { + match self.u8()? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(DecodeError::InvalidValue("bool")), + } + } + + /// Reads a `u32` length followed by that many bytes. + pub fn bytes(&mut self) -> Result<&'a [u8], DecodeError> { + let len = self.u32()? as usize; + self.raw(len) + } + + /// Reads a UTF-8 string with a `u32` length in front. + pub fn string(&mut self) -> Result { + let bytes = self.bytes()?; + std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|_| DecodeError::InvalidValue("utf-8 string")) + } + + /// Succeeds only if every byte has been read. + pub fn finish(self) -> Result<(), DecodeError> { + if self.remaining() == 0 { + Ok(()) + } else { + Err(DecodeError::TrailingBytes) + } + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index de69b99..127e8a1 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -5,3 +5,5 @@ //! only with whole numbers. Given the same commands it produces the same //! world on every machine. That is what lets every player simulate the whole //! world and exchange nothing but commands. + +pub mod codec; diff --git a/core/tests/codec.rs b/core/tests/codec.rs new file mode 100644 index 0000000..6fba8b2 --- /dev/null +++ b/core/tests/codec.rs @@ -0,0 +1,78 @@ +use grovewright_core::codec::{DecodeError, Reader, Writer}; + +#[test] +fn every_kind_of_value_survives_a_round_trip() { + let mut w = Writer::new(); + w.u8(0xAB); + w.u16(0xBEEF); + w.u32(0xDEAD_BEEF); + w.u64(0x0123_4567_89AB_CDEF); + w.i32(-42); + w.bool(true); + w.bool(false); + w.bytes(&[1, 2, 3]); + w.str("Grüner Hain"); + w.raw(&[9, 9]); + let bytes = w.into_bytes(); + + let mut r = Reader::new(&bytes); + assert_eq!(r.u8(), Ok(0xAB)); + assert_eq!(r.u16(), Ok(0xBEEF)); + assert_eq!(r.u32(), Ok(0xDEAD_BEEF)); + assert_eq!(r.u64(), Ok(0x0123_4567_89AB_CDEF)); + assert_eq!(r.i32(), Ok(-42)); + assert_eq!(r.bool(), Ok(true)); + assert_eq!(r.bool(), Ok(false)); + assert_eq!(r.bytes(), Ok(&[1u8, 2, 3][..])); + assert_eq!(r.string(), Ok("Grüner Hain".to_owned())); + assert_eq!(r.raw(2), Ok(&[9u8, 9][..])); + assert_eq!(r.finish(), Ok(())); +} + +#[test] +fn integers_are_little_endian() { + let mut w = Writer::new(); + w.u32(0x0102_0304); + assert_eq!(w.into_bytes(), vec![4, 3, 2, 1]); +} + +#[test] +fn reading_past_the_end_fails() { + let mut r = Reader::new(&[1, 2]); + assert_eq!(r.u32(), Err(DecodeError::UnexpectedEnd)); +} + +#[test] +fn an_oversized_length_fails_without_panicking() { + let mut w = Writer::new(); + w.u32(u32::MAX); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(r.bytes(), Err(DecodeError::UnexpectedEnd)); +} + +#[test] +fn invalid_bool_and_invalid_utf8_are_rejected() { + let mut r = Reader::new(&[2]); + assert_eq!(r.bool(), Err(DecodeError::InvalidValue("bool"))); + + let mut w = Writer::new(); + w.bytes(&[0xFF, 0xFE]); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(r.string(), Err(DecodeError::InvalidValue("utf-8 string"))); +} + +#[test] +fn finish_rejects_bytes_that_were_not_read() { + let r = Reader::new(&[0]); + assert_eq!(r.finish(), Err(DecodeError::TrailingBytes)); +} + +#[test] +fn remaining_counts_down_as_values_are_read() { + let mut r = Reader::new(&[1, 2, 3]); + assert_eq!(r.remaining(), 3); + r.u8().unwrap(); + assert_eq!(r.remaining(), 2); +}