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:
4476
Cargo.lock
generated
4476
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
21
Cargo.toml
@@ -12,6 +12,7 @@ members = [
|
|||||||
"crates/xenia-hid",
|
"crates/xenia-hid",
|
||||||
"crates/xenia-debugger",
|
"crates/xenia-debugger",
|
||||||
"crates/xenia-analysis",
|
"crates/xenia-analysis",
|
||||||
|
"crates/xenia-ui",
|
||||||
"crates/xenia-app",
|
"crates/xenia-app",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -33,10 +34,17 @@ xenia-apu = { path = "crates/xenia-apu" }
|
|||||||
xenia-hid = { path = "crates/xenia-hid" }
|
xenia-hid = { path = "crates/xenia-hid" }
|
||||||
xenia-debugger = { path = "crates/xenia-debugger" }
|
xenia-debugger = { path = "crates/xenia-debugger" }
|
||||||
xenia-analysis = { path = "crates/xenia-analysis" }
|
xenia-analysis = { path = "crates/xenia-analysis" }
|
||||||
|
xenia-ui = { path = "crates/xenia-ui" }
|
||||||
|
|
||||||
# External dependencies
|
# External dependencies
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "registry"] }
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
tracing-chrome = "0.7"
|
||||||
|
tracing-error = "0.2"
|
||||||
|
metrics = "0.24"
|
||||||
|
metrics-util = "0.19"
|
||||||
|
pprof = { version = "0.14", features = ["flamegraph", "protobuf-codec"] }
|
||||||
bitflags = "2"
|
bitflags = "2"
|
||||||
byteorder = "1"
|
byteorder = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
@@ -44,4 +52,13 @@ anyhow = "1"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
aes = "0.8"
|
aes = "0.8"
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
duckdb = { version = "1", features = ["bundled"] }
|
||||||
|
|
||||||
|
# UI / rendering / input (used by xenia-ui and xenia-app with --ui)
|
||||||
|
winit = "0.30"
|
||||||
|
wgpu = "22"
|
||||||
|
gilrs = "0.11"
|
||||||
|
pollster = "0.3"
|
||||||
|
crossbeam-utils = "0.8"
|
||||||
|
crossbeam-channel = "0.5"
|
||||||
|
bytemuck = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
20
crates/xenia-ui/Cargo.toml
Normal file
20
crates/xenia-ui/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "xenia-ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
xenia-hid = { workspace = true }
|
||||||
|
xenia-kernel = { workspace = true }
|
||||||
|
xenia-memory = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
winit = { workspace = true }
|
||||||
|
wgpu = { workspace = true }
|
||||||
|
gilrs = { workspace = true }
|
||||||
|
pollster = { workspace = true }
|
||||||
|
crossbeam-utils = { workspace = true }
|
||||||
|
bytemuck = { workspace = true }
|
||||||
|
metrics = { workspace = true }
|
||||||
|
xenia-gpu = { workspace = true }
|
||||||
501
crates/xenia-ui/src/app.rs
Normal file
501
crates/xenia-ui/src/app.rs
Normal 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(())
|
||||||
|
}
|
||||||
167
crates/xenia-ui/src/bridge.rs
Normal file
167
crates/xenia-ui/src/bridge.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
//! UI/kernel bridge construction.
|
||||||
|
//!
|
||||||
|
//! The UI side owns:
|
||||||
|
//! - a gamepad snapshot cell (writer) + connected flag (writer),
|
||||||
|
//! - a shared staging buffer that the CPU thread fills with the guest's
|
||||||
|
//! frontbuffer bytes on each `VdSwap`,
|
||||||
|
//! - an `EventLoopProxy<SwapEvent>` to wake winit with a redraw request,
|
||||||
|
//! - a shutdown atomic the UI flips on window close.
|
||||||
|
//!
|
||||||
|
//! The kernel side gets a [`xenia_kernel::UiBridge`] made from closures over
|
||||||
|
//! the same shared state, so it can read gamepad + post swaps from the CPU
|
||||||
|
//! thread without knowing about winit.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crossbeam_utils::atomic::AtomicCell;
|
||||||
|
use winit::event_loop::EventLoopProxy;
|
||||||
|
use xenia_gpu::texture_cache::TextureKey;
|
||||||
|
use xenia_gpu::xenos_constants::XenosConstantsBlock;
|
||||||
|
use xenia_hid::GamepadState;
|
||||||
|
use xenia_kernel::{SwapInfo, UiBridge};
|
||||||
|
use xenia_memory::MemoryAccess;
|
||||||
|
|
||||||
|
/// Snapshot of the guest frontbuffer, copied out of guest memory on each
|
||||||
|
/// `VdSwap` so the UI thread can upload it without touching the CPU thread's
|
||||||
|
/// [`xenia_memory::GuestMemory`].
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct FrontbufferSnapshot {
|
||||||
|
/// Width of the most-recently-swapped frontbuffer, in pixels.
|
||||||
|
pub width: u32,
|
||||||
|
/// Height in pixels.
|
||||||
|
pub height: u32,
|
||||||
|
/// Tightly-packed RGBA8 bytes (`width * height * 4`). Decoded from the
|
||||||
|
/// raw guest bytes by the kernel-side closure (see [`build`]).
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
/// Monotonic counter so the UI can tell new frames apart from stale
|
||||||
|
/// re-reads.
|
||||||
|
pub revision: u64,
|
||||||
|
/// The most recent swap metadata. Kept alongside the bytes for HUD use.
|
||||||
|
pub info: Option<SwapInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The UI-side half of the bridge. Installed into the [`App`](crate::app)
|
||||||
|
/// that runs on the main thread.
|
||||||
|
pub struct UiHandles {
|
||||||
|
pub gamepad: Arc<AtomicCell<GamepadState>>,
|
||||||
|
pub gamepad_connected: Arc<AtomicBool>,
|
||||||
|
pub shutdown: Arc<AtomicBool>,
|
||||||
|
pub frontbuffer: Arc<Mutex<FrontbufferSnapshot>>,
|
||||||
|
/// Live-updated summed CPU instruction counter. The `xenia-app` run
|
||||||
|
/// loop stores `cycle_count` into this every ~10k instructions so the
|
||||||
|
/// HUD keeps animating even before the first `VdSwap` fires. Authoritative
|
||||||
|
/// counter comes from scheduler HW-thread contexts; this atomic is just
|
||||||
|
/// a cross-thread cache.
|
||||||
|
pub instructions_counter: Arc<AtomicU64>,
|
||||||
|
/// P3b assets: cloned by `vd_swap` via `UiBridge::publish_assets`.
|
||||||
|
/// The UI redraw path reads them (mutex-guarded) alongside the
|
||||||
|
/// corresponding `SwapInfo` and passes them to the Xenos pipeline.
|
||||||
|
pub shader_blobs: Arc<Mutex<HashMap<u32, Vec<u32>>>>,
|
||||||
|
pub xenos_constants: Arc<Mutex<XenosConstantsBlock>>,
|
||||||
|
/// P5 primary texture — `None` means "no texture this frame, use
|
||||||
|
/// the magenta stub"; `Some((key, bytes))` means the kernel decoded
|
||||||
|
/// fetch-constant slot 0 into linear bytes that the UI should
|
||||||
|
/// upload into the host cache and bind at `@group(1) @binding(0)`.
|
||||||
|
pub primary_texture: Arc<Mutex<Option<(TextureKey, Vec<u8>)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swap event posted by the CPU-side `VdSwap` handler via
|
||||||
|
/// [`EventLoopProxy::send_event`] after the frontbuffer bytes have been
|
||||||
|
/// copied into [`UiHandles::frontbuffer`].
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct SwapEvent(pub SwapInfo);
|
||||||
|
|
||||||
|
/// Build a paired ([`UiHandles`], [`UiBridge`]).
|
||||||
|
///
|
||||||
|
/// The proxy is the winit user-event injector for a freshly-constructed
|
||||||
|
/// `EventLoop<SwapEvent>`; `xenia-app` owns the event loop and keeps the
|
||||||
|
/// `UiHandles` alongside it.
|
||||||
|
pub fn build(proxy: EventLoopProxy<SwapEvent>) -> (UiHandles, UiBridge) {
|
||||||
|
let gamepad = Arc::new(AtomicCell::new(GamepadState::default()));
|
||||||
|
let gamepad_connected = Arc::new(AtomicBool::new(false));
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let frontbuffer = Arc::new(Mutex::new(FrontbufferSnapshot::default()));
|
||||||
|
let instructions_counter = Arc::new(AtomicU64::new(0));
|
||||||
|
let shader_blobs = Arc::new(Mutex::new(HashMap::<u32, Vec<u32>>::new()));
|
||||||
|
let xenos_constants = Arc::new(Mutex::new(XenosConstantsBlock::default()));
|
||||||
|
let primary_texture: Arc<Mutex<Option<(TextureKey, Vec<u8>)>>> =
|
||||||
|
Arc::new(Mutex::new(None));
|
||||||
|
|
||||||
|
let kernel_bridge = UiBridge {
|
||||||
|
gamepad: {
|
||||||
|
let gp = Arc::clone(&gamepad);
|
||||||
|
Arc::new(move || gp.load())
|
||||||
|
},
|
||||||
|
// `post_swap` now only bumps the SwapInfo + revision; the RGBA
|
||||||
|
// bytes arrive via `publish_frontbuffer` earlier in the same
|
||||||
|
// `VdSwap` (P4 replaced the raw-scrape path with a CPU-side
|
||||||
|
// detile). Posting the swap event after the bytes land guarantees
|
||||||
|
// the UI's redraw path sees the latest pixels.
|
||||||
|
post_swap: {
|
||||||
|
let proxy = proxy.clone();
|
||||||
|
let fb = Arc::clone(&frontbuffer);
|
||||||
|
Arc::new(move |info: SwapInfo, _mem: &dyn MemoryAccess| {
|
||||||
|
if let Ok(mut lock) = fb.lock() {
|
||||||
|
lock.info = Some(info);
|
||||||
|
lock.revision = lock.revision.wrapping_add(1);
|
||||||
|
}
|
||||||
|
let _ = proxy.send_event(SwapEvent(info));
|
||||||
|
})
|
||||||
|
},
|
||||||
|
shutdown: Arc::clone(&shutdown),
|
||||||
|
gamepad_connected: Arc::clone(&gamepad_connected),
|
||||||
|
instructions_counter: Arc::clone(&instructions_counter),
|
||||||
|
publish_xenos_assets: {
|
||||||
|
let blobs = Arc::clone(&shader_blobs);
|
||||||
|
let consts = Arc::clone(&xenos_constants);
|
||||||
|
Arc::new(move |new_blobs, new_consts| {
|
||||||
|
if let Ok(mut g) = blobs.lock() {
|
||||||
|
*g = new_blobs;
|
||||||
|
}
|
||||||
|
if let Ok(mut g) = consts.lock() {
|
||||||
|
*g = new_consts;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
publish_frontbuffer: {
|
||||||
|
let fb = Arc::clone(&frontbuffer);
|
||||||
|
Arc::new(move |w, h, bytes| {
|
||||||
|
if let Ok(mut lock) = fb.lock() {
|
||||||
|
lock.width = w;
|
||||||
|
lock.height = h;
|
||||||
|
lock.rgba = bytes;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
publish_texture: {
|
||||||
|
let tex = Arc::clone(&primary_texture);
|
||||||
|
Arc::new(move |payload| {
|
||||||
|
if let Ok(mut lock) = tex.lock() {
|
||||||
|
*lock = payload;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let handles = UiHandles {
|
||||||
|
gamepad,
|
||||||
|
gamepad_connected,
|
||||||
|
shutdown,
|
||||||
|
frontbuffer,
|
||||||
|
instructions_counter,
|
||||||
|
shader_blobs,
|
||||||
|
xenos_constants,
|
||||||
|
primary_texture,
|
||||||
|
};
|
||||||
|
(handles, kernel_bridge)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl UiHandles {
|
||||||
|
pub fn request_shutdown(&self) {
|
||||||
|
self.shutdown.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
231
crates/xenia-ui/src/font.rs
Normal file
231
crates/xenia-ui/src/font.rs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
//! Minimal 8×8 bitmap font for the HUD.
|
||||||
|
//!
|
||||||
|
//! Only printable ASCII (0x20..0x7E) is populated. Each glyph is 8 bytes, one
|
||||||
|
//! byte per row, MSB = leftmost pixel. This is a bespoke hand-drawn font just
|
||||||
|
//! wide enough for status-line text ("Swap: 0x1234 Pad: 0x0040 …"); no
|
||||||
|
//! international glyphs, no kerning, no anti-aliasing.
|
||||||
|
//!
|
||||||
|
//! The font is rendered by uploading the 8×(8·N) atlas as an R8 texture and
|
||||||
|
//! sampling one cell per character quad from the HUD shader.
|
||||||
|
|
||||||
|
pub const CELL_W: u32 = 8;
|
||||||
|
pub const CELL_H: u32 = 8;
|
||||||
|
pub const FIRST: u8 = 0x20;
|
||||||
|
pub const LAST: u8 = 0x7E;
|
||||||
|
pub const GLYPH_COUNT: u32 = (LAST as u32) - (FIRST as u32) + 1;
|
||||||
|
|
||||||
|
/// Build the atlas as a linear R8 buffer, `CELL_W × (CELL_H * GLYPH_COUNT)`,
|
||||||
|
/// one glyph per 8-row strip stacked top-to-bottom.
|
||||||
|
pub fn build_atlas() -> Vec<u8> {
|
||||||
|
let mut out = vec![0u8; (CELL_W * CELL_H * GLYPH_COUNT) as usize];
|
||||||
|
for ch in FIRST..=LAST {
|
||||||
|
let rows = GLYPHS[(ch - FIRST) as usize];
|
||||||
|
for (row, byte) in rows.iter().enumerate() {
|
||||||
|
for col in 0..8 {
|
||||||
|
let bit = (byte >> (7 - col)) & 1;
|
||||||
|
if bit != 0 {
|
||||||
|
let idx = ((ch - FIRST) as u32 * CELL_H + row as u32) * CELL_W + col as u32;
|
||||||
|
out[idx as usize] = 0xFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 95 glyphs (0x20..0x7E). Each entry is 8 bytes of rows.
|
||||||
|
/// Hand-crafted for the HUD lines we actually emit. Most punctuation is
|
||||||
|
/// covered; unsupported chars fall through to the space glyph.
|
||||||
|
#[rustfmt::skip]
|
||||||
|
pub const GLYPHS: [[u8; 8]; 95] = [
|
||||||
|
// 0x20 ' '
|
||||||
|
[0,0,0,0,0,0,0,0],
|
||||||
|
// '!'
|
||||||
|
[0x18,0x18,0x18,0x18,0x00,0x00,0x18,0x00],
|
||||||
|
// '"'
|
||||||
|
[0x36,0x36,0x00,0x00,0x00,0x00,0x00,0x00],
|
||||||
|
// '#'
|
||||||
|
[0x6C,0xFE,0x6C,0x6C,0x6C,0xFE,0x6C,0x00],
|
||||||
|
// '$'
|
||||||
|
[0x18,0x3E,0x60,0x3C,0x06,0x7C,0x18,0x00],
|
||||||
|
// '%'
|
||||||
|
[0x00,0xC6,0xCC,0x18,0x30,0x66,0xC6,0x00],
|
||||||
|
// '&'
|
||||||
|
[0x38,0x6C,0x38,0x76,0xDC,0xCC,0x76,0x00],
|
||||||
|
// '\''
|
||||||
|
[0x18,0x18,0x00,0x00,0x00,0x00,0x00,0x00],
|
||||||
|
// '('
|
||||||
|
[0x0C,0x18,0x30,0x30,0x30,0x18,0x0C,0x00],
|
||||||
|
// ')'
|
||||||
|
[0x30,0x18,0x0C,0x0C,0x0C,0x18,0x30,0x00],
|
||||||
|
// '*'
|
||||||
|
[0x00,0x66,0x3C,0xFF,0x3C,0x66,0x00,0x00],
|
||||||
|
// '+'
|
||||||
|
[0x00,0x18,0x18,0x7E,0x18,0x18,0x00,0x00],
|
||||||
|
// ','
|
||||||
|
[0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x30],
|
||||||
|
// '-'
|
||||||
|
[0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00],
|
||||||
|
// '.'
|
||||||
|
[0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x00],
|
||||||
|
// '/'
|
||||||
|
[0x06,0x0C,0x18,0x30,0x60,0xC0,0x80,0x00],
|
||||||
|
// '0'
|
||||||
|
[0x7C,0xC6,0xCE,0xD6,0xE6,0xC6,0x7C,0x00],
|
||||||
|
// '1'
|
||||||
|
[0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00],
|
||||||
|
// '2'
|
||||||
|
[0x7C,0xC6,0x06,0x1C,0x30,0x66,0xFE,0x00],
|
||||||
|
// '3'
|
||||||
|
[0x7C,0xC6,0x06,0x3C,0x06,0xC6,0x7C,0x00],
|
||||||
|
// '4'
|
||||||
|
[0x1C,0x3C,0x6C,0xCC,0xFE,0x0C,0x1E,0x00],
|
||||||
|
// '5'
|
||||||
|
[0xFE,0xC0,0xC0,0xFC,0x06,0xC6,0x7C,0x00],
|
||||||
|
// '6'
|
||||||
|
[0x38,0x60,0xC0,0xFC,0xC6,0xC6,0x7C,0x00],
|
||||||
|
// '7'
|
||||||
|
[0xFE,0xC6,0x0C,0x18,0x30,0x30,0x30,0x00],
|
||||||
|
// '8'
|
||||||
|
[0x7C,0xC6,0xC6,0x7C,0xC6,0xC6,0x7C,0x00],
|
||||||
|
// '9'
|
||||||
|
[0x7C,0xC6,0xC6,0x7E,0x06,0x0C,0x78,0x00],
|
||||||
|
// ':'
|
||||||
|
[0x00,0x18,0x18,0x00,0x00,0x18,0x18,0x00],
|
||||||
|
// ';'
|
||||||
|
[0x00,0x18,0x18,0x00,0x00,0x18,0x18,0x30],
|
||||||
|
// '<'
|
||||||
|
[0x06,0x0C,0x18,0x30,0x18,0x0C,0x06,0x00],
|
||||||
|
// '='
|
||||||
|
[0x00,0x00,0x7E,0x00,0x7E,0x00,0x00,0x00],
|
||||||
|
// '>'
|
||||||
|
[0x60,0x30,0x18,0x0C,0x18,0x30,0x60,0x00],
|
||||||
|
// '?'
|
||||||
|
[0x7C,0xC6,0x0C,0x18,0x18,0x00,0x18,0x00],
|
||||||
|
// '@'
|
||||||
|
[0x7C,0xC6,0xDE,0xDE,0xDE,0xC0,0x78,0x00],
|
||||||
|
// 'A'
|
||||||
|
[0x38,0x6C,0xC6,0xC6,0xFE,0xC6,0xC6,0x00],
|
||||||
|
// 'B'
|
||||||
|
[0xFC,0x66,0x66,0x7C,0x66,0x66,0xFC,0x00],
|
||||||
|
// 'C'
|
||||||
|
[0x3C,0x66,0xC0,0xC0,0xC0,0x66,0x3C,0x00],
|
||||||
|
// 'D'
|
||||||
|
[0xF8,0x6C,0x66,0x66,0x66,0x6C,0xF8,0x00],
|
||||||
|
// 'E'
|
||||||
|
[0xFE,0x62,0x68,0x78,0x68,0x62,0xFE,0x00],
|
||||||
|
// 'F'
|
||||||
|
[0xFE,0x62,0x68,0x78,0x68,0x60,0xF0,0x00],
|
||||||
|
// 'G'
|
||||||
|
[0x3C,0x66,0xC0,0xC0,0xCE,0x66,0x3E,0x00],
|
||||||
|
// 'H'
|
||||||
|
[0xC6,0xC6,0xC6,0xFE,0xC6,0xC6,0xC6,0x00],
|
||||||
|
// 'I'
|
||||||
|
[0x3C,0x18,0x18,0x18,0x18,0x18,0x3C,0x00],
|
||||||
|
// 'J'
|
||||||
|
[0x1E,0x0C,0x0C,0x0C,0xCC,0xCC,0x78,0x00],
|
||||||
|
// 'K'
|
||||||
|
[0xE6,0x66,0x6C,0x78,0x6C,0x66,0xE6,0x00],
|
||||||
|
// 'L'
|
||||||
|
[0xF0,0x60,0x60,0x60,0x62,0x66,0xFE,0x00],
|
||||||
|
// 'M'
|
||||||
|
[0xC6,0xEE,0xFE,0xFE,0xD6,0xC6,0xC6,0x00],
|
||||||
|
// 'N'
|
||||||
|
[0xC6,0xE6,0xF6,0xDE,0xCE,0xC6,0xC6,0x00],
|
||||||
|
// 'O'
|
||||||
|
[0x38,0x6C,0xC6,0xC6,0xC6,0x6C,0x38,0x00],
|
||||||
|
// 'P'
|
||||||
|
[0xFC,0x66,0x66,0x7C,0x60,0x60,0xF0,0x00],
|
||||||
|
// 'Q'
|
||||||
|
[0x7C,0xC6,0xC6,0xC6,0xCE,0x7C,0x0E,0x00],
|
||||||
|
// 'R'
|
||||||
|
[0xFC,0x66,0x66,0x7C,0x6C,0x66,0xE6,0x00],
|
||||||
|
// 'S'
|
||||||
|
[0x7C,0xC6,0xE0,0x78,0x0E,0xC6,0x7C,0x00],
|
||||||
|
// 'T'
|
||||||
|
[0x7E,0x7E,0x5A,0x18,0x18,0x18,0x3C,0x00],
|
||||||
|
// 'U'
|
||||||
|
[0xC6,0xC6,0xC6,0xC6,0xC6,0xC6,0x7C,0x00],
|
||||||
|
// 'V'
|
||||||
|
[0xC6,0xC6,0xC6,0xC6,0xC6,0x6C,0x38,0x00],
|
||||||
|
// 'W'
|
||||||
|
[0xC6,0xC6,0xC6,0xD6,0xFE,0xEE,0xC6,0x00],
|
||||||
|
// 'X'
|
||||||
|
[0xC6,0xC6,0x6C,0x38,0x6C,0xC6,0xC6,0x00],
|
||||||
|
// 'Y'
|
||||||
|
[0x66,0x66,0x66,0x3C,0x18,0x18,0x3C,0x00],
|
||||||
|
// 'Z'
|
||||||
|
[0xFE,0xC6,0x8C,0x18,0x32,0x66,0xFE,0x00],
|
||||||
|
// '['
|
||||||
|
[0x3C,0x30,0x30,0x30,0x30,0x30,0x3C,0x00],
|
||||||
|
// '\\'
|
||||||
|
[0xC0,0x60,0x30,0x18,0x0C,0x06,0x02,0x00],
|
||||||
|
// ']'
|
||||||
|
[0x3C,0x0C,0x0C,0x0C,0x0C,0x0C,0x3C,0x00],
|
||||||
|
// '^'
|
||||||
|
[0x10,0x38,0x6C,0xC6,0x00,0x00,0x00,0x00],
|
||||||
|
// '_'
|
||||||
|
[0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF],
|
||||||
|
// '`'
|
||||||
|
[0x30,0x30,0x18,0x00,0x00,0x00,0x00,0x00],
|
||||||
|
// 'a'
|
||||||
|
[0x00,0x00,0x78,0x0C,0x7C,0xCC,0x76,0x00],
|
||||||
|
// 'b'
|
||||||
|
[0xE0,0x60,0x7C,0x66,0x66,0x66,0xDC,0x00],
|
||||||
|
// 'c'
|
||||||
|
[0x00,0x00,0x7C,0xC6,0xC0,0xC6,0x7C,0x00],
|
||||||
|
// 'd'
|
||||||
|
[0x1C,0x0C,0x7C,0xCC,0xCC,0xCC,0x76,0x00],
|
||||||
|
// 'e'
|
||||||
|
[0x00,0x00,0x7C,0xC6,0xFE,0xC0,0x7C,0x00],
|
||||||
|
// 'f'
|
||||||
|
[0x1C,0x36,0x30,0x78,0x30,0x30,0x78,0x00],
|
||||||
|
// 'g'
|
||||||
|
[0x00,0x00,0x76,0xCC,0xCC,0x7C,0x0C,0xF8],
|
||||||
|
// 'h'
|
||||||
|
[0xE0,0x60,0x6C,0x76,0x66,0x66,0xE6,0x00],
|
||||||
|
// 'i'
|
||||||
|
[0x18,0x00,0x38,0x18,0x18,0x18,0x3C,0x00],
|
||||||
|
// 'j'
|
||||||
|
[0x06,0x00,0x0E,0x06,0x06,0x66,0x66,0x3C],
|
||||||
|
// 'k'
|
||||||
|
[0xE0,0x60,0x66,0x6C,0x78,0x6C,0xE6,0x00],
|
||||||
|
// 'l'
|
||||||
|
[0x38,0x18,0x18,0x18,0x18,0x18,0x3C,0x00],
|
||||||
|
// 'm'
|
||||||
|
[0x00,0x00,0xEC,0xFE,0xD6,0xD6,0xD6,0x00],
|
||||||
|
// 'n'
|
||||||
|
[0x00,0x00,0xDC,0x66,0x66,0x66,0x66,0x00],
|
||||||
|
// 'o'
|
||||||
|
[0x00,0x00,0x7C,0xC6,0xC6,0xC6,0x7C,0x00],
|
||||||
|
// 'p'
|
||||||
|
[0x00,0x00,0xDC,0x66,0x66,0x7C,0x60,0xF0],
|
||||||
|
// 'q'
|
||||||
|
[0x00,0x00,0x76,0xCC,0xCC,0x7C,0x0C,0x1E],
|
||||||
|
// 'r'
|
||||||
|
[0x00,0x00,0xDC,0x76,0x60,0x60,0xF0,0x00],
|
||||||
|
// 's'
|
||||||
|
[0x00,0x00,0x7E,0xC0,0x7C,0x06,0xFC,0x00],
|
||||||
|
// 't'
|
||||||
|
[0x30,0x30,0x7C,0x30,0x30,0x36,0x1C,0x00],
|
||||||
|
// 'u'
|
||||||
|
[0x00,0x00,0xCC,0xCC,0xCC,0xCC,0x76,0x00],
|
||||||
|
// 'v'
|
||||||
|
[0x00,0x00,0xC6,0xC6,0xC6,0x6C,0x38,0x00],
|
||||||
|
// 'w'
|
||||||
|
[0x00,0x00,0xC6,0xD6,0xD6,0xFE,0x6C,0x00],
|
||||||
|
// 'x'
|
||||||
|
[0x00,0x00,0xC6,0x6C,0x38,0x6C,0xC6,0x00],
|
||||||
|
// 'y'
|
||||||
|
[0x00,0x00,0xC6,0xC6,0xC6,0x7E,0x06,0xFC],
|
||||||
|
// 'z'
|
||||||
|
[0x00,0x00,0xFE,0x4C,0x18,0x32,0xFE,0x00],
|
||||||
|
// '{'
|
||||||
|
[0x0E,0x18,0x18,0x70,0x18,0x18,0x0E,0x00],
|
||||||
|
// '|'
|
||||||
|
[0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x00],
|
||||||
|
// '}'
|
||||||
|
[0x70,0x18,0x18,0x0E,0x18,0x18,0x70,0x00],
|
||||||
|
// '~'
|
||||||
|
[0x76,0xDC,0x00,0x00,0x00,0x00,0x00,0x00],
|
||||||
|
];
|
||||||
159
crates/xenia-ui/src/input.rs
Normal file
159
crates/xenia-ui/src/input.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
//! gilrs → [`xenia_hid::GamepadState`] adapter.
|
||||||
|
|
||||||
|
use gilrs::{Axis, Button, EventType, Gilrs, GilrsBuilder};
|
||||||
|
use xenia_hid::{GamepadState, buttons};
|
||||||
|
|
||||||
|
/// Simulated-pad policy: if no physical controller is attached, advertise
|
||||||
|
/// a connected pad (with all-zero state) so games don't early-exit on
|
||||||
|
/// `XamInputGetState → ERROR_DEVICE_NOT_CONNECTED`. Set
|
||||||
|
/// `XENIA_FAKE_PAD=0` to disable and report the true no-controller status.
|
||||||
|
fn fake_pad_policy() -> bool {
|
||||||
|
match std::env::var("XENIA_FAKE_PAD").as_deref() {
|
||||||
|
Ok("0") | Ok("false") | Ok("off") => false,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HostInput {
|
||||||
|
ctx: Gilrs,
|
||||||
|
/// Currently-active gilrs pad id. `None` until the first Connected event.
|
||||||
|
active: Option<gilrs::GamepadId>,
|
||||||
|
/// `true` if the pad is really physical; `false` for the zeroed simulated
|
||||||
|
/// pad we advertise when no hardware is attached (see `fake_pad_policy`).
|
||||||
|
pub physical_attached: bool,
|
||||||
|
fake_allowed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostInput {
|
||||||
|
pub fn new() -> anyhow::Result<Self> {
|
||||||
|
let ctx = GilrsBuilder::new()
|
||||||
|
.add_included_mappings(true)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| anyhow::anyhow!("gilrs init failed: {e}"))?;
|
||||||
|
let active = ctx.gamepads().next().map(|(id, _)| id);
|
||||||
|
let physical_attached = active.is_some();
|
||||||
|
let fake_allowed = fake_pad_policy();
|
||||||
|
tracing::info!(
|
||||||
|
physical = physical_attached,
|
||||||
|
fake_allowed,
|
||||||
|
"input: init (pad simulation: {})",
|
||||||
|
if fake_allowed && !physical_attached {
|
||||||
|
"simulated (no physical pad)"
|
||||||
|
} else if physical_attached {
|
||||||
|
"physical"
|
||||||
|
} else {
|
||||||
|
"disabled"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Ok(Self {
|
||||||
|
ctx,
|
||||||
|
active,
|
||||||
|
physical_attached,
|
||||||
|
fake_allowed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff a gamepad is "available" for user index 0 — either the
|
||||||
|
/// actual host pad, or a simulated one when `XENIA_FAKE_PAD != 0`.
|
||||||
|
pub fn is_connected(&self) -> bool {
|
||||||
|
if self.physical_attached
|
||||||
|
&& self
|
||||||
|
.active
|
||||||
|
.and_then(|id| self.ctx.connected_gamepad(id))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.fake_allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pump gilrs events until empty. Updates the internal "active pad"
|
||||||
|
/// registration; pad state is sampled by [`snapshot`].
|
||||||
|
pub fn poll(&mut self) {
|
||||||
|
while let Some(event) = self.ctx.next_event() {
|
||||||
|
match event.event {
|
||||||
|
EventType::Connected => {
|
||||||
|
if self.active.is_none() {
|
||||||
|
self.active = Some(event.id);
|
||||||
|
self.physical_attached = true;
|
||||||
|
tracing::info!("gilrs: pad connected (id={:?})", event.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventType::Disconnected => {
|
||||||
|
if self.active == Some(event.id) {
|
||||||
|
self.active = None;
|
||||||
|
tracing::info!("gilrs: pad disconnected (id={:?})", event.id);
|
||||||
|
self.active = self.ctx.gamepads().next().map(|(id, _)| id);
|
||||||
|
self.physical_attached = self.active.is_some();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an Xbox-360-layout `GamepadState` from the active gilrs pad.
|
||||||
|
/// Returns `GamepadState::default()` (all-zero) when no pad is present.
|
||||||
|
pub fn snapshot(&self) -> GamepadState {
|
||||||
|
let Some(id) = self.active else {
|
||||||
|
return GamepadState::default();
|
||||||
|
};
|
||||||
|
let Some(pad) = self.ctx.connected_gamepad(id) else {
|
||||||
|
return GamepadState::default();
|
||||||
|
};
|
||||||
|
let mut state = GamepadState::default();
|
||||||
|
|
||||||
|
let pressed = |btn: Button| pad.is_pressed(btn);
|
||||||
|
let set = |state: &mut GamepadState, btn: Button, mask: u16| {
|
||||||
|
if pressed(btn) {
|
||||||
|
state.buttons |= mask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set(&mut state, Button::DPadUp, buttons::DPAD_UP);
|
||||||
|
set(&mut state, Button::DPadDown, buttons::DPAD_DOWN);
|
||||||
|
set(&mut state, Button::DPadLeft, buttons::DPAD_LEFT);
|
||||||
|
set(&mut state, Button::DPadRight, buttons::DPAD_RIGHT);
|
||||||
|
set(&mut state, Button::Start, buttons::START);
|
||||||
|
set(&mut state, Button::Select, buttons::BACK);
|
||||||
|
set(&mut state, Button::LeftThumb, buttons::LEFT_THUMB);
|
||||||
|
set(&mut state, Button::RightThumb, buttons::RIGHT_THUMB);
|
||||||
|
set(&mut state, Button::LeftTrigger, buttons::LEFT_SHOULDER);
|
||||||
|
set(&mut state, Button::RightTrigger, buttons::RIGHT_SHOULDER);
|
||||||
|
set(&mut state, Button::Mode, buttons::GUIDE);
|
||||||
|
set(&mut state, Button::South, buttons::A);
|
||||||
|
set(&mut state, Button::East, buttons::B);
|
||||||
|
set(&mut state, Button::West, buttons::X);
|
||||||
|
set(&mut state, Button::North, buttons::Y);
|
||||||
|
|
||||||
|
// Triggers: gilrs reports LeftTrigger2 / RightTrigger2 as axes in
|
||||||
|
// range [0.0, 1.0]. Map to u8 0..=255.
|
||||||
|
let trig = |axis: Axis| -> u8 {
|
||||||
|
pad.axis_data(axis)
|
||||||
|
.map(|d| (d.value().clamp(0.0, 1.0) * 255.0) as u8)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
state.left_trigger = pad
|
||||||
|
.button_data(Button::LeftTrigger2)
|
||||||
|
.map(|d| (d.value().clamp(0.0, 1.0) * 255.0) as u8)
|
||||||
|
.unwrap_or_else(|| trig(Axis::LeftZ));
|
||||||
|
state.right_trigger = pad
|
||||||
|
.button_data(Button::RightTrigger2)
|
||||||
|
.map(|d| (d.value().clamp(0.0, 1.0) * 255.0) as u8)
|
||||||
|
.unwrap_or_else(|| trig(Axis::RightZ));
|
||||||
|
|
||||||
|
// Sticks: gilrs reports [-1.0, 1.0]. Xbox uses i16 [-32768, 32767].
|
||||||
|
let stick = |axis: Axis| -> i16 {
|
||||||
|
let v = pad
|
||||||
|
.axis_data(axis)
|
||||||
|
.map(|d| d.value().clamp(-1.0, 1.0))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
(v * 32767.0) as i16
|
||||||
|
};
|
||||||
|
state.left_stick_x = stick(Axis::LeftStickX);
|
||||||
|
state.left_stick_y = stick(Axis::LeftStickY);
|
||||||
|
state.right_stick_x = stick(Axis::RightStickX);
|
||||||
|
state.right_stick_y = stick(Axis::RightStickY);
|
||||||
|
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
21
crates/xenia-ui/src/lib.rs
Normal file
21
crates/xenia-ui/src/lib.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//! Host UI for `xenia-rs exec --ui`.
|
||||||
|
//!
|
||||||
|
//! Opens a winit window, drives a wgpu swapchain, polls gilrs for gamepad
|
||||||
|
//! input, and presents the guest frontbuffer whenever `VdSwap` fires on the
|
||||||
|
//! CPU thread. See the crate-level diagram in `app.rs`.
|
||||||
|
//!
|
||||||
|
//! Entry point is [`run`], which is called on the main thread by `xenia-app`.
|
||||||
|
//! The caller gets a matching [`UiHandles`] to install on the kernel state
|
||||||
|
//! before spawning the CPU worker thread — see [`build`].
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
pub mod bridge;
|
||||||
|
mod font;
|
||||||
|
mod input;
|
||||||
|
mod render;
|
||||||
|
mod texture_cache_host;
|
||||||
|
mod xenos_pipeline;
|
||||||
|
|
||||||
|
pub use app::run;
|
||||||
|
pub use bridge::{SwapEvent, UiHandles, build};
|
||||||
|
pub use xenos_pipeline::{DrawRequest, XenosPipeline};
|
||||||
1004
crates/xenia-ui/src/render.rs
Normal file
1004
crates/xenia-ui/src/render.rs
Normal file
File diff suppressed because it is too large
Load Diff
59
crates/xenia-ui/src/shaders/blit.wgsl
Normal file
59
crates/xenia-ui/src/shaders/blit.wgsl
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Full-screen blit of the guest frontbuffer texture, with aspect-ratio
|
||||||
|
// letterboxing. A three-vertex fullscreen triangle covers the viewport; the
|
||||||
|
// fragment shader samples the frontbuffer and emits sRGB colors directly,
|
||||||
|
// letting the swapchain handle the final gamma step.
|
||||||
|
|
||||||
|
struct Uniforms {
|
||||||
|
// aspect_correction.xy = scaling factors applied to the clip-space
|
||||||
|
// position to letterbox the frontbuffer into the swapchain window.
|
||||||
|
aspect_correction: vec2<f32>,
|
||||||
|
// tint for when the frontbuffer is missing / unknown format. When the
|
||||||
|
// guest has not yet swapped a frame, CPU side uploads a 1x1 texture and
|
||||||
|
// we just want the background color.
|
||||||
|
fallback_rgb: vec3<f32>,
|
||||||
|
_pad: f32,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||||
|
@group(0) @binding(1) var frontbuffer: texture_2d<f32>;
|
||||||
|
@group(0) @binding(2) var samp: sampler;
|
||||||
|
|
||||||
|
struct VsOut {
|
||||||
|
@builtin(position) clip: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
@location(1) in_bounds: f32,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut {
|
||||||
|
// Fullscreen triangle: (-1,-1), (3,-1), (-1,3).
|
||||||
|
var pos = array<vec2<f32>, 3>(
|
||||||
|
vec2<f32>(-1.0, -1.0),
|
||||||
|
vec2<f32>( 3.0, -1.0),
|
||||||
|
vec2<f32>(-1.0, 3.0),
|
||||||
|
);
|
||||||
|
let p = pos[vid];
|
||||||
|
// Scale by the aspect-correction factor so the frontbuffer stays at its
|
||||||
|
// native aspect inside the window.
|
||||||
|
let corrected = p * u.aspect_correction;
|
||||||
|
var out: VsOut;
|
||||||
|
out.clip = vec4<f32>(corrected, 0.0, 1.0);
|
||||||
|
// UV runs [0,1] across the unscaled fullscreen triangle's extent
|
||||||
|
// [(-1,-1), (3,-1), (-1,3)] → UV = (p+1)*0.5 mapped with Y flipped.
|
||||||
|
let uv_raw = (p + vec2<f32>(1.0, 1.0)) * 0.5;
|
||||||
|
out.uv = vec2<f32>(uv_raw.x, 1.0 - uv_raw.y);
|
||||||
|
// Also pass a flag telling the FS whether the original position (before
|
||||||
|
// aspect correction) was inside [-1,1]; outside = letterbox region.
|
||||||
|
let in_bounds = f32(all(abs(corrected) <= vec2<f32>(1.0, 1.0)));
|
||||||
|
out.in_bounds = in_bounds;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||||
|
if (in.uv.x < 0.0 || in.uv.x > 1.0 || in.uv.y < 0.0 || in.uv.y > 1.0) {
|
||||||
|
return vec4<f32>(u.fallback_rgb, 1.0);
|
||||||
|
}
|
||||||
|
let sample = textureSample(frontbuffer, samp, in.uv);
|
||||||
|
return vec4<f32>(sample.rgb, 1.0);
|
||||||
|
}
|
||||||
47
crates/xenia-ui/src/shaders/hud.wgsl
Normal file
47
crates/xenia-ui/src/shaders/hud.wgsl
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Textured-quad pipeline for the HUD overlay. Each character is a 6-vertex
|
||||||
|
// (two-triangle) quad with a UV into the font atlas. The CPU-side HUD code
|
||||||
|
// pushes a vertex buffer every frame describing the text to draw.
|
||||||
|
|
||||||
|
struct VsIn {
|
||||||
|
@location(0) pos_px: vec2<f32>, // pixel-space position in the window
|
||||||
|
@location(1) uv: vec2<f32>, // atlas UV
|
||||||
|
@location(2) rgba: vec4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HudUniforms {
|
||||||
|
// Inverse of the surface dimensions so the VS can map pixel coordinates
|
||||||
|
// into clip space: clip.xy = (pos_px / surface_size) * 2.0 - 1.0 with Y
|
||||||
|
// flipped.
|
||||||
|
inv_surface: vec2<f32>,
|
||||||
|
_pad: vec2<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> u: HudUniforms;
|
||||||
|
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||||
|
@group(0) @binding(2) var samp: sampler;
|
||||||
|
|
||||||
|
struct VsOut {
|
||||||
|
@builtin(position) clip: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
@location(1) rgba: vec4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(in: VsIn) -> VsOut {
|
||||||
|
var out: VsOut;
|
||||||
|
let clip_xy = in.pos_px * u.inv_surface * 2.0 - vec2<f32>(1.0, 1.0);
|
||||||
|
// Invert Y so pos_px=(0,0) is top-left.
|
||||||
|
out.clip = vec4<f32>(clip_xy.x, -clip_xy.y, 0.0, 1.0);
|
||||||
|
out.uv = in.uv;
|
||||||
|
out.rgba = in.rgba;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||||
|
let coverage = textureSample(atlas, samp, in.uv).r;
|
||||||
|
// Atlas is R8 where 0xFF = glyph pixel. We premultiply by the input alpha
|
||||||
|
// so the pipeline's alpha-blend pass composits correctly.
|
||||||
|
let a = coverage * in.rgba.a;
|
||||||
|
return vec4<f32>(in.rgba.rgb * a, a);
|
||||||
|
}
|
||||||
227
crates/xenia-ui/src/texture_cache_host.rs
Normal file
227
crates/xenia-ui/src/texture_cache_host.rs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
//! Host-side wgpu texture cache — pairs with `xenia_gpu::texture_cache` (the
|
||||||
|
//! CPU-side decoded-bytes layer) by mapping the same [`TextureKey`] onto an
|
||||||
|
//! uploaded `wgpu::Texture` + `TextureView`.
|
||||||
|
//!
|
||||||
|
//! The xenos pipeline only binds one texture at a time (group 1 single slot);
|
||||||
|
//! this cache lets the draw-dispatch loop uphold that layout without
|
||||||
|
//! re-uploading the same texture across frames. Entries carry the
|
||||||
|
//! `version_when_uploaded` stamp that originated from
|
||||||
|
//! [`xenia_memory::GuestMemory::page_version`]; on a stale hit the caller
|
||||||
|
//! re-decodes (via the CPU cache) and [`TextureCacheHost::upload`] replaces
|
||||||
|
//! the wgpu texture in place.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use xenia_gpu::texture_cache::{CachedTexture, TextureFormat, TextureKey};
|
||||||
|
|
||||||
|
pub struct HostTextureEntry {
|
||||||
|
/// Retained so the `wgpu::Texture` outlives its `TextureView` for
|
||||||
|
/// the lifetime of the cache entry. wgpu views internally reference
|
||||||
|
/// their backing texture through an `Arc`, so dropping this field
|
||||||
|
/// would technically still leave the view valid — but keeping it
|
||||||
|
/// makes the ownership intent explicit and avoids relying on wgpu
|
||||||
|
/// internals. Read only at construction; the `#[allow]` silences
|
||||||
|
/// `dead_code` since Rust can't tell the field is load-bearing for
|
||||||
|
/// drop ordering.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub texture: wgpu::Texture,
|
||||||
|
pub view: wgpu::TextureView,
|
||||||
|
pub version_when_uploaded: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TextureCacheHost {
|
||||||
|
entries: HashMap<TextureKey, HostTextureEntry>,
|
||||||
|
/// HUD-surfaced counters — mirror the CPU-side cache so a session
|
||||||
|
/// can tell whether uploads are dominated by fresh work or stale
|
||||||
|
/// invalidations.
|
||||||
|
pub uploads_total: u64,
|
||||||
|
pub reuploads_total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TextureCacheHost {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextureCacheHost {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
uploads_total: 0,
|
||||||
|
reuploads_total: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kept for API symmetry with `len` (clippy recommends both together).
|
||||||
|
/// Unused in code today — callers check `len() == 0` via the HUD.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.entries.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload (or refresh) a cached texture. Returns a `&TextureView` the
|
||||||
|
/// pipeline can bind. Idempotent when `cached.version_when_uploaded`
|
||||||
|
/// matches an existing entry — in that case we just hand back the
|
||||||
|
/// existing view without touching GPU bandwidth.
|
||||||
|
pub fn upload(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
cached: &CachedTexture,
|
||||||
|
) -> &HostTextureEntry {
|
||||||
|
let key = cached.key;
|
||||||
|
let needs_refresh = match self.entries.get(&key) {
|
||||||
|
None => true,
|
||||||
|
Some(existing) => existing.version_when_uploaded < cached.version_when_uploaded,
|
||||||
|
};
|
||||||
|
if !needs_refresh {
|
||||||
|
return self.entries.get(&key).unwrap();
|
||||||
|
}
|
||||||
|
// Evict + re-upload. For P5 we always recreate the texture (no
|
||||||
|
// in-place writeback); re-uploading a 1280×720 texture is <3 MB
|
||||||
|
// and happens only when the guest actually touches the bytes.
|
||||||
|
let Some(descriptor) = descriptor_for(&key) else {
|
||||||
|
// Unsupported — keep a record-keeping entry pointing at a
|
||||||
|
// dummy and let the pipeline fall back to its own magenta
|
||||||
|
// placeholder. We still create a minimal 1×1 magenta view
|
||||||
|
// so `get` returns something bindable.
|
||||||
|
let (tex, view) = create_magenta_stub(device, queue);
|
||||||
|
let entry = HostTextureEntry {
|
||||||
|
texture: tex,
|
||||||
|
view,
|
||||||
|
version_when_uploaded: cached.version_when_uploaded,
|
||||||
|
};
|
||||||
|
self.entries.insert(key, entry);
|
||||||
|
self.uploads_total += 1;
|
||||||
|
return self.entries.get(&key).unwrap();
|
||||||
|
};
|
||||||
|
let texture = device.create_texture(&descriptor);
|
||||||
|
// Write pixel data. BCn formats require block-aligned extents;
|
||||||
|
// uncompressed formats use byte-aligned rows. First-Pixels M5:
|
||||||
|
// BC1 = 8 bytes/block, BC2/BC3 = 16 bytes/block.
|
||||||
|
let bytes_per_row = match key.format {
|
||||||
|
TextureFormat::Dxt1 => Some((key.width as u32).div_ceil(4) * 8),
|
||||||
|
TextureFormat::Dxt2_3 | TextureFormat::Dxt4_5 => {
|
||||||
|
Some((key.width as u32).div_ceil(4) * 16)
|
||||||
|
}
|
||||||
|
// K8888 and K565 (CPU-expanded to Rgba8Unorm) both produce
|
||||||
|
// 4 bytes per texel in the wgpu-uploadable buffer.
|
||||||
|
_ => Some(key.width as u32 * 4),
|
||||||
|
};
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::ImageCopyTexture {
|
||||||
|
texture: &texture,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
&cached.bytes,
|
||||||
|
wgpu::ImageDataLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row,
|
||||||
|
rows_per_image: None,
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width: key.width as u32,
|
||||||
|
height: key.height as u32,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
let entry = HostTextureEntry {
|
||||||
|
texture,
|
||||||
|
view,
|
||||||
|
version_when_uploaded: cached.version_when_uploaded,
|
||||||
|
};
|
||||||
|
let had_prior = self.entries.contains_key(&key);
|
||||||
|
self.entries.insert(key, entry);
|
||||||
|
if had_prior {
|
||||||
|
self.reuploads_total += 1;
|
||||||
|
} else {
|
||||||
|
self.uploads_total += 1;
|
||||||
|
}
|
||||||
|
self.entries.get(&key).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view_for(&self, key: &TextureKey) -> Option<&wgpu::TextureView> {
|
||||||
|
self.entries.get(key).map(|e| &e.view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// wgpu `TextureDescriptor` matching a Xenos [`TextureKey`]. Returns `None`
|
||||||
|
/// for unsupported formats — caller uses a magenta stub.
|
||||||
|
///
|
||||||
|
/// First-Pixels M5 adds `K565` (CPU-expanded to `Rgba8Unorm` in the
|
||||||
|
/// decoder), `Dxt2_3` → `Bc2RgbaUnorm`, and `Dxt4_5` → `Bc3RgbaUnorm`.
|
||||||
|
/// BC2/BC3 have a 16-byte block footprint (BC1 is 8), so `upload`'s
|
||||||
|
/// `bytes_per_row` computation branches on the block size too.
|
||||||
|
fn descriptor_for(key: &TextureKey) -> Option<wgpu::TextureDescriptor<'static>> {
|
||||||
|
let format = match key.format {
|
||||||
|
TextureFormat::K8888 => wgpu::TextureFormat::Rgba8Unorm,
|
||||||
|
TextureFormat::K565 => wgpu::TextureFormat::Rgba8Unorm, // CPU-expanded
|
||||||
|
TextureFormat::Dxt1 => wgpu::TextureFormat::Bc1RgbaUnorm,
|
||||||
|
TextureFormat::Dxt2_3 => wgpu::TextureFormat::Bc2RgbaUnorm,
|
||||||
|
TextureFormat::Dxt4_5 => wgpu::TextureFormat::Bc3RgbaUnorm,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(wgpu::TextureDescriptor {
|
||||||
|
label: Some("xenos cached texture"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: key.width as u32,
|
||||||
|
height: key.height as u32,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_magenta_stub(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
) -> (wgpu::Texture, wgpu::TextureView) {
|
||||||
|
let tex = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("xenos cached stub"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::ImageCopyTexture {
|
||||||
|
texture: &tex,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
&[0xFFu8, 0x00, 0xFF, 0xFF],
|
||||||
|
wgpu::ImageDataLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(4),
|
||||||
|
rows_per_image: Some(1),
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
(tex, view)
|
||||||
|
}
|
||||||
643
crates/xenia-ui/src/xenos_pipeline.rs
Normal file
643
crates/xenia-ui/src/xenos_pipeline.rs
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
//! Host pipeline that consumes PM4 `DRAW_INDX*` captures.
|
||||||
|
//!
|
||||||
|
//! Drives [`xenia_gpu::shaders::XENOS_INTERP_WGSL`]. This file owns the
|
||||||
|
//! wgpu bind-group + pipeline + buffer surface the Xenos WGSL interpreter
|
||||||
|
//! binds to. The WGSL module is expected to declare:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! @group(0) @binding(0) var<uniform> xenos_draw : XenosDrawConstants; // 16 B
|
||||||
|
//! @group(0) @binding(1) var<uniform> xenos_consts : XenosConstants; // ~9.2 KB
|
||||||
|
//! @group(0) @binding(2) var<storage, read> vs_ucode : array<u32>;
|
||||||
|
//! @group(0) @binding(3) var<storage, read> ps_ucode : array<u32>;
|
||||||
|
//! @group(0) @binding(4) var<storage, read> vertex_buffer : array<u32>;
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Texture bindings (M6) are a single-slot stub for P3b:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! @group(1) @binding(0) var xenos_tex : texture_2d<f32>;
|
||||||
|
//! @group(1) @binding(1) var xenos_samp : sampler;
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The bound texture is a 1×1 magenta placeholder. Real per-slot guest
|
||||||
|
//! texture uploads + format decode land with the texture cache (P5).
|
||||||
|
|
||||||
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
|
use xenia_gpu::shaders::XENOS_INTERP_WGSL;
|
||||||
|
use xenia_gpu::xenos_constants::XenosConstantsBlock;
|
||||||
|
|
||||||
|
/// Per-draw constants mirroring the WGSL `XenosDrawConstants` uniform
|
||||||
|
/// block. Ordering / padding matches `xenos_interp.wgsl` exactly.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||||
|
struct DrawConstants {
|
||||||
|
draw_index: u32,
|
||||||
|
vertex_count: u32,
|
||||||
|
prim_kind: u32,
|
||||||
|
_pad: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submitted to [`XenosPipeline::render_one`] to render one captured draw.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct DrawRequest {
|
||||||
|
/// Monotonic draw counter; shader uses it for per-draw colour rotation.
|
||||||
|
pub draw_index: u32,
|
||||||
|
/// Host-normalised vertex count (after primitive-processor rewrite).
|
||||||
|
pub vertex_count: u32,
|
||||||
|
/// Xenos primitive-type code; shader may branch on it in P3b+.
|
||||||
|
pub prim_kind: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reasonable upper bound on a single shader blob (dwords). Most Xbox 360
|
||||||
|
/// shaders are ≪ 4 KB; 64 KB is orders-of-magnitude slack.
|
||||||
|
const UCODE_BUFFER_MAX_DWORDS: u64 = 16 * 1024; // 64 KB each for VS & PS
|
||||||
|
/// 16 MB of vertex data — enough for any realistic Xenos draw.
|
||||||
|
const VERTEX_BUFFER_MAX_BYTES: u64 = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub struct XenosPipeline {
|
||||||
|
pipeline: wgpu::RenderPipeline,
|
||||||
|
draw_ctx_buffer: wgpu::Buffer,
|
||||||
|
constants_buffer: wgpu::Buffer,
|
||||||
|
vs_ucode_buffer: wgpu::Buffer,
|
||||||
|
ps_ucode_buffer: wgpu::Buffer,
|
||||||
|
vertex_buffer: wgpu::Buffer,
|
||||||
|
bind_group: wgpu::BindGroup,
|
||||||
|
/// P5: swapped per-draw when a new cached texture becomes active.
|
||||||
|
tex_bind_group: wgpu::BindGroup,
|
||||||
|
/// Layout + sampler retained so `set_texture_view` can rebuild
|
||||||
|
/// `tex_bind_group` on the fly without re-reading the pipeline.
|
||||||
|
tex_bgl: wgpu::BindGroupLayout,
|
||||||
|
sampler: wgpu::Sampler,
|
||||||
|
/// Fallback 1×1 magenta texture — used when no guest texture has been
|
||||||
|
/// uploaded yet or when a draw references an unsupported format.
|
||||||
|
dummy_view: wgpu::TextureView,
|
||||||
|
/// P7 — retained pipeline layout + compiled-pipeline cache for
|
||||||
|
/// Xenos→WGSL translator output. Keyed on `(vs_blob_key, ps_blob_key)`
|
||||||
|
/// so every (vs, ps) pair gets compiled once and re-used for every
|
||||||
|
/// subsequent draw. Interpreter pipeline remains the fallback.
|
||||||
|
pipeline_layout: wgpu::PipelineLayout,
|
||||||
|
translated_cache: std::collections::HashMap<(u32, u32), wgpu::RenderPipeline>,
|
||||||
|
pub target_format: wgpu::TextureFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XenosPipeline {
|
||||||
|
pub fn new(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
target_format: wgpu::TextureFormat,
|
||||||
|
) -> Self {
|
||||||
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("xenos_interp.wgsl"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(XENOS_INTERP_WGSL.into()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("xenos bind group layout"),
|
||||||
|
entries: &[
|
||||||
|
// b0: draw_ctx (16 B uniform)
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: std::num::NonZeroU64::new(
|
||||||
|
std::mem::size_of::<DrawConstants>() as u64,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
// b1: XenosConstants read-only storage (~9.2 KB). Not uniform
|
||||||
|
// because the block contains packed `array<u32>` fields and
|
||||||
|
// WGSL's uniform address space would require 16-byte stride.
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: std::num::NonZeroU64::new(
|
||||||
|
XenosConstantsBlock::SIZE as u64,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
// b2: vs_ucode (read-only storage)
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
// b3: ps_ucode (read-only storage)
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 3,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
// b4: vertex_buffer (read-only storage)
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 4,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let tex_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("xenos tex bind group layout"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("xenos pipeline layout"),
|
||||||
|
bind_group_layouts: &[&bgl, &tex_bgl],
|
||||||
|
push_constant_ranges: &[],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buffer allocation. `queue.write_buffer` uses COPY_DST; all
|
||||||
|
// interpreter-facing buffers need it.
|
||||||
|
let initial_draw = DrawConstants {
|
||||||
|
draw_index: 0,
|
||||||
|
vertex_count: 3,
|
||||||
|
prim_kind: 4,
|
||||||
|
_pad: 0,
|
||||||
|
};
|
||||||
|
let draw_ctx_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("xenos draw ctx"),
|
||||||
|
contents: bytemuck::bytes_of(&initial_draw),
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
});
|
||||||
|
let constants_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("xenos constants"),
|
||||||
|
size: XenosConstantsBlock::SIZE as u64,
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let vs_ucode_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("xenos vs ucode"),
|
||||||
|
size: UCODE_BUFFER_MAX_DWORDS * 4,
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let ps_ucode_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("xenos ps ucode"),
|
||||||
|
size: UCODE_BUFFER_MAX_DWORDS * 4,
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("xenos vertex buffer"),
|
||||||
|
size: VERTEX_BUFFER_MAX_BYTES,
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dummy 1×1 magenta texture — placeholder until P5's texture cache
|
||||||
|
// lands. Every `interpret_texture_fetch` samples this for now so the
|
||||||
|
// interpreter can exercise textureSample paths without a real cache.
|
||||||
|
let dummy_tex = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("xenos dummy texture"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
// Magenta (255, 0, 255, 255) so a missing-texture read visibly stands
|
||||||
|
// out on-screen when the interpreter does sample it.
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::ImageCopyTexture {
|
||||||
|
texture: &dummy_tex,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
&[0xFFu8, 0x00, 0xFF, 0xFF],
|
||||||
|
wgpu::ImageDataLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(4),
|
||||||
|
rows_per_image: Some(1),
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let dummy_view = dummy_tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
let dummy_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
label: Some("xenos dummy sampler"),
|
||||||
|
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||||
|
mag_filter: wgpu::FilterMode::Linear,
|
||||||
|
min_filter: wgpu::FilterMode::Linear,
|
||||||
|
mipmap_filter: wgpu::FilterMode::Nearest,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let tex_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("xenos tex bind group"),
|
||||||
|
layout: &tex_bgl,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&dummy_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&dummy_sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("xenos bind group"),
|
||||||
|
layout: &bgl,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: draw_ctx_buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: constants_buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: vs_ucode_buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: ps_ucode_buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 4,
|
||||||
|
resource: vertex_buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("xenos_interp pipeline"),
|
||||||
|
layout: Some(&layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: "vs_main",
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
buffers: &[],
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: "fs_main",
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: target_format,
|
||||||
|
blend: Some(wgpu::BlendState {
|
||||||
|
color: wgpu::BlendComponent {
|
||||||
|
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||||
|
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||||
|
operation: wgpu::BlendOperation::Add,
|
||||||
|
},
|
||||||
|
alpha: wgpu::BlendComponent::OVER,
|
||||||
|
}),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
strip_index_format: None,
|
||||||
|
front_face: wgpu::FrontFace::Ccw,
|
||||||
|
cull_mode: None,
|
||||||
|
polygon_mode: wgpu::PolygonMode::Fill,
|
||||||
|
unclipped_depth: false,
|
||||||
|
conservative: false,
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState::default(),
|
||||||
|
multiview: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
pipeline,
|
||||||
|
draw_ctx_buffer,
|
||||||
|
constants_buffer,
|
||||||
|
vs_ucode_buffer,
|
||||||
|
ps_ucode_buffer,
|
||||||
|
vertex_buffer,
|
||||||
|
bind_group,
|
||||||
|
tex_bind_group,
|
||||||
|
tex_bgl,
|
||||||
|
sampler: dummy_sampler,
|
||||||
|
dummy_view,
|
||||||
|
pipeline_layout: layout,
|
||||||
|
translated_cache: std::collections::HashMap::new(),
|
||||||
|
target_format,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P7 — does the translator cache already have a pipeline for this
|
||||||
|
/// (vs, ps) pair?
|
||||||
|
pub fn has_translated(&self, vs_blob_key: u32, ps_blob_key: u32) -> bool {
|
||||||
|
self.translated_cache
|
||||||
|
.contains_key(&(vs_blob_key, ps_blob_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P7 — fetch a cached translator pipeline. `None` if not yet built.
|
||||||
|
pub fn translated_pipeline(
|
||||||
|
&self,
|
||||||
|
vs_blob_key: u32,
|
||||||
|
ps_blob_key: u32,
|
||||||
|
) -> Option<&wgpu::RenderPipeline> {
|
||||||
|
self.translated_cache
|
||||||
|
.get(&(vs_blob_key, ps_blob_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P7 — compile a translator-produced WGSL module into a
|
||||||
|
/// `wgpu::RenderPipeline` and insert it into the cache keyed on
|
||||||
|
/// `(vs_blob_key, ps_blob_key)`. Returns `true` on success. Duplicate
|
||||||
|
/// inserts are no-ops. Emits `gpu.shader.compile_ok` on success.
|
||||||
|
pub fn insert_translated(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
vs_blob_key: u32,
|
||||||
|
ps_blob_key: u32,
|
||||||
|
wgsl: &str,
|
||||||
|
) -> bool {
|
||||||
|
let key = (vs_blob_key, ps_blob_key);
|
||||||
|
if self.translated_cache.contains_key(&key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let shader = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("xenos translated module"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(wgsl.to_string().into()),
|
||||||
|
})
|
||||||
|
})) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => {
|
||||||
|
metrics::counter!("gpu.shader.compile_err", "stage" => "module")
|
||||||
|
.increment(1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("xenos translated pipeline"),
|
||||||
|
layout: Some(&self.pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: "vs_main",
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
buffers: &[],
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: "fs_main",
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: self.target_format,
|
||||||
|
blend: Some(wgpu::BlendState {
|
||||||
|
color: wgpu::BlendComponent {
|
||||||
|
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||||
|
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||||
|
operation: wgpu::BlendOperation::Add,
|
||||||
|
},
|
||||||
|
alpha: wgpu::BlendComponent::OVER,
|
||||||
|
}),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
strip_index_format: None,
|
||||||
|
front_face: wgpu::FrontFace::Ccw,
|
||||||
|
cull_mode: None,
|
||||||
|
polygon_mode: wgpu::PolygonMode::Fill,
|
||||||
|
unclipped_depth: false,
|
||||||
|
conservative: false,
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState::default(),
|
||||||
|
multiview: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
self.translated_cache.insert(key, pipeline);
|
||||||
|
metrics::counter!("gpu.shader.compile_ok").increment(1);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one draw with the translator-produced pipeline instead of
|
||||||
|
/// the interpreter. Mirrors [`render_one`] except the bound pipeline
|
||||||
|
/// is swapped for `pipeline`.
|
||||||
|
pub fn render_one_with_pipeline(
|
||||||
|
&self,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
|
target_view: &wgpu::TextureView,
|
||||||
|
req: DrawRequest,
|
||||||
|
pipeline: &wgpu::RenderPipeline,
|
||||||
|
) {
|
||||||
|
let cb = DrawConstants {
|
||||||
|
draw_index: req.draw_index,
|
||||||
|
vertex_count: req.vertex_count.max(3),
|
||||||
|
prim_kind: req.prim_kind,
|
||||||
|
_pad: 0,
|
||||||
|
};
|
||||||
|
queue.write_buffer(&self.draw_ctx_buffer, 0, bytemuck::bytes_of(&cb));
|
||||||
|
|
||||||
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("xenos translated draw"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: target_view,
|
||||||
|
resolve_target: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Load,
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
timestamp_writes: None,
|
||||||
|
occlusion_query_set: None,
|
||||||
|
});
|
||||||
|
pass.set_pipeline(pipeline);
|
||||||
|
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||||
|
pass.set_bind_group(1, &self.tex_bind_group, &[]);
|
||||||
|
let rounded = req.vertex_count.div_ceil(3) * 3;
|
||||||
|
pass.draw(0..rounded.max(3), 0..1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of distinct translator pipelines cached. Surfaced to the HUD.
|
||||||
|
pub fn translated_pipeline_count(&self) -> usize {
|
||||||
|
self.translated_cache.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P5 — swap the active texture bound at `@group(1) @binding(0)`.
|
||||||
|
/// `view` is typically a wgpu texture view obtained from the
|
||||||
|
/// [`TextureCacheHost`]. Pass `None` to revert to the built-in dummy
|
||||||
|
/// magenta stub.
|
||||||
|
pub fn set_texture_view(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
|
||||||
|
let bound = view.unwrap_or(&self.dummy_view);
|
||||||
|
self.tex_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("xenos tex bind group (rebind)"),
|
||||||
|
layout: &self.tex_bgl,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(bound),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear `target_view` to `color`, store.
|
||||||
|
pub fn clear(
|
||||||
|
&self,
|
||||||
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
|
target_view: &wgpu::TextureView,
|
||||||
|
color: [f64; 4],
|
||||||
|
) {
|
||||||
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("xenos frontbuffer clear"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: target_view,
|
||||||
|
resolve_target: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Clear(wgpu::Color {
|
||||||
|
r: color[0],
|
||||||
|
g: color[1],
|
||||||
|
b: color[2],
|
||||||
|
a: color[3],
|
||||||
|
}),
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
timestamp_writes: None,
|
||||||
|
occlusion_query_set: None,
|
||||||
|
});
|
||||||
|
let _ = &mut pass;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload shader microcode + constants once (before the batch of draws
|
||||||
|
/// that share them). Skips zero-length blobs.
|
||||||
|
pub fn upload_shader_and_constants(
|
||||||
|
&self,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
vs_ucode: &[u32],
|
||||||
|
ps_ucode: &[u32],
|
||||||
|
constants: &XenosConstantsBlock,
|
||||||
|
) {
|
||||||
|
queue.write_buffer(&self.constants_buffer, 0, bytemuck::bytes_of(constants));
|
||||||
|
if !vs_ucode.is_empty() {
|
||||||
|
let bytes: &[u8] = bytemuck::cast_slice(vs_ucode);
|
||||||
|
let max = (UCODE_BUFFER_MAX_DWORDS * 4) as usize;
|
||||||
|
queue.write_buffer(&self.vs_ucode_buffer, 0, &bytes[..bytes.len().min(max)]);
|
||||||
|
}
|
||||||
|
if !ps_ucode.is_empty() {
|
||||||
|
let bytes: &[u8] = bytemuck::cast_slice(ps_ucode);
|
||||||
|
let max = (UCODE_BUFFER_MAX_DWORDS * 4) as usize;
|
||||||
|
queue.write_buffer(&self.ps_ucode_buffer, 0, &bytes[..bytes.len().min(max)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload vertex data (as raw big-endian dwords — the WGSL side will
|
||||||
|
/// bswap as needed during format unpacking).
|
||||||
|
pub fn upload_vertex_data(&self, queue: &wgpu::Queue, data: &[u32]) {
|
||||||
|
if data.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let bytes: &[u8] = bytemuck::cast_slice(data);
|
||||||
|
let max = VERTEX_BUFFER_MAX_BYTES as usize;
|
||||||
|
queue.write_buffer(&self.vertex_buffer, 0, &bytes[..bytes.len().min(max)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one captured draw.
|
||||||
|
pub fn render_one(
|
||||||
|
&self,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
|
target_view: &wgpu::TextureView,
|
||||||
|
req: DrawRequest,
|
||||||
|
) {
|
||||||
|
let cb = DrawConstants {
|
||||||
|
draw_index: req.draw_index,
|
||||||
|
vertex_count: req.vertex_count.max(3),
|
||||||
|
prim_kind: req.prim_kind,
|
||||||
|
_pad: 0,
|
||||||
|
};
|
||||||
|
queue.write_buffer(&self.draw_ctx_buffer, 0, bytemuck::bytes_of(&cb));
|
||||||
|
|
||||||
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("xenos draw"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: target_view,
|
||||||
|
resolve_target: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Load,
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
timestamp_writes: None,
|
||||||
|
occlusion_query_set: None,
|
||||||
|
});
|
||||||
|
pass.set_pipeline(&self.pipeline);
|
||||||
|
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||||
|
pass.set_bind_group(1, &self.tex_bind_group, &[]);
|
||||||
|
let rounded = req.vertex_count.div_ceil(3) * 3;
|
||||||
|
pass.draw(0..rounded.max(3), 0..1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_constants_layout_matches_wgsl_uniform() {
|
||||||
|
assert_eq!(std::mem::size_of::<DrawConstants>(), 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user