bevy_symbios_texture 0.10.0

Algorithmic texture generator for Bevy.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
//! Texture cache: avoid regenerating identical configs across spawns.
//!
//! Every texture generated through [`build_procedural_material_async`] takes
//! tens to hundreds of milliseconds at common resolutions.  Re-rolling the
//! same `(generator kind, config, width, height)` tuple wastes that time —
//! the cache stores the resulting [`GeneratedHandles`] (cheap `Handle<Image>`
//! clones, **not** raw pixel buffers) and short-circuits subsequent requests.
//!
//! Two storage backends ship with the crate:
//!
//! * [`MemoryStore`] — process-local `HashMap` bounded by `max_entries`.
//!   When the cap is reached, the oldest entry (by insertion order) is
//!   dropped.  Re-inserting an existing key updates in place without
//!   evicting.  Use this for per-app caches (default biomes warm-up, hot
//!   parameter sweeps).
//! * [`FileStore`] — disk-backed key/value store keyed by the standard
//!   library hash (currently SipHash-1-3 via `DefaultHasher`) of the cache
//!   key.  Survives process restarts and lets a CLI tool warm the cache
//!   from a manifest before the application launches.  Stored blobs are
//!   raw RGBA8 base levels (albedo + normal + ORM, plus emissive when the
//!   generator produced one) — mipmaps are regenerated on upload by
//!   [`map_to_images`] / [`map_to_images_card`].
//!
//! Cache invalidation is driven by [`TextureConfig::fingerprint`]: any change
//! to a config field rolls the fingerprint and therefore the
//! [`TextureCacheKey`], so previously-cached entries become unreachable
//! automatically.  When generator *internals* change (new noise weights, bug
//! fixes, etc.) without a config-field change, bump
//! [`TextureCache::manifest_version`] — [`FileStore`] mixes it into every
//! on-disk key, so a bump rotates the persisted cache without deleting the
//! directory.
//!
//! [`TextureConfig::fingerprint`]: crate::material::TextureConfig::fingerprint
//!
//! [`build_procedural_material_async`]: crate::material::build_procedural_material_async
//! [`GeneratedHandles`]: crate::generator::GeneratedHandles

use std::collections::HashMap;
use std::collections::VecDeque;
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use bevy::asset::Assets;
use bevy::ecs::resource::Resource;
use bevy::image::Image;

use crate::generator::{GeneratedHandles, TextureMap, map_to_images, map_to_images_card};

/// Default maximum number of entries kept in [`MemoryStore`].
///
/// At common resolutions each entry costs three or four [`Image`] handles
/// (albedo, normal, ORM, and optionally emissive) + their pixel buffers (a
/// few hundred kilobytes).  256 entries hovers around 100 MB of GPU memory
/// and covers most building/biome palettes without thrashing.
pub const DEFAULT_MEMORY_CACHE_ENTRIES: usize = 256;

/// Stable identifier for a cached texture set.
///
/// Combines the generator kind (e.g. `"Bark"`), a fingerprint of the config
/// (`TextureConfig::fingerprint`), and the requested resolution.  `kind` is
/// stored as `&'static str` so cloning a key is `Copy`-cheap; only the
/// fingerprint and dimensions allocate.
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TextureCacheKey {
    /// Generator kind label — `TextureConfig::label()`.
    pub kind: &'static str,
    /// `TextureConfig::fingerprint()` — opaque u64.
    pub fingerprint: u64,
    /// Texture width in texels.
    pub width: u32,
    /// Texture height in texels.
    pub height: u32,
}

/// Trait implemented by texture cache backends.
///
/// Implementations must be `Send + Sync` — Bevy's resource lookup hands
/// `&mut TextureCache` to systems on the main scheduling thread, but the
/// trait object can be queried from any thread that holds a reference.
pub trait TextureCacheStore: Send + Sync {
    /// Returns the handles previously stored under `key`, or `None` on miss.
    ///
    /// Implementations that load lazily (e.g. [`FileStore`]) should perform
    /// the I/O and image upload here, returning fully-populated handles ready
    /// to be assigned to `StandardMaterial` slots.
    fn get(
        &mut self,
        key: &TextureCacheKey,
        images: &mut Assets<Image>,
    ) -> Option<Arc<GeneratedHandles>>;

