Files
Sylpheed/crates/sylpheed-cli/src/main.rs
MechaCat02 d394ba6aed style: rustfmt sweep — 107 files the lint gate never saw
This branch predates CI on `main`. `cargo fmt --all` only; no behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:34:40 +02:00

1914 lines
68 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 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,
},
/// UI screen tools — reassemble a screen from its RATC bundle
Screen {
#[command(subcommand)]
cmd: ScreenCommands,
},
/// Save file (`savedata`) tools
Save {
#[command(subcommand)]
cmd: SaveCommands,
},
}
#[derive(Subcommand)]
enum ScreenCommands {
/// List the screen builds in a UI pak, with their element counts
List {
/// Path to a `GP_*.pak`
pak: PathBuf,
/// Widen the list from screen builds to **every composable bundle** —
/// including the ones with no `.rat` layout child, such as the
/// developer-logo splash. 2 859 RATC bundles on the disc compose; only
/// 965 are screen builds, and the rest are mostly two-element fragments
/// (a button and its glow), so this is off by default. **It renumbers
/// `--build`**, which is why it is a flag and not the default.
#[arg(long)]
all: bool,
},
/// Print one build's element declaration table and resting placements
Info {
/// Path to a `GP_*.pak`
pak: PathBuf,
/// Which build (index into `screen list`); default = the largest
#[arg(long)]
build: Option<usize>,
/// Widen the list from screen builds to **every composable bundle** —
/// including the ones with no `.rat` layout child, such as the
/// developer-logo splash. 2 859 RATC bundles on the disc compose; only
/// 965 are screen builds, and the rest are mostly two-element fragments
/// (a button and its glow), so this is off by default. **It renumbers
/// `--build`**, which is why it is a flag and not the default.
#[arg(long)]
all: bool,
/// Also print the bundle's other orderings and, per element, the decoded
/// sprite size beside the declared pivot and every keyframe's
/// scale/position/time — what a placement or paint-order hypothesis has
/// to be tested against.
#[arg(long)]
geometry: bool,
},
/// Composite one build to a PNG — the headless self-verify for the viewer
Render {
/// Path to a `GP_*.pak`
pak: PathBuf,
/// Output PNG
output: PathBuf,
/// Which build (index into `screen list`); default = the largest
#[arg(long)]
build: Option<usize>,
/// Draw the focused-state (`*f`) records over their base elements
#[arg(long)]
focus: bool,
/// Draw `loop*` sprite animations
#[arg(long)]
animated: bool,
/// Start the canvas black instead of the default dim slate — what the
/// game composites over on a screen carrying its own background, and so
/// what a framebuffer capture must be compared against.
#[arg(long)]
black: bool,
/// Draw the untextured `.prm` primitives (fade / dim / flash quads).
/// Off by default: they are decoded, but where they paint on a screen
/// without a measured order is unsolved — see
/// `docs/re/structures/ui-prm-primitives.md`.
#[arg(long)]
primitives: bool,
/// Widen the list from screen builds to **every composable bundle** —
/// including the ones with no `.rat` layout child, such as the
/// developer-logo splash. 2 859 RATC bundles on the disc compose; only
/// 965 are screen builds, and the rest are mostly two-element fragments
/// (a button and its glow), so this is off by default. **It renumbers
/// `--build`**, which is why it is a flag and not the default.
#[arg(long)]
all: bool,
/// Pose every element at this KEYFRAME TIME instead of at its resting
/// pose (60 units = 1 second). The resting pose is each element's last
/// *hold* keyframe, picked independently of every other element, so it is
/// not the screen at any one moment: it omits anything still moving (the
/// title's light sweeps hold off the right edge) and freezes a transient
/// at its PEAK (the title's five two-frame flashes burn forever).
/// ⚠️ This help used to end "Prefer `--settle`". That is WITHDRAWN and was
/// never measured: scored against a live capture of the JP title, settle
/// gives RMSE 40.210 and rest 41.690 — a margin of 1.48 against that
/// instrument's own noise floor of 1.2, which is NOT decisive. `--settle`
/// also has its own failure mode (25.5 % of elements are mid-ramp at their
/// screen's settle instant). Neither is established as better; pick by what
/// you are measuring. See `docs/re/structures/ui-resting-pose.md`.
#[arg(long, conflicts_with = "settle")]
at: Option<u32>,
/// Pose every element at the instant the screen is SETTLED, derived from
/// the disc: the midpoint of the longest interval containing no keyframe
/// of any element. Prints the window it used, whose width is how much the
/// midpoint is worth — a narrow one means the bundle never settles.
/// ⚠️ That is **38 % of the screen builds this command renders** (185 of
/// 491 carrying two or more keyframe times) and 39 % of the wider set
/// `--all` admits (862 of 2 211), mostly `loop*` fragments. This help used
/// to say "42 % of them" without saying of WHAT: 42 % was 731/1 758 over
/// composable bundles, computed before the keyframe record-layout fix,
/// which times a group's final pose and so admits ~450 bundles that
/// previously had only one timed keyframe. ⚠️ NOT established as better
/// than the resting pose — see the note on `--at`. See
/// `docs/re/structures/ui-settle-time.md`.
#[arg(long)]
settle: bool,
},
}
#[derive(Subcommand)]
enum SaveCommands {
/// Parse a `savedata` file and print every field with its confidence
Info {
/// Path to a `savedata` file
file: PathBuf,
/// Also print the still-unidentified fields
#[arg(long)]
all: bool,
},
}
#[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,
},
/// 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),
},
Commands::Audio { cmd } => match cmd {
AudioCommands::Info { file } => cmd_audio_info(&file),
},
Commands::Screen { cmd } => match cmd {
ScreenCommands::List { pak, all } => cmd_screen_list(&pak, all),
ScreenCommands::Info {
pak,
build,
geometry,
all,
} => cmd_screen_info(&pak, build, geometry, all),
ScreenCommands::Render {
pak,
output,
build,
focus,
animated,
black,
all,
primitives,
at,
settle,
} => cmd_screen_render(
&pak, &output, build, focus, animated, black, all, primitives, at, settle,
),
},
Commands::Save { cmd } => match cmd {
SaveCommands::Info { file, all } => cmd_save_info(&file, all),
},
}
}
// ── UI screens ───────────────────────────────────────────────────────────────
/// Every RATC entry of a UI pak that parses as a screen build, with its bytes.
///
/// `all` widens the filter from `is_build` — which requires a `.rat` layout
/// child — to anything `compose` can draw. The developer-logo splash is the
/// reason: it declares its sprites directly, has no `.rat` child at all, and so
/// is invisible to every screen command without this. See
/// `docs/re/structures/ui-composable-bundles.md`.
fn screen_builds(pak: &Path, all: bool) -> Result<Vec<(usize, Vec<u8>)>> {
use sylpheed_formats::{pak::PakArchive, ui_layout};
let ar = PakArchive::open(pak).context("open pak")?;
let mut out = Vec::new();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let keep = if all {
ui_layout::is_composable(&bytes)
} else {
ui_layout::is_build(&bytes)
};
if keep {
out.push((i, bytes));
}
}
Ok(out)
}
/// Resolve `--build`: an explicit index into the build list, else the largest
/// build (a screen pak's biggest bundle is the full screen; the small ones are
/// language or context variants).
fn pick_build(builds: &[(usize, Vec<u8>)], want: Option<usize>) -> Result<usize> {
if builds.is_empty() {
anyhow::bail!("no screen builds in this pak");
}
match want {
Some(i) if i < builds.len() => Ok(i),
Some(i) => anyhow::bail!("build {i} out of range (0..{})", builds.len()),
None => Ok(builds
.iter()
.enumerate()
.max_by_key(|(_, (_, b))| b.len())
.map(|(i, _)| i)
.unwrap()),
}
}
fn cmd_screen_list(pak: &Path, all: bool) -> Result<()> {
use sylpheed_formats::ui_layout;
let builds = screen_builds(pak, all)?;
println!("{} screen build(s) in {}", builds.len(), pak.display());
for (i, (entry, bytes)) in builds.iter().enumerate() {
match ui_layout::parse_build(bytes) {
Some(b) => println!(
" [{i}] entry {entry:<3} {:>8} B {}x{} {} elements, {} sprites{}{}",
bytes.len(),
b.design_w,
b.design_h,
b.elements.len(),
b.sprites.len(),
b.context_hint
.as_deref()
.map(|c| format!(" context={c}"))
.unwrap_or_default(),
if b.from_fallback { " (fallback)" } else { "" },
),
None => println!(" [{i}] entry {entry:<3} {:>8} B (unparsed)", bytes.len()),
}
}
Ok(())
}
fn cmd_screen_info(pak: &Path, want: Option<usize>, geometry: bool, all: bool) -> Result<()> {
use sylpheed_formats::ui_layout;
let builds = screen_builds(pak, all)?;
let idx = pick_build(&builds, want)?;
let bytes = &builds[idx].1;
let b = ui_layout::parse_build(bytes).context("build did not parse")?;
println!(
"build [{idx}] {}x{} {} elements {} sprites{}",
b.design_w,
b.design_h,
b.elements.len(),
b.sprites.len(),
if b.from_fallback {
" (recovered from .rat records — the declaration table was unusable)"
} else {
""
}
);
println!(
"{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} rest / keyframes",
"#", "element", "parent", "kind", "pivot", "kf"
);
for el in &b.elements {
// The resting pose is the max-dwell keyframe, not the first or the last.
let rest = match el.rest() {
None => "".to_string(),
Some(k) if el.keyframes.len() == 1 => format!("({},{})", k.x, k.y),
Some(k) => {
// The final frame of a group carries no time — print it as `-`
// rather than inventing one.
let t = |t: Option<u32>| t.map(|v| v.to_string()).unwrap_or_else(|| "-".into());
format!(
"rest ({},{}) t={} [{}]",
k.x,
k.y,
t(k.time),
el.keyframes
.iter()
.map(|f| format!("{}:{},{}", t(f.time), f.x, f.y))
.collect::<Vec<_>>()
.join(" ")
)
}
};
println!(
"{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} {rest}",
el.index,
el.name,
el.parent
.map(|p| p.to_string())
.unwrap_or_else(|| "-".into()),
format!("{:#x}", el.kind),
format!("({},{})", el.pivot_x, el.pivot_y),
el.keyframes.len(),
);
if let Some(link) = &el.focus_link {
println!("{:<3} {:<30} → focus {link}", "", "");
}
}
if geometry {
print_geometry(&b, bytes);
}
Ok(())
}
/// The two other orderings the bundle carries, and each element's real drawn
/// size. Both were needed to settle the title screen's paint order — see
/// `docs/re/BACKLOG.md`.
fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) {
println!();
let identity: Vec<usize> = (0..b.placement_order.len()).collect();
println!(
"placement-region group order: {:?}{}",
b.placement_order,
if b.placement_order == identity {
" (== declaration order)"
} else {
" (DIFFERS from declaration order)"
}
);
if let Some(kids) = sylpheed_formats::ratc::parse(bytes) {
println!(
"RATC child order: {:?}",
kids.iter()
.map(|c| format!("{}:{}", c.kind, c.name))
.collect::<Vec<_>>()
);
}
println!();
println!("geometry — decoded sprite size vs the declared pivot, and every keyframe");
println!(
"{:<3} {:<26} {:>11} {:>11} {:>5} keyframes t: x,y sx%,sy% a=alpha r=rot°",
"#", "sprite", "decoded", "pivot*2", "same"
);
for el in &b.elements {
let dims = el
.sprite
.as_ref()
.and_then(|s| b.sprites.get(s))
.and_then(|&(off, size)| sylpheed_formats::t8ad::parse(&bytes[off..off + size]))
.map(|img| (img.width, img.height));
let pv = (el.pivot_x * 2, el.pivot_y * 2);
let same = match dims {
Some(d) if d == pv => "yes",
Some(_) => "NO",
None => "-",
};
let kfs = el
.keyframes
.iter()
.map(|f| {
let rot = if f.rotation_deg != 0 {
format!(" r={}", f.rotation_deg)
} else {
String::new()
};
format!(
"{}: {},{} {}%,{}% a={}{rot}",
f.time.map(|v| v.to_string()).unwrap_or_else(|| "-".into()),
f.x,
f.y,
f.scale_x,
f.scale_y,
(f.fade >> 24) & 0xff
)
})
.collect::<Vec<_>>()
.join(" ");
println!(
"{:<3} {:<26} {:>11} {:>11} {:>5} {kfs}",
el.index,
el.sprite.as_deref().unwrap_or(""),
dims.map(|(w, h)| format!("{w}x{h}"))
.unwrap_or_else(|| "".into()),
format!("{}x{}", pv.0, pv.1),
same,
);
}
}
// 8 parameters against a threshold of 7 — a plain function, unlike the Bevy
// systems in the viewer, so this one is real if mild. Left as-is because the
// arguments are the CLI flags this subcommand takes; grouping them into a
// struct is a change to the command surface, not a lint fix.
#[allow(clippy::too_many_arguments)]
fn cmd_screen_render(
pak: &Path,
output: &Path,
want: Option<usize>,
focus: bool,
animated: bool,
black: bool,
all: bool,
primitives: bool,
at: Option<u32>,
settle: bool,
) -> Result<()> {
use sylpheed_formats::ui_layout::{self, ComposeOptions};
let builds = screen_builds(pak, all)?;
let idx = pick_build(&builds, want)?;
let bytes = &builds[idx].1;
let b = ui_layout::parse_build(bytes).context("build did not parse")?;
let at = if settle {
match (b.settle_window(), b.settle_time()) {
(Some((lo, hi)), Some(t)) => {
// Report the width, not just the answer. A 4-unit window and a
// 190-unit one give the same kind of number and mean entirely
// different things.
println!(
"settle window [{lo}, {hi}] = {} units ({:.2} s) -> posing at t={t}{}",
hi - lo,
(hi - lo) as f64 / 60.0,
if hi - lo < 30 {
" ⚠️ narrow — this bundle may never settle"
} else {
""
}
);
Some(t)
}
_ => {
println!(
"no settle window (fewer than two distinct keyframe times) — using rest()"
);
None
}
}
} else {
at
};
let screen = ui_layout::compose(
&b,
bytes,
ComposeOptions {
include_focus: focus,
include_animated: animated,
backdrop: if black {
[0, 0, 0, 255]
} else {
ComposeOptions::default().backdrop
},
include_primitives: primitives,
at,
},
None,
);
image::save_buffer(
output,
&screen.rgba,
screen.width,
screen.height,
image::ExtendedColorType::Rgba8,
)
.context("write PNG")?;
println!(
"build [{idx}]: drew {}/{} elements → {} ({}x{})",
screen.drawn.len(),
b.elements.len(),
output.display(),
screen.width,
screen.height
);
if !screen.missing.is_empty() {
println!(
" sprites that did not resolve/decode: {:?}",
screen.missing
);
}
// 🔴 Report the INDEX, the KIND and WHY, not just the name. A kind-`0x4`
// ghost instance carries its template's name, so a bare name list shows
// `ptlogo1.t32` twice and reads as "the logo is missing" when what is
// skipped is two motion-trail ghosts sitting at alpha 0 off-screen. That
// misreading cost this project a wrong finding sent to another agent.
let undrawn: Vec<String> = b
.elements
.iter()
.filter(|e| !screen.drawn.contains(&e.index))
.map(|e| {
let why = if e.name.ends_with(".prm") {
"untextured primitive, needs --primitives"
} else if e.name.ends_with(".rat") {
"animation, needs --animated"
} else if e.kind == 0x4 {
"kind 0x4 ghost instance"
} else if e.rest().map(|k| k.fade >> 24) == Some(0) {
"transparent at its pose"
} else {
"no reason established"
};
format!("[{}] {} ({why})", e.index, e.name)
})
.collect();
if !undrawn.is_empty() {
println!(" not drawn ({}):", undrawn.len());
for u in &undrawn {
println!(" {u}");
}
}
Ok(())
}
// ── save file ────────────────────────────────────────────────────────────────
fn cmd_save_info(file: &Path, all: bool) -> Result<()> {
use sylpheed_formats::savegame::{self, Confidence, DevelopState, FieldKind, GHAD_LAYOUT};
let raw = std::fs::read(file).context("read save")?;
let save = savegame::parse(&raw).map_err(|e| anyhow::anyhow!("{e}"))?;
let mark = |c: Confidence| match c {
Confidence::Confirmed => "OK ",
Confidence::Probable => "~ ",
Confidence::Unknown => "? ",
Confidence::Refuted => "REF",
};
println!(
"container : GDHA, {} B header + {} B deflate → {} B payload",
save.header.bytes.len(),
raw.len() - save.header.bytes.len(),
save.payload.len()
);
println!(
"round-trip: {}",
if save.round_trips() {
"byte-identical"
} else {
"MISMATCH — the parse is wrong"
}
);
println!("phase : {}", save.phase);
println!("\nGHAD progress block:");
for f in GHAD_LAYOUT {
if !all && f.confidence == Confidence::Unknown && f.name.is_empty() {
continue;
}
let value = match f.kind {
FieldKind::Millis => save
.ghad_value(f)
.map(|v| format!("{v} ms ({})", savegame::fmt_millis(v as u32))),
FieldKind::Percent => save.ghad_value(f).map(|v| format!("{v} %")),
FieldKind::Raw | FieldKind::DevelopBlob => Some(
save.ghad_bytes(f)
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" "),
),
_ => save.ghad_value(f).map(|v| format!("{v}")),
}
.unwrap_or_else(|| "-".into());
println!(
" {} +{:<3} {:<14} {}",
mark(f.confidence),
f.offset,
if f.name.is_empty() {
"(unnamed)"
} else {
f.name
},
value
);
if all && !f.note.is_empty() {
println!(" {}", f.note);
}
}
let dev = save.develop_state();
let owned = dev
.iter()
.filter(|d| **d == DevelopState::Developed)
.count();
let ready = dev
.iter()
.filter(|d| **d == DevelopState::Developable)
.count();
println!(
"\nArsenal : {owned} developed, {ready} developable, {} locked (of {})",
dev.len() - owned - ready,
dev.len()
);
println!("\nper-stage records (SHAB — NOT the UI's save slots):");
for (i, r) in save.records.iter().enumerate() {
if !r.is_used() {
continue;
}
println!(
" stage {:02} difficulty~{} points?{} best {} ",
i + 1,
r.a,
r.b,
savegame::fmt_millis(r.best_time_ms)
);
}
println!("\nheader summary (what the in-game Details panel reads):");
for m in save.header.summary() {
println!(" +{:#04x} {:<14} {}", m.header_offset, m.name, m.value);
}
println!(" — a payload edit that leaves these stale shows no change on the panel,");
println!(" which is not evidence that the payload field was the wrong one.");
Ok(())
}
// ── 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(b) = info.avg_bytes_per_sec {
println!(" Byte rate : {} B/s (declared)", b.to_string().yellow());
}
if let Some(d) = info.duration_secs {
let how = if info.codec == sylpheed_formats::AudioCodec::Xma {
" (from the declared byte rate, not decoded)"
} else {
""
};
println!(" Duration : {d:.2} s{how}");
}
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_key(|&(_, count)| std::cmp::Reverse(count));
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::{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.as_chunks::<3>().0 {
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
.as_chunks::<3>()
.0
.iter()
.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
.as_chunks::<3>()
.0
.iter()
.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.as_chunks::<3>().0 {
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);
let src = tex.data.as_chunks::<4>().0;
let dst = rgba.as_chunks_mut::<4>().0;
for (px, out) in src.iter().zip(dst) {
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(())
}
// ── 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(())
}