//! winit `ApplicationHandler` that drives the xenia-rs UI window. //! //! Threading model: //! //! ```text //! ┌─ main thread ────────────────────────────────────────────────┐ //! │ App: ApplicationHandler │ //! │ on Resumed → create Window (Arc) + RenderState │ //! │ on UserEvent(SwapEvent) → request redraw │ //! │ on WindowEvent::RedrawRequested: │ //! │ 1. poll gilrs → update AtomicCell │ //! │ 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, input: HostInput, render: Option, last_revision: u64, title: String, start: Instant, last_poll: Instant, /// Counter of UserEvents received (== VdSwap count). swap_events: u64, last_swap_info: Option, /// 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, title: String) -> anyhow::Result { 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, rs: &RenderState, ) -> Vec { let mut verts: Vec = 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, 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 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 = self .handles .shader_blobs .lock() .ok() .and_then(|g| g.get(&vs_key).cloned()) .unwrap_or_default(); let raw_ps: Vec = 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, handles: UiHandles, title: impl Into, ) -> 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(()) }