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
34impl GameplayChunkLookupCacheStats {
35 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
147pub(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 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}