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