Synvael/crates/shared/src/save/region.rs

266 lines
9.7 KiB
Rust

// 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<ChunkPos, HeaderEntry>,
/// Reclaimable holes in the file, in no particular order.
free_list: Vec<FreeSpan>,
/// Worldgen-version exceptions: chunks pinned to a version other than `base_worldgen_version`.
stamps: BTreeMap<ChunkPos, u32>,
}
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<HeaderEntry> {
self.header_table.remove(&pos)
}
/// Iterates the resident records in ascending position order.
pub fn entries(&self) -> impl Iterator<Item = (&ChunkPos, &HeaderEntry)> {
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.
///
/// # Errors
///
/// Returns [`SaveError::PayloadTooLarge`] if the header, free-list, or stamp table holds more than `u32::MAX` entries.
pub fn encode(&self) -> Result<Vec<u8>, SaveError> {
let mut out = Vec::new();
out.extend_from_slice(&MAGIC);
out.extend_from_slice(&REGION_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.
///
/// # Errors
///
/// Returns [`SaveError::Truncated`] if the buffer ends mid-field, [`SaveError::BadMagic`] if the leading tag is not the region magic, or [`SaveError::UnsupportedVersion`] if the format version is not recognised.
pub fn decode(bytes: &[u8]) -> Result<Self, SaveError> {
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.
///
/// # Errors
///
/// Returns [`SaveError::Truncated`] if fewer than twelve bytes remain.
fn read_pos(reader: &mut Reader) -> Result<ChunkPos, SaveError> {
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.
///
/// # Errors
///
/// Returns [`SaveError::PayloadTooLarge`] if `len` exceeds `u32::MAX`.
fn len_u32(len: usize) -> Result<u32, SaveError> {
u32::try_from(len).map_err(|_| SaveError::PayloadTooLarge { len })
}
#[cfg(test)]
#[path = "../tests/region.rs"]
mod tests;