//! 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, }, /// 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) -> 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(), "txt" => label.cyan().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> { 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 ──────────────────────────────────────────────────────────────── use sylpheed_formats::pak::inner_format_label as inner_label; /// 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 { 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} ", 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) => (o.identity(), 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 { 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(()) }