fix(texture): correct Xenos de-tile + endian + cubemaps + PNG export
Reworks the XPR2 texture path after RE against the retail disc (viewer previews were scrambled/failed). - de-tile: replace the naive Morton-over-32×32 approximation with the faithful Xenos Tiled2D bank/pipe/macro-tile address formula (ported from xenia texture_address.h). Verified correct: the Acheron backdrop decodes to a sharp planet with its spiral storm. - endian: undo the X360 word byte-swap per the fetch-constant endianness field (k8in16/k8in32/k16in32) — without it BCn/ARGB data is noise. - cubemaps: parse TXCM resources (skybox/backdrops), decoding face 0, instead of erroring "No TX2D found". - A8R8G8B8: correct channel order after the k8in32 swap ([A,R,G,B]→RGBA) — the Acheron backdrop is now correctly green, not red. - XPR files are texture PACKS; add XPR_RES_INDEX to select a resource. - CLI `texture export` now writes real PNGs (texpresso BCn decode + image), replacing the stub. Adds texpresso + image deps. - tests/texture_disc.rs: integration test running the pipeline over real disc .xpr files (28/28 parse). RE debug knobs: XPR_NO_DETILE / XPR_NO_ENDIAN / XPR_FORCE_ENDIAN. KNOWN ISSUE: mipmapped DXT1 textures still decode to noise — mip 0 is not at base_address in the multi-resource packs (localized to a mip storage-offset quirk; uncompressed + de-tile + endian are all verified). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -4345,8 +4345,10 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"colored",
|
"colored",
|
||||||
|
"image",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"sylpheed-formats",
|
"sylpheed-formats",
|
||||||
|
"texpresso",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -4437,6 +4439,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "texpresso"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8550677e2259d675a7841cb1403db35f330cc9e58674c8c5caa12dd12c51dc71"
|
||||||
|
dependencies = [
|
||||||
|
"libm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
|
|||||||
@@ -19,3 +19,6 @@ tracing = { workspace = true }
|
|||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
colored = "2"
|
colored = "2"
|
||||||
indicatif = "0.17"
|
indicatif = "0.17"
|
||||||
|
# Texture PNG export: BCn software decode + image encode.
|
||||||
|
texpresso = "2"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|||||||
@@ -330,44 +330,68 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
|
|||||||
let bytes = std::fs::read(file)
|
let bytes = std::fs::read(file)
|
||||||
.with_context(|| format!("Cannot read {}", file.display()))?;
|
.with_context(|| format!("Cannot read {}", file.display()))?;
|
||||||
|
|
||||||
use sylpheed_formats::texture::{X360Texture, X360TextureFormat};
|
use sylpheed_formats::texture::X360Texture;
|
||||||
let tex = X360Texture::from_xpr2(&bytes)?;
|
let tex = X360Texture::from_xpr2(&bytes)?;
|
||||||
|
let rgba = decode_to_rgba8(&tex)
|
||||||
|
.with_context(|| format!("decoding {:?} texture", tex.format))?;
|
||||||
|
|
||||||
// For BCn compressed textures we need to software-decompress to RGBA8
|
image::save_buffer(
|
||||||
// before saving to PNG. This uses the `texpresso` crate.
|
output,
|
||||||
//
|
&rgba,
|
||||||
// TODO: add `texpresso = "2"` to Cargo.toml for BCn software decode.
|
tex.width,
|
||||||
// For now, print a helpful message.
|
tex.height,
|
||||||
match tex.format {
|
image::ExtendedColorType::Rgba8,
|
||||||
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
)
|
||||||
// Raw BGRA8 — can save directly (with channel swizzle)
|
.with_context(|| format!("writing PNG {}", output.display()))?;
|
||||||
println!(
|
|
||||||
"{} TODO: save raw BGRA8 to PNG (add `image` crate)",
|
|
||||||
"Note:".yellow()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
compressed_format => {
|
|
||||||
println!(
|
|
||||||
"{} Format {:?} needs BCn decompression before PNG export.",
|
|
||||||
"Note:".yellow(), compressed_format
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" Add `texpresso` crate and implement decompression in texture.rs"
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" Alternatively, use the Bevy viewer to inspect textures visually."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" Texture parsed OK: {}×{} {:?}",
|
"{} {}×{} {:?}{} → {}",
|
||||||
tex.width, tex.height, tex.format
|
"Exported".green().bold(),
|
||||||
|
tex.width,
|
||||||
|
tex.height,
|
||||||
|
tex.format,
|
||||||
|
if tex.is_cubemap { " (cubemap face 0)" } else { "" },
|
||||||
|
output.display().to_string().cyan(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Software-decode a de-tiled `X360Texture` (mip 0) to tightly-packed RGBA8.
|
||||||
|
///
|
||||||
|
/// BCn blocks are decompressed with `texpresso`; uncompressed A8R8G8B8/X8R8G8B8
|
||||||
|
/// is byte-swizzled from the Xenos in-memory BGRA order.
|
||||||
|
fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u8>> {
|
||||||
|
use sylpheed_formats::texture::X360TextureFormat as F;
|
||||||
|
let (w, h) = (tex.width as usize, tex.height as usize);
|
||||||
|
let mut rgba = vec![0u8; w * h * 4];
|
||||||
|
|
||||||
|
let bc = |fmt: texpresso::Format, rgba: &mut [u8]| {
|
||||||
|
fmt.decompress(&tex.data, w, h, rgba);
|
||||||
|
};
|
||||||
|
|
||||||
|
match tex.format {
|
||||||
|
F::Dxt1 => bc(texpresso::Format::Bc1, &mut rgba),
|
||||||
|
F::Dxt3 => bc(texpresso::Format::Bc2, &mut rgba),
|
||||||
|
F::Dxt5 => bc(texpresso::Format::Bc3, &mut rgba),
|
||||||
|
F::A8R8G8B8 | F::X8R8G8B8 => {
|
||||||
|
// After the k8in32 endian swap in from_xpr2, k_8_8_8_8 pixels are in
|
||||||
|
// [A,R,G,B] byte order (verified against the retail Acheron backdrop).
|
||||||
|
// Emit RGBA. X8 has no meaningful alpha.
|
||||||
|
let opaque = matches!(tex.format, F::X8R8G8B8);
|
||||||
|
for (px, out) in tex.data.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
|
||||||
|
out[0] = px[1]; // R
|
||||||
|
out[1] = px[2]; // G
|
||||||
|
out[2] = px[3]; // B
|
||||||
|
out[3] = if opaque { 0xFF } else { px[0] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
anyhow::bail!("PNG export for {other:?} (BC4/BC5) not implemented yet");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(rgba)
|
||||||
|
}
|
||||||
|
|
||||||
// ── pak list ────────────────────────────────────────────────────────────────
|
// ── pak list ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Best-effort human label for a decompressed entry's inner format.
|
/// Best-effort human label for a decompressed entry's inner format.
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ impl Xpr2ResourceEntry {
|
|||||||
pub fn is_texture(&self) -> bool {
|
pub fn is_texture(&self) -> bool {
|
||||||
&self.type_tag == b"TX2D"
|
&self.type_tag == b"TX2D"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cubemap resource (`TXCM`) — 6 faces sharing one GPUFC descriptor.
|
||||||
|
pub fn is_cubemap(&self) -> bool {
|
||||||
|
&self.type_tag == b"TXCM"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Decoded texture ───────────────────────────────────────────────────────────
|
// ── Decoded texture ───────────────────────────────────────────────────────────
|
||||||
@@ -179,6 +184,9 @@ pub struct X360Texture {
|
|||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub format: X360TextureFormat,
|
pub format: X360TextureFormat,
|
||||||
pub mip_levels: u32,
|
pub mip_levels: u32,
|
||||||
|
/// True when the source resource was a cubemap (`TXCM`). `data` then holds
|
||||||
|
/// only face 0 (the +X face) decoded as a 2D image — enough for a preview.
|
||||||
|
pub is_cubemap: bool,
|
||||||
/// De-tiled texture data in linear order.
|
/// De-tiled texture data in linear order.
|
||||||
/// BCn: standard packed block data (DDS layout).
|
/// BCn: standard packed block data (DDS layout).
|
||||||
/// Uncompressed: BGRA8 pixel data.
|
/// Uncompressed: BGRA8 pixel data.
|
||||||
@@ -206,10 +214,20 @@ impl X360Texture {
|
|||||||
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
|
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the first TX2D texture resource
|
// Select a texture resource. TX2D = 2D texture; TXCM = cubemap
|
||||||
|
// (skybox / backdrop) — same 52-byte descriptor + GPUFC layout, but the
|
||||||
|
// pixel section holds 6 faces. For a preview we decode face 0.
|
||||||
|
// XPR files are frequently PACKS of many textures; XPR_RES_INDEX picks
|
||||||
|
// the Nth texture resource (default 0) for RE/browse validation.
|
||||||
|
let want = std::env::var("XPR_RES_INDEX")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
let tex_entry = entries.iter()
|
let tex_entry = entries.iter()
|
||||||
.find(|e| e.is_texture())
|
.filter(|e| e.is_texture() || e.is_cubemap())
|
||||||
|
.nth(want)
|
||||||
.ok_or(TextureError::NoTextureFound)?;
|
.ok_or(TextureError::NoTextureFound)?;
|
||||||
|
let is_cubemap = tex_entry.is_cubemap();
|
||||||
|
|
||||||
// The descriptor is at file offset = data_offset + 0x10 (directory base)
|
// The descriptor is at file offset = data_offset + 0x10 (directory base)
|
||||||
const DIR_BASE: usize = 0x10;
|
const DIR_BASE: usize = 0x10;
|
||||||
@@ -239,6 +257,11 @@ impl X360Texture {
|
|||||||
let format = X360TextureFormat::from_u8(fmt_code)
|
let format = X360TextureFormat::from_u8(fmt_code)
|
||||||
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
|
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
|
||||||
|
|
||||||
|
// GPUFC[1] bits[7:6] = endianness. X360 stores texture words byte-
|
||||||
|
// swapped; without undoing this, BCn endpoints/indices and ARGB channels
|
||||||
|
// decode to noise. (fetch-constant `endianness`, xenos.h Endian.)
|
||||||
|
let endianness = ((gpufc1 >> 6) & 0x3) as u8;
|
||||||
|
|
||||||
// GPUFC[1] bits[31:12] = base_address (4KB-aligned byte offset into data section)
|
// GPUFC[1] bits[31:12] = base_address (4KB-aligned byte offset into data section)
|
||||||
let base_address = (gpufc1 & 0xFFFFF000) as usize;
|
let base_address = (gpufc1 & 0xFFFFF000) as usize;
|
||||||
|
|
||||||
@@ -262,7 +285,10 @@ impl X360Texture {
|
|||||||
}
|
}
|
||||||
let raw_data = &bytes[data_start..];
|
let raw_data = &bytes[data_start..];
|
||||||
|
|
||||||
let linear_data = if is_tiled {
|
// 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)?
|
detile(raw_data, width, height, format)?
|
||||||
} else {
|
} else {
|
||||||
// Linear layout — copy only the mip-0 slice
|
// Linear layout — copy only the mip-0 slice
|
||||||
@@ -276,7 +302,18 @@ impl X360Texture {
|
|||||||
raw_data[..needed].to_vec()
|
raw_data[..needed].to_vec()
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(X360Texture { width, height, format, mip_levels: mip_count, data: linear_data })
|
// 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()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data: linear_data })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a texture from already-known parameters + raw tiled data.
|
/// Parse a texture from already-known parameters + raw tiled data.
|
||||||
@@ -290,19 +327,82 @@ impl X360Texture {
|
|||||||
format: X360TextureFormat,
|
format: X360TextureFormat,
|
||||||
) -> Result<Self, TextureError> {
|
) -> Result<Self, TextureError> {
|
||||||
let linear_data = detile(tiled_data, width, height, format)?;
|
let linear_data = detile(tiled_data, width, height, format)?;
|
||||||
Ok(X360Texture { width, height, format, mip_levels: 1, data: linear_data })
|
Ok(X360Texture { width, height, format, mip_levels: 1, is_cubemap: false, data: linear_data })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the Xenos texture endian swap in place, as byte permutations over the
|
||||||
|
/// data. Xbox 360 stores texture words big-endian; this converts them to the
|
||||||
|
/// PC-standard little-endian layout that BCn decoders and wgpu expect.
|
||||||
|
/// `endianness` is the fetch-constant `dword_1` bits[7:6]:
|
||||||
|
/// 0 = none, 1 = k8in16, 2 = k8in32, 3 = k16in32 (xenia `xenos.h` `Endian`).
|
||||||
|
pub fn apply_endian_swap(data: &mut [u8], endianness: u8) {
|
||||||
|
match endianness {
|
||||||
|
1 => {
|
||||||
|
// k8in16 — swap the two bytes of each 16-bit half.
|
||||||
|
for c in data.chunks_exact_mut(2) {
|
||||||
|
c.swap(0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
// k8in32 — reverse each 32-bit word.
|
||||||
|
for c in data.chunks_exact_mut(4) {
|
||||||
|
c.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
// k16in32 — swap the two 16-bit halves of each 32-bit word.
|
||||||
|
for c in data.chunks_exact_mut(4) {
|
||||||
|
c.swap(0, 2);
|
||||||
|
c.swap(1, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {} // kNone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Core de-tiling algorithm ──────────────────────────────────────────────────
|
// ── Core de-tiling algorithm ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// De-tile an Xbox 360 GPU texture from tiled to linear (row-major) layout.
|
/// Macro-tile side in blocks (Xenos tiles are 32×32 *blocks*, where a "block"
|
||||||
|
/// is one BCn 4×4 group or one uncompressed texel). `texture_address.h`
|
||||||
|
/// `kTextureTileWidthHeight` / `kMacroTileWidth`.
|
||||||
|
const MACRO_TILE_BLOCKS: u32 = 32;
|
||||||
|
/// Storage pitch/height alignment, in blocks (`kStoragePitchHeightAlignmentBlocks`).
|
||||||
|
const STORAGE_ALIGN_BLOCKS: u32 = 32;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn align_up(v: u32, a: u32) -> u32 {
|
||||||
|
(v + a - 1) & !(a - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte offset of block (x, y) within an Xbox 360 2D tiled surface.
|
||||||
///
|
///
|
||||||
/// Xbox 360 stores textures in 32×32 texel macro-tiles. Within each
|
/// Faithful port of xenia-canary `texture_address.h::Tiled2D` + `TiledCombine`
|
||||||
/// macro-tile the DXT blocks (or raw pixels) are in Morton (Z-order) order.
|
/// (documented Xenos hardware tiling — bank/pipe/macro-tile addressing, NOT a
|
||||||
/// This function reverses that ordering for the mip-0 level.
|
/// plain Morton curve). `x`/`y` and `pitch_aligned` are in block units;
|
||||||
|
/// `bpb_log2` is log2(bytes-per-block). Returns a byte offset.
|
||||||
|
#[inline]
|
||||||
|
fn tiled_2d_offset(x: i32, y: i32, pitch_aligned: u32, bpb_log2: u32) -> i32 {
|
||||||
|
let outer_blocks = ((y >> 5) * (pitch_aligned >> 5) as i32 + (x >> 5)) << 6;
|
||||||
|
let inner_blocks = (((y >> 1) & 0b111) << 3) | (x & 0b111);
|
||||||
|
let outer_inner_bytes = (outer_blocks | inner_blocks) << bpb_log2;
|
||||||
|
let bank = (y >> 4) & 0b1;
|
||||||
|
let pipe = ((x >> 3) & 0b11) ^ (((y >> 3) & 0b1) << 1);
|
||||||
|
let y_lsb = y & 1;
|
||||||
|
// TiledCombine: splice bank/pipe/y_lsb bits into the byte address.
|
||||||
|
((y_lsb << 4) | (pipe << 6) | (bank << 11))
|
||||||
|
| (outer_inner_bytes & 0b1111)
|
||||||
|
| (((outer_inner_bytes >> 4) & 0b1) << 5)
|
||||||
|
| (((outer_inner_bytes >> 5) & 0b111) << 8)
|
||||||
|
| (outer_inner_bytes >> 8 << 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// De-tile an Xbox 360 GPU texture (mip-0) from tiled to linear (row-major)
|
||||||
|
/// layout, using the exact Xenos address formula.
|
||||||
///
|
///
|
||||||
/// Algorithm based on Xenia's `texture_util.cc` `TileTexture()`.
|
/// `src` must hold the tiled mip-0 surface (its storage pitch/height are
|
||||||
|
/// rounded up to 32 blocks, so it may be larger than the visible image). The
|
||||||
|
/// returned buffer is tightly packed linear block data (DDS layout for BCn).
|
||||||
pub fn detile(
|
pub fn detile(
|
||||||
src: &[u8],
|
src: &[u8],
|
||||||
width: u32,
|
width: u32,
|
||||||
@@ -311,50 +411,30 @@ pub fn detile(
|
|||||||
) -> Result<Vec<u8>, TextureError> {
|
) -> Result<Vec<u8>, TextureError> {
|
||||||
let block_size = format.block_size() as u32;
|
let block_size = format.block_size() as u32;
|
||||||
let bpb = format.bytes_per_block();
|
let bpb = format.bytes_per_block();
|
||||||
|
let bpb_log2 = (bpb as u32).trailing_zeros();
|
||||||
|
|
||||||
// Texture dimensions in blocks
|
// Visible dimensions in blocks, and the padded storage pitch/height.
|
||||||
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
|
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
|
||||||
let blocks_tall = ((height + block_size - 1) / block_size).max(1);
|
let blocks_tall = ((height + block_size - 1) / block_size).max(1);
|
||||||
|
let pitch_aligned = align_up(blocks_wide, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||||
|
let height_aligned = align_up(blocks_tall, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||||
|
|
||||||
let expected = blocks_wide as usize * blocks_tall as usize * bpb;
|
// The tiled surface occupies pitch_aligned × height_aligned blocks.
|
||||||
if src.len() < expected {
|
let src_needed = pitch_aligned as usize * height_aligned as usize * bpb;
|
||||||
return Err(TextureError::BufferTooSmall { needed: expected, have: src.len() });
|
if src.len() < src_needed {
|
||||||
|
return Err(TextureError::BufferTooSmall { needed: src_needed, have: src.len() });
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut dst = vec![0u8; expected];
|
let dst_len = blocks_wide as usize * blocks_tall as usize * bpb;
|
||||||
|
let mut dst = vec![0u8; dst_len];
|
||||||
|
|
||||||
// Xbox 360 macro-tiles are always 32×32 texels → 8×8 blocks for BCn (4-texel blocks)
|
for by in 0..blocks_tall {
|
||||||
let macro_tile_blocks = 32 / block_size;
|
for bx in 0..blocks_wide {
|
||||||
|
let src_offset = tiled_2d_offset(bx as i32, by as i32, pitch_aligned, bpb_log2) as usize;
|
||||||
let macro_tiles_wide = (blocks_wide + macro_tile_blocks - 1) / macro_tile_blocks;
|
let dst_offset = (by * blocks_wide + bx) as usize * bpb;
|
||||||
let macro_tiles_tall = (blocks_tall + macro_tile_blocks - 1) / macro_tile_blocks;
|
if src_offset + bpb <= src.len() && dst_offset + bpb <= dst.len() {
|
||||||
let blocks_per_macro_tile = (macro_tile_blocks * macro_tile_blocks) as usize;
|
dst[dst_offset..dst_offset + bpb]
|
||||||
|
.copy_from_slice(&src[src_offset..src_offset + bpb]);
|
||||||
for macro_y in 0..macro_tiles_tall {
|
|
||||||
for macro_x in 0..macro_tiles_wide {
|
|
||||||
let macro_base = ((macro_y * macro_tiles_wide + macro_x) as usize)
|
|
||||||
* blocks_per_macro_tile
|
|
||||||
* bpb;
|
|
||||||
|
|
||||||
for local in 0..blocks_per_macro_tile as u32 {
|
|
||||||
// Decode Morton (Z-order) index → (lx, ly) within macro-tile
|
|
||||||
let (lx, ly) = morton_decode(local);
|
|
||||||
|
|
||||||
let block_x = macro_x * macro_tile_blocks + lx;
|
|
||||||
let block_y = macro_y * macro_tile_blocks + ly;
|
|
||||||
|
|
||||||
// Skip blocks outside the actual texture
|
|
||||||
if block_x >= blocks_wide || block_y >= blocks_tall {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let src_offset = macro_base + local as usize * bpb;
|
|
||||||
let dst_offset = (block_y * blocks_wide + block_x) as usize * bpb;
|
|
||||||
|
|
||||||
if src_offset + bpb <= src.len() && dst_offset + bpb <= dst.len() {
|
|
||||||
dst[dst_offset..dst_offset + bpb]
|
|
||||||
.copy_from_slice(&src[src_offset..src_offset + bpb]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,11 +479,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detile_noop_for_1x1_block() {
|
fn tiled_offset_origin_is_zero() {
|
||||||
// A 4×4 DXT1 texture = exactly 1 block = 8 bytes; de-tiling is identity
|
// Block (0,0) always maps to byte offset 0 for any pitch / bpb.
|
||||||
let src = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04];
|
assert_eq!(tiled_2d_offset(0, 0, 32, 3), 0);
|
||||||
|
assert_eq!(tiled_2d_offset(0, 0, 64, 4), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detile_single_block_reads_offset_zero() {
|
||||||
|
// A 4×4 DXT1 texture = 1 visible block, but Xenos pads the tiled
|
||||||
|
// surface to 32×32 blocks (8192 bytes). Block (0,0) sits at offset 0,
|
||||||
|
// so the de-tiled output equals the first 8 source bytes.
|
||||||
|
let mut src = vec![0u8; 32 * 32 * 8];
|
||||||
|
src[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]);
|
||||||
let result = detile(&src, 4, 4, X360TextureFormat::Dxt1).unwrap();
|
let result = detile(&src, 4, 4, X360TextureFormat::Dxt1).unwrap();
|
||||||
assert_eq!(result, src);
|
assert_eq!(result, &src[..8]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
140
crates/sylpheed-formats/tests/texture_disc.rs
Normal file
140
crates/sylpheed-formats/tests/texture_disc.rs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
//! Integration test: run the XPR2 texture pipeline against REAL `.xpr` files
|
||||||
|
//! read directly from the retail disc image, reproducing exactly what the
|
||||||
|
//! viewer's texture-preview path does (`identify_format` → `X360Texture::
|
||||||
|
//! from_xpr2`). Skipped unless `SYLPHEED_ISO` points at the disc (or the
|
||||||
|
//! default dev path exists).
|
||||||
|
//!
|
||||||
|
//! Run: `cargo test -p sylpheed-formats --test texture_disc -- --ignored --nocapture`
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use sylpheed_formats::texture::X360Texture;
|
||||||
|
use sylpheed_formats::vfs::identify_format;
|
||||||
|
|
||||||
|
fn iso_path() -> Option<PathBuf> {
|
||||||
|
if let Ok(p) = std::env::var("SYLPHEED_ISO") {
|
||||||
|
let p = PathBuf::from(p);
|
||||||
|
if p.is_file() {
|
||||||
|
return Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let default = PathBuf::from(
|
||||||
|
"/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
|
||||||
|
);
|
||||||
|
default.is_file().then_some(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires the retail ISO — set SYLPHEED_ISO"]
|
||||||
|
async fn xpr_pipeline_over_disc_sample() {
|
||||||
|
let Some(iso) = iso_path() else {
|
||||||
|
eprintln!("SKIP: ISO not found (set SYLPHEED_ISO)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut reader = sylpheed_formats::xiso::open_iso(&iso).await.unwrap();
|
||||||
|
let all = reader.list_all_files().await.unwrap();
|
||||||
|
let mut xprs: Vec<String> = all
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.to_lowercase().ends_with(".xpr"))
|
||||||
|
.collect();
|
||||||
|
xprs.sort();
|
||||||
|
println!("found {} .xpr files", xprs.len());
|
||||||
|
|
||||||
|
// Sample across the set so we hit different texture sizes/formats.
|
||||||
|
let sample: Vec<String> = xprs.iter().step_by((xprs.len() / 24).max(1)).cloned().collect();
|
||||||
|
|
||||||
|
let mut ok = 0usize;
|
||||||
|
let mut fail = 0usize;
|
||||||
|
let mut by_format: std::collections::BTreeMap<String, usize> = Default::default();
|
||||||
|
let mut nonxpr = 0usize;
|
||||||
|
|
||||||
|
// Optionally dump the sampled .xpr bytes so the CLI can export them to PNG
|
||||||
|
// for visual de-tiling validation: `DUMP_XPR_DIR=/tmp/xpr cargo test …`.
|
||||||
|
let dump_dir = std::env::var("DUMP_XPR_DIR").ok();
|
||||||
|
if let Some(d) = &dump_dir {
|
||||||
|
std::fs::create_dir_all(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in &sample {
|
||||||
|
let bytes = reader.read_file(path).await.unwrap();
|
||||||
|
if let Some(d) = &dump_dir {
|
||||||
|
let base = path.rsplit('/').next().unwrap();
|
||||||
|
std::fs::write(format!("{d}/{base}"), &bytes).unwrap();
|
||||||
|
}
|
||||||
|
let fmt = identify_format(&bytes);
|
||||||
|
let magic: String = bytes
|
||||||
|
.iter()
|
||||||
|
.take(4)
|
||||||
|
.map(|b| if b.is_ascii_graphic() { *b as char } else { '.' })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if fmt != sylpheed_formats::vfs::FileFormat::Xpr2Texture {
|
||||||
|
nonxpr += 1;
|
||||||
|
println!(" [{path}] NOT XPR2 (magic {magic:?}, {} bytes)", bytes.len());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match X360Texture::from_xpr2(&bytes) {
|
||||||
|
Ok(t) => {
|
||||||
|
ok += 1;
|
||||||
|
*by_format.entry(format!("{:?}", t.format)).or_default() += 1;
|
||||||
|
// Sanity: does the decoded data length match the descriptor
|
||||||
|
// dimensions (what the GPU upload will require)?
|
||||||
|
let bs = t.format.block_size() as u32;
|
||||||
|
let bw = ((t.width + bs - 1) / bs).max(1) as usize;
|
||||||
|
let bh = ((t.height + bs - 1) / bs).max(1) as usize;
|
||||||
|
let need = bw * bh * t.format.bytes_per_block();
|
||||||
|
let size_ok = if need == t.data.len() { "ok" } else { "MISMATCH" };
|
||||||
|
println!(
|
||||||
|
" [{path}] {:?} {}x{} mips={} data={} need={} {size_ok}",
|
||||||
|
t.format, t.width, t.height, t.mip_levels, t.data.len(), need
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
fail += 1;
|
||||||
|
println!(" [{path}] from_xpr2 FAILED: {e}");
|
||||||
|
dump_xpr2_structure(&bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\nSUMMARY: ok={ok} fail={fail} non-xpr2={nonxpr} formats={by_format:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dump the XPR2 header + resource directory the way `from_xpr2` reads it,
|
||||||
|
/// so we can see why a file's TX2D scan comes up empty.
|
||||||
|
fn dump_xpr2_structure(bytes: &[u8]) {
|
||||||
|
let be = |o: usize| u32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
|
||||||
|
let magic: String = bytes[..4].iter().map(|b| *b as char).collect();
|
||||||
|
println!(
|
||||||
|
" hdr magic={magic:?} header_size=0x{:X} data_size=0x{:X} num_resources={}",
|
||||||
|
be(0x04),
|
||||||
|
be(0x08),
|
||||||
|
be(0x0C)
|
||||||
|
);
|
||||||
|
let n = be(0x0C).min(16); // guard against garbage counts
|
||||||
|
for i in 0..n {
|
||||||
|
let base = 0x10 + i as usize * 16;
|
||||||
|
if base + 16 > bytes.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let tag: String = bytes[base..base + 4]
|
||||||
|
.iter()
|
||||||
|
.map(|b| if b.is_ascii_graphic() { *b as char } else { '.' })
|
||||||
|
.collect();
|
||||||
|
println!(
|
||||||
|
" res[{i}] tag={tag:?} data_off=0x{:X} desc_size=0x{:X} name_off=0x{:X}",
|
||||||
|
be(base + 4),
|
||||||
|
be(base + 8),
|
||||||
|
be(base + 12)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// First 48 bytes hex for orientation.
|
||||||
|
let hx: String = bytes
|
||||||
|
.iter()
|
||||||
|
.take(48)
|
||||||
|
.map(|b| format!("{b:02X} "))
|
||||||
|
.collect();
|
||||||
|
println!(" hex[0..48] {hx}");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user