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>
1288 lines
58 KiB
Rust
1288 lines
58 KiB
Rust
//! 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)
|
||
));
|
||
}
|
||
}
|