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:
MechaCat02
2026-07-26 19:00:49 +02:00
parent 015e49ac8a
commit 737b61242e
5 changed files with 433 additions and 81 deletions

View File

@@ -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.
/// 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)> {
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return None;
@@ -1602,12 +1609,58 @@ fn rot_x(a: f32) -> M3 {
let (s, c) = a.sin_cos();
[[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).
fn rot_z(a: f32) -> M3 {
let (s, c) = a.sin_cos();
[[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
/// DeltaSaber-family model (`_T`/`_W`/`_A` = f001/f002/f004; same layout, slightly
/// 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 prefix = format!("rou_{resource_name}_");
// 8-value TRS from a node's `+0x44` joint table (8 pointers, each f64 @ +8);
// returns identity-safe zeros when the table is absent.
let read_trs = |t44: usize| -> [f64; 8] {
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
};
// 9-channel TRS `[TX TY TZ RY RX RZ SX SY SZ]` from the node's `+0x44` joint
// table (see [`read_trs9`] — the 8 pointers are shifted one track forward).
let read_trs = |t44: usize| read_trs9(d, t44);
// Phase 1: collect node records in document order, each with its local
// 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() {
read_trs(t44)
} else {
[0.0; 8]
[0.0; 9]
};
if std::env::var("XNODEDUMP").is_ok() {
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}]",
trs[0], trs[1], trs[2], trs[3], trs[4], trs[5], trs[6], trs[7]
"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[8]
);
}
// Local transform: translation (t0=X, t2=up→Y, t1=fore/aft→Z), and a
// rotation Rz(r1)·Rx(r0) (r1 = V-tail cant about fore/aft, r0 = pitch).
let local_t = [trs[0] as f32, trs[2] as f32, trs[1] as f32];
let local_m = m3_mul(rot_z(trs[4] as f32), rot_x(trs[3] as f32));
// Local transform: translation (TX, TY, TZ) + Euler rotation (see
// [`node_rotation`]).
let local_t = [trs[0] as f32, trs[1] as f32, trs[2] 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
// real node record begins with "rou_". Everything between here and there
// 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 head_end = d.len();
let read_trs = |t44: usize| -> [f64; 8] {
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
};
let read_trs = |t44: usize| read_trs9(d, t44);
// A node record begins with a name starting `rou_` or `GN_`.
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() {
read_trs(t44)
} 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_m = m3_mul(rot_z(trs[4] as f32), rot_x(trs[3] as f32));
// Slots 57 are per-axis scale (a hardpoint part ships at e.g. 0.5).
let local_t = [trs[0] as f32, trs[1] as f32, trs[2] as f32];
let local_m = node_rotation(trs[3], trs[4], trs[5]);
// Channels 68 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 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.
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_")