Merge pull request #4 from Cryoforge-Nexus/feat/chunk-pipeline

feat(server): voxel chunk system part I — format + runtime lifecycle
This commit is contained in:
Serkyo 2026-07-11 23:00:19 +02:00 committed by GitHub
commit ee943c7fd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 629 additions and 59 deletions

21
Cargo.lock generated
View file

@ -40,6 +40,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "android-activity" name = "android-activity"
version = "0.6.1" version = "0.6.1"
@ -912,6 +918,11 @@ name = "hashbrown"
version = "0.17.0" version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]] [[package]]
name = "heapless" name = "heapless"
@ -1101,6 +1112,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6"
dependencies = [
"hashbrown 0.17.0",
]
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
@ -1861,6 +1881,7 @@ dependencies = [
"bevy_ecs", "bevy_ecs",
"crossbeam-channel", "crossbeam-channel",
"glam 0.33.2", "glam 0.33.2",
"lru",
"serde_json", "serde_json",
"shared", "shared",
"tempfile", "tempfile",

View file

@ -2,9 +2,7 @@
//! Free-fly camera used to observe the world. //! Free-fly camera used to observe the world.
//! //!
//! The camera stores a world-space position and an orientation expressed as yaw and pitch //! The camera stores a world-space position and an orientation expressed as yaw and pitch angles. A view matrix is derived on demand from these values, and the orientation and position are advanced each frame from accumulated keyboard and mouse input.
//! angles. A view matrix is derived on demand from these values, and the orientation and
//! position are advanced each frame from accumulated keyboard and mouse input.
use glam::{Mat4, Vec3}; use glam::{Mat4, Vec3};

View file

@ -2,8 +2,7 @@
//! Main entry point for the Synvael client. //! Main entry point for the Synvael client.
//! //!
//! This crate handles window creation, input processing, and drives the //! This crate handles window creation, input processing, and drives the renderer to display the game world.
//! renderer to display the game world.
mod camera; mod camera;
mod meshing; mod meshing;
@ -22,8 +21,7 @@ use winit::window::{CursorGrabMode, Window, WindowId};
/// Transient per-frame input state sampled from window and device events. /// Transient per-frame input state sampled from window and device events.
/// ///
/// Keyboard fields hold whether a movement key is currently pressed. `mouse_delta` accumulates /// Keyboard fields hold whether a movement key is currently pressed. `mouse_delta` accumulates raw pointer motion between frames and is consumed (reset to zero) once applied to the camera.
/// raw pointer motion between frames and is consumed (reset to zero) once applied to the camera.
// The bools are independent per-key held states, for which a flat struct is the clearest form. // The bools are independent per-key held states, for which a flat struct is the clearest form.
#[expect(clippy::struct_excessive_bools)] #[expect(clippy::struct_excessive_bools)]
#[derive(Default)] #[derive(Default)]

View file

@ -13,6 +13,7 @@ anyhow = { workspace = true }
bevy_ecs = "0.19" bevy_ecs = "0.19"
crossbeam-channel = "0.5.16" crossbeam-channel = "0.5.16"
glam = { workspace = true } glam = { workspace = true }
lru = "0.18.1"
serde_json = { workspace = true } serde_json = { workspace = true }
shared = { version = "0.1.0", path = "../shared" } shared = { version = "0.1.0", path = "../shared" }
tracing = { workspace = true } tracing = { workspace = true }

View file

@ -0,0 +1,114 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! A bounded cache of regenerated chunk baselines, shared across the worker pool.
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use lru::LruCache;
use shared::generator::VoxelGenerator;
use shared::world::{Chunk, ChunkPos};
/// A least-recently-used cache of chunk baselines, cloneable so every worker shares one store.
#[derive(Clone)]
pub struct ChunkCache {
/// The shared LRU store.
inner: Arc<Mutex<LruCache<ChunkPos, Chunk>>>,
}
impl ChunkCache {
/// Creates an empty cache holding at most `capacity` baselines before evicting the least-recently-used entry.
#[must_use]
pub fn new(capacity: NonZeroUsize) -> Self {
Self {
inner: Arc::new(Mutex::new(LruCache::new(capacity))),
}
}
/// Returns the baseline for `pos`, generating and caching it on a miss.
#[must_use]
pub fn get_or_generate(&self, pos: ChunkPos, generator: &VoxelGenerator) -> Chunk {
// A poisoned lock cannot yield a usable cache; recovering the guard lets generation proceed rather than propagating a panic across every worker that shares this cache.
let mut guard = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(hit) = guard.get(&pos) {
return hit.clone();
}
let chunk = generator.generate_chunk(pos);
guard.put(pos, chunk.clone());
chunk
}
}
#[cfg(test)]
impl ChunkCache {
/// Number of baselines currently resident. Test-only introspection.
fn len(&self) -> usize {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
/// Whether a baseline for `pos` is currently resident. Test-only introspection.
fn contains(&self, pos: ChunkPos) -> bool {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(&pos)
}
}
#[cfg(test)]
mod tests {
use super::*;
use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::world::{BlockId, ChunkPos};
use std::num::NonZeroUsize;
/// Builds a generator with a small, cheap terrain configuration for cache tests.
fn test_generator() -> VoxelGenerator {
let config = WorldGenConfig {
base_height: 8,
noise_scale: 0.05,
surface_block: BlockId(1),
subsurface_block: BlockId(2),
stone_block: BlockId(3),
};
VoxelGenerator::new(config, 42)
}
/// A second lookup of the same position must be served from the store, not regenerated.
#[test]
fn repeated_lookup_is_a_cache_hit() {
let generator = test_generator();
let cache = ChunkCache::new(NonZeroUsize::new(4).unwrap_or(NonZeroUsize::MIN));
let pos = ChunkPos::new(0, 0, 0);
let first = cache.get_or_generate(pos, &generator);
let second = cache.get_or_generate(pos, &generator);
// Determinism guarantees identical output, and a single resident entry can only hold if the second call was a hit rather than a fresh generation-and-insert of a distinct value.
assert_eq!(first.blocks, second.blocks);
assert_eq!(cache.len(), 1);
}
/// Inserting beyond capacity evicts the least-recently-used entry.
#[test]
fn exceeding_capacity_evicts_oldest() {
let generator = test_generator();
let cache = ChunkCache::new(NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN));
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
let _ = cache.get_or_generate(ChunkPos::new(1, 0, 0), &generator);
// Touch the first so the second becomes the least-recently-used before the overflow.
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
let _ = cache.get_or_generate(ChunkPos::new(2, 0, 0), &generator);
assert_eq!(cache.len(), 2);
assert!(cache.contains(ChunkPos::new(0, 0, 0)));
assert!(!cache.contains(ChunkPos::new(1, 0, 0)));
}
}

View file

@ -2,9 +2,10 @@
//! Dedicated server for Synvael. //! Dedicated server for Synvael.
//! //!
//! The server handles the authoritative game simulation, including world //! The server handles the authoritative game simulation, including world management, physics, and combat.
//! management, physics, and combat.
/// A bounded LRU cache of regenerated chunk baselines, shared across the worker pool.
pub mod chunk_cache;
/// Entity components describing players and other world-streaming anchors. /// Entity components describing players and other world-streaming anchors.
pub mod player; pub mod player;
/// On-disk persistence: region files and the atomic durability layer. /// On-disk persistence: region files and the atomic durability layer.
@ -73,8 +74,17 @@ fn main() -> anyhow::Result<()> {
let generator = VoxelGenerator::new(worldgen_config, seed); let generator = VoxelGenerator::new(worldgen_config, seed);
// The region directory holds the `.region` save files for this world
// TODO: resolve it per named world under a shared save root.
let region_dir = std::path::PathBuf::from("saves/default/region");
// Number of chunk baselines the worker pool retains before evicting the least-recently-used entry
// TODO: make this configurable through server configs
let cache_capacity =
std::num::NonZeroUsize::new(4_096).context("chunk cache capacity is non-zero")?;
let mut world = World::new(); let mut world = World::new();
world.insert_resource(ServerWorld::new(generator)); world.insert_resource(ServerWorld::new(generator, region_dir, cache_capacity));
// Spawn a single dummy player anchor at the world origin. // Spawn a single dummy player anchor at the world origin.
world.spawn(( world.spawn((

View file

@ -2,12 +2,11 @@
//! Server-side persistence: the durability layer over the `shared` save format. //! Server-side persistence: the durability layer over the `shared` save format.
//! //!
//! `shared::save` owns the pure, in-memory framing (the `SYNR` index and `SYNC` records). This //! `shared::save` owns the pure, in-memory framing (the `SYNR` index and `SYNC` records).
//! module owns the filesystem side: reading a `.region` file into memory, mutating its chunks, and //! This module owns the filesystem side: reading a `.region` file into memory, mutating its chunks, and flushing it back to disk crash-safely. The write strategy is a whole-file atomic rewrite (`.tmp` + fsync + rename); the on-disk format is unchanged.
//! flushing it back to disk crash-safely. The write strategy is a whole-file atomic rewrite
//! (`.tmp` + fsync + rename); the on-disk format is unchanged, so a later slice can switch to an
//! append-in-place strategy without a format change.
mod region_actor;
mod region_file; mod region_file;
pub use region_actor::{SaveActor, SaveRequest};
pub use region_file::{REGION_SIZE, RegionFile, region_coords, region_path}; pub use region_file::{REGION_SIZE, RegionFile, region_coords, region_path};

View file

@ -0,0 +1,150 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! A dedicated thread that owns every open region file and services load, write-back, remove, and flush requests over a channel.
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::path::{Path, PathBuf};
use std::thread::{self, JoinHandle};
use crossbeam_channel::{Receiver, Sender};
use shared::save::SaveError;
use shared::world::{ChunkData, ChunkPos};
use tracing::warn;
use super::region_file::{RegionFile, region_coords, region_path};
/// A request sent to the save actor. Read and flush requests carry a one-shot reply channel; write and remove requests are fire-and-forget, mutating only the in-memory region image until a flush.
pub enum SaveRequest {
/// Reads the stored chunk at a position, replying with the saved modification if one exists.
Read {
/// The chunk position to look up.
pos: ChunkPos,
/// The one-shot channel the actor replies on: `Ok(Some(data))` for a saved modification, `Ok(None)` when the chunk was never modified, or `Err` on a save-layer failure.
reply: Sender<Result<Option<ChunkData>, SaveError>>,
},
/// Writes a modified chunk's diff into its region, replacing any prior record. Mutates only the in-memory image; durability waits for a `Flush`.
Write {
/// The chunk position the diff is stored under.
pos: ChunkPos,
/// The baseline-relative diff to persist.
data: ChunkData,
},
/// Drops any stored record for a position, reclaiming its space into the region free list. Used when a clean chunk is unloaded.
Remove {
/// The chunk position whose record is dropped.
pos: ChunkPos,
},
/// Flushes every dirty region to disk, replying once all are written.
Flush {
/// The one-shot channel the actor replies on: `Ok(())` when every dirty region flushed, or the first `Err` encountered.
reply: Sender<Result<(), SaveError>>,
},
}
/// A handle to the running save actor: the request sender plus the owning thread's join handle.
pub struct SaveActor {
/// The sending end of the request channel; cloned into every worker so it can issue reads.
request_tx: Sender<SaveRequest>,
/// The actor thread handle, retained so it can be joined on shutdown.
// * Retained ahead of a dedicated shutdown path; not yet read because the server has no graceful-stop sequence.
#[expect(dead_code)]
handle: JoinHandle<()>,
}
impl SaveActor {
/// Spawns the actor thread, which owns the region files beneath `region_dir` for its lifetime.
#[must_use]
pub fn spawn(region_dir: PathBuf) -> Self {
let (request_tx, request_rx) = crossbeam_channel::unbounded::<SaveRequest>();
let handle = thread::spawn(move || actor_loop(&region_dir, &request_rx));
Self { request_tx, handle }
}
/// Returns a fresh sender for a worker to issue requests through.
#[must_use]
pub fn sender(&self) -> Sender<SaveRequest> {
self.request_tx.clone()
}
}
/// 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>) {
// 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();
while let Ok(request) = request_rx.recv() {
match request {
SaveRequest::Read { pos, reply } => {
let result = read_chunk(&mut regions, region_dir, pos);
// A send error means the requesting worker has gone away; the reply is simply dropped.
let _ = reply.send(result);
}
SaveRequest::Write { pos, data } => {
// `last_modified` is record metadata only, not a worldgen input, so a zero placeholder is acceptable until a real timestamp source is wired.
let last_modified = 0;
match region_mut(&mut regions, region_dir, pos) {
Ok(region) => {
// A failed encode must not go unnoticed; the write is otherwise silently lost.
if let Err(error) = region.write_chunk(pos, &data, last_modified) {
warn!(?error, ?pos, "chunk write-back failed; edit dropped");
}
}
Err(error) => warn!(?error, ?pos, "region open failed; write-back dropped"),
}
}
SaveRequest::Remove { pos } => match region_mut(&mut regions, region_dir, pos) {
Ok(region) => region.remove_chunk(pos),
Err(error) => warn!(?error, ?pos, "region open failed; record not removed"),
},
SaveRequest::Flush { reply } => {
let result = flush_dirty(&mut regions);
// A send error means the requester has gone away; the reply is simply dropped.
let _ = reply.send(result);
}
}
}
}
/// Flushes every dirty region to disk, returning the first error while still attempting the rest.
fn flush_dirty(regions: &mut HashMap<(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.
if !region.is_dirty() {
continue;
}
if let Err(error) = region.save() {
warn!(?error, "region flush failed");
// The first failure is reported; later regions are still flushed.
if result.is_ok() {
result = Err(error);
}
}
}
result
}
/// Returns the region file covering `pos`, opening and caching it on first access.
fn region_mut<'a>(
regions: &'a mut HashMap<(i32, i32), RegionFile>,
region_dir: &Path,
pos: ChunkPos,
) -> Result<&'a mut RegionFile, SaveError> {
let key = region_coords(pos.x, 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))?))
}
}
}
/// Reads the stored chunk at `pos`, opening and caching its region file on first access.
fn read_chunk(
regions: &mut HashMap<(i32, i32), RegionFile>,
region_dir: &Path,
pos: ChunkPos,
) -> Result<Option<ChunkData>, SaveError> {
region_mut(regions, region_dir, pos)?.read_chunk(pos)
}

