feat: initialise workspace — Milestone 1 asset explorer
Three-crate Cargo workspace structured per PROJECT.md spec: - crates/sylpheed-formats — Xbox 360 format parsers (no Bevy) - crates/sylpheed-viewer — Bevy 0.15 asset viewer + egui UI - crates/sylpheed-cli — CLI tools (extract/list/sniff/texture) Milestone 1 features: - XISO disc image reading via xdvdfs 0.8 - XPR2 texture container parsing + Morton de-tiling - D3DFORMAT → wgpu TextureFormat mapping (DXT1/3/5, DXN, ARGB) - Custom Bevy AssetLoader for .xpr files - Orbit camera (LMB orbit, RMB pan, scroll zoom) - egui file browser + RE notes panel - CLI: extract / list / sniff / texture info / texture export - GitHub Actions CI (Linux, macOS, Windows, WASM) - Trunk WASM build config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
21
crates/sylpheed-formats/Cargo.toml
Normal file
21
crates/sylpheed-formats/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "sylpheed-formats"
|
||||
description = "Xbox 360 game asset format parsers for Project Sylpheed: Arc of Deception"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
xdvdfs = { workspace = true }
|
||||
binrw = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
85
crates/sylpheed-formats/src/audio.rs
Normal file
85
crates/sylpheed-formats/src/audio.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! Audio format parsing — XMA and XWB handling.
|
||||
//!
|
||||
//! Xbox 360 games use **XMA** (Xbox Media Audio) as their primary audio codec.
|
||||
//! XMA is a proprietary Microsoft codec derived from WMA Pro.
|
||||
//!
|
||||
//! ## The Challenge
|
||||
//! XMA decoding requires Microsoft's proprietary XMA decoder, which is only
|
||||
//! available in the Windows DirectX runtime. There are two approaches:
|
||||
//!
|
||||
//! ### Option A: Convert on extraction (recommended for Milestone 1)
|
||||
//! Use `xma2encode.exe` (from Xbox 360 SDK) or `ffmpeg` (has partial XMA support)
|
||||
//! to pre-convert all audio to OGG/WAV during the extraction step.
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Convert a single XMA file to WAV using ffmpeg
|
||||
//! ffmpeg -i audio.xma output.wav
|
||||
//!
|
||||
//! # Or use VGMStream's test.exe for more accurate XMA decoding
|
||||
//! ```
|
||||
//!
|
||||
//! ### Option B: XMA2 software decoder
|
||||
//! The `xma2dec` project provides an open-source XMA2 decoder.
|
||||
//! GitHub: https://github.com/koolkdev/xmalib (research-quality)
|
||||
//!
|
||||
//! ### XWB Wave Bank format
|
||||
//! Xbox 360 games typically bundle audio into XWB (Xbox Wave Bank) files.
|
||||
//! These are containers that hold multiple XMA streams.
|
||||
//! Reference: https://github.com/microsoft/DirectXTK/tree/main/Audio
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AudioError {
|
||||
#[error("Unknown audio format magic: {0:?}")]
|
||||
UnknownMagic([u8; 4]),
|
||||
#[error("XMA decoding requires pre-conversion. See audio.rs for instructions.")]
|
||||
XmaNotSupported,
|
||||
#[error("Parse error: {0}")]
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
/// An audio clip decoded to raw PCM, ready for Bevy's audio system.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameAudio {
|
||||
/// Raw PCM samples (interleaved for stereo: L R L R ...)
|
||||
pub samples: Vec<f32>,
|
||||
pub channels: u16,
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
impl GameAudio {
|
||||
/// Parse audio from raw bytes.
|
||||
///
|
||||
/// Currently only WAV/PCM is supported. For XMA files, pre-convert using:
|
||||
/// `ffmpeg -i file.xma file.wav`
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, AudioError> {
|
||||
// Check for WAV magic
|
||||
if bytes.len() >= 4 && &bytes[..4] == b"RIFF" {
|
||||
return Self::from_wav(bytes);
|
||||
}
|
||||
|
||||
// XMA magic bytes
|
||||
if bytes.len() >= 4 && (
|
||||
&bytes[..4] == b"XWB\0" || // Wave bank
|
||||
&bytes[..4] == b"XMA2" // Raw XMA2
|
||||
) {
|
||||
return Err(AudioError::XmaNotSupported);
|
||||
}
|
||||
|
||||
Err(AudioError::UnknownMagic(
|
||||
bytes[..4.min(bytes.len())].try_into().unwrap_or([0u8; 4])
|
||||
))
|
||||
}
|
||||
|
||||
fn from_wav(bytes: &[u8]) -> Result<Self, AudioError> {
|
||||
// Minimal WAV parser for PCM files
|
||||
// A proper implementation should use the `hound` crate:
|
||||
// https://crates.io/crates/hound
|
||||
//
|
||||
// TODO: Add `hound = "3"` to Cargo.toml and implement properly
|
||||
Err(AudioError::Parse(
|
||||
"WAV parsing TODO: add `hound` crate and implement".to_string()
|
||||
))
|
||||
}
|
||||
}
|
||||
30
crates/sylpheed-formats/src/lib.rs
Normal file
30
crates/sylpheed-formats/src/lib.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
//! # sylpheed-formats
|
||||
//!
|
||||
//! Asset format parsers for Project Sylpheed: Arc of Deception (Xbox 360).
|
||||
//!
|
||||
//! This crate handles:
|
||||
//! - Reading game files from XISO disc images (native only)
|
||||
//! - Parsing Xbox 360 texture formats (XPR2 containers + DXT de-tiling)
|
||||
//! - Parsing mesh formats (to be reverse engineered)
|
||||
//! - Parsing audio formats (XMA → standard PCM)
|
||||
//!
|
||||
//! ## Platform notes
|
||||
//! XISO reading is only available on native (Windows/macOS/Linux).
|
||||
//! On WASM, assets must be pre-extracted and served over HTTP.
|
||||
|
||||
pub mod texture;
|
||||
pub mod vfs;
|
||||
|
||||
// XISO reading is not available in-browser
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod xiso;
|
||||
|
||||
// Mesh parsing — scaffold for reverse engineering
|
||||
pub mod mesh;
|
||||
|
||||
// Audio parsing — scaffold for XMA handling
|
||||
pub mod audio;
|
||||
|
||||
/// Re-export the most commonly used types at the crate root.
|
||||
pub use texture::{X360Texture, X360TextureFormat};
|
||||
pub use vfs::{GameAssets, VfsError};
|
||||
71
crates/sylpheed-formats/src/mesh.rs
Normal file
71
crates/sylpheed-formats/src/mesh.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Mesh format parsing — TO BE REVERSE ENGINEERED.
|
||||
//!
|
||||
//! Project Sylpheed uses a completely custom engine with unknown mesh formats.
|
||||
//! This module is a scaffold: populate these structs as you discover the
|
||||
//! actual binary layout using a hex editor (010 Editor) and Ghidra.
|
||||
//!
|
||||
//! ## RE Strategy for Meshes
|
||||
//!
|
||||
//! 1. Extract the game files using xdvdfs
|
||||
//! 2. Look for files with extensions like .mdl, .mesh, .geo, .obj, .pak
|
||||
//! 3. Open them in a hex editor — look for:
|
||||
//! - Repeating patterns of 12 bytes (XYZ float vertices)
|
||||
//! - Groups of 3 uint16s (triangle indices)
|
||||
//! - Header magic bytes
|
||||
//! 4. Cross-reference with Ghidra's XEX analysis to find the load functions
|
||||
//!
|
||||
//! ## Useful tools
|
||||
//! - 010 Editor with binary templates
|
||||
//! - Noesis (can preview many console formats)
|
||||
//! - binrw (this project) for writing the parser once the format is known
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MeshError {
|
||||
#[error("Unknown mesh magic: {0:?}")]
|
||||
UnknownMagic([u8; 4]),
|
||||
#[error("Unsupported mesh version: {0}")]
|
||||
UnsupportedVersion(u32),
|
||||
#[error("Parse error: {0}")]
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
/// A decoded 3D mesh ready for Bevy.
|
||||
/// Vertex positions, normals, UVs, and indices.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct GameMesh {
|
||||
/// Interleaved vertex positions [x, y, z, x, y, z, ...]
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
/// Vertex normals [nx, ny, nz, ...]
|
||||
pub normals: Vec<[f32; 3]>,
|
||||
/// UV texture coordinates [u, v, ...]
|
||||
pub uvs: Vec<[f32; 2]>,
|
||||
/// Triangle list indices
|
||||
pub indices: Vec<u32>,
|
||||
/// Name of this mesh (if available in the file)
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl GameMesh {
|
||||
/// Parse a mesh from raw bytes.
|
||||
///
|
||||
/// TODO: implement once the actual file format is identified.
|
||||
/// Currently returns an error until the format is reverse engineered.
|
||||
pub fn from_bytes(_bytes: &[u8]) -> Result<Vec<Self>, MeshError> {
|
||||
// ┌──────────────────────────────────────────────────────────────┐
|
||||
// │ REVERSE ENGINEERING TODO │
|
||||
// │ │
|
||||
// │ Steps to implement this: │
|
||||
// │ 1. Find mesh files in the extraction (look for .mdl etc.) │
|
||||
// │ 2. Identify the file format using identify_format() in vfs │
|
||||
// │ 3. Use 010 Editor to map the binary structure │
|
||||
// │ 4. Add binrw #[derive(BinRead)] structs above │
|
||||
// │ 5. Implement this function to parse and return meshes │
|
||||
// └──────────────────────────────────────────────────────────────┘
|
||||
Err(MeshError::Parse(
|
||||
"Mesh format not yet reverse engineered. \
|
||||
See RE TODO in src/mesh.rs".to_string()
|
||||
))
|
||||
}
|
||||
}
|
||||
422
crates/sylpheed-formats/src/texture.rs
Normal file
422
crates/sylpheed-formats/src/texture.rs
Normal file
@@ -0,0 +1,422 @@
|
||||
//! Xbox 360 texture format parsing and de-tiling.
|
||||
//!
|
||||
//! ## The Problem: GPU Tiling
|
||||
//! The Xbox 360 Xenos GPU stores textures in a "tiled" memory layout for
|
||||
//! cache efficiency. The tiles are 32×32 texel macro-tiles, and within
|
||||
//! each macro-tile the DXT compression blocks are arranged in Morton
|
||||
//! (Z-order curve) order. Before we can upload a texture to a modern GPU,
|
||||
//! we must "de-tile" it back to a standard linear (row-major) layout.
|
||||
//!
|
||||
//! ## Reference implementations
|
||||
//! - Xenia emulator: `src/xenia/gpu/texture_util.cc`
|
||||
//! - RareView (C#): Xbox 360 texture de-tiling
|
||||
//! - swizzleinator crate: general console texture unswizzling
|
||||
|
||||
use binrw::{BinRead, binread};
|
||||
use thiserror::Error;
|
||||
|
||||
// ── Error type ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TextureError {
|
||||
#[error("Invalid texture header magic: expected {expected:?}, got {got:?}")]
|
||||
BadMagic { expected: [u8; 4], got: [u8; 4] },
|
||||
|
||||
#[error("Unsupported texture format: 0x{0:02X}")]
|
||||
UnsupportedFormat(u8),
|
||||
|
||||
#[error("Buffer too small: need {needed} bytes, have {have}")]
|
||||
BufferTooSmall { needed: usize, have: usize },
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Parse error: {0}")]
|
||||
Parse(#[from] binrw::Error),
|
||||
}
|
||||
|
||||
// ── Texture formats ───────────────────────────────────────────────────────────
|
||||
|
||||
/// D3DFORMAT values used by the Xbox 360 SDK for texture data.
|
||||
/// These appear in XPR2 headers and in-memory texture descriptors.
|
||||
///
|
||||
/// Format codes sourced from:
|
||||
/// - Xbox 360 SDK documentation (leaked)
|
||||
/// - Xenia emulator source (gpu/xenos.h)
|
||||
/// - ZenHAX community research
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum X360TextureFormat {
|
||||
/// DXT1 / BC1 — 4 bpp, 1-bit alpha
|
||||
Dxt1 = 0x52,
|
||||
/// DXT3 / BC2 — 8 bpp, 4-bit explicit alpha
|
||||
Dxt3 = 0x53,
|
||||
/// DXT5 / BC3 — 8 bpp, 8-bit interpolated alpha
|
||||
Dxt5 = 0x54,
|
||||
/// DXN / BC5 / ATI2 — normal maps, two-channel
|
||||
Dxn = 0x71,
|
||||
/// Uncompressed A8R8G8B8 — 32 bpp
|
||||
A8R8G8B8 = 0x06,
|
||||
/// Uncompressed X8R8G8B8 — 32 bpp, no alpha
|
||||
X8R8G8B8 = 0x07,
|
||||
}
|
||||
|
||||
impl X360TextureFormat {
|
||||
pub fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0x52 => Some(Self::Dxt1),
|
||||
0x53 => Some(Self::Dxt3),
|
||||
0x54 => Some(Self::Dxt5),
|
||||
0x71 => Some(Self::Dxn),
|
||||
0x06 => Some(Self::A8R8G8B8),
|
||||
0x07 => Some(Self::X8R8G8B8),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes per compressed block (4×4 texel group).
|
||||
/// For uncompressed formats, bytes per pixel instead.
|
||||
pub fn bytes_per_block(&self) -> usize {
|
||||
match self {
|
||||
Self::Dxt1 => 8,
|
||||
Self::Dxt3 | Self::Dxt5 | Self::Dxn => 16,
|
||||
Self::A8R8G8B8 | Self::X8R8G8B8 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this a BCn block-compressed format?
|
||||
pub fn is_block_compressed(&self) -> bool {
|
||||
matches!(self, Self::Dxt1 | Self::Dxt3 | Self::Dxt5 | Self::Dxn)
|
||||
}
|
||||
|
||||
/// Texels per block side (4 for BCn, 1 for uncompressed).
|
||||
pub fn block_size(&self) -> usize {
|
||||
if self.is_block_compressed() { 4 } else { 1 }
|
||||
}
|
||||
}
|
||||
|
||||
// ── XPR2 container format ─────────────────────────────────────────────────────
|
||||
|
||||
/// XPR2 is Microsoft's Xbox Packed Resource v2 format.
|
||||
/// It stores D3D resources (textures, vertex buffers, index buffers)
|
||||
/// in a single blob that can be DMA'd directly into GPU memory.
|
||||
///
|
||||
/// The header is big-endian (Xbox 360 is big-endian PowerPC).
|
||||
/// NOTE: Project Sylpheed may use a custom container. If files don't
|
||||
/// start with b"XPR2", check for game-specific magic bytes instead.
|
||||
#[binread]
|
||||
#[br(magic = b"XPR2", big)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Xpr2Header {
|
||||
/// Total file size in bytes
|
||||
pub total_size: u32,
|
||||
/// Size of the header section (texture data starts after this)
|
||||
pub header_size: u32,
|
||||
/// Number of resource entries in this file
|
||||
pub num_resources: u32,
|
||||
}
|
||||
|
||||
/// A single resource entry within an XPR2 file.
|
||||
/// Each entry describes one D3D resource (texture, buffer, etc.)
|
||||
#[binread]
|
||||
#[br(big)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Xpr2ResourceEntry {
|
||||
/// Encoded type and reference count.
|
||||
/// Bits [0..15] = ref_count
|
||||
/// Bits [16..18] = resource type (4 = texture)
|
||||
/// Bits [19..31] = flags
|
||||
pub resource_type_and_flags: u32,
|
||||
/// Offset of this resource's data within the XPR2 file
|
||||
pub data_offset: u32,
|
||||
/// Reserved / unknown
|
||||
pub _unknown: u32,
|
||||
}
|
||||
|
||||
impl Xpr2ResourceEntry {
|
||||
pub fn resource_type(&self) -> u8 {
|
||||
((self.resource_type_and_flags >> 16) & 0x7) as u8
|
||||
}
|
||||
|
||||
pub fn is_texture(&self) -> bool {
|
||||
self.resource_type() == 4
|
||||
}
|
||||
}
|
||||
|
||||
/// Xbox 360 D3D texture descriptor embedded in an XPR2 resource.
|
||||
/// This is what the GPU register `NV097_SET_TEXTURE_FORMAT` receives.
|
||||
///
|
||||
/// Reference: xboxdevwiki.net/XPR
|
||||
#[binread]
|
||||
#[br(big)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct X360TextureDesc {
|
||||
/// Packed GPU texture format register
|
||||
/// Bits [0..3] = DMA channel
|
||||
/// Bits [4..7] = dimensionality (2 = 2D)
|
||||
/// Bits [8..15] = D3DFORMAT (see X360TextureFormat)
|
||||
/// Bits [16..19] = mip levels
|
||||
/// Bits [20..23] = width as power-of-two: actual = 1 << value
|
||||
/// Bits [24..27] = height as power-of-two: actual = 1 << value
|
||||
/// Bits [28..31] = depth (for 3D textures)
|
||||
pub gpu_format: u32,
|
||||
|
||||
/// For non-power-of-two textures, encodes actual dimensions
|
||||
pub npot_size: u32,
|
||||
}
|
||||
|
||||
impl X360TextureDesc {
|
||||
pub fn format_code(&self) -> u8 {
|
||||
((self.gpu_format >> 8) & 0xFF) as u8
|
||||
}
|
||||
|
||||
pub fn format(&self) -> Option<X360TextureFormat> {
|
||||
X360TextureFormat::from_u8(self.format_code())
|
||||
}
|
||||
|
||||
pub fn mip_levels(&self) -> u32 {
|
||||
(self.gpu_format >> 16) & 0xF
|
||||
}
|
||||
|
||||
/// Width from the packed power-of-two field.
|
||||
pub fn width_pow2(&self) -> u32 {
|
||||
1 << ((self.gpu_format >> 20) & 0xF)
|
||||
}
|
||||
|
||||
/// Height from the packed power-of-two field.
|
||||
pub fn height_pow2(&self) -> u32 {
|
||||
1 << ((self.gpu_format >> 24) & 0xF)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decoded texture ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A decoded Xbox 360 texture, ready to upload to a modern GPU.
|
||||
///
|
||||
/// After `from_xpr2()` or `from_raw_tiled()`, the `data` field contains
|
||||
/// the texture in standard linear layout that Bevy / wgpu can consume.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct X360Texture {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub format: X360TextureFormat,
|
||||
pub mip_levels: u32,
|
||||
/// De-tiled texture data in linear (row-major) order.
|
||||
/// For BCn formats: standard DDS-style packed block data.
|
||||
/// For ARGB: standard RGBA8 pixel data.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl X360Texture {
|
||||
/// Parse a texture from a raw XPR2 file's bytes.
|
||||
///
|
||||
/// This handles the full pipeline:
|
||||
/// 1. Parse XPR2 header + resource descriptors
|
||||
/// 2. Locate the texture resource
|
||||
/// 3. De-tile the GPU memory layout → linear layout
|
||||
pub fn from_xpr2(bytes: &[u8]) -> Result<Self, TextureError> {
|
||||
use std::io::Cursor;
|
||||
let mut cur = Cursor::new(bytes);
|
||||
|
||||
// Parse main header (validates "XPR2" magic)
|
||||
let header = Xpr2Header::read(&mut cur)?;
|
||||
|
||||
// Parse resource entries
|
||||
let mut entries = Vec::new();
|
||||
for _ in 0..header.num_resources {
|
||||
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
|
||||
}
|
||||
|
||||
// Find the first texture resource
|
||||
let tex_entry = entries.iter()
|
||||
.find(|e| e.is_texture())
|
||||
.ok_or_else(|| TextureError::UnsupportedFormat(0))?;
|
||||
|
||||
// The texture descriptor immediately follows the resource entries
|
||||
let desc = X360TextureDesc::read(&mut cur)?;
|
||||
|
||||
let format = desc.format()
|
||||
.ok_or(TextureError::UnsupportedFormat(desc.format_code()))?;
|
||||
|
||||
let width = desc.width_pow2();
|
||||
let height = desc.height_pow2();
|
||||
|
||||
// Texture data is at header_size offset
|
||||
let data_start = header.header_size as usize;
|
||||
if bytes.len() <= data_start {
|
||||
return Err(TextureError::BufferTooSmall {
|
||||
needed: data_start + 1,
|
||||
have: bytes.len(),
|
||||
});
|
||||
}
|
||||
let tiled_data = &bytes[data_start..];
|
||||
|
||||
// De-tile!
|
||||
let linear_data = detile(tiled_data, width, height, format)?;
|
||||
|
||||
Ok(X360Texture {
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
mip_levels: desc.mip_levels().max(1),
|
||||
data: linear_data,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a texture from already-known parameters + raw tiled data.
|
||||
///
|
||||
/// Use this when you've reverse-engineered a game-specific container
|
||||
/// and extracted the raw tiled texture bytes yourself.
|
||||
pub fn from_raw_tiled(
|
||||
tiled_data: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: X360TextureFormat,
|
||||
) -> Result<Self, TextureError> {
|
||||
let linear_data = detile(tiled_data, width, height, format)?;
|
||||
Ok(X360Texture {
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
mip_levels: 1,
|
||||
data: linear_data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core de-tiling algorithm ──────────────────────────────────────────────────
|
||||
|
||||
/// De-tile an Xbox 360 GPU texture from tiled to linear layout.
|
||||
///
|
||||
/// Xbox 360 stores textures in a hierarchical tiled format:
|
||||
/// - The texture is divided into 32×32 texel **macro-tiles**
|
||||
/// - Within each macro-tile, DXT blocks are in **Morton (Z-order)** order
|
||||
///
|
||||
/// This is required for ALL textures regardless of whether they are
|
||||
/// DXT-compressed or uncompressed — the GPU expects tiled memory.
|
||||
///
|
||||
/// Algorithm based on:
|
||||
/// - Xenia: `texture_util.cc` `TileTexture()`
|
||||
/// - GTA IV Xbox 360 Texture Editor by Pimpin Tyler and Anthony
|
||||
pub fn detile(
|
||||
src: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: X360TextureFormat,
|
||||
) -> Result<Vec<u8>, TextureError> {
|
||||
let block_size = format.block_size() as u32;
|
||||
let bpb = format.bytes_per_block(); // bytes per block
|
||||
|
||||
// Dimensions in blocks
|
||||
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
|
||||
let blocks_tall = ((height + block_size - 1) / block_size).max(1);
|
||||
|
||||
let expected = blocks_wide as usize * blocks_tall as usize * bpb;
|
||||
if src.len() < expected {
|
||||
return Err(TextureError::BufferTooSmall {
|
||||
needed: expected,
|
||||
have: src.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut dst = vec![0u8; expected];
|
||||
|
||||
// Macro-tile dimensions (in blocks)
|
||||
// Xbox 360 always uses 32×32 texel macro-tiles
|
||||
let macro_tile_blocks = 32 / block_size; // = 8 for BCn (4×4 texels/block)
|
||||
|
||||
let macro_tiles_wide = (blocks_wide + macro_tile_blocks - 1) / macro_tile_blocks;
|
||||
let macro_tiles_tall = (blocks_tall + macro_tile_blocks - 1) / macro_tile_blocks;
|
||||
let blocks_per_macro_tile = (macro_tile_blocks * macro_tile_blocks) as usize;
|
||||
|
||||
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 that fall outside the actual texture dimensions
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
/// Decode a Morton (Z-order curve) index into (x, y) coordinates.
|
||||
///
|
||||
/// Morton encoding interleaves the bits of x and y coordinates:
|
||||
/// index = ...y3 x3 y2 x2 y1 x1 y0 x0
|
||||
///
|
||||
/// This is the inverse operation — extract interleaved bits back to x, y.
|
||||
#[inline]
|
||||
pub fn morton_decode(index: u32) -> (u32, u32) {
|
||||
let x = compact_bits(index);
|
||||
let y = compact_bits(index >> 1);
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// Compact every other bit — the "de-interleave" operation.
|
||||
/// Used by `morton_decode` to separate X and Y from a Morton index.
|
||||
#[inline]
|
||||
fn compact_bits(mut x: u32) -> u32 {
|
||||
x &= 0x5555_5555; // x = -f-e -d-c -b-a -9-8 -7-6 -5-4 -3-2 -1-0
|
||||
x = (x ^ (x >> 1)) & 0x3333_3333; // x = --fe --dc --ba --98 --76 --54 --32 --10
|
||||
x = (x ^ (x >> 2)) & 0x0f0f_0f0f; // x = ----fedc ----ba98 ----7654 ----3210
|
||||
x = (x ^ (x >> 4)) & 0x00ff_00ff; // x = --------fedcba98 --------76543210
|
||||
x = (x ^ (x >> 8)) & 0x0000_ffff; // x = ----------------fedcba9876543210
|
||||
x
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn morton_decode_corners() {
|
||||
// Index 0 → (0, 0)
|
||||
assert_eq!(morton_decode(0), (0, 0));
|
||||
// Index 1 → (1, 0) — bit 0 is x
|
||||
assert_eq!(morton_decode(1), (1, 0));
|
||||
// Index 2 → (0, 1) — bit 1 is y
|
||||
assert_eq!(morton_decode(2), (0, 1));
|
||||
// Index 3 → (1, 1)
|
||||
assert_eq!(morton_decode(3), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detile_noop_for_1x1_block() {
|
||||
// A 4×4 DXT1 texture = exactly 1 block = 8 bytes
|
||||
// De-tiling a single block should be identity
|
||||
let src = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04];
|
||||
let result = detile(&src, 4, 4, X360TextureFormat::Dxt1).unwrap();
|
||||
assert_eq!(result, src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x360_format_bytes_per_block() {
|
||||
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);
|
||||
assert_eq!(X360TextureFormat::Dxt5.bytes_per_block(), 16);
|
||||
assert_eq!(X360TextureFormat::A8R8G8B8.bytes_per_block(), 4);
|
||||
}
|
||||
}
|
||||
144
crates/sylpheed-formats/src/vfs.rs
Normal file
144
crates/sylpheed-formats/src/vfs.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
//! Virtual file system abstraction.
|
||||
//!
|
||||
//! `GameAssets` provides a unified interface for accessing game files
|
||||
//! regardless of whether they're in an extracted directory (the normal
|
||||
//! development workflow) or read directly from an XISO (less common).
|
||||
//!
|
||||
//! ## Recommended workflow
|
||||
//! 1. Run `xdvdfs unpack your_game.iso ./extracted/` once
|
||||
//! 2. Point `GameAssets` at `./extracted/`
|
||||
//! 3. Iterate fast — no ISO re-parsing on every launch
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum VfsError {
|
||||
#[error("File not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("IO error reading {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Provides access to extracted game files.
|
||||
///
|
||||
/// All paths are relative to the extraction root and use `/` as separator.
|
||||
/// The implementation handles OS-specific path separators internally.
|
||||
pub struct GameAssets {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl GameAssets {
|
||||
/// Create a new `GameAssets` pointing at a directory of extracted files.
|
||||
///
|
||||
/// Call `xdvdfs unpack game.iso ./extracted/` first.
|
||||
pub fn from_directory(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// Read a file's bytes by its in-game path.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// # use sylpheed_formats::vfs::GameAssets;
|
||||
/// let assets = GameAssets::from_directory("./extracted");
|
||||
/// let bytes = assets.read("DEFAULT.XEX").unwrap();
|
||||
/// ```
|
||||
pub fn read(&self, game_path: &str) -> Result<Vec<u8>, VfsError> {
|
||||
let disk_path = self.resolve(game_path);
|
||||
std::fs::read(&disk_path).map_err(|e| VfsError::Io {
|
||||
path: game_path.to_string(),
|
||||
source: e,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether a file exists.
|
||||
pub fn exists(&self, game_path: &str) -> bool {
|
||||
self.resolve(game_path).exists()
|
||||
}
|
||||
|
||||
/// List all files under a subdirectory, recursively.
|
||||
///
|
||||
/// Returns paths relative to the extraction root, with `/` separators.
|
||||
pub fn list(&self, subdir: &str) -> Result<Vec<String>, VfsError> {
|
||||
let dir = self.resolve(subdir);
|
||||
let mut result = Vec::new();
|
||||
self.walk_dir(&dir, &dir, &mut result).map_err(|e| VfsError::Io {
|
||||
path: subdir.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Resolve a game-relative path to an OS filesystem path.
|
||||
pub fn resolve(&self, game_path: &str) -> PathBuf {
|
||||
// Normalize separators and join to root
|
||||
let native = game_path.replace('/', std::path::MAIN_SEPARATOR_STR);
|
||||
self.root.join(native)
|
||||
}
|
||||
|
||||
fn walk_dir(
|
||||
&self,
|
||||
dir: &Path,
|
||||
root: &Path,
|
||||
out: &mut Vec<String>,
|
||||
) -> std::io::Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
self.walk_dir(&path, root, out)?;
|
||||
} else {
|
||||
// Make path relative and use forward slashes
|
||||
let rel = path.strip_prefix(root).unwrap_or(&path);
|
||||
out.push(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers for common file type detection ────────────────────────────────────
|
||||
|
||||
/// Sniff a file's format from its first bytes (magic number).
|
||||
/// Use this to identify unknown file types during reverse engineering.
|
||||
pub fn identify_format(bytes: &[u8]) -> FileFormat {
|
||||
if bytes.len() < 4 {
|
||||
return FileFormat::Unknown;
|
||||
}
|
||||
match &bytes[..4] {
|
||||
b"XPR2" => FileFormat::Xpr2Texture,
|
||||
b"RIFF" => FileFormat::Riff, // could be WAV or XWB
|
||||
b"XWB\0" => FileFormat::XwbAudio, // Xbox Wave Bank
|
||||
[0x89, b'P', b'N', b'G'] => FileFormat::Png,
|
||||
b"DDS " => FileFormat::Dds,
|
||||
_ => FileFormat::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileFormat {
|
||||
Xpr2Texture,
|
||||
Riff,
|
||||
XwbAudio,
|
||||
Png,
|
||||
Dds,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl FileFormat {
|
||||
pub fn extension_hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Xpr2Texture => "xpr",
|
||||
Self::Riff => "riff",
|
||||
Self::XwbAudio => "xwb",
|
||||
Self::Png => "png",
|
||||
Self::Dds => "dds",
|
||||
Self::Unknown => "bin",
|
||||
}
|
||||
}
|
||||
}
|
||||
168
crates/sylpheed-formats/src/xiso.rs
Normal file
168
crates/sylpheed-formats/src/xiso.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! XISO / XDVDFS disc image reading.
|
||||
//!
|
||||
//! Xbox 360 game discs use the XDVDFS filesystem (also called XISO).
|
||||
//! This module wraps the `xdvdfs` crate to extract game files into memory
|
||||
//! or onto disk for further processing.
|
||||
//!
|
||||
//! ## Reference projects
|
||||
//! - xdvdfs (Rust): https://github.com/antangelo/xdvdfs
|
||||
//! - extract-xiso (C): https://github.com/XboxDev/extract-xiso
|
||||
//! - Xenia emulator: https://github.com/xenia-canary/xenia-canary
|
||||
|
||||
use std::io::{Read, Seek};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info};
|
||||
use xdvdfs::layout::VolumeDescriptor;
|
||||
|
||||
/// A handle to an open XISO image.
|
||||
pub struct XisoReader<F: Read + Seek> {
|
||||
volume: VolumeDescriptor,
|
||||
file: F,
|
||||
}
|
||||
|
||||
impl<F: Read + Seek + Send + Sync + 'static> XisoReader<F> {
|
||||
pub async fn open(mut file: F) -> Result<Self> {
|
||||
let volume = xdvdfs::read::read_volume(&mut file)
|
||||
.await
|
||||
.context("Failed to read XDVDFS volume descriptor. Is this a valid Xbox 360 ISO?")?;
|
||||
|
||||
info!(
|
||||
"Opened XISO: root directory table at sector {}",
|
||||
{ let s = volume.root_table.region.sector; s }
|
||||
);
|
||||
Ok(Self { volume, file })
|
||||
}
|
||||
|
||||
/// List all files in the disc image, recursively (directories excluded).
|
||||
pub async fn list_all_files(&mut self) -> Result<Vec<String>> {
|
||||
let entries = self
|
||||
.volume
|
||||
.root_table
|
||||
.file_tree(&mut self.file)
|
||||
.await
|
||||
.context("Failed to walk XISO file tree")?;
|
||||
|
||||
let mut paths = Vec::new();
|
||||
for (parent, entry) in &entries {
|
||||
if entry.node.dirent.is_directory() {
|
||||
continue;
|
||||
}
|
||||
let name = entry
|
||||
.name_str::<std::io::Error>()
|
||||
.unwrap_or(std::borrow::Cow::Borrowed("<invalid>"));
|
||||
// file_tree builds parent paths with a leading slash (e.g. "/dat").
|
||||
// Trim it so we get "dat/filename" instead of "/dat/filename".
|
||||
let full_path = format!("{}/{}", parent, name);
|
||||
paths.push(full_path.trim_start_matches('/').to_string());
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Read the raw bytes of a file by its path inside the ISO.
|
||||
pub async fn read_file(&mut self, path: &str) -> Result<Vec<u8>> {
|
||||
let dirent = self
|
||||
.volume
|
||||
.root_table
|
||||
.walk_path(&mut self.file, path)
|
||||
.await
|
||||
.with_context(|| format!("File not found in ISO: {}", path))?;
|
||||
|
||||
let data = dirent
|
||||
.node
|
||||
.dirent
|
||||
.read_data_all(&mut self.file)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read file data: {}", path))?;
|
||||
|
||||
debug!("Read {} bytes from {}", data.len(), path);
|
||||
Ok(data.into_vec())
|
||||
}
|
||||
|
||||
/// Extract all files from the ISO into a directory on disk.
|
||||
pub async fn extract_all(&mut self, output_dir: &Path) -> Result<ExtractStats> {
|
||||
info!("Extracting ISO to {}", output_dir.display());
|
||||
std::fs::create_dir_all(output_dir).context("Failed to create output directory")?;
|
||||
|
||||
let entries = self
|
||||
.volume
|
||||
.root_table
|
||||
.file_tree(&mut self.file)
|
||||
.await
|
||||
.context("Failed to walk XISO file tree")?;
|
||||
|
||||
let mut stats = ExtractStats::default();
|
||||
for (parent, entry) in &entries {
|
||||
if entry.node.dirent.is_directory() {
|
||||
continue;
|
||||
}
|
||||
let name = entry
|
||||
.name_str::<std::io::Error>()
|
||||
.unwrap_or(std::borrow::Cow::Borrowed("<invalid>"));
|
||||
let rel_path = format!("{}/{}", parent, name);
|
||||
let rel_path = rel_path.trim_start_matches('/');
|
||||
|
||||
let disk_path =
|
||||
output_dir.join(rel_path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
|
||||
if let Some(parent_dir) = disk_path.parent() {
|
||||
std::fs::create_dir_all(parent_dir).with_context(|| {
|
||||
format!("Failed to create dir: {}", parent_dir.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
let data = entry
|
||||
.node
|
||||
.dirent
|
||||
.read_data_all(&mut self.file)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read: {}", rel_path))?;
|
||||
|
||||
std::fs::write(&disk_path, &*data)
|
||||
.with_context(|| format!("Failed to write: {}", disk_path.display()))?;
|
||||
|
||||
stats.files_extracted += 1;
|
||||
stats.bytes_extracted += data.len() as u64;
|
||||
debug!("Extracted {}", rel_path);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Extraction complete: {} files, {} bytes",
|
||||
stats.files_extracted, stats.bytes_extracted
|
||||
);
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an XISO from a file path (the common case).
|
||||
pub async fn open_iso(path: &Path) -> Result<XisoReader<std::fs::File>> {
|
||||
let file = std::fs::File::open(path)
|
||||
.with_context(|| format!("Cannot open ISO: {}", path.display()))?;
|
||||
XisoReader::open(file).await
|
||||
}
|
||||
|
||||
/// Statistics from an extraction operation.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ExtractStats {
|
||||
pub files_extracted: usize,
|
||||
pub bytes_extracted: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a real ISO image — set SYLPHEED_ISO env var"]
|
||||
async fn test_list_iso() {
|
||||
let iso_path =
|
||||
std::env::var("SYLPHEED_ISO").expect("Set SYLPHEED_ISO to your ISO path");
|
||||
let mut reader = open_iso(Path::new(&iso_path)).await.unwrap();
|
||||
let files = reader.list_all_files().await.unwrap();
|
||||
for f in &files {
|
||||
println!("{}", f);
|
||||
}
|
||||
println!("Total: {} files", files.len());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user