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:
@@ -19,3 +19,6 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
colored = "2"
|
||||
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)
|
||||
.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 rgba = decode_to_rgba8(&tex)
|
||||
.with_context(|| format!("decoding {:?} texture", tex.format))?;
|
||||
|
||||
// For BCn compressed textures we need to software-decompress to RGBA8
|
||||
// before saving to PNG. This uses the `texpresso` crate.
|
||||
//
|
||||
// TODO: add `texpresso = "2"` to Cargo.toml for BCn software decode.
|
||||
// For now, print a helpful message.
|
||||
match tex.format {
|
||||
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
||||
// Raw BGRA8 — can save directly (with channel swizzle)
|
||||
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."
|
||||
);
|
||||
}
|
||||
}
|
||||
image::save_buffer(
|
||||
output,
|
||||
&rgba,
|
||||
tex.width,
|
||||
tex.height,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
)
|
||||
.with_context(|| format!("writing PNG {}", output.display()))?;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Best-effort human label for a decompressed entry's inner format.
|
||||
|
||||
Reference in New Issue
Block a user