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::{cell::RefCell, marker::PhantomData, ptr, rc::Rc, sync::Arc};
8
9use steel_utils::ChunkPos;
10
11use super::{chunk_holder::ChunkHolder, chunk_map::ChunkMap};
12
13// Vanilla's ServerChunkCache also keeps four recent synchronous lookups. Steel
14// caches holders rather than a status-specific chunk view so concurrent publication stays visible.
15// Unlike Vanilla's insertion-only ordering, hits are promoted here because one
16// holder entry serves every requested status; the cache telemetry validates that choice.
17const CACHE_ENTRY_COUNT: usize = 4;
18
19/// Lookup statistics collected without synchronization inside one cache scope.
20#[derive(Debug, Default)]
21pub struct GameplayChunkLookupCacheStats {
22    /// Lookups served by a cached active holder.
23    pub holder_hits: usize,
24    /// Lookups served by a cached active-map absence.
25    pub missing_hits: usize,
26    /// Cache misses that consulted the active SCC map.
27    pub scc_lookups: usize,
28    /// Lookups for another chunk map while this scope was active.
29    pub foreign_map_bypasses: usize,
30    /// Least-recently-used entries displaced from a full cache.
31    pub evictions: usize,
32}
33
34impl GameplayChunkLookupCacheStats {
35    /// Adds counters from another completed gameplay phase.
36    pub(crate) const fn merge(&mut self, other: Self) {
37        self.holder_hits += other.holder_hits;
38        self.missing_hits += other.missing_hits;
39        self.scc_lookups += other.scc_lookups;
40        self.foreign_map_bypasses += other.foreign_map_bypasses;
41        self.evictions += other.evictions;
42    }
43}
44
45#[derive(PartialEq, Eq)]
46struct CacheOwner(*const ());
47
48impl CacheOwner {
49    const fn for_chunk_map(chunk_map: &ChunkMap) -> Self {
50        Self(ptr::from_ref(chunk_map).cast())
51    }
52
53    #[cfg(test)]
54    const fn for_test<T>(owner: &T) -> Self {
55        Self(ptr::from_ref(owner).cast())
56    }
57}
58
59struct CacheEntry {
60    pos: ChunkPos,
61    holder: Option<Arc<ChunkHolder>>,
62}
63
64struct ActiveCache {
65    owner: CacheOwner,
66    entries: [Option<CacheEntry>; CACHE_ENTRY_COUNT],
67    stats: GameplayChunkLookupCacheStats,
68}
69
70enum CacheEntryProbe {
71    Hit(Option<Arc<ChunkHolder>>),
72    Miss,
73}
74
75impl ActiveCache {
76    fn new(owner: CacheOwner) -> Self {
77        Self {
78            owner,
79            entries: [const { None }; CACHE_ENTRY_COUNT],
80            stats: GameplayChunkLookupCacheStats::default(),
81        }
82    }
83
84    #[inline]
85    fn lookup(&mut self, pos: ChunkPos) -> CacheEntryProbe {
86        let Some(index) = self
87            .entries
88            .iter()
89            .position(|entry| entry.as_ref().is_some_and(|entry| entry.pos == pos))
90        else {
91            return CacheEntryProbe::Miss;
92        };
93        let holder = self.entries[index]
94            .as_ref()
95            .and_then(|entry| entry.holder.as_ref().map(Arc::clone));
96        if holder.is_some() {
97            self.stats.holder_hits += 1;
98        } else {
99            self.stats.missing_hits += 1;
100        }
101        self.promote(index);
102        CacheEntryProbe::Hit(holder)
103    }
104
105    fn insert(&mut self, pos: ChunkPos, holder: Option<Arc<ChunkHolder>>) {
106        if let Some(index) = self
107            .entries
108            .iter()
109            .position(|entry| entry.as_ref().is_some_and(|entry| entry.pos == pos))
110        {
111            self.entries[index] = Some(CacheEntry { pos, holder });
112            self.promote(index);
113            return;
114        }
115
116        if self.entries[CACHE_ENTRY_COUNT - 1].is_some() {
117            self.stats.evictions += 1;
118        }
119        for index in (1..CACHE_ENTRY_COUNT).rev() {
120            self.entries[index] = self.entries[index - 1].take();
121        }
122        self.entries[0] = Some(CacheEntry { pos, holder });
123    }
124
125    fn promote(&mut self, index: usize) {
126        if index == 0 {
127            return;
128        }
129        let entry = self.entries[index].take();
130        for target in (1..=index).rev() {
131            self.entries[target] = self.entries[target - 1].take();
132        }
133        self.entries[0] = entry;
134    }
135}
136
137thread_local! {
138    static ACTIVE_CACHE: RefCell<Option<ActiveCache>> = const { RefCell::new(None) };
139}
140
141enum CacheProbe {
142    Hit(Option<Arc<ChunkHolder>>),
143    Miss,
144    Bypass,
145}
146
147/// Installs an empty cache until the scope is finished or dropped.
148///
149/// Nested scopes restore the prior cache, and the owned holder references are
150/// released on every exit path, including unwinding. Nested guards must exit in
151/// the usual last-in, first-out order.
152pub(crate) struct GameplayChunkLookupCacheScope<'map> {
153    previous: Option<ActiveCache>,
154    active: bool,
155    _chunk_map: PhantomData<&'map ChunkMap>,
156    _thread_bound: PhantomData<Rc<()>>,
157}
158
159impl<'map> GameplayChunkLookupCacheScope<'map> {
160    /// Checks the lifecycle precondition that no same-map gameplay scope is active.
161    pub(crate) fn is_active_for(chunk_map: &ChunkMap) -> bool {
162        let owner = CacheOwner::for_chunk_map(chunk_map);
163        ACTIVE_CACHE.with(|cache| {
164            cache
165                .borrow()
166                .as_ref()
167                .is_some_and(|cache| cache.owner == owner)
168        })
169    }
170
171    pub(crate) fn enter(chunk_map: &'map ChunkMap) -> Self {
172        Self::enter_key(CacheOwner::for_chunk_map(chunk_map))
173    }
174
175    #[cfg(test)]
176    fn enter_owner<T>(owner: &'map T) -> Self {
177        Self::enter_key(CacheOwner::for_test(owner))
178    }
179
180    fn enter_key(owner: CacheOwner) -> Self {
181        let previous = ACTIVE_CACHE.with(|cache| cache.replace(Some(ActiveCache::new(owner))));
182        Self {
183            previous,
184            active: true,
185            _chunk_map: PhantomData,
186            _thread_bound: PhantomData,
187        }
188    }
189
190    pub(crate) fn finish(mut self) -> GameplayChunkLookupCacheStats {
191        self.restore()
192            .map_or_else(GameplayChunkLookupCacheStats::default, |cache| cache.stats)
193    }
194
195    fn restore(&mut self) -> Option<ActiveCache> {
196        if !self.active {
197            return None;
198        }
199        self.active = false;
200        ACTIVE_CACHE.with(|cache| cache.replace(self.previous.take()))
201    }
202}
203
204impl Drop for GameplayChunkLookupCacheScope<'_> {
205    fn drop(&mut self) {
206        drop(self.restore());
207    }
208}
209
210#[inline]
211pub(crate) fn lookup_or_insert_with<F>(
212    chunk_map: &ChunkMap,
213    pos: ChunkPos,
214    load: F,
215) -> Option<Arc<ChunkHolder>>
216where
217    F: FnOnce() -> Option<Arc<ChunkHolder>>,
218{
219    lookup_or_insert_for_owner(CacheOwner::for_chunk_map(chunk_map), pos, load)
220}
221
222#[inline]
223fn lookup_or_insert_for_owner<F>(
224    owner: CacheOwner,
225    pos: ChunkPos,
226    load: F,
227) -> Option<Arc<ChunkHolder>>
228where
229    F: FnOnce() -> Option<Arc<ChunkHolder>>,
230{
231    let probe = ACTIVE_CACHE.with(|cache| {
232        let mut cache = cache.borrow_mut();
233        let Some(cache) = cache.as_mut() else {
234            return CacheProbe::Bypass;
235        };
236        if cache.owner != owner {
237            cache.stats.foreign_map_bypasses += 1;
238            return CacheProbe::Bypass;
239        }
240        match cache.lookup(pos) {
241            CacheEntryProbe::Hit(holder) => return CacheProbe::Hit(holder),
242            CacheEntryProbe::Miss => {}
243        }
244        cache.stats.scc_lookups += 1;
245        CacheProbe::Miss
246    });
247
248    match probe {
249        CacheProbe::Hit(holder) => holder,
250        CacheProbe::Bypass => load(),
251        CacheProbe::Miss => {
252            let holder = load();
253            ACTIVE_CACHE.with(|cache| {
254                let mut cache = cache.borrow_mut();
255                let Some(cache) = cache.as_mut() else {
256                    return;
257                };
258                if cache.owner == owner {
259                    cache.insert(pos, holder.as_ref().map(Arc::clone));
260                }
261            });
262            holder
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::chunk::chunk_ticket_manager::ChunkTicketLevel;
271
272    fn holder(pos: ChunkPos) -> Arc<ChunkHolder> {
273        Arc::new(ChunkHolder::new(
274            pos,
275            ChunkTicketLevel::FULL_CHUNK,
276            None,
277            0,
278            16,
279        ))
280    }
281
282    #[test]
283    fn four_entry_cache_uses_most_recently_used_eviction() {
284        let owner = 0_u8;
285        let scope = GameplayChunkLookupCacheScope::enter_owner(&owner);
286        let holders = (0..=4)
287            .map(|x| holder(ChunkPos::new(x, 0)))
288            .collect::<Vec<_>>();
289        let mut loads = 0;
290
291        for (x, holder) in holders.iter().take(4).enumerate() {
292            let loaded = lookup_or_insert_for_owner(
293                CacheOwner::for_test(&owner),
294                ChunkPos::new(x as i32, 0),
295                || {
296                    loads += 1;
297                    Some(Arc::clone(holder))
298                },
299            );
300            drop(loaded);
301        }
302        drop(lookup_or_insert_for_owner(
303            CacheOwner::for_test(&owner),
304            ChunkPos::new(0, 0),
305            || panic!("the most-recently-used entry should hit"),
306        ));
307        drop(lookup_or_insert_for_owner(
308            CacheOwner::for_test(&owner),
309            ChunkPos::new(4, 0),
310            || {
311                loads += 1;
312                Some(Arc::clone(&holders[4]))
313            },
314        ));
315        drop(lookup_or_insert_for_owner(
316            CacheOwner::for_test(&owner),
317            ChunkPos::new(0, 0),
318            || panic!("the promoted entry should remain cached"),
319        ));
320        drop(lookup_or_insert_for_owner(
321            CacheOwner::for_test(&owner),
322            ChunkPos::new(1, 0),
323            || {
324                loads += 1;
325                Some(Arc::clone(&holders[1]))
326            },
327        ));
328
329        let stats = scope.finish();
330        assert_eq!(loads, 6);
331        assert_eq!(stats.holder_hits, 2);
332        assert_eq!(stats.scc_lookups, 6);
333        assert_eq!(stats.evictions, 2);
334    }
335
336    #[test]
337    fn missing_holder_is_cached_within_scope() {
338        let owner = 0_u8;
339        let scope = GameplayChunkLookupCacheScope::enter_owner(&owner);
340        let pos = ChunkPos::new(3, -7);
341        let mut loads = 0;
342
343        assert!(
344            lookup_or_insert_for_owner(CacheOwner::for_test(&owner), pos, || {
345                loads += 1;
346                None
347            })
348            .is_none()
349        );
350        assert!(
351            lookup_or_insert_for_owner(CacheOwner::for_test(&owner), pos, || {
352                panic!("a cached missing holder should not reload")
353            })
354            .is_none()
355        );
356
357        let stats = scope.finish();
358        assert_eq!(loads, 1);
359        assert_eq!(stats.missing_hits, 1);
360        assert_eq!(stats.scc_lookups, 1);
361    }
362
363    #[test]
364    fn nested_scope_restores_outer_entries_and_releases_holders() {
365        let outer_owner = 0_u8;
366        let inner_owner = 1_u8;
367        let pos = ChunkPos::new(2, 5);
368        let holder = holder(pos);
369        let outer = GameplayChunkLookupCacheScope::enter_owner(&outer_owner);
370
371        drop(lookup_or_insert_for_owner(
372            CacheOwner::for_test(&outer_owner),
373            pos,
374            || Some(Arc::clone(&holder)),
375        ));
376        assert_eq!(Arc::strong_count(&holder), 2);
377
378        let inner = GameplayChunkLookupCacheScope::enter_owner(&inner_owner);
379        assert!(
380            lookup_or_insert_for_owner(
381                CacheOwner::for_test(&inner_owner),
382                ChunkPos::new(-1, -1),
383                || None,
384            )
385            .is_none()
386        );
387        let inner_stats = inner.finish();
388        assert_eq!(inner_stats.scc_lookups, 1);
389
390        drop(lookup_or_insert_for_owner(
391            CacheOwner::for_test(&outer_owner),
392            pos,
393            || panic!("the outer entry should be restored"),
394        ));
395        let outer_stats = outer.finish();
396        assert_eq!(outer_stats.holder_hits, 1);
397        assert_eq!(Arc::strong_count(&holder), 1);
398    }
399
400    #[test]
401    fn dropping_scope_releases_entries_and_next_scope_starts_empty() {
402        let owner = 0_u8;
403        let pos = ChunkPos::new(-6, 11);
404        let holder = holder(pos);
405
406        {
407            let _scope = GameplayChunkLookupCacheScope::enter_owner(&owner);
408            drop(lookup_or_insert_for_owner(
409                CacheOwner::for_test(&owner),
410                pos,
411                || Some(Arc::clone(&holder)),
412            ));
413            assert_eq!(Arc::strong_count(&holder), 2);
414        }
415        assert_eq!(Arc::strong_count(&holder), 1);
416
417        let scope = GameplayChunkLookupCacheScope::enter_owner(&owner);
418        let mut loads = 0;
419        drop(lookup_or_insert_for_owner(
420            CacheOwner::for_test(&owner),
421            pos,
422            || {
423                loads += 1;
424                Some(Arc::clone(&holder))
425            },
426        ));
427        let stats = scope.finish();
428        assert_eq!(loads, 1);
429        assert_eq!(stats.scc_lookups, 1);
430    }
431
432    #[test]
433    fn foreign_owner_bypasses_active_cache() {
434        let owner = 0_u8;
435        let foreign_owner = 1_u8;
436        let scope = GameplayChunkLookupCacheScope::enter_owner(&owner);
437        let pos = ChunkPos::new(8, 9);
438        let holder = holder(pos);
439        let mut loads = 0;
440
441        for _ in 0..2 {
442            drop(lookup_or_insert_for_owner(
443                CacheOwner::for_test(&foreign_owner),
444                pos,
445                || {
446                    loads += 1;
447                    Some(Arc::clone(&holder))
448                },
449            ));
450        }
451
452        let stats = scope.finish();
453        assert_eq!(loads, 2);
454        assert_eq!(stats.foreign_map_bypasses, 2);
455        assert_eq!(stats.scc_lookups, 0);
456    }
457}