Files
xenia-rs/crates/xenia-gpu/src/translator.rs
MechaCat02 2c883e9d5e [iterate-4A] diagnostics: XENIA_PROFILE wall-time profiler + probe/tooling snapshot
Handoff snapshot of the env-gated diagnostic scaffolding used across the
intro-video RE. Kept out of the milestone commits (645feb8..5573ac1) to keep
those clean; committed here so nothing is lost on handoff.

New — XENIA_PROFILE wall-time profiler (crates/xenia-gpu/src/prof.rs):
  Coarse buckets attributing playback wall time to interpreter (step_block),
  kernel HLE (call_export), block decode/cache (lookup_or_build), texture
  decode, host draw, and present; prints periodic snapshots (every 500M guest
  instr, or every 500 presents) + a clean-exit report. Hot path is gated on a
  cached is_on() (one relaxed load) so it is zero-cost when XENIA_PROFILE is
  unset. Call sites: main.rs run_superblock / parallel worker (step_block,
  lookup_or_build, call_export), texture_cache ensure_cached, render.rs present
  + dispatch_xenos_draws.

  First profile (movie playback, headless single-thread lockstep): effective
  ~35 MIPS; interpreter body ~40% @ ~95-102 MIPS; texture decode 0.3% (cache
  works); present ~0%; the rest is per-block dispatch + scheduler plumbing
  (~13 instr/block over 229M blocks). Overhead-bound, not interpreter-body
  bound; the levers are coarser execution units (superblock chaining) and
  ultimately a JIT.

Pre-existing read-only probe knobs (were uncommitted; env-gated, observe-only):
  XENIA_RET_CAPTURE_PC/_REG/_MEM, LOG_RESUMES, LOG_WAITS, LOG_SIGNAL,
  FORCE_TID, STARVE_LIMIT, INCUMBENT_PICK, INSTR_PER_MS, DUMP_FRAME,
  DUMP_WGSL, BIND_LOG, CONST_LOG, DISPATCH_REC, AUDIT_PC_TRACE.

