Commit Graph
6 Commits
Author SHA1 Message Date
ReaperOfVeriod 48d9506cf1 Decode tfrag texture bindings, vertex colours and light records
Adds level_core.rs (the uncompressed core index header and texture table)
and extends tfrag.rs with the data a draw call needs beyond geometry.

An ad-gif is five A+D quadwords holding packed fields rather than GS
registers; the level load rewrites them in place before drawing. On disc
TEX0_1.data_lo indexes the level's texture table, TEX1_1.data_lo is the
LOD bias K and data_hi the minification filter, and CLAMP_1 holds both
wrap modes.

Three things are easy to get wrong here:

* K is a signed 12-bit value in 1/16 units, sign-extended by shifting the
  field up 4 and back down. Retail values are -137..-89 mip-scaled;
  reading it unsigned gives 1911 instead of -137.
* TEX1_1.data_hi is MMIN, and 4 decodes as LinearMipmapNearest, not
  LinearMipmapLinear as the raw bit layout suggests.
* RGBA and the light/normal records are not in the VIF lists at all. They
  sit at fixed offsets in the patch data block: rgba_size*4 colours at
  rgba_ofs, vert_count light records at light_ofs+0x10. The tfrag origin
  lives there too -- if no STROW row supplied one, the quadword at
  light_ofs is it.

The texture table checks out on its own terms: 93 entries at index offset
0x2900 on level 0, and 0x2900 + 93*16 lands exactly on the next range.

Verification: an ad-gif index outside the level's table would mean the
geometry and the textures were misread independently, so every one is
range-checked against real entries. All 27 levels: 47,950 ad-gifs over
18,102 patches, all in range, referencing 1429 of 1595 level texture
entries (90%). Each patch's light array is exactly vert_count records and
its RGBA array exactly rgba_size*4.

Still open: LOD selection and morphing, and the texel data itself.
2026-10-10 02:22:51 +02:00
ReaperOfVeriod 9459f15329 Parse tfrag topology: vertex info, strips, indices and triangles
Turns tfrag positions into drawable geometry. Every unpack in a patch's
VIF lists is now classified by format *and* destination VU address, using
the VU header at the head of the common list. That table is what tells
the strips from the indices from the parent arrays: they are all V4_8
and only the address distinguishes them.

Strip indices address the vertex-info array, not the position array.
Several vertex-info entries can share a position, so an index resolves
through vertex_info[i].vertex / 2 to reach a position. Vertex-info also
carries UVs and the LOD parent link.

A strip run of n vertices is a GS triangle strip of n - 2 triangles with
alternating winding. The vertex count is biased by -128 when negative,
and the first record of a list is always biased even when positive --
real patches open with -116 or -124, never a small positive number.

tri_count lives in the patch header and cannot be derived from the lists
without walking them, so reproducing it is a strong check: all 27 levels,
18,102 patches, 809,879 triangles, every patch matching its header, every
triangle index inside its LOD's vertex-info range.

Four wrong turns got there, each of which failed quietly rather than
loudly: reading the element size from the immediate instead of the
command, 2-byte payload padding instead of 4, ignoring the inline
payload of STROW/STCOL/STMASK/MPG/DIRECT, and banking strips by arrival
order instead of by LOD. Every one produced parseable, plausible geometry.
Only the header's own triangle count caught them.

Still open: ad-gif texture bindings, per-vertex RGBA and light records,
and the LOD switch and morph rules.
2026-10-10 02:15:32 +02:00
ReaperOfVeriod 36641b4cf8 Parse tfrag terrain blocks and decode all vertex positions
Adds gc-formats::tfrag and a gated real-disc test that decodes every
terrain patch on all 27 levels.

Two rules decide whether this works at all, and both were wrong on the
first attempt:

The vector format is encoded in the VIF command byte, not the immediate.
Bits 2-3 are vn (component count minus one) and bits 0-1 are vl, giving
element_size = ((32 >> vl) * (vn + 1)) / 8. Terrain positions are vn=2,
vl=1 (command 0x69) at 6 bytes. Reading the format from the immediate's
low nibble -- the more commonly quoted layout -- yields 12 or 16 bytes
and misaligns everything after it.

