~/vannacut.com
link established posts 17 build 1.0.0 · 18:40 UTC
28 min read Development · Research

V-Rally 2 PC PSX reverse engineering – PAK exporter

TLDR? – just click here to get the tool instead


V-Rally 2 PAK and Asset Format Reference

This document consolidates the reverse-engineering results used by VR2 Asset
Explorer 0.6.11. It covers the PAK container, Eden LZ77, checksums, offsets,
platform detection, ZAP archives, car and track resources, textures, geometry,
sound, naming, failed interpretations, corrected solutions, validation, and
remaining unknowns.

No copyrighted game data is included. The extractor is read-only and does not
modify the selected installation or disc extraction.


1. Evidence and confidence model

The conclusions in this reference have four confidence classes:

ClassMeaning
Developer-confirmedTaken from the declarations and process notes supplied.
ValidatedConfirmed against retail PC/PSX files, stored CRCs, executable loader/renderer behaviour, structural identities, or repeatable Blender results.
Export policyA deliberate conversion choice needed for formats such as OBJ/MTL; not necessarily a one-to-one representation of the original renderer.
OpenDetected but not yet understood well enough for lossless export.

All multibyte fields described here are little-endian, except explicitly
identified PlayStation audio fields.


2. High-level asset organization

The developer notes describe the intended PC build pipeline as follows:

  • the large PAK contains resources from the GRP and SCRIPT trees;
  • OVL plus ROOTGAME, ROOTSPEC, and ROOTCAR(S) are handled separately;
  • every embedded resource is aligned to a 2048-byte sector boundary where
    required by its position in a block;
  • a separate PAX file maps logical file indices to PAK locations;
  • a build-time PAKINF database records source metadata, actual size, allocated
    sector size, timestamps, and update state;
  • stable indices are important because generated C defines reference them;
    file offsets may move, but indices should not change unnecessarily;
  • modified files are updated in place when they still fit their old sector
    allocation, otherwise they are relocated.

The notes propose storing PAK offsets in 16 bits. Because an offset unit is one
2048-byte sector, the addressable range is:

65536 * 2048 = 134217728 bytes = 128 MiB

This was considered sufficient for the GRP/SCR PAK subset even though the CD
itself could hold much more. Every embedded file also needed an integrated
header, or equivalent metadata, from which its exact non-padded byte size could
be recovered.

The described incremental packer workflow was:

  1. load PAKINF or initialize an empty database;
  2. mark known files removed;
  3. recursively scan GRP and SCRIPT;
  4. compare path, timestamp, and size;
  5. classify each entry as new, unchanged, modified, or removed;
  6. retain the existing index whenever possible;
  7. rewrite in the old sector allocation when the new size fits, otherwise
    relocate and update only the offset;
  8. regenerate PAX and pak_files.def from stable indices and paths.

This explains why PAK is a block/resource store rather than a conventional
filename archive. The retail root archives expose physical block and sub-block
indices, while names are recovered from SCR scripts, embedded build paths, and
known index relationships.

2.1 Root archives observed

ArchiveMain role
ROOTCARS.PAK / rootcars.pakCar descriptors, texture banks, and three geometry LODs.
ROOTSPEC.PAK / rootspec.pakPlayable stages, service/podium resources, track textures, scenery, and road grids.
ROOTGEN.PAK / rootGEN.pakReusable country/weather background packages and related resources. These are not necessarily complete driveable stages.

The eight-byte PAK header and four-byte big-block descriptor are shared by PC
and PSX. The important container difference is the layout of each sub-block
record inside a big block.


3. Developer-confirmed PAK declarations

The following structures are from the supplied Eden declarations. The
bloc[99] and subbloc[99] members are variable-length placeholders; their
declared array length must never be used as an on-disk sizeof.

3.1 Main header

typedef struct
{
    edU32 checksum;
    edU16 bloc_count;
    edU16 data_type;
} PAK_HEADER;

Size: 8 bytes.

The developer describes checksum as the main checksum for the whole file,
bloc_count as the number of big data blocks, and data_type as the archive
layout/behaviour flags. The exact algorithm/coverage of the main header
checksum
has not yet been proven; it must not be assumed to equal the
validated per-sub-block CRC formula below.

3.2 Big-block descriptor union

typedef struct
{
    edU16 sector_size;
    edU16 sector_size_leader;
} PAK_BLOC_TYPE_MULTI;

typedef struct
{
    edU16 sector_size;
    edU16 byte_size_leader;
} PAK_BLOC_TYPE_PRELOADED;

typedef struct
{
    edU32 sector_offset;
} PAK_BLOC_TYPE_SINGLE;

typedef struct
{
    union
    {
        PAK_BLOC_TYPE_MULTI multi;
        PAK_BLOC_TYPE_SINGLE single;
        PAK_BLOC_TYPE_PRELOADED preload;
    } info;
} PAK_BLOC;

Every big-block descriptor occupies 4 bytes. Its interpretation depends on
PAK_HEADER.data_type.

3.3 PC sub-block record

typedef struct
{
    edU32 informations;
    edU32 byte_size;
    edU32 unpacked_byte_size;
    edU32 overlap_size;
    edU32 checksum;
} PAK_SUBBLOC;

Size on PC: 20 bytes.

3.4 In-memory file state

The supplied PAK_FILEINFO contains pointers to the leader and header,
absolute leader offset, computed header size, a file handle/name, and current
file position. It is an in-memory loader state structure, not a literal
on-disk header.

3.5 Constants

ALIGNMENT_SIZE                 2048
ALIGNMENT_MASK                 2047

PAK_TYPE_MULTIBLOC             0
PAK_TYPE_MONOBLOC              1
PAK_TYPE_NO_OVERLAP            0
PAK_TYPE_OVERLAPED             2
PAK_TYPE_SCRIPT_IN_BLOC        0
PAK_TYPE_SCRIPT_PRELOADED      4

