refactor(server): move region files to 3D (rx, ry, rz) grid

This commit is contained in:
Serkyo 2026-07-21 22:13:58 +02:00
parent b3f10d8f12
commit 25b088ce0b
4 changed files with 38 additions and 32 deletions

View file

@ -73,7 +73,7 @@ impl SaveActor {
/// The actor's run loop: it owns the region-file map and answers requests until every sender is dropped. /// The actor's run loop: it owns the region-file map and answers requests until every sender is dropped.
fn actor_loop(region_dir: &Path, request_rx: &Receiver<SaveRequest>) { fn actor_loop(region_dir: &Path, request_rx: &Receiver<SaveRequest>) {
// The actor is the sole owner of this map, so region files need no lock of their own. // The actor is the sole owner of this map, so region files need no lock of their own.
let mut regions: HashMap<(i32, i32), RegionFile> = HashMap::new(); let mut regions: HashMap<(i32, i32, i32), RegionFile> = HashMap::new();
while let Ok(request) = request_rx.recv() { while let Ok(request) = request_rx.recv() {
match request { match request {
SaveRequest::Read { pos, reply } => { SaveRequest::Read { pos, reply } => {
@ -112,7 +112,7 @@ fn actor_loop(region_dir: &Path, request_rx: &Receiver<SaveRequest>) {
/// # Errors /// # Errors
/// ///
/// Returns the first [`SaveError`] produced by [`RegionFile::save`]; remaining dirty regions are still flushed. /// Returns the first [`SaveError`] produced by [`RegionFile::save`]; remaining dirty regions are still flushed.
fn flush_dirty(regions: &mut HashMap<(i32, i32), RegionFile>) -> Result<(), SaveError> { fn flush_dirty(regions: &mut HashMap<(i32, i32, i32), RegionFile>) -> Result<(), SaveError> {
let mut result = Ok(()); let mut result = Ok(());
for region in regions.values_mut() { for region in regions.values_mut() {
// Clean regions are skipped so a flush never rewrites an unchanged file. // Clean regions are skipped so a flush never rewrites an unchanged file.
@ -136,17 +136,17 @@ fn flush_dirty(regions: &mut HashMap<(i32, i32), RegionFile>) -> Result<(), Save
/// ///
/// Returns a [`SaveError`] from [`RegionFile::open`] if the region file exists but cannot be read or decoded. /// Returns a [`SaveError`] from [`RegionFile::open`] if the region file exists but cannot be read or decoded.
fn region_mut<'a>( fn region_mut<'a>(
regions: &'a mut HashMap<(i32, i32), RegionFile>, regions: &'a mut HashMap<(i32, i32, i32), RegionFile>,
region_dir: &Path, region_dir: &Path,
pos: ChunkPos, pos: ChunkPos,
) -> Result<&'a mut RegionFile, SaveError> { ) -> Result<&'a mut RegionFile, SaveError> {
let key = region_coords(pos.x, pos.z); let key = region_coords(pos.x, pos.y, pos.z);
// The region file is opened once on first touch; every later access hits the in-memory copy. // The region file is opened once on first touch; every later access hits the in-memory copy.
match regions.entry(key) { match regions.entry(key) {
Entry::Occupied(entry) => Ok(entry.into_mut()), Entry::Occupied(entry) => Ok(entry.into_mut()),
Entry::Vacant(entry) => { Entry::Vacant(entry) => Ok(entry.insert(RegionFile::open(region_path(
Ok(entry.insert(RegionFile::open(region_path(region_dir, pos.x, pos.z))?)) region_dir, pos.x, pos.y, pos.z,
} ))?)),
} }
} }
@ -156,7 +156,7 @@ fn region_mut<'a>(
/// ///
/// Returns a [`SaveError`] if the region file cannot be opened or the stored record cannot be decoded. /// Returns a [`SaveError`] if the region file cannot be opened or the stored record cannot be decoded.
fn read_chunk( fn read_chunk(
regions: &mut HashMap<(i32, i32), RegionFile>, regions: &mut HashMap<(i32, i32, i32), RegionFile>,
region_dir: &Path, region_dir: &Path,
pos: ChunkPos, pos: ChunkPos,
) -> Result<Option<ChunkData>, SaveError> { ) -> Result<Option<ChunkData>, SaveError> {

View file

@ -13,20 +13,26 @@ use shared::save::record;
use shared::save::region::{HeaderEntry, RegionIndex}; use shared::save::region::{HeaderEntry, RegionIndex};
use shared::world::{ChunkData, ChunkPos}; use shared::world::{ChunkData, ChunkPos};
/// The side length, in chunk columns, of the square footprint one region file covers. /// The side length, in chunks, of the cube one region file covers on every axis.
pub const REGION_SIZE: i32 = 32; pub const REGION_SIZE: i32 = 32;
/// Maps a chunk column `(cx, cz)` to the coordinates `(rx, rz)` of the region that contains it. /// Maps a chunk `(cx, cy, cz)` to the coordinates `(rx, ry, rz)` of the region cube that contains it.
///
/// Every axis is floored via `div_euclid` (not truncating division) so negative chunk coordinates map to the region below rather than toward zero: chunk `-1` belongs to region `-1`, not region `0`.
#[must_use] #[must_use]
pub fn region_coords(cx: i32, cz: i32) -> (i32, i32) { pub fn region_coords(cx: i32, cy: i32, cz: i32) -> (i32, i32, i32) {
(cx.div_euclid(REGION_SIZE), cz.div_euclid(REGION_SIZE)) (
cx.div_euclid(REGION_SIZE),
cy.div_euclid(REGION_SIZE),
cz.div_euclid(REGION_SIZE),
)
} }
/// Builds the on-disk path of the region file containing chunk column `(cx, cz)` within `dir`. /// Builds the on-disk path of the region file containing chunk `(cx, cy, cz)` within `dir`.
#[must_use] #[must_use]
pub fn region_path(dir: &Path, cx: i32, cz: i32) -> PathBuf { pub fn region_path(dir: &Path, cx: i32, cy: i32, cz: i32) -> PathBuf {
let (rx, rz) = region_coords(cx, cz); let (rx, ry, rz) = region_coords(cx, cy, cz);
dir.join(format!("r.{rx}.{rz}.region")) dir.join(format!("r.{rx}.{ry}.{rz}.region"))
} }
/// An open region file: its `SYNR` index, the resident chunk records, and its on-disk location. /// An open region file: its `SYNR` index, the resident chunk records, and its on-disk location.

View file

@ -13,27 +13,27 @@ fn sample(pos: ChunkPos) -> ChunkData {
} }
#[test] #[test]
fn region_coords_floor_negative_columns() { fn region_coords_floor_negative_chunks() {
// Truncating division would map -1 to region 0; Euclidean flooring maps it to region -1. // Truncating division would map -1 to region 0; Euclidean flooring maps it to region -1. Every axis, including Y, floors identically under the 3D region grid.
assert_eq!(region_coords(0, 0), (0, 0)); assert_eq!(region_coords(0, 0, 0), (0, 0, 0));
assert_eq!(region_coords(31, 31), (0, 0)); assert_eq!(region_coords(31, 31, 31), (0, 0, 0));
assert_eq!(region_coords(-1, -1), (-1, -1)); assert_eq!(region_coords(-1, -1, -1), (-1, -1, -1));
assert_eq!(region_coords(-32, -33), (-1, -2)); assert_eq!(region_coords(-32, -33, 64), (-1, -2, 2));
} }
#[test] #[test]
fn region_path_names_the_region_file() { fn region_path_names_the_region_file() {
let dir = Path::new("/saves/world/region"); let dir = Path::new("/saves/world/region");
assert_eq!( assert_eq!(
region_path(dir, -1, 5), region_path(dir, -1, 40, 5),
Path::new("/saves/world/region/r.-1.0.region") Path::new("/saves/world/region/r.-1.1.0.region")
); );
} }
#[test] #[test]
fn open_missing_file_is_empty() -> Result<(), SaveError> { fn open_missing_file_is_empty() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?; let dir = tempfile::tempdir()?;
let region = RegionFile::open(dir.path().join("r.0.0.region"))?; let region = RegionFile::open(dir.path().join("r.0.0.0.region"))?;
assert!(region.is_empty()); assert!(region.is_empty());
assert_eq!(region.read_chunk(ChunkPos::new(0, 0, 0))?, None); assert_eq!(region.read_chunk(ChunkPos::new(0, 0, 0))?, None);
Ok(()) Ok(())
@ -42,7 +42,7 @@ fn open_missing_file_is_empty() -> Result<(), SaveError> {
#[test] #[test]
fn round_trips_chunks_through_disk() -> Result<(), SaveError> { fn round_trips_chunks_through_disk() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?; let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region"); let path = dir.path().join("r.0.0.0.region");
let positions = [ let positions = [
ChunkPos::new(0, 0, 0), ChunkPos::new(0, 0, 0),
@ -72,7 +72,7 @@ fn round_trips_chunks_through_disk() -> Result<(), SaveError> {
#[test] #[test]
fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> { fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?; let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region"); let path = dir.path().join("r.0.0.0.region");
let mut region = RegionFile::open(path.clone())?; let mut region = RegionFile::open(path.clone())?;
for pos in [ for pos in [
@ -99,7 +99,7 @@ fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> {
#[test] #[test]
fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> { fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?; let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region"); let path = dir.path().join("r.0.0.0.region");
let kept = ChunkPos::new(0, 0, 0); let kept = ChunkPos::new(0, 0, 0);
let dropped = ChunkPos::new(1, 1, 1); let dropped = ChunkPos::new(1, 1, 1);
@ -121,7 +121,7 @@ fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> {
#[test] #[test]
fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> { fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?; let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region"); let path = dir.path().join("r.0.0.0.region");
let pos = ChunkPos::new(0, 0, 0); let pos = ChunkPos::new(0, 0, 0);
let mut region = RegionFile::open(path.clone())?; let mut region = RegionFile::open(path.clone())?;
@ -129,7 +129,7 @@ fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> {
region.save()?; region.save()?;
// A leftover .tmp from an interrupted save must be ignored: only the renamed target is read. // A leftover .tmp from an interrupted save must be ignored: only the renamed target is read.
fs::write(dir.path().join("r.0.0.region.tmp"), b"garbage")?; fs::write(dir.path().join("r.0.0.0.region.tmp"), b"garbage")?;
let reopened = RegionFile::open(path)?; let reopened = RegionFile::open(path)?;
assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos))); assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos)));
Ok(()) Ok(())

View file

@ -108,7 +108,7 @@ fn saved_modification_is_applied_over_baseline() -> Result<(), SaveError> {
let mut data = ChunkData::new(pos, 0); let mut data = ChunkData::new(pos, 0);
data.set(edited_index, edited_block); data.set(edited_index, edited_block);
let mut region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?; let mut region = RegionFile::open(region_path(dir.path(), pos.x, pos.y, pos.z))?;
region.write_chunk(pos, &data, 0)?; region.write_chunk(pos, &data, 0)?;
region.save()?; region.save()?;
@ -180,7 +180,7 @@ fn clean_chunk_is_not_written_back_on_eviction() -> Result<(), SaveError> {
flush(&world)?; flush(&world)?;
// No record may exist for a chunk that never diverged from its baseline. // No record may exist for a chunk that never diverged from its baseline.
let region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?; let region = RegionFile::open(region_path(dir.path(), pos.x, pos.y, pos.z))?;
assert!(region.read_chunk(pos)?.is_none()); assert!(region.read_chunk(pos)?.is_none());
Ok(()) Ok(())
} }