    /// Stores `handles` under `key`, evicting older entries if needed.
    ///
    /// `is_card` lets disk-backed implementations record the upload mode so
    /// the next cold start can choose between [`map_to_images`] and
    /// [`map_to_images_card`].
    fn put(
        &mut self,
        key: TextureCacheKey,
        handles: Arc<GeneratedHandles>,
        is_card: bool,
        map: Option<&TextureMap>,
    );

    /// Persist the raw pixel buffers for `key` while they are still
    /// available — i.e. **before** the upload consumes the [`TextureMap`].
    ///
    /// [`patch_procedural_material_textures`] calls this on the cache-miss
    /// path with the freshly generated map, then registers the uploaded
    /// handles via [`put`](TextureCacheStore::put) (with `map = None`)
    /// immediately afterwards.  Disk-backed implementations ([`FileStore`])
    /// write their blob here; memory-only backends keep the default no-op.
    ///
    /// [`patch_procedural_material_textures`]: crate::material::patch_procedural_material_textures
    fn put_pixels(&mut self, _key: &TextureCacheKey, _map: &TextureMap, _is_card: bool) {
        // Default: memory-only backends serve handles straight from RAM and
        // have no pixel persistence — the subsequent `put` carries the data
        // they need.  Only disk-backed stores override this.
    }

    /// Optional fast path used by
    /// [`TextureCache::get_handles`] when no `Assets<Image>` is on hand.
    ///
    /// Backends that materialise handles purely from RAM (e.g. [`MemoryStore`])
    /// should override this to return them directly.  Backends that need to
    /// upload pixels to GPU on first hit (e.g. [`FileStore`]) leave the
    /// default — `None` — and the consumer falls back to the regular `get`
    /// path on the system thread that has `Assets<Image>` available.
    fn peek_memory_only(&self, _key: &TextureCacheKey) -> Option<Arc<GeneratedHandles>> {
        None
    }

    /// Number of entries currently held in memory, when the backend can say.
    ///
    /// Purely observational — for a consumer that wants to graph cache size
    /// against process memory. A cache holding `Handle<Image>` keeps those
    /// images alive, so its length is often the missing term when an
    /// application is trying to attribute asset-registry growth.
    ///
    /// `None` (the default) means "this backend does not track a live entry
    /// count", which is the honest answer for a disk-backed store whose
    /// entries live on the filesystem rather than in RAM. Reporting `0`
    /// instead would read as "empty" and mislead exactly the diagnosis this
    /// exists to support.
    fn entry_count(&self) -> Option<usize> {
        None
    }
}

/// Bevy resource wrapper for any [`TextureCacheStore`] implementation.
///
/// Insert this resource before adding [`SymbiosTexturePlugin`](crate::SymbiosTexturePlugin)
/// (or before the first call to
/// [`build_procedural_material_async`](crate::material::build_procedural_material_async))
/// to enable caching:
///
/// ```rust,ignore
/// app.insert_resource(TextureCache::memory(DEFAULT_MEMORY_CACHE_ENTRIES));
/// ```
#[derive(Resource)]
pub struct TextureCache {
    /// Application-supplied schema version for the cached blobs.
    ///
    /// [`TextureCache::file`] passes it into the [`FileStore`], which mixes
    /// it into every on-disk key — bumping the version rotates the cache
    /// when generator internals change without a config-field change.
    /// Memory-backed stores never outlive the process and ignore it.
    pub manifest_version: u32,
    inner: Mutex<Box<dyn TextureCacheStore>>,
}

impl TextureCache {
    /// Wrap any [`TextureCacheStore`] in a [`TextureCache`] resource.
    pub fn new(store: Box<dyn TextureCacheStore>, manifest_version: u32) -> Self {
        Self {
            manifest_version,
            inner: Mutex::new(store),
        }
    }

