Files
Sylpheed/crates/sylpheed-cli/src/main.rs
MechaCat02 8ed8c85f18 [WIP] Audio codec probe/detection + CLI audio-info + viewer audio panel
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>
2026-07-20 20:42:48 +02:00

872 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(())
}