From d9442a2106bc36156bf82745cab8e1e9c55ec8f4 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 26 Jul 2026 18:26:23 +0200 Subject: [PATCH] =?UTF-8?q?ship=5Fcapture:=20bake=20e106=20COMPLETE=20from?= =?UTF-8?q?=20the=202026-07-26=20F10=20capture=20=E2=80=94=20all=208=20par?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second F10 capture (ship at _m LOD distance) closes e106 entirely: all 8 parts placed including the bridge both earlier captures missed, cross-validating the doc's 7 known translations to 0.1 units and adding brg_01 at the exact centreline (0, 153.3, -164.0). Matching hardened by what the real capture taught us: - LOD-aware: each part tries every variant vcount (base/_m/_l/_d) — a distant ship draws its LOD copies, same local frame. - Position-validated, SET-based, against the UNION of a part's variants: buffer order != decode order, and one draw's vcount equalled the _m count while its buffer held the FULL 1633-vert geometry. A coincidental vcount (foreign 51-vert mesh vs the bridge) is rejected by geometry. - Runtime mirror handled: twins share one file geometry; the engine uploads the starboard copy X-reflected. Mirror-validated draws bake diag(-1,1,1) and the viewer reverses triangle winding for det<0 so front faces stay outward. - Near-axis rotation entries snapped to exact 0/+-1 for a clean table; eng_01 keeps its genuine 30-degree nacelle rotation. data/ship_placements.txt now ships e106 (viewer renders via the captured table); embedded_e106_is_complete locks the baked data. part_pos + capture_match helper examples added. 10 ship_capture tests; 80 formats-lib tests green; viewer builds. Co-Authored-By: Claude Fable 5 --- .../sylpheed-formats/data/ship_placements.txt | 12 +- .../examples/capture_match.rs | 43 +++ .../examples/correlate_capture.rs | 92 ++++-- crates/sylpheed-formats/examples/part_pos.rs | 20 ++ crates/sylpheed-formats/src/ship_capture.rs | 283 +++++++++++++++--- crates/sylpheed-viewer/src/iso_loader.rs | 11 + docs/re/ship-placement-runtime-capture.md | 27 +- 7 files changed, 426 insertions(+), 62 deletions(-) create mode 100644 crates/sylpheed-formats/examples/capture_match.rs create mode 100644 crates/sylpheed-formats/examples/part_pos.rs diff --git a/crates/sylpheed-formats/data/ship_placements.txt b/crates/sylpheed-formats/data/ship_placements.txt index f0916c0..c1081b6 100644 --- a/crates/sylpheed-formats/data/ship_placements.txt +++ b/crates/sylpheed-formats/data/ship_placements.txt @@ -9,5 +9,13 @@ # # 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.) + +ship e106 ref=e106_bdy_04 + e106_bdy_01 1 0 0 0 1 0 0 0 1 -263.9948 -150.43242 1164.8667 + e106_bdy_02 -1 0 0 0 1 0 0 0 1 264.0088 -150.41339 1164.8651 + e106_bdy_03 1 0 0 0 1 0 0 0 1 0.0029247368 -34.517372 1075.878 + e106_bdy_04 1 0 0 0 1 0 0 0 1 0 0 0 + e106_brg_01 1 0 0 0 1 0 0 0 1 -0.005471501 153.31795 -164.03748 + e106_eng_01 0.8659965 -0.50002277 0 0.4829627 0.83651507 0.25885975 -0.12941553 -0.22417325 0.96592265 131.2386 -133.06625 -132.06075 + e106_eng_02 1 0 0 0 1 0 0 0 1 0.0013684053 -49.088192 -139.47827 + e106_wep_02_01 1 0 0 0 1 0 0 0 1 0.000040663195 49.126797 1009.06744 diff --git a/crates/sylpheed-formats/examples/capture_match.rs b/crates/sylpheed-formats/examples/capture_match.rs new file mode 100644 index 0000000..37ca6a9 --- /dev/null +++ b/crates/sylpheed-formats/examples/capture_match.rs @@ -0,0 +1,43 @@ +//! Identify which stage + ship a capture log came from: match the capture's +//! draw vertex counts against every resource (incl. LODs) of every Stage_SNN.xpr. +//! cargo run --release --example capture_match -- +use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model}; +use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog}; +use std::collections::{BTreeMap, HashSet}; + +fn main() { + let a: Vec = std::env::args().collect(); + let text = std::fs::read_to_string(&a[1]).unwrap(); + let mut draws = parse_capture(&text); + if draws.is_empty() { draws = parse_drawlog(&text); } + let caps: HashSet = draws.iter().map(|d| d.vcount).filter(|v| *v >= 100).collect(); + eprintln!("{} draws, {} distinct vcounts>=100", draws.len(), caps.len()); + + let mut entries: Vec<_> = std::fs::read_dir(&a[2]).unwrap().filter_map(|e| e.ok()) + .filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n.starts_with("Stage_") && n.ends_with(".xpr") }) + .map(|e| e.path()).collect(); + entries.sort(); + for path in entries { + let bytes = std::fs::read(&path).unwrap(); + let names = xbg7_resource_names(&bytes); + let want: HashSet = names.iter().cloned().collect(); + let models = Xbg7Model::models_named(&bytes, &want, &|| false); + // resource -> vcount; group hits by ship-id prefix + let mut hits: BTreeMap> = BTreeMap::new(); + for m in &models { + let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum(); + if vc >= 100 && caps.contains(&(vc as u32)) { + let id = m.name.get(..4).unwrap_or("?").to_string(); + hits.entry(id).or_default().push((m.name.clone(), vc as u32)); + } + } + let total: usize = hits.values().map(|v| v.len()).sum(); + if total >= 3 { + println!("== {} : {} matching resources ==", path.file_name().unwrap().to_string_lossy(), total); + for (id, v) in &hits { + let s: Vec = v.iter().map(|(n, c)| format!("{n}({c})")).collect(); + println!(" {id}: {}", s.join(" ")); + } + } + } +} diff --git a/crates/sylpheed-formats/examples/correlate_capture.rs b/crates/sylpheed-formats/examples/correlate_capture.rs index 9d9f974..6190dbc 100644 --- a/crates/sylpheed-formats/examples/correlate_capture.rs +++ b/crates/sylpheed-formats/examples/correlate_capture.rs @@ -1,20 +1,31 @@ -//! Recover exact capital-ship part placement from a Canary F10 ship-capture log +//! Recover exact capital-ship part placement from a Canary capture log //! and (optionally) emit a checked-in placement-table block. //! //! 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 +//! get their vertex counts + leading positions (the match keys), correlates, and +//! prints the result. With `--emit` it prints the `ship …` block to paste into //! `crates/sylpheed-formats/data/ship_placements.txt`. //! +//! Matching is **LOD-aware**: a ship on screen at distance is drawn with its +//! `_m`/`_l` LOD copies, which live in the same local frame as the base part — so +//! each base part tries its own vcount first, then its LOD variants', and the +//! recovered transform is recorded under the base name. Same-vcount twins +//! (mirrored port/starboard hulls) are routed by the draw's position dump. +//! //! Usage: //! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \ //! [ref_part_substr] [--emit] -//! e.g. `... /tmp/xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit` +//! e.g. SYLPHEED_ISO="/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of +//! Deception (USA, Europe) (En,Ja).iso" \ +//! cargo run --release --example correlate_capture -- \ +//! 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, parse_drawlog, serialize_table}; +use sylpheed_formats::ship_capture::{ + correlate, parse_capture, parse_drawlog, serialize_table, PartKey, +}; use sylpheed_formats::xiso::open_iso; use std::collections::HashSet; use std::path::Path; @@ -33,8 +44,8 @@ fn main() { 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"); - // Accept either the F10 ship-capture format or the draw-logger format - // (mission_draws.log); auto-detect by trying the F10 parser first. + // Accept either the F10 ship-capture format or the draw-logger format; + // auto-detect by trying the F10 parser first. let text = std::fs::read_to_string(log).expect("read log"); let mut draws = parse_capture(&text); if draws.is_empty() { @@ -44,7 +55,8 @@ fn main() { eprintln!("parsed {} draws (F10 capture format)", draws.len()); } - // Decode the ship's base parts to get each part's vertex count (the match key). + // Decode the ship's base parts AND their LOD copies (vcount + leading + // positions are the match keys; LODs share the base part's local frame). let bytes = { let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); rt.block_on(async { @@ -53,33 +65,69 @@ fn main() { }) }; let names = xbg7_resource_names(&bytes); - let want: HashSet = names + let base_parts: Vec = names .iter() .filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str())) .cloned() .collect(); + // Candidate resources per base part: itself + `_m`/`_l`/`_d` LOD copies. + let mut want: HashSet = base_parts.iter().cloned().collect(); + for p in &base_parts { + for suf in ["_m", "_l", "_d"] { + let cand = format!("{p}{suf}"); + if names.contains(&cand) { + want.insert(cand); + } + } + } 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(); + let positions_of = |name: &str| -> Option> { + let m = models.iter().find(|m| m.name == name)?; + Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect()) + }; - let Some(ship) = correlate(id, &draws, &parts, ref_sub) else { + // One PartKey per (part, variant-vcount present in the capture) — correlate + // takes the first that validates. Validation uses the UNION of the part's + // variant position sets: a draw's `vcount` can match one LOD while its + // buffer holds another variant's geometry (seen on the real e106: the + // 558-vcount draw carried the FULL 1633-vert bdy_04 buffer), and every + // variant is the same part in the same local frame. + let mut keys: Vec = Vec::new(); + for part in &base_parts { + let variants = + [part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")]; + let union: Vec<[f32; 3]> = + variants.iter().filter_map(|v| positions_of(v)).flatten().collect(); + let mut any = false; + for cand in &variants { + if let Some(pos) = positions_of(cand) { + let vcount = pos.len() as u32; + if draws.iter().any(|d| d.vcount == vcount) { + let lod = if cand == part { "full" } else { cand.rsplit('_').next().unwrap_or("?") }; + eprintln!(" {part:20} try vcount={vcount:6} [{lod}]"); + keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() }); + any = true; + } + } + } + if !any { + eprintln!(" {part:20} — no draw matches any LOD (culled/off-screen?)"); + } + } + + let Some(ship) = correlate(id, &draws, &keys, ref_sub) else { eprintln!("no parts matched a captured draw"); return; }; 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]); + eprintln!(" {:18} T=[{:9.1}{:9.1}{:9.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(", ")); + for part in &base_parts { + if !ship.parts.iter().any(|p| &p.part == part) { + eprintln!(" {part:18} — NOT placed (culled, or its only vcount hit failed position validation)"); + } } if emit { diff --git a/crates/sylpheed-formats/examples/part_pos.rs b/crates/sylpheed-formats/examples/part_pos.rs new file mode 100644 index 0000000..f4c4cc3 --- /dev/null +++ b/crates/sylpheed-formats/examples/part_pos.rs @@ -0,0 +1,20 @@ +//! Print a resource's first N decoded positions (buffer order), for matching a +//! capture draw's `pos:` dump to a part: +//! cargo run --release --example part_pos -- [n] +use std::collections::HashSet; +use sylpheed_formats::mesh::Xbg7Model; + +fn main() { + let a: Vec = std::env::args().collect(); + let bytes = std::fs::read(&a[1]).unwrap(); + let n: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(4); + let want: HashSet = [a[2].clone()].into(); + for m in Xbg7Model::models_named(&bytes, &want, &|| false) { + let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum(); + print!("{} vtx={}:", m.name, vc); + for p in m.meshes.iter().flat_map(|s| &s.positions).take(n) { + print!(" ({:.4},{:.4},{:.4})", p[0], p[1], p[2]); + } + println!(); + } +} diff --git a/crates/sylpheed-formats/src/ship_capture.rs b/crates/sylpheed-formats/src/ship_capture.rs index 5a8b00a..23e17a6 100644 --- a/crates/sylpheed-formats/src/ship_capture.rs +++ b/crates/sylpheed-formats/src/ship_capture.rs @@ -36,7 +36,7 @@ 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)] +#[derive(Debug, Clone, PartialEq)] pub struct CapturedDraw { /// Guest vertex-buffer base address (the draw's identity for de-duping). pub vbase: u32, @@ -46,6 +46,25 @@ pub struct CapturedDraw { pub r: M3, /// WorldView view-space translation. pub t: [f64; 3], + /// First few LOCAL vertex positions dumped with the draw (buffer order). + /// Used to disambiguate same-vcount twins (mirrored port/starboard parts). + pub pos: Vec<[f32; 3]>, +} + +/// A ship part to match against the capture. `part` is the **base** part name +/// (what goes in the placement table); `vcount`/`ref_pos` come from whichever +/// resource variant is being tried (base or an `_m`/`_l` LOD copy — a LOD is the +/// same part in the same local frame, so its captured transform is the part's). +#[derive(Debug, Clone, PartialEq)] +pub struct PartKey { + /// Base part name for the table, e.g. `e106_bdy_01`. + pub part: String, + /// The tried resource's vertex count (the draw match key). + pub vcount: u32, + /// The tried resource's decoded positions (any order — validation is + /// set-based), used to validate a vcount hit and to route mirrored twins. + /// Empty = match by vcount alone. + pub ref_pos: Vec<[f32; 3]>, } /// A part's ship-relative rigid placement (in the reference part's frame). @@ -79,9 +98,15 @@ pub fn parse_capture(text: &str) -> Vec { let mut out = Vec::new(); let mut vbase = 0u32; let mut vcount = 0u32; + let mut pos: Vec<[f32; 3]> = Vec::new(); let mut consts: Vec<(usize, [f64; 4])> = Vec::new(); - let flush = |vbase: u32, vcount: u32, consts: &[(usize, [f64; 4])], out: &mut Vec| { + let flush = |vbase: u32, + vcount: u32, + pos: &mut Vec<[f32; 3]>, + consts: &[(usize, [f64; 4])], + out: &mut Vec| { + let pos = std::mem::take(pos); if vbase == 0 { return; } @@ -90,18 +115,20 @@ pub fn parse_capture(text: &str) -> Vec { return; // no WorldView for this draw — skip it }; if let Some((r, t)) = normalize_wvp([c0, c1, c2]) { - out.push(CapturedDraw { vbase, vcount, r, t }); + out.push(CapturedDraw { vbase, vcount, r, t, pos }); } }; for line in text.lines() { let l = line.trim(); if let Some(rest) = l.strip_prefix("DRAW ") { - flush(vbase, vcount, &consts, &mut out); + flush(vbase, vcount, &mut pos, &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("pos:") || l.starts_with("positions:") { + pos = parse_pos_line(l, 8); } else if l.starts_with("vsconst") { for cap in l.split('c').skip(1) { let Some((idx, rest)) = cap.split_once('=') else { continue }; @@ -120,7 +147,23 @@ pub fn parse_capture(text: &str) -> Vec { } } } - flush(vbase, vcount, &consts, &mut out); + flush(vbase, vcount, &mut pos, &consts, &mut out); + out +} + +/// Parse a `pos: (x,y,z) (x,y,z) …` dump line into up to `max` positions. +fn parse_pos_line(l: &str, max: usize) -> Vec<[f32; 3]> { + let mut out = Vec::new(); + for group in l.split('(').skip(1) { + let Some(inner) = group.split(')').next() else { continue }; + let nums: Vec = inner.split(',').filter_map(|x| x.trim().parse().ok()).collect(); + if nums.len() == 3 { + out.push([nums[0], nums[1], nums[2]]); + if out.len() >= max { + break; + } + } + } out } @@ -147,33 +190,38 @@ pub fn parse_drawlog(text: &str) -> Vec { let mut base = 0u32; let mut stride = 0u32; let mut size = 0u32; + let mut pos: Vec<[f32; 3]> = Vec::new(); let mut consts: Vec<(usize, [f64; 4])> = Vec::new(); let mut flush = |base: u32, size: u32, stride: u32, + pos: &mut Vec<[f32; 3]>, consts: &[(usize, [f64; 4])], seen: &mut std::collections::HashSet, out: &mut Vec| { + let pos = std::mem::take(pos); if base == 0 || stride == 0 || !seen.insert(base) { 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 }; if let Some((r, t)) = normalize_wvp([c0, c1, c2]) { - out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t }); + out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t, pos }); } }; for line in text.lines() { let l = line.trim(); if let Some(rest) = l.strip_prefix("DRAW ") { - flush(base, size, stride, &consts, &mut seen, &mut out); + flush(base, size, stride, &mut pos, &consts, &mut seen, &mut out); consts.clear(); base = 0; stride = 0; size = 0; is_ship = rest.contains(&format!("vs={SHIP_VS_HASH}")); + } else if is_ship && l.starts_with("positions:") { + pos = parse_pos_line(l, 8); } else if is_ship && l.starts_with("stream ") { let f = |k: &str| l.split_whitespace().find_map(|t| t.strip_prefix(k)); if let Some(b) = f("base=0x").and_then(|s| u32::from_str_radix(s, 16).ok()) { @@ -192,7 +240,7 @@ pub fn parse_drawlog(text: &str) -> Vec { } } } - flush(base, size, stride, &consts, &mut seen, &mut out); + flush(base, size, stride, &mut pos, &consts, &mut seen, &mut out); out } @@ -213,42 +261,105 @@ fn normalize_wvp(rows: [[f64; 4]; 3]) -> Option<(M3, [f64; 3])> { Some((r, t)) } -/// Correlate captured draws to a ship's base parts and express each in the -/// reference part's frame. +/// Validate a vcount hit by the draw's dumped positions against the part's +/// decoded position SET (order-independent — decode order can differ from buffer +/// order). Returns `Some((hits, mirrored))` when at least half the dumped +/// positions are found among the part's vertices, either directly or **all +/// X-negated** — the engine uploads the second of a mirrored port/starboard pair +/// as an X-reflection of the shared file geometry, so the guest buffer disagrees +/// in X sign with every decoded copy. `None` = the dump belongs to a different +/// model (a coincidental vcount). +fn pos_validate(draw: &CapturedDraw, ref_pos: &[[f32; 3]]) -> Option<(usize, bool)> { + if draw.pos.is_empty() || ref_pos.is_empty() { + return Some((0, false)); // no data to validate with — accept neutrally + } + 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 = 0usize; + let mut mirror = 0usize; + for p in &draw.pos { + if ref_pos.iter().any(|v| near(p, v)) { + direct += 1; + } + let pm = [-p[0], p[1], p[2]]; + if ref_pos.iter().any(|v| near(&pm, v)) { + mirror += 1; + } + } + let need = draw.pos.len().div_ceil(2); + if direct >= need && direct >= mirror { + Some((direct, false)) + } else if mirror >= need { + Some((mirror, true)) + } else { + None + } +} + +/// Correlate captured draws to a ship's 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. +/// Each [`PartKey`]'s `vcount` is the match key; when several draws share the +/// vcount (mirrored port/starboard twins) the draw whose dumped positions match +/// the part's `ref_pos` validates best is chosen. `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)], + parts: &[PartKey], 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 matched: Vec<(String, M3, [f64; 3], bool)> = 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)) { + for key in parts { + // Several PartKeys may carry the same part (one per LOD-variant vcount); + // the first that validates wins, the rest are skipped. + if matched.iter().any(|(p, ..)| p == &key.part) { + continue; + } + // A vcount hit alone can be a coincidence (small LODs share counts across + // unrelated models — a 51-vert draw once matched the bridge but was a + // different mesh). Candidates failing position validation are REJECTED; + // among validated candidates the best hit count wins (routes twins), and + // a mirror-validated match records the X-reflection. + let mut best: Option<(&CapturedDraw, usize, bool)> = None; + for d in draws.iter().filter(|d| d.vcount == key.vcount && !used.contains(&d.vbase)) { + let Some((score, mirrored)) = pos_validate(d, &key.ref_pos) else { + continue; // positions disagree — not this part + }; + if best.map_or(true, |(_, s, _)| score > s) { + best = Some((d, score, mirrored)); + } + } + if let Some((d, _, mirrored)) = best { used.insert(d.vbase); - matched.push((part.clone(), d.r, d.t)); + matched.push((key.part.clone(), d.r, d.t, mirrored)); } } 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 (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); + .map(|(part, r, t, mirrored)| { + let mut 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); + // The captured WorldView transforms the *uploaded* buffer; for the + // mirrored twin that buffer is the X-reflection of the file geometry, + // so the file-local placement is R·diag(−1,1,1) — negate column 0. + if *mirrored { + for row in &mut rel_r { + row[0] = -row[0]; + } + } PartPlacement { part: part.clone(), - m: m3_to_f32(&rel_r), + m: snap_m3(&rel_r), t: [rel_t[0] as f32, rel_t[1] as f32, rel_t[2] as f32], } }) @@ -256,6 +367,27 @@ pub fn correlate( Some(ShipPlacement { id: id.to_string(), reference: ref_part, parts: parts_out }) } +/// Snap near-axis rotation entries (float noise from the WV products) to exact +/// 0/±1 so the checked-in table is clean; real rotations are untouched. +fn snap_m3(m: &M3) -> [[f32; 3]; 3] { + let snap = |v: f64| -> f32 { + if v.abs() < 5e-4 { + 0.0 + } else if (v - 1.0).abs() < 5e-4 { + 1.0 + } else if (v + 1.0).abs() < 5e-4 { + -1.0 + } else { + v as f32 + } + }; + [ + [snap(m[0][0]), snap(m[0][1]), snap(m[0][2])], + [snap(m[1][0]), snap(m[1][1]), snap(m[1][2])], + [snap(m[2][0]), snap(m[2][1]), snap(m[2][2])], + ] +} + /// Convert a captured placement into viewer [`ScenePart`]s (rigid, unit scale). pub fn to_scene_parts(ship: &ShipPlacement) -> Vec { ship.parts @@ -359,13 +491,6 @@ fn mat_vec(m: &M3, v: [f64; 3]) -> [f64; 3] { 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 { @@ -381,6 +506,10 @@ mod tests { ) } + fn key(part: &str, vcount: u32) -> PartKey { + PartKey { part: part.to_string(), vcount, ref_pos: Vec::new() } + } + #[test] fn parse_recovers_worldview() { let log = draw_line(0x1000, 3, [10.0, 0.0, 0.0]); @@ -412,7 +541,7 @@ mod tests { 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 parts = vec![key("e106_bdy_04", 3), key("e106_eng_01", 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(); @@ -442,12 +571,79 @@ mod tests { assert_eq!(draws[0].vcount, 1633); // 9798/6 assert_eq!(draws[1].vcount, 815); // 4890/6 // Correlate: part B sits 50 along Z from reference part A. - let parts = vec![("e106_bdy_04".to_string(), 1633), ("e106_bdy_03".to_string(), 815)]; + let parts = vec![key("e106_bdy_04", 1633), key("e106_bdy_03", 815)]; let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap(); let b = ship.parts.iter().find(|p| p.part == "e106_bdy_03").unwrap(); assert_eq!(b.t, [0.0, 0.0, 50.0]); } + /// The real e106 bdy_01/bdy_02 case: both twin resources decode to + /// IDENTICAL file geometry; the engine uploads the second instance as an + /// X-reflection, so one captured buffer disagrees in X sign with the file. + /// Both draws must be placed, and the mirrored one must bake the X-flip + /// (negated first matrix column) so file-local geometry lands port-side. + #[test] + fn runtime_mirrored_twin_placed_with_reflection() { + let log = "DRAW vbase=0x1000 stride=24 vcount=426 indices=21 prim=4 vs=0x1\n \ + pos: (134.4215,133.8319,-118.1757) (178.8384,85.3847,238.0463)\n \ + vsconst base=0: c0=(1,0,0,264) c1=(0,1,0,0) c2=(0,0,1,0)\n\ + DRAW vbase=0x2000 stride=24 vcount=426 indices=21 prim=4 vs=0x1\n \ + pos: (-134.4215,133.8319,-118.1757) (-178.8384,85.3847,238.0463)\n \ + vsconst base=0: c0=(1,0,0,-264) c1=(0,1,0,0) c2=(0,0,1,0)\n\ + DRAW vbase=0x3000 stride=24 vcount=558 indices=9 prim=4 vs=0x1\n \ + vsconst base=0: c0=(1,0,0,0) c1=(0,1,0,0) c2=(0,0,1,0)\n"; + let draws = parse_capture(log); + assert_eq!(draws.len(), 3); + assert_eq!(draws[0].pos.len(), 2); + // Both twins carry the SAME (file) positions — +X side geometry. + let file_pos = vec![[134.4215, 133.8319, -118.1757], [178.8384, 85.3847, 238.0463]]; + let parts = vec![ + PartKey { part: "e106_bdy_01".to_string(), vcount: 426, ref_pos: file_pos.clone() }, + PartKey { part: "e106_bdy_02".to_string(), vcount: 426, ref_pos: file_pos }, + key("e106_bdy_04", 558), + ]; + let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap(); + assert_eq!(ship.parts.len(), 3, "both twins + reference placed"); + let get = |p: &str| ship.parts.iter().find(|x| x.part == p).unwrap().clone(); + // bdy_01 validated directly → the +264 draw, identity rotation. + let a = get("e106_bdy_01"); + assert_eq!(a.t, [264.0, 0.0, 0.0]); + assert_eq!(a.m[0][0], 1.0); + // bdy_02 validated as the MIRROR → the −264 draw, X-flip baked in. + let b = get("e106_bdy_02"); + assert_eq!(b.t, [-264.0, 0.0, 0.0]); + assert_eq!(b.m[0][0], -1.0, "mirrored twin must negate the X column"); + assert_eq!(b.m[1][1], 1.0); + } + + /// A draw whose vcount matches but whose position dump disagrees must be + /// REJECTED, not placed — the real false-positive: a foreign 51-vert model + /// matched `e106_brg_01_l` by count alone and put the bridge 2 km off-hull. + #[test] + fn vcount_coincidence_rejected_by_positions() { + let log = "DRAW vbase=0x1000 stride=24 vcount=51 indices=36 prim=4 vs=0x1\n \ + pos: (-0.0000,46.2359,-12.9454) (-0.0000,-6.1936,264.8687)\n \ + vsconst base=0: c0=(1,0,0,1975) c1=(0,1,0,0) c2=(0,0,1,0)\n\ + DRAW vbase=0x3000 stride=24 vcount=558 indices=9 prim=4 vs=0x1\n \ + vsconst base=0: c0=(1,0,0,0) c1=(0,1,0,0) c2=(0,0,1,0)\n"; + let draws = parse_capture(log); + let parts = vec![ + PartKey { + part: "e106_brg_01".to_string(), + vcount: 51, + // The REAL bridge LOD's vertices — disagree with the dump. + ref_pos: vec![[35.2480, 26.0376, 49.2504], [6.0, 55.9632, 41.1370]], + }, + key("e106_bdy_04", 558), + ]; + let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap(); + assert!( + !ship.parts.iter().any(|p| p.part == "e106_brg_01"), + "coincidental vcount match must not place the bridge" + ); + assert_eq!(ship.parts.len(), 1); + } + #[test] fn table_round_trips() { let ship = ShipPlacement { @@ -476,4 +672,23 @@ mod tests { // The checked-in data file must always parse (even if empty). let _ = parse_table(EMBEDDED_TABLE); } + + /// The baked e106 capture (2026-07-26 F10, Stage_S01): all 8 parts placed, + /// port/starboard pair symmetric with the mirror on bdy_02, bridge on the + /// centreline. Guards the checked-in data against accidental edits. + #[test] + fn embedded_e106_is_complete() { + let e106 = embedded_placement("e106").expect("e106 baked in"); + assert_eq!(e106.reference, "e106_bdy_04"); + assert_eq!(e106.parts.len(), 8, "all 8 e106 parts placed"); + let get = |p: &str| e106.parts.iter().find(|x| x.part == p).unwrap(); + // Port/starboard hull pair: X = ∓264, the starboard copy mirrored. + assert!((get("e106_bdy_01").t[0] + 264.0).abs() < 0.1); + assert!((get("e106_bdy_02").t[0] - 264.0).abs() < 0.1); + assert_eq!(get("e106_bdy_02").m[0][0], -1.0); + assert_eq!(get("e106_bdy_01").m[0][0], 1.0); + // Bridge: centreline, above and aft of the hull reference. + let brg = get("e106_brg_01"); + assert!(brg.t[0].abs() < 0.1 && brg.t[1] > 150.0 && brg.t[2] < -160.0); + } } diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index a99bf6d..f041f73 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -3992,6 +3992,12 @@ fn build_ship_model( let Some(src) = base.iter().find(|m| m.name == p.resource) else { continue; }; + // A captured mirror placement (det < 0 — the engine X-reflects the + // second of a port/starboard pair) flips triangle winding; reverse each + // triangle's index order so front faces stay outward. + let det = p.m[0][0] * (p.m[1][1] * p.m[2][2] - p.m[1][2] * p.m[2][1]) + - p.m[0][1] * (p.m[1][0] * p.m[2][2] - p.m[1][2] * p.m[2][0]) + + p.m[0][2] * (p.m[1][0] * p.m[2][1] - p.m[1][1] * p.m[2][0]); let mut m = src.clone(); for sub in &mut m.meshes { for v in &mut sub.positions { @@ -4000,6 +4006,11 @@ fn build_ship_model( for nrm in &mut sub.normals { *nrm = rot(&p.m, nrm); } + if det < 0.0 { + for tri in sub.indices.chunks_exact_mut(3) { + tri.swap(1, 2); + } + } } models.push(m); } diff --git a/docs/re/ship-placement-runtime-capture.md b/docs/re/ship-placement-runtime-capture.md index cb05dd6..b4fa790 100644 --- a/docs/re/ship-placement-runtime-capture.md +++ b/docs/re/ship-placement-runtime-capture.md @@ -1,9 +1,10 @@ # 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. **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). +**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. ## Problem @@ -71,6 +72,24 @@ Two capture formats are accepted (auto-detected): the F10 ship-capture snapshot draws by vertex-buffer `base`, `vcount = size_words/stride_words`). So a capital ship seen in a normal instrumented run can be baked without a dedicated F10 pass. +### Matching rules (learned from the real 2026-07-26 capture) + +- **LOD-aware**: a distant ship draws its `_m`/`_l` copies — same part, same + local frame, different vcount. Each part tries every variant vcount. +- **Position-validated**: a vcount hit alone can be a coincidence (a foreign + 51-vert mesh nearly hijacked the bridge slot). Every candidate draw's dumped + positions must be found in the part's decoded position set or it is rejected. + Validation is **set-based** (buffer order ≠ decode order) against the **union** + of the part's variants — the real capture had a draw whose `vcount` equalled + the `_m` count while its buffer held the FULL-detail geometry. +- **Runtime mirror**: a port/starboard pair shares one file geometry; the engine + uploads the second instance **X-reflected**. A draw that validates only under + X-negation is accepted as the mirror, and the placement bakes `diag(−1,1,1)` + (the viewer reverses triangle winding for det < 0). +- The mission ship shader can be a different hash per lighting variant + (`0x3A0829CC71516789` in this capture) — the F10 path doesn't filter by hash, + it validates by geometry instead. + `examples/correlate_capture.rs` is the CLI wrapper: ``` SYLPHEED_ISO=… cargo run --release --example correlate_capture -- \