    /// Entries the backing store currently holds in memory, when it tracks
    /// one — see [`TextureCacheStore::entry_count`]. `None` for backends
    /// whose entries are not resident (e.g. [`FileStore`]).
    ///
    /// Intended for a diagnostics gauge: this cache retains `Handle<Image>`,
    /// so its size is a direct term in the host application's image-asset
    /// count.
    pub fn entry_count(&self) -> Option<usize> {
        self.inner.lock().ok()?.entry_count()
    }

    /// Convenience: in-memory cache with the default capacity.
    pub fn memory(max_entries: usize) -> Self {
        Self::new(Box::new(MemoryStore::new(max_entries)), 0)
    }

    /// Convenience: file-backed cache rooted at `dir`.
    ///
    /// The directory is created if missing.  Each entry produces one
    /// `<sip-hash-of-manifest-and-key>.bin` file containing a short header
    /// (see [`FileStore`]) followed by the three RGBA8 pixel buffers
    /// concatenated.  `images: &mut Assets<Image>` is required at lookup
    /// time to upload the blobs into Bevy's asset system.
    ///
    /// `manifest_version` is mixed into the on-disk key (and validated
    /// against the blob header), so bumping it rotates the cache without
    /// deleting the directory — use it when generator internals change
    /// without a config-field change.
    pub fn file(dir: impl Into<PathBuf>, manifest_version: u32) -> std::io::Result<Self> {
        Ok(Self::new(
            Box::new(FileStore::with_manifest_version(
                dir.into(),
                manifest_version,
            )?),
            manifest_version,
        ))
    }

    /// Full cache lookup.  Backends that load lazily ([`FileStore`]) read
    /// their blob and upload it into `images` here, so a hit returns handles
    /// ready to assign to `StandardMaterial` slots.
    ///
    /// [`build_procedural_material_async`](crate::material::build_procedural_material_async)
    /// uses this, which is what makes disk-backed caches short-circuit
    /// generation exactly like memory-backed ones.
    pub fn get(
        &self,
        key: &TextureCacheKey,
        images: &mut Assets<Image>,
    ) -> Option<Arc<GeneratedHandles>> {
        self.inner.lock().ok()?.get(key, images)
    }

    /// Look up a key without touching `Assets<Image>`.
    ///
    /// Only backends that can materialise handles from RAM ([`MemoryStore`])
    /// return hits here; disk-backed stores need an image upload and return
    /// `None`.  Prefer [`get`](TextureCache::get) whenever an
    /// `Assets<Image>` is on hand.
    pub fn get_handles(&self, key: &TextureCacheKey) -> Option<Arc<GeneratedHandles>> {
        self.inner.lock().ok()?.peek_memory_only(key)
    }

    /// Persist raw pixels for `key` ahead of the upload that consumes them.
    /// No-op for memory-only backends; see [`TextureCacheStore::put_pixels`].
    pub fn persist_pixels(&self, key: &TextureCacheKey, map: &TextureMap, is_card: bool) {
        if let Ok(mut store) = self.inner.lock() {
            store.put_pixels(key, map, is_card);
        }
    }

    /// Insert handles for `key`.  Mirrors `TextureCacheStore::put` without
    /// the texture-map (memory backends never need it).
    pub fn insert(&mut self, key: TextureCacheKey, handles: Arc<GeneratedHandles>) {
        if let Ok(mut store) = self.inner.lock() {
            store.put(key, handles, false, None);
        }
    }
}

/// In-memory cache with bounded capacity and FIFO eviction.
///
/// Eviction is FIFO on insertion order — simpler than full LRU and adequate
/// for the typical access pattern (palettes loaded in bulk, hits clustered
/// around hot configs).  When the cap is reached the oldest entry is
/// dropped; re-inserting an existing key updates in place without evicting.
pub struct MemoryStore {
    max_entries: usize,
    entries: HashMap<TextureCacheKey, Arc<GeneratedHandles>>,
    insertion_order: VecDeque<TextureCacheKey>,
}

