feat(viewer): world cubemap (skybox) viewer — 6-face grid

TXCM XPR2 resources are the game's world skyboxes (BG_Acheron, BG_Hargenteen,
…). The viewer showed only face 0 as a flat 2D image; now it decodes all 6 faces
and shows a labelled grid.

Formats (Bevy-free): X360Texture::cube_faces_from_xpr2() returns a Cubemap with 6
faces in D3D9 order (+X −X +Y −Y +Z −Z), or None for ordinary 2D textures. Face
layout derived from xenia's GetGuestTextureLayout — 6 back-to-back independently-
tiled surfaces, per-face stride = tiled-surface-size aligned to the 4 KiB
subresource boundary (kTextureSubresourceAlignmentBytes). Verified against real
BG_Acheron: data_size == 6 × 0x400000, and all 6 faces decode cleanly (own
Python decode + a disc test asserting 6×4 MiB faces and face 0 == the validated
from_xpr2 green-planet decode). Extracted a shared decode_surface() helper so the
2D and cube paths are byte-identical; from_xpr2 output unchanged (re-verified).

Viewer: new SkyboxPreview resource (6 egui face textures, reusing the existing
per-format x360_texture_to_bevy_image); populated in apply_loaded_texture's TXCM
branch; freed/reset alongside the other previews (factored free_texture/
free_skybox helpers). Central panel gains a skybox branch rendering a 3-column
labelled face grid.

DEFERRED (per "do not guess, else defer"): the interactive 3D skybox. Face data +
D3D9 order are validated, but wgpu cube-sampling handedness can't be confirmed
without eyeballing the GUI — a wrong-oriented skybox is worse than the correct
labelled grid. The grid is the reliable deliverable; the 3D look-around is a
follow-up once orientation is visually confirmed.

(Background agent did the investigation/validation but was blocked from writing
files; implemented here in the main tree from its findings, independently
re-verified.)

23 formats tests + disc cubemap test pass; viewer/CLI build; face-0 export
re-verified as the green planet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 13:38:07 +02:00
parent 765e4a573b
commit 589659bec6
4 changed files with 326 additions and 71 deletions

View File

