Snapshot of local WIP before rebasing onto the movie/voice remote work. - audio.rs: AudioCodec enum, AudioInfo::probe (RIFF/WAVE parse, raw-XMA2 Shannon-entropy heuristic looks_like_raw_xma2), from_wav, riff_data_span. - cli/main.rs: `audio-info` subcommand (cmd_audio_info). - viewer ui.rs/iso_loader.rs: draw_audio_detail panel wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
872 lines
29 KiB
Rust
872 lines
29 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,
|
||
},
|
||
|
||
/// XBG7 mesh tools (inspect / headless render to PNG)
|
||
Mesh {
|
||
#[command(subcommand)]
|
||
cmd: MeshCommands,
|
||
},
|
||
|
||
/// Audio tools (identify WAV/XMA/XMA2 + metadata)
|
||
Audio {
|
||
#[command(subcommand)]
|
||
cmd: AudioCommands,
|
||
},
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum AudioCommands {
|
||
/// Identify an audio file/stream and print its metadata
|
||
Info {
|
||
/// Path to a WAV / XMA / raw audio stream
|
||
file: PathBuf,
|
||
},
|
||
}
|
||
|
||
#[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 MeshCommands {
|
||
/// Print the decoded sub-models of an XBG7 container (`.xpr`)
|
||
Info {
|
||
/// Path to the `.xpr` model / stage container
|
||
file: PathBuf,
|
||
},
|
||
/// Headless-render the decoded mesh(es) to a shaded PNG (software rasterizer)
|
||
Render {
|
||
/// Path to the `.xpr` model / stage container
|
||
file: PathBuf,
|
||
/// Output PNG path
|
||
output: PathBuf,
|
||
/// Image size in pixels (square)
|
||
#[arg(long, default_value_t = 900)]
|
||
size: u32,
|
||
/// Camera yaw in degrees
|
||
#[arg(long, default_value_t = 35.0)]
|
||
yaw: f32,
|
||
/// Camera pitch in degrees
|
||
#[arg(long, default_value_t = 22.0)]
|
||
pitch: f32,
|
||
/// Force the stage grid layout even for single models
|
||
#[arg(long)]
|
||
row: bool,
|
||
/// Only render sub-models whose name contains this substring
|
||
#[arg(long)]
|
||
only: Option<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::Mesh { cmd } => match cmd {
|
||
MeshCommands::Info { file } => cmd_mesh_info(&file),
|
||
MeshCommands::Render { file, output, size, yaw, pitch, row, only } => {
|
||
cmd_mesh_render(&file, &output, size, yaw, pitch, row, only)
|
||
}
|
||
},
|
||
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),
|
||
},
|
||
Commands::Audio { cmd } => match cmd {
|
||
AudioCommands::Info { file } => cmd_audio_info(&file),
|
||
},
|
||
}
|
||
}
|
||
|
||
// ── audio info ───────────────────────────────────────────────────────────────
|
||
|
||
fn cmd_audio_info(file: &Path) -> Result<()> {
|
||
use sylpheed_formats::AudioInfo;
|
||
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
|
||
let info = AudioInfo::probe(&bytes);
|
||
|
||
println!("{} {}", "Audio:".green().bold(), file.display());
|
||
println!(" Codec : {}", info.codec.label().yellow());
|
||
let opt = |v: Option<String>| v.unwrap_or_else(|| "—".dimmed().to_string());
|
||
println!(" Channels : {}", opt(info.channels.map(|c| c.to_string())));
|
||
println!(" Sample rate: {}", opt(info.sample_rate.map(|r| format!("{r} Hz"))));
|
||
println!(" Bit depth : {}", opt(info.bits_per_sample.map(|b| format!("{b}-bit"))));
|
||
if let Some(d) = info.duration_secs {
|
||
println!(" Duration : {d:.2} s");
|
||
}
|
||
if let Some(p) = info.xma_packets {
|
||
println!(" XMA packets: {} (2048 B each)", p.to_string().yellow());
|
||
}
|
||
println!(" Size : {} bytes", info.size_bytes.to_string().yellow());
|
||
if info.codec.needs_decoder() {
|
||
println!(
|
||
" {} decode not supported (needs an XMA2 decoder + the sound-bank descriptor)",
|
||
"note:".dimmed()
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ── 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(),
|
||
"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()))?;
|
||
|
||
let d = tex.format.desc();
|
||
println!("{} {}", "Texture:".green().bold(), file.display());
|
||
println!(" Resolution : {}×{}", tex.width.to_string().yellow(), tex.height.to_string().yellow());
|
||
println!(
|
||
" Format : {} ({:?}) · {} bpp, {}",
|
||
tex.format.gpu_name().yellow(),
|
||
tex.format,
|
||
d.bpp,
|
||
if d.compressed { "compressed" } else { "uncompressed" },
|
||
);
|
||
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(())
|
||
}
|
||
|
||
// ── mesh info / render ──────────────────────────────────────────────────────
|
||
|
||
/// Decode a container and return its sub-models the same way the viewer routes:
|
||
/// a single model (weapon / prop) OR a stage's many sub-models — whichever
|
||
/// yields more geometry.
|
||
fn decode_models(bytes: &[u8]) -> Vec<sylpheed_formats::mesh::Xbg7Model> {
|
||
use sylpheed_formats::mesh::Xbg7Model;
|
||
let single = Xbg7Model::from_xpr2(bytes)
|
||
.ok()
|
||
.filter(|m| !m.meshes.is_empty());
|
||
let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0);
|
||
let stage = Xbg7Model::stage_models(bytes);
|
||
let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum();
|
||
if !stage.is_empty() && stage_verts > single_verts {
|
||
stage
|
||
} else {
|
||
single.into_iter().collect()
|
||
}
|
||
}
|
||
|
||
fn cmd_mesh_info(file: &Path) -> Result<()> {
|
||
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
|
||
let models = decode_models(&bytes);
|
||
if models.is_empty() {
|
||
println!("{} no decodable XBG7 geometry", "Mesh:".yellow().bold());
|
||
return Ok(());
|
||
}
|
||
let (mut tv, mut tt) = (0usize, 0usize);
|
||
println!("{} {}", "Mesh:".green().bold(), file.display());
|
||
for m in &models {
|
||
let (v, t) = m.totals();
|
||
tv += v;
|
||
tt += t;
|
||
let mut lo = [f32::MAX; 3];
|
||
let mut hi = [f32::MIN; 3];
|
||
for sub in &m.meshes {
|
||
for p in &sub.positions {
|
||
for a in 0..3 {
|
||
lo[a] = lo[a].min(p[a]);
|
||
hi[a] = hi[a].max(p[a]);
|
||
}
|
||
}
|
||
}
|
||
println!(
|
||
" {:16} {:>6} v {:>6} t bbox [{:.1} {:.1} {:.1}]",
|
||
m.name,
|
||
v,
|
||
t,
|
||
hi[0] - lo[0],
|
||
hi[1] - lo[1],
|
||
hi[2] - lo[2]
|
||
);
|
||
}
|
||
println!(
|
||
" {} {} sub-models · {} verts · {} tris",
|
||
"TOTAL".bold(),
|
||
models.len(),
|
||
tv,
|
||
tt
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn cmd_mesh_render(
|
||
file: &Path,
|
||
output: &Path,
|
||
size: u32,
|
||
yaw: f32,
|
||
pitch: f32,
|
||
force_row: bool,
|
||
only: Option<String>,
|
||
) -> Result<()> {
|
||
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?;
|
||
let mut models = decode_models(&bytes);
|
||
if let Some(sub) = &only {
|
||
models.retain(|m| m.name.contains(sub.as_str()));
|
||
}
|
||
if models.is_empty() {
|
||
anyhow::bail!("no decodable XBG7 geometry in {}", file.display());
|
||
}
|
||
|
||
// ── Build a triangle soup. ──
|
||
// Single models render centred; multi-model containers (stages) get the
|
||
// viewer's normalised **thumbnail grid**: each sub-model recentred and
|
||
// uniformly scaled to a fixed cell, so all are equally visible regardless of
|
||
// native scale (mirrors `spawn_stage_models`).
|
||
let multi = models.len() > 1 || force_row;
|
||
let mut tris: Vec<[[f32; 3]; 3]> = Vec::new();
|
||
const CELL: f32 = 10.0;
|
||
const GAP: f32 = 4.0;
|
||
let grid_pitch = CELL + GAP;
|
||
let cols = (models.len() as f32).sqrt().ceil().max(1.0) as usize;
|
||
for (i, m) in models.iter().enumerate() {
|
||
let mut lo = [f32::MAX; 3];
|
||
let mut hi = [f32::MIN; 3];
|
||
for sub in &m.meshes {
|
||
for p in &sub.positions {
|
||
for a in 0..3 {
|
||
lo[a] = lo[a].min(p[a]);
|
||
hi[a] = hi[a].max(p[a]);
|
||
}
|
||
}
|
||
}
|
||
if lo[0] > hi[0] {
|
||
continue;
|
||
}
|
||
let center = [
|
||
(lo[0] + hi[0]) * 0.5,
|
||
(lo[1] + hi[1]) * 0.5,
|
||
(lo[2] + hi[2]) * 0.5,
|
||
];
|
||
let (scale, cell) = if multi {
|
||
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]).max(1e-3);
|
||
let col = i % cols;
|
||
let row = i / cols;
|
||
(CELL / extent, [col as f32 * grid_pitch, -(row as f32) * grid_pitch, 0.0])
|
||
} else {
|
||
(1.0, [0.0, 0.0, 0.0])
|
||
};
|
||
for sub in &m.meshes {
|
||
for tri in sub.indices.chunks_exact(3) {
|
||
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
|
||
if a >= sub.positions.len() || b >= sub.positions.len() || c >= sub.positions.len() {
|
||
continue;
|
||
}
|
||
let f = |i: usize| {
|
||
let p = sub.positions[i];
|
||
[
|
||
(p[0] - center[0]) * scale + cell[0],
|
||
(p[1] - center[1]) * scale + cell[1],
|
||
(p[2] - center[2]) * scale + cell[2],
|
||
]
|
||
};
|
||
tris.push([f(a), f(b), f(c)]);
|
||
}
|
||
}
|
||
}
|
||
if tris.is_empty() {
|
||
anyhow::bail!("no triangles to render");
|
||
}
|
||
|
||
let rgba = rasterize(&tris, size, yaw, pitch);
|
||
image::save_buffer(output, &rgba, size, size, image::ExtendedColorType::Rgba8)
|
||
.with_context(|| format!("writing PNG {}", output.display()))?;
|
||
println!(
|
||
"{} {} tris → {} ({}×{}, yaw {:.0}° pitch {:.0}°)",
|
||
"Rendered".green().bold(),
|
||
tris.len(),
|
||
output.display().to_string().cyan(),
|
||
size,
|
||
size,
|
||
yaw,
|
||
pitch,
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Minimal software rasterizer: orthographic, z-buffered, two-sided Lambert +
|
||
/// headlight shading over a flat grey material on a dark background. Enough to
|
||
/// judge whether recovered geometry is coherent.
|
||
fn rasterize(tris: &[[[f32; 3]; 3]], size: u32, yaw_deg: f32, pitch_deg: f32) -> Vec<u8> {
|
||
let n = size as usize;
|
||
let (yaw, pitch) = (yaw_deg.to_radians(), pitch_deg.to_radians());
|
||
let (cy, sy) = (yaw.cos(), yaw.sin());
|
||
let (cp, sp) = (pitch.cos(), pitch.sin());
|
||
// Rotate a world point into view space (yaw about Y, then pitch about X).
|
||
let view = |p: [f32; 3]| -> [f32; 3] {
|
||
let x = p[0] * cy + p[2] * sy;
|
||
let z0 = -p[0] * sy + p[2] * cy;
|
||
let y = p[1] * cp - z0 * sp;
|
||
let z = p[1] * sp + z0 * cp;
|
||
[x, y, z]
|
||
};
|
||
|
||
// View-space bbox → orthographic fit.
|
||
let mut lo = [f32::MAX; 3];
|
||
let mut hi = [f32::MIN; 3];
|
||
for t in tris {
|
||
for v in t {
|
||
let q = view(*v);
|
||
for a in 0..3 {
|
||
lo[a] = lo[a].min(q[a]);
|
||
hi[a] = hi[a].max(q[a]);
|
||
}
|
||
}
|
||
}
|
||
let span = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-3);
|
||
let scale = (n as f32) * 0.9 / span;
|
||
let cx = (lo[0] + hi[0]) * 0.5;
|
||
let cyv = (lo[1] + hi[1]) * 0.5;
|
||
let to_screen = |q: [f32; 3]| -> (f32, f32, f32) {
|
||
let sx = (q[0] - cx) * scale + n as f32 * 0.5;
|
||
let sy = n as f32 * 0.5 - (q[1] - cyv) * scale;
|
||
(sx, sy, q[2])
|
||
};
|
||
|
||
let mut color = vec![18u8; n * n * 4];
|
||
for i in 0..n * n {
|
||
color[i * 4 + 3] = 255;
|
||
}
|
||
let mut depth = vec![f32::MAX; n * n];
|
||
// Light in view space (upper-left-front).
|
||
let light = {
|
||
let l = [-0.4f32, 0.6, 0.7];
|
||
let m = (l[0] * l[0] + l[1] * l[1] + l[2] * l[2]).sqrt();
|
||
[l[0] / m, l[1] / m, l[2] / m]
|
||
};
|
||
|
||
for t in tris {
|
||
let v0 = view(t[0]);
|
||
let v1 = view(t[1]);
|
||
let v2 = view(t[2]);
|
||
// Face normal in view space.
|
||
let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
|
||
let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
|
||
let mut nrm = [
|
||
e1[1] * e2[2] - e1[2] * e2[1],
|
||
e1[2] * e2[0] - e1[0] * e2[2],
|
||
e1[0] * e2[1] - e1[1] * e2[0],
|
||
];
|
||
let nl = (nrm[0] * nrm[0] + nrm[1] * nrm[1] + nrm[2] * nrm[2]).sqrt();
|
||
if nl < 1e-12 {
|
||
continue;
|
||
}
|
||
nrm = [nrm[0] / nl, nrm[1] / nl, nrm[2] / nl];
|
||
// Two-sided: diffuse from |n·L|, plus a headlight term from |n.z|.
|
||
let diff = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).abs();
|
||
let head = nrm[2].abs();
|
||
let inten = (0.18 + 0.55 * diff + 0.3 * head).min(1.0);
|
||
let shade = (inten * 210.0) as u8;
|
||
|
||
let (ax, ay, az) = to_screen(v0);
|
||
let (bx, by, bz) = to_screen(v1);
|
||
let (ccx, ccy, ccz) = to_screen(v2);
|
||
let minx = ax.min(bx).min(ccx).floor().max(0.0) as usize;
|
||
let maxx = ax.max(bx).max(ccx).ceil().min(n as f32 - 1.0) as usize;
|
||
let miny = ay.min(by).min(ccy).floor().max(0.0) as usize;
|
||
let maxy = ay.max(by).max(ccy).ceil().min(n as f32 - 1.0) as usize;
|
||
let area = (bx - ax) * (ccy - ay) - (by - ay) * (ccx - ax);
|
||
if area.abs() < 1e-6 {
|
||
continue;
|
||
}
|
||
for py in miny..=maxy {
|
||
for px in minx..=maxx {
|
||
let fx = px as f32 + 0.5;
|
||
let fy = py as f32 + 0.5;
|
||
let w0 = ((bx - fx) * (ccy - fy) - (by - fy) * (ccx - fx)) / area;
|
||
let w1 = ((ccx - fx) * (ay - fy) - (ccy - fy) * (ax - fx)) / area;
|
||
let w2 = 1.0 - w0 - w1;
|
||
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
|
||
continue;
|
||
}
|
||
let z = w0 * az + w1 * bz + w2 * ccz;
|
||
let idx = py * n + px;
|
||
if z < depth[idx] {
|
||
depth[idx] = z;
|
||
color[idx * 4] = shade;
|
||
color[idx * 4 + 1] = shade;
|
||
color[idx * 4 + 2] = (shade as f32 * 1.02).min(255.0) as u8;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
color
|
||
}
|
||
|
||
/// 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 ────────────────────────────────────────────────────────────────
|
||
|
||
use sylpheed_formats::pak::inner_format_label as inner_label;
|
||
|
||
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) => (o.identity(), o.recover_toc_path(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(())
|
||
}
|