`cargo fmt --all -- --check` has failed on every run in this repository's history, identically on `main` and on every branch. This is #12. Mechanical: `cargo fmt --all`, nothing else. 154 files, all `.rs`, no other extension touched. `cargo check --workspace` exits 0 afterwards, so nothing changed semantically. ON THE ORDERING, WHICH WAS THE REAL QUESTION. HANDOFF-2026-09-06 section 7 warns this is the expensive fix: a whole-tree reformat before #7 and #8 return "would put a conflict in every file of 861 commits and make the reviews those items exist to enable unreadable". That is measurably too pessimistic, and it had been reasoned rather than tested. Measured here by three-way merging a rustfmt'd `main` against both unmerged branches, file by file: file/branch pairs tested 32 merges CLEAN 28 merges CONFLICTING 4 (8 conflict hunks total) sylpheed-cli/src/main.rs 1 hunk sylpheed-export/src/check.rs 1 sylpheed-export/src/screen.rs 4 sylpheed-export/src/video.rs 2 All four are against `auto/frame-blend-draw-path` only; `auto/port-p6-audio` does not conflict anywhere. The earlier framing -- 154 dirty files, 133 that cannot collide, 21 that can, the collision set carrying 147 of 774 hunks (19%) -- reproduces exactly. What it did not say is that most of the 21 still merge cleanly, because rustfmt's edits and the branches' edits rarely land on the same lines. So the cost of sweeping now is 4 files and 8 hunks for one branch, against a check that is otherwise red forever. Deliberately NOT folded into the WASM PR: 154 reformatted files would make that one unreviewable. Closes #12
881 lines
36 KiB
Rust
881 lines
36 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 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.is_none_or(|(_, 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);
|
||
}
|
||
}
|