feat(shared): add SYNR region index framing
This commit is contained in:
parent
b676755633
commit
de6f7dd722
42
crates/shared/src/save/cursor.rs
Normal file
42
crates/shared/src/save/cursor.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
//! A bounds-checked, forward-only cursor over untrusted save-format bytes.
|
||||||
|
|
||||||
|
use super::error::SaveError;
|
||||||
|
|
||||||
|
/// A forward-only reader over a byte slice that bounds-checks every read.
|
||||||
|
pub(crate) struct Reader<'a> {
|
||||||
|
/// The full buffer being read.
|
||||||
|
bytes: &'a [u8],
|
||||||
|
/// The offset of the next unread byte.
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Reader<'a> {
|
||||||
|
pub(crate) 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.
|
||||||
|
pub(crate) 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.
|
||||||
|
pub(crate) 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,15 @@ pub enum SaveError {
|
||||||
len: usize,
|
len: usize,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// The record or region declared a format version this build does not support.
|
||||||
|
#[error("unsupported format version {found}, expected {expected}")]
|
||||||
|
UnsupportedVersion {
|
||||||
|
/// The format version this build writes and can read.
|
||||||
|
expected: u32,
|
||||||
|
/// The format version actually found in the header.
|
||||||
|
found: u32,
|
||||||
|
},
|
||||||
|
|
||||||
/// The payload could not be (de)serialized by `postcard`.
|
/// The payload could not be (de)serialized by `postcard`.
|
||||||
#[error("payload serialization failed")]
|
#[error("payload serialization failed")]
|
||||||
Serialization(#[from] postcard::Error),
|
Serialization(#[from] postcard::Error),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@
|
||||||
//!
|
//!
|
||||||
//! The format is built bottom-up. The smallest unit is the `SYNC` per-chunk [`record`], which wraps one [`crate::world::ChunkData`] in a self-describing, compressed frame. Region-level framing (the `SYNR` file and its header table) is layered on top of it. All parsing treats on-disk bytes as untrusted and reports failures through [`SaveError`].
|
//! The format is built bottom-up. The smallest unit is the `SYNC` per-chunk [`record`], which wraps one [`crate::world::ChunkData`] in a self-describing, compressed frame. Region-level framing (the `SYNR` file and its header table) is layered on top of it. All parsing treats on-disk bytes as untrusted and reports failures through [`SaveError`].
|
||||||
|
|
||||||
|
mod cursor;
|
||||||
mod error;
|
mod error;
|
||||||
pub mod record;
|
pub mod record;
|
||||||
|
pub mod region;
|
||||||
|
|
||||||
pub use error::SaveError;
|
pub use error::SaveError;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
//!
|
//!
|
||||||
//! 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.
|
//! 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 super::error::SaveError;
|
||||||
use crate::world::ChunkData;
|
use crate::world::ChunkData;
|
||||||
|
|
||||||
|
|
@ -95,47 +96,6 @@ pub fn decode(bytes: &[u8]) -> Result<(RecordMeta, ChunkData), SaveError> {
|
||||||
Ok((meta, data))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
327
crates/shared/src/save/region.rs
Normal file
327
crates/shared/src/save/region.rs
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
// 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: u16,
|
||||||
|
/// 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, u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegionIndex {
|
||||||
|
/// Creates an empty index whose chunks default to `base_worldgen_version`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(base_worldgen_version: u16) -> 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) -> u16 {
|
||||||
|
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) -> u16 {
|
||||||
|
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: u16) {
|
||||||
|
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<Vec<u8>, 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.region_tile_version.to_le_bytes());
|
||||||
|
out.extend_from_slice(&self.base_worldgen_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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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 region_tile_version = u32::from_le_bytes(reader.take_array()?);
|
||||||
|
let base_worldgen_version = u16::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, u16::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<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.
|
||||||
|
fn len_u32(len: usize) -> Result<u32, SaveError> {
|
||||||
|
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 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ use super::CHUNK_SIZE;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// The three-dimensional spatial coordinates of a chunk in the world.
|
/// The three-dimensional spatial coordinates of a chunk in the world.
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
pub struct ChunkPos {
|
pub struct ChunkPos {
|
||||||
/// The X coordinate of the chunk.
|
/// The X coordinate of the chunk.
|
||||||
pub x: i32,
|
pub x: i32,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue