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:
MechaCat02
2026-07-25 20:48:00 +02:00
parent a68405216f
commit c6ca5ce9e7
6 changed files with 476 additions and 192 deletions

View File

@@ -0,0 +1,13 @@
# Capital-ship part placements — runtime-captured ground truth (see
# docs/re/ship-placement-runtime-capture.md and src/ship_capture.rs).
#
# Populate with:
# SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
# <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] --emit
# then paste the emitted `ship …` block(s) below. The viewer prefers a ship's
# entry here over the static assembler when present.
#
# Per placement line: <part> <R00 R01 R02 R10 R11 R12 R20 R21 R22> <T0 T1 T2>
#
# (No ships baked in yet — a real F10 capture log is required. e106 ADAN
# Destroyer was validated live; re-run the capture on the emulator box to bake.)

View File

@@ -1,139 +1,42 @@
//! Recover exact capital-ship part placement from a Canary `xenia_ship_capture.log` //! Recover exact capital-ship part placement from a Canary F10 ship-capture log
//! (F10 snapshot: per-draw guest vertex-buffer + up to 48 vertex-shader float //! and (optionally) emit a checked-in placement-table block.
//! constants).
//! //!
//! FINDING (2026-07-24): the captured vertex BUFFER holds LOCAL coordinates — //! The correlation math lives in [`sylpheed_formats::ship_capture`]; this example
//! byte-identical to the `.xpr` — so capital-ship parts are placed entirely in the //! is the CLI wrapper: it reads the log, decodes the ship's parts from the disc to
//! **vertex shader**. The per-part transform is in the VS constants: `c0..c2` are //! get their vertex counts (the match key), correlates, and prints the result.
//! the **WorldViewProjection** matrix rows. Their norms are `(sx, sy, 1)` = the //! With `--emit` it prints the `ship …` block to paste into
//! projection x/y scales (sy/sx = 16:9 aspect); dividing each row by its norm //! `crates/sylpheed-formats/data/ship_placements.txt`.
//! 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.
//! //!
//! Usage: //! Usage:
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \ //! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] //! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
//! e.g. `... /tmp/xenia_ship_capture.log Stage_S01 e106 bdy_04` //! e.g. `... /tmp/xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit`
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model}; use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{is_base_part, ship_id_of}; 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 sylpheed_formats::xiso::open_iso;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::Path; 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() { fn main() {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
if args.len() < 4 { let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
eprintln!("usage: correlate_capture <capture.log> <Stage_SNN> <ship_id> [ref_part_substr]"); 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); std::process::exit(2);
} }
let (log, stage, id) = (&args[1], &args[2], &args[3]); let (log, stage, id) = (positional[0], positional[1], positional[2]);
let ref_sub = args.get(4).map(|s| s.as_str()).unwrap_or("bdy_04"); 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 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 bytes = {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async { rt.block_on(async {
@@ -142,66 +45,36 @@ fn main() {
}) })
}; };
let names = xbg7_resource_names(&bytes); let names = xbg7_resource_names(&bytes);
let parts: Vec<String> = let want: HashSet<String> = names
names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str())).cloned().collect(); .iter()
let want: HashSet<String> = parts.iter().cloned().collect(); .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 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. let Some(ship) = correlate(id, &draws, &parts, ref_sub) else {
struct Placed { eprintln!("no parts matched a captured draw");
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");
return; return;
}; };
let rt_ref = transpose(&rf.r);
let ref_t = rf.t; eprintln!("\nreference = {} → ship-relative placement:", ship.reference);
println!("\nreference = {} → ship-relative placement (M rows, T):", rf.part); for p in &ship.parts {
let mut lo = [f64::MAX; 3]; eprintln!(" {:18} T=[{:8.1}{:8.1}{:8.1}]", p.part, p.t[0], p.t[1], p.t[2]);
let mut hi = [f64::MIN; 3]; }
for p in &placed { let unmatched: Vec<&str> =
// rel R = Rᵀ_ref · R_p ; rel T = Rᵀ_ref · (T_p T_ref) parts.iter().map(|(n, _)| n.as_str()).filter(|n| !ship.parts.iter().any(|p| p.part == *n)).collect();
let rel_r = mmul(&rt_ref, &p.r); if !unmatched.is_empty() {
let dt = [p.t[0] - ref_t[0], p.t[1] - ref_t[1], p.t[2] - ref_t[2]]; eprintln!(" (no captured draw — occluded/culled: {})", unmatched.join(", "));
let rel_t = mt_apply(&rt_ref, &[0.0; 3], dt); }
// Assembled centroid (validation) + overall extent.
let mut c = [0.0; 3]; if emit {
for v in &p.local { print!("{}", serialize_table(std::slice::from_ref(&ship)));
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
);
} }
println!(
"\nassembled extent = [{:.0} {:.0} {:.0}] ({} parts placed)",
hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2], placed.len()
);
} }

