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>,
|
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 {
|
impl X360Texture {
|
||||||
/// Parse the first TX2D texture from an XPR2 file's raw bytes.
|
/// Parse the first TX2D texture from an XPR2 file's raw bytes.
|
||||||
///
|
///
|
||||||
@@ -283,66 +302,74 @@ impl X360Texture {
|
|||||||
have: bytes.len(),
|
have: bytes.len(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let raw_data = &bytes[data_start..];
|
|
||||||
|
|
||||||
// Debug knob: XPR_NO_DETILE=1 skips de-tiling (linear read) so the
|
// De-tile + endian-correct the single (face-0) surface.
|
||||||
// effect of de-tiling can be A/B-measured during RE validation.
|
let data = decode_surface(&bytes[data_start..], width, height, format, is_tiled, endianness)?;
|
||||||
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 the block/pixel data is PC-standard
|
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data })
|
||||||
// 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")
|
/// 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()
|
.ok()
|
||||||
.and_then(|v| v.trim().parse::<u8>().ok())
|
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||||
.unwrap_or(endianness);
|
.unwrap_or(0);
|
||||||
if std::env::var("XPR_NO_ENDIAN").is_err() {
|
let tex_entry = entries
|
||||||
apply_endian_swap(&mut linear_data, endianness);
|
.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
|
const DIR_BASE: usize = 0x10;
|
||||||
// relative to the PC/DDS layout — the colour endpoints live in the high
|
let gpufc_base = tex_entry.data_offset as usize + DIR_BASE + 0x18;
|
||||||
// dword, the indices in the low one. The endian field (k8in16) only
|
if bytes.len() < gpufc_base + 6 * 4 {
|
||||||
// fixes byte order *within* the 16-bit words; it does not reorder the
|
return Err(TextureError::BufferTooSmall { needed: gpufc_base + 24, have: bytes.len() });
|
||||||
// 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.
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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.
|
/// 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.
|
/// 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
|
/// 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>>());
|
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]
|
#[test]
|
||||||
fn x360_format_bytes_per_block() {
|
fn x360_format_bytes_per_block() {
|
||||||
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);
|
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use sylpheed_formats::texture::X360Texture;
|
||||||
use sylpheed_formats::{IdxdObject, PakArchive};
|
use sylpheed_formats::{IdxdObject, PakArchive};
|
||||||
|
|
||||||
/// Locate the extracted disc root, or `None` to skip.
|
/// 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");
|
let obj = IdxdObject::parse(&bytes).expect("weapon.tbl parses as IDXD");
|
||||||
assert!(obj.count > 0);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,23 @@ pub struct PakDetail {
|
|||||||
pub fields: Vec<(String, String)>,
|
pub fields: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One decoded cubemap face, registered as an egui image.
|
||||||
|
pub struct FaceTex {
|
||||||
|
pub label: &'static str,
|
||||||
|
pub handle: Handle<Image>,
|
||||||
|
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<FaceTex>,
|
||||||
|
/// Header summary (dimensions / format).
|
||||||
|
pub info: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// The currently-open IPFB data pack, shown as a master-detail browser.
|
/// The currently-open IPFB data pack, shown as a master-detail browser.
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
pub struct PakView {
|
pub struct PakView {
|
||||||
@@ -237,7 +254,8 @@ impl Plugin for IsoLoaderPlugin {
|
|||||||
// Registered unconditionally (even on wasm, where the load systems
|
// Registered unconditionally (even on wasm, where the load systems
|
||||||
// below are cfg'd out) so the UI can always read them.
|
// below are cfg'd out) so the UI can always read them.
|
||||||
.init_resource::<TextPreview>()
|
.init_resource::<TextPreview>()
|
||||||
.init_resource::<PakView>();
|
.init_resource::<PakView>()
|
||||||
|
.init_resource::<SkyboxPreview>();
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[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<Image>,
|
||||||
|
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<Image>,
|
||||||
|
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,
|
/// 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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn apply_loaded_texture(
|
fn apply_loaded_texture(
|
||||||
mut pending: ResMut<PendingFileBytes>,
|
mut pending: ResMut<PendingFileBytes>,
|
||||||
mut preview: ResMut<TexturePreview>,
|
mut preview: ResMut<TexturePreview>,
|
||||||
mut text_preview: ResMut<TextPreview>,
|
mut text_preview: ResMut<TextPreview>,
|
||||||
mut pak_view: ResMut<PakView>,
|
mut pak_view: ResMut<PakView>,
|
||||||
|
mut skybox: ResMut<SkyboxPreview>,
|
||||||
mut file_info: ResMut<FileInfo>,
|
mut file_info: ResMut<FileInfo>,
|
||||||
mut images: ResMut<Assets<Image>>,
|
mut images: ResMut<Assets<Image>>,
|
||||||
mut contexts: bevy_egui::EguiContexts,
|
mut contexts: bevy_egui::EguiContexts,
|
||||||
@@ -643,13 +690,10 @@ fn apply_loaded_texture(
|
|||||||
|
|
||||||
let fmt = sylpheed_formats::vfs::identify_format(&bytes);
|
let fmt = sylpheed_formats::vfs::identify_format(&bytes);
|
||||||
|
|
||||||
// Always free the previous texture first (GPU memory + egui slot), and clear
|
// Always free the previous previews (GPU memory + egui slots) so exactly one
|
||||||
// the other previews so exactly one viewer is active per selection.
|
// viewer is active per selection.
|
||||||
if let Some(ref handle) = preview.handle {
|
free_texture(&mut preview, &mut images, &mut contexts);
|
||||||
contexts.remove_image(handle);
|
free_skybox(&mut skybox, &mut images, &mut contexts);
|
||||||
images.remove(handle.id());
|
|
||||||
}
|
|
||||||
*preview = TexturePreview::default();
|
|
||||||
*text_preview = TextPreview::default();
|
*text_preview = TextPreview::default();
|
||||||
*pak_view = PakView::default();
|
*pak_view = PakView::default();
|
||||||
|
|
||||||
@@ -685,6 +729,48 @@ fn apply_loaded_texture(
|
|||||||
return; // Not a texture — FileInfo is sufficient
|
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) {
|
let tex = match sylpheed_formats::X360Texture::from_xpr2(&bytes) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -724,6 +810,7 @@ fn apply_pak(
|
|||||||
mut pak_view: ResMut<PakView>,
|
mut pak_view: ResMut<PakView>,
|
||||||
mut preview: ResMut<TexturePreview>,
|
mut preview: ResMut<TexturePreview>,
|
||||||
mut text_preview: ResMut<TextPreview>,
|
mut text_preview: ResMut<TextPreview>,
|
||||||
|
mut skybox: ResMut<SkyboxPreview>,
|
||||||
mut file_info: ResMut<FileInfo>,
|
mut file_info: ResMut<FileInfo>,
|
||||||
mut images: ResMut<Assets<Image>>,
|
mut images: ResMut<Assets<Image>>,
|
||||||
mut contexts: bevy_egui::EguiContexts,
|
mut contexts: bevy_egui::EguiContexts,
|
||||||
@@ -733,13 +820,10 @@ fn apply_pak(
|
|||||||
}
|
}
|
||||||
pending.ready = false;
|
pending.ready = false;
|
||||||
|
|
||||||
// Free any previous texture + clear the other previews (mirrors the texture
|
// Free any previous texture/skybox + clear the other previews (mirrors the
|
||||||
// path) so exactly one viewer is active.
|
// texture path) so exactly one viewer is active.
|
||||||
if let Some(ref handle) = preview.handle {
|
free_texture(&mut preview, &mut images, &mut contexts);
|
||||||
contexts.remove_image(handle);
|
free_skybox(&mut skybox, &mut images, &mut contexts);
|
||||||
images.remove(handle.id());
|
|
||||||
}
|
|
||||||
*preview = TexturePreview::default();
|
|
||||||
*text_preview = TextPreview::default();
|
*text_preview = TextPreview::default();
|
||||||
|
|
||||||
file_info.name = pending.name.clone();
|
file_info.name = pending.name.clone();
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use bevy::prelude::*;
|
|||||||
use bevy_egui::{egui, EguiContexts};
|
use bevy_egui::{egui, EguiContexts};
|
||||||
|
|
||||||
use crate::iso_loader::{
|
use crate::iso_loader::{
|
||||||
FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, TextPreview,
|
FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, SkyboxPreview,
|
||||||
TexturePreview, IsoLoaderSystemSet,
|
TextPreview, TexturePreview, IsoLoaderSystemSet,
|
||||||
};
|
};
|
||||||
use crate::ViewerState;
|
use crate::ViewerState;
|
||||||
|
|
||||||
@@ -131,6 +131,7 @@ fn draw_viewer_ui(
|
|||||||
preview: Res<TexturePreview>,
|
preview: Res<TexturePreview>,
|
||||||
text_preview: Res<TextPreview>,
|
text_preview: Res<TextPreview>,
|
||||||
mut pak_view: ResMut<PakView>,
|
mut pak_view: ResMut<PakView>,
|
||||||
|
skybox: Res<SkyboxPreview>,
|
||||||
file_info: Res<FileInfo>,
|
file_info: Res<FileInfo>,
|
||||||
mut open_iso_events: EventWriter<RequestOpenIso>,
|
mut open_iso_events: EventWriter<RequestOpenIso>,
|
||||||
mut open_dir_events: EventWriter<RequestOpenDir>,
|
mut open_dir_events: EventWriter<RequestOpenDir>,
|
||||||
@@ -265,6 +266,8 @@ fn draw_viewer_ui(
|
|||||||
ui.label(format!("Loading {name}…"));
|
ui.label(format!("Loading {name}…"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} else if !skybox.faces.is_empty() {
|
||||||
|
draw_skybox_grid(ui, &skybox);
|
||||||
} else if pak_view.loaded {
|
} else if pak_view.loaded {
|
||||||
draw_pak_browser(ui, &mut pak_view);
|
draw_pak_browser(ui, &mut pak_view);
|
||||||
} else if let Some(text) = &text_preview.content {
|
} 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;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user