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>
281 lines
13 KiB
Rust
281 lines
13 KiB
Rust
//! Correlate a capture **per frame** and cross-check the frames against each
|
|
//! other — the placement is only believable if independent frames agree.
|
|
//!
|
|
//! `correlate_capture` treats one capture log as one set of draws. It is not:
|
|
//! an F10 press dumps ~14 frames with no delimiter, and `WV_ref⁻¹ · WV_p` only
|
|
//! cancels the camera within a single frame (see
|
|
//! [`sylpheed_formats::ship_capture::segment_frames`]). With a moving camera the
|
|
//! mixed-frame answer is wrong, and — worse — it is wrong *silently*.
|
|
//!
|
|
//! So: segment, correlate each frame independently, then report per part the
|
|
//! median translation and the spread across frames. A part whose spread is a
|
|
//! few units is measured; a part that swings by hundreds is not, whatever the
|
|
//! single-shot number said.
|
|
//!
|
|
//! Usage:
|
|
//! SYLPHEED_ISO=... cargo run --release --example correlate_frames -- \
|
|
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--min-parts N]
|
|
|
|
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
|
use sylpheed_formats::ship::{is_base_part, ship_id_of};
|
|
use sylpheed_formats::ship_capture::{
|
|
correlate, parse_capture, parse_drawlog, segment_frames, PartKey,
|
|
};
|
|
use sylpheed_formats::xiso::open_iso;
|
|
use std::collections::{BTreeMap, HashSet};
|
|
use std::path::Path;
|
|
|
|
fn median(mut v: Vec<f32>) -> f32 {
|
|
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
|
let n = v.len();
|
|
if n % 2 == 1 { v[n / 2] } else { 0.5 * (v[n / 2 - 1] + v[n / 2]) }
|
|
}
|
|
|
|
fn main() {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
|
|
if positional.len() < 3 {
|
|
eprintln!("usage: correlate_frames <capture.log> <Stage_SNN> <ship_id> [ref_part] [--min-parts N]");
|
|
std::process::exit(2);
|
|
}
|
|
let (log, stage, id) = (positional[0], positional[1], positional[2]);
|
|
let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_01");
|
|
let min_parts: usize = args
|
|
.iter()
|
|
.position(|a| a == "--min-parts")
|
|
.and_then(|i| args.get(i + 1))
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(3);
|
|
|
|
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
|
|
let text = std::fs::read_to_string(log).expect("read log");
|
|
let mut draws = parse_capture(&text);
|
|
if draws.is_empty() {
|
|
draws = parse_drawlog(&text);
|
|
}
|
|
let frames = segment_frames(&draws);
|
|
println!("{} draws → {} camera-consistent blocks", draws.len(), frames.len());
|
|
|
|
let bytes = {
|
|
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
|
rt.block_on(async {
|
|
let mut r = open_iso(Path::new(&iso)).await.unwrap();
|
|
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
|
|
})
|
|
};
|
|
let names = xbg7_resource_names(&bytes);
|
|
let base_parts: Vec<String> = names
|
|
.iter()
|
|
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
|
|
.cloned()
|
|
.collect();
|
|
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
|
|
for p in &base_parts {
|
|
for suf in ["_m", "_l", "_d"] {
|
|
let c = format!("{p}{suf}");
|
|
if names.contains(&c) {
|
|
want.insert(c);
|
|
}
|
|
}
|
|
}
|
|
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
|
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
|
|
let m = models.iter().find(|m| m.name == name)?;
|
|
Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
|
|
};
|
|
|
|
// part -> [T per frame], and how many frames placed it at all.
|
|
let mut samples: BTreeMap<String, Vec<[f32; 3]>> = BTreeMap::new();
|
|
let mut rots: BTreeMap<String, Vec<[[f32; 3]; 3]>> = BTreeMap::new();
|
|
let mut used_frames = 0usize;
|
|
for (fi, fr) in frames.iter().enumerate() {
|
|
let mut keys: Vec<PartKey> = Vec::new();
|
|
for part in &base_parts {
|
|
let variants =
|
|
[part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")];
|
|
let union: Vec<[f32; 3]> =
|
|
variants.iter().filter_map(|v| positions_of(v)).flatten().collect();
|
|
for cand in &variants {
|
|
if let Some(pos) = positions_of(cand) {
|
|
let vcount = pos.len() as u32;
|
|
if fr.iter().any(|d| d.vcount == vcount) {
|
|
keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let Some(ship) = correlate(id, fr, &keys, ref_sub) else { continue };
|
|
if ship.parts.len() < min_parts {
|
|
continue;
|
|
}
|
|
// Placements are expressed in the REFERENCE part's frame, so blocks that
|
|
// fell back to a different reference (because the requested one was not
|
|
// drawn in that block) are in a different coordinate system entirely.
|
|
// Averaging them together is what makes an otherwise clean result look
|
|
// like it disagrees by exactly the distance between the two references.
|
|
if !ship.reference.contains(ref_sub) {
|
|
println!(" block {fi:2}: skipped — reference fell back to {}", ship.reference);
|
|
continue;
|
|
}
|
|
used_frames += 1;
|
|
println!(
|
|
" block {fi:2} ({:4} draws): ref={} parts={}",
|
|
fr.len(),
|
|
ship.reference,
|
|
ship.parts.len()
|
|
);
|
|
for p in &ship.parts {
|
|
samples.entry(p.part.clone()).or_default().push(p.t);
|
|
rots.entry(p.part.clone()).or_default().push(p.m);
|
|
}
|
|
}
|
|
|
|
if used_frames == 0 {
|
|
println!("\nno block placed {min_parts}+ parts — the ship is not drawn close enough");
|
|
return;
|
|
}
|
|
// Aggregate by CONSENSUS, not by average. A stage holds several ships of the
|
|
// same class, they share vertex buffers, and a block can therefore contain
|
|
// one instance's full-LOD part next to another instance's `_m` copy — two
|
|
// different buffers, so nothing splits them, and the recovered translation
|
|
// then belongs to whichever instance the correlator happened to pick. Those
|
|
// are outliers by thousands of units, so a mean or a median over all blocks
|
|
// 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<usize>, usize) {
|
|
let mut best: Vec<usize> = Vec::new();
|
|
for seed in ts {
|
|
let near: Vec<usize> = ts
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, t)| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL))
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
if near.len() > best.len() {
|
|
best = near;
|
|
}
|
|
}
|
|
let out = ts.len() - best.len();
|
|
(best, out)
|
|
};
|
|
println!("\nacross {used_frames} blocks — consensus T (largest agreeing cluster):");
|
|
let mut agree = 0usize;
|
|
let mut consensus: BTreeMap<String, ([f32; 3], [[f32; 3]; 3])> = BTreeMap::new();
|
|
for (part, ts) in &samples {
|
|
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()),
|
|
];
|
|
let spread: Vec<f32> = (0..3)
|
|
.map(|a| {
|
|
let v: Vec<f32> = cl.iter().map(|t| t[a]).collect();
|
|
v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min)
|
|
})
|
|
.collect();
|
|
let verdict = if cl.len() < 2 {
|
|
"single block — unverified"
|
|
} else {
|
|
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}] 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() }
|
|
);
|
|
}
|
|
println!("\n{agree}/{} parts reproduce across blocks", samples.len());
|
|
|
|
// `--static <Stage_SNN.xpr>`: diff the offline assembler against this
|
|
// ground truth. Static placements are in ship space, so both sides are
|
|
// re-expressed in the reference part's frame before comparing — and the
|
|
// rotation is compared too, because "wrong orientation" is half of the
|
|
// reported viewer symptom and a translation-only check cannot see it.
|
|
let Some(si) = args.iter().position(|a| a == "--static") else { return };
|
|
let Some(spath) = args.get(si + 1) else { return };
|
|
let sbytes = std::fs::read(spath).expect("read stage container");
|
|
// `include_external = true` — the engine cluster, the bridge and cross-id
|
|
// turrets live in SEPARATE composites (`e_rou_e106_eng`, 3 nodes) that the
|
|
// primary-composite pass does not reach. With `false` an e106 assembles as
|
|
// 5 parts and the runtime capture's bridge/nacelles read as "not produced by
|
|
// assemble_ship", which is a property of the caller, not of the format.
|
|
let scene = sylpheed_formats::ship::assemble_ship(&sbytes, id, true);
|
|
let Some(sref) = scene.iter().find(|p| p.resource.contains(ref_sub)) else {
|
|
println!("\nstatic: no part matching '{ref_sub}' — cannot align frames");
|
|
return;
|
|
};
|
|
// The reference is placed axis-aligned in every ship seen so far; if that
|
|
// ever stops holding, the rotation would have to be unwound here too.
|
|
println!("\nstatic vs runtime (both relative to {}):", sref.resource);
|
|
let mut worst_t = 0.0f32;
|
|
let mut worst_r = 0.0f32;
|
|
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> =
|
|
scene.iter().filter(|p| &p.resource == part).collect();
|
|
if cands.is_empty() {
|
|
println!(" {part:18} — not produced by assemble_ship");
|
|
continue;
|
|
}
|
|
let rel = |p: &sylpheed_formats::mesh::ScenePart| {
|
|
[p.t[0] - sref.t[0], p.t[1] - sref.t[1], p.t[2] - sref.t[2]]
|
|
};
|
|
let best = cands
|
|
.iter()
|
|
.min_by(|a, b| {
|
|
let d = |p: &sylpheed_formats::mesh::ScenePart| {
|
|
let r = rel(p);
|
|
(0..3).map(|i| (r[i] - med[i]).powi(2)).sum::<f32>()
|
|
};
|
|
d(a).partial_cmp(&d(b)).unwrap()
|
|
})
|
|
.unwrap();
|
|
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 drm = (0..3)
|
|
.flat_map(|i| (0..3).map(move |j| (i, j)))
|
|
.map(|(i, j)| (best.m[i][j] - rm[i][j]).abs())
|
|
.fold(0.0f32, f32::max);
|
|
worst_t = worst_t.max(dtm);
|
|
worst_r = worst_r.max(drm);
|
|
let mark = if dtm < 1.0 && drm < 0.02 { "MATCH" } else { "DIFFERS" };
|
|
println!(
|
|
" {part:18} static=[{:9.1}{:9.1}{:9.1}] dT={dtm:7.2} dR={drm:6.3} {mark}",
|
|
r[0], r[1], r[2]
|
|
);
|
|
}
|
|
println!("\nworst dT={worst_t:.2} worst dR={worst_r:.3} ({} static parts, {} captured)",
|
|
scene.len(), samples.len());
|
|
}
|