Skip to main content

steel_core/chunk_saver/
region_manager.rs

1//! Region file manager with seek-based chunk access.
2//!
3//! Uses a sector-based format where only the header (8KB) is kept in memory.
4//! Chunk data is read on-demand from disk and converted directly to runtime
5//! format, avoiding memory duplication.
6
7use std::{
8    fmt,
9    io::{self},
10    path::PathBuf,
11    sync::Weak,
12};
13
14use rustc_hash::FxHashMap;
15use steel_utils::{ChunkPos, locks::AsyncRwLock};
16use tokio::{
17    fs::{self, File, OpenOptions},
18    io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt},
19    sync::oneshot,
20};
21
22use crate::chunk::status::ChunkStatus;
23use crate::world::World;
24
25use super::{
26    ChunkStorage, LoadedChunk, PersistentChunk,
27    format::{
28        CHUNK_TABLE_SIZE, ChunkEntry, FILE_HEADER_SIZE, FIRST_DATA_SECTOR, FORMAT_VERSION,
29        MAX_CHUNK_SIZE, REGION_MAGIC, RegionHeader, RegionPos, SECTOR_SIZE,
30    },
31};
32
33#[derive(Debug)]
34struct CorruptChunkData(String);
35
36impl fmt::Display for CorruptChunkData {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        formatter.write_str(&self.0)
39    }
40}
41
42/// Manages region files with seek-based chunk access.
43///
44/// Only keeps region headers (8KB each) in memory, not chunk data.
45/// Chunks are loaded on-demand and converted directly to runtime format.
46pub struct RegionManager {
47    /// Base directory for region files (e.g., "world/region").
48    base_path: PathBuf,
49    /// Open region file handles with their headers.
50    regions: AsyncRwLock<FxHashMap<RegionPos, RegionHandle>>,
51}
52
53/// Prepared chunk data ready to be saved asynchronously.
54/// Created by `prepare_chunk_save` during the holder's snapshot-preparation phase.
55pub struct PreparedChunkSave {
56    /// The chunk position.
57    pub pos: ChunkPos,
58    /// The highest persisted status captured with the chunk data.
59    pub status: ChunkStatus,
60    /// The serialized chunk data.
61    pub persistent: PersistentChunk<'static>,
62    /// Runtime manager entity IDs that were either serialized or explicitly skipped.
63    pub handled_runtime_entity_ids: Vec<i32>,
64}
65
66/// An open region file with its header.
67struct RegionHandle {
68    /// File handle for reading/writing.
69    file: File,
70    /// Chunk location header (8KB).
71    header: RegionHeader,
72    /// Number of chunks currently loaded from this region.
73    loaded_chunk_count: usize,
74    /// Whether the header has been modified since last save.
75    header_dirty: bool,
76    /// Current file size in sectors.
77    file_sectors: u32,
78}
79
80impl RegionManager {
81    /// Creates a new region manager.
82    ///
83    /// # Arguments
84    /// * `base_path` - Directory where region files are stored.
85    /// * `registry` - The registry for block state and biome conversions.
86    pub fn new(base_path: impl Into<PathBuf>) -> Self {
87        Self {
88            base_path: base_path.into(),
89            regions: AsyncRwLock::new(FxHashMap::default()),
90        }
91    }
92
93    /// Gets the file path for a region.
94    fn region_path(&self, pos: RegionPos) -> PathBuf {
95        self.base_path.join(pos.filename())
96    }
97
98    /// Opens or creates a region file, loading only the header.
99    async fn open_region(&self, pos: RegionPos) -> io::Result<RegionHandle> {
100        let path = self.region_path(pos);
101
102        if !path.exists() {
103            // Create new region file with empty header
104            return self.create_region(pos).await;
105        }
106
107        let mut file = OpenOptions::new()
108            .read(true)
109            .write(true)
110            .open(&path)
111            .await?;
112
113        // Read and verify magic + version
114        let mut header_bytes = [0u8; FILE_HEADER_SIZE];
115        file.read_exact(&mut header_bytes).await?;
116
117        let magic = &header_bytes[0..4];
118        if magic != REGION_MAGIC {
119            return Err(io::Error::new(
120                io::ErrorKind::InvalidData,
121                "Invalid region file magic",
122            ));
123        }
124
125        let version = u16::from_le_bytes([header_bytes[4], header_bytes[5]]);
126        if version != FORMAT_VERSION {
127            // Version mismatch — backup the old file and create a fresh region.
128            drop(file);
129            let backup_path = path.with_extension(format!("srg.v{version}.bak"));
130            tracing::warn!(
131                "Region file {} has version {version} (expected {FORMAT_VERSION}), backing up to {} and recreating",
132                path.display(),
133                backup_path.display()
134            );
135            fs::rename(&path, &backup_path).await?;
136            return self.create_region(pos).await;
137        }
138
139        // Read chunk table
140        let mut table_bytes = vec![0u8; CHUNK_TABLE_SIZE];
141        file.read_exact(&mut table_bytes).await?;
142        let header = RegionHeader::from_bytes(&table_bytes).map_err(|index| {
143            io::Error::new(
144                io::ErrorKind::InvalidData,
145                format!("region chunk table entry {index} has an invalid status byte"),
146            )
147        })?;
148
149        // Calculate file size in sectors
150        let file_size = file.seek(io::SeekFrom::End(0)).await?;
151        let file_sectors = file_size.div_ceil(SECTOR_SIZE as u64) as u32;
152        Self::validate_region_entries(&header, file_sectors)?;
153
154        Ok(RegionHandle {
155            file,
156            header,
157            loaded_chunk_count: 0,
158            header_dirty: false,
159            file_sectors,
160        })
161    }
162
163    /// Creates a new empty region file.
164    async fn create_region(&self, pos: RegionPos) -> io::Result<RegionHandle> {
165        fs::create_dir_all(&self.base_path).await?;
166
167        let path = self.region_path(pos);
168        let mut file = OpenOptions::new()
169            .read(true)
170            .write(true)
171            .create(true)
172            .truncate(true)
173            .open(&path)
174            .await?;
175
176        // Write header
177        let mut header_bytes = [0u8; FILE_HEADER_SIZE];
178        header_bytes[0..4].copy_from_slice(&REGION_MAGIC);
179        header_bytes[4..6].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
180        file.write_all(&header_bytes).await?;
181
182        // Write empty chunk table
183        let header = RegionHeader::new();
184        file.write_all(&header.to_bytes()).await?;
185        file.flush().await?;
186
187        Ok(RegionHandle {
188            file,
189            header,
190            loaded_chunk_count: 0,
191            header_dirty: false,
192            file_sectors: FIRST_DATA_SECTOR,
193        })
194    }
195
196    /// Writes the header to disk.
197    async fn write_header(file: &mut File, header: &RegionHeader) -> io::Result<()> {
198        file.seek(io::SeekFrom::Start(FILE_HEADER_SIZE as u64))
199            .await?;
200        file.write_all(&header.to_bytes()).await?;
201        file.flush().await?;
202        Ok(())
203    }
204
205    /// Reads a chunk's compressed data from disk.
206    async fn read_chunk_data(
207        file: &mut File,
208        sector_offset: u32,
209        size: u32,
210    ) -> io::Result<Vec<u8>> {
211        let byte_offset = u64::from(sector_offset) * SECTOR_SIZE as u64;
212        file.seek(io::SeekFrom::Start(byte_offset)).await?;
213
214        let mut compressed = vec![0u8; size as usize];
215        file.read_exact(&mut compressed).await?;
216        Ok(compressed)
217    }
218
219    fn validate_chunk_entry(entry: ChunkEntry, file_sectors: u32) -> io::Result<()> {
220        if entry.sector_offset < FIRST_DATA_SECTOR {
221            return Err(io::Error::new(
222                io::ErrorKind::InvalidData,
223                format!(
224                    "chunk table entry points into the region header at sector {}",
225                    entry.sector_offset
226                ),
227            ));
228        }
229        if entry.size_bytes == 0 || entry.size_bytes as usize > MAX_CHUNK_SIZE {
230            return Err(io::Error::new(
231                io::ErrorKind::InvalidData,
232                format!("invalid chunk table entry size {}", entry.size_bytes),
233            ));
234        }
235        let Some(end_sector) = entry.sector_offset.checked_add(entry.sector_count()) else {
236            return Err(io::Error::new(
237                io::ErrorKind::InvalidData,
238                "chunk table entry sector range overflowed",
239            ));
240        };
241        if end_sector > file_sectors {
242            return Err(io::Error::new(
243                io::ErrorKind::InvalidData,
244                format!(
245                    "chunk table entry ends at sector {end_sector}, past region end {file_sectors}"
246                ),
247            ));
248        }
249        Ok(())
250    }
251
252    fn validate_region_entries(header: &RegionHeader, file_sectors: u32) -> io::Result<()> {
253        let mut occupied = vec![false; file_sectors as usize];
254        for sector in occupied.iter_mut().take(FIRST_DATA_SECTOR as usize) {
255            *sector = true;
256        }
257        for (index, &entry) in header.entries.iter().enumerate() {
258            if !entry.exists() {
259                continue;
260            }
261            Self::validate_chunk_entry(entry, file_sectors)?;
262            let start = entry.sector_offset as usize;
263            let end = start + entry.sector_count() as usize;
264            if occupied[start..end].iter().any(|is_occupied| *is_occupied) {
265                return Err(io::Error::new(
266                    io::ErrorKind::InvalidData,
267                    format!("chunk table entry {index} overlaps another region allocation"),
268                ));
269            }
270            occupied[start..end].fill(true);
271        }
272        Ok(())
273    }
274
275    async fn clear_corrupt_chunk_if_unchanged(
276        &self,
277        region_pos: RegionPos,
278        index: usize,
279        expected_entry: ChunkEntry,
280    ) -> io::Result<bool> {
281        let mut regions = self.regions.write().await;
282        let Some(handle) = regions.get_mut(&region_pos) else {
283            return Err(io::Error::other(
284                "region was released while clearing corrupt chunk data",
285            ));
286        };
287        if handle.header.entries[index] != expected_entry {
288            return Ok(false);
289        }
290
291        handle.header.entries[index] = ChunkEntry::empty();
292        if let Err(error) = Self::write_header(&mut handle.file, &handle.header).await {
293            handle.header.entries[index] = expected_entry;
294            return Err(error);
295        }
296        handle.header_dirty = false;
297        Ok(true)
298    }
299
300    /// Writes chunk data to disk at the specified sector offset.
301    async fn write_chunk_data(
302        file: &mut File,
303        sector_offset: u32,
304        data: &[u8],
305        file_sectors: &mut u32,
306    ) -> io::Result<()> {
307        let byte_offset = u64::from(sector_offset) * SECTOR_SIZE as u64;
308        file.seek(io::SeekFrom::Start(byte_offset)).await?;
309        file.write_all(data).await?;
310
311        // Pad to sector boundary
312        let padding_needed = (SECTOR_SIZE - (data.len() % SECTOR_SIZE)) % SECTOR_SIZE;
313        if padding_needed > 0 {
314            file.write_all(&vec![0u8; padding_needed]).await?;
315        }
316
317        // Update file sectors if we wrote past the end
318        let sectors_used = data.len().div_ceil(SECTOR_SIZE) as u32;
319        let end_sector = sector_offset + sectors_used;
320        if end_sector > *file_sectors {
321            *file_sectors = end_sector;
322        }
323
324        file.flush().await?;
325        Ok(())
326    }
327
328    /// Saves prepared chunk data to disk after the snapshot-preparation phase has ended.
329    #[expect(
330        clippy::missing_panics_doc,
331        reason = "panic on `just inserted` is unreachable"
332    )]
333    pub async fn save_chunk_data(
334        &self,
335        prepared: PreparedChunkSave,
336        thread_pool: &rayon::ThreadPool,
337    ) -> io::Result<bool> {
338        let pos = prepared.pos;
339        let status = prepared.status;
340        let region_pos = RegionPos::from_chunk(pos.0.x, pos.0.y);
341        let (local_x, local_z) = RegionPos::local_chunk_pos(pos.0.x, pos.0.y);
342        let index = RegionHeader::chunk_index(local_x, local_z);
343
344        let (sender, receiver) = oneshot::channel();
345        thread_pool.spawn(move || {
346            let result = Self::encode_chunk(prepared);
347            if sender.send(result).is_err() {
348                tracing::trace!(
349                    chunk = ?pos,
350                    "Discarding encoded chunk after its save task was canceled"
351                );
352            }
353        });
354        let compressed = receiver.await.map_err(|_| {
355            io::Error::other("chunk encode task ended without returning a result")
356        })??;
357
358        let mut regions = self.regions.write().await;
359
360        // Track if we opened the region (so we can close it after)
361        let we_opened_region = !regions.contains_key(&region_pos);
362
363        // Get or open the region
364        let handle = if let Some(handle) = regions.get_mut(&region_pos) {
365            handle
366        } else {
367            let handle = self.open_region(region_pos).await?;
368            regions.insert(region_pos, handle);
369            regions.get_mut(&region_pos).expect("just inserted")
370        };
371
372        // Find space for the chunk
373        let sectors_needed = compressed.len().div_ceil(SECTOR_SIZE) as u32;
374        let old_entry = handle.header.entries[index];
375
376        // Try to reuse existing space if it fits
377        let sector_offset = if old_entry.exists() && old_entry.sector_count() >= sectors_needed {
378            old_entry.sector_offset
379        } else {
380            handle
381                .header
382                .find_free_sectors(sectors_needed, handle.file_sectors)
383        };
384
385        // Write chunk data
386        Self::write_chunk_data(
387            &mut handle.file,
388            sector_offset,
389            &compressed,
390            &mut handle.file_sectors,
391        )
392        .await?;
393
394        // Update header entry
395        handle.header.entries[index] =
396            super::format::ChunkEntry::new(sector_offset, compressed.len() as u32, status);
397
398        // If we opened this region and no chunks are loaded from it,
399        // write the header and close it immediately
400        if we_opened_region && handle.loaded_chunk_count == 0 {
401            Self::write_header(&mut handle.file, &handle.header).await?;
402            regions.remove(&region_pos);
403        } else {
404            handle.header_dirty = true;
405        }
406
407        Ok(true)
408    }
409
410    fn encode_chunk(prepared: PreparedChunkSave) -> io::Result<Vec<u8>> {
411        let data = wincode::serialize(&prepared.persistent)
412            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
413        let compressed = zstd::encode_all(&data[..], 3)?;
414
415        if compressed.len() > MAX_CHUNK_SIZE {
416            return Err(io::Error::new(
417                io::ErrorKind::InvalidData,
418                format!(
419                    "Chunk too large: {} bytes (max {})",
420                    compressed.len(),
421                    MAX_CHUNK_SIZE
422                ),
423            ));
424        }
425
426        Ok(compressed)
427    }
428
429    /// Loads a chunk from the appropriate region.
430    ///
431    /// Automatically opens the region if not already open. The region's reference
432    /// count is incremented, so you must call `release_chunk` when done with the chunk.
433    ///
434    /// Returns `Ok(None)` if the chunk doesn't exist on disk.
435    ///
436    /// # Arguments
437    /// * `pos` - The chunk position
438    /// * `min_y` - The minimum Y coordinate of the world
439    /// * `height` - The total height of the world
440    /// * `level` - Weak reference to the world for Full chunk runtime access
441    ///
442    /// The region must already be acquired via `acquire_chunk` before calling this.
443    pub async fn load_chunk(
444        &self,
445        pos: ChunkPos,
446        min_y: i32,
447        height: i32,
448        level: Weak<World>,
449        thread_pool: &rayon::ThreadPool,
450    ) -> io::Result<Option<LoadedChunk>> {
451        if height <= 0 || height % 16 != 0 || min_y % 16 != 0 {
452            return Err(io::Error::new(
453                io::ErrorKind::InvalidInput,
454                format!(
455                    "chunk world range must be section-aligned, got min_y={min_y}, height={height}"
456                ),
457            ));
458        }
459        let region_pos = RegionPos::from_chunk(pos.0.x, pos.0.y);
460        let (local_x, local_z) = RegionPos::local_chunk_pos(pos.0.x, pos.0.y);
461        let index = RegionHeader::chunk_index(local_x, local_z);
462
463        let (compressed, entry) = {
464            let mut regions = self.regions.write().await;
465
466            // Get the region (should already be open via acquire_chunk)
467            let Some(handle) = regions.get_mut(&region_pos) else {
468                log::warn!("load_chunk called without acquire_chunk for region {region_pos:?}");
469                return Ok(None);
470            };
471
472            // Check if chunk exists
473            let entry = handle.header.entries[index];
474            if !entry.exists() {
475                return Ok(None);
476            }
477
478            // Invalid offsets and sizes indicate damage to the region's location
479            // table, not a self-contained chunk payload. Do not discard the slot.
480            Self::validate_chunk_entry(entry, handle.file_sectors)?;
481
482            // Read chunk data from disk
483            let compressed =
484                Self::read_chunk_data(&mut handle.file, entry.sector_offset, entry.size_bytes)
485                    .await?;
486            (compressed, entry)
487        };
488
489        // Keep CPU-heavy decoding off the async runtime. Awaiting the Rayon
490        // handoff also lets the region-lock waiter woken above make progress.
491        let (sender, receiver) = oneshot::channel();
492        thread_pool.spawn(move || {
493            let result = Self::decode_chunk(compressed, pos, entry.status, min_y, height, level);
494            if sender.send(result).is_err() {
495                tracing::trace!(
496                    chunk = ?pos,
497                    "Discarding decoded chunk after its load task was canceled"
498                );
499            }
500        });
501
502        let decoded = receiver
503            .await
504            .map_err(|_| io::Error::other("chunk decode task ended without returning a result"))?;
505        match decoded {
506            Ok(loaded) => Ok(Some(loaded)),
507            Err(error) => {
508                if !self
509                    .clear_corrupt_chunk_if_unchanged(region_pos, index, entry)
510                    .await?
511                {
512                    return Err(io::Error::new(
513                        io::ErrorKind::InvalidData,
514                        format!(
515                            "corrupt chunk payload was superseded before it could be removed: {error}"
516                        ),
517                    ));
518                }
519                tracing::error!(
520                    chunk = ?pos,
521                    "Discarded corrupt chunk payload and will regenerate it: {error}",
522                );
523                Ok(None)
524            }
525        }
526    }
527
528    fn decode_chunk(
529        compressed: Vec<u8>,
530        pos: ChunkPos,
531        status: ChunkStatus,
532        min_y: i32,
533        height: i32,
534        level: Weak<World>,
535    ) -> Result<LoadedChunk, CorruptChunkData> {
536        let data = zstd::decode_all(&compressed[..])
537            .map_err(|error| CorruptChunkData(format!("zstd decode failed: {error}")))?;
538        let persistent: PersistentChunk<'_> = wincode::deserialize(&data)
539            .map_err(|error| CorruptChunkData(format!("chunk decode failed: {error}")))?;
540
541        ChunkStorage::try_persistent_to_chunk(&persistent, pos, status, min_y, height, level)
542            .map_err(|error| CorruptChunkData(format!("chunk materialization failed: {error}")))
543    }
544
545    /// Acquires a chunk, incrementing the region's reference count.
546    ///
547    /// This opens or creates the region file. Call this before loading or
548    /// generating a chunk, and call `release_chunk` when done with the chunk.
549    ///
550    /// Returns `Ok(true)` if the chunk exists on disk, `Ok(false)` if it doesn't.
551    #[expect(
552        clippy::missing_panics_doc,
553        reason = "panic on `just inserted` is unreachable"
554    )]
555    pub async fn acquire_chunk(&self, pos: ChunkPos) -> io::Result<bool> {
556        let region_pos = RegionPos::from_chunk(pos.0.x, pos.0.y);
557        let (local_x, local_z) = RegionPos::local_chunk_pos(pos.0.x, pos.0.y);
558        let index = RegionHeader::chunk_index(local_x, local_z);
559
560        let mut regions = self.regions.write().await;
561
562        // Get or open/create the region
563        let handle = if let Some(handle) = regions.get_mut(&region_pos) {
564            handle
565        } else {
566            // open_region creates the file if it doesn't exist
567            let handle = self.open_region(region_pos).await?;
568            regions.insert(region_pos, handle);
569            regions.get_mut(&region_pos).expect("just inserted")
570        };
571
572        // Check if chunk exists
573        let exists = handle.header.entries[index].exists();
574
575        // Increment ref count
576        handle.loaded_chunk_count += 1;
577
578        Ok(exists)
579    }
580
581    /// Releases a loaded chunk, decrementing the region's reference count.
582    ///
583    /// When all chunks from a region are released, the header is saved (if dirty)
584    /// and the file handle is closed.
585    ///
586    /// This must be called for each chunk returned by `load_chunk`.
587    pub async fn release_chunk(&self, pos: ChunkPos) -> io::Result<()> {
588        let region_pos = RegionPos::from_chunk(pos.0.x, pos.0.y);
589
590        let mut regions = self.regions.write().await;
591
592        let should_close = if let Some(handle) = regions.get_mut(&region_pos) {
593            handle.loaded_chunk_count = handle.loaded_chunk_count.saturating_sub(1);
594            handle.loaded_chunk_count == 0
595        } else {
596            return Ok(());
597        };
598
599        if should_close
600            && let Some(mut handle) = regions.remove(&region_pos)
601            && handle.header_dirty
602        {
603            Self::write_header(&mut handle.file, &handle.header).await?;
604        }
605
606        Ok(())
607    }
608
609    /// Checks if a chunk exists on disk without loading it.
610    pub async fn chunk_exists(&self, pos: ChunkPos) -> io::Result<bool> {
611        let region_pos = RegionPos::from_chunk(pos.0.x, pos.0.y);
612        let (local_x, local_z) = RegionPos::local_chunk_pos(pos.0.x, pos.0.y);
613        let index = RegionHeader::chunk_index(local_x, local_z);
614
615        let regions = self.regions.write().await;
616
617        // Check cached header first
618        if let Some(handle) = regions.get(&region_pos) {
619            return Ok(handle.header.entries[index].exists());
620        }
621
622        drop(regions);
623
624        // Need to read header from disk
625        let path = self.region_path(region_pos);
626        if !path.exists() {
627            return Ok(false);
628        }
629
630        let mut file = File::open(&path).await?;
631
632        // Skip magic + version
633        file.seek(io::SeekFrom::Start(FILE_HEADER_SIZE as u64))
634            .await?;
635
636        // Read just the one entry we need (8 bytes at index * 8)
637        file.seek(io::SeekFrom::Current((index * 8) as i64)).await?;
638        let mut entry_bytes = [0u8; 8];
639        file.read_exact(&mut entry_bytes).await?;
640
641        let Some(entry) = super::format::ChunkEntry::from_bytes(entry_bytes) else {
642            return Err(io::Error::new(
643                io::ErrorKind::InvalidData,
644                "chunk table entry has an invalid status byte",
645            ));
646        };
647        Ok(entry.exists())
648    }
649
650    /// Flushes all dirty headers to disk.
651    pub async fn flush_all(&self) -> io::Result<()> {
652        let mut regions = self.regions.write().await;
653
654        for handle in regions.values_mut() {
655            if handle.header_dirty {
656                Self::write_header(&mut handle.file, &handle.header).await?;
657                handle.header_dirty = false;
658            }
659        }
660
661        Ok(())
662    }
663
664    /// Flushes all dirty headers and closes all region file handles.
665    ///
666    /// This should be called during graceful shutdown after all chunks have been saved.
667    /// It ensures all data is persisted and file handles are properly closed.
668    pub async fn close_all(&self) -> io::Result<()> {
669        let mut regions = self.regions.write().await;
670
671        for (_, mut handle) in regions.drain() {
672            if handle.header_dirty {
673                Self::write_header(&mut handle.file, &handle.header).await?;
674            }
675            // File handle is dropped here, closing the file
676        }
677
678        Ok(())
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use std::{
685        env,
686        path::Path,
687        process,
688        sync::{
689            Weak,
690            atomic::{AtomicU64, Ordering},
691        },
692    };
693
694    use super::*;
695    use crate::chunk_saver::{PersistentChunk, PersistentLightData};
696
697    static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0);
698
699    fn test_directory(name: &str) -> PathBuf {
700        let sequence = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed);
701        env::temp_dir().join(format!(
702            "steel-region-manager-{name}-{}-{sequence}",
703            process::id()
704        ))
705    }
706
707    async fn write_test_region(
708        directory: &Path,
709        pos: ChunkPos,
710        payload: &[u8],
711        declared_size: u32,
712    ) -> io::Result<()> {
713        fs::create_dir_all(directory).await?;
714        let region_pos = RegionPos::from_chunk(pos.0.x, pos.0.y);
715        let path = directory.join(region_pos.filename());
716        let mut file = File::create(path).await?;
717        let mut file_header = [0u8; FILE_HEADER_SIZE];
718        file_header[0..4].copy_from_slice(&REGION_MAGIC);
719        file_header[4..6].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
720        file.write_all(&file_header).await?;
721
722        let (local_x, local_z) = RegionPos::local_chunk_pos(pos.0.x, pos.0.y);
723        let index = RegionHeader::chunk_index(local_x, local_z);
724        let mut header = RegionHeader::new();
725        header.entries[index] =
726            ChunkEntry::new(FIRST_DATA_SECTOR, declared_size, ChunkStatus::Empty);
727        file.write_all(&header.to_bytes()).await?;
728        file.seek(io::SeekFrom::Start(
729            u64::from(FIRST_DATA_SECTOR) * SECTOR_SIZE as u64,
730        ))
731        .await?;
732        file.write_all(payload).await?;
733        file.flush().await
734    }
735
736    fn test_thread_pool() -> rayon::ThreadPool {
737        rayon::ThreadPoolBuilder::new()
738            .num_threads(1)
739            .build()
740            .expect("test thread pool should build")
741    }
742
743    async fn assert_slot_exists_on_disk(directory: &Path, pos: ChunkPos) {
744        let region_pos = RegionPos::from_chunk(pos.0.x, pos.0.y);
745        let path = directory.join(region_pos.filename());
746        let mut file = File::open(path)
747            .await
748            .expect("test region should remain readable");
749        let (local_x, local_z) = RegionPos::local_chunk_pos(pos.0.x, pos.0.y);
750        let index = RegionHeader::chunk_index(local_x, local_z);
751        file.seek(io::SeekFrom::Start(
752            FILE_HEADER_SIZE as u64 + (index * 8) as u64,
753        ))
754        .await
755        .expect("chunk table entry should be seekable");
756        let mut bytes = [0; 8];
757        file.read_exact(&mut bytes)
758            .await
759            .expect("chunk table entry should be readable");
760        assert!(ChunkEntry::from_bytes(bytes).is_some_and(|entry| entry.exists()));
761    }
762
763    #[tokio::test]
764    async fn invalid_zstd_payload_is_removed_for_regeneration() {
765        let directory = test_directory("zstd");
766        let pos = ChunkPos::new(0, 0);
767        let payload = b"this is not a zstd frame";
768        write_test_region(&directory, pos, payload, payload.len() as u32)
769            .await
770            .expect("test region should be written");
771
772        let manager = RegionManager::new(&directory);
773        assert!(
774            manager
775                .acquire_chunk(pos)
776                .await
777                .expect("region should open")
778        );
779        let loaded = manager
780            .load_chunk(pos, 0, 16, Weak::new(), &test_thread_pool())
781            .await
782            .expect("corrupt payload should be handled");
783        assert!(loaded.is_none());
784        assert!(
785            !manager
786                .chunk_exists(pos)
787                .await
788                .expect("header should be readable")
789        );
790        manager
791            .release_chunk(pos)
792            .await
793            .expect("region should release");
794
795        let reopened = RegionManager::new(&directory);
796        assert!(
797            !reopened
798                .chunk_exists(pos)
799                .await
800                .expect("header should be flushed")
801        );
802        fs::remove_dir_all(directory)
803            .await
804            .expect("test directory should be removable");
805    }
806
807    #[tokio::test]
808    async fn semantically_invalid_complete_payload_is_removed_for_regeneration() {
809        let directory = test_directory("semantic");
810        let pos = ChunkPos::new(0, 0);
811        let persistent = PersistentChunk {
812            last_modified: 0,
813            block_states: Vec::new(),
814            biomes: Vec::new(),
815            sections: Vec::new(),
816            block_entities: Vec::new(),
817            entities: Vec::new(),
818            block_ticks: Vec::new(),
819            fluid_ticks: Vec::new(),
820            heightmaps: Vec::new(),
821            light: PersistentLightData::default(),
822            carving_mask: None,
823            postprocessing: Vec::new(),
824            structure_starts: Vec::new(),
825            structure_references: Vec::new(),
826            pois: Vec::new(),
827        };
828        let encoded = wincode::serialize(&persistent).expect("test chunk should encode");
829        let payload = zstd::encode_all(encoded.as_slice(), 1).expect("test chunk should compress");
830        write_test_region(&directory, pos, &payload, payload.len() as u32)
831            .await
832            .expect("test region should be written");
833
834        let manager = RegionManager::new(&directory);
835        assert!(
836            manager
837                .acquire_chunk(pos)
838                .await
839                .expect("region should open")
840        );
841        let loaded = manager
842            .load_chunk(pos, 0, 16, Weak::new(), &test_thread_pool())
843            .await
844            .expect("semantic corruption should be handled");
845        assert!(loaded.is_none());
846        assert!(
847            !manager
848                .chunk_exists(pos)
849                .await
850                .expect("slot should be cleared")
851        );
852        manager
853            .release_chunk(pos)
854            .await
855            .expect("region should release");
856        fs::remove_dir_all(directory)
857            .await
858            .expect("test directory should be removable");
859    }
860
861    #[tokio::test]
862    async fn incomplete_payload_read_is_an_error_and_keeps_slot() {
863        let directory = test_directory("short-read");
864        let pos = ChunkPos::new(0, 0);
865        write_test_region(&directory, pos, &[1, 2, 3], 128)
866            .await
867            .expect("test region should be written");
868
869        let manager = RegionManager::new(&directory);
870        assert!(
871            manager
872                .acquire_chunk(pos)
873                .await
874                .expect("region should open")
875        );
876        let Err(error) = manager
877            .load_chunk(pos, 0, 16, Weak::new(), &test_thread_pool())
878            .await
879        else {
880            panic!("short filesystem read must not be treated as payload corruption");
881        };
882        assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof);
883        assert!(manager.chunk_exists(pos).await.expect("slot should remain"));
884        manager
885            .release_chunk(pos)
886            .await
887            .expect("region should release");
888        assert_slot_exists_on_disk(&directory, pos).await;
889        fs::remove_dir_all(directory)
890            .await
891            .expect("test directory should be removable");
892    }
893
894    #[tokio::test]
895    async fn invalid_world_geometry_is_not_classified_as_chunk_corruption() {
896        let directory = test_directory("invalid-world-range");
897        let pos = ChunkPos::new(0, 0);
898        let payload = b"payload must not be decoded";
899        write_test_region(&directory, pos, payload, payload.len() as u32)
900            .await
901            .expect("test region should be written");
902
903        let manager = RegionManager::new(&directory);
904        assert!(
905            manager
906                .acquire_chunk(pos)
907                .await
908                .expect("region should open")
909        );
910        let Err(error) = manager
911            .load_chunk(pos, 1, 16, Weak::new(), &test_thread_pool())
912            .await
913        else {
914            panic!("invalid world geometry must fail before decoding the chunk");
915        };
916        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
917        assert!(manager.chunk_exists(pos).await.expect("slot should remain"));
918        manager
919            .release_chunk(pos)
920            .await
921            .expect("region should release");
922        assert_slot_exists_on_disk(&directory, pos).await;
923        fs::remove_dir_all(directory)
924            .await
925            .expect("test directory should be removable");
926    }
927
928    #[tokio::test]
929    async fn invalid_chunk_status_byte_is_a_structural_error_and_is_preserved() {
930        let directory = test_directory("invalid-status");
931        let pos = ChunkPos::new(0, 0);
932        let payload = b"payload must not be decoded";
933        write_test_region(&directory, pos, payload, payload.len() as u32)
934            .await
935            .expect("test region should be written");
936
937        let region_pos = RegionPos::from_chunk(pos.0.x, pos.0.y);
938        let path = directory.join(region_pos.filename());
939        let mut file = OpenOptions::new()
940            .write(true)
941            .open(&path)
942            .await
943            .expect("test region should reopen for corruption");
944        file.seek(io::SeekFrom::Start(FILE_HEADER_SIZE as u64 + 7))
945            .await
946            .expect("status byte should be seekable");
947        file.write_all(&[u8::MAX])
948            .await
949            .expect("status byte should be writable");
950        file.flush().await.expect("status byte should be flushed");
951        drop(file);
952
953        let manager = RegionManager::new(&directory);
954        let Err(error) = manager.acquire_chunk(pos).await else {
955            panic!("invalid status byte must reject the region header");
956        };
957        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
958
959        let mut file = File::open(path)
960            .await
961            .expect("rejected region should remain readable");
962        file.seek(io::SeekFrom::Start(FILE_HEADER_SIZE as u64 + 7))
963            .await
964            .expect("status byte should remain seekable");
965        let mut status = [0];
966        file.read_exact(&mut status)
967            .await
968            .expect("status byte should remain readable");
969        assert_eq!(status[0], u8::MAX);
970
971        fs::remove_dir_all(directory)
972            .await
973            .expect("test directory should be removable");
974    }
975}