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 meaningless; the largest cluster of blocks that agree with each other
// is the placement, and the rest are honestly reported as other instances. // 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 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 cluster = |ts: &Vec<[f32; 3]>| -> (Vec<usize>, usize) {
let mut best: Vec<[f32; 3]> = Vec::new(); let mut best: Vec<usize> = Vec::new();
for seed in ts { for seed in ts {
let near: Vec<[f32; 3]> = ts let near: Vec<usize> = ts
.iter() .iter()
.filter(|t| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL)) .enumerate()
.copied() .filter(|(_, t)| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL))
.map(|(i, _)| i)
.collect(); .collect();
if near.len() > best.len() { if near.len() > best.len() {
best = near; best = near;
@@ -160,15 +161,15 @@ fn main() {
}; };
println!("\nacross {used_frames} blocks — consensus T (largest agreeing cluster):"); println!("\nacross {used_frames} blocks — consensus T (largest agreeing cluster):");
let mut agree = 0usize; 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 { 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 = [ let med = [
median(cl.iter().map(|t| t[0]).collect()), median(cl.iter().map(|t| t[0]).collect()),
median(cl.iter().map(|t| t[1]).collect()), median(cl.iter().map(|t| t[1]).collect()),
median(cl.iter().map(|t| t[2]).collect()), median(cl.iter().map(|t| t[2]).collect()),
]; ];
consensus.insert(part.clone(), med);
let spread: Vec<f32> = (0..3) let spread: Vec<f32> = (0..3)
.map(|a| { .map(|a| {
let v: Vec<f32> = cl.iter().map(|t| t[a]).collect(); let v: Vec<f32> = cl.iter().map(|t| t[a]).collect();
@@ -181,8 +182,32 @@ fn main() {
agree += 1; agree += 1;
"AGREES" "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!( 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], 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() } 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); println!("\nstatic vs runtime (both relative to {}):", sref.resource);
let mut worst_t = 0.0f32; let mut worst_t = 0.0f32;
let mut worst_r = 0.0f32; let mut worst_r = 0.0f32;
for (part, med) in &consensus { for (part, (med, rm)) in &consensus {
let med = *med; let (med, rm) = (*med, *rm);
// A part may be instanced (mirrored twins share a resource name); take // A part may be instanced (mirrored twins share a resource name); take
// the static copy that lands nearest the captured one. // the static copy that lands nearest the captured one.
let cands: Vec<&sylpheed_formats::mesh::ScenePart> = let cands: Vec<&sylpheed_formats::mesh::ScenePart> =
@@ -238,7 +263,6 @@ fn main() {
let r = rel(best); let r = rel(best);
let dt: Vec<f32> = (0..3).map(|i| r[i] - med[i]).collect(); 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 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) let drm = (0..3)
.flat_map(|i| (0..3).map(move |j| (i, j))) .flat_map(|i| (0..3).map(move |j| (i, j)))
.map(|(i, j)| (best.m[i][j] - rm[i][j]).abs()) .map(|(i, j)| (best.m[i][j] - rm[i][j]).abs())

View File

@@ -349,9 +349,21 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else { let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else {
continue; continue;
}; };
if let Some(frame) = // An index-less part (`e105_brg`, against a `GN_Bridge_01` frame) used to
frames.iter().find(|f| f.resource.contains(gncat) && trailing_index(&f.resource) == idx) // compare `"01" == ""` and fall through, so the bridge was silently
{ // dropped from the assembly while the game draws it — caught by a runtime
// capture, which places `e105_brg` at the `GN_Bridge_01` frame exactly.
// With no index to match on, take the lowest-numbered frame of the
// category; an indexed part still matches its own index only.
let mut cands: Vec<&ScenePart> = frames
.iter()
.filter(|f| {
f.resource.contains(gncat)
&& (idx.is_empty() || trailing_index(&f.resource) == idx)
})
.collect();
cands.sort_by_key(|f| trailing_index(&f.resource).parse::<u32>().unwrap_or(u32::MAX));
if let Some(frame) = cands.first().copied() {
placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s }); placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s });
placed_res.insert(part.clone()); placed_res.insert(part.clone());
} }

View File

@@ -15,9 +15,12 @@ and to 0.000 in rotation for every part that does not move; see
[`ship-placement-capture-generalisation.md`](ship-placement-capture-generalisation.md) [`ship-placement-capture-generalisation.md`](ship-placement-capture-generalisation.md)
§4. So look at **the viewer**: first that it passes `include_external = true` §4. So look at **the viewer**: first that it passes `include_external = true`
(`iso_loader.rs:4012` — with `false` an e106 loses its bridge and both nacelles, (`iso_loader.rs:4012` — with `false` an e106 loses its bridge and both nacelles,
5 parts instead of 11), then its own transform stack. Two narrow format-side leftovers 5 parts instead of 11), then its own transform stack.
remain: `e105_brg` is never produced by `assemble_ship`, and `e105_eng_01`'s rotation
differs by 1.711. One real format-side bug was found on the way and is **fixed**: index-less parts
(`e105_brg`) never matched their `GN_Bridge_01` hardpoint, so 34 (stage, ship) entries
`e102`, `e104`, `e105` across Stages 0229 — assembled without a bridge. The other
apparent exception (`e105_eng_01` rotation) was an aggregation artefact and is 0.000.
The original report and its reasoning follow. The original report and its reasoning follow.

View File

@@ -192,16 +192,32 @@ for, and it is the opposite of the assumption in it: `assemble_ship` is right, s
viewer's "capital ships assemble wrong" is the viewer's own transform stack (the viewer's "capital ships assemble wrong" is the viewer's own transform stack (the
backlog's own "worth ruling out first, cheaply"). backlog's own "worth ruling out first, cheaply").
Two real exceptions, both narrow: Both first-pass exceptions were chased down, and neither survives as an open question:
- **`e105_brg` is never produced by `assemble_ship`**, at either `include_external` - **`e105_brg` was genuinely missing — a real assembler bug, now fixed.** Tier 3
setting, although the runtime draws it and places it at `[0.0, 70.0, -1850.0]` matched a part to its `GN_*` hardpoint by trailing index, so an index-less part
relative to `e105_bdy_01` (4/4 blocks, spread ≤0.13). ❔ A genuine missing part. (`e105_brg`) compared `"01" == ""` against `GN_Bridge_01` and fell through silently.
- **Rotation differs only on parts that move**: `e106_wep_02_01` (turret, dR 0.093), With no index to match on, take the lowest-numbered frame of the category. The
`e106_eng_01`/`eng_02` (dR ~0.097) and `e105_eng_01` (dR **1.711**). A turret aiming runtime is the check: `e105_brg` now assembles at `[0.0, 70.0, -1850.0]` relative to
and a nacelle gimballing at capture time is expected and is not an assembly error; `e105_bdy_01`, **dT 0.03, dR 0.000** against the capture.
1.711 on `e105_eng_01` is too large for that and is ❔ **unexplained — NEEDS-HUMAN** Reach measured by diffing `assemble_ship` part counts over all containers before and
(either a static rotation bug on that one part, or the part is articulated). after: **34 (stage, ship) entries gain parts**`e102` +2 (bridge *and* engine),
`e104` +1, `e105` +1, across Stages 0229. Every one of those ships was assembling
without its bridge. `ship_audit` is unchanged (still exactly the `f002_bdy_05`
outlier), so nothing regressed.
- ✅ **The rotation deltas were an artefact of my own aggregation, plus one real
articulation.** The static diff was comparing against a rotation taken from the
first sampled block, which can belong to *another instance* of the class; scoping it
to the position-agreeing cluster drops `e105_eng_01` from dR 1.711 to **0.000** and
both `e106` nacelles to **0.000**. What remains is `e106_wep_02_01` at dR 0.134 — and
that part's rotation varies by **0.182 between blocks that agree on its position**,
i.e. the runtime disagrees with itself more than it disagrees with the assembler.
It is a turret aiming, not an assembly error. `correlate_frames` now prints that
`rotVar` column precisely so "the part moved" cannot be mistaken for "the rotation
is wrong".
Final numbers, three classes, 21 parts: **worst dT 0.43, worst dR 0.000** for every
part that is not articulating.
`include_external` matters and is a caller-side trap: with `false` an `e106` assembles `include_external` matters and is a caller-side trap: with `false` an `e106` assembles
as **5** parts and with `true` as **11** — the engine cluster, the bridge and the as **5** parts and with `true` as **11** — the engine cluster, the bridge and the
@@ -223,7 +239,9 @@ bridge — which looks exactly like "assembles wrong".
frames. The `e106`-derived rules generalise; the MULTIKEY hypothesis in §1 is *not* frames. The `e106`-derived rules generalise; the MULTIKEY hypothesis in §1 is *not*
needed to explain anything observed so far (`f105` has 2 multikey tracks and still needed to explain anything observed so far (`f105` has 2 multikey tracks and still
matches exactly). matches exactly).
- ❌ Still open, both narrow and both evidenced: `e105_brg` is missing from - ✅ One real assembler bug found and fixed by this route: index-less `brg`/`eng`/`sld`
`assemble_ship`, and `e105_eng_01`'s rotation differs by 1.711 — NEEDS-HUMAN. parts never matched their `GN_*` frame, so **34 (stage, ship) entries** assembled
- ▶ Next: chase those two, and point the viewer investigation at the viewer without a bridge (and `e102` also without its engine). Verified against the capture.
(`include_external`, node-instance recursion), not at the format layer. - ▶ Next: the viewer itself (`include_external`, node-instance recursion) the format
layer is now measured, not assumed. A per-ship regression table over the checked-in
captures would keep it that way.