impl MemoryStore {
    /// Build a memory store bounded by `max_entries`.  Values below `1` are
    /// rounded up — a zero-sized cache is never useful.
    pub fn new(max_entries: usize) -> Self {
        let cap = max_entries.max(1);
        Self {
            max_entries: cap,
            entries: HashMap::with_capacity(cap),
            insertion_order: VecDeque::with_capacity(cap),
        }
    }
}

impl TextureCacheStore for MemoryStore {
    fn get(
        &mut self,
        key: &TextureCacheKey,
        _images: &mut Assets<Image>,
    ) -> Option<Arc<GeneratedHandles>> {
        self.entries.get(key).cloned()
    }

    fn put(
        &mut self,
        key: TextureCacheKey,
        handles: Arc<GeneratedHandles>,
        _is_card: bool,
        _map: Option<&TextureMap>,
    ) {
        // Replace path: keep insertion order untouched, just refresh the value.
        if let std::collections::hash_map::Entry::Occupied(mut e) = self.entries.entry(key.clone())
        {
            e.insert(handles);
            return;
        }
        if self.entries.len() >= self.max_entries
            && let Some(oldest) = self.insertion_order.pop_front()
        {
            self.entries.remove(&oldest);
        }
        self.insertion_order.push_back(key.clone());
        self.entries.insert(key, handles);
    }

    fn peek_memory_only(&self, key: &TextureCacheKey) -> Option<Arc<GeneratedHandles>> {
        self.entries.get(key).cloned()
    }

    fn entry_count(&self) -> Option<usize> {
        Some(self.entries.len())
    }
}

/// On-disk binary blob layout:
///
/// ```text
/// magic:        b"BSTX"        (4 bytes)
/// version:      u32 LE         (FILE_FORMAT_VERSION)
/// manifest:     u32 LE         (manifest_version the blob was written under)
/// is_card:      u8
/// width:        u32 LE
/// height:       u32 LE
/// albedo_len:   u32 LE
/// normal_len:   u32 LE
/// roughness_len:u32 LE
/// emissive_len: u32 LE         (0 = no emissive map)
/// albedo:       albedo_len bytes
/// normal:       normal_len bytes
/// roughness:    roughness_len bytes
/// emissive:     emissive_len bytes
/// ```
const FILE_MAGIC: &[u8; 4] = b"BSTX";
const FILE_FORMAT_VERSION: u32 = 3;

/// Disk-backed cache.  Each entry is a single binary blob in `dir`.
///
/// The on-disk filename is `<DefaultHasher(manifest_version, key)>.bin`
/// (Rust's `std::hash::DefaultHasher`, currently SipHash-1-3).  Because the
/// `manifest_version` is mixed into the filename hash (and validated against
/// the blob header), bumping it rotates the cache: entries written under a
/// different manifest version become unreachable without deleting the
/// directory.  Entries from older crate versions may be unreadable when the
/// on-disk format version (`FILE_FORMAT_VERSION` in the blob header)
/// changes; the loader skips entries that fail magic / version checks, so
/// stale files are inert rather than fatal.
pub struct FileStore {
    root: PathBuf,
    manifest_version: u32,
}

impl FileStore {
    /// Open or create a file-backed store rooted at `root` with
    /// `manifest_version = 0`.
    ///
    /// The directory is created if it does not exist; any I/O error is
    /// returned unchanged so callers can decide whether to fall back to an
    /// in-memory store or abort startup.
    pub fn new(root: PathBuf) -> std::io::Result<Self> {
        Self::with_manifest_version(root, 0)
    }

    /// Open or create a file-backed store rooted at `root`, keyed under
    /// `manifest_version`.
    ///
    /// Entries written under one manifest version are invisible to stores
    /// opened with another — bump the version when generator internals
    /// change without a config-field change.
    pub fn with_manifest_version(root: PathBuf, manifest_version: u32) -> std::io::Result<Self> {
        fs::create_dir_all(&root)?;
        Ok(Self {
            root,
            manifest_version,
        })
    }

