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