ship: bake pipeline — correlator as a tested module + viewer prefers captured table
Promote the F10 ship-capture correlator from an example into a reusable, unit- tested library module (ship_capture): parse_capture (log -> per-draw rigid WorldView from the c0..c2 WVP constants), correlate (match parts to draws by vertex count, express each in the reference part's frame), and a checked-in placement-table format (serialize_table/parse_table, embedded via embedded_placement from data/ship_placements.txt). build_ship_model now prefers a ship's captured placement over the static assemble_ship when a table entry exists (empty table -> unchanged fallback). correlate_capture example refactored onto the module + gains --emit to print a table block. Doc updated: bake plumbing done; remaining is running real F10 captures to populate the table (needs the emulator). 5 new ship_capture tests (parse/normalize/correlate/table round-trip); 76 formats-lib tests + viewer build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,139 +1,42 @@
|
||||
//! Recover exact capital-ship part placement from a Canary `xenia_ship_capture.log`
|
||||
//! (F10 snapshot: per-draw guest vertex-buffer + up to 48 vertex-shader float
|
||||
//! constants).
|
||||
//! Recover exact capital-ship part placement from a Canary F10 ship-capture log
|
||||
//! and (optionally) emit a checked-in placement-table block.
|
||||
//!
|
||||
//! FINDING (2026-07-24): the captured vertex BUFFER holds LOCAL coordinates —
|
||||
//! byte-identical to the `.xpr` — so capital-ship parts are placed entirely in the
|
||||
//! **vertex shader**. The per-part transform is in the VS constants: `c0..c2` are
|
||||
//! the **WorldViewProjection** matrix rows. Their norms are `(sx, sy, 1)` = the
|
||||
//! projection x/y scales (sy/sx = 16:9 aspect); dividing each row by its norm
|
||||
//! yields the rigid **WorldView** (verified: rows orthonormal, det +1). The camera
|
||||
//! View cancels when we express every part relative to a reference part:
|
||||
//! rel_p = WV_ref⁻¹ · WV_p (a pure ship-space rigid transform)
|
||||
//! Applying `rel_p` to part `p`'s local geometry assembles the ship exactly, in the
|
||||
//! reference part's frame — ground truth, no static guessing.
|
||||
//! The correlation math lives in [`sylpheed_formats::ship_capture`]; this example
|
||||
//! is the CLI wrapper: it reads the log, decodes the ship's parts from the disc to
|
||||
//! get their vertex counts (the match key), correlates, and prints the result.
|
||||
//! With `--emit` it prints the `ship …` block to paste into
|
||||
//! `crates/sylpheed-formats/data/ship_placements.txt`.
|
||||
//!
|
||||
//! Usage:
|
||||
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
|
||||
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr]
|
||||
//! e.g. `... /tmp/xenia_ship_capture.log Stage_S01 e106 bdy_04`
|
||||
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
|
||||
//! e.g. `... /tmp/xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit`
|
||||
|
||||
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, serialize_table};
|
||||
use sylpheed_formats::xiso::open_iso;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
type M3 = [[f64; 3]; 3];
|
||||
|
||||
struct Draw {
|
||||
vbase: u32,
|
||||
vcount: u32,
|
||||
/// Rigid WorldView: R (rows) + T (view-space translation).
|
||||
r: M3,
|
||||
t: [f64; 3],
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
fn parse(text: &str) -> Vec<Draw> {
|
||||
let mut out = Vec::new();
|
||||
let mut vbase = 0u32;
|
||||
let mut vcount = 0u32;
|
||||
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
|
||||
let mut flush = |vbase: u32, vcount: u32, consts: &[(usize, [f64; 4])], out: &mut Vec<Draw>| {
|
||||
if vbase == 0 {
|
||||
return;
|
||||
}
|
||||
// c0..c2 = WVP rows; normalise each to unit → rigid WorldView.
|
||||
let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
|
||||
let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else {
|
||||
out.push(Draw { vbase, vcount, r: [[0.0; 3]; 3], t: [0.0; 3], ok: false });
|
||||
return;
|
||||
};
|
||||
let rows = [c0, c1, c2];
|
||||
let mut r = [[0.0; 3]; 3];
|
||||
let mut t = [0.0; 3];
|
||||
let mut ok = true;
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let n = (row[0] * row[0] + row[1] * row[1] + row[2] * row[2]).sqrt();
|
||||
if n < 1e-6 {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
r[i] = [row[0] / n, row[1] / n, row[2] / n];
|
||||
t[i] = row[3] / n;
|
||||
}
|
||||
out.push(Draw { vbase, vcount, r, t, ok });
|
||||
};
|
||||
for line in text.lines() {
|
||||
let l = line.trim();
|
||||
if let Some(rest) = l.strip_prefix("DRAW ") {
|
||||
flush(vbase, vcount, &consts, &mut out);
|
||||
consts.clear();
|
||||
let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
|
||||
vbase = f("vbase=0x").and_then(|s| u32::from_str_radix(s, 16).ok()).unwrap_or(0);
|
||||
vcount = f("vcount=").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
} else if l.starts_with("vsconst") {
|
||||
for cap in l.split("c").skip(1) {
|
||||
// "<i>=(x,y,z,w) ..."
|
||||
if let Some((idx, rest)) = cap.split_once('=') {
|
||||
if let Ok(i) = idx.trim().parse::<usize>() {
|
||||
let nums: Vec<f64> = rest
|
||||
.trim_start_matches('(')
|
||||
.split(')')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split(',')
|
||||
.filter_map(|x| x.trim().parse().ok())
|
||||
.collect();
|
||||
if nums.len() == 4 {
|
||||
consts.push((i, [nums[0], nums[1], nums[2], nums[3]]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(vbase, vcount, &consts, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn mt_apply(r: &M3, t: &[f64; 3], v: [f64; 3]) -> [f64; 3] {
|
||||
[
|
||||
r[0][0] * v[0] + r[0][1] * v[1] + r[0][2] * v[2] + t[0],
|
||||
r[1][0] * v[0] + r[1][1] * v[1] + r[1][2] * v[2] + t[1],
|
||||
r[2][0] * v[0] + r[2][1] * v[1] + r[2][2] * v[2] + t[2],
|
||||
]
|
||||
}
|
||||
fn transpose(m: &M3) -> M3 {
|
||||
[
|
||||
[m[0][0], m[1][0], m[2][0]],
|
||||
[m[0][1], m[1][1], m[2][1]],
|
||||
[m[0][2], m[1][2], m[2][2]],
|
||||
]
|
||||
}
|
||||
fn mmul(a: &M3, b: &M3) -> M3 {
|
||||
let mut o = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 4 {
|
||||
eprintln!("usage: correlate_capture <capture.log> <Stage_SNN> <ship_id> [ref_part_substr]");
|
||||
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
|
||||
let emit = args.iter().any(|a| a == "--emit");
|
||||
if positional.len() < 3 {
|
||||
eprintln!(
|
||||
"usage: correlate_capture <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let (log, stage, id) = (&args[1], &args[2], &args[3]);
|
||||
let ref_sub = args.get(4).map(|s| s.as_str()).unwrap_or("bdy_04");
|
||||
let (log, stage, id) = (positional[0], positional[1], positional[2]);
|
||||
let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_04");
|
||||
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
|
||||
let draws = parse(&std::fs::read_to_string(log).expect("read log"));
|
||||
eprintln!("parsed {} draws ({} with WorldView)", draws.len(), draws.iter().filter(|d| d.ok).count());
|
||||
|
||||
let draws = parse_capture(&std::fs::read_to_string(log).expect("read log"));
|
||||
eprintln!("parsed {} draws with WorldView", draws.len());
|
||||
|
||||
// Decode the ship's base parts to get each part's vertex count (the match key).
|
||||
let bytes = {
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||
rt.block_on(async {
|
||||
@@ -142,66 +45,36 @@ fn main() {
|
||||
})
|
||||
};
|
||||
let names = xbg7_resource_names(&bytes);
|
||||
let parts: Vec<String> =
|
||||
names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str())).cloned().collect();
|
||||
let want: HashSet<String> = parts.iter().cloned().collect();
|
||||
let want: HashSet<String> = names
|
||||
.iter()
|
||||
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||
let parts: Vec<(String, u32)> = models
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let vc = m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32;
|
||||
(m.name.clone(), vc)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Match each part to a captured draw by vertex count; keep its WorldView.
|
||||
struct Placed {
|
||||
part: String,
|
||||
r: M3,
|
||||
t: [f64; 3],
|
||||
local: Vec<[f64; 3]>,
|
||||
}
|
||||
let mut placed: Vec<Placed> = Vec::new();
|
||||
let mut used: HashSet<u32> = HashSet::new();
|
||||
for part in &parts {
|
||||
let Some(m) = models.iter().find(|m| &m.name == part) else { continue };
|
||||
let local: Vec<[f64; 3]> =
|
||||
m.meshes.iter().flat_map(|s| s.positions.iter()).map(|p| [p[0] as f64, p[1] as f64, p[2] as f64]).collect();
|
||||
let vc = local.len() as u32;
|
||||
if let Some(d) = draws.iter().find(|d| d.ok && d.vcount == vc && !used.contains(&d.vbase)) {
|
||||
used.insert(d.vbase);
|
||||
placed.push(Placed { part: part.clone(), r: d.r, t: d.t, local });
|
||||
} else {
|
||||
println!("{part:20} (no matching captured draw — occluded/culled?)");
|
||||
}
|
||||
}
|
||||
|
||||
// Reference part → ship frame. rel_p = WV_ref⁻¹ · WV_p (camera cancels).
|
||||
let Some(rf) = placed.iter().find(|p| p.part.contains(ref_sub)).or(placed.first()) else {
|
||||
eprintln!("no parts placed");
|
||||
let Some(ship) = correlate(id, &draws, &parts, ref_sub) else {
|
||||
eprintln!("no parts matched a captured draw");
|
||||
return;
|
||||
};
|
||||
let rt_ref = transpose(&rf.r);
|
||||
let ref_t = rf.t;
|
||||
println!("\nreference = {} → ship-relative placement (M rows, T):", rf.part);
|
||||
let mut lo = [f64::MAX; 3];
|
||||
let mut hi = [f64::MIN; 3];
|
||||
for p in &placed {
|
||||
// rel R = Rᵀ_ref · R_p ; rel T = Rᵀ_ref · (T_p − T_ref)
|
||||
let rel_r = mmul(&rt_ref, &p.r);
|
||||
let dt = [p.t[0] - ref_t[0], p.t[1] - ref_t[1], p.t[2] - ref_t[2]];
|
||||
let rel_t = mt_apply(&rt_ref, &[0.0; 3], dt);
|
||||
// Assembled centroid (validation) + overall extent.
|
||||
let mut c = [0.0; 3];
|
||||
for v in &p.local {
|
||||
let w = mt_apply(&rel_r, &rel_t, *v);
|
||||
for k in 0..3 {
|
||||
c[k] += w[k];
|
||||
lo[k] = lo[k].min(w[k]);
|
||||
hi[k] = hi[k].max(w[k]);
|
||||
}
|
||||
}
|
||||
let n = p.local.len().max(1) as f64;
|
||||
println!(
|
||||
" {:18} T=[{:8.1}{:8.1}{:8.1}] centroid=[{:8.1}{:8.1}{:8.1}]",
|
||||
p.part, rel_t[0], rel_t[1], rel_t[2], c[0] / n, c[1] / n, c[2] / n
|
||||
);
|
||||
|
||||
eprintln!("\nreference = {} → ship-relative placement:", ship.reference);
|
||||
for p in &ship.parts {
|
||||
eprintln!(" {:18} T=[{:8.1}{:8.1}{:8.1}]", p.part, p.t[0], p.t[1], p.t[2]);
|
||||
}
|
||||
let unmatched: Vec<&str> =
|
||||
parts.iter().map(|(n, _)| n.as_str()).filter(|n| !ship.parts.iter().any(|p| p.part == *n)).collect();
|
||||
if !unmatched.is_empty() {
|
||||
eprintln!(" (no captured draw — occluded/culled: {})", unmatched.join(", "));
|
||||
}
|
||||
|
||||
if emit {
|
||||
print!("{}", serialize_table(std::slice::from_ref(&ship)));
|
||||
}
|
||||
println!(
|
||||
"\nassembled extent = [{:.0} {:.0} {:.0}] ({} parts placed)",
|
||||
hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2], placed.len()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user