From ea32e77e455c0658e614c9cf3b8747446c0b893c Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 26 Jul 2026 19:44:12 +0200 Subject: [PATCH] ship: engine placement CONFIRMED by 43-instance capture; exhaust markers added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capture_verify example: clusters every part's ship-relative transform across the new multi-snapshot, multi-instance F10 captures (5 snapshots, 43 e106 instances, all angles). Verdict: the dominant clusters match the static assembly to ~1 unit on EVERY part — including BOTH engine nacelles at (+-131, -133, -131) — so the "engines inside the hull" appearance is the game's own placement: the engine geometry sits recessed in the aft hull, and the visible "thrusters" in-game are exhaust FX drawn at the GN_Jet/GN_SJet frames (Z ~ -570, past the stern). To close that perception gap the viewer now draws simple exhaust cones at the game's own jet frames (ship::exhaust_frames; part of the external-parts toggle). The jet frames flip Z, so the cone apex is authored at +Z and lands trailing aft — verified in the offline render. Co-Authored-By: Claude Fable 5 --- .../examples/capture_verify.rs | 192 ++++++++++++++++++ .../sylpheed-formats/examples/ship_render.rs | 16 ++ crates/sylpheed-formats/src/ship.rs | 23 +++ crates/sylpheed-viewer/src/iso_loader.rs | 54 +++++ 4 files changed, 285 insertions(+) create mode 100644 crates/sylpheed-formats/examples/capture_verify.rs diff --git a/crates/sylpheed-formats/examples/capture_verify.rs b/crates/sylpheed-formats/examples/capture_verify.rs new file mode 100644 index 0000000..6638d77 --- /dev/null +++ b/crates/sylpheed-formats/examples/capture_verify.rs @@ -0,0 +1,192 @@ +//! Verify static ship assembly against multi-snapshot, multi-instance F10 +//! captures: cluster every part's ship-relative transform across ALL ships in +//! ALL snapshots, and compare the dominant clusters with `assemble_ship`. +//! +//! cargo run --release --example capture_verify -- [log…] +//! +//! Every draw is position-validated against the ship's parts (any LOD, set +//! based). Each `bdy_04`(-LOD) draw acts as a ship reference; every validated +//! part draw within ship range contributes `rel = WV_ref⁻¹ ∘ WV_part`. The true +//! per-part placements appear as tight clusters repeated once PER SHIP per +//! snapshot; cross-ship pairings scatter and stay below the cluster threshold. +use std::collections::HashSet; +use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model}; +use sylpheed_formats::ship::{assemble_ship, is_base_part, ship_id_of}; +use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw}; + +type M3 = [[f64; 3]; 3]; + +fn transpose(m: &M3) -> M3 { + [ + [m[0][0], m[1][0], m[2][0]], + [m[0][1], m[1][1], m[2][1]], + [m[0][2], m[1][2], m[2][2]], + ] +} +fn mmul(a: &M3, b: &M3) -> M3 { + let mut o = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + o +} +fn mv(m: &M3, v: [f64; 3]) -> [f64; 3] { + [ + m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], + m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], + m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], + ] +} + +fn main() { + let a: Vec = std::env::args().collect(); + let bytes = std::fs::read(&a[1]).unwrap(); + let id = &a[2]; + + // Part position sets (base + LODs share the local frame) + per-variant vcounts. + let names = xbg7_resource_names(&bytes); + let base_parts: Vec = names + .iter() + .filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str())) + .cloned() + .collect(); + let mut want: HashSet = base_parts.iter().cloned().collect(); + for p in &base_parts { + for suf in ["_m", "_l", "_d"] { + let c = format!("{p}{suf}"); + if names.contains(&c) { + want.insert(c); + } + } + } + let models = Xbg7Model::models_named(&bytes, &want, &|| false); + struct Part { + base: String, + vcounts: Vec, + pos: Vec<[f32; 3]>, // union of variants + } + let mut parts: Vec = Vec::new(); + for p in &base_parts { + let mut vcounts = Vec::new(); + let mut pos = Vec::new(); + for cand in [p.clone(), format!("{p}_m"), format!("{p}_l"), format!("{p}_d")] { + if let Some(m) = models.iter().find(|m| m.name == cand) { + let mp: Vec<[f32; 3]> = + m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect(); + vcounts.push(mp.len() as u32); + pos.extend(mp); + } + } + parts.push(Part { base: p.clone(), vcounts, pos }); + } + + // Validate a draw against a part (direct or X-mirrored), like ship_capture. + let validate = |d: &CapturedDraw, p: &Part| -> Option { + if !p.vcounts.contains(&d.vcount) || d.pos.is_empty() { + return None; + } + let near = |a: &[f32; 3], b: &[f32; 3]| { + (a[0] - b[0]).abs() <= 1e-2 && (a[1] - b[1]).abs() <= 1e-2 && (a[2] - b[2]).abs() <= 1e-2 + }; + let (mut direct, mut mirror) = (0usize, 0usize); + for q in &d.pos { + if p.pos.iter().any(|v| near(q, v)) { + direct += 1; + } + let qm = [-q[0], q[1], q[2]]; + if p.pos.iter().any(|v| near(&qm, v)) { + mirror += 1; + } + } + let need = d.pos.len().div_ceil(2); + if direct >= need && direct >= mirror { + Some(false) + } else if mirror >= need { + Some(true) + } else { + None + } + }; + + // Collect rel samples per part across all logs. + let mut samples: std::collections::BTreeMap> = Default::default(); + for log in &a[3..] { + let text = std::fs::read_to_string(log).unwrap(); + let draws = parse_capture(&text); + // label validated draws + let mut labeled: Vec<(usize, usize, bool)> = Vec::new(); // (draw, part, mirrored) + for (di, d) in draws.iter().enumerate() { + for (pi, p) in parts.iter().enumerate() { + if let Some(mir) = validate(d, p) { + labeled.push((di, pi, mir)); + break; + } + } + } + let refs: Vec = labeled + .iter() + .filter(|(_, pi, _)| parts[*pi].base.ends_with("bdy_04")) + .map(|(di, ..)| *di) + .collect(); + for &ri in &refs { + let rf = &draws[ri]; + let rt = transpose(&rf.r); + for &(di, pi, _mir) in &labeled { + let d = &draws[di]; + let dt = [d.t[0] - rf.t[0], d.t[1] - rf.t[1], d.t[2] - rf.t[2]]; + let rel = mv(&rt, dt); + if rel.iter().map(|v| v * v).sum::().sqrt() < 2600.0 { + // also require the rel rotation be finite-sane + let _rm = mmul(&rt, &d.r); + samples.entry(parts[pi].base.clone()).or_default().push(rel); + } + } + } + eprintln!("{log}: {} validated draws, {} bdy_04 refs", labeled.len(), refs.len()); + } + + // Cluster per part (greedy, 8-unit radius), print clusters with ≥3 samples. + println!("\n== dominant ship-relative placements (clusters ≥3 samples) =="); + for (part, mut v) in samples { + v.sort_by(|x, y| x[0].partial_cmp(&y[0]).unwrap()); + let mut clusters: Vec<([f64; 3], usize)> = Vec::new(); + for s in &v { + match clusters.iter_mut().find(|(c, _)| { + (c[0] - s[0]).abs() < 8.0 && (c[1] - s[1]).abs() < 8.0 && (c[2] - s[2]).abs() < 8.0 + }) { + Some((c, n)) => { + for k in 0..3 { + c[k] = (c[k] * *n as f64 + s[k]) / (*n as f64 + 1.0); + } + *n += 1; + } + None => clusters.push((*s, 1)), + } + } + clusters.sort_by(|x, y| y.1.cmp(&x.1)); + let tops: Vec = clusters + .iter() + .filter(|(_, n)| *n >= 3) + .take(6) + .map(|(c, n)| format!("[{:7.1} {:7.1} {:7.1}]×{n}", c[0], c[1], c[2])) + .collect(); + println!(" {part:20} {}", tops.join(" ")); + } + + // Static assembly rel bdy_04 for comparison. + println!("\n== static assemble_ship rel bdy_04 =="); + let placed = assemble_ship(&bytes, id, true); + if let Some(r4) = placed.iter().find(|p| p.resource == format!("{id}_bdy_04")) { + for p in &placed { + println!( + " {:20} [{:7.1} {:7.1} {:7.1}]", + p.resource, + p.t[0] - r4.t[0], + p.t[1] - r4.t[1], + p.t[2] - r4.t[2] + ); + } + } +} diff --git a/crates/sylpheed-formats/examples/ship_render.rs b/crates/sylpheed-formats/examples/ship_render.rs index 6b8e4cd..36218a2 100644 --- a/crates/sylpheed-formats/examples/ship_render.rs +++ b/crates/sylpheed-formats/examples/ship_render.rs @@ -45,6 +45,22 @@ fn main() { p.resource, lo[0], hi[0], lo[1], hi[1], lo[2], hi[2] ); } + // Exhaust cones at the GN_Jet/GN_SJet frames (the game's visible thrusters). + for f in sylpheed_formats::ship::exhaust_frames(&bytes, id) { + const SEG: usize = 12; + let (r, len) = (22.0f32, 140.0f32); + let ring: Vec<[f32; 3]> = (0..SEG) + .map(|s| { + let a = s as f32 / SEG as f32 * std::f32::consts::TAU; + f.apply([a.cos() * r, a.sin() * r, 0.0]) + }) + .collect(); + let apex = f.apply([0.0, 0.0, len]); + for s in 0..SEG { + tris.push([apex, ring[(s + 1) % SEG], ring[s]]); + } + println!(" exhaust frame {:16} T=[{:8.1}{:8.1}{:8.1}]", f.resource, f.t[0], f.t[1], f.t[2]); + } println!("total {} tris from {} placements", tris.len(), placed.len()); // Orthographic z-buffered flat renders: top (X/Z, look down −Y) and side (Z/Y, look down −X). diff --git a/crates/sylpheed-formats/src/ship.rs b/crates/sylpheed-formats/src/ship.rs index 80e632e..a3653c7 100644 --- a/crates/sylpheed-formats/src/ship.rs +++ b/crates/sylpheed-formats/src/ship.rs @@ -376,6 +376,29 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec Vec { + let names = xbg7_resource_names(bytes); + let scenes: Vec> = composites_for(&names, id) + .into_iter() + .filter(|c| !c.ends_with("_joint")) + .map(|c| scene_world_nodes(bytes, c)) + .collect(); + let Some(nodes) = scenes.into_iter().max_by_key(|n| n.len()) else { + return Vec::new(); + }; + nodes + .into_iter() + .filter(|n| n.resource.starts_with("GN_Jet") || n.resource.starts_with("GN_SJet")) + .collect() +} + /// 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 `_01`/`_02` pairs with equal vertex data; the reflected diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index 921fb02..62fc99a 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -3646,6 +3646,40 @@ fn handle_voice_request( }); } +/// A simple exhaust-plume cone: base ring at the frame origin opening toward +/// **−Z** (the jet frames face aft), apex trailing behind. Stands in for the +/// game's engine-exhaust FX so assembled ships visually read as powered. +#[cfg(not(target_arch = "wasm32"))] +fn exhaust_cone_mesh() -> sylpheed_formats::mesh::GameMesh { + const SEG: usize = 12; + const R: f32 = 22.0; + const LEN: f32 = 140.0; + let mut positions = Vec::with_capacity(SEG + 2); + let mut normals = Vec::with_capacity(SEG + 2); + positions.push([0.0, 0.0, LEN]); // apex — the jet frame flips Z, sending it aft + normals.push([0.0, 0.0, -1.0]); + for s in 0..SEG { + let a = s as f32 / SEG as f32 * std::f32::consts::TAU; + positions.push([a.cos() * R, a.sin() * R, 0.0]); + normals.push([a.cos(), a.sin(), 0.3]); + } + positions.push([0.0, 0.0, 0.0]); // base centre (cap) + normals.push([0.0, 0.0, 1.0]); + let mut indices = Vec::new(); + for s in 0..SEG as u32 { + let (a, b) = (1 + s, 1 + (s + 1) % SEG as u32); + indices.extend_from_slice(&[0, b, a]); // side + indices.extend_from_slice(&[(SEG + 1) as u32, a, b]); // cap + } + sylpheed_formats::mesh::GameMesh { + positions, + normals, + uvs: Vec::new(), + indices, + name: Some("exhaust".to_string()), + } +} + /// Duration (seconds) of a PCM WAV from its `fmt`/`data` chunks. #[cfg(not(target_arch = "wasm32"))] fn wav_duration(path: &Path) -> Option { @@ -4033,6 +4067,26 @@ fn build_ship_model( return PreparedXpr::Info(format!("No geometry decoded for {label}.")); } + // Exhaust markers: the game's visible "thrusters" are FX drawn at the + // GN_Jet/GN_SJet frames (the engine GEOMETRY is recessed in the hull — + // capture-verified). Draw a simple cone at each frame so the assembled ship + // reads correctly. + if external { + for (i, f) in sylpheed_formats::ship::exhaust_frames(&bytes, id).iter().enumerate() { + let mut cone = exhaust_cone_mesh(); + for v in &mut cone.positions { + *v = f.apply(*v); + } + for nrm in &mut cone.normals { + *nrm = rot(&f.m, nrm); + } + models.push(Xbg7Model { + name: format!("__exhaust_{i}"), + meshes: vec![cone], + }); + } + } + // Assemble, then relabel with the ship's display name. match prepare_ship(&bytes, &models) { PreparedXpr::Model {