//! 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"); }