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

501
crates/xenia-ui/src/app.rs Normal file
View File

@@ -0,0 +1,501 @@
//! winit `ApplicationHandler` that drives the xenia-rs UI window.
//!
//! Threading model:
//!
//! ```text
//! ┌─ main thread ────────────────────────────────────────────────┐
//! │ App: ApplicationHandler<SwapEvent> │
//! │ on Resumed → create Window (Arc) + RenderState │
//! │ on UserEvent(SwapEvent) → request redraw │
//! │ on WindowEvent::RedrawRequested: │
//! │ 1. poll gilrs → update AtomicCell<GamepadState> │
//! │ 2. pull latest frontbuffer snapshot from shared Mutex │
//! │ and upload to wgpu texture │
//! │ 3. rebuild HUD quads │
//! │ 4. render + present │
//! │ on WindowEvent::CloseRequested → flip shutdown, exit │
//! └──────────────────────────────────────────────────────────────┘
//!
//! ┌─ CPU thread (xenia-app owned) ───────────────────────────────┐
//! │ interpreter loop; VdSwap posts SwapEvent via EventLoopProxy │
//! └──────────────────────────────────────────────────────────────┘
//! ```
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Instant;
use winit::application::ApplicationHandler;
use winit::event::{StartCause, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{WindowAttributes, WindowId};
use crate::bridge::{FrontbufferSnapshot, SwapEvent, UiHandles};
use crate::input::HostInput;
use crate::render::{RenderState, HudVertex, push_string};
struct App {
handles: Arc<UiHandles>,
input: HostInput,
render: Option<RenderState>,
last_revision: u64,
title: String,
start: Instant,
last_poll: Instant,
/// Counter of UserEvents received (== VdSwap count).
swap_events: u64,
last_swap_info: Option<xenia_kernel::SwapInfo>,
/// Last `SwapInfo.frame_index` we cleared the frontbuffer for via the
/// Xenos pipeline. Reset-on-advance triggers a fresh clear for the
/// next batch of dispatched draws.
last_xenos_swap_frame: u64,
/// Shader-blob keys we've already run through the static
/// `shader_metrics::emit_for` analyzer. Prevents per-frame
/// double-counting of `gpu.shader.interpret` / `gpu.shader.reject`.
seen_shader_blobs: std::collections::HashSet<(u8, u32)>,
}
impl App {
fn new(handles: Arc<UiHandles>, title: String) -> anyhow::Result<Self> {
let input = HostInput::new()?;
let now = Instant::now();
Ok(Self {
handles,
input,
render: None,
last_revision: 0,
title,
start: now,
last_poll: now,
swap_events: 0,
last_swap_info: None,
last_xenos_swap_frame: 0,
seen_shader_blobs: std::collections::HashSet::new(),
})
}
fn poll_input(
input: &mut HostInput,
handles: &UiHandles,
) {
input.poll();
let connected = input.is_connected();
handles.gamepad_connected.store(connected, Ordering::Relaxed);
let snap = input.snapshot();
handles.gamepad.store(snap);
}
fn build_hud(
handles: &UiHandles,
title: &str,
start: Instant,
swap_events: u64,
last_swap_info: Option<xenia_kernel::SwapInfo>,
rs: &RenderState,
) -> Vec<HudVertex> {
let mut verts: Vec<HudVertex> = Vec::with_capacity(512);
let (sw, _sh) = rs.surface_size();
let scale = ((sw as f32 / 960.0).max(1.0)).min(3.0);
// Glyph cell is 8 px tall at scale=1; `line_h` = row pitch. 14 px
// gives ~6 px of breathing room between lines so adjacent text never
// overlaps (previous bug: line positions `1.9×` and `2.0×` were
// within one glyph of each other and smeared together).
let line_h = 14.0 * scale;
let pad = 6.0 * scale;
let white = [1.0, 1.0, 1.0, 0.95];
let green = [0.55, 0.95, 0.55, 0.95];
let amber = [0.95, 0.85, 0.4, 0.95];
let cyan = [0.55, 0.85, 0.95, 0.95];
let muted = [0.75, 0.75, 0.85, 0.85];
let uptime = start.elapsed().as_secs_f32();
let pad_snapshot = handles.gamepad.load();
let connected = handles.gamepad_connected.load(Ordering::Relaxed);
// Row 0: title + uptime + live instruction rate.
let mut y = pad;
let ins_total = handles
.instructions_counter
.load(Ordering::Relaxed);
let ips = if uptime > 0.05 {
ins_total as f32 / uptime
} else {
0.0
};
push_string(
&mut verts,
&format!(
"xenia-rs UI {} uptime {:>6.1}s ins {:>10} ({:>5.1} kIPS)",
title,
uptime,
ins_total,
ips / 1000.0
),
pad,
y,
scale,
green,
);
// Row 1: swap info.
y += line_h;
let swap_line = match last_swap_info {
Some(info) => format!(
"Swaps: {:>5} fb {:#010x} {}x{} fmt {} cs {}",
swap_events, info.frontbuffer_addr, info.width, info.height,
info.texture_format, info.color_space
),
None => format!("Swaps: {:>5} (waiting for first VdSwap)", swap_events),
};
push_string(&mut verts, &swap_line, pad, y, scale, muted);
// Row 2: GPU totals.
y += line_h;
let gpu_line = match last_swap_info {
Some(info) => format!(
"GPU: draws={:>6} pkts={:>8} IBs={:>4} waits={:>4} resolves={:>4} (cp={:>4}/sk={:>3}) RTs={:>3} irq={:>4}/{:<4} xlated={:>3}",
info.draws_total,
info.packets_total,
info.indirect_buffer_jumps,
info.wait_reg_mem_blocks,
info.resolves_total,
info.resolves_copied_total,
info.resolves_skipped_total,
info.unique_render_targets,
info.interrupts_delivered,
info.interrupts_dropped,
rs.translated_pipeline_count(),
),
None => String::from(
"GPU: draws= 0 pkts= 0 IBs= 0 waits= 0 resolves= 0 (cp= 0/sk= 0) RTs= 0 irq= 0/0 xlated= 0",
),
};
push_string(&mut verts, &gpu_line, pad, y, scale, muted);
// Row 2b: First-Pixels M4 — rendering-path observability. Shows
// whether the Xenos pipeline is actually dispatching and whether
// the translator or the interpreter served each draw. A `1x1`
// fb_size is the classic signature of "ensure_frontbuffer_at_least
// never fired" — useful at-a-glance diagnostic when nothing
// visibly renders.
y += line_h;
let (fbw, fbh) = rs.frontbuffer_size();
let render_line = format!(
"Render: xdispatch: xlated={:>5} interp={:>5} xlated-pipelines={:>3} tex-cache={:>3} fb={}x{}",
rs.xenos_dispatches_translator,
rs.xenos_dispatches_interpreter,
rs.translated_pipeline_count(),
rs.host_texture_count(),
fbw,
fbh,
);
push_string(&mut verts, &render_line, pad, y, scale, cyan);
// Row 3: latest draw details.
y += line_h;
let draw_line = match last_swap_info {
Some(info) if info.draws_total > 0 => {
let prim = match info.last_draw_prim {
0 => "None",
1 => "PointList",
2 => "LineList",
3 => "LineStrip",
4 => "TriangleList",
5 => "TriangleFan",
6 => "TriangleStrip",
8 => "RectangleList",
13 => "QuadList",
_ => "Unknown",
};
format!(
"Draw: last {} verts={} code={:#x}",
prim, info.last_draw_vertex_count, info.last_draw_prim
)
}
_ => String::from("Draw: (no DRAW_INDX seen yet)"),
};
push_string(&mut verts, &draw_line, pad, y, scale, cyan);
// Row 4: gamepad status.
y += line_h;
let pad_line = if connected {
format!(
"Pad[0]: btn {:04X} LT {:02X} RT {:02X} L({:>+6},{:>+6}) R({:>+6},{:>+6})",
pad_snapshot.buttons,
pad_snapshot.left_trigger,
pad_snapshot.right_trigger,
pad_snapshot.left_stick_x,
pad_snapshot.left_stick_y,
pad_snapshot.right_stick_x,
pad_snapshot.right_stick_y,
)
} else {
String::from("Pad[0]: (no controller)")
};
push_string(
&mut verts,
&pad_line,
pad,
y,
scale,
if connected { white } else { amber },
);
// Row 5 (blank gap) + help text.
y += line_h * 2.0;
let help = "Close window or press Alt+F4 to stop the CPU thread and exit.";
push_string(&mut verts, help, pad, y, scale, muted);
verts
}
fn ingest_frontbuffer(
handles: &UiHandles,
last_revision: &mut u64,
last_swap_info: &mut Option<xenia_kernel::SwapInfo>,
rs: &mut RenderState,
) {
let Ok(snap) = handles.frontbuffer.lock() else { return };
if snap.revision == *last_revision {
return;
}
*last_revision = snap.revision;
*last_swap_info = snap.info;
let FrontbufferSnapshot {
width,
height,
ref rgba,
..
} = *snap;
if width == 0 || height == 0 || rgba.is_empty() {
return;
}
rs.upload_frontbuffer(width, height, rgba);
}
}
impl ApplicationHandler<SwapEvent> for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.render.is_some() {
return;
}
let attrs = WindowAttributes::default()
.with_title(format!("xenia-rs — {}", self.title))
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0));
let window = match event_loop.create_window(attrs) {
Ok(w) => Arc::new(w),
Err(e) => {
tracing::error!("failed to create window: {e}");
event_loop.exit();
return;
}
};
match RenderState::new(window.clone()) {
Ok(rs) => self.render = Some(rs),
Err(e) => {
tracing::error!("failed to initialize wgpu: {e}");
event_loop.exit();
}
}
}
fn new_events(&mut self, event_loop: &ActiveEventLoop, _cause: StartCause) {
// Check shutdown (e.g. CPU thread crashed and flipped the flag).
if self.handles.shutdown.load(Ordering::Relaxed) {
event_loop.exit();
return;
}
// Animate the HUD/gamepad readout at ~60 Hz even without swap events.
let now = Instant::now();
if now.duration_since(self.last_poll).as_millis() >= 16 {
self.last_poll = now;
Self::poll_input(&mut self.input, &self.handles);
if let Some(ref rs) = self.render {
rs.window.request_redraw();
}
}
event_loop.set_control_flow(ControlFlow::Poll);
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: SwapEvent) {
self.swap_events = self.swap_events.wrapping_add(1);
self.last_swap_info = Some(event.0);
if let Some(ref rs) = self.render {
rs.window.request_redraw();
}
}
#[tracing::instrument(skip_all, name = "ui.window_event")]
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
if self.render.is_none() {
return;
}
match event {
WindowEvent::CloseRequested => {
self.handles.request_shutdown();
event_loop.exit();
}
WindowEvent::Resized(size) => {
if let Some(rs) = self.render.as_mut() {
rs.resize(size.width, size.height);
}
}
WindowEvent::RedrawRequested => {
let _redraw = tracing::debug_span!("ui.redraw").entered();
Self::poll_input(&mut self.input, &self.handles);
let rs = self.render.as_mut().unwrap();
// P3 draw-dispatch: if the GPU has captured more draws than
// we've rendered via the Xenos host pipeline, catch up.
// We prefer the Xenos pipeline over the raw frontbuffer
// scrape once any draw has fired — the scrape is only
// useful while the game is pre-draw (no DRAW_INDX yet).
let draws_total = self
.last_swap_info
.map(|s| s.draws_total)
.unwrap_or(0);
if draws_total > 0 {
rs.ensure_frontbuffer_at_least(1280, 720);
let already = rs.xenos_draws_rendered;
if draws_total > already {
let frame_idx = self
.last_swap_info
.map(|s| s.frame_index)
.unwrap_or(0);
if frame_idx != self.last_xenos_swap_frame {
rs.clear_frontbuffer([0.04, 0.04, 0.06, 1.0]);
self.last_xenos_swap_frame = frame_idx;
}
let delta = (draws_total - already) as u32;
let (verts_hint, prim_kind, vs_key, ps_key) = self
.last_swap_info
.map(|s| {
(
s.last_draw_vertex_count.max(3),
s.last_draw_prim,
s.vs_blob_key,
s.ps_blob_key,
)
})
.unwrap_or((3, 4, 0, 0));
// Look up blobs + constants from the bridge and
// pack into the WGSL-interpreter layout. Empty
// slices produce zero-clause packed buffers — the
// WGSL walker short-circuits and the placeholder
// export path still renders.
let raw_vs: Vec<u32> = self
.handles
.shader_blobs
.lock()
.ok()
.and_then(|g| g.get(&vs_key).cloned())
.unwrap_or_default();
let raw_ps: Vec<u32> = self
.handles
.shader_blobs
.lock()
.ok()
.and_then(|g| g.get(&ps_key).cloned())
.unwrap_or_default();
let parsed_vs = xenia_gpu::ucode::parse_shader(&raw_vs);
let parsed_ps = xenia_gpu::ucode::parse_shader(&raw_ps);
// First time we see a blob key, run the static
// metrics analyzer. Keyed on (stage_tag, blob_key)
// because the guest can reuse a key across stages.
if self.seen_shader_blobs.insert((0u8, vs_key)) {
xenia_gpu::shader_metrics::emit_for(&parsed_vs, "vs");
}
if self.seen_shader_blobs.insert((1u8, ps_key)) {
xenia_gpu::shader_metrics::emit_for(&parsed_ps, "ps");
}
let vs_packed = xenia_gpu::ucode::pack_for_wgsl(&parsed_vs);
let ps_packed = xenia_gpu::ucode::pack_for_wgsl(&parsed_ps);
let constants = self
.handles
.xenos_constants
.lock()
.map(|g| *g)
.unwrap_or_default();
// P5: bind any kernel-decoded primary texture
// before dispatching the draw. `None` reverts the
// pipeline's magenta-stub placeholder.
let tex_payload = self
.handles
.primary_texture
.lock()
.ok()
.and_then(|g| g.clone());
rs.bind_primary_texture(tex_payload);
rs.dispatch_xenos_draws(
already,
delta,
verts_hint,
prim_kind,
vs_key,
ps_key,
&parsed_vs,
&parsed_ps,
&vs_packed,
&ps_packed,
&constants,
);
}
} else {
Self::ingest_frontbuffer(
&self.handles,
&mut self.last_revision,
&mut self.last_swap_info,
rs,
);
}
let verts = Self::build_hud(
&self.handles,
&self.title,
self.start,
self.swap_events,
self.last_swap_info,
rs,
);
rs.set_hud_vertices(&verts);
match rs.render() {
Ok(()) => {}
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
let (w, h) = rs.surface_size();
rs.resize(w, h);
}
Err(wgpu::SurfaceError::OutOfMemory) => {
tracing::error!("wgpu surface OOM — exiting");
self.handles.request_shutdown();
event_loop.exit();
}
Err(e) => {
tracing::warn!("wgpu surface error: {e:?}");
}
}
}
_ => {}
}
}
}
/// Run the UI on the calling thread (must be the main thread on macOS/Windows).
///
/// Takes ownership of the [`EventLoop`] (created by the caller so the caller
/// can mint the `EventLoopProxy` used by the kernel bridge). Returns when the
/// window is closed or the CPU thread flipped the shutdown flag.
pub fn run(
event_loop: EventLoop<SwapEvent>,
handles: UiHandles,
title: impl Into<String>,
) -> anyhow::Result<()> {
let mut app = App::new(Arc::new(handles), title.into())?;
event_loop.run_app(&mut app)?;
// Make sure the CPU thread sees shutdown even on graceful exit.
app.handles.request_shutdown();
Ok(())
}

