Skip to main content

steel_core/chunk_saver/storage/
chunk.rs

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