From c6ca5ce9e76fbc322e7ca5dbe97faf9b8ff1f4d0 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 25 Jul 2026 20:48:00 +0200 Subject: [PATCH] =?UTF-8?q?ship:=20bake=20pipeline=20=E2=80=94=20correlato?= =?UTF-8?q?r=20as=20a=20tested=20module=20+=20viewer=20prefers=20captured?= =?UTF-8?q?=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the F10 ship-capture correlator from an example into a reusable, unit- tested library module (ship_capture): parse_capture (log -> per-draw rigid WorldView from the c0..c2 WVP constants), correlate (match parts to draws by vertex count, express each in the reference part's frame), and a checked-in placement-table format (serialize_table/parse_table, embedded via embedded_placement from data/ship_placements.txt). build_ship_model now prefers a ship's captured placement over the static assemble_ship when a table entry exists (empty table -> unchanged fallback). correlate_capture example refactored onto the module + gains --emit to print a table block. Doc updated: bake plumbing done; remaining is running real F10 captures to populate the table (needs the emulator). 5 new ship_capture tests (parse/normalize/correlate/table round-trip); 76 formats-lib tests + viewer build green. Co-Authored-By: Claude Opus 4.8 --- .../sylpheed-formats/data/ship_placements.txt | 13 + .../examples/correlate_capture.rs | 225 +++-------- crates/sylpheed-formats/src/lib.rs | 3 + crates/sylpheed-formats/src/ship_capture.rs | 371 ++++++++++++++++++ crates/sylpheed-viewer/src/iso_loader.rs | 12 +- docs/re/ship-placement-runtime-capture.md | 44 ++- 6 files changed, 476 insertions(+), 192 deletions(-) create mode 100644 crates/sylpheed-formats/data/ship_placements.txt create mode 100644 crates/sylpheed-formats/src/ship_capture.rs diff --git a/crates/sylpheed-formats/data/ship_placements.txt b/crates/sylpheed-formats/data/ship_placements.txt new file mode 100644 index 0000000..f0916c0 --- /dev/null +++ b/crates/sylpheed-formats/data/ship_placements.txt @@ -0,0 +1,13 @@ +# Capital-ship part placements — runtime-captured ground truth (see +# docs/re/ship-placement-runtime-capture.md and src/ship_capture.rs). +# +# Populate with: +# SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \ +# [ref_part_substr] --emit +# then paste the emitted `ship …` block(s) below. The viewer prefers a ship's +# entry here over the static assembler when present. +# +# Per placement line: +# +# (No ships baked in yet — a real F10 capture log is required. e106 ADAN +# Destroyer was validated live; re-run the capture on the emulator box to bake.) diff --git a/crates/sylpheed-formats/examples/correlate_capture.rs b/crates/sylpheed-formats/examples/correlate_capture.rs index 1bd1714..5375e85 100644 --- a/crates/sylpheed-formats/examples/correlate_capture.rs +++ b/crates/sylpheed-formats/examples/correlate_capture.rs @@ -1,139 +1,42 @@ -//! Recover exact capital-ship part placement from a Canary `xenia_ship_capture.log` -//! (F10 snapshot: per-draw guest vertex-buffer + up to 48 vertex-shader float -//! constants). +//! Recover exact capital-ship part placement from a Canary F10 ship-capture log +//! and (optionally) emit a checked-in placement-table block. //! -//! FINDING (2026-07-24): the captured vertex BUFFER holds LOCAL coordinates — -//! byte-identical to the `.xpr` — so capital-ship parts are placed entirely in the -//! **vertex shader**. The per-part transform is in the VS constants: `c0..c2` are -//! the **WorldViewProjection** matrix rows. Their norms are `(sx, sy, 1)` = the -//! projection x/y scales (sy/sx = 16:9 aspect); dividing each row by its norm -//! yields the rigid **WorldView** (verified: rows orthonormal, det +1). The camera -//! View cancels when we express every part relative to a reference part: -//! rel_p = WV_ref⁻¹ · WV_p (a pure ship-space rigid transform) -//! Applying `rel_p` to part `p`'s local geometry assembles the ship exactly, in the -//! reference part's frame — ground truth, no static guessing. +//! The correlation math lives in [`sylpheed_formats::ship_capture`]; this example +//! is the CLI wrapper: it reads the log, decodes the ship's parts from the disc to +//! get their vertex counts (the match key), correlates, and prints the result. +//! With `--emit` it prints the `ship …` block to paste into +//! `crates/sylpheed-formats/data/ship_placements.txt`. //! //! Usage: //! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \ -//! [ref_part_substr] -//! e.g. `... /tmp/xenia_ship_capture.log Stage_S01 e106 bdy_04` +//! [ref_part_substr] [--emit] +//! e.g. `... /tmp/xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit` use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model}; use sylpheed_formats::ship::{is_base_part, ship_id_of}; +use sylpheed_formats::ship_capture::{correlate, parse_capture, serialize_table}; use sylpheed_formats::xiso::open_iso; use std::collections::HashSet; use std::path::Path; -type M3 = [[f64; 3]; 3]; - -struct Draw { - vbase: u32, - vcount: u32, - /// Rigid WorldView: R (rows) + T (view-space translation). - r: M3, - t: [f64; 3], - ok: bool, -} - -fn parse(text: &str) -> Vec { - let mut out = Vec::new(); - let mut vbase = 0u32; - let mut vcount = 0u32; - let mut consts: Vec<(usize, [f64; 4])> = Vec::new(); - let mut flush = |vbase: u32, vcount: u32, consts: &[(usize, [f64; 4])], out: &mut Vec| { - if vbase == 0 { - return; - } - // c0..c2 = WVP rows; normalise each to unit → rigid WorldView. - let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v); - let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else { - out.push(Draw { vbase, vcount, r: [[0.0; 3]; 3], t: [0.0; 3], ok: false }); - return; - }; - let rows = [c0, c1, c2]; - let mut r = [[0.0; 3]; 3]; - let mut t = [0.0; 3]; - let mut ok = true; - for (i, row) in rows.iter().enumerate() { - let n = (row[0] * row[0] + row[1] * row[1] + row[2] * row[2]).sqrt(); - if n < 1e-6 { - ok = false; - break; - } - r[i] = [row[0] / n, row[1] / n, row[2] / n]; - t[i] = row[3] / n; - } - out.push(Draw { vbase, vcount, r, t, ok }); - }; - for line in text.lines() { - let l = line.trim(); - if let Some(rest) = l.strip_prefix("DRAW ") { - flush(vbase, vcount, &consts, &mut out); - consts.clear(); - let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k)); - vbase = f("vbase=0x").and_then(|s| u32::from_str_radix(s, 16).ok()).unwrap_or(0); - vcount = f("vcount=").and_then(|s| s.parse().ok()).unwrap_or(0); - } else if l.starts_with("vsconst") { - for cap in l.split("c").skip(1) { - // "=(x,y,z,w) ..." - if let Some((idx, rest)) = cap.split_once('=') { - if let Ok(i) = idx.trim().parse::() { - let nums: Vec = rest - .trim_start_matches('(') - .split(')') - .next() - .unwrap_or("") - .split(',') - .filter_map(|x| x.trim().parse().ok()) - .collect(); - if nums.len() == 4 { - consts.push((i, [nums[0], nums[1], nums[2], nums[3]])); - } - } - } - } - } - } - flush(vbase, vcount, &consts, &mut out); - out -} - -fn mt_apply(r: &M3, t: &[f64; 3], v: [f64; 3]) -> [f64; 3] { - [ - r[0][0] * v[0] + r[0][1] * v[1] + r[0][2] * v[2] + t[0], - r[1][0] * v[0] + r[1][1] * v[1] + r[1][2] * v[2] + t[1], - r[2][0] * v[0] + r[2][1] * v[1] + r[2][2] * v[2] + t[2], - ] -} -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 main() { let args: Vec = std::env::args().collect(); - if args.len() < 4 { - eprintln!("usage: correlate_capture [ref_part_substr]"); + let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect(); + let emit = args.iter().any(|a| a == "--emit"); + if positional.len() < 3 { + eprintln!( + "usage: correlate_capture [ref_part_substr] [--emit]" + ); std::process::exit(2); } - let (log, stage, id) = (&args[1], &args[2], &args[3]); - let ref_sub = args.get(4).map(|s| s.as_str()).unwrap_or("bdy_04"); + let (log, stage, id) = (positional[0], positional[1], positional[2]); + let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_04"); let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO"); - let draws = parse(&std::fs::read_to_string(log).expect("read log")); - eprintln!("parsed {} draws ({} with WorldView)", draws.len(), draws.iter().filter(|d| d.ok).count()); + let draws = parse_capture(&std::fs::read_to_string(log).expect("read log")); + eprintln!("parsed {} draws with WorldView", draws.len()); + + // Decode the ship's base parts to get each part's vertex count (the match key). let bytes = { let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); rt.block_on(async { @@ -142,66 +45,36 @@ fn main() { }) }; let names = xbg7_resource_names(&bytes); - let parts: Vec = - names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str())).cloned().collect(); - let want: HashSet = parts.iter().cloned().collect(); + let want: HashSet = names + .iter() + .filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str())) + .cloned() + .collect(); let models = Xbg7Model::models_named(&bytes, &want, &|| false); + let parts: Vec<(String, u32)> = models + .iter() + .map(|m| { + let vc = m.meshes.iter().map(|s| s.positions.len()).sum::() as u32; + (m.name.clone(), vc) + }) + .collect(); - // Match each part to a captured draw by vertex count; keep its WorldView. - struct Placed { - part: String, - r: M3, - t: [f64; 3], - local: Vec<[f64; 3]>, - } - let mut placed: Vec = Vec::new(); - let mut used: HashSet = HashSet::new(); - for part in &parts { - let Some(m) = models.iter().find(|m| &m.name == part) else { continue }; - let local: Vec<[f64; 3]> = - m.meshes.iter().flat_map(|s| s.positions.iter()).map(|p| [p[0] as f64, p[1] as f64, p[2] as f64]).collect(); - let vc = local.len() as u32; - if let Some(d) = draws.iter().find(|d| d.ok && d.vcount == vc && !used.contains(&d.vbase)) { - used.insert(d.vbase); - placed.push(Placed { part: part.clone(), r: d.r, t: d.t, local }); - } else { - println!("{part:20} (no matching captured draw — occluded/culled?)"); - } - } - - // Reference part → ship frame. rel_p = WV_ref⁻¹ · WV_p (camera cancels). - let Some(rf) = placed.iter().find(|p| p.part.contains(ref_sub)).or(placed.first()) else { - eprintln!("no parts placed"); + let Some(ship) = correlate(id, &draws, &parts, ref_sub) else { + eprintln!("no parts matched a captured draw"); return; }; - let rt_ref = transpose(&rf.r); - let ref_t = rf.t; - println!("\nreference = {} → ship-relative placement (M rows, T):", rf.part); - let mut lo = [f64::MAX; 3]; - let mut hi = [f64::MIN; 3]; - for p in &placed { - // rel R = Rᵀ_ref · R_p ; rel T = Rᵀ_ref · (T_p − T_ref) - let rel_r = mmul(&rt_ref, &p.r); - let dt = [p.t[0] - ref_t[0], p.t[1] - ref_t[1], p.t[2] - ref_t[2]]; - let rel_t = mt_apply(&rt_ref, &[0.0; 3], dt); - // Assembled centroid (validation) + overall extent. - let mut c = [0.0; 3]; - for v in &p.local { - let w = mt_apply(&rel_r, &rel_t, *v); - for k in 0..3 { - c[k] += w[k]; - lo[k] = lo[k].min(w[k]); - hi[k] = hi[k].max(w[k]); - } - } - let n = p.local.len().max(1) as f64; - println!( - " {:18} T=[{:8.1}{:8.1}{:8.1}] centroid=[{:8.1}{:8.1}{:8.1}]", - p.part, rel_t[0], rel_t[1], rel_t[2], c[0] / n, c[1] / n, c[2] / n - ); + + eprintln!("\nreference = {} → ship-relative placement:", ship.reference); + for p in &ship.parts { + eprintln!(" {:18} T=[{:8.1}{:8.1}{:8.1}]", p.part, p.t[0], p.t[1], p.t[2]); + } + let unmatched: Vec<&str> = + parts.iter().map(|(n, _)| n.as_str()).filter(|n| !ship.parts.iter().any(|p| p.part == *n)).collect(); + if !unmatched.is_empty() { + eprintln!(" (no captured draw — occluded/culled: {})", unmatched.join(", ")); + } + + if emit { + print!("{}", serialize_table(std::slice::from_ref(&ship))); } - println!( - "\nassembled extent = [{:.0} {:.0} {:.0}] ({} parts placed)", - hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2], placed.len() - ); } diff --git a/crates/sylpheed-formats/src/lib.rs b/crates/sylpheed-formats/src/lib.rs index 714fdea..28f64fb 100644 --- a/crates/sylpheed-formats/src/lib.rs +++ b/crates/sylpheed-formats/src/lib.rs @@ -68,6 +68,9 @@ pub mod localization; // Whole-ship assembly from XBG7 part families (capital ships as split parts). pub mod ship; +// Exact capital-ship placement from a runtime F10 ship-capture (ground truth). +pub mod ship_capture; + /// Re-export the most commonly used types at the crate root. pub use font::FontInfo; pub use idxd::{IdxdError, IdxdObject}; diff --git a/crates/sylpheed-formats/src/ship_capture.rs b/crates/sylpheed-formats/src/ship_capture.rs new file mode 100644 index 0000000..d99a32f --- /dev/null +++ b/crates/sylpheed-formats/src/ship_capture.rs @@ -0,0 +1,371 @@ +//! Exact capital-ship part placement from a Canary **F10 ship-capture** log — +//! the runtime ground truth that static [`crate::ship::assemble_ship`] only +//! approximates for external parts. +//! +//! ## Why a capture is needed +//! +//! A captured part's vertex BUFFER holds **local** coordinates (byte-identical to +//! the `.xpr`), so capital-ship parts are placed **entirely in the vertex shader** +//! — the buffer carries no placement. The per-part transform lives in the ship +//! shader's **vertex float constants**: `c0..c2` are the **WorldViewProjection** +//! matrix rows. Each row's norm is `(sx, sy, 1)` (the projection x/y scales, with +//! `sy/sx ≈ 1.78 = 16:9`); dividing a row by its norm yields the rigid +//! **WorldView** row (verified orthonormal, `det = +1`) and `row[3]/norm` is that +//! axis' view-space translation. +//! +//! The camera View cancels when every part is expressed relative to a **reference +//! part**: `rel_p = WV_ref⁻¹ · WV_p = (Rᵀ_ref·R_p , Rᵀ_ref·(T_p − T_ref))` — a pure +//! ship-space rigid transform. That is what [`correlate`] emits and what the +//! checked-in [placement table](parse_table) stores, so the viewer can assemble a +//! ship exactly without re-capturing. +//! +//! ## Pipeline +//! +//! 1. Canary F10 → `xenia_ship_capture.log` (per-draw `vbase`/`vcount` + the first +//! 48 VS float4 constants). [`parse_capture`] → [`CapturedDraw`]s. +//! 2. [`correlate`] matches each ship base part to a draw **by vertex count** +//! (unique per part) and expresses it in the reference part's frame → +//! [`ShipPlacement`]. +//! 3. [`serialize_table`]/[`parse_table`] persist it as a checked-in data file +//! (`data/ship_placements.txt`, embedded via [`embedded_placement`]); the viewer +//! prefers it over the static assembler when present. + +use crate::mesh::ScenePart; + +type M3 = [[f64; 3]; 3]; + +/// One captured draw's rigid **WorldView**: rotation rows `r` + view-space +/// translation `t`, recovered from the `c0..c2` WVP constants. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CapturedDraw { + /// Guest vertex-buffer base address (the draw's identity for de-duping). + pub vbase: u32, + /// Vertex count — the key that matches a draw to a decoded part. + pub vcount: u32, + /// WorldView rotation rows (orthonormal). + pub r: M3, + /// WorldView view-space translation. + pub t: [f64; 3], +} + +/// A part's ship-relative rigid placement (in the reference part's frame). +#[derive(Debug, Clone, PartialEq)] +pub struct PartPlacement { + /// Geometry resource name, e.g. `e106_eng_01`. + pub part: String, + /// Rotation rows. + pub m: [[f32; 3]; 3], + /// Ship-relative translation. + pub t: [f32; 3], +} + +/// A whole ship's captured placement: every part in a shared ship-local frame. +#[derive(Debug, Clone, PartialEq)] +pub struct ShipPlacement { + /// Ship family id, e.g. `e106`. + pub id: String, + /// The reference part whose frame the placements are expressed in. + pub reference: String, + /// Ship-relative placement of each matched part. + pub parts: Vec, +} + +/// Parse a Canary ship-capture log into per-draw rigid WorldView transforms. +/// +/// Only draws that carry the `c0..c2` constants are returned (a culled/occluded +/// part produces no draw and is simply absent). Robust to the exact spacing of +/// the `DRAW …` / `vsconst …` lines. +pub fn parse_capture(text: &str) -> Vec { + let mut out = Vec::new(); + let mut vbase = 0u32; + let mut vcount = 0u32; + let mut consts: Vec<(usize, [f64; 4])> = Vec::new(); + + let flush = |vbase: u32, vcount: u32, consts: &[(usize, [f64; 4])], out: &mut Vec| { + if vbase == 0 { + return; + } + let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v); + let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else { + return; // no WorldView for this draw — skip it + }; + let mut r = [[0.0; 3]; 3]; + let mut t = [0.0; 3]; + for (i, row) in [c0, c1, c2].iter().enumerate() { + let n = (row[0] * row[0] + row[1] * row[1] + row[2] * row[2]).sqrt(); + if n < 1e-6 { + return; // degenerate row — not a usable transform + } + r[i] = [row[0] / n, row[1] / n, row[2] / n]; + t[i] = row[3] / n; + } + out.push(CapturedDraw { vbase, vcount, r, t }); + }; + + for line in text.lines() { + let l = line.trim(); + if let Some(rest) = l.strip_prefix("DRAW ") { + flush(vbase, vcount, &consts, &mut out); + consts.clear(); + let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k)); + vbase = f("vbase=0x").and_then(|s| u32::from_str_radix(s, 16).ok()).unwrap_or(0); + vcount = f("vcount=").and_then(|s| s.parse().ok()).unwrap_or(0); + } else if l.starts_with("vsconst") { + for cap in l.split('c').skip(1) { + let Some((idx, rest)) = cap.split_once('=') else { continue }; + let Ok(i) = idx.trim().parse::() else { continue }; + let nums: Vec = rest + .trim_start_matches('(') + .split(')') + .next() + .unwrap_or("") + .split(',') + .filter_map(|x| x.trim().parse().ok()) + .collect(); + if nums.len() == 4 { + consts.push((i, [nums[0], nums[1], nums[2], nums[3]])); + } + } + } + } + flush(vbase, vcount, &consts, &mut out); + out +} + +/// Correlate captured draws to a ship's base parts and express each in the +/// reference part's frame. +/// +/// `parts` is `(resource_name, vertex_count)` for the ship's base parts (from the +/// XBG7 decoder); vertex count is the match key. `ref_sub` selects the reference +/// part by substring (e.g. `bdy_04`); the first matched part is used if none +/// contains it. Parts with no matching captured draw (culled at that camera +/// angle) are omitted. Returns `None` if nothing matched. +pub fn correlate( + id: &str, + draws: &[CapturedDraw], + parts: &[(String, u32)], + ref_sub: &str, +) -> Option { + // Match each part to a distinct captured draw by vertex count. + let mut matched: Vec<(String, M3, [f64; 3])> = Vec::new(); + let mut used: std::collections::HashSet = std::collections::HashSet::new(); + for (part, vc) in parts { + if let Some(d) = draws.iter().find(|d| d.vcount == *vc && !used.contains(&d.vbase)) { + used.insert(d.vbase); + matched.push((part.clone(), d.r, d.t)); + } + } + let ref_idx = matched.iter().position(|(p, ..)| p.contains(ref_sub)).unwrap_or(0); + let (ref_part, ref_r, ref_t) = matched.get(ref_idx)?.clone(); + let rt_ref = transpose(&ref_r); + + let parts_out = matched + .iter() + .map(|(part, r, t)| { + let rel_r = mmul(&rt_ref, r); + let dt = [t[0] - ref_t[0], t[1] - ref_t[1], t[2] - ref_t[2]]; + let rel_t = mat_vec(&rt_ref, dt); + PartPlacement { + part: part.clone(), + m: m3_to_f32(&rel_r), + t: [rel_t[0] as f32, rel_t[1] as f32, rel_t[2] as f32], + } + }) + .collect(); + Some(ShipPlacement { id: id.to_string(), reference: ref_part, parts: parts_out }) +} + +/// Convert a captured placement into viewer [`ScenePart`]s (rigid, unit scale). +pub fn to_scene_parts(ship: &ShipPlacement) -> Vec { + ship.parts + .iter() + .map(|p| ScenePart { resource: p.part.clone(), m: p.m, t: p.t, s: [1.0, 1.0, 1.0] }) + .collect() +} + +/// The checked-in placement table, embedded at build time. Empty until captures +/// are baked in with `correlate_capture --emit`. +const EMBEDDED_TABLE: &str = include_str!("../data/ship_placements.txt"); + +/// The captured placement for ship `id` from the embedded table, if baked in. +pub fn embedded_placement(id: &str) -> Option { + parse_table(EMBEDDED_TABLE).into_iter().find(|s| s.id == id) +} + +/// Serialize a placement table to the checked-in text format (see [`parse_table`]). +pub fn serialize_table(ships: &[ShipPlacement]) -> String { + let mut s = String::new(); + s.push_str("# Capital-ship part placements — runtime-captured ground truth.\n"); + s.push_str("# Generated by: cargo run --release --example correlate_capture -- \\\n"); + s.push_str("# [ref_part_substr] --emit\n"); + s.push_str("# Per line: \n"); + for ship in ships { + s.push_str(&format!("\nship {} ref={}\n", ship.id, ship.reference)); + for p in &ship.parts { + s.push_str(&format!( + " {} {} {} {} {} {} {} {} {} {} {} {} {}\n", + p.part, + p.m[0][0], p.m[0][1], p.m[0][2], + p.m[1][0], p.m[1][1], p.m[1][2], + p.m[2][0], p.m[2][1], p.m[2][2], + p.t[0], p.t[1], p.t[2], + )); + } + } + s +} + +/// Parse the checked-in placement table. `#` comments and blank lines are ignored; +/// a `ship ref=` line starts a block, and each following +/// ` <9 rotation floats> <3 translation floats>` line is one placement. +pub fn parse_table(text: &str) -> Vec { + let mut ships: Vec = Vec::new(); + for line in text.lines() { + let l = line.trim(); + if l.is_empty() || l.starts_with('#') { + continue; + } + if let Some(rest) = l.strip_prefix("ship ") { + let mut it = rest.split_whitespace(); + let id = it.next().unwrap_or("").to_string(); + let reference = it + .next() + .and_then(|s| s.strip_prefix("ref=")) + .unwrap_or("") + .to_string(); + ships.push(ShipPlacement { id, reference, parts: Vec::new() }); + } else if let Some(ship) = ships.last_mut() { + let mut it = l.split_whitespace(); + let part = it.next().unwrap_or("").to_string(); + let nums: Vec = it.filter_map(|x| x.parse().ok()).collect(); + if part.is_empty() || nums.len() != 12 { + continue; + } + ship.parts.push(PartPlacement { + part, + m: [ + [nums[0], nums[1], nums[2]], + [nums[3], nums[4], nums[5]], + [nums[6], nums[7], nums[8]], + ], + t: [nums[9], nums[10], nums[11]], + }); + } + } + ships +} + +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 mat_vec(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 m3_to_f32(m: &M3) -> [[f32; 3]; 3] { + [ + [m[0][0] as f32, m[0][1] as f32, m[0][2] as f32], + [m[1][0] as f32, m[1][1] as f32, m[1][2] as f32], + [m[2][0] as f32, m[2][1] as f32, m[2][2] as f32], + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A `vsconst` line for an already-unit WorldView (norm 1) so R = rows and + /// T = row[3]: identity rotation, translation `t`. + fn draw_line(vbase: u32, vcount: u32, t: [f64; 3]) -> String { + format!( + "DRAW vbase=0x{vbase:X} stride=32 vcount={vcount} indices=0 prim=tri vs=0x1\n \ + vsconst base=0: c0=(1,0,0,{}) c1=(0,1,0,{}) c2=(0,0,1,{})\n", + t[0], t[1], t[2] + ) + } + + #[test] + fn parse_recovers_worldview() { + let log = draw_line(0x1000, 3, [10.0, 0.0, 0.0]); + let draws = parse_capture(&log); + assert_eq!(draws.len(), 1); + assert_eq!(draws[0].vcount, 3); + assert_eq!(draws[0].t, [10.0, 0.0, 0.0]); + assert_eq!(draws[0].r, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + } + + #[test] + fn parse_normalizes_projection_scale() { + // A row scaled by the projection sx=2 must normalize back to unit, and its + // translation divides by the same norm. + let log = "DRAW vbase=0x2000 vcount=4\n \ + vsconst base=0: c0=(2,0,0,20) c1=(0,2,0,0) c2=(0,0,1,0)\n"; + let d = &parse_capture(log)[0]; + assert!((d.r[0][0] - 1.0).abs() < 1e-9); + assert!((d.t[0] - 10.0).abs() < 1e-9, "20/2 = 10"); + } + + #[test] + fn correlate_expresses_parts_in_reference_frame() { + // Two parts, identity rotation: ref at view (10,0,0), other at (10,0,50). + // Relative to ref, the other must sit at (0,0,50). + let log = format!( + "{}{}", + draw_line(0x1000, 3, [10.0, 0.0, 0.0]), + draw_line(0x2000, 4, [10.0, 0.0, 50.0]) + ); + let draws = parse_capture(&log); + let parts = vec![("e106_bdy_04".to_string(), 3), ("e106_eng_01".to_string(), 4)]; + let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap(); + assert_eq!(ship.reference, "e106_bdy_04"); + let refp = ship.parts.iter().find(|p| p.part == "e106_bdy_04").unwrap(); + assert_eq!(refp.t, [0.0, 0.0, 0.0]); + let eng = ship.parts.iter().find(|p| p.part == "e106_eng_01").unwrap(); + assert_eq!(eng.t, [0.0, 0.0, 50.0]); + } + + #[test] + fn table_round_trips() { + let ship = ShipPlacement { + id: "e106".to_string(), + reference: "e106_bdy_04".to_string(), + parts: vec![ + PartPlacement { + part: "e106_bdy_04".to_string(), + m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + t: [0.0, 0.0, 0.0], + }, + PartPlacement { + part: "e106_eng_01".to_string(), + m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + t: [131.0, -133.0, -132.0], + }, + ], + }; + let text = serialize_table(std::slice::from_ref(&ship)); + let back = parse_table(&text); + assert_eq!(back, vec![ship]); + } + + #[test] + fn embedded_table_parses() { + // The checked-in data file must always parse (even if empty). + let _ = parse_table(EMBEDDED_TABLE); + } +} diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index 90508ff..a99bf6d 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -3952,9 +3952,15 @@ fn build_ship_model( }; let should_cancel = || cancel.load(Ordering::Relaxed); - // Scene-graph placement: which resources to draw and where (bridge fore, - // engines aft, hardpoints on their frames). - let placed = sylpheed_formats::ship::assemble_ship(&bytes, id, external); + // 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), + }; 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 6b554a9..0e5e544 100644 --- a/docs/re/ship-placement-runtime-capture.md +++ b/docs/re/ship-placement-runtime-capture.md @@ -1,7 +1,9 @@ # Capital-ship part placement — runtime capture (ground truth) **Status:** pipeline working end-to-end for one ship (`e106` ADAN Destroyer); -correlator recovers exact ship-relative transforms. Not yet baked into the viewer. +correlator recovers exact ship-relative transforms. **Bake plumbing landed** — the +correlator is now a tested library module and the viewer prefers a captured table; +only real capture data still needs baking in (needs the emulator + F10). ## Problem @@ -56,16 +58,30 @@ Branch **`capture-ship-placement`** in the `xenia-canary-native` worktree (off t - Run: `run-canary-native.sh` (HW Vulkan, interactive). Play into the mission, frame the ship side-on, press F10. -## Correlator +## Correlator (now a library module) -`sylpheed-formats/examples/correlate_capture.rs`: +The correlation math lives in **`sylpheed-formats::ship_capture`** (unit-tested with +synthetic captures): `parse_capture` → `CapturedDraw`s, `correlate(id, draws, +parts, ref_sub)` → a `ShipPlacement` (each part in the reference frame), and +`serialize_table`/`parse_table` for the checked-in text table. + +`examples/correlate_capture.rs` is the CLI wrapper: ``` SYLPHEED_ISO=… cargo run --release --example correlate_capture -- \ - [ref_part_substr] + [ref_part_substr] [--emit] ``` -Parses the log, decodes the ship's base parts, matches each part to a draw by -**vertex count** (unique per part), recovers WorldView from `c0..c2`, and prints -each part's ship-relative transform (reference-part frame) + assembled extent. +Decodes the ship's base parts (for their vertex counts = the match key), correlates, +prints each part's ship-relative transform, and with `--emit` prints the `ship …` +block to paste into the placement table. + +## Baked table + viewer preference + +`crates/sylpheed-formats/data/ship_placements.txt` is the checked-in placement +table (embedded via `ship_capture::embedded_placement`). The viewer's +`build_ship_model` uses a ship's captured entry when present, else falls back to +the static `assemble_ship` (`external` toggle). **The table is currently empty** — +a real F10 capture log is needed to bake in `e106` (and others); paste the +`--emit` block into that file and rebuild. ## Validated result — `e106` ADAN Destroyer (Mission 1 / Stage_S01, side view) @@ -95,11 +111,13 @@ angle (0 draws) — needs a second capture framing the superstructure. ## Next steps -1. **Bridge:** re-capture with the bridge visible → fills `brg_01`. -2. **Bake:** promote the correlator to a module; emit a per-ship placement table - (`ship_id → [(part, R, T)]`) as a checked-in data file; have the viewer's - `build_ship_model` prefer the captured table over `assemble_ship` when present. -3. **Other ships:** same capture for the cruiser (`e105`, Stage_S02), ACROPOLIS - (`f101`), TCAF cruiser/destroyer (`f105`/`f106`). One side-on F10 each. +1. ~~**Bake:** promote the correlator to a module + checked-in table + viewer + preference.~~ **DONE** (`ship_capture` module, `data/ship_placements.txt`, + `build_ship_model` prefers it). Remaining: run the captures below and paste the + `--emit` blocks in — needs the emulator (F10), can't be done offline. +2. **e106 + bridge:** re-capture `e106` (Stage_S01) framing the superstructure so + `brg_01` (culled last time) is included, then `--emit` → bake. +3. **Other ships:** one side-on F10 capture each for the cruiser (`e105`, + Stage_S02), ACROPOLIS (`f101`), TCAF cruiser/destroyer (`f105`/`f106`). 4. **Turrets:** the shared `e3NN`/`e4NN` gun models appear as their own draws in the capture too — correlate them to place turrets (static never resolved these).