//! Host-side wgpu texture cache — pairs with `xenia_gpu::texture_cache` (the //! CPU-side decoded-bytes layer) by mapping the same [`TextureKey`] onto an //! uploaded `wgpu::Texture` + `TextureView`. //! //! The xenos pipeline only binds one texture at a time (group 1 single slot); //! this cache lets the draw-dispatch loop uphold that layout without //! re-uploading the same texture across frames. Entries carry the //! `version_when_uploaded` stamp that originated from //! [`xenia_memory::GuestMemory::page_version`]; on a stale hit the caller //! re-decodes (via the CPU cache) and [`TextureCacheHost::upload`] replaces //! the wgpu texture in place. use std::collections::HashMap; use xenia_gpu::texture_cache::{CachedTexture, TextureFormat, TextureKey}; pub struct HostTextureEntry { /// Retained so the `wgpu::Texture` outlives its `TextureView` for /// the lifetime of the cache entry. wgpu views internally reference /// their backing texture through an `Arc`, so dropping this field /// would technically still leave the view valid — but keeping it /// makes the ownership intent explicit and avoids relying on wgpu /// internals. Read only at construction; the `#[allow]` silences /// `dead_code` since Rust can't tell the field is load-bearing for /// drop ordering. #[allow(dead_code)] pub texture: wgpu::Texture, pub view: wgpu::TextureView, pub version_when_uploaded: u64, } pub struct TextureCacheHost { entries: HashMap, /// HUD-surfaced counters — mirror the CPU-side cache so a session /// can tell whether uploads are dominated by fresh work or stale /// invalidations. pub uploads_total: u64, pub reuploads_total: u64, } impl Default for TextureCacheHost { fn default() -> Self { Self::new() } } impl TextureCacheHost { pub fn new() -> Self { Self { entries: HashMap::new(), uploads_total: 0, reuploads_total: 0, } } pub fn len(&self) -> usize { self.entries.len() } /// Kept for API symmetry with `len` (clippy recommends both together). /// Unused in code today — callers check `len() == 0` via the HUD. #[allow(dead_code)] pub fn is_empty(&self) -> bool { self.entries.is_empty() } /// Upload (or refresh) a cached texture. Returns a `&TextureView` the /// pipeline can bind. Idempotent when `cached.version_when_uploaded` /// matches an existing entry — in that case we just hand back the /// existing view without touching GPU bandwidth. pub fn upload( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, cached: &CachedTexture, ) -> &HostTextureEntry { let key = cached.key; let needs_refresh = match self.entries.get(&key) { None => true, Some(existing) => existing.version_when_uploaded < cached.version_when_uploaded, }; if !needs_refresh { return self.entries.get(&key).unwrap(); } // Evict + re-upload. For P5 we always recreate the texture (no // in-place writeback); re-uploading a 1280×720 texture is <3 MB // and happens only when the guest actually touches the bytes. let Some(descriptor) = descriptor_for(&key) else { // Unsupported — keep a record-keeping entry pointing at a // dummy and let the pipeline fall back to its own magenta // placeholder. We still create a minimal 1×1 magenta view // so `get` returns something bindable. let (tex, view) = create_magenta_stub(device, queue); let entry = HostTextureEntry { texture: tex, view, version_when_uploaded: cached.version_when_uploaded, }; self.entries.insert(key, entry); self.uploads_total += 1; return self.entries.get(&key).unwrap(); }; let texture = device.create_texture(&descriptor); // Write pixel data. BCn formats require block-aligned extents; // uncompressed formats use byte-aligned rows. First-Pixels M5: // BC1 = 8 bytes/block, BC2/BC3 = 16 bytes/block. let bytes_per_row = match key.format { TextureFormat::Dxt1 => Some((key.width as u32).div_ceil(4) * 8), TextureFormat::Dxt2_3 | TextureFormat::Dxt4_5 => { Some((key.width as u32).div_ceil(4) * 16) } // K8888 and K565 (CPU-expanded to Rgba8Unorm) both produce // 4 bytes per texel in the wgpu-uploadable buffer. _ => Some(key.width as u32 * 4), }; queue.write_texture( wgpu::ImageCopyTexture { texture: &texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, &cached.bytes, wgpu::ImageDataLayout { offset: 0, bytes_per_row, rows_per_image: None, }, wgpu::Extent3d { width: key.width as u32, height: key.height as u32, depth_or_array_layers: 1, }, ); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let entry = HostTextureEntry { texture, view, version_when_uploaded: cached.version_when_uploaded, }; let had_prior = self.entries.contains_key(&key); self.entries.insert(key, entry); if had_prior { self.reuploads_total += 1; } else { self.uploads_total += 1; } self.entries.get(&key).unwrap() } pub fn view_for(&self, key: &TextureKey) -> Option<&wgpu::TextureView> { self.entries.get(key).map(|e| &e.view) } } /// wgpu `TextureDescriptor` matching a Xenos [`TextureKey`]. Returns `None` /// for unsupported formats — caller uses a magenta stub. /// /// First-Pixels M5 adds `K565` (CPU-expanded to `Rgba8Unorm` in the /// decoder), `Dxt2_3` → `Bc2RgbaUnorm`, and `Dxt4_5` → `Bc3RgbaUnorm`. /// BC2/BC3 have a 16-byte block footprint (BC1 is 8), so `upload`'s /// `bytes_per_row` computation branches on the block size too. fn descriptor_for(key: &TextureKey) -> Option> { let format = match key.format { TextureFormat::K8888 => wgpu::TextureFormat::Rgba8Unorm, TextureFormat::K565 => wgpu::TextureFormat::Rgba8Unorm, // CPU-expanded TextureFormat::Dxt1 => wgpu::TextureFormat::Bc1RgbaUnorm, TextureFormat::Dxt2_3 => wgpu::TextureFormat::Bc2RgbaUnorm, TextureFormat::Dxt4_5 => wgpu::TextureFormat::Bc3RgbaUnorm, _ => return None, }; Some(wgpu::TextureDescriptor { label: Some("xenos cached texture"), size: wgpu::Extent3d { width: key.width as u32, height: key.height as u32, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }) } fn create_magenta_stub( device: &wgpu::Device, queue: &wgpu::Queue, ) -> (wgpu::Texture, wgpu::TextureView) { let tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("xenos cached stub"), size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); queue.write_texture( wgpu::ImageCopyTexture { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, &[0xFFu8, 0x00, 0xFF, 0xFF], wgpu::ImageDataLayout { offset: 0, bytes_per_row: Some(4), rows_per_image: Some(1), }, wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1, }, ); let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); (tex, view) }