//! 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, /// Whether a composite scene graph (`e_rou_`) exists — i.e. this is a /// real assembled ship. Families without one (fighter morph sets, shared /// turret/prop parts) have no placement data and would stack at the origin. pub has_composite: bool, } /// 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()); let has_composite = !composites_for(&names, &id).is_empty(); ShipModel { id: id.clone(), faction: Faction::from_id(&id), parts: Vec::new(), has_composite, } }); 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); let try_stem = |s: &str| -> Option { if resources.contains(s) { return Some(s.to_string()); } // Squeezed digit run (`wep02` names the `wep_02_01` resource): insert an // underscore before the digits and try the `_01` first-part too. if let Some(pos) = s.rfind(|c: char| c.is_ascii_digit()) { let mut start = pos; while start > 0 && s.as_bytes()[start - 1].is_ascii_digit() { start -= 1; } if start > 0 && s.as_bytes()[start - 1] != b'_' { let split = format!("{}_{}", &s[..start], &s[start..]); if resources.contains(&split) { return Some(split); } let first = format!("{split}_01"); if resources.contains(&first) { return Some(first); } } } None }; if let Some(r) = try_stem(stem) { return Some(r); } for suf in ["_root", "_mov"] { if let Some(s) = stem.strip_suffix(suf) { if let Some(r) = try_stem(s) { return Some(r); } } } // 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()) { if let Some(r) = try_stem(&stem[..pos]) { return Some(r); } } } 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. /// /// Fully static and EXACT (validated part-for-part against an e106 runtime /// capture — see `docs/re/ship-placement-runtime-capture.md`): /// 1. **Composite nodes** — every `rou_*` node in the primary composite /// (`e_rou_`) places a geometry resource, INCLUDING repeated instances /// and cross-id turret mounts (`rou_e303_wep_01_root` ×2 on the e106 hull). /// 2. **Detail rigs** — `e_rou__eng` is the engine cluster rig; its nodes /// (e.g. two `eng_01` nacelle instances + `eng_02`) mount at the primary /// composite's `GN_Engine_01` frame: `world = frame ∘ rig_local`. /// 3. **GN hardpoints** — remaining unplaced `brg`/`sld` parts sit exactly at /// their `GN_Bridge_NN` / `GN_ShieldG_NN` frame (frames carry full TRS). /// 4. **Mirrored twins** — a port/starboard pair (`bdy_01`/`bdy_02`) shares one /// geometry; the instance whose lateral offset points the geometry's dominant /// side inboard is drawn X-reflected (capture-verified; det < 0 → the caller /// reverses winding). /// /// `include_external` keeps tiers 2–4 (off = bare composite-node hull). 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 ship-space layout (hull bodies, turret // mounts + every `GN_*` hardpoint frame): the one with the most nodes. let scenes: Vec<(String, Vec)> = composites_for(&names, id) .into_iter() .filter(|c| !c.ends_with("_joint")) .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(); // Tier 1: every composite node placement, instances included. Cross-id // resources (shared e303/e4NN turret models the composite mounts) count too. 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) { if ship_id_of(&res) != Some(id) && !include_external { continue; // hull-only view: own parts only } // Distinct INSTANCES keep their own placement; a nested alias of the // same resource at the same transform (`bdy_01_mov` + its identity // `bdy_01` child) collapses to one draw. let dup = placed.iter().any(|p| { p.resource == res && (p.t[0] - node.t[0]).abs() < 0.01 && (p.t[1] - node.t[1]).abs() < 0.01 && (p.t[2] - node.t[2]).abs() < 0.01 }); if !dup { placed_res.insert(res.clone()); placed.push(ScenePart { resource: res, m: node.m, t: node.t, s: node.s }); } } } // Tier 2: the engine cluster rig, mounted at GN_Engine_01. The rig's nodes // are in the FRAME's local space (two mirrored `eng_01` nacelles + centre // `eng_02` for e106) — capture-verified to 0.1 units. if include_external { let rig_name = format!("e_rou_{id}_eng"); if names.contains(&rig_name) { if let Some(frame) = frames.iter().find(|f| f.resource.starts_with("GN_Engine")) { for rn in scene_world_nodes(bytes, &rig_name) { let Some(res) = resolve_hull_resource(&rn.resource, &resources) else { continue; }; // world = frame ∘ rig_local let m = m3_mul_pub(frame.m, rn.m); let rt = m3_vec_pub(frame.m, rn.t); let t = [rt[0] + frame.t[0], rt[1] + frame.t[1], rt[2] + frame.t[2]]; placed_res.insert(res.clone()); placed.push(ScenePart { resource: res, m, t, s: rn.s }); } } } } // Tier 3: remaining external parts at their exact GN frame (full TRS). 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()); } } // Tier 4: mirror one of a shared-geometry twin pair. The engine uploads the // second of a port/starboard pair X-reflected (capture-verified on // bdy_01/bdy_02): for two same-geometry resources placed at ±TX, the // instance whose TX sign matches the geometry's dominant-X side draws // mirrored (so the pair ends up symmetric instead of twice the same side). apply_twin_mirrors(bytes, &mut placed); // 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 } /// The ship's exhaust mount frames (`GN_Jet_*` / `GN_SJet_*`), world-space, from /// the primary composite. These are where the game renders the engine exhaust /// FX — the visible "thrusters". The engine GEOMETRY itself sits recessed in /// the hull (capture-verified across 43 e106 instances: the game draws /// `eng_01`/`eng_02` exactly where [`assemble_ship`] puts them); without the /// FX the assembled ship reads engine-less, so a viewer can draw exhaust /// markers at these frames. pub fn exhaust_frames(bytes: &[u8], id: &str) -> Vec { let names = xbg7_resource_names(bytes); let scenes: Vec> = composites_for(&names, id) .into_iter() .filter(|c| !c.ends_with("_joint")) .map(|c| scene_world_nodes(bytes, c)) .collect(); let Some(nodes) = scenes.into_iter().max_by_key(|n| n.len()) else { return Vec::new(); }; nodes .into_iter() .filter(|n| n.resource.starts_with("GN_Jet") || n.resource.starts_with("GN_SJet")) .collect() } /// Detect shared-geometry port/starboard twin RESOURCES placed at ±TX and mark /// the dominant-side instance as an X-reflection (negate the matrix X column). /// Twins are `_01`/`_02` pairs with equal vertex data; the reflected /// one is the instance whose TX sign equals the geometry's mean-X sign (the /// un-reflected drawing already covers that side's opposite). fn apply_twin_mirrors(bytes: &[u8], placed: &mut [ScenePart]) { use crate::mesh::Xbg7Model; // Candidate pairs: resources differing only in a trailing _01/_02, both // placed exactly once, at opposite-sign TX of equal magnitude. let mut pairs: Vec<(usize, usize)> = Vec::new(); for i in 0..placed.len() { let a = &placed[i]; let Some(stem) = a.resource.strip_suffix("_01") else { continue }; let twin = format!("{stem}_02"); let Some(j) = placed.iter().position(|p| p.resource == twin) else { continue }; let b = &placed[j]; if (a.t[0] + b.t[0]).abs() < 0.5 && a.t[0].abs() > 1.0 { pairs.push((i, j)); } } if pairs.is_empty() { return; } let want: HashSet = pairs .iter() .flat_map(|&(i, j)| [placed[i].resource.clone(), placed[j].resource.clone()]) .collect(); let models = Xbg7Model::models_named(bytes, &want, &|| false); for (i, j) in pairs { let get = |r: &str| models.iter().find(|m| m.name == r); let (Some(ma), Some(mb)) = (get(&placed[i].resource), get(&placed[j].resource)) else { continue; }; let first = |m: &Xbg7Model| -> Vec<[f32; 3]> { m.meshes.iter().flat_map(|s| s.positions.iter().copied()).take(16).collect() }; let (fa, fb) = (first(ma), first(mb)); if fa.len() != fb.len() || !fa.iter().zip(&fb).all(|(p, q)| { (p[0] - q[0]).abs() < 1e-3 && (p[1] - q[1]).abs() < 1e-3 && (p[2] - q[2]).abs() < 1e-3 }) { continue; // distinct (already-mirrored) geometries — nothing to do } let mean_x: f32 = ma .meshes .iter() .flat_map(|s| s.positions.iter()) .map(|p| p[0]) .sum::() / ma.meshes.iter().map(|s| s.positions.len()).sum::().max(1) as f32; if mean_x.abs() < 1e-3 { continue; // symmetric geometry — mirroring is a no-op } // Reflect the instance whose lateral offset OPPOSES the geometry's // dominant side (drawn plain it would fold onto the centreline; the // capture shows the engine reflects exactly this one — for e106 the // −264 port instance draws the file data plain, the +264 one mirrored). let k = if (placed[i].t[0] > 0.0) == (mean_x > 0.0) { j } else { i }; for row in &mut placed[k].m { row[0] = -row[0]; } } } // Small helpers mirroring mesh.rs's private affine ops. fn m3_mul_pub(a: [[f32; 3]; 3], b: [[f32; 3]; 3]) -> [[f32; 3]; 3] { let mut o = [[0.0f32; 3]; 3]; for r in 0..3 { for c in 0..3 { o[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c]; } } o } fn m3_vec_pub(a: [[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] { [ a[0][0] * v[0] + a[0][1] * v[1] + a[0][2] * v[2], a[1][0] * v[0] + a[1][1] * v[1] + a[1][2] * v[2], a[2][0] * v[0] + a[2][1] * v[1] + a[2][2] * v[2], ] } #[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 GROUND-TRUTH test: the fully-static assembly must reproduce the // runtime F10 capture (the baked e106 table) part-for-part — translations // AND the engine-rig rotation — plus the multi-instance parts the capture's // vbase-dedup could not see (two nacelles, two turrets). #[test] fn static_assembly_matches_runtime_capture() { 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_S01.xpr").await.unwrap() }) }; let placed = assemble_ship(&bytes, "e106", true); let cap = crate::ship_capture::embedded_placement("e106").expect("e106 baked"); // Express static placements relative to bdy_04 (the capture's reference). let r4 = placed .iter() .find(|p| p.resource == "e106_bdy_04") .expect("bdy_04 placed") .clone(); let rel = |p: &crate::mesh::ScenePart| -> [f32; 3] { [p.t[0] - r4.t[0], p.t[1] - r4.t[1], p.t[2] - r4.t[2]] }; for want in &cap.parts { let best = placed .iter() .filter(|p| p.resource == want.part) .map(|p| { let t = rel(p); let d = (t[0] - want.t[0]).abs() + (t[1] - want.t[1]).abs() + (t[2] - want.t[2]).abs(); (d, p) }) .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap()) .unwrap_or_else(|| panic!("{} not placed statically", want.part)); assert!( best.0 < 1.0, "{}: static rel-T {:?} != captured {:?} (Δ={:.2})", want.part, rel(best.1), want.t, best.0 ); // Rotations must match too (the engine rig is the interesting case). let m = &best.1.m; for r in 0..3 { for c in 0..3 { assert!( (m[r][c] - want.m[r][c]).abs() < 0.02, "{}: static M row{r} {:?} != captured {:?}", want.part, m[r], want.m[r] ); } } } // Multi-instance coverage the capture couldn't see (vbase dedup). let count = |res: &str| placed.iter().filter(|p| p.resource == res).count(); assert_eq!(count("e106_eng_01"), 2, "both engine nacelles placed"); assert_eq!(count("e303_wep_01"), 2, "both shared turrets placed"); // The mirrored starboard hull reflects (det < 0), the port one doesn't. let det = |m: &[[f32; 3]; 3]| { m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) }; let one = |res: &str| placed.iter().find(|p| p.resource == res).unwrap(); assert!(det(&one("e106_bdy_02").m) < 0.0, "starboard hull mirrored"); assert!(det(&one("e106_bdy_01").m) > 0.0, "port hull plain"); } // 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" ); } }