Files
Syplheed-Reborn/crates/sylpheed-cli/src/main.rs
MechaCat02 840db9c549 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>
2026-07-10 21:37:20 +02:00

551 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(())
}