diff --git a/crates/sylpheed-formats/src/game_data.rs b/crates/sylpheed-formats/src/game_data.rs index faac208..47c6971 100644 --- a/crates/sylpheed-formats/src/game_data.rs +++ b/crates/sylpheed-formats/src/game_data.rs @@ -224,6 +224,9 @@ pub fn load_units(pak: &PakArchive) -> Vec { pub struct Vessel { pub id: Option, pub name: Option, + /// The 3D hull resource stem, e.g. `rou_e105` — the `eNNN`/`fNNN` id links to + /// the XBG7 part family (`e105_bdy_*`, `e105_brg_*`, …) inside a `Stage_SNN.xpr`. + pub model: Option, pub hp: Option, pub size_x: Option, pub size_y: Option, @@ -247,6 +250,7 @@ impl Vessel { Some(Vessel { id: o.get_raw("ID").map(str::to_string), name: o.get_raw("Name").map(str::to_string), + model: o.get_raw("Model").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")), diff --git a/crates/sylpheed-formats/src/lib.rs b/crates/sylpheed-formats/src/lib.rs index 6c1c0c7..714fdea 100644 --- a/crates/sylpheed-formats/src/lib.rs +++ b/crates/sylpheed-formats/src/lib.rs @@ -65,6 +65,9 @@ pub mod game_data; pub mod localization; +// Whole-ship assembly from XBG7 part families (capital ships as split parts). +pub mod ship; + /// Re-export the most commonly used types at the crate root. pub use font::FontInfo; pub use idxd::{IdxdError, IdxdObject}; diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index fef3ea1..f8ca93b 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -151,6 +151,37 @@ pub fn count_xbg7(bytes: &[u8]) -> usize { n } +/// List every XBG7 resource name in an XPR2 container, in directory order. +/// +/// Cheap: walks only the resource directory (no geometry decode). Used to +/// discover which container holds a named hull / part model when assembling a +/// multi-part ship from a [`crate::game_data::Vessel`] recipe. +pub fn xbg7_resource_names(bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + if bytes.len() < 16 || &bytes[..4] != b"XPR2" { + return out; + } + let mut cur = Cursor::new(bytes); + let header = match Xpr2Header::read(&mut cur) { + Ok(h) => h, + Err(_) => return out, + }; + const DIR_BASE: usize = 0x10; + for _ in 0..header.num_resources { + let e = match Xpr2ResourceEntry::read(&mut cur) { + Ok(e) => e, + Err(_) => break, + }; + if &e.type_tag != b"XBG7" { + continue; + } + if let Some(n) = read_cstr(bytes, e.name_offset as usize + DIR_BASE) { + out.push(n); + } + } + out +} + impl Xbg7Model { /// Total vertex / triangle counts across all sub-meshes. pub fn totals(&self) -> (usize, usize) { @@ -402,6 +433,18 @@ impl Xbg7Model { Self::anchor_models_cancellable(bytes, min_consistency, &|| false) } + /// Decode only the named XBG7 resources from a container (content-anchored, + /// abortable). Used to assemble a whole ship from just its part family + /// (`e106_bdy_*`, `e106_brg_*`, …) instead of decoding the whole ~300-resource + /// stage. Order follows the container's directory. See [`crate::ship`]. + pub fn models_named( + bytes: &[u8], + wanted: &std::collections::HashSet, + should_cancel: &(dyn Fn() -> bool + Sync), + ) -> Vec { + Self::anchor_models_filtered(bytes, 0.0, should_cancel, Some(wanted)) + } + /// [`Xbg7Model::anchor_models`] with a cancellation poll checked between /// resources (a large stage container holds hundreds). See /// [`Xbg7Model::stage_models_cancellable`]. @@ -409,6 +452,15 @@ impl Xbg7Model { bytes: &[u8], min_consistency: f32, should_cancel: &(dyn Fn() -> bool + Sync), + ) -> Vec { + Self::anchor_models_filtered(bytes, min_consistency, should_cancel, None) + } + + fn anchor_models_filtered( + bytes: &[u8], + min_consistency: f32, + should_cancel: &(dyn Fn() -> bool + Sync), + wanted: Option<&std::collections::HashSet>, ) -> Vec { let mut out = Vec::new(); if bytes.len() < 16 || &bytes[..4] != b"XPR2" { @@ -464,6 +516,11 @@ impl Xbg7Model { } let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE) .unwrap_or_else(|| "XBG7".to_string()); + if let Some(w) = wanted { + if !w.contains(&name) { + continue; + } + } resources.push(Res { name, markers, diff --git a/crates/sylpheed-formats/src/ship.rs b/crates/sylpheed-formats/src/ship.rs new file mode 100644 index 0000000..7463f6d --- /dev/null +++ b/crates/sylpheed-formats/src/ship.rs @@ -0,0 +1,213 @@ +//! Whole-ship assembly from XBG7 part families. +//! +//! Project Sylpheed's capital ships are never stored as a single mesh. A ship is +//! modelled, then **split into destructible parts** — hull segments, bridge, +//! engines, integral turrets — each shipped as its own XBG7 resource inside a +//! `hidden/resource3d/Stage_SNN.xpr` container. The split lets the game hide a +//! part when the player destroys it (bridge, shield generator, thruster) and swap +//! in a `_break` variant. +//! +//! Crucially, **each part keeps the world position it had in the whole ship**: +//! the modeller carved the assembled hull into pieces, so `e106_brg_01` (bridge) +//! already sits forward at z≈+291 and `e106_eng_01` (engine) already sits aft at +//! z≈−532 in the shared ship-local frame. There is no per-part joint transform +//! (`mesh::node_transforms` returns nothing for them). So reconstructing the whole +//! ship is simply: **draw every base part of one id together, untransformed.** +//! +//! Resource naming (learned from the retail stage containers): +//! - `` is `[a-z]NNN` (`e106`, `f105`, `n050`). The leading letter is the +//! faction: `e` = ADAN (enemy), `f` = TCAF (player side), `n` = neutral / +//! structures / debris. +//! - A base part is `_` (`e106_bdy_01`, `e106_brg_01`, `f105_sld_02`). +//! - `_l` / `_m` / `_d` suffixes are lower level-of-detail copies of a base part; +//! `_bNN` / `_dead` / `_break` are destruction geometry; `_joint` / `_open` / +//! `_Near` are logic/animation nodes. All are skipped for a clean whole-ship +//! render (the base part always exists alongside them). +//! - `e_rou_` / `_rou__…` are the scene-graph / logic resources; they +//! carry no drawable geometry and are skipped. +//! +//! Modular turrets (the shared `e3NN` / `e4NN` gun models a [`crate::game_data`] +//! `Vessel` mounts at named hull frames) are their *own* ids here, not folded into +//! the hull family — placing them needs the hull's frame table, which this pass +//! does not resolve. A hull-family render therefore shows the ship body plus any +//! *integral* (`_wep_`) weapons, which is the bulk of the silhouette. + +use crate::mesh::xbg7_resource_names; + +/// Which side a ship belongs to, from the first letter of its resource id. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Faction { + /// `e…` — ADAN (the enemy). + Adan, + /// `f…` — TCAF (the player's coalition). + Tcaf, + /// `n…` — neutral: stations, structures, asteroids, debris. + Neutral, + /// Anything else (`j…`, `s…`, …). + Other, +} + +impl Faction { + fn from_id(id: &str) -> Faction { + match id.as_bytes().first() { + Some(b'e') => Faction::Adan, + Some(b'f') => Faction::Tcaf, + Some(b'n') => Faction::Neutral, + _ => Faction::Other, + } + } + + /// Short human label. + pub fn label(self) -> &'static str { + match self { + Faction::Adan => "ADAN", + Faction::Tcaf => "TCAF", + Faction::Neutral => "Neutral", + Faction::Other => "Other", + } + } +} + +/// A ship reconstructed from one XBG7 part family in a stage container. +#[derive(Debug, Clone)] +pub struct ShipModel { + /// The family id, e.g. `e106` — matches a [`crate::game_data::Vessel`] whose + /// `model` is `rou_e106`. + pub id: String, + /// Faction inferred from the id's leading letter. + pub faction: Faction, + /// Base geometry resource names to draw together, in directory order. + pub parts: Vec, +} + +/// The `[a-z]NNN` id prefix of a resource name, if it has one (`e106_bdy_01` +/// → `e106`). Returns `None` for scene/logic resources (`e_rou_e106`) and +/// anything not starting with a letter + three digits. +pub fn ship_id_of(resource: &str) -> Option<&str> { + let b = resource.as_bytes(); + if b.len() >= 4 + && b[0].is_ascii_lowercase() + && b[1].is_ascii_digit() + && b[2].is_ascii_digit() + && b[3].is_ascii_digit() + { + // Reject a longer alnum run (`e1234…`): the id is exactly letter+3 digits, + // followed by end-of-string or a non-alphanumeric (`_`). + if b.get(4).is_none_or(|c| !c.is_ascii_alphanumeric()) { + return Some(&resource[..4]); + } + } + None +} + +/// Whether a resource is a base geometry part (not a LOD copy, destruction +/// variant, or scene/logic node) that should be drawn in a whole-ship render. +pub fn is_base_part(resource: &str) -> bool { + if ship_id_of(resource).is_none() { + return false; + } + // Level-of-detail copies of a base part. + if resource.ends_with("_l") || resource.ends_with("_m") || resource.ends_with("_d") { + return false; + } + // Destruction / logic geometry. + if resource.contains("break") + || resource.contains("dead") + || resource.contains("_joint") + || resource.contains("_open") + || resource.contains("_Near") + { + return false; + } + // Break-piece geometry carries a bare `b` / `bNN` token (`e106_brg_01_b_02`, + // `e108_eng_01_b01`). `bdy` etc. are safe — only an exact `b`-then-digits token + // counts. + if resource.split('_').skip(1).any(|t| { + t == "b" || (t.starts_with('b') && t.len() >= 2 && t[1..].bytes().all(|c| c.is_ascii_digit())) + }) { + return false; + } + true +} + +/// Group every base part in an XPR2 container into whole ships, keyed by id. +/// +/// Ships are returned sorted by id. A stage container also holds shared turret / +/// prop models as their own ids — every group with at least one base part is +/// returned; the caller can filter by [`ShipModel::parts`] length or faction. +pub fn ships_in_container(bytes: &[u8]) -> Vec { + let names = xbg7_resource_names(bytes); + let mut ids: Vec = Vec::new(); + let mut ships: std::collections::HashMap = std::collections::HashMap::new(); + for n in &names { + if !is_base_part(n) { + continue; + } + let id = ship_id_of(n).unwrap().to_string(); + let entry = ships.entry(id.clone()).or_insert_with(|| { + ids.push(id.clone()); + ShipModel { id: id.clone(), faction: Faction::from_id(&id), parts: Vec::new() } + }); + entry.parts.push(n.clone()); + } + ids.sort(); + ids.into_iter().map(|id| ships.remove(&id).unwrap()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn id_extraction() { + assert_eq!(ship_id_of("e106_bdy_01"), Some("e106")); + assert_eq!(ship_id_of("f105_sld_02_l"), Some("f105")); + assert_eq!(ship_id_of("e_rou_e106"), None); + assert_eq!(ship_id_of("_rou_e106_break"), None); + assert_eq!(ship_id_of("Base"), None); + } + + #[test] + fn base_part_filter() { + assert!(is_base_part("e106_bdy_01")); + assert!(is_base_part("e106_brg_01")); + assert!(is_base_part("f105_wep_02_01")); + assert!(!is_base_part("e106_bdy_01_l")); // LOD + assert!(!is_base_part("e106_bdy_01_m")); // LOD + assert!(!is_base_part("e106_brg_01_b_02")); // break piece + assert!(!is_base_part("_rou_e106_break")); // logic + assert!(!is_base_part("e_rou_e106")); // scene node + } + + #[test] + fn faction_from_id() { + assert_eq!(Faction::from_id("e106"), Faction::Adan); + assert_eq!(Faction::from_id("f105"), Faction::Tcaf); + assert_eq!(Faction::from_id("n050"), Faction::Neutral); + assert_eq!(Faction::from_id("j004"), Faction::Other); + } + + // Disc-gated: reconstruct the ADAN cruiser family from a real stage container. + #[test] + fn ships_from_real_stage() { + let Ok(iso) = std::env::var("SYLPHEED_ISO") else { + eprintln!("SYLPHEED_ISO unset — skipping"); + return; + }; + let bytes = { + use crate::xiso::open_iso; + let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); + rt.block_on(async { + let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap(); + r.read_file("hidden/resource3d/Stage_S02.xpr").await.unwrap() + }) + }; + let ships = ships_in_container(&bytes); + assert!(!ships.is_empty(), "stage should hold ships"); + // The ADAN frigate e108 appears in S02 with hull + engine + weapon parts. + let e108 = ships.iter().find(|s| s.id == "e108").expect("e108 present"); + assert_eq!(e108.faction, Faction::Adan); + assert!(e108.parts.iter().any(|p| p.contains("_bdy_"))); + assert!(e108.parts.iter().all(|p| is_base_part(p))); + } +}