View File

@@ -0,0 +1,167 @@
//! UI/kernel bridge construction.
//!
//! The UI side owns:
//! - a gamepad snapshot cell (writer) + connected flag (writer),
//! - a shared staging buffer that the CPU thread fills with the guest's
//! frontbuffer bytes on each `VdSwap`,
//! - an `EventLoopProxy<SwapEvent>` to wake winit with a redraw request,
//! - a shutdown atomic the UI flips on window close.
//!
//! The kernel side gets a [`xenia_kernel::UiBridge`] made from closures over
//! the same shared state, so it can read gamepad + post swaps from the CPU
//! thread without knowing about winit.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use crossbeam_utils::atomic::AtomicCell;
use winit::event_loop::EventLoopProxy;
use xenia_gpu::texture_cache::TextureKey;
use xenia_gpu::xenos_constants::XenosConstantsBlock;
use xenia_hid::GamepadState;
use xenia_kernel::{SwapInfo, UiBridge};
use xenia_memory::MemoryAccess;
/// Snapshot of the guest frontbuffer, copied out of guest memory on each
/// `VdSwap` so the UI thread can upload it without touching the CPU thread's
/// [`xenia_memory::GuestMemory`].
#[derive(Default)]
pub struct FrontbufferSnapshot {
/// Width of the most-recently-swapped frontbuffer, in pixels.
pub width: u32,
/// Height in pixels.
pub height: u32,
/// Tightly-packed RGBA8 bytes (`width * height * 4`). Decoded from the
/// raw guest bytes by the kernel-side closure (see [`build`]).
pub rgba: Vec<u8>,
/// Monotonic counter so the UI can tell new frames apart from stale
/// re-reads.
pub revision: u64,
/// The most recent swap metadata. Kept alongside the bytes for HUD use.
pub info: Option<SwapInfo>,
}
/// The UI-side half of the bridge. Installed into the [`App`](crate::app)
/// that runs on the main thread.
pub struct UiHandles {
pub gamepad: Arc<AtomicCell<GamepadState>>,
pub gamepad_connected: Arc<AtomicBool>,
pub shutdown: Arc<AtomicBool>,
pub frontbuffer: Arc<Mutex<FrontbufferSnapshot>>,
/// Live-updated summed CPU instruction counter. The `xenia-app` run
/// loop stores `cycle_count` into this every ~10k instructions so the
/// HUD keeps animating even before the first `VdSwap` fires. Authoritative
/// counter comes from scheduler HW-thread contexts; this atomic is just
/// a cross-thread cache.
pub instructions_counter: Arc<AtomicU64>,
/// P3b assets: cloned by `vd_swap` via `UiBridge::publish_assets`.
/// The UI redraw path reads them (mutex-guarded) alongside the
/// corresponding `SwapInfo` and passes them to the Xenos pipeline.
pub shader_blobs: Arc<Mutex<HashMap<u32, Vec<u32>>>>,
pub xenos_constants: Arc<Mutex<XenosConstantsBlock>>,
/// P5 primary texture — `None` means "no texture this frame, use
/// the magenta stub"; `Some((key, bytes))` means the kernel decoded
/// fetch-constant slot 0 into linear bytes that the UI should
/// upload into the host cache and bind at `@group(1) @binding(0)`.
pub primary_texture: Arc<Mutex<Option<(TextureKey, Vec<u8>)>>>,
}
/// Swap event posted by the CPU-side `VdSwap` handler via
/// [`EventLoopProxy::send_event`] after the frontbuffer bytes have been
/// copied into [`UiHandles::frontbuffer`].
#[derive(Clone, Copy, Debug)]
pub struct SwapEvent(pub SwapInfo);
/// Build a paired ([`UiHandles`], [`UiBridge`]).
///
/// The proxy is the winit user-event injector for a freshly-constructed
/// `EventLoop<SwapEvent>`; `xenia-app` owns the event loop and keeps the
/// `UiHandles` alongside it.
pub fn build(proxy: EventLoopProxy<SwapEvent>) -> (UiHandles, UiBridge) {
let gamepad = Arc::new(AtomicCell::new(GamepadState::default()));
let gamepad_connected = Arc::new(AtomicBool::new(false));
let shutdown = Arc::new(AtomicBool::new(false));
let frontbuffer = Arc::new(Mutex::new(FrontbufferSnapshot::default()));
let instructions_counter = Arc::new(AtomicU64::new(0));
let shader_blobs = Arc::new(Mutex::new(HashMap::<u32, Vec<u32>>::new()));
let xenos_constants = Arc::new(Mutex::new(XenosConstantsBlock::default()));
let primary_texture: Arc<Mutex<Option<(TextureKey, Vec<u8>)>>> =
Arc::new(Mutex::new(None));
let kernel_bridge = UiBridge {
gamepad: {
let gp = Arc::clone(&gamepad);
Arc::new(move || gp.load())
},
// `post_swap` now only bumps the SwapInfo + revision; the RGBA
// bytes arrive via `publish_frontbuffer` earlier in the same
// `VdSwap` (P4 replaced the raw-scrape path with a CPU-side
// detile). Posting the swap event after the bytes land guarantees
// the UI's redraw path sees the latest pixels.
post_swap: {
let proxy = proxy.clone();
let fb = Arc::clone(&frontbuffer);
Arc::new(move |info: SwapInfo, _mem: &dyn MemoryAccess| {
if let Ok(mut lock) = fb.lock() {
lock.info = Some(info);
lock.revision = lock.revision.wrapping_add(1);
}
let _ = proxy.send_event(SwapEvent(info));
})
},
shutdown: Arc::clone(&shutdown),
gamepad_connected: Arc::clone(&gamepad_connected),
instructions_counter: Arc::clone(&instructions_counter),
publish_xenos_assets: {
let blobs = Arc::clone(&shader_blobs);
let consts = Arc::clone(&xenos_constants);
Arc::new(move |new_blobs, new_consts| {
if let Ok(mut g) = blobs.lock() {
*g = new_blobs;
}
if let Ok(mut g) = consts.lock() {
*g = new_consts;
}
})
},
publish_frontbuffer: {
let fb = Arc::clone(&frontbuffer);
Arc::new(move |w, h, bytes| {
if let Ok(mut lock) = fb.lock() {
lock.width = w;
lock.height = h;
lock.rgba = bytes;
}
})
},
publish_texture: {
let tex = Arc::clone(&primary_texture);
Arc::new(move |payload| {
if let Ok(mut lock) = tex.lock() {
*lock = payload;
}
})
},
};
let handles = UiHandles {
gamepad,
gamepad_connected,
shutdown,
frontbuffer,
instructions_counter,
shader_blobs,
xenos_constants,
primary_texture,
};
(handles, kernel_bridge)
}
impl UiHandles {
pub fn request_shutdown(&self) {
self.shutdown.store(true, Ordering::Relaxed);
}
}

