feat(formats): IPFB archive + IDXD definition readers
Add readers for Project Sylpheed's on-disc data format so the reimplementation can load exact ship/weapon constants straight from the game files. - pak.rs: PakArchive — parse the IPFB `.pak` index + concatenated `.pNN` segments, binary-search the TOC by name-hash, and transparently inflate the per-entry "Z1" (zlib) container. Adds flate2. - idxd.rs: IdxdObject — parse the IDXD reflective object serialization. The string pool stores properties value-before-key with defaulted fields omitted; typed getters (get_f32/get_i64/get_str/get_bool) read explicit values reliably, get_raw exposes identifier fields, and resolved_fields() enumerates every explicit (key,value). Defaulted fields correctly return None rather than a neighbouring key. - sylpheed-cli: `pak list` (inventory entries + identity) and `pak dump <hash>` (full stat sheet for one object). Verified against the real disc (auto-skipped without it): DeltaSaber craft (HP=1000, Acceleration=600, velocity curve 100/700/1200, Turn=100, RadarRange=500000) and the DSaber missile (LoadingCount=144, Interval=3.00, Mass=0.77). 10 unit + 3 integration tests pass. Not yet decoded: defaulted fields (many ratios, the *Count family) whose values come from schema defaults or the hash-keyed binary node region. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,12 @@
|
||||
//! 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};
|
||||
|
||||
@@ -35,6 +41,7 @@ use indicatif::{ProgressBar, ProgressStyle};
|
||||
use tracing::info;
|
||||
|
||||
use sylpheed_formats::vfs::{identify_format, GameAssets};
|
||||
use sylpheed_formats::{IdxdObject, PakArchive};
|
||||
|
||||
// ── CLI definition ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -84,6 +91,31 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
cmd: TextureCommands,
|
||||
},
|
||||
|
||||
/// IPFB archive (`*.pak`) and IDXD definition tools
|
||||
Pak {
|
||||
#[command(subcommand)]
|
||||
cmd: PakCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum PakCommands {
|
||||
/// List the entries of an IPFB archive (hash, size, inner format, identity)
|
||||
List {
|
||||
/// Path to the `.pak` index (sibling `.p00`/`.pNN` segments are loaded automatically)
|
||||
pak: PathBuf,
|
||||
/// Only show IDXD-object entries
|
||||
#[arg(long)]
|
||||
idxd_only: bool,
|
||||
},
|
||||
/// Dump one entry: its IDXD schema and every explicitly-valued field
|
||||
Dump {
|
||||
/// Path to the `.pak` index
|
||||
pak: PathBuf,
|
||||
/// Entry name-hash, e.g. `0x7c96296c`
|
||||
hash: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -123,6 +155,10 @@ async fn main() -> Result<()> {
|
||||
TextureCommands::Info { file } => cmd_texture_info(&file),
|
||||
TextureCommands::Export { file, output } => cmd_texture_export(&file, &output),
|
||||
},
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,3 +367,128 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── pak list ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Best-effort human label for a decompressed entry's inner format.
|
||||
fn inner_label(payload: &[u8]) -> String {
|
||||
if payload.len() < 4 {
|
||||
return "empty".into();
|
||||
}
|
||||
let m = &payload[0..4];
|
||||
if m == b"IDXD" {
|
||||
"IDXD".into()
|
||||
} else if payload.starts_with(b"<?xml") {
|
||||
"xml".into()
|
||||
} else if m.iter().all(|&b| b.is_ascii_graphic()) {
|
||||
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, ttcf, …).
|
||||
String::from_utf8_lossy(m).into_owned()
|
||||
} else {
|
||||
format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
|
||||
}
|
||||
}
|
||||
|
||||
/// For an IDXD entry, a short identity string (ID / Name / Model if present).
|
||||
fn idxd_identity(obj: &IdxdObject) -> String {
|
||||
for key in ["ID", "Name", "Model"] {
|
||||
if let Some(v) = obj.get_raw(key) {
|
||||
return format!("{key}={v}");
|
||||
}
|
||||
}
|
||||
format!("schema {:08x}", obj.schema_hash)
|
||||
}
|
||||
|
||||
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;
|
||||
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 = if is_idxd {
|
||||
IdxdObject::parse(&payload)
|
||||
.map(|o| idxd_identity(&o))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!(
|
||||
" {:08x} {:>9} B {:<6} {}",
|
||||
e.name_hash,
|
||||
payload.len(),
|
||||
label.yellow(),
|
||||
detail.dimmed(),
|
||||
);
|
||||
shown += 1;
|
||||
}
|
||||
println!("\n {} entries shown", shown.to_string().yellow());
|
||||
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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user