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:
MechaCat02
2026-07-23 21:16:04 +02:00
parent df724b6bcb
commit cacb576ecd
4 changed files with 277 additions and 0 deletions

View File

@@ -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,