Files
xenia-rs/crates/xenia-ui/src/app.rs
MechaCat02 e2b8860e10 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>
2026-05-01 16:26:48 +02:00

502 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(())
}