    fn path_for(&self, key: &TextureCacheKey) -> PathBuf {
        use std::hash::{DefaultHasher, Hash, Hasher};
        let mut h = DefaultHasher::new();
        self.manifest_version.hash(&mut h);
        key.hash(&mut h);
        self.root.join(format!("{:016x}.bin", h.finish()))
    }

    /// Serialise `map` into the blob file for `key` (see the layout above).
    /// I/O failures are logged and swallowed — a broken cache write must not
    /// fail texture generation.
    fn write_blob(&self, key: &TextureCacheKey, map: &TextureMap, is_card: bool) {
        let path = self.path_for(key);
        // Persist base levels only: maps arriving from async tasks carry
        // their mip chains appended ([`TextureMap::with_mips`]), but mips
        // are cheap to regenerate on upload and would bloat every blob by a
        // third on disk.
        let base = map.base_len();
        let albedo = &map.albedo[..base];
        let normal = &map.normal[..base];
        let roughness = &map.roughness[..base];
        let emissive = map.emissive.as_deref().map(|e| &e[..base]);
        if let Err(e) = (|| -> std::io::Result<()> {
            let mut file = fs::File::create(&path)?;
            file.write_all(FILE_MAGIC)?;
            file.write_all(&FILE_FORMAT_VERSION.to_le_bytes())?;
            file.write_all(&self.manifest_version.to_le_bytes())?;
            file.write_all(&[is_card as u8])?;
            file.write_all(&map.width.to_le_bytes())?;
            file.write_all(&map.height.to_le_bytes())?;
            file.write_all(&(albedo.len() as u32).to_le_bytes())?;
            file.write_all(&(normal.len() as u32).to_le_bytes())?;
            file.write_all(&(roughness.len() as u32).to_le_bytes())?;
            file.write_all(&(emissive.map_or(0, |e| e.len()) as u32).to_le_bytes())?;
            file.write_all(albedo)?;
            file.write_all(normal)?;
            file.write_all(roughness)?;
            if let Some(emissive) = emissive {
                file.write_all(emissive)?;
            }
            Ok(())
        })() {
            bevy::log::warn!("FileStore write failed for {}: {e}", path.display());
        }
    }
}

impl TextureCacheStore for FileStore {
    fn get(
        &mut self,
        key: &TextureCacheKey,
        images: &mut Assets<Image>,
    ) -> Option<Arc<GeneratedHandles>> {
        let path = self.path_for(key);
        let mut file = fs::File::open(&path).ok()?;
        let mut header = [0u8; 4 + 4 + 4 + 1 + 4 + 4 + 4 + 4 + 4 + 4];
        file.read_exact(&mut header).ok()?;
        if &header[0..4] != FILE_MAGIC {
            return None;
        }
        let version = u32::from_le_bytes(header[4..8].try_into().unwrap());
        if version != FILE_FORMAT_VERSION {
            return None;
        }
        // The filename hash already encodes the manifest version; validating
        // the header copy too guards against hash collisions and hand-moved
        // files.
        let manifest = u32::from_le_bytes(header[8..12].try_into().unwrap());
        if manifest != self.manifest_version {
            return None;
        }
        let is_card = header[12] != 0;
        let width = u32::from_le_bytes(header[13..17].try_into().unwrap());
        let height = u32::from_le_bytes(header[17..21].try_into().unwrap());
        let albedo_len = u32::from_le_bytes(header[21..25].try_into().unwrap()) as usize;
        let normal_len = u32::from_le_bytes(header[25..29].try_into().unwrap()) as usize;
        let roughness_len = u32::from_le_bytes(header[29..33].try_into().unwrap()) as usize;
        let emissive_len = u32::from_le_bytes(header[33..37].try_into().unwrap()) as usize;

        let mut albedo = vec![0u8; albedo_len];
        let mut normal = vec![0u8; normal_len];
        let mut roughness = vec![0u8; roughness_len];
        file.read_exact(&mut albedo).ok()?;
        file.read_exact(&mut normal).ok()?;
        file.read_exact(&mut roughness).ok()?;
        let emissive = if emissive_len > 0 {
            let mut buf = vec![0u8; emissive_len];
            file.read_exact(&mut buf).ok()?;
            Some(buf)
        } else {
            None
        };

        let map = TextureMap {
            albedo,
            normal,
            roughness,
            emissive,
            width,
            height,
            mip_level_count: 1,
        };
        let handles = if is_card {
            map_to_images_card(map, images)
        } else {
            map_to_images(map, images)
        };
        Some(Arc::new(handles))
    }

