Skip to main content

steel_core/world/
block_region.rs

1use std::sync::Arc;
2
3use parking_lot::RwLockReadGuard;
4use smallvec::SmallVec;
5use steel_registry::{REGISTRY, vanilla_blocks};
6use steel_utils::{BlockPos, BlockStateId, ChunkPos, SectionPos};
7
8use crate::chunk::{
9    chunk_holder::ChunkHolder, paletted_container::BlockPalette, section::ChunkSection,
10    status::ChunkStatus,
11};
12
13use super::World;
14
15/// Maximum combined chunk-holder and section slots acquired by one bulk region read.
16pub(crate) const MAX_BLOCK_REGION_WORKSET_SLOTS: usize = 64;
17const SECTION_EDGE_BLOCKS: i32 = BlockPalette::SIZE as i32;
18const SECTION_LOCAL_COORDINATE_MASK: i32 = SECTION_EDGE_BLOCKS - 1;
19
20const fn section_local_coordinate(block_coordinate: i32) -> usize {
21    (block_coordinate & SECTION_LOCAL_COORDINATE_MASK) as usize
22}
23
24/// Inclusive block bounds for one scoped region read.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub(crate) struct BlockRegionBounds {
27    min: BlockPos,
28    max: BlockPos,
29}
30
31impl BlockRegionBounds {
32    /// Creates bounds containing both corners regardless of their order.
33    #[must_use]
34    pub(crate) const fn from_corners(first: BlockPos, second: BlockPos) -> Self {
35        Self {
36            min: BlockPos::min(first, second),
37            max: BlockPos::max(first, second),
38        }
39    }
40
41    /// Returns the inclusive minimum and maximum block corners.
42    #[must_use]
43    pub(crate) const fn corners(self) -> (BlockPos, BlockPos) {
44        (self.min, self.max)
45    }
46
47    #[must_use]
48    const fn contains(self, pos: BlockPos) -> bool {
49        pos.x() >= self.min.x()
50            && pos.y() >= self.min.y()
51            && pos.z() >= self.min.z()
52            && pos.x() <= self.max.x()
53            && pos.y() <= self.max.y()
54            && pos.z() <= self.max.z()
55    }
56}
57
58struct BlockRegionWorkset {
59    bounds: BlockRegionBounds,
60    chunks: SmallVec<[Option<Arc<ChunkHolder>>; 4]>,
61    min_chunk_x: i32,
62    min_chunk_z: i32,
63    chunk_z_count: usize,
64    min_section_y: i32,
65    section_y_count: usize,
66    world_min_y: i32,
67    world_max_y: i32,
68}
69
70impl BlockRegionWorkset {
71    fn try_new(world: &World, bounds: BlockRegionBounds) -> Option<Self> {
72        let min_chunk_x =
73            SectionPos::block_to_section_coord(bounds.min.x()).max(-ChunkPos::MAX_COORDINATE_VALUE);
74        let max_chunk_x =
75            SectionPos::block_to_section_coord(bounds.max.x()).min(ChunkPos::MAX_COORDINATE_VALUE);
76        let min_chunk_z =
77            SectionPos::block_to_section_coord(bounds.min.z()).max(-ChunkPos::MAX_COORDINATE_VALUE);
78        let max_chunk_z =
79            SectionPos::block_to_section_coord(bounds.max.z()).min(ChunkPos::MAX_COORDINATE_VALUE);
80
81        let chunk_width = inclusive_count(min_chunk_x, max_chunk_x);
82        let chunk_depth = inclusive_count(min_chunk_z, max_chunk_z);
83        let world_min_y = world.get_min_y();
84        let world_max_y = world.get_max_y();
85        let min_section_y = SectionPos::block_to_section_coord(bounds.min.y().max(world_min_y));
86        let max_section_y = SectionPos::block_to_section_coord(bounds.max.y().min(world_max_y));
87        let section_y_count = inclusive_count(min_section_y, max_section_y);
88
89        let chunk_count = chunk_width.checked_mul(chunk_depth)?;
90        let section_slot_count = chunk_count.checked_mul(section_y_count)?;
91        let workset_slot_count = chunk_count.checked_add(section_slot_count)?;
92        if workset_slot_count > MAX_BLOCK_REGION_WORKSET_SLOTS {
93            return None;
94        }
95
96        let mut chunks = SmallVec::new();
97        for chunk_x_offset in 0..chunk_width {
98            let chunk_x = min_chunk_x + chunk_x_offset as i32;
99            for chunk_z_offset in 0..chunk_depth {
100                let chunk_z = min_chunk_z + chunk_z_offset as i32;
101                chunks.push(
102                    world
103                        .chunk_map
104                        .active_full_chunk_holder(ChunkPos::new(chunk_x, chunk_z)),
105                );
106            }
107        }
108
109        Some(Self {
110            bounds,
111            chunks,
112            min_chunk_x,
113            min_chunk_z,
114            chunk_z_count: chunk_depth,
115            min_section_y,
116            section_y_count,
117            world_min_y,
118            world_max_y,
119        })
120    }
121
122    fn with_read<R>(&self, f: impl FnOnce(&BlockRegionRead<'_>) -> R) -> R {
123        let mut sections = SmallVec::new();
124
125        // This is the lock order for all block-region operations: chunk X, chunk Z,
126        // then section Y. A future write-region API must use the same order.
127        for holder in &self.chunks {
128            let chunk = holder
129                .as_ref()
130                .and_then(|holder| holder.try_chunk(ChunkStatus::Full));
131            for section_y_offset in 0..self.section_y_count {
132                let guard = chunk.and_then(|chunk| {
133                    let section_y = self.min_section_y + section_y_offset as i32;
134                    let section_index = usize::try_from(
135                        section_y - SectionPos::block_to_section_coord(self.world_min_y),
136                    )
137                    .ok()?;
138                    chunk
139                        .sections()
140                        .sections
141                        .get(section_index)
142                        .map(|section| section.read())
143                });
144                sections.push(guard);
145            }
146        }
147
148        let read = BlockRegionRead {
149            bounds: self.bounds,
150            sections,
151            min_chunk_x: self.min_chunk_x,
152            min_chunk_z: self.min_chunk_z,
153            chunk_z_count: self.chunk_z_count,
154            min_section_y: self.min_section_y,
155            section_y_count: self.section_y_count,
156            world_min_y: self.world_min_y,
157            world_max_y: self.world_max_y,
158            air: REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR),
159            void_air: REGISTRY.blocks.get_base_state_id(&vanilla_blocks::VOID_AIR),
160        };
161        f(&read)
162    }
163}
164
165/// A read-only view of one section already locked by a [`BlockRegionRead`].
166pub(crate) struct BlockSectionRead<'a> {
167    section_pos: SectionPos,
168    section: &'a ChunkSection,
169}
170
171impl BlockSectionRead<'_> {
172    /// Returns a state when `pos` belongs to this section.
173    #[must_use]
174    pub(crate) fn get_block_state(&self, pos: BlockPos) -> Option<BlockStateId> {
175        if SectionPos::from_block_pos(pos) != self.section_pos {
176            return None;
177        }
178        Some(self.section.states().get(
179            section_local_coordinate(pos.x()),
180            section_local_coordinate(pos.y()),
181            section_local_coordinate(pos.z()),
182        ))
183    }
184
185    fn stable_air_box_is_clear(&self, min: BlockPos, max: BlockPos) -> bool {
186        debug_assert_eq!(SectionPos::from_block_pos(min), self.section_pos);
187        debug_assert_eq!(SectionPos::from_block_pos(max), self.section_pos);
188        self.section.stable_air_box_is_clear(
189            section_local_coordinate(min.x()),
190            section_local_coordinate(max.x()),
191            section_local_coordinate(min.y()),
192            section_local_coordinate(max.y()),
193            section_local_coordinate(min.z()),
194            section_local_coordinate(max.z()),
195        )
196    }
197}
198
199/// Scoped block-state reads over a rectangular set of prelocked sections.
200pub(crate) struct BlockRegionRead<'a> {
201    bounds: BlockRegionBounds,
202    sections: SmallVec<[Option<RwLockReadGuard<'a, ChunkSection>>; 8]>,
203    min_chunk_x: i32,
204    min_chunk_z: i32,
205    chunk_z_count: usize,
206    min_section_y: i32,
207    section_y_count: usize,
208    world_min_y: i32,
209    world_max_y: i32,
210    air: BlockStateId,
211    void_air: BlockStateId,
212}
213
214impl BlockRegionRead<'_> {
215    /// Returns the block state at `pos`, or `None` when it is outside this region.
216    ///
217    /// Positions outside world bounds are void air. Missing Full chunks are air,
218    /// matching [`World::get_block_state`].
219    #[must_use]
220    pub(crate) fn get_block_state(&self, pos: BlockPos) -> Option<BlockStateId> {
221        if !self.bounds.contains(pos) {
222            return None;
223        }
224        if pos.y() < self.world_min_y
225            || pos.y() > self.world_max_y
226            || !ChunkPos::is_valid(
227                SectionPos::block_to_section_coord(pos.x()),
228                SectionPos::block_to_section_coord(pos.z()),
229            )
230        {
231            return Some(self.void_air);
232        }
233
234        let section_pos = SectionPos::from_block_pos(pos);
235        let Some(section) = self.section(section_pos) else {
236            return Some(self.air);
237        };
238        section.get_block_state(pos)
239    }
240
241    /// Returns whether every in-height section in this bounded snapshot came from a Full chunk.
242    #[must_use]
243    pub(crate) fn has_complete_data(&self) -> bool {
244        self.sections.iter().all(Option::is_some)
245    }
246
247    /// Returns whether any retained section may require expanded block-collision boundaries.
248    #[must_use]
249    pub(crate) fn maybe_has_special_colliding_blocks(&self) -> bool {
250        self.sections
251            .iter()
252            .flatten()
253            .any(|section| section.maybe_has_special_colliding_blocks())
254    }
255
256    /// Returns whether any retained section requires live collision-state reads around callbacks.
257    #[must_use]
258    pub(crate) fn maybe_has_extensible_collision_behavior(&self) -> bool {
259        self.sections
260            .iter()
261            .flatten()
262            .any(|section| section.maybe_has_extensible_collision_behavior())
263    }
264
265    /// Returns whether the bounded, in-world snapshot contains only immutable Vanilla air.
266    ///
267    /// Missing Full data is never accepted: publication or a live mutation must remain visible to
268    /// the exact fallback query.
269    #[must_use]
270    pub(crate) fn contains_only_stable_air(&self) -> bool {
271        if !self.has_complete_data() {
272            return false;
273        }
274
275        let (bounds_min, bounds_max) = self.bounds.corners();
276        let min_y = bounds_min.y().max(self.world_min_y);
277        let max_y = bounds_max.y().min(self.world_max_y);
278        let min_section_x =
279            SectionPos::block_to_section_coord(bounds_min.x()).max(-ChunkPos::MAX_COORDINATE_VALUE);
280        let max_section_x =
281            SectionPos::block_to_section_coord(bounds_max.x()).min(ChunkPos::MAX_COORDINATE_VALUE);
282        let min_section_z =
283            SectionPos::block_to_section_coord(bounds_min.z()).max(-ChunkPos::MAX_COORDINATE_VALUE);
284        let max_section_z =
285            SectionPos::block_to_section_coord(bounds_max.z()).min(ChunkPos::MAX_COORDINATE_VALUE);
286        if min_y > max_y || min_section_x > max_section_x || min_section_z > max_section_z {
287            return true;
288        }
289
290        let min_section_y = SectionPos::block_to_section_coord(min_y);
291        let max_section_y = SectionPos::block_to_section_coord(max_y);
292        for section_x in min_section_x..=max_section_x {
293            for section_z in min_section_z..=max_section_z {
294                for section_y in min_section_y..=max_section_y {
295                    let section_pos = SectionPos::new(section_x, section_y, section_z);
296                    let Some(section) = self.section(section_pos) else {
297                        return false;
298                    };
299                    let section_min = BlockPos::new(
300                        section_x * SECTION_EDGE_BLOCKS,
301                        section_y * SECTION_EDGE_BLOCKS,
302                        section_z * SECTION_EDGE_BLOCKS,
303                    );
304                    let section_max = BlockPos::new(
305                        section_min.x() + SECTION_LOCAL_COORDINATE_MASK,
306                        section_min.y() + SECTION_LOCAL_COORDINATE_MASK,
307                        section_min.z() + SECTION_LOCAL_COORDINATE_MASK,
308                    );
309                    let clipped_min = BlockPos::max(
310                        BlockPos::new(bounds_min.x(), min_y, bounds_min.z()),
311                        section_min,
312                    );
313                    let clipped_max = BlockPos::min(
314                        BlockPos::new(bounds_max.x(), max_y, bounds_max.z()),
315                        section_max,
316                    );
317                    if !section.stable_air_box_is_clear(clipped_min, clipped_max) {
318                        return false;
319                    }
320                }
321            }
322        }
323        true
324    }
325
326    /// Returns a prelocked section view, or `None` for an unloaded or uncached section.
327    #[must_use]
328    pub(crate) fn section(&self, section_pos: SectionPos) -> Option<BlockSectionRead<'_>> {
329        let chunk_x_offset =
330            usize::try_from(section_pos.x().checked_sub(self.min_chunk_x)?).ok()?;
331        let chunk_z_offset =
332            usize::try_from(section_pos.z().checked_sub(self.min_chunk_z)?).ok()?;
333        let section_y_offset =
334            usize::try_from(section_pos.y().checked_sub(self.min_section_y)?).ok()?;
335        if chunk_z_offset >= self.chunk_z_count || section_y_offset >= self.section_y_count {
336            return None;
337        }
338
339        let chunk_slot = chunk_x_offset
340            .checked_mul(self.chunk_z_count)?
341            .checked_add(chunk_z_offset)?;
342        let section_slot = chunk_slot
343            .checked_mul(self.section_y_count)?
344            .checked_add(section_y_offset)?;
345        let section = self.sections.get(section_slot)?.as_ref()?;
346        Some(BlockSectionRead {
347            section_pos,
348            section,
349        })
350    }
351}
352
353impl World {
354    /// Acquires every loaded section intersecting a small `bounds` once and exposes a scoped read
355    /// view.
356    ///
357    /// Setup and lock count scale with every intersecting chunk and section, so this is an internal
358    /// primitive for bounded synchronous gameplay queries rather than arbitrary or
359    /// player-controlled regions.
360    ///
361    /// The callback must use [`BlockRegionRead`] for reads covered by `bounds` and must not perform
362    /// world writes. Re-entering a world read for a covered section can deadlock if a writer is
363    /// already waiting because the requested section read guards remain held until the callback
364    /// returns.
365    pub(crate) fn try_with_block_region<R>(
366        &self,
367        bounds: BlockRegionBounds,
368        f: impl FnOnce(&BlockRegionRead<'_>) -> R,
369    ) -> Option<R> {
370        Some(BlockRegionWorkset::try_new(self, bounds)?.with_read(f))
371    }
372}
373
374fn inclusive_count(min: i32, max: i32) -> usize {
375    if min > max {
376        return 0;
377    }
378    usize::try_from(i64::from(max) - i64::from(min) + 1).unwrap_or(0)
379}
380
381#[cfg(test)]
382mod tests {
383    use steel_registry::vanilla_blocks;
384    use steel_utils::{BlockPos, ChunkPos, SectionPos, WorldAabb, types::UpdateFlags};
385
386    use crate::{
387        behavior::init_behaviors,
388        test_support::{fresh_test_world, insert_ready_full_chunk},
389    };
390
391    use super::{BlockRegionBounds, BlockRegionWorkset, SECTION_EDGE_BLOCKS};
392
393    #[test]
394    #[expect(
395        clippy::redundant_closure_for_method_calls,
396        reason = "the method pointer cannot satisfy the region callback's higher-ranked lifetime"
397    )]
398    fn stable_air_region_requires_full_data_and_tracks_live_block_writes() {
399        let fixture = fresh_test_world("stable_air_region");
400        let world = &fixture.world;
401        init_behaviors();
402        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
403        insert_ready_full_chunk(world, ChunkPos::new(1, 0));
404        let first_chunk_max_x = SECTION_EDGE_BLOCKS - 1;
405        let second_chunk_min_x = SECTION_EDGE_BLOCKS;
406        let missing_chunk_min_x = SECTION_EDGE_BLOCKS * 2;
407        let test_y = 64;
408        let bounds = BlockRegionBounds::from_corners(
409            BlockPos::new(first_chunk_max_x, test_y, 0),
410            BlockPos::new(second_chunk_min_x, test_y, 0),
411        );
412
413        assert_eq!(
414            world.try_with_block_region(bounds, |region| region.contains_only_stable_air()),
415            Some(true)
416        );
417        assert!(world.set_block(
418            BlockPos::new(second_chunk_min_x, test_y, 0),
419            vanilla_blocks::STONE.default_state(),
420            UpdateFlags::UPDATE_NONE,
421        ));
422        assert_eq!(
423            world.try_with_block_region(bounds, |region| region.contains_only_stable_air()),
424            Some(false)
425        );
426        assert!(world.set_block(
427            BlockPos::new(second_chunk_min_x, test_y, 0),
428            vanilla_blocks::CAVE_AIR.default_state(),
429            UpdateFlags::UPDATE_NONE,
430        ));
431        assert_eq!(
432            world.try_with_block_region(bounds, |region| region.contains_only_stable_air()),
433            Some(true)
434        );
435
436        let missing = BlockRegionBounds::from_corners(
437            BlockPos::new(first_chunk_max_x, test_y, 0),
438            BlockPos::new(missing_chunk_min_x, test_y, 0),
439        );
440        assert_eq!(
441            world.try_with_block_region(missing, |region| region.contains_only_stable_air()),
442            Some(false)
443        );
444    }
445
446    #[test]
447    fn region_reuses_section_reads_across_chunk_and_section_boundaries() {
448        let world_fixture = fresh_test_world("block_region_reads");
449        let world = &world_fixture.world;
450        init_behaviors();
451        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
452        insert_ready_full_chunk(world, ChunkPos::new(1, 0));
453
454        let first = BlockPos::new(15, 64, 0);
455        let second = BlockPos::new(16, 80, 0);
456        assert!(world.set_block(
457            first,
458            vanilla_blocks::STONE.default_state(),
459            UpdateFlags::UPDATE_NONE,
460        ));
461        assert!(world.set_block(
462            second,
463            vanilla_blocks::DIRT.default_state(),
464            UpdateFlags::UPDATE_NONE,
465        ));
466
467        let below_world = BlockPos::new(15, world.get_min_y() - 1, 0);
468        let missing_chunk = BlockPos::new(32, 64, 0);
469        let bounds = BlockRegionBounds::from_corners(below_world, BlockPos::new(32, 80, 0));
470        world
471            .try_with_block_region(bounds, |region| {
472                assert_eq!(
473                    region.get_block_state(first),
474                    Some(vanilla_blocks::STONE.default_state())
475                );
476                assert_eq!(
477                    region.get_block_state(second),
478                    Some(vanilla_blocks::DIRT.default_state())
479                );
480                assert_eq!(
481                    region.get_block_state(missing_chunk),
482                    Some(vanilla_blocks::AIR.default_state())
483                );
484                assert!(!region.has_complete_data());
485                assert!(!region.maybe_has_special_colliding_blocks());
486                assert_eq!(
487                    region.get_block_state(below_world),
488                    Some(vanilla_blocks::VOID_AIR.default_state())
489                );
490                assert_eq!(region.get_block_state(first.west()), None);
491
492                let Some(section) = region.section(SectionPos::from_block_pos(first)) else {
493                    panic!("the first block's loaded section should be cached");
494                };
495                assert_eq!(
496                    section.get_block_state(first),
497                    Some(vanilla_blocks::STONE.default_state())
498                );
499                assert_eq!(section.get_block_state(second), None);
500                assert!(
501                    region
502                        .section(SectionPos::new(i32::MAX, i32::MAX, i32::MAX))
503                        .is_none()
504                );
505            })
506            .expect("focused region should fit the bounded workset");
507
508        assert!(
509            !world.block_states_in_aabb_are_air(WorldAabb::new(15.0, 64.0, 0.0, 15.9, 64.9, 0.9,))
510        );
511        assert!(
512            world.block_states_in_aabb_are_air(WorldAabb::new(32.0, 64.0, 0.0, 32.9, 64.9, 0.9,))
513        );
514    }
515
516    #[test]
517    fn oversized_region_uses_streaming_reads() {
518        let world_fixture = fresh_test_world("oversized_block_region_reads");
519        let world = &world_fixture.world;
520        init_behaviors();
521        insert_ready_full_chunk(world, ChunkPos::new(0, 0));
522
523        let first = BlockPos::new(0, 64, 0);
524        assert!(world.set_block(
525            first,
526            vanilla_blocks::STONE.default_state(),
527            UpdateFlags::UPDATE_NONE,
528        ));
529
530        let oversized_bounds =
531            BlockRegionBounds::from_corners(first, BlockPos::new(16 * 64, first.y(), first.z()));
532        assert!(BlockRegionWorkset::try_new(world, oversized_bounds).is_none());
533        assert!(!world.block_states_in_aabb_are_air(WorldAabb::new(
534            f64::from(first.x()),
535            f64::from(first.y()),
536            f64::from(first.z()),
537            f64::from(16 * 64),
538            f64::from(first.y()),
539            f64::from(first.z()),
540        )));
541    }
542}