Skip to main content

steel_core/chunk_saver/storage/
chunk.rs

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