    fn put(
        &mut self,
        key: TextureCacheKey,
        _handles: Arc<GeneratedHandles>,
        is_card: bool,
        map: Option<&TextureMap>,
    ) {
        let Some(map) = map else {
            // No raw pixels to persist.  The plugin flow persists via
            // `put_pixels` *before* the upload consumes the map, so a
            // handles-only `put` (e.g. `TextureCache::insert`) has nothing
            // left to write — handles cannot be serialised to disk.
            return;
        };
        self.write_blob(&key, map, is_card);
    }

    fn put_pixels(&mut self, key: &TextureCacheKey, map: &TextureMap, is_card: bool) {
        self.write_blob(key, map, is_card);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dummy_handles() -> Arc<GeneratedHandles> {
        Arc::new(GeneratedHandles {
            albedo: Default::default(),
            normal: Default::default(),
            roughness: Default::default(),
            emissive: None,
        })
    }

    fn key(kind: &'static str, fp: u64) -> TextureCacheKey {
        TextureCacheKey {
            kind,
            fingerprint: fp,
            width: 64,
            height: 64,
        }
    }

    #[test]
    fn memory_store_round_trips_handles() {
        let mut store = MemoryStore::new(8);
        let k = key("Bark", 42);
        assert!(store.peek_memory_only(&k).is_none());
        store.put(k.clone(), dummy_handles(), false, None);
        assert!(store.peek_memory_only(&k).is_some());
    }

    #[test]
    fn memory_store_evicts_oldest_at_capacity() {
        let mut store = MemoryStore::new(2);
        store.put(key("Bark", 1), dummy_handles(), false, None);
        store.put(key("Bark", 2), dummy_handles(), false, None);
        store.put(key("Bark", 3), dummy_handles(), false, None);
        // First entry should have been evicted.
        assert!(store.peek_memory_only(&key("Bark", 1)).is_none());
        assert!(store.peek_memory_only(&key("Bark", 2)).is_some());
        assert!(store.peek_memory_only(&key("Bark", 3)).is_some());
    }

    #[test]
    fn memory_store_treats_replace_as_no_evict() {
        let mut store = MemoryStore::new(2);
        store.put(key("Bark", 1), dummy_handles(), false, None);
        store.put(key("Bark", 2), dummy_handles(), false, None);
        // Re-insert existing key — should not trigger eviction.
        store.put(key("Bark", 1), dummy_handles(), false, None);
        assert!(store.peek_memory_only(&key("Bark", 1)).is_some());
        assert!(store.peek_memory_only(&key("Bark", 2)).is_some());
    }

    fn tiny_map(w: u32, h: u32) -> TextureMap {
        let n = (w * h * 4) as usize;
        TextureMap {
            albedo: vec![10u8; n],
            normal: vec![128u8; n],
            roughness: vec![200u8; n],
            width: w,
            height: h,
            mip_level_count: 1,
            emissive: None,
        }
    }

    /// Unique per-test scratch directory under the system temp dir.
    fn scratch_dir(tag: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!("bst-cache-{}-{tag}", std::process::id()));
        let _ = fs::remove_dir_all(&dir);
        dir
    }

