formats: ship module — reconstruct capital ships from XBG7 part families
Project Sylpheed never stores a capital ship as a single mesh. Each 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/replace a part when the player destroys it. Crucially each part keeps its ship-local position, so reconstructing the whole ship is: draw every base part of one id together, untransformed. New `ship` module: Faction (from the e/f/n id prefix — ADAN/TCAF/Neutral), ShipModel, ship_id_of, is_base_part (skips _l/_m/_d LODs, _bNN/_dead/_break destruction geometry, e_rou_ scene nodes), and ships_in_container to group a stage's resources into whole ships. Supporting: - mesh::xbg7_resource_names — list a container's XBG7 resource names (directory walk only, no geometry decode). - Xbg7Model::models_named — decode only the named resources (assemble a ship from its ~8 parts instead of the whole ~300-resource stage). - game_data::Vessel gains `model` (the rou_<id> hull stem) to link stats to the 3D part family. Tests: id/part classification + a disc-gated reconstruction of the ADAN e108 frigate from Stage_S02. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -224,6 +224,9 @@ pub fn load_units(pak: &PakArchive) -> Vec<CraftUnit> {
|
||||
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>,
|
||||
@@ -247,6 +250,7 @@ impl Vessel {
|
||||
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")),
|
||||
|
||||
@@ -65,6 +65,9 @@ pub mod game_data;
|
||||
|
||||
pub mod localization;
|
||||
|
||||
// Whole-ship assembly from XBG7 part families (capital ships as split parts).
|
||||
pub mod ship;
|
||||
|
||||
/// Re-export the most commonly used types at the crate root.
|
||||
pub use font::FontInfo;
|
||||
pub use idxd::{IdxdError, IdxdObject};
|
||||
|
||||
@@ -151,6 +151,37 @@ pub fn count_xbg7(bytes: &[u8]) -> usize {
|
||||
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 {
|
||||
/// Total vertex / triangle counts across all sub-meshes.
|
||||
pub fn totals(&self) -> (usize, usize) {
|
||||
@@ -402,6 +433,18 @@ impl Xbg7Model {
|
||||
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
|
||||
/// resources (a large stage container holds hundreds). See
|
||||
/// [`Xbg7Model::stage_models_cancellable`].
|
||||
@@ -409,6 +452,15 @@ impl Xbg7Model {
|
||||
bytes: &[u8],
|
||||
min_consistency: f32,
|
||||
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> {
|
||||
let mut out = Vec::new();
|
||||
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)
|
||||
.unwrap_or_else(|| "XBG7".to_string());
|
||||
if let Some(w) = wanted {
|
||||
if !w.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
resources.push(Res {
|
||||
name,
|
||||
markers,
|
||||
|
||||
213
crates/sylpheed-formats/src/ship.rs
Normal file
213
crates/sylpheed-formats/src/ship.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Crucially, **each part keeps the world position it had in the whole ship**:
|
||||
//! the modeller carved the assembled hull into pieces, so `e106_brg_01` (bridge)
|
||||
//! already sits forward at z≈+291 and `e106_eng_01` (engine) already sits aft at
|
||||
//! z≈−532 in the shared ship-local frame. There is no per-part joint transform
|
||||
//! (`mesh::node_transforms` returns nothing for them). So reconstructing the whole
|
||||
//! ship is simply: **draw every base part of one id together, untransformed.**
|
||||
//!
|
||||
//! 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` suffixes are lower level-of-detail copies of a base part;
|
||||
//! `_bNN` / `_dead` / `_break` are destruction geometry; `_joint` / `_open` /
|
||||
//! `_Near` are logic/animation nodes. All are skipped for a clean whole-ship
|
||||
//! render (the base part always exists alongside them).
|
||||
//! - `e_rou_<id>` / `_rou_<id>_…` are the scene-graph / logic resources; they
|
||||
//! carry no drawable geometry and are skipped.
|
||||
//!
|
||||
//! Modular turrets (the shared `e3NN` / `e4NN` gun models a [`crate::game_data`]
|
||||
//! `Vessel` mounts at named hull frames) are their *own* ids here, not folded into
|
||||
//! the hull family — placing them needs the hull's frame table, which this pass
|
||||
//! does not resolve. A hull-family render therefore shows the ship body plus any
|
||||
//! *integral* (`_wep_`) weapons, which is the bulk of the silhouette.
|
||||
|
||||
use crate::mesh::xbg7_resource_names;
|
||||
|
||||
/// 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.
|
||||
if resource.ends_with("_l") || resource.ends_with("_m") || resource.ends_with("_d") {
|
||||
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()
|
||||
}
|
||||
|
||||
#[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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user