PAK_SUBBLOC_UNPACKED           0
PAK_SUBBLOC_PACKED             1
PAK_SUBBLOC_DYNAMIQUE          0
PAK_SUBBLOC_STATIQUE           2
PAK_SUBBLOC_SINGLE             0
PAK_SUBBLOC_MULTIPLE           4

PAK_ALLOC_DEFAULT              0
PAK_ALLOC_LOW                  1
PAK_ALLOC_HIGH                 2

data_type is a bit field:

BitClearSet
0 (0x1)MULTIBLOCMONOBLOC
1 (0x2)no overlapoverlapped/in-place unpacking
2 (0x4)script inside blockscript preloaded in leader

informations is also a bit field:

BitClearSet
0 (0x1)stored/unpackedEden LZ77 packed
1 (0x2)dynamic/transientstatic/resident
2 (0x4)one information itemmultiple information items

4. PAK on-disk layout

4.1 Common outer header

0x00  u32 checksum
0x04  u16 block_count
0x06  u16 data_type
0x08  PAK_BLOC block[block_count]     // 4 bytes each

The descriptor table therefore ends at:

header_table_end = 8 + block_count * 4

For a multiblock archive, the first block starts at:

first_block_sector = ceil(header_table_end / 2048)
first_block_offset = first_block_sector * 2048

Subsequent blocks are allocated consecutively:

block_sector[i] = first_block_sector
                + sum(block[j].sector_size for j in 0 .. i-1)

For a monoblock archive, each descriptor is an explicit u32 sector_offset.
The current code supports this structural interpretation, but the retained
retail root-archive validation primarily covers multiblock files.

4.2 Big-block leader

At the big block’s sector-aligned file offset:

u32 sub_block_count
sub_block_record[sub_block_count]
u8  sub_block_0_data[]

Three alignment facts are validated:

  1. sub-block 0 begins immediately after the record table and may therefore
    be unaligned inside the leader sector;
  2. after every stored sub-block payload, the cursor is rounded up to the next
    2048-byte boundary before the next sub-block;
  3. the gaps commonly contain the repeating 64-byte marker:
PADDING DATAS...-ORIC AND ATARI--COOL  MACHINES-STNICC2000 RULEZ

The practical cursor algorithm is:

offset = block_base + 4 + sub_block_count * record_size
for sub in records:
    sub.data_offset = offset
    offset = align_up(offset + sub.stored_size, 2048)

The block’s sector allocation is an upper bound and must be checked before
accepting any record table or payload.

4.3 PC sub-block record

offset +0x00  u32 information_flags
offset +0x04  u32 stored_byte_size
offset +0x08  u32 unpacked_byte_size
offset +0x0C  u32 overlap_size
offset +0x10  u32 stored_checksum
record size: 0x14 / 20 bytes

overlap_size describes extra safe space required for in-place unpacking.
The developer comment mentions a nominal 4096-byte search window and notes a
larger requirement for some VAG material. On stored/unpacked PC records this
field can contain uninitialized garbage and must not be treated as a semantic
size or validation identity.

4.4 PSX sub-block record

Retail PlayStation archives use a different, shorter record:

offset +0x00  u32 stored_byte_size
offset +0x04  u32 information_flags
offset +0x08  u32 unpacked_byte_size
offset +0x0C  u32 stored_checksum
record size: 0x10 / 16 bytes

The outer PAK header, block descriptors, sector alignment, sub-block-0 leader
placement, later sub-block alignment, information bits, and checksum
representation otherwise remain compatible.

4.5 PC/PSX layout detection

Archive names are not used to decide the platform. This matters because PC and
PSX assets can share identical envelope tags, notably (0,16,1,0) for track
geometry groups.

VR2 Asset Explorer tests both candidate record interpretations and accepts one
only when all relevant properties agree:

  • record table fits inside the allocated block;
  • count is reasonable;
  • flags contain no unknown bits outside 0x7;
  • sizes are non-zero;
  • stored records have unpacked_size == stored_size;
  • packed records have a plausible expanded size;
  • payloads fit the block allocation;
  • sample stored-data CRCs match.

If neither or both layouts validate, the archive is rejected rather than
guessed. This fixed a regression where a PC version-0x24 track was routed to
the PSX decoder merely because both used the same outer type-16 tag.


5. Checksums

5.1 Validated sub-block checksum

Every sub-block checksum covers the stored bytes exactly as they appear on
disk
, before decompression. The representation is:

stored_checksum == (~zlib.crc32(stored_bytes)) & 0xFFFFFFFF

This is often described as CRC-32 “without the final inversion” relative to
the value normally returned by zlib.

The following are not part of the checksum input:

  • the sub-block record;
  • sector padding after the payload;
  • decompressed output;
  • the next sub-block.

5.2 Main PAK checksum

The developer explicitly identifies PAK_HEADER.checksum as a checksum for
the complete file. Its exact initialization, field treatment, and byte range
have not yet been reconstructed. Version 0.6.11 displays the value but does
not claim to verify it. Do not substitute the per-sub-block formula without
independent proof.


6. Eden LZ77 compression

The developer describes a 16-bit 12:4 code: 12 bits of backward search range
and 4 bits of length information. The recovered codec is used both for PC PAK
sub-blocks and ZAP entries.

It is not EA RefPack and it is not the usual preinitialized-ring-buffer
variant of LZSS. It is a direct backward-copy LZ77 stream.

6.1 Token stream

repeat until expected output size is reached:
    u8 flags
    for bit 0 through 7, least-significant bit first:
        if flags & (1 << bit):
            u8 literal
        else:
            u16le token

