ship_capture: ingest the draw-logger format + quantify the static gaps

The correlator now accepts BOTH capture formats: the F10 ship-capture snapshot
and the draw-logger format (mission_draws.log / xenia_re_draws.log) via
parse_drawlog — group the ship shader's draws by vertex-buffer base, vcount =
size_words/stride_words, keep the first c0..c2 WVP. So a capital ship seen in a
normal instrumented run bakes without a dedicated F10 pass. correlate_capture
auto-detects the format. Shared normalize_wvp between both parsers.

Also add examples/ship_dump.rs (offline static-placement inspector) and record
the measured static-assembler gaps for e106 in the doc: bdy_01/bdy_02 overlap
(runtime port/starboard mirror at X=+-264), eng_02 and wep_02_01 never placed.
These confirm exact placement is runtime-only — nothing more to squeeze
statically; the capture-driven table is the only path. The draw logs present on
this box are the player fighter, so no capital-ship table can be baked offline.

7 ship_capture tests (adds draw-log parse + correlate); 77 formats-lib green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-25 20:57:08 +02:00
parent c6ca5ce9e7
commit 4efc0278b1
4 changed files with 207 additions and 13 deletions

View File

@@ -14,7 +14,7 @@
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::ship_capture::{correlate, parse_capture, parse_drawlog, serialize_table};
use sylpheed_formats::xiso::open_iso;
use std::collections::HashSet;
use std::path::Path;
@@ -33,8 +33,16 @@ fn main() {
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_capture(&std::fs::read_to_string(log).expect("read log"));
eprintln!("parsed {} draws with WorldView", draws.len());
// Accept either the F10 ship-capture format or the draw-logger format
// (mission_draws.log); auto-detect by trying the F10 parser first.
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);
eprintln!("parsed {} draws (draw-logger format)", draws.len());
} else {
eprintln!("parsed {} draws (F10 capture format)", draws.len());
}
// Decode the ship's base parts to get each part's vertex count (the match key).
let bytes = {

View File

@@ -0,0 +1,50 @@
//! Dump a ship's static placement + scene-graph nodes for a stage container FILE
//! (works offline from an extracted disc, no ISO needed).
//! cargo run --release --example ship_dump -- <Stage_SNN.xpr path> <ship_id>
use sylpheed_formats::mesh::{scene_world_nodes, xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{assemble_ship, is_base_part, ship_id_of};
use std::collections::HashSet;
fn main() {
let a: Vec<String> = std::env::args().collect();
let (path, id) = (&a[1], &a[2]);
let bytes = std::fs::read(path).unwrap();
let names = xbg7_resource_names(&bytes);
// Base parts + their vertex counts.
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);
println!("== base parts ({}) ==", want.len());
for m in &models {
let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
println!(" {:20} vtx={}", m.name, vc);
}
// Composites present for this id.
println!("== composites ==");
for n in &names {
if n.contains(&format!("rou_{id}")) && ship_id_of(n).is_none() {
let nodes = scene_world_nodes(&bytes, n);
println!(" {:24} {} nodes", n, nodes.len());
}
}
// assemble_ship result: centroids + extent.
for ext in [false, true] {
let placed = assemble_ship(&bytes, id, ext);
println!("== assemble_ship(external={ext}) -> {} parts ==", placed.len());
let mut lo=[f32::MAX;3]; let mut hi=[f32::MIN;3];
for p in &placed {
let src = models.iter().find(|m| m.name==p.resource);
let (mut c, mut n) = ([0.0f32;3], 0f32);
if let Some(src)=src { for sub in &src.meshes { for v in &sub.positions {
let w=p.apply(*v); for k in 0..3 { c[k]+=w[k]; lo[k]=lo[k].min(w[k]); hi[k]=hi[k].max(w[k]); } n+=1.0; }}}
let n=n.max(1.0);
println!(" {:20} T=[{:8.1}{:8.1}{:8.1}] centroid=[{:8.1}{:8.1}{:8.1}]",
p.resource, p.t[0],p.t[1],p.t[2], c[0]/n,c[1]/n,c[2]/n);
}
if placed.iter().any(|p| models.iter().any(|m| m.name==p.resource)) {
println!(" EXTENT=[{:.0} {:.0} {:.0}]", hi[0]-lo[0], hi[1]-lo[1], hi[2]-lo[2]);
}
}
}

View File

@@ -89,17 +89,9 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
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;
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
out.push(CapturedDraw { vbase, vcount, r, t });
}
out.push(CapturedDraw { vbase, vcount, r, t });
};
for line in text.lines() {
@@ -132,6 +124,95 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
out
}
/// The vertex shader capital ships (and the player fighter) are drawn with — the
/// `c0..c2` WVP-row layout [`parse_capture`]/[`parse_drawlog`] rely on.
pub const SHIP_VS_HASH: &str = "0xC7F781F4C1D58054";
/// Parse the **draw-logger** format (`xenia_re_draws.log` / `mission_draws.log`,
/// the `--log_draws` cvar) into per-part rigid transforms — the alternative to the
/// F10 [`parse_capture`] snapshot. That log is what a normal instrumented run
/// already produces, so a capital ship seen in-mission can be baked without a
/// dedicated F10 capture.
///
/// A capital ship's parts each own a **distinct vertex buffer**, so we group by
/// the `stream … base=…` address, take its vertex count as `size_words /
/// stride_words`, and keep the first `c0..c2` WVP seen for that buffer. Only draws
/// with `vs=`[`SHIP_VS_HASH`] are kept (the ship shader), so HUD/skybox draws are
/// ignored. (The player fighter shares ONE buffer across its fin draws and so
/// collapses to a single entry here — fine, capital ships are the target.)
pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
let mut out = Vec::new();
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
let mut is_ship = false;
let mut base = 0u32;
let mut stride = 0u32;
let mut size = 0u32;
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
let mut flush = |base: u32,
size: u32,
stride: u32,
consts: &[(usize, [f64; 4])],
seen: &mut std::collections::HashSet<u32>,
out: &mut Vec<CapturedDraw>| {
if base == 0 || stride == 0 || !seen.insert(base) {
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 };
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t });
}
};
for line in text.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("DRAW ") {
flush(base, size, stride, &consts, &mut seen, &mut out);
consts.clear();
base = 0;
stride = 0;
size = 0;
is_ship = rest.contains(&format!("vs={SHIP_VS_HASH}"));
} else if is_ship && l.starts_with("stream ") {
let f = |k: &str| l.split_whitespace().find_map(|t| t.strip_prefix(k));
if let Some(b) = f("base=0x").and_then(|s| u32::from_str_radix(s, 16).ok()) {
base = b;
}
stride = f("stride_words=").and_then(|s| s.parse().ok()).unwrap_or(stride);
size = f("size_words=").and_then(|s| s.parse().ok()).unwrap_or(size);
} else if is_ship && l.starts_with('c') {
// `c<idx> x y z w` — the vsconst rows (space-separated).
let mut it = l.splitn(2, char::is_whitespace);
let Some(tag) = it.next() else { continue };
let Ok(i) = tag[1..].parse::<usize>() else { continue };
let nums: Vec<f64> = it.next().unwrap_or("").split_whitespace().filter_map(|x| x.parse().ok()).collect();
if nums.len() >= 4 {
consts.push((i, [nums[0], nums[1], nums[2], nums[3]]));
}
}
}
flush(base, size, stride, &consts, &mut seen, &mut out);
out
}
/// Normalize the three `c0..c2` WVP rows to a rigid WorldView `(R rows, T)` by
/// dividing each row by its (projection-scale) norm. `None` if any row is
/// degenerate.
fn normalize_wvp(rows: [[f64; 4]; 3]) -> Option<(M3, [f64; 3])> {
let mut r = [[0.0; 3]; 3];
let mut t = [0.0; 3];
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 {
return None;
}
r[i] = [row[0] / n, row[1] / n, row[2] / n];
t[i] = row[3] / n;
}
Some((r, t))
}
/// Correlate captured draws to a ship's base parts and express each in the
/// reference part's frame.
///
@@ -340,6 +421,33 @@ mod tests {
assert_eq!(eng.t, [0.0, 0.0, 50.0]);
}
#[test]
fn drawlog_format_parses_capital_ship_parts() {
// Two capital-ship parts, each its own vertex buffer (distinct base),
// stride 6 → vcount = size_words/6. Identity WVP with known translations.
let log = format!(
"DRAW prim=4 indices=6 src=0 index[...] vs={SHIP_VS_HASH}\n \
stream fc=0 base=0x12A60228 stride_words=6 size_words=9798 endian=2 type=3\n \
vsconst:\n c0 1.0 0.0 0.0 10.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 0.0\n\
DRAW prim=4 indices=6 src=0 index[...] vs={SHIP_VS_HASH}\n \
stream fc=0 base=0x12ABF2CC stride_words=6 size_words=4890 endian=2 type=3\n \
vsconst:\n c0 1.0 0.0 0.0 10.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 50.0\n\
DRAW prim=4 indices=3 src=0 index[...] vs=0xDEADBEEF00000000\n \
stream fc=0 base=0x99990000 stride_words=6 size_words=18 endian=2 type=3\n \
vsconst:\n c0 1.0 0.0 0.0 0.0\n c1 0.0 1.0 0.0 0.0\n c2 0.0 0.0 1.0 0.0\n"
);
let draws = parse_drawlog(&log);
// Two ship-shader buffers; the non-ship shader draw is ignored.
assert_eq!(draws.len(), 2);
assert_eq!(draws[0].vcount, 1633); // 9798/6
assert_eq!(draws[1].vcount, 815); // 4890/6
// Correlate: part B sits 50 along Z from reference part A.
let parts = vec![("e106_bdy_04".to_string(), 1633), ("e106_bdy_03".to_string(), 815)];
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
let b = ship.parts.iter().find(|p| p.part == "e106_bdy_03").unwrap();
assert_eq!(b.t, [0.0, 0.0, 50.0]);
}
#[test]
fn table_round_trips() {
let ship = ShipPlacement {

View File

@@ -65,6 +65,12 @@ 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.
Two capture formats are accepted (auto-detected): the F10 ship-capture snapshot
([`parse_capture`]) and the **draw-logger** format `mission_draws.log`/
`xenia_re_draws.log` (`parse_drawlog` — groups the ship shader `SHIP_VS_HASH`'s
draws by vertex-buffer `base`, `vcount = size_words/stride_words`). So a capital
ship seen in a normal instrumented run can be baked without a dedicated F10 pass.
`examples/correlate_capture.rs` is the CLI wrapper:
```
SYLPHEED_ISO=… cargo run --release --example correlate_capture -- \
@@ -109,6 +115,28 @@ not the 1894-tall tower the static Y put out.
**Missing:** `brg_01` (bridge, vcount 202) was **culled/occluded** at that camera
angle (0 draws) — needs a second capture framing the superstructure.
## What the static assembler gets wrong (measured, `e106` in Stage_S01)
Decoding the real container (`examples/ship_dump.rs`) shows the static
`assemble_ship` is **incomplete** — the capture is required to fix all of:
- **`bdy_01` / `bdy_02` overlap**: both get the *same* composite transform
(T = (116, 0, 857)); the real ship is a **port/starboard pair** at X = ∓264
(the mirror is applied at runtime, like the DeltaSaber fins in `saber_measured`).
- **`eng_02` and `wep_02_01` are never placed**: they aren't `rou_` hull nodes and
aren't in the external category list, so they drop out entirely.
- Only `brg_01` + `eng_01` get the approximate `GN_*`-frame treatment.
So even the *hull* tier is not fully correct, and no static signal supplies the
missing offsets (verified: the part vertex buffers are pure local coords). The
capture-driven table is the only path to correctness — there is nothing more to
squeeze statically.
> Offline status: the draw logs on this box are the **player fighter**, not a
> capital ship, so no capital-ship table can be baked here yet. Capture `e106`
> (Stage_S01, side-on) on the emulator box — F10 **or** just save
> `mission_draws.log` with the ship on screen — then `correlate_capture … --emit`.
## Next steps
1. ~~**Bake:** promote the correlator to a module + checked-in table + viewer