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:
@@ -160,22 +160,45 @@ pub fn ships_in_container(bytes: &[u8]) -> Vec<ShipModel> {
|
||||
/// 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);
|
||||
if resources.contains(stem) {
|
||||
return Some(stem.to_string());
|
||||
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 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_<id>_*` 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 (`<id>_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_<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).
|
||||
///
|
||||
/// 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<ScenePart> {
|
||||
let names = xbg7_resource_names(bytes);
|
||||
let resources: HashSet<String> = names.iter().cloned().collect();
|
||||
|
||||
// The PRIMARY composite carries the real ship-space layout (hull bodies +
|
||||
// every `GN_*` hardpoint frame). Sibling composites (`e_rou_<id>_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<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 {
|
||||
@@ -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.
|
||||
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) {
|
||||
// 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<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
|
||||
// base parts untransformed rather than nothing.
|
||||
if placed.is_empty() {
|
||||
@@ -306,6 +366,89 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
|
||||
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)]
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user