For a match token t:

length   = (t & 0x000F) + 3     // 3 .. 18 bytes
distance = (t >> 4) + 1         // 1 .. 4096 bytes backward

The copy must occur one byte at a time:

for _ in range(length):
    output.append(output[-distance])

Overlapping copies are legal and implement run-length expansion when
distance < length. A bulk slice copy can therefore produce wrong output.

There is no required end marker. The authoritative termination condition is
the record’s unpacked_byte_size. A valid packed record must decode to exactly
that size.

6.2 Invalid backward distances

The retail-compatible decoder treats a distance beyond the currently produced
output as zero-filled/empty safe-area expansion rather than reading before the
buffer. Normal validated streams still finish at the declared output size.

6.3 PC and PSX use

  • PC root resources use packed sub-blocks extensively.
  • The validated NTSC-U PSX root archives contain flags 0 and 2, with no
    packed sub-blocks among the 1,695 validated records.
  • The shared flag definition still permits packed PSX records; absence in the
    validated retail set is an observation, not a platform prohibition.

7. ZAP script archive

ZAP uses the same Eden LZ77 codec but has its own file/name container.

0x00  char[4] "ZAP!"
0x04  u32 version              // 0x00010000
0x08  u32 total_size           // equals file size
0x0C  u32 file_count
0x10  u32 offset[file_count + 1]

Each file range begins with:

u32 unpacked_size
u8  eden_lz77_stream[]

The final offset points to a name table:

u32 table_size
u8  encoded_tree[table_size]

Name encoding:

  • b < 0xF0: leaf; b is the name length, followed by the name;
  • b >= 0xF0: group; name length is 0x100 - b, followed by a child count
    and the group name;
  • group name beginning with .: extension applied to the following leaves;
  • other group name: directory prefix for its child groups.

Name order equals offset-table order. The validated scripts.zap contains 276
files; its archived rootcars.scr is byte-identical to the loose copy.


8. Common resource envelopes and relative offsets

Many resources begin with four u32 tag values and then contain an embedded
root object. Two important conventions recur:

  1. car V3D internal offsets are relative to an object base at sub-block
    offset 0x14;
  2. track-scene pointers are relative to the located scene root, which does not
    necessarily begin at a fixed sub-block offset because build paths and group
    metadata precede it.

Never add a relative pointer to the wrong base. Several early failures were
caused by structurally correct tables interpreted from the wrong origin.

8.1 Important envelope tags

Tag (u32[4])Typical use
(0,2,1,0)PC and PSX car V3D geometry. Version distinguishes layouts.
(1,1,1,0)PC car texture bank or PSX indexed texture/CLUT upload group. Structural parsing distinguishes them.
(1,16,1,0)Type-16 texture/resource group.
(0,16,1,0)Type-16 track/environment geometry group on both PC and PSX. Archive platform and root version distinguish them.

Embedded type-16 build paths commonly begin at sub-block offset 0x14/20,
for example:

GRP\Track\background\catalogne\day\SBKG01d1.grp

9. Car block organization

In ROOTCARS.PAK, the big-block index equals the car ID used by
ROOTCARS.SCR. On the validated PC archive, blocks 0 and 15 are empty; block
15 aligns with the commented-out Puma declaration. Populated car blocks use:

Sub-blockTypical content
0Small stored car descriptor/leader, usually containing HIRES and/or paths.
1High-detail texture bank.
2High-detail car geometry, object type 5.
3Secondary texture bank.
4Medium-detail car geometry, object type 6.
5Low-detail/shadow geometry, object type 2.

The structural relationship is shared conceptually by PC and PSX, although
the texture pixels, geometry versions, records, vertex representation, and
primitive streams are platform-specific.


10. PC car textures

The PC car bank begins with (1,1,1,0):

0x00  u32 tag[4]              // 1,1,1,0
0x10  u32 payload_size        // sub-block size - 24
0x14  u32 pixel_data_size
0x18  u16 page_count
0x1A  u16 flags               // observed 1
0x1C  pixel/mipmap data
      page table at 24 + pixel_data_size

Each page-table record is 20 bytes:

u16 width
u16 height
u16 format                    // 0x0106
u16 mip_levels                // typically 8 or 9
u16 width2
u16 height2
u32 data_offset               // relative to sub-block offset 20
u32 page_link_or_id

Validated car pages are 256×256 straight RGBA8, with no swizzle and no
palette. Alpha is real coverage for glass, decals, wheel cut-outs, and masked
parts.

A full 256-to-1 mip chain has:

87381 pixels * 4 bytes = 349524 bytes

Each populated car has six base pages: four in sub-block 1 and two in
sub-block 3.


11. PSX texture and CLUT groups

PSX groups retain (1,1,1,0) but represent VRAM uploads instead of RGBA8
pages. Their common 20-byte upload record is:

u16 width_words
u16 height
u16 format
u16 flags
u16 width2
u16 height2
u32 data_offset               // relative to sub-block offset 20
u32 link_or_id

Observed formats:

FormatMeaning
44-bpp indexed pixels; logical pixel width is width_words * 4.
16little-endian BGR555/STP CLUT data.

For 4-bpp data, one byte stores the left pixel in the low nibble and the right
pixel in the high nibble.

For a BGR555/STP word:

bits  0..4   red
bits  5..9   green
bits 10..14  blue
bit      15  STP/semi-transparency

Export alpha policy:

  • colour value zero → alpha 0;
  • non-zero colour with STP set → alpha 128;
  • other non-zero colour → alpha 255.

Every consecutive set of 16 CLUT words forms one 4-bpp palette row.

11.1 Why the first PSX colours were wrong

Applying CLUT row zero to the whole image produces structurally recognizable
but incorrectly coloured textures. Geometry contains a material map connecting
each atlas rectangle to its intended CLUT row. The corrected export recolours
each mapped region with that row.

