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:
MechaCat02
2026-07-26 18:26:23 +02:00
parent 4efc0278b1
commit d9442a2106
7 changed files with 426 additions and 62 deletions

View File

@@ -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);
}
}