Files
Sylpheed/docs/re/structures/mcol-collision.md
Sylpheed RE agent e1db4d84ff re: MCOL solved -- a closed triangle collision mesh in a uniform grid
The 0x50 header word, which the first section of this page had dismissed as "a
large value", is two u16 counts: vertices and triangles.  They give the two
remaining blocks their stride, and every derived length is exact in 11/11 --
len(0x54) == align16(12*nv), len(0x58) == align16(6*nt), and nt equals the
bounding-sphere count decoded last iteration.

Checks that cannot pass by accident:

  * sphere i is the TIGHT bounding sphere of triangle i, 4768/4768, with
    max|v-c|/r median 0.99990 (a fixed 1.0001 epsilon), against a 1.32%
    random-triangle control;
  * the mesh is watertight -- every edge shared by exactly two triangles,
    7152/7152, zero degenerate triangles, zero unreferenced vertices;
  * the two smallest objects are 8 vertices and 12 triangles whose positions
    are the eight +-250000 corners of the map bbox: a bare bounding cube.

The cell lists are a correct broad phase: with an exact triangle/box SAT test
only 3 overlapping triangles in 18 577 entries are absent, so a query walking
one cell's list cannot miss a hit.  The 730 conservative extras bracket the
builder's own test between exact-SAT and AABB, which retires the 18 unexplained
"sphere misses" from the previous commit as that same margin.

mcol_probe.py gains `mesh` and `obj`; `verify` now runs all three checks and its
output is recorded in docs/re/data/mcol-verify.txt.
2026-08-26 09:12:36 +00:00

22 KiB
Raw Blame History

MCOL — the same container as REGN, and the same map parameters

🟡 Opened 2026-08-26. MCOL sits beside REGN in hidden/MiscBin.pak, 11 of each, and has never been decoded. This page establishes what it shares with REGN — which is a lot, and gives the next attempt a large head start.

Same container

Over all 11 objects:

POF0 fixup table at data_size@+4 + 16 11 / 11
bbox pad words are 1.0 / 1.0 / 0.0 at +0x1C, +0x2C, +0x3C 11 / 11
extent == max min for the 0x30 block 11 / 11

So the header prefix is byte-for-byte the same shape as REGN's: magic, data size, then bbox min / bbox max / extent as f32[4], then a triple at 0x40. The POF0 mechanism applies, which means the chunk + 0x10 base and the loader's own pointer list are available here too — the two things that cracked REGN.

And the same map parameters, exactly

The bounding boxes and the 0x40 triple are not merely similar — the distributions are identical:

MCOL REGN
bbox ±250 000 2 2
bbox ±50 000 6 6
bbox ±25 000 3 3
0x40 = 50 000 2 2
0x40 = 10 000 9 9

Eleven maps, and for each one an MCOL and a REGN describing the same volume at the same cell size. 0x40 is the cell size in REGN; the same values in the same multiplicities here is strong evidence it is the cell size in MCOL too — though note this is a match of distributions, not a demonstrated object-to-object pairing, which would need the two linked by name or by a stage's tables.

What is not yet known

  • Everything past 0x40. MCOL's words at 0x500x84 do not look like REGN's (REGN has grid dims at 0x50, six u16 counts at 0x60 and six section pointers at 0x70; MCOL has a large value, two mid-range values and 112 at 0x50, mostly zeros at 0x60, and 0x05050501 at 0x70). The headers agree on the spatial prefix and diverge after it.
  • Everything past 0x40 — but see below; the pointer layout is now known.

The pointer layout, from POF0

2026-08-26. Running the known-good decoder (regn_decode.py on auto/regn-reader) rather than my own broken one. Sanity check first: on REGN it returns header slots 0x700x84 exactly — the six section pointers — so the tool and my use of it are right.

On MCOL, over all 11 objects:

header-region relocated slots are exactly 0x54, 0x58, 0x5C, 0x74 11 / 11
slot 0x5C resolves to 0x80 — the first byte after the header 11 / 11
slot 0x74 resolves to (first array pointer 8) 10 / 11

So MCOL has four top-level pointers where REGN has six, and one of them (0x5C) always addresses the data immediately following the header.

The bulk of the relocations form record arrays. 92.7 % of the gaps between consecutive relocated words are 32 bytes, arranged in 7127 contiguous runs per object. Combined with 0x74 landing 8 bytes before the first of them, the reading is an array of 32-byte records each carrying one pointer at +8.