11.2 Shared rectangles and exact track atlases

Several material mappings may share one source rectangle while selecting
different CLUTs. A single in-place composite cannot represent those materials
simultaneously. Track OBJ export therefore builds a packed, scene-specific
atlas with a separate copy of every rectangle/CLUT combination and remaps UVs.

11.3 Multiple HLOD uploads

The first eight PSX high-detail car banks contain two same-size 4-bpp uploads
using the same local coordinates. For static OBJ/MTL export, the second layer
is alpha-composited over the first. Both individual layer images are retained
for research.


12. PC type-16 track textures

PC type-16 texture resources use 256×256 little-endian A1R5G5B5 pages:

bit      15  alpha
bits 10..14  red
bits  5..9   green
bits  0..4   blue

The bank is located structurally because it may be followed by other resources
inside the same type-16 group.

Immediately before pixel data:

u32 total_size               // 8 + page_count * bytes_per_page
u16 page_count               // normally 16
u16 flags                    // observed 2, 5, or 6
u8  pixel_data[]
u32 zero_separator
u8  page_table[]

Page table records are 20 bytes and use format 0x0103 for mipmapped pages or
3 for base-only images. A nine-level 256×256 A1R5G5B5 pyramid occupies
174,762 bytes; a base-only page occupies 131,072 bytes.


13. PC car geometry: V3D version 0x12

13.1 Root

0x00  u32 tag[4]              // 0,2,1,0
0x10  u32 payload_size
0x14  u16 object_type         // 5 high, 6 medium, 2 low
0x16  u16 version             // 0x12
0x18  f32 bounding_box[6]
0x30  u32 mesh_offset         // relative to object base 0x14
0x34  u32 mesh_count
0x38  u32 rect_offset
0x3C  u32 rect_count
0x40  u32 list_offset
0x44  u32 list_count

Atlas rectangle records are 16 bytes:

u16 x, y, width, height
u32 flags
u32 format_hint

13.2 Mesh record

Mesh stride: 0xC8 bytes.

Relevant fields:

0x00  u32 mesh_id
0x04  u32 position_offset
0x08  u32 position_count
0x0C  u32 flat_normal_offset
0x10  u32 flat_normal_count
0x14  u32 smooth_normal_offset
0x18  u32 smooth_normal_count
0x1C  u32 colour_offset
0x20  u32 primitive_offset
0x24  u16 primitive_count

Positions and normals are 16-byte float vectors (f32 x,y,z,w). Colours are
RGBA8.

13.3 Primitive descriptors and strips

Descriptor stride: 0x10 bytes.

u32 stream_offset
u16 strip_count
u16 corner_count
u8  flags
u8  texture_page
u16 auxiliary_base
u32 secondary_stream_offset

Supported primary flags 0, 2, and 12 use 12-byte textured corners. Flag
4 uses 4-byte untextured corners. Flags 5..11 are currently treated as
auxiliary/alternate passes unless separately understood.

strip header:
    u16 corner_count
    u16 auxiliary_index

textured corner:
    u16 position_index
    u16 smooth_normal_colour_index
    f32 u, v

untextured corner:
    u16 position_index
    u16 smooth_normal_colour_index

The first two corners seed a triangle strip. Every following corner emits one
triangle with alternating winding. Repeated indices and collinear points form
deliberate zero-area connector triangles; they are omitted while strip parity
is retained.

13.4 Original PC car failure and fix

Treating a complete strip as one polygon caused Blender to invent fan edges,
apparently remove faces, and overlap distant body panels. Correct strip
expansion produced complete car geometry. Flag-4 untextured faces are black in
the validated car data; using a white fallback incorrectly made tyres,
interior, and underside look like missing texture pages.

13.5 PC coordinate conversion

Native PC convention is X right, Y up, Z forward. OBJ output uses explicit
Z-up coordinates:

(X,Y,Z) -> (X,-Z,Y)

The same rotation is applied to normals. Earlier builds relied on Blender’s
remembered import axes, causing PC cars to appear upside down after PSX tests.


14. PSX car geometry: V3D version 0x11

PSX cars share the (0,2,1,0) envelope and object types 2/5/6 but use version
0x11 and a different fixed-point/native-GPU layout.

14.1 Header and tables

At object base 0x14:

u16 object_type
u16 version                   // 0x11
s16 bounding_box[6]
u32 mesh_offset, mesh_count
u32 rect_offset, rect_count
u32 clut_offset, clut_count
u32 mapping_offset, mapping_count

Mesh stride is 0x74 bytes. Draw-descriptor stride is 0x24 bytes.
Matrices contain signed Q12 coefficients and signed 32-bit translations.

14.2 Material mapping record

Each mapping is 20 bytes:

u32 rectangle_pointer
u32 rectangle_count
u32 clut_pointer
u32 clut_count
u32 tagged_material_id

The pointer fields lead through relative pointer arrays to 16-byte rectangles
and 8-byte CLUT descriptors.

14.3 GTE-paired vertices

Two signed-short vertices occupy 12 bytes in the load-friendly order:

x0, y0, z0, z1, x1, y1

The second vertex must therefore be reconstructed as (x1,y1,z1). Treating
the bytes as two ordinary XYZ triples created split/lobed cars and displaced
panels.

14.4 Native primitive records

Validated ordinary types:

TypeRecord sizeCornersIndex offsetUV byte offsets
2172416012, 20, 28, 36
3140312812, 20, 28
452448none
58448012, 20, 28, 36
66836412, 20, 28
10196418412, 24, 36, 48
11156314412, 24, 36

Type 8 is an auxiliary renderer pass and remains open.

14.5 Quad topology