View file

@ -158,6 +158,8 @@ impl RegionFile {
} }
/// Builds the complete on-disk file image: the encoded index followed by every record. /// Builds the complete on-disk file image: the encoded index followed by every record.
// * NOTE: this is a whole-file rewrite. The right way to do it for large saves is to append changed records into free space and rewriting only the header table, so save cost scales with chunks modified rather than total file size. The free list and absolute offsets already on disk support that switch without a format change.
// TODO: incremental save.
fn serialize(&mut self) -> Result<Vec<u8>, SaveError> { fn serialize(&mut self) -> Result<Vec<u8>, SaveError> {
let index_len = self.index.encode()?.len(); let index_len = self.index.encode()?.len();

View file

@ -6,15 +6,22 @@ use bevy_ecs::prelude::Resource;
use crossbeam_channel::{Receiver, Sender}; use crossbeam_channel::{Receiver, Sender};
use shared::{ use shared::{
generator::VoxelGenerator, generator::VoxelGenerator,
world::{Chunk, ChunkPos}, save::SaveError,
world::{Chunk, ChunkData, ChunkPos},
}; };
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
num::NonZeroUsize,
path::PathBuf,
sync::Arc, sync::Arc,
thread::JoinHandle, thread::JoinHandle,
}; };
use tracing::warn;
/// Outcome of a single streaming reconcile pass, for logging and tests. use crate::chunk_cache::ChunkCache;
use crate::save::{SaveActor, SaveRequest};
/// Outcome of a single streaming reconcile pass, surfaced for logging and tests.
#[derive(Copy, Clone, Debug, PartialEq, Eq)] #[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct StreamStats { pub struct StreamStats {
/// Number of chunks generated and inserted this pass. /// Number of chunks generated and inserted this pass.
@ -32,41 +39,63 @@ pub struct StreamStats {
pub struct ServerWorld { pub struct ServerWorld {
/// Currently resident chunks, keyed by chunk-space position. /// Currently resident chunks, keyed by chunk-space position.
chunks: HashMap<ChunkPos, Chunk>, chunks: HashMap<ChunkPos, Chunk>,
/// Sending end of the job channel; the main thread pushes positions that require generation. /// Sending end of the job channel; the main thread pushes positions to load.
job_tx: Sender<ChunkPos>, job_tx: Sender<ChunkPos>,
/// Receiving end of the result channel; the main thread drains finished chunks returned by workers. /// Receiving end of the result channel; the main thread drains finished chunks returned by workers.
result_rx: Receiver<(ChunkPos, Chunk)>, result_rx: Receiver<(ChunkPos, Chunk)>,
/// Positions dispatched to a worker but not yet returned, preventing the same chunk from being re-dispatched on subsequent passes. /// Positions dispatched to a worker but not yet returned, preventing the same chunk being re-dispatched on subsequent passes.
in_flight: HashSet<ChunkPos>, in_flight: HashSet<ChunkPos>,
/// The dedicated thread owning all region files, kept alive for the world's lifetime.
// Retained so its request channel stays open for the workers; not read again after construction.
#[expect(dead_code)]
save_actor: SaveActor,
/// Handles to the generation worker threads, retained so they can be joined on shutdown. /// Handles to the generation worker threads, retained so they can be joined on shutdown.
// Retained ahead of a dedicated shutdown path; not yet read because the server has no graceful-stop sequence. // * NOTE: Retained ahead of a dedicated shutdown path
// TODO: Remove once the server has a graceful-stop sequence
#[expect(dead_code)] #[expect(dead_code)]
workers: Vec<JoinHandle<()>>, workers: Vec<JoinHandle<()>>,
/// The same read-only generator handle the workers share, held so eviction can regenerate a chunk's baseline to diff against.
generator: Arc<VoxelGenerator>,
/// A handle onto the shared baseline cache, used to resolve the baseline during the unload diff.
cache: ChunkCache,
/// The save actor's request sender, used to issue `Write` and `Remove` on unload.
save_tx: Sender<SaveRequest>,
} }
impl ServerWorld { impl ServerWorld {
/// Initializes a new authoritative server world with the provided generator. /// Initializes a new authoritative server world with the provided generator, resolving chunk loads against region files under `region_dir` and caching up to `cache_capacity` regenerated baselines.
#[must_use] #[must_use]
pub fn new(generator: VoxelGenerator) -> Self { pub fn new(
generator: VoxelGenerator,
region_dir: PathBuf,
cache_capacity: NonZeroUsize,
) -> Self {
let generator = Arc::new(generator); let generator = Arc::new(generator);
let (job_tx, job_rx) = crossbeam_channel::unbounded::<ChunkPos>(); let (job_tx, job_rx) = crossbeam_channel::unbounded::<ChunkPos>();
let (result_tx, result_rx) = crossbeam_channel::unbounded::<(ChunkPos, Chunk)>(); let (result_tx, result_rx) = crossbeam_channel::unbounded::<(ChunkPos, Chunk)>();
// One worker per logical core, falling back to a small fixed pool if the platform cannot report its parallelism. // The actor owns every region file; workers reach it only through cloned request senders.
let save_actor = SaveActor::spawn(region_dir);
// Baselines are shared across the pool through cloned handles onto one bounded store.
let cache = ChunkCache::new(cache_capacity);
let worker_count = std::thread::available_parallelism().map_or(4, std::num::NonZero::get); let worker_count = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
let workers = (0..worker_count) let workers = (0..worker_count)
.map(|_| { .map(|_| {
// Each worker owns its own clones: a shared handle to the read-only generator, its own view of the shared job queue, and its own sender back into the result channel. // Each worker shares a handle to the read-only generator, its own view of the shared job queue, its own sender back into the result channel, its own request sender to the save actor, and a handle onto the shared baseline cache.
let generator = Arc::clone(&generator); let generator = Arc::clone(&generator);
let job_rx = job_rx.clone(); let job_rx = job_rx.clone();
let result_tx = result_tx.clone(); let result_tx = result_tx.clone();
let save_tx = save_actor.sender();
let cache = cache.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
// Block until a job arrives. // Block until a job arrives.
while let Ok(pos) = job_rx.recv() { while let Ok(pos) = job_rx.recv() {
let chunk = generator.generate_chunk(pos); let chunk = load_chunk(&generator, &save_tx, &cache, pos);
// A send error means the main thread has gone away; nothing left to do but let the worker wind down. // A send error means the main thread has gone away; nothing is left to do but let the worker wind down.
if result_tx.send((pos, chunk)).is_err() { if result_tx.send((pos, chunk)).is_err() {
break; break;
} }
@ -75,16 +104,22 @@ impl ServerWorld {
}) })
.collect(); .collect();
// Drop the template ends left over after cloning. // Drop the template ends left over after cloning so the channels close once the real holders are gone.
drop(job_rx); drop(job_rx);
drop(result_tx); drop(result_tx);
let save_tx = save_actor.sender();
Self { Self {
chunks: HashMap::new(), chunks: HashMap::new(),
job_tx, job_tx,
result_rx, result_rx,
in_flight: HashSet::new(), in_flight: HashSet::new(),
save_actor,
workers, workers,
generator,
cache,
save_tx,
} }
} }
@ -94,33 +129,38 @@ impl ServerWorld {
self.chunks.len() self.chunks.len()
} }
/// Returns the resident chunk at `pos`, or `None` if it is not currently loaded.
#[must_use]
pub fn chunk(&self, pos: ChunkPos) -> Option<&Chunk> {
self.chunks.get(&pos)
}
/// Number of chunks dispatched to the worker pool but not yet returned. /// Number of chunks dispatched to the worker pool but not yet returned.
#[must_use] #[must_use]
pub fn in_flight_count(&self) -> usize { pub fn in_flight_count(&self) -> usize {
self.in_flight.len() self.in_flight.len()
} }
/// Returns `true` when the worker pool has no outstanding generation work, i.e. every dispatched chunk has been returned. A loading gate can poll this to decide when an initial region has finished streaming. /// Returns `true` when the worker pool has no outstanding work, i.e. every dispatched chunk has been returned. The loading gate polls this to decide when the initial region has finished streaming.
#[must_use] #[must_use]
pub fn streaming_idle(&self) -> bool { pub fn streaming_idle(&self) -> bool {
self.in_flight.is_empty() self.in_flight.is_empty()
} }
/// Reconciles resident chunks against a desired set without blocking the caller: finished chunks are drained from the worker pool, resident chunks absent from `desired` are evicted, and still-missing chunks are dispatched to the pool. /// Reconciles resident chunks against the desired set without blocking the caller: finished chunks are drained from the worker pool, resident chunks absent from `desired` are evicted, and still-missing chunks are dispatched to the pool.
pub fn reconcile(&mut self, desired: &HashSet<ChunkPos>) -> StreamStats { pub fn reconcile(&mut self, desired: &HashSet<ChunkPos>) -> StreamStats {
// Drain: absorb every chunk the workers have finished since the last pass. // Drain: absorb every chunk the workers finished since the last pass.
let mut loaded = 0; let mut loaded = 0;
while let Ok((pos, chunk)) = self.result_rx.try_recv() { while let Ok((pos, chunk)) = self.result_rx.try_recv() {
// The position is no longer dispatched now that its chunk has returned.
self.in_flight.remove(&pos); self.in_flight.remove(&pos);
// Guard against the eviction race: the anchor may have moved away while this chunk was generating, so a returned chunk is only kept if it is still wanted. // A finished chunk is only kept if it is still wanted.
if desired.contains(&pos) { if desired.contains(&pos) {
self.chunks.insert(pos, chunk); self.chunks.insert(pos, chunk);
loaded += 1; loaded += 1;
} }
} }
// Evict: drop resident chunks that no anchor wants any more. // Evict: drop resident chunks no anchor wants any more.
let stale: Vec<ChunkPos> = self let stale: Vec<ChunkPos> = self
.chunks .chunks
.keys() .keys()
@ -128,14 +168,27 @@ impl ServerWorld {
.copied() .copied()
.collect(); .collect();
for pos in &stale { for pos in &stale {
self.chunks.remove(pos); // The chunk is taken by value so it can be diffed against its baseline before being dropped.
let Some(chunk) = self.chunks.remove(pos) else {
continue;
};
let baseline = self.cache.get_or_generate(*pos, &self.generator);
// * NOTE: The diff is stamped with worldgen version 0: a single version exists today. This must become the chunk's stored version once worldgen versioning lands.
let data = ChunkData::from_diff(*pos, 0, &baseline, &chunk);
// A clean chunk drops any prior record into the region free list; a dirty chunk writes its diff back. Both only mutate the actor's in-memory image until a flush. A send error means the actor is gone, which the tick thread cannot act on.
let request = if data.is_unmodified() {
SaveRequest::Remove { pos: *pos }
} else {
SaveRequest::Write { pos: *pos, data }
};
let _ = self.save_tx.send(request);
} }
// Dispatch: request generation for every wanted position that is neither resident nor already in flight. // Dispatch: request a load for every wanted position that is neither resident nor already in flight.
for &pos in desired { for &pos in desired {
if !self.chunks.contains_key(&pos) && !self.in_flight.contains(&pos) { if !self.chunks.contains_key(&pos) && !self.in_flight.contains(&pos) {
self.in_flight.insert(pos); self.in_flight.insert(pos);
// A send error means every worker has shut down; there is nothing useful to do with the position, so the failure is ignored. // A send error means the workers shut down; nothing useful can be done with the position, so the failure is ignored.
let _ = self.job_tx.send(pos); let _ = self.job_tx.send(pos);
} }
} }
@ -149,6 +202,48 @@ impl ServerWorld {
} }
} }
/// Resolves a chunk position to dense voxel data: a saved modification is applied over its baseline, otherwise the deterministic baseline is regenerated directly.
fn load_chunk(
generator: &VoxelGenerator,
save_tx: &Sender<SaveRequest>,
cache: &ChunkCache,
pos: ChunkPos,
) -> Chunk {
match request_saved_chunk(save_tx, pos) {
Ok(Some(data)) => {
// A saved modification stores only edits, so the baseline is regenerated and the edits are layered on top.
// TODO: once worldgen versioning exists, the baseline must be regenerated at `data.worldgen_version()` rather than the current version; today there is a single version, so the current baseline matches.
data.materialize(&cache.get_or_generate(pos, generator))
}
// The chunk was never modified, so its content is exactly the deterministic baseline.
Ok(None) => cache.get_or_generate(pos, generator),
Err(error) => {
// A save-layer failure must not wedge streaming; the chunk falls back to a fresh baseline and the error is logged.
warn!(?error, ?pos, "chunk load failed; regenerating baseline");
cache.get_or_generate(pos, generator)
}
}
}
/// Sends a read request to the save actor and blocks for its reply, mapping a departed actor to an absent record so generation can still proceed.
fn request_saved_chunk(
save_tx: &Sender<SaveRequest>,
pos: ChunkPos,
) -> Result<Option<ChunkData>, SaveError> {
let (reply_tx, reply_rx) = crossbeam_channel::bounded(1);
if save_tx
.send(SaveRequest::Read {
pos,
reply: reply_tx,
})
.is_err()
{
return Ok(None);
}
// A receive error means the actor dropped the reply end, treated the same as no saved data.
reply_rx.recv().unwrap_or(Ok(None))
}
/// Inserts every chunk position inside the streaming cylinder around `center` into `out`. /// Inserts every chunk position inside the streaming cylinder around `center` into `out`.
pub fn cylinder_chunks<S: std::hash::BuildHasher>( pub fn cylinder_chunks<S: std::hash::BuildHasher>(
center: ChunkPos, center: ChunkPos,
@ -172,14 +267,17 @@ pub fn cylinder_chunks<S: std::hash::BuildHasher>(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ChunkPos, ServerWorld, StreamStats, cylinder_chunks}; use super::*;
use shared::generator::{VoxelGenerator, WorldGenConfig}; use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::world::BlockId; use shared::save::SaveError;
use shared::world::{BlockId, ChunkData, ChunkPos};
use std::collections::HashSet; use std::collections::HashSet;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// Builds a server world backed by a real worker pool for streaming tests. use crate::save::{RegionFile, SaveRequest, region_path};
fn test_world() -> ServerWorld {
/// Builds a generator with a small, cheap terrain configuration for streaming tests.
fn test_generator() -> VoxelGenerator {
let config = WorldGenConfig { let config = WorldGenConfig {
base_height: 8, base_height: 8,
noise_scale: 0.05, noise_scale: 0.05,
@ -187,10 +285,16 @@ mod tests {
subsurface_block: BlockId(2), subsurface_block: BlockId(2),
stone_block: BlockId(3), stone_block: BlockId(3),
}; };
ServerWorld::new(VoxelGenerator::new(config, 42)) VoxelGenerator::new(config, 42)
} }
/// Repeatedly reconciles against `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout. /// Builds a server world whose saves resolve against `region_dir`, backed by a small baseline cache.
fn test_world(region_dir: std::path::PathBuf) -> ServerWorld {
let capacity = std::num::NonZeroUsize::new(64).unwrap_or(std::num::NonZeroUsize::MIN);
ServerWorld::new(test_generator(), region_dir, capacity)
}
/// Repeatedly reconciles `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout.
fn drain_to_idle(world: &mut ServerWorld, desired: &HashSet<ChunkPos>) -> StreamStats { fn drain_to_idle(world: &mut ServerWorld, desired: &HashSet<ChunkPos>) -> StreamStats {
let deadline = Instant::now() + Duration::from_secs(5); let deadline = Instant::now() + Duration::from_secs(5);
loop { loop {
@ -206,13 +310,29 @@ mod tests {
} }
} }
/// Issues a flush against the world's save actor and blocks until every dirty region is written. Because write-backs and this flush travel the same sender to the single actor thread, the reply confirms the preceding writes are durable.
fn flush(world: &ServerWorld) -> Result<(), SaveError> {
let (reply_tx, reply_rx) = crossbeam_channel::bounded(1);
// A send error means the actor has already stopped, leaving nothing to flush.
if world
.save_tx
.send(SaveRequest::Flush { reply: reply_tx })
.is_err()
{
return Ok(());
}
reply_rx.recv().unwrap_or(Ok(()))
}
#[test] #[test]
fn reconcile_converges_over_multiple_passes() { fn reconcile_converges_over_multiple_passes() -> Result<(), SaveError> {
let mut world = test_world(); // A fresh empty directory means every load is a miss and resolves to the baseline.
let dir = tempfile::tempdir()?;
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new(); let mut desired = HashSet::new();
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired); cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired);
// The first pass only dispatches work; because generation is off-thread, nothing is resident yet and every position is in flight. // The first pass only dispatches work; because loading is off-thread, nothing is resident yet and every position is in flight.
let first = world.reconcile(&desired); let first = world.reconcile(&desired);
assert_eq!(first.loaded, 0); assert_eq!(first.loaded, 0);
assert_eq!(first.resident, 0); assert_eq!(first.resident, 0);
@ -222,11 +342,13 @@ mod tests {
let final_stats = drain_to_idle(&mut world, &desired); let final_stats = drain_to_idle(&mut world, &desired);
assert_eq!(final_stats.in_flight, 0); assert_eq!(final_stats.in_flight, 0);
assert_eq!(final_stats.resident, desired.len()); assert_eq!(final_stats.resident, desired.len());
Ok(())
} }
#[test] #[test]
fn evicted_chunk_is_not_repopulated_on_arrival() { fn evicted_chunk_is_not_repopulated_on_arrival() -> Result<(), SaveError> {
let mut world = test_world(); let dir = tempfile::tempdir()?;
let mut world = test_world(dir.path().to_path_buf());
let target = ChunkPos::new(0, 0, 0); let target = ChunkPos::new(0, 0, 0);
let mut desired = HashSet::new(); let mut desired = HashSet::new();
desired.insert(target); desired.insert(target);
@ -234,28 +356,113 @@ mod tests {
// Dispatch the chunk, then immediately stop wanting it. // Dispatch the chunk, then immediately stop wanting it.
world.reconcile(&desired); world.reconcile(&desired);
// Every subsequent pass reconciles against an empty desired set, so whenever the worker returns the chunk the drain guard discards it rather than resurrecting an unwanted chunk. // Every subsequent pass reconciles against an empty desired set, so the finished chunk is discarded on arrival rather than inserted.
let empty = HashSet::new(); let empty = HashSet::new();
let final_stats = drain_to_idle(&mut world, &empty); let final_stats = drain_to_idle(&mut world, &empty);
assert_eq!(final_stats.in_flight, 0); assert_eq!(final_stats.in_flight, 0);
assert_eq!( assert_eq!(final_stats.resident, 0);
final_stats.resident, 0, Ok(())
"a chunk that is no longer wanted must not become resident when it arrives"
);
} }
#[test] #[test]
fn cylinder_is_symmetric_and_bounded() { fn saved_modification_is_applied_over_baseline() -> Result<(), SaveError> {
// A modified chunk is written to disk, then streamed back; the resident chunk must show the edit rather than the bare baseline.
let dir = tempfile::tempdir()?;
let pos = ChunkPos::new(0, 0, 0);
let edited_index = 100u32;
let edited_block = BlockId(999);
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))?;
region.write_chunk(pos, &data, 0)?;
region.save()?;
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new();
desired.insert(pos);
drain_to_idle(&mut world, &desired);
// The resident chunk must carry the stored edit layered over its regenerated baseline.
assert!(
world
.chunk(pos)
.is_some_and(|chunk| chunk.blocks[edited_index as usize] == edited_block)
);
Ok(())
}
#[test]
fn dirty_chunk_is_written_back_on_eviction() -> Result<(), SaveError> {
// A resident chunk edited away from its baseline must survive an evict -> flush -> reload round-trip.
let dir = tempfile::tempdir()?;
let pos = ChunkPos::new(0, 0, 0);
let edited_index = 100usize;
let edited_block = BlockId(999);
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new();
desired.insert(pos);
drain_to_idle(&mut world, &desired);
// Mutate the resident chunk so it diverges from the baseline the eviction diff regenerates.
assert!(
world
.chunks
.get_mut(&pos)
.map(|chunk| chunk.blocks[edited_index] = edited_block)
.is_some()
);
// Reconciling against an empty desired set evicts the chunk, sending its diff to the actor.
world.reconcile(&HashSet::new());
// The flush shares the eviction's sender, so its reply confirms the write-back is on disk.
flush(&world)?;
// A fresh world over the same directory must stream the chunk back with the edit intact.
let mut reloaded = test_world(dir.path().to_path_buf());
drain_to_idle(&mut reloaded, &desired);
assert!(
reloaded
.chunk(pos)
.is_some_and(|chunk| chunk.blocks[edited_index] == edited_block)
);
Ok(())
}
#[test]
fn clean_chunk_is_not_written_back_on_eviction() -> Result<(), SaveError> {
// An unmodified chunk equals its baseline, so eviction must persist no record for it.
let dir = tempfile::tempdir()?;
let pos = ChunkPos::new(0, 0, 0);
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new();
desired.insert(pos);
drain_to_idle(&mut world, &desired);
// Evict without modifying the chunk, then flush.
world.reconcile(&HashSet::new());
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))?;
assert!(region.read_chunk(pos)?.is_none());
Ok(())
}
#[test]
fn cylinder_contains_expected_columns() {
let mut set = HashSet::new(); let mut set = HashSet::new();
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set); cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set);
// Center column is always included.
assert!(set.contains(&ChunkPos::new(0, 0, 0))); assert!(set.contains(&ChunkPos::new(0, 0, 0)));
// A corner outside the disc (dx=2, dz=2 -> 8 > 4) is excluded. // A corner cell is outside the disc (dx=2, dz=2 -> 8 > 4).
assert!(!set.contains(&ChunkPos::new(2, 0, 2))); assert!(!set.contains(&ChunkPos::new(2, 0, 2)));
// An axis cell at exactly the radius is included (dx=2, dz=0 -> 4 == 4). // An axis cell at exactly the radius is included (dx=2, dz=0 -> 4 == 4).
assert!(set.contains(&ChunkPos::new(2, 0, 0))); assert!(set.contains(&ChunkPos::new(2, 0, 0)));
// Vertical extent is radius/2 = 1, so y=2 is out of range. // The vertical extent is radius/2 = 1, so y=2 is out of range.
assert!(!set.contains(&ChunkPos::new(0, 2, 0))); assert!(!set.contains(&ChunkPos::new(0, 2, 0)));
assert!(set.contains(&ChunkPos::new(0, 1, 0))); assert!(set.contains(&ChunkPos::new(0, 1, 0)));
} }
@ -268,7 +475,7 @@ mod tests {
let mut shifted = HashSet::new(); let mut shifted = HashSet::new();
cylinder_chunks(ChunkPos::new(10, 0, -5), 3, &mut shifted); cylinder_chunks(ChunkPos::new(10, 0, -5), 3, &mut shifted);
// Shape is translation-invariant: same count regardless of center. // The shape is translation-invariant: the same count regardless of center.
assert_eq!(origin.len(), shifted.len()); assert_eq!(origin.len(), shifted.len());
} }
} }

