use mercs2_formats::model_cubeize::read_model_meshes;
use std::collections::BTreeSet;
fn load_names(explicit: Option<&str>) -> std::collections::HashMap<u32, String> {
let mut out = std::collections::HashMap::new();
let path = match explicit {
Some(p) => Some(std::path::PathBuf::from(p)),
None => {
let mut dir: Option<&std::path::Path> = Some(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
let mut found = None;
while let Some(d) = dir {
let c = d.join("data/production_names.json");
if c.is_file() {
found = Some(c);
break;
}
dir = d.parent();
}
found
}
};
let Some(path) = path else {
eprintln!("note: no data/production_names.json found — rows will be labelled by hash");
return out;
};
let Ok(text) = std::fs::read_to_string(&path) else { return out };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else { return out };
if let Some(map) = v.get("pandemic_hash_m2").and_then(|m| m.as_object()) {
for (k, name) in map {
let Some(h) = k.strip_prefix("0x").and_then(|s| u32::from_str_radix(s, 16).ok()) else {
continue;
};
if let Some(n) = name.as_str() {
out.insert(h, n.to_string());
}
}
}
out
}
fn unwrap_container(raw: &[u8]) -> &[u8] {
if raw.len() > 4 && &raw[0..4] == b"UCFX" {
raw
} else if raw.len() > 20 {
let n = u32::from_le_bytes(raw[16..20].try_into().unwrap()) as usize;
if 20 + n <= raw.len() && &raw[20..24] == b"UCFX" {
&raw[20..20 + n]
} else {
raw
}
} else {
raw
}
}
fn per_group_csv(label: &str, container: &[u8]) {
let meshes = match read_model_meshes(container) {
Ok(m) => m,
Err(e) => {
eprintln!("{label}: read_model_meshes: {e}");
return;
}
};
for m in &meshes {
if m.joints.is_empty() || m.weights.is_empty() || m.rigid {
continue;
}
println!(
"{label},{},{},{},{},{},{},0x{:02X}",
m.group_index,
m.range_count,
m.palette_slots,
m.distinct_bones,
m.positions.len(),
m.tris.len(),
m.state_mask
);
}
}
fn main() {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let take_flag = |args: &mut Vec<String>, name: &str| -> bool {
if let Some(i) = args.iter().position(|a| a == name) {
args.remove(i);
true
} else {
false
}
};
let take_val = |args: &mut Vec<String>, name: &str| -> Option<String> {
if let Some(i) = args.iter().position(|a| a == name) {
let v = args.get(i + 1).cloned();
args.drain(i..=(i + 1).min(args.len() - 1));
v
} else {
None
}
};
let per_group = take_flag(&mut args, "--per-group");
let from_wad = take_flag(&mut args, "--wad");
let names_path = take_val(&mut args, "--names");
let filter = take_val(&mut args, "--filter");
let wad_path = take_val(&mut args, "--wad-path");
if from_wad {
let names = load_names(names_path.as_deref());
let path = match wad_path.or_else(|| mercs2_engine::wad::resolve_vz_wad(None)) {
Some(p) => p,
None => {
eprintln!("no vz.wad — pass --wad-path, or set MERCS2_GAME_DIR/VZ_WAD");
std::process::exit(2);
}
};
let mut w = match mercs2_engine::wad::open(&path) {
Ok(w) => w,
Err(e) => {
eprintln!("open {path}: {e}");
std::process::exit(2);
}
};
let hashes: Vec<u32> = mercs2_engine::wad::model_list_all(&w)
.into_iter()
.map(|(h, _)| h)
.collect();
let keep = |h: u32| -> Option<String> {
let name = names.get(&h).cloned();
match (&filter, &name) {
(Some(f), Some(n)) => n.contains(f.as_str()).then(|| n.clone()),
(Some(_), None) => None,
(None, Some(n)) => Some(n.clone()),
(None, None) => Some(format!("0x{h:08X}")),
}
};
if per_group {
println!("model,group,range_count,slots,bones,verts,tris,state_mask");
}
let (mut scanned, mut skipped) = (0usize, 0usize);
for h in hashes {
let Some(label) = keep(h) else { continue };
let Ok(c) = mercs2_engine::wad::extract_container(&mut w, h) else {
skipped += 1;
continue;
};
scanned += 1;
if per_group {
per_group_csv(&label, unwrap_container(&c));
} else {
census_one(&label, unwrap_container(&c), None);
}
}
eprintln!("censused {scanned} model assets ({skipped} unreadable)");
return;
}
let mut only_group: Option<usize> = None;
if let Some(i) = args.iter().position(|a| a == "--group") {
only_group = args.get(i + 1).and_then(|s| s.parse().ok());
args.drain(i..=i + 1);
}
if args.is_empty() {
eprintln!(
"usage: skin_census [--group N] <container-or-block.bin> [...]\n\
\x20 skin_census --wad [--wad-path <vz.wad>] [--filter <substr>] [--per-group]"
);
std::process::exit(2);
}
if per_group {
println!("model,group,range_count,slots,bones,verts,tris,state_mask");
for path in &args {
let Ok(raw) = std::fs::read(path) else {
eprintln!("{path}: unreadable");
continue;
};
per_group_csv(&short(path), unwrap_container(&raw));
}
return;
}
println!(
"{:<34} {:>6} {:>7} {:>6} {:>7} {:>7} {:>7}",
"model", "bones", "verts", "grps", "rigid%", "2+%", "4-inf%"
);
println!("{}", "-".repeat(80));
for path in &args {
let raw = match std::fs::read(path) {
Ok(b) => b,
Err(e) => { println!("{path}: {e}"); continue; }
};
census_one(&short(path), unwrap_container(&raw), only_group);
}
println!("\nrigid% = vertices bound to exactly ONE bone. High rigid% with few bones is the shape\nthat survives bind pose and tears when a joint bends.");
}
fn census_one(label: &str, container: &[u8], only_group: Option<usize>) {
{
let meshes = match read_model_meshes(container) {
Ok(m) => m,
Err(e) => { println!("{label}: read_model_meshes: {e}"); return; }
};
let mut bones: BTreeSet<u16> = BTreeSet::new();
let (mut verts, mut rigid, mut multi, mut four, mut skinned_groups) = (0usize, 0usize, 0usize, 0usize, 0usize);
for m in &meshes {
if let Some(g) = only_group { if m.group_index != g { continue; } }
if m.joints.is_empty() || m.weights.is_empty() { continue; }
skinned_groups += 1;
for (j, w) in m.joints.iter().zip(m.weights.iter()) {
verts += 1;
let mut n = 0;
for k in 0..4 {
if w[k] > 0 {
n += 1;
bones.insert(j[k] as u16);
}
}
match n {
0 | 1 => rigid += 1,
_ => {
multi += 1;
if n == 4 { four += 1; }
}
}
}
}
if verts == 0 {
println!("{:<34} {:>6} {:>7} (no skinned groups)", label, 0, 0);
return;
}
let mut bl: Vec<u32> = bones.iter().map(|&b| b as u32).collect();
bl.sort_unstable();
let (rr, _, slots) = mercs2_formats::char_skin::build::build_palette_ranges(&bl);
let pct = |a: usize| 100.0 * a as f64 / verts as f64;
println!(
"{:<34} {:>6} {:>7} {:>6} {:>6.1}% {:>6.1}% {:>6.1}%",
label, bones.len(), verts, skinned_groups, pct(rigid), pct(multi), pct(four)
);
println!(" -> our packer would need {slots} slots over {} runs for those {} bones",
rr.len(), bones.len());
}
}
fn short(p: &str) -> String {
let b = p.rsplit(['/', '\\']).next().unwrap_or(p);
b.chars().take(34).collect()
}