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:
20
crates/xenia-ui/Cargo.toml
Normal file
20
crates/xenia-ui/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "xenia-ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
xenia-hid = { workspace = true }
|
||||
xenia-kernel = { workspace = true }
|
||||
xenia-memory = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
winit = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
gilrs = { workspace = true }
|
||||
pollster = { workspace = true }
|
||||
crossbeam-utils = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
metrics = { workspace = true }
|
||||
xenia-gpu = { workspace = true }
|
||||
501
crates/xenia-ui/src/app.rs
Normal file
501
crates/xenia-ui/src/app.rs
Normal 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(())
|
||||
}
|
||||
167
crates/xenia-ui/src/bridge.rs
Normal file
167
crates/xenia-ui/src/bridge.rs
Normal 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
231
crates/xenia-ui/src/font.rs
Normal 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],
|
||||
];
|
||||
159
crates/xenia-ui/src/input.rs
Normal file
159
crates/xenia-ui/src/input.rs
Normal 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
|
||||
}
|
||||
}
|
||||
21
crates/xenia-ui/src/lib.rs
Normal file
21
crates/xenia-ui/src/lib.rs
Normal 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};
|
||||
1004
crates/xenia-ui/src/render.rs
Normal file
1004
crates/xenia-ui/src/render.rs
Normal file
File diff suppressed because it is too large
Load Diff
59
crates/xenia-ui/src/shaders/blit.wgsl
Normal file
59
crates/xenia-ui/src/shaders/blit.wgsl
Normal 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);
|
||||
}
|
||||
47
crates/xenia-ui/src/shaders/hud.wgsl
Normal file
47
crates/xenia-ui/src/shaders/hud.wgsl
Normal 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);
|
||||
}
|
||||
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)
|
||||
}
|
||||
643
crates/xenia-ui/src/xenos_pipeline.rs
Normal file
643
crates/xenia-ui/src/xenos_pipeline.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user