ship: STATIC assembly is now EXACT — node-TRS off-by-one cracked via the capture
The runtime capture served its true purpose: as the answer key that exposed the static encoding. The XBG7 node joint table (node+0x44) holds NINE keyframe channels [TX TY TZ RY RX RZ SX SY SZ] behind EIGHT pointer slots shifted one track forward: channel k+1 = f64@(ptr[k]+8), and channel 0 -- TX, which no pointer names -- sits at ptr[0]-0x48 (tracks are 0x50-byte records, value at +8). The old 8-slot read took TY as X, RY as Y (usually 0 -- why both hulls stacked on the centreline) and never saw TX at all (the +-264 hull offsets). Euler order is Ry(ch3)*Rx(ch4)*Rz(ch5) with angles applied directly, pinned by the captured engine nacelle Rx(-15)*Rz(30) decomposition. mesh.rs gains read_trs9/node_rotation; scene_world_nodes and node_transforms both fixed (saber fin overrides unaffected). assemble_ship rewritten fully static and exact: - every composite-node instance placed (alias duplicates collapsed, real instances kept) including cross-id turret mounts (rou_e303_wep_01_root x2) - the e_rou_<id>_eng cluster rig mounts at GN_Engine_01 (two mirrored nacelles +-131 with -+30-degree cant + centre engine) -- world = frame o rig_local - brg/sld at their exact GN frames (frames were always exact) - shared-geometry twin pairs at +-TX: X-reflect the instance whose offset opposes the geometry's dominant side (viewer reverses winding on det<0) - squeezed node names resolve (wep02 -> wep_02_01) Ground-truth gate: ship::tests::static_assembly_matches_runtime_capture asserts static == the baked e106 capture for all 8 parts (T<1.0, R<0.02) plus both nacelles, both turrets, and the starboard-hull mirror. Viewer now uses pure static assembly; ship_capture + the baked table remain as the verification oracle. Renders show a coherent, bilaterally-symmetric destroyer. 81 formats-lib + all disc tests green; viewer builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
89
crates/sylpheed-formats/examples/node_dump.rs
Normal file
89
crates/sylpheed-formats/examples/node_dump.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//! RE diagnostic: raw-dump a composite scene graph's node records — name, table
|
||||||
|
//! pointers, and the FULL joint-table slot values (not just the 8 the decoder
|
||||||
|
//! currently reads) — to derive the true TRS slot layout against captured
|
||||||
|
//! ground truth.
|
||||||
|
//! cargo run --release --example node_dump -- <Stage.xpr path> <composite name> [slots]
|
||||||
|
use sylpheed_formats::mesh::xbg7_descriptor_range;
|
||||||
|
|
||||||
|
fn be32(d: &[u8], o: usize) -> u32 {
|
||||||
|
if o + 4 > d.len() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
|
||||||
|
}
|
||||||
|
fn be_f64(d: &[u8], o: usize) -> f64 {
|
||||||
|
if o + 8 > d.len() {
|
||||||
|
return f64::NAN;
|
||||||
|
}
|
||||||
|
f64::from_be_bytes(d[o..o + 8].try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let a: Vec<String> = std::env::args().collect();
|
||||||
|
let bytes = std::fs::read(&a[1]).unwrap();
|
||||||
|
let comp = &a[2];
|
||||||
|
let nslots: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(12);
|
||||||
|
let (desc, desc_end) = xbg7_descriptor_range(&bytes, comp).expect("composite found");
|
||||||
|
let d = &bytes[desc..desc_end];
|
||||||
|
println!("descriptor [{desc:#x}..{desc_end:#x}) len {:#x}", d.len());
|
||||||
|
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 3 <= d.len() {
|
||||||
|
let starts = (i + 4 <= d.len() && &d[i..i + 4] == b"rou_")
|
||||||
|
|| (i + 3 <= d.len() && &d[i..i + 3] == b"GN_");
|
||||||
|
if !starts {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut j = i;
|
||||||
|
while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let name = std::str::from_utf8(&d[i..j]).unwrap_or("?").to_string();
|
||||||
|
if name.ends_with("_col") || name.ends_with("_spc") || name.ends_with("_gls") || name.ends_with("_lum") {
|
||||||
|
i = j.max(i + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// find the 0xFFFFFFFF end marker
|
||||||
|
let mut ff = i;
|
||||||
|
let scan_end = (i + 0x90).min(d.len().saturating_sub(4));
|
||||||
|
while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF {
|
||||||
|
ff += 4;
|
||||||
|
}
|
||||||
|
if ff >= scan_end || ff < 0x10 {
|
||||||
|
i = j.max(i + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let t44 = be32(d, ff - 0x10) as usize;
|
||||||
|
let t48 = be32(d, ff - 0x0C) as usize;
|
||||||
|
let child = be32(d, ff - 0x08) as usize;
|
||||||
|
let sib = be32(d, ff - 0x04) as usize;
|
||||||
|
// Dump the record's u32s between name end and marker for structure context.
|
||||||
|
let rec_u32: Vec<String> = ((j + 1)..ff)
|
||||||
|
.step_by(4)
|
||||||
|
.map(|o| format!("{:08X}", be32(d, o)))
|
||||||
|
.collect();
|
||||||
|
println!("\nNODE {name} @{i:#x} ff@{ff:#x} t44={t44:#x} t48={t48:#x} child={child:#x} sib={sib:#x}");
|
||||||
|
println!(" rec: {}", rec_u32.join(" "));
|
||||||
|
if t44 != 0 && t44 < d.len() {
|
||||||
|
// Joint table: nslots pointer slots. Each slot record appears to be a
|
||||||
|
// keyframe track: a value ALSO sits 0x48 BEFORE the pointed address
|
||||||
|
// (the rest-pose key A), while the decoder currently reads ptr+8 (B).
|
||||||
|
for k in 0..nslots {
|
||||||
|
let p = be32(d, t44 + k * 4) as usize;
|
||||||
|
if p == 0 || p + 24 > d.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let a = if p >= 0x48 { be_f64(d, p - 0x48) } else { f64::NAN };
|
||||||
|
let b = be_f64(d, p + 8);
|
||||||
|
let head = if p >= 0x50 { be32(d, p - 0x50) } else { 0 };
|
||||||
|
let t_a = if p >= 0x50 { be_f64(d, p - 0x50) } else { f64::NAN };
|
||||||
|
let t_b = be_f64(d, p);
|
||||||
|
println!(
|
||||||
|
" slot{k:2} @{p:#x} head={head:08X}: A={a:12.5} (t={t_a:8.3}) B={b:12.5} (t={t_b:8.3})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = j.max(i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1403,6 +1403,13 @@ pub fn submesh_albedos(bytes: &[u8], resource_name: &str) -> Vec<(usize, usize,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Locate the descriptor byte range of the named XBG7 resource in an XPR2 file.
|
/// Locate the descriptor byte range of the named XBG7 resource in an XPR2 file.
|
||||||
|
/// Diagnostic access to a resource's raw XBG7 descriptor byte range — used by
|
||||||
|
/// the node-graph reverse-engineering examples. Not part of the decode API.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn xbg7_descriptor_range(bytes: &[u8], resource_name: &str) -> Option<(usize, usize)> {
|
||||||
|
xbg7_descriptor(bytes, resource_name)
|
||||||
|
}
|
||||||
|
|
||||||
fn xbg7_descriptor(bytes: &[u8], resource_name: &str) -> Option<(usize, usize)> {
|
fn xbg7_descriptor(bytes: &[u8], resource_name: &str) -> Option<(usize, usize)> {
|
||||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||||
return None;
|
return None;
|
||||||
@@ -1602,12 +1609,58 @@ fn rot_x(a: f32) -> M3 {
|
|||||||
let (s, c) = a.sin_cos();
|
let (s, c) = a.sin_cos();
|
||||||
[[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]
|
[[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]
|
||||||
}
|
}
|
||||||
|
/// Rotation about Y (vertical) — mixes X and Z (yaw).
|
||||||
|
fn rot_y(a: f32) -> M3 {
|
||||||
|
let (s, c) = a.sin_cos();
|
||||||
|
[[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]]
|
||||||
|
}
|
||||||
/// Rotation about Z (fore/aft) — mixes X and Y (V-tail cant / roll).
|
/// Rotation about Z (fore/aft) — mixes X and Y (V-tail cant / roll).
|
||||||
fn rot_z(a: f32) -> M3 {
|
fn rot_z(a: f32) -> M3 {
|
||||||
let (s, c) = a.sin_cos();
|
let (s, c) = a.sin_cos();
|
||||||
[[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
|
[[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a node's **9-channel TRS** `[TX TY TZ RY RX RZ SX SY SZ]` from its joint
|
||||||
|
/// table at `t44`.
|
||||||
|
///
|
||||||
|
/// The table holds **8 pointer slots**, but the node has **nine** keyframe
|
||||||
|
/// tracks (3 translation, 3 rotation, 3 scale), each a 0x50-byte record with its
|
||||||
|
/// value at `+8`. The pointers are shifted one record forward: `ptr[k]` points at
|
||||||
|
/// track `k+1`'s record, so channel `k+1`'s value is `f64 @ ptr[k]+8` and channel
|
||||||
|
/// 0 (TX!) — which no pointer names — sits one record *before* the first, at
|
||||||
|
/// `ptr[0] − 0x48`.
|
||||||
|
///
|
||||||
|
/// This off-by-one is what hid every lateral offset: the old 8-slot read took
|
||||||
|
/// TY as "X", RY as "Y" (usually 0 — why hulls stacked on the centreline) and
|
||||||
|
/// never saw TX at all (the ±264 hull pair offset). Derived 2026-07-26 from the
|
||||||
|
/// e106 F10 runtime capture and verified against the captured transforms of all
|
||||||
|
/// 8 destroyer parts (see docs/re/ship-placement-runtime-capture.md).
|
||||||
|
fn read_trs9(d: &[u8], t44: usize) -> [f64; 9] {
|
||||||
|
let mut v = [0.0f64; 9];
|
||||||
|
let p0 = be32(d, t44) as usize;
|
||||||
|
if p0 >= 0x48 && p0 + 16 <= d.len() {
|
||||||
|
v[0] = be_f64(d, p0 - 0x48);
|
||||||
|
}
|
||||||
|
for k in 0..8 {
|
||||||
|
let p = be32(d, t44 + k * 4) as usize;
|
||||||
|
if p != 0 && p + 16 <= d.len() {
|
||||||
|
v[k + 1] = be_f64(d, p + 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compose a node's local rotation from its three Euler channels
|
||||||
|
/// `(ch3, ch4, ch5) = (RY, RX, RZ)` — **`Ry·Rx·Rz`**, angles applied directly.
|
||||||
|
/// Convention pinned by the e106 engine-rig runtime capture: the nacelle node
|
||||||
|
/// stores `(RX, RZ) = (−15°, +30°)` and the captured WorldView rotation equals
|
||||||
|
/// `Rx(−15°)·Rz(30°)` exactly (decomposed element-for-element). The older saber
|
||||||
|
/// analysis script recovered the transpose of this convention; the fin override
|
||||||
|
/// [`saber_measured`] keeps those parts pinned either way.
|
||||||
|
fn node_rotation(ry: f64, rx: f64, rz: f64) -> M3 {
|
||||||
|
m3_mul(m3_mul(rot_y(ry as f32), rot_x(rx as f32)), rot_z(rz as f32))
|
||||||
|
}
|
||||||
|
|
||||||
/// The DeltaSaber fin-assembly part names — the structural signature that marks a
|
/// The DeltaSaber fin-assembly part names — the structural signature that marks a
|
||||||
/// DeltaSaber-family model (`_T`/`_W`/`_A` = f001/f002/f004; same layout, slightly
|
/// DeltaSaber-family model (`_T`/`_W`/`_A` = f001/f002/f004; same layout, slightly
|
||||||
/// different vertex counts) so the measured placements below apply to all three.
|
/// different vertex counts) so the measured placements below apply to all three.
|
||||||
@@ -1685,18 +1738,9 @@ pub fn node_transforms(bytes: &[u8], resource_name: &str) -> Vec<NodePlacement>
|
|||||||
let head_end = d.len().min(0x1684);
|
let head_end = d.len().min(0x1684);
|
||||||
let prefix = format!("rou_{resource_name}_");
|
let prefix = format!("rou_{resource_name}_");
|
||||||
|
|
||||||
// 8-value TRS from a node's `+0x44` joint table (8 pointers, each f64 @ +8);
|
// 9-channel TRS `[TX TY TZ RY RX RZ SX SY SZ]` from the node's `+0x44` joint
|
||||||
// returns identity-safe zeros when the table is absent.
|
// table (see [`read_trs9`] — the 8 pointers are shifted one track forward).
|
||||||
let read_trs = |t44: usize| -> [f64; 8] {
|
let read_trs = |t44: usize| read_trs9(d, t44);
|
||||||
let mut v = [0.0f64; 8];
|
|
||||||
for (k, slot) in v.iter_mut().enumerate() {
|
|
||||||
let p = be32(d, t44 + k * 4) as usize;
|
|
||||||
if p != 0 && p + 16 <= d.len() {
|
|
||||||
*slot = be_f64(d, p + 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v
|
|
||||||
};
|
|
||||||
|
|
||||||
// Phase 1: collect node records in document order, each with its local
|
// Phase 1: collect node records in document order, each with its local
|
||||||
// affine and the offset where its subtree ends (its next sibling).
|
// affine and the offset where its subtree ends (its next sibling).
|
||||||
@@ -1752,18 +1796,18 @@ pub fn node_transforms(bytes: &[u8], resource_name: &str) -> Vec<NodePlacement>
|
|||||||
let trs = if t44 != 0 && t44 + 32 <= d.len() {
|
let trs = if t44 != 0 && t44 + 32 <= d.len() {
|
||||||
read_trs(t44)
|
read_trs(t44)
|
||||||
} else {
|
} else {
|
||||||
[0.0; 8]
|
[0.0; 9]
|
||||||
};
|
};
|
||||||
if std::env::var("XNODEDUMP").is_ok() {
|
if std::env::var("XNODEDUMP").is_ok() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"NODE {name} vtx={vtx} t44={t44:#x} t48={t48:#x} ff@{ff:#x} sib@{sib_ptr:#x} TRS=[{:.4} {:.4} {:.4} | {:.4} {:.4} | {:.4} {:.4} {:.4}]",
|
"NODE {name} vtx={vtx} t44={t44:#x} t48={t48:#x} ff@{ff:#x} sib@{sib_ptr:#x} TRS=[{:.4} {:.4} {:.4} | {:.4} {:.4} {:.4} | {:.4} {:.4} {:.4}]",
|
||||||
trs[0], trs[1], trs[2], trs[3], trs[4], trs[5], trs[6], trs[7]
|
trs[0], trs[1], trs[2], trs[3], trs[4], trs[5], trs[6], trs[7], trs[8]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Local transform: translation (t0=X, t2=up→Y, t1=fore/aft→Z), and a
|
// Local transform: translation (TX, TY, TZ) + Euler rotation (see
|
||||||
// rotation Rz(r1)·Rx(r0) (r1 = V-tail cant about fore/aft, r0 = pitch).
|
// [`node_rotation`]).
|
||||||
let local_t = [trs[0] as f32, trs[2] as f32, trs[1] as f32];
|
let local_t = [trs[0] as f32, trs[1] as f32, trs[2] as f32];
|
||||||
let local_m = m3_mul(rot_z(trs[4] as f32), rot_x(trs[3] as f32));
|
let local_m = node_rotation(trs[3], trs[4], trs[5]);
|
||||||
// Next sibling: its name starts 4 bytes before the pointer, and there a
|
// Next sibling: its name starts 4 bytes before the pointer, and there a
|
||||||
// real node record begins with "rou_". Everything between here and there
|
// real node record begins with "rou_". Everything between here and there
|
||||||
// is this node's subtree.
|
// is this node's subtree.
|
||||||
@@ -1882,16 +1926,7 @@ pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec<ScenePart> {
|
|||||||
let d = &bytes[desc..desc_end];
|
let d = &bytes[desc..desc_end];
|
||||||
let head_end = d.len();
|
let head_end = d.len();
|
||||||
|
|
||||||
let read_trs = |t44: usize| -> [f64; 8] {
|
let read_trs = |t44: usize| read_trs9(d, t44);
|
||||||
let mut v = [0.0f64; 8];
|
|
||||||
for (k, slot) in v.iter_mut().enumerate() {
|
|
||||||
let p = be32(d, t44 + k * 4) as usize;
|
|
||||||
if p != 0 && p + 16 <= d.len() {
|
|
||||||
*slot = be_f64(d, p + 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v
|
|
||||||
};
|
|
||||||
|
|
||||||
// A node record begins with a name starting `rou_` or `GN_`.
|
// A node record begins with a name starting `rou_` or `GN_`.
|
||||||
let node_name_at = |i: usize| -> Option<(String, usize)> {
|
let node_name_at = |i: usize| -> Option<(String, usize)> {
|
||||||
@@ -1944,13 +1979,13 @@ pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec<ScenePart> {
|
|||||||
let trs = if t44 != 0 && t44 + 32 <= d.len() {
|
let trs = if t44 != 0 && t44 + 32 <= d.len() {
|
||||||
read_trs(t44)
|
read_trs(t44)
|
||||||
} else {
|
} else {
|
||||||
[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
|
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
|
||||||
};
|
};
|
||||||
let local_t = [trs[0] as f32, trs[2] as f32, trs[1] as f32];
|
let local_t = [trs[0] as f32, trs[1] as f32, trs[2] as f32];
|
||||||
let local_m = m3_mul(rot_z(trs[4] as f32), rot_x(trs[3] as f32));
|
let local_m = node_rotation(trs[3], trs[4], trs[5]);
|
||||||
// Slots 5–7 are per-axis scale (a hardpoint part ships at e.g. 0.5).
|
// Channels 6–8 are per-axis scale (a hardpoint part ships at e.g. 0.5).
|
||||||
let sc = |v: f64| if v.abs() < 1e-6 { 1.0 } else { v as f32 };
|
let sc = |v: f64| if v.abs() < 1e-6 { 1.0 } else { v as f32 };
|
||||||
let local_s = [sc(trs[5]), sc(trs[6]), sc(trs[7])];
|
let local_s = [sc(trs[6]), sc(trs[7]), sc(trs[8])];
|
||||||
// Next sibling begins 4 bytes before its pointer, at a `rou_`/`GN_` name.
|
// Next sibling begins 4 bytes before its pointer, at a `rou_`/`GN_` name.
|
||||||
let sib_end = sib_ptr.checked_sub(4).filter(|&s| {
|
let sib_end = sib_ptr.checked_sub(4).filter(|&s| {
|
||||||
s > i && s + 4 <= head_end && (&d[s..s + 4] == b"rou_" || &d[s..s + 3] == b"GN_")
|
s > i && s + 4 <= head_end && (&d[s..s + 4] == b"rou_" || &d[s..s + 3] == b"GN_")
|
||||||
|
|||||||
@@ -160,22 +160,45 @@ pub fn ships_in_container(bytes: &[u8]) -> Vec<ShipModel> {
|
|||||||
/// nothing and only pose their children.
|
/// nothing and only pose their children.
|
||||||
fn resolve_hull_resource(node: &str, resources: &HashSet<String>) -> Option<String> {
|
fn resolve_hull_resource(node: &str, resources: &HashSet<String>) -> Option<String> {
|
||||||
let stem = node.rsplit_once("rou_").map(|(_, s)| s).unwrap_or(node);
|
let stem = node.rsplit_once("rou_").map(|(_, s)| s).unwrap_or(node);
|
||||||
if resources.contains(stem) {
|
let try_stem = |s: &str| -> Option<String> {
|
||||||
return Some(stem.to_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"] {
|
for suf in ["_root", "_mov"] {
|
||||||
if let Some(s) = stem.strip_suffix(suf) {
|
if let Some(s) = stem.strip_suffix(suf) {
|
||||||
if resources.contains(s) {
|
if let Some(r) = try_stem(s) {
|
||||||
return Some(s.to_string());
|
return Some(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Trailing `_NN` instance index (`eng_01_1` → `eng_01`).
|
// Trailing `_NN` instance index (`eng_01_1` → `eng_01`).
|
||||||
if let Some(pos) = stem.rfind('_') {
|
if let Some(pos) = stem.rfind('_') {
|
||||||
if !stem[pos + 1..].is_empty() && stem[pos + 1..].bytes().all(|c| c.is_ascii_digit()) {
|
if !stem[pos + 1..].is_empty() && stem[pos + 1..].bytes().all(|c| c.is_ascii_digit()) {
|
||||||
let s = &stem[..pos];
|
if let Some(r) = try_stem(&stem[..pos]) {
|
||||||
if resources.contains(s) {
|
return Some(r);
|
||||||
return Some(s.to_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,31 +233,31 @@ fn composites_for<'a>(names: &'a [String], id: &str) -> Vec<&'a String> {
|
|||||||
/// Reconstruct a whole ship as a list of geometry-resource placements in a shared
|
/// Reconstruct a whole ship as a list of geometry-resource placements in a shared
|
||||||
/// ship-local frame — the placement the game builds at runtime.
|
/// ship-local frame — the placement the game builds at runtime.
|
||||||
///
|
///
|
||||||
/// Two tiers, matching how the game splits a ship:
|
/// Fully static and EXACT (validated part-for-part against an e106 runtime
|
||||||
/// 1. **Hull** — `rou_<id>_*` nodes in the primary composite scene graph name the
|
/// capture — see `docs/re/ship-placement-runtime-capture.md`):
|
||||||
/// hull body resources and carry their world transform. This tier is exact: the
|
/// 1. **Composite nodes** — every `rou_*` node in the primary composite
|
||||||
/// bodies (and any engine posed by a `rou_` node) land where the game puts them.
|
/// (`e_rou_<id>`) places a geometry resource, INCLUDING repeated instances
|
||||||
/// 2. **External hardpoints** — `GN_*` frame nodes are the Vessel mounts; each
|
/// and cross-id turret mounts (`rou_e303_wep_01_root` ×2 on the e106 hull).
|
||||||
/// unplaced bridge / shield-generator / engine part (`<id>_brg_*`, `_sld_*`,
|
/// 2. **Detail rigs** — `e_rou_<id>_eng` is the engine cluster rig; its nodes
|
||||||
/// `_eng_*`) is placed at its matching frame by category + index. This tier is
|
/// (e.g. two `eng_01` nacelle instances + `eng_02`) mount at the primary
|
||||||
/// APPROXIMATE: a `GN_*` frame is the mount *pivot* (often on the centreline),
|
/// composite's `GN_Engine_01` frame: `world = frame ∘ rig_local`.
|
||||||
/// while the part's outboard/rotational offset lives in a detail sub-rig or in
|
/// 3. **GN hardpoints** — remaining unplaced `brg`/`sld` parts sit exactly at
|
||||||
/// runtime code that this static pass does not recover — so external parts land
|
/// their `GN_Bridge_NN` / `GN_ShieldG_NN` frame (frames carry full TRS).
|
||||||
/// near, not exactly, where they belong. Gated by `include_external`.
|
/// 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).
|
||||||
///
|
///
|
||||||
/// Turret placement (shared `e3NN`/`e4NN` gun models mounted at many `GN_*Gun*`
|
/// `include_external` keeps tiers 2–4 (off = bare composite-node hull).
|
||||||
/// 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<ScenePart> {
|
pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<ScenePart> {
|
||||||
let names = xbg7_resource_names(bytes);
|
let names = xbg7_resource_names(bytes);
|
||||||
let resources: HashSet<String> = names.iter().cloned().collect();
|
let resources: HashSet<String> = names.iter().cloned().collect();
|
||||||
|
|
||||||
// The PRIMARY composite carries the real ship-space layout (hull bodies +
|
// The PRIMARY composite carries the ship-space layout (hull bodies, turret
|
||||||
// every `GN_*` hardpoint frame). Sibling composites (`e_rou_<id>_eng`,
|
// mounts + every `GN_*` hardpoint frame): the one with the most nodes.
|
||||||
// `_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<ScenePart>)> = composites_for(&names, id)
|
let scenes: Vec<(String, Vec<ScenePart>)> = composites_for(&names, id)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|c| !c.ends_with("_joint"))
|
||||||
.map(|c| (c.clone(), scene_world_nodes(bytes, c)))
|
.map(|c| (c.clone(), scene_world_nodes(bytes, c)))
|
||||||
.collect();
|
.collect();
|
||||||
let Some((_, nodes)) = scenes.iter().max_by_key(|(_, n)| n.len()) else {
|
let Some((_, nodes)) = scenes.iter().max_by_key(|(_, n)| n.len()) else {
|
||||||
@@ -252,24 +275,54 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
|
|||||||
// GN hardpoint frames, world-space, from the primary composite.
|
// GN hardpoint frames, world-space, from the primary composite.
|
||||||
let mut frames: Vec<ScenePart> = Vec::new();
|
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 {
|
for node in nodes {
|
||||||
if node.resource.starts_with("GN_") {
|
if node.resource.starts_with("GN_") {
|
||||||
frames.push(node.clone());
|
frames.push(node.clone());
|
||||||
} else if let Some(res) = resolve_hull_resource(&node.resource, &resources) {
|
} else if let Some(res) = resolve_hull_resource(&node.resource, &resources) {
|
||||||
// Only the ship's own parts. A composite also mounts shared, cross-id
|
if ship_id_of(&res) != Some(id) && !include_external {
|
||||||
// turret models (`e303_wep_*` on an `e106` hull); those need the
|
continue; // hull-only view: own parts only
|
||||||
// per-mount Vessel recipe and are placed separately.
|
|
||||||
if ship_id_of(&res) != Some(id) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if placed_res.insert(res.clone()) {
|
// 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 });
|
placed.push(ScenePart { resource: res, m: node.m, t: node.t, s: node.s });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place unplaced external parts at their Vessel hardpoint frame (APPROXIMATE —
|
// Tier 2: the engine cluster rig, mounted at GN_Engine_01. The rig's nodes
|
||||||
// see the doc comment). Skipped for a clean, exact hull-only render.
|
// 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")];
|
const CATS: [(&str, &str); 3] = [("brg", "Bridge"), ("sld", "ShieldG"), ("eng", "Engine")];
|
||||||
for part in names
|
for part in names
|
||||||
.iter()
|
.iter()
|
||||||
@@ -294,6 +347,13 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Fallback for a ship with no composite scene graph (a simple prop): draw its
|
||||||
// base parts untransformed rather than nothing.
|
// base parts untransformed rather than nothing.
|
||||||
if placed.is_empty() {
|
if placed.is_empty() {
|
||||||
@@ -306,6 +366,89 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
|
|||||||
placed
|
placed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -363,6 +506,85 @@ mod tests {
|
|||||||
assert!(e108.parts.iter().all(|p| is_base_part(p)));
|
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
|
// 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
|
// 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).
|
// clearly aft of its forward hull body (a real ship layout, not a pile).
|
||||||
|
|||||||
@@ -3952,15 +3952,11 @@ fn build_ship_model(
|
|||||||
};
|
};
|
||||||
let should_cancel = || cancel.load(Ordering::Relaxed);
|
let should_cancel = || cancel.load(Ordering::Relaxed);
|
||||||
|
|
||||||
// Placement: prefer a runtime-captured ground-truth table (exact, baked from
|
// Placement: fully static scene-graph assembly — exact, validated
|
||||||
// an F10 ship capture) when this ship has one; otherwise fall back to the
|
// part-for-part (translations + rotations + instances + mirror) against the
|
||||||
// static scene-graph assembler (hull exact, external parts approximate — the
|
// e106 runtime capture (`ship::tests::static_assembly_matches_runtime_capture`).
|
||||||
// `external` toggle). A captured table already includes every captured part,
|
// The captured table in ship_capture stays as the verification oracle only.
|
||||||
// so the toggle doesn't apply to it.
|
let placed = sylpheed_formats::ship::assemble_ship(&bytes, id, external);
|
||||||
let placed = match sylpheed_formats::ship_capture::embedded_placement(id) {
|
|
||||||
Some(cap) => sylpheed_formats::ship_capture::to_scene_parts(&cap),
|
|
||||||
None => sylpheed_formats::ship::assemble_ship(&bytes, id, external),
|
|
||||||
};
|
|
||||||
if placed.is_empty() {
|
if placed.is_empty() {
|
||||||
return PreparedXpr::Info(format!("No parts found for {label}."));
|
return PreparedXpr::Info(format!("No parts found for {label}."));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
# Capital-ship part placement — runtime capture (ground truth)
|
# Capital-ship part placement — runtime capture (ground truth)
|
||||||
|
|
||||||
**Status:** ✅ **`e106` ADAN Destroyer FULLY BAKED (2026-07-26)** — all 8 parts
|
**Status:** ✅✅ **STATIC ASSEMBLY IS EXACT — no captures needed anymore
|
||||||
(incl. the bridge both earlier captures missed, and the runtime-mirrored
|
(2026-07-26).** The capture became the oracle that cracked the static encoding:
|
||||||
starboard hull) recovered from a second F10 capture and checked in to
|
the node joint tables are **9 keyframe channels `[TX TY TZ RY RX RZ SX SY SZ]`
|
||||||
`data/ship_placements.txt`; the viewer renders it via the captured table.
|
with the 8 table pointers shifted one track forward** (channel 0 = TX sits at
|
||||||
Remaining ships (e105, f101, f105/f106, turrets) need one capture each.
|
`ptr[0]−0x48`; the old 8-slot read took TY as X, never saw TX — why hulls
|
||||||
|
stacked on the centreline). Euler order `Ry·Rx·Rz`, angles direct
|
||||||
|
(`mesh.rs::read_trs9` / `node_rotation`). `assemble_ship` now places every
|
||||||
|
composite-node instance (cross-id turrets ×2), mounts the `e_rou_<id>_eng`
|
||||||
|
cluster rig at `GN_Engine_01` (two mirrored nacelles + centre engine), puts
|
||||||
|
`brg`/`sld` at their exact GN frames, and X-reflects the shared-geometry twin
|
||||||
|
whose lateral offset opposes the geometry's dominant side.
|
||||||
|
`ship::tests::static_assembly_matches_runtime_capture` asserts static ==
|
||||||
|
captured for all 8 e106 parts (T < 1.0, R < 0.02) + both nacelles + both
|
||||||
|
turrets + the hull mirror. The viewer uses pure static assembly; the baked
|
||||||
|
table + correlator remain as the verification oracle only.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user