Native four-corner records are GPU triangle strips ordered:

TL, TR, BL, BR

Writing those four corners as one OBJ polygon creates a self-intersecting
boundary. The correct explicit triangles are:

(0,1,2)
(2,1,3)

This fixed the missing/overlapping PSX car faces.

14.6 Material-state problem

The 16-bit words adjacent to packet UVs are mutable GPU CBA/TPage state. They
are not reliably two equal material-map indices. Requiring equality classified
only 24 of 603 UV-bearing source faces in the representative block-1 HLOD.

Correct resolution uses the immutable authored UV rectangle plus any retained
mapping/material hint:

  • types 2/3/10/11 are primarily local-atlas body faces;
  • types 5/6 can use either a local atlas region or a renderer-wide texture
    slot;
  • type 4 is genuinely UV-less and exported black;
  • renderer-wide faces use runtime_shared_texture.

Version 0.6.11 groups all renderer-wide faces into one OBJ object named
runtime_shared_overlay, while local and untextured faces remain in their
source mesh objects. This changes object membership only; no vertices, UVs,
materials, or faces are removed or duplicated. Hiding that object in Blender
reveals the local-atlas body without destructive editing.

14.7 PSX coordinate conversion

Native PSX/GTE convention is X right, Y down, Z forward. OBJ Z-up conversion:

(X,Y,Z) -> (X,Z,-Y)

This preserves handedness and does not require reversing winding.


15. PC track scenes: type 1, version 0x24

The outer geometry group begins (0,16,1,0). The actual scene root is found
structurally by object type 1 and version 0x24; all its pointers are relative
to that root.

15.1 Relevant root fields

0x00  u16 object_type         // 1
0x02  u16 version             // 0x24
0x04  u32 mesh_group_offset
0x08  u32 mesh_group_count
0x0C  u32 road_chunk_offset
0x10  u32 road_chunk_count
0x14  u32 scene_object_offset
0x18  u32 scene_object_count
0x1C  u32 material_offset
0x20  u32 material_count
0x24  u32 road_material_map_offset
0x28  u32 road_material_map_count

Additional pointer/count pairs continue through root offset 0x48.

15.2 World placement

PC scene-object stride is 0x128 bytes. Each ordinary object owns two mesh
groups:

0x0C  u32 first_group_offset
0x10  u32 second_group_offset
0x18  f32 translation_x
0x1C  f32 translation_y
0x20  f32 translation_z
0x24  f32 homogeneous_w       // 1.0 in validated objects
0xEC  char object_name[]

Adding the float3 translation to every local mesh and road position places the
course in world space. Stored local/world X/Z bounds independently confirm the
translation.

The first track exporter omitted this operation, placing every chunk near its
local origin and producing a dense pile. Applying the owner translation fixed
the complete course layout.

15.3 Scenery mesh records

Mesh groups have 16-byte records. The referenced mesh table has a four-byte
header followed by 0x28-byte mesh records. Their 16-byte primitive
descriptors and triangle strips resemble the PC car format. Textured flags
0/1/2/12 use 12-byte corners; flag 4 is untextured; flag 7 is auxiliary.

Normals are intentionally not exported for PC track scenery because the second
corner index has additional semantics in a small number of meshes. Position,
topology, and UV data validate; target applications can recalculate normals.


16. PSX track scenes: versions 0x22 and 0x122

PSX uses the same (0,16,1,0) outer envelope as PC, but scene roots use type 1
and version 0x22 or 0x122. Version 0x122 is the same validated layout
with an additional 0x100 variant flag.

Before 0x122 support, some high/medium passes were rejected and the remaining
coarse low pass was mislabeled high. Monte Carlo SS01 is a representative
case: high and medium are 0x122, low is 0x22.

16.1 Scene objects and placement

PSX scene-object stride is 0xF4 bytes. Ordinary stages own two 16-byte
placement groups per object; scripts marked twin=1 own one.

At scene-object offset +0x40:

s16 local_min_x
s16 local_max_y
s16 local_max_x
s16 local_min_y
s16 local_min_z
s16 local_max_z

At +0x4C:

s32 world_min_x
s32 world_min_z
s32 world_max_x
s32 world_max_z

The exact origin is:

origin_x = world_min_x - local_min_x = world_max_x - local_max_x
origin_z = world_min_z - local_min_z = world_max_z - local_max_z
origin_y = 0

Y/height values are already absolute. Misreading the four dwords as XYZ
translation caused chunks to be vertically stacked and spatially fragmented.

16.2 Scenery geometry

Placement record stride is 0x20 bytes. Primitive stream descriptors are 8
bytes. Supported stream records:

Stream typeRecord sizeMeaning
012textured triangle
116textured quad
7variable auxiliary data, not emitted

Vertices use the same 12-byte GTE-paired representation as PSX cars. Packet UV
bytes are local to the mapped rectangle, unlike PSX car UVs which are already
atlas coordinates.

16.3 Material mapping and 8-bit wrapping

Each material maps a rectangle, CLUT row, and absolute 256-pixel texture page.
Local U/V addition wraps as unsigned eight-bit hardware coordinates:

u = (rectangle_x + local_u) & 0xFF
v = (rectangle_y + local_v) & 0xFF

Treating the upload as one linear wide image incorrectly moved wrapped samples
to neighbouring pages. Scene-specific packed atlases preserve the requested
rectangle/CLUT pair and remap every UV into a unique tile.

Some mappings have multiple rectangle/CLUT pointers for animation. OBJ/MTL
cannot represent the animation, so frame zero is used as an explicit static
export policy. Dropping multi-frame materials produced unrelated repeating
road textures, especially in Monte Carlo.


17. The separate road-surface grids

