// 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: u32, /// 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: u32) -> 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) -> u32 { 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) -> u32 { 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: u32) { 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.base_worldgen_version.to_le_bytes()); out.extend_from_slice(&self.region_tile_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()); } // The stamp value is widened to u32 to match ChunkData's u32 worldgen version and avoid // truncation; the spec's u16 stamp field (Save format.md) is treated as an oversight. 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 base_worldgen_version = u32::from_le_bytes(reader.take_array()?); let region_tile_version = u32::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, u32::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 preserves_worldgen_versions_above_u16_max() -> Result<(), SaveError> { // Regression guard: base and stamp worldgen versions are u32, so a value that would not // fit a u16 must survive encode -> decode without truncation. let mut index = RegionIndex::new(70_000); index.set_worldgen_version(ChunkPos::new(0, 0, 0), 100_000); let decoded = RegionIndex::decode(&index.encode()?)?; assert_eq!(decoded.base_worldgen_version(), 70_000); assert_eq!(decoded.worldgen_version(ChunkPos::new(0, 0, 0)), 100_000); 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(()) } }