1use 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
20const CACHE_ENTRY_COUNT: usize = 4;
25
26#[derive(Debug, Default)]
28pub struct GameplayChunkLookupCacheStats {
29 pub holder_hits: usize,
31 pub missing_hits: usize,
33 pub scc_lookups: usize,
35 pub foreign_map_bypasses: usize,
37 pub evictions: usize,
39}
40
41impl GameplayChunkLookupCacheStats {
42 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
165pub(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 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
223pub(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 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}