231
crates/xenia-ui/src/font.rs Normal file
View File

@@ -0,0 +1,231 @@
//! Minimal 8×8 bitmap font for the HUD.
//!
//! Only printable ASCII (0x20..0x7E) is populated. Each glyph is 8 bytes, one
//! byte per row, MSB = leftmost pixel. This is a bespoke hand-drawn font just
//! wide enough for status-line text ("Swap: 0x1234 Pad: 0x0040 …"); no
//! international glyphs, no kerning, no anti-aliasing.
//!
//! The font is rendered by uploading the 8×(8·N) atlas as an R8 texture and
//! sampling one cell per character quad from the HUD shader.
pub const CELL_W: u32 = 8;
pub const CELL_H: u32 = 8;
pub const FIRST: u8 = 0x20;
pub const LAST: u8 = 0x7E;
pub const GLYPH_COUNT: u32 = (LAST as u32) - (FIRST as u32) + 1;
/// Build the atlas as a linear R8 buffer, `CELL_W × (CELL_H * GLYPH_COUNT)`,
/// one glyph per 8-row strip stacked top-to-bottom.
pub fn build_atlas() -> Vec<u8> {
let mut out = vec![0u8; (CELL_W * CELL_H * GLYPH_COUNT) as usize];
for ch in FIRST..=LAST {
let rows = GLYPHS[(ch - FIRST) as usize];
for (row, byte) in rows.iter().enumerate() {
for col in 0..8 {
let bit = (byte >> (7 - col)) & 1;
if bit != 0 {
let idx = ((ch - FIRST) as u32 * CELL_H + row as u32) * CELL_W + col as u32;
out[idx as usize] = 0xFF;
}
}
}
}
out
}
/// 95 glyphs (0x20..0x7E). Each entry is 8 bytes of rows.
/// Hand-crafted for the HUD lines we actually emit. Most punctuation is
/// covered; unsupported chars fall through to the space glyph.
#[rustfmt::skip]
pub const GLYPHS: [[u8; 8]; 95] = [
// 0x20 ' '
[0,0,0,0,0,0,0,0],
// '!'
[0x18,0x18,0x18,0x18,0x00,0x00,0x18,0x00],
// '"'
[0x36,0x36,0x00,0x00,0x00,0x00,0x00,0x00],
// '#'
[0x6C,0xFE,0x6C,0x6C,0x6C,0xFE,0x6C,0x00],
// '$'
[0x18,0x3E,0x60,0x3C,0x06,0x7C,0x18,0x00],
// '%'
[0x00,0xC6,0xCC,0x18,0x30,0x66,0xC6,0x00],
// '&'
[0x38,0x6C,0x38,0x76,0xDC,0xCC,0x76,0x00],
// '\''
[0x18,0x18,0x00,0x00,0x00,0x00,0x00,0x00],
// '('
[0x0C,0x18,0x30,0x30,0x30,0x18,0x0C,0x00],
// ')'
[0x30,0x18,0x0C,0x0C,0x0C,0x18,0x30,0x00],
// '*'
[0x00,0x66,0x3C,0xFF,0x3C,0x66,0x00,0x00],
// '+'
[0x00,0x18,0x18,0x7E,0x18,0x18,0x00,0x00],
// ','
[0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x30],
// '-'
[0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00],
// '.'
[0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x00],
// '/'
[0x06,0x0C,0x18,0x30,0x60,0xC0,0x80,0x00],
// '0'
[0x7C,0xC6,0xCE,0xD6,0xE6,0xC6,0x7C,0x00],
// '1'
[0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00],
// '2'
[0x7C,0xC6,0x06,0x1C,0x30,0x66,0xFE,0x00],
// '3'
[0x7C,0xC6,0x06,0x3C,0x06,0xC6,0x7C,0x00],
// '4'
[0x1C,0x3C,0x6C,0xCC,0xFE,0x0C,0x1E,0x00],
// '5'
[0xFE,0xC0,0xC0,0xFC,0x06,0xC6,0x7C,0x00],
// '6'
[0x38,0x60,0xC0,0xFC,0xC6,0xC6,0x7C,0x00],
// '7'
[0xFE,0xC6,0x0C,0x18,0x30,0x30,0x30,0x00],
// '8'
[0x7C,0xC6,0xC6,0x7C,0xC6,0xC6,0x7C,0x00],
// '9'
[0x7C,0xC6,0xC6,0x7E,0x06,0x0C,0x78,0x00],
// ':'
[0x00,0x18,0x18,0x00,0x00,0x18,0x18,0x00],
// ';'
[0x00,0x18,0x18,0x00,0x00,0x18,0x18,0x30],
// '<'
[0x06,0x0C,0x18,0x30,0x18,0x0C,0x06,0x00],
// '='
[0x00,0x00,0x7E,0x00,0x7E,0x00,0x00,0x00],
// '>'
[0x60,0x30,0x18,0x0C,0x18,0x30,0x60,0x00],
// '?'
[0x7C,0xC6,0x0C,0x18,0x18,0x00,0x18,0x00],
// '@'
[0x7C,0xC6,0xDE,0xDE,0xDE,0xC0,0x78,0x00],
// 'A'
[0x38,0x6C,0xC6,0xC6,0xFE,0xC6,0xC6,0x00],
// 'B'
[0xFC,0x66,0x66,0x7C,0x66,0x66,0xFC,0x00],
// 'C'
[0x3C,0x66,0xC0,0xC0,0xC0,0x66,0x3C,0x00],
// 'D'
[0xF8,0x6C,0x66,0x66,0x66,0x6C,0xF8,0x00],
// 'E'
[0xFE,0x62,0x68,0x78,0x68,0x62,0xFE,0x00],
// 'F'
[0xFE,0x62,0x68,0x78,0x68,0x60,0xF0,0x00],
// 'G'
[0x3C,0x66,0xC0,0xC0,0xCE,0x66,0x3E,0x00],
// 'H'
[0xC6,0xC6,0xC6,0xFE,0xC6,0xC6,0xC6,0x00],
// 'I'
[0x3C,0x18,0x18,0x18,0x18,0x18,0x3C,0x00],
// 'J'
[0x1E,0x0C,0x0C,0x0C,0xCC,0xCC,0x78,0x00],
// 'K'
[0xE6,0x66,0x6C,0x78,0x6C,0x66,0xE6,0x00],
// 'L'
[0xF0,0x60,0x60,0x60,0x62,0x66,0xFE,0x00],
// 'M'
[0xC6,0xEE,0xFE,0xFE,0xD6,0xC6,0xC6,0x00],
// 'N'
[0xC6,0xE6,0xF6,0xDE,0xCE,0xC6,0xC6,0x00],
// 'O'
[0x38,0x6C,0xC6,0xC6,0xC6,0x6C,0x38,0x00],
// 'P'
[0xFC,0x66,0x66,0x7C,0x60,0x60,0xF0,0x00],
// 'Q'
[0x7C,0xC6,0xC6,0xC6,0xCE,0x7C,0x0E,0x00],
// 'R'
[0xFC,0x66,0x66,0x7C,0x6C,0x66,0xE6,0x00],
// 'S'
[0x7C,0xC6,0xE0,0x78,0x0E,0xC6,0x7C,0x00],
// 'T'
[0x7E,0x7E,0x5A,0x18,0x18,0x18,0x3C,0x00],
// 'U'
[0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0x7C,0x00],
// 'V'
[0xC6,0xC6,0xC6,0xC6,0xC6,0x6C,0x38,0x00],
// 'W'
[0xC6,0xC6,0xC6,0xD6,0xFE,0xEE,0xC6,0x00],
// 'X'
[0xC6,0xC6,0x6C,0x38,0x6C,0xC6,0xC6,0x00],
// 'Y'
[0x66,0x66,0x66,0x3C,0x18,0x18,0x3C,0x00],
// 'Z'
[0xFE,0xC6,0x8C,0x18,0x32,0x66,0xFE,0x00],
// '['
[0x3C,0x30,0x30,0x30,0x30,0x30,0x3C,0x00],
// '\\'
[0xC0,0x60,0x30,0x18,0x0C,0x06,0x02,0x00],
// ']'
[0x3C,0x0C,0x0C,0x0C,0x0C,0x0C,0x3C,0x00],
// '^'
[0x10,0x38,0x6C,0xC6,0x00,0x00,0x00,0x00],
// '_'
[0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF],
// '`'
[0x30,0x30,0x18,0x00,0x00,0x00,0x00,0x00],
// 'a'
[0x00,0x00,0x78,0x0C,0x7C,0xCC,0x76,0x00],
// 'b'
[0xE0,0x60,0x7C,0x66,0x66,0x66,0xDC,0x00],
// 'c'
[0x00,0x00,0x7C,0xC6,0xC0,0xC6,0x7C,0x00],
// 'd'
[0x1C,0x0C,0x7C,0xCC,0xCC,0xCC,0x76,0x00],
// 'e'
[0x00,0x00,0x7C,0xC6,0xFE,0xC0,0x7C,0x00],
// 'f'
[0x1C,0x36,0x30,0x78,0x30,0x30,0x78,0x00],
// 'g'
[0x00,0x00,0x76,0xCC,0xCC,0x7C,0x0C,0xF8],
// 'h'
[0xE0,0x60,0x6C,0x76,0x66,0x66,0xE6,0x00],
// 'i'
[0x18,0x00,0x38,0x18,0x18,0x18,0x3C,0x00],
// 'j'
[0x06,0x00,0x0E,0x06,0x06,0x66,0x66,0x3C],
// 'k'
[0xE0,0x60,0x66,0x6C,0x78,0x6C,0xE6,0x00],
// 'l'
[0x38,0x18,0x18,0x18,0x18,0x18,0x3C,0x00],
// 'm'
[0x00,0x00,0xEC,0xFE,0xD6,0xD6,0xD6,0x00],
// 'n'
[0x00,0x00,0xDC,0x66,0x66,0x66,0x66,0x00],
// 'o'
[0x00,0x00,0x7C,0xC6,0xC6,0xC6,0x7C,0x00],
// 'p'
[0x00,0x00,0xDC,0x66,0x66,0x7C,0x60,0xF0],
// 'q'
[0x00,0x00,0x76,0xCC,0xCC,0x7C,0x0C,0x1E],
// 'r'
[0x00,0x00,0xDC,0x76,0x60,0x60,0xF0,0x00],
// 's'
[0x00,0x00,0x7E,0xC0,0x7C,0x06,0xFC,0x00],
// 't'
[0x30,0x30,0x7C,0x30,0x30,0x36,0x1C,0x00],
// 'u'
[0x00,0x00,0xCC,0xCC,0xCC,0xCC,0x76,0x00],
// 'v'
[0x00,0x00,0xC6,0xC6,0xC6,0x6C,0x38,0x00],
// 'w'
[0x00,0x00,0xC6,0xD6,0xD6,0xFE,0x6C,0x00],
// 'x'
[0x00,0x00,0xC6,0x6C,0x38,0x6C,0xC6,0x00],
// 'y'
[0x00,0x00,0xC6,0xC6,0xC6,0x7E,0x06,0xFC],
// 'z'
[0x00,0x00,0xFE,0x4C,0x18,0x32,0xFE,0x00],
// '{'
[0x0E,0x18,0x18,0x70,0x18,0x18,0x0E,0x00],
// '|'
[0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x00],
// '}'
[0x70,0x18,0x18,0x0E,0x18,0x18,0x70,0x00],
// '~'
[0x76,0xDC,0x00,0x00,0x00,0x00,0x00,0x00],
];

