Files
Syplheed-Reborn/crates/sylpheed-formats/src/ship.rs
Claude (auto-RE) d83bdd3b68 fix(mesh): distinct anchor assignment -- no two resources may claim one buffer
Selection was per-resource and greedy, so two resources could take one vertex
buffer while a valid one sat unused. A runtime capture proved that wrong for the
mirrored e106 hull twins: the container holds both halves and the engine draws
each from its own address. Now the first claimant keeps a buffer and later
resources re-anchor past everything already claimed (coverage can never regress;
grouped-pool models untouched).

Against the 46 capture-named Stage_S02 buffers: exact anchors 29 -> 40, unclaimed
12 -> 4. Disc-wide: 5480 resources decoded (unchanged), cross-container
inconsistency 125 -> 46.

The twins' mirror therefore lives in the DATA, not in the placement matrix: the
embedded e106_bdy_02 row and the two assertions encoding the old convention are
updated, each with the reason recorded. Full suite green incl. disc/ISO gates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 03:16:51 +00:00

688 lines
30 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.
//! 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_<id>` (see [`assemble_ship`]).
//! Its node hierarchy (`rou_<id>_root`→`…_front`/`…_rear`→`rou_<id>_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):
//! - `<id>` 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 `<id>_<name>` (`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_<id>` is the primary composite; `e_rou_<id>_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<String>,
/// Whether a composite scene graph (`e_rou_<id>`) 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<ShipModel> {
let names = xbg7_resource_names(bytes);
let mut ids: Vec<String> = Vec::new();
let mut ships: std::collections::HashMap<String, ShipModel> = 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_<id>_<part>[_root|_mov|_NN]`; the geometry resource is the
/// `<id>_<part>` stem. Structural nodes (`rou_e106_front`, `…_root`) resolve to
/// nothing and only pose their children.
fn resolve_hull_resource(node: &str, resources: &HashSet<String>) -> Option<String> {
let stem = node.rsplit_once("rou_").map(|(_, s)| s).unwrap_or(node);
let try_stem = |s: &str| -> Option<String> {
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_<id>`) 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_<id>_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 24 (off = bare composite-node hull).
pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<ScenePart> {
let names = xbg7_resource_names(bytes);
let resources: HashSet<String> = 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<ScenePart>)> = 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<ScenePart> = Vec::new();
let mut placed_res: HashSet<String> = HashSet::new();
// GN hardpoint frames, world-space, from the primary composite.
let mut frames: Vec<ScenePart> = 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;
};
// An index-less part (`e105_brg`, against a `GN_Bridge_01` frame) used to
// compare `"01" == ""` and fall through, so the bridge was silently
// dropped from the assembly while the game draws it — caught by a runtime
// capture, which places `e105_brg` at the `GN_Bridge_01` frame exactly.
// With no index to match on, take the lowest-numbered frame of the
// category; an indexed part still matches its own index only.
let mut cands: Vec<&ScenePart> = frames
.iter()
.filter(|f| {
f.resource.contains(gncat)
&& (idx.is_empty() || trailing_index(&f.resource) == idx)
})
.collect();
cands.sort_by_key(|f| trailing_index(&f.resource).parse::<u32>().unwrap_or(u32::MAX));
if let Some(frame) = cands.first().copied() {
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<ScenePart> {
let names = xbg7_resource_names(bytes);
let scenes: Vec<Vec<ScenePart>> = 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 `<stem>_01`/`<stem>_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<String> = 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::<f32>()
/ ma.meshes.iter().map(|s| s.positions.len()).sum::<usize>().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]
);
}
}
}
// ── Extras: the direction this test could not previously fail in. ──
// The loop above walks the CAPTURE's parts and looks each up in ours, so
// a static placement with no counterpart was invisible to it — which is
// how a resource decoded 100x too large (`e303_wep_01`, 2026-08-12) sat
// here unnoticed. Pin the set instead: the capture legitimately misses
// repeated instances of a shared resource (vbase dedup), so `e303_wep_01`
// is expected; anything else appearing only in the static assembly is a
// regression.
let captured: std::collections::BTreeSet<&str> =
cap.parts.iter().map(|p| p.part.as_str()).collect();
let extra: std::collections::BTreeSet<&str> = placed
.iter()
.map(|p| p.resource.as_str())
.filter(|r| !captured.contains(r))
.collect();
let allowed: std::collections::BTreeSet<&str> = ["e303_wep_01"].into_iter().collect();
assert_eq!(
extra, allowed,
"static placements with no counterpart in the runtime capture"
);
// 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");
// NEITHER hull reflects: the twins' geometry is mirrored on the disc,
// so both placements are proper rotations. This flipped on 2026-08-12 —
// while both twins decoded to one buffer, `apply_twin_mirrors` had to
// synthesise the reflection here; a runtime capture showed the container
// holds both halves, and distinct anchor assignment now hands each twin
// its own (see docs/re/structures/xbg7-mesh.md).
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 plain (mirror is in the data)");
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"
);
}
}