//! 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. //! //! Each part's vertices are authored around the origin; the world placement lives //! in the **primary composite scene graph** `e_rou_` (see [`assemble_ship`]). //! Its node hierarchy (`rou__root`→`…_front`/`…_rear`→`rou__bdy_NN`) names //! the separate hull resources and carries their world TRS — the cross-resource //! analogue of how [`mesh::node_transforms`] poses one model's sub-parts. The //! `GN_*` nodes in the same composite are the Vessel hardpoint frames (bridge / //! shield / engine / turret mounts, matching the IDXD recipe `Frame` names). //! //! 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`(`NN`) suffixes are lower level-of-detail copies; `_bNN` / //! `_dead` / `_break` are destruction geometry; `_joint` / `_open` / `_Near` are //! logic/animation nodes. All are skipped. //! - `e_rou_` is the primary composite; `e_rou__eng` / `_wep_*` are LOCAL //! detail rigs (their nodes are in their own frame, not ship-space) and are NOT //! used for placement. use crate::mesh::{scene_world_nodes, xbg7_resource_names, ScenePart}; use std::collections::HashSet; /// 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: a trailing `_l`/`_m`/`_d` token, // optionally with a number (`_d01`, `_l02`). if let Some(last) = resource.rsplit('_').next() { let mut cs = last.chars(); if matches!(cs.next(), Some('l' | 'm' | 'd')) && cs.all(|c| c.is_ascii_digit()) { 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() } /// Map a composite scene-graph node name to the geometry resource it draws. /// Hull nodes are `rou__[_root|_mov|_NN]`; the geometry resource is the /// `_` stem. Structural nodes (`rou_e106_front`, `…_root`) resolve to /// nothing and only pose their children. fn resolve_hull_resource(node: &str, resources: &HashSet) -> Option { let stem = node.rsplit_once("rou_").map(|(_, s)| s).unwrap_or(node); if resources.contains(stem) { return Some(stem.to_string()); } for suf in ["_root", "_mov"] { if let Some(s) = stem.strip_suffix(suf) { if resources.contains(s) { return Some(s.to_string()); } } } // Trailing `_NN` instance index (`eng_01_1` → `eng_01`). if let Some(pos) = stem.rfind('_') { if !stem[pos + 1..].is_empty() && stem[pos + 1..].bytes().all(|c| c.is_ascii_digit()) { let s = &stem[..pos]; if resources.contains(s) { return Some(s.to_string()); } } } None } /// The trailing digit run of a name (`GN_Bridge_01` → `01`, `sld_02` → `02`). fn trailing_index(name: &str) -> &str { let bytes = name.as_bytes(); let mut start = bytes.len(); while start > 0 && bytes[start - 1].is_ascii_digit() { start -= 1; } &name[start..] } /// Which stage-container resources are the composite scene graphs for ship `id` /// (`e_rou_e106`, `e_rou_e106_eng`, …). Excludes destruction (`_break`) scenes. fn composites_for<'a>(names: &'a [String], id: &str) -> Vec<&'a String> { let needle = format!("rou_{id}"); names .iter() .filter(|n| { n.contains(&needle) && ship_id_of(n).is_none() && !n.contains("break") && !n.contains("dead") }) .collect() } /// Reconstruct a whole ship as a list of geometry-resource placements in a shared /// ship-local frame — the placement the game builds at runtime. /// /// Two tiers, matching how the game splits a ship: /// 1. **Hull** — `rou__*` nodes in the primary composite scene graph name the /// hull body resources and carry their world transform. This tier is exact: the /// bodies (and any engine posed by a `rou_` node) land where the game puts them. /// 2. **External hardpoints** — `GN_*` frame nodes are the Vessel mounts; each /// unplaced bridge / shield-generator / engine part (`_brg_*`, `_sld_*`, /// `_eng_*`) is placed at its matching frame by category + index. This tier is /// APPROXIMATE: a `GN_*` frame is the mount *pivot* (often on the centreline), /// while the part's outboard/rotational offset lives in a detail sub-rig or in /// runtime code that this static pass does not recover — so external parts land /// near, not exactly, where they belong. Gated by `include_external`. /// /// Turret placement (shared `e3NN`/`e4NN` gun models mounted at many `GN_*Gun*` /// frames) needs the per-mount Vessel recipe and is not resolved at all. pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec { let names = xbg7_resource_names(bytes); let resources: HashSet = names.iter().cloned().collect(); // The PRIMARY composite carries the real ship-space layout (hull bodies + // every `GN_*` hardpoint frame). Sibling composites (`e_rou__eng`, // `_wep_*`) are LOCAL detail rigs — their nodes are in their own frame, not // ship-space, so folding them in floats parts mid-ship. Pick the composite // with the most nodes; that is the assembled hull. let scenes: Vec<(String, Vec)> = composites_for(&names, id) .into_iter() .map(|c| (c.clone(), scene_world_nodes(bytes, c))) .collect(); let Some((_, nodes)) = scenes.iter().max_by_key(|(_, n)| n.len()) else { // No composite at all → raw fallback below. let mut placed = Vec::new(); const ID: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) { placed.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] }); } return placed; }; let mut placed: Vec = Vec::new(); let mut placed_res: HashSet = HashSet::new(); // GN hardpoint frames, world-space, from the primary composite. let mut frames: Vec = Vec::new(); for node in nodes { if node.resource.starts_with("GN_") { frames.push(node.clone()); } else if let Some(res) = resolve_hull_resource(&node.resource, &resources) { // Only the ship's own parts. A composite also mounts shared, cross-id // turret models (`e303_wep_*` on an `e106` hull); those need the // per-mount Vessel recipe and are placed separately. if ship_id_of(&res) != Some(id) { continue; } if placed_res.insert(res.clone()) { placed.push(ScenePart { resource: res, m: node.m, t: node.t, s: node.s }); } } } // Place unplaced external parts at their Vessel hardpoint frame (APPROXIMATE — // see the doc comment). Skipped for a clean, exact hull-only render. const CATS: [(&str, &str); 3] = [("brg", "Bridge"), ("sld", "ShieldG"), ("eng", "Engine")]; for part in names .iter() .filter(|_| include_external) .filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) { if placed_res.contains(part) { continue; } let rest = &part[(id.len() + 1).min(part.len())..]; // "brg_01" let mut toks = rest.split('_'); let cat = toks.next().unwrap_or(""); let idx = toks.next().unwrap_or(""); let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else { continue; }; if let Some(frame) = frames.iter().find(|f| f.resource.contains(gncat) && trailing_index(&f.resource) == idx) { placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s }); placed_res.insert(part.clone()); } } // Fallback for a ship with no composite scene graph (a simple prop): draw its // base parts untransformed rather than nothing. if placed.is_empty() { const ID: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) { placed.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] }); } } placed } #[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))); } // Disc-gated: the scene graph must SPREAD a ship's parts, not stack them at // the origin. Reconstruct the ADAN frigate e106 and assert its bridge sits // clearly aft of its forward hull body (a real ship layout, not a pile). #[test] fn assemble_spreads_parts() { 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 placed = assemble_ship(&bytes, "e106", true); assert!(placed.len() >= 5, "e106 should place its hull + external parts"); // The forward hull body and the bridge must land at distinct fore/aft Z. let z = |res: &str| placed.iter().find(|p| p.resource == res).map(|p| p.t[2]); let bdy = z("e106_bdy_01").expect("bdy_01 placed"); let brg = z("e106_brg_01").expect("brg_01 placed at its GN_Bridge frame"); assert!( (bdy - brg).abs() > 200.0, "bridge ({brg}) and forward body ({bdy}) must be far apart, not stacked" ); } }