Add xenia-ui crate; switch analysis store to DuckDB
Workspace gains a new xenia-ui member that owns the winit/wgpu window, the Xenos display pipeline (xenos_pipeline + render + texture_cache_host), HUD font/blit shaders, and the input-bridge plumbing the app uses to surface guest framebuffers and overlays. Workspace dependencies grow accordingly: rusqlite is replaced with duckdb (analysis pipeline now writes DuckDB stores), and tracing / metrics / pprof / winit / wgpu / gilrs / pollster / crossbeam / bytemuck are added at workspace level so xenia-ui and xenia-app share versions. Cargo.lock regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
227
crates/xenia-ui/src/texture_cache_host.rs
Normal file
227
crates/xenia-ui/src/texture_cache_host.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
//! 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<TextureKey, HostTextureEntry>,
|
||||
/// 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<wgpu::TextureDescriptor<'static>> {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user