// 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::cursor::Reader; 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, 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)) } #[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(()) } }