Files
Syplheed-Reborn/crates/sylpheed-formats/src/idxd.rs
MechaCat02 fbd4550d62 feat(viewer): data pack (.pak / IDXD) browser
Roadmap #3. The explorer can now open IPFB data packs — the game's ship/weapon/
mission definition tables. Selecting a `.pak` shows a master-detail browser:
entry list (hash · inner-format · identity) on the left, the selected IDXD
object's schema + explicit-field property table on the right.

Load path: a `.pak` selection reads the index plus its sibling `.pNN` segments
(ISO reader or extracted dir, probing p00.. until the first gap), assembles via
PakArchive::from_parts, and builds owned PakRow/PakDetail entirely off-thread —
so the UI holds only plain data (no borrow of a PakArchive, WASM-safe). Two
decode caps (64 MiB cumulative, 16 MiB/entry) keep a huge sound.pak from hanging;
over-budget entries show as "(not decoded)".

Wiring mirrors the texture path: new IsoLoaderMsg::PakLoaded → PendingPak staging
→ apply_pak. A shared reset (free GPU handle + clear texture/text/pak previews)
runs in both apply systems, so exactly one viewer is active per selection and the
prior GPU texture is always freed when switching modes.

Reuse over duplication: the CLI's inner_label / idxd_identity move into the
formats crate as pak::inner_format_label and IdxdObject::identity (bodies
verbatim → CLI output unchanged), now shared by CLI and GUI.

PakView registered unconditionally (wasm-safe; population native-only). ViewMode
unchanged — the central panel dispatches on the populated resource. Workspace
builds; 22 formats tests + all crate tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:17:10 +02:00