View File

@@ -0,0 +1,159 @@
//! gilrs → [`xenia_hid::GamepadState`] adapter.
use gilrs::{Axis, Button, EventType, Gilrs, GilrsBuilder};
use xenia_hid::{GamepadState, buttons};
/// Simulated-pad policy: if no physical controller is attached, advertise
/// a connected pad (with all-zero state) so games don't early-exit on
/// `XamInputGetState → ERROR_DEVICE_NOT_CONNECTED`. Set
/// `XENIA_FAKE_PAD=0` to disable and report the true no-controller status.
fn fake_pad_policy() -> bool {
match std::env::var("XENIA_FAKE_PAD").as_deref() {
Ok("0") | Ok("false") | Ok("off") => false,
_ => true,
}
}
pub struct HostInput {
ctx: Gilrs,
/// Currently-active gilrs pad id. `None` until the first Connected event.
active: Option<gilrs::GamepadId>,
/// `true` if the pad is really physical; `false` for the zeroed simulated
/// pad we advertise when no hardware is attached (see `fake_pad_policy`).
pub physical_attached: bool,
fake_allowed: bool,
}
impl HostInput {
pub fn new() -> anyhow::Result<Self> {
let ctx = GilrsBuilder::new()
.add_included_mappings(true)
.build()
.map_err(|e| anyhow::anyhow!("gilrs init failed: {e}"))?;
let active = ctx.gamepads().next().map(|(id, _)| id);
let physical_attached = active.is_some();
let fake_allowed = fake_pad_policy();
tracing::info!(
physical = physical_attached,
fake_allowed,
"input: init (pad simulation: {})",
if fake_allowed && !physical_attached {
"simulated (no physical pad)"
} else if physical_attached {
"physical"
} else {
"disabled"
}
);
Ok(Self {
ctx,
active,
physical_attached,
fake_allowed,
})
}
/// True iff a gamepad is "available" for user index 0 — either the
/// actual host pad, or a simulated one when `XENIA_FAKE_PAD != 0`.
pub fn is_connected(&self) -> bool {
if self.physical_attached
&& self
.active
.and_then(|id| self.ctx.connected_gamepad(id))
.is_some()
{
return true;
}
self.fake_allowed
}
/// Pump gilrs events until empty. Updates the internal "active pad"
/// registration; pad state is sampled by [`snapshot`].
pub fn poll(&mut self) {
while let Some(event) = self.ctx.next_event() {
match event.event {
EventType::Connected => {
if self.active.is_none() {
self.active = Some(event.id);
self.physical_attached = true;
tracing::info!("gilrs: pad connected (id={:?})", event.id);
}
}
EventType::Disconnected => {
if self.active == Some(event.id) {
self.active = None;
tracing::info!("gilrs: pad disconnected (id={:?})", event.id);
self.active = self.ctx.gamepads().next().map(|(id, _)| id);
self.physical_attached = self.active.is_some();
}
}
_ => {}
}
}
}
/// Build an Xbox-360-layout `GamepadState` from the active gilrs pad.
/// Returns `GamepadState::default()` (all-zero) when no pad is present.
pub fn snapshot(&self) -> GamepadState {
let Some(id) = self.active else {
return GamepadState::default();
};
let Some(pad) = self.ctx.connected_gamepad(id) else {
return GamepadState::default();
};
let mut state = GamepadState::default();
let pressed = |btn: Button| pad.is_pressed(btn);
let set = |state: &mut GamepadState, btn: Button, mask: u16| {
if pressed(btn) {
state.buttons |= mask;
}
};
set(&mut state, Button::DPadUp, buttons::DPAD_UP);
set(&mut state, Button::DPadDown, buttons::DPAD_DOWN);
set(&mut state, Button::DPadLeft, buttons::DPAD_LEFT);
set(&mut state, Button::DPadRight, buttons::DPAD_RIGHT);
set(&mut state, Button::Start, buttons::START);
set(&mut state, Button::Select, buttons::BACK);
set(&mut state, Button::LeftThumb, buttons::LEFT_THUMB);
set(&mut state, Button::RightThumb, buttons::RIGHT_THUMB);
set(&mut state, Button::LeftTrigger, buttons::LEFT_SHOULDER);
set(&mut state, Button::RightTrigger, buttons::RIGHT_SHOULDER);
set(&mut state, Button::Mode, buttons::GUIDE);
set(&mut state, Button::South, buttons::A);
set(&mut state, Button::East, buttons::B);
set(&mut state, Button::West, buttons::X);
set(&mut state, Button::North, buttons::Y);
// Triggers: gilrs reports LeftTrigger2 / RightTrigger2 as axes in
// range [0.0, 1.0]. Map to u8 0..=255.
let trig = |axis: Axis| -> u8 {
pad.axis_data(axis)
.map(|d| (d.value().clamp(0.0, 1.0) * 255.0) as u8)
.unwrap_or(0)
};
state.left_trigger = pad
.button_data(Button::LeftTrigger2)
.map(|d| (d.value().clamp(0.0, 1.0) * 255.0) as u8)
.unwrap_or_else(|| trig(Axis::LeftZ));
state.right_trigger = pad
.button_data(Button::RightTrigger2)
.map(|d| (d.value().clamp(0.0, 1.0) * 255.0) as u8)
.unwrap_or_else(|| trig(Axis::RightZ));
// Sticks: gilrs reports [-1.0, 1.0]. Xbox uses i16 [-32768, 32767].
let stick = |axis: Axis| -> i16 {
let v = pad
.axis_data(axis)
.map(|d| d.value().clamp(-1.0, 1.0))
.unwrap_or(0.0);
(v * 32767.0) as i16
};
state.left_stick_x = stick(Axis::LeftStickX);
state.left_stick_y = stick(Axis::LeftStickY);
state.right_stick_x = stick(Axis::RightStickX);
state.right_stick_y = stick(Axis::RightStickY);
state
}
}