Tooling: sylph-run.sh (movie oracle loop, 180s default timeout),
  zq.py (DuckDB disasm/xref helper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 07:43:53 +02:00

1288 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Xenos → WGSL direct translator (P7).
//!
//! Replaces the runtime uber-shader interpreter (P3b/P3c) for shaders whose
//! feature set we cover. Emits a *standalone* WGSL module per shader
//! instead of walking a ucode buffer at draw time — pipeline compilation
//! happens once, then every subsequent dispatch is a direct `draw()`.
//!
//! The translator is deliberately narrow: when it encounters an opcode /
//! fetch format / CF shape it doesn't know, it returns [`None`] and the
//! caller falls back to the interpreter. This keeps the op-coverage work
//! incremental — future commits can add one opcode at a time without
//! invalidating the scaffolding.
//!
//! Current coverage (v1):
//! * Linear CF: `Exec`/`ExecEnd`, `Alloc`, `Exit`. No loops / branches /
//! calls / predicate-gated clauses.
//! * ALU vector: `ADD`, `MUL`, `MAX`, `MIN`, `MAD`, `DP4`, `DP3`,
//! `DP2_ADD`, `SEQ`, `SGT`, `SGE`, `SNE`, `FRC`, `FLOOR`.
//! * ALU scalar: `ADDS`, `MULS`, `MAXS`, `MINS`, `RCP`, `RETAIN_PREV`.
//! * Vertex fetch: `R32G32B32A32_FLOAT` only.
//! * Texture fetch: 2D via the single `@group(1)` slot (same one P5/M6
//! binds).
//! * Exports: VS writes position + interpolator 0 (color); PS writes
//! color0.
//!
//! When a shader exceeds this subset, [`translate`] returns `None` and
//! `gpu.shader.translate_reject{reason}` is bumped by the caller.
use crate::ucode::alu::{decode_alu, sop, vop, AluInstruction};
use crate::ucode::control_flow::{AllocKind, ControlFlowInstruction};
use crate::ucode::fetch::{decode_fetch, FetchInstruction};
use crate::ucode::ParsedShader;
/// Shader stage we're emitting for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
Vertex,
Pixel,
}
/// Success or refusal from the translator. On refusal, the caller falls
/// back to the runtime uber-shader interpreter.
#[derive(Debug)]
pub enum Translation {
/// The emitted WGSL body for *this stage only*. Both VS + PS get
/// wrapped into one module via [`combine_stages`].
Ok(String),
/// Translator saw an op/pattern it doesn't handle; fallback.
Reject(&'static str),
}
/// Full WGSL module for a (VS, PS) pair ready to hand to
/// `wgpu::Device::create_shader_module`. Shares the header across the two
/// bodies so bindings, struct declarations, and helpers aren't duplicated.
pub fn combine_stages(vs_body: &str, ps_body: &str) -> String {
let mut out = String::with_capacity(4096 + vs_body.len() + ps_body.len());
out.push_str(MODULE_HEADER);
out.push_str(vs_body);
out.push_str(ps_body);
out
}
/// Translate a single shader stage. Returns `None` on any unsupported
/// feature with a short reason string that the caller plumbs into the
/// `gpu.shader.translate_reject{reason}` metric.
pub fn translate(parsed: &ParsedShader, stage: Stage) -> Translation {
let mut ctx = EmitCtx::new(stage);
// Emit the stage entry function body.
if let Err(reason) = ctx.emit_stage_body(parsed) {
return Translation::Reject(reason);
}
Translation::Ok(ctx.finish())
}
/// Reject reasons; kept as static &'str for zero-alloc metrics.
pub mod reject {
pub const VEC_OP_UNSUPPORTED: &str = "vec_op_unsupported";
pub const SCL_OP_UNSUPPORTED: &str = "scl_op_unsupported";
pub const CF_LOOP: &str = "cf_loop";
pub const CF_COND: &str = "cf_cond";
pub const CF_CALL: &str = "cf_call";
pub const CF_UNKNOWN: &str = "cf_unknown";
pub const VFETCH_FMT: &str = "vfetch_fmt";
pub const TFETCH_NON2D: &str = "tfetch_non2d";
pub const INSTR_OOB: &str = "instr_oob";
}
/// Shader-module preamble (bindings, helpers, struct defs). The bindings
/// mirror the xenos pipeline's `@group(0)` + `@group(1)` layout from P5/M6
/// so we can use **the same bind-group slots** — only the pipeline object
/// differs between interpreter mode and translator mode.
const MODULE_HEADER: &str = r#"
struct XenosDrawConstants {
draw_index: u32,
vertex_count: u32,
prim_kind: u32,
vertex_base_dwords: u32,
ndc_scale: vec2<f32>,
ndc_offset: vec2<f32>,
};
struct XenosConstants {
alu: array<vec4<f32>, 512>,
fetch: array<u32, 256>,
bool_consts: array<u32, 8>,
loop_consts: array<u32, 32>,
};
@group(0) @binding(0) var<uniform> draw_ctx : XenosDrawConstants;
@group(0) @binding(1) var<storage, read> xenos_consts : XenosConstants;
@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>;
@group(1) @binding(0) var xenos_samp : sampler;
@group(1) @binding(1) var xenos_tex0 : texture_2d<f32>;
@group(1) @binding(2) var xenos_tex1 : texture_2d<f32>;
@group(1) @binding(3) var xenos_tex2 : texture_2d<f32>;
@group(1) @binding(4) var xenos_tex3 : texture_2d<f32>;
@group(1) @binding(5) var xenos_tex4 : texture_2d<f32>;
@group(1) @binding(6) var xenos_tex5 : texture_2d<f32>;
@group(1) @binding(7) var xenos_tex6 : texture_2d<f32>;
@group(1) @binding(8) var xenos_tex7 : texture_2d<f32>;
// iterate-3T: real interpolator passthrough. The Xenos VS exports up to 16
// interpolators (export index 0..15); the PS reads interpolator i from its
// general register r[i]. We carry 8 interpolator vec4s (covers Sylpheed's
// splash: r0=color, r1=texcoord). `color` retained as an alias of interp0 so
// older single-color paths keep working.
struct VsOut {
@builtin(position) position: vec4<f32>,
@location(0) interp0: vec4<f32>,
@location(1) interp1: vec4<f32>,
@location(2) interp2: vec4<f32>,
@location(3) interp3: vec4<f32>,
@location(4) interp4: vec4<f32>,
@location(5) interp5: vec4<f32>,
@location(6) interp6: vec4<f32>,
@location(7) interp7: vec4<f32>,
};
struct FsOut {
@location(0) color0: vec4<f32>,
};
// Helper: reciprocal guarded against divide-by-zero.
fn xe_rcp(x: f32) -> f32 {
return select(0.0, 1.0 / x, x != 0.0);
}
// GPUBUG-102: per-format byte-swap matching canary's `GpuSwapInline`
// (xenos.h:1090-1109). Xbox 360 vertex data is big-endian; the host is
// little-endian. The fetch constant's `endian` field (low 2 bits of
// dword_1) selects:
// 0 (kNone) — no swap
// 1 (k8in16) — swap bytes within halfwords
// 2 (k8in32) — full byte reverse
// 3 (k16in32) — swap halfwords
fn gpu_swap(value: u32, endian: u32) -> u32 {
switch endian {
case 1u: { return ((value << 8u) & 0xFF00FF00u) | ((value >> 8u) & 0x00FF00FFu); }
case 2u: {
return ((value & 0x000000FFu) << 24u)
| ((value & 0x0000FF00u) << 8u)
| ((value & 0x00FF0000u) >> 8u)
| ((value & 0xFF000000u) >> 24u);
}
case 3u: { return ((value >> 16u) & 0xFFFFu) | (value << 16u); }
default: { return value; }
}
}
"#;
struct EmitCtx {
stage: Stage,
out: String,
indent: usize,
/// GPUBUG-114: dword stride of the most recent *full* vfetch, keyed by
/// fetch-const register offset. A vfetch_mini carries stride=0 and reuses
/// the address + stride of the preceding full vfetch of the same stream
/// (canary ucode.h:733). Without this a mini color attribute indexes by its
/// tight dword count instead of the real vertex stride → reads the wrong
/// vertex's data (Sylpheed's background fill `0x36660986` read garbage →
/// white instead of the intended color).
last_full_stride: std::collections::HashMap<u32, u32>,
}
impl EmitCtx {
fn new(stage: Stage) -> Self {
Self {
stage,
out: String::with_capacity(2048),
indent: 0,
last_full_stride: std::collections::HashMap::new(),
}
}
fn finish(self) -> String {
self.out
}
fn push(&mut self, s: &str) {
for _ in 0..self.indent {
self.out.push_str(" ");
}
self.out.push_str(s);
self.out.push('\n');
}
fn emit_stage_body(&mut self, parsed: &ParsedShader) -> Result<(), &'static str> {
// Entry function + struct header.
match self.stage {
Stage::Vertex => {
self.push("@vertex");
self.push("fn vs_main(@builtin(vertex_index) vidx: u32) -> VsOut {");
}
Stage::Pixel => {
self.push("@fragment");
self.push("fn fs_main(in: VsOut) -> FsOut {");
}
}
self.indent = 1;
// Register file + ps chain + export slots. All local `var`s so each
// invocation gets its own state; translator-emitted code doesn't
// need `var<private>` because we don't share across function calls.
self.push("var r: array<vec4<f32>, 128>;");
self.push("for (var i = 0u; i < 128u; i = i + 1u) { r[i] = vec4<f32>(0.0); }");
self.push("var ps: f32 = 0.0;");
match self.stage {
Stage::Vertex => {
// iterate-3T: host→guest vertex-index remap for primitives the
// replay draws non-indexed as a flat triangle list. wgpu has no
// QuadList/RectangleList topology, so the host issues 6 vertices
// per quad/rect and we map them back to the guest's 4/3 source
// vertices here (mirrors `primitive.rs` index rewrite, but in the
// VS since the replay path is non-indexed):
// QuadList(13): 6 host verts → guest [0,1,2, 0,2,3]
// RectangleList(8): 6 host verts → [0,1,2, 2,1,3]; corner 3
// has no backing vertex, so `rect_synth` flags it and
// `emit_vfetch` extrapolates its attributes as v0+v2-v1
// (parallelogram completion, matching Xenos rect semantics).
// Drawing only the front triangle left a diagonal seam.
// Other prims pass through unchanged.
self.push("var gvidx: u32 = vidx;");
// Rectangle-list 4th-corner synthesis state, consumed by
// `emit_vfetch`. `rect_synth` is false for every other prim.
self.push("var rect_base: u32 = 0u;");
self.push("var rect_synth: bool = false;");
self.push("if (draw_ctx.prim_kind == 13u) {");
self.indent += 1;
self.push("let q = vidx % 6u; let qbase = (vidx / 6u) * 4u;");
self.push("var lut = array<u32, 6>(0u, 1u, 2u, 0u, 2u, 3u);");
self.push("gvidx = qbase + lut[q];");
self.indent -= 1;
self.push("} else if (draw_ctx.prim_kind == 8u) {");
self.indent += 1;
self.push("let local = vidx % 6u; rect_base = (vidx / 6u) * 3u;");
// Triangles (v0,v1,v2) + (v0,v2,v3) — canary's rect tessellation
// (an explicit list, not a strip). v3 (corner 3) is synthesized.
self.push("var rlut = array<u32, 6>(0u, 1u, 2u, 0u, 2u, 3u);");
self.push("let corner = rlut[local];");
self.push("gvidx = rect_base + corner;");
self.push("rect_synth = corner == 3u;");
self.indent -= 1;
self.push("}");
// Seed r0 with vertex index for simple shaders that read it.
self.push("r[0] = vec4<f32>(f32(gvidx), 0.0, 0.0, 1.0);");
// iterate-3T: real export model. Xenos export index 62 = oPos;
// indices 0..15 = interpolators. We hold position + 8
// interpolator vec4s; `emit_export` writes the right slot keyed
// on the export index.
//
// iterate-3AE (WHITE-TRIANGLE ROOT): interpolators a VS does NOT
// export must default to ZERO, not white. The old `ointerp[0] =
// (1,1,1,1)` was an iterate-3T debug convenience ("so a VS that
// only exports position still yields a visible non-zero color")
// — but it is a FAKE: it injects white that no guest value backs.
// The transition/background draws use the position-only VS
// `0xd4c14f46` (one vfetch → oPos; it exports NO color) paired
// with PS `0xed732b5a` (`ocolor0 = interp0`). With the white
// seed, interp0 stayed (1,1,1,1) → the fullscreen fill rendered
// OPAQUE WHITE (the diagonal half-triangle artifact that flashed
// before each splash logo and persisted across the dev-logo
// transition). Canary shows a black background there because the
// un-exported interpolator carries no white. Default to
// (0,0,0,0): a position-only VS now contributes nothing visible
// under its real (opaque or premultiplied) blend, matching
// canary, while every VS that really exports interp0 (the logo
// `0x03b7b020`, the `0x36660986` color fill) overwrites this seed
// and is unaffected. RGB=0 → black fill; A=0 → premultiplied
// overlays stay transparent.
self.push("var opos: vec4<f32> = vec4<f32>(0.0, 0.0, 0.0, 1.0);");
self.push("var ointerp: array<vec4<f32>, 8>;");
self.push("for (var i = 0u; i < 8u; i = i + 1u) { ointerp[i] = vec4<f32>(0.0, 0.0, 0.0, 0.0); }");
}
Stage::Pixel => {
// iterate-3T: the PS reads interpolator i from general register
// r[i] (Xenos PS input GPR mapping). Seed r0..r7 from the VS's
// interpolators so e.g. the logo PS's texcoord (r1) and color
// (r0) arrive correctly; tfetch then samples at the real UV.
self.push("r[0] = in.interp0;");
self.push("r[1] = in.interp1;");
self.push("r[2] = in.interp2;");
self.push("r[3] = in.interp3;");
self.push("r[4] = in.interp4;");
self.push("r[5] = in.interp5;");
self.push("r[6] = in.interp6;");
self.push("r[7] = in.interp7;");
self.push("var ocolor0: vec4<f32> = in.interp0;");
}
}
let mut current_alloc = AllocKind::Other;
for clause in &parsed.cf {
match clause {
ControlFlowInstruction::Exec {
address,
count,
sequence,
is_end,
predicated,
..
} => {
if *predicated {
return Err(reject::CF_COND);
}
self.emit_exec(parsed, *address, *count, *sequence, current_alloc)?;
if *is_end {
break;
}
}
ControlFlowInstruction::Alloc { kind, .. } => {
current_alloc = *kind;
}
ControlFlowInstruction::Exit => break,
// Non-executing CF clauses: padding (`kNop`) and the
// vertex-fetch-done hint (`kMarkVsFetchDone`). Skip them.
ControlFlowInstruction::Nop
| ControlFlowInstruction::MarkVsFetchDone => {}
ControlFlowInstruction::LoopStart { .. }
| ControlFlowInstruction::LoopEnd { .. } => return Err(reject::CF_LOOP),
ControlFlowInstruction::CondJmp { .. } => return Err(reject::CF_COND),
ControlFlowInstruction::CondCall { .. } | ControlFlowInstruction::Return => {
return Err(reject::CF_CALL);
}
ControlFlowInstruction::Unknown { .. } => return Err(reject::CF_UNKNOWN),
}
}
match self.stage {
Stage::Vertex => {
self.push("var out: VsOut;");
// iterate-3S: guest VS position → host clip space. The guest
// emits either clip-space or (screen-space, clip disabled)
// render-target-pixel coords; `ndc_scale`/`ndc_offset` (from
// canary's GetHostViewportInfo, computed CPU-side per draw)
// rescale XY into wgpu clip space with Y already flipped. When
// the transform is unset (all-zero scale, procedural fallback)
// pass the position through unchanged.
self.push("if (draw_ctx.ndc_scale.x != 0.0 || draw_ctx.ndc_scale.y != 0.0) {");
self.indent += 1;
self.push("opos = vec4<f32>(opos.xy * draw_ctx.ndc_scale + draw_ctx.ndc_offset * opos.w, opos.z, opos.w);");
self.indent -= 1;
self.push("}");
self.push("out.position = opos;");
self.push("out.interp0 = ointerp[0];");
self.push("out.interp1 = ointerp[1];");
self.push("out.interp2 = ointerp[2];");
self.push("out.interp3 = ointerp[3];");
self.push("out.interp4 = ointerp[4];");
self.push("out.interp5 = ointerp[5];");
self.push("out.interp6 = ointerp[6];");
self.push("out.interp7 = ointerp[7];");
self.push("return out;");
}
Stage::Pixel => {
self.push("var out: FsOut;");
// GPUBUG-115: saturate the color export to [0,1], flushing NaN
// to 0 — exactly what canary does before writing a UNORM render
// target (spirv_shader_translator.cc:3607 "Saturate, flushing
// NaN to 0"). The Xenos RB clamps PS output for UNORM targets;
// without this an out-of-range guest color (Sylpheed's
// background fill exports a huge negative float `-32896.5` as a
// fullscreen-clear value) writes garbage/NaN to the sRGB target
// → renders white instead of the clamped black canary shows.
// `clamp(x,0,1)` returns 0 for NaN under WGSL's clamp semantics.
self.push("out.color0 = clamp(ocolor0, vec4<f32>(0.0), vec4<f32>(1.0));");
self.push("return out;");
}
}
self.indent = 0;
self.push("}");
Ok(())
}
fn emit_exec(
&mut self,
parsed: &ParsedShader,
address: u32,
count: u32,
sequence: u32,
current_alloc: AllocKind,
) -> Result<(), &'static str> {
for i in 0..(count as usize) {
let triple_idx = address as usize + i;
let base = triple_idx * 3;
if base + 2 >= parsed.instructions.len() {
return Err(reject::INSTR_OOB);
}
let words = [
parsed.instructions[base],
parsed.instructions[base + 1],
parsed.instructions[base + 2],
];
// sequence: 2 bits per instruction — bit[0]=fetch(1)/ALU(0),
// bit[1]=serialize (Xenos `ucode.h:226`).
let is_fetch = ((sequence >> (i * 2)) & 1) != 0;
if is_fetch {
match decode_fetch(words) {
FetchInstruction::Vertex(vf) => self.emit_vfetch(&vf)?,
FetchInstruction::Texture(tf) => {
if tf.dimension != 1 {
return Err(reject::TFETCH_NON2D);
}
self.emit_tfetch(&tf);
}
FetchInstruction::Unknown { .. } => return Err(reject::VFETCH_FMT),
}
} else {
let alu = decode_alu(words);
self.emit_alu(&alu, current_alloc)?;
}
}
Ok(())
}
fn emit_alu(
&mut self,
alu: &AluInstruction,
current_alloc: AllocKind,
) -> Result<(), &'static str> {
// GPUBUG-100/101: per-operand temp-vs-constant selector (w0
// bits 29-31), 8-bit component-relative swizzle (w1 bytes 0-2),
// and 1-bit negate (w1 bits 24-26). Pre-fix all three were
// discarded, so every ALU read came back as r[low7] without
// any swizzle / negation, dropping every shader's uniforms +
// negative operands.
// Xenos splits the 512-entry float-constant file into two halves: the
// vertex shader addresses c0..255 (physical 0..255), the pixel shader
// addresses c0..255 too but the hardware reads them from the UPPER half
// (physical 256..511). Our snapshot is the full 512-entry file read
// linearly, so a PS constant reference `c_n` must index `alu[256 + n]`.
// (RE STEP 95: the movie's YUV→RGB PS reads c254/c255; the game uploads
// the coefficients to physical 510/511 = 256+254/255.)
let const_base = match self.stage {
Stage::Pixel => 256u32,
Stage::Vertex => 0,
};
let a = src_operand(alu.src_a, alu.src_a_is_temp, alu.src_a_swiz, alu.src_a_negate, const_base);
let b = src_operand(alu.src_b, alu.src_b_is_temp, alu.src_b_swiz, alu.src_b_negate, const_base);
let c = src_operand(alu.src_c, alu.src_c_is_temp, alu.src_c_swiz, alu.src_c_negate, const_base);
if (42..=47).contains(&alu.scalar_opcode) && std::env::var("XENIA_SC_LOG").is_ok() {
eprintln!(
"SC-OP opc={} sa={} atmp={} asw={:#04x} sb={} btmp={} bsw={:#04x} sc={} ctmp={} csw={:#04x}",
alu.scalar_opcode,
alu.src_a, alu.src_a_is_temp as u8, alu.src_a_swiz,
alu.src_b, alu.src_b_is_temp as u8, alu.src_b_swiz,
alu.src_c, alu.src_c_is_temp as u8, alu.src_c_swiz,
);
}
// Vector pipe.
if alu.vector_write_mask != 0 {
let expr = vector_expr(alu.vector_opcode, &a, &b, &c)
.ok_or(reject::VEC_OP_UNSUPPORTED)?;
let dst_reg = alu.vector_dest & 0x7F;
if alu.vector_dest_is_export {
self.emit_export(dst_reg, current_alloc, &expr, alu.vector_write_mask);
} else {
self.emit_masked_write(&format!("r[{dst_reg}u]"), &expr, alu.vector_write_mask);
}
}
// Scalar pipe. Most scalar ops use (src_a.x, src_b.x); ps-variants use
// the running `ps`. The scalar-constant family (MULSC/ADDSC/SUBSC,
// 42..=47) is SPECIAL: it reads one temp register and one float
// constant, both addressed through src3 (canary ucode.h
// `scalar_const_reg_op_src_temp_reg`):
// temp reg = (src3_swiz & 0x3C) | (scalar_opc & 1), component = src3_swiz & 3
// const idx = src3_reg (+256 for the PS constant bank), component = .w
// The op is then temp <op> const. Feeding it the plain (src_a.x,
// src_b.x) instead computed e.g. r0.x*r0.x (Yb²) for the YUV→RGB luma
// scale — a squared luma that crushes shadows so dark regions render as
// near-pure chroma (the intro's dark background read purple). The
// earlier attempt at this addressing failed only because the constant
// bank was still mis-indexed (pre-STEP-95): the coefficients read zero.
let (scl_src_a, scl_src_b) = if (42..=47).contains(&alu.scalar_opcode) {
let temp_reg = ((alu.src_c_swiz & 0x3C) | (alu.scalar_opcode & 1)) as u32;
let temp_comp = ['x', 'y', 'z', 'w'][(alu.src_c_swiz & 0x3) as usize];
let const_idx = alu.src_c as u32 + const_base;
(
format!("r[{temp_reg}u].{temp_comp}"),
format!("xenos_consts.alu[{const_idx}u].w"),
)
} else if alu.scalar_src_is_ps {
("ps".to_string(), format!("{}.x", b))
} else {
(format!("{}.x", a), format!("{}.x", b))
};
let expr = match scalar_expr(alu.scalar_opcode, &scl_src_a, &scl_src_b, "ps") {
Some(e) => e,
None => {
if std::env::var("XENIA_BIND_LOG").is_ok() {
eprintln!("SCL-UNSUPPORTED opcode={:#04x} ({})", alu.scalar_opcode, alu.scalar_opcode);
}
return Err(reject::SCL_OP_UNSUPPORTED);
}
};
self.push(&format!("ps = {expr};"));
if alu.scalar_write_mask != 0 {
let v = "vec4<f32>(ps, ps, ps, ps)";
let dst_reg = alu.scalar_dest & 0x7F;
self.emit_masked_write(&format!("r[{dst_reg}u]"), v, alu.scalar_write_mask);
}
Ok(())
}
fn emit_masked_write(&mut self, lhs: &str, rhs: &str, mask: u8) {
if mask == 0xF {
self.push(&format!("{lhs} = {rhs};"));
return;
}
self.push(&"{".to_string());
self.indent += 1;
self.push(&format!("let _prev = {lhs};"));
self.push(&format!("let _new = {rhs};"));
let mut components = Vec::new();
let letters = ['x', 'y', 'z', 'w'];
for (i, c) in letters.iter().enumerate() {
if (mask >> i) & 1 == 1 {
components.push(format!("_new.{c}"));
} else {
components.push(format!("_prev.{c}"));
}
}
self.push(&format!(
"{lhs} = vec4<f32>({}, {}, {}, {});",
components[0], components[1], components[2], components[3]
));
self.indent -= 1;
self.push("}");
}
fn emit_export(&mut self, dst_reg: u8, alloc: AllocKind, expr: &str, mask: u8) {
// iterate-3T: real Xenos export-index model (replaces the `AllocKind`
// heuristic, which collapsed every VS export to a single color slot and
// dropped the texcoord interpolator → tfetch sampled (0,0) → flat).
// When `export_data` is set the 6-bit vector_dest IS the export index:
// VS: 62 = oPos, 63 = oPointSize/edge (ignored), 0..15 = interpolators.
// PS: 0..3 = color render targets (we honor RT0).
let _ = alloc;
match self.stage {
Stage::Vertex => {
let lhs = if dst_reg == 62 {
"opos".to_string()
} else if dst_reg <= 15 {
// Clamp to the 8 interpolator slots we carry; higher slots
// are unused by Sylpheed's splash.
let i = (dst_reg as usize).min(7);
format!("ointerp[{i}u]")
} else {
// oPointSize (63) / unknown export slot — discard.
return;
};
self.emit_masked_write(&lhs, expr, mask);
}
Stage::Pixel => {
// Only RT0 (export index 0) is wired to the single host target.
if dst_reg == 0 {
self.emit_masked_write("ocolor0", expr, mask);
}
}
}
}
fn emit_vfetch(&mut self, vf: &crate::ucode::fetch::VertexFetch) -> Result<(), &'static str> {
// GPUBUG-107 (iterate-3S): decode the vertex FORMAT + dword STRIDE from
// the vfetch instruction instead of hardcoding R32G32B32A32 (4 floats,
// stride 4). Sylpheed's splash quads are `k_32_32_FLOAT` (2 floats,
// stride 2); over-reading them put the next vertex's X into .w → a
// negative W → the whole rectangle clipped behind the camera. We cover
// the float vertex formats (the UI / screen-space draws); other formats
// reject to the interpreter.
//
// GPUBUG-102: the fetch constant holds the endian field in dword_1's
// low 2 bits; Xbox 360 vertex data is big-endian, so `gpu_swap` undoes
// it per component.
// (comps, dwords_read) per format. Float formats are 1 dword/component;
// iterate-3T adds the packed-16 `k_16_16` (format 6) used for the logo
// UV interpolator — 2 components packed into ONE dword.
#[derive(PartialEq)]
enum Pack {
Float, // N f32 lanes, N dwords
Norm16x2, // 2× u16 normalized into [0,1], 1 dword (k_16_16)
Norm8x4, // 4× u8 normalized into [0,1], 1 dword (k_8_8_8_8)
}
let (comps, dwords_read, pack): (u32, u32, Pack) = match vf.format {
36 => (1, 1, Pack::Float), // k_32_FLOAT
37 => (2, 2, Pack::Float), // k_32_32_FLOAT
57 => (3, 3, Pack::Float), // k_32_32_32_FLOAT
38 => (4, 4, Pack::Float), // k_32_32_32_32_FLOAT
6 => (4, 1, Pack::Norm8x4), // k_8_8_8_8 (packed RGBA8 — GPUBUG-112)
25 => (2, 1, Pack::Norm16x2), // k_16_16
_ => return Err(reject::VFETCH_FMT),
};
// iterate-3X (GPUBUG-110): index the fetch-constant region by the full
// `const_index*3 + const_index_sel` mapping (canary `ucode.h:700`),
// packed as `const_index*6 + sel*2` dwords. The previous expression
// `(vf.raw[0] >> 5) & 0x1F` read the *src_reg* bits, not the const
// index — wrong for the endian term and the no-window fallback base.
let const_off = vf.const_reg_offset();
// GPUBUG-114: a full vfetch carries the real vertex dword stride; a
// vfetch_mini reuses the address + stride of the preceding full vfetch
// of the same stream (canary ucode.h:733). Track the last full stride
// per fetch-const and inherit it for mini-fetches (stride field == 0).
let stride = if vf.is_mini_fetch || vf.stride == 0 {
*self
.last_full_stride
.get(&const_off)
.unwrap_or(&dwords_read)
} else {
self.last_full_stride.insert(const_off, vf.stride as u32);
vf.stride as u32
};
// iterate-3T: per-attribute dword offset within the vertex (vfetches
// sharing one fetch constant read different attributes).
let attr_off = vf.offset;
let src_reg = vf.src_register & 0x7F;
let dst_reg = vf.dest_register & 0x7F;
// is_signed selects [-1,1] vs [0,1] for normalized integer formats.
let signed = vf.is_signed;
// Build the per-component reads; unread lanes default to 0/0/0/1 so an
// XY-only position keeps W=1 (and Z=0).
let lane = |i: u32| -> String {
match pack {
Pack::Float => {
if i < comps {
format!("bitcast<f32>(gpu_swap(vertex_buffer[addr + {i}u], endian))")
} else if i == 3 {
"1.0".to_string()
} else {
"0.0".to_string()
}
}
Pack::Norm16x2 => {
// One dword holds [u16 lo | u16 hi] after the endian swap.
// Component 0 = low halfword, component 1 = high halfword.
if i == 0 {
if signed {
"(max(f32(i32(w16 << 16u) >> 16u) / 32767.0, -1.0))".to_string()
} else {
"(f32(w16 & 0xFFFFu) / 65535.0)".to_string()
}
} else if i == 1 {
if signed {
"(max(f32(i32(w16) >> 16u) / 32767.0, -1.0))".to_string()
} else {
"(f32(w16 >> 16u) / 65535.0)".to_string()
}
} else if i == 3 {
"1.0".to_string()
} else {
"0.0".to_string()
}
}
Pack::Norm8x4 => {
// One dword holds 4× u8 (canary spirv_shader_translator_fetch
// k_8_8_8_8: comp0@bit0, comp1@bit8, comp2@bit16, comp3@bit24)
// after the endian swap. All four channels present → normalize
// to [0,1]. GPUBUG-112: this is the logo/background vertex
// COLOR (RGBA8), previously misdecoded as k_16_16 (2 chans,
// B forced 0) → white texture × (R,G,0) = yellow.
let sh = i * 8;
if signed {
format!(
"(max(f32(i32(w16 << {l}u) >> 24u) / 127.0, -1.0))",
l = 24 - sh
)
} else {
format!("(f32((w16 >> {sh}u) & 0xFFu) / 255.0)")
}
}
}
};
let read_bound = dwords_read - 1;
// GPUBUG-108 (iterate-3S): for the captured-geometry path the CPU
// uploads a vertex window that begins EXACTLY at the fetch base, so the
// base within `vertex_buffer` is 0 and vertex i sits at `i * stride`.
// The previous `abs_base - vertex_base_dwords` rebase recomputed the
// base from `xenos_consts.fetch[]`, but that uniform carries the
// *last-published* (per-frame) fetch constant, not this draw's — for
// the splash it was stale (0x8a000002 vs the real 0x0adf… base), so the
// rebase produced a huge out-of-window address, the bounds guard
// failed, and every vertex kept its seed (vertex_index, 0, 0, 1) →
// every quad collapsed to ~one pixel at the origin. Index from 0 when a
// real window is present (`vertex_base_dwords != 0`); only the
// synthetic/no-window fallback consults the uniform fetch constant.
let endian_term = format!("xenos_consts.fetch[{}u] & 0x3u", const_off + 1);
// For packed formats (k_16_16, k_8_8_8_8) we read one dword into `w16`
// (post endian-swap) and the `lane()` exprs above unpack the channels.
let w16_decl = if pack == Pack::Norm16x2 || pack == Pack::Norm8x4 {
"let w16 = gpu_swap(vertex_buffer[addr], endian); "
} else {
""
};
let l0 = lane(0);
let l1 = lane(1);
let l2 = lane(2);
let l3 = lane(3);
// One decode of `addr` → the attribute vec4. Reused verbatim for the
// normal single-vertex read and for each of the three source vertices
// of a synthesized rectangle corner (the lane exprs reference `addr`
// and `w16` by name, so each `{ let addr = …; }` sub-scope re-decodes).
let read_into = |dst: &str, idx_expr: &str| -> String {
format!(
"{{ let addr = base + ({idx_expr}) * {stride}u + {attr_off}u; \
if (addr + {read_bound}u < n) {{ {w16_decl}{dst} = vec4<f32>({l0}, {l1}, {l2}, {l3}); }} }}"
)
};
// RectangleList 4th corner: no backing vertex, so extrapolate the
// attribute as v0 + v2 - v1 (parallelogram completion, matching canary).
// With the (v0,v1,v2)+(v0,v2,v3) tessellation the synthesized corner is
// diagonal to v1 (e.g. TL,TR,BR given → BL = TL+BR-TR). Applies
// uniformly to position and every interpolator. `rect_synth`/`rect_base`
// come from the VS preamble; false/0 for every non-rect draw.
let t0 = read_into("t0", "rect_base + 0u");
let t1 = read_into("t1", "rect_base + 1u");
let t2 = read_into("t2", "rect_base + 2u");
let normal = read_into(&format!("r[{dst_reg}u]"), &format!("u32(r[{src_reg}u].x)"));
self.push(&format!(
"{{ let endian = {endian_term}; \
var base = 0u; \
if (draw_ctx.vertex_base_dwords == 0u) {{ \
base = (xenos_consts.fetch[{fc0_idx}u] & 0xFFFFFFFCu) >> 2u; \
}} \
let n = arrayLength(&vertex_buffer); \
if (rect_synth) {{ \
var t0 = vec4<f32>(0.0, 0.0, 0.0, 1.0); var t1 = t0; var t2 = t0; \
{t0} {t1} {t2} \
r[{dst_reg}u] = t0 + t2 - t1; \
}} else {{ {normal} }} }}",
fc0_idx = const_off,
));
Ok(())
}
fn emit_tfetch(&mut self, tf: &crate::ucode::fetch::TextureFetch) {
// Sample the texture bound to this fetch's constant slot; UV =
// r[src].xy. The UI binds up to 8 planes (one per fetch-constant slot),
// so a multi-plane shader (the intro video's YUV Y/U/V) reads the right
// texture per fetch. Slots >= 8 fall back to slot 0 (matches the
// interpreter's `default` arm).
let src_reg = tf.src_register & 0x7F;
let dst_reg = tf.dest_register & 0x7F;
let slot = if (tf.fetch_const as usize) < 8 {
tf.fetch_const as usize
} else {
0
};
// Honor the 12-bit destination swizzle (3 bits/component). Only the
// selected lanes are written; `keep` codes preserve the prior value.
// This is what lets the YUV video's three fetches (all → r1, each in a
// different lane) coexist instead of clobbering each other.
let letters = ['x', 'y', 'z', 'w'];
let mut comps: Vec<String> = Vec::with_capacity(4);
let mut any_write = false;
for i in 0..4u16 {
let code = (tf.dest_swizzle >> (i * 3)) & 0x7;
let expr = match code {
0..=3 => {
any_write = true;
format!("_tex.{}", letters[code as usize])
}
4 => {
any_write = true;
"0.0".to_string()
}
5 => {
any_write = true;
"1.0".to_string()
}
// 6/7 = keep: preserve the destination's current component.
_ => format!("r[{dst_reg}u].{}", letters[i as usize]),
};
comps.push(expr);
}
if any_write {
self.push("{");
self.indent += 1;
self.push(&format!(
"let _tex = textureSampleLevel(xenos_tex{slot}, xenos_samp, r[{src_reg}u].xy, 0.0);"
));
self.push(&format!(
"r[{dst_reg}u] = vec4<f32>({}, {}, {}, {});",
comps[0], comps[1], comps[2], comps[3]
));
self.indent -= 1;
self.push("}");
}
}
}
/// Emit the WGSL expression that reads an ALU source operand with
/// swizzle + negate applied (no abs — see GPUBUG-100 deferred). Mirrors
/// the interpreter shader's `read_src` + `apply_swizzle` + the negate
/// half of `apply_modifiers`. The 8-bit `swizzle` is component-relative
/// per canary `AluInstruction::GetSwizzledComponentIndex`: for output
/// component i, source component is `((swiz >> (2*i)) + i) & 3`.
/// Identity swizzle is `0x00`. GPUBUG-100 / GPUBUG-101.
fn src_operand(src_byte: u8, is_temp: bool, swizzle: u8, negate: bool, const_base: u32) -> String {
let base = if is_temp {
format!("r[{}u]", (src_byte & 0x3F) as u32)
} else {
format!("xenos_consts.alu[{}u]", src_byte as u32 + const_base)
};
let s = swizzle as u32;
let lane = |i: u32| -> char {
let c = (((s >> (2 * i)) + i) & 3) as usize;
['x', 'y', 'z', 'w'][c]
};
// Identity swizzle (0x00) maps to .xyzw — emit a bare expression.
let swizzled = if swizzle == 0 {
base
} else {
let lx = lane(0);
let ly = lane(1);
let lz = lane(2);
let lw = lane(3);
format!("vec4<f32>({base}.{lx}, {base}.{ly}, {base}.{lz}, {base}.{lw})")
};
if negate {
format!("(-{swizzled})")
} else {
swizzled
}
}
fn vector_expr(op: u8, a: &str, b: &str, c: &str) -> Option<String> {
// Semantics mirror the runtime interpreter's `exec_vector_op`
// (`shaders/xenos_interp.wgsl`), which in turn mirrors canary's
// `AluVectorOpcode` (ucode.h:1001+). Side-effecting ops (kill*, setp_push)
// need per-invocation state the AOT emitter doesn't track yet → still
// `None` (interpreter fallback).
let cmp4 = |op: &str| {
format!(
"vec4<f32>(select(0.0,1.0,{a}.x{op}{b}.x), select(0.0,1.0,{a}.y{op}{b}.y), select(0.0,1.0,{a}.z{op}{b}.z), select(0.0,1.0,{a}.w{op}{b}.w))"
)
};
// CND* : per-lane select(c, b, a <cmp> 0).
let cnd4 = |op: &str| {
format!(
"vec4<f32>(select({c}.x,{b}.x,{a}.x{op}0.0), select({c}.y,{b}.y,{a}.y{op}0.0), select({c}.z,{b}.z,{a}.z{op}0.0), select({c}.w,{b}.w,{a}.w{op}0.0))"
)
};
let s = match op {
vop::ADD => format!("({a} + {b})"),
vop::MUL => format!("({a} * {b})"),
vop::MAX => format!("max({a}, {b})"),
vop::MIN => format!("min({a}, {b})"),
vop::MAD => format!("({a} * {b} + {c})"),
vop::DOT4 => format!("vec4<f32>(dot({a}, {b}))"),
vop::DOT3 => format!("vec4<f32>(dot({a}.xyz, {b}.xyz))"),
vop::DOT2_ADD => format!("vec4<f32>({a}.x * {b}.x + {a}.y * {b}.y + {c}.x)"),
vop::SEQ => cmp4("=="),
vop::SGT => cmp4(">"),
vop::SGE => cmp4(">="),
vop::SNE => cmp4("!="),
vop::CND_EQ => cnd4("=="),
vop::CND_GE => cnd4(">="),
vop::CND_GT => cnd4(">"),
vop::FRC => format!("fract({a})"),
vop::TRUNC => format!("trunc({a})"),
vop::FLOOR => format!("floor({a})"),
vop::MAX4 => format!("vec4<f32>(max(max({a}.x,{a}.y), max({a}.z,{a}.w)))"),
// dst = (1, src0.y*src1.y, src0.z, src1.w) (canary kDst)
vop::DST => format!("vec4<f32>(1.0, {a}.y * {b}.y, {a}.z, {b}.w)"),
_ => return None,
};
Some(s)
}
fn scalar_expr(op: u8, a: &str, b: &str, prev: &str) -> Option<String> {
// Semantics mirror the runtime interpreter's `exec_scalar_op`
// (`shaders/xenos_interp.wgsl`) / canary's `AluScalarOpcode`
// (ucode.h:1001+). Side-effecting ops (setp*, kills*, maxas*) need
// per-invocation predicate/kill/address state the AOT emitter doesn't
// track yet → still `None` (interpreter fallback).
let s = match op {
sop::ADDS => format!("({a} + {b})"),
sop::ADDS_PREV => format!("({a} + {prev})"),
sop::MULS => format!("({a} * {b})"),
sop::MULS_PREV => format!("({a} * {prev})"),
// muls_prev2 / LIT emulation (canary kMulsPrev2): guard against
// -FLT_MAX / non-finite ps & b, and b <= 0.
sop::MULS_PREV2 => format!(
"select({a} * {prev}, -3.4028235e38, {prev} == -3.4028235e38 || !(\
{prev} == {prev}) || abs({prev}) > 3.4028235e38 || !({b} == {b}) || \
abs({b}) > 3.4028235e38 || {b} <= 0.0)"
),
sop::MAXS => format!("max({a}, {b})"),
sop::MINS => format!("min({a}, {b})"),
sop::SEQS => format!("select(0.0, 1.0, {a} == 0.0)"),
sop::SGTS => format!("select(0.0, 1.0, {a} > 0.0)"),
sop::SGES => format!("select(0.0, 1.0, {a} >= 0.0)"),
sop::SNES => format!("select(0.0, 1.0, {a} != 0.0)"),
sop::FRCS => format!("fract({a})"),
sop::TRUNCS => format!("trunc({a})"),
sop::FLOORS => format!("floor({a})"),
sop::SUBS => format!("({a} - {b})"),
sop::SUBS_PREV => format!("({a} - {prev})"),
sop::EXP => format!("exp2({a})"),
sop::LOG | sop::LOGC => format!("select(log2({a}), 0.0, {a} == 1.0)"),
sop::RCP | sop::RCPC | sop::RCPF => format!("xe_rcp({a})"),
sop::RSQ | sop::RSQC | sop::RSQF => {
format!("select(0.0, inverseSqrt({a}), {a} > 0.0)")
}
sop::SQRT => format!("select(0.0, sqrt({a}), {a} >= 0.0)"),
sop::SIN => format!("sin({a})"),
sop::COS => format!("cos({a})"),
// Scalar-constant family (canary `kMulsc0..kSubsc1`): a two-source
// scalar op `ps = src0.x OP src1.x`. Our `a`/`b` are already src0.x /
// src1.x (see the `scl_src_a`/`scl_src_b` setup in the interpreter and
// above), so the `c0`/`c1` variants collapse to the same arithmetic —
// the constant-bank distinction is resolved during operand read. The
// intro video's YUV→RGB pixel shader builds its matrix-multiply from
// these (multiply-by-coefficient + add-offset).
sop::MULSC0 | sop::MULSC1 => format!("({a} * {b})"),
sop::ADDSC0 | sop::ADDSC1 => format!("({a} + {b})"),
sop::SUBSC0 | sop::SUBSC1 => format!("({a} - {b})"),
sop::RETAIN_PREV => prev.to_string(),
_ => return None,
};
Some(s)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ucode::alu::{sop, vop};
use crate::ucode::control_flow::ControlFlowInstruction;
/// iterate-3T: the real publisher-logo VS (`vs_key 0x03b7b020`, captured
/// from the live boot) must now TRANSLATE — pre-3T it rejected with
/// `vfetch_fmt` because (a) the `k_16_16` color stream (format 6) was
/// unsupported and (b) the export-index model (62=oPos, 0/1=interpolators)
/// was a wrong AllocKind heuristic. This locks in the format-6 + per-
/// attribute-offset + export-index work so the UV interpolator reaches the
/// pixel shader (texcoord in r1) instead of collapsing to a single color.
#[test]
fn real_logo_vs_translates_with_interpolators() {
let ucode: [u32; 30] = [
0x70153003, 0x00001200, 0xC2000000, 0x00001006, 0x00001200, 0xC4000000,
0x00002007, 0x00002200, 0x00000000, 0x2DF82000, 0x00393A88, 0x00000006,
0x05F81000, 0x4006060A, 0x00000306, 0x05F80000, 0x40253FC8, 0x00000406,
0xC80F803E, 0x00000000, 0xC2020200, 0xC8038001, 0x00B0B000, 0xC2000000,
0xC80F8000, 0x00000000, 0xC2010100, 0x00000000, 0x00000000, 0x00000000,
];
let p = crate::ucode::parse_shader(&ucode);
let body = match translate(&p, Stage::Vertex) {
Translation::Ok(b) => b,
Translation::Reject(r) => panic!("logo VS rejected: {r}"),
};
// Position must come from the export-index-62 path (`opos`) and the
// UV/color interpolators must be exported as distinct slots.
assert!(body.contains("opos ="), "no position export: {body}");
assert!(body.contains("ointerp[0u]"), "no interp0 export: {body}");
assert!(body.contains("ointerp[1u]"), "no interp1 export: {body}");
// The k_16_16 attribute must unpack via the packed-16 helper.
assert!(body.contains("w16"), "no packed-16 unpack for k_16_16: {body}");
}
/// The logo pixel shader (`ps_key 0x03b79001`) samples its texture at the
/// interpolated texcoord register r1 — which the PS now seeds from the VS
/// interpolator `in.interp1` (Xenos PS-input-GPR mapping). Verifies the UV
/// chain so tfetch samples the real UV instead of (0,0).
#[test]
fn ps_seeds_interpolators_into_registers() {
// A trivial PS that just exports — we only assert the preamble wiring.
let p = crate::ucode::ParsedShader {
cf: vec![ControlFlowInstruction::Exit],
instructions: vec![],
};
let body = match translate(&p, Stage::Pixel) {
Translation::Ok(b) => b,
Translation::Reject(r) => panic!("trivial PS rejected: {r}"),
};
assert!(body.contains("r[1] = in.interp1;"), "PS must seed r1 from interp1: {body}");
}
fn synthetic_trivial_shader() -> ParsedShader {
// Single Exec clause: ALU add r0 = r0 + r0; scalar_op = RETAIN_PREV
// with full write-mask on vector, zero on scalar. Alloc(Position)
// precedes so the ALU's export (if it were one) would target oPos.
// GPUBUG-106 canary layout: dest/mask/scalar_opc in w0; vector_opc +
// src_sel in w2. All three operands temps → r0.
let w0 = (0u32) // vector_dest = 0
| (0xFu32 << 16) // vector_write_mask = 0xF
| ((sop::RETAIN_PREV as u32) << 26); // scalar_opc
let w1 = 0u32;
let w2 = ((vop::ADD as u32) << 24) // vector_opc
| (1u32 << 31) // src1_sel = temp
| (1u32 << 30) // src2_sel = temp
| (1u32 << 29); // src3_sel = temp
ParsedShader {
cf: vec![
ControlFlowInstruction::Alloc {
size: 1,
kind: AllocKind::Position,
},
ControlFlowInstruction::Exec {
address: 0,
count: 1,
sequence: 0,
is_end: true,
predicated: false,
predicate_condition: false,
},
],
instructions: vec![w0, w1, w2],
}
}
#[test]
fn trivial_shader_translates() {
let shader = synthetic_trivial_shader();
match translate(&shader, Stage::Vertex) {
Translation::Ok(body) => {
assert!(body.contains("fn vs_main"));
assert!(body.contains("r[0u] = (r[0u] + r[0u]);"));
assert!(body.contains("return out;"));
}
Translation::Reject(r) => panic!("rejected: {r}"),
}
}
#[test]
fn combined_module_parses_as_wgsl() {
let shader = synthetic_trivial_shader();
let vs = match translate(&shader, Stage::Vertex) {
Translation::Ok(body) => body,
Translation::Reject(r) => panic!("VS rejected: {r}"),
};
let ps = match translate(&shader, Stage::Pixel) {
Translation::Ok(body) => body,
Translation::Reject(r) => panic!("PS rejected: {r}"),
};
let module = combine_stages(&vs, &ps);
// naga is pinned as a dev-dep in this crate; if this fails the
// translator is emitting invalid WGSL.
match naga::front::wgsl::parse_str(&module) {
Ok(_) => {}
Err(e) => panic!(
"emitted WGSL failed to parse:\n{}\n--- module ---\n{}",
e, module
),
}
}
#[test]
fn src_operand_decodes_temp_vs_constant_no_modifiers() {
// GPUBUG-101: is_temp=true → r[low6]; is_temp=false → xenos_consts.alu[full].
// Identity swizzle (0x00), no negate → bare base expression.
assert_eq!(src_operand(0x00, true, 0x00, false, 0), "r[0u]");
assert_eq!(src_operand(0x05, true, 0x00, false, 0), "r[5u]");
assert_eq!(src_operand(0x3F, true, 0x00, false, 0), "r[63u]");
// For temps, bits 6/7 are reserved (abs/rel) — they don't widen
// the register index even if set. Phase D2 will consume them.
assert_eq!(src_operand(0x80, true, 0x00, false, 0), "r[0u]");
assert_eq!(src_operand(0xFF, true, 0x00, false, 0), "r[63u]");
// Constants: full 8-bit index.
assert_eq!(src_operand(0x00, false, 0x00, false, 0), "xenos_consts.alu[0u]");
assert_eq!(src_operand(0x05, false, 0x00, false, 0), "xenos_consts.alu[5u]");
assert_eq!(src_operand(0xFF, false, 0x00, false, 0), "xenos_consts.alu[255u]");
}
#[test]
fn src_operand_applies_swizzle_and_negate() {
// GPUBUG-100. Component-relative swizzle. swizzle=0x1B reverses
// the lanes (.wzyx): for i=0 → ((0x1B >> 0) + 0) & 3 = 3 = w;
// for i=1 → ((0x1B >> 2) + 1) & 3 = (6+1)&3 = 3 = w. Hmm —
// canary's identity is 0x00 = .xyzw, so .wzyx in component-
// relative terms = `s0=3, s1=2, s2=1, s3=0` → bits would be
// (3, (2-1)&3=1, (1-2)&3=3, (0-3)&3=1) which combines weirdly.
// We just verify the mechanics by precomputing a known case:
// swizzle=0x00 (identity) outputs .xyzw — matched by no-swizzle
// branch. Negate wraps in `(-…)`.
assert_eq!(src_operand(0x05, true, 0x00, true, 0), "(-r[5u])");
// swizzle=0xFF → for each i, ((0xFF >> (2i)) + i) & 3:
// i=0: (3 + 0) & 3 = 3 → w
// i=1: ((0x3F) + 1) & 3 = (63+1)&3 = 0 → x
// i=2: ((0x0F) + 2) & 3 = (15+2)&3 = 1 → y
// i=3: ((0x03) + 3) & 3 = (3+3)&3 = 2 → z
// Output: .wxyz
assert_eq!(
src_operand(0x05, true, 0xFF, false, 0),
"vec4<f32>(r[5u].w, r[5u].x, r[5u].y, r[5u].z)"
);
// Combined: negate of constant with .wxyz swizzle.
assert_eq!(
src_operand(0x07, false, 0xFF, true, 0),
"(-vec4<f32>(xenos_consts.alu[7u].w, xenos_consts.alu[7u].x, xenos_consts.alu[7u].y, xenos_consts.alu[7u].z))"
);
}
#[test]
fn shader_using_c0_emits_xenos_consts_read() {
// ALU: r0 = c0 + r0. GPUBUG-106 canary layout. src_a = src1 (w2
// 16:23), src_b = src2 (w2 8:15). src1_sel (w2 bit31) = 0 → c0;
// src2_sel (w2 bit30) = 1 → r0.
let w0 = (0u32) // vector_dest = 0
| (0xFu32 << 16) // vector_write_mask
| ((sop::RETAIN_PREV as u32) << 26); // scalar_opc
let w2 = ((vop::ADD as u32) << 24) // vector_opc
| (0u32 << 16) // src1_reg = 0 → c0
| (0u32 << 8) // src2_reg = 0 → r0
| (0u32 << 31) // src1_sel = 0 (constant)
| (1u32 << 30); // src2_sel = 1 (temp)
let shader = ParsedShader {
cf: vec![
ControlFlowInstruction::Alloc {
size: 1,
kind: AllocKind::Position,
},
ControlFlowInstruction::Exec {
address: 0,
count: 1,
sequence: 0,
is_end: true,
predicated: false,
predicate_condition: false,
},
],
instructions: vec![w0, 0, w2],
};
match translate(&shader, Stage::Vertex) {
Translation::Ok(body) => {
assert!(
body.contains("xenos_consts.alu[0u]"),
"expected c0 operand, got: {body}"
);
assert!(
body.contains("r[0u]"),
"expected r0 temp operand, got: {body}"
);
}
Translation::Reject(r) => panic!("rejected: {r}"),
}
}
#[test]
fn vfetch_emit_includes_gpu_swap_helper_call() {
// GPUBUG-102: emit_vfetch should reference `gpu_swap(...)` for
// each lane. Ensures the per-format endian byte-swap is wired
// into the AOT path.
let mut ctx = EmitCtx::new(Stage::Vertex);
let vf = crate::ucode::fetch::VertexFetch {
fetch_const: 0,
const_index_sel: 0,
src_register: 0,
dest_register: 0,
dest_write_mask: 0xF,
format: 38, // k_32_32_32_32_FLOAT (4 floats)
stride: 4,
offset: 0,
is_signed: false,
is_normalized: true,
is_mini_fetch: false,
raw: [0; 3],
};
ctx.emit_vfetch(&vf).expect("emit_vfetch");
let body = ctx.finish();
assert!(body.contains("gpu_swap("), "emitted vfetch body: {body}");
}
fn vf(format: u8, stride: u8, offset: u32, mini: bool) -> crate::ucode::fetch::VertexFetch {
crate::ucode::fetch::VertexFetch {
fetch_const: 0,
const_index_sel: 0,
src_register: 0,
dest_register: 0,
dest_write_mask: 0xF,
format,
stride,
offset,
is_signed: false,
is_normalized: true,
is_mini_fetch: mini,
raw: [0; 3],
}
}
#[test]
fn vfetch_k8888_unpacks_four_channels() {
// GPUBUG-112: VertexFormat 6 = k_8_8_8_8 (4× u8 normalized, 1 dword),
// NOT k_16_16. All four channels (R,G,B,A) must be unpacked so a
// vertex COLOR keeps its blue channel (white texture × white color =
// white, not yellow).
let mut ctx = EmitCtx::new(Stage::Vertex);
ctx.emit_vfetch(&vf(6, 6, 3, false)).expect("emit");
let body = ctx.finish();
// Four /255.0 channel reads from one packed dword `w16`.
assert!(body.contains("let w16 ="), "needs packed dword: {body}");
assert_eq!(body.matches("/ 255.0").count(), 4, "four 8-bit channels: {body}");
}
#[test]
fn vfetch_mini_inherits_full_stride() {
// GPUBUG-114: a vfetch_mini (stride field 0) inherits the stride of the
// preceding full vfetch of the same stream (canary ucode.h:733). Emit a
// full fetch (stride 7) then a mini fetch and assert the mini indexes by
// stride 7, not its tight dword count.
let mut ctx = EmitCtx::new(Stage::Vertex);
ctx.emit_vfetch(&vf(57, 7, 0, false)).expect("full"); // k_32_32_32_FLOAT
ctx.emit_vfetch(&vf(38, 0, 3, true)).expect("mini"); // k_32_32_32_32_FLOAT, mini
let body = ctx.finish();
assert!(body.contains("vidx * 7u + 3u"), "mini must inherit stride 7: {body}");
assert!(!body.contains("vidx * 4u"), "mini must not use tight stride 4: {body}");
}
#[test]
fn ps_color_export_is_saturated() {
// GPUBUG-115: the PS color export must be clamped to [0,1] (canary
// saturates before UNORM RT write) so an out-of-range guest color
// doesn't write garbage/white to the sRGB target.
let p = crate::ucode::ParsedShader {
cf: vec![ControlFlowInstruction::Exit],
instructions: vec![],
};
let body = match translate(&p, Stage::Pixel) {
Translation::Ok(b) => b,
Translation::Reject(r) => panic!("PS rejected: {r}"),
};
assert!(
body.contains("clamp(ocolor0, vec4<f32>(0.0), vec4<f32>(1.0))"),
"PS must saturate color export: {body}"
);
}
#[test]
fn loop_clause_rejected() {
let shader = ParsedShader {
cf: vec![ControlFlowInstruction::LoopStart {
address: 0,
loop_id: 0,
}],
instructions: vec![],
};
assert!(matches!(
translate(&shader, Stage::Vertex),
Translation::Reject(reject::CF_LOOP)
));
}
#[test]
fn unsupported_op_rejected() {
// GPUBUG-106 layout: vector_write_mask in w0 (16:19), vector_opc in
// w2 (24:28). MAX_A (29) is outside the supported subset → reject.
let w0 = (0xFu32 << 16) | ((sop::RETAIN_PREV as u32) << 26);
let w2 = (29u32) << 24; // VOP_MAX_A
let shader = ParsedShader {
cf: vec![ControlFlowInstruction::Exec {
address: 0,
count: 1,
sequence: 0,
is_end: true,
predicated: false,
predicate_condition: false,
}],
instructions: vec![w0, 0, w2],
};
assert!(matches!(
translate(&shader, Stage::Vertex),
Translation::Reject(reject::VEC_OP_UNSUPPORTED)
));
}
}