diff --git a/crates/sylpheed-formats/src/texture.rs b/crates/sylpheed-formats/src/texture.rs index 9952a49..ebea014 100644 --- a/crates/sylpheed-formats/src/texture.rs +++ b/crates/sylpheed-formats/src/texture.rs @@ -193,6 +193,25 @@ pub struct X360Texture { pub data: Vec, } +/// 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>, +} + +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, 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::().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::().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, 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::().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::>()); } + #[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); diff --git a/crates/sylpheed-formats/tests/pak_idxd_disc.rs b/crates/sylpheed-formats/tests/pak_idxd_disc.rs index 100582d..743b862 100644 --- a/crates/sylpheed-formats/tests/pak_idxd_disc.rs +++ b/crates/sylpheed-formats/tests/pak_idxd_disc.rs @@ -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); +} diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index e147102..4c7a76e 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -131,6 +131,23 @@ pub struct PakDetail { pub fields: Vec<(String, String)>, } +/// One decoded cubemap face, registered as an egui image. +pub struct FaceTex { + pub label: &'static str, + pub handle: Handle, + pub egui_id: egui::TextureId, + pub width: u32, + pub height: u32, +} + +/// The currently-open world cubemap (`TXCM`), shown as a labelled 6-face grid. +#[derive(Resource, Default)] +pub struct SkyboxPreview { + pub faces: Vec, + /// Header summary (dimensions / format). + pub info: String, +} + /// The currently-open IPFB data pack, shown as a master-detail browser. #[derive(Resource, Default)] pub struct PakView { @@ -237,7 +254,8 @@ impl Plugin for IsoLoaderPlugin { // Registered unconditionally (even on wasm, where the load systems // below are cfg'd out) so the UI can always read them. .init_resource::() - .init_resource::(); + .init_resource::() + .init_resource::(); #[cfg(not(target_arch = "wasm32"))] { @@ -621,14 +639,43 @@ fn poll_loader_channel( } } +/// Free the current texture preview's GPU image + egui slot and reset it. +#[cfg(not(target_arch = "wasm32"))] +fn free_texture( + preview: &mut TexturePreview, + images: &mut Assets, + contexts: &mut bevy_egui::EguiContexts<'_, '_>, +) { + if let Some(ref handle) = preview.handle { + contexts.remove_image(handle); + images.remove(handle.id()); + } + *preview = TexturePreview::default(); +} + +/// Free the current skybox preview's per-face GPU images + egui slots and reset it. +#[cfg(not(target_arch = "wasm32"))] +fn free_skybox( + skybox: &mut SkyboxPreview, + images: &mut Assets, + contexts: &mut bevy_egui::EguiContexts<'_, '_>, +) { + for f in &skybox.faces { + contexts.remove_image(&f.handle); + images.remove(f.handle.id()); + } + *skybox = SkyboxPreview::default(); +} + /// Converts pending raw bytes into a Bevy `Image`, registers it with egui, -/// and populates `TexturePreview` / `FileInfo`. +/// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`. #[cfg(not(target_arch = "wasm32"))] fn apply_loaded_texture( mut pending: ResMut, mut preview: ResMut, mut text_preview: ResMut, mut pak_view: ResMut, + mut skybox: ResMut, mut file_info: ResMut, mut images: ResMut>, mut contexts: bevy_egui::EguiContexts, @@ -643,13 +690,10 @@ fn apply_loaded_texture( let fmt = sylpheed_formats::vfs::identify_format(&bytes); - // Always free the previous texture first (GPU memory + egui slot), and clear - // the other previews so exactly one viewer is active per selection. - if let Some(ref handle) = preview.handle { - contexts.remove_image(handle); - images.remove(handle.id()); - } - *preview = TexturePreview::default(); + // Always free the previous previews (GPU memory + egui slots) so exactly one + // viewer is active per selection. + free_texture(&mut preview, &mut images, &mut contexts); + free_skybox(&mut skybox, &mut images, &mut contexts); *text_preview = TextPreview::default(); *pak_view = PakView::default(); @@ -685,6 +729,48 @@ fn apply_loaded_texture( return; // Not a texture — FileInfo is sufficient } + // World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid. + // Returns `None` for ordinary 2D textures, which fall through below. + match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(&bytes) { + Ok(Some(cube)) => { + use sylpheed_formats::texture::{Cubemap, X360Texture}; + for (i, face) in cube.faces.iter().enumerate() { + let face_tex = X360Texture { + width: cube.width, + height: cube.height, + format: cube.format, + mip_levels: 1, + is_cubemap: false, + data: face.clone(), + }; + if let Ok(img) = crate::asset_loader::x360_texture_to_bevy_image(face_tex) { + let handle = images.add(img); + let egui_id = contexts.add_image(handle.clone_weak()); + skybox.faces.push(FaceTex { + label: Cubemap::face_label(i), + handle, + egui_id, + width: cube.width, + height: cube.height, + }); + } + } + skybox.info = format!( + "Cubemap {}×{} {:?} · {} faces", + cube.width, + cube.height, + cube.format, + skybox.faces.len() + ); + return; + } + Ok(None) => {} // ordinary 2D texture + Err(e) => { + preview.format_info = format!("Cubemap parse failed: {e}"); + return; + } + } + let tex = match sylpheed_formats::X360Texture::from_xpr2(&bytes) { Ok(t) => t, Err(e) => { @@ -724,6 +810,7 @@ fn apply_pak( mut pak_view: ResMut, mut preview: ResMut, mut text_preview: ResMut, + mut skybox: ResMut, mut file_info: ResMut, mut images: ResMut>, mut contexts: bevy_egui::EguiContexts, @@ -733,13 +820,10 @@ fn apply_pak( } pending.ready = false; - // Free any previous texture + clear the other previews (mirrors the texture - // path) so exactly one viewer is active. - if let Some(ref handle) = preview.handle { - contexts.remove_image(handle); - images.remove(handle.id()); - } - *preview = TexturePreview::default(); + // Free any previous texture/skybox + clear the other previews (mirrors the + // texture path) so exactly one viewer is active. + free_texture(&mut preview, &mut images, &mut contexts); + free_skybox(&mut skybox, &mut images, &mut contexts); *text_preview = TextPreview::default(); file_info.name = pending.name.clone(); diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 5f711cc..95a9c37 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -10,8 +10,8 @@ use bevy::prelude::*; use bevy_egui::{egui, EguiContexts}; use crate::iso_loader::{ - FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, TextPreview, - TexturePreview, IsoLoaderSystemSet, + FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, SkyboxPreview, + TextPreview, TexturePreview, IsoLoaderSystemSet, }; use crate::ViewerState; @@ -131,6 +131,7 @@ fn draw_viewer_ui( preview: Res, text_preview: Res, mut pak_view: ResMut, + skybox: Res, file_info: Res, mut open_iso_events: EventWriter, mut open_dir_events: EventWriter, @@ -265,6 +266,8 @@ fn draw_viewer_ui( ui.label(format!("Loading {name}…")); }); }); + } else if !skybox.faces.is_empty() { + draw_skybox_grid(ui, &skybox); } else if pak_view.loaded { draw_pak_browser(ui, &mut pak_view); } else if let Some(text) = &text_preview.content { @@ -485,3 +488,40 @@ fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) { pak.selected = new_selection; } } + +// ── World cubemap (skybox) viewer ───────────────────────────────────────────── + +/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true +/// interactive skybox is a follow-up — the face data + order are validated, but +/// the wgpu cube-sampling handedness needs visual confirmation first. +fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) { + ui.horizontal(|ui| { + ui.heading("World cubemap"); + ui.separator(); + ui.label(&skybox.info); + }); + ui.label("6 cube faces, D3D9 order (+X −X +Y −Y +Z −Z)."); + ui.separator(); + + egui::ScrollArea::both().show(ui, |ui| { + const CELL: f32 = 240.0; + egui::Grid::new("cube_faces") + .num_columns(3) + .spacing([10.0, 10.0]) + .show(ui, |ui| { + for (i, face) in skybox.faces.iter().enumerate() { + ui.vertical(|ui| { + ui.strong(face.label); + let aspect = face.height as f32 / face.width.max(1) as f32; + ui.add(egui::Image::new(egui::load::SizedTexture::new( + face.egui_id, + [CELL, CELL * aspect], + ))); + }); + if (i + 1) % 3 == 0 { + ui.end_row(); + } + } + }); + }); +}