1use 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
42pub struct RegionManager {
47 base_path: PathBuf,
49 regions: AsyncRwLock<FxHashMap<RegionPos, RegionHandle>>,
51}
52
53pub struct PreparedChunkSave {
56 pub pos: ChunkPos,
58 pub status: ChunkStatus,
60 pub persistent: PersistentChunk<'static>,
62 pub handled_runtime_entity_ids: Vec<i32>,
64}
65
66struct RegionHandle {
68 file: File,
70 header: RegionHeader,
72 loaded_chunk_count: usize,
74 header_dirty: bool,
76 file_sectors: u32,
78}
79
80impl RegionManager {
81 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 fn region_path(&self, pos: RegionPos) -> PathBuf {
95 self.base_path.join(pos.filename())
96 }
97
98 async fn open_region(&self, pos: RegionPos) -> io::Result<RegionHandle> {
100 let path = self.region_path(pos);
101
102 if !path.exists() {
103 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 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 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 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 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 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 let mut header_bytes = [0u8; FILE_HEADER_SIZE];
178 header_bytes[0..4].copy_from_slice(®ION_MAGIC);
179 header_bytes[4..6].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
180 file.write_all(&header_bytes).await?;
181
182 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 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 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(®ion_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 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 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 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 #[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 let we_opened_region = !regions.contains_key(®ion_pos);
362
363 let handle = if let Some(handle) = regions.get_mut(®ion_pos) {
365 handle
366 } else {
367 let handle = self.open_region(region_pos).await?;
368 regions.insert(region_pos, handle);
369 regions.get_mut(®ion_pos).expect("just inserted")
370 };
371
372 let sectors_needed = compressed.len().div_ceil(SECTOR_SIZE) as u32;
374 let old_entry = handle.header.entries[index];
375
376 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 Self::write_chunk_data(
387 &mut handle.file,
388 sector_offset,
389 &compressed,
390 &mut handle.file_sectors,
391 )
392 .await?;
393
394 handle.header.entries[index] =
396 super::format::ChunkEntry::new(sector_offset, compressed.len() as u32, status);
397
398 if we_opened_region && handle.loaded_chunk_count == 0 {
401 Self::write_header(&mut handle.file, &handle.header).await?;
402 regions.remove(®ion_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 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 let Some(handle) = regions.get_mut(®ion_pos) else {
468 log::warn!("load_chunk called without acquire_chunk for region {region_pos:?}");
469 return Ok(None);
470 };
471
472 let entry = handle.header.entries[index];
474 if !entry.exists() {
475 return Ok(None);
476 }
477
478 Self::validate_chunk_entry(entry, handle.file_sectors)?;
481
482 let compressed =
484 Self::read_chunk_data(&mut handle.file, entry.sector_offset, entry.size_bytes)
485 .await?;
486 (compressed, entry)
487 };
488
489 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 #[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 let handle = if let Some(handle) = regions.get_mut(®ion_pos) {
564 handle
565 } else {
566 let handle = self.open_region(region_pos).await?;
568 regions.insert(region_pos, handle);
569 regions.get_mut(®ion_pos).expect("just inserted")
570 };
571
572 let exists = handle.header.entries[index].exists();
574
575 handle.loaded_chunk_count += 1;
577
578 Ok(exists)
579 }
580
581 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(®ion_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(®ion_pos)
601 && handle.header_dirty
602 {
603 Self::write_header(&mut handle.file, &handle.header).await?;
604 }
605
606 Ok(())
607 }
608
609 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 if let Some(handle) = regions.get(®ion_pos) {
619 return Ok(handle.header.entries[index].exists());
620 }
621
622 drop(regions);
623
624 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 file.seek(io::SeekFrom::Start(FILE_HEADER_SIZE as u64))
634 .await?;
635
636 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 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 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 }
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(®ION_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}