View file

@ -2,8 +2,7 @@
//! Shared types and logic for Synvael. //! Shared types and logic for Synvael.
//! //!
//! This crate contains data structures and constants that are used by both //! This crate contains data structures and constants that are used by both the client and the server.
//! the client and the server.
pub mod generator; pub mod generator;
pub mod save; pub mod save;

View file

@ -38,6 +38,7 @@ Each subsystem note should name the design topic it implements (by title, e.g. "
- [`adr/0006-base-game-on-modding-api.md`](adr/0006-base-game-on-modding-api.md): base game built on the modding API. - [`adr/0006-base-game-on-modding-api.md`](adr/0006-base-game-on-modding-api.md): base game built on the modding API.
- [`adr/0007-declarative-content-via-modding-api.md`](adr/0007-declarative-content-via-modding-api.md): declarative content loads through the modding API. - [`adr/0007-declarative-content-via-modding-api.md`](adr/0007-declarative-content-via-modding-api.md): declarative content loads through the modding API.
- [`adr/0008-split-coordinate-entity-positions.md`](adr/0008-split-coordinate-entity-positions.md): split-coordinate entity positions. - [`adr/0008-split-coordinate-entity-positions.md`](adr/0008-split-coordinate-entity-positions.md): split-coordinate entity positions.
- [`adr/0009-baseline-relative-sparse-chunk-persistence.md`](adr/0009-baseline-relative-sparse-chunk-persistence.md): baseline-relative sparse chunk persistence.
- [`adr/template.md`](adr/template.md): template for new decisions. - [`adr/template.md`](adr/template.md): template for new decisions.
Subsystem notes: Subsystem notes:
@ -45,5 +46,6 @@ Subsystem notes:
- [`packs.md`](packs.md): data packs & resource packs (load order, layout, resolution). - [`packs.md`](packs.md): data packs & resource packs (load order, layout, resolution).
- [`rendering.md`](rendering.md): rendering & coordinate gotchas (Vulkan clip space, Blender/glTF import). - [`rendering.md`](rendering.md): rendering & coordinate gotchas (Vulkan clip space, Blender/glTF import).
- [`chunk_streaming.md`](chunk_streaming.md): chunk streaming and async worker pipeline. - [`chunk_streaming.md`](chunk_streaming.md): chunk streaming and async worker pipeline.
- [`save_format.md`](save_format.md): chunk persistence, region-file layout, save actor, and load pipeline.
Further subsystem notes are added here as systems are implemented and locked. Further subsystem notes are added here as systems are implemented and locked.

View file

@ -0,0 +1,27 @@
# 0009. Baseline-relative sparse chunk persistence
- **Status:** Accepted
- **Date:** 2026-07-10
## Context
Worldgen is seed-deterministic ([ADR-0003](0003-seed-deterministic-worldgen.md)): any unmodified chunk is reproducible bit-for-bit from `(seed, chunk_coord, worldgen_version)`. The world is procedurally generated, unbounded in Y, and viewed at large horizontal distance in both single-player and multiplayer, so the set of chunks a session *visits* is effectively unbounded.
Persisting the full voxel contents of every visited chunk, the naive model, makes save size scale with the volume *explored* rather than the volume *changed*. In a half-scale voxel grid ([ADR-0002](0002-half-scale-voxel-grid.md)), where a unit volume holds roughly eight times the voxels of a 1 m grid, that cost is compounded. The overwhelming majority of visited chunks are never modified, so storing them at all duplicates data the generator can reproduce on demand.
The decision that is hard to reverse is the *on-disk representation* of a chunk: the `SYNC` record format and the `ChunkData` type are both shaped by it, and changing the representation later requires a save-format migration.
## Decision
A chunk is persisted only when its contents diverge from its deterministic baseline.
- **Representation.** Both on disk (the `SYNC` record) and in memory (`ChunkData`), a modified chunk is stored as a sparse `local_index → BlockId` edit map layered over the regenerated baseline, together with the `worldgen_version` the baseline is pinned to. An unmodified chunk stores no voxel data and is omitted from its region file entirely.
- **Load.** A load resolves the baseline by regenerating it from the seed, then applies the stored diff when a record exists (a hit). A miss means the chunk was never modified, so the regenerated baseline *is* the chunk.
- **Version pinning.** Each persisted chunk records the `worldgen_version` its baseline was generated under, so a later generator update does not silently shift the baseline beneath an already-modified chunk. A region pins a `base_worldgen_version` and stores only per-chunk exceptions.
## Consequences
- Save size scales with the volume *modified*, not the volume explored. A session that walks across untouched terrain writes nothing.
- Revisiting an unmodified chunk re-runs worldgen instead of reading it back. This CPU cost is mitigated by an LRU cache of regenerated baselines, which is a pure performance layer and does not affect authority or determinism.
- Worldgen determinism is promoted from a worldgen-local property to a hard invariant of the persistence layer: if the generator ceased to be reproducible, every unmodified chunk and every stored diff's baseline would be corrupted. Determinism regressions are therefore guarded aggressively by tests.
- A per-chunk `worldgen_version` stamp is mandatory metadata, and the load and write-back paths must both honour it once more than one worldgen version exists. Until then a single version is assumed, tracked as follow-on work.

42
docs/save_format.md Normal file
View file

@ -0,0 +1,42 @@
# Save format
How modified chunks are framed, stored, and read back from disk. This note describes the implementation.
The pure, in-memory framing (the `SYNR` region index and `SYNC` chunk records) lives in [`crates/shared/src/save/`](../crates/shared/src/save/). The filesystem side — reading a region file, mutating its chunks, and flushing it back crash-safely — lives in [`crates/server/src/save/`](../crates/server/src/save/). The runtime load path that turns a `ChunkPos` into a resident chunk lives in [`crates/server/src/world_server.rs`](../crates/server/src/world_server.rs) and is driven by the streaming reconcile loop documented in [`chunk_streaming.md`](chunk_streaming.md).
## What is persisted
Only chunks that diverge from their deterministic worldgen baseline are stored; the rationale is recorded in [ADR-0009](adr/0009-baseline-relative-sparse-chunk-persistence.md). A modified chunk is a [`ChunkData`](../crates/shared/src/world/chunk_data.rs): the chunk position, the `worldgen_version` its baseline is pinned to, and a sparse `local_index → BlockId` edit map. An unmodified chunk stores no voxel data and is absent from its region file.
## On-disk layout
Voxel storage is partitioned into **region files**, each covering a 32×32 grid of chunk columns in the XZ plane (the grid is 2D; Y is not partitioned). A chunk's region is `(cx.div_euclid(32), cz.div_euclid(32))``div_euclid`, not truncating division, so negative columns floor toward negative infinity rather than toward zero. All multi-byte integers are little-endian.
A region file is a `SYNR` index followed by the `SYNC` records the index points at.
- **`SYNR` region index** ([`region.rs`](../crates/shared/src/save/region.rs)): magic tag, framing version, and three side tables — a **header table** (`ChunkPos → offset+length+flags` for every resident record), a **free list** (reclaimable spans left by removed or shrunken records), and a **stamp table** (per-chunk `worldgen_version` exceptions; the region pins a `base_worldgen_version` and stores only chunks that differ from it). The header and stamp tables are `BTreeMap`s so their serialization order is deterministic.
- **`SYNC` chunk record** ([`record.rs`](../crates/shared/src/save/record.rs)): a fixed header (magic, chunk-format version, flags, `last_modified` timestamp in unix-ms, and the compressed and uncompressed payload lengths) followed by a zstd-compressed, postcard-serialized `ChunkData`. The header is never compressed, so a repair tool can read framing without decompressing. Compression is zstd level 3, favouring speed.
## Durability layer
[`RegionFile`](../crates/server/src/save/region_file.rs) reads a region file into memory, mutates its chunks (`write_chunk`, `remove_chunk`), and flushes it back. The flush strategy is a **whole-file atomic rewrite**: the complete file image is serialized, written to a `.tmp` sibling, fsynced, renamed over the target, and the containing directory is fsynced. This is a simpler alternative to an incremental append-plus-header-rewrite scheme; the deviation is noted at the `serialize` site and tracked for revision as follow-on work. The on-disk format is unchanged, so the switch requires no migration (the free list and absolute record offsets already support it).
## Concurrency: the save actor
Region files are owned by a single dedicated thread, the **save actor** ([`region_actor.rs`](../crates/server/src/save/region_actor.rs)). It holds the map of open `RegionFile`s and is their sole owner, so no region file needs a lock of its own. Worker threads never touch a region file directly; they hold cloned senders on the actor's request channel and communicate by message. This is the message-passing-over-shared-state concurrency stance from `AGENTS.md` applied to persistence: one queue thread serializes all region I/O, keeping it off both the simulation tick and the worker pool. A region file is opened on first access and its contents are served from memory thereafter.
## Load pipeline
A load of `ChunkPos` runs on the worker pool (off the tick thread), in [`world_server.rs`](../crates/server/src/world_server.rs) `load_chunk`:
1. The worker asks the save actor for the stored record at the position.
2. **Hit** (`Some(ChunkData)`): the baseline is regenerated (via the LRU baseline cache) and the stored diff is materialized over it.
3. **Miss** (`None`): the chunk was never modified, so the regenerated baseline is the chunk.
4. **Save-layer error**: streaming must not wedge, so the chunk falls back to a fresh baseline and the error is logged.
The regenerated baseline currently uses the current worldgen version rather than the record's stored `worldgen_version`; while a single version exists these coincide. Honouring the stored version on both load and write-back is follow-on work.
## Related decisions
- [ADR-0003](adr/0003-seed-deterministic-worldgen.md): seed-deterministic worldgen — the invariant that makes regen-on-load sound.
- [ADR-0009](adr/0009-baseline-relative-sparse-chunk-persistence.md): baseline-relative sparse persistence — why only diffs are stored.