diff --git a/crates/sylpheed-formats/examples/ship_audit.rs b/crates/sylpheed-formats/examples/ship_audit.rs new file mode 100644 index 0000000..f099335 --- /dev/null +++ b/crates/sylpheed-formats/examples/ship_audit.rs @@ -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 -- +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 = 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 = 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 = 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 = 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"); +} diff --git a/crates/sylpheed-formats/src/ship.rs b/crates/sylpheed-formats/src/ship.rs index 29910f3..80e632e 100644 --- a/crates/sylpheed-formats/src/ship.rs +++ b/crates/sylpheed-formats/src/ship.rs @@ -74,6 +74,10 @@ pub struct ShipModel { pub faction: Faction, /// Base geometry resource names to draw together, in directory order. pub parts: Vec, + /// Whether a composite scene graph (`e_rou_`) 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` @@ -146,7 +150,13 @@ pub fn ships_in_container(bytes: &[u8]) -> Vec { 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() } + 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()); } diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index ed48d2b..921fb02 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -613,7 +613,7 @@ pub struct ShipRow { } /// The Ships browser state — capital ships assembled from XBG7 part families. -#[derive(Resource, Default)] +#[derive(Resource)] pub struct ShipBrowser { pub open: bool, pub loading: bool, @@ -624,11 +624,27 @@ pub struct ShipBrowser { pub rows: Vec, /// Family id of the ship currently rendered (for row highlight). pub selected: Option, - /// Also place the approximate external parts (bridge / shield gen / engines at - /// their `GN_*` hardpoint frames). Off by default → the exact hull only. + /// 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; @@ -3860,8 +3876,11 @@ fn build_ship_catalog(source: &SourceKind) -> Vec { }; let label = format!("S{n:02}"); for sm in ships_in_container(&bytes) { - // Skip single-piece props / debris — not "whole ships". - if sm.parts.len() < 2 { + // 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); diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index c5e560d..bc77a4d 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -1590,7 +1590,7 @@ fn draw_ships_ui( } }); ui.horizontal(|ui| { - ui.checkbox(&mut ships.show_external, "Show external parts"); + ui.checkbox(&mut ships.show_external, "External parts (bridge/engines/turrets)"); ui.label( egui::RichText::new("(bridge / shield gens / engines — approximate placement)") .weak()