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>
502 lines
20 KiB
Rust
502 lines
20 KiB
Rust
//! 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(())
|
||
}
|