Skip to main content

steel_core/chunk/chunk_map/
mod.rs

1use arc_swap::ArcSwap;
2use rayon::ThreadPool;
3use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
4use std::{
5    io, mem,
6    sync::{
7        Arc, Weak,
8        atomic::{AtomicBool, AtomicUsize, Ordering},
9    },
10    time::{Duration, Instant},
11};
12use steel_protocol::packet_traits::EncodedPacket;
13use steel_protocol::packets::game::{
14    BlockChange, CBlockUpdate, CLightUpdate, CSectionBlocksUpdate, CSetChunkCenter,
15};
16use steel_protocol::utils::ConnectionProtocol;
17use steel_registry::dimension_type::DimensionTypeRef;
18use steel_registry::{
19    blocks::{BlockRef, block_state_ext::BlockStateExt},
20    fluid::FluidRef,
21};
22use steel_utils::{BlockPos, BlockStateId, ChunkPos, PackedChunkPos, SectionPos, locks::SyncMutex};
23use tokio::runtime::Runtime;
24use tokio::sync::Notify;
25use tokio::time::sleep;
26use tokio_util::sync::CancellationToken;
27use tokio_util::task::TaskTracker;
28use tracing::instrument;
29
30use crate::behavior::{BLOCK_BEHAVIORS, FLUID_BEHAVIORS};
31use crate::block_entity::{BlockEntityLifecycleExt as _, ClearedBlockEntities, SharedBlockEntity};
32use crate::chunk::chunk_holder::{
33    ChunkHolder, ChunkSaveDependency, PostProcessGenerationError, TickingReadiness,
34};
35use crate::chunk::chunk_request::ChunkRequestLease;
36pub use crate::chunk::chunk_scheduler::ChunkMapSchedulingTimings;
37#[cfg(test)]
38use crate::chunk::chunk_scheduler::PlayerTicketOperation;
39use crate::chunk::chunk_scheduler::{ChunkSchedulingCoordinator, ChunkTicketReceipt};
40use crate::chunk::chunk_ticket_manager::{
41    ChunkTicketLevel, LoadLevelChange, generation_status, is_block_ticking, is_entity_ticking,
42    is_full,
43};
44use crate::chunk::chunk_ticket_storage::{
45    ChunkTicketStorage, ENDER_PEARL_TICKET_TIMEOUT_TICKS, PersistentChunkTickets,
46    TimedTicketExpiration,
47};
48use crate::chunk::full_chunk_readiness::{
49    FullNeighborhoodCounts, FullNeighborhoodError, FullNeighborhoodIndex, FullPublication,
50    FullPublicationQueue,
51};
52pub use crate::chunk::gameplay_chunk_lookup_cache::GameplayChunkLookupCacheStats;
53use crate::chunk::gameplay_chunk_lookup_cache::{
54    GameplayChunkLookupCacheScope, lookup_or_insert_with,
55};
56use crate::chunk::light::{
57    LIGHT_CACHE_RADIUS, LightCacheLayout, LightCacheSetupRadius, LightLayer,
58    LightSectionEmptinessChange, LightSectionRange, LightWorkWindowGate, LightWorkset,
59    build_chunk_light_update_packet_for_sections,
60    propagate_block_light_changes_with_empty_sections,
61    propagate_sky_light_changes_with_empty_sections,
62};
63use crate::chunk::player_chunk_view::PlayerChunkView;
64use crate::chunk::simulation_ticket_manager::SimulationLevelChange;
65use crate::chunk::{
66    Chunk,
67    chunk_generation_task::ChunkGenerationTask,
68    full_chunk::{BlockRandomPositionGenerator, FullChunkRef},
69    section::RandomTickSectionBits,
70    status::ChunkStatus,
71};
72use crate::chunk_saver::ChunkStorage;
73use crate::player::connection::NetworkConnection;
74use crate::world::World;
75use crate::world::tick_scheduler::{BlockTick, FluidTick, ScheduledTickRunBatch};
76use crate::worldgen::{ChunkGeneratorType, WorldGenContext};
77use crate::{entity::Entity, player::Player};
78
79mod generation_readiness;
80mod light_update_state;
81mod light_updates;
82mod persistence;
83mod player_tracking;
84mod scheduled_ticks;
85
86#[cfg(test)]
87use light_update_state::PendingLightUpdates;
88use light_update_state::{InFlightLightUpdates, LightUpdateState, PendingChunkLightUpdates};
89
90const GENERATION_THREAD_MULTIPLE: usize = 2;
91// Vanilla applies this limit independently to block ticks and fluid ticks.
92const MAX_SCHEDULED_TICKS_PER_TICK: usize = 65_536;
93
94/// Lifetime, in ticks, of a thrown ender pearl's chunk ticket (vanilla
95/// `TicketType.ENDER_PEARL` timeout). The pearl refreshes it every
96/// `ENDER_PEARL_TICKET_TIMEOUT - 1` ticks while it flies.
97pub const ENDER_PEARL_TICKET_TIMEOUT: i64 = ENDER_PEARL_TICKET_TIMEOUT_TICKS;
98
99/// Timing information for the game tick portion of chunk map operations.
100#[derive(Debug, Default)]
101pub struct ChunkMapGameTickTimings {
102    /// Timing for the unified loading and simulation chunk-source phase.
103    pub scheduling: ChunkMapSchedulingTimings,
104    /// Time spent broadcasting block changes.
105    pub broadcast_changes: Duration,
106    /// Time spent collecting tickable chunks.
107    pub collect_tickable: Duration,
108    /// Time spent ticking chunks (random ticks, etc.).
109    pub tick_chunks: Duration,
110    /// Time spent ticking block entities.
111    pub tick_block_entities: Duration,
112    /// Number of block-ticking chunks.
113    pub tickable_count: usize,
114    /// Total number of loaded chunks.
115    pub total_chunks: usize,
116    /// Scoped holder-cache activity across the world game tick.
117    pub lookup_cache: GameplayChunkLookupCacheStats,
118}
119
120#[derive(Clone)]
121struct TickableChunk {
122    pos: ChunkPos,
123    holder: Arc<ChunkHolder>,
124    randomly_ticking_sections: Arc<RandomTickSectionBits>,
125}
126
127/// Immutable views of the chunk sets consumed during a game tick.
128///
129/// Entries retain the optimized SCC traversal order captured at the last
130/// membership-changing lifecycle boundary. This is also Steel's documented
131/// final order for the implementation-specific cross-chunk ties that Vanilla
132/// derives from its fastutil map state.
133#[derive(Default)]
134struct TickingChunkSnapshot {
135    block: Box<[TickableChunk]>,
136    random_chunk_indices: Box<[usize]>,
137    entity_indices: Box<[usize]>,
138}
139
140struct FinalizedBlockEntityUnload {
141    holder: Arc<ChunkHolder>,
142    lifecycle_dispatchers: Vec<SharedBlockEntity>,
143    positions: Vec<BlockPos>,
144}
145
146struct BlockTickBatchGuard<'a> {
147    world: &'a World,
148    batch: Arc<ScheduledTickRunBatch<BlockRef>>,
149}
150
151impl<'a> BlockTickBatchGuard<'a> {
152    fn new(world: &'a World, ticks: Vec<BlockTick>) -> Self {
153        Self {
154            world,
155            batch: world.begin_scheduled_block_tick_batch(ticks),
156        }
157    }
158
159    fn ticks(&self) -> &[BlockTick] {
160        self.batch.ticks()
161    }
162
163    fn start(&self, index: usize) {
164        self.batch.start(index);
165    }
166}
167
168impl Drop for BlockTickBatchGuard<'_> {
169    fn drop(&mut self) {
170        self.world.end_scheduled_block_tick_batch(&self.batch);
171    }
172}
173
174struct FluidTickBatchGuard<'a> {
175    world: &'a World,
176    batch: Arc<ScheduledTickRunBatch<FluidRef>>,
177}
178
179impl<'a> FluidTickBatchGuard<'a> {
180    fn new(world: &'a World, ticks: Vec<FluidTick>) -> Self {
181        Self {
182            world,
183            batch: world.begin_scheduled_fluid_tick_batch(ticks),
184        }
185    }
186
187    fn ticks(&self) -> &[FluidTick] {
188        self.batch.ticks()
189    }
190
191    fn start(&self, index: usize) {
192        self.batch.start(index);
193    }
194}
195
196impl Drop for FluidTickBatchGuard<'_> {
197    fn drop(&mut self) {
198        self.world.end_scheduled_fluid_tick_batch(&self.batch);
199    }
200}
201
202struct TickingReadinessCandidate {
203    pos: ChunkPos,
204    holder: Arc<ChunkHolder>,
205    desired: TickingReadiness,
206    target: TickingReadiness,
207}
208
209#[derive(Debug, Clone, Copy)]
210struct DeferredChunkRevival {
211    load_level: ChunkTicketLevel,
212}
213
214#[derive(Default)]
215struct ReadinessReconcileResult {
216    snapshot_changed: bool,
217    post_process_generation: Duration,
218    post_process_chunk_count: usize,
219    post_process_position_count: usize,
220    candidate_count: usize,
221}
222
223/// A map of chunks managing their state, loading, and generation.
224pub struct ChunkMap {
225    /// Map of active chunks.
226    pub(crate) chunks: scc::HashMap<ChunkPos, Arc<ChunkHolder>, FxBuildHasher>,
227    /// Map of chunks currently being unloaded.
228    pub(crate) unloading_chunks: scc::HashMap<ChunkPos, Arc<ChunkHolder>, FxBuildHasher>,
229    /// Ticket states waiting for an unloading holder's save preparation to finish.
230    deferred_revivals: SyncMutex<FxHashMap<ChunkPos, DeferredChunkRevival>>,
231    /// Queue of pending generation tasks.
232    pub pending_generation_tasks: SyncMutex<Vec<Arc<ChunkGenerationTask>>>,
233    /// Tracker for generation, save, and unload tasks.
234    pub task_tracker: TaskTracker,
235    /// Authoritative ticket storage and loading/simulation propagation.
236    scheduling: ChunkSchedulingCoordinator,
237    /// Serializes live and startup chunk-phase drivers.
238    source_phase_guard: SyncMutex<()>,
239    /// Full status completions awaiting lifecycle-boundary reconciliation.
240    full_publications: Arc<FullPublicationQueue>,
241    /// Incremental radius-1/radius-2 Full-neighborhood state.
242    full_neighborhood: SyncMutex<FullNeighborhoodIndex>,
243    /// Readiness-driven chunk views published at lifecycle boundaries.
244    ticking_chunks: ArcSwap<TickingChunkSnapshot>,
245    /// Final-unload callbacks waiting for the serialized lifecycle boundary.
246    finalized_block_entity_unloads: SyncMutex<Vec<FinalizedBlockEntityUnload>>,
247    /// The world generation context.
248    pub world_gen_context: Arc<WorldGenContext>,
249    /// The thread pool to use for chunk generation (throughput-oriented).
250    pub generation_pool: Arc<ThreadPool>,
251    /// The thread pool to use for CPU-heavy chunk persistence work.
252    chunk_encoding_pool: Arc<ThreadPool>,
253    /// The thread pool to use for chunk ticking (latency-oriented).
254    //pub tick_pool: Arc<ThreadPool>,
255    /// The runtime to use for chunk tasks.
256    pub chunk_runtime: Arc<Runtime>,
257    /// Storage backend for chunk saving and loading.
258    pub storage: Arc<ChunkStorage>,
259    /// Chunk holders with pending block changes to broadcast.
260    pub chunks_to_broadcast: SyncMutex<Vec<Arc<ChunkHolder>>>,
261    /// Coalesced light changes and drained-but-not-yet-applied light work.
262    light_updates: SyncMutex<LightUpdateState>,
263    /// Notifies save barriers when in-flight light propagation state changes.
264    light_updates_progress_notify: Notify,
265    /// Radius-2 work-window gate for light-engine worksets.
266    light_work_window_gate: Arc<LightWorkWindowGate>,
267    /// Number of top-level generation tasks currently running.
268    running_generation_tasks: AtomicUsize,
269    /// Wakes the generation refill loop when pending/running task state changes.
270    generation_refill_notify: Notify,
271    /// Cancels the generation refill loop without cancelling active generation tasks.
272    generation_refill_cancel_token: CancellationToken,
273    /// Fast shutdown flag for the generation refill loop.
274    generation_refill_stopped: AtomicBool,
275    /// Whether the notify-driven refill loop has been started for this map.
276    generation_refill_started: AtomicBool,
277    /// Parent cancellation token for all generation tasks.
278    /// Child tokens are created per-task; cancelling this cancels everything.
279    pub cancel_token: CancellationToken,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
283struct GenerationTaskPriority {
284    simulation_bucket: u8,
285    simulation_level: ChunkTicketLevel,
286    load_level: ChunkTicketLevel,
287}
288
289impl GenerationTaskPriority {
290    const fn for_levels(
291        load_level: Option<ChunkTicketLevel>,
292        simulation_level: Option<ChunkTicketLevel>,
293    ) -> Self {
294        let simulation_bucket = if simulation_level.is_some() { 0 } else { 1 };
295        Self {
296            simulation_bucket,
297            simulation_level: match simulation_level {
298                Some(level) => level,
299                None => ChunkTicketLevel::MAX,
300            },
301            load_level: match load_level {
302                Some(level) => level,
303                None => ChunkTicketLevel::MAX,
304            },
305        }
306    }
307}
308
309struct RunningGenerationTaskPermit {
310    chunk_map: Arc<ChunkMap>,
311    task: Arc<ChunkGenerationTask>,
312}
313
314impl Drop for RunningGenerationTaskPermit {
315    fn drop(&mut self) {
316        self.task
317            .center_holder()
318            .clear_generation_task_if_current(&self.task);
319        self.chunk_map
320            .running_generation_tasks
321            .fetch_sub(1, Ordering::AcqRel);
322        self.chunk_map.notify_generation_refill();
323    }
324}
325
326impl ChunkMap {
327    /// Creates a new chunk map with a custom storage backend.
328    ///
329    /// This allows using different storage implementations (disk, RAM, etc.).
330    #[must_use]
331    #[expect(
332        clippy::too_many_arguments,
333        reason = "chunk-map construction requires its world and scheduling configuration"
334    )]
335    pub fn new_with_storage(
336        chunk_runtime: Arc<Runtime>,
337        world: Weak<World>,
338        dimension_type: DimensionTypeRef,
339        sea_level: i32,
340        view_distance: u8,
341        simulation_distance: u8,
342        storage: Arc<ChunkStorage>,
343        generator: Arc<ChunkGeneratorType>,
344        generation_pool: Arc<ThreadPool>,
345    ) -> Self {
346        let chunk_encoding_pool = Arc::clone(&generation_pool);
347        Self::new_with_storage_and_ticket_storage(
348            chunk_runtime,
349            world,
350            dimension_type,
351            sea_level,
352            storage,
353            generator,
354            generation_pool,
355            chunk_encoding_pool,
356            view_distance,
357            simulation_distance,
358            ChunkTicketStorage::new(),
359        )
360    }
361
362    #[must_use]
363    #[expect(
364        clippy::too_many_arguments,
365        reason = "extends ChunkMap::new_with_storage with restored runtime ticket state"
366    )]
367    pub(crate) fn new_with_storage_and_ticket_storage(
368        chunk_runtime: Arc<Runtime>,
369        world: Weak<World>,
370        dimension_type: DimensionTypeRef,
371        sea_level: i32,
372        storage: Arc<ChunkStorage>,
373        generator: Arc<ChunkGeneratorType>,
374        generation_pool: Arc<ThreadPool>,
375        chunk_encoding_pool: Arc<ThreadPool>,
376        view_distance: u8,
377        simulation_distance: u8,
378        ticket_storage: ChunkTicketStorage,
379    ) -> Self {
380        let full_publications = Arc::new(FullPublicationQueue::default());
381
382        Self {
383            chunks: scc::HashMap::default(),
384            unloading_chunks: scc::HashMap::default(),
385            deferred_revivals: SyncMutex::new(FxHashMap::default()),
386            pending_generation_tasks: SyncMutex::new(Vec::new()),
387            task_tracker: TaskTracker::new(),
388            scheduling: ChunkSchedulingCoordinator::new(
389                ticket_storage,
390                view_distance,
391                simulation_distance,
392            ),
393            source_phase_guard: SyncMutex::new(()),
394            full_publications,
395            full_neighborhood: SyncMutex::new(FullNeighborhoodIndex::default()),
396            ticking_chunks: ArcSwap::from_pointee(TickingChunkSnapshot::default()),
397            finalized_block_entity_unloads: SyncMutex::new(Vec::new()),
398            world_gen_context: Arc::new(WorldGenContext::new(
399                generator,
400                world,
401                dimension_type.min_y,
402                dimension_type.height,
403                sea_level,
404            )),
405            generation_pool,
406            chunk_encoding_pool,
407            chunk_runtime,
408            storage,
409            chunks_to_broadcast: SyncMutex::new(Vec::new()),
410            light_updates: SyncMutex::new(LightUpdateState::default()),
411            light_updates_progress_notify: Notify::new(),
412            light_work_window_gate: Arc::new(LightWorkWindowGate::new()),
413            running_generation_tasks: AtomicUsize::new(0),
414            generation_refill_notify: Notify::new(),
415            generation_refill_cancel_token: CancellationToken::new(),
416            generation_refill_stopped: AtomicBool::new(false),
417            generation_refill_started: AtomicBool::new(false),
418            cancel_token: CancellationToken::new(),
419        }
420    }
421
422    pub(crate) fn light_work_window_gate(&self) -> Arc<LightWorkWindowGate> {
423        Arc::clone(&self.light_work_window_gate)
424    }
425
426    /// Starts the notify-driven generation refill loop for this chunk map.
427    pub fn start_generation_refill_loop(self: &Arc<Self>) {
428        if self.generation_refill_started.swap(true, Ordering::AcqRel) {
429            return;
430        }
431
432        let chunk_map = Arc::clone(self);
433        self.task_tracker.spawn_on(
434            async move {
435                loop {
436                    tokio::select! {
437                        () = chunk_map.generation_refill_cancel_token.cancelled() => break,
438                        () = chunk_map.generation_refill_notify.notified() => {
439                            chunk_map.run_generation_tasks_b();
440                        }
441                    }
442                }
443            },
444            self.chunk_runtime.handle(),
445        );
446    }
447
448    /// Stops the generation refill loop and cancels work that has not started.
449    /// Active generation tasks are left alone for the task tracker to drain.
450    pub fn stop_generation_refill_loop(&self) {
451        self.generation_refill_stopped
452            .store(true, Ordering::Release);
453        self.generation_refill_cancel_token.cancel();
454        self.generation_refill_notify.notify_waiters();
455
456        let pending = mem::take(&mut *self.pending_generation_tasks.lock());
457        for task in pending {
458            task.cancel();
459            task.center_holder().clear_generation_task_if_current(&task);
460        }
461    }
462
463    pub(crate) fn notify_generation_refill(&self) {
464        self.generation_refill_notify.notify_one();
465    }
466
467    fn run_or_notify_generation_refill(&self) {
468        if self.generation_refill_started.load(Ordering::Acquire) {
469            self.notify_generation_refill();
470        } else {
471            self.run_generation_tasks_b();
472        }
473    }
474
475    /// Executes a function with access to a fully loaded chunk.
476    /// Returns `None` if the chunk is not loaded or not at Full status.
477    pub fn with_full_chunk<F, R>(&self, pos: ChunkPos, f: F) -> Option<R>
478    where
479        F: FnOnce(FullChunkRef<'_>) -> R,
480    {
481        let holder = self.lookup_active_holder(pos)?;
482        if holder.is_status_disallowed(ChunkStatus::Full) {
483            return None;
484        }
485        holder.try_full_chunk().map(f)
486    }
487
488    /// Returns the active holder for a fully loaded chunk.
489    pub(crate) fn active_full_chunk_holder(&self, pos: ChunkPos) -> Option<Arc<ChunkHolder>> {
490        let holder = self.lookup_active_holder(pos)?;
491        if holder.is_status_disallowed(ChunkStatus::Full)
492            || holder.try_chunk(ChunkStatus::Full).is_none()
493        {
494            return None;
495        }
496        Some(holder)
497    }
498
499    /// Inserts a non-simulated holder into an empty gameplay view for worldgen benchmarks.
500    ///
501    /// Runtime lifecycle code must use ticket-driven insertion. Benchmark holders
502    /// cannot enter a ticking snapshot, so bulk fixture construction needs no rebuild.
503    #[doc(hidden)]
504    #[cfg(feature = "benchmark-support")]
505    pub fn insert_benchmark_chunk_holder(&self, pos: ChunkPos, holder: Arc<ChunkHolder>) {
506        assert!(holder.simulation_level().is_none());
507        assert!(self.ticking_chunks.load().block.is_empty());
508        let _ = self.chunks.insert_sync(pos, holder);
509    }
510
511    #[inline]
512    fn lookup_active_holder(&self, pos: ChunkPos) -> Option<Arc<ChunkHolder>> {
513        lookup_or_insert_with(self, pos, || {
514            self.chunks.read_sync(&pos, |_, holder| Arc::clone(holder))
515        })
516    }
517
518    /// Returns whether an active full chunk is currently block ticking.
519    #[must_use]
520    pub(crate) fn is_block_ticking_full_chunk_loaded(&self, pos: ChunkPos) -> bool {
521        self.lookup_active_holder(pos).is_some_and(|holder| {
522            is_block_ticking(holder.load_level())
523                && holder.ticking_readiness_snapshot().is_block_ticking()
524        })
525    }
526
527    /// Returns whether the chunk is in block simulation range with confirmed r1 readiness.
528    #[must_use]
529    pub(crate) fn is_block_ticking_full_chunk_simulated(&self, pos: ChunkPos) -> bool {
530        self.lookup_active_holder(pos).is_some_and(|holder| {
531            is_block_ticking(holder.simulation_level())
532                && holder.ticking_readiness_snapshot().is_block_ticking()
533        })
534    }
535
536    /// Executes a function with access to a chunk at the requested generation status or later.
537    /// Returns `None` if the chunk is not loaded or has not reached the requested status.
538    pub(crate) fn with_chunk_at_status<F, R>(
539        &self,
540        pos: ChunkPos,
541        status: ChunkStatus,
542        f: F,
543    ) -> Option<R>
544    where
545        F: FnOnce(&Chunk) -> R,
546    {
547        let chunk_holder = self.lookup_active_holder(pos)?;
548        // Holders retain completed higher-status data for saving and quick revival. Gameplay
549        // lookups must still honor the currently permitted generation status.
550        if chunk_holder.is_status_disallowed(status) {
551            return None;
552        }
553        let chunk = chunk_holder.try_chunk(status)?;
554        Some(f(chunk))
555    }
556
557    #[cfg(test)]
558    pub(crate) fn queue_test_player_ticket_add(
559        &self,
560        pos: ChunkPos,
561        player_id: uuid::Uuid,
562    ) -> ChunkTicketReceipt {
563        self.scheduling
564            .queue_player_ticket_operation(PlayerTicketOperation::Add { pos, player_id })
565    }
566
567    #[cfg(test)]
568    pub(crate) fn queue_test_player_ticket_remove(
569        &self,
570        pos: ChunkPos,
571        player_id: uuid::Uuid,
572    ) -> ChunkTicketReceipt {
573        self.scheduling
574            .queue_player_ticket_operation(PlayerTicketOperation::Remove { pos, player_id })
575    }
576
577    pub(crate) fn acquire_chunk_request_leases(
578        &self,
579        positions: &[ChunkPos],
580        ticket_level: ChunkTicketLevel,
581    ) -> Option<ChunkTicketReceipt> {
582        self.scheduling
583            .acquire_chunk_request_leases(positions.iter().copied(), ticket_level)
584    }
585
586    pub(crate) fn release_chunk_request_leases(
587        &self,
588        positions: &[ChunkPos],
589        ticket_level: ChunkTicketLevel,
590    ) -> Option<ChunkTicketReceipt> {
591        self.scheduling
592            .release_chunk_request_leases(positions.iter().copied(), ticket_level)
593    }
594
595    pub(crate) fn is_ticket_receipt_committed(&self, receipt: ChunkTicketReceipt) -> bool {
596        self.scheduling.is_receipt_committed(receipt)
597    }
598
599    /// Drives startup scheduling until a full square is ready, runs `f`, then
600    /// removes the temporary ticket.
601    pub(crate) async fn with_full_chunks_in_radius<F, R>(
602        self: &Arc<Self>,
603        center: ChunkPos,
604        radius: u8,
605        f: F,
606    ) -> Option<R>
607    where
608        F: FnOnce() -> R,
609    {
610        let ticket_level = ChunkTicketLevel::for_full_chunk_radius(radius);
611        let lease = ChunkRequestLease::new(Arc::clone(self), Box::new([center]), ticket_level);
612        let Some(ticket_receipt) = lease.submission_receipt else {
613            unreachable!("one chunk request lease must produce a receipt");
614        };
615        let radius = i32::from(radius);
616
617        loop {
618            self.advance_scheduling();
619            if self.is_ticket_receipt_committed(ticket_receipt)
620                && self.full_square_is_ready(center, radius)
621            {
622                break;
623            }
624
625            if self.cancel_token.is_cancelled() {
626                drop(lease);
627                self.advance_scheduling();
628                return None;
629            }
630
631            sleep(Duration::from_millis(10)).await;
632        }
633
634        let result = f();
635        drop(lease);
636        self.advance_scheduling();
637
638        Some(result)
639    }
640
641    /// Adds or refreshes vanilla's post-portal chunk ticket.
642    pub(crate) fn place_portal_ticket(&self, ticket_position: BlockPos) {
643        let center = ChunkPos::from_block_pos(ticket_position);
644        self.scheduling.add_or_refresh_portal_ticket(center);
645    }
646
647    /// Advances eligible timed chunk tickets by one server tick.
648    pub(crate) fn tick_timed_tickets(&self) {
649        let expirations = self.eligible_timed_ticket_expirations();
650        self.scheduling.tick_timed_tickets(&expirations);
651    }
652
653    fn eligible_timed_ticket_expirations(&self) -> Vec<TimedTicketExpiration> {
654        // Holder readiness is checked without holding the ticket-ingress lock.
655        let mut expirations = self.scheduling.timed_ticket_expirations();
656        expirations.retain(|expiration| {
657            expiration.can_expire_if_unloaded() || self.can_timed_ticket_expire(expiration.pos())
658        });
659        expirations
660    }
661
662    pub(crate) fn persistent_chunk_tickets(&self) -> PersistentChunkTickets {
663        self.scheduling.persistent_chunk_tickets()
664    }
665
666    fn can_timed_ticket_expire(&self, pos: ChunkPos) -> bool {
667        self.chunks
668            .read_sync(&pos, |_, holder| holder.is_ready_for_saving())
669            .unwrap_or(true)
670    }
671
672    fn full_square_is_ready(&self, center: ChunkPos, radius: i32) -> bool {
673        for dz in -radius..=radius {
674            for dx in -radius..=radius {
675                let pos = ChunkPos::new(center.0.x + dx, center.0.y + dz);
676                let Some(holder) = self.chunks.read_sync(&pos, |_, holder| holder.clone()) else {
677                    return false;
678                };
679                if holder.try_chunk(ChunkStatus::Full).is_none() {
680                    return false;
681                }
682            }
683        }
684        true
685    }
686
687    /// Broadcasts pending block changes and completed light changes to nearby players.
688    #[expect(
689        clippy::too_many_lines,
690        reason = "block and light packet construction share the same holder drain"
691    )]
692    pub fn broadcast_changed_chunks(&self) {
693        self.propagate_queued_light_changes();
694
695        let holders = {
696            let mut guard = self.chunks_to_broadcast.lock();
697            if guard.is_empty() {
698                return;
699            }
700            mem::take(&mut *guard)
701        };
702
703        let mut world = None;
704
705        for holder in holders {
706            let chunk_pos = holder.get_pos();
707            // Vanilla publishes block changes independently of unfinished light propagation.
708            let world = world.get_or_insert_with(|| self.world_gen_context.world());
709            let has_skylight = world.dimension_type.has_skylight;
710            let min_y = holder.min_y();
711            holder.clear_broadcast_queued();
712
713            let light_changes = holder.take_changed_light_sections();
714            // Take all pending changes from this chunk holder
715            let changes_by_section = holder.take_changed_blocks();
716            let has_publishable_light_changes =
717                !light_changes.block.is_empty() || (has_skylight && !light_changes.sky.is_empty());
718
719            if !has_publishable_light_changes && changes_by_section.is_empty() {
720                continue;
721            }
722
723            if has_publishable_light_changes
724                && let Some(chunk) = holder.try_chunk(ChunkStatus::Full)
725            {
726                let tracking_players = world.get_light_packet_tracking_players(chunk_pos);
727                if !tracking_players.is_empty() {
728                    let light_data = {
729                        let light = chunk.light();
730                        let sky_sections = if has_skylight {
731                            light_changes.sky.as_slice()
732                        } else {
733                            &[]
734                        };
735                        build_chunk_light_update_packet_for_sections(
736                            chunk_pos,
737                            &light,
738                            has_skylight,
739                            sky_sections,
740                            &light_changes.block,
741                        )
742                    };
743                    let light_packet = CLightUpdate {
744                        x: chunk_pos.0.x,
745                        z: chunk_pos.0.y,
746                        light_data,
747                    };
748
749                    let Ok(encoded) = EncodedPacket::from_bare(
750                        light_packet,
751                        world.compression,
752                        ConnectionProtocol::Play,
753                    ) else {
754                        log::warn!("Failed to encode light update packet");
755                        continue;
756                    };
757
758                    for entity_id in &tracking_players {
759                        if let Some(player) = world.players.get_by_entity_id(*entity_id) {
760                            player.connection.send_encoded(encoded.clone());
761                        }
762                    }
763                }
764            }
765
766            if changes_by_section.is_empty() {
767                continue;
768            }
769
770            // Get players whose client already has the base chunk packet.
771            let tracking_players = world.get_packet_tracking_players(chunk_pos);
772            if tracking_players.is_empty() {
773                continue;
774            }
775
776            // For each section with changes, send appropriate packet
777            for (section_index, changed_positions) in changes_by_section {
778                let section_y = min_y / 16 + section_index as i32;
779                let section_pos = SectionPos::new(chunk_pos.0.x, section_y, chunk_pos.0.y);
780
781                if changed_positions.len() == 1 {
782                    // Single block change - use CBlockUpdate
783                    let Some(&packed) = changed_positions.iter().next() else {
784                        continue;
785                    };
786                    let block_pos = section_pos.relative_to_block_pos(packed);
787                    let block_state = world.get_block_state(block_pos);
788
789                    tracing::trace!(
790                        ?block_pos,
791                        ?block_state,
792                        player_count = tracking_players.len(),
793                        "Broadcasting single block update"
794                    );
795
796                    let update_packet = CBlockUpdate {
797                        pos: block_pos,
798                        block_state,
799                    };
800
801                    let Ok(encoded) = EncodedPacket::from_bare(
802                        update_packet,
803                        world.compression,
804                        ConnectionProtocol::Play,
805                    ) else {
806                        log::warn!("Failed to encode block update packet");
807                        continue;
808                    };
809
810                    for entity_id in &tracking_players {
811                        if let Some(player) = world.players.get_by_entity_id(*entity_id) {
812                            player.connection.send_encoded(encoded.clone());
813                        }
814                    }
815                    world.broadcast_block_entity_if_needed(block_pos);
816                } else {
817                    // Multiple block changes - use CSectionBlocksUpdate
818                    let changes: Vec<BlockChange> = changed_positions
819                        .iter()
820                        .map(|&packed| {
821                            let block_pos = section_pos.relative_to_block_pos(packed);
822                            let block_state = world.get_block_state(block_pos);
823                            BlockChange {
824                                pos: packed,
825                                block_state,
826                            }
827                        })
828                        .collect();
829
830                    tracing::trace!(
831                        change_count = changes.len(),
832                        ?section_pos,
833                        player_count = tracking_players.len(),
834                        "Broadcasting section block updates"
835                    );
836
837                    let packet = CSectionBlocksUpdate {
838                        section_pos,
839                        changes,
840                    };
841
842                    let Ok(encoded) = EncodedPacket::from_bare(
843                        packet,
844                        world.compression,
845                        ConnectionProtocol::Play,
846                    ) else {
847                        log::warn!("Failed to encode section block update packet");
848                        continue;
849                    };
850
851                    for entity_id in &tracking_players {
852                        if let Some(player) = world.players.get_by_entity_id(*entity_id) {
853                            player.connection.send_encoded(encoded.clone());
854                        }
855                    }
856                    for &packed in &changed_positions {
857                        let block_pos = section_pos.relative_to_block_pos(packed);
858                        world.broadcast_block_entity_if_needed(block_pos);
859                    }
860                }
861            }
862        }
863    }
864
865    /// Processes chunk updates, ticks chunks, and executes ready scheduled ticks.
866    ///
867    /// # Arguments
868    /// * `world` - The world reference (needed for executing scheduled tick callbacks)
869    /// Game tick: scheduled ticks, the unified chunk-source phase, random ticks,
870    /// broadcasts, and unload handoff. Generation and persistence work remain async.
871    #[instrument(level = "trace", skip(self, world), name = "chunk_map_game_tick")]
872    pub fn tick_game(
873        self: &Arc<Self>,
874        world: &Arc<World>,
875        tick_count: u64,
876        random_tick_speed: u32,
877        runs_normally: bool,
878    ) -> ChunkMapGameTickTimings {
879        let _source_phase_guard = self.source_phase_guard.lock();
880        let mut timings = ChunkMapGameTickTimings::default();
881
882        if tick_count.is_multiple_of(100) {
883            tracing::debug!(
884                chunks = self.chunks.len(),
885                unloading = self.unloading_chunks.len(),
886                "Chunk map status"
887            );
888        }
889
890        if runs_normally {
891            let lookup_cache_scope = GameplayChunkLookupCacheScope::enter(self);
892            let _span = tracing::trace_span!("collect_tickable").entered();
893            let start = Instant::now();
894            let tickable_chunks = self.ticking_chunks.load();
895            timings.collect_tickable = start.elapsed();
896            if !tickable_chunks.block.is_empty() {
897                let _span = tracing::trace_span!(
898                    "scheduled_ticks",
899                    block_ticking_count = tickable_chunks.block.len(),
900                    total_chunks = self.chunks.len()
901                )
902                .entered();
903                let start = Instant::now();
904                // Block and fluid collection share the same post-`tick_time`
905                // timestamp even though block callbacks run between the phases.
906                let current_tick = world.game_time();
907                let ready_block_ticks =
908                    Self::collect_scheduled_block_ticks(world, &tickable_chunks, current_tick);
909                Self::execute_scheduled_block_ticks(world, ready_block_ticks);
910
911                let ready_fluid_ticks =
912                    Self::collect_scheduled_fluid_ticks(world, &tickable_chunks, current_tick);
913                Self::execute_scheduled_fluid_ticks(world, ready_fluid_ticks);
914                timings.tick_chunks += start.elapsed();
915            }
916            timings.lookup_cache.merge(lookup_cache_scope.finish());
917        }
918
919        // Vanilla purges stale tickets only during normal level ticks, then
920        // propagates simulation and loading levels together in ServerChunkCache::tick.
921        if runs_normally {
922            self.tick_timed_tickets();
923        }
924        timings.scheduling = self.run_chunk_source_updates();
925
926        {
927            let lookup_cache_scope = GameplayChunkLookupCacheScope::enter(self);
928            let _span = tracing::trace_span!("collect_tickable").entered();
929            let start = Instant::now();
930            let tickable_chunks = self.ticking_chunks.load();
931            timings.collect_tickable += start.elapsed();
932            timings.total_chunks = self.chunks.len();
933            timings.tickable_count = tickable_chunks.block.len();
934
935            if runs_normally && random_tick_speed > 0 && !tickable_chunks.block.is_empty() {
936                let _span = tracing::trace_span!(
937                    "random_chunk_ticks",
938                    block_ticking_count = tickable_chunks.block.len(),
939                    total_chunks = timings.total_chunks
940                )
941                .entered();
942                let start = Instant::now();
943                // Intentional Steel difference: this uses Vanilla's coordinate LCG,
944                // but seeds it per tick from runtime RNG instead of sharing Level RNG.
945                let mut random_positions = BlockRandomPositionGenerator::from_runtime_rng();
946                for &index in &tickable_chunks.random_chunk_indices {
947                    // Vanilla random chunk ticks use the entity-ticking range but only
948                    // require the same confirmed block-ticking chunk used by scheduled ticks.
949                    let tickable_chunk = &tickable_chunks.block[index];
950                    if tickable_chunk.randomly_ticking_sections.is_empty() {
951                        continue;
952                    }
953                    if let Some(chunk) = tickable_chunk.holder.try_full_chunk() {
954                        chunk.tick_random_blocks(world, random_tick_speed, &mut random_positions);
955                    }
956                }
957                timings.tick_chunks += start.elapsed();
958            }
959            timings.lookup_cache.merge(lookup_cache_scope.finish());
960        }
961
962        {
963            let _span = tracing::trace_span!("broadcast_changes").entered();
964            let start = Instant::now();
965            self.broadcast_changed_chunks();
966            timings.broadcast_changes = start.elapsed();
967        }
968
969        {
970            let _span = tracing::trace_span!("process_unloads").entered();
971            let start = Instant::now();
972            self.process_pending_unloads();
973            timings.scheduling.process_unloads = start.elapsed();
974        }
975
976        timings
977    }
978
979    /// Runs the chunk-source phase outside a live game tick.
980    ///
981    /// # Calling constraint
982    ///
983    /// This startup, pregeneration, and test helper must not overlap
984    /// [`Self::tick_game`]. Live worlds run the same work from `tick_game`.
985    #[instrument(level = "trace", skip(self), name = "advance_chunk_scheduling")]
986    pub(crate) fn advance_scheduling(self: &Arc<Self>) -> ChunkMapSchedulingTimings {
987        let _source_phase_guard = self.source_phase_guard.lock();
988        let mut timings = self.run_chunk_source_updates();
989        let start = Instant::now();
990        self.process_pending_unloads();
991        timings.process_unloads = start.elapsed();
992        timings
993    }
994
995    fn apply_simulation_changes(&self, changes: &[SimulationLevelChange]) -> bool {
996        let world = self.world_gen_context.world();
997        let mut snapshot_changed = false;
998        for change in changes {
999            let Some(holder) = self
1000                .chunks
1001                .read_sync(&change.pos, |_, holder| Arc::clone(holder))
1002            else {
1003                continue;
1004            };
1005            let previous_level = holder.simulation_level();
1006            if previous_level == change.new_level {
1007                continue;
1008            }
1009
1010            let readiness = holder.ticking_readiness_snapshot().readiness();
1011            let previous_visibility = holder.entity_visibility();
1012            holder.set_simulation_level(change.new_level);
1013            let previous_membership = Self::ticking_snapshot_membership(readiness, previous_level);
1014            let current_membership = Self::ticking_snapshot_membership(readiness, change.new_level);
1015            snapshot_changed |= previous_membership != current_membership;
1016            let new_visibility = holder.entity_visibility();
1017            if previous_visibility != new_visibility
1018                && holder.try_chunk(ChunkStatus::Empty).is_some()
1019            {
1020                world.update_entity_chunk_visibility(change.pos, new_visibility);
1021            }
1022        }
1023        snapshot_changed
1024    }
1025
1026    #[expect(
1027        clippy::too_many_lines,
1028        reason = "the ordered chunk-source commit is kept together so its Vanilla phase guarantees stay auditable"
1029    )]
1030    fn run_chunk_source_updates(self: &Arc<Self>) -> ChunkMapSchedulingTimings {
1031        // Gameplay caches retain misses as well as holders, so their scopes must
1032        // end before active-map membership changes, as in Vanilla's clearCache.
1033        debug_assert!(!GameplayChunkLookupCacheScope::is_active_for(self));
1034        let mut timings = ChunkMapSchedulingTimings::default();
1035        let mut batch = {
1036            let _span = tracing::trace_span!("ticket_updates").entered();
1037            let start = Instant::now();
1038            let world = self.world_gen_context.world();
1039            let batch = self
1040                .scheduling
1041                .run_all_updates(world.view_distance, world.simulation_distance);
1042            timings.ticket_updates = start.elapsed();
1043            batch
1044        };
1045
1046        self.merge_deferred_revivals(&mut batch.load_changes);
1047
1048        {
1049            let _span = tracing::trace_span!("block_entity_unloads").entered();
1050            let start = Instant::now();
1051            // Finalized old holders leave the block-entity world before a new holder at
1052            // the same position can be committed and activated below.
1053            self.finish_block_entity_unloads();
1054            timings.block_entity_unloads = start.elapsed();
1055        }
1056
1057        let simulation_snapshot_changed = self.apply_simulation_changes(&batch.simulation_changes);
1058
1059        let (changed_positions, mut rebuild_ticking_snapshot, rebuild_readiness) = {
1060            let _span = tracing::trace_span!("readiness_demotions").entered();
1061            let start = Instant::now();
1062            let changed_positions = batch
1063                .load_changes
1064                .iter()
1065                .map(|change| change.pos)
1066                .collect::<Vec<_>>();
1067            let mut rebuild_ticking_snapshot = simulation_snapshot_changed;
1068            let rebuild_readiness = match self
1069                .prepare_ticking_readiness_demotions(&batch.load_changes)
1070            {
1071                Ok(changed) => {
1072                    rebuild_ticking_snapshot |= changed;
1073                    false
1074                }
1075                Err(error) => {
1076                    tracing::error!(
1077                        ?error,
1078                        "Full-neighborhood index invariant failed before lifecycle commit; rebuilding after the commit"
1079                    );
1080                    self.clear_all_ticking_readiness();
1081                    *self.full_neighborhood.lock() = FullNeighborhoodIndex::default();
1082                    true
1083                }
1084            };
1085            timings.readiness_demotions = start.elapsed();
1086            (
1087                changed_positions,
1088                rebuild_ticking_snapshot,
1089                rebuild_readiness,
1090            )
1091        };
1092
1093        let holders_to_schedule = {
1094            let _span = tracing::trace_span!("lifecycle_commit").entered();
1095            let start = Instant::now();
1096            let holders: Vec<(Arc<ChunkHolder>, ChunkTicketLevel)> = batch
1097                .load_changes
1098                .drain(..)
1099                .filter_map(|change| {
1100                    self.update_chunk_level(change.pos, change.new_level)
1101                        .zip(change.new_level)
1102                })
1103                .collect();
1104            timings.lifecycle_commit = start.elapsed();
1105            holders
1106        };
1107
1108        let lookup_cache_scope = GameplayChunkLookupCacheScope::enter(self);
1109        let readiness_result = {
1110            let _span = tracing::trace_span!("readiness_reconcile").entered();
1111            let start = Instant::now();
1112            let result = if rebuild_readiness {
1113                rebuild_ticking_snapshot = true;
1114                match self.rebuild_ticking_readiness() {
1115                    Ok(result) => result,
1116                    Err(error) => self.recover_ticking_readiness_index(error),
1117                }
1118            } else {
1119                match self.reconcile_ticking_readiness_measured(&changed_positions) {
1120                    Ok(result) => {
1121                        rebuild_ticking_snapshot |= result.snapshot_changed;
1122                        result
1123                    }
1124                    Err(error) => {
1125                        rebuild_ticking_snapshot = true;
1126                        self.recover_ticking_readiness_index(error)
1127                    }
1128                }
1129            };
1130            timings.readiness_reconcile = start.elapsed();
1131            result
1132        };
1133        timings.lookup_cache = lookup_cache_scope.finish();
1134        timings.post_process_generation = readiness_result.post_process_generation;
1135        timings.post_process_chunk_count = readiness_result.post_process_chunk_count;
1136        timings.post_process_position_count = readiness_result.post_process_position_count;
1137        timings.readiness_candidate_count = readiness_result.candidate_count;
1138
1139        if rebuild_ticking_snapshot {
1140            let _span = tracing::trace_span!("ticking_snapshot_rebuild").entered();
1141            let start = Instant::now();
1142            timings.rebuilt_ticking_chunk_count = self.rebuild_ticking_chunk_snapshot();
1143            timings.ticking_snapshot_rebuild = start.elapsed();
1144        }
1145
1146        {
1147            let _span = tracing::trace_span!("schedule_generation").entered();
1148            let start = Instant::now();
1149            timings.scheduled_count = holders_to_schedule
1150                .iter()
1151                .filter(|(holder, level)| {
1152                    let Some(status) = generation_status(Some(*level)) else {
1153                        return false;
1154                    };
1155                    holder.schedule_chunk_generation_task_b(status, self)
1156                })
1157                .count();
1158            timings.schedule_generation = start.elapsed();
1159        }
1160
1161        {
1162            let _span = tracing::trace_span!("run_generation").entered();
1163            let start = Instant::now();
1164            self.run_or_notify_generation_refill();
1165            timings.run_generation = start.elapsed();
1166        }
1167
1168        let through_receipt = batch.through_receipt;
1169        // A staged revival has not published the ticket's holder state yet. A later
1170        // phase returns the same watermark and commits it after every revival lands.
1171        if self.deferred_revivals.lock().is_empty() {
1172            self.scheduling.publish_committed(through_receipt);
1173        }
1174        self.scheduling.recycle_update_batch(batch);
1175        timings
1176    }
1177
1178    fn process_pending_unloads(self: &Arc<Self>) {
1179        let staged_revivals = self
1180            .deferred_revivals
1181            .lock()
1182            .keys()
1183            .copied()
1184            .collect::<FxHashSet<_>>();
1185        self.process_unloads(&staged_revivals);
1186    }
1187
1188    /// Returns full chunks whose simulation level currently allows entity ticks.
1189    pub fn tickable_full_chunk_positions(&self) -> Vec<ChunkPos> {
1190        let snapshot = self.ticking_chunks.load();
1191        snapshot
1192            .entity_indices
1193            .iter()
1194            .map(|&index| snapshot.block[index].pos)
1195            .collect()
1196    }
1197
1198    /// Returns whether the chunk is full and currently allows entity ticks.
1199    pub(crate) fn is_entity_ticking_full_chunk_loaded(&self, pos: ChunkPos) -> bool {
1200        self.chunks
1201            .read_sync(&pos, |_, holder| holder.entity_visibility().is_ticking())
1202            .unwrap_or(false)
1203    }
1204
1205    /// Captures the live state for an exact block-entity owner in an eligible holder.
1206    ///
1207    /// Holder data remains the outermost guard; the Full chunk view then acquires section
1208    /// and storage reads in the same order as block-state writers.
1209    pub(crate) fn block_entity_tick_state_if_owned(
1210        &self,
1211        holder: &Arc<ChunkHolder>,
1212        pos: BlockPos,
1213        expected: &SharedBlockEntity,
1214    ) -> Option<BlockStateId> {
1215        let chunk_pos = ChunkPos::from_block_pos(pos);
1216        let active = self
1217            .chunks
1218            .read_sync(&chunk_pos, |_, current| Arc::ptr_eq(current, holder))
1219            .unwrap_or(false);
1220        if !active
1221            || !is_block_ticking(holder.simulation_level())
1222            || !holder.ticking_readiness_snapshot().is_block_ticking()
1223        {
1224            return None;
1225        }
1226
1227        holder
1228            .try_full_chunk()?
1229            .block_entity_tick_state_if_owned(pos, expected)
1230    }
1231
1232    /// Re-selects one ticker from the live state without retaining a component lock
1233    /// across behavior selection or manager registration.
1234    pub(crate) fn reconcile_block_entity_ticker(&self, holder: &Arc<ChunkHolder>, pos: BlockPos) {
1235        let world = self.world_gen_context.world();
1236        let chunk_pos = ChunkPos::from_block_pos(pos);
1237        let active = self
1238            .chunks
1239            .read_sync(&chunk_pos, |_, current| Arc::ptr_eq(current, holder))
1240            .unwrap_or(false);
1241        if !active {
1242            world.block_entity_tickers().remove(holder, pos);
1243            return;
1244        }
1245
1246        let target = {
1247            let Some(chunk) = holder.try_full_chunk() else {
1248                world.block_entity_tickers().remove(holder, pos);
1249                return;
1250            };
1251            chunk.block_entity_tick_target(pos)
1252        };
1253        let Some((state, block_entity)) = target else {
1254            world.block_entity_tickers().remove(holder, pos);
1255            return;
1256        };
1257        if block_entity.is_removed() {
1258            world.block_entity_tickers().remove(holder, pos);
1259            return;
1260        }
1261
1262        let behavior = BLOCK_BEHAVIORS.get_behavior(state.get_block());
1263        let ticker = behavior.get_block_entity_ticker(&world, state, block_entity.get_type());
1264        let ticker = ticker.filter(|ticker| {
1265            let valid = ticker.accepts(block_entity.get_type());
1266            if !valid {
1267                tracing::error!(
1268                    block = %state.get_block().key,
1269                    block_entity_type = %block_entity.get_type().key,
1270                    ?pos,
1271                    "Block behavior returned a ticker for the wrong block-entity type"
1272                );
1273            }
1274            valid
1275        });
1276        world
1277            .block_entity_tickers()
1278            .reconcile(holder, block_entity, ticker);
1279    }
1280
1281    pub(crate) fn activate_block_entities<'a>(
1282        &self,
1283        holders: impl IntoIterator<Item = &'a Arc<ChunkHolder>>,
1284    ) {
1285        for holder in holders {
1286            if !holder.load_level().is_some_and(is_full)
1287                || !self
1288                    .chunks
1289                    .read_sync(&holder.get_pos(), |_, active| Arc::ptr_eq(active, holder))
1290                    .unwrap_or(false)
1291                || !holder.is_full_status_initialized()
1292                || holder.published_status() != Some(ChunkStatus::Full)
1293            {
1294                continue;
1295            }
1296            let batch = {
1297                let Some(chunk) = holder.try_full_chunk() else {
1298                    continue;
1299                };
1300                chunk.prepare_block_entity_activation(holder)
1301            };
1302            let Some(batch) = batch else {
1303                continue;
1304            };
1305            for block_entity in batch.lifecycle_dispatchers {
1306                block_entity.dispatch_lifecycle_events();
1307            }
1308            for pos in batch.positions {
1309                {
1310                    let Some(chunk) = holder.try_full_chunk() else {
1311                        break;
1312                    };
1313                    chunk.reconcile_block_entity_game_event_listener(pos);
1314                }
1315                self.reconcile_block_entity_ticker(holder, pos);
1316            }
1317        }
1318    }
1319
1320    fn finish_block_entity_unloads(&self) {
1321        let finalized = mem::take(&mut *self.finalized_block_entity_unloads.lock());
1322        if finalized.is_empty() {
1323            return;
1324        }
1325
1326        let world = self.world_gen_context.world();
1327        for mut unload in finalized {
1328            let mut lifecycle_dispatchers = unload
1329                .holder
1330                .try_full_chunk()
1331                .map(|chunk| chunk.deactivate_block_entities(&unload.holder))
1332                .unwrap_or_default();
1333            world
1334                .block_entity_tickers()
1335                .remove_positions(&unload.holder, &unload.positions);
1336            lifecycle_dispatchers.append(&mut unload.lifecycle_dispatchers);
1337            for block_entity in lifecycle_dispatchers {
1338                block_entity.dispatch_lifecycle_events();
1339            }
1340        }
1341    }
1342
1343    /// Places (or refreshes) the timeout ticket that keeps a thrown ender pearl's
1344    /// chunk loaded and ticking while it flies.
1345    ///
1346    /// Mirrors vanilla `ServerPlayer.placeEnderPearlTicket` →
1347    /// `chunkSource.addTicketWithRadius(ENDER_PEARL, chunk, 2)`. Re-placing the
1348    /// same ticket resets its countdown rather than stacking duplicates.
1349    // TODO: Steel records vanilla's FLAG_KEEP_DIMENSION_ACTIVE, but has no
1350    // idle-dimension system that consumes it yet.
1351    pub fn place_ender_pearl_ticket(&self, chunk: ChunkPos) {
1352        self.scheduling.add_or_refresh_ender_pearl_ticket(chunk);
1353    }
1354}
1355
1356#[cfg(test)]
1357mod tests;