@@ -193,6 +193,25 @@ pub struct X360Texture {
pub data: Vec<u8>,
}
/// A decoded Xbox 360 cubemap (`TXCM`) — a world skybox. The 6 faces are each a
/// de-tiled 2D surface in the same layout as [`X360Texture::data`], in D3D9
/// `D3DCUBEMAP_FACES` order.
#[derive(Debug, Clone)]
pub struct Cubemap {
pub width: u32,
pub height: u32,
pub format: X360TextureFormat,
/// Exactly 6 faces, D3D9 order: +X, -X, +Y, -Y, +Z, -Z.
pub faces: Vec<Vec<u8>>,
}
impl Cubemap {
/// The D3D9 cube-face name for slice `i` (0..6).
pub fn face_label(i: usize) -> &'static str {
["+X", "-X", "+Y", "-Y", "+Z", "-Z"].get(i).copied().unwrap_or("?")
}
}
impl X360Texture {
/// Parse the first TX2D texture from an XPR2 file's raw bytes.
///
@@ -283,66 +302,74 @@ impl X360Texture {
have: bytes.len(),
});
}
let raw_data = &bytes[data_start..];
// Debug knob: XPR_NO_DETILE=1 skips de-tiling (linear read) so the
// effect of de-tiling can be A/B-measured during RE validation.
let force_linear = std::env::var("XPR_NO_DETILE").is_ok();
let mut linear_data = if is_tiled && !force_linear {
detile(raw_data, width, height, format)?
} else {
// Linear layout — copy only the mip-0 slice
let block_size = format.block_size() as u32;
let bw = ((width + block_size - 1) / block_size).max(1);
let bh = ((height + block_size - 1) / block_size).max(1);
let needed = bw as usize * bh as usize * format.bytes_per_block();
if raw_data.len() < needed {
return Err(TextureError::BufferTooSmall { needed, have: raw_data.len() });
}
raw_data[..needed].to_vec()
};
// De-tile + endian-correct the single (face-0) surface.
let data = decode_surface(&bytes[data_start..], width, height, format, is_tiled, endianness)?;
// Undo the X360 word byte-swap so the block/pixel data is PC-standard
// little-endian. Debug knobs: XPR_NO_ENDIAN skips it; XPR_FORCE_ENDIAN=N
// overrides the mode (0..3) for A/B validation.
let endianness = std::env::var("XPR_FORCE_ENDIAN")
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data })
}
/// Decode all 6 faces of a cubemap (`TXCM`) resource, or `Ok(None)` if the
/// selected resource is an ordinary 2D texture.
///
/// X360 cubemaps store the 6 faces back-to-back in the pixel section, each a
/// full independently-tiled 2D surface whose stride is the tiled surface size
/// rounded up to a 4 KiB subresource boundary (`kTextureSubresourceAlignmentBytes`).
/// Faces are in D3D9 `D3DCUBEMAP_FACES` order: +X, -X, +Y, -Y, +Z, -Z.
/// (Derived from xenia `texture_util.cc::GetGuestTextureLayout`; verified on
/// `BG_Acheron`: `data_size == 6 × 0x400000` and all 6 faces decode cleanly.)
pub fn cube_faces_from_xpr2(bytes: &[u8]) -> Result<Option<Cubemap>, TextureError> {
use std::io::Cursor;
let mut cur = Cursor::new(bytes);
let header = Xpr2Header::read(&mut cur)?;
let mut entries = Vec::new();
for _ in 0..header.num_resources {
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
}
let want = std::env::var("XPR_RES_INDEX")
.ok()
.and_then(|v| v.trim().parse::<u8>().ok())
.unwrap_or(endianness);
if std::env::var("XPR_NO_ENDIAN").is_err() {
apply_endian_swap(&mut linear_data, endianness);
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0);
let tex_entry = entries
.iter()
.filter(|e| e.is_texture() || e.is_cubemap())
.nth(want)
.ok_or(TextureError::NoTextureFound)?;
if !tex_entry.is_cubemap() {
return Ok(None); // ordinary 2D texture — use `from_xpr2`
}
// X360 stores the BC1 *colour* block with its two 32-bit words swapped
// relative to the PC/DDS layout — the colour endpoints live in the high
// dword, the indices in the low one. The endian field (k8in16) only
// fixes byte order *within* the 16-bit words; it does not reorder the
// dwords, so without this the endpoints/indices are transposed and the
// texture decodes to noise. (Discovered by RE: de-tiled DXT1 blocks had
// coherent endpoints only in bytes[4..8].)
//
// This applies to the colour block only. The BC4-style alpha block
// (DXT5A, and the alpha half of DXT2_3/DXT4_5) is byte-indexed and needs
// the endian swap alone — RE-confirmed: dword-swapping DXT5A makes it
// *worse*. So the swap is format-targeted, not blanket.
if std::env::var("XPR_NO_BC_DWORD_SWAP").is_err() {
match format {
// Pure colour block, 8 bytes.
X360TextureFormat::Dxt1 => swap_bc_block_dwords(&mut linear_data),
// 16-byte block = [alpha 8B][colour 8B]; swap only the colour
// half. TENTATIVE: DXT2_3/DXT4_5 are near-absent in the game's
// assets, so this half-swap is unvalidated against a clear image.
X360TextureFormat::Dxt3 | X360TextureFormat::Dxt5 => {
for block in linear_data.chunks_exact_mut(16) {
swap_bc_block_dwords(&mut block[8..16]);
}
}
// BC4 / BC5 (DXT5A / DXN): alpha/normal blocks, endian swap only.
_ => {}
}
const DIR_BASE: usize = 0x10;
let gpufc_base = tex_entry.data_offset as usize + DIR_BASE + 0x18;
if bytes.len() < gpufc_base + 6 * 4 {
return Err(TextureError::BufferTooSmall { needed: gpufc_base + 24, have: bytes.len() });
}
let be_u32 = |o: usize| u32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
let gpufc0 = be_u32(gpufc_base);
let gpufc1 = be_u32(gpufc_base + 0x04);
let gpufc2 = be_u32(gpufc_base + 0x08);
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data: linear_data })
let fmt_code = (gpufc1 & 0x3F) as u8;
let format = X360TextureFormat::from_u8(fmt_code)
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
let endianness = ((gpufc1 >> 6) & 0x3) as u8;
let base_address = (gpufc1 & 0xFFFFF000) as usize;
let width = (gpufc2 & 0x1FFF) + 1;
let height = ((gpufc2 >> 13) & 0x1FFF) + 1;
let is_tiled = (gpufc0 >> 31) != 0;
let data_start = header.header_size as usize + base_address;
let stride = tiled_face_stride(width, height, format);
let mut faces = Vec::with_capacity(6);
for f in 0..6 {
let start = data_start + f * stride;
let raw = bytes
.get(start..)
.ok_or(TextureError::BufferTooSmall { needed: start + 1, have: bytes.len() })?;
faces.push(decode_surface(raw, width, height, format, is_tiled, endianness)?);
}
Ok(Some(Cubemap { width, height, format, faces }))
}
/// Parse a texture from already-known parameters + raw tiled data.
@@ -390,6 +417,72 @@ pub fn apply_endian_swap(data: &mut [u8], endianness: u8) {
}
}
/// De-tile (if tiled) + endian-correct one texture surface into linear PC
/// layout. Shared by `from_xpr2` (face 0) and `cube_faces_from_xpr2` (6 faces).
/// The `XPR_NO_DETILE` / `XPR_NO_ENDIAN` / `XPR_FORCE_ENDIAN` / `XPR_NO_BC_DWORD_SWAP`
/// debug knobs are honoured here so both paths behave identically.
fn decode_surface(
raw_data: &[u8],
width: u32,
height: u32,
format: X360TextureFormat,
is_tiled: bool,
endianness: u8,
) -> Result<Vec<u8>, TextureError> {
let force_linear = std::env::var("XPR_NO_DETILE").is_ok();
let mut linear_data = if is_tiled && !force_linear {
detile(raw_data, width, height, format)?
} else {
// Linear layout — copy only the mip-0 slice.
let block_size = format.block_size() as u32;
let bw = ((width + block_size - 1) / block_size).max(1);
let bh = ((height + block_size - 1) / block_size).max(1);
let needed = bw as usize * bh as usize * format.bytes_per_block();
if raw_data.len() < needed {
return Err(TextureError::BufferTooSmall { needed, have: raw_data.len() });
}
raw_data[..needed].to_vec()
};
// Undo the X360 word byte-swap so block/pixel data is PC little-endian.
let endianness = std::env::var("XPR_FORCE_ENDIAN")
.ok()
.and_then(|v| v.trim().parse::<u8>().ok())
.unwrap_or(endianness);
if std::env::var("XPR_NO_ENDIAN").is_err() {
apply_endian_swap(&mut linear_data, endianness);
}
// BC1 colour-block dword swap (see the long note where this was discovered):
// colour endpoints live in the high dword on X360; format-targeted so BC4/BC5
// (byte-indexed alpha/normal blocks) are left as endian-only.
if std::env::var("XPR_NO_BC_DWORD_SWAP").is_err() {
match format {
X360TextureFormat::Dxt1 => swap_bc_block_dwords(&mut linear_data),
X360TextureFormat::Dxt3 | X360TextureFormat::Dxt5 => {
for block in linear_data.chunks_exact_mut(16) {
swap_bc_block_dwords(&mut block[8..16]);
}
}
_ => {}
}
}
Ok(linear_data)
}
/// Byte stride between consecutive cubemap faces: the tiled surface size
/// (`pitch_aligned × height_aligned × bpb`, both padded to 32-block macro tiles)
/// rounded up to the 4 KiB subresource alignment (`kTextureSubresourceAlignmentBytes`).
fn tiled_face_stride(width: u32, height: u32, format: X360TextureFormat) -> usize {
let bs = format.block_size() as u32;
let bw = ((width + bs - 1) / bs).max(1);
let bh = ((height + bs - 1) / bs).max(1);
let pitch_aligned = align_up(bw, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let height_aligned = align_up(bh, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let surface = pitch_aligned as usize * height_aligned as usize * format.bytes_per_block();
(surface + 0xFFF) & !0xFFF
}
/// Swap the two 32-bit dwords within each 64-bit unit of `data`, in place.
///
/// Xbox 360 stores the BC1 *colour* block with its two 32-bit words in the
@@ -562,6 +655,21 @@ mod tests {
assert_eq!(back, (0..16).collect::<Vec<u8>>());
}
#[test]
fn cubemap_face_stride_and_labels() {
// Acheron: 1024×1024 A8R8G8B8 → 1024*1024*4 = 4 MiB, already 4 KiB-aligned.
// Verified against the real file: data_size == 6 × 0x400000.
assert_eq!(
tiled_face_stride(1024, 1024, X360TextureFormat::A8R8G8B8),
0x400000
);
// A tiny surface still occupies a full 32×32-block tile, 4 KiB-aligned.
assert_eq!(tiled_face_stride(4, 4, X360TextureFormat::A8R8G8B8), 0x1000);
assert_eq!(Cubemap::face_label(0), "+X");
assert_eq!(Cubemap::face_label(2), "+Y");
assert_eq!(Cubemap::face_label(5), "-Z");
}
#[test]
fn x360_format_bytes_per_block() {
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);

View File

@@ -6,6 +6,7 @@
use std::path::{Path, PathBuf};
use sylpheed_formats::texture::X360Texture;
use sylpheed_formats::{IdxdObject, PakArchive};
/// Locate the extracted disc root, or `None` to skip.
@@ -122,3 +123,25 @@ fn name_lookup_resolves_weapon_tbl() {
let obj = IdxdObject::parse(&bytes).expect("weapon.tbl parses as IDXD");
assert!(obj.count > 0);
}
/// The Acheron backdrop is a TXCM world cubemap: 6 faces, and the cube path's
/// face 0 matches the (validated) 2D `from_xpr2` face-0 decode.
#[test]
fn acheron_world_cubemap_six_faces() {
skip_without_disc!(root);
let bytes = std::fs::read(root.join("hidden/resource3d/BG_Acheron.xpr")).unwrap();
let cube = X360Texture::cube_faces_from_xpr2(&bytes)
.expect("cube decode ok")
.expect("BG_Acheron is a TXCM cubemap");
assert_eq!((cube.width, cube.height), (1024, 1024));
assert_eq!(cube.faces.len(), 6);
// A8R8G8B8 → 4 bytes/texel, de-tiled to width×height.
for face in &cube.faces {
assert_eq!(face.len(), 1024 * 1024 * 4);
}
// Face 0 must equal what the existing 2D path decodes (the green planet).
let face0_2d = X360Texture::from_xpr2(&bytes).unwrap();
assert!(face0_2d.is_cubemap);
assert_eq!(cube.faces[0], face0_2d.data);
}