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:
MechaCat02
2026-03-25 21:04:07 +01:00
commit f8127e73b0
23 changed files with 8107 additions and 0 deletions

View 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"

View 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(())
}