diff --git a/crates/xenia-app/src/main.rs b/crates/xenia-app/src/main.rs index 6f3009e..26b2071 100644 --- a/crates/xenia-app/src/main.rs +++ b/crates/xenia-app/src/main.rs @@ -966,8 +966,21 @@ fn cmd_exec_inner( let v = v.trim().to_ascii_lowercase(); v == "1" || v == "true" || v == "yes" }); - let force_inline = gpu_inline || env_inline || ui; - let force_thread = gpu_thread || env_thread; + // A.5 — opt-in threaded GPU under `--ui`. Off by default: `--ui` still + // forces the inline backend (the safe, milestone-verified path). When + // `XENIA_UI_GPU_THREAD=1` is set alongside `--ui`, the GPU command + // processing + per-swap UI publish move to the worker thread, freeing the + // emulation thread from the ~12 ms/frame inline PM4 drain. See + // `run_with_ui` (hook install) and `GpuSystem::run_ui_publish`. + let env_ui_thread = std::env::var("XENIA_UI_GPU_THREAD") + .ok() + .is_some_and(|v| { + let v = v.trim().to_ascii_lowercase(); + v == "1" || v == "true" || v == "yes" + }); + let ui_threaded = ui && env_ui_thread; + let force_inline = gpu_inline || env_inline || (ui && !ui_threaded); + let force_thread = gpu_thread || env_thread || ui_threaded; let use_threaded = if force_inline { false } else if force_thread { @@ -1768,8 +1781,17 @@ fn cmd_exec_inner( // `xenia_gpu::handle::GpuWorker::run` for the concurrency model. // M1.3's `spawn_noop_worker` is now superseded for the threaded // path; the no-op helper is retained for unit tests. - let gpu_thread_resources = if let Some(worker) = maybe_gpu_worker.take() { + let gpu_thread_resources = if let Some(mut worker) = maybe_gpu_worker.take() { info!("gpu: threaded backend — spawning worker thread"); + // A.5 threaded `--ui`: the UI replays real per-draw geometry, so the + // worker's `GpuSystem` needs frame capture on before it starts + // draining. (Inline `--ui` enables this inside `run_with_ui` via + // `as_inline_mut`; the threaded worker owns the system exclusively + // once spawned, so we flip it here first.) Harmless in headless + // threaded mode — `ui` is false there. + if ui { + worker.system.enable_frame_capture(); + } let join = xenia_gpu::spawn_gpu_worker(worker, mem_arc.clone()); Some((shutdown_arc.clone(), join)) } else { @@ -1791,17 +1813,14 @@ fn cmd_exec_inner( let result = if ui { run_with_ui( path, - // `run_with_ui` consumes `GuestMemory` by value today; M1.4 - // keeps that path on the inline backend until the UI worker - // is migrated to the Arc-shared model. Recover ownership via - // `Arc::try_unwrap` — succeeds because the GPU worker is not - // spawned in inline mode (`maybe_gpu_worker` is `None`). - std::sync::Arc::try_unwrap(mem_arc).unwrap_or_else(|_| { - panic!( - "M1.4: --ui + --gpu-thread cohabitation not yet wired; \ - choose one" - ) - }), + // A.5: `run_with_ui` now takes the shared `Arc` + // directly (previously it recovered sole ownership via + // `Arc::try_unwrap`, which panicked if a GPU worker held a + // clone). Both the CPU worker inside `run_with_ui` and the GPU + // worker (threaded `--ui`) borrow `&*mem_arc`; writes are `&self` + // post the M1.4(b) trait flip, so concurrent disjoint-range + // access is sound. + mem_arc.clone(), kernel, debugger, thunk_map, @@ -4878,7 +4897,7 @@ fn dump_thread_diagnostic( #[instrument(skip_all, fields(title))] fn run_with_ui( title: &str, - mut mem: xenia_memory::GuestMemory, + mem: std::sync::Arc, mut kernel: xenia_kernel::KernelState, mut debugger: xenia_debugger::Debugger, thunk_map: HashMap, @@ -4895,10 +4914,68 @@ fn run_with_ui( .build() .map_err(|e| anyhow::anyhow!("winit event loop build failed: {e}"))?; let (ui_handles, kernel_bridge) = xenia_ui::build(event_loop.create_proxy()); + + // A.5 threaded `--ui`: if the GPU runs on the worker thread, the worker + // owns the `GpuSystem`, so the per-swap UI publish must run there. Build + // the publish closures (mapped into `xenia-gpu` types) from the live + // bridge and install them on the worker via the command channel. No-op on + // the inline backend — `install_ui_hooks` only sends under + // `GpuBackend::Threaded`, and inline `--ui` keeps publishing directly from + // `vd_swap`. Built from `&kernel_bridge` before it moves into `kernel.ui`. + { + use std::sync::atomic::Ordering; + let instr = std::sync::Arc::clone(&ui_handles.instructions_counter); + let post = std::sync::Arc::clone(&kernel_bridge.post_swap); + let hooks = xenia_gpu::UiPublishHooks { + publish_assets: std::sync::Arc::clone(&kernel_bridge.publish_xenos_assets), + publish_texture: std::sync::Arc::clone(&kernel_bridge.publish_texture), + publish_geometry: std::sync::Arc::clone(&kernel_bridge.publish_geometry), + publish_frontbuffer: std::sync::Arc::clone( + &kernel_bridge.publish_frontbuffer, + ), + notify_swap: std::sync::Arc::new( + move |w: xenia_gpu::WorkerSwapInfo, + m: &dyn xenia_memory::MemoryAccess| { + let info = xenia_kernel::SwapInfo { + frontbuffer_addr: w.frontbuffer_addr, + width: w.width, + height: w.height, + // HUD-only; the worker can't see the guest fetch + // pointers `vd_swap` reads these from. + texture_format: 0, + color_space: 0, + frame_index: w.frame_index, + draws_total: w.draws_total, + packets_total: w.packets_total, + last_draw_prim: w.last_draw_prim, + last_draw_vertex_count: w.last_draw_vertex_count, + indirect_buffer_jumps: w.indirect_buffer_jumps, + wait_reg_mem_blocks: w.wait_reg_mem_blocks, + instructions_total: instr.load(Ordering::Relaxed), + vs_blob_key: w.vs_blob_key, + ps_blob_key: w.ps_blob_key, + resolves_total: w.resolves_total, + resolves_copied_total: w.resolves_copied_total, + resolves_skipped_total: w.resolves_skipped_total, + unique_render_targets: w.unique_render_targets, + // HUD-only; kernel interrupt bookkeeping isn't visible + // to the worker on this path. + interrupts_delivered: 0, + interrupts_dropped: 0, + }; + (post)(info, m); + }, + ), + }; + kernel.gpu.install_ui_hooks(hooks); + } + kernel.ui = Some(kernel_bridge); // iterate-3O: enable per-draw geometry capture so the UI can replay real // guest draws. Only on the `--ui` path; headless `check` never gets here, - // so the deterministic core/golden stays untouched. + // so the deterministic core/golden stays untouched. Threaded `--ui` + // enables capture on the worker's `GpuSystem` before spawn (see + // `cmd_exec_inner`); this covers the inline backend. if let Some(gpu) = kernel.gpu.as_inline_mut() { gpu.enable_frame_capture(); } @@ -4911,12 +4988,15 @@ fn run_with_ui( .to_string(); let worker_span = tracing::info_span!("cpu_worker"); + // A.5: the CPU worker borrows the shared `Arc` (`&*mem_w`) + // instead of owning the buffer, so a threaded GPU worker can share it. + let mem_w = std::sync::Arc::clone(&mem); let worker = std::thread::Builder::new() .name("xenia-cpu".into()) - .spawn(move || -> Result<(ExecStats, xenia_memory::GuestMemory, xenia_kernel::KernelState, xenia_debugger::Debugger, Option)> { + .spawn(move || -> Result<(ExecStats, xenia_kernel::KernelState, xenia_debugger::Debugger, Option)> { let _guard = worker_span.enter(); let stats = run_execution( - &mut mem, + &mem_w, &mut kernel, &mut debugger, &thunk_map, @@ -4930,7 +5010,7 @@ fn run_with_ui( if let Some(ref mut db) = db_writer { db.finalize_traces()?; } - Ok((stats, mem, kernel, debugger, db_writer)) + Ok((stats, kernel, debugger, db_writer)) }) .map_err(|e| anyhow::anyhow!("spawn CPU worker: {e}"))?; @@ -4938,7 +5018,7 @@ fn run_with_ui( // flips the shutdown flag itself (e.g. after max_instructions). xenia_ui::run(event_loop, ui_handles, &title_owned)?; - let (stats, mem, kernel, debugger, db_writer) = match worker.join() { + let (stats, kernel, debugger, db_writer) = match worker.join() { Ok(res) => res?, Err(_) => { return Err(anyhow::anyhow!("CPU worker thread panicked")); @@ -4946,7 +5026,7 @@ fn run_with_ui( }; print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet); - dump_thread_diagnostic(&kernel, &mem, quiet); + dump_thread_diagnostic(&kernel, &*mem, quiet); info!( wall_ms = started.elapsed().as_millis() as u64, instructions = stats.instruction_count, diff --git a/crates/xenia-gpu/src/handle.rs b/crates/xenia-gpu/src/handle.rs index 467b156..f9fdd58 100644 --- a/crates/xenia-gpu/src/handle.rs +++ b/crates/xenia-gpu/src/handle.rs @@ -24,6 +24,7 @@ //! `into_handle` on the live `KernelState.gpu` — the constructor exists for //! the unit test below and for the synthetic-test path. +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread::{self, JoinHandle}; @@ -33,7 +34,10 @@ use crossbeam_channel::{Receiver, Sender, bounded, unbounded}; use xenia_memory::GuestMemory; +use crate::draw_capture::DrawCapture; use crate::gpu_system::{ExecOutcome, GpuMmio, GpuStats, GpuSystem, PendingInterrupt}; +use crate::texture_cache::TextureKey; +use crate::xenos_constants::XenosConstantsBlock; /// Reply channel for a [`GpuCommand::DrainFence`]. Single-shot /// `bounded(1)` — the GPU sends `()` once it's drained the ring up to the @@ -41,6 +45,57 @@ use crate::gpu_system::{ExecOutcome, GpuMmio, GpuStats, GpuSystem, PendingInterr /// is the first user of this; step 1 only validates the type fits. pub type DrainReply = crossbeam_channel::Sender<()>; +/// GPU-derived swap metadata the worker hands to the UI `notify_swap` hook +/// under threaded `--ui` (A.5). The app-side glue maps this into the kernel's +/// `SwapInfo`, filling the two non-GPU fields itself (`instructions_total` +/// from the shared instruction counter; the interrupt counts are HUD-cosmetic +/// and passed as 0 on this path — the worker has no view of kernel interrupt +/// bookkeeping). `texture_format`/`color_space` likewise aren't visible to the +/// worker (they come from guest pointers in `vd_swap`'s args) and are HUD-only. +#[derive(Debug, Clone, Copy, Default)] +pub struct WorkerSwapInfo { + pub frontbuffer_addr: u32, + pub width: u32, + pub height: u32, + pub frame_index: u64, + pub draws_total: u64, + pub packets_total: u64, + pub last_draw_prim: u32, + pub last_draw_vertex_count: u32, + pub indirect_buffer_jumps: u64, + pub wait_reg_mem_blocks: u64, + pub vs_blob_key: u32, + pub ps_blob_key: u32, + pub resolves_total: u64, + pub resolves_copied_total: u64, + pub resolves_skipped_total: u64, + pub unique_render_targets: u64, +} + +/// UI publish closures the GPU worker calls when it consumes a swap under +/// threaded `--ui` (A.5). These mirror the kernel `UiBridge`'s publish +/// closures but are expressed purely in `xenia-gpu` types so the worker +/// (which lives in this crate and can't depend on `xenia-kernel`) can hold +/// them. Built app-side from the live `UiBridge` and installed on the worker +/// via [`GpuCommand::InstallUiHooks`]. All closures are `Send + Sync` and are +/// invoked from the GPU worker thread, never the emulation thread. +#[derive(Clone)] +pub struct UiPublishHooks { + pub publish_assets: + Arc>>, XenosConstantsBlock) + Send + Sync>, + pub publish_texture: Arc)>) + Send + Sync>, + pub publish_geometry: Arc) + Send + Sync>, + pub publish_frontbuffer: Arc) + Send + Sync>, + pub notify_swap: + Arc, +} + +impl std::fmt::Debug for UiPublishHooks { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("UiPublishHooks { .. }") + } +} + /// Control-plane RPC the CPU thread sends to the GPU thread. Data-plane /// signals (WPTR/RPTR/INT_STATUS) ride atomic mailboxes instead — see /// [`GpuMmio`]. Channels are for events that need ordered delivery and @@ -80,6 +135,12 @@ pub enum GpuCommand { width: u32, height: u32, }, + /// A.5 threaded `--ui`: install the UI publish closures on the worker so + /// it can run the per-swap capture/publish itself (blobs, constants, + /// texture, geometry, frontbuffer detile, notify) off the emulation + /// thread. Sent once by `run_with_ui` after the UI bridge is built. + /// Boxed to keep [`GpuCommand`] small (the hooks carry five `Arc`s). + InstallUiHooks(Box), /// Tear-down signal. The worker drains any in-flight reply channels, /// drops its `GpuSystem`, and the host thread joins. Shutdown, @@ -189,6 +250,15 @@ pub struct GpuWorker { /// Shutdown flag. Set by `shutdown_and_join_with_timeout`; the worker /// loop checks `Acquire` each iteration. pub shutdown: Arc, + /// A.5: UI publish closures, installed via [`GpuCommand::InstallUiHooks`]. + /// `None` in headless / inline modes → the worker does zero UI publish + /// (byte-identical to the pre-A.5 headless-threaded path). + pub ui_hooks: Option, + /// A.5: last `swaps_seen` value the worker ran the UI publish for. The + /// publish is level-triggered on this counter advancing, so multiple + /// swaps consumed in one iteration collapse to a single publish of the + /// latest state. + pub last_published_swaps: u64, } impl GpuSystem { @@ -220,6 +290,8 @@ impl GpuSystem { int_tx, digest: digest.clone(), shutdown: shutdown.clone(), + ui_hooks: None, + last_published_swaps: 0, }; let handle = GpuHandle { cmd_tx, @@ -427,6 +499,16 @@ impl GpuBackend { } } + /// A.5 threaded `--ui`: hand the worker the UI publish closures so it can + /// run the per-swap capture/publish on its own thread. No-op on the inline + /// backend (that path publishes directly from `vd_swap` on the emulation + /// thread and never needs the hooks). + pub fn install_ui_hooks(&self, hooks: UiPublishHooks) { + if let GpuBackend::Threaded(h) = self { + let _ = h.send_cmd(GpuCommand::InstallUiHooks(Box::new(hooks))); + } + } + /// Bump `swaps_seen` + record `last_swap` + push a swap interrupt. /// Inline calls directly. Threaded sends `NotifyXeSwap` over the /// command channel — fire-and-forget; the worker handles it on its @@ -616,6 +698,9 @@ impl GpuWorker { self.system .notify_xe_swap(frontbuffer_phys, width, height); } + GpuCommand::InstallUiHooks(hooks) => { + self.ui_hooks = Some(*hooks); + } GpuCommand::Shutdown => { self.shutdown.store(true, Ordering::Release); return; @@ -662,6 +747,21 @@ impl GpuWorker { *g = snap; } } + // (5c) A.5 threaded `--ui`: if a swap was consumed this iteration + // (either an in-stream PM4_XE_SWAP during the drain above or a + // `NotifyXeSwap` safety-net command), run the per-swap UI + // publish on THIS worker thread — shader blobs, constants, + // texture, geometry, frontbuffer detile, and `notify_swap`. + // Level-triggered on `swaps_seen` so it fires exactly once per + // new frame. Inline / headless modes leave `ui_hooks == None` + // and skip this entirely. + if let Some(hooks) = self.ui_hooks.as_ref() { + let cur = self.system.stats.swaps_seen; + if cur > self.last_published_swaps { + self.last_published_swaps = cur; + self.system.run_ui_publish(&memory, hooks); + } + } // (6) M1.7 parker — `park_timeout` replaces the polling // sleep. The standard parker idiom defends against the // producer-races-park lost-wakeup: @@ -794,6 +894,150 @@ pub fn shutdown_and_join_with_timeout( } } +impl GpuSystem { + /// A.5 worker-side UI publish. Mirrors the inline `vd_swap` publish block + /// (`crates/xenia-kernel/src/exports.rs`) but runs on the GPU worker + /// thread against this worker's own `GpuSystem` state + the shared guest + /// memory. Called once per consumed swap (level-triggered on + /// `stats.swaps_seen`). Takes the concrete `&GuestMemory` because + /// `max_page_version`/`read_bulk` are inherent methods, not on the + /// `MemoryAccess` trait. + /// + /// Field-for-field parity with the inline path is intentional: the same + /// publish-on-change blob gating (A.2), the same slot-0 texture fallback, + /// the same bulk frontbuffer detile (A.1). The only differences are the + /// two HUD-only fields the worker can't see (see [`WorkerSwapInfo`]). + pub fn run_ui_publish(&mut self, mem: &GuestMemory, hooks: &UiPublishHooks) { + use crate::gpu_system::{CONST_BASE_FETCH, SwapNotification}; + + // Source of truth for this frame is whatever the executor recorded + // from the in-stream PM4_XE_SWAP (or the NotifyXeSwap safety net). + let swap = self.last_swap.unwrap_or(SwapNotification { + frame_index: self.swap_counter, + frontbuffer_phys: 0, + width: 0, + height: 0, + }); + + // Shader blobs: rebuild + clone only on change; constants always. + let blobs: Option>> = + if self.shader_blobs_version != self.last_published_blobs_version { + self.last_published_blobs_version = self.shader_blobs_version; + Some( + self.shader_blobs + .iter() + .map(|(k, b)| (*k, b.dwords.clone())) + .collect(), + ) + } else { + None + }; + let constants = XenosConstantsBlock::snapshot(&self.register_file); + (hooks.publish_assets)(blobs, constants); + + // Primary texture: prefer the last draw's sampled texture; else probe + // fetch-constant slot 0 directly (flat-shader frames). + let published = self + .last_draw_textures + .first() + .map(|(_slot, k, _v, b)| (*k, b.clone())) + .or_else(|| { + const TEX_SLOT: u32 = 0; + let mut fetch6 = [0u32; 6]; + for (i, slot) in fetch6.iter_mut().enumerate() { + *slot = self + .register_file + .read(CONST_BASE_FETCH + TEX_SLOT * 6 + i as u32); + } + let key = crate::texture_cache::decode_fetch_constant(fetch6)?; + let bi = key.format.block_info(); + let span_bytes = (key.pitch_texels as u32) + * (key.height as u32) + * (bi.bytes_per_block as u32) + / (bi.block_w as u32); + let version = mem.max_page_version(key.base_address, span_bytes.max(4)); + match self.texture_cache.ensure_cached(key, version, mem) { + Ok(entry) => Some((entry.key, entry.bytes.clone())), + Err(_) => None, + } + }); + (hooks.publish_texture)(published); + + // Geometry: drain this frame's captured per-draw geometry. + if let Some(caps) = self.frame_captures.as_mut() { + let drained = std::mem::take(caps); + (hooks.publish_geometry)(drained); + } + + // Frontbuffer: bulk read the tiled k_8_8_8_8 image and detile (A.1). + if swap.frontbuffer_phys != 0 && swap.width > 0 && swap.height > 0 { + let pitch_aligned = + crate::tiled_address::align_pitch_to_macro_tile(swap.width); + let total_tiled_bytes = (pitch_aligned * swap.height * 4) as usize; + let fb_backing = crate::physical_to_backing(swap.frontbuffer_phys); + let ok = (fb_backing as u64) + .checked_add(total_tiled_bytes as u64) + .is_some_and(|end| end <= 0x1_0000_0000); + if ok { + let mut tiled = vec![0u8; total_tiled_bytes]; + mem.read_bulk(fb_backing, &mut tiled); + let mut linear = vec![0u8; (swap.width * swap.height * 4) as usize]; + if crate::tiled_address::detile_2d( + &tiled, + &mut linear, + swap.width, + swap.height, + pitch_aligned, + 4, + ) + .is_ok() + { + (hooks.publish_frontbuffer)(swap.width, swap.height, linear); + } + } + } + + // Notify: assemble the GPU-derived swap metadata for the UI redraw. + let (last_draw_prim, last_draw_vertex_count) = match self.last_draw { + Some(ds) => { + let code = match ds.primitive { + crate::draw_state::PrimitiveType::None => 0, + crate::draw_state::PrimitiveType::PointList => 1, + crate::draw_state::PrimitiveType::LineList => 2, + crate::draw_state::PrimitiveType::LineStrip => 3, + crate::draw_state::PrimitiveType::TriangleList => 4, + crate::draw_state::PrimitiveType::TriangleFan => 5, + crate::draw_state::PrimitiveType::TriangleStrip => 6, + crate::draw_state::PrimitiveType::RectangleList => 8, + crate::draw_state::PrimitiveType::QuadList => 13, + crate::draw_state::PrimitiveType::Unknown(x) => x as u32, + }; + (code, ds.vertex_count) + } + None => (0, 0), + }; + let wsi = WorkerSwapInfo { + frontbuffer_addr: swap.frontbuffer_phys, + width: swap.width, + height: swap.height, + frame_index: swap.frame_index, + draws_total: self.stats.draws_seen, + packets_total: self.stats.packets_executed, + last_draw_prim, + last_draw_vertex_count, + indirect_buffer_jumps: self.stats.indirect_buffer_jumps, + wait_reg_mem_blocks: self.stats.wait_reg_mem_blocks, + vs_blob_key: self.active_vs_key.unwrap_or(0), + ps_blob_key: self.active_ps_key.unwrap_or(0), + resolves_total: self.stats.resolves_total, + resolves_copied_total: self.stats.resolves_copied_total, + resolves_skipped_total: self.stats.resolves_skipped_total, + unique_render_targets: self.stats.unique_render_targets, + }; + (hooks.notify_swap)(wsi, mem); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/xenia-gpu/src/lib.rs b/crates/xenia-gpu/src/lib.rs index 156dc55..aeee327 100644 --- a/crates/xenia-gpu/src/lib.rs +++ b/crates/xenia-gpu/src/lib.rs @@ -40,7 +40,8 @@ pub use gpu_system::{ }; pub use handle::{ DrainReply, GpuBackend, GpuCommand, GpuDigestSnapshot, GpuHandle, GpuWorker, - shutdown_and_join_with_timeout, spawn_gpu_worker, spawn_noop_worker, + UiPublishHooks, WorkerSwapInfo, shutdown_and_join_with_timeout, spawn_gpu_worker, + spawn_noop_worker, }; pub use mmio_region::build_region as build_mmio_region; pub use pm4::{ diff --git a/crates/xenia-kernel/src/exports.rs b/crates/xenia-kernel/src/exports.rs index dbd92e1..a51cbb5 100644 --- a/crates/xenia-kernel/src/exports.rs +++ b/crates/xenia-kernel/src/exports.rs @@ -3136,6 +3136,20 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // comment above). The drain below consumes only the packets the game has // legitimately advanced the write-pointer over. + // A.5 threaded `--ui`: when the GPU runs on the worker thread AND a UI is + // attached, vd_swap must not block-drain or publish on the emulation + // thread. The worker drains the ring continuously and runs the entire + // per-swap UI publish itself (see `GpuSystem::run_ui_publish`) when it + // consumes the in-stream PM4_XE_SWAP. So here we've only filled the + // reserved ring slot (above) and return immediately — this is the A.5 + // decoupling that lifts the ~12 ms/frame GPU work off the CPU thread. + // Inline `--ui` (the default) and headless-threaded are unaffected: + // `as_inline()` is `Some` for inline, and `state.ui` is `None` headless. + if state.ui.is_some() && state.gpu.as_inline().is_none() { + ctx.gpr[3] = 0; + return; + } + // Drain the ring up to whatever the game has actually submitted; any // in-stream `PM4_INTERRUPT` / draw packets execute in order. The // reserved-slot PM4_XE_SWAP is consumed by the GPU only once the game