What the four targets look like:

0x5C -> 0x80     c685620b 4596789d 4694b3b5 44a9a634   floats
0x54 -> …        c6826964 456b1aa4 469ab065 c685cfe8   floats
0x58 -> …        00000001 00020003 00040005 00050004   small ints / u16 pairs
0x74 -> …        00000001 00000001 00007710 00000000   counts, then the array

Two float blocks, an index block and a record array is the shape of a mesh — which is what a name like MCOL beside a navigation mesh would suggest. 🟡 That is a reading of the shape; none of the four blocks has been decoded.

Still open: the record layout, what the index block indexes, the one object in eleven where 0x74 does not land 8 before the array, and the 7.3 % of gaps that are not 32 (they are the boundaries between runs, but that has not been checked).

🟡 The 32-byte record — a cell entry, and there are two interleaved arrays

2026-08-26. Reading each record as 8 big-endian words (record start = pointer slot 8), the first entries of the smallest object are:

@0x6780  00000001 00000001 00007710 00000000 00000000 00000000 00000000 47295092
@0x67A0  01000001 00000001 00007730 00000000 …
@0x67C0  02000001 00000001 00007750 00000000 46023555 C6023555 C6023555 471FA1A7
@0x6820  00010001 00000001 000077B0 00000000 …

Word 0 read as four bytes is (x, y, z, 1) — a 3-D cell index. That object's grid is 5×5×5 (bbox ±25 000, cell 10 000), and the values run 04 in the first byte and step the second byte at the right point. Words 46 are a position and word 7 a positive scalar — a bounding sphere. Word 1 is a count and word 2 the relocated pointer.

Every record pointer lands in the same region — 8 976 / 8 976 (100 %) — and each points 0xFA0 further on with the same stride, so there are two parallel arrays, not one: array A at 0x6780 and array B at 0x7720.

The 50 % is the tell, not a failure

Testing the cell-index reading over all relocated records gives almost exactly half:

byte 3 == 1                                  4 491 / 8 976  (50.03 %)
bytes 0..2 a valid cell index                4 488          (50.00 %)
the record's sphere reaches that cell        4 485          (49.97 %)

Three independent criteria all landing on 50.0 % is not a partial fit — it says half the records are not this type. The POF0 slot list interleaves both arrays, and I was testing array B's records against array A's layout. Reported as a rate it would read like a half-working hypothesis; split by array it is two clean populations.

🟡 So array A is a per-cell record — cell index, count, pointer into array B, bounding sphere — the same role REGN's section 3 plays. Array B's layout is unread, and the split has not yet been re-run per array to confirm 100 % on A.

Split by array, and it goes to 100 %

Done. Separating the records by address and re-running the same three criteria:

array A (2 509) array B (6 467)
byte 3 of word 0 == 1 100.00 % 30.65 %
bytes 02 a valid cell index 100.00 % 30.60 %
the record's sphere reaches that cell 99.92 %

Array A is the per-cell record, exactly as read: cell index (x, y, z), a count, a pointer into array B, and a bounding sphere — the same role REGN's section 3 plays. Array B is a different record type; its ~30 % is incidental, and it is the control that shows A's 100 % is not something any 32-byte block would score.

Worth noting the split was crude — "first half by address", giving 2 509 vs 6 467 rather than an even cut — and A still came out clean at 100 %. A rough partition that isolates a perfect population is stronger evidence than a careful one that isolates a good-ish population.

A → B is one-to-one, and every count is 1

Filtering array A by the cell-index criteria (so only genuine A records) and following each one's pointer, over all 11 objects:

every A record points at a distinct B record 11 / 11
every A record's count field is exactly 1 11 / 11

Per object the A-record count is the number of occupied cells — 110, 118, 117, 488, 488, 575, 514, 468, 492, 546, 572 — and the total of the count fields equals it exactly.

That is the same design REGN uses: this corpus already records for REGN that "every occupied cell has count exactly 1 — total items equals occupied cells". Two sibling formats, same cell-index convention. It is a further independent confirmation of the A-record reading, since the filter and the cardinality are unrelated criteria.

Array B resisted a first pass, and the tests I reached for were bad ones

