Skip to main content

steel_core/chunk_saver/storage/
mod.rs

1use crate::block_entity::{BLOCK_ENTITIES, SharedBlockEntity};
2use crate::chunk::full_chunk::FullChunkRef;
3use crate::chunk::heightmap::{ChunkHeightmaps, Heightmap, HeightmapType};
4use crate::chunk::light::{
5    ChunkLightData, ChunkLightLayerStorage, DATA_LAYER_SIZE, LightSection, LightSectionData,
6};
7use crate::chunk::paletted_container::PalettedContainer;
8use crate::chunk::section::{ChunkSection, SectionHolder, Sections};
9use crate::chunk::{Chunk, status::ChunkStatus};
10use crate::chunk_saver::bit_pack::{bits_for_palette_len, pack_indices_from_iter, unpack_indices};
11use crate::entity::{
12    ENTITIES, Entity, EntityBase, EntityBaseSaveData, EntityFireFreezeState, EntityLoadRequest,
13    MAX_ENTITY_TAGS, RemovalReason, SharedEntity,
14};
15use crate::world::World;
16use crate::world::tick_scheduler::{BlockTickList, FluidTickList, SavedTick, TickPriority};
17use crate::worldgen::carving_mask::CarvingMask;
18use glam::{DVec3, IVec3};
19use rustc_hash::FxHashSet;
20use simdnbt::borrow::read_compound as read_borrowed_compound;
21use simdnbt::owned::NbtCompound;
22use std::cmp::Ordering as CmpOrdering;
23use std::io::Cursor;
24use std::sync::atomic::Ordering;
25use std::time::{SystemTime, UNIX_EPOCH};
26use std::{
27    io,
28    sync::{Arc, Weak},
29};
30use steel_registry::structure::{
31    LiquidSettingsData, OceanRuinBiomeTempData, RuinedPortalPlacementData, TerrainAdjustment,
32};
33use steel_registry::template_pool::{PoolElement, ProcessorList, Projection};
34use steel_registry::{
35    REGISTRY, Registry, RegistryExt,
36    blocks::{BlockRef, block_state_ext::BlockStateExt as _},
37    fluid::FluidRef,
38};
39use steel_utils::{
40    BlockPos, BlockStateId, ChunkPos, Direction, Identifier, PackedChunkPos, Rotation,
41};
42use text_components::TextComponent;
43
44use steel_worldgen::structure::desert_pyramid::DesertPyramidPieceData;
45use steel_worldgen::structure::fortress::FortressPieceData;
46use steel_worldgen::structure::jigsaw::{JigsawJunction, JigsawPieceData};
47use steel_worldgen::structure::jungle_temple::JungleTemplePieceData;
48use steel_worldgen::structure::mineshaft::{
49    MineshaftPieceKind, MineshaftPiecePayload, MineshaftType,
50};
51use steel_worldgen::structure::ocean_monument::{
52    OceanMonumentChildPiece, OceanMonumentChildPieceKind, OceanMonumentPieceData,
53    OceanMonumentRoomData,
54};
55use steel_worldgen::structure::stronghold::{StrongholdPieceData, StrongholdSmallDoorType};
56use steel_worldgen::structure::swamp_hut::SwampHutPieceData;
57use steel_worldgen::structure::{
58    ProceduralPieceData, RuinedPortalProperties, StructureBlockIgnore, StructureMirror,
59    StructurePiece, StructurePiecePayload, StructureReferenceMap, StructureStart,
60    StructureStartMap, TemplateMarkerHandling, TemplatePieceData, TemplatePlacementAdjustment,
61    TemplatePlacementClip, TemplatePostProcess, TemplateProcessorList,
62};
63
64mod chunk;
65mod entities;
66mod light;
67mod structures;
68mod ticks;
69
70#[cfg(test)]
71mod tests;
72
73/// Converts `Option<Direction>` to the vanilla 2D data value encoding for persistence.
74/// -1 = none, 0 = south, 1 = west, 2 = north, 3 = east.
75const fn direction_to_2d(dir: Option<Direction>) -> i8 {
76    match dir {
77        Some(Direction::South) => 0,
78        Some(Direction::West) => 1,
79        Some(Direction::North) => 2,
80        Some(Direction::East) => 3,
81        None | Some(Direction::Down | Direction::Up) => -1,
82    }
83}
84
85/// Converts a vanilla 2D data value to `Option<Direction>`.
86const fn direction_from_2d(value: i8) -> Option<Direction> {
87    match value {
88        0 => Some(Direction::South),
89        1 => Some(Direction::West),
90        2 => Some(Direction::North),
91        3 => Some(Direction::East),
92        _ => None,
93    }
94}
95
96const fn required_direction_from_2d(value: i8) -> Direction {
97    match value {
98        1 => Direction::West,
99        2 => Direction::North,
100        3 => Direction::East,
101        _ => Direction::South,
102    }
103}
104
105const fn mineshaft_type_to_persistent(mineshaft_type: MineshaftType) -> i8 {
106    match mineshaft_type {
107        MineshaftType::Normal => 0,
108        MineshaftType::Mesa => 1,
109    }
110}
111
112const fn mineshaft_type_from_persistent(value: i8) -> MineshaftType {
113    match value {
114        1 => MineshaftType::Mesa,
115        _ => MineshaftType::Normal,
116    }
117}
118
119const fn projection_to_persistent(projection: Option<Projection>) -> i8 {
120    match projection {
121        None => -1,
122        Some(Projection::Rigid) => 0,
123        Some(Projection::TerrainMatching) => 1,
124    }
125}
126
127const fn projection_from_persistent(value: i8) -> Option<Projection> {
128    match value {
129        0 => Some(Projection::Rigid),
130        1 => Some(Projection::TerrainMatching),
131        _ => None,
132    }
133}
134
135const fn required_projection_from_persistent(value: i8) -> Projection {
136    match value {
137        1 => Projection::TerrainMatching,
138        _ => Projection::Rigid,
139    }
140}
141
142const fn rotation_to_persistent(rotation: Rotation) -> i8 {
143    match rotation {
144        Rotation::None => 0,
145        Rotation::Clockwise90 => 1,
146        Rotation::Clockwise180 => 2,
147        Rotation::CounterClockwise90 => 3,
148    }
149}
150
151const fn rotation_from_persistent(value: i8) -> Rotation {
152    match value {
153        1 => Rotation::Clockwise90,
154        2 => Rotation::Clockwise180,
155        3 => Rotation::CounterClockwise90,
156        _ => Rotation::None,
157    }
158}
159
160const fn liquid_settings_to_persistent(settings: LiquidSettingsData) -> i8 {
161    match settings {
162        LiquidSettingsData::ApplyWaterlogging => 0,
163        LiquidSettingsData::IgnoreWaterlogging => 1,
164    }
165}
166
167const fn liquid_settings_from_persistent(value: i8) -> LiquidSettingsData {
168    match value {
169        1 => LiquidSettingsData::IgnoreWaterlogging,
170        _ => LiquidSettingsData::ApplyWaterlogging,
171    }
172}
173
174const fn ruined_portal_placement_to_persistent(placement: RuinedPortalPlacementData) -> i8 {
175    match placement {
176        RuinedPortalPlacementData::OnLandSurface => 0,
177        RuinedPortalPlacementData::PartlyBuried => 1,
178        RuinedPortalPlacementData::Underground => 2,
179        RuinedPortalPlacementData::InMountain => 3,
180        RuinedPortalPlacementData::OnOceanFloor => 4,
181        RuinedPortalPlacementData::InNether => 5,
182    }
183}
184
185const fn ruined_portal_placement_from_persistent(value: i8) -> RuinedPortalPlacementData {
186    match value {
187        1 => RuinedPortalPlacementData::PartlyBuried,
188        2 => RuinedPortalPlacementData::Underground,
189        3 => RuinedPortalPlacementData::InMountain,
190        4 => RuinedPortalPlacementData::OnOceanFloor,
191        5 => RuinedPortalPlacementData::InNether,
192        _ => RuinedPortalPlacementData::OnLandSurface,
193    }
194}
195
196const fn mirror_to_persistent(mirror: StructureMirror) -> i8 {
197    match mirror {
198        StructureMirror::None => 0,
199        StructureMirror::FrontBack => 1,
200        StructureMirror::LeftRight => 2,
201    }
202}
203
204const fn mirror_from_persistent(value: i8) -> StructureMirror {
205    match value {
206        1 => StructureMirror::FrontBack,
207        2 => StructureMirror::LeftRight,
208        _ => StructureMirror::None,
209    }
210}
211
212const fn block_ignore_to_persistent(block_ignore: StructureBlockIgnore) -> i8 {
213    match block_ignore {
214        StructureBlockIgnore::None => 0,
215        StructureBlockIgnore::StructureBlock => 1,
216        StructureBlockIgnore::StructureAndAir => 2,
217    }
218}
219
220const fn block_ignore_from_persistent(value: i8) -> StructureBlockIgnore {
221    match value {
222        1 => StructureBlockIgnore::StructureBlock,
223        2 => StructureBlockIgnore::StructureAndAir,
224        _ => StructureBlockIgnore::None,
225    }
226}
227
228const fn marker_handling_to_persistent(marker_handling: TemplateMarkerHandling) -> i8 {
229    match marker_handling {
230        TemplateMarkerHandling::Ignore => 0,
231        TemplateMarkerHandling::DataMarkers => 1,
232        TemplateMarkerHandling::Shipwreck => 2,
233        TemplateMarkerHandling::Igloo => 3,
234        TemplateMarkerHandling::OceanRuin { is_large: false } => 4,
235        TemplateMarkerHandling::OceanRuin { is_large: true } => 5,
236        TemplateMarkerHandling::EndCity => 6,
237        TemplateMarkerHandling::WoodlandMansion => 7,
238    }
239}
240
241const fn marker_handling_from_persistent(value: i8) -> TemplateMarkerHandling {
242    match value {
243        1 => TemplateMarkerHandling::DataMarkers,
244        2 => TemplateMarkerHandling::Shipwreck,
245        3 => TemplateMarkerHandling::Igloo,
246        4 => TemplateMarkerHandling::OceanRuin { is_large: false },
247        5 => TemplateMarkerHandling::OceanRuin { is_large: true },
248        6 => TemplateMarkerHandling::EndCity,
249        7 => TemplateMarkerHandling::WoodlandMansion,
250        _ => TemplateMarkerHandling::Ignore,
251    }
252}
253
254const fn ocean_ruin_biome_temp_to_persistent(biome_temp: OceanRuinBiomeTempData) -> i8 {
255    match biome_temp {
256        OceanRuinBiomeTempData::Warm => 0,
257        OceanRuinBiomeTempData::Cold => 1,
258    }
259}
260
261const fn ocean_ruin_biome_temp_from_persistent(value: i8) -> OceanRuinBiomeTempData {
262    match value {
263        1 => OceanRuinBiomeTempData::Cold,
264        _ => OceanRuinBiomeTempData::Warm,
265    }
266}
267
268const fn placement_adjustment_to_persistent(
269    adjustment: TemplatePlacementAdjustment,
270) -> PersistentTemplatePlacementAdjustment {
271    match adjustment {
272        TemplatePlacementAdjustment::None => PersistentTemplatePlacementAdjustment::None,
273        TemplatePlacementAdjustment::Shipwreck {
274            is_beached,
275            height_adjusted,
276        } => PersistentTemplatePlacementAdjustment::Shipwreck {
277            is_beached,
278            height_adjusted,
279        },
280        TemplatePlacementAdjustment::Igloo { template_offset } => {
281            PersistentTemplatePlacementAdjustment::Igloo {
282                template_offset: [template_offset.0, template_offset.1, template_offset.2],
283            }
284        }
285        TemplatePlacementAdjustment::OceanRuin => PersistentTemplatePlacementAdjustment::OceanRuin,
286    }
287}
288
289const fn placement_adjustment_from_persistent(
290    adjustment: &PersistentTemplatePlacementAdjustment,
291) -> TemplatePlacementAdjustment {
292    match adjustment {
293        PersistentTemplatePlacementAdjustment::None => TemplatePlacementAdjustment::None,
294        PersistentTemplatePlacementAdjustment::Shipwreck {
295            is_beached,
296            height_adjusted,
297        } => TemplatePlacementAdjustment::Shipwreck {
298            is_beached: *is_beached,
299            height_adjusted: *height_adjusted,
300        },
301        PersistentTemplatePlacementAdjustment::Igloo { template_offset } => {
302            TemplatePlacementAdjustment::Igloo {
303                template_offset: (template_offset[0], template_offset[1], template_offset[2]),
304            }
305        }
306        PersistentTemplatePlacementAdjustment::OceanRuin => TemplatePlacementAdjustment::OceanRuin,
307    }
308}
309
310const fn placement_clip_to_persistent(placement_clip: TemplatePlacementClip) -> i8 {
311    match placement_clip {
312        TemplatePlacementClip::CenterChunk => 0,
313        TemplatePlacementClip::CenterChunkExpandedToTemplate => 1,
314        TemplatePlacementClip::CenterChunkContainsTemplateCenterExpandedToTemplate => 2,
315    }
316}
317
318const fn placement_clip_from_persistent(value: i8) -> TemplatePlacementClip {
319    match value {
320        1 => TemplatePlacementClip::CenterChunkExpandedToTemplate,
321        2 => TemplatePlacementClip::CenterChunkContainsTemplateCenterExpandedToTemplate,
322        _ => TemplatePlacementClip::CenterChunk,
323    }
324}
325
326const fn post_process_to_persistent(post_process: TemplatePostProcess) -> i8 {
327    match post_process {
328        TemplatePostProcess::None => 0,
329        TemplatePostProcess::NetherFossil => 1,
330        TemplatePostProcess::IglooTop => 2,
331        TemplatePostProcess::RuinedPortal => 3,
332    }
333}
334
335const fn post_process_from_persistent(value: i8) -> TemplatePostProcess {
336    match value {
337        1 => TemplatePostProcess::NetherFossil,
338        2 => TemplatePostProcess::IglooTop,
339        3 => TemplatePostProcess::RuinedPortal,
340        _ => TemplatePostProcess::None,
341    }
342}
343
344fn compare_identifiers(a: &Identifier, b: &Identifier) -> CmpOrdering {
345    a.namespace
346        .cmp(&b.namespace)
347        .then_with(|| a.path.cmp(&b.path))
348}
349
350fn homogeneous_packed_light_value(data: &[u8; DATA_LAYER_SIZE]) -> Option<u8> {
351    let first = data[0];
352    let value = first & 0x0F;
353    if first >> 4 != value {
354        return None;
355    }
356    data.iter().all(|byte| *byte == first).then_some(value)
357}
358
359#[derive(Clone, Copy)]
360enum EntityPersistenceMode {
361    ChunkSave,
362    DimensionTransition,
363}
364
365use super::ram_only::RamOnlyStorage;
366use super::region_manager::RegionManager;
367use super::{
368    PersistentBiomeData, PersistentBlockEntity, PersistentBlockState, PersistentBoundingBox,
369    PersistentChunk, PersistentDesertPyramidPieceData, PersistentEntity, PersistentHeightmap,
370    PersistentJigsawJunction, PersistentJigsawPieceData, PersistentJungleTemplePieceData,
371    PersistentLightData, PersistentLightSection, PersistentMineshaftPieceData,
372    PersistentMineshaftPieceKind, PersistentNetherFortressPieceData,
373    PersistentOceanMonumentChildPiece, PersistentOceanMonumentChildPieceKind,
374    PersistentOceanMonumentPieceData, PersistentOceanMonumentRoomData, PersistentPoi,
375    PersistentPoolElement, PersistentProceduralPieceData, PersistentProcessorList,
376    PersistentSection, PersistentStrongholdPieceData, PersistentStrongholdSmallDoorType,
377    PersistentStructurePiece, PersistentStructurePiecePayload, PersistentStructureReference,
378    PersistentStructureStart, PersistentSwampHutPieceData, PersistentTemplatePieceData,
379    PersistentTemplatePlacementAdjustment, PersistentTemplateProcessorList, PersistentTick,
380    PreparedChunkSave,
381};
382
383/// Builder for creating a persistent chunk with its own palettes.
384struct ChunkBuilder<'a> {
385    block_states: Vec<PersistentBlockState<'static>>,
386    biomes: Vec<Identifier>,
387    registry: &'a Registry,
388}
389
390impl<'a> ChunkBuilder<'a> {
391    const fn new(registry: &'a Registry) -> Self {
392        Self {
393            block_states: Vec::new(),
394            biomes: Vec::new(),
395            registry,
396        }
397    }
398
399    /// Ensures a block state exists in the chunk's palette, returning its index.
400    fn ensure_block_state(&mut self, block_id: BlockStateId) -> u16 {
401        // Get block and properties from registry
402        let block = self
403            .registry
404            .blocks
405            .by_state_id(block_id)
406            .expect("Invalid block state ID");
407        let properties = self.registry.blocks.get_properties(block_id);
408
409        let persistent = PersistentBlockState {
410            name: block.key.clone(),
411            properties,
412        };
413
414        // Check if already exists
415        if let Some(idx) = self.block_states.iter().position(|s| s == &persistent) {
416            return idx as u16;
417        }
418
419        // Add new entry
420        let idx = self.block_states.len();
421        self.block_states.push(persistent);
422        idx as u16
423    }
424
425    /// Ensures a biome exists in the chunk's palette, returning its index.
426    fn ensure_biome(&mut self, biome_id: u16) -> u16 {
427        // Get biome identifier from registry
428        let biome = self
429            .registry
430            .biomes
431            .by_id(biome_id as usize)
432            .expect("Invalid biome ID");
433        let identifier = biome.key.clone();
434
435        if let Some(idx) = self.biomes.iter().position(|b| b == &identifier) {
436            return idx as u16;
437        }
438
439        let idx = self.biomes.len();
440        self.biomes.push(identifier);
441        idx as u16
442    }
443}
444
445/// Chunk storage backend.
446///
447/// This enum provides persistence for chunks, either to disk (region files)
448/// or in-memory (for testing/minigames).
449/// TODO: make it possible to give plugins the option to load a custom backend
450pub enum ChunkStorage {
451    /// Disk-based storage using region files.
452    Disk(RegionManager),
453    /// In-memory storage for testing and minigames.
454    RamOnly(RamOnlyStorage),
455}
456
457/// Runtime chunk data loaded from persistence.
458pub struct LoadedChunk {
459    /// The deserialized chunk.
460    pub chunk: Chunk,
461    /// The highest persisted status for the chunk.
462    pub status: ChunkStatus,
463    /// Full-chunk entities waiting for lifecycle-approved world registration.
464    pub pending_entities: Vec<SharedEntity>,
465}
466
467impl ChunkStorage {
468    /// Loads a chunk from storage.
469    ///
470    /// Returns `Ok(None)` if the chunk doesn't exist in storage.
471    /// For `RamOnly` with `create_empty_on_miss=true`, this always
472    /// returns an empty chunk (never `None`).
473    pub async fn load_chunk(
474        &self,
475        pos: ChunkPos,
476        min_y: i32,
477        height: i32,
478        level: Weak<World>,
479        thread_pool: &rayon::ThreadPool,
480    ) -> io::Result<Option<LoadedChunk>> {
481        match self {
482            Self::Disk(rm) => rm.load_chunk(pos, min_y, height, level, thread_pool).await,
483            Self::RamOnly(ram) => ram.load_chunk(pos, min_y, height, level).await,
484        }
485    }
486
487    /// Saves prepared chunk data to storage.
488    ///
489    /// Returns `Ok(true)` if the chunk was saved, `Ok(false)` if it was a no-op.
490    pub async fn save_chunk_data(
491        &self,
492        prepared: PreparedChunkSave,
493        thread_pool: &rayon::ThreadPool,
494    ) -> io::Result<bool> {
495        match self {
496            Self::Disk(rm) => rm.save_chunk_data(prepared, thread_pool).await,
497            Self::RamOnly(ram) => ram.save_chunk_data(prepared).await,
498        }
499    }
500
501    /// Checks if a chunk exists in storage.
502    pub async fn chunk_exists(&self, pos: ChunkPos) -> io::Result<bool> {
503        match self {
504            Self::Disk(rm) => rm.chunk_exists(pos).await,
505            Self::RamOnly(ram) => ram.chunk_exists(pos).await,
506        }
507    }
508
509    /// Acquires a chunk for loading, preparing any necessary resources.
510    ///
511    /// For disk storage, this opens/creates the region file and returns
512    /// whether the chunk exists. For RAM storage, this just checks existence.
513    pub async fn acquire_chunk(&self, pos: ChunkPos) -> io::Result<bool> {
514        match self {
515            Self::Disk(rm) => rm.acquire_chunk(pos).await,
516            Self::RamOnly(ram) => ram.chunk_exists(pos).await,
517        }
518    }
519
520    /// Releases a loaded chunk, allowing the storage to clean up resources.
521    pub async fn release_chunk(&self, pos: ChunkPos) -> io::Result<()> {
522        match self {
523            Self::Disk(rm) => rm.release_chunk(pos).await,
524            Self::RamOnly(_) => Ok(()), // No-op for RAM storage
525        }
526    }
527
528    /// Flushes all dirty data to storage.
529    pub async fn flush_all(&self) -> io::Result<()> {
530        match self {
531            Self::Disk(rm) => rm.flush_all().await,
532            Self::RamOnly(_) => Ok(()), // No-op for RAM storage
533        }
534    }
535
536    /// Closes all storage handles and flushes pending data.
537    pub async fn close_all(&self) -> io::Result<()> {
538        match self {
539            Self::Disk(rm) => rm.close_all().await,
540            Self::RamOnly(_) => Ok(()), // No-op for RAM storage
541        }
542    }
543
544    /// Saves a chunk to the appropriate region.
545    ///
546    /// The chunk is serialized, compressed, and written to disk immediately.
547    /// If the region was already open (has loaded chunks), the header update is
548    /// deferred. If this call opened the region, it will be closed after saving.
549    ///
550    /// If the chunk is not dirty and `force` is false, this is a no-op.
551    /// Returns `Ok(true)` if the chunk was saved.
552    /// Prepares chunk data and its authoritative persisted status for saving.
553    /// Call this during the holder's snapshot-preparation phase, then pass the result to
554    /// `save_chunk_data` after ending that phase.
555    ///
556    /// # Panics
557    ///
558    /// Panics if `status` does not match whether Full runtime state is initialized.
559    #[must_use]
560    #[expect(
561        clippy::similar_names,
562        reason = "`pois` vs `pos` are semantically distinct"
563    )]
564    #[expect(
565        clippy::too_many_lines,
566        reason = "chunk save preparation keeps related serialization setup in one pass"
567    )]
568    pub(crate) fn prepare_chunk_save(
569        chunk: &Chunk,
570        status: ChunkStatus,
571        runtime_entities: &[SharedEntity],
572        force: bool,
573    ) -> Option<PreparedChunkSave> {
574        assert_eq!(
575            status == ChunkStatus::Full,
576            chunk.full_runtime().is_some(),
577            "persisted chunk status must match its Full runtime state"
578        );
579        if !force && !chunk.is_dirty() {
580            return None;
581        }
582
583        // Finalize any sections still in worldgen Building mode. Proto chunks
584        // can be saved before being upgraded by `Chunk::promote_to_full`
585        // (which is where `recalculate_counts` normally runs and implicitly
586        // finalizes). Without this, `section_to_persistent` would panic on
587        // the Building variant.
588        for section_holder in &chunk.sections().sections {
589            let mut guard = section_holder.write();
590            if matches!(&guard.states, PalettedContainer::Building(_)) {
591                guard.recalculate_counts();
592            }
593        }
594
595        let pos = chunk.pos();
596
597        let full = (status == ChunkStatus::Full).then(|| FullChunkRef::from_full_context(chunk));
598        let (block_entities, pending_block_entities) = if full.is_some() {
599            chunk.block_entities.save_snapshot()
600        } else {
601            chunk
602                .block_entities
603                .save_snapshot_without_lifecycle_filter()
604        };
605
606        let mut seen_entity_ids = FxHashSet::default();
607        let mut seen_entity_uuids = FxHashSet::default();
608        let mut entities = Vec::new();
609        for entity in if full.is_some() {
610            Vec::new()
611        } else {
612            chunk.get_saveable_entities()
613        } {
614            if !Self::entity_position_is_finite(entity.as_ref()) {
615                Self::warn_skipping_non_finite_entity(entity.as_ref());
616                continue;
617            }
618            if seen_entity_ids.insert(entity.id()) {
619                Self::assert_unique_save_uuid(
620                    &mut seen_entity_uuids,
621                    entity.uuid(),
622                    entity.id(),
623                    pos,
624                );
625                entities.push(entity);
626            }
627        }
628        let mut handled_runtime_entity_ids = Vec::new();
629        for entity in runtime_entities {
630            handled_runtime_entity_ids.push(entity.id());
631            if !Self::entity_position_is_finite(entity.as_ref()) {
632                Self::warn_skipping_non_finite_entity(entity.as_ref());
633                continue;
634            }
635            if seen_entity_ids.insert(entity.id()) {
636                Self::assert_unique_save_uuid(
637                    &mut seen_entity_uuids,
638                    entity.uuid(),
639                    entity.id(),
640                    pos,
641                );
642                entities.push(Arc::clone(entity));
643            }
644        }
645
646        // Serialize scheduled ticks
647        let (block_ticks, fluid_ticks) = if let Some(full) = full {
648            let snapshot = full.scheduled_tick_snapshot();
649            let bt = Self::block_ticks_to_persistent(snapshot.block, pos);
650            let ft = Self::fluid_ticks_to_persistent(snapshot.fluid, pos);
651            (bt, ft)
652        } else {
653            // Proto ticks are pending, so Vanilla ignores the current game
654            // time when serializing their already-relative delays.
655            let Some(snapshot) = chunk.scheduled_ticks.snapshot(0) else {
656                panic!("Proto chunk scheduled-tick container was finalized before saving");
657            };
658            let bt = Self::block_ticks_to_persistent(snapshot.block, pos);
659            let ft = Self::fluid_ticks_to_persistent(snapshot.fluid, pos);
660            (bt, ft)
661        };
662
663        // Serialize the heightmaps required by the persisted generation status.
664        let heightmaps = chunk.heightmaps.read();
665        if full.is_some() {
666            for &heightmap_type in HeightmapType::final_types() {
667                let _ = heightmaps.get_final(heightmap_type);
668            }
669        }
670        let heightmaps = Self::heightmaps_to_persistent(&heightmaps, status);
671
672        let light = Self::light_to_persistent(&chunk.light.read());
673
674        // Serialize structure data (works for both proto and full chunks)
675        let structure_starts = Self::structure_starts_to_persistent(&chunk.structure_starts());
676        let structure_references =
677            Self::structure_references_to_persistent(&chunk.structure_references());
678
679        // Collect POI occupancy data from world storage
680        let pois = full
681            .map(|full| Self::pois_to_persistent(full, pos))
682            .unwrap_or_default();
683
684        let carving_mask = if full.is_some() {
685            None
686        } else {
687            chunk
688                .carving_mask
689                .read()
690                .as_ref()
691                .map(CarvingMask::to_packed_u64s)
692        };
693
694        let postprocessing = if let Some(full) = full {
695            full.postprocessing_for_serialization()
696        } else {
697            chunk.postprocessing.lock().iter().map(Vec::clone).collect()
698        };
699
700        let persistent = Self::to_persistent(
701            chunk.sections(),
702            &block_entities,
703            &pending_block_entities,
704            &entities,
705            block_ticks,
706            fluid_ticks,
707            heightmaps,
708            light,
709            carving_mask,
710            postprocessing,
711            structure_starts,
712            structure_references,
713            pois,
714            pos,
715        );
716
717        Some(PreparedChunkSave {
718            pos,
719            status,
720            persistent,
721            handled_runtime_entity_ids,
722        })
723    }
724
725    fn entity_position_is_finite(entity: &dyn Entity) -> bool {
726        let pos = entity.position();
727        pos.x.is_finite() && pos.y.is_finite() && pos.z.is_finite()
728    }
729
730    fn warn_skipping_non_finite_entity(entity: &dyn Entity) {
731        tracing::warn!(
732            uuid = ?entity.uuid(),
733            "Entity has non-finite position {:?}, skipping save",
734            entity.position()
735        );
736    }
737
738    fn assert_unique_save_uuid(
739        seen_uuids: &mut FxHashSet<uuid::Uuid>,
740        uuid: uuid::Uuid,
741        entity_id: i32,
742        chunk_pos: ChunkPos,
743    ) {
744        assert!(
745            seen_uuids.insert(uuid),
746            "duplicate saveable entity uuid {uuid} while preparing chunk {chunk_pos:?} for save; latest entity id {entity_id}"
747        );
748    }
749
750    /// Converts chunk data to persistent format.
751    #[expect(
752        clippy::too_many_arguments,
753        clippy::similar_names,
754        reason = "chunk serialization requires all fields; `block_ticks`/`fluid_ticks` are distinct"
755    )]
756    fn to_persistent(
757        sections: &Sections,
758        block_entities: &[SharedBlockEntity],
759        pending_block_entities: &[BlockPos],
760        entities: &[SharedEntity],
761        block_ticks: Vec<PersistentTick>,
762        fluid_ticks: Vec<PersistentTick>,
763        heightmaps: Vec<PersistentHeightmap>,
764        light: PersistentLightData,
765        carving_mask: Option<Vec<u64>>,
766        postprocessing: Vec<Vec<u16>>,
767        structure_starts: Vec<PersistentStructureStart>,
768        structure_references: Vec<PersistentStructureReference>,
769        pois: Vec<PersistentPoi>,
770        chunk_pos: ChunkPos,
771    ) -> PersistentChunk<'static> {
772        let mut builder = ChunkBuilder::new(&REGISTRY);
773
774        let persistent_sections = sections
775            .sections
776            .iter()
777            .map(|section| Self::section_to_persistent(section, &mut builder))
778            .collect();
779
780        // Serialize block entities
781        let persistent_block_entities: Vec<PersistentBlockEntity> = block_entities
782            .iter()
783            .map(|entity| {
784                let pos = entity.get_block_pos();
785
786                // Serialize NBT data
787                let mut nbt = NbtCompound::new();
788                entity.save_additional(&mut nbt);
789                let mut nbt_bytes = Vec::new();
790                nbt.write(&mut nbt_bytes);
791
792                PersistentBlockEntity {
793                    x: (pos.0.x - chunk_pos.0.x * 16) as u8,
794                    y: pos.0.y as i16,
795                    z: (pos.0.z - chunk_pos.0.y * 16) as u8,
796                    entity_type: Some(entity.get_type().key.clone()),
797                    nbt_data: nbt_bytes,
798                }
799            })
800            .chain(
801                pending_block_entities
802                    .iter()
803                    .map(|pos| PersistentBlockEntity {
804                        x: (pos.0.x - chunk_pos.0.x * 16) as u8,
805                        y: pos.0.y as i16,
806                        z: (pos.0.z - chunk_pos.0.y * 16) as u8,
807                        entity_type: None,
808                        nbt_data: Vec::new(),
809                    }),
810            )
811            .collect();
812
813        let persistent_entities = Self::entities_to_persistent(entities);
814
815        PersistentChunk {
816            last_modified: SystemTime::now()
817                .duration_since(UNIX_EPOCH)
818                .map_or(0, |d| d.as_secs() as u32),
819            block_states: builder.block_states,
820            biomes: builder.biomes,
821            sections: persistent_sections,
822            block_entities: persistent_block_entities,
823            entities: persistent_entities,
824            block_ticks,
825            fluid_ticks,
826            heightmaps,
827            light,
828            carving_mask,
829            postprocessing,
830            structure_starts,
831            structure_references,
832            pois,
833        }
834    }
835}