Unpack payloads pad to 4 bytes, and several non-unpack commands carry
inline data: STROW/STCOL 16, STMASK 4, MPG num*8, DIRECT/DIRECTHL
imm*16. Ignoring those desynchronises the walk, and a desynchronised
walk still finds code words that look like positions.

Positions are also not all in one list. vert_count is the total across
three tiers, the LOD-2 list precedes the common one in offset order, and
the LOD-0-only list starts at shared_ofs + lod_1_size*16 rather than at
an offset of its own. Scanning only the common list matches about a
quarter of patches, each exactly -- which is what made it convincing.

The invariant is that every patch yields exactly its own vert_count. It
caught both errors, so tfrag_positions returns None on a mismatch rather
than partial geometry. All 27 levels: 18,102 patches, 821,439 vertices,
zero mismatches.

Still open: strips, triangle indices and the RGBA vertex-info arrays.
Positions alone are not drawable.
2026-10-10 02:07:02 +02:00
ReaperOfVeriod 16c04db559 Fix level extents read as bytes, and extract the level geometry
A defect in the Phase 1 extractor, found by reading the data extent's contents:
level extents are declared in sector counts, the generic G/ pair table uses byte
counts, and level containers were being routed through the generic path. So
LEVEL0.WAD's data extent -- 6,484 sectors -- was taken as 6,484 bytes. Every
test still passed, because a truncated file is still a file.

The extent's own header gave it away: it declares an uncompressed total of
13,277,920 bytes, and 13,277,920 / 6,484 is 2047.7. A ratio landing on 2048 is
a unit error, not a compression ratio. Across 27 levels this had been discarding
roughly 300 MB of level content.

Level containers now go through the typed LevelHeader instead. Level 0 yields:

  data.bin         13,279,232   the data extent
  core_data.wad     9,738,678   the geometry, compressed
  core_data.bin    16,969,296   the geometry, decompressed
  core_index.bin       31,600   the geometry directory
  gameplay.bin       659,456
  sound_bank.bin   1,503,232

core_data is a WAD LZ77 stream -- the format already implemented for the game --
and it decompresses cleanly. The first real level content this project has
recovered.

New: LevelDataHeader parses the data extent's own header, a table of byte ranges
naming the level overlay executable, the geometry directory, the GS RAM image,
the HUD banks and the geometry. Note this is a third meaning for a "size" field
on the same disc, after sector counts in the level header and byte counts in the
generic table; all three are documented together in level-data.md so the next
parser does not rediscover this the hard way.

The geometry directory's offsets were checked against the geometry rather than
taken on trust: all twelve land inside the 16.9 MB of decompressed core_data,
which is what confirms the header is being read correctly. The class tables
themselves do not yet decode under the documented layout, and that is recorded
as open work in plan/level-assets.md rather than papered over.

75 unit tests, 9 integration tests against the real disc. Hash table regenerated
at 5,159 files and 5,671,915,799 bytes -- hashes only, no content. Extraction
now writes to ~/Documents/gc-extracted rather than /tmp, which is 16 GB and did
not have room for a 5.3 GB output.
2026-10-09 16:28:26 +02:00
ReaperOfVeriod a9e43e39c1 Parse the level container header, naming each level's four extents
The first typed structure read from real data. Every one of the 27 level
containers begins with the same 0x60-byte header, which turns the generic pair
table into named assets: data, sound_bank, gameplay and occlusion, plus three
chunk descriptors.

Two things about this header are worth stating because both are silent failures
rather than errors.

Sizes here are sector counts, not byte counts. The generic G/ pair table on the
same disc uses byte counts, so both readings type-check and the wrong one is off
by a factor of 2048. The tiling check settles it: read as sectors, the four
extents tile every one of the 27 containers exactly; read as bytes they overlap
each other.

