//! 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)
        ));
    }
}
