Add xenia-ui crate; switch analysis store to DuckDB

Workspace gains a new xenia-ui member that owns the winit/wgpu
window, the Xenos display pipeline (xenos_pipeline + render +
texture_cache_host), HUD font/blit shaders, and the input-bridge
plumbing the app uses to surface guest framebuffers and overlays.

Workspace dependencies grow accordingly: rusqlite is replaced with
duckdb (analysis pipeline now writes DuckDB stores), and tracing /
metrics / pprof / winit / wgpu / gilrs / pollster / crossbeam /
bytemuck are added at workspace level so xenia-ui and xenia-app
share versions. Cargo.lock regenerated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-01 16:26:48 +02:00
parent f166d061be
commit e2b8860e10
13 changed files with 7534 additions and 42 deletions

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

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