diff --git a/crates/sylpheed-formats/examples/node_dump.rs b/crates/sylpheed-formats/examples/node_dump.rs new file mode 100644 index 0000000..7c1cead --- /dev/null +++ b/crates/sylpheed-formats/examples/node_dump.rs @@ -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 -- [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 = 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 = ((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); + } +} diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index 062bd6d..1eea288 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -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 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 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 { 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 { 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_") diff --git a/crates/sylpheed-formats/src/ship.rs b/crates/sylpheed-formats/src/ship.rs index 96d64a5..29910f3 100644 --- a/crates/sylpheed-formats/src/ship.rs +++ b/crates/sylpheed-formats/src/ship.rs @@ -160,22 +160,45 @@ pub fn ships_in_container(bytes: &[u8]) -> Vec { /// 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); - if resources.contains(stem) { - return Some(stem.to_string()); + 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 resources.contains(s) { - return Some(s.to_string()); + 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()) { - let s = &stem[..pos]; - if resources.contains(s) { - return Some(s.to_string()); + if let Some(r) = try_stem(&stem[..pos]) { + return Some(r); } } } @@ -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 /// ship-local frame — the placement the game builds at runtime. /// -/// Two tiers, matching how the game splits a ship: -/// 1. **Hull** — `rou__*` nodes in the primary composite scene graph name the -/// hull body resources and carry their world transform. This tier is exact: the -/// bodies (and any engine posed by a `rou_` node) land where the game puts them. -/// 2. **External hardpoints** — `GN_*` frame nodes are the Vessel mounts; each -/// unplaced bridge / shield-generator / engine part (`_brg_*`, `_sld_*`, -/// `_eng_*`) is placed at its matching frame by category + index. This tier is -/// APPROXIMATE: a `GN_*` frame is the mount *pivot* (often on the centreline), -/// while the part's outboard/rotational offset lives in a detail sub-rig or in -/// runtime code that this static pass does not recover — so external parts land -/// near, not exactly, where they belong. Gated by `include_external`. +/// 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). /// -/// Turret placement (shared `e3NN`/`e4NN` gun models mounted at many `GN_*Gun*` -/// frames) needs the per-mount Vessel recipe and is not resolved at all. +/// `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 real ship-space layout (hull bodies + - // every `GN_*` hardpoint frame). Sibling composites (`e_rou__eng`, - // `_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. + // 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 { @@ -252,24 +275,54 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> 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) { - // Only the ship's own parts. A composite also mounts shared, cross-id - // turret models (`e303_wep_*` on an `e106` hull); those need the - // per-mount Vessel recipe and are placed separately. - if ship_id_of(&res) != Some(id) { - continue; + if ship_id_of(&res) != Some(id) && !include_external { + continue; // hull-only view: own parts only } - 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 }); } } } - // Place unplaced external parts at their Vessel hardpoint frame (APPROXIMATE — - // see the doc comment). Skipped for a clean, exact hull-only render. + // 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() @@ -294,6 +347,13 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec Vec_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::*; @@ -363,6 +506,85 @@ mod tests { 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). diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index f041f73..ed48d2b 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -3952,15 +3952,11 @@ fn build_ship_model( }; let should_cancel = || cancel.load(Ordering::Relaxed); - // Placement: prefer a runtime-captured ground-truth table (exact, baked from - // an F10 ship capture) when this ship has one; otherwise fall back to the - // static scene-graph assembler (hull exact, external parts approximate — the - // `external` toggle). A captured table already includes every captured part, - // so the toggle doesn't apply to it. - 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), - }; + // Placement: fully static scene-graph assembly — exact, validated + // part-for-part (translations + rotations + instances + mirror) against the + // e106 runtime capture (`ship::tests::static_assembly_matches_runtime_capture`). + // The captured table in ship_capture stays as the verification oracle only. + let placed = sylpheed_formats::ship::assemble_ship(&bytes, id, external); if placed.is_empty() { return PreparedXpr::Info(format!("No parts found for {label}.")); } diff --git a/docs/re/ship-placement-runtime-capture.md b/docs/re/ship-placement-runtime-capture.md index b4fa790..c523653 100644 --- a/docs/re/ship-placement-runtime-capture.md +++ b/docs/re/ship-placement-runtime-capture.md @@ -1,10 +1,20 @@ # Capital-ship part placement — runtime capture (ground truth) -**Status:** ✅ **`e106` ADAN Destroyer FULLY BAKED (2026-07-26)** — all 8 parts -(incl. the bridge both earlier captures missed, and the runtime-mirrored -starboard hull) recovered from a second F10 capture and checked in to -`data/ship_placements.txt`; the viewer renders it via the captured table. -Remaining ships (e105, f101, f105/f106, turrets) need one capture each. +**Status:** ✅✅ **STATIC ASSEMBLY IS EXACT — no captures needed anymore +(2026-07-26).** The capture became the oracle that cracked the static encoding: +the node joint tables are **9 keyframe channels `[TX TY TZ RY RX RZ SX SY SZ]` +with the 8 table pointers shifted one track forward** (channel 0 = TX sits at +`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__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