1use 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
13const CACHE_ENTRY_COUNT: usize = 4;
18
19#[derive(Debug, Default)]
21pub struct GameplayChunkLookupCacheStats {
22 pub holder_hits: usize,
24 pub missing_hits: usize,
26 pub scc_lookups: usize,
28 pub foreign_map_bypasses: usize,
30 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
136pub(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}