Compare commits
15 Commits
a68405216f
...
auto/re-we
| Author | SHA1 | Date | |
|---|---|---|---|
| e3f3d55d29 | |||
| 9a86a09f8d | |||
| aaa4ea60b3 | |||
| 621b9dd6e9 | |||
| 07eff85819 | |||
| c3b5f3f401 | |||
| ba33c533da | |||
|
|
c067761e56 | ||
|
|
ea32e77e45 | ||
|
|
31f2637e6c | ||
|
|
737b61242e | ||
|
|
015e49ac8a | ||
|
|
d9442a2106 | ||
|
|
4efc0278b1 | ||
|
|
c6ca5ce9e7 |
21
crates/sylpheed-formats/data/ship_placements.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 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>
|
||||||
|
#
|
||||||
|
|
||||||
|
ship e106 ref=e106_bdy_04
|
||||||
|
e106_bdy_01 1 0 0 0 1 0 0 0 1 -263.9948 -150.43242 1164.8667
|
||||||
|
e106_bdy_02 -1 0 0 0 1 0 0 0 1 264.0088 -150.41339 1164.8651
|
||||||
|
e106_bdy_03 1 0 0 0 1 0 0 0 1 0.0029247368 -34.517372 1075.878
|
||||||
|
e106_bdy_04 1 0 0 0 1 0 0 0 1 0 0 0
|
||||||
|
e106_brg_01 1 0 0 0 1 0 0 0 1 -0.005471501 153.31795 -164.03748
|
||||||
|
e106_eng_01 0.8659965 -0.50002277 0 0.4829627 0.83651507 0.25885975 -0.12941553 -0.22417325 0.96592265 131.2386 -133.06625 -132.06075
|
||||||
|
e106_eng_02 1 0 0 0 1 0 0 0 1 0.0013684053 -49.088192 -139.47827
|
||||||
|
e106_wep_02_01 1 0 0 0 1 0 0 0 1 0.000040663195 49.126797 1009.06744
|
||||||
43
crates/sylpheed-formats/examples/capture_match.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//! Identify which stage + ship a capture log came from: match the capture's
|
||||||
|
//! draw vertex counts against every resource (incl. LODs) of every Stage_SNN.xpr.
|
||||||
|
//! cargo run --release --example capture_match -- <capture.log> <resource3d dir>
|
||||||
|
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
||||||
|
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
|
||||||
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let a: Vec<String> = std::env::args().collect();
|
||||||
|
let text = std::fs::read_to_string(&a[1]).unwrap();
|
||||||
|
let mut draws = parse_capture(&text);
|
||||||
|
if draws.is_empty() { draws = parse_drawlog(&text); }
|
||||||
|
let caps: HashSet<u32> = draws.iter().map(|d| d.vcount).filter(|v| *v >= 100).collect();
|
||||||
|
eprintln!("{} draws, {} distinct vcounts>=100", draws.len(), caps.len());
|
||||||
|
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(&a[2]).unwrap().filter_map(|e| e.ok())
|
||||||
|
.filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n.starts_with("Stage_") && n.ends_with(".xpr") })
|
||||||
|
.map(|e| e.path()).collect();
|
||||||
|
entries.sort();
|
||||||
|
for path in entries {
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
let names = xbg7_resource_names(&bytes);
|
||||||
|
let want: HashSet<String> = names.iter().cloned().collect();
|
||||||
|
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||||
|
// resource -> vcount; group hits by ship-id prefix
|
||||||
|
let mut hits: BTreeMap<String, Vec<(String, u32)>> = BTreeMap::new();
|
||||||
|
for m in &models {
|
||||||
|
let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||||
|
if vc >= 100 && caps.contains(&(vc as u32)) {
|
||||||
|
let id = m.name.get(..4).unwrap_or("?").to_string();
|
||||||
|
hits.entry(id).or_default().push((m.name.clone(), vc as u32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total: usize = hits.values().map(|v| v.len()).sum();
|
||||||
|
if total >= 3 {
|
||||||
|
println!("== {} : {} matching resources ==", path.file_name().unwrap().to_string_lossy(), total);
|
||||||
|
for (id, v) in &hits {
|
||||||
|
let s: Vec<String> = v.iter().map(|(n, c)| format!("{n}({c})")).collect();
|
||||||
|
println!(" {id}: {}", s.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
crates/sylpheed-formats/examples/capture_verify.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
//! Verify static ship assembly against multi-snapshot, multi-instance F10
|
||||||
|
//! captures: cluster every part's ship-relative transform across ALL ships in
|
||||||
|
//! ALL snapshots, and compare the dominant clusters with `assemble_ship`.
|
||||||
|
//!
|
||||||
|
//! cargo run --release --example capture_verify -- <stage.xpr> <ship_id> <log> [log…]
|
||||||
|
//!
|
||||||
|
//! Every draw is position-validated against the ship's parts (any LOD, set
|
||||||
|
//! based). Each `bdy_04`(-LOD) draw acts as a ship reference; every validated
|
||||||
|
//! part draw within ship range contributes `rel = WV_ref⁻¹ ∘ WV_part`. The true
|
||||||
|
//! per-part placements appear as tight clusters repeated once PER SHIP per
|
||||||
|
//! snapshot; cross-ship pairings scatter and stay below the cluster threshold.
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
|
||||||
|
use sylpheed_formats::ship::{assemble_ship, is_base_part, ship_id_of};
|
||||||
|
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
|
||||||
|
|
||||||
|
type M3 = [[f64; 3]; 3];
|
||||||
|
|
||||||
|
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 mv(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 main() {
|
||||||
|
let a: Vec<String> = std::env::args().collect();
|
||||||
|
let bytes = std::fs::read(&a[1]).unwrap();
|
||||||
|
let id = &a[2];
|
||||||
|
|
||||||
|
// Part position sets (base + LODs share the local frame) + per-variant vcounts.
|
||||||
|
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);
|
||||||
|
struct Part {
|
||||||
|
base: String,
|
||||||
|
vcounts: Vec<u32>,
|
||||||
|
pos: Vec<[f32; 3]>, // union of variants
|
||||||
|
}
|
||||||
|
let mut parts: Vec<Part> = Vec::new();
|
||||||
|
for p in &base_parts {
|
||||||
|
let mut vcounts = Vec::new();
|
||||||
|
let mut pos = Vec::new();
|
||||||
|
for cand in [p.clone(), format!("{p}_m"), format!("{p}_l"), format!("{p}_d")] {
|
||||||
|
if let Some(m) = models.iter().find(|m| m.name == cand) {
|
||||||
|
let mp: Vec<[f32; 3]> =
|
||||||
|
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect();
|
||||||
|
vcounts.push(mp.len() as u32);
|
||||||
|
pos.extend(mp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(Part { base: p.clone(), vcounts, pos });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a draw against a part (direct or X-mirrored), like ship_capture.
|
||||||
|
let validate = |d: &CapturedDraw, p: &Part| -> Option<bool> {
|
||||||
|
if !p.vcounts.contains(&d.vcount) || d.pos.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let near = |a: &[f32; 3], b: &[f32; 3]| {
|
||||||
|
(a[0] - b[0]).abs() <= 1e-2 && (a[1] - b[1]).abs() <= 1e-2 && (a[2] - b[2]).abs() <= 1e-2
|
||||||
|
};
|
||||||
|
let (mut direct, mut mirror) = (0usize, 0usize);
|
||||||
|
for q in &d.pos {
|
||||||
|
if p.pos.iter().any(|v| near(q, v)) {
|
||||||
|
direct += 1;
|
||||||
|
}
|
||||||
|
let qm = [-q[0], q[1], q[2]];
|
||||||
|
if p.pos.iter().any(|v| near(&qm, v)) {
|
||||||
|
mirror += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let need = d.pos.len().div_ceil(2);
|
||||||
|
if direct >= need && direct >= mirror {
|
||||||
|
Some(false)
|
||||||
|
} else if mirror >= need {
|
||||||
|
Some(true)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect rel samples per part across all logs.
|
||||||
|
let mut samples: std::collections::BTreeMap<String, Vec<[f64; 3]>> = Default::default();
|
||||||
|
for log in &a[3..] {
|
||||||
|
let text = std::fs::read_to_string(log).unwrap();
|
||||||
|
let draws = parse_capture(&text);
|
||||||
|
// label validated draws
|
||||||
|
let mut labeled: Vec<(usize, usize, bool)> = Vec::new(); // (draw, part, mirrored)
|
||||||
|
for (di, d) in draws.iter().enumerate() {
|
||||||
|
for (pi, p) in parts.iter().enumerate() {
|
||||||
|
if let Some(mir) = validate(d, p) {
|
||||||
|
labeled.push((di, pi, mir));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let refs: Vec<usize> = labeled
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, pi, _)| parts[*pi].base.ends_with("bdy_04"))
|
||||||
|
.map(|(di, ..)| *di)
|
||||||
|
.collect();
|
||||||
|
for &ri in &refs {
|
||||||
|
let rf = &draws[ri];
|
||||||
|
let rt = transpose(&rf.r);
|
||||||
|
for &(di, pi, _mir) in &labeled {
|
||||||
|
let d = &draws[di];
|
||||||
|
let dt = [d.t[0] - rf.t[0], d.t[1] - rf.t[1], d.t[2] - rf.t[2]];
|
||||||
|
let rel = mv(&rt, dt);
|
||||||
|
if rel.iter().map(|v| v * v).sum::<f64>().sqrt() < 2600.0 {
|
||||||
|
// also require the rel rotation be finite-sane
|
||||||
|
let _rm = mmul(&rt, &d.r);
|
||||||
|
samples.entry(parts[pi].base.clone()).or_default().push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!("{log}: {} validated draws, {} bdy_04 refs", labeled.len(), refs.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cluster per part (greedy, 8-unit radius), print clusters with ≥3 samples.
|
||||||
|
println!("\n== dominant ship-relative placements (clusters ≥3 samples) ==");
|
||||||
|
for (part, mut v) in samples {
|
||||||
|
v.sort_by(|x, y| x[0].partial_cmp(&y[0]).unwrap());
|
||||||
|
let mut clusters: Vec<([f64; 3], usize)> = Vec::new();
|
||||||
|
for s in &v {
|
||||||
|
match clusters.iter_mut().find(|(c, _)| {
|
||||||
|
(c[0] - s[0]).abs() < 8.0 && (c[1] - s[1]).abs() < 8.0 && (c[2] - s[2]).abs() < 8.0
|
||||||
|
}) {
|
||||||
|
Some((c, n)) => {
|
||||||
|
for k in 0..3 {
|
||||||
|
c[k] = (c[k] * *n as f64 + s[k]) / (*n as f64 + 1.0);
|
||||||
|
}
|
||||||
|
*n += 1;
|
||||||
|
}
|
||||||
|
None => clusters.push((*s, 1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clusters.sort_by(|x, y| y.1.cmp(&x.1));
|
||||||
|
let tops: Vec<String> = clusters
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, n)| *n >= 3)
|
||||||
|
.take(6)
|
||||||
|
.map(|(c, n)| format!("[{:7.1} {:7.1} {:7.1}]×{n}", c[0], c[1], c[2]))
|
||||||
|
.collect();
|
||||||
|
println!(" {part:20} {}", tops.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assembly rel bdy_04 for comparison.
|
||||||
|
println!("\n== static assemble_ship rel bdy_04 ==");
|
||||||
|
let placed = assemble_ship(&bytes, id, true);
|
||||||
|
if let Some(r4) = placed.iter().find(|p| p.resource == format!("{id}_bdy_04")) {
|
||||||
|
for p in &placed {
|
||||||
|
println!(
|
||||||
|
" {:20} [{:7.1} {:7.1} {:7.1}]",
|
||||||
|
p.resource,
|
||||||
|
p.t[0] - r4.t[0],
|
||||||
|
p.t[1] - r4.t[1],
|
||||||
|
p.t[2] - r4.t[2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,139 +1,62 @@
|
|||||||
//! Recover exact capital-ship part placement from a Canary `xenia_ship_capture.log`
|
//! Recover exact capital-ship part placement from a Canary 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 + leading positions (the match keys), correlates, and
|
||||||
//! the **WorldViewProjection** matrix rows. Their norms are `(sx, sy, 1)` = the
|
//! prints the result. 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:
|
//! Matching is **LOD-aware**: a ship on screen at distance is drawn with its
|
||||||
//! rel_p = WV_ref⁻¹ · WV_p (a pure ship-space rigid transform)
|
//! `_m`/`_l` LOD copies, which live in the same local frame as the base part — so
|
||||||
//! Applying `rel_p` to part `p`'s local geometry assembles the ship exactly, in the
|
//! each base part tries its own vcount first, then its LOD variants', and the
|
||||||
//! reference part's frame — ground truth, no static guessing.
|
//! recovered transform is recorded under the base name. Same-vcount twins
|
||||||
|
//! (mirrored port/starboard hulls) are routed by the draw's position dump.
|
||||||
//!
|
//!
|
||||||
//! 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. SYLPHEED_ISO="/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of
|
||||||
|
//! Deception (USA, Europe) (En,Ja).iso" \
|
||||||
|
//! cargo run --release --example correlate_capture -- \
|
||||||
|
//! 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, parse_drawlog, serialize_table, PartKey,
|
||||||
|
};
|
||||||
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());
|
|
||||||
|
|
||||||
|
// Accept either the F10 ship-capture format or the draw-logger format;
|
||||||
|
// 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 AND their LOD copies (vcount + leading
|
||||||
|
// positions are the match keys; LODs share the base part's local frame).
|
||||||
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 +65,72 @@ fn main() {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
let names = xbg7_resource_names(&bytes);
|
let names = xbg7_resource_names(&bytes);
|
||||||
let parts: Vec<String> =
|
let base_parts: Vec<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()))
|
||||||
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
.cloned()
|
||||||
|
.collect();
|
||||||
// Match each part to a captured draw by vertex count; keep its WorldView.
|
// Candidate resources per base part: itself + `_m`/`_l`/`_d` LOD copies.
|
||||||
struct Placed {
|
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
|
||||||
part: String,
|
for p in &base_parts {
|
||||||
r: M3,
|
for suf in ["_m", "_l", "_d"] {
|
||||||
t: [f64; 3],
|
let cand = format!("{p}{suf}");
|
||||||
local: Vec<[f64; 3]>,
|
if names.contains(&cand) {
|
||||||
}
|
want.insert(cand);
|
||||||
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;
|
|
||||||
};
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
println!(
|
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||||
"\nassembled extent = [{:.0} {:.0} {:.0}] ({} parts placed)",
|
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
|
||||||
hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2], placed.len()
|
let m = models.iter().find(|m| m.name == name)?;
|
||||||
);
|
Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
|
||||||
|
};
|
||||||
|
|
||||||
|
// One PartKey per (part, variant-vcount present in the capture) — correlate
|
||||||
|
// takes the first that validates. Validation uses the UNION of the part's
|
||||||
|
// variant position sets: a draw's `vcount` can match one LOD while its
|
||||||
|
// buffer holds another variant's geometry (seen on the real e106: the
|
||||||
|
// 558-vcount draw carried the FULL 1633-vert bdy_04 buffer), and every
|
||||||
|
// variant is the same part in the same local frame.
|
||||||
|
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();
|
||||||
|
let mut any = false;
|
||||||
|
for cand in &variants {
|
||||||
|
if let Some(pos) = positions_of(cand) {
|
||||||
|
let vcount = pos.len() as u32;
|
||||||
|
if draws.iter().any(|d| d.vcount == vcount) {
|
||||||
|
let lod = if cand == part { "full" } else { cand.rsplit('_').next().unwrap_or("?") };
|
||||||
|
eprintln!(" {part:20} try vcount={vcount:6} [{lod}]");
|
||||||
|
keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !any {
|
||||||
|
eprintln!(" {part:20} — no draw matches any LOD (culled/off-screen?)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ship) = correlate(id, &draws, &keys, ref_sub) else {
|
||||||
|
eprintln!("no parts matched a captured draw");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
eprintln!("\nreference = {} → ship-relative placement:", ship.reference);
|
||||||
|
for p in &ship.parts {
|
||||||
|
eprintln!(" {:18} T=[{:9.1}{:9.1}{:9.1}]", p.part, p.t[0], p.t[1], p.t[2]);
|
||||||
|
}
|
||||||
|
for part in &base_parts {
|
||||||
|
if !ship.parts.iter().any(|p| &p.part == part) {
|
||||||
|
eprintln!(" {part:18} — NOT placed (culled, or its only vcount hit failed position validation)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if emit {
|
||||||
|
print!("{}", serialize_table(std::slice::from_ref(&ship)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
crates/sylpheed-formats/examples/default_owners.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//! Scratch analysis: for one IDXD key, list every object that DECLARES it and
|
||||||
|
//! whether it carries a value on disc or is left at the title-code default.
|
||||||
|
//!
|
||||||
|
//! Run: cargo run -p sylpheed-formats --example default_owners -- <KEY> [KEY...]
|
||||||
|
|
||||||
|
use sylpheed_formats::idxd::IdxdObject;
|
||||||
|
use sylpheed_formats::pak::PakArchive;
|
||||||
|
|
||||||
|
fn is_number(s: &str) -> bool {
|
||||||
|
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
|
||||||
|
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||||
|
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
|
||||||
|
}
|
||||||
|
fn is_key(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||||
|
&& !is_number(s)
|
||||||
|
}
|
||||||
|
fn is_value(s: &str) -> bool {
|
||||||
|
is_number(s) || !is_key(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let root = std::env::var("SYLPHEED_DISC")
|
||||||
|
.unwrap_or_else(|_| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
|
||||||
|
let wanted: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let arc = PakArchive::open(std::path::Path::new(&root).join("dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
|
||||||
|
for e in arc.entries() {
|
||||||
|
let Ok(bytes) = arc.read(e) else { continue };
|
||||||
|
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
|
||||||
|
let toks = obj.tokens().to_vec();
|
||||||
|
let mut hits: Vec<String> = vec![];
|
||||||
|
for (i, t) in toks.iter().enumerate() {
|
||||||
|
if !wanted.iter().any(|w| w == t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let valued = i > 0 && is_value(&toks[i - 1]);
|
||||||
|
hits.push(if valued {
|
||||||
|
format!("{t}={}", toks[i - 1])
|
||||||
|
} else {
|
||||||
|
format!("{t}=<DEFAULT>")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !hits.is_empty() {
|
||||||
|
println!("0x{:08x} {:<44} {}", obj.schema_hash, obj.identity(), hits.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
crates/sylpheed-formats/examples/defaulted_fields.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
//! Scratch analysis: which IDXD fields are DECLARED but left at their default on
|
||||||
|
//! disc, per schema. Those defaults live in title code, so they can only be read
|
||||||
|
//! from the running game — this prints the shopping list for that dynamic capture.
|
||||||
|
//!
|
||||||
|
//! Run: cargo run -p sylpheed-formats --example defaulted_fields -- <disc-root>
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use sylpheed_formats::idxd::IdxdObject;
|
||||||
|
use sylpheed_formats::pak::PakArchive;
|
||||||
|
|
||||||
|
fn is_number(s: &str) -> bool {
|
||||||
|
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
|
||||||
|
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||||
|
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same shape-test the parser uses: an identifier-looking token that is not a
|
||||||
|
/// value literal is a field-name key.
|
||||||
|
fn is_key(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||||
|
&& !is_number(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_value(s: &str) -> bool {
|
||||||
|
is_number(s) || !is_key(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let root = std::env::args()
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_else(|| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
|
||||||
|
let paks: Vec<String> = std::env::args().skip(2).collect();
|
||||||
|
let paks = if paks.is_empty() {
|
||||||
|
vec![
|
||||||
|
"dat/GP_MAIN_GAME_E.pak".to_string(),
|
||||||
|
"dat/GP_HANGAR_ARSENAL.pak".to_string(),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
paks
|
||||||
|
};
|
||||||
|
|
||||||
|
for pak_rel in &paks {
|
||||||
|
let path = std::path::Path::new(&root).join(pak_rel);
|
||||||
|
let Ok(arc) = PakArchive::open(&path) else {
|
||||||
|
eprintln!("-- skip {pak_rel} (open failed)");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
println!("\n================ {pak_rel} ================");
|
||||||
|
|
||||||
|
// schema -> key -> (n_set, n_defaulted, distinct values, example owners)
|
||||||
|
type Stat = (usize, usize, BTreeSet<String>, Vec<String>);
|
||||||
|
let mut per_schema: BTreeMap<u32, (usize, BTreeMap<String, Stat>)> = BTreeMap::new();
|
||||||
|
|
||||||
|
for e in arc.entries() {
|
||||||
|
let Ok(bytes) = arc.read(e) else { continue };
|
||||||
|
let Ok(obj) = IdxdObject::parse(&bytes) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let ident = obj.identity();
|
||||||
|
let toks = obj.tokens().to_vec();
|
||||||
|
let entry = per_schema.entry(obj.schema_hash).or_default();
|
||||||
|
entry.0 += 1;
|
||||||
|
// Track which keys this object declares, and whether each is valued.
|
||||||
|
let mut seen_here: BTreeMap<String, Option<String>> = BTreeMap::new();
|
||||||
|
for (i, t) in toks.iter().enumerate() {
|
||||||
|
if !is_key(t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let prev = if i == 0 { None } else { Some(&toks[i - 1]) };
|
||||||
|
let valued = prev.map(|p| is_value(p)).unwrap_or(false);
|
||||||
|
let v = if valued { Some(toks[i - 1].clone()) } else { None };
|
||||||
|
seen_here.entry(t.clone()).or_insert(v);
|
||||||
|
}
|
||||||
|
for (k, v) in seen_here {
|
||||||
|
let s = entry.1.entry(k).or_default();
|
||||||
|
match v {
|
||||||
|
Some(val) => {
|
||||||
|
s.0 += 1;
|
||||||
|
if s.2.len() < 12 {
|
||||||
|
s.2.insert(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
s.1 += 1;
|
||||||
|
if s.3.len() < 6 {
|
||||||
|
s.3.push(ident.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (schema, (n_obj, keys)) in per_schema {
|
||||||
|
if n_obj < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = match schema {
|
||||||
|
0x0426_e81d => "PLAYER",
|
||||||
|
0x6ab4_825a => "WEAPON",
|
||||||
|
0x43fa_a517 => "UNIT",
|
||||||
|
0x3c5b_0549 => "VESSEL",
|
||||||
|
0xbd86_d41c => "CHARACTER",
|
||||||
|
0x3c9a_e32e => "STAGE",
|
||||||
|
0xb412_e6d8 => "MESSAGE",
|
||||||
|
_ => "?",
|
||||||
|
};
|
||||||
|
let defaulted: Vec<_> = keys
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| s.1 > 0)
|
||||||
|
.collect();
|
||||||
|
if defaulted.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
println!("\n--- schema 0x{schema:08x} {name} ({n_obj} objects) ---");
|
||||||
|
println!(
|
||||||
|
"{:<30} {:>5} {:>5} {}",
|
||||||
|
"KEY", "set", "dflt", "values seen (≤12) | owners defaulting"
|
||||||
|
);
|
||||||
|
for (k, (n_set, n_def, vals, owners)) in defaulted {
|
||||||
|
let vv: Vec<&str> = vals.iter().map(|s| s.as_str()).collect();
|
||||||
|
println!(
|
||||||
|
"{:<30} {:>5} {:>5} {} | {}",
|
||||||
|
k,
|
||||||
|
n_set,
|
||||||
|
n_def,
|
||||||
|
vv.join(","),
|
||||||
|
owners.join(",")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
crates/sylpheed-formats/examples/node_dump.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//! RE diagnostic: raw-dump a composite scene graph's node records — name, table
|
||||||
|
//! pointers, and the FULL joint-table slot values (not just the 8 the decoder
|
||||||
|
//! currently reads) — to derive the true TRS slot layout against captured
|
||||||
|
//! ground truth.
|
||||||
|
//! cargo run --release --example node_dump -- <Stage.xpr path> <composite name> [slots]
|
||||||
|
use sylpheed_formats::mesh::xbg7_descriptor_range;
|
||||||
|
|
||||||
|
fn be32(d: &[u8], o: usize) -> u32 {
|
||||||
|
if o + 4 > d.len() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
|
||||||
|
}
|
||||||
|
fn be_f64(d: &[u8], o: usize) -> f64 {
|
||||||
|
if o + 8 > d.len() {
|
||||||
|
return f64::NAN;
|
||||||
|
}
|
||||||
|
f64::from_be_bytes(d[o..o + 8].try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let a: Vec<String> = std::env::args().collect();
|
||||||
|
let bytes = std::fs::read(&a[1]).unwrap();
|
||||||
|
let comp = &a[2];
|
||||||
|
let nslots: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(12);
|
||||||
|
let (desc, desc_end) = xbg7_descriptor_range(&bytes, comp).expect("composite found");
|
||||||
|
let d = &bytes[desc..desc_end];
|
||||||
|
println!("descriptor [{desc:#x}..{desc_end:#x}) len {:#x}", d.len());
|
||||||
|
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 3 <= d.len() {
|
||||||
|
let starts = (i + 4 <= d.len() && &d[i..i + 4] == b"rou_")
|
||||||
|
|| (i + 3 <= d.len() && &d[i..i + 3] == b"GN_");
|
||||||
|
if !starts {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut j = i;
|
||||||
|
while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let name = std::str::from_utf8(&d[i..j]).unwrap_or("?").to_string();
|
||||||
|
if name.ends_with("_col") || name.ends_with("_spc") || name.ends_with("_gls") || name.ends_with("_lum") {
|
||||||
|
i = j.max(i + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// find the 0xFFFFFFFF end marker
|
||||||
|
let mut ff = i;
|
||||||
|
let scan_end = (i + 0x90).min(d.len().saturating_sub(4));
|
||||||
|
while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF {
|
||||||
|
ff += 4;
|
||||||
|
}
|
||||||
|
if ff >= scan_end || ff < 0x10 {
|
||||||
|
i = j.max(i + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let t44 = be32(d, ff - 0x10) as usize;
|
||||||
|
let t48 = be32(d, ff - 0x0C) as usize;
|
||||||
|
let child = be32(d, ff - 0x08) as usize;
|
||||||
|
let sib = be32(d, ff - 0x04) as usize;
|
||||||
|
// Dump the record's u32s between name end and marker for structure context.
|
||||||
|
let rec_u32: Vec<String> = ((j + 1)..ff)
|
||||||
|
.step_by(4)
|
||||||
|
.map(|o| format!("{:08X}", be32(d, o)))
|
||||||
|
.collect();
|
||||||
|
println!("\nNODE {name} @{i:#x} ff@{ff:#x} t44={t44:#x} t48={t48:#x} child={child:#x} sib={sib:#x}");
|
||||||
|
println!(" rec: {}", rec_u32.join(" "));
|
||||||
|
if t44 != 0 && t44 < d.len() {
|
||||||
|
// Joint table: nslots pointer slots. Each slot record appears to be a
|
||||||
|
// keyframe track: a value ALSO sits 0x48 BEFORE the pointed address
|
||||||
|
// (the rest-pose key A), while the decoder currently reads ptr+8 (B).
|
||||||
|
for k in 0..nslots {
|
||||||
|
let p = be32(d, t44 + k * 4) as usize;
|
||||||
|
if p == 0 || p + 24 > d.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let a = if p >= 0x48 { be_f64(d, p - 0x48) } else { f64::NAN };
|
||||||
|
let b = be_f64(d, p + 8);
|
||||||
|
let head = if p >= 0x50 { be32(d, p - 0x50) } else { 0 };
|
||||||
|
let t_a = if p >= 0x50 { be_f64(d, p - 0x50) } else { f64::NAN };
|
||||||
|
let t_b = be_f64(d, p);
|
||||||
|
println!(
|
||||||
|
" slot{k:2} @{p:#x} head={head:08X}: A={a:12.5} (t={t_a:8.3}) B={b:12.5} (t={t_b:8.3})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = j.max(i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
crates/sylpheed-formats/examples/part_pos.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//! Print a resource's first N decoded positions (buffer order), for matching a
|
||||||
|
//! capture draw's `pos:` dump to a part:
|
||||||
|
//! cargo run --release --example part_pos -- <xpr path> <resource> [n]
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use sylpheed_formats::mesh::Xbg7Model;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let a: Vec<String> = std::env::args().collect();
|
||||||
|
let bytes = std::fs::read(&a[1]).unwrap();
|
||||||
|
let n: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(4);
|
||||||
|
let want: HashSet<String> = [a[2].clone()].into();
|
||||||
|
for m in Xbg7Model::models_named(&bytes, &want, &|| false) {
|
||||||
|
let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||||||
|
print!("{} vtx={}:", m.name, vc);
|
||||||
|
for p in m.meshes.iter().flat_map(|s| &s.positions).take(n) {
|
||||||
|
print!(" ({:.4},{:.4},{:.4})", p[0], p[1], p[2]);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
141
crates/sylpheed-formats/examples/ship_audit.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
//! Audit static ship assembly across ALL stages: flag parts placed far outside
|
||||||
|
//! their ship's cluster (placement bugs) and joint tracks with more than one
|
||||||
|
//! keyframe (which the single-key TRS read does not model).
|
||||||
|
//! cargo run --release --example ship_audit -- <resource3d dir>
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use sylpheed_formats::mesh::{xbg7_descriptor_range, xbg7_resource_names, Xbg7Model};
|
||||||
|
use sylpheed_formats::ship::{assemble_ship, ships_in_container};
|
||||||
|
|
||||||
|
fn be32(d: &[u8], o: usize) -> u32 {
|
||||||
|
if o + 4 > d.len() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count joint-track records whose key count != 1 in a composite's descriptor.
|
||||||
|
fn multikey_tracks(bytes: &[u8], comp: &str) -> usize {
|
||||||
|
let Some((desc, desc_end)) = xbg7_descriptor_range(bytes, comp) else { return 0 };
|
||||||
|
let d = &bytes[desc..desc_end];
|
||||||
|
let mut n = 0usize;
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 4 <= d.len() {
|
||||||
|
let starts = &d[i..i.min(d.len() - 4) + 4] == b"rou_"
|
||||||
|
|| (i + 3 <= d.len() && &d[i..i + 3] == b"GN_");
|
||||||
|
if !starts {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut j = i;
|
||||||
|
while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let mut ff = i;
|
||||||
|
let scan_end = (i + 0x90).min(d.len().saturating_sub(4));
|
||||||
|
while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF {
|
||||||
|
ff += 4;
|
||||||
|
}
|
||||||
|
if ff < scan_end && ff >= 0x10 {
|
||||||
|
let t44 = be32(d, ff - 0x10) as usize;
|
||||||
|
if t44 != 0 && t44 + 32 <= d.len() {
|
||||||
|
for k in 0..8 {
|
||||||
|
let p = be32(d, t44 + k * 4) as usize;
|
||||||
|
if p >= 0x50 && p + 16 <= d.len() {
|
||||||
|
let head = be32(d, p - 0x50);
|
||||||
|
if head != 1 && head != 0 {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = j.max(i + 1);
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let dir = std::env::args().nth(1).unwrap();
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| {
|
||||||
|
let n = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||||
|
n.starts_with("Stage_") && n.ends_with(".xpr")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entries.sort();
|
||||||
|
|
||||||
|
for path in entries {
|
||||||
|
let stage = path.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
let names = xbg7_resource_names(&bytes);
|
||||||
|
|
||||||
|
// Multi-key animation tracks per composite (breaks the single-key read).
|
||||||
|
for n in names.iter().filter(|n| n.contains("rou_") && !n.contains("break")) {
|
||||||
|
let mk = multikey_tracks(&bytes, n);
|
||||||
|
if mk > 0 {
|
||||||
|
println!("MULTIKEY {stage} {n}: {mk} tracks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ship in ships_in_container(&bytes) {
|
||||||
|
if ship.parts.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let placed = assemble_ship(&bytes, &ship.id, true);
|
||||||
|
if placed.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let want: HashSet<String> = placed.iter().map(|p| p.resource.clone()).collect();
|
||||||
|
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||||
|
// Per-placement world centroid.
|
||||||
|
let mut cents: Vec<(String, [f32; 3])> = Vec::new();
|
||||||
|
for p in &placed {
|
||||||
|
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
|
||||||
|
let (mut c, mut n) = ([0.0f32; 3], 0f32);
|
||||||
|
for sub in &m.meshes {
|
||||||
|
for v in &sub.positions {
|
||||||
|
let w = p.apply(*v);
|
||||||
|
c[0] += w[0];
|
||||||
|
c[1] += w[1];
|
||||||
|
c[2] += w[2];
|
||||||
|
n += 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n > 0.0 {
|
||||||
|
cents.push((p.resource.clone(), [c[0] / n, c[1] / n, c[2] / n]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cents.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Ship cluster = median centroid; flag parts > 3× the median spread.
|
||||||
|
let med = |k: usize| -> f32 {
|
||||||
|
let mut v: Vec<f32> = cents.iter().map(|(_, c)| c[k]).collect();
|
||||||
|
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
v[v.len() / 2]
|
||||||
|
};
|
||||||
|
let m = [med(0), med(1), med(2)];
|
||||||
|
let dists: Vec<f32> = cents
|
||||||
|
.iter()
|
||||||
|
.map(|(_, c)| {
|
||||||
|
((c[0] - m[0]).powi(2) + (c[1] - m[1]).powi(2) + (c[2] - m[2]).powi(2)).sqrt()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut sd: Vec<f32> = dists.clone();
|
||||||
|
sd.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
let spread = sd[sd.len() / 2].max(50.0);
|
||||||
|
for ((res, c), dist) in cents.iter().zip(&dists) {
|
||||||
|
if *dist > 6.0 * spread && *dist > 800.0 {
|
||||||
|
println!(
|
||||||
|
"OUTLIER {stage} {}: {res} centroid=[{:.0} {:.0} {:.0}] dist={:.0} (spread {:.0})",
|
||||||
|
ship.id, c[0], c[1], c[2], dist, spread
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("audit done");
|
||||||
|
}
|
||||||
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
crates/sylpheed-formats/examples/ship_render.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
//! Diagnostic: assemble a ship from the BAKED capture table (or the static
|
||||||
|
//! scene graph with --static) and render orthographic PNGs + print per-part
|
||||||
|
//! world bounds, to see exactly what the viewer shows.
|
||||||
|
//! cargo run --release --example ship_render -- <Stage_SNN.xpr path> <ship_id> <out_prefix> [--static]
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use sylpheed_formats::mesh::{ScenePart, Xbg7Model};
|
||||||
|
use sylpheed_formats::ship::assemble_ship;
|
||||||
|
use sylpheed_formats::ship_capture::{embedded_placement, to_scene_parts};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let a: Vec<String> = std::env::args().collect();
|
||||||
|
let use_static = a.iter().any(|s| s == "--static");
|
||||||
|
let (path, id, out) = (&a[1], &a[2], &a[3]);
|
||||||
|
let bytes = std::fs::read(path).unwrap();
|
||||||
|
|
||||||
|
let placed: Vec<ScenePart> = if use_static {
|
||||||
|
assemble_ship(&bytes, id, true)
|
||||||
|
} else {
|
||||||
|
let cap = embedded_placement(id).expect("ship not in baked table");
|
||||||
|
to_scene_parts(&cap)
|
||||||
|
};
|
||||||
|
let want: HashSet<String> = placed.iter().map(|p| p.resource.clone()).collect();
|
||||||
|
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||||
|
|
||||||
|
// World triangles + per-part bounds.
|
||||||
|
let mut tris: Vec<[[f32; 3]; 3]> = Vec::new();
|
||||||
|
for p in &placed {
|
||||||
|
let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
|
||||||
|
let mut lo = [f32::MAX; 3];
|
||||||
|
let mut hi = [f32::MIN; 3];
|
||||||
|
for sub in &m.meshes {
|
||||||
|
let w: Vec<[f32; 3]> = sub.positions.iter().map(|v| p.apply(*v)).collect();
|
||||||
|
for v in &w {
|
||||||
|
for k in 0..3 {
|
||||||
|
lo[k] = lo[k].min(v[k]);
|
||||||
|
hi[k] = hi[k].max(v[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for t in sub.indices.chunks_exact(3) {
|
||||||
|
tris.push([w[t[0] as usize], w[t[1] as usize], w[t[2] as usize]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{:20} X[{:8.1},{:8.1}] Y[{:8.1},{:8.1}] Z[{:8.1},{:8.1}]",
|
||||||
|
p.resource, lo[0], hi[0], lo[1], hi[1], lo[2], hi[2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Exhaust cones at the GN_Jet/GN_SJet frames (the game's visible thrusters).
|
||||||
|
for f in sylpheed_formats::ship::exhaust_frames(&bytes, id) {
|
||||||
|
const SEG: usize = 12;
|
||||||
|
let (r, len) = (22.0f32, 140.0f32);
|
||||||
|
let ring: Vec<[f32; 3]> = (0..SEG)
|
||||||
|
.map(|s| {
|
||||||
|
let a = s as f32 / SEG as f32 * std::f32::consts::TAU;
|
||||||
|
f.apply([a.cos() * r, a.sin() * r, 0.0])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let apex = f.apply([0.0, 0.0, len]);
|
||||||
|
for s in 0..SEG {
|
||||||
|
tris.push([apex, ring[(s + 1) % SEG], ring[s]]);
|
||||||
|
}
|
||||||
|
println!(" exhaust frame {:16} T=[{:8.1}{:8.1}{:8.1}]", f.resource, f.t[0], f.t[1], f.t[2]);
|
||||||
|
}
|
||||||
|
println!("total {} tris from {} placements", tris.len(), placed.len());
|
||||||
|
|
||||||
|
// Orthographic z-buffered flat renders: top (X/Z, look down −Y) and side (Z/Y, look down −X).
|
||||||
|
for (name, ax, ay, az) in [("top", 0usize, 2usize, 1usize), ("side", 2usize, 1usize, 0usize)] {
|
||||||
|
let size = 900usize;
|
||||||
|
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||||
|
for t in &tris {
|
||||||
|
for v in t {
|
||||||
|
for k in 0..3 {
|
||||||
|
lo[k] = lo[k].min(v[k]);
|
||||||
|
hi[k] = hi[k].max(v[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cx = (lo[ax] + hi[ax]) * 0.5;
|
||||||
|
let cy = (lo[ay] + hi[ay]) * 0.5;
|
||||||
|
let ext = (hi[ax] - lo[ax]).max(hi[ay] - lo[ay]) * 0.55;
|
||||||
|
let scale = (size as f32 * 0.5) / ext;
|
||||||
|
let mut img = vec![0u8; size * size * 3];
|
||||||
|
let mut zbuf = vec![f32::MIN; size * size];
|
||||||
|
for t in &tris {
|
||||||
|
// screen coords
|
||||||
|
let p: Vec<[f32; 3]> = t
|
||||||
|
.iter()
|
||||||
|
.map(|v| {
|
||||||
|
[
|
||||||
|
(v[ax] - cx) * scale + size as f32 * 0.5,
|
||||||
|
(cy - v[ay]) * scale + size as f32 * 0.5,
|
||||||
|
v[az],
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// flat shade by triangle normal Z-ish
|
||||||
|
let e1 = [t[1][0] - t[0][0], t[1][1] - t[0][1], t[1][2] - t[0][2]];
|
||||||
|
let e2 = [t[2][0] - t[0][0], t[2][1] - t[0][1], t[2][2] - t[0][2]];
|
||||||
|
let n = [
|
||||||
|
e1[1] * e2[2] - e1[2] * e2[1],
|
||||||
|
e1[2] * e2[0] - e1[0] * e2[2],
|
||||||
|
e1[0] * e2[1] - e1[1] * e2[0],
|
||||||
|
];
|
||||||
|
let nl = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-6);
|
||||||
|
let lum = (n[az].abs() / nl * 200.0 + 40.0) as u8;
|
||||||
|
// bbox raster
|
||||||
|
let minx = p.iter().map(|v| v[0]).fold(f32::MAX, f32::min).max(0.0) as usize;
|
||||||
|
let maxx = (p.iter().map(|v| v[0]).fold(f32::MIN, f32::max).min(size as f32 - 1.0)) as usize;
|
||||||
|
let miny = p.iter().map(|v| v[1]).fold(f32::MAX, f32::min).max(0.0) as usize;
|
||||||
|
let maxy = (p.iter().map(|v| v[1]).fold(f32::MIN, f32::max).min(size as f32 - 1.0)) as usize;
|
||||||
|
let det = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]);
|
||||||
|
if det.abs() < 1e-6 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for y in miny..=maxy {
|
||||||
|
for x in minx..=maxx {
|
||||||
|
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||||
|
let w0 = ((p[1][0] - fx) * (p[2][1] - fy) - (p[2][0] - fx) * (p[1][1] - fy)) / det;
|
||||||
|
let w1 = ((p[2][0] - fx) * (p[0][1] - fy) - (p[0][0] - fx) * (p[2][1] - fy)) / det;
|
||||||
|
let w2 = 1.0 - w0 - w1;
|
||||||
|
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let z = w0 * p[0][2] + w1 * p[1][2] + w2 * p[2][2];
|
||||||
|
let idx = y * size + x;
|
||||||
|
if z > zbuf[idx] {
|
||||||
|
zbuf[idx] = z;
|
||||||
|
img[idx * 3] = lum;
|
||||||
|
img[idx * 3 + 1] = lum;
|
||||||
|
img[idx * 3 + 2] = lum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let file = format!("{out}_{name}.ppm");
|
||||||
|
let mut ppm = format!("P6\n{size} {size}\n255\n").into_bytes();
|
||||||
|
ppm.extend_from_slice(&img);
|
||||||
|
std::fs::write(&file, ppm).unwrap();
|
||||||
|
println!("wrote {file}");
|
||||||
|
}
|
||||||
|
}
|
||||||
55
crates/sylpheed-formats/examples/ui_screen.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//! Scratch analysis: inventory one UI screen's pak — every entry, and for RATC
|
||||||
|
//! entries the children they bundle. Optionally write an entry's decompressed
|
||||||
|
//! bytes out for hex inspection.
|
||||||
|
//!
|
||||||
|
//! Run: cargo run -p sylpheed-formats --example ui_screen -- <PAK> [--dump 0xHASH out.bin]
|
||||||
|
|
||||||
|
use sylpheed_formats::pak::{self, PakArchive};
|
||||||
|
use sylpheed_formats::ratc;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let pak = args.next().expect("usage: ui_screen <pak> [--dump 0xHASH out.bin]");
|
||||||
|
let arc = PakArchive::open(&pak).expect("open pak");
|
||||||
|
|
||||||
|
let rest: Vec<String> = args.collect();
|
||||||
|
if rest.first().map(|s| s == "--dump").unwrap_or(false) {
|
||||||
|
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
|
||||||
|
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
|
||||||
|
std::fs::write(&rest[2], &bytes).unwrap();
|
||||||
|
println!("wrote {} bytes to {}", bytes.len(), rest[2]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rest.first().map(|s| s == "--child").unwrap_or(false) {
|
||||||
|
// --child 0xHASH <child-name> <out>: carve one RATC child out by its
|
||||||
|
// listed offset/size, so a 165-byte layout record can be hexdumped alone.
|
||||||
|
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
|
||||||
|
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
|
||||||
|
let kids = ratc::parse(&bytes).expect("ratc");
|
||||||
|
let k = kids.iter().find(|k| k.name == rest[2]).expect("child not found");
|
||||||
|
std::fs::write(&rest[3], &bytes[k.offset..k.offset + k.size]).unwrap();
|
||||||
|
println!("wrote {} B ({} @ {:#x}) to {}", k.size, k.name, k.offset, rest[3]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{pak}: {} entries", arc.len());
|
||||||
|
for e in arc.entries() {
|
||||||
|
let Ok(bytes) = arc.read(e) else {
|
||||||
|
println!(" {:08x} <decompress failed>", e.name_hash);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let label = pak::inner_format_label(&bytes);
|
||||||
|
println!(" {:08x} {:>9} B {}", e.name_hash, bytes.len(), label);
|
||||||
|
if ratc::is_ratc(&bytes) {
|
||||||
|
if let Some(kids) = ratc::parse(&bytes) {
|
||||||
|
for k in &kids {
|
||||||
|
println!(" - {:<40} {:>9} B {}", k.name, k.size, k.kind);
|
||||||
|
}
|
||||||
|
if kids.is_empty() {
|
||||||
|
println!(" (no children found)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -1403,6 +1403,13 @@ pub fn submesh_albedos(bytes: &[u8], resource_name: &str) -> Vec<(usize, usize,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Locate the descriptor byte range of the named XBG7 resource in an XPR2 file.
|
/// Locate the descriptor byte range of the named XBG7 resource in an XPR2 file.
|
||||||
|
/// Diagnostic access to a resource's raw XBG7 descriptor byte range — used by
|
||||||
|
/// the node-graph reverse-engineering examples. Not part of the decode API.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn xbg7_descriptor_range(bytes: &[u8], resource_name: &str) -> Option<(usize, usize)> {
|
||||||
|
xbg7_descriptor(bytes, resource_name)
|
||||||
|
}
|
||||||
|
|
||||||
fn xbg7_descriptor(bytes: &[u8], resource_name: &str) -> Option<(usize, usize)> {
|
fn xbg7_descriptor(bytes: &[u8], resource_name: &str) -> Option<(usize, usize)> {
|
||||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||||
return None;
|
return None;
|
||||||
@@ -1602,12 +1609,58 @@ fn rot_x(a: f32) -> M3 {
|
|||||||
let (s, c) = a.sin_cos();
|
let (s, c) = a.sin_cos();
|
||||||
[[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]
|
[[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]
|
||||||
}
|
}
|
||||||
|
/// Rotation about Y (vertical) — mixes X and Z (yaw).
|
||||||
|
fn rot_y(a: f32) -> M3 {
|
||||||
|
let (s, c) = a.sin_cos();
|
||||||
|
[[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]]
|
||||||
|
}
|
||||||
/// Rotation about Z (fore/aft) — mixes X and Y (V-tail cant / roll).
|
/// Rotation about Z (fore/aft) — mixes X and Y (V-tail cant / roll).
|
||||||
fn rot_z(a: f32) -> M3 {
|
fn rot_z(a: f32) -> M3 {
|
||||||
let (s, c) = a.sin_cos();
|
let (s, c) = a.sin_cos();
|
||||||
[[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
|
[[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a node's **9-channel TRS** `[TX TY TZ RY RX RZ SX SY SZ]` from its joint
|
||||||
|
/// table at `t44`.
|
||||||
|
///
|
||||||
|
/// The table holds **8 pointer slots**, but the node has **nine** keyframe
|
||||||
|
/// tracks (3 translation, 3 rotation, 3 scale), each a 0x50-byte record with its
|
||||||
|
/// value at `+8`. The pointers are shifted one record forward: `ptr[k]` points at
|
||||||
|
/// track `k+1`'s record, so channel `k+1`'s value is `f64 @ ptr[k]+8` and channel
|
||||||
|
/// 0 (TX!) — which no pointer names — sits one record *before* the first, at
|
||||||
|
/// `ptr[0] − 0x48`.
|
||||||
|
///
|
||||||
|
/// This off-by-one is what hid every lateral offset: the old 8-slot read took
|
||||||
|
/// TY as "X", RY as "Y" (usually 0 — why hulls stacked on the centreline) and
|
||||||
|
/// never saw TX at all (the ±264 hull pair offset). Derived 2026-07-26 from the
|
||||||
|
/// e106 F10 runtime capture and verified against the captured transforms of all
|
||||||
|
/// 8 destroyer parts (see docs/re/ship-placement-runtime-capture.md).
|
||||||
|
fn read_trs9(d: &[u8], t44: usize) -> [f64; 9] {
|
||||||
|
let mut v = [0.0f64; 9];
|
||||||
|
let p0 = be32(d, t44) as usize;
|
||||||
|
if p0 >= 0x48 && p0 + 16 <= d.len() {
|
||||||
|
v[0] = be_f64(d, p0 - 0x48);
|
||||||
|
}
|
||||||
|
for k in 0..8 {
|
||||||
|
let p = be32(d, t44 + k * 4) as usize;
|
||||||
|
if p != 0 && p + 16 <= d.len() {
|
||||||
|
v[k + 1] = be_f64(d, p + 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compose a node's local rotation from its three Euler channels
|
||||||
|
/// `(ch3, ch4, ch5) = (RY, RX, RZ)` — **`Ry·Rx·Rz`**, angles applied directly.
|
||||||
|
/// Convention pinned by the e106 engine-rig runtime capture: the nacelle node
|
||||||
|
/// stores `(RX, RZ) = (−15°, +30°)` and the captured WorldView rotation equals
|
||||||
|
/// `Rx(−15°)·Rz(30°)` exactly (decomposed element-for-element). The older saber
|
||||||
|
/// analysis script recovered the transpose of this convention; the fin override
|
||||||
|
/// [`saber_measured`] keeps those parts pinned either way.
|
||||||
|
fn node_rotation(ry: f64, rx: f64, rz: f64) -> M3 {
|
||||||
|
m3_mul(m3_mul(rot_y(ry as f32), rot_x(rx as f32)), rot_z(rz as f32))
|
||||||
|
}
|
||||||
|
|
||||||
/// The DeltaSaber fin-assembly part names — the structural signature that marks a
|
/// The DeltaSaber fin-assembly part names — the structural signature that marks a
|
||||||
/// DeltaSaber-family model (`_T`/`_W`/`_A` = f001/f002/f004; same layout, slightly
|
/// DeltaSaber-family model (`_T`/`_W`/`_A` = f001/f002/f004; same layout, slightly
|
||||||
/// different vertex counts) so the measured placements below apply to all three.
|
/// different vertex counts) so the measured placements below apply to all three.
|
||||||
@@ -1685,18 +1738,9 @@ pub fn node_transforms(bytes: &[u8], resource_name: &str) -> Vec<NodePlacement>
|
|||||||
let head_end = d.len().min(0x1684);
|
let head_end = d.len().min(0x1684);
|
||||||
let prefix = format!("rou_{resource_name}_");
|
let prefix = format!("rou_{resource_name}_");
|
||||||
|
|
||||||
// 8-value TRS from a node's `+0x44` joint table (8 pointers, each f64 @ +8);
|
// 9-channel TRS `[TX TY TZ RY RX RZ SX SY SZ]` from the node's `+0x44` joint
|
||||||
// returns identity-safe zeros when the table is absent.
|
// table (see [`read_trs9`] — the 8 pointers are shifted one track forward).
|
||||||
let read_trs = |t44: usize| -> [f64; 8] {
|
let read_trs = |t44: usize| read_trs9(d, t44);
|
||||||
let mut v = [0.0f64; 8];
|
|
||||||
for (k, slot) in v.iter_mut().enumerate() {
|
|
||||||
let p = be32(d, t44 + k * 4) as usize;
|
|
||||||
if p != 0 && p + 16 <= d.len() {
|
|
||||||
*slot = be_f64(d, p + 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v
|
|
||||||
};
|
|
||||||
|
|
||||||
// Phase 1: collect node records in document order, each with its local
|
// Phase 1: collect node records in document order, each with its local
|
||||||
// affine and the offset where its subtree ends (its next sibling).
|
// affine and the offset where its subtree ends (its next sibling).
|
||||||
@@ -1752,18 +1796,18 @@ pub fn node_transforms(bytes: &[u8], resource_name: &str) -> Vec<NodePlacement>
|
|||||||
let trs = if t44 != 0 && t44 + 32 <= d.len() {
|
let trs = if t44 != 0 && t44 + 32 <= d.len() {
|
||||||
read_trs(t44)
|
read_trs(t44)
|
||||||
} else {
|
} else {
|
||||||
[0.0; 8]
|
[0.0; 9]
|
||||||
};
|
};
|
||||||
if std::env::var("XNODEDUMP").is_ok() {
|
if std::env::var("XNODEDUMP").is_ok() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"NODE {name} vtx={vtx} t44={t44:#x} t48={t48:#x} ff@{ff:#x} sib@{sib_ptr:#x} TRS=[{:.4} {:.4} {:.4} | {:.4} {:.4} | {:.4} {:.4} {:.4}]",
|
"NODE {name} vtx={vtx} t44={t44:#x} t48={t48:#x} ff@{ff:#x} sib@{sib_ptr:#x} TRS=[{:.4} {:.4} {:.4} | {:.4} {:.4} {:.4} | {:.4} {:.4} {:.4}]",
|
||||||
trs[0], trs[1], trs[2], trs[3], trs[4], trs[5], trs[6], trs[7]
|
trs[0], trs[1], trs[2], trs[3], trs[4], trs[5], trs[6], trs[7], trs[8]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Local transform: translation (t0=X, t2=up→Y, t1=fore/aft→Z), and a
|
// Local transform: translation (TX, TY, TZ) + Euler rotation (see
|
||||||
// rotation Rz(r1)·Rx(r0) (r1 = V-tail cant about fore/aft, r0 = pitch).
|
// [`node_rotation`]).
|
||||||
let local_t = [trs[0] as f32, trs[2] as f32, trs[1] as f32];
|
let local_t = [trs[0] as f32, trs[1] as f32, trs[2] as f32];
|
||||||
let local_m = m3_mul(rot_z(trs[4] as f32), rot_x(trs[3] as f32));
|
let local_m = node_rotation(trs[3], trs[4], trs[5]);
|
||||||
// Next sibling: its name starts 4 bytes before the pointer, and there a
|
// Next sibling: its name starts 4 bytes before the pointer, and there a
|
||||||
// real node record begins with "rou_". Everything between here and there
|
// real node record begins with "rou_". Everything between here and there
|
||||||
// is this node's subtree.
|
// is this node's subtree.
|
||||||
@@ -1882,16 +1926,7 @@ pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec<ScenePart> {
|
|||||||
let d = &bytes[desc..desc_end];
|
let d = &bytes[desc..desc_end];
|
||||||
let head_end = d.len();
|
let head_end = d.len();
|
||||||
|
|
||||||
let read_trs = |t44: usize| -> [f64; 8] {
|
let read_trs = |t44: usize| read_trs9(d, t44);
|
||||||
let mut v = [0.0f64; 8];
|
|
||||||
for (k, slot) in v.iter_mut().enumerate() {
|
|
||||||
let p = be32(d, t44 + k * 4) as usize;
|
|
||||||
if p != 0 && p + 16 <= d.len() {
|
|
||||||
*slot = be_f64(d, p + 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v
|
|
||||||
};
|
|
||||||
|
|
||||||
// A node record begins with a name starting `rou_` or `GN_`.
|
// A node record begins with a name starting `rou_` or `GN_`.
|
||||||
let node_name_at = |i: usize| -> Option<(String, usize)> {
|
let node_name_at = |i: usize| -> Option<(String, usize)> {
|
||||||
@@ -1944,13 +1979,13 @@ pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec<ScenePart> {
|
|||||||
let trs = if t44 != 0 && t44 + 32 <= d.len() {
|
let trs = if t44 != 0 && t44 + 32 <= d.len() {
|
||||||
read_trs(t44)
|
read_trs(t44)
|
||||||
} else {
|
} else {
|
||||||
[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
|
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
|
||||||
};
|
};
|
||||||
let local_t = [trs[0] as f32, trs[2] as f32, trs[1] as f32];
|
let local_t = [trs[0] as f32, trs[1] as f32, trs[2] as f32];
|
||||||
let local_m = m3_mul(rot_z(trs[4] as f32), rot_x(trs[3] as f32));
|
let local_m = node_rotation(trs[3], trs[4], trs[5]);
|
||||||
// Slots 5–7 are per-axis scale (a hardpoint part ships at e.g. 0.5).
|
// Channels 6–8 are per-axis scale (a hardpoint part ships at e.g. 0.5).
|
||||||
let sc = |v: f64| if v.abs() < 1e-6 { 1.0 } else { v as f32 };
|
let sc = |v: f64| if v.abs() < 1e-6 { 1.0 } else { v as f32 };
|
||||||
let local_s = [sc(trs[5]), sc(trs[6]), sc(trs[7])];
|
let local_s = [sc(trs[6]), sc(trs[7]), sc(trs[8])];
|
||||||
// Next sibling begins 4 bytes before its pointer, at a `rou_`/`GN_` name.
|
// Next sibling begins 4 bytes before its pointer, at a `rou_`/`GN_` name.
|
||||||
let sib_end = sib_ptr.checked_sub(4).filter(|&s| {
|
let sib_end = sib_ptr.checked_sub(4).filter(|&s| {
|
||||||
s > i && s + 4 <= head_end && (&d[s..s + 4] == b"rou_" || &d[s..s + 3] == b"GN_")
|
s > i && s + 4 <= head_end && (&d[s..s + 4] == b"rou_" || &d[s..s + 3] == b"GN_")
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ pub struct ShipModel {
|
|||||||
pub faction: Faction,
|
pub faction: Faction,
|
||||||
/// Base geometry resource names to draw together, in directory order.
|
/// Base geometry resource names to draw together, in directory order.
|
||||||
pub parts: Vec<String>,
|
pub parts: Vec<String>,
|
||||||
|
/// Whether a composite scene graph (`e_rou_<id>`) exists — i.e. this is a
|
||||||
|
/// real assembled ship. Families without one (fighter morph sets, shared
|
||||||
|
/// turret/prop parts) have no placement data and would stack at the origin.
|
||||||
|
pub has_composite: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `[a-z]NNN` id prefix of a resource name, if it has one (`e106_bdy_01`
|
/// The `[a-z]NNN` id prefix of a resource name, if it has one (`e106_bdy_01`
|
||||||
@@ -146,7 +150,13 @@ pub fn ships_in_container(bytes: &[u8]) -> Vec<ShipModel> {
|
|||||||
let id = ship_id_of(n).unwrap().to_string();
|
let id = ship_id_of(n).unwrap().to_string();
|
||||||
let entry = ships.entry(id.clone()).or_insert_with(|| {
|
let entry = ships.entry(id.clone()).or_insert_with(|| {
|
||||||
ids.push(id.clone());
|
ids.push(id.clone());
|
||||||
ShipModel { id: id.clone(), faction: Faction::from_id(&id), parts: Vec::new() }
|
let has_composite = !composites_for(&names, &id).is_empty();
|
||||||
|
ShipModel {
|
||||||
|
id: id.clone(),
|
||||||
|
faction: Faction::from_id(&id),
|
||||||
|
parts: Vec::new(),
|
||||||
|
has_composite,
|
||||||
|
}
|
||||||
});
|
});
|
||||||
entry.parts.push(n.clone());
|
entry.parts.push(n.clone());
|
||||||
}
|
}
|
||||||
@@ -160,22 +170,45 @@ pub fn ships_in_container(bytes: &[u8]) -> Vec<ShipModel> {
|
|||||||
/// nothing and only pose their children.
|
/// nothing and only pose their children.
|
||||||
fn resolve_hull_resource(node: &str, resources: &HashSet<String>) -> Option<String> {
|
fn resolve_hull_resource(node: &str, resources: &HashSet<String>) -> Option<String> {
|
||||||
let stem = node.rsplit_once("rou_").map(|(_, s)| s).unwrap_or(node);
|
let stem = node.rsplit_once("rou_").map(|(_, s)| s).unwrap_or(node);
|
||||||
if resources.contains(stem) {
|
let try_stem = |s: &str| -> Option<String> {
|
||||||
return Some(stem.to_string());
|
if resources.contains(s) {
|
||||||
|
return Some(s.to_string());
|
||||||
|
}
|
||||||
|
// Squeezed digit run (`wep02` names the `wep_02_01` resource): insert an
|
||||||
|
// underscore before the digits and try the `_01` first-part too.
|
||||||
|
if let Some(pos) = s.rfind(|c: char| c.is_ascii_digit()) {
|
||||||
|
let mut start = pos;
|
||||||
|
while start > 0 && s.as_bytes()[start - 1].is_ascii_digit() {
|
||||||
|
start -= 1;
|
||||||
|
}
|
||||||
|
if start > 0 && s.as_bytes()[start - 1] != b'_' {
|
||||||
|
let split = format!("{}_{}", &s[..start], &s[start..]);
|
||||||
|
if resources.contains(&split) {
|
||||||
|
return Some(split);
|
||||||
|
}
|
||||||
|
let first = format!("{split}_01");
|
||||||
|
if resources.contains(&first) {
|
||||||
|
return Some(first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(r) = try_stem(stem) {
|
||||||
|
return Some(r);
|
||||||
}
|
}
|
||||||
for suf in ["_root", "_mov"] {
|
for suf in ["_root", "_mov"] {
|
||||||
if let Some(s) = stem.strip_suffix(suf) {
|
if let Some(s) = stem.strip_suffix(suf) {
|
||||||
if resources.contains(s) {
|
if let Some(r) = try_stem(s) {
|
||||||
return Some(s.to_string());
|
return Some(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Trailing `_NN` instance index (`eng_01_1` → `eng_01`).
|
// Trailing `_NN` instance index (`eng_01_1` → `eng_01`).
|
||||||
if let Some(pos) = stem.rfind('_') {
|
if let Some(pos) = stem.rfind('_') {
|
||||||
if !stem[pos + 1..].is_empty() && stem[pos + 1..].bytes().all(|c| c.is_ascii_digit()) {
|
if !stem[pos + 1..].is_empty() && stem[pos + 1..].bytes().all(|c| c.is_ascii_digit()) {
|
||||||
let s = &stem[..pos];
|
if let Some(r) = try_stem(&stem[..pos]) {
|
||||||
if resources.contains(s) {
|
return Some(r);
|
||||||
return Some(s.to_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,31 +243,31 @@ fn composites_for<'a>(names: &'a [String], id: &str) -> Vec<&'a String> {
|
|||||||
/// Reconstruct a whole ship as a list of geometry-resource placements in a shared
|
/// Reconstruct a whole ship as a list of geometry-resource placements in a shared
|
||||||
/// ship-local frame — the placement the game builds at runtime.
|
/// ship-local frame — the placement the game builds at runtime.
|
||||||
///
|
///
|
||||||
/// Two tiers, matching how the game splits a ship:
|
/// Fully static and EXACT (validated part-for-part against an e106 runtime
|
||||||
/// 1. **Hull** — `rou_<id>_*` nodes in the primary composite scene graph name the
|
/// capture — see `docs/re/ship-placement-runtime-capture.md`):
|
||||||
/// hull body resources and carry their world transform. This tier is exact: the
|
/// 1. **Composite nodes** — every `rou_*` node in the primary composite
|
||||||
/// bodies (and any engine posed by a `rou_` node) land where the game puts them.
|
/// (`e_rou_<id>`) places a geometry resource, INCLUDING repeated instances
|
||||||
/// 2. **External hardpoints** — `GN_*` frame nodes are the Vessel mounts; each
|
/// and cross-id turret mounts (`rou_e303_wep_01_root` ×2 on the e106 hull).
|
||||||
/// unplaced bridge / shield-generator / engine part (`<id>_brg_*`, `_sld_*`,
|
/// 2. **Detail rigs** — `e_rou_<id>_eng` is the engine cluster rig; its nodes
|
||||||
/// `_eng_*`) is placed at its matching frame by category + index. This tier is
|
/// (e.g. two `eng_01` nacelle instances + `eng_02`) mount at the primary
|
||||||
/// APPROXIMATE: a `GN_*` frame is the mount *pivot* (often on the centreline),
|
/// composite's `GN_Engine_01` frame: `world = frame ∘ rig_local`.
|
||||||
/// while the part's outboard/rotational offset lives in a detail sub-rig or in
|
/// 3. **GN hardpoints** — remaining unplaced `brg`/`sld` parts sit exactly at
|
||||||
/// runtime code that this static pass does not recover — so external parts land
|
/// their `GN_Bridge_NN` / `GN_ShieldG_NN` frame (frames carry full TRS).
|
||||||
/// near, not exactly, where they belong. Gated by `include_external`.
|
/// 4. **Mirrored twins** — a port/starboard pair (`bdy_01`/`bdy_02`) shares one
|
||||||
|
/// geometry; the instance whose lateral offset points the geometry's dominant
|
||||||
|
/// side inboard is drawn X-reflected (capture-verified; det < 0 → the caller
|
||||||
|
/// reverses winding).
|
||||||
///
|
///
|
||||||
/// Turret placement (shared `e3NN`/`e4NN` gun models mounted at many `GN_*Gun*`
|
/// `include_external` keeps tiers 2–4 (off = bare composite-node hull).
|
||||||
/// frames) needs the per-mount Vessel recipe and is not resolved at all.
|
|
||||||
pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<ScenePart> {
|
pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<ScenePart> {
|
||||||
let names = xbg7_resource_names(bytes);
|
let names = xbg7_resource_names(bytes);
|
||||||
let resources: HashSet<String> = names.iter().cloned().collect();
|
let resources: HashSet<String> = names.iter().cloned().collect();
|
||||||
|
|
||||||
// The PRIMARY composite carries the real ship-space layout (hull bodies +
|
// The PRIMARY composite carries the ship-space layout (hull bodies, turret
|
||||||
// every `GN_*` hardpoint frame). Sibling composites (`e_rou_<id>_eng`,
|
// mounts + every `GN_*` hardpoint frame): the one with the most nodes.
|
||||||
// `_wep_*`) are LOCAL detail rigs — their nodes are in their own frame, not
|
|
||||||
// ship-space, so folding them in floats parts mid-ship. Pick the composite
|
|
||||||
// with the most nodes; that is the assembled hull.
|
|
||||||
let scenes: Vec<(String, Vec<ScenePart>)> = composites_for(&names, id)
|
let scenes: Vec<(String, Vec<ScenePart>)> = composites_for(&names, id)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|c| !c.ends_with("_joint"))
|
||||||
.map(|c| (c.clone(), scene_world_nodes(bytes, c)))
|
.map(|c| (c.clone(), scene_world_nodes(bytes, c)))
|
||||||
.collect();
|
.collect();
|
||||||
let Some((_, nodes)) = scenes.iter().max_by_key(|(_, n)| n.len()) else {
|
let Some((_, nodes)) = scenes.iter().max_by_key(|(_, n)| n.len()) else {
|
||||||
@@ -252,24 +285,54 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
|
|||||||
// GN hardpoint frames, world-space, from the primary composite.
|
// GN hardpoint frames, world-space, from the primary composite.
|
||||||
let mut frames: Vec<ScenePart> = Vec::new();
|
let mut frames: Vec<ScenePart> = Vec::new();
|
||||||
|
|
||||||
|
// Tier 1: every composite node placement, instances included. Cross-id
|
||||||
|
// resources (shared e303/e4NN turret models the composite mounts) count too.
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
if node.resource.starts_with("GN_") {
|
if node.resource.starts_with("GN_") {
|
||||||
frames.push(node.clone());
|
frames.push(node.clone());
|
||||||
} else if let Some(res) = resolve_hull_resource(&node.resource, &resources) {
|
} else if let Some(res) = resolve_hull_resource(&node.resource, &resources) {
|
||||||
// Only the ship's own parts. A composite also mounts shared, cross-id
|
if ship_id_of(&res) != Some(id) && !include_external {
|
||||||
// turret models (`e303_wep_*` on an `e106` hull); those need the
|
continue; // hull-only view: own parts only
|
||||||
// per-mount Vessel recipe and are placed separately.
|
|
||||||
if ship_id_of(&res) != Some(id) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if placed_res.insert(res.clone()) {
|
// Distinct INSTANCES keep their own placement; a nested alias of the
|
||||||
|
// same resource at the same transform (`bdy_01_mov` + its identity
|
||||||
|
// `bdy_01` child) collapses to one draw.
|
||||||
|
let dup = placed.iter().any(|p| {
|
||||||
|
p.resource == res
|
||||||
|
&& (p.t[0] - node.t[0]).abs() < 0.01
|
||||||
|
&& (p.t[1] - node.t[1]).abs() < 0.01
|
||||||
|
&& (p.t[2] - node.t[2]).abs() < 0.01
|
||||||
|
});
|
||||||
|
if !dup {
|
||||||
|
placed_res.insert(res.clone());
|
||||||
placed.push(ScenePart { resource: res, m: node.m, t: node.t, s: node.s });
|
placed.push(ScenePart { resource: res, m: node.m, t: node.t, s: node.s });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place unplaced external parts at their Vessel hardpoint frame (APPROXIMATE —
|
// Tier 2: the engine cluster rig, mounted at GN_Engine_01. The rig's nodes
|
||||||
// see the doc comment). Skipped for a clean, exact hull-only render.
|
// are in the FRAME's local space (two mirrored `eng_01` nacelles + centre
|
||||||
|
// `eng_02` for e106) — capture-verified to 0.1 units.
|
||||||
|
if include_external {
|
||||||
|
let rig_name = format!("e_rou_{id}_eng");
|
||||||
|
if names.contains(&rig_name) {
|
||||||
|
if let Some(frame) = frames.iter().find(|f| f.resource.starts_with("GN_Engine")) {
|
||||||
|
for rn in scene_world_nodes(bytes, &rig_name) {
|
||||||
|
let Some(res) = resolve_hull_resource(&rn.resource, &resources) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// world = frame ∘ rig_local
|
||||||
|
let m = m3_mul_pub(frame.m, rn.m);
|
||||||
|
let rt = m3_vec_pub(frame.m, rn.t);
|
||||||
|
let t = [rt[0] + frame.t[0], rt[1] + frame.t[1], rt[2] + frame.t[2]];
|
||||||
|
placed_res.insert(res.clone());
|
||||||
|
placed.push(ScenePart { resource: res, m, t, s: rn.s });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: remaining external parts at their exact GN frame (full TRS).
|
||||||
const CATS: [(&str, &str); 3] = [("brg", "Bridge"), ("sld", "ShieldG"), ("eng", "Engine")];
|
const CATS: [(&str, &str); 3] = [("brg", "Bridge"), ("sld", "ShieldG"), ("eng", "Engine")];
|
||||||
for part in names
|
for part in names
|
||||||
.iter()
|
.iter()
|
||||||
@@ -294,6 +357,13 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tier 4: mirror one of a shared-geometry twin pair. The engine uploads the
|
||||||
|
// second of a port/starboard pair X-reflected (capture-verified on
|
||||||
|
// bdy_01/bdy_02): for two same-geometry resources placed at ±TX, the
|
||||||
|
// instance whose TX sign matches the geometry's dominant-X side draws
|
||||||
|
// mirrored (so the pair ends up symmetric instead of twice the same side).
|
||||||
|
apply_twin_mirrors(bytes, &mut placed);
|
||||||
|
|
||||||
// Fallback for a ship with no composite scene graph (a simple prop): draw its
|
// Fallback for a ship with no composite scene graph (a simple prop): draw its
|
||||||
// base parts untransformed rather than nothing.
|
// base parts untransformed rather than nothing.
|
||||||
if placed.is_empty() {
|
if placed.is_empty() {
|
||||||
@@ -306,6 +376,112 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
|
|||||||
placed
|
placed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The ship's exhaust mount frames (`GN_Jet_*` / `GN_SJet_*`), world-space, from
|
||||||
|
/// the primary composite. These are where the game renders the engine exhaust
|
||||||
|
/// FX — the visible "thrusters". The engine GEOMETRY itself sits recessed in
|
||||||
|
/// the hull (capture-verified across 43 e106 instances: the game draws
|
||||||
|
/// `eng_01`/`eng_02` exactly where [`assemble_ship`] puts them); without the
|
||||||
|
/// FX the assembled ship reads engine-less, so a viewer can draw exhaust
|
||||||
|
/// markers at these frames.
|
||||||
|
pub fn exhaust_frames(bytes: &[u8], id: &str) -> Vec<ScenePart> {
|
||||||
|
let names = xbg7_resource_names(bytes);
|
||||||
|
let scenes: Vec<Vec<ScenePart>> = composites_for(&names, id)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| !c.ends_with("_joint"))
|
||||||
|
.map(|c| scene_world_nodes(bytes, c))
|
||||||
|
.collect();
|
||||||
|
let Some(nodes) = scenes.into_iter().max_by_key(|n| n.len()) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
nodes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| n.resource.starts_with("GN_Jet") || n.resource.starts_with("GN_SJet"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect shared-geometry port/starboard twin RESOURCES placed at ±TX and mark
|
||||||
|
/// the dominant-side instance as an X-reflection (negate the matrix X column).
|
||||||
|
/// Twins are `<stem>_01`/`<stem>_02` pairs with equal vertex data; the reflected
|
||||||
|
/// one is the instance whose TX sign equals the geometry's mean-X sign (the
|
||||||
|
/// un-reflected drawing already covers that side's opposite).
|
||||||
|
fn apply_twin_mirrors(bytes: &[u8], placed: &mut [ScenePart]) {
|
||||||
|
use crate::mesh::Xbg7Model;
|
||||||
|
// Candidate pairs: resources differing only in a trailing _01/_02, both
|
||||||
|
// placed exactly once, at opposite-sign TX of equal magnitude.
|
||||||
|
let mut pairs: Vec<(usize, usize)> = Vec::new();
|
||||||
|
for i in 0..placed.len() {
|
||||||
|
let a = &placed[i];
|
||||||
|
let Some(stem) = a.resource.strip_suffix("_01") else { continue };
|
||||||
|
let twin = format!("{stem}_02");
|
||||||
|
let Some(j) = placed.iter().position(|p| p.resource == twin) else { continue };
|
||||||
|
let b = &placed[j];
|
||||||
|
if (a.t[0] + b.t[0]).abs() < 0.5 && a.t[0].abs() > 1.0 {
|
||||||
|
pairs.push((i, j));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pairs.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let want: HashSet<String> = pairs
|
||||||
|
.iter()
|
||||||
|
.flat_map(|&(i, j)| [placed[i].resource.clone(), placed[j].resource.clone()])
|
||||||
|
.collect();
|
||||||
|
let models = Xbg7Model::models_named(bytes, &want, &|| false);
|
||||||
|
for (i, j) in pairs {
|
||||||
|
let get = |r: &str| models.iter().find(|m| m.name == r);
|
||||||
|
let (Some(ma), Some(mb)) = (get(&placed[i].resource), get(&placed[j].resource)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let first = |m: &Xbg7Model| -> Vec<[f32; 3]> {
|
||||||
|
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).take(16).collect()
|
||||||
|
};
|
||||||
|
let (fa, fb) = (first(ma), first(mb));
|
||||||
|
if fa.len() != fb.len()
|
||||||
|
|| !fa.iter().zip(&fb).all(|(p, q)| {
|
||||||
|
(p[0] - q[0]).abs() < 1e-3 && (p[1] - q[1]).abs() < 1e-3 && (p[2] - q[2]).abs() < 1e-3
|
||||||
|
})
|
||||||
|
{
|
||||||
|
continue; // distinct (already-mirrored) geometries — nothing to do
|
||||||
|
}
|
||||||
|
let mean_x: f32 = ma
|
||||||
|
.meshes
|
||||||
|
.iter()
|
||||||
|
.flat_map(|s| s.positions.iter())
|
||||||
|
.map(|p| p[0])
|
||||||
|
.sum::<f32>()
|
||||||
|
/ ma.meshes.iter().map(|s| s.positions.len()).sum::<usize>().max(1) as f32;
|
||||||
|
if mean_x.abs() < 1e-3 {
|
||||||
|
continue; // symmetric geometry — mirroring is a no-op
|
||||||
|
}
|
||||||
|
// Reflect the instance whose lateral offset OPPOSES the geometry's
|
||||||
|
// dominant side (drawn plain it would fold onto the centreline; the
|
||||||
|
// capture shows the engine reflects exactly this one — for e106 the
|
||||||
|
// −264 port instance draws the file data plain, the +264 one mirrored).
|
||||||
|
let k = if (placed[i].t[0] > 0.0) == (mean_x > 0.0) { j } else { i };
|
||||||
|
for row in &mut placed[k].m {
|
||||||
|
row[0] = -row[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small helpers mirroring mesh.rs's private affine ops.
|
||||||
|
fn m3_mul_pub(a: [[f32; 3]; 3], b: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
|
||||||
|
let mut o = [[0.0f32; 3]; 3];
|
||||||
|
for r in 0..3 {
|
||||||
|
for c in 0..3 {
|
||||||
|
o[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}
|
||||||
|
fn m3_vec_pub(a: [[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
|
||||||
|
[
|
||||||
|
a[0][0] * v[0] + a[0][1] * v[1] + a[0][2] * v[2],
|
||||||
|
a[1][0] * v[0] + a[1][1] * v[1] + a[1][2] * v[2],
|
||||||
|
a[2][0] * v[0] + a[2][1] * v[1] + a[2][2] * v[2],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -363,6 +539,85 @@ mod tests {
|
|||||||
assert!(e108.parts.iter().all(|p| is_base_part(p)));
|
assert!(e108.parts.iter().all(|p| is_base_part(p)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disc-gated GROUND-TRUTH test: the fully-static assembly must reproduce the
|
||||||
|
// runtime F10 capture (the baked e106 table) part-for-part — translations
|
||||||
|
// AND the engine-rig rotation — plus the multi-instance parts the capture's
|
||||||
|
// vbase-dedup could not see (two nacelles, two turrets).
|
||||||
|
#[test]
|
||||||
|
fn static_assembly_matches_runtime_capture() {
|
||||||
|
let Ok(iso) = std::env::var("SYLPHEED_ISO") else {
|
||||||
|
eprintln!("SYLPHEED_ISO unset — skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let bytes = {
|
||||||
|
use crate::xiso::open_iso;
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap();
|
||||||
|
r.read_file("hidden/resource3d/Stage_S01.xpr").await.unwrap()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let placed = assemble_ship(&bytes, "e106", true);
|
||||||
|
let cap = crate::ship_capture::embedded_placement("e106").expect("e106 baked");
|
||||||
|
// Express static placements relative to bdy_04 (the capture's reference).
|
||||||
|
let r4 = placed
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.resource == "e106_bdy_04")
|
||||||
|
.expect("bdy_04 placed")
|
||||||
|
.clone();
|
||||||
|
let rel = |p: &crate::mesh::ScenePart| -> [f32; 3] {
|
||||||
|
[p.t[0] - r4.t[0], p.t[1] - r4.t[1], p.t[2] - r4.t[2]]
|
||||||
|
};
|
||||||
|
for want in &cap.parts {
|
||||||
|
let best = placed
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.resource == want.part)
|
||||||
|
.map(|p| {
|
||||||
|
let t = rel(p);
|
||||||
|
let d = (t[0] - want.t[0]).abs()
|
||||||
|
+ (t[1] - want.t[1]).abs()
|
||||||
|
+ (t[2] - want.t[2]).abs();
|
||||||
|
(d, p)
|
||||||
|
})
|
||||||
|
.min_by(|a, b| a.0.partial_cmp(&b.0).unwrap())
|
||||||
|
.unwrap_or_else(|| panic!("{} not placed statically", want.part));
|
||||||
|
assert!(
|
||||||
|
best.0 < 1.0,
|
||||||
|
"{}: static rel-T {:?} != captured {:?} (Δ={:.2})",
|
||||||
|
want.part,
|
||||||
|
rel(best.1),
|
||||||
|
want.t,
|
||||||
|
best.0
|
||||||
|
);
|
||||||
|
// Rotations must match too (the engine rig is the interesting case).
|
||||||
|
let m = &best.1.m;
|
||||||
|
for r in 0..3 {
|
||||||
|
for c in 0..3 {
|
||||||
|
assert!(
|
||||||
|
(m[r][c] - want.m[r][c]).abs() < 0.02,
|
||||||
|
"{}: static M row{r} {:?} != captured {:?}",
|
||||||
|
want.part,
|
||||||
|
m[r],
|
||||||
|
want.m[r]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Multi-instance coverage the capture couldn't see (vbase dedup).
|
||||||
|
let count = |res: &str| placed.iter().filter(|p| p.resource == res).count();
|
||||||
|
assert_eq!(count("e106_eng_01"), 2, "both engine nacelles placed");
|
||||||
|
assert_eq!(count("e303_wep_01"), 2, "both shared turrets placed");
|
||||||
|
// The mirrored starboard hull reflects (det < 0), the port one doesn't.
|
||||||
|
let det = |m: &[[f32; 3]; 3]| {
|
||||||
|
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
|
||||||
|
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||||
|
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||||
|
};
|
||||||
|
let one = |res: &str| placed.iter().find(|p| p.resource == res).unwrap();
|
||||||
|
assert!(det(&one("e106_bdy_02").m) < 0.0, "starboard hull mirrored");
|
||||||
|
assert!(det(&one("e106_bdy_01").m) > 0.0, "port hull plain");
|
||||||
|
}
|
||||||
|
|
||||||
// Disc-gated: the scene graph must SPREAD a ship's parts, not stack them at
|
// Disc-gated: the scene graph must SPREAD a ship's parts, not stack them at
|
||||||
// the origin. Reconstruct the ADAN frigate e106 and assert its bridge sits
|
// the origin. Reconstruct the ADAN frigate e106 and assert its bridge sits
|
||||||
// clearly aft of its forward hull body (a real ship layout, not a pile).
|
// clearly aft of its forward hull body (a real ship layout, not a pile).
|
||||||
|
|||||||
694
crates/sylpheed-formats/src/ship_capture.rs
Normal file
@@ -0,0 +1,694 @@
|
|||||||
|
//! 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, 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],
|
||||||
|
/// First few LOCAL vertex positions dumped with the draw (buffer order).
|
||||||
|
/// Used to disambiguate same-vcount twins (mirrored port/starboard parts).
|
||||||
|
pub pos: Vec<[f32; 3]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ship part to match against the capture. `part` is the **base** part name
|
||||||
|
/// (what goes in the placement table); `vcount`/`ref_pos` come from whichever
|
||||||
|
/// resource variant is being tried (base or an `_m`/`_l` LOD copy — a LOD is the
|
||||||
|
/// same part in the same local frame, so its captured transform is the part's).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PartKey {
|
||||||
|
/// Base part name for the table, e.g. `e106_bdy_01`.
|
||||||
|
pub part: String,
|
||||||
|
/// The tried resource's vertex count (the draw match key).
|
||||||
|
pub vcount: u32,
|
||||||
|
/// The tried resource's decoded positions (any order — validation is
|
||||||
|
/// set-based), used to validate a vcount hit and to route mirrored twins.
|
||||||
|
/// Empty = match by vcount alone.
|
||||||
|
pub ref_pos: Vec<[f32; 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 pos: Vec<[f32; 3]> = Vec::new();
|
||||||
|
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
|
||||||
|
|
||||||
|
let flush = |vbase: u32,
|
||||||
|
vcount: u32,
|
||||||
|
pos: &mut Vec<[f32; 3]>,
|
||||||
|
consts: &[(usize, [f64; 4])],
|
||||||
|
out: &mut Vec<CapturedDraw>| {
|
||||||
|
let pos = std::mem::take(pos);
|
||||||
|
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
|
||||||
|
};
|
||||||
|
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
|
||||||
|
out.push(CapturedDraw { vbase, vcount, r, t, pos });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let l = line.trim();
|
||||||
|
if let Some(rest) = l.strip_prefix("DRAW ") {
|
||||||
|
flush(vbase, vcount, &mut pos, &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("pos:") || l.starts_with("positions:") {
|
||||||
|
pos = parse_pos_line(l, 8);
|
||||||
|
} 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, &mut pos, &consts, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `pos: (x,y,z) (x,y,z) …` dump line into up to `max` positions.
|
||||||
|
fn parse_pos_line(l: &str, max: usize) -> Vec<[f32; 3]> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for group in l.split('(').skip(1) {
|
||||||
|
let Some(inner) = group.split(')').next() else { continue };
|
||||||
|
let nums: Vec<f32> = inner.split(',').filter_map(|x| x.trim().parse().ok()).collect();
|
||||||
|
if nums.len() == 3 {
|
||||||
|
out.push([nums[0], nums[1], nums[2]]);
|
||||||
|
if out.len() >= max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 pos: Vec<[f32; 3]> = Vec::new();
|
||||||
|
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
|
||||||
|
|
||||||
|
let mut flush = |base: u32,
|
||||||
|
size: u32,
|
||||||
|
stride: u32,
|
||||||
|
pos: &mut Vec<[f32; 3]>,
|
||||||
|
consts: &[(usize, [f64; 4])],
|
||||||
|
seen: &mut std::collections::HashSet<u32>,
|
||||||
|
out: &mut Vec<CapturedDraw>| {
|
||||||
|
let pos = std::mem::take(pos);
|
||||||
|
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, pos });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let l = line.trim();
|
||||||
|
if let Some(rest) = l.strip_prefix("DRAW ") {
|
||||||
|
flush(base, size, stride, &mut pos, &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("positions:") {
|
||||||
|
pos = parse_pos_line(l, 8);
|
||||||
|
} 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, &mut pos, &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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a vcount hit by the draw's dumped positions against the part's
|
||||||
|
/// decoded position SET (order-independent — decode order can differ from buffer
|
||||||
|
/// order). Returns `Some((hits, mirrored))` when at least half the dumped
|
||||||
|
/// positions are found among the part's vertices, either directly or **all
|
||||||
|
/// X-negated** — the engine uploads the second of a mirrored port/starboard pair
|
||||||
|
/// as an X-reflection of the shared file geometry, so the guest buffer disagrees
|
||||||
|
/// in X sign with every decoded copy. `None` = the dump belongs to a different
|
||||||
|
/// model (a coincidental vcount).
|
||||||
|
fn pos_validate(draw: &CapturedDraw, ref_pos: &[[f32; 3]]) -> Option<(usize, bool)> {
|
||||||
|
if draw.pos.is_empty() || ref_pos.is_empty() {
|
||||||
|
return Some((0, false)); // no data to validate with — accept neutrally
|
||||||
|
}
|
||||||
|
let near = |a: &[f32; 3], b: &[f32; 3]| {
|
||||||
|
(a[0] - b[0]).abs() <= 1e-2 && (a[1] - b[1]).abs() <= 1e-2 && (a[2] - b[2]).abs() <= 1e-2
|
||||||
|
};
|
||||||
|
let mut direct = 0usize;
|
||||||
|
let mut mirror = 0usize;
|
||||||
|
for p in &draw.pos {
|
||||||
|
if ref_pos.iter().any(|v| near(p, v)) {
|
||||||
|
direct += 1;
|
||||||
|
}
|
||||||
|
let pm = [-p[0], p[1], p[2]];
|
||||||
|
if ref_pos.iter().any(|v| near(&pm, v)) {
|
||||||
|
mirror += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let need = draw.pos.len().div_ceil(2);
|
||||||
|
if direct >= need && direct >= mirror {
|
||||||
|
Some((direct, false))
|
||||||
|
} else if mirror >= need {
|
||||||
|
Some((mirror, true))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Correlate captured draws to a ship's parts and express each in the reference
|
||||||
|
/// part's frame.
|
||||||
|
///
|
||||||
|
/// Each [`PartKey`]'s `vcount` is the match key; when several draws share the
|
||||||
|
/// vcount (mirrored port/starboard twins) the draw whose dumped positions match
|
||||||
|
/// the part's `ref_pos` validates best is chosen. `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: &[PartKey],
|
||||||
|
ref_sub: &str,
|
||||||
|
) -> Option<ShipPlacement> {
|
||||||
|
let mut matched: Vec<(String, M3, [f64; 3], bool)> = Vec::new();
|
||||||
|
let mut used: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||||
|
for key in parts {
|
||||||
|
// Several PartKeys may carry the same part (one per LOD-variant vcount);
|
||||||
|
// the first that validates wins, the rest are skipped.
|
||||||
|
if matched.iter().any(|(p, ..)| p == &key.part) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A vcount hit alone can be a coincidence (small LODs share counts across
|
||||||
|
// unrelated models — a 51-vert draw once matched the bridge but was a
|
||||||
|
// different mesh). Candidates failing position validation are REJECTED;
|
||||||
|
// among validated candidates the best hit count wins (routes twins), and
|
||||||
|
// a mirror-validated match records the X-reflection.
|
||||||
|
let mut best: Option<(&CapturedDraw, usize, bool)> = None;
|
||||||
|
for d in draws.iter().filter(|d| d.vcount == key.vcount && !used.contains(&d.vbase)) {
|
||||||
|
let Some((score, mirrored)) = pos_validate(d, &key.ref_pos) else {
|
||||||
|
continue; // positions disagree — not this part
|
||||||
|
};
|
||||||
|
if best.map_or(true, |(_, s, _)| score > s) {
|
||||||
|
best = Some((d, score, mirrored));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((d, _, mirrored)) = best {
|
||||||
|
used.insert(d.vbase);
|
||||||
|
matched.push((key.part.clone(), d.r, d.t, mirrored));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, mirrored)| {
|
||||||
|
let mut 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);
|
||||||
|
// The captured WorldView transforms the *uploaded* buffer; for the
|
||||||
|
// mirrored twin that buffer is the X-reflection of the file geometry,
|
||||||
|
// so the file-local placement is R·diag(−1,1,1) — negate column 0.
|
||||||
|
if *mirrored {
|
||||||
|
for row in &mut rel_r {
|
||||||
|
row[0] = -row[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PartPlacement {
|
||||||
|
part: part.clone(),
|
||||||
|
m: snap_m3(&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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snap near-axis rotation entries (float noise from the WV products) to exact
|
||||||
|
/// 0/±1 so the checked-in table is clean; real rotations are untouched.
|
||||||
|
fn snap_m3(m: &M3) -> [[f32; 3]; 3] {
|
||||||
|
let snap = |v: f64| -> f32 {
|
||||||
|
if v.abs() < 5e-4 {
|
||||||
|
0.0
|
||||||
|
} else if (v - 1.0).abs() < 5e-4 {
|
||||||
|
1.0
|
||||||
|
} else if (v + 1.0).abs() < 5e-4 {
|
||||||
|
-1.0
|
||||||
|
} else {
|
||||||
|
v as f32
|
||||||
|
}
|
||||||
|
};
|
||||||
|
[
|
||||||
|
[snap(m[0][0]), snap(m[0][1]), snap(m[0][2])],
|
||||||
|
[snap(m[1][0]), snap(m[1][1]), snap(m[1][2])],
|
||||||
|
[snap(m[2][0]), snap(m[2][1]), snap(m[2][2])],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(part: &str, vcount: u32) -> PartKey {
|
||||||
|
PartKey { part: part.to_string(), vcount, ref_pos: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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![key("e106_bdy_04", 3), key("e106_eng_01", 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 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![key("e106_bdy_04", 1633), key("e106_bdy_03", 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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The real e106 bdy_01/bdy_02 case: both twin resources decode to
|
||||||
|
/// IDENTICAL file geometry; the engine uploads the second instance as an
|
||||||
|
/// X-reflection, so one captured buffer disagrees in X sign with the file.
|
||||||
|
/// Both draws must be placed, and the mirrored one must bake the X-flip
|
||||||
|
/// (negated first matrix column) so file-local geometry lands port-side.
|
||||||
|
#[test]
|
||||||
|
fn runtime_mirrored_twin_placed_with_reflection() {
|
||||||
|
let log = "DRAW vbase=0x1000 stride=24 vcount=426 indices=21 prim=4 vs=0x1\n \
|
||||||
|
pos: (134.4215,133.8319,-118.1757) (178.8384,85.3847,238.0463)\n \
|
||||||
|
vsconst base=0: c0=(1,0,0,264) c1=(0,1,0,0) c2=(0,0,1,0)\n\
|
||||||
|
DRAW vbase=0x2000 stride=24 vcount=426 indices=21 prim=4 vs=0x1\n \
|
||||||
|
pos: (-134.4215,133.8319,-118.1757) (-178.8384,85.3847,238.0463)\n \
|
||||||
|
vsconst base=0: c0=(1,0,0,-264) c1=(0,1,0,0) c2=(0,0,1,0)\n\
|
||||||
|
DRAW vbase=0x3000 stride=24 vcount=558 indices=9 prim=4 vs=0x1\n \
|
||||||
|
vsconst base=0: c0=(1,0,0,0) c1=(0,1,0,0) c2=(0,0,1,0)\n";
|
||||||
|
let draws = parse_capture(log);
|
||||||
|
assert_eq!(draws.len(), 3);
|
||||||
|
assert_eq!(draws[0].pos.len(), 2);
|
||||||
|
// Both twins carry the SAME (file) positions — +X side geometry.
|
||||||
|
let file_pos = vec![[134.4215, 133.8319, -118.1757], [178.8384, 85.3847, 238.0463]];
|
||||||
|
let parts = vec![
|
||||||
|
PartKey { part: "e106_bdy_01".to_string(), vcount: 426, ref_pos: file_pos.clone() },
|
||||||
|
PartKey { part: "e106_bdy_02".to_string(), vcount: 426, ref_pos: file_pos },
|
||||||
|
key("e106_bdy_04", 558),
|
||||||
|
];
|
||||||
|
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
|
||||||
|
assert_eq!(ship.parts.len(), 3, "both twins + reference placed");
|
||||||
|
let get = |p: &str| ship.parts.iter().find(|x| x.part == p).unwrap().clone();
|
||||||
|
// bdy_01 validated directly → the +264 draw, identity rotation.
|
||||||
|
let a = get("e106_bdy_01");
|
||||||
|
assert_eq!(a.t, [264.0, 0.0, 0.0]);
|
||||||
|
assert_eq!(a.m[0][0], 1.0);
|
||||||
|
// bdy_02 validated as the MIRROR → the −264 draw, X-flip baked in.
|
||||||
|
let b = get("e106_bdy_02");
|
||||||
|
assert_eq!(b.t, [-264.0, 0.0, 0.0]);
|
||||||
|
assert_eq!(b.m[0][0], -1.0, "mirrored twin must negate the X column");
|
||||||
|
assert_eq!(b.m[1][1], 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A draw whose vcount matches but whose position dump disagrees must be
|
||||||
|
/// REJECTED, not placed — the real false-positive: a foreign 51-vert model
|
||||||
|
/// matched `e106_brg_01_l` by count alone and put the bridge 2 km off-hull.
|
||||||
|
#[test]
|
||||||
|
fn vcount_coincidence_rejected_by_positions() {
|
||||||
|
let log = "DRAW vbase=0x1000 stride=24 vcount=51 indices=36 prim=4 vs=0x1\n \
|
||||||
|
pos: (-0.0000,46.2359,-12.9454) (-0.0000,-6.1936,264.8687)\n \
|
||||||
|
vsconst base=0: c0=(1,0,0,1975) c1=(0,1,0,0) c2=(0,0,1,0)\n\
|
||||||
|
DRAW vbase=0x3000 stride=24 vcount=558 indices=9 prim=4 vs=0x1\n \
|
||||||
|
vsconst base=0: c0=(1,0,0,0) c1=(0,1,0,0) c2=(0,0,1,0)\n";
|
||||||
|
let draws = parse_capture(log);
|
||||||
|
let parts = vec![
|
||||||
|
PartKey {
|
||||||
|
part: "e106_brg_01".to_string(),
|
||||||
|
vcount: 51,
|
||||||
|
// The REAL bridge LOD's vertices — disagree with the dump.
|
||||||
|
ref_pos: vec![[35.2480, 26.0376, 49.2504], [6.0, 55.9632, 41.1370]],
|
||||||
|
},
|
||||||
|
key("e106_bdy_04", 558),
|
||||||
|
];
|
||||||
|
let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
|
||||||
|
assert!(
|
||||||
|
!ship.parts.iter().any(|p| p.part == "e106_brg_01"),
|
||||||
|
"coincidental vcount match must not place the bridge"
|
||||||
|
);
|
||||||
|
assert_eq!(ship.parts.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The baked e106 capture (2026-07-26 F10, Stage_S01): all 8 parts placed,
|
||||||
|
/// port/starboard pair symmetric with the mirror on bdy_02, bridge on the
|
||||||
|
/// centreline. Guards the checked-in data against accidental edits.
|
||||||
|
#[test]
|
||||||
|
fn embedded_e106_is_complete() {
|
||||||
|
let e106 = embedded_placement("e106").expect("e106 baked in");
|
||||||
|
assert_eq!(e106.reference, "e106_bdy_04");
|
||||||
|
assert_eq!(e106.parts.len(), 8, "all 8 e106 parts placed");
|
||||||
|
let get = |p: &str| e106.parts.iter().find(|x| x.part == p).unwrap();
|
||||||
|
// Port/starboard hull pair: X = ∓264, the starboard copy mirrored.
|
||||||
|
assert!((get("e106_bdy_01").t[0] + 264.0).abs() < 0.1);
|
||||||
|
assert!((get("e106_bdy_02").t[0] - 264.0).abs() < 0.1);
|
||||||
|
assert_eq!(get("e106_bdy_02").m[0][0], -1.0);
|
||||||
|
assert_eq!(get("e106_bdy_01").m[0][0], 1.0);
|
||||||
|
// Bridge: centreline, above and aft of the hull reference.
|
||||||
|
let brg = get("e106_brg_01");
|
||||||
|
assert!(brg.t[0].abs() < 0.1 && brg.t[1] > 150.0 && brg.t[2] < -160.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -613,7 +613,7 @@ pub struct ShipRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The Ships browser state — capital ships assembled from XBG7 part families.
|
/// The Ships browser state — capital ships assembled from XBG7 part families.
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource)]
|
||||||
pub struct ShipBrowser {
|
pub struct ShipBrowser {
|
||||||
pub open: bool,
|
pub open: bool,
|
||||||
pub loading: bool,
|
pub loading: bool,
|
||||||
@@ -624,11 +624,27 @@ pub struct ShipBrowser {
|
|||||||
pub rows: Vec<ShipRow>,
|
pub rows: Vec<ShipRow>,
|
||||||
/// Family id of the ship currently rendered (for row highlight).
|
/// Family id of the ship currently rendered (for row highlight).
|
||||||
pub selected: Option<String>,
|
pub selected: Option<String>,
|
||||||
/// Also place the approximate external parts (bridge / shield gen / engines at
|
/// Also place the external parts: bridge / shield generators / the engine
|
||||||
/// their `GN_*` hardpoint frames). Off by default → the exact hull only.
|
/// cluster rig / cross-mounted turrets. EXACT (capture-validated) since the
|
||||||
|
/// node-TRS fix, so ON by default; off = bare hull bodies only.
|
||||||
pub show_external: bool,
|
pub show_external: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for ShipBrowser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
open: false,
|
||||||
|
loading: false,
|
||||||
|
loaded: false,
|
||||||
|
filter: String::new(),
|
||||||
|
faction: String::new(),
|
||||||
|
rows: Vec::new(),
|
||||||
|
selected: None,
|
||||||
|
show_external: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the loader to scan the stage containers and build the ship catalog.
|
/// Ask the loader to scan the stage containers and build the ship catalog.
|
||||||
#[derive(Event, Default)]
|
#[derive(Event, Default)]
|
||||||
pub struct RequestShipCatalog;
|
pub struct RequestShipCatalog;
|
||||||
@@ -3630,6 +3646,40 @@ fn handle_voice_request(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A simple exhaust-plume cone: base ring at the frame origin opening toward
|
||||||
|
/// **−Z** (the jet frames face aft), apex trailing behind. Stands in for the
|
||||||
|
/// game's engine-exhaust FX so assembled ships visually read as powered.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn exhaust_cone_mesh() -> sylpheed_formats::mesh::GameMesh {
|
||||||
|
const SEG: usize = 12;
|
||||||
|
const R: f32 = 22.0;
|
||||||
|
const LEN: f32 = 140.0;
|
||||||
|
let mut positions = Vec::with_capacity(SEG + 2);
|
||||||
|
let mut normals = Vec::with_capacity(SEG + 2);
|
||||||
|
positions.push([0.0, 0.0, LEN]); // apex — the jet frame flips Z, sending it aft
|
||||||
|
normals.push([0.0, 0.0, -1.0]);
|
||||||
|
for s in 0..SEG {
|
||||||
|
let a = s as f32 / SEG as f32 * std::f32::consts::TAU;
|
||||||
|
positions.push([a.cos() * R, a.sin() * R, 0.0]);
|
||||||
|
normals.push([a.cos(), a.sin(), 0.3]);
|
||||||
|
}
|
||||||
|
positions.push([0.0, 0.0, 0.0]); // base centre (cap)
|
||||||
|
normals.push([0.0, 0.0, 1.0]);
|
||||||
|
let mut indices = Vec::new();
|
||||||
|
for s in 0..SEG as u32 {
|
||||||
|
let (a, b) = (1 + s, 1 + (s + 1) % SEG as u32);
|
||||||
|
indices.extend_from_slice(&[0, b, a]); // side
|
||||||
|
indices.extend_from_slice(&[(SEG + 1) as u32, a, b]); // cap
|
||||||
|
}
|
||||||
|
sylpheed_formats::mesh::GameMesh {
|
||||||
|
positions,
|
||||||
|
normals,
|
||||||
|
uvs: Vec::new(),
|
||||||
|
indices,
|
||||||
|
name: Some("exhaust".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Duration (seconds) of a PCM WAV from its `fmt`/`data` chunks.
|
/// Duration (seconds) of a PCM WAV from its `fmt`/`data` chunks.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn wav_duration(path: &Path) -> Option<f32> {
|
fn wav_duration(path: &Path) -> Option<f32> {
|
||||||
@@ -3860,8 +3910,11 @@ fn build_ship_catalog(source: &SourceKind) -> Vec<ShipRow> {
|
|||||||
};
|
};
|
||||||
let label = format!("S{n:02}");
|
let label = format!("S{n:02}");
|
||||||
for sm in ships_in_container(&bytes) {
|
for sm in ships_in_container(&bytes) {
|
||||||
// Skip single-piece props / debris — not "whole ships".
|
// Skip single-piece props / debris — not "whole ships" — and any
|
||||||
if sm.parts.len() < 2 {
|
// family with no composite scene graph (fighter morph sets, shared
|
||||||
|
// turret parts): those have no placement data and would render as a
|
||||||
|
// pile at the origin with stray far-away pieces.
|
||||||
|
if sm.parts.len() < 2 || !sm.has_composite {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let v = vmap.get(&sm.id);
|
let v = vmap.get(&sm.id);
|
||||||
@@ -3952,8 +4005,10 @@ 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: fully static scene-graph assembly — exact, validated
|
||||||
// engines aft, hardpoints on their frames).
|
// part-for-part (translations + rotations + instances + mirror) against the
|
||||||
|
// e106 runtime capture (`ship::tests::static_assembly_matches_runtime_capture`).
|
||||||
|
// The captured table in ship_capture stays as the verification oracle only.
|
||||||
let placed = sylpheed_formats::ship::assemble_ship(&bytes, id, external);
|
let placed = 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}."));
|
||||||
@@ -3986,6 +4041,12 @@ fn build_ship_model(
|
|||||||
let Some(src) = base.iter().find(|m| m.name == p.resource) else {
|
let Some(src) = base.iter().find(|m| m.name == p.resource) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
// A captured mirror placement (det < 0 — the engine X-reflects the
|
||||||
|
// second of a port/starboard pair) flips triangle winding; reverse each
|
||||||
|
// triangle's index order so front faces stay outward.
|
||||||
|
let det = p.m[0][0] * (p.m[1][1] * p.m[2][2] - p.m[1][2] * p.m[2][1])
|
||||||
|
- p.m[0][1] * (p.m[1][0] * p.m[2][2] - p.m[1][2] * p.m[2][0])
|
||||||
|
+ p.m[0][2] * (p.m[1][0] * p.m[2][1] - p.m[1][1] * p.m[2][0]);
|
||||||
let mut m = src.clone();
|
let mut m = src.clone();
|
||||||
for sub in &mut m.meshes {
|
for sub in &mut m.meshes {
|
||||||
for v in &mut sub.positions {
|
for v in &mut sub.positions {
|
||||||
@@ -3994,6 +4055,11 @@ fn build_ship_model(
|
|||||||
for nrm in &mut sub.normals {
|
for nrm in &mut sub.normals {
|
||||||
*nrm = rot(&p.m, nrm);
|
*nrm = rot(&p.m, nrm);
|
||||||
}
|
}
|
||||||
|
if det < 0.0 {
|
||||||
|
for tri in sub.indices.chunks_exact_mut(3) {
|
||||||
|
tri.swap(1, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
models.push(m);
|
models.push(m);
|
||||||
}
|
}
|
||||||
@@ -4001,6 +4067,26 @@ fn build_ship_model(
|
|||||||
return PreparedXpr::Info(format!("No geometry decoded for {label}."));
|
return PreparedXpr::Info(format!("No geometry decoded for {label}."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exhaust markers: the game's visible "thrusters" are FX drawn at the
|
||||||
|
// GN_Jet/GN_SJet frames (the engine GEOMETRY is recessed in the hull —
|
||||||
|
// capture-verified). Draw a simple cone at each frame so the assembled ship
|
||||||
|
// reads correctly.
|
||||||
|
if external {
|
||||||
|
for (i, f) in sylpheed_formats::ship::exhaust_frames(&bytes, id).iter().enumerate() {
|
||||||
|
let mut cone = exhaust_cone_mesh();
|
||||||
|
for v in &mut cone.positions {
|
||||||
|
*v = f.apply(*v);
|
||||||
|
}
|
||||||
|
for nrm in &mut cone.normals {
|
||||||
|
*nrm = rot(&f.m, nrm);
|
||||||
|
}
|
||||||
|
models.push(Xbg7Model {
|
||||||
|
name: format!("__exhaust_{i}"),
|
||||||
|
meshes: vec![cone],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Assemble, then relabel with the ship's display name.
|
// Assemble, then relabel with the ship's display name.
|
||||||
match prepare_ship(&bytes, &models) {
|
match prepare_ship(&bytes, &models) {
|
||||||
PreparedXpr::Model {
|
PreparedXpr::Model {
|
||||||
|
|||||||
@@ -1590,7 +1590,7 @@ fn draw_ships_ui(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.checkbox(&mut ships.show_external, "Show external parts");
|
ui.checkbox(&mut ships.show_external, "External parts (bridge/engines/turrets)");
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new("(bridge / shield gens / engines — approximate placement)")
|
egui::RichText::new("(bridge / shield gens / engines — approximate placement)")
|
||||||
.weak()
|
.weak()
|
||||||
|
|||||||
71
docs/HANDOFF-ship-placement-2026-07-26.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Handoff — capital-ship placement: static assembly EXACT (2026-07-26)
|
||||||
|
|
||||||
|
Branch `feature/ipfb-idxd-parser`, commits `c6ca5ce..ea32e77`. Canary fork
|
||||||
|
branch `capture-ship-placement`, tip `877163792`.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
**Capital ships assemble exactly from the ISO alone — no captures needed.**
|
||||||
|
Runtime captures served as the oracle that cracked the static encoding and now
|
||||||
|
remain as verification tooling only.
|
||||||
|
|
||||||
|
## The two format discoveries (docs/re/ship-placement-runtime-capture.md)
|
||||||
|
|
||||||
|
1. **Node-TRS off-by-one** (`mesh.rs::read_trs9`): an XBG7 scene-graph node's
|
||||||
|
joint table holds NINE keyframe channels `[TX TY TZ RY RX RZ SX SY SZ]`
|
||||||
|
behind EIGHT pointer slots shifted one track forward — channel k+1 =
|
||||||
|
`f64@(ptr[k]+8)`, channel 0 (TX) = `f64@(ptr[0]−0x48)`. The old read never
|
||||||
|
saw TX (why hulls stacked on the centreline) and misassigned TY/RY.
|
||||||
|
2. **Euler order** (`mesh.rs::node_rotation`): `Ry(ch3)·Rx(ch4)·Rz(ch5)`,
|
||||||
|
angles direct — pinned by decomposing the captured engine-nacelle rotation
|
||||||
|
`Rx(−15°)·Rz(30°)` element-for-element.
|
||||||
|
|
||||||
|
## assemble_ship (ship.rs) — fully static, capture-validated
|
||||||
|
|
||||||
|
- Every composite-node instance (alias duplicates collapsed), including
|
||||||
|
cross-id turret mounts (`rou_e303_wep_01_root` ×2 on e106; e108 places 7).
|
||||||
|
- Engine cluster rig `e_rou_<id>_eng` mounts at `GN_Engine_01`
|
||||||
|
(two mirrored nacelles + centre engine).
|
||||||
|
- `brg`/`sld` at their exact GN frames (frames were always exact).
|
||||||
|
- Shared-geometry ±TX twins: the instance whose offset opposes the geometry's
|
||||||
|
dominant side draws X-reflected (viewer reverses winding on det<0).
|
||||||
|
- `exhaust_frames()`: the `GN_Jet`/`GN_SJet` frames — the game renders its
|
||||||
|
engine-exhaust FX there; the viewer draws marker cones at them (the engine
|
||||||
|
GEOMETRY is recessed in the hull by design; without the FX ships read
|
||||||
|
engine-less — that was the "engines inside the hull" report).
|
||||||
|
|
||||||
|
## Validation chain (all green)
|
||||||
|
|
||||||
|
- `ship::tests::static_assembly_matches_runtime_capture` (SYLPHEED_ISO): static
|
||||||
|
== the baked e106 capture, all 8 parts, T<1.0 R<0.02, nacelles ×2, turrets
|
||||||
|
×2, starboard mirror.
|
||||||
|
- `examples/capture_verify.rs` over the user's 5 multi-angle snapshots
|
||||||
|
(43 e106 instances): dominant clusters match static to ~1 unit on every part
|
||||||
|
incl. both nacelles. Run:
|
||||||
|
`cargo run --release --example capture_verify -- <Stage.xpr> e106 <logs…>`
|
||||||
|
- Offline audit (`examples/ship_audit.rs`) across all stages: no outliers among
|
||||||
|
composite ships; composite-less families (fighter morph sets, turret/prop
|
||||||
|
parts) are filtered from the viewer's Ships catalog.
|
||||||
|
- 81 formats-lib + all disc tests; viewer builds.
|
||||||
|
|
||||||
|
## Canary instrumentation (branch `capture-ship-placement`)
|
||||||
|
|
||||||
|
F10 → `xenia_ship_capture_NN.log` per press (multi-angle in one run), de-duped
|
||||||
|
by (vertex-buffer, c0..c2 WVP hash) so every instance of every part is
|
||||||
|
recorded. Launch:
|
||||||
|
```
|
||||||
|
cd xenia-canary/build/bin/Linux/Release
|
||||||
|
./xenia_canary "<…/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open / next
|
||||||
|
|
||||||
|
- Verify remaining capital ships against the existing 5 snapshots (they contain
|
||||||
|
every on-screen ship): e105 (its engine block assembles dorsally — the one
|
||||||
|
placement to confirm), f105/f106/f101, e102. Same capture_verify invocation
|
||||||
|
with the other ship id.
|
||||||
|
- AA-gun models at `GN_AAGun*` frames need the Vessel recipe binding.
|
||||||
|
- SJet (maneuvering-vernier) marker orientation is approximate; main aft plumes
|
||||||
|
verified.
|
||||||
|
- `_mov` node animation keys (hull-opening sequences) — rest pose rendered now.
|
||||||
|
- Viewer GUI verification by the user (exhaust cones, catalog cleanup).
|
||||||
@@ -21,6 +21,8 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
|||||||
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
|
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
|
||||||
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined |
|
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined |
|
||||||
| Capital-ship part placement | 🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 → VS-constant WorldView) gives ground truth — validated on `e106` destroyer; not yet baked into the viewer |
|
| Capital-ship part placement | 🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 → VS-constant WorldView) gives ground truth — validated on `e106` destroyer; not yet baked into the viewer |
|
||||||
|
| Weapon fields defaulted on disc | ✅/🟡 | [runtime DATA SHEET](weapon-datasheet-runtime.md) | The Arsenal Gallery-Mode panel reads the live record: `Ammo Capacity` = `LoadingCount` ✅, `Max. Lock Ons` = `TriggerShotCount` ✅. Recovers `TriggerShotCount` for `wep_05`/`wep_60` (both **4**); brackets defaulted `Power`/`MaximumRange` via the letter classes. 9 of ~61 weapons reachable at 5 % save progress |
|
||||||
|
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` (screen-level draw order) not yet decoded |
|
||||||
|
|
||||||
## Functions / code paths
|
## Functions / code paths
|
||||||
|
|
||||||
|
|||||||
BIN
docs/re/captures/hud-runtime/hud-afterburner-1193.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
docs/re/captures/hud-runtime/hud-cruise-loadout.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
docs/re/captures/hud-runtime/hud-target-armor-gauge.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
docs/re/captures/ui-layout/pause-mission-rebuilt.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
docs/re/captures/ui-layout/pause-tutorial-real-vs-rebuilt.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
docs/re/captures/ui-layout/title-mainmenu-real-vs-rebuilt.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
docs/re/captures/weapon-datasheet/asm-hound-smh.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/re/captures/weapon-datasheet/asm-terrier-smh.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
docs/re/captures/weapon-datasheet/beam-dagger.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/re/captures/weapon-datasheet/beam-pilum-bp.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
docs/re/captures/weapon-datasheet/beam-stiletto.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/re/captures/weapon-datasheet/br-dart-23-rocket.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/re/captures/weapon-datasheet/cn-tomahawk-alpha-rail-gun.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/re/captures/weapon-datasheet/gun-broad-sword-sg1.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/re/captures/weapon-datasheet/gun-light-machine-gun-mg1.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/re/captures/weapon-datasheet/gun-mg1-full.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/re/captures/weapon-datasheet/hangar-mainweapon1-falcon.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
docs/re/captures/weapon-datasheet/mpm-buzzard-10am.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/re/captures/weapon-datasheet/mpm-falcon-9am.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
@@ -1,7 +1,20 @@
|
|||||||
# 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:** ✅✅ **STATIC ASSEMBLY IS EXACT — no captures needed anymore
|
||||||
correlator recovers exact ship-relative transforms. Not yet baked into the viewer.
|
(2026-07-26).** The capture became the oracle that cracked the static encoding:
|
||||||
|
the node joint tables are **9 keyframe channels `[TX TY TZ RY RX RZ SX SY SZ]`
|
||||||
|
with the 8 table pointers shifted one track forward** (channel 0 = TX sits at
|
||||||
|
`ptr[0]−0x48`; the old 8-slot read took TY as X, never saw TX — why hulls
|
||||||
|
stacked on the centreline). Euler order `Ry·Rx·Rz`, angles direct
|
||||||
|
(`mesh.rs::read_trs9` / `node_rotation`). `assemble_ship` now places every
|
||||||
|
composite-node instance (cross-id turrets ×2), mounts the `e_rou_<id>_eng`
|
||||||
|
cluster rig at `GN_Engine_01` (two mirrored nacelles + centre engine), puts
|
||||||
|
`brg`/`sld` at their exact GN frames, and X-reflects the shared-geometry twin
|
||||||
|
whose lateral offset opposes the geometry's dominant side.
|
||||||
|
`ship::tests::static_assembly_matches_runtime_capture` asserts static ==
|
||||||
|
captured for all 8 e106 parts (T < 1.0, R < 0.02) + both nacelles + both
|
||||||
|
turrets + the hull mirror. The viewer uses pure static assembly; the baked
|
||||||
|
table + correlator remain as the verification oracle only.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
@@ -56,16 +69,54 @@ 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Matching rules (learned from the real 2026-07-26 capture)
|
||||||
|
|
||||||
|
- **LOD-aware**: a distant ship draws its `_m`/`_l` copies — same part, same
|
||||||
|
local frame, different vcount. Each part tries every variant vcount.
|
||||||
|
- **Position-validated**: a vcount hit alone can be a coincidence (a foreign
|
||||||
|
51-vert mesh nearly hijacked the bridge slot). Every candidate draw's dumped
|
||||||
|
positions must be found in the part's decoded position set or it is rejected.
|
||||||
|
Validation is **set-based** (buffer order ≠ decode order) against the **union**
|
||||||
|
of the part's variants — the real capture had a draw whose `vcount` equalled
|
||||||
|
the `_m` count while its buffer held the FULL-detail geometry.
|
||||||
|
- **Runtime mirror**: a port/starboard pair shares one file geometry; the engine
|
||||||
|
uploads the second instance **X-reflected**. A draw that validates only under
|
||||||
|
X-negation is accepted as the mirror, and the placement bakes `diag(−1,1,1)`
|
||||||
|
(the viewer reverses triangle winding for det < 0).
|
||||||
|
- The mission ship shader can be a different hash per lighting variant
|
||||||
|
(`0x3A0829CC71516789` in this capture) — the F10 path doesn't filter by hash,
|
||||||
|
it validates by geometry instead.
|
||||||
|
|
||||||
|
`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)
|
||||||
|
|
||||||
@@ -93,13 +144,37 @@ not the 1894-tall tower the static Y put out.
|
|||||||
**Missing:** `brg_01` (bridge, vcount 202) was **culled/occluded** at that camera
|
**Missing:** `brg_01` (bridge, vcount 202) was **culled/occluded** at that camera
|
||||||
angle (0 draws) — needs a second capture framing the superstructure.
|
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
|
## 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).
|
||||||
|
|||||||
182
docs/re/structures/ui-rat-layout.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# `.rat` — the UI element layout / animation record
|
||||||
|
|
||||||
|
**Status:** ✅ `CONFIRMED` for placement (2026-07-28). The retail UI can be
|
||||||
|
**reassembled from the disc**: the tutorial PAUSE menu rebuilds pixel-accurately from its
|
||||||
|
sprites placed at the coordinates in their `.rat` records — no fitting, no manual nudging.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Left: the running game (Canary screenshot). Right: rebuilt from `GP_PAUSE_MENU.pak` alone.
|
||||||
|
The remaining differences are the animated frame/glow sprites (`*eff*`) that were not
|
||||||
|
placed, and the live 3D background.*
|
||||||
|
|
||||||
|
## Screen composition
|
||||||
|
|
||||||
|
The UI is **one pak per screen** — `GP_TITLE`, `GP_PAUSE_MENU`, `GP_READY_ROOM`,
|
||||||
|
`GP_SAVE_LOAD`, `GP_MISSION_SELECT`, `GP_OPTIONS`, `GP_SYSTEM`, `GP_TUTORIAL`,
|
||||||
|
`GP_STAGE_CLEAR`, `GP_MOVIE_THEATER`, `GP_DEBRIEFING_PILOTLOG`, `GP_LEADERBOARD`,
|
||||||
|
`GP_MISSION_LOG`, `GP_BUNK`, `GP_DIALOG`, `GP_GAMEOVER`, `GP_CHALLENGE`,
|
||||||
|
`GP_HANGAR_ARSENAL`.
|
||||||
|
|
||||||
|
Inside a screen pak, each top-level [RATC](../INDEX.md) bundle is **one (context × language)
|
||||||
|
build of that screen**. Its own header is the screen's **element declaration table**, and
|
||||||
|
the elements themselves follow as children:
|
||||||
|
|
||||||
|
| child | what it is |
|
||||||
|
|---|---|
|
||||||
|
| `<name>.t32` | the sprite ([T8aD](texture-color-k8888.md)) |
|
||||||
|
| `<name>.rat` | that sprite's **layout record** (this document) |
|
||||||
|
| `<screen>loop1.rat` | a looping sprite animation (see below) |
|
||||||
|
|
||||||
|
### The bundle header — element declaration table
|
||||||
|
|
||||||
|
```
|
||||||
|
0x14 u32 entry count
|
||||||
|
0x20 entry[count], 60 bytes each:
|
||||||
|
+0 char[28] element name, NUL-padded ("pgp_ttrl_eff10.t32", "pgp_ttrl_btn10.rat")
|
||||||
|
+28 u32 ×4 flags (0xffffffff / 0xffffffff / 0 / 0xffffffff on every entry seen)
|
||||||
|
+48 u32 pivot X
|
||||||
|
+52 u32 pivot Y
|
||||||
|
+56 u32 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The table lists **both** sprites and `.rat` records — it is the screen's element list.
|
||||||
|
`pgp_ttrl` declares 11: six `eff*`, `msg`, and four `.rat`s (`title`, `btn10..12`).
|
||||||
|
|
||||||
|
✅ **Verified:** for all 7 `.t32` entries the declared pivot is *exactly* half the decoded
|
||||||
|
texture's dimensions — `eff10` 408×120 → 204,60; `eff21` 428×360 → 214,180; `msg` 381×38 →
|
||||||
|
190,19; and so on, 7/7 with no mismatch (`tools/re-capture/ratc_decls.py`).
|
||||||
|
|
||||||
|
`GP_PAUSE_MENU.pak`'s six bundles are `{in-mission, tutorial} × {English, Japanese}`, with
|
||||||
|
the two in-mission builds each present twice at identical size. The purpose of that
|
||||||
|
duplicate is **NEEDS-HUMAN** (resolution or aspect variant?), and where the other four
|
||||||
|
shipped languages live is likewise unresolved — this pak holds only EN and JP.
|
||||||
|
|
||||||
|
Naming is transparent: `pgp` = pause screen, `pgp_ttrl_` = its tutorial context, `btnNN` =
|
||||||
|
menu item, `btnNNf` = that item's **focused** sprite, `eff*` = frame/glow decoration,
|
||||||
|
`deli*` = the item divider, `title`, `msg`.
|
||||||
|
|
||||||
|
## Record layout
|
||||||
|
|
||||||
|
Big-endian u32 throughout (Xbox 360), and **tag-driven**: 4-char ASCII tags (`opt `,
|
||||||
|
`PRMD`, `end `) mark sections, so a record is a stream of blocks rather than a fixed struct.
|
||||||
|
A minimal record (a static button) is 165 bytes:
|
||||||
|
|
||||||
|
```
|
||||||
|
0x00 "RATC" magic — a RATC bundle reused as a data record
|
||||||
|
0x08 u32 payload size
|
||||||
|
0x14 u32 entry count (loop1.rat: 3, matching its 3 sprite names)
|
||||||
|
0x18 u32 design width = 1280
|
||||||
|
0x1c u32 design height = 720
|
||||||
|
0x20 char[16] the sprite this record places, e.g. "pgpbtn00.t32"
|
||||||
|
0x50 u32 pivot X = texture width / 2
|
||||||
|
0x54 u32 pivot Y = texture height / 2
|
||||||
|
...
|
||||||
|
── placement block ──
|
||||||
|
u32 scale X = 100 (percent)
|
||||||
|
u32 scale Y = 100
|
||||||
|
u32 tint = 0xffffffff (RGBA, white = untinted)
|
||||||
|
u32 X ← top-left position
|
||||||
|
u32 Y ←
|
||||||
|
u32 time (keyframe records only)
|
||||||
|
...
|
||||||
|
"opt " u32 len char[len] link to another record, e.g. "pgpbtn00f.rat"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **X/Y is the sprite's top-left**, not its centre: compositing at these coordinates
|
||||||
|
reproduces the screenshot, which drawing centred on them would not.
|
||||||
|
- **The pivot at 0x50/0x54 is half the texture size** — 4 of the 5 plain sprites match
|
||||||
|
exactly (`pgpbtn00` 86×42 → 43,21; `pgpbtn05` 221×42 → 110,21; `pgptitle` 202×73 →
|
||||||
|
101,36; `pgpbtn04` 172×43 → 86,22). It is a rotation/scale centre, not a draw offset.
|
||||||
|
- **Animated elements are a keyframe list.** `pgptitle.rat` (752 B) is the same placement
|
||||||
|
block repeated with a varying trailing `time` field — the PAUSE title's fly-in.
|
||||||
|
- **`opt `** carries a length-prefixed record name. On `pgpbtn00.rat` it points at
|
||||||
|
`pgpbtn00f.rat`, i.e. *normal state → focused state*. Focused records place their sprite
|
||||||
|
42 px left and 8 px up of the base, because the focused art includes the selection ring
|
||||||
|
that hangs off the left edge; their pivot is a constant (21,25) rather than half-size.
|
||||||
|
- `<screen>loop1.rat` is **not** a screen composition — it is a looping sprite animation:
|
||||||
|
a 3-name table (`pgpeff34/35/36.t32`) plus ~30 keyframes all at one position.
|
||||||
|
- The small (380 B) top-level entries are **`PRMD` primitives**, not sprites: a colour and
|
||||||
|
four explicit corner coordinates `(0,0) (1280,0) (0,720) (1280,720)` — the full-screen
|
||||||
|
quad that dims the scene behind the pause menu — terminated by `end `.
|
||||||
|
|
||||||
|
### The records are language-independent
|
||||||
|
|
||||||
|
`pgpbtn00.rat` is **byte-identical** in the English and Japanese bundles. The layout is
|
||||||
|
authored once and only the `.t32` sprites are swapped, which has two consequences:
|
||||||
|
|
||||||
|
- The baked pivot belongs to *whichever build the record was authored from*, not to the
|
||||||
|
sprite actually shipped beside it. That is why `pgpbtn01`'s pivot (113 → a 226 px wide
|
||||||
|
texture) matches neither the English sprite (207) nor the Japanese one (148).
|
||||||
|
- The split is visible inside a single bundle: in the **English** tutorial build, every
|
||||||
|
`.t32` declaration carries the correct English pivot (7/7), while the `.rat` declarations
|
||||||
|
carry Japanese-derived ones (`btn10` → 43,21 = 86/2, the *Japanese* sprite). So the
|
||||||
|
`.t32` table is regenerated per language and the `.rat` layer is inherited from the
|
||||||
|
Japanese master.
|
||||||
|
- **Do not infer anything about a language from a texture size** — see the traps below.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Positions were read out of the records and checked against a screenshot of the running
|
||||||
|
game, twice, in that order — the records were never fitted to the picture.
|
||||||
|
|
||||||
|
1. **Differential.** Across `pgpbtn00/01/04/05.rat`, exactly one field varies and it steps
|
||||||
|
`268 → 338 → 408 → 478` — a constant 70 px pitch — while the field before it is 226 in
|
||||||
|
all four. A vertical menu: constant X, evenly spaced Y.
|
||||||
|
2. **Absolute placement — the decisive test.** The *tutorial* build's records give
|
||||||
|
546/288, 546/358, 546/428 and 540/119. Compositing its sprites at exactly those numbers,
|
||||||
|
with no offset and no fitting, reproduces the screenshot (image above).
|
||||||
|
3. **Pivot.** 4 of 5 plain sprites carry exactly half their texture's dimensions at
|
||||||
|
0x50/0x54 (above).
|
||||||
|
|
||||||
|
> A caution on step 2, because the first pass here got it subtly wrong: the screenshot is
|
||||||
|
> of the **tutorial** pause menu, so only the `pgp_ttrl_*` records can be checked against
|
||||||
|
> it. The in-mission records (X = 226) also map onto the same screenshot under a single
|
||||||
|
> constant offset — but that only works because both builds share the 70 px pitch, and it
|
||||||
|
> proves nothing. The in-mission coordinates remain **unverified**: confirming them needs a
|
||||||
|
> screenshot of a pause during an actual mission.
|
||||||
|
|
||||||
|
## It generalizes — the title screen
|
||||||
|
|
||||||
|
The same method run against `GP_TITLE.pak` reproduces the **main menu**, which is a
|
||||||
|
different screen with a different item count and a different pitch:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`ptbtn01..05.rat` give X = 542 for all five and Y = 162 / 242 / 322 / 402 / 482 — an
|
||||||
|
**80 px** pitch, where the pause menu used 70. Measured against the screenshot, the sprite
|
||||||
|
tops land at a constant **+46 px** for all five (one reads 45, a 1-px edge-detection
|
||||||
|
wobble), and 46 is exactly the 45 px of Xenia window chrome plus one. So the record's Y is
|
||||||
|
the sprite's top edge in the guest framebuffer, to the pixel, on a second screen.
|
||||||
|
|
||||||
|
`GP_TITLE.pak` also splits by sub-screen the way the pause pak splits by context:
|
||||||
|
`ptbtn00` alone (the `PRESS Ⓐ BUTTON` prompt), `ptbtn01..05` (main menu), `ptbtn11..13`
|
||||||
|
(the EXTRAS submenu), plus `pgloading_*` for the loading screen.
|
||||||
|
|
||||||
|
## Two traps this caught
|
||||||
|
|
||||||
|
Both were mistakes made during this analysis, caught by comparing against the real game —
|
||||||
|
recording them because a static-only reading would have shipped them:
|
||||||
|
|
||||||
|
- **Texture width does not identify a language.** English `RESUME` (166 px) and Japanese
|
||||||
|
`再開` (86 px) differ hugely, but Japanese `通信ログ` (148 px) is within a few px of an
|
||||||
|
English label. The first language assignment made here was wrong; rendering the sprites
|
||||||
|
is the only reliable check. (The records being language-independent makes this worse:
|
||||||
|
a record's baked pivot implies a texture width that matches *no* shipped sprite.)
|
||||||
|
- **The in-mission and tutorial pause menus are different sprite sets, not one set
|
||||||
|
re-packed.** In-mission is `RESUME / RADIO LOG / OPTIONS / BACK TO TITLE` (4 items,
|
||||||
|
`pgpbtnNN`); the tutorial is `RESUME / OPTIONS / BACK TO MENU` (3 items,
|
||||||
|
`pgp_ttrl_btn1N`). Matching the 3-item screenshot against the 4-item set suggests a
|
||||||
|
runtime slot-packing rule that does not exist.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- **The `eff*` / `deli*` / `msg` placements are still missing** — those sprites have no
|
||||||
|
`.rat` of their own, and `loop1.rat` turned out to be an animation, not a composition.
|
||||||
|
So the screen's draw list lives somewhere not yet found (the parent RATC's own header
|
||||||
|
region, or title code). That is the gap between the rebuild above and a complete screen.
|
||||||
|
- The same method should now unroll the other screens directly; `GP_HANGAR_ARSENAL.pak`
|
||||||
|
(789 T8aD + 510 RATC) is the big one, and the ARSENAL `DATA SHEET` panel documented in
|
||||||
|
[weapon-datasheet-runtime.md](../weapon-datasheet-runtime.md) is a ready-made oracle for it.
|
||||||
|
- Tooling: `crates/sylpheed-formats/examples/ui_screen.rs` (inventory a screen pak, carve a
|
||||||
|
named RATC child), `sylpheed-cli pak textures` (decode every sprite).
|
||||||
232
docs/re/weapon-datasheet-runtime.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Weapon DATA SHEET — runtime capture (Route B)
|
||||||
|
|
||||||
|
**Status:** 🟡 first dynamic capture, 2026-07-28. The Arsenal's *Gallery Mode* panel is a
|
||||||
|
direct runtime readout of the IDXD weapon record, which makes it an oracle for the fields
|
||||||
|
the disc leaves **defaulted**. Two field mappings are ✅ `CONFIRMED`; two defaulted values
|
||||||
|
are recovered at 🟡 `PROBABLE`. Captured from the retail game under Xenia Canary
|
||||||
|
(software Vulkan, headless) — see [the container recipe](#how-this-was-captured).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`sylpheed-formats::game_data` reads the combat tables out of `dat/GP_MAIN_GAME_E.pak`, but
|
||||||
|
**a field left at its default value carries no value on disc** — the key is present in the
|
||||||
|
IDXD string pool with no value token in front of it. Those defaults live in title code, so
|
||||||
|
a static read can only ever say "not set", never *what* the game uses. That is a real hole
|
||||||
|
for the reimplementation: e.g. `Weapon_DSaber_P_wep_01_Beam` — the Delta Saber's starting
|
||||||
|
beam gun — has no `TriggerShotCount` and no `Power` on disc.
|
||||||
|
|
||||||
|
Ruled out first: the defaults are **not** hiding in another pak. `hidden/DefTables.pak`
|
||||||
|
contains no `WEAPON`-schema (`0x6ab4825a`) objects at all, and the other `GP_MAIN_GAME_*`
|
||||||
|
paks are localized duplicates of the English one.
|
||||||
|
|
||||||
|
The two scratch analyses that produced the shopping list live next to the crate:
|
||||||
|
`crates/sylpheed-formats/examples/defaulted_fields.rs` (per-schema: which keys are declared
|
||||||
|
but defaulted, and by whom) and `examples/default_owners.rs` (per-key: every owner, valued
|
||||||
|
or `<DEFAULT>`).
|
||||||
|
|
||||||
|
> Caveat on those tools: they classify a token as a *value* only if it is numeric or
|
||||||
|
> non-identifier-shaped. **Boolean/enum-valued fields therefore read as `<DEFAULT>`
|
||||||
|
> spuriously** (`Yes`, `Homing`, `Single`, `Burst` are identifier-shaped). Every finding
|
||||||
|
> below concerns numeric fields, where the classification is sound.
|
||||||
|
|
||||||
|
## Finding — the DATA SHEET reads the record
|
||||||
|
|
||||||
|
In **ARSENAL → (weapon type) → Y (Gallery Mode)** each entry shows a `DATA SHEET`, and it
|
||||||
|
is shown for weapons that have **not** been developed yet — only fully hidden (dashed)
|
||||||
|
entries are withheld. The same panel appears in **HANGAR → (hard point)**, with an extra
|
||||||
|
`Weapon Type` row.
|
||||||
|
|
||||||
|
| DATA SHEET row | IDXD field | Confidence | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Ammo Capacity` | `LoadingCount` | ✅ CONFIRMED | every one of the 10 identified entries lands on a `LoadingCount` that exists in its own weapon-type tab — and see the circularity note below |
|
||||||
|
| `Max. Lock Ons` | `TriggerShotCount` | ✅ CONFIRMED | FALCON 9AM = 12 vs `wep_02`'s 12; BUZZARD 10AM = 22 vs `wep_04`'s 22 (two distinctive values, two independent records) |
|
||||||
|
| `Hard Point` | mount slot | ✅ CONFIRMED | matches the HANGAR slot the weapon is mountable/equipped on (`STILETTO BG I` → NOSE, `FALCON 9AM` → MAIN WEAPON1) |
|
||||||
|
| `Range Class` | bucket of `MaximumRange` | 🟡 PROBABLE | monotone in the on-disc metres, see the bracket table below |
|
||||||
|
| `Damage Class` | bucket of `Power` | 🟡 PROBABLE | monotone in the on-disc power, see below |
|
||||||
|
| `Weight Class` | bucket of the hangar-table `Weight` | ❔ HYPOTHESIS | only one clean pair so far (`wep_33`, Weight 0.3 → "Light") |
|
||||||
|
| `Speed Class` | ❔ | ❔ | present only on missiles (MPM = A, ASM = B); no on-disc pairing established |
|
||||||
|
| `Sight Homing`, `Lock on Overlap` | ❔ (`Available` / `–`) | ❔ | the plausible on-disc partners (`Homing`, `OverlapLockon`) are identifier-valued and not yet decoded; **NEEDS-HUMAN** |
|
||||||
|
|
||||||
|
### Why the `Ammo Capacity` evidence is not circular
|
||||||
|
|
||||||
|
Each UI entry was **identified** by matching its `Ammo Capacity` against the records in
|
||||||
|
that weapon-type tab, so "the ammo matches" cannot on its own prove the mapping. What does:
|
||||||
|
|
||||||
|
1. **The match is forced and unique.** For 10 of the 11 entries, exactly one record in that
|
||||||
|
tab carries that number (`600` occurs three times overall — `wep_04`, `wep_24`, `wep_55`
|
||||||
|
— but in three different tabs: MPM, BEAM, B/R). An unrelated quantity would not land on
|
||||||
|
a valid, tab-unique `LoadingCount` eleven times running.
|
||||||
|
2. **A second, independent field then agrees.** FALCON 9AM and BUZZARD 10AM were pinned by
|
||||||
|
ammo alone, and their `Max. Lock Ons` (12, 22) then matched those same records'
|
||||||
|
`TriggerShotCount` (12, 22) — values that play no part in the identification. A wrong
|
||||||
|
identification would have to be wrong twice, consistently.
|
||||||
|
3. **One entry is identified without ammo at all.** STILETTO BG I is described in-game as
|
||||||
|
"the initially mounted Delta Saber beam gun" and is the weapon the HANGAR shows equipped
|
||||||
|
on the nose; `wep_01_Beam` is the corresponding record, and its `LoadingCount` 6000 is
|
||||||
|
what the panel shows.
|
||||||
|
|
||||||
|
The weakest identification is LIGHT MACHINE GUN MG I: three guns share `LoadingCount = 3000`
|
||||||
|
(`wep_09`, `wep_33`, `wep_83`). `wep_33` is picked on `Weight Class = Light` (it has by far
|
||||||
|
the smallest `Mass`, 1.5 vs 3.6 / 33.0) — 🟡 PROBABLE, not certain.
|
||||||
|
|
||||||
|
## Finding — recovered defaults
|
||||||
|
|
||||||
|
| Weapon | UI name | Field | On disc | **Runtime** | Conf. |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `Weapon_DSaber_P_wep_05_ASMissile` | TERRIER SMH | `TriggerShotCount` | *(defaulted)* | **4** | 🟡 |
|
||||||
|
| `Weapon_DSaber_P_wep_60_ASMissile` | HOUND SMH | `TriggerShotCount` | *(defaulted)* | **4** | 🟡 |
|
||||||
|
|
||||||
|
Both defaulted weapons read **4**, which is consistent with a single title-code default of
|
||||||
|
`TriggerShotCount = 4` rather than two per-weapon constants — but two samples cannot tell
|
||||||
|
those apart. ❔ HYPOTHESIS: *the title-code default for `TriggerShotCount` is 4.* It would
|
||||||
|
be confirmed by a third weapon that defaults the field and also reads 4 (candidates that
|
||||||
|
were still locked in this save: `wep_27`, `wep_30`, and the seven Beams), or refuted by one
|
||||||
|
that reads anything else.
|
||||||
|
|
||||||
|
`Power` and `MaximumRange` defaults are **not** exactly recoverable from this panel — it
|
||||||
|
shows only the letter bucket. They are bracketed instead (below).
|
||||||
|
|
||||||
|
## Captured rows
|
||||||
|
|
||||||
|
All values are from one session on save slot 01 (Stage 02, "At Standby", 5 % clear, 4101 P);
|
||||||
|
`✓` marks a value that matches the on-disc record exactly.
|
||||||
|
|
||||||
|
| UI name | Record | Rng | Dmg | Spd | Weight | Ammo | Lock | Homing | Overlap | Hard point |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| LIGHT MACHINE GUN MG I | `wep_33_Gun` 🟡 | D | E | – | Light | 3000 ✓ | – | – | – | NOSE WEAPON (Nose) |
|
||||||
|
| BROAD SWORD SG I | *see below* | E | D | – | Light | 200 | – | – | – | NOSE WEAPON (Nose) |
|
||||||
|
| STILETTO BG I | `wep_01_Beam` | E | E | – | Light | 6000 ✓ | – | – | – | NOSE WEAPON (Nose) |
|
||||||
|
| DAGGER BG2 | `wep_37_Beam` | D | E | – | Light | 4000 ✓ | – | – | – | NOSE WEAPON (Nose) |
|
||||||
|
| PILUM BP | `wep_24_Beam` | B | D | – | Light | 600 ✓ | – | – | – | MAIN WEAPON1 (Fore) |
|
||||||
|
| FALCON 9AM | `wep_02_Missile` | D | D | A | Heavy | 300 ✓ | 12 ✓ | Available | – | MAIN WEAPON1 (Fore) |
|
||||||
|
| BUZZARD 10AM | `wep_04_Missile` | C | D | A | Heavy | 600 ✓ | 22 ✓ | – | Available | MAIN WEAPON1 (Fore) |
|
||||||
|
| DART 23 ROCKET | `wep_55_Rocket` | C | D | – | Heavy | 600 ✓ | – | – | – | MAIN WEAPON1 (Fore) |
|
||||||
|
| TERRIER SMH | `wep_05_ASMissile` | D | C | B | Heavy | 45 ✓ | **4** | Available | Available | MAIN WEAPON2 (Rear) |
|
||||||
|
| HOUND SMH | `wep_60_ASMissile` | B | C | B | Medium | 18 ✓ | **4** | Available | Available | MAIN WEAPON2 (Rear) |
|
||||||
|
| TOMAHAWK ALPHA RAIL GUN | `wep_03_Cannon` | C | C | – | Medium | 75 ✓ | – | – | – | MAIN WEAPON3 (Lower) |
|
||||||
|
|
||||||
|
**BROAD SWORD SG I is unidentified — NEEDS-HUMAN.** `Ammo Capacity 200` narrows it to
|
||||||
|
`wep_38` / `wep_41` / `wep_42_Shotgun` (all `LoadingCount = 200`); "SG" and the GUN tab fit
|
||||||
|
a shotgun. `wep_42` is excluded by range (2500 m would not share class E with `wep_38`/
|
||||||
|
`wep_41`'s 3000 m *if* the class is a pure range bucket), leaving `wep_38` vs `wep_41`,
|
||||||
|
which the panel cannot separate. Its `Damage Class D` also does not fit the `Power` bracket
|
||||||
|
below (all three shotguns are `Power ≤ 16`, i.e. bucket E), so either the identification or
|
||||||
|
the "Damage Class = bucket of `Power`" model is wrong for shotguns.
|
||||||
|
|
||||||
|
### Letter-class brackets
|
||||||
|
|
||||||
|
Sorting the identified rows by their on-disc numbers gives monotone, non-overlapping bands:
|
||||||
|
|
||||||
|
```
|
||||||
|
Range Class E: 3000 (wep_01)
|
||||||
|
D: 3500 · 4000 · 4000 · 4000 (wep_33, wep_37, wep_02, wep_05)
|
||||||
|
C: 4500 · 5000 · 5000 (wep_55, wep_04, wep_03)
|
||||||
|
B: 6500 · 6500 (wep_24, wep_60)
|
||||||
|
|
||||||
|
Damage Class E: 10 · 14 · 16 (wep_33, wep_01, wep_37)
|
||||||
|
D: 70 · 75 · 100 (wep_24, wep_04, wep_55)
|
||||||
|
C: 200 · 400 (wep_03, wep_05)
|
||||||
|
```
|
||||||
|
|
||||||
|
Two consequences for the reimplementation:
|
||||||
|
|
||||||
|
- The **thresholds are not pinned** — only bracketed (e.g. the D/C range boundary lies in
|
||||||
|
(4000, 4500]). More weapons, or a static read of the title-code table, would pin them.
|
||||||
|
- They **bracket the defaulted numbers**: `wep_02_Missile`'s defaulted `Power` sits in the
|
||||||
|
D band (≈ 17…150 by the observed edges) and `wep_60_ASMissile`'s in the C band
|
||||||
|
(≈ 150…500). 🟡 PROBABLE, and only as good as the bucket model.
|
||||||
|
|
||||||
|
## How this was captured
|
||||||
|
|
||||||
|
Container recipe (`sylph-container/mission.md`), with two corrections worth keeping:
|
||||||
|
|
||||||
|
- **Skip the intro movie with A.** The brief warns it crashes; it does not. Skipping cuts
|
||||||
|
boot-to-main-menu from ~13 min to **~1 min** under lavapipe. (Thanks: user tip.)
|
||||||
|
- **The title screen falls back to the attract loop within a few seconds**, so a
|
||||||
|
screenshot→look→tap cycle always misses it. Poll the framebuffer and tap in the same
|
||||||
|
process. Both are automated in `scratchpad/skip_intro.sh` (movie detected by frame-to-
|
||||||
|
frame RMSE; title by the green Ⓐ glyph at pixel 625,618).
|
||||||
|
- Under lavapipe the game polls input at its own low frame rate: **a 60 ms d-pad tap is
|
||||||
|
dropped roughly half the time**; 200 ms is reliable and 300 ms starts to auto-repeat.
|
||||||
|
- The Hangar hard-point weapon carousel is cycled with d-pad **down**, not left/right.
|
||||||
|
|
||||||
|
Path: title → A → LOAD GAME → slot 01 → *Load game?* **YES** → READY ROOM → ARSENAL →
|
||||||
|
type tab (LB/RB) → **Y** for the DATA SHEET → d-pad down through the list.
|
||||||
|
|
||||||
|
## Open / next
|
||||||
|
|
||||||
|
- Only **9 weapons of ~61** are revealed at 5 % completion, and none of the four whose
|
||||||
|
`LoadingCount` is defaulted (`wep_11`, `wep_28`, `wep_36`, `wep_70`) is among them.
|
||||||
|
Progressing the save (or a later save) is what unlocks the rest — the panel itself
|
||||||
|
already shows undeveloped weapons, so no points need to be spent.
|
||||||
|
- **Craft (`UNIT`-schema) defaults are not reachable this way.** The Hangar exposes exactly
|
||||||
|
one craft-level runtime number, `Gross Weight` (a class, "Light"). The defaulted craft
|
||||||
|
fields (`ShieldRatio`, `BarrelRoll_Count*`, `HoldPosition_*Ratio`, `Slalom_TurnCount_Max`,
|
||||||
|
`UsingChaffRatio`, …) are AI/flight-model constants with no UI surface; they would need
|
||||||
|
in-flight behavioural measurement or a guest-memory read, not a menu screenshot.
|
||||||
|
- The `Sight Homing` / `Lock on Overlap` on-disc partners are still unidentified.
|
||||||
|
|
||||||
|
Screenshots for every row above: `/sylph-home/re/caps/` in the container.
|
||||||
|
|
||||||
|
Evidence PNGs are committed under [`captures/weapon-datasheet/`](captures/weapon-datasheet/)
|
||||||
|
(64-colour quantized for size; the numbers stay legible).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Addendum — the in-flight HUD (2026-07-28)
|
||||||
|
|
||||||
|
Reached via **TUTORIAL → BASIC CONTROLS / HEADS-UP DISPLAY** from the title menu (no
|
||||||
|
save needed). Evidence: [`captures/hud-runtime/`](captures/hud-runtime/).
|
||||||
|
|
||||||
|
## The HUD corroborates the ammo mapping, independently
|
||||||
|
|
||||||
|
The Delta Saber's HUD prints its two equipped weapons as `NOSE <TYPE> <AMMO>` and
|
||||||
|
`MAIN <TYPE> <AMMO>`. With the loadout known from the HANGAR (nose = STILETTO BG I, main =
|
||||||
|
FALCON 9AM) the HUD reads **`NOSE BM 06000`** and **`MAIN MPM 00300`** — exactly
|
||||||
|
`wep_01_Beam`'s `LoadingCount = 6000` and `wep_02_Missile`'s `300`.
|
||||||
|
|
||||||
|
This matters because it is **not** the Arsenal panel: the weapons were identified from the
|
||||||
|
HANGAR loadout, and the numbers come from a different renderer in a different game mode. It
|
||||||
|
is a genuinely independent confirmation of `Ammo Capacity == LoadingCount`.
|
||||||
|
|
||||||
|
The type tags (`BM`, `MPM`) are the same short codes as the Arsenal type tabs.
|
||||||
|
|
||||||
|
## HUD element inventory
|
||||||
|
|
||||||
|
| HUD element | Backing data | Conf. |
|
||||||
|
|---|---|---|
|
||||||
|
| `NOSE` / `MAIN` + type tag + 5-digit ammo | equipped weapon, `LoadingCount` | ✅ |
|
||||||
|
| `HEAT` / `L.HEAT` bar under each weapon | the `Heating` / `Cooling` pair | 🟡 |
|
||||||
|
| Speed readout + throttle scale (`0`, `100`, `A/B`) | craft velocity | ✅ |
|
||||||
|
| `SHIELD` and `ARMOR` bars (two separate pools) | craft `HP` + a shield pool | 🟡 |
|
||||||
|
| Target reticle: name / type / distance + ring **Armor Gauge** | target unit record | 🟡 |
|
||||||
|
| `RANGE` gauge with `MAIN` and `NOSE` tick markers | each equipped weapon's `MaximumRange`, plotted against target distance | 🟡 |
|
||||||
|
| `YOU KILLED: WARSHIPS nnnn` + a second counter | score / `ScorePoint` | ❔ |
|
||||||
|
|
||||||
|
The `RANGE` gauge is the interesting one for future work: it draws a **per-weapon marker on
|
||||||
|
a distance scale**, so a weapon whose `MaximumRange` is defaulted on disc (`wep_25`,
|
||||||
|
`wep_58`, `wep_82`) would have its value *drawn* rather than bucketed into a letter — if the
|
||||||
|
scale can be calibrated against two weapons with known ranges, that recovers a real number
|
||||||
|
where the Arsenal panel only gives a class.
|
||||||
|
|
||||||
|
## Craft velocity
|
||||||
|
|
||||||
|
Full afterburner (RT) peaked at **1193** against `UN_f001_TCAF_DeltaSaber_T`'s on-disc
|
||||||
|
`MaximumVelocity = 1200`. 🟡 PROBABLE — one observation, and the readout may have been
|
||||||
|
still climbing. Cruise sat at 350, which is *not* the record's `CruisingVelocity` (700), so
|
||||||
|
the number is the current throttle setting, not a named constant; don't read more into it.
|
||||||
|
|
||||||
|
## Notes for the next session
|
||||||
|
|
||||||
|
- The tutorials need **no save game** and are reachable in ~1 min from a cold boot, which
|
||||||
|
makes them the cheapest way back into a live flight scene.
|
||||||
|
- `tools/re-capture/autopilot.py` chases the yellow off-screen waypoint arrow (colour +
|
||||||
|
shape, `-sample` not `-resize`, ~0.65 s per detection). It **finds the arrow reliably but
|
||||||
|
oscillates** — the proportional gain is too high for the craft's turn rate. It needs a
|
||||||
|
damping/derivative term before it can actually fly a waypoint.
|
||||||
|
- The HEADS-UP DISPLAY tutorial reaches a scripted targeting segment where the ship stops
|
||||||
|
moving (target distance pinned) and the on-screen controller highlights **A**; tapping and
|
||||||
|
holding A did not advance it. **NEEDS-HUMAN**: what input that segment wants.
|
||||||
|
- Canary is unstable here: it died twice mid-session (once loading BEAM `Resource3D`, once
|
||||||
|
hanging on tutorial teardown) with no crash dump. Re-launch is cheap; just don't assume a
|
||||||
|
long session survives.
|
||||||
20
tools/re-capture/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Runtime-capture harness (sylph-re container)
|
||||||
|
|
||||||
|
Screenshot-driven scripts for reading the running retail game's menus under Xenia
|
||||||
|
Canary + lavapipe, headless. They assume the container helpers `screenshot`,
|
||||||
|
`vgamepad`, `pad` are on `$PATH` and `HOME=/sylph-home/re`.
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `skip_intro.sh` | Boot → main menu, unattended. Taps A only while the intro movie is actually playing (frame-to-frame RMSE), then once at the `PRESS Ⓐ BUTTON` title. Static logo screens are left alone, so a stray tap can never land on NEW GAME. |
|
||||||
|
| `wait_title.sh` | Older variant: wait for the title (green Ⓐ glyph at px 625,618) and tap A. Superseded by `skip_intro.sh`. |
|
||||||
|
| `step.sh` | One Arsenal navigation step (`down`/`up`/`next`/`prev`/`none`) + a compact capture: weapon list stacked over the `DATA SHEET`. |
|
||||||
|
| `sweep.sh` | Walk a whole weapon-type list, capturing **only** rows that show a `DATA SHEET` — locked rows (a "Conditions to Develop" panel) are detected by the brightness of the `Range Class` label box and skipped. |
|
||||||
|
| `type.sh` | Change weapon-type tab N times (RB) and report the header strip. |
|
||||||
|
| `hp.sh` / `cyc.sh` | Hangar hard-point carousel: `cyc.sh` steps it (d-pad **down**, not left/right) and captures the Name + `DATA SHEET`. |
|
||||||
|
|
||||||
|
**Input timing under lavapipe:** the game polls input at its own low frame rate, so a
|
||||||
|
60 ms d-pad tap is dropped roughly half the time. 200 ms is reliable; 300 ms starts to
|
||||||
|
auto-repeat (two rows per press).
|
||||||
|
|
||||||
|
Findings produced with these: [`docs/re/weapon-datasheet-runtime.md`](../../docs/re/weapon-datasheet-runtime.md).
|
||||||
95
tools/re-capture/autopilot.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fly the Delta Saber toward the tutorial waypoint by chasing the yellow
|
||||||
|
off-screen direction arrow.
|
||||||
|
|
||||||
|
Detection: no PIL/numpy in the box, so the frame comes from `convert ... txt:-`.
|
||||||
|
Two things make it fast AND correct:
|
||||||
|
* `-sample` (point sampling, not `-resize`/`-scale` averaging) — a 1-px-thin
|
||||||
|
glyph keeps its true colour, so the exact colour predicate still fires while
|
||||||
|
the dump shrinks ~9x (3.5s -> 0.65s).
|
||||||
|
* shape, not just colour — the instruction-frame bars and the throttle marker
|
||||||
|
are the same dim yellow, so the arrow is the largest blob that is roughly as
|
||||||
|
tall as it is wide, with the fixed throttle marker blacklisted by position.
|
||||||
|
A ~1.1 s control loop is fast enough to converge; the earlier ~6 s loop was not.
|
||||||
|
"""
|
||||||
|
import re, subprocess, sys, time
|
||||||
|
|
||||||
|
X0, Y0, W, H = 80, 60, 820, 640 # flight view (the arrow also rides the bottom edge)
|
||||||
|
K = 3.03 # 1/0.33 sample factor
|
||||||
|
CX, CY = 640, 405 # crosshair
|
||||||
|
THROTTLE = (382, 456) # fixed yellow decoy on the speed gauge
|
||||||
|
PX = re.compile(r"^(\d+),(\d+):.*?srgb\((\d+),(\d+),(\d+)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def pad(*a):
|
||||||
|
subprocess.run(["/opt/sylph/vgamepad.py", *a], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def arrow(png="/tmp/ap.png"):
|
||||||
|
subprocess.run(["/opt/sylph/screenshot.sh", png], check=False,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
out = subprocess.run(["convert", png, "-crop", f"{W}x{H}+{X0}+{Y0}", "+repage",
|
||||||
|
"-sample", "33%", "txt:-"], capture_output=True, text=True).stdout
|
||||||
|
pts = set()
|
||||||
|
for l in out.splitlines()[1:]:
|
||||||
|
m = PX.match(l)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
x, y, r, g, b = (int(v) for v in m.groups())
|
||||||
|
if r >= 60 and g >= 48 and b <= 0.35 * g and (r - g) <= 0.45 * r:
|
||||||
|
pts.add((x, y))
|
||||||
|
seen, best = set(), None
|
||||||
|
for p in pts:
|
||||||
|
if p in seen:
|
||||||
|
continue
|
||||||
|
st, c = [p], []
|
||||||
|
seen.add(p)
|
||||||
|
while st:
|
||||||
|
x, y = st.pop(); c.append((x, y))
|
||||||
|
for dx in (-1, 0, 1):
|
||||||
|
for dy in (-1, 0, 1):
|
||||||
|
q = (x + dx, y + dy)
|
||||||
|
if q in pts and q not in seen:
|
||||||
|
seen.add(q); st.append(q)
|
||||||
|
xs = [q[0] for q in c]; ys = [q[1] for q in c]
|
||||||
|
cx, cy = X0 + sum(xs) / len(c) * K, Y0 + sum(ys) / len(c) * K
|
||||||
|
if abs(cx - THROTTLE[0]) < 30 and abs(cy - THROTTLE[1]) < 30:
|
||||||
|
continue
|
||||||
|
if len(c) < 8:
|
||||||
|
continue
|
||||||
|
if best is None or len(c) > best[2]:
|
||||||
|
best = (cx, cy, len(c))
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
steps = int(sys.argv[1]) if len(sys.argv) > 1 else 25
|
||||||
|
centred = 0
|
||||||
|
for i in range(steps):
|
||||||
|
a = arrow()
|
||||||
|
if a is None:
|
||||||
|
print(f"{i:2d}: no arrow -> boost (target ahead)")
|
||||||
|
pad("trig", "RT", "0.55"); time.sleep(1.2); pad("trig", "RT", "0")
|
||||||
|
centred += 1
|
||||||
|
if centred >= 6:
|
||||||
|
print(" arrival likely — stopping"); return 0
|
||||||
|
continue
|
||||||
|
x, y, n = a
|
||||||
|
dx, dy = x - CX, y - CY
|
||||||
|
if abs(dx) < 70 and abs(dy) < 70:
|
||||||
|
centred += 1
|
||||||
|
print(f"{i:2d}: arrow ({x:.0f},{y:.0f}) centred -> boost")
|
||||||
|
pad("trig", "RT", "0.55"); time.sleep(1.2); pad("trig", "RT", "0")
|
||||||
|
continue
|
||||||
|
centred = 0
|
||||||
|
lx = max(-1.0, min(1.0, dx / 200))
|
||||||
|
ly = max(-1.0, min(1.0, dy / 160))
|
||||||
|
print(f"{i:2d}: arrow ({x:.0f},{y:.0f}) n={n} -> LX {lx:+.2f} LY {ly:+.2f}")
|
||||||
|
pad("axis", "LX", f"{lx:.2f}"); pad("axis", "LY", f"{ly:.2f}")
|
||||||
|
time.sleep(0.45)
|
||||||
|
pad("axis", "LX", "0"); pad("axis", "LY", "0")
|
||||||
|
print("steps exhausted")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
9
tools/re-capture/cyc.sh
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Step the Hangar weapons-container carousel right and capture the Name+DATA SHEET.
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
|
||||||
|
vgamepad dpad right; sleep 0.30; vgamepad dpad center; sleep 3.0
|
||||||
|
screenshot /tmp/h.png >/dev/null 2>&1
|
||||||
|
convert /tmp/h.png -crop 530x480+700+95 +repage "$OUT/$1.png"
|
||||||
|
echo "$OUT/$1.png"
|
||||||
11
tools/re-capture/hp.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cycle the Hangar hard-point weapon carousel one step right and capture the
|
||||||
|
# Name + DATA SHEET panel (the mountable-weapon list for that hard point).
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
|
||||||
|
[ "${1:-}" = "none" ] || { vgamepad dpad right; sleep 0.20; vgamepad dpad center; }
|
||||||
|
sleep 2.5
|
||||||
|
screenshot /tmp/h.png >/dev/null 2>&1
|
||||||
|
convert /tmp/h.png -crop 495x460+705+95 +repage "$OUT/$2.png"
|
||||||
|
echo "$OUT/$2.png"
|
||||||
41
tools/re-capture/ratc_decls.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Parse a RATC bundle's header SPRITE DECLARATION TABLE: u32 count at 0x14, then
|
||||||
|
fixed 60-byte entries of [name, NUL-padded | 4 u32 flags | pivotX | pivotY | 0].
|
||||||
|
Check each pivot against half the decoded texture's real dimensions."""
|
||||||
|
import struct, sys, glob, re, os
|
||||||
|
|
||||||
|
def decls(d):
|
||||||
|
n = struct.unpack_from(">I", d, 0x14)[0]
|
||||||
|
out = []
|
||||||
|
for i in range(n):
|
||||||
|
o = 0x20 + i * 60
|
||||||
|
if o + 60 > len(d): break
|
||||||
|
name = d[o:o+28].split(b"\0")[0].decode("ascii", "replace")
|
||||||
|
px, py = struct.unpack_from(">II", d, o + 48)
|
||||||
|
out.append((name, px, py))
|
||||||
|
return n, out
|
||||||
|
|
||||||
|
def texmap(prefix):
|
||||||
|
t = {}
|
||||||
|
for f in glob.glob(f"pause-tex/{prefix}_*.png"):
|
||||||
|
m = re.match(rf".*/{prefix}_(.+)\.t32_(\d+)x(\d+)\.png", f)
|
||||||
|
if m: t[m.group(1) + ".t32"] = (int(m.group(2)), int(m.group(3)))
|
||||||
|
return t
|
||||||
|
|
||||||
|
for path, prefix in [(sys.argv[1], sys.argv[2])]:
|
||||||
|
d = open(path, "rb").read()
|
||||||
|
n, ds = decls(d)
|
||||||
|
tm = texmap(prefix)
|
||||||
|
print(f"{os.path.basename(path)}: count={n}, parsed={len(ds)}")
|
||||||
|
ok = bad = miss = 0
|
||||||
|
for name, px, py in ds:
|
||||||
|
if name in tm:
|
||||||
|
w, h = tm[name]
|
||||||
|
hit = (px == w // 2 and py == h // 2)
|
||||||
|
ok, bad = ok + hit, bad + (not hit)
|
||||||
|
flag = "OK " if hit else "MISMATCH"
|
||||||
|
print(f" {flag} {name:28s} tex {w:4d}x{h:<4d} half {w//2:4d},{h//2:<4d} decl {px:4d},{py:<4d}")
|
||||||
|
else:
|
||||||
|
miss += 1
|
||||||
|
print(f" ? {name:28s} (no decoded texture) decl {px:4d},{py:<4d}")
|
||||||
|
print(f" => pivot == half(texture): {ok} ok, {bad} mismatch, {miss} unchecked")
|
||||||
27
tools/re-capture/skip_intro.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Drive the boot sequence to the main menu without a human in the loop.
|
||||||
|
# - movie playing (two grabs 0.6s apart differ a lot) -> tap A to skip
|
||||||
|
# - static screen -> if the green "PRESS (A) BUTTON" glyph is there, tap A and stop
|
||||||
|
# Static logo screens are left alone, so a stray tap can never land on NEW GAME.
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
# The X root keeps the DEAD session's last frame, so a fresh launch would be
|
||||||
|
# detected as "already at the title". Blank it, and wait for the new window.
|
||||||
|
xsetroot -solid black 2>/dev/null || true
|
||||||
|
until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do sleep 1; done
|
||||||
|
deadline=$(( SECONDS + ${1:-900} ))
|
||||||
|
while [ $SECONDS -lt $deadline ]; do
|
||||||
|
screenshot /tmp/f1.png >/dev/null 2>&1; sleep 0.6
|
||||||
|
screenshot /tmp/f2.png >/dev/null 2>&1
|
||||||
|
d=$(compare -metric RMSE /tmp/f1.png /tmp/f2.png null: 2>&1 | sed 's/ .*//' | cut -d. -f1)
|
||||||
|
d=${d:-0}
|
||||||
|
read -r r g b < <(convert /tmp/f2.png -format "%[fx:int(255*p{625,618}.r)] %[fx:int(255*p{625,618}.g)] %[fx:int(255*p{625,618}.b)]" info:)
|
||||||
|
if [ "$g" -gt 130 ] && [ $((g - r)) -gt 45 ] && [ $((g - b)) -gt 45 ]; then
|
||||||
|
echo "TITLE at ${SECONDS}s -> A"; vgamepad tap A 250; exit 0
|
||||||
|
fi
|
||||||
|
if [ "$d" -gt 1500 ]; then
|
||||||
|
echo "movie (rmse $d) at ${SECONDS}s -> skip A"; vgamepad tap A 250; sleep 3
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "TIMEOUT"; exit 1
|
||||||
22
tools/re-capture/step.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One Arsenal navigation step + a compact capture: the weapon list (which row is
|
||||||
|
# selected) stacked over the DATA SHEET numeric rows. Everything else is dropped.
|
||||||
|
# Usage: step.sh <down|up|next|prev|none> <tag> [settle_s]
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
|
||||||
|
case "${1}" in
|
||||||
|
down) vgamepad dpad down; sleep 0.20; vgamepad dpad center ;;
|
||||||
|
up) vgamepad dpad up; sleep 0.20; vgamepad dpad center ;;
|
||||||
|
next) vgamepad hold RB 0.35 ;;
|
||||||
|
prev) vgamepad hold LB 0.35 ;;
|
||||||
|
none) : ;;
|
||||||
|
esac
|
||||||
|
sleep "${3:-2.5}"
|
||||||
|
raw="$OUT/$2.raw.png"
|
||||||
|
screenshot "$raw" >/dev/null 2>&1
|
||||||
|
convert "$raw" -crop 500x420+150+180 +repage /tmp/_list.png
|
||||||
|
convert "$raw" -crop 500x200+700+100 +repage /tmp/_sheet.png
|
||||||
|
convert /tmp/_list.png /tmp/_sheet.png -background black -append "$OUT/$2.png"
|
||||||
|
rm -f "$raw"
|
||||||
|
echo "$OUT/$2.png"
|
||||||
21
tools/re-capture/sweep.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Walk down a weapon-type list N rows; capture ONLY rows that show a DATA SHEET
|
||||||
|
# (locked rows show a "Conditions to Develop" panel instead — detected by the
|
||||||
|
# brightness of the "Range Class" label box, ~5 when absent, >>20 when present).
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
|
||||||
|
pfx=$1; n=${2:-8}
|
||||||
|
for ((i=1;i<=n;i++)); do
|
||||||
|
vgamepad dpad down; sleep 0.20; vgamepad dpad center; sleep 3.0
|
||||||
|
screenshot /tmp/s.png >/dev/null 2>&1
|
||||||
|
m=$(convert /tmp/s.png -crop 150x24+730+116 +repage -colorspace Gray -format "%[fx:int(255*mean)]" info:)
|
||||||
|
if [ "$m" -gt 45 ]; then
|
||||||
|
convert /tmp/s.png -crop 500x420+150+180 +repage /tmp/_l.png
|
||||||
|
convert /tmp/s.png -crop 500x200+700+100 +repage /tmp/_s.png
|
||||||
|
convert /tmp/_l.png /tmp/_s.png -background black -append "$OUT/$pfx-r$i.png"
|
||||||
|
echo "row $i: DATA SHEET -> $OUT/$pfx-r$i.png"
|
||||||
|
else
|
||||||
|
echo "row $i: locked (mean $m)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
11
tools/re-capture/type.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Change Arsenal weapon type N times (RB) and report the header strip only.
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
|
||||||
|
n=${1:-1}
|
||||||
|
for ((i=0;i<n;i++)); do vgamepad hold RB 0.35; sleep 2.5; done
|
||||||
|
sleep 1.5
|
||||||
|
screenshot /tmp/hdr.png >/dev/null 2>&1
|
||||||
|
convert /tmp/hdr.png -crop 420x50+130+148 +repage "$OUT/hdr.png"
|
||||||
|
echo "$OUT/hdr.png"
|
||||||
21
tools/re-capture/wait_title.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Poll the framebuffer for the "PRESS (A) BUTTON" title screen (the green A glyph
|
||||||
|
# at ~625,618) and tap A the instant it appears — the title auto-returns to the
|
||||||
|
# attract loop after a few seconds, which is why a human-paced tap misses it.
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
TMP=/tmp/title-probe.png
|
||||||
|
deadline=$(( SECONDS + ${1:-900} ))
|
||||||
|
while [ $SECONDS -lt $deadline ]; do
|
||||||
|
if screenshot "$TMP" >/dev/null 2>&1; then
|
||||||
|
read -r r g b < <(convert "$TMP" -format "%[fx:int(255*p{625,618}.r)] %[fx:int(255*p{625,618}.g)] %[fx:int(255*p{625,618}.b)]" info:)
|
||||||
|
if [ "$g" -gt 130 ] && [ $((g - r)) -gt 45 ] && [ $((g - b)) -gt 45 ]; then
|
||||||
|
echo "TITLE detected (rgb $r,$g,$b) at ${SECONDS}s — tapping A"
|
||||||
|
vgamepad tap A 200
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "TIMEOUT: title not seen"
|
||||||
|
exit 1
|
||||||