Skip to main content

steel_core/chunk/
gameplay_chunk_lookup_cache.rs

1//! Scoped holder cache for synchronous gameplay chunk lookups.
2//!
3//! Scopes may only cover intervals where active-map membership is stable. The
4//! cache retains holder identity (including map absence), but never generation
5//! permission or published status; those remain live per lookup.
6
7use std::{
8    cell::{Cell, RefCell},
9    marker::PhantomData,
10    num::NonZeroU64,
11    ptr,
12    rc::Rc,
13    sync::Arc,
14};
15
16use steel_utils::{BlockPos, BlockStateId, ChunkPos};
17
18use super::{chunk_holder::ChunkHolder, chunk_map::ChunkMap, status::ChunkStatus};
19
20// Vanilla's ServerChunkCache also keeps four recent synchronous lookups. Steel
21// caches holders rather than a status-specific chunk view so concurrent publication stays visible.
22// Unlike Vanilla's insertion-only ordering, hits are promoted here because one
23// holder entry serves every requested status; the cache telemetry validates that choice.
24const CACHE_ENTRY_COUNT: usize = 4;
25
26/// Lookup statistics collected without synchronization inside one cache scope.
27#[derive(Debug, Default)]
28pub struct GameplayChunkLookupCacheStats {
29    /// Lookups served by a cached active holder.
30    pub holder_hits: usize,
31    /// Lookups served by a cached active-map absence.
32    pub missing_hits: usize,
33    /// Cache misses that consulted the active SCC map.
34    pub scc_lookups: usize,
35    /// Lookups for another chunk map while this scope was active.
36    pub foreign_map_bypasses: usize,
37    /// Least-recently-used entries displaced from a full cache.
38    pub evictions: usize,
39}
40
41impl GameplayChunkLookupCacheStats {
42    /// Adds counters from another completed gameplay phase.
43    pub(crate) const fn merge(&mut self, other: Self) {
44        self.holder_hits += other.holder_hits;
45        self.missing_hits += other.missing_hits;
46        self.scc_lookups += other.scc_lookups;
47        self.foreign_map_bypasses += other.foreign_map_bypasses;
48        self.evictions += other.evictions;
49    }
50}
51
52#[derive(PartialEq, Eq)]
53struct CacheOwner(*const ());
54
55impl CacheOwner {
56    const fn for_chunk_map(chunk_map: &ChunkMap) -> Self {
57        Self(ptr::from_ref(chunk_map).cast())
58    }
59
60    #[cfg(test)]
61    const fn for_test<T>(owner: &T) -> Self {
62        Self(ptr::from_ref(owner).cast())
63    }
64}
65
66struct CacheEntry {
67    pos: ChunkPos,
68    holder: Option<Arc<ChunkHolder>>,
69}
70
71struct ActiveCache {
72    owner: CacheOwner,
73    scope_id: Option<NonZeroU64>,
74    entries: [Option<CacheEntry>; CACHE_ENTRY_COUNT],
75    stats: GameplayChunkLookupCacheStats,
76}
77
78enum CacheEntryProbe {
79    Hit(Option<Arc<ChunkHolder>>),
80    Miss,
81}
82
83impl ActiveCache {
84    fn new(owner: CacheOwner, scope_id: Option<NonZeroU64>) -> Self {
85        Self {
86            owner,
87            scope_id,
88            entries: [const { None }; CACHE_ENTRY_COUNT],
89            stats: GameplayChunkLookupCacheStats::default(),
90        }
91    }
92
93    #[inline]
94    fn lookup(&mut self, pos: ChunkPos) -> CacheEntryProbe {
95        let Some(index) = self
96            .entries
97            .iter()
98            .position(|entry| entry.as_ref().is_some_and(|entry| entry.pos == pos))
99        else {
100            return CacheEntryProbe::Miss;
101        };
102        let holder = self.entries[index]
103            .as_ref()
104            .and_then(|entry| entry.holder.as_ref().map(Arc::clone));
105        if holder.is_some() {
106            self.stats.holder_hits += 1;
107        } else {
108            self.stats.missing_hits += 1;
109        }
110        self.promote(index);
111        CacheEntryProbe::Hit(holder)
112    }
113
114    fn insert(&mut self, pos: ChunkPos, holder: Option<Arc<ChunkHolder>>) {
115        if let Some(index) = self
116            .entries
117            .iter()
118            .position(|entry| entry.as_ref().is_some_and(|entry| entry.pos == pos))
119        {
120            self.entries[index] = Some(CacheEntry { pos, holder });
121            self.promote(index);
122            return;
123        }
124
125        if self.entries[CACHE_ENTRY_COUNT - 1].is_some() {
126            self.stats.evictions += 1;
127        }
128        for index in (1..CACHE_ENTRY_COUNT).rev() {
129            self.entries[index] = self.entries[index - 1].take();
130        }
131        self.entries[0] = Some(CacheEntry { pos, holder });
132    }
133
134    fn promote(&mut self, index: usize) {
135        if index == 0 {
136            return;
137        }
138        let entry = self.entries[index].take();
139        for target in (1..=index).rev() {
140            self.entries[target] = self.entries[target - 1].take();
141        }
142        self.entries[0] = entry;
143    }
144}
145
146thread_local! {
147    static ACTIVE_CACHE: RefCell<Option<ActiveCache>> = const { RefCell::new(None) };
148    static NEXT_SCOPE_ID: Cell<u64> = const { Cell::new(1) };
149}
150
151fn next_scope_id() -> Option<NonZeroU64> {
152    NEXT_SCOPE_ID.with(|next| {
153        let current = NonZeroU64::new(next.get())?;
154        next.set(current.get().checked_add(1).unwrap_or(0));
155        Some(current)
156    })
157}
158
159enum CacheProbe {
160    Hit(Option<Arc<ChunkHolder>>),
161    Miss,
162    Bypass,
163}
164
165/// Installs an empty cache until the scope is finished or dropped.
166///
167/// Nested scopes restore the prior cache, and the owned holder references are
168/// released on every exit path, including unwinding. Nested guards must exit in
169/// the usual last-in, first-out order.
170pub(crate) struct GameplayChunkLookupCacheScope<'map> {
171    previous: Option<ActiveCache>,
172    active: bool,
173    _chunk_map: PhantomData<&'map ChunkMap>,
174    _thread_bound: PhantomData<Rc<()>>,
175}
176
177impl<'map> GameplayChunkLookupCacheScope<'map> {
178    /// Checks the lifecycle precondition that no same-map gameplay scope is active.
179    pub(crate) fn is_active_for(chunk_map: &ChunkMap) -> bool {
180        let owner = CacheOwner::for_chunk_map(chunk_map);
181        ACTIVE_CACHE.with(|cache| {
182            cache
183                .borrow()
184                .as_ref()
185                .is_some_and(|cache| cache.owner == owner)
186        })
187    }
188
189    pub(crate) fn enter(chunk_map: &'map ChunkMap) -> Self {
190        Self::enter_key(CacheOwner::for_chunk_map(chunk_map))
191    }
192
193    #[cfg(test)]
194    fn enter_owner<T>(owner: &'map T) -> Self {
195        Self::enter_key(CacheOwner::for_test(owner))
196    }
197
198    fn enter_key(owner: CacheOwner) -> Self {
199        let previous = ACTIVE_CACHE
200            .with(|cache| cache.replace(Some(ActiveCache::new(owner, next_scope_id()))));
201        Self {
202            previous,
203            active: true,
204            _chunk_map: PhantomData,
205            _thread_bound: PhantomData,
206        }
207    }
208
209    pub(crate) fn finish(mut self) -> GameplayChunkLookupCacheStats {
210        self.restore()
211            .map_or_else(GameplayChunkLookupCacheStats::default, |cache| cache.stats)
212    }
213
214    fn restore(&mut self) -> Option<ActiveCache> {
215        if !self.active {
216            return None;
217        }
218        self.active = false;
219        ACTIVE_CACHE.with(|cache| cache.replace(self.previous.take()))
220    }
221}
222
223/// A tiny holder cache for repeated, live Full-chunk block-state reads.
224///
225/// Entries are usable only inside the exact gameplay lookup-cache scope that populated them. That
226/// scope is the proof that active-map membership is stable. The holder's current Full permission
227/// and publication are rechecked for every read, and the block section itself is locked only long
228/// enough to copy the state. Missing and unpublished chunks are never retained.
229pub(crate) struct LocalFullChunkHolderCache {
230    scope_id: Option<NonZeroU64>,
231    entries: [Option<LocalFullChunkHolderCacheEntry>; CACHE_ENTRY_COUNT],
232    #[cfg(test)]
233    stats: LocalFullChunkHolderCacheStats,
234}
235
236struct LocalFullChunkHolderCacheEntry {
237    pos: ChunkPos,
238    holder: Arc<ChunkHolder>,
239}
240
241#[cfg(test)]
242#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
243pub(crate) struct LocalFullChunkHolderCacheStats {
244    pub(crate) holder_hits: usize,
245    pub(crate) active_holder_lookups: usize,
246    pub(crate) fallback_reads: usize,
247}
248
249impl LocalFullChunkHolderCache {
250    pub(crate) const fn new() -> Self {
251        Self {
252            scope_id: None,
253            entries: [const { None }; CACHE_ENTRY_COUNT],
254            #[cfg(test)]
255            stats: LocalFullChunkHolderCacheStats {
256                holder_hits: 0,
257                active_holder_lookups: 0,
258                fallback_reads: 0,
259            },
260        }
261    }
262
263    /// Reads the current state without retaining a section guard or a missing chunk result.
264    pub(crate) fn block_state(
265        &mut self,
266        chunk_map: &ChunkMap,
267        pos: BlockPos,
268    ) -> Option<BlockStateId> {
269        let owner = CacheOwner::for_chunk_map(chunk_map);
270        let active_scope_id = active_scope_id(owner);
271        if active_scope_id != self.scope_id {
272            self.entries.fill_with(|| None);
273            self.scope_id = active_scope_id;
274        }
275
276        let chunk_pos = ChunkPos::from_block_pos(pos);
277        let Some(_) = active_scope_id else {
278            #[cfg(test)]
279            {
280                self.stats.fallback_reads += 1;
281            }
282            return chunk_map.with_full_chunk(chunk_pos, |chunk| chunk.get_block_state(pos));
283        };
284
285        if let Some(index) = self
286            .entries
287            .iter()
288            .position(|entry| entry.as_ref().is_some_and(|entry| entry.pos == chunk_pos))
289        {
290            let state = {
291                let entry = self.entries[index].as_ref()?;
292                live_full_block_state(&entry.holder, pos)
293            };
294            let Some(state) = state else {
295                self.remove(index);
296                return None;
297            };
298            #[cfg(test)]
299            {
300                self.stats.holder_hits += 1;
301            }
302            self.promote(index);
303            return Some(state);
304        }
305
306        #[cfg(test)]
307        {
308            self.stats.active_holder_lookups += 1;
309        }
310        let holder = chunk_map.active_full_chunk_holder(chunk_pos)?;
311        let state = live_full_block_state(&holder, pos)?;
312        self.insert(chunk_pos, holder);
313        Some(state)
314    }
315
316    fn insert(&mut self, pos: ChunkPos, holder: Arc<ChunkHolder>) {
317        for index in (1..CACHE_ENTRY_COUNT).rev() {
318            self.entries[index] = self.entries[index - 1].take();
319        }
320        self.entries[0] = Some(LocalFullChunkHolderCacheEntry { pos, holder });
321    }
322
323    fn promote(&mut self, index: usize) {
324        if index == 0 {
325            return;
326        }
327        let entry = self.entries[index].take();
328        for target in (1..=index).rev() {
329            self.entries[target] = self.entries[target - 1].take();
330        }
331        self.entries[0] = entry;
332    }
333
334    fn remove(&mut self, index: usize) {
335        for target in index..CACHE_ENTRY_COUNT - 1 {
336            self.entries[target] = self.entries[target + 1].take();
337        }
338        self.entries[CACHE_ENTRY_COUNT - 1] = None;
339    }
340
341    #[cfg(test)]
342    pub(crate) const fn stats(&self) -> LocalFullChunkHolderCacheStats {
343        self.stats
344    }
345}
346
347fn active_scope_id(owner: CacheOwner) -> Option<NonZeroU64> {
348    ACTIVE_CACHE.with(|cache| {
349        let cache = cache.borrow();
350        let cache = cache.as_ref()?;
351        (cache.owner == owner).then_some(cache.scope_id).flatten()
352    })
353}
354
355fn live_full_block_state(holder: &ChunkHolder, pos: BlockPos) -> Option<BlockStateId> {
356    if holder.is_status_disallowed(ChunkStatus::Full) {
357        return None;
358    }
359    holder
360        .try_full_chunk()
361        .map(|chunk| chunk.get_block_state(pos))
362}
363
364impl Drop for GameplayChunkLookupCacheScope<'_> {
365    fn drop(&mut self) {
366        drop(self.restore());
367    }
368}
369
370#[inline]
371pub(crate) fn lookup_or_insert_with<F>(
372    chunk_map: &ChunkMap,
373    pos: ChunkPos,
374    load: F,
375) -> Option<Arc<ChunkHolder>>
376where
377    F: FnOnce() -> Option<Arc<ChunkHolder>>,
378{
379    lookup_or_insert_for_owner(CacheOwner::for_chunk_map(chunk_map), pos, load)
380}
381
382#[inline]
383fn lookup_or_insert_for_owner<F>(
384    owner: CacheOwner,
385    pos: ChunkPos,
386    load: F,
387) -> Option<Arc<ChunkHolder>>
388where
389    F: FnOnce() -> Option<Arc<ChunkHolder>>,
390{
391    let probe = ACTIVE_CACHE.with(|cache| {
392        let mut cache = cache.borrow_mut();
393        let Some(cache) = cache.as_mut() else {
394            return CacheProbe::Bypass;
395        };
396        if cache.owner != owner {
397            cache.stats.foreign_map_bypasses += 1;
398            return CacheProbe::Bypass;
399        }
400        match cache.lookup(pos) {
401            CacheEntryProbe::Hit(holder) => return CacheProbe::Hit(holder),
402            CacheEntryProbe::Miss => {}
403        }
404        cache.stats.scc_lookups += 1;
405        CacheProbe::Miss
406    });
407
408    match probe {
409        CacheProbe::Hit(holder) => holder,
410        CacheProbe::Bypass => load(),
411        CacheProbe::Miss => {
412            let holder = load();
413            ACTIVE_CACHE.with(|cache| {
414                let mut cache = cache.borrow_mut();
415                let Some(cache) = cache.as_mut() else {
416                    return;
417                };
418                if cache.owner == owner {
419                    cache.insert(pos, holder.clone());
420                }
421            });
422            holder
423        }
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use steel_registry::{blocks::BlockRef, vanilla_blocks};
431    use steel_utils::types::UpdateFlags;
432
433    use crate::behavior::init_behaviors;
434    use crate::chunk::Chunk;
435    use crate::chunk::chunk_ticket_manager::ChunkTicketLevel;
436    use crate::chunk::section::{ChunkSection, Sections};
437    use crate::test_support::{fresh_test_world, insert_ready_full_chunk};
438    use crate::world::World;
439
440    fn holder(pos: ChunkPos) -> Arc<ChunkHolder> {
441        Arc::new(ChunkHolder::new(
442            pos,
443            ChunkTicketLevel::FULL_CHUNK,
444            None,
445            0,
446            16,
447        ))
448    }
449
450    fn set_test_block(world: &Arc<World>, pos: BlockPos, block: BlockRef) {
451        assert!(world.set_block(pos, block.default_state(), UpdateFlags::UPDATE_NONE));
452    }
453
454    #[test]
455    fn four_entry_cache_uses_most_recently_used_eviction() {
456        let owner = 0_u8;
457        let scope = GameplayChunkLookupCacheScope::enter_owner(&owner);
458        let holders = (0..=4)
459            .map(|x| holder(ChunkPos::new(x, 0)))
460            .collect::<Vec<_>>();
461        let mut loads = 0;
462
463        for (x, holder) in holders.iter().take(4).enumerate() {
464            let loaded = lookup_or_insert_for_owner(
465                CacheOwner::for_test(&owner),
466                ChunkPos::new(x as i32, 0),
467                || {
468                    loads += 1;
469                    Some(Arc::clone(holder))
470                },
471            );
472            drop(loaded);
473        }
474        drop(lookup_or_insert_for_owner(
475            CacheOwner::for_test(&owner),
476            ChunkPos::new(0, 0),
477            || panic!("the most-recently-used entry should hit"),
478        ));
479        drop(lookup_or_insert_for_owner(
480            CacheOwner::for_test(&owner),
481            ChunkPos::new(4, 0),
482            || {
483                loads += 1;
484                Some(Arc::clone(&holders[4]))
485            },
486        ));
487        drop(lookup_or_insert_for_owner(
488            CacheOwner::for_test(&owner),
489            ChunkPos::new(0, 0),
490            || panic!("the promoted entry should remain cached"),
491        ));
492        drop(lookup_or_insert_for_owner(
493            CacheOwner::for_test(&owner),
494            ChunkPos::new(1, 0),
495            || {
496                loads += 1;
497                Some(Arc::clone(&holders[1]))
498            },
499        ));
500
501        let stats = scope.finish();
502        assert_eq!(loads, 6);
503        assert_eq!(stats.holder_hits, 2);
504        assert_eq!(stats.scc_lookups, 6);
505        assert_eq!(stats.evictions, 2);
506    }
507
508    #[test]
509    fn missing_holder_is_cached_within_scope() {
510        let owner = 0_u8;
511        let scope = GameplayChunkLookupCacheScope::enter_owner(&owner);
512        let pos = ChunkPos::new(3, -7);
513        let mut loads = 0;
514
515        assert!(
516            lookup_or_insert_for_owner(CacheOwner::for_test(&owner), pos, || {
517                loads += 1;
518                None
519            })
520            .is_none()
521        );
522        assert!(
523            lookup_or_insert_for_owner(CacheOwner::for_test(&owner), pos, || {
524                panic!("a cached missing holder should not reload")
525            })
526            .is_none()
527        );
528
529        let stats = scope.finish();
530        assert_eq!(loads, 1);
531        assert_eq!(stats.missing_hits, 1);
532        assert_eq!(stats.scc_lookups, 1);
533    }
534
535    #[test]
536    fn nested_scope_restores_outer_entries_and_releases_holders() {
537        let outer_owner = 0_u8;
538        let inner_owner = 1_u8;
539        let pos = ChunkPos::new(2, 5);
540        let holder = holder(pos);
541        let outer = GameplayChunkLookupCacheScope::enter_owner(&outer_owner);
542
543        drop(lookup_or_insert_for_owner(
544            CacheOwner::for_test(&outer_owner),
545            pos,
546            || Some(Arc::clone(&holder)),
547        ));
548        assert_eq!(Arc::strong_count(&holder), 2);
549
550        let inner = GameplayChunkLookupCacheScope::enter_owner(&inner_owner);
551        assert!(
552            lookup_or_insert_for_owner(
553                CacheOwner::for_test(&inner_owner),
554                ChunkPos::new(-1, -1),
555                || None,
556            )
557            .is_none()
558        );
559        let inner_stats = inner.finish();
560        assert_eq!(inner_stats.scc_lookups, 1);
561
562        drop(lookup_or_insert_for_owner(
563            CacheOwner::for_test(&outer_owner),
564            pos,
565            || panic!("the outer entry should be restored"),
566        ));
567        let outer_stats = outer.finish();
568        assert_eq!(outer_stats.holder_hits, 1);
569        assert_eq!(Arc::strong_count(&holder), 1);
570    }
571
572    #[test]
573    fn dropping_scope_releases_entries_and_next_scope_starts_empty() {
574        let owner = 0_u8;
575        let pos = ChunkPos::new(-6, 11);
576        let holder = holder(pos);
577
578        {
579            let _scope = GameplayChunkLookupCacheScope::enter_owner(&owner);
580            drop(lookup_or_insert_for_owner(
581                CacheOwner::for_test(&owner),
582                pos,
583                || Some(Arc::clone(&holder)),
584            ));
585            assert_eq!(Arc::strong_count(&holder), 2);
586        }
587        assert_eq!(Arc::strong_count(&holder), 1);
588
589        let scope = GameplayChunkLookupCacheScope::enter_owner(&owner);
590        let mut loads = 0;
591        drop(lookup_or_insert_for_owner(
592            CacheOwner::for_test(&owner),
593            pos,
594            || {
595                loads += 1;
596                Some(Arc::clone(&holder))
597            },
598        ));
599        let stats = scope.finish();
600        assert_eq!(loads, 1);
601        assert_eq!(stats.scc_lookups, 1);
602    }
603
604    #[test]
605    fn foreign_owner_bypasses_active_cache() {
606        let owner = 0_u8;
607        let foreign_owner = 1_u8;
608        let scope = GameplayChunkLookupCacheScope::enter_owner(&owner);
609        let pos = ChunkPos::new(8, 9);
610        let holder = holder(pos);
611        let mut loads = 0;
612
613        for _ in 0..2 {
614            drop(lookup_or_insert_for_owner(
615                CacheOwner::for_test(&foreign_owner),
616                pos,
617                || {
618                    loads += 1;
619                    Some(Arc::clone(&holder))
620                },
621            ));
622        }
623
624        let stats = scope.finish();
625        assert_eq!(loads, 2);
626        assert_eq!(stats.foreign_map_bypasses, 2);
627        assert_eq!(stats.scc_lookups, 0);
628    }
629
630    #[test]
631    fn local_full_holder_cache_rechecks_full_permission() {
632        let fixture = fresh_test_world("local_full_holder_cache_permission");
633        let world = &fixture.world;
634        init_behaviors();
635        let pos = BlockPos::new(3, 64, 7);
636        let holder = insert_ready_full_chunk(world, ChunkPos::from_block_pos(pos));
637        set_test_block(world, pos, &vanilla_blocks::STONE);
638
639        let scope = GameplayChunkLookupCacheScope::enter(&world.chunk_map);
640        let mut local = LocalFullChunkHolderCache::new();
641        assert_eq!(
642            local.block_state(&world.chunk_map, pos),
643            Some(vanilla_blocks::STONE.default_state())
644        );
645
646        holder.update_highest_allowed_status(None);
647        assert_eq!(local.block_state(&world.chunk_map, pos), None);
648
649        holder.update_highest_allowed_status(Some(ChunkTicketLevel::FULL_CHUNK));
650        assert_eq!(
651            local.block_state(&world.chunk_map, pos),
652            Some(vanilla_blocks::STONE.default_state())
653        );
654        assert_eq!(local.stats().active_holder_lookups, 2);
655        drop(scope);
656    }
657
658    #[test]
659    fn local_full_holder_cache_does_not_retain_unpublished_data() {
660        let fixture = fresh_test_world("local_full_holder_cache_publication");
661        let world = &fixture.world;
662        let chunk_pos = ChunkPos::new(-2, 3);
663        let pos = BlockPos::new(-31, 64, 49);
664        let min_y = world.chunk_map.world_gen_context.min_y();
665        let height = world.chunk_map.world_gen_context.height();
666        let holder = Arc::new(ChunkHolder::new(
667            chunk_pos,
668            ChunkTicketLevel::FULL_CHUNK,
669            None,
670            min_y,
671            height,
672        ));
673        let _ = world
674            .chunk_map
675            .chunks
676            .insert_sync(chunk_pos, Arc::clone(&holder));
677        let scope = GameplayChunkLookupCacheScope::enter(&world.chunk_map);
678        let mut local = LocalFullChunkHolderCache::new();
679
680        assert_eq!(local.block_state(&world.chunk_map, pos), None);
681
682        let sections = (0..height / 16)
683            .map(|_| ChunkSection::new_empty())
684            .collect::<Vec<_>>()
685            .into_boxed_slice();
686        let chunk = Chunk::new(
687            Sections::from_owned(sections),
688            chunk_pos,
689            min_y,
690            height,
691            Arc::downgrade(world),
692        );
693        assert!(
694            chunk
695                .set_block_state_for_generation(
696                    ChunkStatus::Empty,
697                    pos,
698                    vanilla_blocks::STONE.default_state(),
699                    UpdateFlags::UPDATE_NONE,
700                )
701                .is_some()
702        );
703        drop(chunk.promote_to_full());
704        holder.insert_chunk(chunk, ChunkStatus::Full);
705
706        assert_eq!(
707            local.block_state(&world.chunk_map, pos),
708            Some(vanilla_blocks::STONE.default_state())
709        );
710        assert_eq!(local.stats().holder_hits, 0);
711        assert_eq!(local.stats().active_holder_lookups, 2);
712        drop(scope);
713    }
714
715    #[test]
716    fn local_full_holder_cache_does_not_cross_scope_boundaries() {
717        let fixture = fresh_test_world("local_full_holder_cache_scope_boundary");
718        let world = &fixture.world;
719        init_behaviors();
720        let pos = BlockPos::new(5, 64, 5);
721        let chunk_pos = ChunkPos::from_block_pos(pos);
722        insert_ready_full_chunk(world, chunk_pos);
723        set_test_block(world, pos, &vanilla_blocks::STONE);
724        let mut local = LocalFullChunkHolderCache::new();
725
726        let first_scope = GameplayChunkLookupCacheScope::enter(&world.chunk_map);
727        assert_eq!(
728            local.block_state(&world.chunk_map, pos),
729            Some(vanilla_blocks::STONE.default_state())
730        );
731        drop(first_scope);
732
733        let Some((_, old_holder)) = world.chunk_map.chunks.remove_sync(&chunk_pos) else {
734            panic!("fixture holder should remain active");
735        };
736        drop(old_holder);
737        world.unregister_full_chunk_ticks(chunk_pos);
738        insert_ready_full_chunk(world, chunk_pos);
739        set_test_block(world, pos, &vanilla_blocks::DIRT);
740
741        let second_scope = GameplayChunkLookupCacheScope::enter(&world.chunk_map);
742        assert_eq!(
743            local.block_state(&world.chunk_map, pos),
744            Some(vanilla_blocks::DIRT.default_state())
745        );
746        assert_eq!(local.stats().active_holder_lookups, 2);
747        drop(second_scope);
748    }
749}