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>
|
# 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.
|
//! and (optionally) emit a checked-in placement-table block.
|
||||||
//!
|
//!
|
||||||
//! The correlation math lives in [`sylpheed_formats::ship_capture`]; this example
|
//! 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
|
//! 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.
|
//! get their vertex counts + leading positions (the match keys), correlates, and
|
||||||
//! With `--emit` it prints the `ship …` block to paste into
|
//! prints the result. With `--emit` it prints the `ship …` block to paste into
|
||||||
//! `crates/sylpheed-formats/data/ship_placements.txt`.
|
//! `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:
|
//! Usage:
|
||||||
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
|
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
|
||||||
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
|
//! <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::mesh::{xbg7_resource_names, Xbg7Model};
|
||||||
use sylpheed_formats::ship::{is_base_part, ship_id_of};
|
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 sylpheed_formats::xiso::open_iso;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::Path;
|
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 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 iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
|
||||||
|
|
||||||
// Accept either the F10 ship-capture format or the draw-logger format
|
// Accept either the F10 ship-capture format or the draw-logger format;
|
||||||
// (mission_draws.log); auto-detect by trying the F10 parser first.
|
// auto-detect by trying the F10 parser first.
|
||||||
let text = std::fs::read_to_string(log).expect("read log");
|
let text = std::fs::read_to_string(log).expect("read log");
|
||||||
let mut draws = parse_capture(&text);
|
let mut draws = parse_capture(&text);
|
||||||
if draws.is_empty() {
|
if draws.is_empty() {
|
||||||
@@ -44,7 +55,8 @@ fn main() {
|
|||||||
eprintln!("parsed {} draws (F10 capture format)", draws.len());
|
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 bytes = {
|
||||||
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
@@ -53,33 +65,69 @@ fn main() {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
let names = xbg7_resource_names(&bytes);
|
let names = xbg7_resource_names(&bytes);
|
||||||
let want: HashSet<String> = names
|
let base_parts: Vec<String> = names
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
|
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.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 models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||||
let parts: Vec<(String, u32)> = models
|
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
|
||||||
.iter()
|
let m = models.iter().find(|m| m.name == name)?;
|
||||||
.map(|m| {
|
Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
|
||||||
let vc = m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32;
|
};
|
||||||
(m.name.clone(), vc)
|
|
||||||
})
|
|
||||||
.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");
|
eprintln!("no parts matched a captured draw");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
eprintln!("\nreference = {} → ship-relative placement:", ship.reference);
|
eprintln!("\nreference = {} → ship-relative placement:", ship.reference);
|
||||||
for p in &ship.parts {
|
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> =
|
for part in &base_parts {
|
||||||
parts.iter().map(|(n, _)| n.as_str()).filter(|n| !ship.parts.iter().any(|p| p.part == *n)).collect();
|
if !ship.parts.iter().any(|p| &p.part == part) {
|
||||||
if !unmatched.is_empty() {
|
eprintln!(" {part:18} — NOT placed (culled, or its only vcount hit failed position validation)");
|
||||||
eprintln!(" (no captured draw — occluded/culled: {})", unmatched.join(", "));
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if emit {
|
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
|
/// One captured draw's rigid **WorldView**: rotation rows `r` + view-space
|
||||||
/// translation `t`, recovered from the `c0..c2` WVP constants.
|
/// translation `t`, recovered from the `c0..c2` WVP constants.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct CapturedDraw {
|
pub struct CapturedDraw {
|
||||||
/// Guest vertex-buffer base address (the draw's identity for de-duping).
|
/// Guest vertex-buffer base address (the draw's identity for de-duping).
|
||||||
pub vbase: u32,
|
pub vbase: u32,
|
||||||
@@ -46,6 +46,25 @@ pub struct CapturedDraw {
|
|||||||
pub r: M3,
|
pub r: M3,
|
||||||
/// WorldView view-space translation.
|
/// WorldView view-space translation.
|
||||||
pub t: [f64; 3],
|
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).
|
/// 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 out = Vec::new();
|
||||||
let mut vbase = 0u32;
|
let mut vbase = 0u32;
|
||||||
let mut vcount = 0u32;
|
let mut vcount = 0u32;
|
||||||
|
let mut pos: Vec<[f32; 3]> = Vec::new();
|
||||||
let mut consts: Vec<(usize, [f64; 4])> = 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 {
|
if vbase == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -90,18 +115,20 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
|
|||||||
return; // no WorldView for this draw — skip it
|
return; // no WorldView for this draw — skip it
|
||||||
};
|
};
|
||||||
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
|
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() {
|
for line in text.lines() {
|
||||||
let l = line.trim();
|
let l = line.trim();
|
||||||
if let Some(rest) = l.strip_prefix("DRAW ") {
|
if let Some(rest) = l.strip_prefix("DRAW ") {
|
||||||
flush(vbase, vcount, &consts, &mut out);
|
flush(vbase, vcount, &mut pos, &consts, &mut out);
|
||||||
consts.clear();
|
consts.clear();
|
||||||
let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
|
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);
|
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);
|
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") {
|
} else if l.starts_with("vsconst") {
|
||||||
for cap in l.split('c').skip(1) {
|
for cap in l.split('c').skip(1) {
|
||||||
let Some((idx, rest)) = cap.split_once('=') else { continue };
|
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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,33 +190,38 @@ pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
|
|||||||
let mut base = 0u32;
|
let mut base = 0u32;
|
||||||
let mut stride = 0u32;
|
let mut stride = 0u32;
|
||||||
let mut size = 0u32;
|
let mut size = 0u32;
|
||||||
|
let mut pos: Vec<[f32; 3]> = Vec::new();
|
||||||
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
|
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
|
||||||
|
|
||||||
let mut flush = |base: u32,
|
let mut flush = |base: u32,
|
||||||
size: u32,
|
size: u32,
|
||||||
stride: u32,
|
stride: u32,
|
||||||
|
pos: &mut Vec<[f32; 3]>,
|
||||||
consts: &[(usize, [f64; 4])],
|
consts: &[(usize, [f64; 4])],
|
||||||
seen: &mut std::collections::HashSet<u32>,
|
seen: &mut std::collections::HashSet<u32>,
|
||||||
out: &mut Vec<CapturedDraw>| {
|
out: &mut Vec<CapturedDraw>| {
|
||||||
|
let pos = std::mem::take(pos);
|
||||||
if base == 0 || stride == 0 || !seen.insert(base) {
|
if base == 0 || stride == 0 || !seen.insert(base) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
|
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 };
|
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]) {
|
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() {
|
for line in text.lines() {
|
||||||
let l = line.trim();
|
let l = line.trim();
|
||||||
if let Some(rest) = l.strip_prefix("DRAW ") {
|
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();
|
consts.clear();
|
||||||
base = 0;
|
base = 0;
|
||||||
stride = 0;
|
stride = 0;
|
||||||
size = 0;
|
size = 0;
|
||||||
is_ship = rest.contains(&format!("vs={SHIP_VS_HASH}"));
|
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 ") {
|
} else if is_ship && l.starts_with("stream ") {
|
||||||
let f = |k: &str| l.split_whitespace().find_map(|t| t.strip_prefix(k));
|
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()) {
|
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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,42 +261,105 @@ fn normalize_wvp(rows: [[f64; 4]; 3]) -> Option<(M3, [f64; 3])> {
|
|||||||
Some((r, t))
|
Some((r, t))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Correlate captured draws to a ship's base parts and express each in the
|
/// Validate a vcount hit by the draw's dumped positions against the part's
|
||||||
/// reference part's frame.
|
/// 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
|
/// Each [`PartKey`]'s `vcount` is the match key; when several draws share the
|
||||||
/// XBG7 decoder); vertex count is the match key. `ref_sub` selects the reference
|
/// vcount (mirrored port/starboard twins) the draw whose dumped positions match
|
||||||
/// part by substring (e.g. `bdy_04`); the first matched part is used if none
|
/// the part's `ref_pos` validates best is chosen. `ref_sub` selects the reference part by
|
||||||
/// contains it. Parts with no matching captured draw (culled at that camera
|
/// substring (e.g. `bdy_04`); the first matched part is used if none contains it.
|
||||||
/// angle) are omitted. Returns `None` if nothing matched.
|
/// Parts with no matching captured draw (culled at that camera angle) are
|
||||||
|
/// omitted. Returns `None` if nothing matched.
|
||||||
pub fn correlate(
|
pub fn correlate(
|
||||||
id: &str,
|
id: &str,
|
||||||
draws: &[CapturedDraw],
|
draws: &[CapturedDraw],
|
||||||
parts: &[(String, u32)],
|
parts: &[PartKey],
|
||||||
ref_sub: &str,
|
ref_sub: &str,
|
||||||
) -> Option<ShipPlacement> {
|
) -> Option<ShipPlacement> {
|
||||||
// Match each part to a distinct captured draw by vertex count.
|
let mut matched: Vec<(String, M3, [f64; 3], bool)> = Vec::new();
|
||||||
let mut matched: Vec<(String, M3, [f64; 3])> = Vec::new();
|
|
||||||
let mut used: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
let mut used: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||||
for (part, vc) in parts {
|
for key in parts {
|
||||||
if let Some(d) = draws.iter().find(|d| d.vcount == *vc && !used.contains(&d.vbase)) {
|
// 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);
|
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_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 rt_ref = transpose(&ref_r);
|
||||||
|
|
||||||
let parts_out = matched
|
let parts_out = matched
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(part, r, t)| {
|
.map(|(part, r, t, mirrored)| {
|
||||||
let rel_r = mmul(&rt_ref, r);
|
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 dt = [t[0] - ref_t[0], t[1] - ref_t[1], t[2] - ref_t[2]];
|
||||||
let rel_t = mat_vec(&rt_ref, dt);
|
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 {
|
PartPlacement {
|
||||||
part: part.clone(),
|
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],
|
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 })
|
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).
|
/// Convert a captured placement into viewer [`ScenePart`]s (rigid, unit scale).
|
||||||
pub fn to_scene_parts(ship: &ShipPlacement) -> Vec<ScenePart> {
|
pub fn to_scene_parts(ship: &ShipPlacement) -> Vec<ScenePart> {
|
||||||
ship.parts
|
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],
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn parse_recovers_worldview() {
|
fn parse_recovers_worldview() {
|
||||||
let log = draw_line(0x1000, 3, [10.0, 0.0, 0.0]);
|
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])
|
draw_line(0x2000, 4, [10.0, 0.0, 50.0])
|
||||||
);
|
);
|
||||||
let draws = parse_capture(&log);
|
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();
|
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
|
||||||
assert_eq!(ship.reference, "e106_bdy_04");
|
assert_eq!(ship.reference, "e106_bdy_04");
|
||||||
let refp = ship.parts.iter().find(|p| p.part == "e106_bdy_04").unwrap();
|
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[0].vcount, 1633); // 9798/6
|
||||||
assert_eq!(draws[1].vcount, 815); // 4890/6
|
assert_eq!(draws[1].vcount, 815); // 4890/6
|
||||||
// Correlate: part B sits 50 along Z from reference part A.
|
// 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 ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
|
||||||
let b = ship.parts.iter().find(|p| p.part == "e106_bdy_03").unwrap();
|
let b = ship.parts.iter().find(|p| p.part == "e106_bdy_03").unwrap();
|
||||||
assert_eq!(b.t, [0.0, 0.0, 50.0]);
|
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]
|
#[test]
|
||||||
fn table_round_trips() {
|
fn table_round_trips() {
|
||||||
let ship = ShipPlacement {
|
let ship = ShipPlacement {
|
||||||
@@ -476,4 +672,23 @@ mod tests {
|
|||||||
// The checked-in data file must always parse (even if empty).
|
// The checked-in data file must always parse (even if empty).
|
||||||
let _ = parse_table(EMBEDDED_TABLE);
|
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 {
|
let Some(src) = base.iter().find(|m| m.name == p.resource) else {
|
||||||
continue;
|
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();
|
let mut m = src.clone();
|
||||||
for sub in &mut m.meshes {
|
for sub in &mut m.meshes {
|
||||||
for v in &mut sub.positions {
|
for v in &mut sub.positions {
|
||||||
@@ -4000,6 +4006,11 @@ fn build_ship_model(
|
|||||||
for nrm in &mut sub.normals {
|
for nrm in &mut sub.normals {
|
||||||
*nrm = rot(&p.m, nrm);
|
*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);
|
models.push(m);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# Capital-ship part placement — runtime capture (ground truth)
|
# Capital-ship part placement — runtime capture (ground truth)
|
||||||
|
|
||||||
**Status:** pipeline working end-to-end for one ship (`e106` ADAN Destroyer);
|
**Status:** ✅ **`e106` ADAN Destroyer FULLY BAKED (2026-07-26)** — all 8 parts
|
||||||
correlator recovers exact ship-relative transforms. **Bake plumbing landed** — the
|
(incl. the bridge both earlier captures missed, and the runtime-mirrored
|
||||||
correlator is now a tested library module and the viewer prefers a captured table;
|
starboard hull) recovered from a second F10 capture and checked in to
|
||||||
only real capture data still needs baking in (needs the emulator + F10).
|
`data/ship_placements.txt`; the viewer renders it via the captured table.
|
||||||
|
Remaining ships (e105, f101, f105/f106, turrets) need one capture each.
|
||||||
|
|
||||||
## Problem
|
## 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
|
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.
|
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:
|
`examples/correlate_capture.rs` is the CLI wrapper:
|
||||||
```
|
```
|
||||||
SYLPHEED_ISO=… cargo run --release --example correlate_capture -- \
|
SYLPHEED_ISO=… cargo run --release --example correlate_capture -- \
|
||||||
|
|||||||
Reference in New Issue
Block a user