synvael/crates/shared/src/save/record.rs

111 lines
4.6 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::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).
///
/// # Errors
///
/// Returns [`SaveError::Postcard`] if serialization fails, [`SaveError::Io`] if zstd compression fails, or [`SaveError::PayloadTooLarge`] if either the uncompressed or compressed length exceeds `u32::MAX`.
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.
///
/// # Errors
///
/// Returns [`SaveError::Truncated`] if the buffer ends mid-field, [`SaveError::BadMagic`] if the leading tag is not `SYNC`, [`SaveError::Io`] if zstd decompression fails, [`SaveError::LengthMismatch`] if the decompressed length disagrees with the header, or [`SaveError::Postcard`] if the payload fails to deserialize.
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)]
#[path = "../tests/record.rs"]
mod tests;