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:
@@ -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 5–7 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 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 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_")
|
||||
|
||||
Reference in New Issue
Block a user