From 4f0d21f50d32b3d03bbe087665fd31683d189f82 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Wed, 26 Aug 2026 09:04:30 +0000 Subject: [PATCH] re: MCOL's 0x5C block is bounding spheres at stride 16 -- the 0.75 was 12/16 The unexplained ~0.75 ratio left at the end of the last iteration was my own stride. I had read the block as 12-byte points because REGN's vertex section is 12 bytes, and never checked it: len(0x5C) is not a multiple of 12 in 5 of the 11 objects, so that stride was never arithmetically possible. At stride 16 the relation is exact in 11/11 -- max u16 == len(0x5C)/16 - 1 -- and the record reads as {centre f32[3], radius f32}. Powered test, since a u16 is reached through a specific grid cell: the sphere it names reaches that cell in 18 559/18 577 = 99.90%, against a 12.02% random-sphere control. Both fields carry signal (centre alone 26.75%, radius shuffled 70.19%). The converse -- is the list *exactly* the intersecting set? -- is 0.38%, which is the expected direction: a bounding sphere is conservative, so membership implies overlap but not the reverse. The tighter geometry is in 0x54/0x58, still undecoded. 18 entries (0.10%) go the wrong way and are recorded as open. tools/re-capture/regn_decode.py is copied unchanged from auto/regn-reader so the probe's POF0 reader is the known-good one rather than a second copy. --- docs/re/BACKLOG.md | 16 ++ docs/re/structures/mcol-collision.md | 96 +++++++- tools/re-capture/mcol_probe.py | 143 ++++++++++++ tools/re-capture/regn_decode.py | 331 +++++++++++++++++++++++++++ 4 files changed, 584 insertions(+), 2 deletions(-) create mode 100755 tools/re-capture/mcol_probe.py create mode 100644 tools/re-capture/regn_decode.py diff --git a/docs/re/BACKLOG.md b/docs/re/BACKLOG.md index d3cd4e5..e9cf8df 100644 --- a/docs/re/BACKLOG.md +++ b/docs/re/BACKLOG.md @@ -381,6 +381,22 @@ search cannot find a *schedule*. See [`structures/regn-map-grid.md`](structures/regn-map-grid.md). ❔ Still open there: the **runtime consumer** (not reached; a `float4`-aligned header read with VMX loads leaves no displacement signature to search for). + ✅ **`MCOL` is the collision sibling, and its broad phase is decoded + (2026-08-26).** Same container, same 11 maps, same cell size. Chain: + `position → cell → A record → B record {count, u16[n]} → 16-byte bounding + sphere`. The `0x5C` block is **stride 16 `{centre f32[3], radius f32}`**, not + the 12-byte points I had assumed — `len(0x5C)` is not even a multiple of 12 in + 5 of 11 objects, and `max u16 == len/16 − 1` in **11/11**. Powered check: the + referenced sphere reaches the cell that reached it, **18 559/18 577 = 99.90 %** + against a 12.02 % random-sphere control, and ablating either the centre or the + radius costs most of the signal. Reproduce with + `tools/re-capture/mcol_probe.py verify`. + ⚠️ The stale ≈**0.75×** ratio I had recorded as "too consistent to be + coincidence" was **12/16** — my own wrong stride, not a fact about the data. + ❔ Still open: the `0x54` and `0x58` blocks (the geometry these spheres bound; + neither has a constant stride against the sphere count), and 18 cell entries + (0.10 %) whose sphere misses its cell by 0.4–13 %. + See [`structures/mcol-collision.md`](structures/mcol-collision.md). * 🔴 **`hidden/DefTables.pak` is NOT it** (checked 2026-08-24). The three unnamed schemas are more **model/render** tables in the same vocabulary as the named ones — `7e66225f` (283 objects) carries `RenderFrameModel`, diff --git a/docs/re/structures/mcol-collision.md b/docs/re/structures/mcol-collision.md index af41565..4f71c40 100644 --- a/docs/re/structures/mcol-collision.md +++ b/docs/re/structures/mcol-collision.md @@ -229,7 +229,7 @@ evidence.) position → cell → A record {cell index, count 1, →B, bounding sphere} → B record {count n, →u16[n]} - → n indices into the 0x5C point block + → n indices into the 0x5C point block ← wrong, see below which is the same shape as `REGN`'s `cell → item → refs → geometry`, as the two formats' shared header and shared count-1 convention already suggested. @@ -282,4 +282,96 @@ confident and false statement about `MCOL`'s geometry. ❔ What the `u16`s index is open. A datum for the next attempt: the maximum `u16` is consistently ≈ **0.75 ×** the point count (923/1 232, 1 019/1 360, -1 163/1 552, 59/80) — too consistent to be coincidence, and not explained. \ No newline at end of file +1 163/1 552, 59/80) — too consistent to be coincidence, and not explained. + +> **Resolved in the next section.** 0.75 is `12 / 16`: "the point count" was +> computed with an assumed 12-byte stride that the block lengths refute. The +> `u16`s *do* index this block — at stride 16. Both this test and the 100 % +> bound-check above were reading the block wrongly; only the powered one could +> say so. +## ✅ The `0x5C` block is **bounding spheres at stride 16** — and that explains the 0.75 + +**2026-08-26.** The unexplained ≈0.75 ratio left at the end of the section above +was **12 / 16**: my own stride. I had been reading the `0x5C` block as 12-byte +points because `REGN`'s vertex section is 12 bytes, and never checked the +assumption. + +It does not survive the cheapest possible check — **`len(0x5C)` is not a +multiple of 12** in 5 of the 11 objects, so a 12-byte stride was never +arithmetically possible: + +| object | `len(0x5C)` | ÷12 | ÷16 | max `u16` | +|---|---|---|---|---| +| `2cf7eb47` | 960 | 80.00 | 60 | 59 | +| `cbb99d34` | 192 | 16.00 | 12 | 11 | +| `d84a95fb` | 3 488 | 290.67 ❌ | 218 | 217 | +| `db066592` | 12 640 | 1 053.33 ❌ | 790 | 789 | +| `db61c506` | 2 144 | 178.67 ❌ | 134 | 133 | +| `dc44fe0c` | 2 784 | 232.00 | 174 | 173 | +| `dc4b0896` | 14 784 | 1 232.00 | 924 | 923 | +| `dd89a110` | 18 624 | 1 552.00 | 1 164 | 1 163 | +| `df4628c2` | 4 160 | 346.67 ❌ | 260 | 259 | +| `e084c13c` | 192 | 16.00 | 12 | 11 | +| `e16460cf` | 16 320 | 1 360.00 | 1 020 | 1 019 | + +At stride 16 the relation is not "≈0.75×" but **exact, in 11 / 11 objects**: + + max u16 == len(0x5C) / 16 − 1 + +The `u16` array indexes the `0x5C` block at stride 16, and *covers it fully* — +the largest index is always the last element. + +### The record is `{ centre f32[3], radius f32 }` + +Read at stride 16, the first three floats lie inside the object's own bounding +box in **every record of every object**, and are never unit-length, so this is a +position and not a plane normal. The fourth float is a positive scalar which is +**not** `|centre|`. + +The powered test is the one the previous section said was needed: a `u16` is +reached *through a specific cell*, so the thing it names should be present in +that cell. Treating the record as a sphere and the cell as its grid box: + +| | | +|---|---| +| **referenced sphere intersects the cell that reached it** | **18 559 / 18 577 = 99.90 %** | +| a random sphere from the same object (control) | 2 233 / 18 577 = 12.02 % | + +And both halves of the record are load-bearing — ablating either one costs most +of the signal: + +| | | +|---|---| +| centre + radius | **99.90 %** | +| centre alone, radius treated as 0 | 26.75 % | +| centre kept, radius shuffled within the object | 70.19 % | +| radius kept, centre shuffled within the object | 23.74 % | + +So the `0x5C` block is a **broad-phase bounding-sphere array**, and each grid +cell's `u16` list names the primitives that reach into that cell — the standard +shape for a collision mesh, and the sibling of `REGN`'s `cell → tetrahedra`. + +### The list is a *subset* of what the spheres allow, which is the expected direction + +Testing the converse — is the `u16` set **exactly** the set of spheres that +intersect the cell? — gives **17 / 4 488 cells (0.38 %)**, with 46 525 spheres +intersecting a cell but absent from its list. That is the right direction and +not a problem: a bounding sphere is a conservative bound on the primitive inside +it, so "sphere overlaps cell" must be implied by membership but cannot imply it. +The tighter true geometry lives in the `0x54` / `0x58` blocks, still undecoded. + +❔ **18 exceptions (0.10 %)** go the wrong way — listed, but the sphere misses +the cell, by `dist / radius` of 1.004 to 1.129. They are spread over 7 of the 11 +objects with no object dominating, so this looks like a small build-time margin +rather than a decode error, but it is **not explained** and is recorded as open. + +### The chain, corrected + + position → cell → A record {cell index (x,y,z,1), count 1, →B at +8, bounding sphere} + → B record {u32 count, →u16[n] at +4} + → n indices into the 0x5C array of 16-byte bounding spheres + +❔ Still open: the `0x54` and `0x58` blocks — the actual collision geometry that +these spheres bound. Their lengths are **not** a constant multiple of the sphere +count (`0x58 / n` is ≈6.0 for the large objects but 6.13 and 6.67 for the two +smallest), so at least one of them is variable-stride or has its own count. diff --git a/tools/re-capture/mcol_probe.py b/tools/re-capture/mcol_probe.py new file mode 100755 index 0000000..3f1f793 --- /dev/null +++ b/tools/re-capture/mcol_probe.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Check the `MCOL` decode in `docs/re/structures/mcol-collision.md`. + +`MCOL` (`hidden/MiscBin.pak`, 11 objects) shares `REGN`'s container, so the +`POF0` reader is imported from `regn_decode.py` rather than duplicated. + + ./mcol_probe.py stride # the 0x5C block is stride 16, not 12 + ./mcol_probe.py cells # the u16s name spheres in their cell + ./mcol_probe.py verify # both, with pass/fail + +The `cells` check is the powered one: a `u16` is reached *through* a specific +grid cell, so the sphere it names must reach that cell. A bound-check ("is it +a valid index?") cannot fail here and is deliberately not used -- see the +hazard section of the doc. +""" + +import math +import os +import random +import struct +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from regn_decode import pak_entries, pof0_pointer_slots, FIXUP_BASE + +SPHERE = 16 # stride of the 0x5C record: centre f32[3] then radius f32 + + +def u32(b, o): return struct.unpack_from(">I", b, o)[0] +def f32(b, o): return struct.unpack_from(">f", b, o)[0] +def ptr(b, o): return u32(b, o) + FIXUP_BASE + + +def objects(pak): + return [(h, b) for h, b in pak_entries(pak) if b[:4] == b"MCOL"] + + +class Mcol: + def __init__(self, blob): + self.b = blob + self.p54, self.p58, self.p5c, self.p74 = (ptr(blob, o) + for o in (0x54, 0x58, 0x5C, 0x74)) + self.sphere_bytes = self.p54 - self.p5c + self.n = self.sphere_bytes // SPHERE + self.bbox_min = [f32(blob, 0x10 + 4 * i) for i in range(3)] + self.cell_size = [f32(blob, 0x40 + 4 * i) for i in range(3)] + + def sphere(self, i): + o = self.p5c + SPHERE * i + return [f32(self.b, o + 4 * k) for k in range(3)], f32(self.b, o + 12) + + def cell_box(self, cx, cy, cz): + lo = [self.bbox_min[k] + self.cell_size[k] * (cx, cy, cz)[k] for k in range(3)] + return lo, [lo[k] + self.cell_size[k] for k in range(3)] + + def cells(self): + """(cell box, [u16]) for every A record that carries a cell index.""" + b = self.b + for slot in sorted(s for s in set(pof0_pointer_slots(b)) if s >= self.p74): + rec = slot - 8 + if b[rec + 3] != 1: # the other interleaved array + continue + pb = ptr(b, slot) + if pb + 8 > len(b): + continue + count, refs = u32(b, pb), ptr(b, pb + 4) + if count > 100_000 or refs + 2 * count > len(b): + continue + idx = list(struct.unpack_from(">%dH" % count, b, refs)) if count else [] + yield self.cell_box(b[rec], b[rec + 1], b[rec + 2]), idx + + +def dist2(c, lo, hi): + """Squared distance from a point to an axis-aligned box (0 if inside).""" + d = 0.0 + for k in range(3): + if c[k] < lo[k]: + d += (lo[k] - c[k]) ** 2 + elif c[k] > hi[k]: + d += (c[k] - hi[k]) ** 2 + return d + + +def cmd_stride(pak): + print(f"{'object':>8} {'len(0x5C)':>10} {'/12':>10} {'/16':>6} {'max u16':>8}") + ok12 = ok16 = total = 0 + for h, blob in objects(pak): + m = Mcol(blob) + top = max((v for _, idx in m.cells() for v in idx if v < m.n), default=-1) + div12 = m.sphere_bytes % 12 == 0 + print(f"{h:08x} {m.sphere_bytes:10d} " + f"{m.sphere_bytes / 12:10.2f}{'' if div12 else ' *'} " + f"{m.n:6d} {top:8d}" + f"{' max == n-1' if top == m.n - 1 else ''}") + total += 1 + ok12 += div12 + ok16 += top == m.n - 1 + print(f"\nlength divisible by 12: {ok12}/{total} " + f"max u16 == len/16 - 1: {ok16}/{total}") + return ok16 == total and ok12 < total + + +def cmd_cells(pak, seed=12345): + random.seed(seed) + hit = ctl = tot = 0 + for _, blob in objects(pak): + m = Mcol(blob) + spheres = [m.sphere(i) for i in range(m.n)] + for (lo, hi), idx in m.cells(): + for v in idx: + if v >= m.n: + continue + tot += 1 + c, r = spheres[v] + hit += dist2(c, lo, hi) <= r * r + c2, r2 = spheres[random.randrange(m.n)] + ctl += dist2(c2, lo, hi) <= r2 * r2 + print(f"referenced sphere reaches its own cell : {hit}/{tot} = {100*hit/tot:.2f}%") + print(f"random sphere, same object (control) : {ctl}/{tot} = {100*ctl/tot:.2f}%") + return hit / tot > 0.99 and ctl / tot < 0.25 + + +def main(): + if len(sys.argv) < 3: + raise SystemExit(__doc__) + cmd, pak = sys.argv[1], sys.argv[2] + if cmd == "stride": + ok = cmd_stride(pak) + elif cmd == "cells": + ok = cmd_cells(pak) + elif cmd == "verify": + a = cmd_stride(pak) + print() + b = cmd_cells(pak) + ok = a and b + else: + raise SystemExit(__doc__) + print("\nPASS" if ok else "\nFAIL") + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/tools/re-capture/regn_decode.py b/tools/re-capture/regn_decode.py new file mode 100644 index 0000000..e4e20cf --- /dev/null +++ b/tools/re-capture/regn_decode.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Decode a `REGN` object (a stage's `MapPath`, `hidden/MiscBin.pak`). + +A `REGN` object is a serialised C++ object graph with a trailing `POF0` +pointer-fixup table. The fixup table is the primary evidence used here: it +names every word in the file that the retail loader turns into a pointer, so +the pointer graph does not have to be guessed. The loader code that consumes +it is `sub_82465110` / `sub_82465138` / `sub_82465200` (see +`docs/re/structures/regn-map-grid.md`). + + ./regn_decode.py list # entries + magics + ./regn_decode.py dump [hash] # one object's structure + ./regn_decode.py verify # the checks in the doc + +Everything is big-endian. All file offsets printed are absolute, i.e. the +stored pointer value plus 0x10 (the fixup base is chunk+0x10). +""" + +import glob +import itertools +import math +import os +import random +import struct +import sys +import zlib + +FIXUP_BASE = 0x10 # sub_82465138: the POF0 applier is called with r3 = chunk+16 + +# stride of each of the six sections, in bytes +STRIDE = (12, 96, 48, 8, 32, 4) +SECTION_NAME = ( + "vertices", "tetrahedra", "faces", "cell index", "cell items", "tet refs", +) +PAIRS = list(itertools.combinations(range(4), 2)) + + +# ── IPFB archive ──────────────────────────────────────────────────────────── + +def pak_entries(pak_path): + """[(name_hash, payload_bytes)] for every entry of an IPFB archive.""" + index = open(pak_path, "rb").read() + if index[:4] != b"IPFB": + raise SystemExit(f"{pak_path}: not an IPFB index") + count, = struct.unpack_from(">I", index, 4) + toc = [struct.unpack_from(">III", index, 0x10 + 12 * i) for i in range(count)] + segs = sorted(glob.glob(os.path.splitext(pak_path)[0] + ".p[0-9][0-9]")) + data = b"".join(open(s, "rb").read() for s in segs) + out = [] + for name_hash, offset, comp_size in toc: + stored = data[offset:offset + comp_size] + if stored[:2] == b"Z1": + stored = zlib.decompress(stored[10:]) + out.append((name_hash, stored)) + return out + + +# ── POF0 ──────────────────────────────────────────────────────────────────── + +def pof0_pointer_slots(blob): + """Byte offsets of every word the retail loader relocates. + + Mirrors `sub_82465200` exactly: a run-length delta stream over word + indices, 6 / 14 / 22 bits selected by the top two bits of the lead byte. + """ + data_size, = struct.unpack_from(">I", blob, 4) + table = data_size + 16 + if blob[table:table + 4] != b"POF0": + raise SystemExit("no POF0 chunk at header[0x04]+16") + size, = struct.unpack_from(">I", blob, table + 4) + p, end, word = table + 16, table + 16 + size, 0 + slots = [] + while p < end: + b = blob[p] + if b == 0: + break + tag = b & 0xC0 + if tag == 0x40: + word += b & 0x3F + p += 1 + elif tag == 0x80: + word += ((b & 0x3F) << 8) + blob[p + 1] + p += 2 + elif tag == 0xC0: + word += ((((b & 0x3F) << 8) + blob[p + 1]) << 8) + blob[p + 2] + p += 3 + else: + p += 1 + continue + slots.append(FIXUP_BASE + 4 * word) + return slots + + +# ── the object ────────────────────────────────────────────────────────────── + +class Regn: + def __init__(self, blob): + if blob[:4] != b"REGN": + raise SystemExit(f"not a REGN object (magic {blob[:4]!r})") + self.b = blob + self.data_size, = struct.unpack_from(">I", blob, 4) + self.bbox_min = self.f3(0x10) + self.bbox_max = self.f3(0x20) + self.extent = self.f3(0x30) + self.cell_size = self.f3(0x40) + self.dims = [self.u32(0x50 + 4 * i) for i in range(3)] + self.count = [self.u16(0x60 + 2 * i) for i in range(6)] + # the six section pointers, resolved to absolute file offsets + self.sec = [self.u32(0x70 + 4 * i) + FIXUP_BASE for i in range(6)] + + def u32(self, o): return struct.unpack_from(">I", self.b, o)[0] + def u16(self, o): return struct.unpack_from(">H", self.b, o)[0] + def f32(self, o): return struct.unpack_from(">f", self.b, o)[0] + def f3(self, o): return tuple(self.f32(o + 4 * i) for i in range(3)) + def ptr(self, o): return self.u32(o) + FIXUP_BASE + + # section 0 — vertex + def vertex(self, i): + return self.f3(self.sec[0] + 12 * i) + + # section 1 — tetrahedron + def tet(self, i): + b = self.sec[1] + 96 * i + return { + "sphere_centre": self.f3(b), + "sphere_radius": self.f32(b + 12), + "vertices": [self.u16(b + 16 + 2 * k) for k in range(4)], + "faces": [self.u16(b + 24 + 2 * k) for k in range(4)], + # six (face_i, face_j) pairs in itertools.combinations order + "portal_cost": [self.f32(b + 32 + 8 * k) for k in range(6)], + "portal_unk": [self.f32(b + 36 + 8 * k) for k in range(6)], + "index": self.u32(b + 80), + "flags": self.b[b + 84:b + 88], + } + + # section 2 — face (a plane plus its adjacency) + def face(self, i): + b = self.sec[2] + 48 * i + return { + "normal": self.f3(b), + "d": self.f32(b + 12), + "point": self.f3(b + 16), + "one": self.f32(b + 28), + "vertices": [self.u16(b + 32 + 2 * k) for k in range(3)], + "index": self.u16(b + 38), + "tet": [self.u16(b + 40), self.u16(b + 42)], # 0xFFFF = hull + "slot": [self.u16(b + 44), self.u16(b + 46)], + } + + # section 3/4/5 — the grid + def cell_index(self, x, y, z): + return (z * self.dims[1] + y) * self.dims[0] + x + + def cell(self, ci): + """[tet index] for one cell — count/pointer, item record, ref array.""" + b = self.sec[3] + 8 * ci + n, p = self.u32(b), self.u32(b + 4) + if n == 0 or p == 0: + return [] + item = p + FIXUP_BASE + k = self.u32(item + 0x10) + refs = self.ptr(item + 0x14) + return [(self.ptr(refs + 4 * j) - self.sec[1]) // 96 for j in range(k)] + + def cell_bounds(self, ci): + dx, dy = self.dims[0], self.dims[1] + x, y, z = ci % dx, (ci // dx) % dy, ci // (dx * dy) + lo = tuple(self.bbox_min[k] + self.cell_size[k] * (x, y, z)[k] + for k in range(3)) + return lo, tuple(lo[k] + self.cell_size[k] for k in range(3)) + + +# ── commands ──────────────────────────────────────────────────────────────── + +def cmd_list(pak): + for name_hash, blob in pak_entries(pak): + magic = blob[:4].decode("latin1") + magic = magic if magic.isprintable() else "?" + print(f"{name_hash:08x} {magic:4s} {len(blob):9d}") + + +def cmd_dump(pak, want=None): + for name_hash, blob in pak_entries(pak): + if blob[:4] != b"REGN": + continue + if want is not None and name_hash != want: + continue + r = Regn(blob) + print(f"── {name_hash:08x} {len(blob)} bytes") + print(f" bbox {r.bbox_min} .. {r.bbox_max}") + print(f" cell {r.cell_size} dims {r.dims}") + print(f" counts {r.count}") + for i in range(6): + print(f" sec{i} {SECTION_NAME[i]:<11s} @0x{r.sec[i]:06x} " + f"{r.count[i]:6d} x {STRIDE[i]}") + t = r.tet(0) + print(f" tet 0 verts {t['vertices']} faces {t['faces']} " + f"r={t['sphere_radius']:.1f}") + f0 = r.face(t["faces"][0]) + print(f" face {t['faces'][0]:<4d} verts {f0['vertices']} " + f"tets {f0['tet']} slots {f0['slot']}") + occupied = sum(1 for ci in range(r.dims[0] * r.dims[1] * r.dims[2]) + if r.cell(ci)) + print(f" cells {occupied} occupied of " + f"{r.dims[0] * r.dims[1] * r.dims[2]}") + if want is not None: + return + + +def cmd_verify(pak): + random.seed(0) + objs = [(h, b) for h, b in pak_entries(pak) if b[:4] == b"REGN"] + print(f"{len(objs)} REGN objects\n") + hdr = ("object ptr-slots chain face==3 verts ctrl " + "sphere/cell ctrl portal cost") + print(hdr) + for name_hash, blob in objs: + r = Regn(blob) + slots = pof0_pointer_slots(blob) + + # 1. where the fixup table says the pointers are + header = [o for o in slots if o < r.sec[0]] + in3 = [o for o in slots if r.sec[3] <= o < r.sec[4]] + in4 = [o for o in slots if r.sec[4] <= o < r.sec[5]] + in5 = [o for o in slots if o >= r.sec[5]] + others = len(slots) - len(header) - len(in3) - len(in4) - len(in5) + shape = (header == [0x70 + 4 * i for i in range(6)] + and others == 0 + and all((o - r.sec[3]) % 8 == 4 for o in in3) + and all((o - r.sec[4]) % 32 == 20 for o in in4) + and len(in5) == r.count[5] + and len(in4) == r.count[4] == len(in3)) + + # 2. the cell -> item -> ref-array chain is contiguously packed + total, chain = 0, True + for i in range(r.count[4]): + item = r.sec[4] + 32 * i + if r.ptr(item + 0x14) != r.sec[5] + 4 * total: + chain = False + total += r.u32(item + 0x10) + chain = chain and total == r.count[5] + + # 3. each of a tet's four faces passes through exactly 3 of its + # four vertices — against a random-face control + good = ctrl = n = 0 + for i in range(r.count[1]): + t = r.tet(i) + V = [r.vertex(p) for p in t["vertices"]] + scale = max(abs(x) for v in V for x in v) + 1 + for want_real in (True, False): + for fi in (t["faces"] if want_real else + [random.randrange(r.count[2]) for _ in range(4)]): + fc = r.face(fi) + on = sum(1 for v in V + if abs(sum(fc["normal"][j] * v[j] for j in range(3)) + + fc["d"]) <= 1e-4 * scale) + if want_real: + good += on == 3 + n += 1 + else: + ctrl += on == 3 + + # 4. a listed tet's bounding sphere reaches the listed cell — + # control is the same test with the cell axes transposed + hit = thit = m = 0 + for ci in range(r.dims[0] * r.dims[1] * r.dims[2]): + tets = r.cell(ci) + if not tets: + continue + lo, hi = r.cell_bounds(ci) + tlo, thi = tuple(reversed(lo)), tuple(reversed(hi)) + for ti in tets: + t = r.tet(ti) + c, rad = t["sphere_centre"], t["sphere_radius"] + for (a, b), which in (((lo, hi), 0), ((tlo, thi), 1)): + q = 0.0 + for j in range(3): + if c[j] < a[j]: + q += (a[j] - c[j]) ** 2 + elif c[j] > b[j]: + q += (c[j] - b[j]) ** 2 + if q <= rad * rad: + if which == 0: + hit += 1 + else: + thit += 1 + m += 1 + + # 5. the six portal costs are the face-centroid distances + pc = pn = 0 + for i in range(r.count[1]): + t = r.tet(i) + V = [r.vertex(p) for p in t["vertices"]] + omit = [] + for fi in t["faces"]: + fv = set(r.face(fi)["vertices"]) + miss = [k for k in range(4) if t["vertices"][k] not in fv] + omit.append(miss[0] if len(miss) == 1 else None) + if any(o is None for o in omit): + continue + for k, (a, b) in enumerate(PAIRS): + want = math.dist(V[omit[a]], V[omit[b]]) / 3 + pn += 1 + pc += abs(t["portal_cost"][k] - want) <= 1e-4 * max(1.0, want) + + print(f"{name_hash:08x} {'ok' if shape else 'BAD':>9s} " + f"{'ok' if chain else 'BAD':>7s} " + f"{100 * good / n:6.2f}% {100 * ctrl / n:6.2f}% " + f"{100 * hit / m:6.2f}% {100 * thit / m:6.2f}% " + f"{100 * pc / pn:7.3f}%") + + +def main(): + if len(sys.argv) < 3: + print(__doc__) + raise SystemExit(2) + cmd, pak = sys.argv[1], sys.argv[2] + if cmd == "list": + cmd_list(pak) + elif cmd == "dump": + want = int(sys.argv[3], 16) if len(sys.argv) > 3 else None + cmd_dump(pak, want) + elif cmd == "verify": + cmd_verify(pak) + else: + print(__doc__) + raise SystemExit(2) + + +if __name__ == "__main__": + main()