View File

@@ -0,0 +1,21 @@
//! Host UI for `xenia-rs exec --ui`.
//!
//! Opens a winit window, drives a wgpu swapchain, polls gilrs for gamepad
//! input, and presents the guest frontbuffer whenever `VdSwap` fires on the
//! CPU thread. See the crate-level diagram in `app.rs`.
//!
//! Entry point is [`run`], which is called on the main thread by `xenia-app`.
//! The caller gets a matching [`UiHandles`] to install on the kernel state
//! before spawning the CPU worker thread — see [`build`].
mod app;
pub mod bridge;
mod font;
mod input;
mod render;
mod texture_cache_host;
mod xenos_pipeline;
pub use app::run;
pub use bridge::{SwapEvent, UiHandles, build};
pub use xenos_pipeline::{DrawRequest, XenosPipeline};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
// Full-screen blit of the guest frontbuffer texture, with aspect-ratio
// letterboxing. A three-vertex fullscreen triangle covers the viewport; the
// fragment shader samples the frontbuffer and emits sRGB colors directly,
// letting the swapchain handle the final gamma step.
struct Uniforms {
// aspect_correction.xy = scaling factors applied to the clip-space
// position to letterbox the frontbuffer into the swapchain window.
aspect_correction: vec2<f32>,
// tint for when the frontbuffer is missing / unknown format. When the
// guest has not yet swapped a frame, CPU side uploads a 1x1 texture and
// we just want the background color.
fallback_rgb: vec3<f32>,
_pad: f32,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var frontbuffer: texture_2d<f32>;
@group(0) @binding(2) var samp: sampler;
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) in_bounds: f32,
};
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut {
// Fullscreen triangle: (-1,-1), (3,-1), (-1,3).
var pos = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let p = pos[vid];
// Scale by the aspect-correction factor so the frontbuffer stays at its
// native aspect inside the window.
let corrected = p * u.aspect_correction;
var out: VsOut;
out.clip = vec4<f32>(corrected, 0.0, 1.0);
// UV runs [0,1] across the unscaled fullscreen triangle's extent
// [(-1,-1), (3,-1), (-1,3)] → UV = (p+1)*0.5 mapped with Y flipped.
let uv_raw = (p + vec2<f32>(1.0, 1.0)) * 0.5;
out.uv = vec2<f32>(uv_raw.x, 1.0 - uv_raw.y);
// Also pass a flag telling the FS whether the original position (before
// aspect correction) was inside [-1,1]; outside = letterbox region.
let in_bounds = f32(all(abs(corrected) <= vec2<f32>(1.0, 1.0)));
out.in_bounds = in_bounds;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
if (in.uv.x < 0.0 || in.uv.x > 1.0 || in.uv.y < 0.0 || in.uv.y > 1.0) {
return vec4<f32>(u.fallback_rgb, 1.0);
}
let sample = textureSample(frontbuffer, samp, in.uv);
return vec4<f32>(sample.rgb, 1.0);
}

