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

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

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

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

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

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

1051 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Texture cache — P5.
//!
//! Two-layer design mirroring canary's `TextureCache`:
//!
//! * **CPU layer** (this module): owns decoded, linear, host-endian texel
//! byte buffers keyed by [`TextureKey`]. `ensure_cached` consults the
//! guest memory's page-version counter to decide whether the cached
//! bytes are still fresh and re-decodes on miss or staleness.
//! * **GPU layer** (xenia-ui `texture_cache_host`): owns the
//! `wgpu::Texture` + `TextureView` for each cached key; pulls decoded
//! bytes from this CPU layer on upload.
//!
//! Canary references: `texture_cache.h/.cc`, `texture_info.cc`, and
//! `texture_info_formats.inl` for the format table.
use std::collections::HashMap;
use crate::tiled_address;
/// Xenos texture formats — `xenos::TextureFormat` at `xenos.h:489-579`.
/// Values are the raw enum numbers the guest writes into
/// `xe_gpu_texture_fetch_t.format`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum TextureFormat {
K1Reverse = 0,
K1 = 1,
K8 = 2,
K1555 = 3,
K565 = 4,
K6_5_5 = 5,
K8888 = 6,
K1010102 = 7,
K8_8 = 10,
K4_4_4_4 = 15,
K10_11_11 = 16,
K11_11_10 = 17,
Dxt1 = 18,
Dxt2_3 = 19,
Dxt4_5 = 20,
K24_8 = 22,
K24_8Float = 23,
K16 = 24,
K16_16 = 25,
K16_16_16_16 = 26,
K16Float = 30,
K16_16Float = 31,
K16_16_16_16Float = 32,
K32 = 33,
K32_32 = 34,
K32_32_32_32 = 35,
K32Float = 36,
K32_32Float = 37,
K32_32_32_32Float = 38,
Unknown(u8),
}
impl TextureFormat {
pub fn from_raw(v: u8) -> Self {
use TextureFormat::*;
match v & 0x3F {
0 => K1Reverse,
1 => K1,
2 => K8,
3 => K1555,
4 => K565,
5 => K6_5_5,
6 => K8888,
7 => K1010102,
10 => K8_8,
15 => K4_4_4_4,
16 => K10_11_11,
17 => K11_11_10,
18 => Dxt1,
19 => Dxt2_3,
20 => Dxt4_5,
22 => K24_8,
23 => K24_8Float,
24 => K16,
25 => K16_16,
26 => K16_16_16_16,
30 => K16Float,
31 => K16_16Float,
32 => K16_16_16_16Float,
33 => K32,
34 => K32_32,
35 => K32_32_32_32,
36 => K32Float,
37 => K32_32Float,
38 => K32_32_32_32Float,
other => Unknown(other),
}
}
/// Block width/height in texels + bytes-per-block. For uncompressed
/// formats block_w = block_h = 1. For DXT formats block_w = block_h =
/// 4 (one 4×4 compressed block).
pub fn block_info(self) -> BlockInfo {
use TextureFormat::*;
match self {
K1Reverse | K1 => BlockInfo::new(1, 1, 1), // round up to 1 byte
K8 => BlockInfo::new(1, 1, 1),
K1555 | K565 | K6_5_5 | K4_4_4_4 | K16 | K16Float | K8_8 => BlockInfo::new(1, 1, 2),
K8888 | K1010102 | K10_11_11 | K11_11_10 | K24_8 | K24_8Float | K16_16
| K16_16Float | K32 | K32Float => BlockInfo::new(1, 1, 4),
K16_16_16_16 | K16_16_16_16Float | K32_32 | K32_32Float => BlockInfo::new(1, 1, 8),
K32_32_32_32 | K32_32_32_32Float => BlockInfo::new(1, 1, 16),
Dxt1 => BlockInfo::new(4, 4, 8),
Dxt2_3 | Dxt4_5 => BlockInfo::new(4, 4, 16),
Unknown(_) => BlockInfo::new(1, 1, 4), // safe-ish fallback
}
}
/// True iff this format lands on a wgpu texture format we can
/// natively bind — no CPU-side conversion per frame required. M5
/// adds `k_5_6_5` (CPU-expanded to `Rgba8Unorm` on decode; still
/// counts as supported for the host-cache wiring), `k_DXT2_3`
/// (BC2), and `k_DXT4_5` (BC3).
pub fn is_host_supported(self) -> bool {
matches!(
self,
TextureFormat::K8
| TextureFormat::K8888
| TextureFormat::K565
| TextureFormat::Dxt1
| TextureFormat::Dxt2_3
| TextureFormat::Dxt4_5
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct BlockInfo {
pub block_w: u8,
pub block_h: u8,
pub bytes_per_block: u8,
}
impl BlockInfo {
pub const fn new(block_w: u8, block_h: u8, bytes_per_block: u8) -> Self {
Self {
block_w,
block_h,
bytes_per_block,
}
}
pub fn log2_bpb(self) -> u32 {
match self.bytes_per_block {
1 => 0,
2 => 1,
4 => 2,
8 => 3,
16 => 4,
_ => 0,
}
}
}
/// Xenos `Endian` enum from `xenos.h:198-204`. 2-bit field in fetch dword 1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Endian {
None = 0,
Swap8In16 = 1,
Swap8In32 = 2,
Swap16In32 = 3,
}
impl Endian {
pub fn from_raw(v: u8) -> Self {
match v & 0x3 {
1 => Endian::Swap8In16,
2 => Endian::Swap8In32,
3 => Endian::Swap16In32,
_ => Endian::None,
}
}
/// Apply this endian's byte swap to one 32-bit unit. Matches canary's
/// `shaders/endian.xesli:25-55` semantics; the WGSL translator pulls
/// the same mask-shift pattern.
pub fn swap32(self, v: u32) -> u32 {
match self {
Endian::None => v,
Endian::Swap8In16 => ((v & 0x00FF_00FF) << 8) | ((v & 0xFF00_FF00) >> 8),
Endian::Swap8In32 => v.swap_bytes(),
Endian::Swap16In32 => v.rotate_right(16),
}
}
}
/// Texture dimensionality (`xenos::DataDimension`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Dimension {
D1 = 0,
D2 = 1,
D3Stacked = 2,
Cube = 3,
}
impl Dimension {
pub fn from_raw(v: u8) -> Self {
match v & 0x3 {
1 => Dimension::D2,
2 => Dimension::D3Stacked,
3 => Dimension::Cube,
_ => Dimension::D1,
}
}
}
/// Identity of a cached texture. Matches canary's `TextureCache::TextureKey`
/// at the semantic level — we exclude mip/border state for P5 since neither
/// is populated yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TextureKey {
/// Guest physical base (byte address — already shifted left by 12 from
/// the fetch-constant `base_address` field).
pub base_address: u32,
pub width: u16,
pub height: u16,
pub depth_or_slices: u16,
pub format: TextureFormat,
pub endian: Endian,
pub dimension: Dimension,
pub tiled: bool,
/// Row pitch in texels, already aligned to 32. Canary stores pitch/32
/// in the fetch constant; we keep the raw texel count to avoid
/// callers remembering to shift.
pub pitch_texels: u16,
}
/// Decode a 6-dword texture fetch constant (layout at `xenos.h:1229-1329`).
/// Returns `None` if the constant is obviously unset (all zeros) or if
/// `type` is not the texture-constant marker.
pub fn decode_fetch_constant(dwords: [u32; 6]) -> Option<TextureKey> {
let d0 = dwords[0];
let d1 = dwords[1];
let d2 = dwords[2];
let d5 = dwords[5];
// type: low 2 bits of dword 0 should be 2 (texture) per canary —
// 0 = vertex, 2 = texture. An all-zero constant reads as type 0 so
// `None` filters it out here.
let ty = d0 & 0x3;
if d0 == 0 && d1 == 0 {
return None;
}
// Not a texture constant (e.g. 0 = vertex fetch constant reused).
if ty != 2 {
return None;
}
let pitch_5 = (d0 >> 22) & 0x1FF; // pitch/32 in texels
let tiled = ((d0 >> 31) & 1) != 0;
let format = TextureFormat::from_raw((d1 & 0x3F) as u8);
let endian = Endian::from_raw(((d1 >> 6) & 0x3) as u8);
let base_address = (d1 >> 12) << 12; // base >> 12, re-shifted.
let dim = Dimension::from_raw(((d5 >> 9) & 0x3) as u8);
// Size decode depends on dimension.
let (width, height, depth) = match dim {
Dimension::D1 => ((d2 & 0x00FF_FFFF) as u16 + 1, 1u16, 1u16),
Dimension::D2 => (
(d2 & 0x1FFF) as u16 + 1,
((d2 >> 13) & 0x1FFF) as u16 + 1,
((d2 >> 26) & 0x3F) as u16 + 1,
),
Dimension::D3Stacked | Dimension::Cube => (
(d2 & 0x7FF) as u16 + 1,
((d2 >> 11) & 0x7FF) as u16 + 1,
((d2 >> 22) & 0x3FF) as u16 + 1,
),
};
Some(TextureKey {
base_address,
width,
height,
depth_or_slices: depth,
format,
endian,
dimension: dim,
tiled,
pitch_texels: ((pitch_5 as u16) * 32).max(width),
})
}
/// Decoded, linear, host-endian texture bytes ready for wgpu upload.
#[derive(Debug, Clone)]
pub struct CachedTexture {
pub key: TextureKey,
pub version_when_uploaded: u64,
/// Tightly packed. Layout depends on `key.format`:
/// - `K8888` → `width*height*4` bytes in Rgba8Unorm order.
/// - `Dxt1` → `ceil(w/4)*ceil(h/4)*8` bytes of raw BC1 blocks, after
/// block-level detile + dword-endian swap.
pub bytes: Vec<u8>,
}
impl CachedTexture {
pub fn byte_size(&self) -> usize {
self.bytes.len()
}
}
/// Errors that can happen during decode. The `ensure_cached` caller maps
/// these to `gpu.texture.reject{reason}` metrics so the HUD surfaces when
/// a texture fell back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecodeError {
UnsupportedFormat,
OutOfBounds,
ZeroSize,
}
/// Read `len` bytes from guest memory starting at `addr`. Returns `None`
/// if the span would exceed the memory's reported end; otherwise returns
/// a freshly-allocated buffer with the bytes.
///
/// The `MemoryAccess` trait exposes per-byte reads only; we batch them in
/// a single pass to avoid the per-byte virtual dispatch overhead for large
/// textures (1 MiB frontbuffer = 1M dispatch calls).
pub fn read_guest_bytes(
mem: &dyn xenia_memory::MemoryAccess,
addr: u32,
len: usize,
) -> Vec<u8> {
let mut out = Vec::with_capacity(len);
for i in 0..len {
let a = addr.wrapping_add(i as u32);
out.push(mem.read_u8(a));
if a < addr {
// 32-bit overflow; unmap the tail.
break;
}
}
out
}
/// Byte-swap the 32-bit dwords of `buf` in place according to `endian`.
/// `buf.len()` should be a multiple of 4; tail bytes are left untouched.
pub fn apply_endian_32(buf: &mut [u8], endian: Endian) {
if matches!(endian, Endian::None) {
return;
}
let mut i = 0;
while i + 4 <= buf.len() {
let v = u32::from_le_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]);
let swapped = endian.swap32(v);
buf[i..i + 4].copy_from_slice(&swapped.to_le_bytes());
i += 4;
}
}
/// Decode a k_8_8_8_8 texture out of guest memory into `Rgba8Unorm` bytes.
/// Applies Xenos→host channel swizzle (Xbox 360 stores BGRA in memory →
/// we emit RGBA for wgpu) and the declared endian swap, then detiles via
/// the Xenos Tiled2D formula.
pub fn decode_k8888_tiled(
key: &TextureKey,
mem: &dyn xenia_memory::MemoryAccess,
) -> Result<Vec<u8>, DecodeError> {
if key.width == 0 || key.height == 0 {
return Err(DecodeError::ZeroSize);
}
let w = key.width as u32;
let h = key.height as u32;
let pitch_aligned = tiled_address::align_pitch_to_macro_tile(key.pitch_texels as u32);
let total_bytes = (pitch_aligned * h * 4) as usize;
let mut raw = read_guest_bytes(mem, key.base_address, total_bytes);
if raw.len() < total_bytes {
return Err(DecodeError::OutOfBounds);
}
apply_endian_32(&mut raw, key.endian);
let mut linear = vec![0u8; (w * h * 4) as usize];
if key.tiled {
if tiled_address::detile_2d(&raw, &mut linear, w, h, pitch_aligned, 4).is_err() {
return Err(DecodeError::OutOfBounds);
}
} else {
// Non-tiled copy row-by-row honoring pitch.
for y in 0..h as usize {
let src = y * (pitch_aligned as usize) * 4;
let dst = y * (w as usize) * 4;
linear[dst..dst + (w as usize) * 4]
.copy_from_slice(&raw[src..src + (w as usize) * 4]);
}
}
// Xenos stores `k_8_8_8_8` in ARGB byte order (high nibble = A). After
// endian.Swap8In32 guests' typical per-dword byte order becomes BGRA
// in little-endian host bytes. Swap B↔R so we hand Rgba8Unorm to wgpu.
for px in linear.chunks_exact_mut(4) {
px.swap(0, 2);
}
Ok(linear)
}
/// Decode a `k_8` (single 8-bit channel) texture into `Rgba8Unorm` bytes,
/// replicating the lone component into R, G, B and A so the guest sampler
/// reads the value back on whatever channel its swizzle selects (we do not
/// apply the fetch-constant swizzle downstream). Used by the intro-video
/// YUV420 planes — Y at full resolution and U/V at half — which the guest
/// uploads as **linear** `k_8` textures and its pixel shader converts
/// YUV→RGB. `k_8` is one byte per texel, so the 32-bit endian swap that the
/// packed formats need does not apply here.
pub fn decode_k8(
key: &TextureKey,
mem: &dyn xenia_memory::MemoryAccess,
) -> Result<Vec<u8>, DecodeError> {
if key.width == 0 || key.height == 0 {
return Err(DecodeError::ZeroSize);
}
let w = key.width as u32;
let h = key.height as u32;
let pitch_aligned = tiled_address::align_pitch_to_macro_tile(key.pitch_texels as u32);
let total_bytes = (pitch_aligned * h) as usize; // 1 byte per texel
let raw = read_guest_bytes(mem, key.base_address, total_bytes);
if raw.len() < total_bytes {
return Err(DecodeError::OutOfBounds);
}
// Gather one byte per texel into a tightly-packed w*h plane, honoring the
// row pitch (linear) or the Xenos tiling (tiled — bytes_per_element = 1).
let mut plane = vec![0u8; (w * h) as usize];
if key.tiled {
if tiled_address::detile_2d(&raw, &mut plane, w, h, pitch_aligned, 1).is_err() {
return Err(DecodeError::OutOfBounds);
}
} else {
for y in 0..h as usize {
let src = y * (pitch_aligned as usize);
let dst = y * (w as usize);
plane[dst..dst + w as usize].copy_from_slice(&raw[src..src + w as usize]);
}
}
// Expand to Rgba8Unorm, replicating the component to every channel.
let mut rgba = vec![0u8; (w * h * 4) as usize];
for (i, &v) in plane.iter().enumerate() {
let o = i * 4;
rgba[o] = v;
rgba[o + 1] = v;
rgba[o + 2] = v;
rgba[o + 3] = v;
}
Ok(rgba)
}
/// Decode a DXT-compressed texture to raw block bytes (no format
/// conversion — wgpu understands `Bc{1,2,3}RgbaUnorm` natively so the
/// GPU does the actual decompression on upload).
///
/// Xenos stores DXT blocks in 4×4 block-tiled order using the Tiled2D
/// formula, with stride counted in blocks. `bytes_per_block` is 8 for
/// BC1 (DXT1), 16 for BC2 (DXT2_3) and BC3 (DXT4_5).
pub fn decode_dxt_tiled(
key: &TextureKey,
mem: &dyn xenia_memory::MemoryAccess,
bytes_per_block: u32,
) -> Result<Vec<u8>, DecodeError> {
if key.width == 0 || key.height == 0 {
return Err(DecodeError::ZeroSize);
}
let block_w = 4u32;
let block_h = 4u32;
let w_blocks = (key.width as u32).div_ceil(block_w);
let h_blocks = (key.height as u32).div_ceil(block_h);
let pitch_blocks = tiled_address::align_pitch_to_macro_tile(
(key.pitch_texels as u32).div_ceil(block_w),
);
let total_bytes = (pitch_blocks * h_blocks * bytes_per_block) as usize;
let mut raw = read_guest_bytes(mem, key.base_address, total_bytes);
if raw.len() < total_bytes {
return Err(DecodeError::OutOfBounds);
}
// DXT blocks are stored as 4×u16 + 4×u8-indices (BC1) or similar
// u16/u32-width fields for BC2/BC3; the Xbox 360's big-endian word
// order requires an endian swap at the u16/u32 level regardless of
// which BC-family format.
apply_endian_32(&mut raw, key.endian);
let mut out = vec![0u8; (w_blocks * h_blocks * bytes_per_block) as usize];
if key.tiled {
if tiled_address::detile_2d(
&raw,
&mut out,
w_blocks,
h_blocks,
pitch_blocks,
bytes_per_block,
)
.is_err()
{
return Err(DecodeError::OutOfBounds);
}
} else {
for y in 0..h_blocks as usize {
let src = y * (pitch_blocks as usize) * (bytes_per_block as usize);
let dst = y * (w_blocks as usize) * (bytes_per_block as usize);
out[dst..dst + (w_blocks as usize) * (bytes_per_block as usize)]
.copy_from_slice(&raw[src..src + (w_blocks as usize) * (bytes_per_block as usize)]);
}
}
Ok(out)
}
/// BC1 / DXT1 — 8-byte blocks.
pub fn decode_dxt1_tiled(
key: &TextureKey,
mem: &dyn xenia_memory::MemoryAccess,
) -> Result<Vec<u8>, DecodeError> {
decode_dxt_tiled(key, mem, 8)
}
/// BC2 / DXT2_3 — 16-byte blocks.
pub fn decode_dxt23_tiled(
key: &TextureKey,
mem: &dyn xenia_memory::MemoryAccess,
) -> Result<Vec<u8>, DecodeError> {
decode_dxt_tiled(key, mem, 16)
}
/// BC3 / DXT4_5 — 16-byte blocks.
pub fn decode_dxt45_tiled(
key: &TextureKey,
mem: &dyn xenia_memory::MemoryAccess,
) -> Result<Vec<u8>, DecodeError> {
decode_dxt_tiled(key, mem, 16)
}
/// **k_5_6_5** — 16-bit R:5 G:6 B:5 per texel (Xbox stores R in the high
/// 5 bits of the 16-bit word). We unpack each texel into 4 bytes of
/// `Rgba8Unorm` (A = 0xFF). wgpu doesn't ship `R5G6B5Unorm` as a
/// sampled texture format on every backend, so CPU-side conversion is
/// the safe path even if it's 2× the texture memory.
///
/// Tiling: Tiled2D at the **texel** level (block = 1 texel = 2 bytes),
/// then we expand each 2-byte u16 into the 4-byte Rgba8 in the linear
/// output buffer.
pub fn decode_k565_tiled(
key: &TextureKey,
mem: &dyn xenia_memory::MemoryAccess,
) -> Result<Vec<u8>, DecodeError> {
if key.width == 0 || key.height == 0 {
return Err(DecodeError::ZeroSize);
}
let w = key.width as u32;
let h = key.height as u32;
// Pitch/block counts — block = 1 texel here, 2 bytes.
let pitch_aligned = tiled_address::align_pitch_to_macro_tile(key.pitch_texels as u32);
let total_bytes = (pitch_aligned * h * 2) as usize;
let mut raw = read_guest_bytes(mem, key.base_address, total_bytes);
if raw.len() < total_bytes {
return Err(DecodeError::OutOfBounds);
}
// 16-bit word order is endian-swap-sensitive.
apply_endian_32(&mut raw, key.endian);
// Step 1: detile (bytes_per_block=2, tile in blocks=texels).
let mut linear_u16 = vec![0u8; (w * h * 2) as usize];
if key.tiled {
if tiled_address::detile_2d(&raw, &mut linear_u16, w, h, pitch_aligned, 2).is_err() {
return Err(DecodeError::OutOfBounds);
}
} else {
for y in 0..h as usize {
let src = y * (pitch_aligned as usize) * 2;
let dst = y * (w as usize) * 2;
linear_u16[dst..dst + (w as usize) * 2]
.copy_from_slice(&raw[src..src + (w as usize) * 2]);
}
}
// Step 2: expand each 16-bit RGB565 to Rgba8Unorm. The in-memory u16
// is little-endian after `apply_endian_32` has normalized the word
// order (we keep host-native byte ordering post-swap).
let mut rgba = vec![0u8; (w * h * 4) as usize];
for y in 0..h as usize {
for x in 0..w as usize {
let off = (y * w as usize + x) * 2;
let lo = linear_u16[off];
let hi = linear_u16[off + 1];
let word = u16::from_le_bytes([lo, hi]);
// 5 bits R (bits 11-15), 6 bits G (5-10), 5 bits B (0-4).
// Expand to full-range u8: replicate high bits into low
// (so 0b11111 → 0xFF, matching the standard 565→888 convention).
let r5 = ((word >> 11) & 0x1F) as u8;
let g6 = ((word >> 5) & 0x3F) as u8;
let b5 = (word & 0x1F) as u8;
let r = (r5 << 3) | (r5 >> 2);
let g = (g6 << 2) | (g6 >> 4);
let b = (b5 << 3) | (b5 >> 2);
let o = (y * w as usize + x) * 4;
rgba[o] = r;
rgba[o + 1] = g;
rgba[o + 2] = b;
rgba[o + 3] = 0xFF;
}
}
Ok(rgba)
}
/// Version-aware CPU-side texture cache. Entries are keyed on
/// `TextureKey.hash` and carry a `version_when_uploaded` watermark against
/// the guest memory's page-version counter. `ensure_cached` queries
/// `GuestMemory::max_page_version` over the texture's byte span; if the
/// span has been written since cache time, the entry is re-decoded.
pub struct TextureCache {
entries: HashMap<TextureKey, CachedTexture>,
/// Monotonic counter of decodes performed — HUD surface.
pub decodes_total: u64,
/// Count of stale-miss re-decodes.
pub restale_total: u64,
}
impl Default for TextureCache {
fn default() -> Self {
Self::new()
}
}
impl TextureCache {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
decodes_total: 0,
restale_total: 0,
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn get(&self, key: &TextureKey) -> Option<&CachedTexture> {
self.entries.get(key)
}
/// Return a cached (or freshly-decoded) texture. The caller supplies
/// the current guest-memory page version covering the texture span;
/// see [`max_page_version_for`].
pub fn ensure_cached(
&mut self,
key: TextureKey,
current_version: u64,
mem: &dyn xenia_memory::MemoryAccess,
) -> Result<&CachedTexture, DecodeError> {
// Fast path: fresh entry exists.
if let Some(e) = self.entries.get(&key) {
if e.version_when_uploaded >= current_version {
return Ok(self.entries.get(&key).unwrap());
}
self.restale_total += 1;
}
let _prof_t0 = std::time::Instant::now();
let bytes = match key.format {
TextureFormat::K8 => decode_k8(&key, mem)?,
TextureFormat::K8888 => decode_k8888_tiled(&key, mem)?,
TextureFormat::K565 => decode_k565_tiled(&key, mem)?,
TextureFormat::Dxt1 => decode_dxt1_tiled(&key, mem)?,
TextureFormat::Dxt2_3 => decode_dxt23_tiled(&key, mem)?,
TextureFormat::Dxt4_5 => decode_dxt45_tiled(&key, mem)?,
_ => {
// XENIA_TEX_REJECT_LOG diagnostic (STEP 87, read-only): the
// intro video uploads its YUV420 planes as `k_8` linear
// textures; if we have no decoder for the requested format we
// reject it here and it never reaches the GPU (→ black video).
// Rate-limited so a per-frame flood stays grep-able.
use std::sync::atomic::{AtomicUsize, Ordering};
static N: AtomicUsize = AtomicUsize::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
if n < 120 {
tracing::warn!(
fmt = ?key.format,
w = key.width,
h = key.height,
base = format_args!("0x{:08x}", key.base_address),
dim = ?key.dimension,
pitch = key.pitch_texels,
n,
"TEX-REJECT: unsupported texture format (no host decoder)"
);
}
return Err(DecodeError::UnsupportedFormat);
}
};
self.decodes_total += 1;
{
use crate::prof;
prof::add(&prof::TEXDEC_NS, _prof_t0.elapsed().as_nanos() as u64);
prof::add(&prof::TEXDEC_CALLS, 1);
prof::add(&prof::TEXDEC_BYTES, bytes.len() as u64);
}
let entry = CachedTexture {
key,
version_when_uploaded: current_version,
bytes,
};
self.entries.insert(key, entry);
Ok(self.entries.get(&key).unwrap())
}
pub fn byte_budget(&self) -> usize {
self.entries.values().map(|e| e.byte_size()).sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
struct FakeMem(Box<[Cell<u8>]>);
impl FakeMem {
fn from_vec(v: Vec<u8>) -> Self {
FakeMem(v.into_iter().map(Cell::new).collect())
}
}
impl xenia_memory::MemoryAccess for FakeMem {
fn read_u8(&self, a: u32) -> u8 {
self.0.get(a as usize).map(|c| c.get()).unwrap_or(0)
}
fn read_u16(&self, a: u32) -> u16 {
u16::from_be_bytes([self.read_u8(a), self.read_u8(a + 1)])
}
fn read_u32(&self, a: u32) -> u32 {
u32::from_be_bytes([
self.read_u8(a),
self.read_u8(a + 1),
self.read_u8(a + 2),
self.read_u8(a + 3),
])
}
fn read_u64(&self, a: u32) -> u64 {
u64::from_be_bytes([
self.read_u8(a),
self.read_u8(a + 1),
self.read_u8(a + 2),
self.read_u8(a + 3),
self.read_u8(a + 4),
self.read_u8(a + 5),
self.read_u8(a + 6),
self.read_u8(a + 7),
])
}
fn write_u8(&self, a: u32, v: u8) {
if let Some(slot) = self.0.get(a as usize) {
slot.set(v);
}
}
fn write_u16(&self, a: u32, v: u16) {
let b = v.to_be_bytes();
self.write_u8(a, b[0]);
self.write_u8(a + 1, b[1]);
}
fn write_u32(&self, a: u32, v: u32) {
let b = v.to_be_bytes();
for i in 0..4 {
self.write_u8(a + i as u32, b[i]);
}
}
fn write_u64(&self, a: u32, v: u64) {
let b = v.to_be_bytes();
for i in 0..8 {
self.write_u8(a + i as u32, b[i]);
}
}
fn translate(&self, _: u32) -> Option<*const u8> {
None
}
fn translate_mut(&self, _: u32) -> Option<*mut u8> {
None
}
}
#[test]
fn format_block_info_matches_canary_expectations() {
assert_eq!(
TextureFormat::K8888.block_info(),
BlockInfo::new(1, 1, 4)
);
assert_eq!(TextureFormat::Dxt1.block_info(), BlockInfo::new(4, 4, 8));
assert_eq!(
TextureFormat::Dxt4_5.block_info(),
BlockInfo::new(4, 4, 16)
);
}
#[test]
fn endian_swap_variants() {
assert_eq!(Endian::None.swap32(0x11223344), 0x11223344);
assert_eq!(Endian::Swap8In16.swap32(0x11223344), 0x22114433);
assert_eq!(Endian::Swap8In32.swap32(0x11223344), 0x44332211);
assert_eq!(Endian::Swap16In32.swap32(0x11223344), 0x33441122);
}
#[test]
fn decode_fetch_constant_rejects_empty() {
let z = [0u32; 6];
assert!(decode_fetch_constant(z).is_none());
}
#[test]
fn decode_fetch_constant_parses_2d_k8888() {
// Build a synthetic k_8_8_8_8 2D texture fetch constant:
// dword0: pitch_5=40 (1280/32), tiled=1, type=2
// dword1: format=6 (K8888), endian=2 (Swap8In32), base=0xAB000>>12
// dword2: width-1=1279, height-1=719
// dword5: dimension=1 (2D)
let d0 = 0x8000_0000 | (40u32 << 22) | 2;
let d1 = (0xAB000u32 >> 12 << 12) | (2u32 << 6) | 6u32;
let d2 = 1279u32 | ((719u32) << 13);
let d5 = 1u32 << 9;
let k = decode_fetch_constant([d0, d1, d2, 0, 0, d5]).expect("parsed");
assert_eq!(k.format, TextureFormat::K8888);
assert_eq!(k.endian, Endian::Swap8In32);
assert_eq!(k.width, 1280);
assert_eq!(k.height, 720);
assert_eq!(k.dimension, Dimension::D2);
assert!(k.tiled);
assert_eq!(k.pitch_texels, 1280);
}
#[test]
fn decode_k8888_roundtrip_linear() {
// Build a 4×4 non-tiled image with pitch=32 (one macro-tile row).
// Each pixel at (x, y) stores ARGB = (0xFF, x, y, y*4+x) as a
// big-endian dword. After Swap8In32 + B↔R swizzle, out[off..] must
// be (x, y, y*4+x, 0xFF) in RGBA order.
let w = 4u32;
let h = 4u32;
let pitch = 32u32;
let mut bytes = vec![0u8; (pitch * h * 4) as usize];
for y in 0..h {
for x in 0..w {
let off = ((y * pitch + x) * 4) as usize;
let argb = (0xFFu32 << 24)
| ((x as u32) << 16)
| ((y as u32) << 8)
| ((y * 4 + x) as u32);
bytes[off..off + 4].copy_from_slice(&argb.to_be_bytes());
}
}
let mem = FakeMem::from_vec(bytes);
let key = TextureKey {
base_address: 0,
width: 4,
height: 4,
depth_or_slices: 1,
format: TextureFormat::K8888,
endian: Endian::Swap8In32,
dimension: Dimension::D2,
tiled: false,
pitch_texels: pitch as u16,
};
let out = decode_k8888_tiled(&key, &mem).expect("decode");
assert_eq!(out.len(), 16 * 4);
assert_eq!(&out[0..4], &[0, 0, 0, 0xFF]);
let off = ((3 * 4 + 3) * 4) as usize;
assert_eq!(&out[off..off + 4], &[3, 3, 15, 0xFF]);
}
// ── First-Pixels M5 format tests ──────────────────────────────
/// BC2 (DXT2_3) roundtrip: 16-byte blocks, 4×4 image = 1 block.
/// Synthetic source of 0xDEADBEEF... bytes; assert the decoder
/// returns the same bytes (passthrough after endian swap).
#[test]
fn decode_dxt23_small_roundtrip() {
// 4×4 texture = 1 BC2 block (16 bytes). With pitch_texels=32
// (macro-tile-aligned) the block pitch is 8 (=32/4), and we
// allocate 8*1*16 = 128 bytes of source.
let mut bytes = vec![0u8; 128];
for (i, b) in bytes.iter_mut().enumerate().take(16) {
*b = i as u8;
}
let mem = FakeMem::from_vec(bytes);
let key = TextureKey {
base_address: 0,
width: 4,
height: 4,
depth_or_slices: 1,
format: TextureFormat::Dxt2_3,
endian: Endian::None, // no swap — we can eyeball passthrough
dimension: Dimension::D2,
tiled: false,
pitch_texels: 32,
};
let out = decode_dxt23_tiled(&key, &mem).expect("decode");
assert_eq!(out.len(), 16); // 1 block × 16 bytes
for i in 0..16 {
assert_eq!(out[i], i as u8);
}
}
/// BC3 (DXT4_5) uses the same 16-byte block infra as BC2; a
/// parallel test prevents a regression that sneaks up via the
/// generic `decode_dxt_tiled`.
#[test]
fn decode_dxt45_uses_16byte_blocks() {
let mem = FakeMem::from_vec(vec![0xAAu8; 256]);
let key = TextureKey {
base_address: 0,
width: 8,
height: 4, // 2×1 blocks
depth_or_slices: 1,
format: TextureFormat::Dxt4_5,
endian: Endian::None,
dimension: Dimension::D2,
tiled: false,
pitch_texels: 32,
};
let out = decode_dxt45_tiled(&key, &mem).expect("decode");
assert_eq!(out.len(), 2 * 16);
}
/// k_5_6_5: a single white texel (all bits set, 0xFFFF) should
/// expand to RGBA8 white (0xFF, 0xFF, 0xFF, 0xFF). A single pure-red
/// texel (R=31, G=0, B=0 → word 0xF800) should expand to R=255 G=0
/// B=0 via the high-bit-replicate convention.
#[test]
fn decode_k565_texel_expansion() {
// Memory layout for a 2×1 non-tiled k_5_6_5 image (pitch=32 texels
// → 32 × 1 × 2 = 64 bytes). We store texel[0] = 0xFFFF (white),
// texel[1] = 0xF800 (pure red).
let mut bytes = vec![0u8; 64];
// 0xFFFF
bytes[0] = 0xFF;
bytes[1] = 0xFF;
// 0xF800 (big-endian memory): high byte 0xF8, low 0x00.
// But after apply_endian_32(Endian::None) we use little-endian
// word decoding — so memory must carry the bytes in LE order.
bytes[2] = 0x00;
bytes[3] = 0xF8;
let mem = FakeMem::from_vec(bytes);
let key = TextureKey {
base_address: 0,
width: 2,
height: 1,
depth_or_slices: 1,
format: TextureFormat::K565,
endian: Endian::None,
dimension: Dimension::D2,
tiled: false,
pitch_texels: 32,
};
let out = decode_k565_tiled(&key, &mem).expect("decode");
assert_eq!(out.len(), 2 * 4);
// Texel 0: white.
assert_eq!(&out[0..4], &[0xFF, 0xFF, 0xFF, 0xFF]);
// Texel 1: pure red via 5-bit-expand (0b11111 → 0xFF).
assert_eq!(&out[4..8], &[0xFF, 0x00, 0x00, 0xFF]);
}
#[test]
fn is_host_supported_covers_m5_formats() {
assert!(TextureFormat::K8888.is_host_supported());
assert!(TextureFormat::K565.is_host_supported());
assert!(TextureFormat::Dxt1.is_host_supported());
assert!(TextureFormat::Dxt2_3.is_host_supported());
assert!(TextureFormat::Dxt4_5.is_host_supported());
// Unsupported formats should still report false.
assert!(!TextureFormat::K16.is_host_supported());
assert!(!TextureFormat::K32Float.is_host_supported());
}
#[test]
fn texture_cache_caches_and_reuses() {
let mut cache = TextureCache::new();
let mem = FakeMem::from_vec(vec![0u8; 8 * 1024]);
let key = TextureKey {
base_address: 0,
width: 4,
height: 4,
depth_or_slices: 1,
format: TextureFormat::K8888,
endian: Endian::None,
dimension: Dimension::D2,
tiled: false,
pitch_texels: 32,
};
cache.ensure_cached(key, 0, &mem).unwrap();
assert_eq!(cache.decodes_total, 1);
// Same version: should hit cache.
cache.ensure_cached(key, 0, &mem).unwrap();
assert_eq!(cache.decodes_total, 1);
// Higher version: stale → re-decode.
cache.ensure_cached(key, 1, &mem).unwrap();
assert_eq!(cache.decodes_total, 2);
assert_eq!(cache.restale_total, 1);
}
/// End-to-end P5 test: a 6-dword fetch constant → decoded `TextureKey`
/// → `ensure_cached` on fresh/version-bumped memory → stale re-decode.
/// Mirrors what `vd_swap` does per frame.
#[test]
fn e2e_fetch_const_to_cache_with_versioning() {
// 4×4 k_8_8_8_8 2D tiled texture at base 0x100, pitch=32 aligned.
let d0 = 0x8000_0000u32 | (1u32 << 22) | 2; // pitch_5=1, tiled, type=2
let d1 = (0x100u32 >> 12 << 12) | (0u32 << 6) | 6; // K8888, endian=none
let d2 = 3u32 | (3u32 << 13); // width-1=3, height-1=3
let d5 = 1u32 << 9; // 2D
let key = decode_fetch_constant([d0, d1, d2, 0, 0, d5]).expect("decoded");
assert_eq!(key.format, TextureFormat::K8888);
assert_eq!(key.width, 4);
let mut mem = FakeMem::from_vec(vec![0xAAu8; 4 * 1024]);
let mut cache = TextureCache::new();
// v0 decode.
let first = cache
.ensure_cached(key, 0, &mem)
.expect("initial decode")
.clone();
// Same version → cache hit.
cache.ensure_cached(key, 0, &mem).expect("hit");
assert_eq!(cache.decodes_total, 1);
// Simulate the guest writing to the texture's pages: version bumps.
for b in &mem.0[..16] {
b.set(0xFF);
}
cache.ensure_cached(key, 1, &mem).expect("re-decode");
assert_eq!(cache.decodes_total, 2);
assert_eq!(cache.restale_total, 1);
// Bytes differ from v0 (proof the re-decode happened).
let second = cache.get(&key).unwrap();
assert_ne!(first.bytes, second.bytes);
}
#[test]
fn texture_cache_rejects_unsupported_format() {
let mut cache = TextureCache::new();
let mem = FakeMem::from_vec(vec![0u8; 1024]);
let key = TextureKey {
base_address: 0,
width: 4,
height: 4,
depth_or_slices: 1,
format: TextureFormat::K16,
endian: Endian::None,
dimension: Dimension::D2,
tiled: false,
pitch_texels: 32,
};
assert!(matches!(
cache.ensure_cached(key, 0, &mem),
Err(DecodeError::UnsupportedFormat)
));
}
}