#!/usr/bin/env python3 """Decode `CollisionSet_*.bin` from `hidden/MiscBin.pak`. All 18 of them are byte-identical, so the per-stage naming is nominal: there is one shared library of 158 named per-object collision meshes (ships, stations, asteroids), separate from the per-map `MCOL` hull. ./collisionset.py list # the 158 meshes ./collisionset.py verify # the checks in the doc ./collisionset.py link # cmesh names <-> GameResourceID ./collisionset.py obj A record is u32 size bytes that follow this word's own 8-byte prefix u32 name_len 16, 24 or 32 -- the name field, padded to a multiple of 8 char name[name_len] u32 vertex_count u32 triangle_count f32 vertex[3] x vertex_count u32 index[3] x triangle_count -- note u32, not the u16 MCOL uses and the next record begins at `offset + 8 + size`. """ import collections import math import os import struct import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from regn_decode import pak_entries from unitgroup import name_hash def meshes(pak_path, which="CollisionSet_S01.bin"): """[(name, vertices, triangles)] for one CollisionSet blob.""" blob = dict(pak_entries(pak_path))[name_hash(which)] u32 = lambda o: struct.unpack_from(">I", blob, o)[0] out, p = [], 0 while p < len(blob): size, nlen = u32(p), u32(p + 4) name = blob[p + 8:p + 8 + nlen].split(b"\0")[0].decode("latin-1") nv, nt = u32(p + 8 + nlen), u32(p + 12 + nlen) vb = p + 16 + nlen tb = vb + 12 * nv verts = [struct.unpack_from(">fff", blob, vb + 12 * i) for i in range(nv)] tris = [struct.unpack_from(">III", blob, tb + 12 * i) for i in range(nt)] expect = 16 + nlen + 12 * nv + 12 * nt - 8 out.append((name, verts, tris, size == expect)) p += 8 + size return out, len(blob), p def cmd_list(pak): ms, _, _ = meshes(pak) print(f"{'name':>28} {'verts':>6} {'tris':>6} {'bbox diagonal':>14}") for name, v, t, _ in ms: lo = [min(q[k] for q in v) for k in range(3)] hi = [max(q[k] for q in v) for k in range(3)] print(f"{name:>28} {len(v):6d} {len(t):6d} {math.dist(lo, hi):14.1f}") print(f"\n{len(ms)} meshes, {sum(len(t) for _, _, t, _ in ms)} triangles") return True def cmd_verify(pak): blobs = dict(pak_entries(pak)) tags = ["test", "Tutorial"] + ["S%02d" % i for i in range(1, 17)] distinct = {blobs[name_hash(f"CollisionSet_{t}.bin")] for t in tags} print(f"CollisionSet blobs: {len(tags)} distinct contents: {len(distinct)}") ms, total, consumed = meshes(pak) print(f"records: {len(ms)} bytes consumed: {consumed}/{total}" f" ({'EXACT' if consumed == total else 'MISMATCH'})") print(f"size word == 16 + name_len + 12*nv + 12*nt - 8 : " f"{sum(ok for _, _, _, ok in ms)}/{len(ms)}") in_range = sum(all(max(t) < len(v) for t in tris) if tris else 1 for _, v, tris, _ in ms) print(f"every triangle index < vertex count : {in_range}/{len(ms)}") closed = e2 = etot = 0 for _, v, tris, _ in ms: ec = collections.Counter() for t in tris: for k in range(3): a, b = t[k], t[(k + 1) % 3] ec[(min(a, b), max(a, b))] += 1 etot += len(ec) e2 += sum(1 for n in ec.values() if n == 2) closed += all(n == 2 for n in ec.values()) print(f"edges shared by exactly two triangles : " f"{e2}/{etot} = {100*e2/etot:.2f}%") print(f"fully manifold meshes : {closed}/{len(ms)}") return (len(distinct) == 1 and consumed == total and all(ok for _, _, _, ok in ms) and in_range == len(ms)) def cmd_obj(pak, want, out): for name, v, tris, _ in meshes(pak)[0]: if name != want: continue with open(out, "w") as fh: fh.write(f"# CollisionSet {name} -- {len(v)} vertices, {len(tris)} triangles\n") for q in v: fh.write("v %.4f %.4f %.4f\n" % q) for t in tris: fh.write(f"f {t[0]+1} {t[1]+1} {t[2]+1}\n") print(f"{out}: {len(v)} vertices, {len(tris)} triangles") return True raise SystemExit(f"no mesh named {want!r}") def cmd_link(root): """Are the mesh names the game's own resource ids? With a control.""" import random import re sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from unitgroup import tag_hash grid = tag_hash("GameResourceID") ids = set() for pak in (os.path.join(root, "hidden", "DefTables.pak"), os.path.join(root, "dat", "GP_MAIN_GAME_E.pak")): for _, blob in pak_entries(pak): p = _idxd(blob) if not p: continue for key, _, voff in p["fields"]: if key == grid: v = _pool(p, voff) if v: ids.add(v) stems = [re.sub(r"_(cmesh|c)$", "", n) for n, _, _, _ in meshes(os.path.join(root, "hidden", "MiscBin.pak"))[0]] ship = [s for s in stems if s[:4] in ("rou_", "rob_", "mob_")] rock = [s for s in stems if s not in ship] pre = sum(any(s.startswith(i) for i in ids) for s in ship) exact = sum(s in ids for s in ship) random.seed(3) ctl = 0 for s in ship: t = list(s) random.shuffle(t) ctl += any("".join(t).startswith(i) for i in ids) print(f"distinct GameResourceID values : {len(ids)}") print(f"ship/mob stems prefixed by a resource id: {pre}/{len(ship)} " f"= {100*pre/len(ship):.1f}% (exact match {exact})") print(f"same stems, characters shuffled (control): {ctl}/{len(ship)} " f"= {100*ctl/len(ship):.1f}%") print(f"asteroid stems prefixed (expected none) : " f"{sum(any(s.startswith(i) for i in ids) for s in rock)}/{len(rock)}") return pre / len(ship) > 0.9 and ctl == 0 def _idxd(b): if b[:4] != b"IDXD" or len(b) < 0x10: return None n = struct.unpack_from(">I", b, 4)[0] if not 0 < n <= 200_000: return None recs, o = [(None,) + struct.unpack_from(">III", b, 0x0C)], 0x18 if o + 16 * (n - 1) + 4 > len(b): return None for _ in range(n - 1): recs.append(struct.unpack_from(">IIII", b, o)) o += 16 m = struct.unpack_from(">I", b, o)[0] o += 4 if o + 12 * m + 4 > len(b): return None return dict(recs=recs, fields=[struct.unpack_from(">III", b, o + 12 * i) for i in range(m)], pool=o + 12 * m + 4, raw=b) def _pool(p, off): b, base = p["raw"], p["pool"] if base + off >= len(b): return None return b[base + off:b.index(b"\0", base + off)].decode("latin-1") if __name__ == "__main__": if len(sys.argv) < 3: raise SystemExit(__doc__) cmd, pak = sys.argv[1], sys.argv[2] fn = {"list": cmd_list, "verify": cmd_verify, "link": cmd_link}.get(cmd) ok = cmd_obj(pak, sys.argv[3], sys.argv[4]) if cmd == "obj" else fn(pak) print("\nPASS" if ok else "\nFAIL") sys.exit(0 if ok else 1)