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:
| Class | Meaning |
|---|---|
| Developer-confirmed | Taken from the declarations and process notes supplied. |
| Validated | Confirmed against retail PC/PSX files, stored CRCs, executable loader/renderer behaviour, structural identities, or repeatable Blender results. |
| Export policy | A deliberate conversion choice needed for formats such as OBJ/MTL; not necessarily a one-to-one representation of the original renderer. |
| Open | Detected 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
GRPandSCRIPTtrees; OVLplusROOTGAME,ROOTSPEC, andROOTCAR(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:
- load PAKINF or initialize an empty database;
- mark known files removed;
- recursively scan
GRPandSCRIPT; - compare path, timestamp, and size;
- classify each entry as new, unchanged, modified, or removed;
- retain the existing index whenever possible;
- rewrite in the old sector allocation when the new size fits, otherwise
relocate and update only the offset; - regenerate PAX and
pak_files.deffrom 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
| Archive | Main role |
ROOTCARS.PAK / rootcars.pak | Car descriptors, texture banks, and three geometry LODs. |
ROOTSPEC.PAK / rootspec.pak | Playable stages, service/podium resources, track textures, scenery, and road grids. |
ROOTGEN.PAK / rootGEN.pak | Reusable 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. Thebloc[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 onPAK_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:
| Bit | Clear | Set |
0 (0x1) | MULTIBLOC | MONOBLOC |
1 (0x2) | no overlap | overlapped/in-place unpacking |
2 (0x4) | script inside block | script preloaded in leader |
informations is also a bit field:
| Bit | Clear | Set |
0 (0x1) | stored/unpacked | Eden LZ77 packed |
1 (0x2) | dynamic/transient | static/resident |
2 (0x4) | one information item | multiple 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:
- sub-block 0 begins immediately after the record table and may therefore
be unaligned inside the leader sector; - after every stored sub-block payload, the cursor is rounded up to the next
2048-byte boundary before the next sub-block; - 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 whendistance < 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
0and2, 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;bis the name length, followed by the name;b >= 0xF0: group; name length is0x100 - 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:
- car V3D internal offsets are relative to an object base at sub-block
offset0x14; - 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 byROOTCARS.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-block | Typical content |
| 0 | Small stored car descriptor/leader, usually containing HIRES and/or paths. |
| 1 | High-detail texture bank. |
| 2 | High-detail car geometry, object type 5. |
| 3 | Secondary texture bank. |
| 4 | Medium-detail car geometry, object type 6. |
| 5 | Low-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:
| Format | Meaning |
| 4 | 4-bpp indexed pixels; logical pixel width is width_words * 4. |
| 16 | little-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 or3 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. Flag4 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 version0x11 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:
| Type | Record size | Corners | Index offset | UV byte offsets |
| 2 | 172 | 4 | 160 | 12, 20, 28, 36 |
| 3 | 140 | 3 | 128 | 12, 20, 28 |
| 4 | 52 | 4 | 48 | none |
| 5 | 84 | 4 | 80 | 12, 20, 28, 36 |
| 6 | 68 | 3 | 64 | 12, 20, 28 |
| 10 | 196 | 4 | 184 | 12, 24, 36, 48 |
| 11 | 156 | 3 | 144 | 12, 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 namedruntime_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 type | Record size | Meaning |
| 0 | 12 | textured triangle |
| 1 | 16 | textured quad |
| 7 | variable 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:
| Traversal | Half | Stream | Stored-pair order into packet |
| forward | left / columns 0–3 | A | 3,0,2,1 |
| forward | right / columns 4–7 | B | 2,1,3,0 |
| reverse | left / columns 0–3 | B | 0,3,1,2 |
| reverse | right / columns 4–7 | A | 1,2,0,3 |
The native packet’s position slots are TL,BL,TR,BR, while the OBJ grid usesTL,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 inDSPK01GE.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:
| Mode | Pixels |
| 0 | 4-bpp indexed, optional multiple 16-colour CLUTs |
| 1 | 8-bpp indexed, optional multiple 256-colour CLUTs |
| 2 | 16-bpp BGR555/STP |
| 3 | 24-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
| Area | PC | PlayStation | Shared principle |
| PAK outer header | 8-byte header, 4-byte block descriptors | same | 2048-byte sector organization |
| Sub-block record | 20 bytes: flags, stored, unpacked, overlap, CRC | 16 bytes: stored, flags, unpacked, CRC | same flag bits and stored-byte CRC representation |
| Compression in validated roots | Eden LZ77 widely used | no packed sub-blocks observed in validated NTSC-U roots | bit 0 is the packed flag |
| Car texture pixels | straight RGBA8 | 4-bpp indices + BGR555/STP CLUT | geometry supplies atlas/material relationships |
| Track texture pixels | A1R5G5B5 pages | indexed VRAM uploads + CLUT | 256-pixel texture-page model |
| Car geometry version | 0x12 | 0x11 | envelope (0,2,1,0), types 5/6/2 |
| Car vertices | float4 arrays | signed GTE-paired shorts + Q12 matrix | multiple meshes and LODs |
| Car topology | variable triangle strips | fixed native GPU triangle/quad records | explicit triangulation required for OBJ |
| Track scene version | type 1 / 0x24 | type 1 / 0x22 or 0x122 | outer tag (0,16,1,0) |
| Track placement | float3 object translation | origin derived from local/world X/Z bounds; Y already absolute | scenery and road share object ownership |
| Road positions | float4 grid vertices | GTE-paired signed shorts | implicit row-major cell topology |
| Road UVs | four float2 packet pairs per cell | compact branch-dependent byte streams | packet order differs from OBJ grid order |
| Audio | RIFF/WAVE | VAG-ADPCM | same type-6 GRP entry wrapper |
| Native axes | X-right, Y-up, Z-forward | X-right, Y-down, Z-forward | exported explicitly as Z-up |
22. Problem/solution record
| Symptom | Incorrect assumption | Correct interpretation / solution |
| PC car faces missing and crossing | one strip exported as one polygon | expand alternating triangle-strip winding; omit only degenerate connectors |
| PC black parts looked untextured | UV-less faces given white fallback | validated flag-4 faces use black fallback |
| PC/PSX car upside down | raw axes or Blender import history | explicit platform-specific Z-up conversion |
| PSX car split into lobes | paired vertices read as linear XYZ triples | decode x0,y0,z0,z1,x1,y1 |
| PSX quads missing/overlapping | GPU strip corners written as one OBJ quad | emit (0,1,2) and (2,1,3) |
| HLOD looked like two interpenetrating cars | all unresolved faces merged into one ambiguous fallback | distinguish local atlas, runtime shared, and UV-less black; group shared pass as runtime_shared_overlay |
| PSX colours wrong | CLUT zero applied globally | use geometry rectangle-to-CLUT material map |
| Track scenery piled at origin | local meshes exported without object placement | apply PC translation or derive PSX X/Z origin from bounds |
| PSX chunks stacked vertically | world X/Z bounds read as XYZ translation | height is already absolute; translate X/Z only |
| Road completely missing | assumed road was part of scenery strips | decode separate road-grid table |
| Correct PSX material but random page content | local UV addition treated as wide-atlas addressing | reproduce 8-bit wrap inside the selected 256×256 page |
| Shared-rectangle palettes wrong | one composite reused one CLUT per rectangle | create a unique packed tile per material/CLUT pair |
| Monte Carlo road repeated unrelated texture | multi-frame material mappings rejected | use rectangle/CLUT frame zero for static OBJ |
| PSX road rotated/mirrored | compact arrays indexed/sorted like canonical cells | reproduce renderer traversal, branch permutation, then packet-to-grid swap |
| PC road textures transposed | packet UVs paired directly with grid vertices | swap middle UV pairs 0,2,1,3 for road_* only |
| PC tracks stopped exporting after PSX work | shared type-16 tag used as platform signature | route using validated PAK record layout and root version |
| Track name duplicates/false SS01 | SBKG01 interpreted as stage number | classify ROOTGEN resources as reusable backgrounds; prefer script block IDs |
| Some PSX tracks had textures but no geometry | only 0x22 and ordinary two-group layout accepted | support 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-
0x11geometry 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_overlayobject; - 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-
0x24scenes 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-
0x22scenes 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
- The exact algorithm and byte range for
PAK_HEADER.checksumare open. - Complete PAX logical path/index decoding is not implemented.
- MONOBLOC and SCRIPT_PRELOADED interpretations follow developer structures
but need broader real-file validation. - Five known PC type-1/version-
0x27track roots remain undecoded. - PC auxiliary car/track renderer passes are skipped until their semantics
are proven. - PSX car primitive type 8 and track stream type 7 remain auxiliary research
targets. - PSX
runtime_shared_texturefaces are now isolated correctly, but their
final renderer-wide VRAM texture source is not yet connected. - OBJ/MTL cannot express texture animation; frame zero is exported.
- OBJ does not preserve road surface/collision flags as structured gameplay
metadata. - Normals are omitted from PC track scenery because one secondary index has
unresolved renderer semantics in a small set of meshes. - OVL and executable analysis currently supports classification and targeted
loader/renderer research, not complete decompilation. - 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:
- read the 8-byte header and reject impossible block counts;
- compute/validate the block table before reading any payload;
- honor
data_typewhen interpreting four-byte descriptors; - try PC and PSX sub-block layouts structurally rather than using filenames;
- bound the record table by the block’s sector allocation;
- verify flags and stored/unpacked size relationships;
- compute sub-block offsets using the special unaligned sub-block-0 rule;
- validate the stored-byte CRC before decompressing;
- stop Eden LZ77 at the declared output size and require exact output length;
- keep relative-pointer bases explicit per resource type;
- reject unknown root versions rather than forcing a similar decoder;
- preserve native primitive order until its topology and packet corner order
are understood; - distinguish scenery meshes from separate road grids;
- keep platform dispatch independent from shared envelope tags;
- 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.