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