diff --git a/crates/server/src/save/region_actor.rs b/crates/server/src/save/region_actor.rs index 0a96da7..0799234 100644 --- a/crates/server/src/save/region_actor.rs +++ b/crates/server/src/save/region_actor.rs @@ -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. fn actor_loop(region_dir: &Path, request_rx: &Receiver) { // 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() { match request { SaveRequest::Read { pos, reply } => { @@ -112,7 +112,7 @@ fn actor_loop(region_dir: &Path, request_rx: &Receiver) { /// # Errors /// /// 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(()); for region in regions.values_mut() { // 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. fn region_mut<'a>( - regions: &'a mut HashMap<(i32, i32), RegionFile>, + regions: &'a mut HashMap<(i32, i32, i32), RegionFile>, region_dir: &Path, pos: ChunkPos, ) -> 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. match regions.entry(key) { Entry::Occupied(entry) => Ok(entry.into_mut()), - Entry::Vacant(entry) => { - Ok(entry.insert(RegionFile::open(region_path(region_dir, pos.x, pos.z))?)) - } + Entry::Vacant(entry) => Ok(entry.insert(RegionFile::open(region_path( + 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. fn read_chunk( - regions: &mut HashMap<(i32, i32), RegionFile>, + regions: &mut HashMap<(i32, i32, i32), RegionFile>, region_dir: &Path, pos: ChunkPos, ) -> Result, SaveError> { diff --git a/crates/server/src/save/region_file.rs b/crates/server/src/save/region_file.rs index 49ab57d..e8622bb 100644 --- a/crates/server/src/save/region_file.rs +++ b/crates/server/src/save/region_file.rs @@ -13,20 +13,26 @@ use shared::save::record; use shared::save::region::{HeaderEntry, RegionIndex}; 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; -/// 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] -pub fn region_coords(cx: i32, cz: i32) -> (i32, i32) { - (cx.div_euclid(REGION_SIZE), cz.div_euclid(REGION_SIZE)) +pub fn region_coords(cx: i32, cy: i32, cz: i32) -> (i32, i32, i32) { + ( + 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] -pub fn region_path(dir: &Path, cx: i32, cz: i32) -> PathBuf { - let (rx, rz) = region_coords(cx, cz); - dir.join(format!("r.{rx}.{rz}.region")) +pub fn region_path(dir: &Path, cx: i32, cy: i32, cz: i32) -> PathBuf { + let (rx, ry, rz) = region_coords(cx, cy, cz); + dir.join(format!("r.{rx}.{ry}.{rz}.region")) } /// An open region file: its `SYNR` index, the resident chunk records, and its on-disk location. diff --git a/crates/server/src/tests/region_file.rs b/crates/server/src/tests/region_file.rs index 439cfb7..1fb1bb4 100644 --- a/crates/server/src/tests/region_file.rs +++ b/crates/server/src/tests/region_file.rs @@ -13,27 +13,27 @@ fn sample(pos: ChunkPos) -> ChunkData { } #[test] -fn region_coords_floor_negative_columns() { - // Truncating division would map -1 to region 0; Euclidean flooring maps it to region -1. - assert_eq!(region_coords(0, 0), (0, 0)); - assert_eq!(region_coords(31, 31), (0, 0)); - assert_eq!(region_coords(-1, -1), (-1, -1)); - assert_eq!(region_coords(-32, -33), (-1, -2)); +fn region_coords_floor_negative_chunks() { + // 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, 0, 0)); + assert_eq!(region_coords(31, 31, 31), (0, 0, 0)); + assert_eq!(region_coords(-1, -1, -1), (-1, -1, -1)); + assert_eq!(region_coords(-32, -33, 64), (-1, -2, 2)); } #[test] fn region_path_names_the_region_file() { let dir = Path::new("/saves/world/region"); assert_eq!( - region_path(dir, -1, 5), - Path::new("/saves/world/region/r.-1.0.region") + region_path(dir, -1, 40, 5), + Path::new("/saves/world/region/r.-1.1.0.region") ); } #[test] fn open_missing_file_is_empty() -> Result<(), SaveError> { 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_eq!(region.read_chunk(ChunkPos::new(0, 0, 0))?, None); Ok(()) @@ -42,7 +42,7 @@ fn open_missing_file_is_empty() -> Result<(), SaveError> { #[test] fn round_trips_chunks_through_disk() -> Result<(), SaveError> { 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 = [ ChunkPos::new(0, 0, 0), @@ -72,7 +72,7 @@ fn round_trips_chunks_through_disk() -> Result<(), SaveError> { #[test] fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> { 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())?; for pos in [ @@ -99,7 +99,7 @@ fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> { #[test] fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> { 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 dropped = ChunkPos::new(1, 1, 1); @@ -121,7 +121,7 @@ fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> { #[test] fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> { 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 mut region = RegionFile::open(path.clone())?; @@ -129,7 +129,7 @@ fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> { region.save()?; // 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)?; assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos))); Ok(()) diff --git a/crates/server/src/tests/world_server.rs b/crates/server/src/tests/world_server.rs index 89650d4..76a12f1 100644 --- a/crates/server/src/tests/world_server.rs +++ b/crates/server/src/tests/world_server.rs @@ -108,7 +108,7 @@ fn saved_modification_is_applied_over_baseline() -> Result<(), SaveError> { let mut data = ChunkData::new(pos, 0); 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.save()?; @@ -180,7 +180,7 @@ fn clean_chunk_is_not_written_back_on_eviction() -> Result<(), SaveError> { flush(&world)?; // 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()); Ok(()) }