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:
@@ -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 = {
|
||||
|
||||
50
crates/sylpheed-formats/examples/ship_dump.rs
Normal file
50
crates/sylpheed-formats/examples/ship_dump.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user