The apparent hole between track shoulders was not missing ordinary mesh data.
Both platforms store driveable road surfaces in a parallel implicit grid table
owned by the scene objects. Scenery contains terrain, banks, walls, vegetation,
buildings, signs, and road edges; the road corridor itself comes from this
separate representation.

17.1 PC road record

PC road record stride: 0x20 bytes.

0x00  u8  rows
0x01  u8  columns
0x02  u8  cell_count
0x03  u8  position_count
0x04  u32 packed_flags
0x08  u32 position_offset       // float4[position_count]
0x0C  u32 colour_offset         // RGBA8[position_count]
0x10  u32 material_id_offset    // u16[cell_count]
0x14  u32 reserved
0x18  u32 surface_flag_offset   // u8[cell_count]
0x1C  u32 uv_offset             // float2[cell_count][4]

Exact identities:

position_count == rows * columns
cell_count     == (rows - 1) * (columns - 1)

Positions are a row-major grid. Each cell becomes triangles
(TL,TR,BL) and (BL,TR,BR).

PC UV pairs are stored in renderer-packet order TL,BL,TR,BR, while grid
vertices use TL,TR,BL,BR. Swapping the middle two pairs (0,2,1,3) fixes a
road-only orientation bug without altering scenery UVs or materials.

17.2 PSX road record

PSX road record stride: 0x24 bytes.

u8  rows
u8  columns
u8  cell_count
u8  position_slots             // rows*columns == position_slots*3
u32 profile_flags
u32 gte_vertex_offset
u32 bgr555_colour_offset       // optional in low LOD
u32 material_id_offset         // u16[cell_count]
u32 reserved
u32 uv_stream_a_pointer
u32 uv_stream_b_pointer        // zero for twin layout
u32 surface_flag_offset        // u8[cell_count]

Ordinary roads commonly have nine cross-section vertices and eight cells.
Twin roads have six vertices and five cells.

17.3 PSX road UV stream traversal

The two ordinary arrays are sequential half-road streams, not one edge record
per cell. Each selected stream contributes eight bytes / four UV pairs per
rendered cell. One stream feeds each half of the cross-section.

Profile bit 0x00010000 reverses full cell traversal, swaps half-stream
assignment, and changes packet pair order:

TraversalHalfStreamStored-pair order into packet
forwardleft / columns 0–3A3,0,2,1
forwardright / columns 4–7B2,1,3,0
reverseleft / columns 0–3B0,3,1,2
reverseright / columns 4–7A1,2,0,3

The native packet’s position slots are TL,BL,TR,BR, while the OBJ grid uses
TL,TR,BL,BR; packet slots 1 and 2 must then be exchanged.

Twin roads use one complete eight-byte record per cell. Forward uses stored
order 2,1,3,0; reverse traverses backwards and uses 0,3,1,2, followed by
the same packet-to-grid middle swap.

Sorting UV coordinates into a canonical rectangle is wrong because the native
order carries authored rotations, mirrors, and trapezoidal mappings. This was
the final reason materials were correct but road_* textures remained rotated
or mirrored.

17.4 Surface flags

Per-cell surface flags are parsed and range-checked on both platforms. OBJ has
no standardized representation for collision/driving-surface semantics, so
they are not currently emitted as gameplay metadata.


18. Sounds

Type-6 sound GRPs share an outer entry wrapper:

u32 version                   // 1
u32 group_type                // 6
u32 sample_count

repeat sample_count:
    u32 tag                   // high 16 bits bank ID, low 16 sample ID
    u32 stored_size
    u8  payload[stored_size]

18.1 PC

Payload is original RIFF/WAVE and can be copied without transcoding. Two shared
menu samples have a RIFF declared size four bytes greater than their exact GRP
allocation; the data chunks are valid and the extractor preserves/reports this
source quirk.

18.2 PSX

Payload is standard SPU VAG-ADPCM, commonly under a VR2-specific 48-byte header
whose first 12 bytes are zero; some entries retain VAGp.

header +0x0C  u32be ADPCM data size
header +0x10  u32be sample rate
body          16-byte ADPCM frames, 28 mono PCM samples per frame

The standard five PSX predictor filters are used. One final source entry in
DSPK01GE.GRP is physically truncated; complete frames remain decodable and
the manifest explicitly marks it.


19. Loose PlayStation formats and executable containers

These files are not part of the PAK container itself but occur alongside it in
the extracted disc and are relevant to complete asset discovery.

19.1 TIM

Standard Sony TIM magic is u32 0x10. Supported modes are:

ModePixels
04-bpp indexed, optional multiple 16-colour CLUTs
18-bpp indexed, optional multiple 256-colour CLUTs
216-bpp BGR555/STP
324-bpp BGR

TIM block headers contain their own byte size and VRAM X/Y/width/height. Their
width is measured in 16-bit words, so logical pixel width depends on the mode.

19.2 STR

.STR files are PlayStation sectorized media streams. Version 0.6.11
classifies and preserves them but does not decode video/audio frames.

19.3 PS-X EXE and overlays

PS-X EXE headers expose the MIPS entry point, load address, text size, and
initial stack address. Several .BIN overlays begin with MWo1. Executable
analysis supplied crucial renderer evidence—especially PSX road UV traversal
and packet corner order—but 0.6.11 does not claim full executable or overlay
decompilation.


20. Naming and logical grouping

20.1 Cars

ROOTCARS.SCR maps car IDs to resource paths and display labels. The PAK big
block index is the ID. The parser handles retail/prototype syntax variations,
comments, CP1252, UTF-16, and NUL padding. Embedded paths and a validated PC
reference map are fallbacks only; installation scripts are authoritative.

20.2 Tracks

On PSX, active ROOTSPEC.SCR rows use:

ADDSPECIALE id ... "display label" ...

