ship: index-less brg/eng/sld parts never matched their GN frame — 34 ships assembled without a bridge

Tier 3 matched a part to its hardpoint by trailing index, so `e105_brg`
compared "01" == "" against GN_Bridge_01 and fell through silently. The runtime
capture is what exposed it: the game draws the bridge and places it at
[0, 70, -1850] rel e105_bdy_01, and assemble_ship emitted nothing there.

With no index to match on, take the lowest-numbered frame of the category.
Diffing assemble_ship part counts across every container: 34 (stage, ship)
entries gain parts — e102 +2 (bridge and engine), e104 +1, e105 +1, Stages
02-29. ship_audit is unchanged, so nothing regressed, and the capture now agrees
to dT 0.03 / dR 0.000.

Also fixes the diff itself: correlate_frames compared static against a rotation
sampled from the first block, which can belong to another INSTANCE of the class.
Scoped to the position-agreeing cluster, e105_eng_01 goes 1.711 -> 0.000 and
both e106 nacelles to 0.000. The one remaining rotation delta (e106_wep_02_01,
0.134) is a turret whose rotation varies by 0.182 between blocks that agree on
its position — the runtime disagrees with itself more than with the assembler.
The new rotVar column makes that distinction visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-10 19:16:06 +00:00
parent 3d9f21f030
commit ca500c171e
4 changed files with 88 additions and 31 deletions

View File

@@ -143,13 +143,14 @@ fn main() {
// is meaningless; the largest cluster of blocks that agree with each other
// is the placement, and the rest are honestly reported as other instances.
const TOL: f32 = 25.0; // float noise in the WV products, measured ≤0.4
let cluster = |ts: &Vec<[f32; 3]>| -> (Vec<[f32; 3]>, usize) {
let mut best: Vec<[f32; 3]> = Vec::new();
let cluster = |ts: &Vec<[f32; 3]>| -> (Vec<usize>, usize) {
let mut best: Vec<usize> = Vec::new();
for seed in ts {
let near: Vec<[f32; 3]> = ts
let near: Vec<usize> = ts
.iter()
.filter(|t| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL))
.copied()
.enumerate()
.filter(|(_, t)| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL))
.map(|(i, _)| i)
.collect();
if near.len() > best.len() {
best = near;
@@ -160,15 +161,15 @@ fn main() {
};
println!("\nacross {used_frames} blocks — consensus T (largest agreeing cluster):");
let mut agree = 0usize;
let mut consensus: BTreeMap<String, [f32; 3]> = BTreeMap::new();
let mut consensus: BTreeMap<String, ([f32; 3], [[f32; 3]; 3])> = BTreeMap::new();
for (part, ts) in &samples {
let (cl, outliers) = cluster(ts);
let (cl_idx, outliers) = cluster(ts);
let cl: Vec<[f32; 3]> = cl_idx.iter().map(|&i| ts[i]).collect();
let med = [
median(cl.iter().map(|t| t[0]).collect()),
median(cl.iter().map(|t| t[1]).collect()),
median(cl.iter().map(|t| t[2]).collect()),
];
consensus.insert(part.clone(), med);
let spread: Vec<f32> = (0..3)
.map(|a| {
let v: Vec<f32> = cl.iter().map(|t| t[a]).collect();
@@ -181,8 +182,32 @@ fn main() {
agree += 1;
"AGREES"
};
// How much the ROTATION varies between blocks that agree on position.
// A part bolted to the hull reads 0 here; a part that is articulating
// (turret aiming, engine gimballing) does not — which is what separates
// "the assembler has the rotation wrong" from "the part moved".
let all_ms = rots.get(part).cloned().unwrap_or_default();
let ms: Vec<[[f32; 3]; 3]> =
cl_idx.iter().filter_map(|&i| all_ms.get(i).copied()).collect();
// Keep a rotation from INSIDE the cluster: the first sample overall can
// belong to another instance, and diffing static against that reads as a
// rotation error that is really an instance mix-up.
consensus.insert(
part.clone(),
(med, ms.first().copied().unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])),
);
let rot_var = ms
.iter()
.flat_map(|a| ms.iter().map(move |b| (a, b)))
.map(|(a, b)| {
(0..3)
.flat_map(|i| (0..3).map(move |j| (i, j)))
.map(|(i, j)| (a[i][j] - b[i][j]).abs())
.fold(0.0f32, f32::max)
})
.fold(0.0f32, f32::max);
println!(
" {part:18} {:2}/{:2} blocks T=[{:9.1}{:9.1}{:9.1}] spread=[{:6.2}{:6.2}{:6.2}] {verdict}{}",
" {part:18} {:2}/{:2} blocks T=[{:9.1}{:9.1}{:9.1}] spread=[{:6.2}{:6.2}{:6.2}] rotVar={rot_var:5.3} {verdict}{}",
cl.len(), ts.len(), med[0], med[1], med[2], spread[0], spread[1], spread[2],
if outliers > 0 { format!(" (+{outliers} other-instance)") } else { String::new() }
);
@@ -212,8 +237,8 @@ fn main() {
println!("\nstatic vs runtime (both relative to {}):", sref.resource);
let mut worst_t = 0.0f32;
let mut worst_r = 0.0f32;
for (part, med) in &consensus {
let med = *med;
for (part, (med, rm)) in &consensus {
let (med, rm) = (*med, *rm);
// A part may be instanced (mirrored twins share a resource name); take
// the static copy that lands nearest the captured one.
let cands: Vec<&sylpheed_formats::mesh::ScenePart> =
@@ -238,7 +263,6 @@ fn main() {
let r = rel(best);
let dt: Vec<f32> = (0..3).map(|i| r[i] - med[i]).collect();
let dtm = dt.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
let rm = rots.get(part).map(|v| v[0]).unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
let drm = (0..3)
.flat_map(|i| (0..3).map(move |j| (i, j)))
.map(|(i, j)| (best.m[i][j] - rm[i][j]).abs())