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:
MechaCat02
2026-05-01 16:26:48 +02:00
parent f166d061be
commit e2b8860e10
13 changed files with 7534 additions and 42 deletions

View 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)
}