The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE manifest (the authoritative movie -> subtitle -> voice index). Also flushes several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md. Subtitles (movie_subtitle.rs): - Full movie->track->text chain; join multi-line captions sharing one timing (fixes S13A dropped "Look at it father" line); overlap-safe active_cues(); Latin-1 accents preserved. Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its declared data size (fixes S10-S16 alternate-take garble); list_voice_clips. Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\). Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem). Viewer: manifest-driven voice (movie player toggle + solo button), standalone "Voice Lines" browser, stacked caption overlay. Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc tests; full workspace green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1218 lines
43 KiB
Rust
1218 lines
43 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,
|
||
},
|
||
}
|
||
|
||
#[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<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, 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<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()))?;
|
||
|
||
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<sylpheed_formats::mesh::Xbg7Model> {
|
||
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<f32> = 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<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 {
|
||
// 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<usize> = 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<Option<&sylpheed_formats::mesh::NodePlacement>> = {
|
||
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<f32> = 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<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 * 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<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;
|
||
|
||
/// 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<String> {
|
||
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} <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(), 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<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(())
|
||
}
|
||
|
||
// ── 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(())
|
||
}
|