//! 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, }, /// 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, }, } #[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| 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) -> 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 { 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, ) -> 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 { 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> { 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} ", 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 { 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(()) }