Skip to main content

steel_core/chunk_saver/storage/
chunk.rs

1use small_map::FxSmallMap;
2
3use super::{
4    BlockPos, BlockStateId, BlockTickList, CarvingMask, Chunk, ChunkBuilder, ChunkHeightmaps,
5    ChunkPos, ChunkSection, ChunkStatus, ChunkStorage, DATA_LAYER_SIZE, FluidTickList,
6    FullChunkRef, FxHashSet, Heightmap, HeightmapType, LoadedChunk, Ordering, PalettedContainer,
7    PersistentBiomeData, PersistentChunk, PersistentHeightmap, PersistentLightSection,
8    PersistentPoi, PersistentSection, REGISTRY, RegistryExt, SectionHolder, Sections, Weak, World,
9    bits_for_palette_len, io, pack_indices_from_iter, unpack_indices,
10};
11const PALETTE_INLINE_CAPACITY: usize = 8;
12
13impl ChunkStorage {
14    fn invalid_chunk_data(message: impl Into<String>) -> io::Error {
15        io::Error::new(io::ErrorKind::InvalidData, message.into())
16    }
17
18    fn validate_packed_palette(
19        palette: &[u16],
20        bits_per_entry: u8,
21        data: &[u64],
22        entry_count: usize,
23        global_palette_len: usize,
24        name: &str,
25    ) -> io::Result<()> {
26        let Some(expected_bits) = bits_for_palette_len(palette.len()) else {
27            return Err(Self::invalid_chunk_data(format!(
28                "heterogeneous {name} palette has fewer than two entries"
29            )));
30        };
31        if bits_per_entry != expected_bits {
32            return Err(Self::invalid_chunk_data(format!(
33                "heterogeneous {name} palette uses {bits_per_entry} bits, expected {expected_bits}"
34            )));
35        }
36        let values_per_word = 64 / usize::from(bits_per_entry);
37        let expected_words = entry_count.div_ceil(values_per_word);
38        if data.len() != expected_words {
39            return Err(Self::invalid_chunk_data(format!(
40                "heterogeneous {name} data has {} words, expected {expected_words}",
41                data.len()
42            )));
43        }
44        if let Some(invalid) = palette
45            .iter()
46            .find(|&&index| usize::from(index) >= global_palette_len)
47        {
48            return Err(Self::invalid_chunk_data(format!(
49                "{name} palette references missing global entry {invalid}"
50            )));
51        }
52        if let Some(invalid) = unpack_indices(data, bits_per_entry)
53            .take(entry_count)
54            .find(|&index| index as usize >= palette.len())
55        {
56            return Err(Self::invalid_chunk_data(format!(
57                "{name} data references missing local palette entry {invalid}"
58            )));
59        }
60        Ok(())
61    }
62
63    #[expect(
64        clippy::too_many_lines,
65        reason = "keeps complete chunk payload validation in one ordered pass"
66    )]
67    fn validate_persistent_chunk(
68        persistent: &PersistentChunk<'_>,
69        status: ChunkStatus,
70        min_y: i32,
71        height: i32,
72    ) -> io::Result<()> {
73        if height <= 0 || height % 16 != 0 || min_y % 16 != 0 {
74            return Err(io::Error::new(
75                io::ErrorKind::InvalidInput,
76                format!(
77                    "chunk world range must be section-aligned, got min_y={min_y}, height={height}"
78                ),
79            ));
80        }
81        let expected_sections = (height / 16) as usize;
82        if persistent.sections.len() != expected_sections {
83            return Err(Self::invalid_chunk_data(format!(
84                "chunk has {} sections, expected {expected_sections}",
85                persistent.sections.len()
86            )));
87        }
88
89        for (section_index, section) in persistent.sections.iter().enumerate() {
90            let biomes = match section {
91                PersistentSection::Homogeneous {
92                    block_state,
93                    biomes,
94                } => {
95                    if usize::from(*block_state) >= persistent.block_states.len() {
96                        return Err(Self::invalid_chunk_data(format!(
97                            "section {section_index} references missing block-state entry {block_state}"
98                        )));
99                    }
100                    biomes
101                }
102                PersistentSection::Heterogeneous {
103                    palette,
104                    bits_per_entry,
105                    block_data,
106                    biomes,
107                } => {
108                    Self::validate_packed_palette(
109                        palette,
110                        *bits_per_entry,
111                        block_data,
112                        4096,
113                        persistent.block_states.len(),
114                        "block-state",
115                    )?;
116                    biomes
117                }
118            };
119            match biomes {
120                PersistentBiomeData::Homogeneous { biome } => {
121                    if usize::from(*biome) >= persistent.biomes.len() {
122                        return Err(Self::invalid_chunk_data(format!(
123                            "section {section_index} references missing biome entry {biome}"
124                        )));
125                    }
126                }
127                PersistentBiomeData::Heterogeneous {
128                    palette,
129                    bits_per_entry,
130                    biome_data,
131                } => Self::validate_packed_palette(
132                    palette,
133                    *bits_per_entry,
134                    biome_data,
135                    64,
136                    persistent.biomes.len(),
137                    "biome",
138                )?,
139            }
140        }
141
142        let mut heightmap_types = FxHashSet::default();
143        for heightmap in &persistent.heightmaps {
144            let Some(heightmap_type) = HeightmapType::from_persistence_id(heightmap.heightmap_type)
145            else {
146                return Err(Self::invalid_chunk_data(format!(
147                    "unknown heightmap type {}",
148                    heightmap.heightmap_type
149                )));
150            };
151            if heightmap.data.len() != 256 {
152                return Err(Self::invalid_chunk_data(format!(
153                    "{heightmap_type:?} heightmap has {} columns, expected 256",
154                    heightmap.data.len()
155                )));
156            }
157            if !heightmap_types.insert(heightmap_type) {
158                return Err(Self::invalid_chunk_data(format!(
159                    "duplicate {heightmap_type:?} heightmap"
160                )));
161            }
162            if let Some(value) = heightmap
163                .data
164                .iter()
165                .find(|&&value| i32::from(value) > height)
166            {
167                return Err(Self::invalid_chunk_data(format!(
168                    "{heightmap_type:?} heightmap contains out-of-range value {value} for height {height}"
169                )));
170            }
171        }
172
173        let light_section_count = expected_sections + 2;
174        for (layer_name, sections) in [
175            ("block", persistent.light.block.as_slice()),
176            ("sky", persistent.light.sky.as_slice()),
177        ] {
178            let mut indices = FxHashSet::default();
179            for section in sections {
180                let index = usize::try_from(section.section_index()).map_err(|_| {
181                    Self::invalid_chunk_data(format!(
182                        "{layer_name} light section index does not fit this platform"
183                    ))
184                })?;
185                if index >= light_section_count {
186                    return Err(Self::invalid_chunk_data(format!(
187                        "{layer_name} light section index {index} is outside 0..{light_section_count}"
188                    )));
189                }
190                if !indices.insert(index) {
191                    return Err(Self::invalid_chunk_data(format!(
192                        "duplicate {layer_name} light section {index}"
193                    )));
194                }
195                match section {
196                    PersistentLightSection::Initialized { data, .. }
197                    | PersistentLightSection::Internal { data, .. }
198                        if data.len() != DATA_LAYER_SIZE =>
199                    {
200                        return Err(Self::invalid_chunk_data(format!(
201                            "{layer_name} light section {index} has {} bytes, expected {DATA_LAYER_SIZE}",
202                            data.len()
203                        )));
204                    }
205                    _ => {}
206                }
207            }
208        }
209
210        let max_y = min_y.checked_add(height).ok_or_else(|| {
211            io::Error::new(io::ErrorKind::InvalidInput, "world height range overflowed")
212        })?;
213        for (name, x, y, z) in persistent
214            .block_entities
215            .iter()
216            .map(|entry| ("block entity", entry.x, entry.y, entry.z))
217            .chain(
218                persistent
219                    .block_ticks
220                    .iter()
221                    .map(|entry| ("block tick", entry.x, entry.y, entry.z)),
222            )
223            .chain(
224                persistent
225                    .fluid_ticks
226                    .iter()
227                    .map(|entry| ("fluid tick", entry.x, entry.y, entry.z)),
228            )
229            .chain(
230                persistent
231                    .pois
232                    .iter()
233                    .map(|entry| ("POI", entry.x, entry.y, entry.z)),
234            )
235        {
236            let y = i32::from(y);
237            if x >= 16 || z >= 16 || y < min_y || y >= max_y {
238                return Err(Self::invalid_chunk_data(format!(
239                    "{name} position ({x}, {y}, {z}) is outside the chunk"
240                )));
241            }
242        }
243
244        if status == ChunkStatus::Full && persistent.carving_mask.is_some() {
245            return Err(Self::invalid_chunk_data(
246                "Full chunk contains a proto carving mask",
247            ));
248        }
249        if let Some(mask) = &persistent.carving_mask {
250            let max_words = (256usize * height as usize).div_ceil(64);
251            if mask.len() > max_words {
252                return Err(Self::invalid_chunk_data(format!(
253                    "carving mask has {} words, maximum is {max_words}",
254                    mask.len()
255                )));
256            }
257        }
258        if persistent.postprocessing.len() > expected_sections {
259            return Err(Self::invalid_chunk_data(format!(
260                "chunk has {} postprocessing section lists, maximum is {expected_sections}",
261                persistent.postprocessing.len()
262            )));
263        }
264
265        Ok(())
266    }
267
268    /// Converts a runtime section to persistent format.
269    pub(super) fn section_to_persistent(
270        section: &SectionHolder,
271        builder: &mut ChunkBuilder,
272    ) -> PersistentSection {
273        let section = section.read();
274        let biomes = Self::biomes_to_persistent(&section.biomes, builder);
275
276        match &section.states {
277            PalettedContainer::Homogeneous(block_id) => {
278                let block_idx = builder.ensure_block_state(*block_id);
279                PersistentSection::Homogeneous {
280                    block_state: block_idx,
281                    biomes,
282                }
283            }
284            PalettedContainer::Heterogeneous(data) => {
285                // Build section-local palette (indices into chunk's block_states)
286                let palette: Vec<u16> = data
287                    .palette
288                    .iter()
289                    .map(|(block_id, _)| builder.ensure_block_state(*block_id))
290                    .collect();
291
292                // Pack block indices (indices into section-local palette),
293                // inverting the palette once instead of scanning it per block.
294                let bits = bits_for_palette_len(palette.len())
295                    .expect("Heterogeneous section should have palette length >= 2");
296                let palette_indices: FxSmallMap<PALETTE_INLINE_CAPACITY, BlockStateId, u32> = data
297                    .palette
298                    .iter()
299                    .enumerate()
300                    .map(|(index, (block_id, _))| (*block_id, index as u32))
301                    .collect();
302                let block_data = pack_indices_from_iter(
303                    data.cube
304                        .as_flattened()
305                        .as_flattened()
306                        .iter()
307                        .map(|block_id| palette_indices.get(block_id).copied().unwrap_or(0)),
308                    bits,
309                );
310
311                PersistentSection::Heterogeneous {
312                    palette,
313                    bits_per_entry: bits,
314                    block_data,
315                    biomes,
316                }
317            }
318            PalettedContainer::Building(_) => panic!(
319                "section_to_persistent called on a section still in worldgen Building mode; \
320                 finalize_building must be called before serialization"
321            ),
322        }
323    }
324
325    /// Converts runtime biome data to persistent format.
326    pub(super) fn biomes_to_persistent(
327        biomes: &PalettedContainer<u16, 4>,
328        builder: &mut ChunkBuilder,
329    ) -> PersistentBiomeData {
330        match biomes {
331            PalettedContainer::Homogeneous(biome_id) => {
332                let biome_idx = builder.ensure_biome(*biome_id);
333                PersistentBiomeData::Homogeneous { biome: biome_idx }
334            }
335            PalettedContainer::Heterogeneous(data) => {
336                // Build section-local palette (indices into chunk's biomes)
337                let palette: Vec<u16> = data
338                    .palette
339                    .iter()
340                    .map(|(biome_id, _)| builder.ensure_biome(*biome_id))
341                    .collect();
342
343                let bits = bits_for_palette_len(palette.len())
344                    .expect("Heterogeneous biome data should have palette length >= 2");
345                let palette_indices: FxSmallMap<PALETTE_INLINE_CAPACITY, u16, u32> = data
346                    .palette
347                    .iter()
348                    .enumerate()
349                    .map(|(index, (biome_id, _))| (*biome_id, index as u32))
350                    .collect();
351                let biome_data = pack_indices_from_iter(
352                    data.cube
353                        .as_flattened()
354                        .as_flattened()
355                        .iter()
356                        .map(|biome_id| palette_indices.get(biome_id).copied().unwrap_or(0)),
357                    bits,
358                );
359
360                PersistentBiomeData::Heterogeneous {
361                    palette,
362                    bits_per_entry: bits,
363                    biome_data,
364                }
365            }
366            PalettedContainer::Building(_) => panic!(
367                "biomes_to_persistent called on a section still in worldgen Building mode; \
368                 finalize_building must be called before serialization"
369            ),
370        }
371    }
372
373    /// Converts a persistent chunk to runtime format.
374    /// The returned chunk is not dirty (freshly loaded from disk).
375    ///
376    /// # Arguments
377    /// * `persistent` - The persistent chunk data
378    /// * `pos` - The chunk position
379    /// * `status` - The chunk status
380    /// * `min_y` - The minimum Y coordinate of the world
381    /// * `height` - The total height of the world
382    /// * `level` - Weak reference to the world for Full chunk runtime access
383    #[expect(
384        clippy::too_many_lines,
385        reason = "chunk persistence conversion is a linear field-by-field transform"
386    )]
387    pub(crate) fn try_persistent_to_chunk(
388        persistent: &PersistentChunk<'_>,
389        pos: ChunkPos,
390        status: ChunkStatus,
391        min_y: i32,
392        height: i32,
393        level: Weak<World>,
394    ) -> io::Result<LoadedChunk> {
395        // Validate every persisted shape that materialization relies on before
396        // constructing a Chunk. Full construction populates world POI state, so
397        // a late validation failure would otherwise leak partial loaded state.
398        Self::validate_persistent_chunk(persistent, status, min_y, height)?;
399        let sections: Vec<ChunkSection> = persistent
400            .sections
401            .iter()
402            .map(|section| Self::persistent_to_section(section, persistent))
403            .collect::<io::Result<_>>()?;
404        let sections = Sections::from_owned(sections.into_boxed_slice());
405
406        // Reconstruct structure data
407        let structure_starts = Self::persistent_to_structure_starts(&persistent.structure_starts);
408        let structure_references =
409            Self::persistent_to_structure_references(&persistent.structure_references);
410        let light = Self::persistent_to_light(&persistent.light, min_y, height, status);
411        let mut heightmaps =
412            Self::persistent_to_heightmaps(&persistent.heightmaps, status, min_y, height);
413        heightmaps.prime_from_sections(
414            status.heightmaps_after(),
415            min_y,
416            height,
417            &sections.sections,
418        );
419
420        if status == ChunkStatus::Full {
421            // Reconstruct scheduled ticks from persistent data
422            let block_ticks = BlockTickList::from_saved_ticks(
423                Self::persistent_to_block_saved_ticks(&persistent.block_ticks, pos),
424            );
425            let fluid_ticks = FluidTickList::from_saved_ticks(
426                Self::persistent_to_fluid_saved_ticks(&persistent.fluid_ticks, pos),
427            );
428
429            let chunk = Chunk::from_full_disk(
430                sections,
431                pos,
432                min_y,
433                height,
434                level.clone(),
435                block_ticks,
436                fluid_ticks,
437                heightmaps,
438                persistent.postprocessing.iter().map(Vec::clone).collect(),
439                structure_starts,
440                structure_references,
441                light,
442            );
443            let full = FullChunkRef::from_full_context(&chunk);
444
445            // Load block entities
446            for persistent_be in &persistent.block_entities {
447                if persistent_be.entity_type.is_none() {
448                    let block_entity_pos = Self::persistent_block_entity_pos(persistent_be, pos);
449                    full.set_pending_block_entity(block_entity_pos);
450                    continue;
451                }
452                if let Some(block_entity) =
453                    Self::persistent_to_block_entity(persistent_be, pos, full)
454                {
455                    let _ = full.add_and_register_block_entity(block_entity);
456                }
457            }
458
459            let mut pending_entities = Vec::with_capacity(persistent.entities.len());
460            let level_weak = full.level_weak();
461            for persistent_entity in &persistent.entities {
462                let mut loaded_entities =
463                    Self::persistent_to_entity_tree_at_level(persistent_entity, pos, &level_weak);
464                pending_entities.append(&mut loaded_entities);
465            }
466
467            // Restore POI ticket state (populate_poi ran in from_disk, now apply saved occupancy)
468            if !persistent.pois.is_empty()
469                && let Some(world) = level.upgrade()
470            {
471                let tickets: Vec<_> = persistent
472                    .pois
473                    .iter()
474                    .map(|p| {
475                        let block_pos = BlockPos::new(
476                            pos.0.x * 16 + i32::from(p.x),
477                            i32::from(p.y),
478                            pos.0.y * 16 + i32::from(p.z),
479                        );
480                        (block_pos, p.free_tickets)
481                    })
482                    .collect();
483                world.poi_storage.lock().restore_tickets(pos, &tickets);
484            }
485
486            // Clear dirty flag since we just loaded (add_and_register marks dirty)
487            full.common().dirty.store(false, Ordering::Release);
488
489            Ok(LoadedChunk {
490                chunk,
491                status,
492                pending_entities,
493            })
494        } else {
495            let block_ticks = BlockTickList::from_proto_saved_ticks(
496                Self::persistent_to_block_saved_ticks(&persistent.block_ticks, pos),
497            );
498            let fluid_ticks = FluidTickList::from_proto_saved_ticks(
499                Self::persistent_to_fluid_saved_ticks(&persistent.fluid_ticks, pos),
500            );
501            let carving_mask = persistent
502                .carving_mask
503                .as_deref()
504                .map(|packed| CarvingMask::from_packed_u64s(height, min_y, packed));
505
506            let chunk = Chunk::from_disk(
507                sections,
508                pos,
509                status,
510                min_y,
511                height,
512                heightmaps,
513                structure_starts,
514                structure_references,
515                carving_mask,
516                persistent.postprocessing.iter().map(Vec::clone).collect(),
517                block_ticks,
518                fluid_ticks,
519                level.clone(),
520                light,
521            );
522
523            for persistent_be in &persistent.block_entities {
524                let block_entity_pos = Self::persistent_block_entity_pos(persistent_be, pos);
525                if persistent_be.entity_type.is_none() {
526                    chunk.set_pending_block_entity(block_entity_pos);
527                    continue;
528                }
529                let state = chunk.get_block_state(block_entity_pos);
530                if let Some(block_entity) = Self::persistent_to_block_entity_at(
531                    persistent_be,
532                    block_entity_pos,
533                    level.clone(),
534                    state,
535                ) {
536                    let _ = chunk.set_block_entity(block_entity);
537                }
538            }
539
540            for persistent_entity in &persistent.entities {
541                let loaded_entities =
542                    Self::persistent_to_entity_tree_at_level(persistent_entity, pos, &level);
543                for entity in loaded_entities {
544                    chunk.add_entity(entity);
545                }
546            }
547
548            chunk.dirty.store(false, Ordering::Release);
549
550            Ok(LoadedChunk {
551                chunk,
552                status,
553                pending_entities: Vec::new(),
554            })
555        }
556    }
557
558    #[cfg(test)]
559    pub(crate) fn persistent_to_chunk(
560        persistent: &PersistentChunk<'_>,
561        pos: ChunkPos,
562        status: ChunkStatus,
563        min_y: i32,
564        height: i32,
565        level: Weak<World>,
566    ) -> LoadedChunk {
567        Self::try_persistent_to_chunk(persistent, pos, status, min_y, height, level)
568            .expect("test persistent chunk should be valid")
569    }
570
571    /// Converts chunk heightmaps to persistent format for saving.
572    pub(super) fn heightmaps_to_persistent(
573        heightmaps: &ChunkHeightmaps,
574        status: ChunkStatus,
575    ) -> Vec<PersistentHeightmap> {
576        status
577            .heightmaps_after()
578            .iter()
579            .filter_map(|&hm_type| {
580                let hm = heightmaps.get(hm_type)?;
581                Some(PersistentHeightmap {
582                    heightmap_type: hm_type.persistence_id(),
583                    data: hm.raw_data().to_vec(),
584                })
585            })
586            .collect()
587    }
588
589    /// Reconstructs chunk heightmaps from persistent data.
590    pub(super) fn persistent_to_heightmaps(
591        persistent: &[PersistentHeightmap],
592        status: ChunkStatus,
593        min_y: i32,
594        height: i32,
595    ) -> ChunkHeightmaps {
596        let mut heightmaps = ChunkHeightmaps::empty();
597
598        for ph in persistent {
599            let Some(hm_type) = HeightmapType::from_persistence_id(ph.heightmap_type) else {
600                continue;
601            };
602            if !status.heightmaps_after().contains(&hm_type) {
603                continue;
604            }
605            if ph.data.len() != 256 {
606                tracing::warn!(
607                    "Heightmap data length mismatch: expected 256, got {}. Skipping.",
608                    ph.data.len()
609                );
610                continue;
611            }
612            let mut data = Box::new([0u16; 256]);
613            data.copy_from_slice(&ph.data);
614            heightmaps.replace(Heightmap::from_raw_data(hm_type, min_y, height, data));
615        }
616
617        heightmaps
618    }
619
620    /// Collects POI occupancy data from the world's POI storage for this chunk.
621    pub(super) fn pois_to_persistent(
622        chunk: FullChunkRef<'_>,
623        chunk_pos: ChunkPos,
624    ) -> Vec<PersistentPoi> {
625        let Some(world) = chunk.get_level() else {
626            return Vec::new();
627        };
628        world
629            .poi_storage
630            .lock()
631            .collect_for_chunk(chunk_pos)
632            .into_iter()
633            .map(|(pos, free_tickets)| PersistentPoi {
634                x: (pos.0.x - chunk_pos.0.x * 16) as u8,
635                y: pos.0.y as i16,
636                z: (pos.0.z - chunk_pos.0.y * 16) as u8,
637                free_tickets,
638            })
639            .collect()
640    }
641
642    /// Converts a persistent section to runtime format.
643    pub(super) fn persistent_to_section(
644        persistent: &PersistentSection,
645        chunk: &PersistentChunk<'_>,
646    ) -> io::Result<ChunkSection> {
647        match persistent {
648            PersistentSection::Homogeneous {
649                block_state,
650                biomes,
651            } => {
652                let block_id = Self::resolve_block_state(chunk, *block_state)?;
653                let biome_data = Self::persistent_to_biomes(biomes, chunk)?;
654                Ok(ChunkSection::new_with_biomes(
655                    PalettedContainer::Homogeneous(block_id),
656                    biome_data,
657                ))
658            }
659            PersistentSection::Heterogeneous {
660                palette,
661                bits_per_entry,
662                block_data,
663                biomes,
664            } => {
665                let mut indices = unpack_indices(block_data, *bits_per_entry);
666                let runtime_palette: Vec<BlockStateId> = palette
667                    .iter()
668                    .map(|&idx| Self::resolve_block_state(chunk, idx))
669                    .collect::<io::Result<_>>()?;
670                let mut cube = Box::new([[[BlockStateId(0); 16]; 16]; 16]);
671                for plane in &mut cube {
672                    for row in plane {
673                        for cell in row {
674                            *cell = runtime_palette[indices.next().expect(
675                                "this should never fail, we know the iterator is long enough",
676                            ) as usize];
677                        }
678                    }
679                }
680                let states = PalettedContainer::from_cube(cube);
681                let biome_data = Self::persistent_to_biomes(biomes, chunk)?;
682                Ok(ChunkSection::new_with_biomes(states, biome_data))
683            }
684        }
685    }
686
687    /// Converts persistent biome data to runtime format.
688    pub(super) fn persistent_to_biomes(
689        persistent: &PersistentBiomeData,
690        chunk: &PersistentChunk<'_>,
691    ) -> io::Result<PalettedContainer<u16, 4>> {
692        match persistent {
693            PersistentBiomeData::Homogeneous { biome } => {
694                let biome_id = Self::resolve_biome(chunk, *biome)?;
695                Ok(PalettedContainer::Homogeneous(biome_id))
696            }
697            PersistentBiomeData::Heterogeneous {
698                palette,
699                bits_per_entry,
700                biome_data,
701            } => {
702                let mut indices = unpack_indices(biome_data, *bits_per_entry);
703                let runtime_palette: Vec<u16> = palette
704                    .iter()
705                    .map(|&idx| Self::resolve_biome(chunk, idx))
706                    .collect::<io::Result<_>>()?;
707                let mut cube = [[[0u16; 4]; 4]; 4];
708                for plane in &mut cube {
709                    for row in plane {
710                        for cell in row {
711                            *cell = runtime_palette[indices.next().expect(
712                                "this should never fail, we know the iterator is long enough",
713                            ) as usize];
714                        }
715                    }
716                }
717                Ok(PalettedContainer::from_cube(Box::new(cube)))
718            }
719        }
720    }
721
722    /// Resolves a chunk palette index to a runtime `BlockStateId`.
723    pub(super) fn resolve_block_state(
724        chunk: &PersistentChunk<'_>,
725        index: u16,
726    ) -> io::Result<BlockStateId> {
727        let Some(state) = chunk.block_states.get(index as usize) else {
728            return Err(Self::invalid_chunk_data(format!(
729                "missing block-state palette entry {index}"
730            )));
731        };
732        let Some(state_id) = REGISTRY
733            .blocks
734            .state_id_from_properties(&state.name, &state.properties)
735        else {
736            return Err(Self::invalid_chunk_data(format!(
737                "unresolvable block state {} with properties {:?}",
738                state.name, state.properties
739            )));
740        };
741        let canonical = REGISTRY.blocks.get_properties(state_id);
742        let unique_names = state
743            .properties
744            .iter()
745            .map(|(name, _)| *name)
746            .collect::<FxHashSet<_>>();
747        if unique_names.len() != state.properties.len()
748            || canonical.len() != state.properties.len()
749            || canonical
750                .iter()
751                .any(|property| !state.properties.contains(property))
752        {
753            return Err(Self::invalid_chunk_data(format!(
754                "noncanonical block state {} with properties {:?}",
755                state.name, state.properties
756            )));
757        }
758        Ok(state_id)
759    }
760
761    /// Resolves a chunk palette index to a runtime biome ID.
762    pub(super) fn resolve_biome(chunk: &PersistentChunk<'_>, index: u16) -> io::Result<u16> {
763        let Some(biome_key) = chunk.biomes.get(index as usize) else {
764            return Err(Self::invalid_chunk_data(format!(
765                "missing biome palette entry {index}"
766            )));
767        };
768        let Some(id) = REGISTRY.biomes.id_from_key(biome_key) else {
769            return Err(Self::invalid_chunk_data(format!(
770                "unknown biome {biome_key}"
771            )));
772        };
773        u16::try_from(id).map_err(|_| {
774            Self::invalid_chunk_data(format!("biome {biome_key} id {id} does not fit u16"))
775        })
776    }
777}