The ID maps directly to the ROOTSPEC.PAK big block. This fixed guesses based
on reusable filenames such as SBKG01.

20.3 ROOTGEN backgrounds

ROOTGEN.PAK SBKG01 assets are reusable country/weather background packages,
not automatically stage SS01. They are listed separately from driveable track
assets to prevent duplicates and false names such as “Monaco SS01”.

20.4 PAX status

The developer notes confirm that PAX stores PAK indices separately so its
format can change without rebuilding the large PAK. Complete logical PAX
path/index reconstruction is not yet implemented in 0.6.11; script IDs and
embedded paths currently provide most useful grouping.


21. PC and PSX comparison

AreaPCPlayStationShared principle
PAK outer header8-byte header, 4-byte block descriptorssame2048-byte sector organization
Sub-block record20 bytes: flags, stored, unpacked, overlap, CRC16 bytes: stored, flags, unpacked, CRCsame flag bits and stored-byte CRC representation
Compression in validated rootsEden LZ77 widely usedno packed sub-blocks observed in validated NTSC-U rootsbit 0 is the packed flag
Car texture pixelsstraight RGBA84-bpp indices + BGR555/STP CLUTgeometry supplies atlas/material relationships
Track texture pixelsA1R5G5B5 pagesindexed VRAM uploads + CLUT256-pixel texture-page model
Car geometry version0x120x11envelope (0,2,1,0), types 5/6/2
Car verticesfloat4 arrayssigned GTE-paired shorts + Q12 matrixmultiple meshes and LODs
Car topologyvariable triangle stripsfixed native GPU triangle/quad recordsexplicit triangulation required for OBJ
Track scene versiontype 1 / 0x24type 1 / 0x22 or 0x122outer tag (0,16,1,0)
Track placementfloat3 object translationorigin derived from local/world X/Z bounds; Y already absolutescenery and road share object ownership
Road positionsfloat4 grid verticesGTE-paired signed shortsimplicit row-major cell topology
Road UVsfour float2 packet pairs per cellcompact branch-dependent byte streamspacket order differs from OBJ grid order
AudioRIFF/WAVEVAG-ADPCMsame type-6 GRP entry wrapper
Native axesX-right, Y-up, Z-forwardX-right, Y-down, Z-forwardexported explicitly as Z-up

22. Problem/solution record

SymptomIncorrect assumptionCorrect interpretation / solution
PC car faces missing and crossingone strip exported as one polygonexpand alternating triangle-strip winding; omit only degenerate connectors
PC black parts looked untexturedUV-less faces given white fallbackvalidated flag-4 faces use black fallback
PC/PSX car upside downraw axes or Blender import historyexplicit platform-specific Z-up conversion
PSX car split into lobespaired vertices read as linear XYZ triplesdecode x0,y0,z0,z1,x1,y1
PSX quads missing/overlappingGPU strip corners written as one OBJ quademit (0,1,2) and (2,1,3)
HLOD looked like two interpenetrating carsall unresolved faces merged into one ambiguous fallbackdistinguish local atlas, runtime shared, and UV-less black; group shared pass as runtime_shared_overlay
PSX colours wrongCLUT zero applied globallyuse geometry rectangle-to-CLUT material map
Track scenery piled at originlocal meshes exported without object placementapply PC translation or derive PSX X/Z origin from bounds
PSX chunks stacked verticallyworld X/Z bounds read as XYZ translationheight is already absolute; translate X/Z only
Road completely missingassumed road was part of scenery stripsdecode separate road-grid table
Correct PSX material but random page contentlocal UV addition treated as wide-atlas addressingreproduce 8-bit wrap inside the selected 256×256 page
Shared-rectangle palettes wrongone composite reused one CLUT per rectanglecreate a unique packed tile per material/CLUT pair
Monte Carlo road repeated unrelated texturemulti-frame material mappings rejecteduse rectangle/CLUT frame zero for static OBJ
PSX road rotated/mirroredcompact arrays indexed/sorted like canonical cellsreproduce renderer traversal, branch permutation, then packet-to-grid swap
PC road textures transposedpacket UVs paired directly with grid verticesswap middle UV pairs 0,2,1,3 for road_* only
PC tracks stopped exporting after PSX workshared type-16 tag used as platform signatureroute using validated PAK record layout and root version
Track name duplicates/false SS01SBKG01 interpreted as stage numberclassify ROOTGEN resources as reusable backgrounds; prefer script block IDs
Some PSX tracks had textures but no geometryonly 0x22 and ordinary two-group layout acceptedsupport 0x122 and twin=1 one-group/one-stream variants

23. Validation baseline for 0.6.11

23.1 PAK and PSX resources

  • ROOTCARS.PAK: 29 blocks, 198 sub-blocks;
  • ROOTGEN.PAK: 48 blocks, 424 sub-blocks;
  • ROOTSPEC.PAK: 234 blocks, 1,073 sub-blocks;
  • total: 1,695/1,695 stored sub-block CRCs matched;
  • 553 PSX texture groups parsed;
  • 4,218 exported 256-pixel page slices;
  • 54,604 16-colour CLUT rows;
  • all 11 loose TIM images decoded.

23.2 Cars

PC:

  • 81 geometry objects = 27 cars × 3 LODs;
  • 1,023 mesh records and 4,016 primitive descriptors;
  • 179,841 positions, 39,829 strips, 291,382 corners;
  • 173,716 non-degenerate OBJ triangles;
  • 38,076 zero-area strip connectors omitted without changing parity;
  • all positive OBJ indices validated.

PSX:

  • 86 version-0x11 geometry groups;
  • 32,080 used vertices and 21,011 native faces;
  • 37,418 explicit OBJ triangles;
  • no out-of-range face index;
  • 78 of 86 scenes contain exactly one runtime_shared_overlay object;
  • representative block 1 HLOD retains 1,204 triangles: 433 local-atlas,
    170 runtime-shared, and 63 untextured source faces.

