Skip to main content

steel_core/chunk_saver/
format.rs

1//! Data structures for the chunk persistence format.
2//!
3//! ## Format Overview
4//!
5//! Region files use a sector-based format with a fixed header for fast random access:
6//!
7//! ```text
8//! ┌─────────────────────────────────────────────────────┐
9//! │ Magic (4 bytes): "STLR"                             │
10//! │ Version (2 bytes): u16                              │
11//! │ Padding (2 bytes): reserved                         │
12//! ├─────────────────────────────────────────────────────┤
13//! │ Header: 1024 entries × 8 bytes = 8KB                │
14//! │   Each entry: offset (u32) + size (u24) + flags (u8)│
15//! ├─────────────────────────────────────────────────────┤
16//! │ Chunk data in 4KB sectors                           │
17//! │   [chunk data padded to 4KB boundary]               │
18//! │   [chunk data padded to 4KB boundary]               │
19//! │   ...                                               │
20//! └─────────────────────────────────────────────────────┘
21//! ```
22//!
23//! ## Design
24//!
25//! Each chunk stores its own block state and biome palettes, making chunks
26//! self-contained and avoiding expensive region-wide table rebuilds.
27//!
28//! Block data uses power-of-2 bit packing (1, 2, 4, 8, 16 bits) to avoid entries
29//! spanning u64 boundaries.
30
31use glam::IVec3;
32use steel_utils::{BoundingBox, Identifier, PackedChunkPos};
33use wincode::{SchemaRead, SchemaWrite};
34
35use crate::chunk::status::ChunkStatus;
36
37/// Magic bytes for region file identification: "STLR" (Steel Region)
38pub const REGION_MAGIC: [u8; 4] = *b"STLR";
39
40/// Current format version. Increment when making breaking changes.
41/// v3: Added entity persistence (`PersistentEntity`).
42/// v4: Added scheduled tick persistence (`PersistentTick`).
43/// v5: Added heightmap persistence (`PersistentHeightmap`).
44/// v6: Added structure start and structure reference persistence.
45/// v7: Added POI persistence (`PersistentPoi`).
46/// v8: Added typed jigsaw piece-state persistence.
47/// v9: Added proto chunk carving mask persistence and typed packed chunk references.
48/// v10: Added template piece clip and postprocess persistence.
49/// v11: Added template piece placement adjustment persistence.
50/// v12: Added igloo template marker, placement adjustment, and postprocess persistence.
51/// v13: Split template processor persistence and added ruined-portal processors.
52/// v14: Added buried treasure procedural piece persistence.
53/// v15: Added procedural structure-piece payload persistence.
54/// v16: Added entity fall distance persistence.
55/// v17: Added entity `NoGravity` persistence.
56/// v18: Added entity `Invulnerable` persistence.
57/// v19: Added shared entity save-data persistence.
58/// v20: Added chunk-owned light section persistence.
59/// v21: Matched vanilla scheduled-tick persistence by rebuilding sub-tick order on load.
60/// v22: Preserve Vanilla pending `DUMMY` block entities across chunk stages.
61/// v23: Replaced Noise, Surface, and Carvers with Terrain status; removed persisted carving masks.
62pub const FORMAT_VERSION: u16 = 23;
63
64/// Number of chunks per region side (32×32 = 1024 chunks per region).
65pub const REGION_SIZE: usize = 32;
66
67/// Total chunks in a region.
68pub const CHUNKS_PER_REGION: usize = REGION_SIZE * REGION_SIZE;
69
70/// Number of blocks per section side (16×16×16 = 4096 blocks per section).
71pub const SECTION_SIZE: usize = 16;
72
73/// Total blocks in a section.
74pub const BLOCKS_PER_SECTION: usize = SECTION_SIZE * SECTION_SIZE * SECTION_SIZE;
75
76/// Number of biome cells per section side (4×4×4 = 64 biomes per section).
77pub const BIOME_SIZE: usize = 4;
78
79/// Total biome cells in a section.
80pub const BIOMES_PER_SECTION: usize = BIOME_SIZE * BIOME_SIZE * BIOME_SIZE;
81
82/// Sector size in bytes (4KB, matches modern disk physical sectors).
83pub const SECTOR_SIZE: usize = 4096;
84
85/// Size of the file header (magic + version + padding).
86pub const FILE_HEADER_SIZE: usize = 8;
87
88/// Size of the chunk location table (1024 entries × 8 bytes).
89pub const CHUNK_TABLE_SIZE: usize = CHUNKS_PER_REGION * 8;
90
91/// Total header size (file header + chunk table).
92pub const TOTAL_HEADER_SIZE: usize = FILE_HEADER_SIZE + CHUNK_TABLE_SIZE;
93
94/// First sector where chunk data can be stored.
95/// Header takes `ceil(TOTAL_HEADER_SIZE` / `SECTOR_SIZE`) = 3 sectors (8 + 8192 = 8200 bytes).
96pub const FIRST_DATA_SECTOR: u32 = 3;
97
98/// Maximum chunk size in bytes (16MB - should be plenty).
99pub const MAX_CHUNK_SIZE: usize = 16 * 1024 * 1024;
100
101/// Entry in the chunk location table.
102///
103/// Layout (8 bytes total):
104/// - offset: u32 - sector offset (0 = chunk doesn't exist)
105/// - size: u24 - compressed size in bytes
106/// - flags: u8 - status and flags
107#[derive(Clone, Copy, PartialEq, Eq)]
108pub struct ChunkEntry {
109    /// Sector offset from start of file. 0 means chunk doesn't exist.
110    /// Multiply by `SECTOR_SIZE` to get byte offset.
111    pub sector_offset: u32,
112    /// Size of compressed chunk data in bytes (stored as u24, max ~16MB).
113    pub size_bytes: u32,
114    /// Chunk status (generation state).
115    pub status: ChunkStatus,
116}
117
118impl ChunkEntry {
119    /// Creates a new chunk entry.
120    #[must_use]
121    pub const fn new(sector_offset: u32, size_bytes: u32, status: ChunkStatus) -> Self {
122        Self {
123            sector_offset,
124            size_bytes,
125            status,
126        }
127    }
128
129    /// Returns true if this entry represents an existing chunk.
130    #[must_use]
131    pub const fn exists(&self) -> bool {
132        self.sector_offset != 0
133    }
134
135    /// Creates an empty/non-existent chunk entry.
136    #[must_use]
137    pub const fn empty() -> Self {
138        Self {
139            sector_offset: 0,
140            size_bytes: 0,
141            status: ChunkStatus::Empty,
142        }
143    }
144
145    /// Calculates the number of sectors this chunk occupies.
146    #[must_use]
147    pub const fn sector_count(&self) -> u32 {
148        if self.size_bytes == 0 {
149            0
150        } else {
151            (self.size_bytes as usize).div_ceil(SECTOR_SIZE) as u32
152        }
153    }
154
155    /// Serializes to 8 bytes: [offset: 4][size: 3][flags: 1]
156    #[must_use]
157    pub const fn to_bytes(self) -> [u8; 8] {
158        let offset_bytes = self.sector_offset.to_le_bytes();
159        let size_bytes = self.size_bytes.to_le_bytes();
160        let flags = self.status.get_index() as u8;
161        [
162            offset_bytes[0],
163            offset_bytes[1],
164            offset_bytes[2],
165            offset_bytes[3],
166            size_bytes[0],
167            size_bytes[1],
168            size_bytes[2],
169            flags,
170        ]
171    }
172
173    /// Deserializes from 8 bytes.
174    #[must_use]
175    pub fn from_bytes(bytes: [u8; 8]) -> Option<Self> {
176        let sector_offset = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
177        let size_bytes = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], 0]);
178        let status = if sector_offset == 0 {
179            ChunkStatus::Empty
180        } else {
181            ChunkStatus::from_index(bytes[7] as usize)?
182        };
183        Some(Self {
184            sector_offset,
185            size_bytes,
186            status,
187        })
188    }
189}
190
191impl Default for ChunkEntry {
192    fn default() -> Self {
193        Self::empty()
194    }
195}
196
197/// Region header containing chunk location table.
198pub struct RegionHeader {
199    /// Chunk entries (1024 = 32×32).
200    pub entries: Box<[ChunkEntry; CHUNKS_PER_REGION]>,
201}
202
203impl RegionHeader {
204    /// Creates an empty header with no chunks.
205    #[must_use]
206    pub fn new() -> Self {
207        Self {
208            entries: Box::new([ChunkEntry::default(); CHUNKS_PER_REGION]),
209        }
210    }
211
212    /// Gets the local index for a chunk position within this region.
213    #[must_use]
214    pub const fn chunk_index(local_x: usize, local_z: usize) -> usize {
215        debug_assert!(local_x < REGION_SIZE);
216        debug_assert!(local_z < REGION_SIZE);
217        local_z * REGION_SIZE + local_x
218    }
219
220    /// Converts a chunk index back to local coordinates.
221    #[must_use]
222    pub const fn index_to_local(index: usize) -> (usize, usize) {
223        debug_assert!(index < CHUNKS_PER_REGION);
224        (index % REGION_SIZE, index / REGION_SIZE)
225    }
226
227    /// Serializes the header to bytes.
228    #[must_use]
229    pub fn to_bytes(&self) -> Vec<u8> {
230        let mut bytes = Vec::with_capacity(CHUNK_TABLE_SIZE);
231        for entry in &self.entries {
232            bytes.extend_from_slice(&entry.to_bytes());
233        }
234        bytes
235    }
236
237    /// Deserializes the header from bytes.
238    ///
239    /// # Panics
240    /// Panics if bytes length is not exactly `CHUNK_TABLE_SIZE`.
241    pub fn from_bytes(bytes: &[u8]) -> Result<Self, usize> {
242        assert_eq!(bytes.len(), CHUNK_TABLE_SIZE);
243        let mut entries = Box::new([ChunkEntry::default(); CHUNKS_PER_REGION]);
244        for (i, &chunk) in bytes.as_chunks::<8>().0.iter().enumerate() {
245            let Some(entry) = ChunkEntry::from_bytes(chunk) else {
246                return Err(i);
247            };
248            entries[i] = entry;
249        }
250        Ok(Self { entries })
251    }
252
253    /// Finds a contiguous range of free sectors for allocation.
254    ///
255    /// Returns the starting sector offset, or `None` if no suitable range exists.
256    #[must_use]
257    pub fn find_free_sectors(&self, sectors_needed: u32, file_sectors: u32) -> u32 {
258        if sectors_needed == 0 {
259            return FIRST_DATA_SECTOR;
260        }
261
262        // Build a list of (start, end) ranges for used sectors
263        let mut used_ranges: Vec<(u32, u32)> = self
264            .entries
265            .iter()
266            .filter(|e| e.exists())
267            .map(|e| (e.sector_offset, e.sector_offset + e.sector_count()))
268            .collect();
269        used_ranges.sort_by_key(|r| r.0);
270
271        // Try to find a gap between used ranges
272        let mut current_sector = FIRST_DATA_SECTOR;
273        for (start, end) in used_ranges {
274            if start >= current_sector + sectors_needed {
275                // Found a gap
276                return current_sector;
277            }
278            current_sector = current_sector.max(end);
279        }
280
281        // No gap found, append at the end
282        current_sector.max(file_sectors)
283    }
284}
285
286impl Default for RegionHeader {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292/// A block state with its identifier and properties.
293#[derive(SchemaWrite, SchemaRead, Clone, PartialEq, Eq, Hash, Debug)]
294pub struct PersistentBlockState<'a> {
295    /// Block identifier (e.g., "`minecraft:oak_stairs`").
296    pub name: Identifier,
297    /// Block properties as key-value pairs (e.g., [("facing", "north")]).
298    pub properties: Vec<(&'a str, &'a str)>,
299}
300
301/// A heightmap stored with a chunk.
302///
303/// Height values are stored relative to `min_y` (same as the runtime `Heightmap`).
304/// Type discriminants: 0=WorldSurface, 1=MotionBlocking, 2=MotionBlockingNoLeaves, 3=OceanFloor,
305/// 4=WorldSurfaceWg, 5=OceanFloorWg.
306#[derive(SchemaWrite, SchemaRead)]
307pub struct PersistentHeightmap {
308    /// Heightmap type discriminant.
309    pub heightmap_type: u8,
310    /// 256 height values (one per column), stored relative to `min_y`.
311    pub data: Vec<u16>,
312}
313
314/// Chunk-owned light data stored with a chunk.
315#[derive(SchemaWrite, SchemaRead, Default)]
316pub struct PersistentLightData {
317    /// Block-light sections indexed by light-section index.
318    pub block: Vec<PersistentLightSection>,
319    /// Sky-light sections indexed by light-section index.
320    pub sky: Vec<PersistentLightSection>,
321}
322
323/// One persisted chunk-owned light section.
324#[derive(SchemaWrite, SchemaRead)]
325pub enum PersistentLightSection {
326    /// Present zero-filled section without backing bytes.
327    Uninitialized {
328        /// Index in the chunk's padded light-section array.
329        section_index: u32,
330    },
331    /// Visible initialized light bytes.
332    Initialized {
333        /// Index in the chunk's padded light-section array.
334        section_index: u32,
335        /// Packed 4-bit light values.
336        data: Vec<u8>,
337    },
338    /// Initialized bytes hidden from vanilla packet conversion.
339    Internal {
340        /// Index in the chunk's padded light-section array.
341        section_index: u32,
342        /// Packed 4-bit light values.
343        data: Vec<u8>,
344    },
345}
346
347impl PersistentLightSection {
348    /// Returns the padded light-section index.
349    #[must_use]
350    pub const fn section_index(&self) -> u32 {
351        match self {
352            Self::Uninitialized { section_index }
353            | Self::Initialized { section_index, .. }
354            | Self::Internal { section_index, .. } => *section_index,
355        }
356    }
357}
358
359/// A persistent chunk containing sections and metadata.
360///
361/// Each chunk stores its own block state and biome palettes, making it
362/// self-contained. Sections reference indices into these chunk-level palettes.
363#[derive(SchemaWrite, SchemaRead)]
364pub struct PersistentChunk<'a> {
365    /// Unix timestamp of last modification.
366    pub last_modified: u32,
367    /// Block states used in this chunk. Sections reference indices into this.
368    pub block_states: Vec<PersistentBlockState<'a>>,
369    /// Biomes used in this chunk. Sections reference indices into this.
370    pub biomes: Vec<Identifier>,
371    /// Vertical sections (typically 24 for -64 to 319).
372    pub sections: Vec<PersistentSection>,
373    /// Block entities (chests, signs, etc.).
374    pub block_entities: Vec<PersistentBlockEntity>,
375    /// Entities in this chunk (excludes players and non-serializable types).
376    pub entities: Vec<PersistentEntity>,
377    /// Scheduled block ticks pending in this chunk.
378    pub block_ticks: Vec<PersistentTick>,
379    /// Scheduled fluid ticks pending in this chunk.
380    pub fluid_ticks: Vec<PersistentTick>,
381    /// Materialized heightmaps allowed by the chunk's persisted status.
382    pub heightmaps: Vec<PersistentHeightmap>,
383    /// Chunk-owned light sections.
384    pub light: PersistentLightData,
385    /// Pending postprocessing offsets grouped by section index.
386    pub postprocessing: Vec<Vec<u16>>,
387    /// Structure starts originating in this chunk.
388    pub structure_starts: Vec<PersistentStructureStart>,
389    /// References to structures from nearby origin chunks.
390    pub structure_references: Vec<PersistentStructureReference>,
391    /// POI occupancy data (ticket state for beds, workstations, etc.).
392    pub pois: Vec<PersistentPoi>,
393}
394
395/// A 16×16×16 section of a chunk.
396#[derive(SchemaWrite, SchemaRead)]
397pub enum PersistentSection {
398    /// All blocks are the same type.
399    Homogeneous {
400        /// Index into chunk's `block_states` palette.
401        block_state: u16,
402        /// Biome data for this section.
403        biomes: PersistentBiomeData,
404    },
405    /// Multiple block types present.
406    Heterogeneous {
407        /// Section-local palette: indices into chunk's `block_states` palette.
408        palette: Vec<u16>,
409        /// Bits per entry (1, 2, 4, 8, or 16).
410        bits_per_entry: u8,
411        /// Packed block indices into section-local palette. 4096 entries.
412        block_data: Box<[u64]>,
413        /// Biome data for this section.
414        biomes: PersistentBiomeData,
415    },
416}
417
418/// Biome data for a section (4×4×4 = 64 cells).
419#[derive(SchemaWrite, SchemaRead)]
420pub enum PersistentBiomeData {
421    /// All 64 biome cells are the same.
422    Homogeneous {
423        /// Index into chunk's `biomes` palette.
424        biome: u16,
425    },
426    /// Multiple biomes present.
427    Heterogeneous {
428        /// Section-local palette: indices into chunk's `biomes` palette.
429        palette: Vec<u16>,
430        /// Bits per entry (1, 2, 4, or 8).
431        bits_per_entry: u8,
432        /// Packed biome indices into section-local palette. 64 entries.
433        biome_data: Box<[u64]>,
434    },
435}
436
437/// A block entity (tile entity) stored with a chunk.
438///
439/// Block entities are serialized with their type and NBT data.
440/// The NBT data is stored as raw bytes (simdnbt binary format).
441#[derive(SchemaWrite, SchemaRead)]
442pub struct PersistentBlockEntity {
443    /// Relative X position within chunk (0-15).
444    pub x: u8,
445    /// Absolute Y position (world height).
446    pub y: i16,
447    /// Relative Z position within chunk (0-15).
448    pub z: u8,
449    /// Block entity type identifier, or `None` for Vanilla's pending `DUMMY` marker.
450    pub entity_type: Option<Identifier>,
451    /// Serialized NBT data (simdnbt binary format).
452    /// Contains entity-specific data and stored components without type/position metadata.
453    pub nbt_data: Vec<u8>,
454}
455
456/// An entity stored with a chunk.
457///
458/// Unlike vanilla which stores entities in separate region files,
459/// Steel stores entities inline with chunk data for simplicity.
460/// Base entity fields are stored directly; type-specific data is in `nbt_data`.
461#[derive(Debug, Clone, SchemaWrite, SchemaRead)]
462pub struct PersistentEntity {
463    /// Entity type identifier (e.g., "minecraft:item").
464    pub entity_type: Identifier,
465    /// Persistent UUID (16 bytes).
466    pub uuid: [u8; 16],
467    /// Position (x, y, z) in absolute world coordinates.
468    pub pos: [f64; 3],
469    /// Velocity (x, y, z) in blocks per tick.
470    pub motion: [f64; 3],
471    /// Rotation (yaw, pitch) in degrees.
472    pub rotation: [f32; 2],
473    /// Accumulated vanilla fall distance.
474    pub fall_distance: f64,
475    /// Remaining fire duration, in ticks.
476    pub remaining_fire_ticks: i32,
477    /// Synchronized vanilla `TicksFrozen`.
478    pub ticks_frozen: i32,
479    /// Whether the entity is currently standing in powder snow.
480    pub is_in_powder_snow: bool,
481    /// Whether the entity was standing in powder snow last tick.
482    pub was_in_powder_snow: bool,
483    /// Whether the entity renders as on fire regardless of actual fire ticks.
484    pub has_visual_fire: bool,
485    /// Whether entity is on ground.
486    pub on_ground: bool,
487    /// Shared vanilla `NoGravity` flag.
488    pub no_gravity: bool,
489    /// Shared vanilla `Invulnerable` flag.
490    pub invulnerable: bool,
491    /// Synchronized vanilla `Air` value.
492    pub air_supply: i32,
493    /// Vanilla dimension-change portal cooldown.
494    pub portal_cooldown: i32,
495    /// Optional vanilla custom name stored as a root compound containing `CustomName`.
496    pub custom_name_nbt: Vec<u8>,
497    /// Synchronized vanilla custom-name visibility flag.
498    pub custom_name_visible: bool,
499    /// Synchronized vanilla silent flag.
500    pub silent: bool,
501    /// Server-owned vanilla glowing tag.
502    pub glowing: bool,
503    /// Vanilla scoreboard tags.
504    pub tags: Vec<String>,
505    /// Vanilla custom data compound.
506    pub custom_data_nbt: Vec<u8>,
507    /// Type-specific NBT data from `save_additional`.
508    pub nbt_data: Vec<u8>,
509    /// Direct passengers nested under this entity.
510    pub passengers: Vec<PersistentEntity>,
511}
512
513/// A scheduled tick stored with a chunk.
514///
515/// Stores the tick's position relative to the chunk, its remaining delay,
516/// priority, and the block/fluid identifier. Sub-tick order is rebuilt on load.
517#[derive(SchemaWrite, SchemaRead)]
518pub struct PersistentTick {
519    /// Relative X position within chunk (0-15).
520    pub x: u8,
521    /// Absolute Y position (world height).
522    pub y: i16,
523    /// Relative Z position within chunk (0-15).
524    pub z: u8,
525    /// Remaining delay in game ticks until this tick fires.
526    pub delay: i32,
527    /// Tick priority as `i8` (maps to `TickPriority` enum, -3 to 3).
528    pub priority: i8,
529    /// Block or fluid identifier (e.g., "`minecraft:stone_button`").
530    pub tick_type: Identifier,
531}
532
533/// A structure start stored with a chunk.
534///
535/// Only valid structure starts (those with at least one piece) are stored.
536/// Vanilla's `INVALID_START` sentinel is represented by absence from the vec.
537#[derive(SchemaWrite, SchemaRead)]
538pub struct PersistentStructureStart {
539    /// Structure type identifier (e.g., "minecraft:village").
540    pub structure: Identifier,
541    /// Origin chunk X coordinate.
542    pub chunk_x: i32,
543    /// Origin chunk Z coordinate.
544    pub chunk_z: i32,
545    /// Number of chunks referencing this structure start.
546    pub references: i32,
547    /// The pieces composing this structure.
548    pub pieces: Vec<PersistentStructurePiece>,
549}
550
551/// A structure bounding box stored as six scalar coordinates.
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SchemaWrite, SchemaRead)]
553pub struct PersistentBoundingBox {
554    /// Minimum X coordinate.
555    pub min_x: i32,
556    /// Minimum Y coordinate.
557    pub min_y: i32,
558    /// Minimum Z coordinate.
559    pub min_z: i32,
560    /// Maximum X coordinate.
561    pub max_x: i32,
562    /// Maximum Y coordinate.
563    pub max_y: i32,
564    /// Maximum Z coordinate.
565    pub max_z: i32,
566}
567
568impl PersistentBoundingBox {
569    /// Converts a runtime bounding box to its persistent representation.
570    #[must_use]
571    pub const fn from_bounding_box(bounding_box: BoundingBox) -> Self {
572        Self {
573            min_x: bounding_box.min_x(),
574            min_y: bounding_box.min_y(),
575            min_z: bounding_box.min_z(),
576            max_x: bounding_box.max_x(),
577            max_y: bounding_box.max_y(),
578            max_z: bounding_box.max_z(),
579        }
580    }
581
582    /// Converts this persistent representation to a runtime bounding box.
583    #[must_use]
584    pub const fn to_bounding_box(self) -> BoundingBox {
585        BoundingBox::new(
586            IVec3::new(self.min_x, self.min_y, self.min_z),
587            IVec3::new(self.max_x, self.max_y, self.max_z),
588        )
589    }
590}
591
592/// A structure piece stored with a chunk.
593///
594/// Common fields are stored directly; type-specific placement data is in
595/// `payload`.
596#[derive(SchemaWrite, SchemaRead)]
597pub struct PersistentStructurePiece {
598    /// Piece type identifier (e.g., "minecraft:jigsaw").
599    pub piece_type: Identifier,
600    /// Bounding box of this piece in world coordinates.
601    pub bounding_box: PersistentBoundingBox,
602    /// Generation depth in the piece tree.
603    pub gen_depth: i32,
604    /// 2D direction orientation (-1 = none, 0-3 = south/west/north/east).
605    pub orientation: i8,
606    /// Type-specific structure piece placement data.
607    pub payload: PersistentStructurePiecePayload,
608    /// Offset from piece minY to terrain ground level.
609    pub ground_level_delta: i32,
610    /// Projection mode: -1 = none, 0 = rigid, 1 = terrain matching.
611    pub projection: i8,
612    /// Jigsaw junctions used by terrain adaptation.
613    pub junctions: Vec<PersistentJigsawJunction>,
614}
615
616/// Persisted type-specific structure piece placement data.
617#[derive(SchemaWrite, SchemaRead)]
618pub enum PersistentStructurePiecePayload {
619    /// Jigsaw pool piece payload.
620    Jigsaw(PersistentJigsawPieceData),
621    /// Template-backed non-jigsaw payload.
622    Template(PersistentTemplatePieceData),
623    /// Procedural family payload.
624    Procedural(PersistentProceduralPieceData),
625}
626
627/// Steel-native persistent state for a jigsaw pool piece.
628#[derive(SchemaWrite, SchemaRead)]
629pub struct PersistentJigsawPieceData {
630    /// Selected pool element.
631    pub pool_element: PersistentPoolElement,
632    /// World-space template origin.
633    pub position: [i32; 3],
634    /// Rotation: 0=none, `1=clockwise_90`, `2=clockwise_180`, `3=counterclockwise_90`.
635    pub rotation: i8,
636    /// Liquid settings: `0=apply_waterlogging`, `1=ignore_waterlogging`.
637    pub liquid_settings: i8,
638}
639
640/// Persisted template-backed non-jigsaw piece data.
641#[derive(SchemaWrite, SchemaRead)]
642pub struct PersistentTemplatePieceData {
643    /// Structure template identifier.
644    pub template_id: Identifier,
645    /// World-space template origin.
646    pub template_position: [i32; 3],
647    /// Rotation: 0=none, `1=clockwise_90`, `2=clockwise_180`, `3=counterclockwise_90`.
648    pub rotation: i8,
649    /// Mirror: `0=none`, `1=front_back`, `2=left_right`.
650    pub mirror: i8,
651    /// Rotation pivot in template-local block coordinates.
652    pub rotation_pivot: [i32; 3],
653    /// Early block-ignore processor: `0=none`, `1=structure_block`, `2=structure_and_air`.
654    pub block_ignore: i8,
655    /// Late block-ignore processor: `0=none`, `1=structure_block`, `2=structure_and_air`.
656    pub late_block_ignore: i8,
657    /// Processors applied during block placement.
658    pub processors: PersistentTemplateProcessorList,
659    /// Liquid settings: `0=apply_waterlogging`, `1=ignore_waterlogging`.
660    pub liquid_settings: i8,
661    /// Marker handling:
662    /// `0=ignore`, `1=data_markers`, `2=shipwreck`, `3=igloo`,
663    /// `4=ocean_ruin_small`, `5=ocean_ruin_large`, `6=end_city`, `7=woodland_mansion`.
664    pub marker_handling: i8,
665    /// Family-specific position adjustment before template block placement.
666    pub placement_adjustment: PersistentTemplatePlacementAdjustment,
667    /// Placement clip: `0=center_chunk`, `1=center_chunk_expanded_to_template`,
668    /// `2=center_chunk_contains_template_center_expanded_to_template`.
669    pub placement_clip: i8,
670    /// Postprocess: `0=none`, `1=nether_fossil`, `2=igloo_top`, `3=ruined_portal`.
671    pub post_process: i8,
672}
673
674/// Persisted processors for template-backed non-jigsaw pieces.
675#[derive(SchemaWrite, SchemaRead)]
676pub enum PersistentTemplateProcessorList {
677    /// Direct empty processor list.
678    Empty,
679    /// Registry-backed processor list.
680    Registry(Identifier),
681    /// Vanilla's hardcoded ocean-ruin processor sequence.
682    OceanRuin {
683        /// Ocean ruin biome temperature: 0=warm, 1=cold.
684        biome_temp: i8,
685        /// Block-rot integrity.
686        integrity: f32,
687    },
688    /// Vanilla's hardcoded ruined-portal processor sequence.
689    RuinedPortal {
690        /// Ruined-portal vertical placement.
691        vertical_placement: i8,
692        /// Whether cold lava/aging behavior is active.
693        cold: bool,
694        /// Block age processor mossiness.
695        mossiness: f32,
696        /// Whether structure air is preserved.
697        air_pocket: bool,
698        /// Whether netherrack can grow leaves.
699        overgrown: bool,
700        /// Whether vines can be added.
701        vines: bool,
702        /// Whether blackstone replacement is active.
703        replace_with_blackstone: bool,
704    },
705}
706
707/// Persisted template position adjustment.
708#[derive(SchemaWrite, SchemaRead)]
709pub enum PersistentTemplatePlacementAdjustment {
710    /// Place at the persisted template position.
711    None,
712    /// Shipwreck height adjustment state.
713    Shipwreck {
714        /// Whether this is the beached shipwreck variant.
715        is_beached: bool,
716        /// Whether the piece's terrain-based height adjustment has run.
717        height_adjusted: bool,
718    },
719    /// Igloo per-placement height adjustment.
720    Igloo {
721        /// Vanilla template offset for this igloo piece.
722        template_offset: [i32; 3],
723    },
724    /// Ocean ruin terrain height adjustment.
725    OceanRuin,
726}
727
728/// Persisted procedural piece data.
729#[derive(SchemaWrite, SchemaRead)]
730pub enum PersistentProceduralPieceData {
731    /// Procedural family whose placement state has not been captured yet.
732    Unimplemented,
733    /// Buried treasure chest placement.
734    BuriedTreasure,
735    /// Desert pyramid piece payload.
736    DesertPyramid(PersistentDesertPyramidPieceData),
737    /// Jungle temple piece payload.
738    JungleTemple(PersistentJungleTemplePieceData),
739    /// Mineshaft room/corridor/crossing/stairs payload.
740    Mineshaft(PersistentMineshaftPieceData),
741    /// Nether fortress bridge/castle payload.
742    NetherFortress(PersistentNetherFortressPieceData),
743    /// Ocean monument building payload.
744    OceanMonument(PersistentOceanMonumentPieceData),
745    /// Stronghold recursive piece payload.
746    Stronghold(PersistentStrongholdPieceData),
747    /// Swamp hut piece payload.
748    SwampHut(PersistentSwampHutPieceData),
749}
750
751/// Persisted stronghold door variant.
752#[derive(SchemaWrite, SchemaRead)]
753pub enum PersistentStrongholdSmallDoorType {
754    /// Three-block cave-air opening.
755    Opening,
756    /// Oak door framed by stone bricks.
757    WoodDoor,
758    /// Iron-bar grate opening.
759    Grates,
760    /// Iron door with stone buttons.
761    IronDoor,
762}
763
764/// Persisted piece-specific stronghold data.
765#[derive(SchemaWrite, SchemaRead)]
766pub enum PersistentStrongholdPieceData {
767    /// Straight corridor with optional side exits.
768    Straight {
769        /// Door type generated at this piece's entrance.
770        entry_door: PersistentStrongholdSmallDoorType,
771        /// Whether a corridor branches left from this piece.
772        left_child: bool,
773        /// Whether a corridor branches right from this piece.
774        right_child: bool,
775    },
776    /// Prison hall.
777    PrisonHall {
778        /// Door type generated at this piece's entrance.
779        entry_door: PersistentStrongholdSmallDoorType,
780    },
781    /// Left turn.
782    LeftTurn {
783        /// Door type generated at this piece's entrance.
784        entry_door: PersistentStrongholdSmallDoorType,
785    },
786    /// Right turn.
787    RightTurn {
788        /// Door type generated at this piece's entrance.
789        entry_door: PersistentStrongholdSmallDoorType,
790    },
791    /// Room crossing with one of five vanilla decorations.
792    RoomCrossing {
793        /// Door type generated at this piece's entrance.
794        entry_door: PersistentStrongholdSmallDoorType,
795        /// Index selecting this room's decoration variant.
796        crossing_type: i32,
797    },
798    /// Straight stair corridor.
799    StraightStairsDown {
800        /// Door type generated at this piece's entrance.
801        entry_door: PersistentStrongholdSmallDoorType,
802    },
803    /// Descending stairs, including the source/start piece.
804    StairsDown {
805        /// Door type generated at this piece's entrance.
806        entry_door: PersistentStrongholdSmallDoorType,
807        /// Whether this is the stronghold's starting piece.
808        is_source: bool,
809    },
810    /// Five-way crossing with low/high side exits.
811    FiveCrossing {
812        /// Door type generated at this piece's entrance.
813        entry_door: PersistentStrongholdSmallDoorType,
814        /// Whether the crossing has a low-level opening on the left.
815        left_low: bool,
816        /// Whether the crossing has a high-level opening on the left.
817        left_high: bool,
818        /// Whether the crossing has a low-level opening on the right.
819        right_low: bool,
820        /// Whether the crossing has a high-level opening on the right.
821        right_high: bool,
822    },
823    /// Corridor containing a loot chest.
824    ChestCorridor {
825        /// Door type generated at this piece's entrance.
826        entry_door: PersistentStrongholdSmallDoorType,
827        /// Whether the loot chest has already been placed.
828        has_placed_chest: bool,
829    },
830    /// Library room.
831    Library {
832        /// Door type generated at this piece's entrance.
833        entry_door: PersistentStrongholdSmallDoorType,
834        /// Whether the library uses the two-story variant.
835        is_tall: bool,
836    },
837    /// End portal room.
838    PortalRoom {
839        /// Whether the room's silverfish spawner has already been placed.
840        has_placed_spawner: bool,
841    },
842    /// Collision filler corridor.
843    FillerCorridor {
844        /// Length of the filler corridor along its connecting axis.
845        steps: i32,
846    },
847}
848
849/// Persisted piece-specific nether fortress data.
850#[derive(SchemaWrite, SchemaRead)]
851pub enum PersistentNetherFortressPieceData {
852    /// Bridge crossing piece.
853    BridgeCrossing,
854    /// Dead-end bridge filler piece.
855    BridgeEndFiller {
856        /// Seed for this piece's own RNG, used to vary the filler pattern.
857        self_seed: i32,
858    },
859    /// Straight bridge segment.
860    BridgeStraight,
861    /// Castle corridor stair segment.
862    CastleCorridorStairs,
863    /// Castle corridor T balcony segment.
864    CastleCorridorTBalcony,
865    /// Castle entrance room.
866    CastleEntrance,
867    /// Small castle corridor crossing.
868    CastleSmallCorridorCrossing,
869    /// Small castle corridor left turn.
870    CastleSmallCorridorLeftTurn {
871        /// Whether this corridor still needs a loot chest placed.
872        is_needing_chest: bool,
873    },
874    /// Small straight castle corridor.
875    CastleSmallCorridor,
876    /// Small castle corridor right turn.
877    CastleSmallCorridorRightTurn {
878        /// Whether this corridor still needs a loot chest placed.
879        is_needing_chest: bool,
880    },
881    /// Nether-wart stair room.
882    CastleStalkRoom,
883    /// Blaze-spawner throne room.
884    MonsterThrone {
885        /// Whether the throne room's blaze spawner has already been placed.
886        has_placed_spawner: bool,
887    },
888    /// Bridge room crossing.
889    RoomCrossing,
890    /// Bridge stair room.
891    StairsRoom,
892}
893
894/// Persisted desert pyramid piece payload.
895#[derive(SchemaWrite, SchemaRead)]
896pub struct PersistentDesertPyramidPieceData {
897    /// Vanilla `ScatteredFeaturePiece.heightPosition`; -1 means not height-adjusted yet.
898    pub height_position: i32,
899    /// Chest placement flags ordered by `Direction.get2DDataValue`.
900    pub has_placed_chest: [bool; 4],
901}
902
903/// Persisted jungle temple piece payload.
904#[derive(SchemaWrite, SchemaRead)]
905pub struct PersistentJungleTemplePieceData {
906    /// Vanilla `ScatteredFeaturePiece.heightPosition`; -1 means not height-adjusted yet.
907    pub height_position: i32,
908    /// Whether the main chest has already been placed.
909    pub placed_main_chest: bool,
910    /// Whether the hidden chest has already been placed.
911    pub placed_hidden_chest: bool,
912    /// Whether the first arrow-dispenser trap has already been placed.
913    pub placed_trap1: bool,
914    /// Whether the second arrow-dispenser trap has already been placed.
915    pub placed_trap2: bool,
916}
917
918/// Persisted mineshaft piece payload.
919#[derive(SchemaWrite, SchemaRead)]
920pub struct PersistentMineshaftPieceData {
921    /// Mineshaft type: 0=normal, 1=mesa.
922    pub mineshaft_type: i8,
923    /// Piece-specific mineshaft data.
924    pub kind: PersistentMineshaftPieceKind,
925}
926
927/// Persisted piece-specific mineshaft data.
928#[derive(SchemaWrite, SchemaRead)]
929pub enum PersistentMineshaftPieceKind {
930    /// Start room.
931    Room {
932        /// Child entrance boxes.
933        child_entrance_boxes: Vec<PersistentBoundingBox>,
934    },
935    /// Horizontal corridor.
936    Corridor {
937        /// Whether rails can generate through this corridor.
938        has_rails: bool,
939        /// Whether this is a cobweb-heavy cave-spider corridor.
940        spider_corridor: bool,
941        /// Whether the cave-spider spawner has already been placed.
942        has_placed_spider: bool,
943        /// Number of five-block corridor sections.
944        num_sections: i32,
945    },
946    /// Corridor crossing.
947    Crossing {
948        /// Direction: 0=south, 1=west, 2=north, 3=east.
949        direction: i8,
950        /// Whether the crossing has the upper floor.
951        is_two_floored: bool,
952    },
953    /// Stair segment.
954    Stairs,
955}
956
957/// Persisted swamp hut piece payload.
958#[derive(SchemaWrite, SchemaRead)]
959pub struct PersistentSwampHutPieceData {
960    /// Vanilla `ScatteredFeaturePiece.heightPosition`; -1 means not height-adjusted yet.
961    pub height_position: i32,
962    /// Whether the structure witch has already been spawned.
963    pub spawned_witch: bool,
964    /// Whether the structure black cat has already been spawned.
965    pub spawned_cat: bool,
966}
967
968/// Persisted ocean monument building payload.
969#[derive(SchemaWrite, SchemaRead)]
970pub struct PersistentOceanMonumentPieceData {
971    /// Internal child pieces generated by vanilla `MonumentBuilding`.
972    pub child_pieces: Vec<PersistentOceanMonumentChildPiece>,
973}
974
975/// Persisted internal ocean monument child piece.
976#[derive(SchemaWrite, SchemaRead)]
977pub struct PersistentOceanMonumentChildPiece {
978    /// World-space child bounding box after building-relative offset.
979    pub bounding_box: PersistentBoundingBox,
980    /// Child piece variant and variant-specific placement state.
981    pub kind: PersistentOceanMonumentChildPieceKind,
982}
983
984/// Persisted ocean monument child piece variant.
985#[derive(SchemaWrite, SchemaRead)]
986pub enum PersistentOceanMonumentChildPieceKind {
987    /// `OceanMonumentEntryRoom`.
988    EntryRoom {
989        /// Source room snapshot.
990        room: PersistentOceanMonumentRoomData,
991    },
992    /// `OceanMonumentCoreRoom`.
993    CoreRoom,
994    /// `OceanMonumentDoubleXRoom`.
995    DoubleXRoom {
996        /// Western room snapshot.
997        west: PersistentOceanMonumentRoomData,
998        /// Eastern room snapshot.
999        east: PersistentOceanMonumentRoomData,
1000    },
1001    /// `OceanMonumentDoubleXYRoom`.
1002    DoubleXYRoom {
1003        /// Lower western room.
1004        west: PersistentOceanMonumentRoomData,
1005        /// Lower eastern room.
1006        east: PersistentOceanMonumentRoomData,
1007        /// Upper western room.
1008        west_up: PersistentOceanMonumentRoomData,
1009        /// Upper eastern room.
1010        east_up: PersistentOceanMonumentRoomData,
1011    },
1012    /// `OceanMonumentDoubleYRoom`.
1013    DoubleYRoom {
1014        /// Lower room.
1015        room: PersistentOceanMonumentRoomData,
1016        /// Upper room.
1017        above: PersistentOceanMonumentRoomData,
1018    },
1019    /// `OceanMonumentDoubleYZRoom`.
1020    DoubleYZRoom {
1021        /// Southern lower room.
1022        south: PersistentOceanMonumentRoomData,
1023        /// Northern lower room.
1024        north: PersistentOceanMonumentRoomData,
1025        /// Southern upper room.
1026        south_up: PersistentOceanMonumentRoomData,
1027        /// Northern upper room.
1028        north_up: PersistentOceanMonumentRoomData,
1029    },
1030    /// `OceanMonumentDoubleZRoom`.
1031    DoubleZRoom {
1032        /// Southern room.
1033        south: PersistentOceanMonumentRoomData,
1034        /// Northern room.
1035        north: PersistentOceanMonumentRoomData,
1036    },
1037    /// `OceanMonumentSimpleRoom`.
1038    SimpleRoom {
1039        /// Room snapshot.
1040        room: PersistentOceanMonumentRoomData,
1041        /// Index selecting this room's structural design variant.
1042        main_design: i32,
1043    },
1044    /// `OceanMonumentSimpleTopRoom`.
1045    SimpleTopRoom {
1046        /// Room snapshot.
1047        room: PersistentOceanMonumentRoomData,
1048    },
1049    /// `OceanMonumentWingRoom`.
1050    WingRoom {
1051        /// Index selecting this room's structural design variant.
1052        main_design: i32,
1053    },
1054    /// `OceanMonumentPenthouse`.
1055    Penthouse,
1056}
1057
1058/// Persisted ocean monument room snapshot.
1059#[derive(SchemaWrite, SchemaRead)]
1060pub struct PersistentOceanMonumentRoomData {
1061    /// Vanilla room index.
1062    pub index: i32,
1063    /// Vanilla `hasOpening`, ordered by `Direction.get3DDataValue`.
1064    pub has_opening: [bool; 6],
1065    /// Whether `connections[UP] != null`.
1066    pub has_up_connection: bool,
1067}
1068
1069/// Persisted pool element selected during jigsaw assembly.
1070#[derive(SchemaWrite, SchemaRead)]
1071pub enum PersistentPoolElement {
1072    /// Single structure template piece.
1073    Single {
1074        /// Template location.
1075        location: Identifier,
1076        /// Processors applied during block placement.
1077        processors: PersistentProcessorList,
1078        /// Projection mode: 0 = rigid, 1 = terrain matching.
1079        projection: i8,
1080    },
1081    /// Legacy single piece.
1082    LegacySingle {
1083        /// Template location.
1084        location: Identifier,
1085        /// Processors applied during block placement.
1086        processors: PersistentProcessorList,
1087        /// Projection mode: 0 = rigid, 1 = terrain matching.
1088        projection: i8,
1089    },
1090    /// Empty placeholder element.
1091    Empty,
1092    /// Placed feature element.
1093    Feature {
1094        /// Feature identifier.
1095        feature: Identifier,
1096        /// Projection mode: 0 = rigid, 1 = terrain matching.
1097        projection: i8,
1098    },
1099    /// Group of sub-elements.
1100    List {
1101        /// Sub-elements.
1102        elements: Vec<PersistentPoolElement>,
1103        /// Projection mode: 0 = rigid, 1 = terrain matching.
1104        projection: i8,
1105    },
1106}
1107
1108/// Persisted processor list holder for single pool elements.
1109#[derive(SchemaWrite, SchemaRead)]
1110pub enum PersistentProcessorList {
1111    /// Direct empty processor list.
1112    Empty,
1113    /// Registry-backed processor list.
1114    Registry(Identifier),
1115}
1116
1117/// A persisted jigsaw junction used by Beardifier terrain adaptation.
1118#[derive(SchemaWrite, SchemaRead)]
1119pub struct PersistentJigsawJunction {
1120    /// World X.
1121    pub source_x: i32,
1122    /// Ground-adjusted Y.
1123    pub source_ground_y: i32,
1124    /// World Z.
1125    pub source_z: i32,
1126    /// Y delta between source and target.
1127    pub delta_y: i32,
1128    /// Destination projection: 0 = rigid, 1 = terrain matching.
1129    pub dest_projection: i8,
1130}
1131
1132/// A structure reference entry stored with a chunk.
1133///
1134/// References point to structure starts in nearby origin chunks.
1135#[derive(SchemaWrite, SchemaRead)]
1136pub struct PersistentStructureReference {
1137    /// Structure type identifier.
1138    pub structure: Identifier,
1139    /// Packed chunk positions of origin chunks.
1140    pub references: Vec<PackedChunkPos>,
1141}
1142
1143/// A point of interest's occupancy state stored with a chunk.
1144///
1145/// Only the position and remaining free tickets are persisted — the POI type
1146/// is derived from the block state on load via `scan_and_populate`.
1147#[derive(SchemaWrite, SchemaRead)]
1148pub struct PersistentPoi {
1149    /// Relative X position within chunk (0-15).
1150    pub x: u8,
1151    /// Absolute Y position (world height).
1152    pub y: i16,
1153    /// Relative Z position within chunk (0-15).
1154    pub z: u8,
1155    /// Number of tickets still available for claiming.
1156    pub free_tickets: u32,
1157}
1158
1159/// Position of a region in region coordinates.
1160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1161pub struct RegionPos {
1162    /// Region X coordinate (`chunk_x` / 32).
1163    pub x: i32,
1164    /// Region Z coordinate (`chunk_z` / 32).
1165    pub z: i32,
1166}
1167
1168impl RegionPos {
1169    /// Creates a new region position.
1170    #[must_use]
1171    pub const fn new(x: i32, z: i32) -> Self {
1172        Self { x, z }
1173    }
1174
1175    /// Converts a chunk position to a region position.
1176    #[must_use]
1177    pub const fn from_chunk(chunk_x: i32, chunk_z: i32) -> Self {
1178        Self {
1179            x: chunk_x.div_euclid(REGION_SIZE as i32),
1180            z: chunk_z.div_euclid(REGION_SIZE as i32),
1181        }
1182    }
1183
1184    /// Gets the local chunk coordinates within this region for a global chunk position.
1185    #[must_use]
1186    pub const fn local_chunk_pos(chunk_x: i32, chunk_z: i32) -> (usize, usize) {
1187        (
1188            chunk_x.rem_euclid(REGION_SIZE as i32) as usize,
1189            chunk_z.rem_euclid(REGION_SIZE as i32) as usize,
1190        )
1191    }
1192
1193    /// Returns the filename for this region (e.g., "r.0.-1.srg").
1194    #[must_use]
1195    pub fn filename(self) -> String {
1196        format!("r.{}.{}.srg", self.x, self.z)
1197    }
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202    use super::*;
1203
1204    #[test]
1205    fn persistent_block_state_properties_round_trip() {
1206        let state = PersistentBlockState {
1207            name: Identifier::vanilla_static("oak_stairs"),
1208            properties: vec![("facing", "north"), ("waterlogged", "false")],
1209        };
1210
1211        let encoded = wincode::serialize(&state).expect("block state should serialize");
1212        let decoded: PersistentBlockState<'_> =
1213            wincode::deserialize_exact(&encoded).expect("block state should deserialize");
1214
1215        assert_eq!(decoded, state);
1216    }
1217
1218    #[test]
1219    fn test_region_pos_from_chunk() {
1220        // Positive chunks
1221        assert_eq!(RegionPos::from_chunk(0, 0), RegionPos::new(0, 0));
1222        assert_eq!(RegionPos::from_chunk(31, 31), RegionPos::new(0, 0));
1223        assert_eq!(RegionPos::from_chunk(32, 32), RegionPos::new(1, 1));
1224
1225        // Negative chunks
1226        assert_eq!(RegionPos::from_chunk(-1, -1), RegionPos::new(-1, -1));
1227        assert_eq!(RegionPos::from_chunk(-32, -32), RegionPos::new(-1, -1));
1228        assert_eq!(RegionPos::from_chunk(-33, -33), RegionPos::new(-2, -2));
1229    }
1230
1231    #[test]
1232    fn test_local_chunk_pos() {
1233        assert_eq!(RegionPos::local_chunk_pos(0, 0), (0, 0));
1234        assert_eq!(RegionPos::local_chunk_pos(31, 31), (31, 31));
1235        assert_eq!(RegionPos::local_chunk_pos(32, 32), (0, 0));
1236        assert_eq!(RegionPos::local_chunk_pos(-1, -1), (31, 31));
1237        assert_eq!(RegionPos::local_chunk_pos(-32, -32), (0, 0));
1238    }
1239
1240    #[test]
1241    fn test_chunk_index() {
1242        assert_eq!(RegionHeader::chunk_index(0, 0), 0);
1243        assert_eq!(RegionHeader::chunk_index(31, 0), 31);
1244        assert_eq!(RegionHeader::chunk_index(0, 1), 32);
1245        assert_eq!(RegionHeader::chunk_index(31, 31), 1023);
1246    }
1247
1248    #[test]
1249    fn test_chunk_entry_roundtrip() {
1250        let entry = ChunkEntry::new(42, 12345, ChunkStatus::Full);
1251        let bytes = entry.to_bytes();
1252        let decoded = ChunkEntry::from_bytes(bytes).expect("serialized entry should decode");
1253        assert_eq!(entry.sector_offset, decoded.sector_offset);
1254        assert_eq!(entry.size_bytes, decoded.size_bytes);
1255        assert_eq!(entry.status, decoded.status);
1256    }
1257
1258    #[test]
1259    fn test_chunk_entry_empty() {
1260        let entry = ChunkEntry::default();
1261        assert!(!entry.exists());
1262        assert_eq!(entry.sector_count(), 0);
1263    }
1264
1265    #[test]
1266    fn test_sector_count() {
1267        // Empty
1268        assert_eq!(ChunkEntry::new(1, 0, ChunkStatus::Full).sector_count(), 0);
1269        // Exactly one sector
1270        assert_eq!(
1271            ChunkEntry::new(1, 4096, ChunkStatus::Full).sector_count(),
1272            1
1273        );
1274        // Just over one sector
1275        assert_eq!(
1276            ChunkEntry::new(1, 4097, ChunkStatus::Full).sector_count(),
1277            2
1278        );
1279        // Multiple sectors
1280        assert_eq!(
1281            ChunkEntry::new(1, 12000, ChunkStatus::Full).sector_count(),
1282            3
1283        );
1284    }
1285
1286    #[test]
1287    fn test_find_free_sectors_empty() {
1288        let header = RegionHeader::new();
1289        // Should return first data sector
1290        assert_eq!(header.find_free_sectors(1, 3), FIRST_DATA_SECTOR);
1291    }
1292
1293    #[test]
1294    fn test_find_free_sectors_gap() {
1295        let mut header = RegionHeader::new();
1296        // Chunk at sector 3-4 (2 sectors)
1297        header.entries[0] = ChunkEntry::new(3, 8000, ChunkStatus::Full);
1298        // Chunk at sector 10-11 (2 sectors)
1299        header.entries[1] = ChunkEntry::new(10, 8000, ChunkStatus::Full);
1300
1301        // Should find gap at sector 5-9
1302        assert_eq!(header.find_free_sectors(3, 12), 5);
1303        // Needs more than gap, append at end
1304        assert_eq!(header.find_free_sectors(6, 12), 12);
1305    }
1306}