    #[test]
    fn file_store_round_trips_pixels_via_put_pixels() {
        let dir = scratch_dir("roundtrip");
        let mut store = FileStore::new(dir.clone()).expect("create store dir");
        let mut images = Assets::<Image>::default();

        let k = key("Bark", 7);
        assert!(store.get(&k, &mut images).is_none(), "cold store must miss");

        store.put_pixels(&k, &tiny_map(4, 4), false);
        let handles = store.get(&k, &mut images).expect("hit after put_pixels");
        let img = images.get(&handles.albedo).expect("albedo uploaded");
        assert_eq!(img.texture_descriptor.size.width, 4);
        assert_eq!(img.texture_descriptor.size.height, 4);

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn manifest_version_rotates_file_cache() {
        let dir = scratch_dir("manifest");
        let k = key("Bark", 21);
        let mut images = Assets::<Image>::default();

        let mut v0 = FileStore::with_manifest_version(dir.clone(), 0).expect("create v0");
        v0.put_pixels(&k, &tiny_map(2, 2), false);
        assert!(v0.get(&k, &mut images).is_some(), "v0 sees its own entry");

        // A store opened under a different manifest version must miss.
        let mut v1 = FileStore::with_manifest_version(dir.clone(), 1).expect("open v1");
        assert!(
            v1.get(&k, &mut images).is_none(),
            "bumped manifest version must rotate the cache"
        );

        // Reopening under the original version still hits.
        let mut v0_again = FileStore::with_manifest_version(dir.clone(), 0).expect("reopen v0");
        assert!(
            v0_again.get(&k, &mut images).is_some(),
            "original manifest version must still reach its entry"
        );

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn file_store_persists_base_level_only_for_mipped_maps() {
        let dir = scratch_dir("mipped");
        let mut store = FileStore::new(dir.clone()).expect("create store dir");
        let k = key("Bark", 31);

        let map = tiny_map(4, 4).with_mips();
        assert!(map.mip_level_count > 1, "precondition: chain present");
        store.put_pixels(&k, &map, false);

        let mut images = Assets::<Image>::default();
        let handles = store.get(&k, &mut images).expect("hit after put_pixels");
        let img = images.get(&handles.albedo).expect("albedo uploaded");
        assert_eq!(img.texture_descriptor.size.width, 4);
        // The blob stored the base level only; the chain was regenerated on
        // upload (4 → 2 → 1 = 3 levels).
        assert_eq!(img.texture_descriptor.mip_level_count, 3);

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn file_store_round_trips_emissive_maps() {
        let dir = scratch_dir("emissive");
        let mut store = FileStore::new(dir.clone()).expect("create store dir");
        let k = key("Bark", 41);

        let mut map = tiny_map(4, 4);
        map.emissive = Some(vec![222u8; map.base_len()]);
        // Persist a mipped map: only base levels should hit the disk.
        store.put_pixels(&k, &map.with_mips(), false);

        let mut images = Assets::<Image>::default();
        let handles = store.get(&k, &mut images).expect("hit after put_pixels");
        let emissive = handles.emissive.as_ref().expect("emissive restored");
        let img = images.get(emissive).expect("emissive uploaded");
        assert_eq!(img.texture_descriptor.size.width, 4);
        // Chain regenerated on upload from the persisted base level.
        assert_eq!(img.texture_descriptor.mip_level_count, 3);

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn file_store_preserves_card_mode_across_restart() {
        use bevy::image::{ImageAddressMode, ImageSampler};

        let dir = scratch_dir("cardmode");
        let k = key("Leaf", 9);
        {
            let mut store = FileStore::new(dir.clone()).expect("create store dir");
            store.put_pixels(&k, &tiny_map(2, 2), true);
        }
        // Fresh store over the same directory — simulates a process restart.
        let mut store = FileStore::new(dir.clone()).expect("reopen store dir");
        let mut images = Assets::<Image>::default();
        let handles = store.get(&k, &mut images).expect("hit after restart");
        let img = images.get(&handles.albedo).expect("albedo uploaded");
        match &img.sampler {
            ImageSampler::Descriptor(d) => {
                assert_eq!(
                    d.address_mode_u,
                    ImageAddressMode::ClampToEdge,
                    "is_card=true must restore a clamp-to-edge sampler"
                );
            }
            _ => panic!("expected a descriptor sampler"),
        }

        let _ = fs::remove_dir_all(&dir);
    }
}