23.3 Tracks

PC:

  • 160 supported type-1/version-0x24 scenes in the validated set;
  • every one of 19,599 mesh groups had exactly one owning scene object;
  • 10,086 road chunks validated across 112 ROOTSPEC and 48 ROOTGEN scenes;
  • 1,505,103 finite road positions and 1,255,632 cells;
  • six fully retained scenes validated 386 road chunks and 49,256 corrected
    packet-to-grid UV records.

PSX:

  • complete archive scan found 234 self-contained version-0x22 scenes in an
    earlier structural pass;
  • renderer-exact retained-prefix validation accepted 174 ordinary/twin scenes;
  • 9,585 road chunks and 1,168,272 cells;
  • 4,815 ordinary-forward, 3,819 ordinary-reverse, 474 twin-forward, and 477
    twin-reverse chunks;
  • every compact UV stream was exhausted exactly at the final cell;
  • material atlas validation packed 26,581 material tiles with no unresolved
    referenced material IDs in the tested set.

23.4 Sound and regression suite

  • PC: 16 type-6 GRPs, 312 RIFF/WAVE samples, 196.242 seconds;
  • PSX: 20 type-6 GRPs, 409 VAG entries, 408 complete plus one physically
    truncated final source entry;
  • VR2 Asset Explorer 0.6.11: 31/31 automated tests pass;
  • all 86 PSX car OBJ exports preserve source-face counts and overlay rules;
  • PC cars, PC tracks, and PSX tracks remained unchanged by the 0.6.11 overlay
    object split.

24. Remaining unknowns and limitations

  1. The exact algorithm and byte range for PAK_HEADER.checksum are open.
  2. Complete PAX logical path/index decoding is not implemented.
  3. MONOBLOC and SCRIPT_PRELOADED interpretations follow developer structures
    but need broader real-file validation.
  4. Five known PC type-1/version-0x27 track roots remain undecoded.
  5. PC auxiliary car/track renderer passes are skipped until their semantics
    are proven.
  6. PSX car primitive type 8 and track stream type 7 remain auxiliary research
    targets.
  7. PSX runtime_shared_texture faces are now isolated correctly, but their
    final renderer-wide VRAM texture source is not yet connected.
  8. OBJ/MTL cannot express texture animation; frame zero is exported.
  9. OBJ does not preserve road surface/collision flags as structured gameplay
    metadata.
  10. Normals are omitted from PC track scenery because one secondary index has
    unresolved renderer semantics in a small set of meshes.
  11. OVL and executable analysis currently supports classification and targeted
    loader/renderer research, not complete decompilation.
  12. Export is intentionally read-only. Repacking, replacement, index
    allocation, and checksum regeneration are not implemented or claimed safe.

25. Safe parser checklist

A robust implementation should:

  1. read the 8-byte header and reject impossible block counts;
  2. compute/validate the block table before reading any payload;
  3. honor data_type when interpreting four-byte descriptors;
  4. try PC and PSX sub-block layouts structurally rather than using filenames;
  5. bound the record table by the block’s sector allocation;
  6. verify flags and stored/unpacked size relationships;
  7. compute sub-block offsets using the special unaligned sub-block-0 rule;
  8. validate the stored-byte CRC before decompressing;
  9. stop Eden LZ77 at the declared output size and require exact output length;
  10. keep relative-pointer bases explicit per resource type;
  11. reject unknown root versions rather than forcing a similar decoder;
  12. preserve native primitive order until its topology and packet corner order
    are understood;
  13. distinguish scenery meshes from separate road grids;
  14. keep platform dispatch independent from shared envelope tags;
  15. label unresolved renderer data instead of silently deleting or assigning
    unrelated textures.

26. Minimal reference pseudocode

26.1 Stored CRC

def eden_crc(stored: bytes) -> int:
    return (~zlib.crc32(stored)) & 0xFFFFFFFF

26.2 Alignment

def align_2048(value: int) -> int:
    return (value + 2047) & ~2047

26.3 Eden LZ77

def decode(src: bytes, expected: int) -> bytes:
    out = bytearray()
    p = 0
    while p < len(src) and len(out) < expected:
        flags = src[p]
        p += 1
        for bit in range(8):
            if p >= len(src) or len(out) >= expected:
                break
            if flags & (1 << bit):
                out.append(src[p])
                p += 1
            else:
                token = src[p] | (src[p + 1] << 8)
                p += 2
                length = (token & 0x0F) + 3
                distance = (token >> 4) + 1
                for _ in range(length):
                    if len(out) >= expected:
                        break
                    out.append(out[-distance])
    if len(out) != expected:
        raise ValueError("decompressed size mismatch")
    return bytes(out)

Real code should additionally handle malformed/truncated tokens and invalid
backward distances according to its desired strictness policy.


27. Closing status

At the 0.6.11 milestone, the common PAK container, PC Eden LZ77, stored CRCs,
ZAP tree, PC and PSX car textures/geometry, PC and PSX track scenery/roads,
platform-specific UV behaviour, and PC/PSX sound payloads are understood well
enough for repeatable read-only extraction. The remaining work is concentrated
in auxiliary renderer passes, global PSX VRAM sources, version-0x27 scenes,
PAX reconstruction, gameplay metadata, and eventual write/repack research.

The most important methodological result is that similar-looking PC and PSX
resources often share outer envelopes and logical roles while differing in
their leaf records, coordinate systems, texture state, and renderer packet
order. Reliable extraction therefore depends on structural validation and
cross-checking—not tag matching or visual heuristics alone.


Credits

DefenceForce – for providing important information regarding PAK file format.