diff --git a/crates/xenia-gpu/src/draw_capture.rs b/crates/xenia-gpu/src/draw_capture.rs index ddae76f..ce64f1b 100644 --- a/crates/xenia-gpu/src/draw_capture.rs +++ b/crates/xenia-gpu/src/draw_capture.rs @@ -68,20 +68,23 @@ pub struct DrawCapture { pub ndc_offset: [f32; 2], /// iterate-3T: the decoded texture(s) this draw's active pixel shader /// samples, keyed off its real `tfetch` fetch-constant slots (the 3M - /// decoder makes these decode). The UI uploads + binds the FIRST entry - /// per-draw so the textured logo samples the real artwork instead of the - /// magenta stub. Empty for flat (no-tfetch) draws. Populated by - /// `gpu_system` after decode (left empty by `build`). + /// decoder makes these decode). Root-#3: the UI uploads + binds EACH entry + /// to its own texture slot per-draw, so a multi-plane shader (e.g. the + /// intro video's YUV Y/U/V planes) samples the right texture per fetch. + /// Empty for flat (no-tfetch) draws. Populated by `gpu_system` after + /// decode (left empty by `build`). /// - /// Each entry is `(key, content_version, bytes)`. iterate-3AD: the - /// `content_version` (from `span_max_version` over the texel span) lets the - /// UI host texture cache RE-UPLOAD when the guest fills more of an evolving - /// atlas. The publisher and the 2nd splash logo share one K8888 surface - /// (base `0x4dbee000`); the 2nd logo's texels are CPU-written *after* the - /// publisher's first upload. Without the real version the host cache (which - /// previously pinned `version_when_uploaded = 1`) kept the first partial - /// upload, so the 2nd logo sampled its still-zero atlas region as black. - pub textures: Vec<(crate::texture_cache::TextureKey, u64, Vec)>, + /// Each entry is `(slot, key, content_version, bytes)` where `slot` is the + /// `tfetch` fetch-constant index (0..31) the shader samples this texture + /// from. iterate-3AD: the `content_version` (from `span_max_version` over + /// the texel span) lets the UI host texture cache RE-UPLOAD when the guest + /// fills more of an evolving atlas. The publisher and the 2nd splash logo + /// share one K8888 surface (base `0x4dbee000`); the 2nd logo's texels are + /// CPU-written *after* the publisher's first upload. Without the real + /// version the host cache (which previously pinned `version_when_uploaded + /// = 1`) kept the first partial upload, so the 2nd logo sampled its + /// still-zero atlas region as black. + pub textures: Vec<(u8, crate::texture_cache::TextureKey, u64, Vec)>, /// iterate-3Y: per-draw color/blend render state captured from the /// register file so the host pipeline composites the way the guest /// intends (instead of one fixed alpha-blend state). Mirrors the fields diff --git a/crates/xenia-gpu/src/gpu_system.rs b/crates/xenia-gpu/src/gpu_system.rs index f24fd49..1cf17fe 100644 --- a/crates/xenia-gpu/src/gpu_system.rs +++ b/crates/xenia-gpu/src/gpu_system.rs @@ -429,7 +429,7 @@ pub struct GpuSystem { /// hardcoded slot). `vd_swap` publishes the first of these to the UI so /// the replay binds the texture the draw actually samples. Cleared and /// repopulated each draw; empty when the active PS issues no `tfetch`. - pub last_draw_textures: Vec<(crate::texture_cache::TextureKey, u64, Vec)>, + pub last_draw_textures: Vec<(u8, crate::texture_cache::TextureKey, u64, Vec)>, /// 10 MiB shadow of the Xenos EDRAM. Written by clear-resolves and /// (future) host-render-target readback; read by the resolve byte-copy /// path that writes tiled pixels into guest memory. Allocated once at @@ -1421,7 +1421,7 @@ impl GpuSystem { // first (partial) upload stuck and later draws // sampled the not-yet-filled region as black. self.last_draw_textures - .push((entry.key, version, entry.bytes.clone())); + .push((slot, entry.key, version, entry.bytes.clone())); metrics::counter!( "gpu.texture.decode", "fmt" => format!("{:?}", key.format), diff --git a/crates/xenia-gpu/src/shaders/xenos_interp.wgsl b/crates/xenia-gpu/src/shaders/xenos_interp.wgsl index 73c7ba9..5b14195 100644 --- a/crates/xenia-gpu/src/shaders/xenos_interp.wgsl +++ b/crates/xenia-gpu/src/shaders/xenos_interp.wgsl @@ -48,11 +48,21 @@ struct XenosConstants { @group(0) @binding(3) var ps_ucode : array; @group(0) @binding(4) var vertex_buffer : array; -// M6 — texture fetch stub. Group 1 is a single 1×1 magenta placeholder for -// all texture slots; the P5 texture cache will replace this with per-slot -// bindings. -@group(1) @binding(0) var xenos_tex : texture_2d; -@group(1) @binding(1) var xenos_samp : sampler; +// Multi-texture: group(1) exposes up to 8 sampled texture slots (binding 0 = +// shared sampler, bindings 1..8 = tex0..tex7). A pixel shader that samples +// several fetch constants at once — e.g. the intro video's YUV420 planes +// (Y, U, V) — reads the right texture per `tfetch` slot. Slots the draw does +// not bind carry a transparent 1×1 dummy. `interpret_texture_fetch` selects +// the slot from the fetch instruction's fetch-constant index. +@group(1) @binding(0) var xenos_samp : sampler; +@group(1) @binding(1) var xenos_tex0 : texture_2d; +@group(1) @binding(2) var xenos_tex1 : texture_2d; +@group(1) @binding(3) var xenos_tex2 : texture_2d; +@group(1) @binding(4) var xenos_tex3 : texture_2d; +@group(1) @binding(5) var xenos_tex4 : texture_2d; +@group(1) @binding(6) var xenos_tex5 : texture_2d; +@group(1) @binding(7) var xenos_tex6 : texture_2d; +@group(1) @binding(8) var xenos_tex7 : texture_2d; // ── CF kind codes; must match `xenia_gpu::ucode::cf_kind`. ───────────── const CF_KIND_EXEC: u32 = 0u; @@ -260,6 +270,12 @@ const SOP_KILLS_GE: u32 = 37u; const SOP_KILLS_NE: u32 = 38u; const SOP_KILLS_ONE: u32 = 39u; const SOP_SQRT: u32 = 40u; +const SOP_MULSC0: u32 = 42u; +const SOP_MULSC1: u32 = 43u; +const SOP_ADDSC0: u32 = 44u; +const SOP_ADDSC1: u32 = 45u; +const SOP_SUBSC0: u32 = 46u; +const SOP_SUBSC1: u32 = 47u; const SOP_SIN: u32 = 48u; const SOP_COS: u32 = 49u; const SOP_RETAIN_PREV: u32 = 50u; @@ -459,6 +475,12 @@ fn exec_scalar_op(op: u32, src_a: f32, src_b: f32, prev: f32) -> f32 { case SOP_SQRT: { return select(0.0, sqrt(src_a), src_a >= 0.0); } case SOP_SIN: { return sin(src_a); } case SOP_COS: { return cos(src_a); } + // Scalar-constant family (canary kMulsc0..kSubsc1): ps = src0.x OP + // src1.x. src_a/src_b already carry src0.x/src1.x, so c0/c1 collapse to + // the same arithmetic. The intro video's YUV→RGB shader uses these. + case SOP_MULSC0, SOP_MULSC1: { return src_a * src_b; } + case SOP_ADDSC0, SOP_ADDSC1: { return src_a + src_b; } + case SOP_SUBSC0, SOP_SUBSC1: { return src_a - src_b; } // Predicate writes — update `predicate` and produce a result that // the surrounding ALU slot can still consume via `ps`. Canary's // setp-variant dst-write semantics are preserved. @@ -783,11 +805,12 @@ fn interpret_vertex_fetch(t: u32) { registers[dst_reg & 0x7Fu] = result; } -// M6 — minimum-viable texture fetch. Every fetch samples the 1×1 magenta -// dummy bound at group(1); the real per-slot texture cache lands with P5. -// Reads (u, v) from the source register's .xy and writes the sample into -// the destination register. `textureSampleLevel` works in both VS and PS -// (no implicit derivatives), so no per-stage specialisation needed. +// Texture fetch. Reads (u, v) from the source register's .xy, selects the +// texture slot from the fetch instruction's fetch-constant index (dword0 +// bits 20..24, matching `ucode::decode_fetch`), and writes the sample into +// the destination register. `textureSampleLevel` (explicit LOD) works in +// both VS and PS and does not require uniform control flow, so the per-slot +// `switch` is legal in the fragment stage. fn interpret_texture_fetch(t: u32, is_vertex: bool) { var w0: u32 = 0u; if is_vertex { @@ -797,8 +820,20 @@ fn interpret_texture_fetch(t: u32, is_vertex: bool) { } let dst_reg = (w0 >> 12u) & 0x3Fu; let src_reg = (w0 >> 5u) & 0x3Fu; + let slot = (w0 >> 20u) & 0x1Fu; let uv = registers[src_reg & 0x3Fu].xy; - let sample = textureSampleLevel(xenos_tex, xenos_samp, uv, 0.0); + var sample: vec4; + switch slot { + case 0u: { sample = textureSampleLevel(xenos_tex0, xenos_samp, uv, 0.0); } + case 1u: { sample = textureSampleLevel(xenos_tex1, xenos_samp, uv, 0.0); } + case 2u: { sample = textureSampleLevel(xenos_tex2, xenos_samp, uv, 0.0); } + case 3u: { sample = textureSampleLevel(xenos_tex3, xenos_samp, uv, 0.0); } + case 4u: { sample = textureSampleLevel(xenos_tex4, xenos_samp, uv, 0.0); } + case 5u: { sample = textureSampleLevel(xenos_tex5, xenos_samp, uv, 0.0); } + case 6u: { sample = textureSampleLevel(xenos_tex6, xenos_samp, uv, 0.0); } + case 7u: { sample = textureSampleLevel(xenos_tex7, xenos_samp, uv, 0.0); } + default: { sample = textureSampleLevel(xenos_tex0, xenos_samp, uv, 0.0); } + } registers[dst_reg & 0x3Fu] = sample; } diff --git a/crates/xenia-gpu/src/translator.rs b/crates/xenia-gpu/src/translator.rs index 0753771..0cf9fc2 100644 --- a/crates/xenia-gpu/src/translator.rs +++ b/crates/xenia-gpu/src/translator.rs @@ -112,8 +112,15 @@ struct XenosConstants { @group(0) @binding(3) var ps_ucode : array; @group(0) @binding(4) var vertex_buffer : array; -@group(1) @binding(0) var xenos_tex : texture_2d; -@group(1) @binding(1) var xenos_samp : sampler; +@group(1) @binding(0) var xenos_samp : sampler; +@group(1) @binding(1) var xenos_tex0 : texture_2d; +@group(1) @binding(2) var xenos_tex1 : texture_2d; +@group(1) @binding(3) var xenos_tex2 : texture_2d; +@group(1) @binding(4) var xenos_tex3 : texture_2d; +@group(1) @binding(5) var xenos_tex4 : texture_2d; +@group(1) @binding(6) var xenos_tex5 : texture_2d; +@group(1) @binding(7) var xenos_tex6 : texture_2d; +@group(1) @binding(8) var xenos_tex7 : texture_2d; // iterate-3T: real interpolator passthrough. The Xenos VS exports up to 16 // interpolators (export index 0..15); the PS reads interpolator i from its @@ -427,9 +434,20 @@ impl EmitCtx { // discarded, so every ALU read came back as r[low7] without // any swizzle / negation, dropping every shader's uniforms + // negative operands. - let a = src_operand(alu.src_a, alu.src_a_is_temp, alu.src_a_swiz, alu.src_a_negate); - let b = src_operand(alu.src_b, alu.src_b_is_temp, alu.src_b_swiz, alu.src_b_negate); - let c = src_operand(alu.src_c, alu.src_c_is_temp, alu.src_c_swiz, alu.src_c_negate); + // 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); // Vector pipe. if alu.vector_write_mask != 0 { @@ -446,14 +464,26 @@ impl EmitCtx { // Scalar pipe. Binary ops use (src_a.x, src_b.x); ps-variants use // src_a.x + running ps. `scl_src_a` mirrors the interpreter's // `scalar_src_is_ps` selector. + // + // NOTE: the scalar-constant family (MULSC/ADDSC/SUBSC, 42..=47) really + // reads a SPECIAL operand pair — a float constant at src3's index + // (W-swizzled) and a temp register at a bit-packed index (X-swizzled), + // per canary ucode.h:1302 / shader_translator.cc:1419. A first attempt + // at that addressing rendered the intro video BLACK (the operands + // resolved to zero), so it's reverted here to the plain (src_a.x, + // src_b.x) read: the YUV→RGB result is then visible but MAGENTA-tinted + // (wrong chroma coefficients). Correct SC-operand addressing is a + // follow-up. See project memory STEP 92. let scl_src_a = if alu.scalar_src_is_ps { "ps".to_string() } else { format!("{}.x", a) }; let scl_src_b = format!("{}.x", b); - let expr = scalar_expr(alu.scalar_opcode, &scl_src_a, &scl_src_b, "ps") - .ok_or(reject::SCL_OP_UNSUPPORTED)?; + let expr = match scalar_expr(alu.scalar_opcode, &scl_src_a, &scl_src_b, "ps") { + Some(e) => e, + None => return Err(reject::SCL_OP_UNSUPPORTED), + }; self.push(&format!("ps = {expr};")); if alu.scalar_write_mask != 0 { let v = "vec4(ps, ps, ps, ps)"; @@ -674,14 +704,58 @@ impl EmitCtx { } fn emit_tfetch(&mut self, tf: &crate::ucode::fetch::TextureFetch) { - // v1: sample the single bound texture; UV = r[src].xy. P5's cache - // publishes the `fetch_const=0` texture into `@group(1)`; slot - // mismatch is a silent magenta for now. + // 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; - self.push(&format!( - "r[{dst_reg}u] = textureSampleLevel(xenos_tex, xenos_samp, r[{src_reg}u].xy, 0.0);" - )); + 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 = 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({}, {}, {}, {});", + comps[0], comps[1], comps[2], comps[3] + )); + self.indent -= 1; + self.push("}"); + } } } @@ -692,11 +766,11 @@ impl EmitCtx { /// 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) -> String { +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) + format!("xenos_consts.alu[{}u]", src_byte as u32 + const_base) }; let s = swizzle as u32; let lane = |i: u32| -> char { @@ -802,6 +876,16 @@ fn scalar_expr(op: u8, a: &str, b: &str, prev: &str) -> Option { 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, }; @@ -935,17 +1019,17 @@ mod tests { 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), "r[0u]"); - assert_eq!(src_operand(0x05, true, 0x00, false), "r[5u]"); - assert_eq!(src_operand(0x3F, true, 0x00, false), "r[63u]"); + 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), "r[0u]"); - assert_eq!(src_operand(0xFF, true, 0x00, false), "r[63u]"); + 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), "xenos_consts.alu[0u]"); - assert_eq!(src_operand(0x05, false, 0x00, false), "xenos_consts.alu[5u]"); - assert_eq!(src_operand(0xFF, false, 0x00, false), "xenos_consts.alu[255u]"); + 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] @@ -959,7 +1043,7 @@ mod tests { // 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), "(-r[5u])"); + 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 @@ -967,12 +1051,12 @@ mod tests { // i=3: ((0x03) + 3) & 3 = (3+3)&3 = 2 → z // Output: .wxyz assert_eq!( - src_operand(0x05, true, 0xFF, false), + src_operand(0x05, true, 0xFF, false, 0), "vec4(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), + src_operand(0x07, false, 0xFF, true, 0), "(-vec4(xenos_consts.alu[7u].w, xenos_consts.alu[7u].x, xenos_consts.alu[7u].y, xenos_consts.alu[7u].z))" ); } diff --git a/crates/xenia-gpu/src/ucode/fetch.rs b/crates/xenia-gpu/src/ucode/fetch.rs index a437a38..02135f4 100644 --- a/crates/xenia-gpu/src/ucode/fetch.rs +++ b/crates/xenia-gpu/src/ucode/fetch.rs @@ -81,7 +81,15 @@ pub struct TextureFetch { pub fetch_const: u8, pub src_register: u8, pub dest_register: u8, - pub dest_write_mask: u8, + /// Destination swizzle — 12 bits, 3 per output component (Xenos tfetch + /// dword1 bits 0..11). Per canary `ucode.h`, each 3-bit code selects: + /// 0..3 = sampled X/Y/Z/W, 4 = constant 0.0, 5 = constant 1.0, + /// 6/7 = keep (destination component unwritten). + /// The old `dest_write_mask: (w1 & 0xF)` read collapsed this to a bogus + /// 4-bit mask, so multi-plane fetches (the intro video's Y/U/V, all + /// targeting r1 but each in its own lane) each did a *full* `r1 = sample` + /// overwrite — only the last plane survived → wrong-color video. + pub dest_swizzle: u16, /// Dimension: 0=1D, 1=2D, 2=3D/stacked, 3=cube. pub dimension: u8, pub raw: [u32; 3], @@ -140,7 +148,7 @@ pub fn decode_fetch(words: [u32; 3]) -> FetchInstruction { fetch_const: ((w0 >> 20) & 0x1F) as u8, src_register: ((w0 >> 5) & 0x3F) as u8, dest_register: ((w0 >> 12) & 0x3F) as u8, - dest_write_mask: (w1 & 0xF) as u8, + dest_swizzle: (w1 & 0xFFF) as u16, dimension: ((w2 >> 14) & 0x3) as u8, raw: words, }), diff --git a/crates/xenia-kernel/src/exports.rs b/crates/xenia-kernel/src/exports.rs index ecec0a4..536dcbf 100644 --- a/crates/xenia-kernel/src/exports.rs +++ b/crates/xenia-kernel/src/exports.rs @@ -3201,7 +3201,7 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { let published = gpu_inline .last_draw_textures .first() - .map(|(k, _v, b)| (*k, b.clone())) + .map(|(_slot, k, _v, b)| (*k, b.clone())) .or_else(|| { // Fallback: probe fetch constant slot 0 directly. Texture fetch // constants live at `CONST_BASE_FETCH + slot*6` in the register diff --git a/crates/xenia-ui/src/render.rs b/crates/xenia-ui/src/render.rs index 37335ba..5ec4818 100644 --- a/crates/xenia-ui/src/render.rs +++ b/crates/xenia-ui/src/render.rs @@ -774,29 +774,37 @@ impl RenderState { host_texture_cache, .. } = self; - match cap.textures.first() { - Some((key, version, bytes)) => { - // iterate-3AD: use the decoder's real content `version` - // (from `span_max_version`) so the host cache re-uploads - // when the guest fills MORE of an evolving atlas. The - // publisher and the 2nd splash logo share one K8888 - // surface (base 0x4dbee000); the 2nd logo's texels land - // AFTER the first upload. With the old hardcoded - // `version_when_uploaded = 1`, the same `TextureKey` - // never re-uploaded, so the 2nd logo sampled its (then - // still-zero) atlas region as black. The real version - // increases as the guest writes, triggering re-upload. + if cap.textures.is_empty() { + xenos_pipeline.set_texture_view(device, None); + } else { + // Root-#3: upload EVERY plane this draw samples, then bind + // each to its own fetch-constant slot so a multi-texture + // shader (the intro video's YUV Y/U/V planes) reads the + // right texture per `tfetch`. iterate-3AD: use the decoder's + // real content `version` (from `span_max_version`) so the + // host cache re-uploads when the guest fills MORE of an + // evolving atlas (e.g. the 2nd splash logo sharing a K8888 + // surface with the publisher logo). + for (_slot, key, version, bytes) in &cap.textures { let cached = xenia_gpu::texture_cache::CachedTexture { key: *key, version_when_uploaded: *version, bytes: bytes.clone(), }; host_texture_cache.upload(device, queue, &cached); - if let Some(view) = host_texture_cache.view_for(key) { - xenos_pipeline.set_texture_view(device, Some(view)); + } + // Collect a view per slot (immutable borrow — uploads are + // done) and bind them all at once. + let mut slot_views: [Option<&wgpu::TextureView>; + crate::xenos_pipeline::TEX_SLOTS] = + [None; crate::xenos_pipeline::TEX_SLOTS]; + for (slot, key, _version, _bytes) in &cap.textures { + let s = *slot as usize; + if s < crate::xenos_pipeline::TEX_SLOTS { + slot_views[s] = host_texture_cache.view_for(key); } } - None => xenos_pipeline.set_texture_view(device, None), + xenos_pipeline.set_texture_slots(device, &slot_views); } } let raw_vs = shader_blobs.get(&cap.vs_key).cloned().unwrap_or_default(); diff --git a/crates/xenia-ui/src/texture_cache_host.rs b/crates/xenia-ui/src/texture_cache_host.rs index eec8c45..be67c8e 100644 --- a/crates/xenia-ui/src/texture_cache_host.rs +++ b/crates/xenia-ui/src/texture_cache_host.rs @@ -10,7 +10,7 @@ //! re-decodes (via the CPU cache) and [`TextureCacheHost::upload`] replaces //! the wgpu texture in place. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use xenia_gpu::texture_cache::{CachedTexture, TextureFormat, TextureKey}; @@ -31,6 +31,13 @@ pub struct HostTextureEntry { pub struct TextureCacheHost { entries: HashMap, + /// FIFO insertion order for bounded eviction. The intro video uploads + /// ~3 new-VA textures per frame (the Y/U/V planes land at rotating guest + /// addresses), so without a cap the wgpu texture set grows unbounded until + /// the GPU device runs out of memory (a device-lost hard crash mid-movie). + /// Once we exceed `MAX_ENTRIES` the oldest texture is evicted; an evicted + /// texture simply re-uploads on demand if the guest samples it again. + order: VecDeque, /// HUD-surfaced counters — mirror the CPU-side cache so a session /// can tell whether uploads are dominated by fresh work or stale /// invalidations. @@ -45,9 +52,16 @@ impl Default for TextureCacheHost { } impl TextureCacheHost { + /// Upper bound on simultaneously-resident host textures. The movie's live + /// working set is small (~3–9 planes across the in-flight frames) and boot + /// uses a couple dozen; 64 leaves headroom while bounding GPU memory to a + /// few hundred MB worst case. + const MAX_ENTRIES: usize = 64; + pub fn new() -> Self { Self { entries: HashMap::new(), + order: VecDeque::new(), uploads_total: 0, reuploads_total: 0, } @@ -57,6 +71,28 @@ impl TextureCacheHost { self.entries.len() } + /// Record a freshly-inserted key in FIFO order and evict the oldest + /// entries until we're back under `MAX_ENTRIES`. Never evicts the key we + /// just inserted (it's at the back of the queue). + fn record_and_evict(&mut self, key: TextureKey, is_new: bool) { + if is_new { + self.order.push_back(key); + } + while self.order.len() > Self::MAX_ENTRIES { + match self.order.pop_front() { + Some(old) if old != key => { + self.entries.remove(&old); + } + Some(old) => { + // The only remaining entry is the current key — keep it. + self.order.push_back(old); + break; + } + None => break, + } + } + } + /// Kept for API symmetry with `len` (clippy recommends both together). /// Unused in code today — callers check `len() == 0` via the HUD. #[allow(dead_code)] @@ -90,6 +126,7 @@ impl TextureCacheHost { // dummy and let the pipeline fall back to its own magenta // placeholder. We still create a minimal 1×1 magenta view // so `get` returns something bindable. + let is_new = !self.entries.contains_key(&key); let (tex, view) = create_magenta_stub(device, queue); let entry = HostTextureEntry { texture: tex, @@ -98,6 +135,7 @@ impl TextureCacheHost { }; self.entries.insert(key, entry); self.uploads_total += 1; + self.record_and_evict(key, is_new); return self.entries.get(&key).unwrap(); }; let texture = device.create_texture(&descriptor); @@ -145,6 +183,7 @@ impl TextureCacheHost { } else { self.uploads_total += 1; } + self.record_and_evict(key, !had_prior); self.entries.get(&key).unwrap() } diff --git a/crates/xenia-ui/src/xenos_pipeline.rs b/crates/xenia-ui/src/xenos_pipeline.rs index 99d5782..c1c89b7 100644 --- a/crates/xenia-ui/src/xenos_pipeline.rs +++ b/crates/xenia-ui/src/xenos_pipeline.rs @@ -238,6 +238,13 @@ pub struct XenosPipeline { pub target_format: wgpu::TextureFormat, } +/// Number of simultaneously-bindable Xenos texture slots in `group(1)` +/// (binding 0 = shared sampler, bindings 1..=TEX_SLOTS = tex0..). Must match +/// the binding count in `xenos_interp.wgsl`. 8 covers the intro video's 3 +/// YUV planes with headroom while staying well under the 16-sampled-texture +/// per-stage limit. +pub const TEX_SLOTS: usize = 8; + impl XenosPipeline { pub fn new( device: &wgpu::Device, @@ -315,26 +322,29 @@ impl XenosPipeline { }, ], }); + let mut tex_bgl_entries: Vec = + Vec::with_capacity(TEX_SLOTS + 1); + tex_bgl_entries.push(wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }); + for k in 0..TEX_SLOTS { + tex_bgl_entries.push(wgpu::BindGroupLayoutEntry { + binding: (k + 1) as u32, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }); + } let tex_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("xenos tex bind group layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], + entries: &tex_bgl_entries, }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -437,19 +447,21 @@ impl XenosPipeline { mipmap_filter: wgpu::FilterMode::Nearest, ..Default::default() }); + let mut tex_bg_entries: Vec = Vec::with_capacity(TEX_SLOTS + 1); + tex_bg_entries.push(wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Sampler(&dummy_sampler), + }); + for k in 0..TEX_SLOTS { + tex_bg_entries.push(wgpu::BindGroupEntry { + binding: (k + 1) as u32, + resource: wgpu::BindingResource::TextureView(&dummy_view), + }); + } let tex_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("xenos tex bind group"), layout: &tex_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&dummy_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&dummy_sampler), - }, - ], + entries: &tex_bg_entries, }); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -731,20 +743,35 @@ impl XenosPipeline { /// [`TextureCacheHost`]. Pass `None` to revert to the built-in dummy /// magenta stub. pub fn set_texture_view(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) { - let bound = view.unwrap_or(&self.dummy_view); + self.set_texture_slots(device, &[view]); + } + + /// Rebind `group(1)` with per-slot texture views. `views[k]` binds to + /// texture slot `k` (the `tfetch` fetch-constant index the shader selects); + /// `None`, or any slot past `views.len()`/`TEX_SLOTS`, falls back to the + /// transparent dummy. The shared sampler stays at binding 0. Each call + /// rebuilds the bind group, so per-draw slot sets composite correctly. + pub fn set_texture_slots( + &mut self, + device: &wgpu::Device, + views: &[Option<&wgpu::TextureView>], + ) { + let mut entries: Vec = Vec::with_capacity(TEX_SLOTS + 1); + entries.push(wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }); + for k in 0..TEX_SLOTS { + let v = views.get(k).copied().flatten().unwrap_or(&self.dummy_view); + entries.push(wgpu::BindGroupEntry { + binding: (k + 1) as u32, + resource: wgpu::BindingResource::TextureView(v), + }); + } self.tex_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("xenos tex bind group (rebind)"), + label: Some("xenos tex bind group (slots)"), layout: &self.tex_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(bound), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&self.sampler), - }, - ], + entries: &entries, }); }