diff --git a/crates/shared/src/save/cursor.rs b/crates/shared/src/save/cursor.rs new file mode 100644 index 0000000..9678b56 --- /dev/null +++ b/crates/shared/src/save/cursor.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! A bounds-checked, forward-only cursor over untrusted save-format bytes. + +use super::error::SaveError; + +/// A forward-only reader over a byte slice that bounds-checks every read. +pub(crate) struct Reader<'a> { + /// The full buffer being read. + bytes: &'a [u8], + /// The offset of the next unread byte. + offset: usize, +} + +impl<'a> Reader<'a> { + pub(crate) fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + /// Returns the next `n` bytes and advances the cursor, or [`SaveError::Truncated`] if fewer remain. + pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], SaveError> { + let end = self.offset.checked_add(n).ok_or(SaveError::Truncated { + offset: self.offset, + needed: n, + available: self.bytes.len().saturating_sub(self.offset), + })?; + let slice = self.bytes.get(self.offset..end).ok_or(SaveError::Truncated { + offset: self.offset, + needed: n, + available: self.bytes.len().saturating_sub(self.offset), + })?; + self.offset = end; + Ok(slice) + } + + /// Returns the next `N` bytes as a fixed-size array and advances the cursor. + pub(crate) fn take_array(&mut self) -> Result<[u8; N], SaveError> { + let mut array = [0u8; N]; + array.copy_from_slice(self.take(N)?); + Ok(array) + } +} diff --git a/crates/shared/src/save/error.rs b/crates/shared/src/save/error.rs index 481f016..92522a2 100644 --- a/crates/shared/src/save/error.rs +++ b/crates/shared/src/save/error.rs @@ -43,6 +43,15 @@ pub enum SaveError { len: usize, }, + /// The record or region declared a format version this build does not support. + #[error("unsupported format version {found}, expected {expected}")] + UnsupportedVersion { + /// The format version this build writes and can read. + expected: u32, + /// The format version actually found in the header. + found: u32, + }, + /// The payload could not be (de)serialized by `postcard`. #[error("payload serialization failed")] Serialization(#[from] postcard::Error), diff --git a/crates/shared/src/save/mod.rs b/crates/shared/src/save/mod.rs index b3db647..f7304eb 100644 --- a/crates/shared/src/save/mod.rs +++ b/crates/shared/src/save/mod.rs @@ -4,7 +4,9 @@ //! //! The format is built bottom-up. The smallest unit is the `SYNC` per-chunk [`record`], which wraps one [`crate::world::ChunkData`] in a self-describing, compressed frame. Region-level framing (the `SYNR` file and its header table) is layered on top of it. All parsing treats on-disk bytes as untrusted and reports failures through [`SaveError`]. +mod cursor; mod error; pub mod record; +pub mod region; pub use error::SaveError; diff --git a/crates/shared/src/save/record.rs b/crates/shared/src/save/record.rs index 7114830..d35cd74 100644 --- a/crates/shared/src/save/record.rs +++ b/crates/shared/src/save/record.rs @@ -4,6 +4,7 @@ //! //! A record is a fixed-size header followed by a zstd-compressed, postcard-serialized [`ChunkData`] payload. All multi-byte integers are little-endian. The framing itself is never compressed, so a repair tool can read the header without decompressing. +use super::cursor::Reader; use super::error::SaveError; use crate::world::ChunkData; @@ -95,47 +96,6 @@ pub fn decode(bytes: &[u8]) -> Result<(RecordMeta, ChunkData), SaveError> { Ok((meta, data)) } -/// A forward-only cursor over a byte slice that bounds-checks every read. -struct Reader<'a> { - /// The full buffer being read. - bytes: &'a [u8], - /// The offset of the next unread byte. - offset: usize, -} - -impl<'a> Reader<'a> { - /// Creates a reader positioned at the start of `bytes`. - fn new(bytes: &'a [u8]) -> Self { - Self { bytes, offset: 0 } - } - - /// Returns the next `n` bytes and advances the cursor, or [`SaveError::Truncated`] if fewer remain. - fn take(&mut self, n: usize) -> Result<&'a [u8], SaveError> { - let end = self - .offset - .checked_add(n) - .ok_or(SaveError::Truncated { - offset: self.offset, - needed: n, - available: self.bytes.len().saturating_sub(self.offset), - })?; - let slice = self.bytes.get(self.offset..end).ok_or(SaveError::Truncated { - offset: self.offset, - needed: n, - available: self.bytes.len().saturating_sub(self.offset), - })?; - self.offset = end; - Ok(slice) - } - - /// Returns the next `N` bytes as a fixed-size array and advances the cursor. - fn take_array(&mut self) -> Result<[u8; N], SaveError> { - let mut array = [0u8; N]; - array.copy_from_slice(self.take(N)?); - Ok(array) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/shared/src/save/region.rs b/crates/shared/src/save/region.rs new file mode 100644 index 0000000..9f92afd --- /dev/null +++ b/crates/shared/src/save/region.rs @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! The `SYNR` region index: the header table and side tables that frame a region file. +//! +//! A region file is this fixed header and its three tables, followed by the `SYNC` chunk +//! records the header table points at. This module owns only the index; the record bytes +//! and their placement are managed by the durability layer. All integers are little-endian. + +use std::collections::BTreeMap; + +use super::cursor::Reader; +use super::error::SaveError; +use crate::world::ChunkPos; + +/// The magic tag every region file begins with. +const MAGIC: [u8; 4] = *b"SYNR"; + +/// The current region-file framing version, written on encode and checked on decode. +pub const REGION_FORMAT_VERSION: u32 = 1; + +/// The location and flags of one chunk record within the region file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HeaderEntry { + /// The byte offset of the record from the start of the region file. + pub offset: u64, + /// The length of the record in bytes. + pub length: u32, + /// Record flags; currently always zero. + pub flags: u32, +} + +/// A reclaimable span of free space left by a removed or shrunken record. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FreeSpan { + /// The byte offset of the free span from the start of the region file. + pub offset: u64, + /// The length of the free span in bytes. + pub length: u32, +} + +/// The in-memory index of a region file: where every resident chunk record lives, the free spans between them, and the worldgen-version exceptions. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RegionIndex { + /// Monotonic cache-coherence counter for derived LOD tiles; only ever increases. + region_tile_version: u32, + /// The worldgen version most chunks in the region are pinned to; the stamp table holds only exceptions. + base_worldgen_version: u16, + /// Location of every resident chunk record, keyed by chunk position. + header_table: BTreeMap, + /// Reclaimable holes in the file, in no particular order. + free_list: Vec, + /// Worldgen-version exceptions: chunks pinned to a version other than `base_worldgen_version`. + stamps: BTreeMap, +} + +impl RegionIndex { + /// Creates an empty index whose chunks default to `base_worldgen_version`. + #[must_use] + pub fn new(base_worldgen_version: u16) -> Self { + Self { + region_tile_version: 0, + base_worldgen_version, + header_table: BTreeMap::new(), + free_list: Vec::new(), + stamps: BTreeMap::new(), + } + } + + /// Returns the monotonic LOD-tile version. + #[must_use] + pub fn region_tile_version(&self) -> u32 { + self.region_tile_version + } + + /// Increments the LOD-tile version; called once per save that commits a rebuild-triggering change. + pub fn bump_tile_version(&mut self) { + self.region_tile_version = self.region_tile_version.saturating_add(1); + } + + /// Returns the region's base worldgen version. + #[must_use] + pub fn base_worldgen_version(&self) -> u16 { + self.base_worldgen_version + } + + /// Looks up the record location for `pos`, if the chunk is resident. + #[must_use] + pub fn entry(&self, pos: ChunkPos) -> Option<&HeaderEntry> { + self.header_table.get(&pos) + } + + /// Records the location of the chunk at `pos`, replacing any existing entry. + pub fn insert(&mut self, pos: ChunkPos, entry: HeaderEntry) { + self.header_table.insert(pos, entry); + } + + /// Removes the chunk at `pos` from the header table, returning its former location. + pub fn remove(&mut self, pos: ChunkPos) -> Option { + self.header_table.remove(&pos) + } + + /// Iterates the resident records in ascending position order. + pub fn entries(&self) -> impl Iterator { + self.header_table.iter() + } + + /// Returns the pinned worldgen version for `pos`: its stamp exception, or the region base. + #[must_use] + pub fn worldgen_version(&self, pos: ChunkPos) -> u16 { + self.stamps + .get(&pos) + .copied() + .unwrap_or(self.base_worldgen_version) + } + + /// Pins `pos` to `version`, recording it as a stamp exception only when it differs from the base. + pub fn set_worldgen_version(&mut self, pos: ChunkPos, version: u16) { + if version == self.base_worldgen_version { + self.stamps.remove(&pos); + } else { + self.stamps.insert(pos, version); + } + } + + /// Returns the reclaimable free spans. + #[must_use] + pub fn free_spans(&self) -> &[FreeSpan] { + &self.free_list + } + + /// Adds a reclaimable free span. + pub fn push_free(&mut self, span: FreeSpan) { + self.free_list.push(span); + } + + /// Serializes the index to its on-disk framing bytes. + pub fn encode(&self) -> Result, SaveError> { + let mut out = Vec::new(); + out.extend_from_slice(&MAGIC); + out.extend_from_slice(®ION_FORMAT_VERSION.to_le_bytes()); + out.extend_from_slice(&self.region_tile_version.to_le_bytes()); + out.extend_from_slice(&self.base_worldgen_version.to_le_bytes()); + + out.extend_from_slice(&len_u32(self.header_table.len())?.to_le_bytes()); + for (pos, entry) in &self.header_table { + out.extend_from_slice(&pos.x.to_le_bytes()); + out.extend_from_slice(&pos.y.to_le_bytes()); + out.extend_from_slice(&pos.z.to_le_bytes()); + out.extend_from_slice(&entry.offset.to_le_bytes()); + out.extend_from_slice(&entry.length.to_le_bytes()); + out.extend_from_slice(&entry.flags.to_le_bytes()); + } + + out.extend_from_slice(&len_u32(self.free_list.len())?.to_le_bytes()); + for span in &self.free_list { + out.extend_from_slice(&span.offset.to_le_bytes()); + out.extend_from_slice(&span.length.to_le_bytes()); + } + + out.extend_from_slice(&len_u32(self.stamps.len())?.to_le_bytes()); + for (pos, version) in &self.stamps { + out.extend_from_slice(&pos.x.to_le_bytes()); + out.extend_from_slice(&pos.y.to_le_bytes()); + out.extend_from_slice(&pos.z.to_le_bytes()); + out.extend_from_slice(&version.to_le_bytes()); + } + + Ok(out) + } + + /// Parses a region index from its framing bytes, ignoring any chunk records that follow it. + pub fn decode(bytes: &[u8]) -> Result { + let mut reader = Reader::new(bytes); + + let magic = reader.take_array::<4>()?; + if magic != MAGIC { + return Err(SaveError::BadMagic { + expected: MAGIC, + found: magic, + }); + } + + let format_version = u32::from_le_bytes(reader.take_array()?); + if format_version != REGION_FORMAT_VERSION { + return Err(SaveError::UnsupportedVersion { + expected: REGION_FORMAT_VERSION, + found: format_version, + }); + } + + let region_tile_version = u32::from_le_bytes(reader.take_array()?); + let base_worldgen_version = u16::from_le_bytes(reader.take_array()?); + + let header_len = u32::from_le_bytes(reader.take_array()?); + let mut header_table = BTreeMap::new(); + for _ in 0..header_len { + let pos = read_pos(&mut reader)?; + let entry = HeaderEntry { + offset: u64::from_le_bytes(reader.take_array()?), + length: u32::from_le_bytes(reader.take_array()?), + flags: u32::from_le_bytes(reader.take_array()?), + }; + header_table.insert(pos, entry); + } + + let free_len = u32::from_le_bytes(reader.take_array()?); + let mut free_list = Vec::with_capacity(free_len as usize); + for _ in 0..free_len { + free_list.push(FreeSpan { + offset: u64::from_le_bytes(reader.take_array()?), + length: u32::from_le_bytes(reader.take_array()?), + }); + } + + let stamp_len = u32::from_le_bytes(reader.take_array()?); + let mut stamps = BTreeMap::new(); + for _ in 0..stamp_len { + let pos = read_pos(&mut reader)?; + stamps.insert(pos, u16::from_le_bytes(reader.take_array()?)); + } + + Ok(Self { + region_tile_version, + base_worldgen_version, + header_table, + free_list, + stamps, + }) + } +} + +/// Reads a chunk position as three little-endian `i32`s. +fn read_pos(reader: &mut Reader) -> Result { + let x = i32::from_le_bytes(reader.take_array()?); + let y = i32::from_le_bytes(reader.take_array()?); + let z = i32::from_le_bytes(reader.take_array()?); + Ok(ChunkPos::new(x, y, z)) +} + +/// Narrows a table length to the `u32` the framing uses, failing loudly rather than truncating. +fn len_u32(len: usize) -> Result { + u32::try_from(len).map_err(|_| SaveError::PayloadTooLarge { len }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a populated index with entries at varied positions, a free span, and a stamp exception. + fn sample() -> RegionIndex { + let mut index = RegionIndex::new(3); + index.insert( + ChunkPos::new(0, 0, 0), + HeaderEntry { + offset: 4096, + length: 128, + flags: 0, + }, + ); + index.insert( + ChunkPos::new(-5, 12, -30), + HeaderEntry { + offset: 8192, + length: 256, + flags: 0, + }, + ); + index.push_free(FreeSpan { + offset: 512, + length: 64, + }); + index.set_worldgen_version(ChunkPos::new(0, 0, 0), 2); + index.bump_tile_version(); + index + } + + #[test] + fn round_trips_index() -> Result<(), SaveError> { + let index = sample(); + let decoded = RegionIndex::decode(&index.encode()?)?; + assert_eq!(decoded, index); + Ok(()) + } + + #[test] + fn stamp_equal_to_base_is_not_recorded() { + let mut index = RegionIndex::new(7); + // A stamp equal to the base is redundant, so no exception entry is stored. + index.set_worldgen_version(ChunkPos::new(1, 1, 1), 7); + assert_eq!(index.worldgen_version(ChunkPos::new(1, 1, 1)), 7); + assert!(index.stamps.is_empty()); + } + + #[test] + fn rejects_bad_magic() -> Result<(), SaveError> { + let mut bytes = sample().encode()?; + bytes[0] = b'X'; + assert!(matches!( + RegionIndex::decode(&bytes), + Err(SaveError::BadMagic { .. }) + )); + Ok(()) + } + + #[test] + fn rejects_unsupported_version() -> Result<(), SaveError> { + let mut bytes = sample().encode()?; + // The format_version u32 sits just after the 4 magic bytes. + bytes[4..8].copy_from_slice(&999u32.to_le_bytes()); + assert!(matches!( + RegionIndex::decode(&bytes), + Err(SaveError::UnsupportedVersion { .. }) + )); + Ok(()) + } + + #[test] + fn rejects_truncated_table() -> Result<(), SaveError> { + let bytes = sample().encode()?; + // Cut the buffer mid-header-table so an entry read runs off the end. + assert!(matches!( + RegionIndex::decode(&bytes[..20]), + Err(SaveError::Truncated { .. }) + )); + Ok(()) + } +} diff --git a/crates/shared/src/world/coords.rs b/crates/shared/src/world/coords.rs index afbeb87..e526da7 100644 --- a/crates/shared/src/world/coords.rs +++ b/crates/shared/src/world/coords.rs @@ -6,7 +6,7 @@ use super::CHUNK_SIZE; use serde::{Deserialize, Serialize}; /// The three-dimensional spatial coordinates of a chunk in the world. -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ChunkPos { /// The X coordinate of the chunk. pub x: i32,