//! 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, }, /// XBG7 mesh tools (inspect / headless render to PNG) Mesh { #[command(subcommand)] cmd: MeshCommands, }, } #[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, }, /// Decode every T8aD texture in the pak (direct, RATC-nested, and LSTA /// frames) to PNG — our decoder's output, for A/B against the running game. Textures { /// Path to the `.pak` index pak: PathBuf, /// Output directory for the PNGs (created if missing) output: PathBuf, /// Print per-texture dimensions + tile count. #[arg(long)] verbose: bool, }, } #[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, /// Camera distance multiplier (1.0 = framed; <1 zooms in, >1 out) #[arg(long, default_value_t = 1.0)] dist: 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, }, } #[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, dist, row, only } => { cmd_mesh_render(&file, &output, size, yaw, pitch, dist, 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), PakCommands::Textures { pak, output, verbose } => { cmd_pak_textures(&pak, &output, verbose) } }, } } // ── 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(()) } // ── 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 { use sylpheed_formats::mesh::{count_xbg7, Xbg7Model}; // Route by container kind, NOT by whichever decoder yields more verts (that // old heuristic let `stage_models`' content-anchoring win on single-model // files, fabricating phantom / duplicate / mis-anchored blocks). A file with // one XBG7 resource is a single model (weapon / prop) → use only the // validated records-based list decode; content-anchoring a single-model file // invents geometry. Many XBG7 resources → a Stage_* collection → anchor them. if count_xbg7(bytes) > 1 { // Multi-resource Stage_* collection → content-anchor every resource // (each anchor now gated by stored-normal agreement). Xbg7Model::stage_models(bytes) } else if let Some(m) = Xbg7Model::from_xpr2(bytes) .ok() .filter(|m| !m.meshes.is_empty()) { // Single model the records-based list decode carved (authoritative). vec![m] } else { // Single model from_xpr2 couldn't locate (its sequential carve missed // the block) — fall back to content-anchoring, which finds it by shape. // Use a STRICT winding-consistency gate (0.85): on a single-model file a // mis-anchor is an obvious phantom / spike-mess and must be declined, // unlike the large stage corpus which keeps the ungated path. Xbg7Model::anchor_models(bytes, 0.85) } } 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] ); // Per-sub-mesh integrity diagnostics: degenerate triangles (a zero-area // "hole"), vertices referenced by no triangle (dropped geometry), and the // referenced index range vs the vertex count (short/over reads). for (si, sub) in m.meshes.iter().enumerate() { let nv = sub.positions.len(); let mut referenced = vec![false; nv]; let (mut degen, mut oob, mut imax) = (0usize, 0usize, 0u32); for tri in sub.indices.chunks_exact(3) { let (a, b, c) = (tri[0], tri[1], tri[2]); imax = imax.max(a).max(b).max(c); if a == b || b == c || a == c { degen += 1; } for &i in tri { if (i as usize) < nv { referenced[i as usize] = true; } else { oob += 1; } } } let unref = referenced.iter().filter(|&&r| !r).count(); // Spanning triangles: longest edge ≫ the median (strip-junction spikes). let edge = |a: u32, b: u32| { let (p, q) = (sub.positions[a as usize], sub.positions[b as usize]); ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() }; let mut maxedges: Vec = sub .indices .chunks_exact(3) .map(|t| edge(t[0], t[1]).max(edge(t[1], t[2])).max(edge(t[0], t[2]))) .collect(); maxedges.sort_by(|a, b| a.partial_cmp(b).unwrap()); let median = maxedges.get(maxedges.len() / 2).copied().unwrap_or(1.0).max(1e-6); let spanning = maxedges.iter().filter(|&&e| e > 6.0 * median).count(); println!( " sub{si}: {nv} v, {} tris | degenerate {degen}, unref-verts {unref}, spanning>6×med {spanning}, idx_max {imax}/{}{}", sub.indices.len() / 3, nv.saturating_sub(1), if oob > 0 { format!(", OOB {oob}") } else { String::new() }, ); // XDUMPVERT=1 → print the first few vertex positions per sub-mesh, for // content-matching a decoded sub-mesh against the GPU draw log. if std::env::var("XDUMPVERT").is_ok() { let mut lo = [f32::MAX; 3]; let mut hi = [f32::MIN; 3]; 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!( " bbox X[{:.2}..{:.2}] Y[{:.2}..{:.2}] Z[{:.2}..{:.2}] ctr({:.2},{:.2},{:.2})", lo[0], hi[0], lo[1], hi[1], lo[2], hi[2], (lo[0]+hi[0])/2.0, (lo[1]+hi[1])/2.0, (lo[2]+hi[2])/2.0 ); } } } 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, dist: f32, force_row: bool, only: Option, ) -> 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 { // Prefer an exact name match (e.g. `f001` for the neutral ship pose, // excluding the `_rou_f001_mnv*` animation poses that also *contain* // "f001"); fall back to substring when nothing matches exactly. if models.iter().any(|m| m.name == *sub) { models.retain(|m| m.name == *sub); } else { 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; // XMIRROR=x|y|z → negate that axis, to test an Xbox(LH)→Bevy(RH) handedness // flip against reference screenshots. let mirror: [f32; 3] = match std::env::var("XMIRROR").ok().as_deref() { Some("x") => [-1.0, 1.0, 1.0], Some("y") => [1.0, -1.0, 1.0], Some("z") => [1.0, 1.0, -1.0], _ => [1.0, 1.0, 1.0], }; let mut tris: Vec<[[f32; 3]; 3]> = Vec::new(); // XCOLORSUB=1 tints each sub-mesh a distinct colour (to see which sub is // which part / where the "extra fin" comes from). Parallel to `tris`. let color_sub = std::env::var("XCOLORSUB").is_ok(); // XONLYSUB=N renders only the N-th global sub-mesh (to isolate one part). let only_sub: Option = std::env::var("XONLYSUB").ok().and_then(|s| s.parse().ok()); let mut tints: Vec<[f32; 3]> = Vec::new(); const PALETTE: [[f32; 3]; 8] = [ [1.0, 1.0, 1.0], // sub0 body = white [1.0, 0.35, 0.35], // sub1 red [0.35, 1.0, 0.35], // sub2 green [0.4, 0.55, 1.0], // sub3 blue [1.0, 0.9, 0.3], // sub4 yellow [1.0, 0.5, 1.0], // sub5 magenta [0.3, 1.0, 1.0], // sub6 cyan [1.0, 0.6, 0.2], // sub7 orange ]; let mut sub_gi = 0usize; 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]) }; // XNODEXFORM=1 applies the XBG7 scene-graph node placement (fins move to // the tail) — to verify the transforms recovered from the graph. let placements = if std::env::var("XNODEXFORM").is_ok() { sylpheed_formats::mesh::node_transforms(&bytes, &m.name) } else { Vec::new() }; for (sub_local, sub) in m.meshes.iter().enumerate() { // Every scene-graph instance that draws this sub-mesh (mirrored fin // pair, L/R winglets…); `None` = no graph placement → identity. let mine: Vec> = { let v: Vec<_> = placements .iter() .filter(|p| p.sub_index == sub_local) .map(Some) .collect(); if v.is_empty() { vec![None] } else { v } }; // Sub-mesh indices are a triangle list (the decoder has already // expanded the file's triangle strips). let n = sub.positions.len(); // XSPANONLY=1 renders ONLY long-edge ("spanning") triangles; XSPANHIDE=1 // renders everything EXCEPT them — to see whether the flagged spanning // triangles are real geometry or decode artifacts (phantom sheets). let span_only = std::env::var("XSPANONLY").is_ok(); let span_hide = std::env::var("XSPANHIDE").is_ok(); let med = { let mut e: Vec = sub .indices .chunks_exact(3) .filter(|t| (t[0] as usize) < n && (t[1] as usize) < n && (t[2] as usize) < n) .map(|t| { let d = |a: u32, b: u32| { let (p, q) = (sub.positions[a as usize], sub.positions[b as usize]); ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)) .sqrt() }; d(t[0], t[1]).max(d(t[1], t[2])).max(d(t[0], t[2])) }) .collect(); e.sort_by(|a, b| a.partial_cmp(b).unwrap()); e.get(e.len() / 2).copied().unwrap_or(1.0).max(1e-6) }; if let Some(want) = only_sub { if sub_gi != want { sub_gi += 1; continue; } } let tint = if color_sub { PALETTE[sub_gi % PALETTE.len()] } else { [1.0, 1.0, 1.0] }; for place in &mine { let f = |i: usize| { let p = place.map(|pl| pl.apply(sub.positions[i])).unwrap_or(sub.positions[i]); [ (p[0] - center[0]) * scale * mirror[0] + cell[0], (p[1] - center[1]) * scale * mirror[1] + cell[1], (p[2] - center[2]) * scale * mirror[2] + cell[2], ] }; 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 < n && b < n && c < n { if span_only || span_hide { let d = |i: usize, j: usize| { let (p, q) = (sub.positions[i], sub.positions[j]); ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)) .sqrt() }; let spanning = d(a, b).max(d(b, c)).max(d(a, c)) > 6.0 * med; if span_only && !spanning { continue; } if span_hide && spanning { continue; } } tris.push([f(a), f(b), f(c)]); tints.push(tint); } } } sub_gi += 1; } } if tris.is_empty() { anyhow::bail!("no triangles to render"); } let rgba = rasterize(&tris, &tints, size, yaw, pitch, dist); 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]], tints: &[[f32; 3]], size: u32, yaw_deg: f32, pitch_deg: f32, dist: f32, ) -> Vec { 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 * dist.max(1e-3)); 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 (ti, t) in tris.iter().enumerate() { let tint = tints.get(ti).copied().unwrap_or([1.0, 1.0, 1.0]); 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 as f32 * tint[0]).min(255.0) as u8; color[idx * 4 + 1] = (shade as f32 * tint[1]).min(255.0) as u8; color[idx * 4 + 2] = (shade as f32 * tint[2] * 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> { 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(()) } // ── pak textures ───────────────────────────────────────────────────────────── /// Turn a child name into a filesystem-safe fragment. fn safe_name(s: &str) -> String { s.chars() .map(|c| { if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' } }) .collect() } #[derive(Default)] struct TexStats { written: usize, skipped: usize, } fn be32_at(b: &[u8], off: usize) -> u32 { u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]]) } /// Decode one T8aD slice (whose first bytes are the magic) to PNG. With /// `verbose`, prints its dimensions + tile count; the `XDUMPHDR` env var dumps /// the raw header (base + offset table) for format RE. fn emit_t8ad( slice: &[u8], hash: u32, stem: &str, output: &Path, verbose: bool, stats: &mut TexStats, ) -> Result<()> { use sylpheed_formats::t8ad; if !t8ad::is_t8ad(slice) || slice.len() < 0x40 { return Ok(()); } let (w, h, tiles) = ( be32_at(slice, 0x14), be32_at(slice, 0x18), be32_at(slice, 0x1c), ); // Debug: dump the header — 44-byte base + the `tiles`-entry u32 offset table. if std::env::var("XDUMPHDR").is_ok() { println!("\n{stem} {w}x{h} tiles={tiles}"); print!(" base[0x00..0x2c]:"); for i in (0..44).step_by(4) { print!(" {:08x}", be32_at(slice, i)); } print!("\n offsets:"); for t in 0..(tiles as usize).min(64) { if 0x2c + t * 4 + 4 <= slice.len() { print!(" {}", be32_at(slice, 0x2c + t * 4)); } } println!(); return Ok(()); } if verbose { println!(" {hash:08x} {stem:<34} {w:>4}x{h:<4} tiles {tiles}"); } match t8ad::parse(slice) { Some(img) => { let out = output.join(format!("{hash:08x}_{stem}_{}x{}.png", img.width, img.height)); image::save_buffer( &out, &img.rgba, img.width, img.height, image::ExtendedColorType::Rgba8, ) .with_context(|| format!("writing PNG {}", out.display()))?; stats.written += 1; } None => stats.skipped += 1, } Ok(()) } /// Decode every T8aD in a pak (direct entries, RATC-nested children, LSTA /// frames) to PNG — our decoder's exact output — for A/B against the game. fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool) -> Result<()> { use sylpheed_formats::{lsta, ratc, t8ad}; let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?; std::fs::create_dir_all(output) .with_context(|| format!("creating {}", output.display()))?; println!( "{} {} → {}", "Textures".green().bold(), pak.display().to_string().cyan(), output.display().to_string().cyan(), ); let mut stats = TexStats::default(); for e in arc.entries() { let payload = match arc.read(e) { Ok(p) => p, Err(_) => continue, }; let hash = e.name_hash; // Direct T8aD entry. if t8ad::is_t8ad(&payload) { emit_t8ad(&payload, hash, "direct", output, verbose, &mut stats)?; continue; } // LSTA sprite list = N inline T8aD frames (walk by magic, emit each). if lsta::is_lsta(&payload) { let mut off = 0usize; let mut idx = 0usize; while let Some(pos) = payload[off..] .windows(4) .position(|w| w == &t8ad::T8AD_MAGIC) { let start = off + pos; let next = payload[start + 4..] .windows(4) .position(|w| w == &t8ad::T8AD_MAGIC) .map(|p| start + 4 + p) .unwrap_or(payload.len()); emit_t8ad( &payload[start..next], hash, &format!("lsta{idx:03}"), output, verbose, &mut stats, )?; idx += 1; off = next; } continue; } // RATC bundle: decode its T8aD children (named, e.g. `foo.t32`). if ratc::is_ratc(&payload) { if let Some(children) = ratc::parse(&payload) { for (i, child) in children.iter().enumerate() { if child.kind != "T8aD" { continue; } let end = (child.offset + child.size).min(payload.len()); if child.offset >= end { continue; } let stem = if child.name.is_empty() { format!("child{i:03}") } else { safe_name(&child.name) }; emit_t8ad(&payload[child.offset..end], hash, &stem, output, verbose, &mut stats)?; } } continue; } } println!( "\n {} PNG(s) written, {} undecodable (non-tilecount variants — likely DXT)", stats.written.to_string().green(), stats.skipped.to_string().yellow(), ); Ok(()) }