Skip to main content

steel_core/world/block_updates/
mod.rs

1use super::*;
2use crate::chunk::{Chunk, gameplay_chunk_lookup_cache::LocalFullChunkHolderCache};
3
4mod neighbor_updater;
5
6pub(in crate::world) use neighbor_updater::{CollectingNeighborUpdater, ShapeUpdate};
7
8static LARGE_BLOCK_REGION_WARNING_EMITTED: AtomicBool = AtomicBool::new(false);
9
10impl World {
11    /// Vanilla block-update recursion limit (`Block.UPDATE_LIMIT`).
12    pub const UPDATE_LIMIT: i32 = 512;
13
14    /// Gets the block state at the given position.
15    ///
16    /// Returns void air out of bounds and air when the containing chunk is not loaded.
17    #[must_use]
18    pub fn get_block_state(&self, pos: BlockPos) -> BlockStateId {
19        if !self.is_in_valid_bounds(pos) {
20            return REGISTRY.blocks.get_base_state_id(&vanilla_blocks::VOID_AIR);
21        }
22
23        let chunk_pos = Self::chunk_pos_for_block(pos);
24        self.chunk_map
25            .with_full_chunk(chunk_pos, |chunk| chunk.get_block_state(pos))
26            .unwrap_or_else(|| REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR))
27    }
28
29    /// Gets a live block state while locally retaining Full holder identity when the enclosing
30    /// gameplay lookup scope guarantees stable active-map membership.
31    pub(crate) fn get_block_state_with_local_holder_cache(
32        &self,
33        pos: BlockPos,
34        cache: &mut LocalFullChunkHolderCache,
35    ) -> BlockStateId {
36        if !self.is_in_valid_bounds(pos) {
37            return REGISTRY.blocks.get_base_state_id(&vanilla_blocks::VOID_AIR);
38        }
39
40        cache
41            .block_state(&self.chunk_map, pos)
42            .unwrap_or_else(|| REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR))
43    }
44
45    ///Vanilla equivalent: `level.getBrightness()`
46    pub fn light_value_at(&self, layer: LightLayer, pos: BlockPos) -> u8 {
47        if layer == LightLayer::Sky && !self.dimension_type.has_skylight {
48            return 0;
49        }
50        if !self.is_in_valid_bounds_horizontal(pos) {
51            return self.default_light_value(layer);
52        }
53
54        let chunk_pos = Self::chunk_pos_for_block(pos);
55        self.chunk_map
56            .with_chunk_at_status(chunk_pos, ChunkStatus::Light, |chunk| {
57                let light = chunk.light();
58                light.get_light_value(layer, pos)
59            })
60            .unwrap_or_else(|| self.default_light_value(layer))
61    }
62
63    pub(crate) fn is_entity_ticking_chunk_loaded(&self, pos: BlockPos) -> bool {
64        self.chunk_map
65            .is_entity_ticking_full_chunk_loaded(Self::chunk_pos_for_block(pos))
66    }
67
68    pub(crate) fn is_block_ticking_chunk_loaded(&self, pos: BlockPos) -> bool {
69        self.chunk_map
70            .is_block_ticking_full_chunk_loaded(Self::chunk_pos_for_block(pos))
71    }
72
73    pub(crate) fn is_full_chunk_loaded_at(&self, pos: BlockPos) -> bool {
74        self.chunk_map
75            .with_full_chunk(Self::chunk_pos_for_block(pos), |_| ())
76            .is_some()
77    }
78
79    pub(crate) fn queue_light_change_after_block_set(
80        &self,
81        pos: BlockPos,
82        old_state: BlockStateId,
83        new_state: BlockStateId,
84        empty_section_change: Option<LightSectionEmptinessChange>,
85    ) {
86        let light_properties_changed = has_different_light_properties(old_state, new_state);
87        if !light_properties_changed && empty_section_change.is_none() {
88            return;
89        }
90
91        self.chunk_map
92            .queue_light_change(pos, light_properties_changed, empty_section_change);
93    }
94
95    pub(super) const fn default_light_value(&self, layer: LightLayer) -> u8 {
96        match layer {
97            LightLayer::Sky if self.dimension_type.has_skylight => MAX_LIGHT_LEVEL,
98            LightLayer::Sky | LightLayer::Block => 0,
99        }
100    }
101
102    /// Returns whether every block state in the vanilla AABB block range is air.
103    ///
104    /// Matches `BlockGetter.getBlockStates(AABB)` using
105    /// `BlockPos.betweenClosedStream(AABB)`: both min and max coordinates are
106    /// floored before iterating the inclusive block range. Large ranges fall back
107    /// to streaming reads instead of acquiring an unbounded section workset.
108    #[must_use]
109    pub fn block_states_in_aabb_are_air(&self, aabb: WorldAabb) -> bool {
110        let min_x = aabb.min_x().floor() as i32;
111        let min_y = aabb.min_y().floor() as i32;
112        let min_z = aabb.min_z().floor() as i32;
113        let max_x = aabb.max_x().floor() as i32;
114        let max_y = aabb.max_y().floor() as i32;
115        let max_z = aabb.max_z().floor() as i32;
116
117        let bounds = BlockRegionBounds::from_corners(
118            BlockPos::new(min_x, min_y, min_z),
119            BlockPos::new(max_x, max_y, max_z),
120        );
121
122        let streaming_read = || {
123            for y in min_y..=max_y {
124                for z in min_z..=max_z {
125                    for x in min_x..=max_x {
126                        if !self.get_block_state(BlockPos::new(x, y, z)).is_air() {
127                            return false;
128                        }
129                    }
130                }
131            }
132            true
133        };
134
135        let Some(all_air) = self.try_with_block_region(bounds, |region| {
136            for y in min_y..=max_y {
137                for z in min_z..=max_z {
138                    for x in min_x..=max_x {
139                        let Some(state) = region.get_block_state(BlockPos::new(x, y, z)) else {
140                            return false;
141                        };
142                        if !state.is_air() {
143                            return false;
144                        }
145                    }
146                }
147            }
148            true
149        }) else {
150            if !LARGE_BLOCK_REGION_WARNING_EMITTED.swap(true, Ordering::Relaxed) {
151                tracing::warn!(
152                    min_x,
153                    min_y,
154                    min_z,
155                    max_x,
156                    max_y,
157                    max_z,
158                    max_workset_slots = MAX_BLOCK_REGION_WORKSET_SLOTS,
159                    "Block-state AABB exceeds the bulk-read limit; using streaming reads"
160                );
161            }
162            return streaming_read();
163        };
164        all_air
165    }
166
167    /// Sets a block at the given position.
168    ///
169    /// Returns `true` if the block was successfully set, `false` otherwise.
170    /// Uses the default update limit of 512 (matching vanilla).
171    ///
172    /// Live gameplay callers must run in Steel's serialized world-mutation phase. Palette and
173    /// block-entity ownership claims are atomic, but the following Vanilla-ordered callbacks,
174    /// neighbor updates, and derived-cache writes are intentionally not one concurrent
175    /// transaction for the same position.
176    pub fn set_block(
177        self: &Arc<Self>,
178        pos: BlockPos,
179        block_state: BlockStateId,
180        flags: UpdateFlags,
181    ) -> bool {
182        self.set_block_with_limit(pos, block_state, flags, Self::UPDATE_LIMIT)
183    }
184
185    /// Sets a block at the given position with a custom update limit.
186    ///
187    /// The update limit bounds recursive shape propagation. The block mutation
188    /// itself still occurs when the limit is zero or negative, matching vanilla.
189    ///
190    /// Returns `true` if the block was successfully set, `false` otherwise.
191    /// See [`Self::set_block`] for the serialized world-mutation requirement.
192    pub fn set_block_with_limit(
193        self: &Arc<Self>,
194        pos: BlockPos,
195        block_state: BlockStateId,
196        flags: UpdateFlags,
197        update_limit: i32,
198    ) -> bool {
199        if !self.is_in_valid_bounds(pos) {
200            return false;
201        }
202
203        let chunk_pos = Self::chunk_pos_for_block(pos);
204        let Some(old_state) = self
205            .chunk_map
206            .with_full_chunk(chunk_pos, |chunk| {
207                chunk.set_block_state(pos, block_state, flags)
208            })
209            .flatten()
210        else {
211            return false;
212        };
213
214        self.finish_block_set(pos, old_state, block_state, flags, update_limit);
215        true
216    }
217
218    /// Replaces a block only if it still has `expected_state`.
219    ///
220    /// The comparison and palette write are performed under one chunk-section write lock. This
221    /// prevents two consumers of the same observed block state from both succeeding. Block
222    /// callbacks still run after that state claim, so callers must remain in a serialized
223    /// world-mutation phase such as an exclusive packet handler or ordered tick commit.
224    pub fn set_block_if_unchanged(
225        self: &Arc<Self>,
226        pos: BlockPos,
227        expected_state: BlockStateId,
228        new_state: BlockStateId,
229        flags: UpdateFlags,
230    ) -> ConditionalBlockSetResult {
231        self.set_block_if_unchanged_with_limit(
232            pos,
233            expected_state,
234            new_state,
235            flags,
236            Self::UPDATE_LIMIT,
237        )
238    }
239
240    /// Conditional variant of [`Self::set_block_with_limit`].
241    pub fn set_block_if_unchanged_with_limit(
242        self: &Arc<Self>,
243        pos: BlockPos,
244        expected_state: BlockStateId,
245        new_state: BlockStateId,
246        flags: UpdateFlags,
247        update_limit: i32,
248    ) -> ConditionalBlockSetResult {
249        if !self.is_in_valid_bounds(pos) {
250            return ConditionalBlockSetResult::Unavailable;
251        }
252
253        let chunk_pos = Self::chunk_pos_for_block(pos);
254        let Some(result) = self
255            .chunk_map
256            .with_full_chunk(chunk_pos, |chunk| {
257                chunk.set_block_state_if_unchanged(pos, expected_state, new_state, flags)
258            })
259            .flatten()
260        else {
261            return ConditionalBlockSetResult::Unavailable;
262        };
263
264        match result {
265            FullChunkBlockSetResult::Changed(old_state) => {
266                self.finish_block_set(pos, old_state, new_state, flags, update_limit);
267                ConditionalBlockSetResult::Changed
268            }
269            FullChunkBlockSetResult::Unchanged => ConditionalBlockSetResult::Unchanged,
270            FullChunkBlockSetResult::Stale(current_state) => {
271                ConditionalBlockSetResult::Stale(current_state)
272            }
273        }
274    }
275
276    pub(super) fn finish_block_set(
277        self: &Arc<Self>,
278        pos: BlockPos,
279        old_state: BlockStateId,
280        block_state: BlockStateId,
281        flags: UpdateFlags,
282        update_limit: i32,
283    ) {
284        let new_state = self.get_block_state(pos);
285        if new_state != block_state {
286            return;
287        }
288
289        let chunk_pos = Self::chunk_pos_for_block(pos);
290        if flags.contains(UpdateFlags::UPDATE_CLIENTS)
291            && self.chunk_map.is_block_ticking_full_chunk_loaded(chunk_pos)
292        {
293            self.chunk_map.block_changed(pos);
294            self.update_navigating_mobs_after_block_collision_change(pos, old_state, block_state);
295        }
296
297        if flags.contains(UpdateFlags::UPDATE_NEIGHBORS) {
298            self.update_neighbors_at(pos, old_state.get_block());
299            let behavior = BLOCK_BEHAVIORS.get_behavior(block_state.get_block());
300            if behavior.has_analog_output_signal(block_state) {
301                self.update_neighbor_for_output_signal(pos, block_state.get_block());
302            }
303        }
304
305        if !flags.contains(UpdateFlags::UPDATE_KNOWN_SHAPE) && update_limit > 0 {
306            let neighbor_flags =
307                flags & !(UpdateFlags::UPDATE_NEIGHBORS | UpdateFlags::UPDATE_SUPPRESS_DROPS);
308            let old_behavior = BLOCK_BEHAVIORS.get_behavior(old_state.get_block());
309            old_behavior.update_indirect_neighbour_shapes(
310                old_state,
311                self,
312                pos,
313                neighbor_flags,
314                update_limit - 1,
315            );
316            self.update_neighbour_shapes(block_state, pos, neighbor_flags, update_limit - 1);
317            let new_behavior = BLOCK_BEHAVIORS.get_behavior(block_state.get_block());
318            new_behavior.update_indirect_neighbour_shapes(
319                block_state,
320                self,
321                pos,
322                neighbor_flags,
323                update_limit - 1,
324            );
325        }
326
327        if REGISTRY.poi_types.type_id_for_state(old_state)
328            != REGISTRY.poi_types.type_id_for_state(new_state)
329        {
330            self.poi_storage
331                .lock()
332                .on_block_state_change(pos, old_state, new_state);
333        }
334    }
335
336    pub(super) fn update_navigating_mobs_after_block_collision_change(
337        self: &Arc<Self>,
338        pos: BlockPos,
339        old_state: BlockStateId,
340        new_state: BlockStateId,
341    ) {
342        let collision_shape_changed = self.block_collision_shape_changed(pos, old_state, new_state);
343        let game_time = self.game_time();
344        for entity_id in self.navigating_mob_ids() {
345            let Some(entity) = self.entity_manager.get_by_id(entity_id) else {
346                self.untrack_navigating_mob(entity_id);
347                continue;
348            };
349            let Some(pathfinder) = entity.as_pathfinder_mob() else {
350                self.untrack_navigating_mob(entity_id);
351                continue;
352            };
353            {
354                let mut navigation = pathfinder.mob_base().navigation().lock();
355                navigation.invalidate_path_type(pos);
356            }
357            if !collision_shape_changed {
358                continue;
359            }
360            if !pathfinder.is_path_finding() {
361                continue;
362            }
363
364            let should_recompute = {
365                let navigation = pathfinder.mob_base().navigation().lock();
366                navigation.should_recompute_path(pos, pathfinder.position())
367            };
368            if !should_recompute {
369                continue;
370            }
371
372            let request = {
373                let mut navigation = pathfinder.mob_base().navigation().lock();
374                navigation.request_recompute_path(game_time, pathfinder.can_update_path())
375            };
376            if let Some(request) = request {
377                pathfinder.recompute_path(request);
378            }
379        }
380    }
381
382    pub(super) fn navigating_mob_ids(&self) -> Vec<i32> {
383        self.navigating_mobs.ids()
384    }
385
386    pub(super) fn block_collision_shape_changed(
387        &self,
388        pos: BlockPos,
389        old_state: BlockStateId,
390        new_state: BlockStateId,
391    ) -> bool {
392        let old_shape = self.block_collision_shape(pos, old_state);
393        let new_shape = self.block_collision_shape(pos, new_state);
394        join_is_not_empty(old_shape, new_shape, BooleanOp::NotSame)
395    }
396
397    pub(super) fn block_collision_shape(&self, pos: BlockPos, state: BlockStateId) -> VoxelShape {
398        BLOCK_BEHAVIORS
399            .get_behavior(state.get_block())
400            .get_collision_shape(state, self, pos, BlockCollisionContext::empty())
401    }
402
403    /// Updates all neighbors of the given position about a block change.
404    ///
405    /// This is the Rust equivalent of vanilla's `Level.updateNeighborsAt()`.
406    pub fn update_neighbors_at(self: &Arc<Self>, pos: BlockPos, source_block: BlockRef) {
407        self.neighbor_updater
408            .update_neighbors_at_except_from_facing(self, pos, source_block, None);
409    }
410
411    /// Runs Vanilla's deferred command-placement neighbor notifications.
412    ///
413    /// `/fill`, `/setblock`, and `/clone` suppress ordinary neighbor updates
414    /// while mutating their regions, then replay this operation in a stable
415    /// second pass.
416    pub(crate) fn update_neighbors_on_block_set(
417        self: &Arc<Self>,
418        pos: BlockPos,
419        old_state: BlockStateId,
420    ) {
421        let state = self.get_block_state(pos);
422        let block = state.get_block();
423        if old_state.get_block() != block {
424            BLOCK_BEHAVIORS
425                .get_behavior(old_state.get_block())
426                .affect_neighbors_after_removal(old_state, self, pos, false);
427        }
428
429        self.update_neighbors_at(pos, block);
430        if BLOCK_BEHAVIORS
431            .get_behavior(block)
432            .has_analog_output_signal(state)
433        {
434            self.update_neighbor_for_output_signal(pos, block);
435        }
436    }
437
438    /// Updates all neighbors except the one in `skip_direction`.
439    ///
440    /// Mirrors vanilla `Level.updateNeighborsAtExceptFromFacing` without the
441    /// experimental redstone `Orientation` value.
442    pub fn update_neighbors_at_except_from_facing(
443        self: &Arc<Self>,
444        pos: BlockPos,
445        source_block: BlockRef,
446        skip_direction: Direction,
447    ) {
448        self.neighbor_updater
449            .update_neighbors_at_except_from_facing(self, pos, source_block, Some(skip_direction));
450    }
451
452    /// Updates all neighboring shapes around `pos`.
453    pub fn update_neighbor_shapes_at(
454        self: &Arc<Self>,
455        state: BlockStateId,
456        pos: BlockPos,
457        flags: UpdateFlags,
458        update_limit: i32,
459    ) {
460        for direction in Direction::UPDATE_SHAPE_ORDER {
461            let neighbor_pos = pos.relative(direction);
462            self.neighbor_shape_changed(
463                direction.opposite(),
464                neighbor_pos,
465                pos,
466                state,
467                flags,
468                update_limit,
469            );
470        }
471    }
472
473    /// Updates comparators that can read analog output from `pos`.
474    ///
475    /// Steel intentionally never synchronously loads the second neighbor chunk:
476    /// block-ticking chunks have a radius-one Full-chunk safety border, while
477    /// other call sites retain the game-tick no-blocking policy.
478    pub(crate) fn update_neighbor_for_output_signal(
479        self: &Arc<Self>,
480        pos: BlockPos,
481        changed_block: BlockRef,
482    ) {
483        for direction in Direction::HORIZONTAL {
484            let mut relative_pos = pos.relative(direction);
485            if !self.has_full_chunk(Self::chunk_pos_for_block(relative_pos)) {
486                continue;
487            }
488
489            let mut state = self.get_block_state(relative_pos);
490            if state.get_block() == &vanilla_blocks::COMPARATOR {
491                self.neighbor_changed_with_state(state, relative_pos, changed_block, false);
492                continue;
493            }
494
495            if !self.is_redstone_conductor(state, relative_pos) {
496                continue;
497            }
498
499            relative_pos = relative_pos.relative(direction);
500            if !self.has_full_chunk(Self::chunk_pos_for_block(relative_pos)) {
501                continue;
502            }
503            state = self.get_block_state(relative_pos);
504            if state.get_block() == &vanilla_blocks::COMPARATOR {
505                self.neighbor_changed_with_state(state, relative_pos, changed_block, false);
506            }
507        }
508    }
509
510    pub(crate) fn update_neighbour_shapes(
511        self: &Arc<Self>,
512        state: BlockStateId,
513        pos: BlockPos,
514        flags: UpdateFlags,
515        update_limit: i32,
516    ) {
517        for direction in Direction::UPDATE_SHAPE_ORDER {
518            let neighbor_pos = pos.relative(direction);
519            self.neighbor_shape_changed(
520                direction.opposite(),
521                neighbor_pos,
522                pos,
523                state,
524                flags,
525                update_limit,
526            );
527        }
528    }
529
530    /// Recomputes a state against all neighbors in vanilla shape-update order.
531    pub fn update_from_neighbor_shapes(
532        self: &Arc<Self>,
533        state: BlockStateId,
534        pos: BlockPos,
535    ) -> BlockStateId {
536        let mut updated = state;
537        for direction in Direction::UPDATE_SHAPE_ORDER {
538            let neighbor_pos = pos.relative(direction);
539            let neighbor_state = self.get_block_state(neighbor_pos);
540            updated = BLOCK_BEHAVIORS
541                .get_behavior(updated.get_block())
542                .update_shape(updated, self, pos, direction, neighbor_pos, neighbor_state);
543        }
544        updated
545    }
546
547    /// Called when a neighbor's shape changes, to update this block's state.
548    ///
549    /// This is the Rust equivalent of vanilla's `NeighborUpdater.executeShapeUpdate()`.
550    pub(crate) fn neighbor_shape_changed(
551        self: &Arc<Self>,
552        direction: Direction,
553        pos: BlockPos,
554        neighbor_pos: BlockPos,
555        neighbor_state: BlockStateId,
556        flags: UpdateFlags,
557        update_limit: i32,
558    ) {
559        self.neighbor_updater.shape_update(
560            self,
561            ShapeUpdate::new(
562                direction,
563                neighbor_state,
564                pos,
565                neighbor_pos,
566                flags,
567                update_limit,
568            ),
569        );
570    }
571
572    pub(super) fn execute_neighbor_shape_update(
573        self: &Arc<Self>,
574        direction: Direction,
575        pos: BlockPos,
576        neighbor_pos: BlockPos,
577        neighbor_state: BlockStateId,
578        flags: UpdateFlags,
579        update_limit: i32,
580    ) {
581        if !self.is_in_valid_bounds(pos) {
582            return;
583        }
584
585        let current_state = self.get_block_state(pos);
586
587        if flags.contains(UpdateFlags::UPDATE_SKIP_SHAPE_UPDATE_ON_WIRE)
588            && current_state.get_block() == &vanilla_blocks::REDSTONE_WIRE
589        {
590            return;
591        }
592
593        let block_behaviors = &*BLOCK_BEHAVIORS;
594        let behavior = block_behaviors.get_behavior(current_state.get_block());
595        let new_state = behavior.update_shape(
596            current_state,
597            self,
598            pos,
599            direction,
600            neighbor_pos,
601            neighbor_state,
602        );
603
604        self.update_or_destroy(current_state, new_state, pos, flags, update_limit);
605    }
606
607    pub(crate) fn update_or_destroy(
608        self: &Arc<World>,
609        old_state: BlockStateId,
610        new_state: BlockStateId,
611        pos: BlockPos,
612        flags: UpdateFlags,
613        recursion_left: i32,
614    ) {
615        if new_state == old_state {
616            return;
617        }
618
619        if new_state.is_air() {
620            self.destroy_block_with_limit(
621                pos,
622                !flags.contains(UpdateFlags::UPDATE_SUPPRESS_DROPS),
623                recursion_left,
624            );
625        } else {
626            self.set_block_with_limit(
627                pos,
628                new_state,
629                flags & !UpdateFlags::UPDATE_SUPPRESS_DROPS,
630                recursion_left,
631            );
632        }
633    }
634
635    /// Notifies a block that one of its neighbors changed.
636    ///
637    /// This is the Rust equivalent of vanilla's `Level.neighborChanged()`.
638    pub(crate) fn neighbor_changed(self: &Arc<Self>, pos: BlockPos, source_block: BlockRef) {
639        self.neighbor_updater
640            .neighbor_changed(self, pos, source_block);
641    }
642
643    pub(crate) fn neighbor_changed_with_state(
644        self: &Arc<Self>,
645        state: BlockStateId,
646        pos: BlockPos,
647        source_block: BlockRef,
648        moved_by_piston: bool,
649    ) {
650        self.neighbor_updater.neighbor_changed_with_state(
651            self,
652            state,
653            pos,
654            source_block,
655            moved_by_piston,
656        );
657    }
658
659    pub(super) fn execute_neighbor_update(
660        self: &Arc<Self>,
661        state: BlockStateId,
662        pos: BlockPos,
663        source_block: BlockRef,
664        moved_by_piston: bool,
665    ) {
666        if !self.is_in_valid_bounds(pos) {
667            return;
668        }
669        let block_behaviors = &*BLOCK_BEHAVIORS;
670        let behavior = block_behaviors.get_behavior(state.get_block());
671        behavior.handle_neighbor_changed(state, self, pos, source_block, moved_by_piston);
672    }
673
674    pub(super) const fn chunk_pos_for_block(pos: BlockPos) -> ChunkPos {
675        ChunkPos::new(
676            SectionPos::block_to_section_coord(pos.0.x),
677            SectionPos::block_to_section_coord(pos.0.z),
678        )
679    }
680
681    /// Gets a block entity at the given position.
682    ///
683    /// Returns `None` if the chunk is not loaded or there is no block entity at the position.
684    #[must_use]
685    pub fn get_block_entity(&self, pos: BlockPos) -> Option<SharedBlockEntity> {
686        let chunk_pos = Self::chunk_pos_for_block(pos);
687        self.chunk_map
688            .with_full_chunk(chunk_pos, |chunk| chunk.get_block_entity_immediate(pos))
689            .flatten()
690    }
691
692    /// Adds a block entity to the loaded full chunk at its position.
693    pub(crate) fn set_block_entity(&self, block_entity: SharedBlockEntity) -> bool {
694        let pos = block_entity.get_block_pos();
695        if !self.is_in_valid_bounds(pos) {
696            return false;
697        }
698
699        self.chunk_map
700            .with_full_chunk(Self::chunk_pos_for_block(pos), |chunk| {
701                chunk.add_and_register_block_entity(block_entity)
702            })
703            .unwrap_or(false)
704    }
705
706    /// Removes a block entity only while it still owns its position.
707    pub(crate) fn remove_block_entity_if_same(&self, expected: &dyn BlockEntity) -> bool {
708        let pos = expected.get_block_pos();
709        if !self.is_in_valid_bounds(pos) {
710            return false;
711        }
712
713        self.chunk_map
714            .with_full_chunk(Self::chunk_pos_for_block(pos), |chunk| {
715                chunk.remove_block_entity_if_same(expected)
716            })
717            .unwrap_or(false)
718    }
719
720    /// Called when a block entity's data changes.
721    ///
722    /// Marks the containing chunk as unsaved so it will be persisted to disk.
723    pub fn block_entity_changed(&self, pos: BlockPos) {
724        let chunk_pos = Self::chunk_pos_for_block(pos);
725        self.chunk_map.packet_content_changed(chunk_pos);
726        self.mark_chunk_dirty(chunk_pos);
727    }
728
729    /// Queues a same-state block update and its block-entity update packet.
730    ///
731    /// Mirrors vanilla `Level.sendBlockUpdated` for callers that changed only
732    /// block-entity data.
733    pub(crate) fn send_block_updated(&self, pos: BlockPos) {
734        self.chunk_map.block_changed(pos);
735    }
736
737    /// Marks a chunk as dirty (unsaved) so it will be persisted to disk.
738    ///
739    /// Called when entities move, are added/removed, or when block entities change.
740    pub fn mark_chunk_dirty(&self, chunk_pos: ChunkPos) {
741        self.chunk_map
742            .with_chunk_at_status(chunk_pos, ChunkStatus::Empty, Chunk::mark_dirty);
743    }
744}