From ca500c171e10d2932e32e7fd77e5b94fde81ca4f Mon Sep 17 00:00:00 2001 From: "Claude (auto-RE)" Date: Mon, 10 Aug 2026 19:16:06 +0000 Subject: [PATCH] =?UTF-8?q?ship:=20index-less=20brg/eng/sld=20parts=20neve?= =?UTF-8?q?r=20matched=20their=20GN=20frame=20=E2=80=94=2034=20ships=20ass?= =?UTF-8?q?embled=20without=20a=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../examples/correlate_frames.rs | 48 ++++++++++++++----- crates/sylpheed-formats/src/ship.rs | 18 +++++-- docs/re/BACKLOG.md | 9 ++-- .../ship-placement-capture-generalisation.md | 44 ++++++++++++----- 4 files changed, 88 insertions(+), 31 deletions(-) diff --git a/crates/sylpheed-formats/examples/correlate_frames.rs b/crates/sylpheed-formats/examples/correlate_frames.rs index 15f7cc0..4cca47a 100644 --- a/crates/sylpheed-formats/examples/correlate_frames.rs +++ b/crates/sylpheed-formats/examples/correlate_frames.rs @@ -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) { + let mut best: Vec = Vec::new(); for seed in ts { - let near: Vec<[f32; 3]> = ts + let near: Vec = 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 = BTreeMap::new(); + let mut consensus: BTreeMap = 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 = (0..3) .map(|a| { let v: Vec = 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 = (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()) diff --git a/crates/sylpheed-formats/src/ship.rs b/crates/sylpheed-formats/src/ship.rs index a3653c7..fa415a2 100644 --- a/crates/sylpheed-formats/src/ship.rs +++ b/crates/sylpheed-formats/src/ship.rs @@ -349,9 +349,21 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec = 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::().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_res.insert(part.clone()); } diff --git a/docs/re/BACKLOG.md b/docs/re/BACKLOG.md index 2ebf08d..03ea31c 100644 --- a/docs/re/BACKLOG.md +++ b/docs/re/BACKLOG.md @@ -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) §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, -5 parts instead of 11), then its own transform stack. Two narrow format-side leftovers -remain: `e105_brg` is never produced by `assemble_ship`, and `e105_eng_01`'s rotation -differs by 1.711. +5 parts instead of 11), then its own transform stack. + +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. diff --git a/docs/re/ship-placement-capture-generalisation.md b/docs/re/ship-placement-capture-generalisation.md index ddc5423..1152447 100644 --- a/docs/re/ship-placement-capture-generalisation.md +++ b/docs/re/ship-placement-capture-generalisation.md @@ -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 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` - setting, although the runtime draws it and places it at `[0.0, 70.0, -1850.0]` - relative to `e105_bdy_01` (4/4 blocks, spread ≤0.13). ❔ A genuine missing part. -- **Rotation differs only on parts that move**: `e106_wep_02_01` (turret, dR 0.093), - `e106_eng_01`/`eng_02` (dR ~0.097) and `e105_eng_01` (dR **1.711**). A turret aiming - and a nacelle gimballing at capture time is expected and is not an assembly error; - 1.711 on `e105_eng_01` is too large for that and is ❔ **unexplained — NEEDS-HUMAN** - (either a static rotation bug on that one part, or the part is articulated). +- ✅ **`e105_brg` was genuinely missing — a real assembler bug, now fixed.** Tier 3 + matched a part to its `GN_*` hardpoint by trailing index, so an index-less part + (`e105_brg`) compared `"01" == ""` against `GN_Bridge_01` and fell through silently. + With no index to match on, take the lowest-numbered frame of the category. The + runtime is the check: `e105_brg` now assembles at `[0.0, 70.0, -1850.0]` relative to + `e105_bdy_01`, **dT 0.03, dR 0.000** against the capture. + Reach measured by diffing `assemble_ship` part counts over all containers before and + 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 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* needed to explain anything observed so far (`f105` has 2 multikey tracks and still matches exactly). -- ❌ Still open, both narrow and both evidenced: `e105_brg` is missing from - `assemble_ship`, and `e105_eng_01`'s rotation differs by 1.711 — NEEDS-HUMAN. -- ▶ Next: chase those two, and point the viewer investigation at the viewer - (`include_external`, node-instance recursion), not at the format layer. +- ✅ One real assembler bug found and fixed by this route: index-less `brg`/`eng`/`sld` + parts never matched their `GN_*` frame, so **34 (stage, ship) entries** assembled + without a bridge (and `e102` also without its engine). Verified against the capture. +- ▶ 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.