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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user