Two changes after user feedback that externals sit near-but-wrong (shield inset into the hull, thruster floating close): - Scale: the joint table's slots 5–7 are per-axis scale (a GN_ShieldG frame ships at 0.5, GN_Jet FX at 2.4–6.5) and were being ignored, rendering scaled parts at the wrong size. ScenePart now carries `s` and applies R·(S·v)+T. - External placement is fundamentally approximate: a GN_* frame is the mount PIVOT (f105's two GN_ShieldG frames are both on the centreline), while the part's outboard/rotational offset lives in a detail sub-rig / runtime code this static pass can't recover. So assemble_ship gains `include_external`: the hull tier (rou_ nodes) is exact and always drawn; the external tier (bridge / shield / engine at GN_ frames) is opt-in via a "Show external parts" checkbox in the Ships browser, off by default so the clean, correct hull is the default view. The module doc is corrected to the scene-graph mechanism (the old "draw parts untransformed" claim was wrong). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
395 lines
16 KiB
Rust
395 lines
16 KiB
Rust
//! 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>,
|
|
}
|
|
|
|
/// 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());
|
|
ShipModel { id: id.clone(), faction: Faction::from_id(&id), parts: Vec::new() }
|
|
});
|
|
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);
|
|
if resources.contains(stem) {
|
|
return Some(stem.to_string());
|
|
}
|
|
for suf in ["_root", "_mov"] {
|
|
if let Some(s) = stem.strip_suffix(suf) {
|
|
if resources.contains(s) {
|
|
return Some(s.to_string());
|
|
}
|
|
}
|
|
}
|
|
// 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()) {
|
|
let s = &stem[..pos];
|
|
if resources.contains(s) {
|
|
return Some(s.to_string());
|
|
}
|
|
}
|
|
}
|
|
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.
|
|
///
|
|
/// Two tiers, matching how the game splits a ship:
|
|
/// 1. **Hull** — `rou_<id>_*` nodes in the primary composite scene graph name the
|
|
/// hull body resources and carry their world transform. This tier is exact: the
|
|
/// bodies (and any engine posed by a `rou_` node) land where the game puts them.
|
|
/// 2. **External hardpoints** — `GN_*` frame nodes are the Vessel mounts; each
|
|
/// unplaced bridge / shield-generator / engine part (`<id>_brg_*`, `_sld_*`,
|
|
/// `_eng_*`) is placed at its matching frame by category + index. This tier is
|
|
/// APPROXIMATE: a `GN_*` frame is the mount *pivot* (often on the centreline),
|
|
/// while the part's outboard/rotational offset lives in a detail sub-rig or in
|
|
/// runtime code that this static pass does not recover — so external parts land
|
|
/// near, not exactly, where they belong. Gated by `include_external`.
|
|
///
|
|
/// Turret placement (shared `e3NN`/`e4NN` gun models mounted at many `GN_*Gun*`
|
|
/// frames) needs the per-mount Vessel recipe and is not resolved at all.
|
|
pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<ScenePart> {
|
|
let names = xbg7_resource_names(bytes);
|
|
let resources: HashSet<String> = names.iter().cloned().collect();
|
|
|
|
// The PRIMARY composite carries the real ship-space layout (hull bodies +
|
|
// every `GN_*` hardpoint frame). Sibling composites (`e_rou_<id>_eng`,
|
|
// `_wep_*`) are LOCAL detail rigs — their nodes are in their own frame, not
|
|
// ship-space, so folding them in floats parts mid-ship. Pick the composite
|
|
// with the most nodes; that is the assembled hull.
|
|
let scenes: Vec<(String, Vec<ScenePart>)> = composites_for(&names, id)
|
|
.into_iter()
|
|
.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();
|
|
|
|
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) {
|
|
// Only the ship's own parts. A composite also mounts shared, cross-id
|
|
// turret models (`e303_wep_*` on an `e106` hull); those need the
|
|
// per-mount Vessel recipe and are placed separately.
|
|
if ship_id_of(&res) != Some(id) {
|
|
continue;
|
|
}
|
|
if placed_res.insert(res.clone()) {
|
|
placed.push(ScenePart { resource: res, m: node.m, t: node.t, s: node.s });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Place unplaced external parts at their Vessel hardpoint frame (APPROXIMATE —
|
|
// see the doc comment). Skipped for a clean, exact hull-only render.
|
|
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());
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
#[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: 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"
|
|
);
|
|
}
|
|
}
|