Files
Syplheed-Reborn/crates/sylpheed-cli/src/main.rs
MechaCat02 95de29f290 feat(formats,viewer): movie subtitles, voice decode, and the movie manifest
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>
2026-07-19 20:15:41 +02:00

1218 lines
43 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,
},
}
#[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(())
}