360 lines
14 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.
//! `IDXD` reflective object reader — the game's data-definition serialization.
//!
//! Almost every gameplay definition (ships/craft, weapons, effects, menu configs)
//! is an `IDXD` object embedded as a [`crate::pak`] entry. Each object is a small
//! reflective property tree: a **binary node/index region** followed by a
//! **string pool**.
//!
//! ## Layout (all fields big-endian)
//!
//! ```text
//! Offset Size Field
//! 0x00 4 Magic: "IDXD"
//! 0x04 4 count (number of top-level records; small)
//! 0x08 4 schema_hash (identifies the object type; custom hash family, preimage unknown)
//! 0x0C .. NODE / INDEX region — hash-keyed records + (for some schemas) auxiliary
//! binary tables. Not required for the string values below.
//! .. .. STRING POOL — the trailing, (almost) all-ASCII region.
//! ```
//!
//! ## The string pool: **value-before-key, defaults omitted**
//!
//! Each property that has an explicit value is serialized as `<value>\0<key>\0` —
//! the value string comes *immediately before* its field name. Numbers are stored
//! as ASCII text (`"600.0"`, `"144"`, `"0.3333"`), so they read out exactly:
//!
//! ```text
//! … "1000.0" "HP" "500000.0" "RadarRange" "10.0" "Size_X" …
//! value key value key value key
//! ```
//!
//! Crucially, **fields left at their default value omit the value string** — they
//! appear as a bare key with the *previous* field's territory ending before them
//! (e.g. `… "RadarRange" "FCSRange" "YES" "MountedShieldGenerator" …`: `FCSRange`
//! has no value). So "the token before a key" is a real value *only when that token
//! is itself value-shaped*. The typed getters enforce this:
//!
//! - [`IdxdObject::get_f32`] / [`get_i64`](IdxdObject::get_i64) — parse the preceding
//! token as a number; `None` if the field is absent or defaulted. **Reliable** —
//! this is the API for numeric stats.
//! - [`IdxdObject::get_str`] — the preceding token, but only when it is unambiguously
//! a value (numeric, a `Yes/No`-style enum, or a delimited string like
//! `"Vessel,Craft"`). `None` for bare identifiers, to avoid mistaking a neighbour
//! key for a value.
//! - [`IdxdObject::get_raw`] — the raw preceding token, no validation. Use for fields
//! you *know* are identifier-valued (`ID`, `Name`, `Type`, `Model`).
//!
//! ## Not yet decoded
//!
//! Default/omitted fields (many ratios, the `…Count` family) carry no value in the
//! pool — their values live in the binary node/index region (hash-keyed by the same
//! unrecovered custom hash) or come from schema defaults. Binding those requires
//! decoding that region per schema; until then the typed getters return `None` for
//! them (honest "unknown") rather than guessing. See `reference_ipfb_archive_format`
//! in the project notes.
use thiserror::Error;
/// Magic at the start of every IDXD object.
pub const IDXD_MAGIC: [u8; 4] = *b"IDXD";
#[derive(Debug, Error)]
pub enum IdxdError {
#[error("bad IDXD magic: expected {:?}, got {got:?}", IDXD_MAGIC)]
BadMagic { got: [u8; 4] },
#[error("truncated IDXD object: {0} bytes")]
Truncated(usize),
}
/// A parsed IDXD object: its schema id plus the decoded string pool.
#[derive(Debug, Clone)]
pub struct IdxdObject {
/// Object-type id (which kind of definition this is). Custom hash; groups
/// entries by schema even though the preimage is unknown.
pub schema_hash: u32,
/// The `count` header field (number of top-level records).
pub count: u32,
/// Ordered string-pool tokens (see module docs; value-before-key).
tokens: Vec<String>,
}
impl IdxdObject {
/// Parse an IDXD object from a decompressed [`crate::pak`] entry.
pub fn parse(bytes: &[u8]) -> Result<Self, IdxdError> {
if bytes.len() < 12 {
return Err(IdxdError::Truncated(bytes.len()));
}
let magic: [u8; 4] = bytes[0..4].try_into().unwrap();
if magic != IDXD_MAGIC {
return Err(IdxdError::BadMagic { got: magic });
}
let count = be32(bytes, 4);
let schema_hash = be32(bytes, 8);
let tokens = extract_string_pool(bytes);
Ok(Self {
schema_hash,
count,
tokens,
})
}
/// Quick check for the IDXD magic without fully parsing.
pub fn is_idxd(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == IDXD_MAGIC
}
/// The ordered string-pool tokens (value-before-key interleaving).
pub fn tokens(&self) -> &[String] {
&self.tokens
}
/// The raw token immediately preceding the first occurrence of `key`.
///
/// No validation: for a defaulted/omitted field this is the *neighbouring key*,
/// not a value. Prefer [`get_str`](Self::get_str) / [`get_f32`](Self::get_f32)
/// unless you know the field is identifier-valued (`ID`, `Name`, `Model`, …).
pub fn get_raw(&self, key: &str) -> Option<&str> {
let idx = self.tokens.iter().position(|t| t == key)?;
if idx == 0 {
return None;
}
Some(&self.tokens[idx - 1])
}
/// The value of `key`, but only when the preceding token is unambiguously a
/// value (numeric, a `Yes/No`-style enum, or a delimited string). Returns `None`
/// for bare-identifier neighbours (which would usually be a defaulted field).
pub fn get_str(&self, key: &str) -> Option<&str> {
let v = self.get_raw(key)?;
is_definite_value(v).then_some(v)
}
/// `key`'s value parsed as `f32` (ASCII; a trailing `f` as in `"2.0f"` is
/// tolerated). `None` if the field is missing, defaulted, or non-numeric.
/// This is the reliable API for numeric stats.
pub fn get_f32(&self, key: &str) -> Option<f32> {
let v = self.get_raw(key)?;
let v = v.strip_suffix(['f', 'F']).unwrap_or(v);
parse_number(v).then(|| v.parse().ok()).flatten()
}
/// `key`'s value parsed as `i64`. `None` if missing, defaulted, or non-integer.
pub fn get_i64(&self, key: &str) -> Option<i64> {
let v = self.get_raw(key)?;
v.parse().ok()
}
/// `key`'s value as a bool (`Yes`/`YES`/`On`/`1` → true; `No`/`NO`/`Off`/`0` → false).
pub fn get_bool(&self, key: &str) -> Option<bool> {
match self.get_raw(key)? {
"Yes" | "YES" | "On" | "ON" | "1" => Some(true),
"No" | "NO" | "Off" | "OFF" | "0" => Some(false),
_ => None,
}
}
/// Every field that carries an explicit, value-shaped value, as `(key, value)`
/// in on-disc order. Uses the value-before-key rule but only emits a pair when
/// the key is identifier-shaped *and* the preceding token is unambiguously a
/// value — so every returned pair is real, and defaulted fields are simply
/// absent. Identifier-valued fields (e.g. `Model = rou_f001`) are excluded;
/// read those with [`get_raw`](Self::get_raw). Ideal for a full-object dump.
pub fn resolved_fields(&self) -> Vec<(&str, &str)> {
let mut out = Vec::new();
for i in 1..self.tokens.len() {
let key = self.tokens[i].as_str();
let val = self.tokens[i - 1].as_str();
if is_key_like(key) && is_definite_value(val) {
out.push((key, val));
}
}
out
}
/// A short identity string for the object: the first of `ID` / `Name` /
/// `Model` present, else the schema hash. Shared by the CLI `pak list` and
/// the GUI pack browser.
pub fn identity(&self) -> String {
for key in ["ID", "Name", "Model"] {
if let Some(v) = self.get_raw(key) {
return format!("{key}={v}");
}
}
format!("schema {:08x}", self.schema_hash)
}
}
/// Extract the trailing string pool. Finds the smallest offset whose suffix is
/// ≥98% printable-ASCII-or-NUL (the pool runs to end-of-buffer), then tokenises
/// into maximal printable runs (NUL *or* any non-printable byte separates tokens,
/// so a slightly-early boundary can't glue binary onto the first field).
fn extract_string_pool(bytes: &[u8]) -> Vec<String> {
let start = pool_start(bytes);
let printable = |b: u8| (0x20..0x7f).contains(&b);
let mut tokens = Vec::new();
let mut i = start;
let n = bytes.len();
while i < n {
if !printable(bytes[i]) {
i += 1;
continue;
}
let mut j = i;
while j < n && printable(bytes[j]) {
j += 1;
}
// Safe: bytes[i..j] verified all ASCII printable.
tokens.push(String::from_utf8_lossy(&bytes[i..j]).into_owned());
i = j;
}
tokens
}
/// The string pool is the maximal trailing run of bytes that are all
/// printable-ASCII-or-NUL. Since NUL-terminated ASCII strings never contain a
/// byte outside `[0x20,0x7e] {0}`, the boundary is exactly one past the last
/// non-printable byte — precise for both tiny and multi-KB node regions.
///
/// (Caveat: an IDXD value stored in raw Shift-JIS would contain high bytes and
/// truncate the pool here; the gameplay-stat tables are ASCII, so this is safe
/// for them. Localized text lives in separate `IXUD` entries, not IDXD.)
fn pool_start(bytes: &[u8]) -> usize {
let printable_or_nul = |b: u8| (0x20..0x7f).contains(&b) || b == 0;
match bytes.iter().rposition(|&b| !printable_or_nul(b)) {
Some(last_bin) => last_bin + 1,
None => 0,
}
}
/// A token that is unambiguously a *value* (not a field-name key): a number, a
/// scalar enum literal, or a string containing a non-identifier char (comma,
/// space, dot, slash, …).
fn is_definite_value(s: &str) -> bool {
if parse_number(s.strip_suffix(['f', 'F']).unwrap_or(s)) {
return true;
}
if matches!(s, "Yes" | "No" | "YES" | "NO" | "On" | "Off" | "ON" | "OFF") {
return true;
}
// A delimited string (comma/space/…) is a value, but only if it *starts* with
// an alphanumeric — e.g. `Vessel,Craft`. Tokens beginning with punctuation
// (`|Generic`, `{`, `.`) are node-table noise, not values.
s.bytes().next().is_some_and(|b| b.is_ascii_alphanumeric())
&& s.bytes().any(|b| !(b.is_ascii_alphanumeric() || b == b'_'))
}
/// Whether `s` is plausibly a field-name key: an identifier that is not itself a
/// value literal.
fn is_key_like(s: &str) -> bool {
let mut cs = s.chars();
// Field names are identifiers of ≥3 chars (Size_X, Acceleration, …) or a
// 2-char all-caps abbreviation (HP, ID, AV, AA). This rejects the short,
// mixed-case junk tokens (`cV`, `nx`, `xb`) that leak from the node-table tail.
matches!(cs.next(), Some(c) if c.is_ascii_alphabetic())
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& (s.len() >= 3 || s.chars().all(|c| c.is_ascii_uppercase()))
&& !is_definite_value(s)
}
/// Whether `s` is a plain decimal number (optionally signed, optionally fractional).
fn parse_number(s: &str) -> bool {
!s.is_empty()
&& s.bytes().any(|b| b.is_ascii_digit())
&& s.bytes().enumerate().all(|(i, b)| {
b.is_ascii_digit() || b == b'.' || (i == 0 && (b == b'-' || b == b'+'))
})
}
#[inline]
fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a synthetic IDXD: header + 32 bytes of binary filler + pool.
/// `pool` is a flat token list already in on-disc (value-before-key) order.
fn synth(pool: &[&str]) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(&IDXD_MAGIC);
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&0xDEAD_BEEFu32.to_be_bytes());
b.extend_from_slice(&[0xFFu8; 32]); // binary node region (non-printable → skipped)
for t in pool {
b.extend_from_slice(t.as_bytes());
b.push(0);
}
b
}
#[test]
fn reads_explicit_scalars() {
// "1000.0" HP "500000.0" RadarRange "Vessel,Craft" TargetType "Yes" Mounted
let bytes = synth(&[
"1000.0",
"HP",
"500000.0",
"RadarRange",
"Vessel,Craft",
"TargetType",
"Yes",
"Mounted",
]);
let o = IdxdObject::parse(&bytes).unwrap();
assert_eq!(o.schema_hash, 0xDEAD_BEEF);
assert_eq!(o.get_f32("HP"), Some(1000.0));
assert_eq!(o.get_f32("RadarRange"), Some(500000.0));
assert_eq!(o.get_i64("RadarRange"), None); // "500000.0" is not an integer
assert_eq!(o.get_str("TargetType"), Some("Vessel,Craft"));
assert_eq!(o.get_bool("Mounted"), Some(true));
}
#[test]
fn defaulted_field_yields_none_not_neighbour() {
// RadarRange has value 500000; FCSRange is defaulted (no value) → its
// preceding token is the *key* "RadarRange", which must NOT be returned.
let bytes = synth(&["500000.0", "RadarRange", "FCSRange", "Yes", "Mounted"]);
let o = IdxdObject::parse(&bytes).unwrap();
assert_eq!(o.get_f32("RadarRange"), Some(500000.0));
assert_eq!(o.get_f32("FCSRange"), None);
assert_eq!(o.get_str("FCSRange"), None); // not the neighbour key
}
#[test]
fn get_raw_exposes_identifier_values() {
let bytes = synth(&["rou_f001", "Model", "Craft", "Type"]);
let o = IdxdObject::parse(&bytes).unwrap();
assert_eq!(o.get_raw("Model"), Some("rou_f001"));
assert_eq!(o.get_raw("Type"), Some("Craft"));
// get_str is conservative: bare identifiers are not "definite values".
assert_eq!(o.get_str("Model"), None);
}
#[test]
fn resolved_fields_lists_explicit_values_only() {
let bytes = synth(&[
"rou_f001", "Model", // identifier value → excluded
"10.0", "Size_X", // explicit → included
"FCSRange", // defaulted (no value) → excluded
"Yes", "Mounted", // enum → included
]);
let o = IdxdObject::parse(&bytes).unwrap();
let fields = o.resolved_fields();
assert!(fields.contains(&("Size_X", "10.0")));
assert!(fields.contains(&("Mounted", "Yes")));
assert!(!fields.iter().any(|(k, _)| *k == "Model"));
assert!(!fields.iter().any(|(k, _)| *k == "FCSRange"));
}
#[test]
fn rejects_bad_magic() {
assert!(matches!(
IdxdObject::parse(b"NOPE\0\0\0\0\0\0\0\0"),
Err(IdxdError::BadMagic { .. })
));
}
}