Skip to main content

steel_core/chunk_saver/storage/
mod.rs

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