View File

@@ -0,0 +1,47 @@
// Textured-quad pipeline for the HUD overlay. Each character is a 6-vertex
// (two-triangle) quad with a UV into the font atlas. The CPU-side HUD code
// pushes a vertex buffer every frame describing the text to draw.
struct VsIn {
@location(0) pos_px: vec2<f32>, // pixel-space position in the window
@location(1) uv: vec2<f32>, // atlas UV
@location(2) rgba: vec4<f32>,
};
struct HudUniforms {
// Inverse of the surface dimensions so the VS can map pixel coordinates
// into clip space: clip.xy = (pos_px / surface_size) * 2.0 - 1.0 with Y
// flipped.
inv_surface: vec2<f32>,
_pad: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: HudUniforms;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@group(0) @binding(2) var samp: sampler;
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) rgba: vec4<f32>,
};
@vertex
fn vs_main(in: VsIn) -> VsOut {
var out: VsOut;
let clip_xy = in.pos_px * u.inv_surface * 2.0 - vec2<f32>(1.0, 1.0);
// Invert Y so pos_px=(0,0) is top-left.
out.clip = vec4<f32>(clip_xy.x, -clip_xy.y, 0.0, 1.0);
out.uv = in.uv;
out.rgba = in.rgba;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let coverage = textureSample(atlas, samp, in.uv).r;
// Atlas is R8 where 0xFF = glyph pixel. We premultiply by the input alpha
// so the pipeline's alpha-blend pass composits correctly.
let a = coverage * in.rgba.a;
return vec4<f32>(in.rgba.rgb * a, a);
}

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