Level id is not container order. LEVEL21.WAD reports id 30, so a level was cut
from the disc and its number left a gap. Anything keying off file order -- a
save slot, the planet select screen -- would mislabel that planet. There is also
a second trap in the same area: the ISO filesystem sorts names alphabetically, so
LEVEL10 precedes LEVEL2, and any tool needing order has to sort numerically.

The header's field at 0x04 is zero on all 27 levels. It is not the container
size despite looking like one, so the code reads it as a runtime field and takes
the container length from the ISO entry instead.

Measured across all 27 levels, three invariants now hold in tests: extents tile
in file order; extent coverage plus chunks reaches at least the end of the
container (21 levels land exactly on its length, 6 leave 134 to 689 sectors of
trailing data); and exactly one level's id differs from its container index. The
coverage assertion is >= rather than == on purpose -- 21 levels end one sector
past the file end because the header occupies sector 0, and asserting equality
would truncate a level's tail.

gameplay is left unparsed and that is recorded rather than hidden. It begins
"WAD" plus a per-level byte and does not decode with the WAD LZ77 decoder: all
27 levels produce plausible output for the first packet and then fail on a rule
the format's own encoder never breaks, which means the input is wrong rather
than the decoder. docs/plan/level-assets.md records what is ruled out and what
to try next.

66 unit tests, plus 7 integration tests against the real disc. The unpack hash
table is unchanged, so this adds parsing without altering the pipeline.
2026-10-09 16:20:25 +02:00
ReaperOfVeriod 027373e65b Add the disc pipeline: identify, unpack and verify
Phase 1 of the roadmap. The disc is now turned into verified asset files, and
the result is checked against a committed hash table rather than against
whatever happens to be on disk.

  gc-extract identify disc.iso        SCUS_972.68 v1.01 NTSC, ELF SHA-1 matches
  gc-extract unpack   disc.iso out/   5135 files, 4,500,106,960 bytes, 10.8s
  gc-extract verify   out/ table.tsv  OK: 5135 files match

All 92 G/*.WAD containers parse. MPEG1 and MPEG2 are raw video and pass
through whole; the other 90 are indexed containers whose extents are extracted
individually.

Formats recovered and written up in docs/formats/:

- iso9660.rs. R&C2 lists its assets in the filesystem, unlike R&C1, so a
  directory walk is the primary asset index. Three details that all fail
  quietly rather than loudly: directory records store every 32-bit field twice
  (little-endian then big-endian), the CD001 signature is at sector offset 1
  rather than 0x8001, and the directory flag at record offset 25 is
  authoritative where the identifier's trailing slash is not.

- global_wad.rs. Container headers hold a table of extents. All 92 indexed
  containers use {offset in sectors, size in bytes}. Measured, not assumed: the
  sector-count reading places every entry of all 27 LEVEL<n> and all 27
  AUDIO<n> containers out of range, while the byte reading places all of them
  correctly. SCENE<n> is the one exception, a flat sector list whose extents
  are sized by the next value.

- wad.rs. Insomniac's LZ77 stream: four flag-selected packet classes, with 0-3
  trailing literal bytes per packet carried in unused low bits. This is not a
  bit stream, which is why it resists the usual LZ77 reading.

- buf.rs. A bounds-checked reader, so untrusted 2003 disc data produces typed
  errors rather than panics.

gc-extract carries the three stages and a SHA-1 implementation with FIPS 180-4
test vectors, so identification works without pulling in a crypto dependency.

Verification: 54 unit tests, plus four integration tests that run only when
GC_DISC names a disc. Those four check identification against the recorded
values, cross-check the Rust identifier against tools/identify-disc.py, parse
all 92 containers, and confirm a full unpack matches the committed table. The
committed table is 316 KB of sizes and SHA-1s for all 5,135 files: hashes only,
no game content, so it is safe to version and it turns "matches retail" into
something a test can assert.

docs/plan/disc-pipeline.md records two wrong turns, because both are easy to
repeat and both failed by producing plausible wrong output rather than an
error: the container table was read backwards, and the LZ77 stream was modelled
as a bit stream. The first survived because it happened to be correct on the
one container used for manual checking.
2026-10-09 16:08:30 +02:00