Compare commits
24 Commits
053aa81953
...
feature/ip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c067761e56 | ||
|
|
ea32e77e45 | ||
|
|
31f2637e6c | ||
|
|
737b61242e | ||
|
|
015e49ac8a | ||
|
|
d9442a2106 | ||
|
|
4efc0278b1 | ||
|
|
c6ca5ce9e7 | ||
|
|
a68405216f | ||
|
|
79c78cc7e1 | ||
|
|
8c8e2dbeb4 | ||
|
|
a8b5691d1c | ||
|
|
720a5fec27 | ||
|
|
cacb576ecd | ||
|
|
df724b6bcb | ||
|
|
d41b065813 | ||
|
|
f2991b61ab | ||
|
|
f5182f9649 | ||
|
|
551291f869 | ||
|
|
6e704291be | ||
|
|
2930770060 | ||
|
|
d73601fe45 | ||
|
|
99f2ef8871 | ||
|
|
161359e151 |
21
crates/sylpheed-formats/data/ship_placements.txt
Normal file
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
|
||||||
9
crates/sylpheed-formats/examples/arsenal.rs
Normal file
9
crates/sylpheed-formats/examples/arsenal.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use sylpheed_formats::{game_data as gd, PakArchive};
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let pak=PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||||||
|
let a=gd::load_arsenal(&pak);
|
||||||
|
for (hp,list) in [("NOSE",&a.nose),("ARM1",&a.arm1),("ARM2",&a.arm2),("ARM3",&a.arm3)]{
|
||||||
|
println!("{hp} ({}): {:?}", list.len(), list.iter().take(8).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
}
|
||||||
19
crates/sylpheed-formats/examples/battle.rs
Normal file
19
crates/sylpheed-formats/examples/battle.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use sylpheed_formats::{game_data as gd, PakArchive};
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
let units=gd::load_units(&pak); let vessels=gd::load_vessels(&pak);
|
||||||
|
let hp=|id:&str|->String{
|
||||||
|
units.iter().find(|u|u.id.as_deref()==Some(id)).and_then(|u|u.hp).map(|h|format!("{h:.0}hp"))
|
||||||
|
.or_else(||vessels.iter().find(|v|v.id.as_deref()==Some(id)).and_then(|v|v.hp).map(|h|format!("{h:.0}HP")))
|
||||||
|
.unwrap_or("·".into())
|
||||||
|
};
|
||||||
|
let rosters=gd::load_unit_rosters(&pak);
|
||||||
|
for r in rosters.iter().filter(|r|r.stage.is_some()).take(4){
|
||||||
|
println!("\n{} — {} combatants:", r.stage.as_deref().unwrap(), r.units.len());
|
||||||
|
for u in r.units.iter().filter(|u|u.contains("ADAN")).take(6){
|
||||||
|
let short=u.trim_start_matches("UN_").split('_').skip(1).collect::<Vec<_>>().join("_");
|
||||||
|
println!(" {short:32} {}", hp(u));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
crates/sylpheed-formats/examples/campaign.rs
Normal file
24
crates/sylpheed-formats/examples/campaign.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
let text=TextIndex::build(&pak);
|
||||||
|
let mut stages=game_data::load_stages(&pak);
|
||||||
|
stages.retain(|s|s.id.starts_with('S') && s.id.len()==3 && s.id[1..].parse::<u32>().map(|n|n<=16).unwrap_or(false));
|
||||||
|
stages.sort_by(|a,b|a.id.cmp(&b.id));
|
||||||
|
println!("═══ CAMPAIGN (S01–S16) ═══");
|
||||||
|
for s in &stages{
|
||||||
|
let obj=text.objectives(&s.id,1);
|
||||||
|
println!("\n{} · {}", s.id, s.location.as_deref().unwrap_or("?"));
|
||||||
|
for o in obj.iter().take(2){ println!(" ▸ {o}"); }
|
||||||
|
}
|
||||||
|
// roster with real names
|
||||||
|
let mut chars=game_data::load_characters(&pak);
|
||||||
|
chars.retain(|c|c.faction.as_deref()==Some("TCAF") && c.unique==Some(true) && c.faces.len()>=4);
|
||||||
|
println!("\n═══ PRINCIPAL CAST (TCAF, named) ═══");
|
||||||
|
for c in &chars{
|
||||||
|
let id=c.id.as_deref().unwrap_or("");
|
||||||
|
let name=text.character_name(id.trim_start_matches("Character")).or_else(||c.name_key.as_deref().and_then(|k|text.get(k))).unwrap_or("?");
|
||||||
|
println!(" {name:12} ({} portraits) [{}]", c.faces.len(), id.trim_start_matches("Character"));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
crates/sylpheed-formats/examples/capture_match.rs
Normal file
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
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
crates/sylpheed-formats/examples/correlate_capture.rs
Normal file
136
crates/sylpheed-formats/examples/correlate_capture.rs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
//! Recover exact capital-ship part placement from a Canary capture log
|
||||||
|
//! and (optionally) emit a checked-in placement-table block.
|
||||||
|
//!
|
||||||
|
//! The correlation math lives in [`sylpheed_formats::ship_capture`]; this example
|
||||||
|
//! is the CLI wrapper: it reads the log, decodes the ship's parts from the disc to
|
||||||
|
//! get their vertex counts + leading positions (the match keys), correlates, and
|
||||||
|
//! prints the result. With `--emit` it prints the `ship …` block to paste into
|
||||||
|
//! `crates/sylpheed-formats/data/ship_placements.txt`.
|
||||||
|
//!
|
||||||
|
//! Matching is **LOD-aware**: a ship on screen at distance is drawn with its
|
||||||
|
//! `_m`/`_l` LOD copies, which live in the same local frame as the base part — so
|
||||||
|
//! each base part tries its own vcount first, then its LOD variants', and the
|
||||||
|
//! recovered transform is recorded under the base name. Same-vcount twins
|
||||||
|
//! (mirrored port/starboard hulls) are routed by the draw's position dump.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
|
||||||
|
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
|
||||||
|
//! 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::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 std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
|
||||||
|
let emit = args.iter().any(|a| a == "--emit");
|
||||||
|
if positional.len() < 3 {
|
||||||
|
eprintln!(
|
||||||
|
"usage: correlate_capture <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]"
|
||||||
|
);
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
let (log, stage, id) = (positional[0], positional[1], positional[2]);
|
||||||
|
let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_04");
|
||||||
|
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
|
||||||
|
|
||||||
|
// 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 rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut r = open_iso(Path::new(&iso)).await.unwrap();
|
||||||
|
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
// Candidate resources per base part: itself + `_m`/`_l`/`_d` LOD copies.
|
||||||
|
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
|
||||||
|
for p in &base_parts {
|
||||||
|
for suf in ["_m", "_l", "_d"] {
|
||||||
|
let cand = format!("{p}{suf}");
|
||||||
|
if names.contains(&cand) {
|
||||||
|
want.insert(cand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
|
||||||
|
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
59
crates/sylpheed-formats/examples/deep_map.rs
Normal file
59
crates/sylpheed-formats/examples/deep_map.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use sylpheed_formats::{ratc, idxd::IdxdObject, PakArchive};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
// 1. RATC child-type census across the big RATC paks
|
||||||
|
let mut childtypes:BTreeMap<String,u64>=BTreeMap::new();
|
||||||
|
let mut ratc_unparsed=0u64;
|
||||||
|
for pk in ["GP_READY_ROOM","GP_MOVIE_THEATER","GP_DIALOG","GP_DEBRIEFING_PILOTLOG","GP_TITLE","GP_BUNK"]{
|
||||||
|
let Ok(arc)=PakArchive::open(format!("{disc}/dat/{pk}.pak")) else{continue};
|
||||||
|
for e in arc.entries(){
|
||||||
|
let Ok(b)=arc.read(e) else{continue};
|
||||||
|
if !ratc::is_ratc(&b){continue;}
|
||||||
|
match ratc::parse(&b){
|
||||||
|
Some(kids)=> for k in kids{
|
||||||
|
let ext=k.name.rsplit('.').next().unwrap_or("?").to_lowercase();
|
||||||
|
*childtypes.entry(ext).or_default()+=1;
|
||||||
|
},
|
||||||
|
None=>ratc_unparsed+=1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("=== RATC child-type census (big RATC paks) ===");
|
||||||
|
let mut cv:Vec<_>=childtypes.into_iter().collect(); cv.sort_by_key(|x|std::cmp::Reverse(x.1));
|
||||||
|
for (e,c) in &cv{ println!(" .{e:8} ×{c}"); }
|
||||||
|
println!(" (RATC bundles that failed to parse: {ratc_unparsed})");
|
||||||
|
|
||||||
|
// 2. The 00000002 mystery format (GP_MAIN_GAME_E)
|
||||||
|
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
for e in arc.entries(){
|
||||||
|
let Ok(b)=arc.read(e) else{continue};
|
||||||
|
if b.len()>=4 && b[0..4]==[0,0,0,2]{
|
||||||
|
let hx:String=b[..48.min(b.len())].iter().map(|x|format!("{x:02x}")).collect::<Vec<_>>().join(" ");
|
||||||
|
let asc:String=b[..64.min(b.len())].iter().map(|&x|if(0x20..0x7f).contains(&x){x as char}else{'.'}).collect();
|
||||||
|
println!("\n=== 00000002 format sample ({}B) ===\n{hx}\n{asc}",b.len());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Sample the top undecoded IDXD schemas: first tokens (guess semantics)
|
||||||
|
println!("\n=== top undecoded IDXD schemas — sample tokens (semantic hints) ===");
|
||||||
|
let targets:[u32;6]=[0xb412e6d8,0x026379ab,0x43faa517,0x3c5b0549,0x6ab4825a,0x0426e81d];
|
||||||
|
for want in targets{
|
||||||
|
let mut shown=false;
|
||||||
|
for e in arc.entries(){
|
||||||
|
if shown{break;}
|
||||||
|
let Ok(b)=arc.read(e) else{continue};
|
||||||
|
if b.len()<12 || &b[0..4]!=b"IDXD"{continue;}
|
||||||
|
let s=u32::from_be_bytes([b[8],b[9],b[10],b[11]]);
|
||||||
|
if s!=want{continue;}
|
||||||
|
if let Ok(o)=IdxdObject::parse(&b){
|
||||||
|
let t=o.tokens();
|
||||||
|
let sample:Vec<String>=t.iter().take(14).map(|s|{let s=s.chars().take(18).collect::<String>();s}).collect();
|
||||||
|
println!(" {want:08x}: {sample:?}");
|
||||||
|
shown=true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !shown{ println!(" {want:08x}: (parse failed / not found)"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/sylpheed-formats/examples/dialogue.rs
Normal file
13
crates/sylpheed-formats/examples/dialogue.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
let text=TextIndex::build(&pak);
|
||||||
|
let msgs=game_data::load_demo_messages(&pak);
|
||||||
|
println!("{} dialogue lines total\n", msgs.len());
|
||||||
|
for m in msgs.iter().filter(|m|m.character.is_some()&&!m.page_keys.is_empty()).take(8){
|
||||||
|
let who=m.character.as_deref().unwrap_or("?").trim_start_matches("Character");
|
||||||
|
let line:String=m.page_keys.iter().filter_map(|k|text.get(k)).collect::<Vec<_>>().join(" ");
|
||||||
|
println!(" {who:10} [{}] “{}”", m.voice_clip.as_deref().unwrap_or("-"), line.chars().take(64).collect::<String>());
|
||||||
|
}
|
||||||
|
}
|
||||||
29
crates/sylpheed-formats/examples/dm_extract.rs
Normal file
29
crates/sylpheed-formats/examples/dm_extract.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
let tables:[(u32,&str);4]=[(0x0426e81d,"Player / physics+scoring"),(0x6ab4825a,"Weapon"),(0x43faa517,"Unit / craft"),(0x3c5b0549,"Vessel / capital ship")];
|
||||||
|
for (want,label) in tables{
|
||||||
|
// collect all records of this schema
|
||||||
|
let mut recs:Vec<IdxdObject>=vec![];
|
||||||
|
for e in arc.entries(){ let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue}; if o.schema_hash==want{recs.push(o);} }
|
||||||
|
// field-union (explicit-valued keys), with occurrence count
|
||||||
|
let mut cols:BTreeMap<String,u32>=BTreeMap::new();
|
||||||
|
for o in &recs{ for (k,_) in o.resolved_fields(){ *cols.entry(k.into()).or_default()+=1; } }
|
||||||
|
println!("\n╔══ {label} [{want:08x}] {} records ══", recs.len());
|
||||||
|
let mut cv:Vec<_>=cols.into_iter().collect(); cv.sort_by_key(|x|std::cmp::Reverse(x.1));
|
||||||
|
let colstr:String=cv.iter().take(28).map(|(k,c)|format!("{k}({c})")).collect::<Vec<_>>().join(" ");
|
||||||
|
println!("║ numeric/enum fields: {colstr}");
|
||||||
|
// dump 2 sample records: identity + explicit fields
|
||||||
|
for o in recs.iter().take(2){
|
||||||
|
let id=o.get_raw("ID").unwrap_or("?");
|
||||||
|
let name=o.get_raw("Name").unwrap_or("");
|
||||||
|
print!("║ • {id}");
|
||||||
|
if !name.is_empty(){print!(" «{name}»");}
|
||||||
|
println!();
|
||||||
|
let fields:Vec<String>=o.resolved_fields().iter().map(|(k,v)|format!("{k}={v}")).collect();
|
||||||
|
for chunk in fields.chunks(5){ println!("║ {}", chunk.join(" ")); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/sylpheed-formats/examples/dm_roster.rs
Normal file
11
crates/sylpheed-formats/examples/dm_roster.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use sylpheed_formats::{game_data, PakArchive};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
let chars=game_data::load_characters(&pak);
|
||||||
|
let mut byfac:BTreeMap<String,Vec<String>>=Default::default();
|
||||||
|
for c in &chars{ byfac.entry(c.faction.clone().unwrap_or("·".into())).or_default().push(format!("{}({})",c.id.clone().unwrap_or_default().trim_start_matches("Character"),c.faces.len())); }
|
||||||
|
println!("{} characters across {} factions:",chars.len(),byfac.len());
|
||||||
|
for (f,mut v) in byfac{ v.sort(); println!(" [{f}] {}", v.join(" ")); }
|
||||||
|
}
|
||||||
14
crates/sylpheed-formats/examples/dm_rows.rs
Normal file
14
crates/sylpheed-formats/examples/dm_rows.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
|
||||||
|
fn short(s:&str)->String{ s.trim_start_matches("Weapon_").trim_start_matches("UN_").trim_start_matches("UnitName_UN_").into() }
|
||||||
|
fn g<'a>(o:&'a IdxdObject,k:&str)->String{ o.get_f32(k).map(|v|{if v==v.trunc(){format!("{}",v as i64)}else{format!("{v}")}}).or_else(||o.get_raw(k).filter(|x|x.chars().next().map(|c|c.is_ascii_digit()).unwrap_or(false)).map(|s|s.to_string())).unwrap_or("·".into()) }
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
let recs=|want:u32|->Vec<IdxdObject>{ arc.entries().iter().filter_map(|e|arc.read(e).ok()).filter_map(|b|IdxdObject::parse(&b).ok()).filter(|o|o.schema_hash==want).collect() };
|
||||||
|
println!("### WEAPONS (name | target | load | int | power | vel | range | trig)");
|
||||||
|
for o in recs(0x6ab4825a).iter().take(16){ println!("{:<34}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), o.get_raw("TargetType").unwrap_or("·"), g(o,"LoadingCount"),g(o,"Interval"),g(o,"Power"),g(o,"Velocity"),g(o,"MaximumRange"),g(o,"TriggerShotCount")); }
|
||||||
|
println!("\n### UNITS/CRAFT (name | HP | cruise | accel | radar | turrets | score)");
|
||||||
|
for o in recs(0x43faa517).iter().take(16){ println!("{:<38}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), g(o,"HP"),g(o,"CruisingVelocity"),g(o,"Acceleration"),g(o,"RadarRange"),g(o,"TurretCount"),g(o,"ScorePoint")); }
|
||||||
|
println!("\n### VESSELS/CAPITAL SHIPS (name | HP | Sz_X | Sz_Z | radar | turrets | bridges | hatches | shieldgen | thrusters)");
|
||||||
|
for o in recs(0x3c5b0549).iter(){ println!("{:<30}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), g(o,"HP"),g(o,"Size_X"),g(o,"Size_Z"),g(o,"RadarRange"),g(o,"TurretCount"),g(o,"BridgeCount"),g(o,"HatchCount"),g(o,"ShieldGeneratorCount"),g(o,"ThrusterCount")); }
|
||||||
|
}
|
||||||
15
crates/sylpheed-formats/examples/dossier.rs
Normal file
15
crates/sylpheed-formats/examples/dossier.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
let text=TextIndex::build(&pak);
|
||||||
|
for n in 1..=16u32{
|
||||||
|
let sid=format!("S{n:02}");
|
||||||
|
// primary objective across phases (first that resolves)
|
||||||
|
let obj:Vec<String>=(1..=3).flat_map(|p|text.objectives(&sid,p)).map(|s|s.to_string()).collect();
|
||||||
|
let lose:Vec<String>=(1..=3).flat_map(|p|text.lose_conditions(&sid,p)).map(|s|s.to_string()).collect();
|
||||||
|
let full_obj=obj.join(" ");
|
||||||
|
let full_lose=lose.into_iter().take(2).collect::<Vec<_>>().join(" ");
|
||||||
|
println!("{sid}\t{full_obj}\t{full_lose}");
|
||||||
|
}
|
||||||
|
}
|
||||||
17
crates/sylpheed-formats/examples/flights.rs
Normal file
17
crates/sylpheed-formats/examples/flights.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use sylpheed_formats::{game_data as gd, PakArchive};
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let pak=PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||||||
|
let rosters=gd::load_pilot_rosters(&pak);
|
||||||
|
// distinct rosters by their pilot set
|
||||||
|
let mut seen=std::collections::BTreeSet::new(); let mut shown=0;
|
||||||
|
println!("{} pilot-roster configs; distinct line-ups:", rosters.len());
|
||||||
|
for r in &rosters{
|
||||||
|
let key:String=r.pilots.iter().map(|(c,p)|format!("{c}:{p}")).collect::<Vec<_>>().join(",");
|
||||||
|
if seen.insert(key) && shown<8 {
|
||||||
|
shown+=1;
|
||||||
|
let flt:Vec<String>=r.pilots.iter().map(|(c,p)|format!("{c}={p}")).collect();
|
||||||
|
println!(" {}", flt.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
crates/sylpheed-formats/examples/hokyu_demo_resolve.rs
Normal file
43
crates/sylpheed-formats/examples/hokyu_demo_resolve.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use sylpheed_formats::{hash::name_hash, movie_manifest, movie_subtitle as ms, movie_voice, slb, PakArchive};
|
||||||
|
use std::fs;use std::io::{Read,Seek,SeekFrom};use std::process::Command;
|
||||||
|
fn rg(disc:&str,g:u64,n:usize)->Vec<u8>{let mut segs=vec![];let mut cum=0u64;for i in 0..5{let p=format!("{disc}/dat/sound.p{i:02}");if let Ok(m)=fs::metadata(&p){segs.push((cum,m.len(),p));cum+=m.len();}}let mut out=vec![];let(mut need,mut pos)=(n,g);for(base,len,path)in &segs{if need==0||pos>=base+len||pos<*base{continue;}let local=pos-base;let take=need.min((len-local)as usize);let mut f=fs::File::open(path).unwrap();f.seek(SeekFrom::Start(local)).unwrap();let mut b=vec![0u8;take];f.read_exact(&mut b).unwrap();out.extend_from_slice(&b);need-=take;pos+=take as u64;}out}
|
||||||
|
fn ct(h:&[u8],n:&[u8])->bool{h.windows(n.len()).any(|w|w==n)}
|
||||||
|
fn dur(w:&str)->String{let o=Command::new("ffprobe").args(["-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",w]).output().unwrap();String::from_utf8_lossy(&o.stdout).trim().to_string()}
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let tpak=PakArchive::open(format!("{disc}/dat/tables.pak")).unwrap();
|
||||||
|
let man=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|movie_manifest::is_manifest(b))).unwrap();
|
||||||
|
let lpak=PakArchive::open(format!("{disc}/dat/movie/eng.pak")).unwrap();
|
||||||
|
let reg=tpak.entries().iter().find_map(|e|tpak.read(e).ok().filter(|b|ct(b,b"eng\\Movie\\VOICE_ADV.slb"))).unwrap();
|
||||||
|
let ids=movie_voice::registry_voice_ids(®);
|
||||||
|
let stoc=fs::read(format!("{disc}/dat/sound.pak")).unwrap();
|
||||||
|
let ents=PakArchive::parse_toc(&stoc).unwrap();
|
||||||
|
// demo->token from bound hokyu
|
||||||
|
let hok:Vec<_>=movie_manifest::parse(&man).into_iter().filter(|m|m.movie.starts_with("hokyu_")).collect();
|
||||||
|
let resolve=|movie:&str|->Option<String>{
|
||||||
|
movie_manifest::voice_token(&man,movie).or_else(||{
|
||||||
|
let want=ms::track_voice_cues(&lpak,movie).first().map(|&(d,_)|d)?;
|
||||||
|
hok.iter().find_map(|e|{let t=e.voice_token.clone().filter(|_|e.movie.starts_with("hokyu_"))?; ms::track_voice_cues(&lpak,&e.movie).iter().any(|&(d,_)|d==want).then_some(t)})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
for e in &hok{
|
||||||
|
let mv=&e.movie;
|
||||||
|
let bound=e.voice_token.is_some();
|
||||||
|
let tok=resolve(mv);
|
||||||
|
let demo=ms::track_voice_cues(&lpak,mv).first().map(|&(d,_)|d);
|
||||||
|
let mut d="—".to_string();
|
||||||
|
if let Some(tok)=&tok{
|
||||||
|
if let Some(&id)=ids.get(tok){
|
||||||
|
if let Some(anchor)=["Movie","etc","Voice"].iter().find_map(|dir|{let h=name_hash(&format!("eng\\{dir}\\{tok}.slb"));ents.binary_search_by_key(&h,|x|x.name_hash).ok().map(|i|ents[i].offset as u64)}){
|
||||||
|
let ws=(anchor.saturating_sub(2*1024*1024))&!3; let win=rg(&disc,ws,8*1024*1024);
|
||||||
|
if let Some(el)=movie_voice::find_descriptor(&win,id){let end=ws+el as u64;
|
||||||
|
let start=movie_voice::find_descriptor(&win,id.wrapping_sub(1)).or_else(||movie_voice::find_descriptor_before(&win,el)).map(|o|ws+o as u64).filter(|&s|s<end&&end-s<1_500_000).unwrap_or(anchor);
|
||||||
|
let region=rg(&disc,start,(end-start)as usize); let mut rf=slb::to_xma_riffs(®ion); if rf.is_empty(){rf=slb::to_xma_riff_best(®ion).into_iter().collect();}
|
||||||
|
if let Some(r)=rf.first(){let xp=format!("/tmp/hd_{mv}.xma.wav");let wp=format!("/tmp/hd_{mv}.wav");fs::write(&xp,r).unwrap();let _=Command::new("ffmpeg").args(["-hide_banner","-v","error","-y","-i",&xp,&wp]).status();d=dur(&wp);}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{mv:16} {:8} demo={:?} -> {:11} voice={d}s", if bound{"BOUND"}else{"unbound"}, demo, tok.unwrap_or("(silent)".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
50
crates/sylpheed-formats/examples/iso_map.rs
Normal file
50
crates/sylpheed-formats/examples/iso_map.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use sylpheed_formats::PakArchive;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
fn be32(b:&[u8],o:usize)->u32{ if o+4<=b.len(){u32::from_be_bytes([b[o],b[o+1],b[o+2],b[o+3]])}else{0} }
|
||||||
|
fn magic(b:&[u8])->String{
|
||||||
|
if b.len()<4 {return "(<4B)".into();}
|
||||||
|
let m=&b[0..4];
|
||||||
|
if m.iter().all(|&c|(0x20..0x7f).contains(&c)){ String::from_utf8_lossy(m).into() }
|
||||||
|
else if m==b"\x89PNG"{"PNG".into()} else if m==[0,1,0,0]{"ttf".into()}
|
||||||
|
else { format!("{:02x}{:02x}{:02x}{:02x}",m[0],m[1],m[2],m[3]) }
|
||||||
|
}
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
// Known/understood IDXD schemas (semantic parsers we have)
|
||||||
|
let known_schema:BTreeMap<u32,&str>=[(0x067025b9,"ADVERTISE_MOVIE (movie_manifest)"),(0x13cb84ba,"sound registry / sounds.tbl")].into();
|
||||||
|
let mut fmt_count:BTreeMap<String,(u64,u64)>=BTreeMap::new(); // fmt -> (count, bytes)
|
||||||
|
let mut schema_census:BTreeMap<u32,(u64,u64,String)>=BTreeMap::new(); // schema -> (count,bytes,sample pak)
|
||||||
|
let mut unknown_magics:BTreeMap<String,(u64,Vec<String>)>=BTreeMap::new();
|
||||||
|
let paks:Vec<String>={let mut v=vec![]; for e in std::fs::read_dir(format!("{disc}/dat")).unwrap(){let p=e.unwrap().path(); if p.extension().map(|x|x=="pak").unwrap_or(false){v.push(p.file_stem().unwrap().to_string_lossy().into());}} v.sort(); v};
|
||||||
|
println!("pak entries decompressed formats");
|
||||||
|
for pk in &paks{
|
||||||
|
let Ok(arc)=PakArchive::open(format!("{disc}/dat/{pk}.pak")) else{continue};
|
||||||
|
let mut per:BTreeMap<String,u64>=BTreeMap::new();
|
||||||
|
let mut tot=0u64; let n=arc.entries().len();
|
||||||
|
for e in arc.entries(){
|
||||||
|
let Ok(b)=arc.read(e) else{continue};
|
||||||
|
tot+=b.len() as u64;
|
||||||
|
let mut f=magic(&b);
|
||||||
|
if f=="IDXD"{ let s=be32(&b,8); schema_census.entry(s).or_insert((0,0,pk.clone())).0+=1; schema_census.get_mut(&s).unwrap().1+=b.len() as u64; f=format!("IDXD:{s:08x}"); }
|
||||||
|
else if !["XPR2","IXUD","LSTA","RATC","RIFF","XBG7","OTTO","PNG","ttf","DDS ","T8AD"].contains(&f.as_str()){
|
||||||
|
let ent=unknown_magics.entry(f.clone()).or_insert((0,vec![])); ent.0+=1; if ent.1.len()<3 && !ent.1.contains(pk){ent.1.push(pk.clone());}
|
||||||
|
}
|
||||||
|
*per.entry(if f.starts_with("IDXD:"){"IDXD".into()}else{f.clone()}).or_default()+=1;
|
||||||
|
let g=fmt_count.entry(if f.starts_with("IDXD:"){"IDXD".into()}else{f}).or_insert((0,0)); g.0+=1; g.1+=b.len() as u64;
|
||||||
|
}
|
||||||
|
let mut fs:Vec<_>=per.into_iter().collect(); fs.sort_by_key(|x|std::cmp::Reverse(x.1));
|
||||||
|
let top:String=fs.iter().take(4).map(|(k,v)|format!("{k}×{v}")).collect::<Vec<_>>().join(" ");
|
||||||
|
println!("{pk:26} {n:>7} {:>10}KB {top}", tot/1024);
|
||||||
|
}
|
||||||
|
println!("\n=== FORMAT TOTALS (across all paks) ===");
|
||||||
|
let mut fv:Vec<_>=fmt_count.into_iter().collect(); fv.sort_by_key(|x|std::cmp::Reverse(x.1.1));
|
||||||
|
for (f,(c,b)) in &fv{ println!(" {f:12} {c:>6} entries {:>8}KB", b/1024); }
|
||||||
|
println!("\n=== IDXD SCHEMA CENSUS ({} distinct schemas) ===", schema_census.len());
|
||||||
|
let mut sv:Vec<_>=schema_census.into_iter().collect(); sv.sort_by_key(|x|std::cmp::Reverse(x.1.1));
|
||||||
|
for (s,(c,b,pk)) in &sv{
|
||||||
|
let tag=known_schema.get(s).copied().unwrap_or("??? UNDECODED");
|
||||||
|
println!(" {s:08x} {c:>5} ent {:>7}KB e.g. {pk:22} {tag}", b/1024);
|
||||||
|
}
|
||||||
|
println!("\n=== UNKNOWN / UNCLASSIFIED MAGICS ===");
|
||||||
|
for (m,(c,pks)) in &unknown_magics{ println!(" {m:12} ×{c:<5} in {pks:?}"); }
|
||||||
|
}
|
||||||
47
crates/sylpheed-formats/examples/mission_map.rs
Normal file
47
crates/sylpheed-formats/examples/mission_map.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
// 1. All stages: BackGroundID + phases + stage tag (from EnumUnit_SNN)
|
||||||
|
println!("=== STAGES (StageResource 3c9ae32e) ===");
|
||||||
|
let mut rows=vec![];
|
||||||
|
for e in arc.entries(){
|
||||||
|
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
|
||||||
|
if o.schema_hash!=0x3c9ae32e {continue;}
|
||||||
|
let t=o.tokens();
|
||||||
|
let bg=o.get_raw("BackGroundID").unwrap_or("?").to_string();
|
||||||
|
let stage=t.iter().find_map(|s|s.strip_prefix("EnumUnit_").map(|x|x.trim_end_matches(".tbl").to_string())).unwrap_or("?".into());
|
||||||
|
let phases=t.iter().filter(|s|s.starts_with("Phase_")).count();
|
||||||
|
let unitgrp=t.iter().any(|s|s.starts_with("UnitGroup_"));
|
||||||
|
let route=t.iter().any(|s|s.starts_with("Route_"));
|
||||||
|
let formation=t.iter().any(|s|s.starts_with("FormationSet_"));
|
||||||
|
rows.push((stage,bg,phases,unitgrp,route,formation));
|
||||||
|
}
|
||||||
|
rows.sort();
|
||||||
|
for (s,bg,p,ug,rt,fm) in &rows{ println!(" {s:8} location={bg:14} phases={p} [squadrons:{} route:{} formations:{}]", if *ug{"Y"}else{"·"},if *rt{"Y"}else{"·"},if *fm{"Y"}else{"·"}); }
|
||||||
|
|
||||||
|
// 2. Parse objectives table into (stage,phase) -> key counts
|
||||||
|
println!("\n=== OBJECTIVES (033b5b7e) — per stage/phase key groups ===");
|
||||||
|
for e in arc.entries(){
|
||||||
|
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
|
||||||
|
if o.schema_hash!=0x033b5b7e {continue;}
|
||||||
|
let t=o.tokens();
|
||||||
|
let mut groups:std::collections::BTreeMap<String,u32>=Default::default();
|
||||||
|
for tok in t{ if let Some(p)=tok.find("_Objective_").or_else(||tok.find("_Lose_")).or_else(||tok.find("_Hint_")){ let key=&tok[..p]; *groups.entry(key.trim_start_matches(|c:char|!c.is_ascii_alphabetic()).into()).or_default()+=1; } }
|
||||||
|
let stages:std::collections::BTreeSet<String>=groups.keys().filter_map(|k|k.get(..3).map(|s|s.to_string())).collect();
|
||||||
|
println!(" covers {} stages: {:?}", stages.len(), stages);
|
||||||
|
println!(" sample S01_P1 group counts: {:?}", groups.iter().filter(|(k,_)|k.starts_with("S01_P1")).collect::<Vec<_>>());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Resolve objective TEXT: search all pak entries for the key "S01_P1_Objective_00" as an ID with English text
|
||||||
|
println!("\n=== OBJECTIVE TEXT resolution probe ===");
|
||||||
|
for e in arc.entries(){
|
||||||
|
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
|
||||||
|
let t=o.tokens();
|
||||||
|
if let Some(i)=t.iter().position(|s|s=="S01_P1_Objective_00"){
|
||||||
|
let lo=i.saturating_sub(1); let hi=(i+3).min(t.len());
|
||||||
|
println!(" schema {:08x}: ...{:?}...", o.schema_hash, &t[lo..hi]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
crates/sylpheed-formats/examples/node_dump.rs
Normal file
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
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
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
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
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
33
crates/sylpheed-formats/examples/spawn_survey.rs
Normal file
33
crates/sylpheed-formats/examples/spawn_survey.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use sylpheed_formats::{idxd::IdxdObject, PakArchive};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
// schemas whose type0/content is squadron/formation/route/group/position
|
||||||
|
let mut hit:BTreeMap<u32,(u32,String,String)>=BTreeMap::new();
|
||||||
|
for e in arc.entries(){
|
||||||
|
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
|
||||||
|
let t=o.tokens(); let t0=t.first().cloned().unwrap_or_default();
|
||||||
|
let joined:String=t.iter().take(20).map(|s|s.as_str()).collect::<Vec<_>>().join(" ");
|
||||||
|
if ["Squadron","Formation","Route","UnitGroup","Position","Spawn","Wave","Frame","NullFrame"].iter().any(|k|t0.contains(k)||joined.contains(k)){
|
||||||
|
let ent=hit.entry(o.schema_hash).or_insert((0,t0.clone(),joined.chars().take(90).collect()));
|
||||||
|
ent.0+=1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut v:Vec<_>=hit.into_iter().collect(); v.sort_by_key(|x|std::cmp::Reverse(x.1.0));
|
||||||
|
for (s,(c,t0,sample)) in &v{ println!("[{s:08x}] ×{c:<4} type0={t0:<18.18} :: {sample}"); }
|
||||||
|
// dump a Squadron/UnitGroup blob fully to see if it has positions (binary floats) or refs
|
||||||
|
println!("\n─── sample squadron/formation blob (first matching) ───");
|
||||||
|
for e in arc.entries(){
|
||||||
|
let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue};
|
||||||
|
let t=o.tokens(); let t0=t.first().cloned().unwrap_or_default();
|
||||||
|
if t0.contains("Squadron")||t0.contains("Formation")||t0.contains("UnitGroup"){
|
||||||
|
println!("schema {:08x} type0={t0} count={} tokens={}, blob {}B", o.schema_hash,o.count,t.len(),b.len());
|
||||||
|
for (i,tk) in t.iter().take(30).enumerate(){ print!("{i:>2}:{tk:<20.20}"); if i%4==3{println!();} } println!();
|
||||||
|
// scan blob for float-like values (reasonable coords) in the binary region
|
||||||
|
let nfloats=(0..b.len().saturating_sub(4)).step_by(4).filter(|&i|{let f=f32::from_be_bytes([b[i],b[i+1],b[i+2],b[i+3]]); f.is_finite()&&f.abs()>1.0&&f.abs()<1e6}).count();
|
||||||
|
println!(" ~{nfloats} plausible BE-float words in blob (positions live in binary region if high)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/sylpheed-formats/examples/squadrons.rs
Normal file
11
crates/sylpheed-formats/examples/squadrons.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use sylpheed_formats::{game_data, PakArchive};
|
||||||
|
fn main(){
|
||||||
|
let disc=std::env::var("SYLPHEED_DISC").unwrap();
|
||||||
|
let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
let sq=game_data::load_squadrons(&pak);
|
||||||
|
println!("{} squadron definitions", sq.len());
|
||||||
|
for s in sq.iter().filter(|s|s.side.as_deref()==Some("TCAF")).take(6){
|
||||||
|
let mem:Vec<String>=s.members.iter().map(|m|m.trim_start_matches("UN_").chars().take(18).collect()).collect();
|
||||||
|
println!(" {:8} {:22} {:24} x{} {:?}", s.id.clone().unwrap_or("?".into()), s.formation_id.clone().unwrap_or_default(), s.ai_id.clone().unwrap_or_default(), s.count.unwrap_or(0), mem);
|
||||||
|
}
|
||||||
|
}
|
||||||
980
crates/sylpheed-formats/src/game_data.rs
Normal file
980
crates/sylpheed-formats/src/game_data.rs
Normal file
@@ -0,0 +1,980 @@
|
|||||||
|
//! Typed loaders for Project Sylpheed's combat data tables.
|
||||||
|
//!
|
||||||
|
//! The game keeps its balance data in reflective [`crate::idxd`] tables — one
|
||||||
|
//! blob per entity, `token[0]` naming the table, fields laid out value-before-key.
|
||||||
|
//! This module maps the four combat schemas to plain, cloneable structs: the
|
||||||
|
//! common stats as named `Option<…>` fields, and **every** explicitly-set field in
|
||||||
|
//! a [`fields`](Weapon::fields) map so nothing is lost.
|
||||||
|
//!
|
||||||
|
//! Reverse-engineered 2026-07-23 from `GP_MAIN_GAME_E.pak` (English; the D/F/I/J/S
|
||||||
|
//! paks are localized duplicates). Fields left at their default value omit their
|
||||||
|
//! value on disc — those live in title code, not here — so a `None` means "not set
|
||||||
|
//! on disc", never a guess.
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use sylpheed_formats::{game_data, PakArchive};
|
||||||
|
//! let pak = PakArchive::open("dat/GP_MAIN_GAME_E.pak").unwrap();
|
||||||
|
//! for w in game_data::load_weapons(&pak) {
|
||||||
|
//! println!("{} — power {:?}, range {:?}", w.id.unwrap_or_default(), w.power, w.max_range);
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::idxd::IdxdObject;
|
||||||
|
use crate::pak::PakArchive;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// IDXD schema ids of the combat tables (the `schema_hash` field of each blob).
|
||||||
|
pub mod schema {
|
||||||
|
/// Player craft config: physics, cameras, scoring, difficulty, render pipeline.
|
||||||
|
pub const PLAYER: u32 = 0x0426_e81d;
|
||||||
|
/// Weapon definitions (guns, missiles, beams, bombs).
|
||||||
|
pub const WEAPON: u32 = 0x6ab4_825a;
|
||||||
|
/// Craft / units — fighters, bombers, turrets, with their AI flight model.
|
||||||
|
pub const UNIT: u32 = 0x43fa_a517;
|
||||||
|
/// Vessels — capital ships, with structural component counts.
|
||||||
|
pub const VESSEL: u32 = 0x3c5b_0549;
|
||||||
|
/// Characters — pilots / crew / comms, with faction and portrait set.
|
||||||
|
pub const CHARACTER: u32 = 0xbd86_d41c;
|
||||||
|
/// Stage resource manifest — per mission: location, phases, resource tables.
|
||||||
|
pub const STAGE: u32 = 0x3c9a_e32e;
|
||||||
|
/// Message / demo dialogue — per line: speaker, portrait, voice clip, pages.
|
||||||
|
pub const MESSAGE: u32 = 0xb412_e6d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a numeric field value, tolerating a trailing `f`/`F` (e.g. `"3.0f"`).
|
||||||
|
fn as_f32(v: Option<&String>) -> Option<f32> {
|
||||||
|
let v = v?;
|
||||||
|
let v = v.strip_suffix(['f', 'F']).unwrap_or(v);
|
||||||
|
v.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_i64(v: Option<&String>) -> Option<i64> {
|
||||||
|
v?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect every explicitly-set field of an IDXD blob into a `key → value` map,
|
||||||
|
/// plus the identity strings. Identifier-valued fields (`Model`, `ShotType`, …)
|
||||||
|
/// that carry a bare name rather than a value are not in the map — read those from
|
||||||
|
/// [`IdxdObject::get_raw`] if needed.
|
||||||
|
fn field_map(o: &IdxdObject) -> BTreeMap<String, String> {
|
||||||
|
o.resolved_fields()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read every record of `schema` from a pak, mapping each with `build`.
|
||||||
|
fn load_table<T>(pak: &PakArchive, schema: u32, build: impl Fn(&IdxdObject) -> Option<T>) -> Vec<T> {
|
||||||
|
pak.entries()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| pak.read(e).ok())
|
||||||
|
.filter_map(|b| IdxdObject::parse(&b).ok())
|
||||||
|
.filter(|o| o.schema_hash == schema)
|
||||||
|
.filter_map(|o| build(&o))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Weapon ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A weapon definition (schema [`schema::WEAPON`]).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Weapon {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Target mask, e.g. `"Vessel,Craft,Structure"`.
|
||||||
|
pub target_type: Option<String>,
|
||||||
|
pub power: Option<f32>,
|
||||||
|
pub velocity: Option<f32>,
|
||||||
|
pub min_velocity: Option<f32>,
|
||||||
|
pub max_velocity: Option<f32>,
|
||||||
|
pub min_range: Option<f32>,
|
||||||
|
pub max_range: Option<f32>,
|
||||||
|
/// Ammo / reload budget (`LoadingCount`).
|
||||||
|
pub loading_count: Option<i64>,
|
||||||
|
/// Seconds between shots.
|
||||||
|
pub interval: Option<f32>,
|
||||||
|
/// Shots per trigger pull (volley / lock count).
|
||||||
|
pub trigger_shot_count: Option<i64>,
|
||||||
|
pub mass: Option<f32>,
|
||||||
|
pub heating: Option<f32>,
|
||||||
|
pub cooling: Option<f32>,
|
||||||
|
pub life_time: Option<f32>,
|
||||||
|
/// All explicitly-set fields (a superset of the named ones above).
|
||||||
|
pub fields: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Weapon {
|
||||||
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
||||||
|
let f = field_map(o);
|
||||||
|
Some(Weapon {
|
||||||
|
id: o.get_raw("ID").map(str::to_string),
|
||||||
|
name: o.get_raw("Name").map(str::to_string),
|
||||||
|
target_type: o.get_raw("TargetType").map(str::to_string),
|
||||||
|
power: as_f32(f.get("Power")),
|
||||||
|
velocity: as_f32(f.get("Velocity")),
|
||||||
|
min_velocity: as_f32(f.get("MinimumVelocity")),
|
||||||
|
max_velocity: as_f32(f.get("MaximumVelocity")),
|
||||||
|
min_range: as_f32(f.get("MinimumRange")),
|
||||||
|
max_range: as_f32(f.get("MaximumRange")),
|
||||||
|
loading_count: as_i64(f.get("LoadingCount")),
|
||||||
|
interval: as_f32(f.get("Interval")),
|
||||||
|
trigger_shot_count: as_i64(f.get("TriggerShotCount")),
|
||||||
|
mass: as_f32(f.get("Mass")),
|
||||||
|
heating: as_f32(f.get("Heating")),
|
||||||
|
cooling: as_f32(f.get("Cooling")),
|
||||||
|
life_time: as_f32(f.get("LifeTime")),
|
||||||
|
fields: f,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// Any explicit field parsed as `f32` (for the long-tail stats not named above).
|
||||||
|
pub fn field_f32(&self, key: &str) -> Option<f32> {
|
||||||
|
as_f32(self.fields.get(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every weapon from a pak. Catches all `Weapon`-shaped records, not just the
|
||||||
|
/// main [`schema::WEAPON`] — player special/missile weapons live in variant schemas
|
||||||
|
/// (e.g. `Weapon_DSaber_P_wep_*` in `GP_HANGAR_ARSENAL.pak`). Deduplicated by id.
|
||||||
|
pub fn load_weapons(pak: &PakArchive) -> Vec<Weapon> {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for e in pak.entries() {
|
||||||
|
let Ok(b) = pak.read(e) else { continue };
|
||||||
|
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||||
|
// `Weapon`/`QWeapon`/… but not the `EnumWeapon` name lists.
|
||||||
|
let t0 = o.tokens().first().map(String::as_str).unwrap_or("");
|
||||||
|
if !t0.ends_with("Weapon") || t0.contains("Enum") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(w) = Weapon::from_idxd(&o) {
|
||||||
|
if w.id.as_deref().is_some_and(|id| seen.insert(id.to_string())) {
|
||||||
|
out.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Craft / unit ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A craft / unit — fighter, bomber, turret (schema [`schema::UNIT`]). Carries the
|
||||||
|
/// AI flight model in [`fields`](CraftUnit::fields) (`AV_Pitch*`, `BarrelRoll_*`,
|
||||||
|
/// `TurnAttack_*`, …).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CraftUnit {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub hp: Option<f32>,
|
||||||
|
pub is_destructible: Option<bool>,
|
||||||
|
pub size_x: Option<f32>,
|
||||||
|
pub size_y: Option<f32>,
|
||||||
|
pub size_z: Option<f32>,
|
||||||
|
pub size_radius: Option<f32>,
|
||||||
|
pub radar_range: Option<f32>,
|
||||||
|
pub fcs_range: Option<f32>,
|
||||||
|
pub cruising_velocity: Option<f32>,
|
||||||
|
pub maximum_velocity: Option<f32>,
|
||||||
|
pub acceleration: Option<f32>,
|
||||||
|
pub deceleration: Option<f32>,
|
||||||
|
pub shield_ratio: Option<f32>,
|
||||||
|
pub turret_count: Option<i64>,
|
||||||
|
pub score_point: Option<i64>,
|
||||||
|
pub fields: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CraftUnit {
|
||||||
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
||||||
|
let f = field_map(o);
|
||||||
|
Some(CraftUnit {
|
||||||
|
id: o.get_raw("ID").map(str::to_string),
|
||||||
|
name: o.get_raw("Name").map(str::to_string),
|
||||||
|
hp: as_f32(f.get("HP")),
|
||||||
|
is_destructible: o.get_bool("IsDestructible"),
|
||||||
|
size_x: as_f32(f.get("Size_X")),
|
||||||
|
size_y: as_f32(f.get("Size_Y")),
|
||||||
|
size_z: as_f32(f.get("Size_Z")),
|
||||||
|
size_radius: as_f32(f.get("Size_Radius")),
|
||||||
|
radar_range: as_f32(f.get("RadarRange")),
|
||||||
|
fcs_range: as_f32(f.get("FCSRange")),
|
||||||
|
cruising_velocity: as_f32(f.get("CruisingVelocity")),
|
||||||
|
maximum_velocity: as_f32(f.get("MaximumVelocity")),
|
||||||
|
acceleration: as_f32(f.get("Acceleration")),
|
||||||
|
deceleration: as_f32(f.get("Deceleration")),
|
||||||
|
shield_ratio: as_f32(f.get("ShieldRatio")),
|
||||||
|
turret_count: as_i64(f.get("TurretCount")),
|
||||||
|
score_point: as_i64(f.get("ScorePoint")),
|
||||||
|
fields: f,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn field_f32(&self, key: &str) -> Option<f32> {
|
||||||
|
as_f32(self.fields.get(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every craft / unit from a pak.
|
||||||
|
pub fn load_units(pak: &PakArchive) -> Vec<CraftUnit> {
|
||||||
|
load_table(pak, schema::UNIT, CraftUnit::from_idxd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vessel / capital ship ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A capital ship (schema [`schema::VESSEL`]). Component counts drive the
|
||||||
|
/// destructible hardpoints.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Vessel {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// The 3D hull resource stem, e.g. `rou_e105` — the `eNNN`/`fNNN` id links to
|
||||||
|
/// the XBG7 part family (`e105_bdy_*`, `e105_brg_*`, …) inside a `Stage_SNN.xpr`.
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub hp: Option<f32>,
|
||||||
|
pub size_x: Option<f32>,
|
||||||
|
pub size_y: Option<f32>,
|
||||||
|
pub size_z: Option<f32>,
|
||||||
|
pub radar_range: Option<f32>,
|
||||||
|
pub fcs_range: Option<f32>,
|
||||||
|
pub maximum_velocity: Option<f32>,
|
||||||
|
pub shield_ratio: Option<f32>,
|
||||||
|
pub score_point: Option<i64>,
|
||||||
|
pub turret_count: Option<i64>,
|
||||||
|
pub bridge_count: Option<i64>,
|
||||||
|
pub hatch_count: Option<i64>,
|
||||||
|
pub shield_generator_count: Option<i64>,
|
||||||
|
pub thruster_count: Option<i64>,
|
||||||
|
pub fields: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vessel {
|
||||||
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
||||||
|
let f = field_map(o);
|
||||||
|
Some(Vessel {
|
||||||
|
id: o.get_raw("ID").map(str::to_string),
|
||||||
|
name: o.get_raw("Name").map(str::to_string),
|
||||||
|
model: o.get_raw("Model").map(str::to_string),
|
||||||
|
hp: as_f32(f.get("HP")),
|
||||||
|
size_x: as_f32(f.get("Size_X")),
|
||||||
|
size_y: as_f32(f.get("Size_Y")),
|
||||||
|
size_z: as_f32(f.get("Size_Z")),
|
||||||
|
radar_range: as_f32(f.get("RadarRange")),
|
||||||
|
fcs_range: as_f32(f.get("FCSRange")),
|
||||||
|
maximum_velocity: as_f32(f.get("MaximumVelocity")),
|
||||||
|
shield_ratio: as_f32(f.get("ShieldRatio")),
|
||||||
|
score_point: as_i64(f.get("ScorePoint")),
|
||||||
|
turret_count: as_i64(f.get("TurretCount")),
|
||||||
|
bridge_count: as_i64(f.get("BridgeCount")),
|
||||||
|
hatch_count: as_i64(f.get("HatchCount")),
|
||||||
|
shield_generator_count: as_i64(f.get("ShieldGeneratorCount")),
|
||||||
|
thruster_count: as_i64(f.get("ThrusterCount")),
|
||||||
|
fields: f,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn field_f32(&self, key: &str) -> Option<f32> {
|
||||||
|
as_f32(self.fields.get(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every capital ship from a pak.
|
||||||
|
pub fn load_vessels(pak: &PakArchive) -> Vec<Vessel> {
|
||||||
|
load_table(pak, schema::VESSEL, Vessel::from_idxd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Player config ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Player craft config (schema [`schema::PLAYER`]) — one per mission. Physics,
|
||||||
|
/// projectile caps and scoring here; cameras, difficulty and the render pipeline
|
||||||
|
/// live in [`fields`](PlayerConfig::fields).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlayerConfig {
|
||||||
|
pub air_drag_factor: Option<f32>,
|
||||||
|
pub gravity_factor: Option<f32>,
|
||||||
|
pub bullet_limit: Option<i64>,
|
||||||
|
pub laser_limit: Option<i64>,
|
||||||
|
pub homing_limit: Option<i64>,
|
||||||
|
pub space_size: Option<f32>,
|
||||||
|
pub supply_range: Option<f32>,
|
||||||
|
pub main_mission_bonus: Option<i64>,
|
||||||
|
pub rank_score_s: Option<i64>,
|
||||||
|
pub rank_score_a: Option<i64>,
|
||||||
|
pub rank_score_b: Option<i64>,
|
||||||
|
pub rank_score_c: Option<i64>,
|
||||||
|
pub rank_score_d: Option<i64>,
|
||||||
|
pub fields: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerConfig {
|
||||||
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
||||||
|
let f = field_map(o);
|
||||||
|
Some(PlayerConfig {
|
||||||
|
air_drag_factor: as_f32(f.get("AirDragFactor")),
|
||||||
|
gravity_factor: as_f32(f.get("GravityFactor")),
|
||||||
|
bullet_limit: as_i64(f.get("BulletLimit")),
|
||||||
|
laser_limit: as_i64(f.get("LaserLimit")),
|
||||||
|
homing_limit: as_i64(f.get("HomingLimit")),
|
||||||
|
space_size: as_f32(f.get("SpaceSize")),
|
||||||
|
supply_range: as_f32(f.get("SupplyRange")),
|
||||||
|
main_mission_bonus: as_i64(f.get("MainMissionBonus")),
|
||||||
|
rank_score_s: as_i64(f.get("RankScore_S")),
|
||||||
|
rank_score_a: as_i64(f.get("RankScore_A")),
|
||||||
|
rank_score_b: as_i64(f.get("RankScore_B")),
|
||||||
|
rank_score_c: as_i64(f.get("RankScore_C")),
|
||||||
|
rank_score_d: as_i64(f.get("RankScore_D")),
|
||||||
|
fields: f,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn field_f32(&self, key: &str) -> Option<f32> {
|
||||||
|
as_f32(self.fields.get(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every player config (one per mission) from a pak.
|
||||||
|
pub fn load_player_configs(pak: &PakArchive) -> Vec<PlayerConfig> {
|
||||||
|
load_table(pak, schema::PLAYER, PlayerConfig::from_idxd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Character ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A pilot / crew portrait: an emotion id (`FaceRAYMOND_07`) and its texture
|
||||||
|
/// (`pjf003_C02.t32`, a T8aD tile inside a RATC bundle).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Face {
|
||||||
|
pub id: String,
|
||||||
|
pub texture: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A character (schema [`CHARACTER`](schema::CHARACTER)) — pilots, crew, comms.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Character {
|
||||||
|
pub id: Option<String>,
|
||||||
|
/// Localized display-name key (resolve against the game's string tables).
|
||||||
|
pub name_key: Option<String>,
|
||||||
|
/// Faction / side, e.g. `TCAF` (player) or `ADAN` (enemy).
|
||||||
|
pub faction: Option<String>,
|
||||||
|
pub unique: Option<bool>,
|
||||||
|
/// Portrait set: each emotion face and the texture that draws it.
|
||||||
|
pub faces: Vec<Face>,
|
||||||
|
pub fields: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Character {
|
||||||
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
||||||
|
let t = o.tokens();
|
||||||
|
// Faces are `(texture, FaceID)` pairs after the `Faces` marker (value-
|
||||||
|
// before-key: the FaceID names the emotion, the `.t32` before it is its art).
|
||||||
|
let mut faces = Vec::new();
|
||||||
|
if let Some(start) = t.iter().position(|s| s == "Faces") {
|
||||||
|
for i in (start + 1)..t.len() {
|
||||||
|
if t[i].starts_with("Face") && i > 0 {
|
||||||
|
faces.push(Face {
|
||||||
|
id: t[i].clone(),
|
||||||
|
texture: t[i - 1].clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Character {
|
||||||
|
id: o.get_raw("ID").map(str::to_string),
|
||||||
|
name_key: o.get_raw("Name").map(str::to_string),
|
||||||
|
faction: o.get_raw("SideID").map(str::to_string),
|
||||||
|
unique: o.get_bool("Unique"),
|
||||||
|
faces,
|
||||||
|
fields: field_map(o),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every character from a pak.
|
||||||
|
pub fn load_characters(pak: &PakArchive) -> Vec<Character> {
|
||||||
|
load_table(pak, schema::CHARACTER, Character::from_idxd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage / mission ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The raw token immediately before `key` in `tokens` (value-before-key), for
|
||||||
|
/// identifier-valued fields the generic resolver skips (filenames, locations).
|
||||||
|
fn raw_before<'a>(tokens: &'a [String], key: &str) -> Option<&'a str> {
|
||||||
|
let i = tokens.iter().position(|t| t == key)?;
|
||||||
|
(i > 0).then(|| tokens[i - 1].as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mission / stage (schema [`STAGE`](schema::STAGE)). The manifest that wires a
|
||||||
|
/// mission together: its location, phase count, and the per-stage tables that
|
||||||
|
/// populate it (unit roster, squadrons, formations, flight routes, AI, objectives).
|
||||||
|
/// Resolve the referenced `*_S<NN>.tbl` tables with [`load_records`] for the
|
||||||
|
/// spawn/formation detail.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Stage {
|
||||||
|
/// Stage tag derived from the unit-table name, e.g. `"S01"`.
|
||||||
|
pub id: String,
|
||||||
|
/// In-world location (`BackGroundID`), e.g. `Lebendorf`, `Acheron`, `Earth`.
|
||||||
|
pub location: Option<String>,
|
||||||
|
/// Number of `Phase_N` sections.
|
||||||
|
pub phases: u32,
|
||||||
|
pub unit_table: Option<String>,
|
||||||
|
pub character_table: Option<String>,
|
||||||
|
pub squadron_table: Option<String>,
|
||||||
|
pub formation_table: Option<String>,
|
||||||
|
pub route_table: Option<String>,
|
||||||
|
pub ai_params_table: Option<String>,
|
||||||
|
pub subobjective_table: Option<String>,
|
||||||
|
pub message_table: Option<String>,
|
||||||
|
/// Every `resource-kind → table` reference in the manifest.
|
||||||
|
pub resources: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stage {
|
||||||
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
||||||
|
let t = o.tokens();
|
||||||
|
// Each resource is `table-name → Enumerate<Kind>` (value-before-key).
|
||||||
|
let mut resources = BTreeMap::new();
|
||||||
|
for i in 1..t.len() {
|
||||||
|
let key = t[i].as_str();
|
||||||
|
if key.starts_with("Enumerate") || key == "MessageSet" || key == "NamePlate" {
|
||||||
|
resources.insert(key.to_string(), t[i - 1].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let unit_table = raw_before(t, "EnumerateUnit").map(str::to_string);
|
||||||
|
let id = unit_table
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| s.strip_prefix("EnumUnit_"))
|
||||||
|
.map(|s| s.trim_end_matches(".tbl").to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Some(Stage {
|
||||||
|
id,
|
||||||
|
location: o.get_raw("BackGroundID").map(str::to_string),
|
||||||
|
phases: t.iter().filter(|s| s.starts_with("Phase_")).count() as u32,
|
||||||
|
unit_table,
|
||||||
|
character_table: raw_before(t, "EnumerateCharacter").map(str::to_string),
|
||||||
|
squadron_table: raw_before(t, "EnumerateSquadron").map(str::to_string),
|
||||||
|
formation_table: raw_before(t, "EnumerateFormation").map(str::to_string),
|
||||||
|
route_table: raw_before(t, "EnumerateNullFrame").map(str::to_string),
|
||||||
|
ai_params_table: raw_before(t, "EnumerateAIParams").map(str::to_string),
|
||||||
|
subobjective_table: raw_before(t, "EnumerateSubobjective").map(str::to_string),
|
||||||
|
message_table: raw_before(t, "MessageSet").map(str::to_string),
|
||||||
|
resources,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every stage / mission manifest from a pak.
|
||||||
|
pub fn load_stages(pak: &PakArchive) -> Vec<Stage> {
|
||||||
|
load_table(pak, schema::STAGE, Stage::from_idxd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Squadron / flight group ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A squadron — a flight group that spawns together: its formation shape, AI
|
||||||
|
/// behaviour, side, and member craft (with their pilots). Parsed from the
|
||||||
|
/// per-stage `Enumerate_Squadrons` tables (`UnitGroup_S<NN>.tbl`). The player's
|
||||||
|
/// Rhino flight, for instance, is a 2-craft TCAF squadron flying `Formation_2_Rhino`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Squadron {
|
||||||
|
/// Squadron tag (`TCN001`, `ADT101`, …), matched positionally to its
|
||||||
|
/// definition; `None` if the id list and definitions don't line up.
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub formation_id: Option<String>,
|
||||||
|
pub ai_id: Option<String>,
|
||||||
|
/// Faction — `TCAF` (allied) or `ADAN` (enemy).
|
||||||
|
pub side: Option<String>,
|
||||||
|
pub count: Option<i64>,
|
||||||
|
/// Member craft (`UN_*` unit ids).
|
||||||
|
pub members: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every squadron from a pak. Scans all `Enumerate_Squadrons` tables (their
|
||||||
|
/// schema hash varies per stage, but the table name is stable).
|
||||||
|
pub fn load_squadrons(pak: &PakArchive) -> Vec<Squadron> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for e in pak.entries() {
|
||||||
|
let Ok(b) = pak.read(e) else { continue };
|
||||||
|
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||||
|
let t = o.tokens();
|
||||||
|
if !t.first().is_some_and(|s| s.contains("Enumerate_Squadron")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Definitions are delimited by the `FormationID` key (its value, the
|
||||||
|
// formation, sits just before it). The id list precedes the first one.
|
||||||
|
let fpos: Vec<usize> = t
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, s)| *s == "FormationID")
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
let Some(&first) = fpos.first() else { continue };
|
||||||
|
// The squadron id list: tokens after the header up to the first formation.
|
||||||
|
let ids: Vec<&String> = t[1..first.saturating_sub(1)].iter().collect();
|
||||||
|
for (k, &fi) in fpos.iter().enumerate() {
|
||||||
|
let start = fi.saturating_sub(1);
|
||||||
|
let end = fpos.get(k + 1).map(|&n| n.saturating_sub(1)).unwrap_or(t.len());
|
||||||
|
let span = &t[start..end];
|
||||||
|
let before = |key: &str| -> Option<String> {
|
||||||
|
span.iter().position(|s| s == key).and_then(|i| (i > 0).then(|| span[i - 1].clone()))
|
||||||
|
};
|
||||||
|
let count = before("Count").and_then(|v| v.parse::<i64>().ok());
|
||||||
|
// Members are the first `count` `UN_` craft after the header block
|
||||||
|
// (which ends at DisableInterval, else Count); anything past them
|
||||||
|
// belongs to the next squadron or the stage roster.
|
||||||
|
let member_start = span
|
||||||
|
.iter()
|
||||||
|
.position(|s| s == "DisableInterval")
|
||||||
|
.or_else(|| span.iter().position(|s| s == "Count"))
|
||||||
|
.map(|i| i + 1)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let take = count.unwrap_or(0).max(0) as usize;
|
||||||
|
out.push(Squadron {
|
||||||
|
id: (ids.len() == fpos.len()).then(|| ids[k].clone()),
|
||||||
|
formation_id: Some(t[fi - 1].clone()),
|
||||||
|
ai_id: before("AIID"),
|
||||||
|
side: before("SideID"),
|
||||||
|
count,
|
||||||
|
members: span[member_start..]
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.starts_with("UN_"))
|
||||||
|
.take(take)
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Demo message / dialogue line ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// One dialogue line (schema [`MESSAGE`](schema::MESSAGE)): who speaks, with which
|
||||||
|
/// portrait and voice clip, and the text-key(s) of its caption pages. Resolve the
|
||||||
|
/// page keys against a [`crate::localization::TextIndex`] for the words, and the
|
||||||
|
/// voice clip against the movie-voice banks for the audio.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DemoMessage {
|
||||||
|
/// Line id, e.g. `MSG_VOICE_A_007`.
|
||||||
|
pub id: String,
|
||||||
|
/// Speaker (`CharacterRAYMOND`) when the line names one; many system /
|
||||||
|
/// continuation lines are unattributed (`None`).
|
||||||
|
pub character: Option<String>,
|
||||||
|
/// Portrait / emotion (`FaceRAYMOND_07`).
|
||||||
|
pub face: Option<String>,
|
||||||
|
/// Voice clip token (`VOICE_A_007`).
|
||||||
|
pub voice_clip: Option<String>,
|
||||||
|
/// Caption page text keys (`MSG_VOICE_A_007_000_00`, …) — resolve for the text.
|
||||||
|
pub page_keys: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every dialogue line from a pak. Each `Message_NNN` sub-record within the
|
||||||
|
/// message blobs becomes one [`DemoMessage`]; fields are found by prefix, so the
|
||||||
|
/// positional/defaulted layout doesn't matter.
|
||||||
|
pub fn load_demo_messages(pak: &PakArchive) -> Vec<DemoMessage> {
|
||||||
|
let is_marker = |s: &str| {
|
||||||
|
s.strip_prefix("Message_").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for e in pak.entries() {
|
||||||
|
let Ok(b) = pak.read(e) else { continue };
|
||||||
|
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||||
|
if o.schema_hash != schema::MESSAGE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let t = o.tokens();
|
||||||
|
let marks: Vec<usize> = t.iter().enumerate().filter(|(_, s)| is_marker(s)).map(|(i, _)| i).collect();
|
||||||
|
for (k, &m) in marks.iter().enumerate() {
|
||||||
|
let end = marks.get(k + 1).copied().unwrap_or(t.len());
|
||||||
|
let slice = &t[m + 1..end];
|
||||||
|
let Some(id) = slice.first().cloned() else { continue };
|
||||||
|
out.push(DemoMessage {
|
||||||
|
character: slice.iter().find(|s| s.starts_with("Character")).cloned(),
|
||||||
|
face: slice.iter().find(|s| s.starts_with("Face")).cloned(),
|
||||||
|
voice_clip: slice.iter().find(|s| s.starts_with("VOICE_")).cloned(),
|
||||||
|
page_keys: slice
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.len() > id.len() && s.starts_with(&id) && s[id.len()..].starts_with('_'))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unit roster (per-mission combatants) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Schema of the per-stage `EnumUnit` roster tables.
|
||||||
|
const ENUM_UNIT_SCHEMA: u32 = 0x35b8_dc67;
|
||||||
|
|
||||||
|
/// A mission's unit roster — the craft, vessels and props that can appear in one
|
||||||
|
/// battle (an `EnumUnit_S<NN>` table). Join the ids against [`load_units`] /
|
||||||
|
/// [`load_vessels`] for stats. The table isn't tagged with its stage on disc, so
|
||||||
|
/// [`stage`](UnitRoster::stage) is inferred from any `S<NN>_`-prefixed prop id in
|
||||||
|
/// the roster (asteroid collision meshes etc.) and is `None` when none is present.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UnitRoster {
|
||||||
|
pub stage: Option<String>,
|
||||||
|
/// Distinct combatant unit ids (`UN_*`), excluding stage props (asteroids,
|
||||||
|
/// collision meshes).
|
||||||
|
pub units: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every mission unit roster from a pak.
|
||||||
|
pub fn load_unit_rosters(pak: &PakArchive) -> Vec<UnitRoster> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for e in pak.entries() {
|
||||||
|
let Ok(b) = pak.read(e) else { continue };
|
||||||
|
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||||
|
if o.schema_hash != ENUM_UNIT_SCHEMA {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ids: Vec<&str> = o.tokens().iter().filter(|s| s.starts_with("UN_")).map(String::as_str).collect();
|
||||||
|
// Stage tag from an `UN_S<NN>_…` prop id (e.g. UN_S01_Asteroid_cmesh_…).
|
||||||
|
let stage = ids.iter().find_map(|s| {
|
||||||
|
let r = s.trim_start_matches("UN_");
|
||||||
|
let bytes = r.as_bytes();
|
||||||
|
(r.len() >= 3 && bytes[0] == b'S' && bytes[1].is_ascii_digit() && bytes[2].is_ascii_digit())
|
||||||
|
.then(|| r[..3].to_string())
|
||||||
|
});
|
||||||
|
// Combatants: drop the stage props, dedup preserving order.
|
||||||
|
let mut units = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
let prop = id.contains("Asteroid") || id.contains("cmesh") || id.contains("_Box");
|
||||||
|
if !prop && !units.iter().any(|u| u == id) {
|
||||||
|
units.push(id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !units.is_empty() {
|
||||||
|
out.push(UnitRoster { stage, units });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pilot roster (player squadron assignments) ──────────────────────────────────
|
||||||
|
|
||||||
|
/// The player squadron's flight assignments for a mission — which pilot flies each
|
||||||
|
/// callsign (`Rhino1` → `Katana`, `Bird1` → `Sandra`, …). Parsed from the
|
||||||
|
/// `UNITS`/`ZUNITS` player-unit tables (in `GP_HANGAR_ARSENAL.pak`); assignments
|
||||||
|
/// vary per mission (different missions field different wingmen).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PilotRoster {
|
||||||
|
/// `(callsign, pilot)` pairs in flight order, e.g. `("Rhino1", "Katana")`.
|
||||||
|
pub pilots: Vec<(String, String)>,
|
||||||
|
/// The player craft unit id (`UN_f002_TCAF_DeltaSaber…`).
|
||||||
|
pub player_unit: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for a `Callsign<N>-Pilot` token (a single hyphen, digit-suffixed callsign,
|
||||||
|
/// alphabetic pilot) — the flight-assignment shape.
|
||||||
|
fn is_assignment(s: &str) -> bool {
|
||||||
|
let Some((cs, pilot)) = s.split_once('-') else { return false };
|
||||||
|
!pilot.is_empty()
|
||||||
|
&& pilot.chars().all(|c| c.is_ascii_alphabetic())
|
||||||
|
&& cs.len() >= 2
|
||||||
|
&& cs.as_bytes()[cs.len() - 1].is_ascii_digit()
|
||||||
|
&& cs.chars().all(|c| c.is_ascii_alphanumeric())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every player pilot roster from a pak (scan `GP_HANGAR_ARSENAL.pak`). One
|
||||||
|
/// per player-unit config; rosters with no assignment tokens are skipped.
|
||||||
|
pub fn load_pilot_rosters(pak: &PakArchive) -> Vec<PilotRoster> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for e in pak.entries() {
|
||||||
|
let Ok(b) = pak.read(e) else { continue };
|
||||||
|
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||||
|
let t = o.tokens();
|
||||||
|
let pilots: Vec<(String, String)> = t
|
||||||
|
.iter()
|
||||||
|
.filter(|s| is_assignment(s))
|
||||||
|
.filter_map(|s| s.split_once('-').map(|(c, p)| (c.to_string(), p.to_string())))
|
||||||
|
.collect();
|
||||||
|
if pilots.len() >= 2 {
|
||||||
|
out.push(PilotRoster {
|
||||||
|
pilots,
|
||||||
|
player_unit: t.iter().find(|s| s.starts_with("UN_")).cloned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Arsenal (player weapon options per hardpoint) ───────────────────────────────
|
||||||
|
|
||||||
|
/// The player's selectable weapons, by hardpoint (from the `UNITS` player-unit
|
||||||
|
/// tables in `GP_HANGAR_ARSENAL.pak`). These are the arsenal display ids — the
|
||||||
|
/// Delta Saber's nose guns (`Stiletto_BG1`, `Broad_Sword_SG1`, …) and arm missiles
|
||||||
|
/// (`Falcon_9AM`, `White_Shark_T53R`, …) the player equips in the hangar.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Arsenal {
|
||||||
|
/// Nose slot — the fixed forward gun options.
|
||||||
|
pub nose: Vec<String>,
|
||||||
|
/// The three arm hardpoints — missile / bomb / special options.
|
||||||
|
pub arm1: Vec<String>,
|
||||||
|
pub arm2: Vec<String>,
|
||||||
|
pub arm3: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The weapon ids listed under a `STANDARD_<hp>` header (skipping its `Type` key),
|
||||||
|
/// up to the next `STANDARD_`/`PlayerSET_` header.
|
||||||
|
fn hardpoint_options(tokens: &[String], header: &str) -> Vec<String> {
|
||||||
|
let Some(start) = tokens.iter().position(|s| s == header) else { return Vec::new() };
|
||||||
|
tokens[start + 1..]
|
||||||
|
.iter()
|
||||||
|
.skip_while(|s| *s == "Type")
|
||||||
|
.take_while(|s| !s.starts_with("STANDARD_") && !s.starts_with("PlayerSET"))
|
||||||
|
.filter(|s| *s != "Type")
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the player arsenal from a pak (`GP_HANGAR_ARSENAL.pak`). Unions the weapon
|
||||||
|
/// options across every player-unit config, deduped in first-seen order.
|
||||||
|
pub fn load_arsenal(pak: &PakArchive) -> Arsenal {
|
||||||
|
let mut a = Arsenal::default();
|
||||||
|
let mut seen = [(); 4].map(|_| std::collections::BTreeSet::new());
|
||||||
|
let slots: [(&str, usize); 4] = [
|
||||||
|
("STANDARD_NOSE", 0),
|
||||||
|
("STANDARD_ARM1", 1),
|
||||||
|
("STANDARD_ARM2", 2),
|
||||||
|
("STANDARD_ARM3", 3),
|
||||||
|
];
|
||||||
|
for e in pak.entries() {
|
||||||
|
let Ok(b) = pak.read(e) else { continue };
|
||||||
|
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||||
|
if !o.tokens().first().is_some_and(|s| s.ends_with("UNITS")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (header, idx) in slots {
|
||||||
|
let dst = match idx {
|
||||||
|
0 => &mut a.nose,
|
||||||
|
1 => &mut a.arm1,
|
||||||
|
2 => &mut a.arm2,
|
||||||
|
_ => &mut a.arm3,
|
||||||
|
};
|
||||||
|
for w in hardpoint_options(o.tokens(), header) {
|
||||||
|
if seen[idx].insert(w.clone()) {
|
||||||
|
dst.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generic record access (any of the ~105 schemas) ─────────────────────────────
|
||||||
|
|
||||||
|
/// A generically-decoded IDXD record: its table name, schema id, identity, and
|
||||||
|
/// every explicitly-set field. Use this to read the ~99 schemas without a bespoke
|
||||||
|
/// struct (missions, UI layouts, effects, enums, …).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Record {
|
||||||
|
/// The table name — `token[0]` (e.g. `Weapon`, `StageResource`, `Sperkers`).
|
||||||
|
/// May carry a stray leading byte from the string-pool boundary on some
|
||||||
|
/// records; group by [`schema`](Self::schema), not this, for reliability.
|
||||||
|
pub table: String,
|
||||||
|
pub schema: u32,
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Every explicitly-set field (`key → value`).
|
||||||
|
pub fields: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Record {
|
||||||
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
||||||
|
Some(Record {
|
||||||
|
table: o.tokens().first().cloned().unwrap_or_default(),
|
||||||
|
schema: o.schema_hash,
|
||||||
|
id: o.get_raw("ID").map(str::to_string),
|
||||||
|
name: o.get_raw("Name").map(str::to_string),
|
||||||
|
fields: field_map(o),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn f32(&self, key: &str) -> Option<f32> {
|
||||||
|
as_f32(self.fields.get(key))
|
||||||
|
}
|
||||||
|
pub fn i64(&self, key: &str) -> Option<i64> {
|
||||||
|
as_i64(self.fields.get(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every record of an arbitrary `schema` from a pak, generically. Pair with
|
||||||
|
/// the [`schema`] constants or a hash from the IDXD census.
|
||||||
|
pub fn load_records(pak: &PakArchive, schema: u32) -> Vec<Record> {
|
||||||
|
load_table(pak, schema, Record::from_idxd)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn as_f32_tolerates_trailing_f() {
|
||||||
|
assert_eq!(as_f32(Some(&"3.0f".to_string())), Some(3.0));
|
||||||
|
assert_eq!(as_f32(Some(&"-0.5".to_string())), Some(-0.5));
|
||||||
|
assert_eq!(as_f32(Some(&"Vessel,Craft".to_string())), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration tests against a real disc — skipped unless SYLPHEED_DISC is set.
|
||||||
|
fn pak() -> Option<PakArchive> {
|
||||||
|
let disc = std::env::var("SYLPHEED_DISC").ok()?;
|
||||||
|
PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_weapons() {
|
||||||
|
let Some(pak) = pak() else { return };
|
||||||
|
let ws = load_weapons(&pak);
|
||||||
|
assert!(ws.len() >= 100, "expected ≥100 weapons, got {}", ws.len());
|
||||||
|
let rocket = ws
|
||||||
|
.iter()
|
||||||
|
.find(|w| w.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_Rocket")))
|
||||||
|
.expect("Delta Saber rocket present");
|
||||||
|
assert_eq!(rocket.velocity, Some(3000.0));
|
||||||
|
assert_eq!(rocket.max_range, Some(6000.0));
|
||||||
|
assert_eq!(rocket.loading_count, Some(1000));
|
||||||
|
assert_eq!(rocket.target_type.as_deref(), Some("Vessel,Craft,Structure"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_units_and_vessels() {
|
||||||
|
let Some(pak) = pak() else { return };
|
||||||
|
let units = load_units(&pak);
|
||||||
|
assert!(units.len() >= 80);
|
||||||
|
// Player craft: HP 1000, radar 500000.
|
||||||
|
let player = units
|
||||||
|
.iter()
|
||||||
|
.find(|u| u.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_T")))
|
||||||
|
.expect("player craft present");
|
||||||
|
assert_eq!(player.hp, Some(1000.0));
|
||||||
|
assert_eq!(player.radar_range, Some(500000.0));
|
||||||
|
|
||||||
|
let vessels = load_vessels(&pak);
|
||||||
|
let flagship = vessels
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.id.as_deref().is_some_and(|s| s.contains("SDBattleship")))
|
||||||
|
.expect("SD-Battleship present");
|
||||||
|
assert_eq!(flagship.hp, Some(100000.0));
|
||||||
|
assert_eq!(flagship.turret_count, Some(25));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_player_configs() {
|
||||||
|
let Some(pak) = pak() else { return };
|
||||||
|
let cfgs = load_player_configs(&pak);
|
||||||
|
assert!(!cfgs.is_empty());
|
||||||
|
let c = &cfgs[0];
|
||||||
|
assert_eq!(c.bullet_limit, Some(512));
|
||||||
|
assert_eq!(c.laser_limit, Some(32));
|
||||||
|
assert_eq!(c.rank_score_s, Some(10000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_characters() {
|
||||||
|
let Some(pak) = pak() else { return };
|
||||||
|
let chars = load_characters(&pak);
|
||||||
|
assert!(chars.len() >= 20, "expected many characters, got {}", chars.len());
|
||||||
|
let raymond = chars
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id.as_deref() == Some("CharacterRAYMOND"))
|
||||||
|
.expect("Raymond present");
|
||||||
|
assert_eq!(raymond.faction.as_deref(), Some("TCAF"));
|
||||||
|
assert!(!raymond.faces.is_empty());
|
||||||
|
assert!(raymond.faces.iter().all(|f| f.texture.ends_with(".t32")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_stages() {
|
||||||
|
let Some(pak) = pak() else { return };
|
||||||
|
let stages = load_stages(&pak);
|
||||||
|
let s01 = stages.iter().find(|s| s.id == "S01").expect("stage S01 present");
|
||||||
|
assert_eq!(s01.location.as_deref(), Some("Lebendorf"));
|
||||||
|
assert_eq!(s01.phases, 3);
|
||||||
|
assert_eq!(s01.unit_table.as_deref(), Some("EnumUnit_S01.tbl"));
|
||||||
|
assert_eq!(s01.squadron_table.as_deref(), Some("UnitGroup_S01.tbl"));
|
||||||
|
// 16-mission main campaign is present.
|
||||||
|
for n in 1..=16 {
|
||||||
|
assert!(stages.iter().any(|s| s.id == format!("S{n:02}")), "missing S{n:02}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_squadrons() {
|
||||||
|
let Some(pak) = pak() else { return };
|
||||||
|
let sq = load_squadrons(&pak);
|
||||||
|
assert!(sq.len() >= 20, "expected many squadrons, got {}", sq.len());
|
||||||
|
// A TCAF Rhino flight of Delta Sabers, membership matching its count.
|
||||||
|
let rhino = sq
|
||||||
|
.iter()
|
||||||
|
.find(|s| {
|
||||||
|
s.side.as_deref() == Some("TCAF")
|
||||||
|
&& s.formation_id.as_deref().is_some_and(|f| f.contains("Rhino"))
|
||||||
|
&& s.members.iter().any(|m| m.contains("DeltaSaber"))
|
||||||
|
})
|
||||||
|
.expect("a TCAF Rhino Delta Saber squadron");
|
||||||
|
assert_eq!(rhino.members.len() as i64, rhino.count.unwrap_or(-1));
|
||||||
|
assert!(rhino.members.iter().all(|m| m.contains("DeltaSaber")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_demo_messages() {
|
||||||
|
let Some(pak) = pak() else { return };
|
||||||
|
let msgs = load_demo_messages(&pak);
|
||||||
|
assert!(msgs.len() > 200, "expected many dialogue lines, got {}", msgs.len());
|
||||||
|
// Most lines are `MSG_*` ids with a speaker; page keys extend the id.
|
||||||
|
let msg_ids = msgs.iter().filter(|m| m.id.starts_with("MSG_")).count();
|
||||||
|
assert!(msg_ids > msgs.len() * 9 / 10, "{msg_ids}/{}", msgs.len());
|
||||||
|
// Attributed dialogue (an explicit speaker) is a minority — most lines are
|
||||||
|
// unattributed system/continuation messages — but there are hundreds.
|
||||||
|
let attributed = msgs.iter().filter(|m| m.character.is_some()).count();
|
||||||
|
assert!(attributed > 500, "attributed lines: {attributed}");
|
||||||
|
let m = msgs.iter().find(|m| m.character.is_some() && !m.page_keys.is_empty()).unwrap();
|
||||||
|
assert!(m.page_keys.iter().all(|k| k.starts_with(&m.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_unit_rosters() {
|
||||||
|
let Some(pak) = pak() else { return };
|
||||||
|
let rosters = load_unit_rosters(&pak);
|
||||||
|
assert!(rosters.len() > 20, "expected many rosters, got {}", rosters.len());
|
||||||
|
// At least a few tag their stage; all rosters carry combatants, no props.
|
||||||
|
assert!(rosters.iter().filter(|r| r.stage.is_some()).count() >= 4);
|
||||||
|
assert!(rosters.iter().all(|r| {
|
||||||
|
!r.units.is_empty() && r.units.iter().all(|u| !u.contains("Asteroid"))
|
||||||
|
}));
|
||||||
|
// A roster tagged S01 exists and names the player's Delta Saber.
|
||||||
|
let s01 = rosters.iter().find(|r| r.stage.as_deref() == Some("S01"));
|
||||||
|
assert!(s01.is_some_and(|r| r.units.iter().any(|u| u.contains("DeltaSaber"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_pilot_rosters() {
|
||||||
|
let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return };
|
||||||
|
let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")) else { return };
|
||||||
|
let rosters = load_pilot_rosters(&pak);
|
||||||
|
assert!(!rosters.is_empty(), "expected player pilot rosters");
|
||||||
|
// Some config assigns Katana to a Rhino callsign (she leads Rhino flight).
|
||||||
|
assert!(rosters.iter().any(|r| {
|
||||||
|
r.pilots.iter().any(|(cs, p)| cs.starts_with("Rhino") && p == "Katana")
|
||||||
|
}));
|
||||||
|
// Bird flight exists too.
|
||||||
|
assert!(rosters.iter().any(|r| r.pilots.iter().any(|(cs, _)| cs.starts_with("Bird"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_arsenal() {
|
||||||
|
let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return };
|
||||||
|
let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")) else { return };
|
||||||
|
let a = load_arsenal(&pak);
|
||||||
|
assert!(a.nose.len() >= 5 && a.arm1.len() >= 5, "nose {} arm1 {}", a.nose.len(), a.arm1.len());
|
||||||
|
assert!(a.nose.iter().any(|w| w.starts_with("Stiletto")));
|
||||||
|
assert!(a.arm1.iter().any(|w| w.starts_with("Falcon")));
|
||||||
|
// Headers never leak into the option lists.
|
||||||
|
assert!([&a.nose, &a.arm1, &a.arm2, &a.arm3]
|
||||||
|
.iter()
|
||||||
|
.all(|v| v.iter().all(|w| !w.starts_with("STANDARD_") && *w != "Type")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generic_records_read_any_schema() {
|
||||||
|
let Some(pak) = pak() else { return };
|
||||||
|
// Stage-resource table via the generic reader (no bespoke struct).
|
||||||
|
let stages = load_records(&pak, 0x3c9a_e32e);
|
||||||
|
assert!(!stages.is_empty());
|
||||||
|
assert!(stages.iter().all(|r| r.table.ends_with("StageResource")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,16 @@ pub mod movie_manifest;
|
|||||||
|
|
||||||
pub mod movie_voice;
|
pub mod movie_voice;
|
||||||
|
|
||||||
|
pub mod game_data;
|
||||||
|
|
||||||
|
pub mod localization;
|
||||||
|
|
||||||
|
// Whole-ship assembly from XBG7 part families (capital ships as split parts).
|
||||||
|
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};
|
||||||
|
|||||||
179
crates/sylpheed-formats/src/localization.rs
Normal file
179
crates/sylpheed-formats/src/localization.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
//! Localization — resolve the game's text keys to display strings.
|
||||||
|
//!
|
||||||
|
//! Project Sylpheed stores its UI/story text in `IXUD` entries as **UTF-16LE**,
|
||||||
|
//! interleaved `text, key, text, key, …`: a prose string immediately followed by
|
||||||
|
//! the identifier that names it. Objectives (`S01_P1_Objective_00`), hints, lose
|
||||||
|
//! conditions, character names (`CharacterName_CharacterRAYMOND`) and cutscene
|
||||||
|
//! dialogue (`MSG_*`) all resolve this way. One pak per language — pass
|
||||||
|
//! `GP_MAIN_GAME_E.pak` for English, `GP_MAIN_GAME_J.pak` for Japanese, etc.
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use sylpheed_formats::{localization::TextIndex, PakArchive};
|
||||||
|
//! let pak = PakArchive::open("dat/GP_MAIN_GAME_E.pak").unwrap();
|
||||||
|
//! let text = TextIndex::build(&pak);
|
||||||
|
//! assert_eq!(text.character_name("CharacterRAYMOND"), Some("Raymond"));
|
||||||
|
//! for line in text.objectives("S01", 1) { println!("• {line}"); }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::pak::PakArchive;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// A UTF-16 unit that belongs to a printable text run (ASCII + Latin-1 + Latin
|
||||||
|
/// Extended-A). Control codes and the CJK planes split tokens.
|
||||||
|
fn is_text_unit(u: u16) -> bool {
|
||||||
|
matches!(u, 0x20..=0x7e | 0xa0..=0xff | 0x100..=0x17f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a UTF-16LE blob into printable tokens (mirrors the subtitle tokenizer:
|
||||||
|
/// step 2 inside a run, 1 on a break to resync odd alignment).
|
||||||
|
fn utf16_tokens(bytes: &[u8]) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 1 < bytes.len() {
|
||||||
|
let u = u16::from_le_bytes([bytes[i], bytes[i + 1]]);
|
||||||
|
if is_text_unit(u) {
|
||||||
|
if let Some(c) = char::from_u32(u as u32) {
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
if cur.chars().count() >= 2 {
|
||||||
|
out.push(std::mem::take(&mut cur));
|
||||||
|
} else {
|
||||||
|
cur.clear();
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur.chars().count() >= 2 {
|
||||||
|
out.push(cur);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An identifier token — the key half of a `text, key` pair.
|
||||||
|
fn is_key(s: &str) -> bool {
|
||||||
|
s.len() >= 5
|
||||||
|
&& s.contains('_')
|
||||||
|
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
&& s.chars().any(|c| c.is_ascii_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A display-text token — anything with a lowercase letter that isn't itself a key.
|
||||||
|
fn is_prose(s: &str) -> bool {
|
||||||
|
!is_key(s) && s.chars().any(|c| c.is_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text-key → display-string index for one language, built from a `GP_MAIN_GAME_*`
|
||||||
|
/// pak.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct TextIndex {
|
||||||
|
entries: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextIndex {
|
||||||
|
/// Scan a language pak's `IXUD` entries and index every `text → key` pair.
|
||||||
|
pub fn build(pak: &PakArchive) -> Self {
|
||||||
|
let mut entries = BTreeMap::new();
|
||||||
|
for e in pak.entries() {
|
||||||
|
let Ok(b) = pak.read(e) else { continue };
|
||||||
|
if b.len() < 4 || &b[0..4] != b"IXUD" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let toks = utf16_tokens(&b);
|
||||||
|
for w in toks.windows(2) {
|
||||||
|
if is_key(&w[1]) && is_prose(&w[0]) {
|
||||||
|
entries.entry(w[1].clone()).or_insert_with(|| w[0].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextIndex { entries }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The display string for a text key, if present.
|
||||||
|
pub fn get(&self, key: &str) -> Option<&str> {
|
||||||
|
self.entries.get(key).map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of indexed keys.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.entries.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The objective lines for a stage/phase, e.g. `("S01", 1)`. Empty if none
|
||||||
|
/// resolve (unused objective slots are simply absent on disc).
|
||||||
|
pub fn objectives(&self, stage: &str, phase: u32) -> Vec<&str> {
|
||||||
|
self.numbered(&format!("{stage}_P{phase}_Objective"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lose-condition lines for a stage/phase.
|
||||||
|
pub fn lose_conditions(&self, stage: &str, phase: u32) -> Vec<&str> {
|
||||||
|
self.numbered(&format!("{stage}_P{phase}_Lose"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hint lines for a stage/phase.
|
||||||
|
pub fn hints(&self, stage: &str, phase: u32) -> Vec<&str> {
|
||||||
|
self.numbered(&format!("{stage}_P{phase}_Hint"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A character's display name from its id (`CharacterRAYMOND` → `Raymond`).
|
||||||
|
pub fn character_name(&self, character_id: &str) -> Option<&str> {
|
||||||
|
self.get(&format!("CharacterName_{character_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect a `<base>_NN` run of lines while they resolve.
|
||||||
|
fn numbered(&self, base: &str) -> Vec<&str> {
|
||||||
|
(0..16)
|
||||||
|
.filter_map(|i| self.get(&format!("{base}_{i:02}")))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokenizes_mixed_runs() {
|
||||||
|
// "Hi" NUL "K_1" as UTF-16LE → two tokens.
|
||||||
|
let mut b = Vec::new();
|
||||||
|
for s in ["Hi", "\0", "K_1x"] {
|
||||||
|
for c in s.chars() {
|
||||||
|
b.extend_from_slice(&(c as u16).to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let t = utf16_tokens(&b);
|
||||||
|
assert!(t.contains(&"Hi".to_string()));
|
||||||
|
assert!(t.contains(&"K_1x".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_and_prose_classify() {
|
||||||
|
assert!(is_key("S01_P1_Objective_00"));
|
||||||
|
assert!(is_key("CharacterName_CharacterRAYMOND"));
|
||||||
|
assert!(!is_key("Repel the attack."));
|
||||||
|
assert!(is_prose("Repel the attack."));
|
||||||
|
assert!(is_prose("Raymond"));
|
||||||
|
assert!(!is_prose("S01_P1_Objective_00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disc-backed — skipped unless SYLPHEED_DISC is set.
|
||||||
|
#[test]
|
||||||
|
fn resolves_real_text() {
|
||||||
|
let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return };
|
||||||
|
let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")) else { return };
|
||||||
|
let t = TextIndex::build(&pak);
|
||||||
|
assert!(t.len() > 5000, "expected a large index, got {}", t.len());
|
||||||
|
assert_eq!(t.character_name("CharacterRAYMOND"), Some("Raymond"));
|
||||||
|
assert_eq!(
|
||||||
|
t.get("S01_P1_Objective_00"),
|
||||||
|
Some("Repel the enemy's surprise attack.")
|
||||||
|
);
|
||||||
|
assert!(!t.objectives("S01", 1).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,6 +151,37 @@ pub fn count_xbg7(bytes: &[u8]) -> usize {
|
|||||||
n
|
n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List every XBG7 resource name in an XPR2 container, in directory order.
|
||||||
|
///
|
||||||
|
/// Cheap: walks only the resource directory (no geometry decode). Used to
|
||||||
|
/// discover which container holds a named hull / part model when assembling a
|
||||||
|
/// multi-part ship from a [`crate::game_data::Vessel`] recipe.
|
||||||
|
pub fn xbg7_resource_names(bytes: &[u8]) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let mut cur = Cursor::new(bytes);
|
||||||
|
let header = match Xpr2Header::read(&mut cur) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => return out,
|
||||||
|
};
|
||||||
|
const DIR_BASE: usize = 0x10;
|
||||||
|
for _ in 0..header.num_resources {
|
||||||
|
let e = match Xpr2ResourceEntry::read(&mut cur) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
if &e.type_tag != b"XBG7" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(n) = read_cstr(bytes, e.name_offset as usize + DIR_BASE) {
|
||||||
|
out.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
impl Xbg7Model {
|
impl Xbg7Model {
|
||||||
/// Total vertex / triangle counts across all sub-meshes.
|
/// Total vertex / triangle counts across all sub-meshes.
|
||||||
pub fn totals(&self) -> (usize, usize) {
|
pub fn totals(&self) -> (usize, usize) {
|
||||||
@@ -402,6 +433,18 @@ impl Xbg7Model {
|
|||||||
Self::anchor_models_cancellable(bytes, min_consistency, &|| false)
|
Self::anchor_models_cancellable(bytes, min_consistency, &|| false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode only the named XBG7 resources from a container (content-anchored,
|
||||||
|
/// abortable). Used to assemble a whole ship from just its part family
|
||||||
|
/// (`e106_bdy_*`, `e106_brg_*`, …) instead of decoding the whole ~300-resource
|
||||||
|
/// stage. Order follows the container's directory. See [`crate::ship`].
|
||||||
|
pub fn models_named(
|
||||||
|
bytes: &[u8],
|
||||||
|
wanted: &std::collections::HashSet<String>,
|
||||||
|
should_cancel: &(dyn Fn() -> bool + Sync),
|
||||||
|
) -> Vec<Xbg7Model> {
|
||||||
|
Self::anchor_models_filtered(bytes, 0.0, should_cancel, Some(wanted))
|
||||||
|
}
|
||||||
|
|
||||||
/// [`Xbg7Model::anchor_models`] with a cancellation poll checked between
|
/// [`Xbg7Model::anchor_models`] with a cancellation poll checked between
|
||||||
/// resources (a large stage container holds hundreds). See
|
/// resources (a large stage container holds hundreds). See
|
||||||
/// [`Xbg7Model::stage_models_cancellable`].
|
/// [`Xbg7Model::stage_models_cancellable`].
|
||||||
@@ -409,6 +452,15 @@ impl Xbg7Model {
|
|||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
min_consistency: f32,
|
min_consistency: f32,
|
||||||
should_cancel: &(dyn Fn() -> bool + Sync),
|
should_cancel: &(dyn Fn() -> bool + Sync),
|
||||||
|
) -> Vec<Xbg7Model> {
|
||||||
|
Self::anchor_models_filtered(bytes, min_consistency, should_cancel, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn anchor_models_filtered(
|
||||||
|
bytes: &[u8],
|
||||||
|
min_consistency: f32,
|
||||||
|
should_cancel: &(dyn Fn() -> bool + Sync),
|
||||||
|
wanted: Option<&std::collections::HashSet<String>>,
|
||||||
) -> Vec<Xbg7Model> {
|
) -> Vec<Xbg7Model> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||||||
@@ -464,6 +516,11 @@ impl Xbg7Model {
|
|||||||
}
|
}
|
||||||
let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
||||||
.unwrap_or_else(|| "XBG7".to_string());
|
.unwrap_or_else(|| "XBG7".to_string());
|
||||||
|
if let Some(w) = wanted {
|
||||||
|
if !w.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
resources.push(Res {
|
resources.push(Res {
|
||||||
name,
|
name,
|
||||||
markers,
|
markers,
|
||||||
@@ -1346,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;
|
||||||
@@ -1545,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.
|
||||||
@@ -1628,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).
|
||||||
@@ -1695,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.
|
||||||
@@ -1783,6 +1884,136 @@ pub fn node_transforms(bytes: &[u8], resource_name: &str) -> Vec<NodePlacement>
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A geometry resource placed by a composite scene graph, with its world affine.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScenePart {
|
||||||
|
/// The geometry resource to draw (e.g. `e106_bdy_01`).
|
||||||
|
pub resource: String,
|
||||||
|
/// World rotation (row-major 3×3).
|
||||||
|
pub m: [[f32; 3]; 3],
|
||||||
|
/// World translation, `(X, up→Y, fore/aft→Z)`.
|
||||||
|
pub t: [f32; 3],
|
||||||
|
/// Per-axis local scale (a shield generator ships at 0.5, a jet FX at 2.4).
|
||||||
|
pub s: [f32; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScenePart {
|
||||||
|
/// Apply the world transform to a local vertex: `R·(S·v) + T` (scale is a
|
||||||
|
/// leaf-local factor; the composite's structural nodes are all unit-scale).
|
||||||
|
pub fn apply(&self, v: [f32; 3]) -> [f32; 3] {
|
||||||
|
let sv = [v[0] * self.s[0], v[1] * self.s[1], v[2] * self.s[2]];
|
||||||
|
[
|
||||||
|
self.m[0][0] * sv[0] + self.m[0][1] * sv[1] + self.m[0][2] * sv[2] + self.t[0],
|
||||||
|
self.m[1][0] * sv[0] + self.m[1][1] * sv[1] + self.m[1][2] * sv[2] + self.t[1],
|
||||||
|
self.m[2][0] * sv[0] + self.m[2][1] * sv[1] + self.m[2][2] * sv[2] + self.t[2],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk a composite scene graph (`e_rou_eNNN`) and return **every** node with its
|
||||||
|
/// composed world transform (parent∘child down the first-child/next-sibling tree).
|
||||||
|
///
|
||||||
|
/// A capital ship's parts are authored around the origin in their own resources;
|
||||||
|
/// the composite's node tree carries the world placement — `rou_<id>_*` nodes name
|
||||||
|
/// the hull geometry resources, and `GN_*` nodes are the Vessel hardpoint frames
|
||||||
|
/// (bridge / engine / shield-generator / turret mounts). The [`crate::ship`]
|
||||||
|
/// assembler resolves those names to resources and places the parts. Same node
|
||||||
|
/// record layout + TRS convention as [`node_transforms`], but cross-resource.
|
||||||
|
pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec<ScenePart> {
|
||||||
|
let Some((desc, desc_end)) = xbg7_descriptor(bytes, composite_name) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let d = &bytes[desc..desc_end];
|
||||||
|
let head_end = d.len();
|
||||||
|
|
||||||
|
let read_trs = |t44: usize| read_trs9(d, t44);
|
||||||
|
|
||||||
|
// A node record begins with a name starting `rou_` or `GN_`.
|
||||||
|
let node_name_at = |i: usize| -> Option<(String, usize)> {
|
||||||
|
let starts = i + 4 <= d.len() && &d[i..i + 4] == b"rou_"
|
||||||
|
|| i + 3 <= d.len() && &d[i..i + 3] == b"GN_";
|
||||||
|
if !starts {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
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]).ok()?.to_string();
|
||||||
|
if name.ends_with("_col")
|
||||||
|
|| name.ends_with("_spc")
|
||||||
|
|| name.ends_with("_gls")
|
||||||
|
|| name.ends_with("_lum")
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((name, j))
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Rec {
|
||||||
|
name_start: usize,
|
||||||
|
name: String,
|
||||||
|
local_m: M3,
|
||||||
|
local_t: [f32; 3],
|
||||||
|
local_s: [f32; 3],
|
||||||
|
sib_end: Option<usize>,
|
||||||
|
}
|
||||||
|
let mut recs: Vec<Rec> = Vec::new();
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 3 <= head_end {
|
||||||
|
let Some((name, j)) = node_name_at(i) else {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
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 sib_ptr = be32(d, ff - 0x04) as usize;
|
||||||
|
let trs = if t44 != 0 && t44 + 32 <= d.len() {
|
||||||
|
read_trs(t44)
|
||||||
|
} else {
|
||||||
|
[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[1] as f32, trs[2] as f32];
|
||||||
|
let local_m = node_rotation(trs[3], trs[4], trs[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 local_s = [sc(trs[6]), sc(trs[7]), sc(trs[8])];
|
||||||
|
// Next sibling begins 4 bytes before its pointer, at a `rou_`/`GN_` name.
|
||||||
|
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_")
|
||||||
|
});
|
||||||
|
recs.push(Rec { name_start: i, name, local_m, local_t, local_s, sib_end });
|
||||||
|
i = j.max(i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose transforms down the first-child / next-sibling tree.
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut stack: Vec<(usize, M3, [f32; 3])> = Vec::new();
|
||||||
|
for rec in &recs {
|
||||||
|
while stack.last().is_some_and(|s| s.0 <= rec.name_start) {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
let (pm, pt) = stack.last().map(|s| (s.1, s.2)).unwrap_or((M3_ID, [0.0; 3]));
|
||||||
|
let wm = m3_mul(pm, rec.local_m);
|
||||||
|
let r = m3_vec(pm, rec.local_t);
|
||||||
|
let wt = [r[0] + pt[0], r[1] + pt[1], r[2] + pt[2]];
|
||||||
|
out.push(ScenePart { resource: rec.name.clone(), m: wm, t: wt, s: rec.local_s });
|
||||||
|
let end = rec
|
||||||
|
.sib_end
|
||||||
|
.unwrap_or_else(|| stack.last().map(|s| s.0).unwrap_or(head_end));
|
||||||
|
stack.push((end, wm, wt));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
649
crates/sylpheed-formats/src/ship.rs
Normal file
649
crates/sylpheed-formats/src/ship.rs
Normal file
@@ -0,0 +1,649 @@
|
|||||||
|
//! Whole-ship assembly from XBG7 part families.
|
||||||
|
//!
|
||||||
|
//! Project Sylpheed's capital ships are never stored as a single mesh. A ship is
|
||||||
|
//! modelled, then **split into destructible parts** — hull segments, bridge,
|
||||||
|
//! engines, integral turrets — each shipped as its own XBG7 resource inside a
|
||||||
|
//! `hidden/resource3d/Stage_SNN.xpr` container. The split lets the game hide a
|
||||||
|
//! part when the player destroys it (bridge, shield generator, thruster) and swap
|
||||||
|
//! in a `_break` variant.
|
||||||
|
//!
|
||||||
|
//! Each part's vertices are authored around the origin; the world placement lives
|
||||||
|
//! in the **primary composite scene graph** `e_rou_<id>` (see [`assemble_ship`]).
|
||||||
|
//! Its node hierarchy (`rou_<id>_root`→`…_front`/`…_rear`→`rou_<id>_bdy_NN`) names
|
||||||
|
//! the separate hull resources and carries their world TRS — the cross-resource
|
||||||
|
//! analogue of how [`mesh::node_transforms`] poses one model's sub-parts. The
|
||||||
|
//! `GN_*` nodes in the same composite are the Vessel hardpoint frames (bridge /
|
||||||
|
//! shield / engine / turret mounts, matching the IDXD recipe `Frame` names).
|
||||||
|
//!
|
||||||
|
//! Resource naming (learned from the retail stage containers):
|
||||||
|
//! - `<id>` is `[a-z]NNN` (`e106`, `f105`, `n050`). The leading letter is the
|
||||||
|
//! faction: `e` = ADAN (enemy), `f` = TCAF (player side), `n` = neutral /
|
||||||
|
//! structures / debris.
|
||||||
|
//! - A base part is `<id>_<name>` (`e106_bdy_01`, `e106_brg_01`, `f105_sld_02`).
|
||||||
|
//! - `_l` / `_m` / `_d`(`NN`) suffixes are lower level-of-detail copies; `_bNN` /
|
||||||
|
//! `_dead` / `_break` are destruction geometry; `_joint` / `_open` / `_Near` are
|
||||||
|
//! logic/animation nodes. All are skipped.
|
||||||
|
//! - `e_rou_<id>` is the primary composite; `e_rou_<id>_eng` / `_wep_*` are LOCAL
|
||||||
|
//! detail rigs (their nodes are in their own frame, not ship-space) and are NOT
|
||||||
|
//! used for placement.
|
||||||
|
|
||||||
|
use crate::mesh::{scene_world_nodes, xbg7_resource_names, ScenePart};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
/// Which side a ship belongs to, from the first letter of its resource id.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Faction {
|
||||||
|
/// `e…` — ADAN (the enemy).
|
||||||
|
Adan,
|
||||||
|
/// `f…` — TCAF (the player's coalition).
|
||||||
|
Tcaf,
|
||||||
|
/// `n…` — neutral: stations, structures, asteroids, debris.
|
||||||
|
Neutral,
|
||||||
|
/// Anything else (`j…`, `s…`, …).
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Faction {
|
||||||
|
fn from_id(id: &str) -> Faction {
|
||||||
|
match id.as_bytes().first() {
|
||||||
|
Some(b'e') => Faction::Adan,
|
||||||
|
Some(b'f') => Faction::Tcaf,
|
||||||
|
Some(b'n') => Faction::Neutral,
|
||||||
|
_ => Faction::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short human label.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Faction::Adan => "ADAN",
|
||||||
|
Faction::Tcaf => "TCAF",
|
||||||
|
Faction::Neutral => "Neutral",
|
||||||
|
Faction::Other => "Other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ship reconstructed from one XBG7 part family in a stage container.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ShipModel {
|
||||||
|
/// The family id, e.g. `e106` — matches a [`crate::game_data::Vessel`] whose
|
||||||
|
/// `model` is `rou_e106`.
|
||||||
|
pub id: String,
|
||||||
|
/// Faction inferred from the id's leading letter.
|
||||||
|
pub faction: Faction,
|
||||||
|
/// Base geometry resource names to draw together, in directory order.
|
||||||
|
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`
|
||||||
|
/// → `e106`). Returns `None` for scene/logic resources (`e_rou_e106`) and
|
||||||
|
/// anything not starting with a letter + three digits.
|
||||||
|
pub fn ship_id_of(resource: &str) -> Option<&str> {
|
||||||
|
let b = resource.as_bytes();
|
||||||
|
if b.len() >= 4
|
||||||
|
&& b[0].is_ascii_lowercase()
|
||||||
|
&& b[1].is_ascii_digit()
|
||||||
|
&& b[2].is_ascii_digit()
|
||||||
|
&& b[3].is_ascii_digit()
|
||||||
|
{
|
||||||
|
// Reject a longer alnum run (`e1234…`): the id is exactly letter+3 digits,
|
||||||
|
// followed by end-of-string or a non-alphanumeric (`_`).
|
||||||
|
if b.get(4).is_none_or(|c| !c.is_ascii_alphanumeric()) {
|
||||||
|
return Some(&resource[..4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a resource is a base geometry part (not a LOD copy, destruction
|
||||||
|
/// variant, or scene/logic node) that should be drawn in a whole-ship render.
|
||||||
|
pub fn is_base_part(resource: &str) -> bool {
|
||||||
|
if ship_id_of(resource).is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Level-of-detail copies of a base part: a trailing `_l`/`_m`/`_d` token,
|
||||||
|
// optionally with a number (`_d01`, `_l02`).
|
||||||
|
if let Some(last) = resource.rsplit('_').next() {
|
||||||
|
let mut cs = last.chars();
|
||||||
|
if matches!(cs.next(), Some('l' | 'm' | 'd')) && cs.all(|c| c.is_ascii_digit()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Destruction / logic geometry.
|
||||||
|
if resource.contains("break")
|
||||||
|
|| resource.contains("dead")
|
||||||
|
|| resource.contains("_joint")
|
||||||
|
|| resource.contains("_open")
|
||||||
|
|| resource.contains("_Near")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Break-piece geometry carries a bare `b` / `bNN` token (`e106_brg_01_b_02`,
|
||||||
|
// `e108_eng_01_b01`). `bdy` etc. are safe — only an exact `b`-then-digits token
|
||||||
|
// counts.
|
||||||
|
if resource.split('_').skip(1).any(|t| {
|
||||||
|
t == "b" || (t.starts_with('b') && t.len() >= 2 && t[1..].bytes().all(|c| c.is_ascii_digit()))
|
||||||
|
}) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group every base part in an XPR2 container into whole ships, keyed by id.
|
||||||
|
///
|
||||||
|
/// Ships are returned sorted by id. A stage container also holds shared turret /
|
||||||
|
/// prop models as their own ids — every group with at least one base part is
|
||||||
|
/// returned; the caller can filter by [`ShipModel::parts`] length or faction.
|
||||||
|
pub fn ships_in_container(bytes: &[u8]) -> Vec<ShipModel> {
|
||||||
|
let names = xbg7_resource_names(bytes);
|
||||||
|
let mut ids: Vec<String> = Vec::new();
|
||||||
|
let mut ships: std::collections::HashMap<String, ShipModel> = std::collections::HashMap::new();
|
||||||
|
for n in &names {
|
||||||
|
if !is_base_part(n) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = ship_id_of(n).unwrap().to_string();
|
||||||
|
let entry = ships.entry(id.clone()).or_insert_with(|| {
|
||||||
|
ids.push(id.clone());
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
ids.sort();
|
||||||
|
ids.into_iter().map(|id| ships.remove(&id).unwrap()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a composite scene-graph node name to the geometry resource it draws.
|
||||||
|
/// Hull nodes are `rou_<id>_<part>[_root|_mov|_NN]`; the geometry resource is the
|
||||||
|
/// `<id>_<part>` stem. Structural nodes (`rou_e106_front`, `…_root`) resolve to
|
||||||
|
/// nothing and only pose their children.
|
||||||
|
fn resolve_hull_resource(node: &str, resources: &HashSet<String>) -> Option<String> {
|
||||||
|
let stem = node.rsplit_once("rou_").map(|(_, s)| s).unwrap_or(node);
|
||||||
|
let try_stem = |s: &str| -> Option<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"] {
|
||||||
|
if let Some(s) = stem.strip_suffix(suf) {
|
||||||
|
if let Some(r) = try_stem(s) {
|
||||||
|
return Some(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trailing `_NN` instance index (`eng_01_1` → `eng_01`).
|
||||||
|
if let Some(pos) = stem.rfind('_') {
|
||||||
|
if !stem[pos + 1..].is_empty() && stem[pos + 1..].bytes().all(|c| c.is_ascii_digit()) {
|
||||||
|
if let Some(r) = try_stem(&stem[..pos]) {
|
||||||
|
return Some(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trailing digit run of a name (`GN_Bridge_01` → `01`, `sld_02` → `02`).
|
||||||
|
fn trailing_index(name: &str) -> &str {
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
let mut start = bytes.len();
|
||||||
|
while start > 0 && bytes[start - 1].is_ascii_digit() {
|
||||||
|
start -= 1;
|
||||||
|
}
|
||||||
|
&name[start..]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which stage-container resources are the composite scene graphs for ship `id`
|
||||||
|
/// (`e_rou_e106`, `e_rou_e106_eng`, …). Excludes destruction (`_break`) scenes.
|
||||||
|
fn composites_for<'a>(names: &'a [String], id: &str) -> Vec<&'a String> {
|
||||||
|
let needle = format!("rou_{id}");
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.filter(|n| {
|
||||||
|
n.contains(&needle)
|
||||||
|
&& ship_id_of(n).is_none()
|
||||||
|
&& !n.contains("break")
|
||||||
|
&& !n.contains("dead")
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconstruct a whole ship as a list of geometry-resource placements in a shared
|
||||||
|
/// ship-local frame — the placement the game builds at runtime.
|
||||||
|
///
|
||||||
|
/// Fully static and EXACT (validated part-for-part against an e106 runtime
|
||||||
|
/// capture — see `docs/re/ship-placement-runtime-capture.md`):
|
||||||
|
/// 1. **Composite nodes** — every `rou_*` node in the primary composite
|
||||||
|
/// (`e_rou_<id>`) places a geometry resource, INCLUDING repeated instances
|
||||||
|
/// and cross-id turret mounts (`rou_e303_wep_01_root` ×2 on the e106 hull).
|
||||||
|
/// 2. **Detail rigs** — `e_rou_<id>_eng` is the engine cluster rig; its nodes
|
||||||
|
/// (e.g. two `eng_01` nacelle instances + `eng_02`) mount at the primary
|
||||||
|
/// composite's `GN_Engine_01` frame: `world = frame ∘ rig_local`.
|
||||||
|
/// 3. **GN hardpoints** — remaining unplaced `brg`/`sld` parts sit exactly at
|
||||||
|
/// their `GN_Bridge_NN` / `GN_ShieldG_NN` frame (frames carry full TRS).
|
||||||
|
/// 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).
|
||||||
|
///
|
||||||
|
/// `include_external` keeps tiers 2–4 (off = bare composite-node hull).
|
||||||
|
pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<ScenePart> {
|
||||||
|
let names = xbg7_resource_names(bytes);
|
||||||
|
let resources: HashSet<String> = names.iter().cloned().collect();
|
||||||
|
|
||||||
|
// The PRIMARY composite carries the ship-space layout (hull bodies, turret
|
||||||
|
// mounts + every `GN_*` hardpoint frame): the one with the most nodes.
|
||||||
|
let scenes: Vec<(String, Vec<ScenePart>)> = composites_for(&names, id)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| !c.ends_with("_joint"))
|
||||||
|
.map(|c| (c.clone(), scene_world_nodes(bytes, c)))
|
||||||
|
.collect();
|
||||||
|
let Some((_, nodes)) = scenes.iter().max_by_key(|(_, n)| n.len()) else {
|
||||||
|
// No composite at all → raw fallback below.
|
||||||
|
let mut placed = Vec::new();
|
||||||
|
const ID: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||||
|
for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) {
|
||||||
|
placed.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] });
|
||||||
|
}
|
||||||
|
return placed;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut placed: Vec<ScenePart> = Vec::new();
|
||||||
|
let mut placed_res: HashSet<String> = HashSet::new();
|
||||||
|
// GN hardpoint frames, world-space, from the primary composite.
|
||||||
|
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 {
|
||||||
|
if node.resource.starts_with("GN_") {
|
||||||
|
frames.push(node.clone());
|
||||||
|
} else if let Some(res) = resolve_hull_resource(&node.resource, &resources) {
|
||||||
|
if ship_id_of(&res) != Some(id) && !include_external {
|
||||||
|
continue; // hull-only view: own parts only
|
||||||
|
}
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2: the engine cluster rig, mounted at GN_Engine_01. The rig's nodes
|
||||||
|
// 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")];
|
||||||
|
for part in names
|
||||||
|
.iter()
|
||||||
|
.filter(|_| include_external)
|
||||||
|
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id))
|
||||||
|
{
|
||||||
|
if placed_res.contains(part) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rest = &part[(id.len() + 1).min(part.len())..]; // "brg_01"
|
||||||
|
let mut toks = rest.split('_');
|
||||||
|
let cat = toks.next().unwrap_or("");
|
||||||
|
let idx = toks.next().unwrap_or("");
|
||||||
|
let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(frame) =
|
||||||
|
frames.iter().find(|f| f.resource.contains(gncat) && trailing_index(&f.resource) == idx)
|
||||||
|
{
|
||||||
|
placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s });
|
||||||
|
placed_res.insert(part.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// base parts untransformed rather than nothing.
|
||||||
|
if placed.is_empty() {
|
||||||
|
const ID: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||||
|
for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) {
|
||||||
|
placed.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn id_extraction() {
|
||||||
|
assert_eq!(ship_id_of("e106_bdy_01"), Some("e106"));
|
||||||
|
assert_eq!(ship_id_of("f105_sld_02_l"), Some("f105"));
|
||||||
|
assert_eq!(ship_id_of("e_rou_e106"), None);
|
||||||
|
assert_eq!(ship_id_of("_rou_e106_break"), None);
|
||||||
|
assert_eq!(ship_id_of("Base"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_part_filter() {
|
||||||
|
assert!(is_base_part("e106_bdy_01"));
|
||||||
|
assert!(is_base_part("e106_brg_01"));
|
||||||
|
assert!(is_base_part("f105_wep_02_01"));
|
||||||
|
assert!(!is_base_part("e106_bdy_01_l")); // LOD
|
||||||
|
assert!(!is_base_part("e106_bdy_01_m")); // LOD
|
||||||
|
assert!(!is_base_part("e106_brg_01_b_02")); // break piece
|
||||||
|
assert!(!is_base_part("_rou_e106_break")); // logic
|
||||||
|
assert!(!is_base_part("e_rou_e106")); // scene node
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn faction_from_id() {
|
||||||
|
assert_eq!(Faction::from_id("e106"), Faction::Adan);
|
||||||
|
assert_eq!(Faction::from_id("f105"), Faction::Tcaf);
|
||||||
|
assert_eq!(Faction::from_id("n050"), Faction::Neutral);
|
||||||
|
assert_eq!(Faction::from_id("j004"), Faction::Other);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disc-gated: reconstruct the ADAN cruiser family from a real stage container.
|
||||||
|
#[test]
|
||||||
|
fn ships_from_real_stage() {
|
||||||
|
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_S02.xpr").await.unwrap()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let ships = ships_in_container(&bytes);
|
||||||
|
assert!(!ships.is_empty(), "stage should hold ships");
|
||||||
|
// The ADAN frigate e108 appears in S02 with hull + engine + weapon parts.
|
||||||
|
let e108 = ships.iter().find(|s| s.id == "e108").expect("e108 present");
|
||||||
|
assert_eq!(e108.faction, Faction::Adan);
|
||||||
|
assert!(e108.parts.iter().any(|p| p.contains("_bdy_")));
|
||||||
|
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
|
||||||
|
// 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).
|
||||||
|
#[test]
|
||||||
|
fn assemble_spreads_parts() {
|
||||||
|
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_S02.xpr").await.unwrap()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let placed = assemble_ship(&bytes, "e106", true);
|
||||||
|
assert!(placed.len() >= 5, "e106 should place its hull + external parts");
|
||||||
|
// The forward hull body and the bridge must land at distinct fore/aft Z.
|
||||||
|
let z = |res: &str| placed.iter().find(|p| p.resource == res).map(|p| p.t[2]);
|
||||||
|
let bdy = z("e106_bdy_01").expect("bdy_01 placed");
|
||||||
|
let brg = z("e106_brg_01").expect("brg_01 placed at its GN_Bridge frame");
|
||||||
|
assert!(
|
||||||
|
(bdy - brg).abs() > 200.0,
|
||||||
|
"bridge ({brg}) and forward body ({bdy}) must be far apart, not stacked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
694
crates/sylpheed-formats/src/ship_capture.rs
Normal file
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -503,6 +503,165 @@ pub struct VoiceLibrary {
|
|||||||
#[derive(Event, Default)]
|
#[derive(Event, Default)]
|
||||||
pub struct RequestVoiceLibrary;
|
pub struct RequestVoiceLibrary;
|
||||||
|
|
||||||
|
// ── Game-data browser (decoded IDXD tables) ────────────────────────────────────
|
||||||
|
|
||||||
|
/// Which decoded table the Game Data browser is showing.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum GameCategory {
|
||||||
|
#[default]
|
||||||
|
Weapons,
|
||||||
|
Craft,
|
||||||
|
Vessels,
|
||||||
|
Characters,
|
||||||
|
Missions,
|
||||||
|
Arsenal,
|
||||||
|
Flights,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameCategory {
|
||||||
|
pub const ALL: [GameCategory; 7] = [
|
||||||
|
GameCategory::Weapons,
|
||||||
|
GameCategory::Craft,
|
||||||
|
GameCategory::Vessels,
|
||||||
|
GameCategory::Characters,
|
||||||
|
GameCategory::Missions,
|
||||||
|
GameCategory::Arsenal,
|
||||||
|
GameCategory::Flights,
|
||||||
|
];
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
GameCategory::Weapons => "Weapons",
|
||||||
|
GameCategory::Craft => "Craft",
|
||||||
|
GameCategory::Vessels => "Capital Ships",
|
||||||
|
GameCategory::Characters => "Characters",
|
||||||
|
GameCategory::Missions => "Missions",
|
||||||
|
GameCategory::Arsenal => "Arsenal",
|
||||||
|
GameCategory::Flights => "Flights",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A character row, name resolved via localization.
|
||||||
|
pub struct CharRow {
|
||||||
|
pub name: String,
|
||||||
|
pub faction: String,
|
||||||
|
pub faces: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mission row: stage + its resolved briefing.
|
||||||
|
pub struct MissionRow {
|
||||||
|
pub id: String,
|
||||||
|
pub location: String,
|
||||||
|
pub phases: u32,
|
||||||
|
pub objectives: Vec<String>,
|
||||||
|
pub lose: Vec<String>,
|
||||||
|
pub enemies: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the Game Data browser shows, decoded off-thread from the paks.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct GameSnapshot {
|
||||||
|
pub weapons: Vec<sylpheed_formats::game_data::Weapon>,
|
||||||
|
pub craft: Vec<sylpheed_formats::game_data::CraftUnit>,
|
||||||
|
pub vessels: Vec<sylpheed_formats::game_data::Vessel>,
|
||||||
|
pub characters: Vec<CharRow>,
|
||||||
|
pub missions: Vec<MissionRow>,
|
||||||
|
pub arsenal: sylpheed_formats::game_data::Arsenal,
|
||||||
|
/// Distinct flight line-ups: `(callsign, pilot)` rows.
|
||||||
|
pub flights: Vec<Vec<(String, String)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Game Data browser state (decoded combat / mission / cast tables).
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct GameData {
|
||||||
|
pub open: bool,
|
||||||
|
pub loading: bool,
|
||||||
|
pub loaded: bool,
|
||||||
|
pub category: GameCategory,
|
||||||
|
pub filter: String,
|
||||||
|
pub snapshot: GameSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the loader to decode the game-data tables.
|
||||||
|
#[derive(Event, Default)]
|
||||||
|
pub struct RequestGameData;
|
||||||
|
|
||||||
|
/// One assemblable ship in the Ships browser: an XBG7 part family reconstructed
|
||||||
|
/// into a whole model, with any linked [`sylpheed_formats::game_data::Vessel`]
|
||||||
|
/// stats.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ShipRow {
|
||||||
|
/// Resource-family id, e.g. `e106`.
|
||||||
|
pub id: String,
|
||||||
|
/// `ADAN` / `TCAF` / `Neutral` / `Other`.
|
||||||
|
pub faction: String,
|
||||||
|
/// Display name (from the linked Vessel, else the id).
|
||||||
|
pub name: String,
|
||||||
|
/// The stage container to render from, e.g. `hidden/resource3d/Stage_S02.xpr`.
|
||||||
|
pub stage_file: String,
|
||||||
|
/// Stage labels the ship appears in (`S02`, `S04`).
|
||||||
|
pub stages: Vec<String>,
|
||||||
|
/// Base part resource names to draw together.
|
||||||
|
pub parts: Vec<String>,
|
||||||
|
/// True when the id matched a `Vessel` recipe (a real capital ship).
|
||||||
|
pub has_vessel: bool,
|
||||||
|
pub hp: Option<f32>,
|
||||||
|
pub size: Option<(f32, f32, f32)>,
|
||||||
|
pub turrets: Option<i64>,
|
||||||
|
pub bridges: Option<i64>,
|
||||||
|
pub shield_gens: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Ships browser state — capital ships assembled from XBG7 part families.
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct ShipBrowser {
|
||||||
|
pub open: bool,
|
||||||
|
pub loading: bool,
|
||||||
|
pub loaded: bool,
|
||||||
|
pub filter: String,
|
||||||
|
/// Faction filter: "" = all, else "ADAN"/"TCAF"/"Neutral".
|
||||||
|
pub faction: String,
|
||||||
|
pub rows: Vec<ShipRow>,
|
||||||
|
/// Family id of the ship currently rendered (for row highlight).
|
||||||
|
pub selected: Option<String>,
|
||||||
|
/// Also place the external parts: bridge / shield generators / the engine
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
#[derive(Event, Default)]
|
||||||
|
pub struct RequestShipCatalog;
|
||||||
|
|
||||||
|
/// Ask the loader to assemble + render one ship's part family.
|
||||||
|
#[derive(Event)]
|
||||||
|
pub struct RequestShipRender {
|
||||||
|
/// Stage container path to read the parts from.
|
||||||
|
pub file: String,
|
||||||
|
/// Ship family id (`e106`) — drives the scene-graph assembly.
|
||||||
|
pub id: String,
|
||||||
|
/// Also place the approximate external hardpoint parts.
|
||||||
|
pub external: bool,
|
||||||
|
/// Display label for the model info line.
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// The currently-open IPFB data pack, shown as a master-detail browser.
|
/// The currently-open IPFB data pack, shown as a master-detail browser.
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
pub struct PakView {
|
pub struct PakView {
|
||||||
@@ -593,6 +752,10 @@ enum IsoLoaderMsg {
|
|||||||
VoiceLibraryLoaded {
|
VoiceLibraryLoaded {
|
||||||
clips: Vec<sylpheed_formats::slb::VoiceClip>,
|
clips: Vec<sylpheed_formats::slb::VoiceClip>,
|
||||||
},
|
},
|
||||||
|
/// The assembled-ship catalog for the Ships browser.
|
||||||
|
ShipCatalogLoaded(Vec<ShipRow>),
|
||||||
|
/// The decoded game-data tables for the browser.
|
||||||
|
GameDataLoaded(Box<GameSnapshot>),
|
||||||
/// User dismissed the file dialog — not an error.
|
/// User dismissed the file dialog — not an error.
|
||||||
Cancelled,
|
Cancelled,
|
||||||
Error(String),
|
Error(String),
|
||||||
@@ -810,10 +973,15 @@ impl Plugin for IsoLoaderPlugin {
|
|||||||
.init_resource::<MovieVoice>()
|
.init_resource::<MovieVoice>()
|
||||||
.init_resource::<AudioPreview>()
|
.init_resource::<AudioPreview>()
|
||||||
.init_resource::<VoiceLibrary>()
|
.init_resource::<VoiceLibrary>()
|
||||||
|
.init_resource::<GameData>()
|
||||||
|
.init_resource::<ShipBrowser>()
|
||||||
.add_event::<RequestSubtitles>()
|
.add_event::<RequestSubtitles>()
|
||||||
.add_event::<RequestVoice>()
|
.add_event::<RequestVoice>()
|
||||||
.add_event::<RequestAudio>()
|
.add_event::<RequestAudio>()
|
||||||
.add_event::<RequestVoiceLibrary>();
|
.add_event::<RequestVoiceLibrary>()
|
||||||
|
.add_event::<RequestGameData>()
|
||||||
|
.add_event::<RequestShipCatalog>()
|
||||||
|
.add_event::<RequestShipRender>();
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
@@ -842,6 +1010,9 @@ impl Plugin for IsoLoaderPlugin {
|
|||||||
handle_audio_request,
|
handle_audio_request,
|
||||||
advance_audio_playback,
|
advance_audio_playback,
|
||||||
handle_voice_library_request,
|
handle_voice_library_request,
|
||||||
|
handle_game_data_request,
|
||||||
|
handle_ship_catalog_request,
|
||||||
|
handle_ship_render_request,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.in_set(IsoLoaderSystemSet),
|
.in_set(IsoLoaderSystemSet),
|
||||||
@@ -1681,6 +1852,8 @@ fn poll_loader_channel(
|
|||||||
mut mvoice: ResMut<MovieVoice>,
|
mut mvoice: ResMut<MovieVoice>,
|
||||||
mut audio_preview: ResMut<AudioPreview>,
|
mut audio_preview: ResMut<AudioPreview>,
|
||||||
mut voice_lib: ResMut<VoiceLibrary>,
|
mut voice_lib: ResMut<VoiceLibrary>,
|
||||||
|
mut game_data: ResMut<GameData>,
|
||||||
|
mut ships: ResMut<ShipBrowser>,
|
||||||
) {
|
) {
|
||||||
let receiver = channels.receiver.lock().unwrap();
|
let receiver = channels.receiver.lock().unwrap();
|
||||||
loop {
|
loop {
|
||||||
@@ -1793,6 +1966,23 @@ fn poll_loader_channel(
|
|||||||
voice_lib.loaded = true;
|
voice_lib.loaded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(IsoLoaderMsg::GameDataLoaded(snap)) => {
|
||||||
|
game_data.loading = false;
|
||||||
|
// An empty snapshot means the decode failed (no game source, or a
|
||||||
|
// bare pak) — leave `loaded=false` so re-opening retries.
|
||||||
|
let empty = snap.weapons.is_empty() && snap.craft.is_empty() && snap.missions.is_empty();
|
||||||
|
if empty {
|
||||||
|
game_data.loaded = false;
|
||||||
|
} else {
|
||||||
|
game_data.snapshot = *snap;
|
||||||
|
game_data.loaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(IsoLoaderMsg::ShipCatalogLoaded(rows)) => {
|
||||||
|
ships.loading = false;
|
||||||
|
ships.loaded = !rows.is_empty();
|
||||||
|
ships.rows = rows;
|
||||||
|
}
|
||||||
Ok(IsoLoaderMsg::Cancelled) => {
|
Ok(IsoLoaderMsg::Cancelled) => {
|
||||||
iso_state.loading = false;
|
iso_state.loading = false;
|
||||||
browser.loading = false;
|
browser.loading = false;
|
||||||
@@ -2220,6 +2410,25 @@ fn pack_metallic_roughness(spc: Option<&Image>, gls: Option<&Image>) -> Option<I
|
|||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
|
fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
|
||||||
|
prepare_models_impl(bytes, models, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assemble several XBG7 resources into ONE whole model in a shared coordinate
|
||||||
|
/// frame — the capital-ship case, where each part (`e106_bdy_01`, `e106_brg_01`,
|
||||||
|
/// …) already carries its ship-local position. Unlike the stage path, the parts
|
||||||
|
/// are NOT laid out in a grid and are recentred by a single shared bbox so their
|
||||||
|
/// relative placement is preserved. See [`sylpheed_formats::ship`].
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn prepare_ship(bytes: &[u8], parts: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
|
||||||
|
prepare_models_impl(bytes, parts, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn prepare_models_impl(
|
||||||
|
bytes: &[u8],
|
||||||
|
models: &[sylpheed_formats::mesh::Xbg7Model],
|
||||||
|
assemble: bool,
|
||||||
|
) -> PreparedXpr {
|
||||||
use bevy::render::mesh::{Indices, PrimitiveTopology};
|
use bevy::render::mesh::{Indices, PrimitiveTopology};
|
||||||
use sylpheed_formats::texture::X360Texture;
|
use sylpheed_formats::texture::X360Texture;
|
||||||
|
|
||||||
@@ -2227,7 +2436,9 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
|||||||
const GAP: f32 = 4.0;
|
const GAP: f32 = 4.0;
|
||||||
const MAX_EXTENT: f32 = 20_000.0; // drop skybox planes / stray-vertex anchors
|
const MAX_EXTENT: f32 = 20_000.0; // drop skybox planes / stray-vertex anchors
|
||||||
let pitch = CELL + GAP;
|
let pitch = CELL + GAP;
|
||||||
let is_stage = models.len() > 1;
|
// Grid layout only for a true multi-model stage browse — an assembled ship
|
||||||
|
// keeps its parts in one shared space.
|
||||||
|
let is_stage = models.len() > 1 && !assemble;
|
||||||
|
|
||||||
// Keep models with real, bounded geometry.
|
// Keep models with real, bounded geometry.
|
||||||
let drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = models
|
let drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = models
|
||||||
@@ -2324,6 +2535,25 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
|||||||
let mut total_v = 0usize;
|
let mut total_v = 0usize;
|
||||||
let mut total_t = 0usize;
|
let mut total_t = 0usize;
|
||||||
|
|
||||||
|
// Assembled ship: one shared recentre across ALL parts so each part keeps its
|
||||||
|
// ship-local offset (bridge fore, engines aft). Computed from the raw parts —
|
||||||
|
// ship parts carry no node transform, so raw == placed.
|
||||||
|
let shared_center = if assemble {
|
||||||
|
let mut lo = Vec3::splat(f32::MAX);
|
||||||
|
let mut hi = Vec3::splat(f32::MIN);
|
||||||
|
for m in &drawn {
|
||||||
|
for sub in &m.meshes {
|
||||||
|
for p in &sub.positions {
|
||||||
|
lo = lo.min(Vec3::from_array(*p));
|
||||||
|
hi = hi.max(Vec3::from_array(*p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(lo.x <= hi.x).then(|| (lo + hi) * 0.5)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
for (i, model) in drawn.iter().enumerate() {
|
for (i, model) in drawn.iter().enumerate() {
|
||||||
// Per-model bbox → recentre + (stage) uniform scale to the grid cell.
|
// Per-model bbox → recentre + (stage) uniform scale to the grid cell.
|
||||||
let mut lo = Vec3::splat(f32::MAX);
|
let mut lo = Vec3::splat(f32::MAX);
|
||||||
@@ -2337,7 +2567,9 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
|||||||
if lo.x > hi.x {
|
if lo.x > hi.x {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let center = (lo + hi) * 0.5;
|
// An assembled ship uses the shared centre (preserve relative placement);
|
||||||
|
// otherwise recentre each model on its own bbox.
|
||||||
|
let center = shared_center.unwrap_or((lo + hi) * 0.5);
|
||||||
let (scale, cell) = if is_stage {
|
let (scale, cell) = if is_stage {
|
||||||
let extent = (hi - lo).max_element().max(1e-3);
|
let extent = (hi - lo).max_element().max(1e-3);
|
||||||
let col = i % cols;
|
let col = i % cols;
|
||||||
@@ -2500,7 +2732,17 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
|||||||
} else {
|
} else {
|
||||||
CELL
|
CELL
|
||||||
};
|
};
|
||||||
let (name, info) = if is_stage {
|
let (name, info) = if assemble {
|
||||||
|
(
|
||||||
|
"ship".to_string(),
|
||||||
|
format!(
|
||||||
|
"Assembled ship · {} parts · {} verts · {} tris",
|
||||||
|
drawn.len(),
|
||||||
|
total_v,
|
||||||
|
total_t
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if is_stage {
|
||||||
(
|
(
|
||||||
"stage".to_string(),
|
"stage".to_string(),
|
||||||
format!(
|
format!(
|
||||||
@@ -3213,29 +3455,40 @@ fn decode_riffs_to_wav(riffs: Vec<Vec<u8>>, tag: &str, duration: f32) -> Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Categorical fallback voice token for a hokyu (resupply) cutscene the manifest
|
/// Voice token for a hokyu (resupply) cutscene the manifest leaves unbound.
|
||||||
/// leaves unbound. Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`;
|
///
|
||||||
/// the game reuses those 5 generic recordings across stages, keyed by
|
/// Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`; the rest reuse
|
||||||
/// ship (`LS`/`DS`) × resupply source (carrier `…A` / tanker `…H`). The four
|
/// those 5 recordings. The selector is the cutscene's **demo id** (from its
|
||||||
/// category→recording pairs are read off the bound examples; `LS`/carrier has two
|
/// subtitle track), NOT the ship/source category: `hokyu_LS_s02A` and
|
||||||
/// takes (450 from s02A, 451 from s09A) — we default unbound LS/carrier to 450.
|
/// `hokyu_LS_s11A` are both LS/carrier but use demos 600 vs 601 → `VOICE_D_450`
|
||||||
/// Returns `None` for non-hokyu movies (they resolve via the manifest only).
|
/// vs `_451` (their lines differ: "Rhino 3 has landed" vs "Rhino Leader has
|
||||||
fn hokyu_fallback_token(movie: &str) -> Option<String> {
|
/// landed"). We derive the demo→token map from the 5 BOUND hokyu (each has both a
|
||||||
|
/// subtitle demo id and a VOICETRACK), then look up the target movie's demo id.
|
||||||
|
/// Returns `None` for hokyu with no voice cue (`hokyu_LS_s24A`/`s27A` — correctly
|
||||||
|
/// silent) and for non-hokyu movies (they resolve via the manifest only).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn hokyu_voice_token(
|
||||||
|
source: &SourceKind,
|
||||||
|
movie: &str,
|
||||||
|
lang: sylpheed_formats::slb::VoiceLang,
|
||||||
|
manifest: &[u8],
|
||||||
|
) -> Option<String> {
|
||||||
|
use sylpheed_formats::{movie_manifest, movie_subtitle as ms};
|
||||||
if !movie.starts_with("hokyu_") {
|
if !movie.starts_with("hokyu_") {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let ls = movie.contains("_LS_");
|
let lang_pak =
|
||||||
let ds = movie.contains("_DS_");
|
read_pak_archive_blocking(source, &format!("dat/movie/{}.pak", lang.code_pub())).ok()?;
|
||||||
let carrier = movie.ends_with('A');
|
// The target cutscene's demo id (its subtitle's single voice cue).
|
||||||
let tanker = movie.ends_with('H');
|
let want = ms::track_voice_cues(&lang_pak, movie).first().map(|&(d, _)| d)?;
|
||||||
let token = match (ls, ds, carrier, tanker) {
|
// Match it against a bound hokyu carrying the same demo id → that VOICETRACK.
|
||||||
(true, _, true, _) => "VOICE_D_450", // light ship, carrier resupply
|
movie_manifest::parse(manifest).into_iter().find_map(|e| {
|
||||||
(true, _, _, true) => "VOICE_D_453", // light ship, tanker resupply
|
let tok = e.voice_token.filter(|_| e.movie.starts_with("hokyu_"))?;
|
||||||
(_, true, true, _) => "VOICE_D_452", // Delta Saber, carrier resupply
|
ms::track_voice_cues(&lang_pak, &e.movie)
|
||||||
(_, true, _, true) => "VOICE_D_454", // Delta Saber, tanker resupply
|
.iter()
|
||||||
_ => return None,
|
.any(|&(d, _)| d == want)
|
||||||
};
|
.then_some(tok)
|
||||||
Some(token.to_string())
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a movie's cutscene voice to a **continuous byte region** of the
|
/// Resolve a movie's cutscene voice to a **continuous byte region** of the
|
||||||
@@ -3264,7 +3517,7 @@ fn resolve_movie_voice_region(
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?;
|
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?;
|
||||||
let token = movie_manifest::voice_token(&manifest, movie)
|
let token = movie_manifest::voice_token(&manifest, movie)
|
||||||
.or_else(|| hokyu_fallback_token(movie))?;
|
.or_else(|| hokyu_voice_token(source, movie, lang, &manifest))?;
|
||||||
// token → sound-id via the master registry (the large per-language IDXD entry
|
// token → sound-id via the master registry (the large per-language IDXD entry
|
||||||
// carrying the `<lang>\Movie\VOICE_*.slb` paths).
|
// carrying the `<lang>\Movie\VOICE_*.slb` paths).
|
||||||
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
|
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
|
||||||
@@ -3393,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> {
|
||||||
@@ -3451,6 +3738,380 @@ fn handle_audio_request(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles a [`RequestGameData`]: decodes the combat / mission / cast tables
|
||||||
|
/// off-thread and posts the snapshot back for the browser.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn handle_game_data_request(
|
||||||
|
mut events: EventReader<RequestGameData>,
|
||||||
|
iso_state: Res<IsoState>,
|
||||||
|
channels: Res<IsoChannels>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let source = iso_state.source_kind.clone();
|
||||||
|
let sender = channels.sender.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let snap = build_game_snapshot(&source).unwrap_or_default();
|
||||||
|
let _ = sender.send(IsoLoaderMsg::GameDataLoaded(Box::new(snap)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the game-data tables into a browser snapshot (English pak + hangar pak).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn build_game_snapshot(source: &SourceKind) -> Option<GameSnapshot> {
|
||||||
|
use sylpheed_formats::{game_data as gd, localization::TextIndex};
|
||||||
|
let main = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak").ok()?;
|
||||||
|
let text = TextIndex::build(&main);
|
||||||
|
|
||||||
|
let mut weapons = gd::load_weapons(&main);
|
||||||
|
weapons.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
let mut craft = gd::load_units(&main);
|
||||||
|
craft.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
let mut vessels = gd::load_vessels(&main);
|
||||||
|
vessels.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
|
||||||
|
let mut characters: Vec<CharRow> = gd::load_characters(&main)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|c| {
|
||||||
|
let id = c.id?;
|
||||||
|
let short = id.trim_start_matches("Character").to_string();
|
||||||
|
let name = text.character_name(&short).unwrap_or(&short).to_string();
|
||||||
|
Some(CharRow { name, faction: c.faction.unwrap_or_default(), faces: c.faces.len() })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
characters.sort_by(|a, b| (a.faction.clone(), a.name.clone()).cmp(&(b.faction.clone(), b.name.clone())));
|
||||||
|
|
||||||
|
// Combat rosters, keyed by stage where the table self-identifies.
|
||||||
|
let rosters = gd::load_unit_rosters(&main);
|
||||||
|
let stat_hp = |id: &str| -> Option<f32> {
|
||||||
|
craft.iter().find(|u| u.id.as_deref() == Some(id)).and_then(|u| u.hp)
|
||||||
|
.or_else(|| vessels.iter().find(|v| v.id.as_deref() == Some(id)).and_then(|v| v.hp))
|
||||||
|
};
|
||||||
|
let mut missions: Vec<MissionRow> = Vec::new();
|
||||||
|
for s in gd::load_stages(&main) {
|
||||||
|
// main + extra story stages only (skip the Test stage)
|
||||||
|
if s.id.len() != 3 || !s.id.starts_with('S') || s.id[1..].parse::<u32>().is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let objectives: Vec<String> =
|
||||||
|
(1..=s.phases).flat_map(|p| text.objectives(&s.id, p)).map(str::to_string).collect();
|
||||||
|
let lose: Vec<String> =
|
||||||
|
(1..=s.phases).flat_map(|p| text.lose_conditions(&s.id, p)).map(str::to_string).collect();
|
||||||
|
let enemies: Vec<String> = rosters
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.stage.as_deref() == Some(s.id.as_str()))
|
||||||
|
.map(|r| {
|
||||||
|
r.units.iter().filter(|u| u.contains("ADAN")).map(|u| {
|
||||||
|
let name = u.trim_start_matches("UN_").splitn(3, '_').nth(2).unwrap_or(u).replace('_', " ");
|
||||||
|
match stat_hp(u) {
|
||||||
|
Some(h) => format!("{name} ({h:.0} HP)"),
|
||||||
|
None => name,
|
||||||
|
}
|
||||||
|
}).collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
missions.push(MissionRow {
|
||||||
|
id: s.id.clone(),
|
||||||
|
location: s.location.unwrap_or_default().replace('_', " "),
|
||||||
|
phases: s.phases,
|
||||||
|
objectives,
|
||||||
|
lose,
|
||||||
|
enemies,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
missions.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
|
||||||
|
// Arsenal + flights from the hangar pak (optional).
|
||||||
|
let (arsenal, flights) = read_pak_archive_blocking(source, "dat/GP_HANGAR_ARSENAL.pak")
|
||||||
|
.map(|h| {
|
||||||
|
let arsenal = gd::load_arsenal(&h);
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
let mut flights = Vec::new();
|
||||||
|
for r in gd::load_pilot_rosters(&h) {
|
||||||
|
let key: String = r.pilots.iter().map(|(c, p)| format!("{c}:{p}")).collect::<Vec<_>>().join(",");
|
||||||
|
if seen.insert(key) {
|
||||||
|
flights.push(r.pilots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(arsenal, flights)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Some(GameSnapshot { weapons, craft, vessels, characters, missions, arsenal, flights })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a [`RequestShipCatalog`]: scans the stage containers and reconstructs
|
||||||
|
/// the assemblable ship catalog off-thread.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn handle_ship_catalog_request(
|
||||||
|
mut events: EventReader<RequestShipCatalog>,
|
||||||
|
iso_state: Res<IsoState>,
|
||||||
|
channels: Res<IsoChannels>,
|
||||||
|
mut ships: ResMut<ShipBrowser>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ships.loaded || ships.loading {
|
||||||
|
return; // already built / building
|
||||||
|
}
|
||||||
|
ships.loading = true;
|
||||||
|
let source = iso_state.source_kind.clone();
|
||||||
|
let sender = channels.sender.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rows = build_ship_catalog(&source);
|
||||||
|
let _ = sender.send(IsoLoaderMsg::ShipCatalogLoaded(rows));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan every `Stage_SNN.xpr` container, group XBG7 resources into whole ships,
|
||||||
|
/// and join each to its [`sylpheed_formats::game_data::Vessel`] stats.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn build_ship_catalog(source: &SourceKind) -> Vec<ShipRow> {
|
||||||
|
use sylpheed_formats::game_data::{self as gd, Vessel};
|
||||||
|
use sylpheed_formats::ship::ships_in_container;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
// Vessel stats keyed by family id (`rou_e105` → `e105`).
|
||||||
|
let vessels: Vec<Vessel> = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak")
|
||||||
|
.map(|p| gd::load_vessels(&p))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let vmap: BTreeMap<String, &Vessel> = vessels
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| {
|
||||||
|
let id = v.model.as_deref()?.trim_start_matches("rou_").to_string();
|
||||||
|
Some((id, v))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// A capital-ship-ish display name from the Vessel id (`UN_e105_ADAN_Cruiser`
|
||||||
|
// → `Cruiser`), else the family id.
|
||||||
|
let pretty = |id: &str, v: Option<&&Vessel>| -> String {
|
||||||
|
if let Some(v) = v {
|
||||||
|
if let Some(vid) = &v.id {
|
||||||
|
// UN_<id>_<FACTION>_<Class…>
|
||||||
|
if let Some(rest) = vid.strip_prefix("UN_") {
|
||||||
|
let parts: Vec<&str> = rest.splitn(3, '_').collect();
|
||||||
|
if parts.len() == 3 {
|
||||||
|
return parts[2].replace('_', " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut agg: BTreeMap<String, ShipRow> = BTreeMap::new();
|
||||||
|
for n in 1..=30u32 {
|
||||||
|
let file = format!("hidden/resource3d/Stage_S{n:02}.xpr");
|
||||||
|
let Ok(bytes) = read_source_file(source, &file) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let label = format!("S{n:02}");
|
||||||
|
for sm in ships_in_container(&bytes) {
|
||||||
|
// Skip single-piece props / debris — not "whole ships" — and any
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
let v = vmap.get(&sm.id);
|
||||||
|
let entry = agg.entry(sm.id.clone()).or_insert_with(|| ShipRow {
|
||||||
|
id: sm.id.clone(),
|
||||||
|
faction: sm.faction.label().to_string(),
|
||||||
|
name: pretty(&sm.id, v),
|
||||||
|
stage_file: file.clone(),
|
||||||
|
stages: Vec::new(),
|
||||||
|
parts: sm.parts.clone(),
|
||||||
|
has_vessel: v.is_some(),
|
||||||
|
hp: v.and_then(|v| v.hp),
|
||||||
|
size: v.and_then(|v| Some((v.size_x?, v.size_y?, v.size_z?))),
|
||||||
|
turrets: v.and_then(|v| v.turret_count),
|
||||||
|
bridges: v.and_then(|v| v.bridge_count),
|
||||||
|
shield_gens: v.and_then(|v| v.shield_generator_count),
|
||||||
|
});
|
||||||
|
if !entry.stages.contains(&label) {
|
||||||
|
entry.stages.push(label.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows: Vec<ShipRow> = agg.into_values().collect();
|
||||||
|
// Real capital ships (Vessel-matched) first, then by faction, then id.
|
||||||
|
rows.sort_by(|a, b| {
|
||||||
|
b.has_vessel
|
||||||
|
.cmp(&a.has_vessel)
|
||||||
|
.then(a.faction.cmp(&b.faction))
|
||||||
|
.then(a.id.cmp(&b.id))
|
||||||
|
});
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a [`RequestShipRender`]: assembles the ship's part family into one
|
||||||
|
/// world-space model off-thread and posts it through the shared XPR render path.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn handle_ship_render_request(
|
||||||
|
mut events: EventReader<RequestShipRender>,
|
||||||
|
iso_state: Res<IsoState>,
|
||||||
|
channels: Res<IsoChannels>,
|
||||||
|
mut xpr_load: ResMut<XprLoadState>,
|
||||||
|
mut browser: ResMut<FileBrowserState>,
|
||||||
|
) {
|
||||||
|
let Some(req) = events.read().last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (file, id, external, label) =
|
||||||
|
(req.file.clone(), req.id.clone(), req.external, req.label.clone());
|
||||||
|
|
||||||
|
// Supersede any in-flight XPR/stage decode, exactly like a file selection.
|
||||||
|
xpr_load.generation = xpr_load.generation.wrapping_add(1);
|
||||||
|
if let Some(prev) = xpr_load.cancel.take() {
|
||||||
|
prev.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
xpr_load.cancel = Some(cancel.clone());
|
||||||
|
let generation = xpr_load.generation;
|
||||||
|
browser.loading = true;
|
||||||
|
|
||||||
|
let source = iso_state.source_kind.clone();
|
||||||
|
let sender = channels.sender.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let prepared = build_ship_model(&source, &file, &id, external, &label, &cancel);
|
||||||
|
let _ = sender.send(IsoLoaderMsg::XprPrepared {
|
||||||
|
generation,
|
||||||
|
prepared: Box::new(prepared),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a stage container, resolve the ship's scene-graph placement, decode only
|
||||||
|
/// the referenced parts, transform each into its ship-local pose, and assemble
|
||||||
|
/// them into a single world-space model (reusing the XPR model prepare path).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn build_ship_model(
|
||||||
|
source: &SourceKind,
|
||||||
|
file: &str,
|
||||||
|
id: &str,
|
||||||
|
external: bool,
|
||||||
|
label: &str,
|
||||||
|
cancel: &Arc<AtomicBool>,
|
||||||
|
) -> PreparedXpr {
|
||||||
|
use sylpheed_formats::mesh::Xbg7Model;
|
||||||
|
let bytes = match read_source_file(source, file) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return PreparedXpr::Info(format!("Could not read {file}: {e}")),
|
||||||
|
};
|
||||||
|
let should_cancel = || cancel.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Placement: fully static scene-graph assembly — exact, validated
|
||||||
|
// 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);
|
||||||
|
if placed.is_empty() {
|
||||||
|
return PreparedXpr::Info(format!("No parts found for {label}."));
|
||||||
|
}
|
||||||
|
// Decode each distinct referenced resource once.
|
||||||
|
let wanted: std::collections::HashSet<String> =
|
||||||
|
placed.iter().map(|p| p.resource.clone()).collect();
|
||||||
|
let base = Xbg7Model::models_named(&bytes, &wanted, &should_cancel);
|
||||||
|
if should_cancel() {
|
||||||
|
return PreparedXpr::Cancelled;
|
||||||
|
}
|
||||||
|
if base.is_empty() {
|
||||||
|
return PreparedXpr::Info(format!("No geometry decoded for {label}."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One transformed model per placement: bake the part's world pose into its
|
||||||
|
// vertices (positions by the full affine, normals by the rotation) so the
|
||||||
|
// shared prepare path can draw it in place. A resource placed at several
|
||||||
|
// hardpoints yields several posed copies. The name is kept so `material_groups`
|
||||||
|
// still finds the part's textures.
|
||||||
|
let rot = |m: &[[f32; 3]; 3], n: &[f32; 3]| {
|
||||||
|
[
|
||||||
|
m[0][0] * n[0] + m[0][1] * n[1] + m[0][2] * n[2],
|
||||||
|
m[1][0] * n[0] + m[1][1] * n[1] + m[1][2] * n[2],
|
||||||
|
m[2][0] * n[0] + m[2][1] * n[1] + m[2][2] * n[2],
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let mut models: Vec<Xbg7Model> = Vec::with_capacity(placed.len());
|
||||||
|
for p in &placed {
|
||||||
|
let Some(src) = base.iter().find(|m| m.name == p.resource) else {
|
||||||
|
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();
|
||||||
|
for sub in &mut m.meshes {
|
||||||
|
for v in &mut sub.positions {
|
||||||
|
*v = p.apply(*v);
|
||||||
|
}
|
||||||
|
for nrm in &mut sub.normals {
|
||||||
|
*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);
|
||||||
|
}
|
||||||
|
if models.is_empty() {
|
||||||
|
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.
|
||||||
|
match prepare_ship(&bytes, &models) {
|
||||||
|
PreparedXpr::Model {
|
||||||
|
meshes,
|
||||||
|
materials,
|
||||||
|
focus,
|
||||||
|
radius,
|
||||||
|
min_radius,
|
||||||
|
max_radius,
|
||||||
|
info,
|
||||||
|
..
|
||||||
|
} => PreparedXpr::Model {
|
||||||
|
meshes,
|
||||||
|
materials,
|
||||||
|
focus,
|
||||||
|
radius,
|
||||||
|
min_radius,
|
||||||
|
max_radius,
|
||||||
|
name: label.to_string(),
|
||||||
|
info,
|
||||||
|
},
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and
|
/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and
|
||||||
/// enumerates the voice clips off-thread.
|
/// enumerates the voice clips off-thread.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ use bevy::prelude::*;
|
|||||||
use bevy_egui::{egui, EguiContexts};
|
use bevy_egui::{egui, EguiContexts};
|
||||||
|
|
||||||
use crate::iso_loader::{
|
use crate::iso_loader::{
|
||||||
AudioPreview, FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, MovieSubtitles,
|
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
|
||||||
MovieVoice, PakContent, PakView, RequestAudio, RequestOpenDir, RequestOpenIso, RequestSubtitles,
|
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
|
||||||
RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, VoiceLibrary,
|
RequestOpenIso, RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary,
|
||||||
IsoLoaderSystemSet,
|
ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
|
||||||
|
VideoPreview, VoiceLibrary, IsoLoaderSystemSet,
|
||||||
};
|
};
|
||||||
use crate::ViewerState;
|
use crate::ViewerState;
|
||||||
use sylpheed_formats::SubLang;
|
use sylpheed_formats::SubLang;
|
||||||
@@ -25,6 +26,8 @@ impl Plugin for ViewerUiPlugin {
|
|||||||
app.insert_resource(FileBrowserState::default());
|
app.insert_resource(FileBrowserState::default());
|
||||||
// Run after the iso_loader chain so we see the frame's final state.
|
// Run after the iso_loader chain so we see the frame's final state.
|
||||||
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
|
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
|
||||||
|
app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet));
|
||||||
|
app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +139,8 @@ struct UiEvents<'w> {
|
|||||||
subtitles: EventWriter<'w, RequestSubtitles>,
|
subtitles: EventWriter<'w, RequestSubtitles>,
|
||||||
audio: EventWriter<'w, RequestAudio>,
|
audio: EventWriter<'w, RequestAudio>,
|
||||||
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
|
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
|
||||||
|
game_data: EventWriter<'w, RequestGameData>,
|
||||||
|
ships: EventWriter<'w, RequestShipCatalog>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_viewer_ui(
|
fn draw_viewer_ui(
|
||||||
@@ -193,6 +198,18 @@ fn draw_viewer_ui(
|
|||||||
}
|
}
|
||||||
ui.close_menu();
|
ui.close_menu();
|
||||||
}
|
}
|
||||||
|
if ui.button("🗃 Game Data…").clicked() {
|
||||||
|
// The Game Data window (its own system) reads this event to
|
||||||
|
// open + lazily decode the tables.
|
||||||
|
events.game_data.send_default();
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui.button("🚀 Ships…").clicked() {
|
||||||
|
// The Ships window (its own system) reads this event to open +
|
||||||
|
// lazily scan the stage containers for assemblable ships.
|
||||||
|
events.ships.send_default();
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.menu_button("Help", |ui| {
|
ui.menu_button("Help", |ui| {
|
||||||
@@ -1280,3 +1297,397 @@ fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Game Data browser ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn fnum(v: Option<f32>) -> String {
|
||||||
|
match v {
|
||||||
|
Some(x) if x == x.trunc() => format!("{}", x as i64),
|
||||||
|
Some(x) => format!("{x}"),
|
||||||
|
None => "·".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn fint(v: Option<i64>) -> String {
|
||||||
|
v.map(|x| x.to_string()).unwrap_or_else(|| "·".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Game Data browser — a floating window over the decoded IDXD tables
|
||||||
|
/// (weapons, craft, ships, cast, missions, arsenal, flights). Its own system so
|
||||||
|
/// `draw_viewer_ui` stays under Bevy's system-parameter limit; a `RequestGameData`
|
||||||
|
/// event (from the View menu) opens it and lazily kicks off decoding.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn draw_game_data_ui(
|
||||||
|
mut contexts: EguiContexts,
|
||||||
|
mut game_data: ResMut<GameData>,
|
||||||
|
mut requests: EventReader<RequestGameData>,
|
||||||
|
) {
|
||||||
|
if requests.read().next().is_some() {
|
||||||
|
game_data.open = true;
|
||||||
|
if !game_data.loaded {
|
||||||
|
game_data.loading = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !game_data.open {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ctx = contexts.ctx_mut().clone();
|
||||||
|
let mut open = true;
|
||||||
|
egui::Window::new("🗃 Game Data")
|
||||||
|
.default_width(700.0)
|
||||||
|
.default_height(560.0)
|
||||||
|
.open(&mut open)
|
||||||
|
.show(&ctx, |ui| {
|
||||||
|
if game_data.loading {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spinner();
|
||||||
|
ui.label("Decoding game tables…");
|
||||||
|
});
|
||||||
|
ctx.request_repaint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !game_data.loaded {
|
||||||
|
ui.label("Open a game source first (File ▸ Open…).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let GameData { category, filter, snapshot, .. } = &mut *game_data;
|
||||||
|
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
for cat in GameCategory::ALL {
|
||||||
|
ui.selectable_value(category, cat, cat.label());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let filterable = !matches!(category, GameCategory::Arsenal | GameCategory::Flights);
|
||||||
|
if filterable {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("🔍");
|
||||||
|
ui.text_edit_singleline(filter);
|
||||||
|
if !filter.is_empty() && ui.small_button("✖").clicked() {
|
||||||
|
filter.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
let f = filter.to_lowercase();
|
||||||
|
let hit = |s: &str| f.is_empty() || s.to_lowercase().contains(&f);
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match *category {
|
||||||
|
GameCategory::Weapons => {
|
||||||
|
egui::Grid::new("g_weap").striped(true).num_columns(6).show(ui, |ui| {
|
||||||
|
for h in ["Weapon", "Targets", "Power", "Velocity", "Range", "Reload"] {
|
||||||
|
ui.strong(h);
|
||||||
|
}
|
||||||
|
ui.end_row();
|
||||||
|
for w in &snapshot.weapons {
|
||||||
|
let name = w.id.as_deref().unwrap_or("?").trim_start_matches("Weapon_");
|
||||||
|
if !hit(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ui.label(name);
|
||||||
|
ui.label(w.target_type.as_deref().unwrap_or("·"));
|
||||||
|
ui.label(fnum(w.power));
|
||||||
|
ui.label(fnum(w.velocity));
|
||||||
|
ui.label(fnum(w.max_range));
|
||||||
|
ui.label(fint(w.loading_count));
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
GameCategory::Craft => {
|
||||||
|
egui::Grid::new("g_craft").striped(true).num_columns(6).show(ui, |ui| {
|
||||||
|
for h in ["Craft", "HP", "Cruise", "Accel", "Radar", "Turrets"] {
|
||||||
|
ui.strong(h);
|
||||||
|
}
|
||||||
|
ui.end_row();
|
||||||
|
for u in &snapshot.craft {
|
||||||
|
let name = u.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
|
||||||
|
if !hit(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ui.label(name);
|
||||||
|
ui.label(fnum(u.hp));
|
||||||
|
ui.label(fnum(u.cruising_velocity));
|
||||||
|
ui.label(fnum(u.acceleration));
|
||||||
|
ui.label(fnum(u.radar_range));
|
||||||
|
ui.label(fint(u.turret_count));
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
GameCategory::Vessels => {
|
||||||
|
egui::Grid::new("g_ves").striped(true).num_columns(6).show(ui, |ui| {
|
||||||
|
for h in ["Vessel", "HP", "Length", "Turrets", "Bridges", "Shield gen"] {
|
||||||
|
ui.strong(h);
|
||||||
|
}
|
||||||
|
ui.end_row();
|
||||||
|
for v in &snapshot.vessels {
|
||||||
|
let name = v.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
|
||||||
|
if !hit(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ui.label(name);
|
||||||
|
ui.label(fnum(v.hp));
|
||||||
|
ui.label(fnum(v.size_z));
|
||||||
|
ui.label(fint(v.turret_count));
|
||||||
|
ui.label(fint(v.bridge_count));
|
||||||
|
ui.label(fint(v.shield_generator_count));
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
GameCategory::Characters => {
|
||||||
|
egui::Grid::new("g_char").striped(true).num_columns(3).show(ui, |ui| {
|
||||||
|
for h in ["Name", "Faction", "Portraits"] {
|
||||||
|
ui.strong(h);
|
||||||
|
}
|
||||||
|
ui.end_row();
|
||||||
|
for c in &snapshot.characters {
|
||||||
|
if !hit(&c.name) && !hit(&c.faction) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ui.label(&c.name);
|
||||||
|
let col = if c.faction == "TCAF" {
|
||||||
|
egui::Color32::from_rgb(90, 160, 232)
|
||||||
|
} else if c.faction == "ADAN" {
|
||||||
|
egui::Color32::from_rgb(224, 86, 122)
|
||||||
|
} else {
|
||||||
|
egui::Color32::GRAY
|
||||||
|
};
|
||||||
|
ui.colored_label(col, if c.faction.is_empty() { "—" } else { &c.faction });
|
||||||
|
ui.label(c.faces.to_string());
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
GameCategory::Missions => {
|
||||||
|
for m in &snapshot.missions {
|
||||||
|
if !hit(&m.id) && !hit(&m.location) && !m.objectives.iter().any(|o| hit(o)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let head = format!("{} · {} · {} phases", m.id, m.location, m.phases);
|
||||||
|
egui::CollapsingHeader::new(head).id_salt(&m.id).show(ui, |ui| {
|
||||||
|
if !m.objectives.is_empty() {
|
||||||
|
ui.strong("Objectives");
|
||||||
|
for o in &m.objectives {
|
||||||
|
ui.label(format!("▸ {o}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !m.lose.is_empty() {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.strong("Fail conditions");
|
||||||
|
for l in &m.lose {
|
||||||
|
ui.colored_label(egui::Color32::from_rgb(224, 86, 122), l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !m.enemies.is_empty() {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.strong("Enemy roster");
|
||||||
|
for e in &m.enemies {
|
||||||
|
ui.label(format!("• {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GameCategory::Arsenal => {
|
||||||
|
ui.columns(4, |cols| {
|
||||||
|
for (i, (title, list)) in [
|
||||||
|
("Nose", &snapshot.arsenal.nose),
|
||||||
|
("Arm 1", &snapshot.arsenal.arm1),
|
||||||
|
("Arm 2", &snapshot.arsenal.arm2),
|
||||||
|
("Arm 3", &snapshot.arsenal.arm3),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
cols[i].strong(format!("{title} ({})", list.len()));
|
||||||
|
for w in list {
|
||||||
|
cols[i].label(w.replace('_', " "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
GameCategory::Flights => {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("Distinct wingman line-ups (story order)").weak().small(),
|
||||||
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
for (n, lineup) in snapshot.flights.iter().enumerate() {
|
||||||
|
egui::CollapsingHeader::new(format!("Line-up {}", n + 1))
|
||||||
|
.id_salt(n)
|
||||||
|
.default_open(n == 0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
egui::Grid::new(("g_flight", n)).striped(true).num_columns(2).show(ui, |ui| {
|
||||||
|
for (cs, pilot) in lineup {
|
||||||
|
ui.label(cs);
|
||||||
|
ui.strong(pilot);
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
game_data.open &= open;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ships browser ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The Ships browser — a floating window over the capital ships reconstructed
|
||||||
|
/// from XBG7 part families. Picking a ship assembles its parts and renders the
|
||||||
|
/// whole model in the 3D view. Its own system (keeps `draw_viewer_ui` under the
|
||||||
|
/// parameter limit); a `RequestShipCatalog` event (View menu) opens it and lazily
|
||||||
|
/// kicks off the stage scan, while clicking a row fires `RequestShipRender`.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn draw_ships_ui(
|
||||||
|
mut contexts: EguiContexts,
|
||||||
|
mut ships: ResMut<ShipBrowser>,
|
||||||
|
mut requests: EventReader<RequestShipCatalog>,
|
||||||
|
mut render: EventWriter<RequestShipRender>,
|
||||||
|
) {
|
||||||
|
if requests.read().next().is_some() {
|
||||||
|
ships.open = true;
|
||||||
|
}
|
||||||
|
if !ships.open {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ctx = contexts.ctx_mut().clone();
|
||||||
|
let mut open = true;
|
||||||
|
egui::Window::new("🚀 Ships")
|
||||||
|
.default_width(560.0)
|
||||||
|
.default_height(560.0)
|
||||||
|
.open(&mut open)
|
||||||
|
.show(&ctx, |ui| {
|
||||||
|
if ships.loading {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spinner();
|
||||||
|
ui.label("Scanning stage models for ships…");
|
||||||
|
});
|
||||||
|
ctx.request_repaint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !ships.loaded {
|
||||||
|
ui.label("Open a game source first (File ▸ Open…).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faction filter chips.
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
for (label, val) in
|
||||||
|
[("All", ""), ("ADAN", "ADAN"), ("TCAF", "TCAF"), ("Neutral", "Neutral")]
|
||||||
|
{
|
||||||
|
let sel = ships.faction == val;
|
||||||
|
if ui.selectable_label(sel, label).clicked() {
|
||||||
|
ships.faction = val.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("🔍");
|
||||||
|
ui.text_edit_singleline(&mut ships.filter);
|
||||||
|
if !ships.filter.is_empty() && ui.small_button("✖").clicked() {
|
||||||
|
ships.filter.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.checkbox(&mut ships.show_external, "External parts (bridge/engines/turrets)");
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("(bridge / shield gens / engines — approximate placement)")
|
||||||
|
.weak()
|
||||||
|
.small(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
let ShipBrowser { rows, filter, faction, selected, show_external, .. } = &mut *ships;
|
||||||
|
let show_external = *show_external;
|
||||||
|
let needle = filter.to_lowercase();
|
||||||
|
let mut to_render: Option<(String, String, String)> = None;
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
let mut last_section = String::new();
|
||||||
|
for row in rows.iter() {
|
||||||
|
if !faction.is_empty() && &row.faction != faction {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !needle.is_empty()
|
||||||
|
&& !row.name.to_lowercase().contains(&needle)
|
||||||
|
&& !row.id.contains(&needle)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Section header: capital ships first, then other assemblies.
|
||||||
|
let section = if row.has_vessel { "Capital ships" } else { "Other assemblies" };
|
||||||
|
if section != last_section {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(egui::RichText::new(section).strong().weak());
|
||||||
|
last_section = section.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_sel = selected.as_deref() == Some(row.id.as_str());
|
||||||
|
let color = match row.faction.as_str() {
|
||||||
|
"TCAF" => egui::Color32::from_rgb(120, 170, 235),
|
||||||
|
"ADAN" => egui::Color32::from_rgb(235, 120, 120),
|
||||||
|
_ => egui::Color32::from_rgb(180, 180, 180),
|
||||||
|
};
|
||||||
|
egui::CollapsingHeader::new(
|
||||||
|
egui::RichText::new(format!("{} · {}", row.name, row.id)).color(color),
|
||||||
|
)
|
||||||
|
.id_salt(&row.id)
|
||||||
|
.default_open(false)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
egui::Grid::new(("shipstat", &row.id)).num_columns(2).show(ui, |ui| {
|
||||||
|
ui.label("Faction");
|
||||||
|
ui.strong(&row.faction);
|
||||||
|
ui.end_row();
|
||||||
|
if let Some(hp) = row.hp {
|
||||||
|
ui.label("Hull HP");
|
||||||
|
ui.strong(format!("{hp:.0}"));
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
if let Some((x, y, z)) = row.size {
|
||||||
|
ui.label("Size (m)");
|
||||||
|
ui.strong(format!("{x:.0} × {y:.0} × {z:.0}"));
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
if row.turrets.is_some() || row.shield_gens.is_some() {
|
||||||
|
ui.label("Hardpoints");
|
||||||
|
ui.strong(format!(
|
||||||
|
"{} turrets · {} bridges · {} shield gens",
|
||||||
|
fint(row.turrets),
|
||||||
|
fint(row.bridges),
|
||||||
|
fint(row.shield_gens),
|
||||||
|
));
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
ui.label("Model parts");
|
||||||
|
ui.strong(format!("{}", row.parts.len()));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Appears in");
|
||||||
|
ui.strong(row.stages.join(", "));
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
let btn = egui::Button::new(if is_sel {
|
||||||
|
"● Showing in 3D view"
|
||||||
|
} else {
|
||||||
|
"▶ Assemble & view in 3D"
|
||||||
|
});
|
||||||
|
if ui.add(btn).clicked() {
|
||||||
|
to_render = Some((
|
||||||
|
row.id.clone(),
|
||||||
|
row.stage_file.clone(),
|
||||||
|
format!("{} ({})", row.name, row.id),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some((id, file, label)) = to_render {
|
||||||
|
let id2 = id.clone();
|
||||||
|
*selected = Some(id);
|
||||||
|
render.send(RequestShipRender { file, id: id2, external: show_external, label });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ships.open &= open;
|
||||||
|
}
|
||||||
|
|||||||
71
docs/HANDOFF-ship-placement-2026-07-26.md
Normal file
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).
|
||||||
@@ -20,6 +20,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
|||||||
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) |
|
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) |
|
||||||
| 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 |
|
||||||
|
|
||||||
## Functions / code paths
|
## Functions / code paths
|
||||||
|
|
||||||
|
|||||||
180
docs/re/ship-placement-runtime-capture.md
Normal file
180
docs/re/ship-placement-runtime-capture.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Capital-ship part placement — runtime capture (ground truth)
|
||||||
|
|
||||||
|
**Status:** ✅✅ **STATIC ASSEMBLY IS EXACT — no captures needed anymore
|
||||||
|
(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
|
||||||
|
|
||||||
|
Capital ships ship as **split XBG7 parts** (hull bodies, bridge, engines, turrets)
|
||||||
|
in `hidden/resource3d/Stage_SNN.xpr`. Reconstructing the whole ship needs each
|
||||||
|
part's placement. Static reverse-engineering of the composite scene graph
|
||||||
|
(`e_rou_<id>`, `GN_*` frames) got the **hull right but external parts only
|
||||||
|
approximately** — a `GN_*` frame is the mount *pivot*, and the real outboard /
|
||||||
|
rotational offset lives in a detail sub-rig or in runtime code not recoverable
|
||||||
|
statically. See `sylpheed-formats::ship::assemble_ship` (the static best-effort).
|
||||||
|
|
||||||
|
## Finding
|
||||||
|
|
||||||
|
The **guest vertex buffer holds LOCAL coordinates** — byte-identical to the
|
||||||
|
`.xpr` (verified: `e106_bdy_04`'s first vertex `(-20.0000,153.1539,45.9997)`
|
||||||
|
matches the decode exactly). So parts are placed **entirely in the vertex
|
||||||
|
shader**; the buffer carries no placement. (Contrast: static *stage* geometry is
|
||||||
|
pre-transformed into world space and byte-matches the `.xpr` at file offsets —
|
||||||
|
that's a different case.)
|
||||||
|
|
||||||
|
The per-part transform is in the **vertex-shader float constants**. For the ship
|
||||||
|
shader (`vs=0xC7F781F4C1D58054`), constants `c0..c2` are the **WorldViewProjection**
|
||||||
|
matrix rows:
|
||||||
|
|
||||||
|
- Their norms are `(sx, sy, 1)` = the projection x/y scales; `sy/sx ≈ 1.78 = 16:9`
|
||||||
|
aspect. Dividing each row by its norm gives the rigid **WorldView** (verified:
|
||||||
|
normalized rows orthonormal to 1e-4, `det = +1`).
|
||||||
|
- The camera View cancels when expressing every part 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. Applying `rel_p` to part `p`'s local geometry
|
||||||
|
assembles the ship exactly, in the reference part's frame.
|
||||||
|
|
||||||
|
(`c4..c14` also vary per part — likely a normal matrix / a second WVP variant /
|
||||||
|
WorldView split; not needed, `c0..c2` suffice.)
|
||||||
|
|
||||||
|
## Capture instrumentation
|
||||||
|
|
||||||
|
Branch **`capture-ship-placement`** in the `xenia-canary-native` worktree (off the
|
||||||
|
`instrument-current`-descended native branch, which already has GPU `log_draws`).
|
||||||
|
|
||||||
|
- **F10** in the emulator window → `xe::gpu::RequestShipCaptureFrame()`.
|
||||||
|
- Dumps the next ≤8000 draws (de-duped by vertex-buffer address) to
|
||||||
|
**`xenia_ship_capture.log`** in the binary's dir
|
||||||
|
(`build/bin/Linux/Release/`). Per draw:
|
||||||
|
```
|
||||||
|
DRAW vbase=0x… stride=… vcount=… indices=… prim=… vs=0x…
|
||||||
|
pos: (x,y,z) … up to 64 LOCAL vertex positions (== .xpr)
|
||||||
|
vsconst base=N: c0=(x,y,z,w) c1=… … first 48 VS float4 constants
|
||||||
|
```
|
||||||
|
- Files: `src/xenia/gpu/command_processor.{cc,h}` (`CaptureShipDrawForRE`,
|
||||||
|
`RequestShipCaptureFrame`), `src/xenia/app/emulator_window.cc` (F10 key).
|
||||||
|
- Run: `run-canary-native.sh` (HW Vulkan, interactive). Play into the mission,
|
||||||
|
frame the ship side-on, press F10.
|
||||||
|
|
||||||
|
## Correlator (now a library module)
|
||||||
|
|
||||||
|
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 -- \
|
||||||
|
<capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
|
||||||
|
```
|
||||||
|
Decodes the ship's base parts (for their vertex counts = the match key), correlates,
|
||||||
|
prints each part's ship-relative transform, and with `--emit` prints the `ship …`
|
||||||
|
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)
|
||||||
|
|
||||||
|
Vertex-count → part map (all matched exactly): bdy_01/02 = 1261, bdy_03 = 815,
|
||||||
|
bdy_04 = 1633, eng_01 = 583, eng_02 = 491, wep_02_01 = 1002, brg_01 = 202.
|
||||||
|
|
||||||
|
Ship-relative placement (reference = `bdy_04`, aft hull):
|
||||||
|
|
||||||
|
| part | T (ship-relative) | note |
|
||||||
|
|---|---|---|
|
||||||
|
| bdy_04 | (0, 0, 0) | reference, aft hull |
|
||||||
|
| eng_01 | (131, −133, −132) | **aft** (−Z), starboard |
|
||||||
|
| eng_02 | (0, −49, −140) | aft, centre |
|
||||||
|
| bdy_01 | (**−264**, −150, 1165) | **port** hull, forward |
|
||||||
|
| bdy_02 | (**+264**, −150, 1165) | **starboard** hull, forward |
|
||||||
|
| bdy_03 | (0, −35, 1076) | forward keel/spine |
|
||||||
|
| wep_02_01 | (0, 49, 1009) | forward weapon |
|
||||||
|
|
||||||
|
Assembled extent **872 × 670 × 2181** (a coherent ~2181-long destroyer).
|
||||||
|
|
||||||
|
**Key wins vs static:** `bdy_01`/`bdy_02` are a **port/starboard pair** (X = ∓264),
|
||||||
|
which static had overlapping at one point; engines land correctly aft; the ship is
|
||||||
|
not the 1894-tall tower the static Y put out.
|
||||||
|
|
||||||
|
**Missing:** `brg_01` (bridge, vcount 202) was **culled/occluded** at that camera
|
||||||
|
angle (0 draws) — needs a second capture framing the superstructure.
|
||||||
|
|
||||||
|
## What the static assembler gets wrong (measured, `e106` in Stage_S01)
|
||||||
|
|
||||||
|
Decoding the real container (`examples/ship_dump.rs`) shows the static
|
||||||
|
`assemble_ship` is **incomplete** — the capture is required to fix all of:
|
||||||
|
|
||||||
|
- **`bdy_01` / `bdy_02` overlap**: both get the *same* composite transform
|
||||||
|
(T = (−116, 0, 857)); the real ship is a **port/starboard pair** at X = ∓264
|
||||||
|
(the mirror is applied at runtime, like the DeltaSaber fins in `saber_measured`).
|
||||||
|
- **`eng_02` and `wep_02_01` are never placed**: they aren't `rou_` hull nodes and
|
||||||
|
aren't in the external category list, so they drop out entirely.
|
||||||
|
- Only `brg_01` + `eng_01` get the approximate `GN_*`-frame treatment.
|
||||||
|
|
||||||
|
So even the *hull* tier is not fully correct, and no static signal supplies the
|
||||||
|
missing offsets (verified: the part vertex buffers are pure local coords). The
|
||||||
|
capture-driven table is the only path to correctness — there is nothing more to
|
||||||
|
squeeze statically.
|
||||||
|
|
||||||
|
> Offline status: the draw logs on this box are the **player fighter**, not a
|
||||||
|
> capital ship, so no capital-ship table can be baked here yet. Capture `e106`
|
||||||
|
> (Stage_S01, side-on) on the emulator box — F10 **or** just save
|
||||||
|
> `mission_draws.log` with the ship on screen — then `correlate_capture … --emit`.
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
1. ~~**Bake:** promote the correlator to a module + checked-in table + viewer
|
||||||
|
preference.~~ **DONE** (`ship_capture` module, `data/ship_placements.txt`,
|
||||||
|
`build_ship_model` prefers it). Remaining: run the captures below and paste the
|
||||||
|
`--emit` blocks in — needs the emulator (F10), can't be done offline.
|
||||||
|
2. **e106 + bridge:** re-capture `e106` (Stage_S01) framing the superstructure so
|
||||||
|
`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
|
||||||
|
capture too — correlate them to place turrets (static never resolved these).
|
||||||
Reference in New Issue
Block a user