210 lines
7.5 KiB
Rust
210 lines
7.5 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
//! The `SYNC` per-chunk record: the on-disk framing around one [`ChunkData`].
|
|
//!
|
|
//! 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::error::SaveError;
|
|
use crate::world::ChunkData;
|
|
|
|
/// The magic tag every chunk record begins with.
|
|
const MAGIC: [u8; 4] = *b"SYNC";
|
|
|
|
/// The current schema version of the `ChunkData` payload, written into every new record.
|
|
pub const CHUNK_FORMAT_VERSION: u16 = 1;
|
|
|
|
/// The zstd compression level used for chunk payloads: level 3 favours speed, per the save-format design.
|
|
const ZSTD_LEVEL: i32 = 3;
|
|
|
|
/// The size in bytes of the fixed record header: magic + version + flags + timestamp + two length fields.
|
|
const HEADER_LEN: usize = 4 + 2 + 2 + 8 + 4 + 4;
|
|
|
|
/// The non-payload header fields of a decoded record.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct RecordMeta {
|
|
/// The schema version the payload was written under, used to drive per-chunk migration on load.
|
|
pub chunk_format_version: u16,
|
|
/// Reserved record flags; currently always zero.
|
|
pub flags: u16,
|
|
/// The wall-clock time the chunk was last modified, in milliseconds since the Unix epoch.
|
|
pub last_modified: u64,
|
|
}
|
|
|
|
/// Encodes `data` into a `SYNC` record, stamping it with `last_modified` (unix-ms).
|
|
pub fn encode(data: &ChunkData, last_modified: u64) -> Result<Vec<u8>, SaveError> {
|
|
let uncompressed = postcard::to_stdvec(data)?;
|
|
let compressed = zstd::encode_all(uncompressed.as_slice(), ZSTD_LEVEL)?;
|
|
|
|
// Checked conversion so an oversized payload fails loudly instead of truncating.
|
|
let uncompressed_len =
|
|
u32::try_from(uncompressed.len()).map_err(|_| SaveError::PayloadTooLarge {
|
|
len: uncompressed.len(),
|
|
})?;
|
|
let compressed_len = u32::try_from(compressed.len()).map_err(|_| SaveError::PayloadTooLarge {
|
|
len: compressed.len(),
|
|
})?;
|
|
|
|
let mut out = Vec::with_capacity(HEADER_LEN + compressed.len());
|
|
out.extend_from_slice(&MAGIC);
|
|
out.extend_from_slice(&CHUNK_FORMAT_VERSION.to_le_bytes());
|
|
out.extend_from_slice(&0u16.to_le_bytes());
|
|
out.extend_from_slice(&last_modified.to_le_bytes());
|
|
out.extend_from_slice(&uncompressed_len.to_le_bytes());
|
|
out.extend_from_slice(&compressed_len.to_le_bytes());
|
|
out.extend_from_slice(&compressed);
|
|
Ok(out)
|
|
}
|
|
|
|
/// Decodes a `SYNC` record, returning its header metadata and the reconstructed [`ChunkData`].
|
|
///
|
|
/// `bytes` is untrusted on-disk input, so every field is bounds-checked and the decompressed
|
|
/// payload length is validated against the header before deserialization is attempted.
|
|
pub fn decode(bytes: &[u8]) -> Result<(RecordMeta, ChunkData), 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 chunk_format_version = u16::from_le_bytes(reader.take_array()?);
|
|
let flags = u16::from_le_bytes(reader.take_array()?);
|
|
let last_modified = u64::from_le_bytes(reader.take_array()?);
|
|
// Widening u32 -> usize is lossless on every supported (64-bit) target.
|
|
let uncompressed_len = u32::from_le_bytes(reader.take_array()?) as usize;
|
|
let compressed_len = u32::from_le_bytes(reader.take_array()?) as usize;
|
|
|
|
let payload = reader.take(compressed_len)?;
|
|
let decompressed = zstd::decode_all(payload)?;
|
|
if decompressed.len() != uncompressed_len {
|
|
return Err(SaveError::LengthMismatch {
|
|
expected: uncompressed_len,
|
|
actual: decompressed.len(),
|
|
});
|
|
}
|
|
|
|
let data: ChunkData = postcard::from_bytes(&decompressed)?;
|
|
let meta = RecordMeta {
|
|
chunk_format_version,
|
|
flags,
|
|
last_modified,
|
|
};
|
|
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<const N: usize>(&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::*;
|
|
use crate::world::{BlockId, ChunkPos};
|
|
|
|
/// Builds a representative modified chunk with a few edits spanning the local index range.
|
|
fn sample() -> ChunkData {
|
|
let mut data = ChunkData::new(ChunkPos::new(1, -2, 3), 7);
|
|
data.set(0, BlockId(4));
|
|
data.set(1000, BlockId(9));
|
|
data.set(32_767, BlockId(2));
|
|
data
|
|
}
|
|
|
|
#[test]
|
|
fn round_trips_payload_and_metadata() -> Result<(), SaveError> {
|
|
let data = sample();
|
|
let bytes = encode(&data, 123_456)?;
|
|
let (meta, decoded) = decode(&bytes)?;
|
|
|
|
assert_eq!(decoded, data);
|
|
assert_eq!(meta.chunk_format_version, CHUNK_FORMAT_VERSION);
|
|
assert_eq!(meta.flags, 0);
|
|
assert_eq!(meta.last_modified, 123_456);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_bad_magic() -> Result<(), SaveError> {
|
|
let mut bytes = encode(&sample(), 0)?;
|
|
bytes[0] = b'X';
|
|
assert!(matches!(decode(&bytes), Err(SaveError::BadMagic { .. })));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_truncated_header() -> Result<(), SaveError> {
|
|
let bytes = encode(&sample(), 0)?;
|
|
// A buffer shorter than the fixed header cannot yield a full record.
|
|
assert!(matches!(
|
|
decode(&bytes[..HEADER_LEN - 1]),
|
|
Err(SaveError::Truncated { .. })
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_truncated_payload() -> Result<(), SaveError> {
|
|
let bytes = encode(&sample(), 0)?;
|
|
// Keep the whole header but cut the compressed payload short.
|
|
assert!(matches!(
|
|
decode(&bytes[..=HEADER_LEN]),
|
|
Err(SaveError::Truncated { .. })
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn detects_declared_length_mismatch() -> Result<(), SaveError> {
|
|
let mut bytes = encode(&sample(), 0)?;
|
|
// The uncompressed-length field is the u32 at offset 16 (after magic, version,
|
|
// flags, and the timestamp). Overwriting it with a value the payload cannot
|
|
// decompress to must be caught by the post-decompression length check.
|
|
bytes[16..20].copy_from_slice(&1u32.to_le_bytes());
|
|
assert!(matches!(
|
|
decode(&bytes),
|
|
Err(SaveError::LengthMismatch { .. })
|
|
));
|
|
Ok(())
|
|
}
|
|
}
|