View File

@@ -68,6 +68,9 @@ pub mod localization;
// Whole-ship assembly from XBG7 part families (capital ships as split parts). // Whole-ship assembly from XBG7 part families (capital ships as split parts).
pub mod ship; pub mod ship;
// Exact capital-ship placement from a runtime F10 ship-capture (ground truth).
pub mod ship_capture;
/// Re-export the most commonly used types at the crate root. /// Re-export the most commonly used types at the crate root.
pub use font::FontInfo; pub use font::FontInfo;
pub use idxd::{IdxdError, IdxdObject}; pub use idxd::{IdxdError, IdxdObject};

View File

@@ -0,0 +1,371 @@
//! Exact capital-ship part placement from a Canary **F10 ship-capture** log —
//! the runtime ground truth that static [`crate::ship::assemble_ship`] only
//! approximates for external parts.
//!
//! ## Why a capture is needed
//!
//! A captured part's vertex BUFFER holds **local** coordinates (byte-identical to
//! the `.xpr`), so capital-ship parts are placed **entirely in the vertex shader**
//! — the buffer carries no placement. The per-part transform lives in the ship
//! shader's **vertex float constants**: `c0..c2` are the **WorldViewProjection**
//! matrix rows. Each row's norm is `(sx, sy, 1)` (the projection x/y scales, with
//! `sy/sx ≈ 1.78 = 16:9`); dividing a row by its norm yields the rigid
//! **WorldView** row (verified orthonormal, `det = +1`) and `row[3]/norm` is that
//! axis' view-space translation.
//!
//! The camera View cancels when every part is expressed relative to a **reference
//! part**: `rel_p = WV_ref⁻¹ · WV_p = (Rᵀ_ref·R_p , Rᵀ_ref·(T_p T_ref))` — a pure
//! ship-space rigid transform. That is what [`correlate`] emits and what the
//! checked-in [placement table](parse_table) stores, so the viewer can assemble a
//! ship exactly without re-capturing.
//!
//! ## Pipeline
//!
//! 1. Canary F10 → `xenia_ship_capture.log` (per-draw `vbase`/`vcount` + the first
//! 48 VS float4 constants). [`parse_capture`] → [`CapturedDraw`]s.
//! 2. [`correlate`] matches each ship base part to a draw **by vertex count**
//! (unique per part) and expresses it in the reference part's frame →
//! [`ShipPlacement`].
//! 3. [`serialize_table`]/[`parse_table`] persist it as a checked-in data file
//! (`data/ship_placements.txt`, embedded via [`embedded_placement`]); the viewer
//! prefers it over the static assembler when present.
use crate::mesh::ScenePart;
type M3 = [[f64; 3]; 3];
/// One captured draw's rigid **WorldView**: rotation rows `r` + view-space
/// translation `t`, recovered from the `c0..c2` WVP constants.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CapturedDraw {
/// Guest vertex-buffer base address (the draw's identity for de-duping).
pub vbase: u32,
/// Vertex count — the key that matches a draw to a decoded part.
pub vcount: u32,
/// WorldView rotation rows (orthonormal).
pub r: M3,
/// WorldView view-space translation.
pub t: [f64; 3],
}
/// A part's ship-relative rigid placement (in the reference part's frame).
#[derive(Debug, Clone, PartialEq)]
pub struct PartPlacement {
/// Geometry resource name, e.g. `e106_eng_01`.
pub part: String,
/// Rotation rows.
pub m: [[f32; 3]; 3],
/// Ship-relative translation.
pub t: [f32; 3],
}
/// A whole ship's captured placement: every part in a shared ship-local frame.
#[derive(Debug, Clone, PartialEq)]
pub struct ShipPlacement {
/// Ship family id, e.g. `e106`.
pub id: String,
/// The reference part whose frame the placements are expressed in.
pub reference: String,
/// Ship-relative placement of each matched part.
pub parts: Vec<PartPlacement>,
}
/// Parse a Canary ship-capture log into per-draw rigid WorldView transforms.
///
/// Only draws that carry the `c0..c2` constants are returned (a culled/occluded
/// part produces no draw and is simply absent). Robust to the exact spacing of
/// the `DRAW …` / `vsconst …` lines.
pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
let mut out = Vec::new();
let mut vbase = 0u32;
let mut vcount = 0u32;
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
let flush = |vbase: u32, vcount: u32, consts: &[(usize, [f64; 4])], out: &mut Vec<CapturedDraw>| {
if vbase == 0 {
return;
}
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 {
return; // no WorldView for this draw — skip it
};
let mut r = [[0.0; 3]; 3];
let mut t = [0.0; 3];
for (i, row) in [c0, c1, c2].iter().enumerate() {
let n = (row[0] * row[0] + row[1] * row[1] + row[2] * row[2]).sqrt();
if n < 1e-6 {
return; // degenerate row — not a usable transform
}
r[i] = [row[0] / n, row[1] / n, row[2] / n];
t[i] = row[3] / n;
}
out.push(CapturedDraw { vbase, vcount, r, t });
};
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) {
let Some((idx, rest)) = cap.split_once('=') else { continue };
let Ok(i) = idx.trim().parse::<usize>() else { continue };
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
}
/// Correlate captured draws to a ship's base parts and express each in the
/// reference part's frame.
///
/// `parts` is `(resource_name, vertex_count)` for the ship's base parts (from the
/// XBG7 decoder); vertex count is the match key. `ref_sub` selects the reference
/// part by substring (e.g. `bdy_04`); the first matched part is used if none
/// contains it. Parts with no matching captured draw (culled at that camera
/// angle) are omitted. Returns `None` if nothing matched.
pub fn correlate(
id: &str,
draws: &[CapturedDraw],
parts: &[(String, u32)],
ref_sub: &str,
) -> Option<ShipPlacement> {
// Match each part to a distinct captured draw by vertex count.
let mut matched: Vec<(String, M3, [f64; 3])> = Vec::new();
let mut used: std::collections::HashSet<u32> = std::collections::HashSet::new();
for (part, vc) in parts {
if let Some(d) = draws.iter().find(|d| d.vcount == *vc && !used.contains(&d.vbase)) {
used.insert(d.vbase);
matched.push((part.clone(), d.r, d.t));
}
}
let ref_idx = matched.iter().position(|(p, ..)| p.contains(ref_sub)).unwrap_or(0);
let (ref_part, ref_r, ref_t) = matched.get(ref_idx)?.clone();
let rt_ref = transpose(&ref_r);
let parts_out = matched
.iter()
.map(|(part, r, t)| {
let rel_r = mmul(&rt_ref, r);
let dt = [t[0] - ref_t[0], t[1] - ref_t[1], t[2] - ref_t[2]];
let rel_t = mat_vec(&rt_ref, dt);
PartPlacement {
part: part.clone(),
m: m3_to_f32(&rel_r),
t: [rel_t[0] as f32, rel_t[1] as f32, rel_t[2] as f32],
}
})
.collect();
Some(ShipPlacement { id: id.to_string(), reference: ref_part, parts: parts_out })
}
/// Convert a captured placement into viewer [`ScenePart`]s (rigid, unit scale).
pub fn to_scene_parts(ship: &ShipPlacement) -> Vec<ScenePart> {
ship.parts
.iter()
.map(|p| ScenePart { resource: p.part.clone(), m: p.m, t: p.t, s: [1.0, 1.0, 1.0] })
.collect()
}
/// The checked-in placement table, embedded at build time. Empty until captures
/// are baked in with `correlate_capture --emit`.
const EMBEDDED_TABLE: &str = include_str!("../data/ship_placements.txt");
/// The captured placement for ship `id` from the embedded table, if baked in.
pub fn embedded_placement(id: &str) -> Option<ShipPlacement> {
parse_table(EMBEDDED_TABLE).into_iter().find(|s| s.id == id)
}
/// Serialize a placement table to the checked-in text format (see [`parse_table`]).
pub fn serialize_table(ships: &[ShipPlacement]) -> String {
let mut s = String::new();
s.push_str("# Capital-ship part placements — runtime-captured ground truth.\n");
s.push_str("# Generated by: cargo run --release --example correlate_capture -- \\\n");
s.push_str("# <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] --emit\n");
s.push_str("# Per line: <part> <R00 R01 R02 R10 R11 R12 R20 R21 R22> <T0 T1 T2>\n");
for ship in ships {
s.push_str(&format!("\nship {} ref={}\n", ship.id, ship.reference));
for p in &ship.parts {
s.push_str(&format!(
" {} {} {} {} {} {} {} {} {} {} {} {} {}\n",
p.part,
p.m[0][0], p.m[0][1], p.m[0][2],
p.m[1][0], p.m[1][1], p.m[1][2],
p.m[2][0], p.m[2][1], p.m[2][2],
p.t[0], p.t[1], p.t[2],
));
}
}
s
}
/// Parse the checked-in placement table. `#` comments and blank lines are ignored;
/// a `ship <id> ref=<part>` line starts a block, and each following
/// `<part> <9 rotation floats> <3 translation floats>` line is one placement.
pub fn parse_table(text: &str) -> Vec<ShipPlacement> {
let mut ships: Vec<ShipPlacement> = Vec::new();
for line in text.lines() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') {
continue;
}
if let Some(rest) = l.strip_prefix("ship ") {
let mut it = rest.split_whitespace();
let id = it.next().unwrap_or("").to_string();
let reference = it
.next()
.and_then(|s| s.strip_prefix("ref="))
.unwrap_or("")
.to_string();
ships.push(ShipPlacement { id, reference, parts: Vec::new() });
} else if let Some(ship) = ships.last_mut() {
let mut it = l.split_whitespace();
let part = it.next().unwrap_or("").to_string();
let nums: Vec<f32> = it.filter_map(|x| x.parse().ok()).collect();
if part.is_empty() || nums.len() != 12 {
continue;
}
ship.parts.push(PartPlacement {
part,
m: [
[nums[0], nums[1], nums[2]],
[nums[3], nums[4], nums[5]],
[nums[6], nums[7], nums[8]],
],
t: [nums[9], nums[10], nums[11]],
});
}
}
ships
}
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 mat_vec(m: &M3, v: [f64; 3]) -> [f64; 3] {
[
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
]
}
fn m3_to_f32(m: &M3) -> [[f32; 3]; 3] {
[
[m[0][0] as f32, m[0][1] as f32, m[0][2] as f32],
[m[1][0] as f32, m[1][1] as f32, m[1][2] as f32],
[m[2][0] as f32, m[2][1] as f32, m[2][2] as f32],
]
}
#[cfg(test)]
mod tests {
use super::*;
/// A `vsconst` line for an already-unit WorldView (norm 1) so R = rows and
/// T = row[3]: identity rotation, translation `t`.
fn draw_line(vbase: u32, vcount: u32, t: [f64; 3]) -> String {
format!(
"DRAW vbase=0x{vbase:X} stride=32 vcount={vcount} indices=0 prim=tri vs=0x1\n \
vsconst base=0: c0=(1,0,0,{}) c1=(0,1,0,{}) c2=(0,0,1,{})\n",
t[0], t[1], t[2]
)
}
#[test]
fn parse_recovers_worldview() {
let log = draw_line(0x1000, 3, [10.0, 0.0, 0.0]);
let draws = parse_capture(&log);
assert_eq!(draws.len(), 1);
assert_eq!(draws[0].vcount, 3);
assert_eq!(draws[0].t, [10.0, 0.0, 0.0]);
assert_eq!(draws[0].r, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
}
#[test]
fn parse_normalizes_projection_scale() {
// A row scaled by the projection sx=2 must normalize back to unit, and its
// translation divides by the same norm.
let log = "DRAW vbase=0x2000 vcount=4\n \
vsconst base=0: c0=(2,0,0,20) c1=(0,2,0,0) c2=(0,0,1,0)\n";
let d = &parse_capture(log)[0];
assert!((d.r[0][0] - 1.0).abs() < 1e-9);
assert!((d.t[0] - 10.0).abs() < 1e-9, "20/2 = 10");
}
#[test]
fn correlate_expresses_parts_in_reference_frame() {
// Two parts, identity rotation: ref at view (10,0,0), other at (10,0,50).
// Relative to ref, the other must sit at (0,0,50).
let log = format!(
"{}{}",
draw_line(0x1000, 3, [10.0, 0.0, 0.0]),
draw_line(0x2000, 4, [10.0, 0.0, 50.0])
);
let draws = parse_capture(&log);
let parts = vec![("e106_bdy_04".to_string(), 3), ("e106_eng_01".to_string(), 4)];
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
assert_eq!(ship.reference, "e106_bdy_04");
let refp = ship.parts.iter().find(|p| p.part == "e106_bdy_04").unwrap();
assert_eq!(refp.t, [0.0, 0.0, 0.0]);
let eng = ship.parts.iter().find(|p| p.part == "e106_eng_01").unwrap();
assert_eq!(eng.t, [0.0, 0.0, 50.0]);
}
#[test]
fn table_round_trips() {
let ship = ShipPlacement {
id: "e106".to_string(),
reference: "e106_bdy_04".to_string(),
parts: vec![
PartPlacement {
part: "e106_bdy_04".to_string(),
m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
t: [0.0, 0.0, 0.0],
},
PartPlacement {
part: "e106_eng_01".to_string(),
m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
t: [131.0, -133.0, -132.0],
},
],
};
let text = serialize_table(std::slice::from_ref(&ship));
let back = parse_table(&text);
assert_eq!(back, vec![ship]);
}
#[test]
fn embedded_table_parses() {
// The checked-in data file must always parse (even if empty).
let _ = parse_table(EMBEDDED_TABLE);
}
}

View File

@@ -3952,9 +3952,15 @@ fn build_ship_model(
}; };
let should_cancel = || cancel.load(Ordering::Relaxed); let should_cancel = || cancel.load(Ordering::Relaxed);
// Scene-graph placement: which resources to draw and where (bridge fore, // Placement: prefer a runtime-captured ground-truth table (exact, baked from
// engines aft, hardpoints on their frames). // an F10 ship capture) when this ship has one; otherwise fall back to the
let placed = sylpheed_formats::ship::assemble_ship(&bytes, id, external); // static scene-graph assembler (hull exact, external parts approximate — the
// `external` toggle). A captured table already includes every captured part,
// so the toggle doesn't apply to it.
let placed = match sylpheed_formats::ship_capture::embedded_placement(id) {
Some(cap) => sylpheed_formats::ship_capture::to_scene_parts(&cap),
None => sylpheed_formats::ship::assemble_ship(&bytes, id, external),
};
if placed.is_empty() { if placed.is_empty() {
return PreparedXpr::Info(format!("No parts found for {label}.")); return PreparedXpr::Info(format!("No parts found for {label}."));
} }