I could not read B's record layout this iteration, and both attempts failed in ways worth recording rather than retrying:

  • The boundary was off by 8 again. Dumping from pointer 8 (as A's layout needed) produced records that begin with what is plainly the tail of the previous structure — a zero and 0x47295092, the same radius value A's first record carries. Same mistake as the 0x74 check two iterations ago.
  • The u16-index test had no power. The 0x5C block holds ~1 232 points, so "is this u16 below the point count" is satisfied by almost any small value — and duly reported 100 % at seven different offsets. That is the fourth time in this pair of formats that a bound-check against a large collection has produced a meaningless 100 %.

So B's layout is open. What would have power: B records are 1:1 with occupied cells and A already carries the cell's bounding sphere, so a candidate field in B can be tested by whether it is spatially consistent with that specific cell — the same design that worked for REGN's faces, and the same design that these two bound-checks lack.

Array B decoded — {count, u16 index array}, and the chain closes

2026-08-26. Two corrections got there.

Where the other relocations live. MCOL has 9 020 relocated words and only 4 488 are A-record pointers. I assumed the rest sat at B+8, mirroring A — refuted, 0 / 4 488. Measuring their offset from the nearest preceding B record instead:

at B + 4              4 488  (99.0 %)
before the first B       44  (1.0 %)

and those 44 are exactly the four header pointers × 11 objects. Nothing unaccounted for.

So B is {u32 count, pointer} — pointer at +4, not +8. Read that way:

B@0x7720   count 0x2B   ptr 0x84D0
B@0x7740   count 0x1C   ptr 0x8526      0x8526  0x84D0 = 0x56 = 2 × 0x2B
B@0x7760   count 0x02   ptr 0x855E      0x855E  0x8526 = 0x38 = 2 × 0x1C
B@0x7780   count 0x02   ptr 0x8562      = 2 × 2

The pointers advance by exactly twice the count — a packed u16 array, no padding. Over all 11 objects:

consecutive B pointers differ by exactly 2 × count 4 477 / 4 477 (100.00 %)
the u16 entries are valid 0x5C point indices 18 379 / 18 379

The first row is the load-bearing one: an exact arithmetic identity over 4 477 consecutive pairs. (The second is the same weak bound-check flagged above — the point block is large, so almost any u16 passes. It is consistent, not evidence.)

The chain

position → cell → A record {cell index, count 1, →B, bounding sphere}
                → B record {count n, →u16[n]}
                → 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.

Still open: the 0x54 and 0x58 blocks (neither is reached by this chain). What the u16 entries index is not the point block — see immediately below.

The u16 entries do NOT index the point block — and the weak test said they did

2026-08-26. Two tests, and the contrast between them is the point of this section.

Not a triangle list. If the u16 array held triangle corners, every count would be divisible by 3. Counts modulo 3 across all objects:

n % 3 == 0   639        n % 3 == 1   2 063        n % 3 == 2   1 786

Spread across all three residues. Refuted.

Not cell-local either. A B record is reached through a specific cell, so a point it references should lie in that cell. With a random-point control:

referenced point inside the cell that reached it 142 / 17 871 = 0.79 %
a random point inside that cell (control) 85 / 17 871 = 0.48 %

Chance. So whatever the u16s address, it is not the 0x5C point block in any spatially meaningful way.

⚠️ This is the bound-check hazard caught in the act

One section above, the same u16 entries scored 18 379 / 18 379 (100 %) on "are these valid 0x5C point indices". I flagged that at the time as the weak bound-check rather than evidence, and recorded it as consistent rather than as a finding. That caution was correct: the powered version of the same question now returns chance.

This is the fifth appearance of the pattern across REGN and MCOL, and the first time both halves have been run side by side on the same field, so it is worth stating exactly:

A bound-check asks "could this be an index?". Almost always, yes — the answer is set by how large the target collection is, not by whether the field means anything. The powered version asks "does the thing it points at make sense where it was reached from?", and only that version can be wrong.

Had the 100 % been written up as the decode, this page would now carry a confident and false statement about MCOL's geometry.

What the u16s 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.

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 u16s 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.

MCOL is a closed triangle collision mesh — decoded

2026-08-26. The header's 0x50 word, which the first section on this page dismissed as "a large value", is two u16 counts, and they give both remaining blocks a stride:

0x50  u16 vertex count     0x52  u16 triangle count

Every derived length is exact in 11 / 11 objects:

len(0x54) == align16(12 × vertices) 11 / 11
len(0x58) == align16(6 × triangles) 11 / 11
triangle count == sphere count (len(0x5C)/16) 11 / 11
object verts tris 0x54 0x58 spheres
2cf7eb47 34 60 416 368 60
cbb99d34 8 12 96 80 12
d84a95fb 111 218 1 344 1 312 218
db066592 405 790 4 864 4 752 790
db61c506 73 134 880 816 134
dc44fe0c 89 174 1 072 1 056 174
dc4b0896 502 924 6 032 5 552 924
dd89a110 632 1 164 7 584 6 992 1 164
df4628c2 134 260 1 616 1 568 260
e084c13c 8 12 96 80 12
e16460cf 554 1 020 6 656 6 128 1 020

The two smallest objects are the tell on their own: 8 vertices and 12 triangles, and their 0x54 block is exactly the eight ±250 000 corners of the map's bounding box. That is a cube as a triangle mesh — a map whose only collision is its outer wall.

Sphere i bounds triangle i, and it is tight

The algebraic test, which cannot pass by accident:

all three vertices of triangle i inside sphere i 4 768 / 4 768 = 100.00 %
…and the sphere is tight (max‖vc‖ / r within 1 %) 4 768 / 4 768 = 100.00 %
sphere i encloses a random triangle (control) 63 / 4 768 = 1.32 %

max‖vc‖ / r has median 0.99990 across every triangle of every object, so the radius is the enclosing radius times a fixed 1.0001 epsilon. That is the same 1.0001 visible by hand on the cube, where the sphere of a box-face triangle came out as 250 000·√2 × 1.0001.

And the mesh is closed

edges shared by exactly two triangles 7 152 / 7 152 = 100.00 %
degenerate triangles 0
vertices never referenced by a triangle 0

A watertight manifold with no orphans — which is what a collision hull must be, and a result that a wrong stride or a wrong index width could not produce. cbb99d34 has Euler characteristic V E + F = 8 18 + 12 = 2 (one closed surface, the box); 2cf7eb47 has 4, i.e. two closed components.

The cell lists are a correct broad phase

Re-running the completeness test with the actual triangles instead of their bounding spheres, using a separating-axis triangle/box test:

cells whose u16 list is exactly the overlapping triangle set 3 973 / 4 488 = 88.52 %
triangles overlapping a cell but absent from its list 3
triangles listed but not overlapping 730 (3.9 % of 18 577 entries)

Three misses in 18 577 entries: the lists are complete, which is the property a broad phase must have — a collision query that walks one cell's list cannot miss a triangle it should have hit. The 730 extras are conservative and harmless.

Their size identifies the builder's own test as sitting between an exact triangle/box test and a cheap AABB one: replacing the SAT test with "triangle AABB overlaps cell" gives 0 listed-but-not-overlapping (so every listed triangle's AABB does reach its cell) but 7 913 missing, so the builder is stricter than AABB and looser than exact — an exact test with a margin. This also explains the 18 sphere misses left open in the section above: they were never sphere-vs-cell facts, they are that same margin.

The full format

0x00  magic 'MCOL'
0x04  data size (POF0 table at data_size + 16)
0x10  bbox min f32[4]      0x20  bbox max f32[4]      0x30  extent f32[4]
0x40  cell size f32[3]
0x50  u16 vertex count     0x52  u16 triangle count
0x54  → vertex array       f32[3] × nv,  padded to 16
0x58  → triangle array     u16[3] × nt,  padded to 16
0x5C  → bounding spheres   {centre f32[3], radius f32} × nt   (= 0x80)
0x74  → grid cell array    32-byte A records, pointer at +8

position → cell → A {cell index (x,y,z,1), count 1, →B, bounding sphere}
                → B {u32 count, →u16[n] at +4}
                → n triangle indices

Reproduce with tools/re-capture/mcol_probe.py verify (recorded in ../data/mcol-verify.txt); mcol_probe.py obj writes any object out as a Wavefront OBJ.

Still open: MCOL objects are not name-resolved, so which map each one belongs to — and the object-to-object pairing with REGN that the header distributions only hint at — is still unknown. And the runtime consumer has not been found, the same gap REGN has.