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>
551 lines
18 KiB
Rust
551 lines
18 KiB
Rust
//! sylpheed-cli — command line tools for Project Sylpheed asset work.
|
||
//!
|
||
//! ## Commands
|
||
//!
|
||
//! ### Extract an ISO
|
||
//! ```bash
|
||
//! sylpheed-cli extract game.iso ./assets/
|
||
//! ```
|
||
//!
|
||
//! ### List files inside an ISO
|
||
//! ```bash
|
||
//! sylpheed-cli list game.iso
|
||
//! sylpheed-cli list game.iso --filter .xpr
|
||
//! ```
|
||
//!
|
||
//! ### Sniff the format of unknown files
|
||
//! ```bash
|
||
//! sylpheed-cli sniff ./assets/DATA/
|
||
//! ```
|
||
//! Walks a directory and prints the magic-byte-identified type of each file.
|
||
//! Invaluable for the first pass of reverse engineering.
|
||
//!
|
||
//! ### Inspect a texture
|
||
//! ```bash
|
||
//! sylpheed-cli texture info ./assets/DATA/TEXTURES/SHIP01.XPR
|
||
//! sylpheed-cli texture export ./assets/DATA/TEXTURES/SHIP01.XPR ship01.png
|
||
//! ```
|
||
//!
|
||
//! ### Inspect IPFB archives and IDXD definitions
|
||
//! ```bash
|
||
//! sylpheed-cli pak list ./assets/dat/GP_MAIN_GAME_E.pak # inventory entries
|
||
//! sylpheed-cli pak dump ./assets/dat/GP_MAIN_GAME_E.pak 0x7c96296c # one object's stat sheet
|
||
//! ```
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use anyhow::{Context, Result};
|
||
use clap::{Parser, Subcommand};
|
||
use colored::*;
|
||
use indicatif::{ProgressBar, ProgressStyle};
|
||
use tracing::info;
|
||
|
||
use sylpheed_formats::vfs::{identify_format, GameAssets};
|
||
use sylpheed_formats::{IdxdObject, PakArchive};
|
||
|
||
// ── CLI definition ─────────────────────────────────────────────────────────
|
||
|
||
#[derive(Parser)]
|
||
#[command(
|
||
name = "sylpheed-cli",
|
||
about = "Project Sylpheed: Arc of Deception — asset tools",
|
||
version,
|
||
long_about = None,
|
||
)]
|
||
struct Cli {
|
||
#[command(subcommand)]
|
||
command: Commands,
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum Commands {
|
||
/// Extract all files from an XISO disc image
|
||
Extract {
|
||
/// Path to the .iso file
|
||
iso: PathBuf,
|
||
/// Output directory (will be created if it doesn't exist)
|
||
output: PathBuf,
|
||
},
|
||
|
||
/// List files inside an XISO disc image
|
||
List {
|
||
/// Path to the .iso file
|
||
iso: PathBuf,
|
||
/// Only show files matching this substring
|
||
#[arg(long)]
|
||
filter: Option<String>,
|
||
},
|
||
|
||
/// Walk a directory and identify file formats by magic bytes.
|
||
/// Essential for the first pass of reverse engineering.
|
||
Sniff {
|
||
/// Directory to walk (use your extraction output)
|
||
dir: PathBuf,
|
||
/// Only show unrecognized files (focus RE effort)
|
||
#[arg(long)]
|
||
unknown_only: bool,
|
||
},
|
||
|
||
/// Texture tools
|
||
Texture {
|
||
#[command(subcommand)]
|
||
cmd: TextureCommands,
|
||
},
|
||
|
||
/// IPFB archive (`*.pak`) and IDXD definition tools
|
||
Pak {
|
||
#[command(subcommand)]
|
||
cmd: PakCommands,
|
||
},
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum PakCommands {
|
||
/// List the entries of an IPFB archive (hash, size, inner format, identity)
|
||
List {
|
||
/// Path to the `.pak` index (sibling `.p00`/`.pNN` segments are loaded automatically)
|
||
pak: PathBuf,
|
||
/// Only show IDXD-object entries
|
||
#[arg(long)]
|
||
idxd_only: bool,
|
||
},
|
||
/// Dump one entry: its IDXD schema and every explicitly-valued field
|
||
Dump {
|
||
/// Path to the `.pak` index
|
||
pak: PathBuf,
|
||
/// Entry name-hash, e.g. `0x7c96296c`
|
||
hash: String,
|
||
},
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum TextureCommands {
|
||
/// Print information about a texture file
|
||
Info {
|
||
/// Path to the texture file
|
||
file: PathBuf,
|
||
},
|
||
/// Export a texture to PNG
|
||
Export {
|
||
/// Path to the texture file
|
||
file: PathBuf,
|
||
/// Output PNG path
|
||
output: PathBuf,
|
||
},
|
||
}
|
||
|
||
// ── Entry point ────────────────────────────────────────────────────────────
|
||
|
||
#[tokio::main]
|
||
async fn main() -> Result<()> {
|
||
tracing_subscriber::fmt()
|
||
.with_env_filter(
|
||
tracing_subscriber::EnvFilter::from_default_env()
|
||
.add_directive("sylpheed=info".parse().unwrap())
|
||
)
|
||
.init();
|
||
|
||
let cli = Cli::parse();
|
||
|
||
match cli.command {
|
||
Commands::Extract { iso, output } => cmd_extract(&iso, &output).await,
|
||
Commands::List { iso, filter } => cmd_list(&iso, filter).await,
|
||
Commands::Sniff { dir, unknown_only } => cmd_sniff(&dir, unknown_only),
|
||
Commands::Texture { cmd } => match cmd {
|
||
TextureCommands::Info { file } => cmd_texture_info(&file),
|
||
TextureCommands::Export { file, output } => cmd_texture_export(&file, &output),
|
||
},
|
||
Commands::Pak { cmd } => match cmd {
|
||
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
|
||
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
|
||
},
|
||
}
|
||
}
|
||
|
||
// ── extract ────────────────────────────────────────────────────────────────
|
||
|
||
async fn cmd_extract(iso_path: &Path, output_dir: &Path) -> Result<()> {
|
||
println!(
|
||
"{} {} → {}",
|
||
"Extracting".green().bold(),
|
||
iso_path.display().to_string().cyan(),
|
||
output_dir.display().to_string().cyan()
|
||
);
|
||
|
||
let mut reader = sylpheed_formats::xiso::open_iso(iso_path).await?;
|
||
|
||
// Count files first for a meaningful progress bar
|
||
println!("{} Scanning ISO contents...", " ·".dimmed());
|
||
let all_files = reader.list_all_files().await?;
|
||
let total = all_files.len();
|
||
println!(" Found {} files", total.to_string().yellow());
|
||
|
||
let pb = ProgressBar::new(total as u64);
|
||
pb.set_style(
|
||
ProgressStyle::default_bar()
|
||
.template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
|
||
.unwrap()
|
||
.progress_chars("█▉▊▋▌▍▎▏ ")
|
||
);
|
||
|
||
let stats = reader.extract_all(output_dir).await?;
|
||
pb.finish_and_clear();
|
||
|
||
println!(
|
||
"{} Extracted {} files ({:.2} MB)",
|
||
"✓".green().bold(),
|
||
stats.files_extracted.to_string().yellow(),
|
||
(stats.bytes_extracted as f64) / (1024.0 * 1024.0)
|
||
);
|
||
println!(
|
||
" Assets ready at: {}",
|
||
output_dir.display().to_string().cyan()
|
||
);
|
||
println!();
|
||
println!(
|
||
" {} Run the viewer: {}",
|
||
"→".cyan(),
|
||
"cargo run --bin sylpheed-viewer".bold()
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ── list ───────────────────────────────────────────────────────────────────
|
||
|
||
async fn cmd_list(iso_path: &Path, filter: Option<String>) -> Result<()> {
|
||
println!("{} {}", "Listing".green().bold(), iso_path.display().to_string().cyan());
|
||
|
||
let mut reader = sylpheed_formats::xiso::open_iso(iso_path).await?;
|
||
let files = reader.list_all_files().await?;
|
||
|
||
let filter_lower = filter.as_deref().unwrap_or("").to_lowercase();
|
||
|
||
let mut shown = 0;
|
||
for file in &files {
|
||
if filter_lower.is_empty() || file.to_lowercase().contains(&filter_lower) {
|
||
println!(" {}", file);
|
||
shown += 1;
|
||
}
|
||
}
|
||
|
||
println!(
|
||
"\n {} files{}",
|
||
shown.to_string().yellow(),
|
||
if !filter_lower.is_empty() {
|
||
format!(" (filtered from {})", files.len())
|
||
} else {
|
||
String::new()
|
||
}
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ── sniff ──────────────────────────────────────────────────────────────────
|
||
|
||
fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
|
||
println!(
|
||
"{} {}",
|
||
"Sniffing formats in".green().bold(),
|
||
dir.display().to_string().cyan()
|
||
);
|
||
println!("{}", " (reading magic bytes of each file)".dimmed());
|
||
println!();
|
||
|
||
let assets = GameAssets::from_directory(dir);
|
||
let files = assets.list("").context("Failed to read directory")?;
|
||
|
||
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||
|
||
for file in &files {
|
||
let Ok(bytes) = assets.read(file) else { continue; };
|
||
let fmt = identify_format(&bytes);
|
||
let label = fmt.extension_hint();
|
||
*counts.entry(label).or_insert(0) += 1;
|
||
|
||
if unknown_only && label != "bin" {
|
||
continue;
|
||
}
|
||
|
||
let color_label = match label {
|
||
"bin" => label.red().to_string(),
|
||
"xpr" => label.green().to_string(),
|
||
"dds" => label.green().to_string(),
|
||
_ => label.yellow().to_string(),
|
||
};
|
||
|
||
// Show first 8 bytes as hex for unknown files
|
||
let hex_preview = if label == "bin" && bytes.len() >= 8 {
|
||
format!(
|
||
" {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X}",
|
||
bytes[0], bytes[1], bytes[2], bytes[3],
|
||
bytes[4], bytes[5], bytes[6], bytes[7]
|
||
).dimmed().to_string()
|
||
} else {
|
||
String::new()
|
||
};
|
||
|
||
println!(" [{color_label}] {file}{hex_preview}");
|
||
}
|
||
|
||
// Summary
|
||
println!();
|
||
println!("{}", "Format Summary:".bold());
|
||
let mut summary: Vec<_> = counts.into_iter().collect();
|
||
summary.sort_by(|a, b| b.1.cmp(&a.1));
|
||
for (fmt, count) in summary {
|
||
println!(
|
||
" {:>6} .{}",
|
||
count.to_string().yellow(),
|
||
fmt
|
||
);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ── texture info ──────────────────────────────────────────────────────────
|
||
|
||
fn cmd_texture_info(file: &Path) -> Result<()> {
|
||
let bytes = std::fs::read(file)
|
||
.with_context(|| format!("Cannot read {}", file.display()))?;
|
||
|
||
use sylpheed_formats::texture::X360Texture;
|
||
let tex = X360Texture::from_xpr2(&bytes)
|
||
.with_context(|| format!("Failed to parse texture: {}", file.display()))?;
|
||
|
||
println!("{} {}", "Texture:".green().bold(), file.display());
|
||
println!(" Resolution : {}×{}", tex.width.to_string().yellow(), tex.height.to_string().yellow());
|
||
println!(" Format : {:?}", tex.format);
|
||
println!(" Mip levels : {}", tex.mip_levels);
|
||
println!(" Data size : {} bytes", tex.data.len().to_string().yellow());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ── texture export ────────────────────────────────────────────────────────
|
||
|
||
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;
|
||
let tex = X360Texture::from_xpr2(&bytes)?;
|
||
let rgba = decode_to_rgba8(&tex)
|
||
.with_context(|| format!("decoding {:?} texture", tex.format))?;
|
||
|
||
image::save_buffer(
|
||
output,
|
||
&rgba,
|
||
tex.width,
|
||
tex.height,
|
||
image::ExtendedColorType::Rgba8,
|
||
)
|
||
.with_context(|| format!("writing PNG {}", output.display()))?;
|
||
|
||
println!(
|
||
"{} {}×{} {:?}{} → {}",
|
||
"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.
|
||
fn inner_label(payload: &[u8]) -> String {
|
||
if payload.len() < 4 {
|
||
return "empty".into();
|
||
}
|
||
let m = &payload[0..4];
|
||
if m == b"IDXD" {
|
||
"IDXD".into()
|
||
} else if payload.starts_with(b"<?xml") {
|
||
"xml".into()
|
||
} else if m.iter().all(|&b| b.is_ascii_graphic()) {
|
||
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, ttcf, …).
|
||
String::from_utf8_lossy(m).into_owned()
|
||
} else {
|
||
format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
|
||
}
|
||
}
|
||
|
||
/// For an IDXD entry, a short identity string (ID / Name / Model if present).
|
||
fn idxd_identity(obj: &IdxdObject) -> String {
|
||
for key in ["ID", "Name", "Model"] {
|
||
if let Some(v) = obj.get_raw(key) {
|
||
return format!("{key}={v}");
|
||
}
|
||
}
|
||
format!("schema {:08x}", obj.schema_hash)
|
||
}
|
||
|
||
/// Try to recover an IDXD entry's original TOC path from its identity tokens.
|
||
/// Uses the entry's ID/Name/Model fields plus identifier-like pool tokens as
|
||
/// candidates for [`sylpheed_formats::hash::recover_toc_name`].
|
||
fn idxd_toc_name(obj: &IdxdObject, name_hash: u32) -> Option<String> {
|
||
let mut cands: Vec<&str> = Vec::new();
|
||
for key in ["ID", "Name", "Model"] {
|
||
if let Some(v) = obj.get_raw(key) {
|
||
cands.push(v);
|
||
}
|
||
}
|
||
for t in obj.tokens() {
|
||
if t.contains('_') || t.len() >= 5 {
|
||
cands.push(t.as_str());
|
||
}
|
||
}
|
||
sylpheed_formats::hash::recover_toc_name(name_hash, &cands)
|
||
}
|
||
|
||
fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> {
|
||
let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
|
||
println!(
|
||
"{} {} ({} entries, block 0x{:x})",
|
||
"Archive".green().bold(),
|
||
pak.display().to_string().cyan(),
|
||
arc.len().to_string().yellow(),
|
||
arc.block_size,
|
||
);
|
||
|
||
let mut shown = 0usize;
|
||
let mut named = 0usize;
|
||
for e in arc.entries() {
|
||
let payload = match arc.read(e) {
|
||
Ok(p) => p,
|
||
Err(err) => {
|
||
eprintln!(" {:08x} <read error: {err}>", e.name_hash);
|
||
continue;
|
||
}
|
||
};
|
||
let label = inner_label(&payload);
|
||
let is_idxd = label == "IDXD";
|
||
if idxd_only && !is_idxd {
|
||
continue;
|
||
}
|
||
let (detail, name) = if is_idxd {
|
||
match IdxdObject::parse(&payload) {
|
||
Ok(o) => (idxd_identity(&o), idxd_toc_name(&o, e.name_hash)),
|
||
Err(_) => (String::new(), None),
|
||
}
|
||
} else {
|
||
(String::new(), None)
|
||
};
|
||
let name_col = match &name {
|
||
Some(p) => p.clone(),
|
||
None => "?".into(),
|
||
};
|
||
println!(
|
||
" {:08x} {:<28} {:>9} B {:<6} {}",
|
||
e.name_hash,
|
||
name_col.green(),
|
||
payload.len(),
|
||
label.yellow(),
|
||
detail.dimmed(),
|
||
);
|
||
if name.is_some() {
|
||
named += 1;
|
||
}
|
||
shown += 1;
|
||
}
|
||
println!(
|
||
"\n {} entries shown ({} name-resolved)",
|
||
shown.to_string().yellow(),
|
||
named.to_string().green(),
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
// ── pak dump ────────────────────────────────────────────────────────────────
|
||
|
||
fn parse_hash(s: &str) -> Result<u32> {
|
||
let t = s.trim_start_matches("0x").trim_start_matches("0X");
|
||
u32::from_str_radix(t, 16).with_context(|| format!("invalid hex hash: {s:?}"))
|
||
}
|
||
|
||
fn cmd_pak_dump(pak: &Path, hash_str: &str) -> Result<()> {
|
||
let hash = parse_hash(hash_str)?;
|
||
let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
|
||
let entry = arc
|
||
.find(hash)
|
||
.with_context(|| format!("no entry with hash 0x{hash:08x} in {}", pak.display()))?;
|
||
let payload = arc.read(entry)?;
|
||
|
||
if !IdxdObject::is_idxd(&payload) {
|
||
println!(
|
||
"{} entry 0x{hash:08x} is {} ({} bytes) — not an IDXD object",
|
||
"Note:".yellow(),
|
||
inner_label(&payload),
|
||
payload.len(),
|
||
);
|
||
return Ok(());
|
||
}
|
||
|
||
let obj = IdxdObject::parse(&payload)?;
|
||
println!(
|
||
"{} 0x{hash:08x} schema 0x{:08x} count {}",
|
||
"IDXD".green().bold(),
|
||
obj.schema_hash,
|
||
obj.count,
|
||
);
|
||
// Identity fields — the head-of-object fields that are reliably identifier-valued.
|
||
for key in ["ID", "Name", "Type", "Model"] {
|
||
if let Some(v) = obj.get_raw(key) {
|
||
println!(" {:<10} {}", format!("{key}:").dimmed(), v.cyan());
|
||
}
|
||
}
|
||
|
||
let fields = obj.resolved_fields();
|
||
println!(
|
||
"\n {} ({} explicit-value fields; defaulted fields omitted):",
|
||
"Fields".bold(),
|
||
fields.len().to_string().yellow(),
|
||
);
|
||
for (key, val) in &fields {
|
||
println!(" {:<28} = {}", key, val.yellow());
|
||
}
|
||
Ok(())
|
||
}
|