Add tfrag LOD selection, morph weights and parent linkage

The block header's float at +0x08 gives L and the switch distances are
[D0, D1, D2] = [6L, 4L, 2L] world units -- 192/128/64 on every level
measured. The distance test uses the patch's bounding sphere truncated
to integers against trunc(D * 1024), not the sphere's centre, so a
patch straddling a threshold takes the coarser mode. Six modes result:
LOD 2, LOD 1 collapse, LOD 1 morph, LOD 0 collapse, LOD 0 morph, LOD 0.

A morph weight runs 0 to 1 across its tier's band. Past the far edge an
entry is not blended but replaced by its first parent -- a collapse.

The tested property is that distance never moves back to a finer mode,
swept across the whole range, because that is what keeps terrain from
popping as the camera moves.

The four V4_8 parent arrays are classified by VU address and an
unrecognised address is now an error, which is a real check: all 18,102
patches still parse. Every recorded parent resolves to real vertex info
and real positions, and no common entry claims a parent.

That is as far as it goes, and the limit is a finding rather than a
rounding error. The addresses are not unique: on level 0 patch 1,
unk_indices_2_lod_01_addr == parent_indices_lod_0_addr == 291, with
unk_06 and unk_0a both zero, so nothing but unpacking order separates
them. Across all 27 levels 57,782 of 374,349 LOD entries resolve, and
316,548 unresolved LOD-0 entries against 28,739 resolved is far too
lopsided to be genuine -- either the arrays are misassigned where
addresses collide, or RAC2's positions_lod_0_count means something other
than RAC1's. Only 3,274 of 18,102 patches carry enough V4_8 bytes to
cover positions_lod_01_count + positions_lod_0_count + extras.

The vertex-info tiers themselves are confirmed: the addresses at +9 and
+11 match the observed V4_16 unpacks and the three tier counts sum to
vert_count on every patch, so the header layout is right and it is
specifically the parent arrays that are open. Ruled out so far:
address-only classification (collides) and one-parent-per-position (fails
on 15,000 patches). Not yet tried: ordering by VU address instead of by
declaration, and using unk_06/unk_0a to suppress an empty tier before
falling through. Written up in plan/level-assets.md.
This commit is contained in:
2026-10-10 02:36:40 +02:00
parent 29fdf89f53
commit 2a5307a157
4 changed files with 542 additions and 9 deletions
+49 -5
View File
@@ -435,10 +435,54 @@ CLUT remap still yields a well-formed image of the wrong thing. Two layers:
That tool is worth keeping: it is the cheapest way to tell a decode that is
merely well-formed from one that is right.
### LOD selection
Mostly settled. The block header's float at +0x08 gives `L`, and the switch
distances are `[D0, D1, D2] = [6L, 4L, 2L]` world units; on every level `L` is
32.0, so `[192, 128, 64]`. The distance test compares the patch's bounding
sphere, truncated to integers, against `trunc(D * 1024)`. Six modes result —
LOD 2, LOD 1 collapse, LOD 1 morph, LOD 0 collapse, LOD 0 morph, LOD 0 — and
the *sphere* is what is tested, not the centre, so a patch straddling a
threshold takes the coarser mode. That is what stops geometry popping as the
camera moves, and it is worth testing for directly: sweeping the depth must
never move back to a finer mode.
A morph weight runs 0 to 1 across its tier's band: LOD-01 between D1 and D0,
LOD-0 between D2 and D1. Past the far edge of the band an entry is not morphed
but *replaced* by its first parent — a collapse, not a blend.
### What is still unresolved: the parent-index arrays
The four V4_8 parent arrays (`parent_indices_lod01`, `unk_indices_2_lod01`,
`parent_indices_lod0`, `unk_indices_2_lod0`) are classified by VU address, but
**the addresses are not unique**. On level 0 patch 1,
`unk_indices_2_lod_01_addr == parent_indices_lod_0_addr == 291`, and the VU
header's `unk_06` and `unk_0a` are both zero — there are no extra entries at
all, so nothing but the order the unpacks appear in distinguishes the two.
The consequence is measurable. Across all 27 levels, of 886,841 vertex-info
entries, 516,492 are common and 57,782 LOD entries resolve to a parent. But
316,548 LOD-0 entries have no recorded parent against only 28,739 that do, and
only 3,274 of 18,102 patches carry enough V4_8 bytes to cover
`positions_lod_01_count + positions_lod_0_count + extras`. The asymmetry is too
large to be genuine, so one of two things is true: the parent arrays are being
misassigned where addresses collide, or RAC2's VU header means something
different by `positions_lod_0_count` than RAC1's does.
The vertex-info tiers themselves *are* confirmed — the addresses at +9 and +11
do match the observed V4_16 unpacks, and the three tier counts sum to
`vert_count` on every patch. So the header layout is right; it is specifically
the parent arrays that are unresolved.
Ruled out for now: address-only classification (collides), and assuming the
arrays are one entry per position (fails on 15,000 patches). Not yet tried:
ordering by VU address rather than by declaration, and using `unk_06`/`unk_0a`
to suppress an empty tier before falling through to the next candidate.
### Still open
The LOD switch and morph rules in `TfragProc`, and the GS register values the
level load builds from the texture entry (TBP0/TBP1 and so on) for a renderer
that emulates the GS rather than resolving addresses itself. A tfrag can now be
turned into positioned, UV-mapped, textured triangles; what is missing is
choosing which LOD to draw.
The parent arrays above, and the GS register values the level load builds from
the texture entry (TBP0/TBP1 and so on) for a renderer that emulates the GS
rather than resolving addresses itself. A tfrag can be turned into positioned,
UV-mapped, textured triangles and told which LOD to draw; morphing cannot be
completed until the parent arrays resolve.
+12 -2
View File
@@ -20,7 +20,7 @@ early, while they are still cheap to retire.
header, and the `data` extent is parsed as a container of byte ranges. The
geometry decompresses: level 0 yields 16,969,296 bytes of level content
from a 9.7 MB stream.
- [ ] Next: tfrag LOD selection and morphing, then the first drawable frame.
- [ ] Next: tfrag parent indices, then the first drawable frame.
The disc being the exact build the reference projects pin is the single
biggest favourable fact so far: no disc conversion, no regional timing work,
@@ -141,7 +141,17 @@ Phase 1 hands over the extents; this phase works out what each one contains.
96.3% with real detail. Verified visually via the new
`gc-extract dump-texture`, because a wrong lookup still yields a
well-formed image of the wrong thing.
- [ ] tfrag LOD selection: the switch and morph rules in `TfragProc`.
- [x] **tfrag LOD selection.** Switch distances scale as `[6L, 4L, 2L]` from the
block header's float; six draw modes result from testing the bounding
sphere against `trunc(D * 1024)`, so a patch straddling a threshold takes
the coarser mode and distance never moves back to finer detail. Morph
weights run 0..1 across each tier's band, and collapse replaces an entry
with its first parent rather than blending it.
- [ ] tfrag parent indices: the four V4_8 arrays are classified by VU address,
but the addresses collide (`unk_06` and `unk_0a` are zero, so an absent
tier shares the next one's address). 57,782 of 374,349 LOD entries
resolve; the 316,548 unresolved LOD-0 entries are far too lopsided to be
genuine. Tried and ruled out in `plan/level-assets.md`.
- [ ] `gameplay` — begins `WAD` + a per-level byte, and does *not* decode with
the WAD LZ77 decoder. All 27 levels fail identically, so the input is
wrong rather than the decoder. Ruled out: bit order, header length, the