View File

@@ -0,0 +1,643 @@
//! Host pipeline that consumes PM4 `DRAW_INDX*` captures.
//!
//! Drives [`xenia_gpu::shaders::XENOS_INTERP_WGSL`]. This file owns the
//! wgpu bind-group + pipeline + buffer surface the Xenos WGSL interpreter
//! binds to. The WGSL module is expected to declare:
//!
//! ```text
//! @group(0) @binding(0) var<uniform> xenos_draw : XenosDrawConstants; // 16 B
//! @group(0) @binding(1) var<uniform> xenos_consts : XenosConstants; // ~9.2 KB
//! @group(0) @binding(2) var<storage, read> vs_ucode : array<u32>;
//! @group(0) @binding(3) var<storage, read> ps_ucode : array<u32>;
//! @group(0) @binding(4) var<storage, read> vertex_buffer : array<u32>;
//! ```
//!
//! Texture bindings (M6) are a single-slot stub for P3b:
//!
//! ```text
//! @group(1) @binding(0) var xenos_tex : texture_2d<f32>;
//! @group(1) @binding(1) var xenos_samp : sampler;
//! ```
//!
//! The bound texture is a 1×1 magenta placeholder. Real per-slot guest
//! texture uploads + format decode land with the texture cache (P5).
use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use xenia_gpu::shaders::XENOS_INTERP_WGSL;
use xenia_gpu::xenos_constants::XenosConstantsBlock;
/// Per-draw constants mirroring the WGSL `XenosDrawConstants` uniform
/// block. Ordering / padding matches `xenos_interp.wgsl` exactly.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct DrawConstants {
draw_index: u32,
vertex_count: u32,
prim_kind: u32,
_pad: u32,
}
/// Submitted to [`XenosPipeline::render_one`] to render one captured draw.
#[derive(Clone, Copy, Debug)]
pub struct DrawRequest {
/// Monotonic draw counter; shader uses it for per-draw colour rotation.
pub draw_index: u32,
/// Host-normalised vertex count (after primitive-processor rewrite).
pub vertex_count: u32,
/// Xenos primitive-type code; shader may branch on it in P3b+.
pub prim_kind: u32,
}
/// Reasonable upper bound on a single shader blob (dwords). Most Xbox 360
/// shaders are ≪ 4 KB; 64 KB is orders-of-magnitude slack.
const UCODE_BUFFER_MAX_DWORDS: u64 = 16 * 1024; // 64 KB each for VS & PS
/// 16 MB of vertex data — enough for any realistic Xenos draw.
const VERTEX_BUFFER_MAX_BYTES: u64 = 16 * 1024 * 1024;
pub struct XenosPipeline {
pipeline: wgpu::RenderPipeline,
draw_ctx_buffer: wgpu::Buffer,
constants_buffer: wgpu::Buffer,
vs_ucode_buffer: wgpu::Buffer,
ps_ucode_buffer: wgpu::Buffer,
vertex_buffer: wgpu::Buffer,
bind_group: wgpu::BindGroup,
/// P5: swapped per-draw when a new cached texture becomes active.
tex_bind_group: wgpu::BindGroup,
/// Layout + sampler retained so `set_texture_view` can rebuild
/// `tex_bind_group` on the fly without re-reading the pipeline.
tex_bgl: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
/// Fallback 1×1 magenta texture — used when no guest texture has been
/// uploaded yet or when a draw references an unsupported format.
dummy_view: wgpu::TextureView,
/// P7 — retained pipeline layout + compiled-pipeline cache for
/// Xenos→WGSL translator output. Keyed on `(vs_blob_key, ps_blob_key)`
/// so every (vs, ps) pair gets compiled once and re-used for every
/// subsequent draw. Interpreter pipeline remains the fallback.
pipeline_layout: wgpu::PipelineLayout,
translated_cache: std::collections::HashMap<(u32, u32), wgpu::RenderPipeline>,
pub target_format: wgpu::TextureFormat,
}
impl XenosPipeline {
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
target_format: wgpu::TextureFormat,
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("xenos_interp.wgsl"),
source: wgpu::ShaderSource::Wgsl(XENOS_INTERP_WGSL.into()),
});
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("xenos bind group layout"),
entries: &[
// b0: draw_ctx (16 B uniform)
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: std::num::NonZeroU64::new(
std::mem::size_of::<DrawConstants>() as u64,
),
},
count: None,
},
// b1: XenosConstants read-only storage (~9.2 KB). Not uniform
// because the block contains packed `array<u32>` fields and
// WGSL's uniform address space would require 16-byte stride.
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: std::num::NonZeroU64::new(
XenosConstantsBlock::SIZE as u64,
),
},
count: None,
},
// b2: vs_ucode (read-only storage)
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// b3: ps_ucode (read-only storage)
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// b4: vertex_buffer (read-only storage)
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let tex_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("xenos tex bind group layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("xenos pipeline layout"),
bind_group_layouts: &[&bgl, &tex_bgl],
push_constant_ranges: &[],
});
// Buffer allocation. `queue.write_buffer` uses COPY_DST; all
// interpreter-facing buffers need it.
let initial_draw = DrawConstants {
draw_index: 0,
vertex_count: 3,
prim_kind: 4,
_pad: 0,
};
let draw_ctx_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("xenos draw ctx"),
contents: bytemuck::bytes_of(&initial_draw),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let constants_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("xenos constants"),
size: XenosConstantsBlock::SIZE as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let vs_ucode_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("xenos vs ucode"),
size: UCODE_BUFFER_MAX_DWORDS * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let ps_ucode_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("xenos ps ucode"),
size: UCODE_BUFFER_MAX_DWORDS * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("xenos vertex buffer"),
size: VERTEX_BUFFER_MAX_BYTES,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Dummy 1×1 magenta texture — placeholder until P5's texture cache
// lands. Every `interpret_texture_fetch` samples this for now so the
// interpreter can exercise textureSample paths without a real cache.
let dummy_tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("xenos dummy texture"),
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: &[],
});
// Magenta (255, 0, 255, 255) so a missing-texture read visibly stands
// out on-screen when the interpreter does sample it.
queue.write_texture(
wgpu::ImageCopyTexture {
texture: &dummy_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 dummy_view = dummy_tex.create_view(&wgpu::TextureViewDescriptor::default());
let dummy_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("xenos dummy sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let tex_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("xenos tex bind group"),
layout: &tex_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&dummy_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&dummy_sampler),
},
],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("xenos bind group"),
layout: &bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: draw_ctx_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: constants_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: vs_ucode_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: ps_ucode_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: vertex_buffer.as_entire_binding(),
},
],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("xenos_interp pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_main",
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_main",
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: target_format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::OVER,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
Self {
pipeline,
draw_ctx_buffer,
constants_buffer,
vs_ucode_buffer,
ps_ucode_buffer,
vertex_buffer,
bind_group,
tex_bind_group,
tex_bgl,
sampler: dummy_sampler,
dummy_view,
pipeline_layout: layout,
translated_cache: std::collections::HashMap::new(),
target_format,
}
}
/// P7 — does the translator cache already have a pipeline for this
/// (vs, ps) pair?
pub fn has_translated(&self, vs_blob_key: u32, ps_blob_key: u32) -> bool {
self.translated_cache
.contains_key(&(vs_blob_key, ps_blob_key))
}
/// P7 — fetch a cached translator pipeline. `None` if not yet built.
pub fn translated_pipeline(
&self,
vs_blob_key: u32,
ps_blob_key: u32,
) -> Option<&wgpu::RenderPipeline> {
self.translated_cache
.get(&(vs_blob_key, ps_blob_key))
}
/// P7 — compile a translator-produced WGSL module into a
/// `wgpu::RenderPipeline` and insert it into the cache keyed on
/// `(vs_blob_key, ps_blob_key)`. Returns `true` on success. Duplicate
/// inserts are no-ops. Emits `gpu.shader.compile_ok` on success.
pub fn insert_translated(
&mut self,
device: &wgpu::Device,
vs_blob_key: u32,
ps_blob_key: u32,
wgsl: &str,
) -> bool {
let key = (vs_blob_key, ps_blob_key);
if self.translated_cache.contains_key(&key) {
return true;
}
let shader = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("xenos translated module"),
source: wgpu::ShaderSource::Wgsl(wgsl.to_string().into()),
})
})) {
Ok(m) => m,
Err(_) => {
metrics::counter!("gpu.shader.compile_err", "stage" => "module")
.increment(1);
return false;
}
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("xenos translated pipeline"),
layout: Some(&self.pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_main",
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_main",
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: self.target_format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::OVER,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
self.translated_cache.insert(key, pipeline);
metrics::counter!("gpu.shader.compile_ok").increment(1);
true
}
/// Render one draw with the translator-produced pipeline instead of
/// the interpreter. Mirrors [`render_one`] except the bound pipeline
/// is swapped for `pipeline`.
pub fn render_one_with_pipeline(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target_view: &wgpu::TextureView,
req: DrawRequest,
pipeline: &wgpu::RenderPipeline,
) {
let cb = DrawConstants {
draw_index: req.draw_index,
vertex_count: req.vertex_count.max(3),
prim_kind: req.prim_kind,
_pad: 0,
};
queue.write_buffer(&self.draw_ctx_buffer, 0, bytemuck::bytes_of(&cb));
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("xenos translated draw"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_bind_group(1, &self.tex_bind_group, &[]);
let rounded = req.vertex_count.div_ceil(3) * 3;
pass.draw(0..rounded.max(3), 0..1);
}
/// Number of distinct translator pipelines cached. Surfaced to the HUD.
pub fn translated_pipeline_count(&self) -> usize {
self.translated_cache.len()
}
/// P5 — swap the active texture bound at `@group(1) @binding(0)`.
/// `view` is typically a wgpu texture view obtained from the
/// [`TextureCacheHost`]. Pass `None` to revert to the built-in dummy
/// magenta stub.
pub fn set_texture_view(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
let bound = view.unwrap_or(&self.dummy_view);
self.tex_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("xenos tex bind group (rebind)"),
layout: &self.tex_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(bound),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
}
/// Clear `target_view` to `color`, store.
pub fn clear(
&self,
encoder: &mut wgpu::CommandEncoder,
target_view: &wgpu::TextureView,
color: [f64; 4],
) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("xenos frontbuffer clear"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: color[0],
g: color[1],
b: color[2],
a: color[3],
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
let _ = &mut pass;
}
/// Upload shader microcode + constants once (before the batch of draws
/// that share them). Skips zero-length blobs.
pub fn upload_shader_and_constants(
&self,
queue: &wgpu::Queue,
vs_ucode: &[u32],
ps_ucode: &[u32],
constants: &XenosConstantsBlock,
) {
queue.write_buffer(&self.constants_buffer, 0, bytemuck::bytes_of(constants));
if !vs_ucode.is_empty() {
let bytes: &[u8] = bytemuck::cast_slice(vs_ucode);
let max = (UCODE_BUFFER_MAX_DWORDS * 4) as usize;
queue.write_buffer(&self.vs_ucode_buffer, 0, &bytes[..bytes.len().min(max)]);
}
if !ps_ucode.is_empty() {
let bytes: &[u8] = bytemuck::cast_slice(ps_ucode);
let max = (UCODE_BUFFER_MAX_DWORDS * 4) as usize;
queue.write_buffer(&self.ps_ucode_buffer, 0, &bytes[..bytes.len().min(max)]);
}
}
/// Upload vertex data (as raw big-endian dwords — the WGSL side will
/// bswap as needed during format unpacking).
pub fn upload_vertex_data(&self, queue: &wgpu::Queue, data: &[u32]) {
if data.is_empty() {
return;
}
let bytes: &[u8] = bytemuck::cast_slice(data);
let max = VERTEX_BUFFER_MAX_BYTES as usize;
queue.write_buffer(&self.vertex_buffer, 0, &bytes[..bytes.len().min(max)]);
}
/// Render one captured draw.
pub fn render_one(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target_view: &wgpu::TextureView,
req: DrawRequest,
) {
let cb = DrawConstants {
draw_index: req.draw_index,
vertex_count: req.vertex_count.max(3),
prim_kind: req.prim_kind,
_pad: 0,
};
queue.write_buffer(&self.draw_ctx_buffer, 0, bytemuck::bytes_of(&cb));
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("xenos draw"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_bind_group(1, &self.tex_bind_group, &[]);
let rounded = req.vertex_count.div_ceil(3) * 3;
pass.draw(0..rounded.max(3), 0..1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn draw_constants_layout_matches_wgsl_uniform() {
assert_eq!(std::mem::size_of::<DrawConstants>(), 16);
}
}