View File

@@ -1,7 +1,9 @@
# Capital-ship part placement — runtime capture (ground truth) # Capital-ship part placement — runtime capture (ground truth)
**Status:** pipeline working end-to-end for one ship (`e106` ADAN Destroyer); **Status:** pipeline working end-to-end for one ship (`e106` ADAN Destroyer);
correlator recovers exact ship-relative transforms. Not yet baked into the viewer. correlator recovers exact ship-relative transforms. **Bake plumbing landed** — the
correlator is now a tested library module and the viewer prefers a captured table;
only real capture data still needs baking in (needs the emulator + F10).
## Problem ## Problem
@@ -56,16 +58,30 @@ Branch **`capture-ship-placement`** in the `xenia-canary-native` worktree (off t
- Run: `run-canary-native.sh` (HW Vulkan, interactive). Play into the mission, - Run: `run-canary-native.sh` (HW Vulkan, interactive). Play into the mission,
frame the ship side-on, press F10. frame the ship side-on, press F10.
## Correlator ## Correlator (now a library module)
`sylpheed-formats/examples/correlate_capture.rs`: The correlation math lives in **`sylpheed-formats::ship_capture`** (unit-tested with
synthetic captures): `parse_capture` → `CapturedDraw`s, `correlate(id, draws,
parts, ref_sub)` → a `ShipPlacement` (each part in the reference frame), and
`serialize_table`/`parse_table` for the checked-in text table.
`examples/correlate_capture.rs` is the CLI wrapper:
``` ```
SYLPHEED_ISO=… cargo run --release --example correlate_capture -- \ SYLPHEED_ISO=… cargo run --release --example correlate_capture -- \
<capture.log> <Stage_SNN> <ship_id> [ref_part_substr] <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
``` ```
Parses the log, decodes the ship's base parts, matches each part to a draw by Decodes the ship's base parts (for their vertex counts = the match key), correlates,
**vertex count** (unique per part), recovers WorldView from `c0..c2`, and prints prints each part's ship-relative transform, and with `--emit` prints the `ship …`
each part's ship-relative transform (reference-part frame) + assembled extent. block to paste into the placement table.
## Baked table + viewer preference
`crates/sylpheed-formats/data/ship_placements.txt` is the checked-in placement
table (embedded via `ship_capture::embedded_placement`). The viewer's
`build_ship_model` uses a ship's captured entry when present, else falls back to
the static `assemble_ship` (`external` toggle). **The table is currently empty** —
a real F10 capture log is needed to bake in `e106` (and others); paste the
`--emit` block into that file and rebuild.
## Validated result — `e106` ADAN Destroyer (Mission 1 / Stage_S01, side view) ## Validated result — `e106` ADAN Destroyer (Mission 1 / Stage_S01, side view)
@@ -95,11 +111,13 @@ angle (0 draws) — needs a second capture framing the superstructure.
## Next steps ## Next steps
1. **Bridge:** re-capture with the bridge visible → fills `brg_01`. 1. ~~**Bake:** promote the correlator to a module + checked-in table + viewer
2. **Bake:** promote the correlator to a module; emit a per-ship placement table preference.~~ **DONE** (`ship_capture` module, `data/ship_placements.txt`,
(`ship_id → [(part, R, T)]`) as a checked-in data file; have the viewer's `build_ship_model` prefers it). Remaining: run the captures below and paste the
`build_ship_model` prefer the captured table over `assemble_ship` when present. `--emit` blocks in — needs the emulator (F10), can't be done offline.
3. **Other ships:** same capture for the cruiser (`e105`, Stage_S02), ACROPOLIS 2. **e106 + bridge:** re-capture `e106` (Stage_S01) framing the superstructure so
(`f101`), TCAF cruiser/destroyer (`f105`/`f106`). One side-on F10 each. `brg_01` (culled last time) is included, then `--emit` → bake.
3. **Other ships:** one side-on F10 capture each for the cruiser (`e105`,
Stage_S02), ACROPOLIS (`f101`), TCAF cruiser/destroyer (`f105`/`f106`).
4. **Turrets:** the shared `e3NN`/`e4NN` gun models appear as their own draws in the 4. **Turrets:** the shared `e3NN`/`e4NN` gun models appear as their own draws in the
capture too — correlate them to place turrets (static never resolved these). capture too — correlate them to place turrets (static never resolved these).