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-cli/Cargo.toml
Normal file
21
crates/sylpheed-cli/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "sylpheed-cli"
|
||||
description = "Command-line asset tools for Project Sylpheed: Arc of Deception"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "sylpheed-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
sylpheed-formats = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
colored = "2"
|
||||
indicatif = "0.17"
|
||||
333
crates/sylpheed-cli/src/main.rs
Normal file
333
crates/sylpheed-cli/src/main.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
//! 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
|
||||
//! ```
|
||||
|
||||
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};
|
||||
|
||||
// ── 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,
|
||||
},
|
||||
}
|
||||
|
||||
#[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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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, X360TextureFormat};
|
||||
let tex = X360Texture::from_xpr2(&bytes)?;
|
||||
|
||||
// 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
" Texture parsed OK: {}×{} {:?}",
|
||||
tex.width, tex.height, tex.format
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user