1use std::collections::{BTreeMap, VecDeque};
16use thiserror::Error;
17
18#[derive(Debug, Clone, PartialEq, Error)]
22pub enum CacheError {
23 #[error("Texel coordinate ({x}, {y}) out of bounds ({width}x{height})")]
25 OutOfBounds {
26 x: u32,
27 y: u32,
28 width: u32,
29 height: u32,
30 },
31 #[error("Cache capacity must be at least 1")]
33 ZeroCapacity,
34 #[error("Tile size must be at least 1")]
36 ZeroTileSize,
37 #[error("Texture dimensions must be at least 1×1")]
39 ZeroDimensions,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub struct TileKey {
47 pub tile_x: u32,
49 pub tile_y: u32,
51}
52
53impl TileKey {
54 #[must_use]
56 pub fn from_texel(x: u32, y: u32, tile_size: u32) -> Self {
57 Self {
58 tile_x: x / tile_size,
59 tile_y: y / tile_size,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Default)]
68pub struct TexCacheStats {
69 pub hits: u64,
71 pub misses: u64,
73 pub prefetches: u64,
75 pub evictions: u64,
77}
78
79impl TexCacheStats {
80 #[must_use]
82 pub fn hit_rate(&self) -> f64 {
83 let total = self.hits + self.misses;
84 if total == 0 {
85 0.0
86 } else {
87 self.hits as f64 / total as f64
88 }
89 }
90
91 #[must_use]
93 pub fn total_accesses(&self) -> u64 {
94 self.hits + self.misses
95 }
96}
97
98pub struct TextureCache {
112 pub width: u32,
114 pub height: u32,
116 pub tile_size: u32,
118 capacity: usize,
120 lines: BTreeMap<TileKey, Vec<u8>>,
122 lru: VecDeque<TileKey>,
124 stats: TexCacheStats,
126 texture_data: Vec<u8>,
128}
129
130impl TextureCache {
131 pub fn new(
140 width: u32,
141 height: u32,
142 tile_size: u32,
143 capacity: usize,
144 texture_data: Vec<u8>,
145 ) -> Result<Self, CacheError> {
146 if width == 0 || height == 0 {
147 return Err(CacheError::ZeroDimensions);
148 }
149 if tile_size == 0 {
150 return Err(CacheError::ZeroTileSize);
151 }
152 if capacity == 0 {
153 return Err(CacheError::ZeroCapacity);
154 }
155 let total = (width as usize) * (height as usize);
156 let mut padded = texture_data;
157 padded.resize(total, 0);
158
159 Ok(Self {
160 width,
161 height,
162 tile_size,
163 capacity,
164 lines: BTreeMap::new(),
165 lru: VecDeque::new(),
166 stats: TexCacheStats::default(),
167 texture_data: padded,
168 })
169 }
170
171 pub fn fetch(&mut self, x: u32, y: u32) -> Result<u8, CacheError> {
180 self.check_bounds(x, y)?;
181 let key = TileKey::from_texel(x, y, self.tile_size);
182 if self.lines.contains_key(&key) {
183 self.stats.hits += 1;
184 self.promote_lru(key);
185 } else {
186 self.stats.misses += 1;
187 self.load_tile(key);
188 }
189 let tile = &self.lines[&key];
190 let local_x = (x % self.tile_size) as usize;
191 let local_y = (y % self.tile_size) as usize;
192 Ok(tile[local_y * self.tile_size as usize + local_x])
193 }
194
195 pub fn prefetch(&mut self, x: u32, y: u32) -> Result<(), CacheError> {
205 self.check_bounds(x, y)?;
206 let key = TileKey::from_texel(x, y, self.tile_size);
207 if !self.lines.contains_key(&key) {
208 self.load_tile(key);
209 self.stats.prefetches += 1;
210 }
211 Ok(())
212 }
213
214 pub fn flush(&mut self) {
218 self.lines.clear();
219 self.lru.clear();
220 }
221
222 pub fn invalidate_tile(&mut self, key: TileKey) -> bool {
226 if self.lines.remove(&key).is_some() {
227 self.lru.retain(|k| k != &key);
228 true
229 } else {
230 false
231 }
232 }
233
234 #[must_use]
236 pub fn resident_count(&self) -> usize {
237 self.lines.len()
238 }
239
240 #[must_use]
242 pub fn is_resident(&self, key: TileKey) -> bool {
243 self.lines.contains_key(&key)
244 }
245
246 #[must_use]
248 pub fn stats(&self) -> &TexCacheStats {
249 &self.stats
250 }
251
252 pub fn reset_stats(&mut self) {
254 self.stats = TexCacheStats::default();
255 }
256
257 #[must_use]
259 pub fn capacity(&self) -> usize {
260 self.capacity
261 }
262
263 #[must_use]
265 pub fn grid_size(&self) -> (u32, u32) {
266 let tiles_x = (self.width + self.tile_size - 1) / self.tile_size;
267 let tiles_y = (self.height + self.tile_size - 1) / self.tile_size;
268 (tiles_x, tiles_y)
269 }
270
271 fn check_bounds(&self, x: u32, y: u32) -> Result<(), CacheError> {
274 if x >= self.width || y >= self.height {
275 Err(CacheError::OutOfBounds {
276 x,
277 y,
278 width: self.width,
279 height: self.height,
280 })
281 } else {
282 Ok(())
283 }
284 }
285
286 fn load_tile(&mut self, key: TileKey) {
288 if self.lines.len() >= self.capacity {
290 if let Some(victim) = self.lru.pop_back() {
291 self.lines.remove(&victim);
292 self.stats.evictions += 1;
293 }
294 }
295 let ts = self.tile_size as usize;
297 let w = self.width as usize;
298 let tile_x0 = key.tile_x as usize * ts;
299 let tile_y0 = key.tile_y as usize * ts;
300 let mut tile = vec![0u8; ts * ts];
301 for local_y in 0..ts {
302 let global_y = tile_y0 + local_y;
303 if global_y >= self.height as usize {
304 break;
305 }
306 for local_x in 0..ts {
307 let global_x = tile_x0 + local_x;
308 if global_x >= self.width as usize {
309 break;
310 }
311 tile[local_y * ts + local_x] = self.texture_data[global_y * w + global_x];
312 }
313 }
314 self.lines.insert(key, tile);
315 self.lru.push_front(key);
316 }
317
318 fn promote_lru(&mut self, key: TileKey) {
320 self.lru.retain(|k| k != &key);
321 self.lru.push_front(key);
322 }
323}
324
325#[cfg(test)]
328mod tests {
329 use super::*;
330
331 fn make_texture(width: u32, height: u32) -> Vec<u8> {
333 (0..(width * height)).map(|i| (i % 256) as u8).collect()
334 }
335
336 fn make_cache(capacity: usize, tile_size: u32) -> TextureCache {
337 let data = make_texture(16, 16);
338 TextureCache::new(16, 16, tile_size, capacity, data).unwrap()
339 }
340
341 #[test]
344 fn test_tile_key_from_texel() {
345 let key = TileKey::from_texel(9, 5, 4);
346 assert_eq!(key.tile_x, 2); assert_eq!(key.tile_y, 1); }
349
350 #[test]
351 fn test_tile_key_origin() {
352 let key = TileKey::from_texel(0, 0, 8);
353 assert_eq!(key.tile_x, 0);
354 assert_eq!(key.tile_y, 0);
355 }
356
357 #[test]
360 fn test_stats_hit_rate_zero_accesses() {
361 let s = TexCacheStats::default();
362 assert_eq!(s.hit_rate(), 0.0);
363 }
364
365 #[test]
366 fn test_stats_hit_rate_all_hits() {
367 let s = TexCacheStats {
368 hits: 10,
369 misses: 0,
370 ..Default::default()
371 };
372 assert!((s.hit_rate() - 1.0).abs() < 1e-9);
373 }
374
375 #[test]
376 fn test_stats_total_accesses() {
377 let s = TexCacheStats {
378 hits: 3,
379 misses: 7,
380 ..Default::default()
381 };
382 assert_eq!(s.total_accesses(), 10);
383 }
384
385 #[test]
388 fn test_new_zero_capacity_error() {
389 let err = TextureCache::new(8, 8, 4, 0, vec![]);
390 assert!(matches!(err, Err(CacheError::ZeroCapacity)));
391 }
392
393 #[test]
394 fn test_new_zero_tile_size_error() {
395 let err = TextureCache::new(8, 8, 0, 4, vec![]);
396 assert!(matches!(err, Err(CacheError::ZeroTileSize)));
397 }
398
399 #[test]
400 fn test_new_zero_dimensions_error() {
401 let err = TextureCache::new(0, 8, 4, 4, vec![]);
402 assert!(matches!(err, Err(CacheError::ZeroDimensions)));
403 }
404
405 #[test]
408 fn test_fetch_first_access_is_miss() {
409 let mut cache = make_cache(8, 4);
410 let _ = cache.fetch(0, 0).unwrap();
411 assert_eq!(cache.stats().misses, 1);
412 assert_eq!(cache.stats().hits, 0);
413 }
414
415 #[test]
416 fn test_fetch_second_access_same_tile_is_hit() {
417 let mut cache = make_cache(8, 4);
418 let _ = cache.fetch(0, 0).unwrap();
419 let _ = cache.fetch(1, 1).unwrap(); assert_eq!(cache.stats().misses, 1);
421 assert_eq!(cache.stats().hits, 1);
422 }
423
424 #[test]
425 fn test_fetch_correct_texel_value() {
426 let data = make_texture(16, 16);
427 let expected_at_3_5 = data[5 * 16 + 3];
428 let mut cache = TextureCache::new(16, 16, 4, 8, data).unwrap();
429 let val = cache.fetch(3, 5).unwrap();
430 assert_eq!(val, expected_at_3_5);
431 }
432
433 #[test]
434 fn test_fetch_out_of_bounds() {
435 let mut cache = make_cache(4, 4);
436 let err = cache.fetch(16, 0);
437 assert!(matches!(err, Err(CacheError::OutOfBounds { .. })));
438 }
439
440 #[test]
443 fn test_lru_eviction_when_full() {
444 let mut cache = make_cache(2, 4); cache.fetch(0, 0).unwrap(); cache.fetch(4, 0).unwrap(); cache.fetch(8, 0).unwrap(); assert_eq!(cache.stats().evictions, 1);
451 assert_eq!(cache.resident_count(), 2);
452 }
453
454 #[test]
455 fn test_lru_promotion_prevents_eviction() {
456 let mut cache = make_cache(2, 4);
457 cache.fetch(0, 0).unwrap(); cache.fetch(4, 0).unwrap(); cache.fetch(0, 0).unwrap(); cache.fetch(8, 0).unwrap();
462 let key_0_0 = TileKey {
463 tile_x: 0,
464 tile_y: 0,
465 };
466 assert!(
467 cache.is_resident(key_0_0),
468 "promoted tile should survive eviction"
469 );
470 }
471
472 #[test]
475 fn test_prefetch_loads_tile_without_hit_count() {
476 let mut cache = make_cache(8, 4);
477 cache.prefetch(0, 0).unwrap();
478 assert_eq!(cache.stats().prefetches, 1);
479 assert_eq!(cache.stats().hits, 0);
480 assert_eq!(cache.stats().misses, 0);
481 cache.fetch(0, 0).unwrap();
483 assert_eq!(cache.stats().hits, 1);
484 }
485
486 #[test]
487 fn test_prefetch_already_resident_is_noop() {
488 let mut cache = make_cache(8, 4);
489 cache.fetch(0, 0).unwrap(); cache.prefetch(0, 0).unwrap(); assert_eq!(cache.stats().prefetches, 0);
492 }
493
494 #[test]
497 fn test_flush_clears_cache() {
498 let mut cache = make_cache(8, 4);
499 cache.fetch(0, 0).unwrap();
500 cache.fetch(4, 0).unwrap();
501 assert_eq!(cache.resident_count(), 2);
502 cache.flush();
503 assert_eq!(cache.resident_count(), 0);
504 assert_eq!(cache.stats().misses, 2);
506 }
507
508 #[test]
509 fn test_invalidate_tile() {
510 let mut cache = make_cache(8, 4);
511 cache.fetch(0, 0).unwrap();
512 let key = TileKey {
513 tile_x: 0,
514 tile_y: 0,
515 };
516 assert!(cache.invalidate_tile(key));
517 assert!(!cache.is_resident(key));
518 assert!(!cache.invalidate_tile(key));
520 }
521
522 #[test]
525 fn test_grid_size_exact_division() {
526 let data = vec![0u8; 64];
527 let cache = TextureCache::new(8, 8, 4, 4, data).unwrap();
528 assert_eq!(cache.grid_size(), (2, 2));
529 }
530
531 #[test]
532 fn test_grid_size_non_exact() {
533 let data = vec![0u8; 100];
534 let cache = TextureCache::new(10, 10, 4, 4, data).unwrap();
535 assert_eq!(cache.grid_size(), (3, 3));
537 }
538}