use std::collections::{BTreeMap, BTreeSet, HashMap};
use mercs2_engine::wad;
use mercs2_formats::hash::pandemic_hash_m2;
use mercs2_formats::orchestrator;
#[derive(Default)]
struct BoneRec {
models: BTreeSet<u32>,
as_root: usize,
in_swit: usize,
as_leaf: usize,
states: BTreeSet<String>,
anim_tracks: usize,
anim_skel: usize,
depth_min: usize,
depth_max: usize,
max_bbox: f32,
offset: bool,
}
impl BoneRec {
fn note_hier(&mut self, model: u32, n: &orchestrator::HierNode, depth: usize) {
if self.models.is_empty() {
self.depth_min = depth;
self.depth_max = depth;
} else {
self.depth_min = self.depth_min.min(depth);
self.depth_max = self.depth_max.max(depth);
}
self.models.insert(model);
if n.parent.is_none() {
self.as_root += 1;
}
let d = [
n.bbox_max[0] - n.bbox_min[0],
n.bbox_max[1] - n.bbox_min[1],
n.bbox_max[2] - n.bbox_min[2],
];
let diag = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if diag.is_finite() && diag > self.max_bbox {
self.max_bbox = diag;
}
let t = [n.local[12], n.local[13], n.local[14]];
if t.iter().any(|v| v.is_finite() && v.abs() > 1e-4) {
self.offset = true;
}
}
}
struct Resolved {
names: Vec<String>,
source: &'static str,
}
struct SkelRow {
model: u32,
idx: usize,
hash: u32,
parent: Option<u32>,
depth: usize,
world: [f32; 3],
leaf: bool,
swit: bool,
}
fn world_positions(hier: &[orchestrator::HierNode]) -> Vec<[f32; 3]> {
let mut w: Vec<[f32; 16]> = Vec::with_capacity(hier.len());
for n in hier {
let m = match n.parent {
Some(p) if p < w.len() => mat_mul(&w[p], &n.local),
_ => n.local,
};
w.push(m);
}
w.iter().map(|m| [m[12], m[13], m[14]]).collect()
}
fn mat_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
let mut o = [0.0f32; 16];
for c in 0..4 {
for r in 0..4 {
o[c * 4 + r] = (0..4).map(|k| a[k * 4 + r] * b[c * 4 + k]).sum();
}
}
o
}
pub fn bone_census(
wadpaths: &[String],
csv_out: Option<String>,
names_file: Option<String>,
skeleton_csv: Option<String>,
) -> Result<(), String> {
let mut bones: HashMap<u32, BoneRec> = HashMap::new();
let mut skel: Vec<SkelRow> = Vec::new();
let mut model_roots: HashMap<u32, u32> = HashMap::new();
let mut n_models_seen = 0usize;
let mut n_models_parsed = 0usize;
let mut n_models_no_hier = 0usize;
let mut n_animgroups = 0usize;
let mut n_clips = 0usize;
for wadpath in wadpaths {
let mut w = wad::open(wadpath).map_err(|e| format!("open {wadpath}: {e}"))?;
let mut by_block: BTreeMap<u16, Vec<u32>> = BTreeMap::new();
for (hash, block) in wad::model_list_all(&w) {
by_block.entry(block).or_default().push(hash);
}
let n_blocks = by_block.len();
eprintln!(
"[{wadpath}] {} model assets in {n_blocks} blocks",
by_block.values().map(|v| v.len()).sum::<usize>()
);
for (bi, (block, models)) in by_block.into_iter().enumerate() {
if bi % 100 == 0 {
eprintln!(" block {bi}/{n_blocks} ...");
}
let Ok(dec) = wad::decompress_block_index(&mut w, block) else { continue };
for m in models {
n_models_seen += 1;
let mut container = wad::model_span_in(&dec, m);
let mut hier = container.as_deref().map(orchestrator::parse_hier).unwrap_or_default();
if hier.is_empty() {
if let Ok(c) = wad::extract_container(&mut w, m) {
hier = orchestrator::parse_hier(&c);
container = Some(c);
}
}
let Some(container) = container else { continue };
if hier.is_empty() {
n_models_no_hier += 1;
continue;
}
n_models_parsed += 1;
let swit: BTreeSet<u32> = orchestrator::parse_swit(&container).into_iter().collect();
let dest = orchestrator::classify(&container);
let mut depth = vec![0usize; hier.len()];
let mut has_kids = vec![false; hier.len()];
for n in &hier {
if let Some(p) = n.parent {
if p < hier.len() {
depth[n.index] = depth[p] + 1;
has_kids[p] = true;
}
}
}
let wpos = if skeleton_csv.is_some() { world_positions(&hier) } else { Vec::new() };
for n in &hier {
if n.parent.is_none() {
model_roots.insert(m, n.hash);
}
if skeleton_csv.is_some() {
skel.push(SkelRow {
model: m,
idx: n.index,
hash: n.hash,
parent: n.parent.and_then(|p| hier.get(p)).map(|p| p.hash),
depth: depth[n.index],
world: wpos.get(n.index).copied().unwrap_or([0.0; 3]),
leaf: !has_kids[n.index],
swit: swit.contains(&n.hash),
});
}
let rec = bones.entry(n.hash).or_default();
rec.note_hier(m, n, depth[n.index]);
if swit.contains(&n.hash) {
rec.in_swit += 1;
}
if !has_kids[n.index] {
rec.as_leaf += 1;
}
if let Some(s) = dest.as_ref().and_then(|d| d.state_of_node(n.index)) {
rec.states.insert(s.as_str().to_string());
}
}
}
}
for blk in wad::animgroup_blocks(&w) {
let Ok(data) = wad::decompress_block_index(&mut w, blk) else { continue };
let Ok(ag) = mercs2_formats::animgroup::parse_animgroup(&data) else { continue };
n_animgroups += 1;
if let Some(sk) = &ag.skeleton {
for &h in &sk.bone_name_hashes {
bones.entry(h).or_default().anim_skel += 1;
}
}
for c in &ag.clips {
n_clips += 1;
for &h in &c.binding.track_to_bone_hash {
bones.entry(h).or_default().anim_tracks += 1;
}
}
}
}
let all: BTreeSet<u32> = bones.keys().copied().collect();
let mut resolved: HashMap<u32, Resolved> = HashMap::new();
let rb = load_rainbow(&all);
for (h, names) in rb {
resolved.insert(h, Resolved { names, source: "rainbow" });
}
if let Some(p) = &names_file {
let text = std::fs::read_to_string(p).map_err(|e| format!("read {p}: {e}"))?;
for line in text.lines() {
let cand = line.trim();
if cand.len() < 2 {
continue;
}
let h = pandemic_hash_m2(cand);
if !all.contains(&h) {
continue;
}
match resolved.get_mut(&h) {
Some(r) => {
if !r.names.iter().any(|n| n == cand) {
r.names.push(cand.to_string());
}
}
None => {
resolved.insert(
h,
Resolved { names: vec![cand.to_string()], source: "candidate" },
);
}
}
}
}
let named = bones.keys().filter(|h| resolved.contains_key(h)).count();
let rainbow_named = bones
.keys()
.filter(|h| resolved.get(h).map(|r| r.source == "rainbow").unwrap_or(false))
.count();
let animated = bones.values().filter(|r| r.anim_tracks > 0).count();
let mesh_only = bones.values().filter(|r| r.anim_tracks == 0 && !r.models.is_empty()).count();
let anim_only = bones.values().filter(|r| r.models.is_empty()).count();
println!("\n=== BONE CENSUS ===");
println!("wads scanned : {}", wadpaths.len());
println!("model assets seen : {n_models_seen}");
println!(" with a HIER : {n_models_parsed}");
println!(" no HIER / no span : {}", n_models_seen - n_models_parsed);
println!(" (empty HIER) : {n_models_no_hier}");
println!("animgroups / clips : {n_animgroups} / {n_clips}");
println!("DISTINCT NODE HASHES : {}", bones.len());
println!(" named (any source) : {named} (rainbow-backed: {rainbow_named})");
println!(" animation-driven : {animated}");
println!(" mesh-only (no anim): {mesh_only}");
println!(" anim-only (no mesh): {anim_only}");
if let Some(path) = &csv_out {
let mut out = String::from(
"hash,name,name_source,name_candidates,n_models,as_root,in_swit,as_leaf,anim_tracks,anim_skel,depth_min,depth_max,max_bbox,offset,states,example_models\n",
);
let mut rows: Vec<(&u32, &BoneRec)> = bones.iter().collect();
rows.sort_by(|a, b| {
b.1.models
.len()
.cmp(&a.1.models.len())
.then(b.1.anim_tracks.cmp(&a.1.anim_tracks))
.then(a.0.cmp(b.0))
});
for (h, r) in rows {
let (name, source, ncand) = match resolved.get(h) {
Some(res) => (
res.names.first().cloned().unwrap_or_default(),
res.source,
res.names.len(),
),
None => (String::new(), "", 0),
};
let ex: Vec<String> =
r.models.iter().take(3).map(|m| format!("0x{m:08X}")).collect();
let states: Vec<&str> = r.states.iter().map(|s| s.as_str()).collect();
out.push_str(&format!(
"0x{h:08X},{name},{source},{ncand},{},{},{},{},{},{},{},{},{:.2},{},{},{}\n",
r.models.len(),
r.as_root,
r.in_swit,
r.as_leaf,
r.anim_tracks,
r.anim_skel,
r.depth_min,
r.depth_max,
r.max_bbox,
u8::from(r.offset),
states.join("|"),
ex.join("|"),
));
}
std::fs::write(path, out).map_err(|e| format!("write {path}: {e}"))?;
println!("\ncsv -> {path} ({} rows)", bones.len());
}
if let Some(path) = &skeleton_csv {
let mut out = String::from(
"model,node_idx,hash,parent,depth,wx,wy,wz,leaf,in_swit\n",
);
for r in &skel {
out.push_str(&format!(
"0x{:08X},{},0x{:08X},{},{},{:.4},{:.4},{:.4},{},{}\n",
r.model,
r.idx,
r.hash,
r.parent.map(|p| format!("0x{p:08X}")).unwrap_or_default(),
r.depth,
r.world[0],
r.world[1],
r.world[2],
u8::from(r.leaf),
u8::from(r.swit),
));
}
std::fs::write(path, out).map_err(|e| format!("write {path}: {e}"))?;
println!("skeleton -> {path} ({} rows)", skel.len());
}
let named_roots = model_roots
.values()
.filter(|h| resolved.get(h).map(|r| r.source == "rainbow").unwrap_or(false))
.count();
println!(
"model roots: {} ({named_roots} rainbow-named, {} unnamed)",
model_roots.len(),
model_roots.len() - named_roots
);
Ok(())
}
fn load_rainbow(want: &BTreeSet<u32>) -> HashMap<u32, Vec<String>> {
let mut out = HashMap::new();
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../rainbow_table.json");
let Ok(text) = std::fs::read_to_string(path) else {
eprintln!("[bone-census] no rainbow table at {path} — names will be blank");
return out;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else { return out };
let Some(map) = v.get("pandemic_hash_m2").and_then(|m| m.as_object()) else { return out };
for (k, val) in map {
let Some(h) = k.strip_prefix("0x").and_then(|s| u32::from_str_radix(s, 16).ok()) else {
continue;
};
if !want.contains(&h) {
continue;
}
let names: Vec<String> = match val {
serde_json::Value::Array(a) => {
a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()
}
serde_json::Value::String(s) => vec![s.clone()],
_ => continue,
};
if !names.is_empty() {
out.insert(h, names);
}
}
out
}