Extending the capture comparison from index COUNTS to index VALUES (`examples/capture_index_bytes.rs`, using the batch offsets the new ib logging gives) showed 76 of 93 Stage_S02 index runs identical to the GPU's and 17 differing — every difference a shift by exactly one element, on buffers whose index data sits at pad 2. `anchor_pool_mesh` returned the FIRST pad that validated, and pad 0 is tried first with the looser winding gate (0.70 vs 0.85). Read at pad 0, a pad-2 block yields [true[1], true[2], …, garbage]: every index in range, the pool covered, the positions right, the winding often just above 0.70 — so it validated, and every triangle was mis-wired. Nothing count-based could see it. The signature is decidable without the capture: a shifted run wires arbitrary vertices, so triangles come out degenerate. 282 of 283 correctly anchored Stage_S02 blocks have zero degenerate triangles, while the shifted readings carry 1–2 156. So score every validating pad by (degenerate triangles, then winding) and keep the best. `XBG7_PAD_FIRST_MATCH=1` restores the old behaviour. captured index runs identical: 76/93 -> 93/93 (2 025 elements) decoded runs with a degenerate triangle: 579 -> 16 (disc-wide) sub-meshes whose index run changed: 575 of 8 850 resources decoded / vertex anchors / consistency: unchanged (6 209 / same vb / 89) Locked in by tests/mesh_disc.rs::decoded_index_runs_have_almost_no_degenerate_triangles. Suite green with --include-ignored apart from the pre-existing known-failing cross-container consistency test (the 24-vertex bounding-box class). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
798 lines
35 KiB
Rust
798 lines
35 KiB
Rust
//! Exact capital-ship part placement from a Canary **F10 ship-capture** log —
|
||
//! the runtime ground truth that static [`crate::ship::assemble_ship`] only
|
||
//! approximates for external parts.
|
||
//!
|
||
//! ## Why a capture is needed
|
||
//!
|
||
//! A captured part's vertex BUFFER holds **local** coordinates (byte-identical to
|
||
//! the `.xpr`), so capital-ship parts are placed **entirely in the vertex shader**
|
||
//! — the buffer carries no placement. The per-part transform lives in the ship
|
||
//! shader's **vertex float constants**: `c0..c2` are the **WorldViewProjection**
|
||
//! matrix rows. Each row's norm is `(sx, sy, 1)` (the projection x/y scales, with
|
||
//! `sy/sx ≈ 1.78 = 16:9`); dividing a row by its norm yields the rigid
|
||
//! **WorldView** row (verified orthonormal, `det = +1`) and `row[3]/norm` is that
|
||
//! axis' view-space translation.
|
||
//!
|
||
//! The camera View cancels when every part is expressed relative to a **reference
|
||
//! part**: `rel_p = WV_ref⁻¹ · WV_p = (Rᵀ_ref·R_p , Rᵀ_ref·(T_p − T_ref))` — a pure
|
||
//! ship-space rigid transform. That is what [`correlate`] emits and what the
|
||
//! checked-in [placement table](parse_table) stores, so the viewer can assemble a
|
||
//! ship exactly without re-capturing.
|
||
//!
|
||
//! ## Pipeline
|
||
//!
|
||
//! 1. Canary F10 → `xenia_ship_capture.log` (per-draw `vbase`/`vcount` + the first
|
||
//! 48 VS float4 constants). [`parse_capture`] → [`CapturedDraw`]s.
|
||
//! 2. [`correlate`] matches each ship base part to a draw **by vertex count**
|
||
//! (unique per part) and expresses it in the reference part's frame →
|
||
//! [`ShipPlacement`].
|
||
//! 3. [`serialize_table`]/[`parse_table`] persist it as a checked-in data file
|
||
//! (`data/ship_placements.txt`, embedded via [`embedded_placement`]); the viewer
|
||
//! prefers it over the static assembler when present.
|
||
|
||
use crate::mesh::ScenePart;
|
||
|
||
type M3 = [[f64; 3]; 3];
|
||
|
||
/// One captured draw's rigid **WorldView**: rotation rows `r` + view-space
|
||
/// translation `t`, recovered from the `c0..c2` WVP constants.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct CapturedDraw {
|
||
/// Guest vertex-buffer base address (the draw's identity for de-duping).
|
||
pub vbase: u32,
|
||
/// Vertex count — the key that matches a draw to a decoded part.
|
||
pub vcount: u32,
|
||
/// WorldView rotation rows (orthonormal).
|
||
pub r: M3,
|
||
/// WorldView view-space translation.
|
||
pub t: [f64; 3],
|
||
/// 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]>,
|
||
/// The draw's INDEX buffer, when the capture recorded one (`ib base=…`,
|
||
/// added 2026-08-13): guest base address, index count, and the min/max index
|
||
/// value the emulator read out of guest memory. `None` for older logs and
|
||
/// for auto-index draws. This is ground truth for two things the offline
|
||
/// decoder can only assume — where a block's index buffer lives relative to
|
||
/// its vertex buffer, and how much of the vertex pool a draw really covers.
|
||
pub ib: Option<CapturedIndexBuffer>,
|
||
}
|
||
|
||
/// The index buffer a captured draw used. See [`CapturedDraw::ib`].
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct CapturedIndexBuffer {
|
||
/// Guest base address of the index data.
|
||
pub ibase: u32,
|
||
/// Number of indices the draw issued (== `VGT_DRAW_INITIATOR.num_indices`).
|
||
pub icount: u32,
|
||
/// Lowest index value in the buffer.
|
||
pub imin: u32,
|
||
/// Highest index value in the buffer — with `vcount` this says whether the
|
||
/// draw covers its whole vertex pool or only a sub-range.
|
||
pub imax: u32,
|
||
/// The first indices, verbatim (the capture prints up to 24). Byte-level
|
||
/// ground truth for the offline index decode: a matched block's decoded
|
||
/// index prefix must equal this run.
|
||
pub head: [u32; 24],
|
||
/// How many of `head` the capture actually carried.
|
||
pub head_len: u8,
|
||
}
|
||
|
||
/// 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).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct PartPlacement {
|
||
/// Geometry resource name, e.g. `e106_eng_01`.
|
||
pub part: String,
|
||
/// Rotation rows.
|
||
pub m: [[f32; 3]; 3],
|
||
/// Ship-relative translation.
|
||
pub t: [f32; 3],
|
||
}
|
||
|
||
/// A whole ship's captured placement: every part in a shared ship-local frame.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct ShipPlacement {
|
||
/// Ship family id, e.g. `e106`.
|
||
pub id: String,
|
||
/// The reference part whose frame the placements are expressed in.
|
||
pub reference: String,
|
||
/// Ship-relative placement of each matched part.
|
||
pub parts: Vec<PartPlacement>,
|
||
}
|
||
|
||
/// Parse a Canary ship-capture log into per-draw rigid WorldView transforms.
|
||
///
|
||
/// Only draws that carry the `c0..c2` constants are returned (a culled/occluded
|
||
/// part produces no draw and is simply absent). Robust to the exact spacing of
|
||
/// the `DRAW …` / `vsconst …` lines.
|
||
pub fn parse_capture(text: &str) -> Vec<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 mut ib: Option<CapturedIndexBuffer> = None;
|
||
|
||
let flush = |vbase: u32,
|
||
vcount: u32,
|
||
pos: &mut Vec<[f32; 3]>,
|
||
ib: &mut Option<CapturedIndexBuffer>,
|
||
consts: &[(usize, [f64; 4])],
|
||
out: &mut Vec<CapturedDraw>| {
|
||
let pos = std::mem::take(pos);
|
||
let ib = ib.take();
|
||
if vbase == 0 {
|
||
return;
|
||
}
|
||
let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
|
||
let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else {
|
||
return; // no WorldView for this draw — skip it
|
||
};
|
||
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
|
||
out.push(CapturedDraw { vbase, vcount, r, t, pos, ib });
|
||
}
|
||
};
|
||
|
||
for line in text.lines() {
|
||
let l = line.trim();
|
||
if let Some(rest) = l.strip_prefix("DRAW ") {
|
||
flush(vbase, vcount, &mut pos, &mut ib, &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 let Some(rest) = l.strip_prefix("ib base=0x") {
|
||
// `ib base=0x… count=N fmt=u16 endian=E len=L delta_vb=D min=a max=b idx: …`
|
||
let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
|
||
let base = rest
|
||
.split_whitespace()
|
||
.next()
|
||
.and_then(|s| u32::from_str_radix(s, 16).ok());
|
||
if let (Some(ibase), Some(icount)) = (base, f("count=").and_then(|s| s.parse().ok())) {
|
||
let mut head = [0u32; 24];
|
||
let mut head_len = 0u8;
|
||
if let Some((_, list)) = l.split_once("idx:") {
|
||
for tok in list.split_whitespace() {
|
||
let Ok(v) = tok.parse::<u32>() else { break };
|
||
if head_len as usize >= head.len() {
|
||
break;
|
||
}
|
||
head[head_len as usize] = v;
|
||
head_len += 1;
|
||
}
|
||
}
|
||
ib = Some(CapturedIndexBuffer {
|
||
ibase,
|
||
icount,
|
||
imin: f("min=").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||
imax: f("max=").and_then(|s| s.parse().ok()).unwrap_or(0),
|
||
head,
|
||
head_len,
|
||
});
|
||
}
|
||
} 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 };
|
||
let Ok(i) = idx.trim().parse::<usize>() else { continue };
|
||
let nums: Vec<f64> = rest
|
||
.trim_start_matches('(')
|
||
.split(')')
|
||
.next()
|
||
.unwrap_or("")
|
||
.split(',')
|
||
.filter_map(|x| x.trim().parse().ok())
|
||
.collect();
|
||
if nums.len() == 4 {
|
||
consts.push((i, [nums[0], nums[1], nums[2], nums[3]]));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
flush(vbase, vcount, &mut pos, &mut ib, &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
|
||
}
|
||
|
||
/// The vertex shader capital ships (and the player fighter) are drawn with — the
|
||
/// `c0..c2` WVP-row layout [`parse_capture`]/[`parse_drawlog`] rely on.
|
||
pub const SHIP_VS_HASH: &str = "0xC7F781F4C1D58054";
|
||
|
||
/// Parse the **draw-logger** format (`xenia_re_draws.log` / `mission_draws.log`,
|
||
/// the `--log_draws` cvar) into per-part rigid transforms — the alternative to the
|
||
/// F10 [`parse_capture`] snapshot. That log is what a normal instrumented run
|
||
/// already produces, so a capital ship seen in-mission can be baked without a
|
||
/// dedicated F10 capture.
|
||
///
|
||
/// A capital ship's parts each own a **distinct vertex buffer**, so we group by
|
||
/// the `stream … base=…` address, take its vertex count as `size_words /
|
||
/// stride_words`, and keep the first `c0..c2` WVP seen for that buffer. Only draws
|
||
/// with `vs=`[`SHIP_VS_HASH`] are kept (the ship shader), so HUD/skybox draws are
|
||
/// ignored. (The player fighter shares ONE buffer across its fin draws and so
|
||
/// collapses to a single entry here — fine, capital ships are the target.)
|
||
/// Split a capture into blocks that are guaranteed to share one camera.
|
||
///
|
||
/// **Why this is not optional.** One F10 press dumps a flat list of draws with
|
||
/// no frame delimiter, and it spans ~14 frames (the same vertex buffer recurs
|
||
/// that many times). The placement math is `WV_ref⁻¹ · WV_p`, which cancels the
|
||
/// camera **only when both draws come from the same frame** — mix frames and
|
||
/// the residual is the camera's motion between them. With a static ship and a
|
||
/// static camera that error is invisible, which is how the single validated
|
||
/// `e106` capture passed; closing on a cruiser at ~760 u/s it is hundreds of
|
||
/// units, and two frames of the same ship then disagree about where its parts
|
||
/// are (measured 2026-08-10: `f105_bdy_02` at `[488, 736, -620]` vs
|
||
/// `[0, 0, -1090]`).
|
||
///
|
||
/// The split rule is the recurrence itself: a vertex buffer that appears again
|
||
/// starts a new block. Splitting too eagerly is harmless (a block is still one
|
||
/// camera, just with fewer parts in it) and it separates two instances of the
|
||
/// same class as a bonus; failing to split is what corrupts the result.
|
||
pub fn segment_frames(draws: &[CapturedDraw]) -> Vec<Vec<CapturedDraw>> {
|
||
let mut out: Vec<Vec<CapturedDraw>> = Vec::new();
|
||
let mut cur: Vec<CapturedDraw> = Vec::new();
|
||
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||
for d in draws {
|
||
if !seen.insert(d.vbase) {
|
||
out.push(std::mem::take(&mut cur));
|
||
seen.clear();
|
||
seen.insert(d.vbase);
|
||
}
|
||
cur.push(d.clone());
|
||
}
|
||
if !cur.is_empty() {
|
||
out.push(cur);
|
||
}
|
||
out
|
||
}
|
||
|
||
pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
|
||
let mut out = Vec::new();
|
||
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||
let mut is_ship = false;
|
||
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]) {
|
||
// The draw-logger format carries an index base too, but it de-dups
|
||
// by vertex declaration, so it never lines up per part — left None.
|
||
out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t, pos, ib: None });
|
||
}
|
||
};
|
||
|
||
for line in text.lines() {
|
||
let l = line.trim();
|
||
if let Some(rest) = l.strip_prefix("DRAW ") {
|
||
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()) {
|
||
base = b;
|
||
}
|
||
stride = f("stride_words=").and_then(|s| s.parse().ok()).unwrap_or(stride);
|
||
size = f("size_words=").and_then(|s| s.parse().ok()).unwrap_or(size);
|
||
} else if is_ship && l.starts_with('c') {
|
||
// `c<idx> x y z w` — the vsconst rows (space-separated).
|
||
let mut it = l.splitn(2, char::is_whitespace);
|
||
let Some(tag) = it.next() else { continue };
|
||
let Ok(i) = tag[1..].parse::<usize>() else { continue };
|
||
let nums: Vec<f64> = it.next().unwrap_or("").split_whitespace().filter_map(|x| x.parse().ok()).collect();
|
||
if nums.len() >= 4 {
|
||
consts.push((i, [nums[0], nums[1], nums[2], nums[3]]));
|
||
}
|
||
}
|
||
}
|
||
flush(base, size, stride, &mut pos, &consts, &mut seen, &mut out);
|
||
out
|
||
}
|
||
|
||
/// Normalize the three `c0..c2` WVP rows to a rigid WorldView `(R rows, T)` by
|
||
/// dividing each row by its (projection-scale) norm. `None` if any row is
|
||
/// degenerate.
|
||
fn normalize_wvp(rows: [[f64; 4]; 3]) -> Option<(M3, [f64; 3])> {
|
||
let mut r = [[0.0; 3]; 3];
|
||
let mut t = [0.0; 3];
|
||
for (i, row) in rows.iter().enumerate() {
|
||
let n = (row[0] * row[0] + row[1] * row[1] + row[2] * row[2]).sqrt();
|
||
if n < 1e-6 {
|
||
return None;
|
||
}
|
||
r[i] = [row[0] / n, row[1] / n, row[2] / n];
|
||
t[i] = row[3] / n;
|
||
}
|
||
Some((r, t))
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// 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: &[PartKey],
|
||
ref_sub: &str,
|
||
) -> Option<ShipPlacement> {
|
||
let mut matched: Vec<(String, M3, [f64; 3], bool)> = Vec::new();
|
||
let mut used: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||
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((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 rt_ref = transpose(&ref_r);
|
||
|
||
let parts_out = matched
|
||
.iter()
|
||
.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: snap_m3(&rel_r),
|
||
t: [rel_t[0] as f32, rel_t[1] as f32, rel_t[2] as f32],
|
||
}
|
||
})
|
||
.collect();
|
||
Some(ShipPlacement { id: id.to_string(), reference: ref_part, parts: parts_out })
|
||
}
|
||
|
||
/// 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
|
||
.iter()
|
||
.map(|p| ScenePart { resource: p.part.clone(), m: p.m, t: p.t, s: [1.0, 1.0, 1.0] })
|
||
.collect()
|
||
}
|
||
|
||
/// The checked-in placement table, embedded at build time. Empty until captures
|
||
/// are baked in with `correlate_capture --emit`.
|
||
const EMBEDDED_TABLE: &str = include_str!("../data/ship_placements.txt");
|
||
|
||
/// The captured placement for ship `id` from the embedded table, if baked in.
|
||
pub fn embedded_placement(id: &str) -> Option<ShipPlacement> {
|
||
parse_table(EMBEDDED_TABLE).into_iter().find(|s| s.id == id)
|
||
}
|
||
|
||
/// Serialize a placement table to the checked-in text format (see [`parse_table`]).
|
||
pub fn serialize_table(ships: &[ShipPlacement]) -> String {
|
||
let mut s = String::new();
|
||
s.push_str("# Capital-ship part placements — runtime-captured ground truth.\n");
|
||
s.push_str("# Generated by: cargo run --release --example correlate_capture -- \\\n");
|
||
s.push_str("# <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] --emit\n");
|
||
s.push_str("# Per line: <part> <R00 R01 R02 R10 R11 R12 R20 R21 R22> <T0 T1 T2>\n");
|
||
for ship in ships {
|
||
s.push_str(&format!("\nship {} ref={}\n", ship.id, ship.reference));
|
||
for p in &ship.parts {
|
||
s.push_str(&format!(
|
||
" {} {} {} {} {} {} {} {} {} {} {} {} {}\n",
|
||
p.part,
|
||
p.m[0][0], p.m[0][1], p.m[0][2],
|
||
p.m[1][0], p.m[1][1], p.m[1][2],
|
||
p.m[2][0], p.m[2][1], p.m[2][2],
|
||
p.t[0], p.t[1], p.t[2],
|
||
));
|
||
}
|
||
}
|
||
s
|
||
}
|
||
|
||
/// Parse the checked-in placement table. `#` comments and blank lines are ignored;
|
||
/// a `ship <id> ref=<part>` line starts a block, and each following
|
||
/// `<part> <9 rotation floats> <3 translation floats>` line is one placement.
|
||
pub fn parse_table(text: &str) -> Vec<ShipPlacement> {
|
||
let mut ships: Vec<ShipPlacement> = Vec::new();
|
||
for line in text.lines() {
|
||
let l = line.trim();
|
||
if l.is_empty() || l.starts_with('#') {
|
||
continue;
|
||
}
|
||
if let Some(rest) = l.strip_prefix("ship ") {
|
||
let mut it = rest.split_whitespace();
|
||
let id = it.next().unwrap_or("").to_string();
|
||
let reference = it
|
||
.next()
|
||
.and_then(|s| s.strip_prefix("ref="))
|
||
.unwrap_or("")
|
||
.to_string();
|
||
ships.push(ShipPlacement { id, reference, parts: Vec::new() });
|
||
} else if let Some(ship) = ships.last_mut() {
|
||
let mut it = l.split_whitespace();
|
||
let part = it.next().unwrap_or("").to_string();
|
||
let nums: Vec<f32> = it.filter_map(|x| x.parse().ok()).collect();
|
||
if part.is_empty() || nums.len() != 12 {
|
||
continue;
|
||
}
|
||
ship.parts.push(PartPlacement {
|
||
part,
|
||
m: [
|
||
[nums[0], nums[1], nums[2]],
|
||
[nums[3], nums[4], nums[5]],
|
||
[nums[6], nums[7], nums[8]],
|
||
],
|
||
t: [nums[9], nums[10], nums[11]],
|
||
});
|
||
}
|
||
}
|
||
ships
|
||
}
|
||
|
||
fn transpose(m: &M3) -> M3 {
|
||
[
|
||
[m[0][0], m[1][0], m[2][0]],
|
||
[m[0][1], m[1][1], m[2][1]],
|
||
[m[0][2], m[1][2], m[2][2]],
|
||
]
|
||
}
|
||
fn mmul(a: &M3, b: &M3) -> M3 {
|
||
let mut o = [[0.0; 3]; 3];
|
||
for i in 0..3 {
|
||
for j in 0..3 {
|
||
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
|
||
}
|
||
}
|
||
o
|
||
}
|
||
fn mat_vec(m: &M3, v: [f64; 3]) -> [f64; 3] {
|
||
[
|
||
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
||
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
||
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// A `vsconst` line for an already-unit WorldView (norm 1) so R = rows and
|
||
/// T = row[3]: identity rotation, translation `t`.
|
||
fn draw_line(vbase: u32, vcount: u32, t: [f64; 3]) -> String {
|
||
format!(
|
||
"DRAW vbase=0x{vbase:X} stride=32 vcount={vcount} indices=0 prim=tri vs=0x1\n \
|
||
vsconst base=0: c0=(1,0,0,{}) c1=(0,1,0,{}) c2=(0,0,1,{})\n",
|
||
t[0], t[1], t[2]
|
||
)
|
||
}
|
||
|
||
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]);
|
||
let draws = parse_capture(&log);
|
||
assert_eq!(draws.len(), 1);
|
||
assert_eq!(draws[0].vcount, 3);
|
||
assert_eq!(draws[0].t, [10.0, 0.0, 0.0]);
|
||
assert_eq!(draws[0].r, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_normalizes_projection_scale() {
|
||
// A row scaled by the projection sx=2 must normalize back to unit, and its
|
||
// translation divides by the same norm.
|
||
let log = "DRAW vbase=0x2000 vcount=4\n \
|
||
vsconst base=0: c0=(2,0,0,20) c1=(0,2,0,0) c2=(0,0,1,0)\n";
|
||
let d = &parse_capture(log)[0];
|
||
assert!((d.r[0][0] - 1.0).abs() < 1e-9);
|
||
assert!((d.t[0] - 10.0).abs() < 1e-9, "20/2 = 10");
|
||
}
|
||
|
||
#[test]
|
||
fn correlate_expresses_parts_in_reference_frame() {
|
||
// Two parts, identity rotation: ref at view (10,0,0), other at (10,0,50).
|
||
// Relative to ref, the other must sit at (0,0,50).
|
||
let log = format!(
|
||
"{}{}",
|
||
draw_line(0x1000, 3, [10.0, 0.0, 0.0]),
|
||
draw_line(0x2000, 4, [10.0, 0.0, 50.0])
|
||
);
|
||
let draws = parse_capture(&log);
|
||
let parts = vec![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();
|
||
assert_eq!(refp.t, [0.0, 0.0, 0.0]);
|
||
let eng = ship.parts.iter().find(|p| p.part == "e106_eng_01").unwrap();
|
||
assert_eq!(eng.t, [0.0, 0.0, 50.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn drawlog_format_parses_capital_ship_parts() {
|
||
// Two capital-ship parts, each its own vertex buffer (distinct base),
|
||
// stride 6 → vcount = size_words/6. Identity WVP with known translations.
|
||
let log = format!(
|
||
"DRAW prim=4 indices=6 src=0 index[...] vs={SHIP_VS_HASH}\n \
|
||
stream fc=0 base=0x12A60228 stride_words=6 size_words=9798 endian=2 type=3\n \
|
||
vsconst:\n c0 1.0 0.0 0.0 10.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 0.0\n\
|
||
DRAW prim=4 indices=6 src=0 index[...] vs={SHIP_VS_HASH}\n \
|
||
stream fc=0 base=0x12ABF2CC stride_words=6 size_words=4890 endian=2 type=3\n \
|
||
vsconst:\n c0 1.0 0.0 0.0 10.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 50.0\n\
|
||
DRAW prim=4 indices=3 src=0 index[...] vs=0xDEADBEEF00000000\n \
|
||
stream fc=0 base=0x99990000 stride_words=6 size_words=18 endian=2 type=3\n \
|
||
vsconst:\n c0 1.0 0.0 0.0 0.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 0.0\n"
|
||
);
|
||
let draws = parse_drawlog(&log);
|
||
// Two ship-shader buffers; the non-ship shader draw is ignored.
|
||
assert_eq!(draws.len(), 2);
|
||
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![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 {
|
||
id: "e106".to_string(),
|
||
reference: "e106_bdy_04".to_string(),
|
||
parts: vec![
|
||
PartPlacement {
|
||
part: "e106_bdy_04".to_string(),
|
||
m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||
t: [0.0, 0.0, 0.0],
|
||
},
|
||
PartPlacement {
|
||
part: "e106_eng_01".to_string(),
|
||
m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||
t: [131.0, -133.0, -132.0],
|
||
},
|
||
],
|
||
};
|
||
let text = serialize_table(std::slice::from_ref(&ship));
|
||
let back = parse_table(&text);
|
||
assert_eq!(back, vec![ship]);
|
||
}
|
||
|
||
#[test]
|
||
fn embedded_table_parses() {
|
||
// The checked-in data file must always parse (even if empty).
|
||
let _ = parse_table(EMBEDDED_TABLE);
|
||
}
|
||
|
||
/// 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, **both plain**. The mirror is
|
||
// baked into the disc data, not into the placement: a runtime capture
|
||
// shows the container carrying two 119-vertex buffers whose contents are
|
||
// exact X-reflections, each drawn from its own address (see
|
||
// docs/re/structures/xbg7-mesh.md). Until 2026-08-12 both twins decoded
|
||
// to ONE buffer and this row carried diag(-1,1,1) to compensate; with
|
||
// distinct anchor assignment they decode to their own, and re-emitting
|
||
// from the capture produces identity here.
|
||
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);
|
||
}
|
||
}
|