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:
@@ -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())
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 02–29 — 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 02–29. 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user