From 99f2ef8871798255548bcbf2416c0ff300773a4b Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 23 Jul 2026 19:49:21 +0200 Subject: [PATCH] game_data: typed loaders for the combat / character / mission tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode Project Sylpheed's IDXD data model into plain, cloneable structs (from GP_MAIN_GAME_E.pak; the D/F/I/J/S paks are localized dups): - Weapon (121), CraftUnit (89, with the AI flight model in .fields), Vessel (23, structural component counts), PlayerConfig (24) — the full combat balance. - Character (68 — faction + portrait set), Stage (29 missions: location, phases, and the per-stage resource tables it wires up). - Generic Record / load_records(schema) reads any of the 105 IDXD schemas without a bespoke struct. Each blob = one entity; token[0] names the table, fields are value-before-key. Named Option<> fields for the common stats + a full `fields` map; defaulted fields stay None (they're code-resident, not on disc — honest blanks, never guessed). 8 tests against the real disc. Examples: iso_map / deep_map census the ISO's formats and IDXD schemas; dm_extract / dm_rows / dm_roster / mission_map dump the decoded data. Co-Authored-By: Claude Opus 4.8 --- crates/sylpheed-formats/examples/deep_map.rs | 59 ++ .../sylpheed-formats/examples/dm_extract.rs | 29 + crates/sylpheed-formats/examples/dm_roster.rs | 11 + crates/sylpheed-formats/examples/dm_rows.rs | 14 + crates/sylpheed-formats/examples/iso_map.rs | 50 ++ .../sylpheed-formats/examples/mission_map.rs | 47 ++ .../sylpheed-formats/examples/spawn_survey.rs | 33 + crates/sylpheed-formats/src/game_data.rs | 583 ++++++++++++++++++ crates/sylpheed-formats/src/lib.rs | 2 + 9 files changed, 828 insertions(+) create mode 100644 crates/sylpheed-formats/examples/deep_map.rs create mode 100644 crates/sylpheed-formats/examples/dm_extract.rs create mode 100644 crates/sylpheed-formats/examples/dm_roster.rs create mode 100644 crates/sylpheed-formats/examples/dm_rows.rs create mode 100644 crates/sylpheed-formats/examples/iso_map.rs create mode 100644 crates/sylpheed-formats/examples/mission_map.rs create mode 100644 crates/sylpheed-formats/examples/spawn_survey.rs create mode 100644 crates/sylpheed-formats/src/game_data.rs diff --git a/crates/sylpheed-formats/examples/deep_map.rs b/crates/sylpheed-formats/examples/deep_map.rs new file mode 100644 index 0000000..ca28867 --- /dev/null +++ b/crates/sylpheed-formats/examples/deep_map.rs @@ -0,0 +1,59 @@ +use sylpheed_formats::{ratc, idxd::IdxdObject, PakArchive}; +use std::collections::BTreeMap; +fn main(){ + let disc=std::env::var("SYLPHEED_DISC").unwrap(); + // 1. RATC child-type census across the big RATC paks + let mut childtypes:BTreeMap=BTreeMap::new(); + let mut ratc_unparsed=0u64; + for pk in ["GP_READY_ROOM","GP_MOVIE_THEATER","GP_DIALOG","GP_DEBRIEFING_PILOTLOG","GP_TITLE","GP_BUNK"]{ + let Ok(arc)=PakArchive::open(format!("{disc}/dat/{pk}.pak")) else{continue}; + for e in arc.entries(){ + let Ok(b)=arc.read(e) else{continue}; + if !ratc::is_ratc(&b){continue;} + match ratc::parse(&b){ + Some(kids)=> for k in kids{ + let ext=k.name.rsplit('.').next().unwrap_or("?").to_lowercase(); + *childtypes.entry(ext).or_default()+=1; + }, + None=>ratc_unparsed+=1, + } + } + } + println!("=== RATC child-type census (big RATC paks) ==="); + let mut cv:Vec<_>=childtypes.into_iter().collect(); cv.sort_by_key(|x|std::cmp::Reverse(x.1)); + for (e,c) in &cv{ println!(" .{e:8} ×{c}"); } + println!(" (RATC bundles that failed to parse: {ratc_unparsed})"); + + // 2. The 00000002 mystery format (GP_MAIN_GAME_E) + let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); + for e in arc.entries(){ + let Ok(b)=arc.read(e) else{continue}; + if b.len()>=4 && b[0..4]==[0,0,0,2]{ + let hx:String=b[..48.min(b.len())].iter().map(|x|format!("{x:02x}")).collect::>().join(" "); + let asc:String=b[..64.min(b.len())].iter().map(|&x|if(0x20..0x7f).contains(&x){x as char}else{'.'}).collect(); + println!("\n=== 00000002 format sample ({}B) ===\n{hx}\n{asc}",b.len()); + break; + } + } + + // 3. Sample the top undecoded IDXD schemas: first tokens (guess semantics) + println!("\n=== top undecoded IDXD schemas — sample tokens (semantic hints) ==="); + let targets:[u32;6]=[0xb412e6d8,0x026379ab,0x43faa517,0x3c5b0549,0x6ab4825a,0x0426e81d]; + for want in targets{ + let mut shown=false; + for e in arc.entries(){ + if shown{break;} + let Ok(b)=arc.read(e) else{continue}; + if b.len()<12 || &b[0..4]!=b"IDXD"{continue;} + let s=u32::from_be_bytes([b[8],b[9],b[10],b[11]]); + if s!=want{continue;} + if let Ok(o)=IdxdObject::parse(&b){ + let t=o.tokens(); + let sample:Vec=t.iter().take(14).map(|s|{let s=s.chars().take(18).collect::();s}).collect(); + println!(" {want:08x}: {sample:?}"); + shown=true; + } + } + if !shown{ println!(" {want:08x}: (parse failed / not found)"); } + } +} diff --git a/crates/sylpheed-formats/examples/dm_extract.rs b/crates/sylpheed-formats/examples/dm_extract.rs new file mode 100644 index 0000000..e6ed04d --- /dev/null +++ b/crates/sylpheed-formats/examples/dm_extract.rs @@ -0,0 +1,29 @@ +use sylpheed_formats::{idxd::IdxdObject, PakArchive}; +use std::collections::BTreeMap; +fn main(){ + let disc=std::env::var("SYLPHEED_DISC").unwrap(); + let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); + let tables:[(u32,&str);4]=[(0x0426e81d,"Player / physics+scoring"),(0x6ab4825a,"Weapon"),(0x43faa517,"Unit / craft"),(0x3c5b0549,"Vessel / capital ship")]; + for (want,label) in tables{ + // collect all records of this schema + let mut recs:Vec=vec![]; + for e in arc.entries(){ let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue}; if o.schema_hash==want{recs.push(o);} } + // field-union (explicit-valued keys), with occurrence count + let mut cols:BTreeMap=BTreeMap::new(); + for o in &recs{ for (k,_) in o.resolved_fields(){ *cols.entry(k.into()).or_default()+=1; } } + println!("\n╔══ {label} [{want:08x}] {} records ══", recs.len()); + let mut cv:Vec<_>=cols.into_iter().collect(); cv.sort_by_key(|x|std::cmp::Reverse(x.1)); + let colstr:String=cv.iter().take(28).map(|(k,c)|format!("{k}({c})")).collect::>().join(" "); + println!("║ numeric/enum fields: {colstr}"); + // dump 2 sample records: identity + explicit fields + for o in recs.iter().take(2){ + let id=o.get_raw("ID").unwrap_or("?"); + let name=o.get_raw("Name").unwrap_or(""); + print!("║ • {id}"); + if !name.is_empty(){print!(" «{name}»");} + println!(); + let fields:Vec=o.resolved_fields().iter().map(|(k,v)|format!("{k}={v}")).collect(); + for chunk in fields.chunks(5){ println!("║ {}", chunk.join(" ")); } + } + } +} diff --git a/crates/sylpheed-formats/examples/dm_roster.rs b/crates/sylpheed-formats/examples/dm_roster.rs new file mode 100644 index 0000000..816665f --- /dev/null +++ b/crates/sylpheed-formats/examples/dm_roster.rs @@ -0,0 +1,11 @@ +use sylpheed_formats::{game_data, PakArchive}; +use std::collections::BTreeMap; +fn main(){ + let disc=std::env::var("SYLPHEED_DISC").unwrap(); + let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); + let chars=game_data::load_characters(&pak); + let mut byfac:BTreeMap>=Default::default(); + for c in &chars{ byfac.entry(c.faction.clone().unwrap_or("·".into())).or_default().push(format!("{}({})",c.id.clone().unwrap_or_default().trim_start_matches("Character"),c.faces.len())); } + println!("{} characters across {} factions:",chars.len(),byfac.len()); + for (f,mut v) in byfac{ v.sort(); println!(" [{f}] {}", v.join(" ")); } +} diff --git a/crates/sylpheed-formats/examples/dm_rows.rs b/crates/sylpheed-formats/examples/dm_rows.rs new file mode 100644 index 0000000..76a72fd --- /dev/null +++ b/crates/sylpheed-formats/examples/dm_rows.rs @@ -0,0 +1,14 @@ +use sylpheed_formats::{idxd::IdxdObject, PakArchive}; +fn short(s:&str)->String{ s.trim_start_matches("Weapon_").trim_start_matches("UN_").trim_start_matches("UnitName_UN_").into() } +fn g<'a>(o:&'a IdxdObject,k:&str)->String{ o.get_f32(k).map(|v|{if v==v.trunc(){format!("{}",v as i64)}else{format!("{v}")}}).or_else(||o.get_raw(k).filter(|x|x.chars().next().map(|c|c.is_ascii_digit()).unwrap_or(false)).map(|s|s.to_string())).unwrap_or("·".into()) } +fn main(){ + let disc=std::env::var("SYLPHEED_DISC").unwrap(); + let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); + let recs=|want:u32|->Vec{ arc.entries().iter().filter_map(|e|arc.read(e).ok()).filter_map(|b|IdxdObject::parse(&b).ok()).filter(|o|o.schema_hash==want).collect() }; + println!("### WEAPONS (name | target | load | int | power | vel | range | trig)"); + for o in recs(0x6ab4825a).iter().take(16){ println!("{:<34}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), o.get_raw("TargetType").unwrap_or("·"), g(o,"LoadingCount"),g(o,"Interval"),g(o,"Power"),g(o,"Velocity"),g(o,"MaximumRange"),g(o,"TriggerShotCount")); } + println!("\n### UNITS/CRAFT (name | HP | cruise | accel | radar | turrets | score)"); + for o in recs(0x43faa517).iter().take(16){ println!("{:<38}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), g(o,"HP"),g(o,"CruisingVelocity"),g(o,"Acceleration"),g(o,"RadarRange"),g(o,"TurretCount"),g(o,"ScorePoint")); } + println!("\n### VESSELS/CAPITAL SHIPS (name | HP | Sz_X | Sz_Z | radar | turrets | bridges | hatches | shieldgen | thrusters)"); + for o in recs(0x3c5b0549).iter(){ println!("{:<30}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), g(o,"HP"),g(o,"Size_X"),g(o,"Size_Z"),g(o,"RadarRange"),g(o,"TurretCount"),g(o,"BridgeCount"),g(o,"HatchCount"),g(o,"ShieldGeneratorCount"),g(o,"ThrusterCount")); } +} diff --git a/crates/sylpheed-formats/examples/iso_map.rs b/crates/sylpheed-formats/examples/iso_map.rs new file mode 100644 index 0000000..78d4a03 --- /dev/null +++ b/crates/sylpheed-formats/examples/iso_map.rs @@ -0,0 +1,50 @@ +use sylpheed_formats::PakArchive; +use std::collections::BTreeMap; +fn be32(b:&[u8],o:usize)->u32{ if o+4<=b.len(){u32::from_be_bytes([b[o],b[o+1],b[o+2],b[o+3]])}else{0} } +fn magic(b:&[u8])->String{ + if b.len()<4 {return "(<4B)".into();} + let m=&b[0..4]; + if m.iter().all(|&c|(0x20..0x7f).contains(&c)){ String::from_utf8_lossy(m).into() } + else if m==b"\x89PNG"{"PNG".into()} else if m==[0,1,0,0]{"ttf".into()} + else { format!("{:02x}{:02x}{:02x}{:02x}",m[0],m[1],m[2],m[3]) } +} +fn main(){ + let disc=std::env::var("SYLPHEED_DISC").unwrap(); + // Known/understood IDXD schemas (semantic parsers we have) + let known_schema:BTreeMap=[(0x067025b9,"ADVERTISE_MOVIE (movie_manifest)"),(0x13cb84ba,"sound registry / sounds.tbl")].into(); + let mut fmt_count:BTreeMap=BTreeMap::new(); // fmt -> (count, bytes) + let mut schema_census:BTreeMap=BTreeMap::new(); // schema -> (count,bytes,sample pak) + let mut unknown_magics:BTreeMap)>=BTreeMap::new(); + let paks:Vec={let mut v=vec![]; for e in std::fs::read_dir(format!("{disc}/dat")).unwrap(){let p=e.unwrap().path(); if p.extension().map(|x|x=="pak").unwrap_or(false){v.push(p.file_stem().unwrap().to_string_lossy().into());}} v.sort(); v}; + println!("pak entries decompressed formats"); + for pk in &paks{ + let Ok(arc)=PakArchive::open(format!("{disc}/dat/{pk}.pak")) else{continue}; + let mut per:BTreeMap=BTreeMap::new(); + let mut tot=0u64; let n=arc.entries().len(); + for e in arc.entries(){ + let Ok(b)=arc.read(e) else{continue}; + tot+=b.len() as u64; + let mut f=magic(&b); + if f=="IDXD"{ let s=be32(&b,8); schema_census.entry(s).or_insert((0,0,pk.clone())).0+=1; schema_census.get_mut(&s).unwrap().1+=b.len() as u64; f=format!("IDXD:{s:08x}"); } + else if !["XPR2","IXUD","LSTA","RATC","RIFF","XBG7","OTTO","PNG","ttf","DDS ","T8AD"].contains(&f.as_str()){ + let ent=unknown_magics.entry(f.clone()).or_insert((0,vec![])); ent.0+=1; if ent.1.len()<3 && !ent.1.contains(pk){ent.1.push(pk.clone());} + } + *per.entry(if f.starts_with("IDXD:"){"IDXD".into()}else{f.clone()}).or_default()+=1; + let g=fmt_count.entry(if f.starts_with("IDXD:"){"IDXD".into()}else{f}).or_insert((0,0)); g.0+=1; g.1+=b.len() as u64; + } + let mut fs:Vec<_>=per.into_iter().collect(); fs.sort_by_key(|x|std::cmp::Reverse(x.1)); + let top:String=fs.iter().take(4).map(|(k,v)|format!("{k}×{v}")).collect::>().join(" "); + println!("{pk:26} {n:>7} {:>10}KB {top}", tot/1024); + } + println!("\n=== FORMAT TOTALS (across all paks) ==="); + let mut fv:Vec<_>=fmt_count.into_iter().collect(); fv.sort_by_key(|x|std::cmp::Reverse(x.1.1)); + for (f,(c,b)) in &fv{ println!(" {f:12} {c:>6} entries {:>8}KB", b/1024); } + println!("\n=== IDXD SCHEMA CENSUS ({} distinct schemas) ===", schema_census.len()); + let mut sv:Vec<_>=schema_census.into_iter().collect(); sv.sort_by_key(|x|std::cmp::Reverse(x.1.1)); + for (s,(c,b,pk)) in &sv{ + let tag=known_schema.get(s).copied().unwrap_or("??? UNDECODED"); + println!(" {s:08x} {c:>5} ent {:>7}KB e.g. {pk:22} {tag}", b/1024); + } + println!("\n=== UNKNOWN / UNCLASSIFIED MAGICS ==="); + for (m,(c,pks)) in &unknown_magics{ println!(" {m:12} ×{c:<5} in {pks:?}"); } +} diff --git a/crates/sylpheed-formats/examples/mission_map.rs b/crates/sylpheed-formats/examples/mission_map.rs new file mode 100644 index 0000000..24772c5 --- /dev/null +++ b/crates/sylpheed-formats/examples/mission_map.rs @@ -0,0 +1,47 @@ +use sylpheed_formats::{idxd::IdxdObject, PakArchive}; +fn main(){ + let disc=std::env::var("SYLPHEED_DISC").unwrap(); + let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); + // 1. All stages: BackGroundID + phases + stage tag (from EnumUnit_SNN) + println!("=== STAGES (StageResource 3c9ae32e) ==="); + let mut rows=vec![]; + for e in arc.entries(){ + let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue}; + if o.schema_hash!=0x3c9ae32e {continue;} + let t=o.tokens(); + let bg=o.get_raw("BackGroundID").unwrap_or("?").to_string(); + let stage=t.iter().find_map(|s|s.strip_prefix("EnumUnit_").map(|x|x.trim_end_matches(".tbl").to_string())).unwrap_or("?".into()); + let phases=t.iter().filter(|s|s.starts_with("Phase_")).count(); + let unitgrp=t.iter().any(|s|s.starts_with("UnitGroup_")); + let route=t.iter().any(|s|s.starts_with("Route_")); + let formation=t.iter().any(|s|s.starts_with("FormationSet_")); + rows.push((stage,bg,phases,unitgrp,route,formation)); + } + rows.sort(); + for (s,bg,p,ug,rt,fm) in &rows{ println!(" {s:8} location={bg:14} phases={p} [squadrons:{} route:{} formations:{}]", if *ug{"Y"}else{"·"},if *rt{"Y"}else{"·"},if *fm{"Y"}else{"·"}); } + + // 2. Parse objectives table into (stage,phase) -> key counts + println!("\n=== OBJECTIVES (033b5b7e) — per stage/phase key groups ==="); + for e in arc.entries(){ + let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue}; + if o.schema_hash!=0x033b5b7e {continue;} + let t=o.tokens(); + let mut groups:std::collections::BTreeMap=Default::default(); + for tok in t{ if let Some(p)=tok.find("_Objective_").or_else(||tok.find("_Lose_")).or_else(||tok.find("_Hint_")){ let key=&tok[..p]; *groups.entry(key.trim_start_matches(|c:char|!c.is_ascii_alphabetic()).into()).or_default()+=1; } } + let stages:std::collections::BTreeSet=groups.keys().filter_map(|k|k.get(..3).map(|s|s.to_string())).collect(); + println!(" covers {} stages: {:?}", stages.len(), stages); + println!(" sample S01_P1 group counts: {:?}", groups.iter().filter(|(k,_)|k.starts_with("S01_P1")).collect::>()); + break; + } + + // 3. Resolve objective TEXT: search all pak entries for the key "S01_P1_Objective_00" as an ID with English text + println!("\n=== OBJECTIVE TEXT resolution probe ==="); + for e in arc.entries(){ + let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue}; + let t=o.tokens(); + if let Some(i)=t.iter().position(|s|s=="S01_P1_Objective_00"){ + let lo=i.saturating_sub(1); let hi=(i+3).min(t.len()); + println!(" schema {:08x}: ...{:?}...", o.schema_hash, &t[lo..hi]); + } + } +} diff --git a/crates/sylpheed-formats/examples/spawn_survey.rs b/crates/sylpheed-formats/examples/spawn_survey.rs new file mode 100644 index 0000000..085dc51 --- /dev/null +++ b/crates/sylpheed-formats/examples/spawn_survey.rs @@ -0,0 +1,33 @@ +use sylpheed_formats::{idxd::IdxdObject, PakArchive}; +use std::collections::BTreeMap; +fn main(){ + let disc=std::env::var("SYLPHEED_DISC").unwrap(); + let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); + // schemas whose type0/content is squadron/formation/route/group/position + let mut hit:BTreeMap=BTreeMap::new(); + for e in arc.entries(){ + let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue}; + let t=o.tokens(); let t0=t.first().cloned().unwrap_or_default(); + let joined:String=t.iter().take(20).map(|s|s.as_str()).collect::>().join(" "); + if ["Squadron","Formation","Route","UnitGroup","Position","Spawn","Wave","Frame","NullFrame"].iter().any(|k|t0.contains(k)||joined.contains(k)){ + let ent=hit.entry(o.schema_hash).or_insert((0,t0.clone(),joined.chars().take(90).collect())); + ent.0+=1; + } + } + let mut v:Vec<_>=hit.into_iter().collect(); v.sort_by_key(|x|std::cmp::Reverse(x.1.0)); + for (s,(c,t0,sample)) in &v{ println!("[{s:08x}] ×{c:<4} type0={t0:<18.18} :: {sample}"); } + // dump a Squadron/UnitGroup blob fully to see if it has positions (binary floats) or refs + println!("\n─── sample squadron/formation blob (first matching) ───"); + for e in arc.entries(){ + let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue}; + let t=o.tokens(); let t0=t.first().cloned().unwrap_or_default(); + if t0.contains("Squadron")||t0.contains("Formation")||t0.contains("UnitGroup"){ + println!("schema {:08x} type0={t0} count={} tokens={}, blob {}B", o.schema_hash,o.count,t.len(),b.len()); + for (i,tk) in t.iter().take(30).enumerate(){ print!("{i:>2}:{tk:<20.20}"); if i%4==3{println!();} } println!(); + // scan blob for float-like values (reasonable coords) in the binary region + let nfloats=(0..b.len().saturating_sub(4)).step_by(4).filter(|&i|{let f=f32::from_be_bytes([b[i],b[i+1],b[i+2],b[i+3]]); f.is_finite()&&f.abs()>1.0&&f.abs()<1e6}).count(); + println!(" ~{nfloats} plausible BE-float words in blob (positions live in binary region if high)"); + break; + } + } +} diff --git a/crates/sylpheed-formats/src/game_data.rs b/crates/sylpheed-formats/src/game_data.rs new file mode 100644 index 0000000..2f2ded4 --- /dev/null +++ b/crates/sylpheed-formats/src/game_data.rs @@ -0,0 +1,583 @@ +//! Typed loaders for Project Sylpheed's combat data tables. +//! +//! The game keeps its balance data in reflective [`crate::idxd`] tables — one +//! blob per entity, `token[0]` naming the table, fields laid out value-before-key. +//! This module maps the four combat schemas to plain, cloneable structs: the +//! common stats as named `Option<…>` fields, and **every** explicitly-set field in +//! a [`fields`](Weapon::fields) map so nothing is lost. +//! +//! Reverse-engineered 2026-07-23 from `GP_MAIN_GAME_E.pak` (English; the D/F/I/J/S +//! paks are localized duplicates). Fields left at their default value omit their +//! value on disc — those live in title code, not here — so a `None` means "not set +//! on disc", never a guess. +//! +//! ```no_run +//! use sylpheed_formats::{game_data, PakArchive}; +//! let pak = PakArchive::open("dat/GP_MAIN_GAME_E.pak").unwrap(); +//! for w in game_data::load_weapons(&pak) { +//! println!("{} — power {:?}, range {:?}", w.id.unwrap_or_default(), w.power, w.max_range); +//! } +//! ``` + +use crate::idxd::IdxdObject; +use crate::pak::PakArchive; +use std::collections::BTreeMap; + +/// IDXD schema ids of the combat tables (the `schema_hash` field of each blob). +pub mod schema { + /// Player craft config: physics, cameras, scoring, difficulty, render pipeline. + pub const PLAYER: u32 = 0x0426_e81d; + /// Weapon definitions (guns, missiles, beams, bombs). + pub const WEAPON: u32 = 0x6ab4_825a; + /// Craft / units — fighters, bombers, turrets, with their AI flight model. + pub const UNIT: u32 = 0x43fa_a517; + /// Vessels — capital ships, with structural component counts. + pub const VESSEL: u32 = 0x3c5b_0549; + /// Characters — pilots / crew / comms, with faction and portrait set. + pub const CHARACTER: u32 = 0xbd86_d41c; + /// Stage resource manifest — per mission: location, phases, resource tables. + pub const STAGE: u32 = 0x3c9a_e32e; +} + +/// Parse a numeric field value, tolerating a trailing `f`/`F` (e.g. `"3.0f"`). +fn as_f32(v: Option<&String>) -> Option { + let v = v?; + let v = v.strip_suffix(['f', 'F']).unwrap_or(v); + v.parse().ok() +} + +fn as_i64(v: Option<&String>) -> Option { + v?.parse().ok() +} + +/// Collect every explicitly-set field of an IDXD blob into a `key → value` map, +/// plus the identity strings. Identifier-valued fields (`Model`, `ShotType`, …) +/// that carry a bare name rather than a value are not in the map — read those from +/// [`IdxdObject::get_raw`] if needed. +fn field_map(o: &IdxdObject) -> BTreeMap { + o.resolved_fields() + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +/// Read every record of `schema` from a pak, mapping each with `build`. +fn load_table(pak: &PakArchive, schema: u32, build: impl Fn(&IdxdObject) -> Option) -> Vec { + pak.entries() + .iter() + .filter_map(|e| pak.read(e).ok()) + .filter_map(|b| IdxdObject::parse(&b).ok()) + .filter(|o| o.schema_hash == schema) + .filter_map(|o| build(&o)) + .collect() +} + +// ── Weapon ──────────────────────────────────────────────────────────────────── + +/// A weapon definition (schema [`schema::WEAPON`]). +#[derive(Debug, Clone)] +pub struct Weapon { + pub id: Option, + pub name: Option, + /// Target mask, e.g. `"Vessel,Craft,Structure"`. + pub target_type: Option, + pub power: Option, + pub velocity: Option, + pub min_velocity: Option, + pub max_velocity: Option, + pub min_range: Option, + pub max_range: Option, + /// Ammo / reload budget (`LoadingCount`). + pub loading_count: Option, + /// Seconds between shots. + pub interval: Option, + /// Shots per trigger pull (volley / lock count). + pub trigger_shot_count: Option, + pub mass: Option, + pub heating: Option, + pub cooling: Option, + pub life_time: Option, + /// All explicitly-set fields (a superset of the named ones above). + pub fields: BTreeMap, +} + +impl Weapon { + fn from_idxd(o: &IdxdObject) -> Option { + let f = field_map(o); + Some(Weapon { + id: o.get_raw("ID").map(str::to_string), + name: o.get_raw("Name").map(str::to_string), + target_type: o.get_raw("TargetType").map(str::to_string), + power: as_f32(f.get("Power")), + velocity: as_f32(f.get("Velocity")), + min_velocity: as_f32(f.get("MinimumVelocity")), + max_velocity: as_f32(f.get("MaximumVelocity")), + min_range: as_f32(f.get("MinimumRange")), + max_range: as_f32(f.get("MaximumRange")), + loading_count: as_i64(f.get("LoadingCount")), + interval: as_f32(f.get("Interval")), + trigger_shot_count: as_i64(f.get("TriggerShotCount")), + mass: as_f32(f.get("Mass")), + heating: as_f32(f.get("Heating")), + cooling: as_f32(f.get("Cooling")), + life_time: as_f32(f.get("LifeTime")), + fields: f, + }) + } + /// Any explicit field parsed as `f32` (for the long-tail stats not named above). + pub fn field_f32(&self, key: &str) -> Option { + as_f32(self.fields.get(key)) + } +} + +/// Load every weapon from a pak (e.g. `GP_MAIN_GAME_E.pak`). +pub fn load_weapons(pak: &PakArchive) -> Vec { + load_table(pak, schema::WEAPON, Weapon::from_idxd) +} + +// ── Craft / unit ──────────────────────────────────────────────────────────────── + +/// A craft / unit — fighter, bomber, turret (schema [`schema::UNIT`]). Carries the +/// AI flight model in [`fields`](CraftUnit::fields) (`AV_Pitch*`, `BarrelRoll_*`, +/// `TurnAttack_*`, …). +#[derive(Debug, Clone)] +pub struct CraftUnit { + pub id: Option, + pub name: Option, + pub hp: Option, + pub is_destructible: Option, + pub size_x: Option, + pub size_y: Option, + pub size_z: Option, + pub size_radius: Option, + pub radar_range: Option, + pub fcs_range: Option, + pub cruising_velocity: Option, + pub maximum_velocity: Option, + pub acceleration: Option, + pub deceleration: Option, + pub shield_ratio: Option, + pub turret_count: Option, + pub score_point: Option, + pub fields: BTreeMap, +} + +impl CraftUnit { + fn from_idxd(o: &IdxdObject) -> Option { + let f = field_map(o); + Some(CraftUnit { + id: o.get_raw("ID").map(str::to_string), + name: o.get_raw("Name").map(str::to_string), + hp: as_f32(f.get("HP")), + is_destructible: o.get_bool("IsDestructible"), + size_x: as_f32(f.get("Size_X")), + size_y: as_f32(f.get("Size_Y")), + size_z: as_f32(f.get("Size_Z")), + size_radius: as_f32(f.get("Size_Radius")), + radar_range: as_f32(f.get("RadarRange")), + fcs_range: as_f32(f.get("FCSRange")), + cruising_velocity: as_f32(f.get("CruisingVelocity")), + maximum_velocity: as_f32(f.get("MaximumVelocity")), + acceleration: as_f32(f.get("Acceleration")), + deceleration: as_f32(f.get("Deceleration")), + shield_ratio: as_f32(f.get("ShieldRatio")), + turret_count: as_i64(f.get("TurretCount")), + score_point: as_i64(f.get("ScorePoint")), + fields: f, + }) + } + pub fn field_f32(&self, key: &str) -> Option { + as_f32(self.fields.get(key)) + } +} + +/// Load every craft / unit from a pak. +pub fn load_units(pak: &PakArchive) -> Vec { + load_table(pak, schema::UNIT, CraftUnit::from_idxd) +} + +// ── Vessel / capital ship ─────────────────────────────────────────────────────── + +/// A capital ship (schema [`schema::VESSEL`]). Component counts drive the +/// destructible hardpoints. +#[derive(Debug, Clone)] +pub struct Vessel { + pub id: Option, + pub name: Option, + pub hp: Option, + pub size_x: Option, + pub size_y: Option, + pub size_z: Option, + pub radar_range: Option, + pub fcs_range: Option, + pub maximum_velocity: Option, + pub shield_ratio: Option, + pub score_point: Option, + pub turret_count: Option, + pub bridge_count: Option, + pub hatch_count: Option, + pub shield_generator_count: Option, + pub thruster_count: Option, + pub fields: BTreeMap, +} + +impl Vessel { + fn from_idxd(o: &IdxdObject) -> Option { + let f = field_map(o); + Some(Vessel { + id: o.get_raw("ID").map(str::to_string), + name: o.get_raw("Name").map(str::to_string), + hp: as_f32(f.get("HP")), + size_x: as_f32(f.get("Size_X")), + size_y: as_f32(f.get("Size_Y")), + size_z: as_f32(f.get("Size_Z")), + radar_range: as_f32(f.get("RadarRange")), + fcs_range: as_f32(f.get("FCSRange")), + maximum_velocity: as_f32(f.get("MaximumVelocity")), + shield_ratio: as_f32(f.get("ShieldRatio")), + score_point: as_i64(f.get("ScorePoint")), + turret_count: as_i64(f.get("TurretCount")), + bridge_count: as_i64(f.get("BridgeCount")), + hatch_count: as_i64(f.get("HatchCount")), + shield_generator_count: as_i64(f.get("ShieldGeneratorCount")), + thruster_count: as_i64(f.get("ThrusterCount")), + fields: f, + }) + } + pub fn field_f32(&self, key: &str) -> Option { + as_f32(self.fields.get(key)) + } +} + +/// Load every capital ship from a pak. +pub fn load_vessels(pak: &PakArchive) -> Vec { + load_table(pak, schema::VESSEL, Vessel::from_idxd) +} + +// ── Player config ─────────────────────────────────────────────────────────────── + +/// Player craft config (schema [`schema::PLAYER`]) — one per mission. Physics, +/// projectile caps and scoring here; cameras, difficulty and the render pipeline +/// live in [`fields`](PlayerConfig::fields). +#[derive(Debug, Clone)] +pub struct PlayerConfig { + pub air_drag_factor: Option, + pub gravity_factor: Option, + pub bullet_limit: Option, + pub laser_limit: Option, + pub homing_limit: Option, + pub space_size: Option, + pub supply_range: Option, + pub main_mission_bonus: Option, + pub rank_score_s: Option, + pub rank_score_a: Option, + pub rank_score_b: Option, + pub rank_score_c: Option, + pub rank_score_d: Option, + pub fields: BTreeMap, +} + +impl PlayerConfig { + fn from_idxd(o: &IdxdObject) -> Option { + let f = field_map(o); + Some(PlayerConfig { + air_drag_factor: as_f32(f.get("AirDragFactor")), + gravity_factor: as_f32(f.get("GravityFactor")), + bullet_limit: as_i64(f.get("BulletLimit")), + laser_limit: as_i64(f.get("LaserLimit")), + homing_limit: as_i64(f.get("HomingLimit")), + space_size: as_f32(f.get("SpaceSize")), + supply_range: as_f32(f.get("SupplyRange")), + main_mission_bonus: as_i64(f.get("MainMissionBonus")), + rank_score_s: as_i64(f.get("RankScore_S")), + rank_score_a: as_i64(f.get("RankScore_A")), + rank_score_b: as_i64(f.get("RankScore_B")), + rank_score_c: as_i64(f.get("RankScore_C")), + rank_score_d: as_i64(f.get("RankScore_D")), + fields: f, + }) + } + pub fn field_f32(&self, key: &str) -> Option { + as_f32(self.fields.get(key)) + } +} + +/// Load every player config (one per mission) from a pak. +pub fn load_player_configs(pak: &PakArchive) -> Vec { + load_table(pak, schema::PLAYER, PlayerConfig::from_idxd) +} + +// ── Character ─────────────────────────────────────────────────────────────────── + +/// A pilot / crew portrait: an emotion id (`FaceRAYMOND_07`) and its texture +/// (`pjf003_C02.t32`, a T8aD tile inside a RATC bundle). +#[derive(Debug, Clone)] +pub struct Face { + pub id: String, + pub texture: String, +} + +/// A character (schema [`CHARACTER`](schema::CHARACTER)) — pilots, crew, comms. +#[derive(Debug, Clone)] +pub struct Character { + pub id: Option, + /// Localized display-name key (resolve against the game's string tables). + pub name_key: Option, + /// Faction / side, e.g. `TCAF` (player) or `ADAN` (enemy). + pub faction: Option, + pub unique: Option, + /// Portrait set: each emotion face and the texture that draws it. + pub faces: Vec, + pub fields: BTreeMap, +} + +impl Character { + fn from_idxd(o: &IdxdObject) -> Option { + let t = o.tokens(); + // Faces are `(texture, FaceID)` pairs after the `Faces` marker (value- + // before-key: the FaceID names the emotion, the `.t32` before it is its art). + let mut faces = Vec::new(); + if let Some(start) = t.iter().position(|s| s == "Faces") { + for i in (start + 1)..t.len() { + if t[i].starts_with("Face") && i > 0 { + faces.push(Face { + id: t[i].clone(), + texture: t[i - 1].clone(), + }); + } + } + } + Some(Character { + id: o.get_raw("ID").map(str::to_string), + name_key: o.get_raw("Name").map(str::to_string), + faction: o.get_raw("SideID").map(str::to_string), + unique: o.get_bool("Unique"), + faces, + fields: field_map(o), + }) + } +} + +/// Load every character from a pak. +pub fn load_characters(pak: &PakArchive) -> Vec { + load_table(pak, schema::CHARACTER, Character::from_idxd) +} + +// ── Stage / mission ───────────────────────────────────────────────────────────── + +/// The raw token immediately before `key` in `tokens` (value-before-key), for +/// identifier-valued fields the generic resolver skips (filenames, locations). +fn raw_before<'a>(tokens: &'a [String], key: &str) -> Option<&'a str> { + let i = tokens.iter().position(|t| t == key)?; + (i > 0).then(|| tokens[i - 1].as_str()) +} + +/// A mission / stage (schema [`STAGE`](schema::STAGE)). The manifest that wires a +/// mission together: its location, phase count, and the per-stage tables that +/// populate it (unit roster, squadrons, formations, flight routes, AI, objectives). +/// Resolve the referenced `*_S.tbl` tables with [`load_records`] for the +/// spawn/formation detail. +#[derive(Debug, Clone)] +pub struct Stage { + /// Stage tag derived from the unit-table name, e.g. `"S01"`. + pub id: String, + /// In-world location (`BackGroundID`), e.g. `Lebendorf`, `Acheron`, `Earth`. + pub location: Option, + /// Number of `Phase_N` sections. + pub phases: u32, + pub unit_table: Option, + pub character_table: Option, + pub squadron_table: Option, + pub formation_table: Option, + pub route_table: Option, + pub ai_params_table: Option, + pub subobjective_table: Option, + pub message_table: Option, + /// Every `resource-kind → table` reference in the manifest. + pub resources: BTreeMap, +} + +impl Stage { + fn from_idxd(o: &IdxdObject) -> Option { + let t = o.tokens(); + // Each resource is `table-name → Enumerate` (value-before-key). + let mut resources = BTreeMap::new(); + for i in 1..t.len() { + let key = t[i].as_str(); + if key.starts_with("Enumerate") || key == "MessageSet" || key == "NamePlate" { + resources.insert(key.to_string(), t[i - 1].clone()); + } + } + let unit_table = raw_before(t, "EnumerateUnit").map(str::to_string); + let id = unit_table + .as_deref() + .and_then(|s| s.strip_prefix("EnumUnit_")) + .map(|s| s.trim_end_matches(".tbl").to_string()) + .unwrap_or_default(); + Some(Stage { + id, + location: o.get_raw("BackGroundID").map(str::to_string), + phases: t.iter().filter(|s| s.starts_with("Phase_")).count() as u32, + unit_table, + character_table: raw_before(t, "EnumerateCharacter").map(str::to_string), + squadron_table: raw_before(t, "EnumerateSquadron").map(str::to_string), + formation_table: raw_before(t, "EnumerateFormation").map(str::to_string), + route_table: raw_before(t, "EnumerateNullFrame").map(str::to_string), + ai_params_table: raw_before(t, "EnumerateAIParams").map(str::to_string), + subobjective_table: raw_before(t, "EnumerateSubobjective").map(str::to_string), + message_table: raw_before(t, "MessageSet").map(str::to_string), + resources, + }) + } +} + +/// Load every stage / mission manifest from a pak. +pub fn load_stages(pak: &PakArchive) -> Vec { + load_table(pak, schema::STAGE, Stage::from_idxd) +} + +// ── Generic record access (any of the ~105 schemas) ───────────────────────────── + +/// A generically-decoded IDXD record: its table name, schema id, identity, and +/// every explicitly-set field. Use this to read the ~99 schemas without a bespoke +/// struct (missions, UI layouts, effects, enums, …). +#[derive(Debug, Clone)] +pub struct Record { + /// The table name — `token[0]` (e.g. `Weapon`, `StageResource`, `Sperkers`). + /// May carry a stray leading byte from the string-pool boundary on some + /// records; group by [`schema`](Self::schema), not this, for reliability. + pub table: String, + pub schema: u32, + pub id: Option, + pub name: Option, + /// Every explicitly-set field (`key → value`). + pub fields: BTreeMap, +} + +impl Record { + fn from_idxd(o: &IdxdObject) -> Option { + Some(Record { + table: o.tokens().first().cloned().unwrap_or_default(), + schema: o.schema_hash, + id: o.get_raw("ID").map(str::to_string), + name: o.get_raw("Name").map(str::to_string), + fields: field_map(o), + }) + } + pub fn f32(&self, key: &str) -> Option { + as_f32(self.fields.get(key)) + } + pub fn i64(&self, key: &str) -> Option { + as_i64(self.fields.get(key)) + } +} + +/// Load every record of an arbitrary `schema` from a pak, generically. Pair with +/// the [`schema`] constants or a hash from the IDXD census. +pub fn load_records(pak: &PakArchive, schema: u32) -> Vec { + load_table(pak, schema, Record::from_idxd) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn as_f32_tolerates_trailing_f() { + assert_eq!(as_f32(Some(&"3.0f".to_string())), Some(3.0)); + assert_eq!(as_f32(Some(&"-0.5".to_string())), Some(-0.5)); + assert_eq!(as_f32(Some(&"Vessel,Craft".to_string())), None); + } + + // Integration tests against a real disc — skipped unless SYLPHEED_DISC is set. + fn pak() -> Option { + let disc = std::env::var("SYLPHEED_DISC").ok()?; + PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).ok() + } + + #[test] + fn loads_weapons() { + let Some(pak) = pak() else { return }; + let ws = load_weapons(&pak); + assert!(ws.len() >= 100, "expected ≥100 weapons, got {}", ws.len()); + let rocket = ws + .iter() + .find(|w| w.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_Rocket"))) + .expect("Delta Saber rocket present"); + assert_eq!(rocket.velocity, Some(3000.0)); + assert_eq!(rocket.max_range, Some(6000.0)); + assert_eq!(rocket.loading_count, Some(1000)); + assert_eq!(rocket.target_type.as_deref(), Some("Vessel,Craft,Structure")); + } + + #[test] + fn loads_units_and_vessels() { + let Some(pak) = pak() else { return }; + let units = load_units(&pak); + assert!(units.len() >= 80); + // Player craft: HP 1000, radar 500000. + let player = units + .iter() + .find(|u| u.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_T"))) + .expect("player craft present"); + assert_eq!(player.hp, Some(1000.0)); + assert_eq!(player.radar_range, Some(500000.0)); + + let vessels = load_vessels(&pak); + let flagship = vessels + .iter() + .find(|v| v.id.as_deref().is_some_and(|s| s.contains("SDBattleship"))) + .expect("SD-Battleship present"); + assert_eq!(flagship.hp, Some(100000.0)); + assert_eq!(flagship.turret_count, Some(25)); + } + + #[test] + fn loads_player_configs() { + let Some(pak) = pak() else { return }; + let cfgs = load_player_configs(&pak); + assert!(!cfgs.is_empty()); + let c = &cfgs[0]; + assert_eq!(c.bullet_limit, Some(512)); + assert_eq!(c.laser_limit, Some(32)); + assert_eq!(c.rank_score_s, Some(10000)); + } + + #[test] + fn loads_characters() { + let Some(pak) = pak() else { return }; + let chars = load_characters(&pak); + assert!(chars.len() >= 20, "expected many characters, got {}", chars.len()); + let raymond = chars + .iter() + .find(|c| c.id.as_deref() == Some("CharacterRAYMOND")) + .expect("Raymond present"); + assert_eq!(raymond.faction.as_deref(), Some("TCAF")); + assert!(!raymond.faces.is_empty()); + assert!(raymond.faces.iter().all(|f| f.texture.ends_with(".t32"))); + } + + #[test] + fn loads_stages() { + let Some(pak) = pak() else { return }; + let stages = load_stages(&pak); + let s01 = stages.iter().find(|s| s.id == "S01").expect("stage S01 present"); + assert_eq!(s01.location.as_deref(), Some("Lebendorf")); + assert_eq!(s01.phases, 3); + assert_eq!(s01.unit_table.as_deref(), Some("EnumUnit_S01.tbl")); + assert_eq!(s01.squadron_table.as_deref(), Some("UnitGroup_S01.tbl")); + // 16-mission main campaign is present. + for n in 1..=16 { + assert!(stages.iter().any(|s| s.id == format!("S{n:02}")), "missing S{n:02}"); + } + } + + #[test] + fn generic_records_read_any_schema() { + let Some(pak) = pak() else { return }; + // Stage-resource table via the generic reader (no bespoke struct). + let stages = load_records(&pak, 0x3c9a_e32e); + assert!(!stages.is_empty()); + assert!(stages.iter().all(|r| r.table.ends_with("StageResource"))); + } +} diff --git a/crates/sylpheed-formats/src/lib.rs b/crates/sylpheed-formats/src/lib.rs index 118f2a9..c16e713 100644 --- a/crates/sylpheed-formats/src/lib.rs +++ b/crates/sylpheed-formats/src/lib.rs @@ -61,6 +61,8 @@ pub mod movie_manifest; pub mod movie_voice; +pub mod game_data; + /// Re-export the most commonly used types at the crate root. pub use font::FontInfo; pub use idxd::{IdxdError, IdxdObject};