ship_capture: bake e106 COMPLETE from the 2026-07-26 F10 capture — all 8 parts
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 <noreply@anthropic.com>
This commit is contained in:
@@ -9,5 +9,13 @@
|
||||
#
|
||||
# Per placement line: <part> <R00 R01 R02 R10 R11 R12 R20 R21 R22> <T0 T1 T2>
|
||||
#
|
||||
# (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
|
||||
|
||||
43
crates/sylpheed-formats/examples/capture_match.rs
Normal file
43
crates/sylpheed-formats/examples/capture_match.rs
Normal file
@@ -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 -- <capture.log> <resource3d dir>
|
||||
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<String> = 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<u32> = 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<String> = names.iter().cloned().collect();
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
// resource -> vcount; group hits by ship-id prefix
|
||||
let mut hits: BTreeMap<String, Vec<(String, u32)>> = 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<String> = v.iter().map(|(n, c)| format!("{n}({c})")).collect();
|
||||
println!(" {id}: {}", s.join(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 -- \
|
||||
//! <capture.log> <Stage_SNN> <ship_id> [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<String> = names
|
||||
let base_parts: Vec<String> = 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<String> = 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::<usize>() as u32;
|
||||
(m.name.clone(), vc)
|
||||
})
|
||||
.collect();
|
||||
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
|
||||
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<PartKey> = 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 {
|
||||
|
||||
20
crates/sylpheed-formats/examples/part_pos.rs
Normal file
20
crates/sylpheed-formats/examples/part_pos.rs
Normal file
@@ -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 -- <xpr path> <resource> [n]
|
||||
use std::collections::HashSet;
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = 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<String> = [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!();
|
||||
}
|
||||
}
|
||||
@@ -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<CapturedDraw> {
|
||||
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<CapturedDraw>| {
|
||||
let flush = |vbase: u32,
|
||||
vcount: u32,
|
||||
pos: &mut Vec<[f32; 3]>,
|
||||
consts: &[(usize, [f64; 4])],
|
||||
out: &mut Vec<CapturedDraw>| {
|
||||
let pos = std::mem::take(pos);
|
||||
if vbase == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -90,18 +115,20 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
|
||||
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<CapturedDraw> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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<f32> = 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<CapturedDraw> {
|
||||
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<u32>,
|
||||
out: &mut Vec<CapturedDraw>| {
|
||||
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<CapturedDraw> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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<ShipPlacement> {
|
||||
// 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<u32> = 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<ScenePart> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 -- \
|
||||
|
||||
Reference in New Issue
Block a user