Tracks (CIRCUITS/*.BL3, *.BL4)
One file per track, in two variants. State: decoded to the end of every entry in all 36 files (measured, 2026-10-23: entry 0 reads to the first byte of entry 1, and entries 1 to 7 each to the first byte of the next; entry 8 stops inside the last block, whose rest the game never reads). Reader: crates/pod-formats/src/track/ (prototype decoders/track.py). Viewer: the 3D view in apps/viewer, pod-viewer render NAME.BL3 OUT.png [--time S], with the sky and the texture animations among the pictures (pod-viewer export). Not yet round-trip: there is no writer, and many records are kept as bytes whose fields are not traced.
Reader: load_track_file (FUN_0046a1e8) in WINPOD.EXE. It opens the file with key 0x0F7E and block size 0x4000 (container.md).
Entry table
Every track file has 10 entries (measured: all 66 POD track files). The loader treats any other count as a different mode (it clears DAT_0061d5e8 and sets DAT_00572fa4; observed). Offsets count raw file bytes (container.md, "The stream header").
| Entry | First item (measured) | Content | Read by (observed) |
|---|---|---|---|
| 0 | version = 3 | The track, then the forward race with its EASY paths | load_track_file, in order |
| 1 | name MEDIUM | Forward race paths, MEDIUM | FUN_00467ca8, seek esi + 1 when DAT_0061d5cc is 2 |
| 2 | name HARD | Forward race paths, HARD | FUN_00467ca8, seek esi + 2 when DAT_0061d5cc is 4 |
| 3 | 0x38 bytes | The reverse track: beacons, start records, EASY paths | load_track_file seeks entry 3 when DAT_0061d5e8 > 0 |
| 4 | name MEDIUM | Reverse race paths, MEDIUM | FUN_00467ca8 with esi = 3 |
| 5 | name HARD | Reverse race paths, HARD | as entry 4 |
| 6 | name EASY | Opponents, EASY | FUN_0046cb00 when DAT_0061d5cc is 1 |
| 7 | name MEDIUM | Opponents, MEDIUM | FUN_0046cb00 when it is 2 |
| 8 | name HARD | Opponents, HARD | FUN_0046cb00 otherwise |
| 9 | unused (raw zero) |
pod_file_seek_entry (FUN_00468dc4) takes the entry number in EAX (observed: mov eax, 3 at 0x46f3e9; 6, 7, 8 at 0x46cb2b..0x46cb3e). DAT_0061d5cc is the difficulty (1, 2 or 4; inferred from the entry names) and DAT_0061d5e8 > 0 selects the reverse track (inferred: entry 3's names start with -, "-eltane.bal"). Entry 3 is not a name: it starts with the 0x38 bytes that load_track_file reads after the seek.
Entry 0: the order of the reads
Observed in load_track_file. Each "name" is a string (strings.md); a name equal to NEANT means the section is absent and the loader clears that part of the game state. The sizes are the register arguments of pod_read_small (FUN_0046846c), read from the disassembly (decoders/reads.py).
| Step | Read | Function | Section below |
|---|---|---|---|
| 1 | u32 version (3), u32 | load_track_file | |
| 2 | the macro table | read_track_macros (FUN_00468124) | "The start of entry 0" |
| 3 | name: the project ("beltane.RTE") | ||
| 4 | [.BL4: 16 u32], the sectors, the visibility | read_track_sectors, read_track_visibility | "After the sectors" |
| 5 | name (".env") | read_track_environment (FUN_00468cfc) | props |
| 6 | name (".lum") | read_track_lighting (FUN_00469f24) | lighting |
| 7 | name (".anc") | read_track_animations (FUN_0046856c) | "The animation library" |
| 8 | name (".hp") | read_track_points (FUN_00468508) | "Positioned records" |
| 9 | 6 × u32 | load_track_file | "The sky" |
| 10 | name, the horizon block | FUN_0046a7bc (mode 3) or read_texture_block | "The sky" |
| 11 | 8 × u32 | load_track_file | "The sky" |
| 12 | name, the cloud block [.BL4: + 0x8000] | FUN_0046a7bc (mode 1) | "The sky" |
| 13 | u32, u32 n, n / 2 named sections | read_texture_anims (FUN_00463c8c) | "Texture animations" |
| 14 | name (".lev") | read_track_zones (FUN_0046765c) | "The race" |
| 15 | 0x38 bytes, name (".bal") | read_track_beacons (FUN_004683d0), read_track_events (FUN_00468308) | "The race" |
| 16 | u32 n, n × 0x24, name ("EASY") | read_track_race (FUN_00467ca8) | "The race" |
| 17 | the EASY paths | read_race_sections (FUN_00466c20), read_race_graph (FUN_00466f84) | "The race" |
The start of entry 0, as decoded
| Read | Content | Function |
|---|---|---|
| u32, u32 | Version 3, a second word | load_track_file |
u32 n, u32 total | The macro (event script) table: 52 items on every track | FUN_00468124 |
n items | name (Sinon, Active Macro, Arret Anim ...), u32 size, u32 count, then size × count bytes when count > 0: the macro's instances, size bytes each, which the chains' nodes name by number (a loudspeaker action's instance starts with a record number from 1; docs/systems/sound.md, "Who starts and stops the music") | FUN_00468124 |
| 7 × u32 | Counts of macro instances: all zero on this disc | FUN_00467ea0 |
| name | The track's project file (beltane.RTE) | load_track_file |
| 16 × u32 | .BL4 only: one flag per track texture page, 0 or 1; 1 halves the page to 128 × 128 | WPOD3DFX.EXE FUN_00468f5c, FUN_004a4414 (docs/hardware/3d-cards.md) |
| name, texture block | N_BELTAN, the track's textures (textures.md) | FUN_00469b80 |
u32, u32 s | A word, the sector count | FUN_00469b80 |
s sectors | An object (cars.md, "Objects"), one byte per vertex, 0x18 bytes: the sector's bounds, 3 × 16.16 lowest then 3 × 16.16 highest corner of its vertices (measured on all 66 POD track files) | FUN_00469b80 |
The table sizes add to 216 bytes against a stated total of 240 on every track (measured; the difference is open). The names are French and describe a small scripting language (Sinon: else; Joueur: player; Arret Anim: stop animation).
Sector counts run from 1 (TEST) to 167 (VEITLAND); Cocoon has the most faces (6,821).
Axes. Axes 0 and 1 run across the ground; axis 2 is the height and grows upwards (measured: in a recorded race the car stays 0.45 above the z of the one road face under it, 15.0 in Beltane's sector 51 and 0.0 in sector 59, while each sector's walls reach higher z). The car positions the race keeps (run/vm/racers.py) use the same axes. A face's normal is the unit normal of its corners in that order: Newell's method gives the stored normal to within 0.999 (dot product) on 97.9 % (Beltane) to 99.6 % of each track's faces. The cross product of the first two edges agrees on only 74 %, so the rest are bent quads (measured, apps/viewer/src/editor/tests.rs). A third check: run/vm/follow.py steers with Left when the aim is counter-clockwise of the heading in (x, y), as seen from +z. Car models are the other way round: their axis 2 grows downwards, so the viewer turns them over (scene.rs).
After the sectors
Measured on all 66 POD track files: every section below decodes and the next name follows it.
| Read | Content | Function |
|---|---|---|
u32 s, then per sector i32 k and k sector numbers | Visibility: the sectors to draw from each sector (inferred from the values; a sector is never in its own list). A count below 1 is an empty list (observed) | FUN_00469e1c |
| name | alderon.env, or NEANT (11 tracks have an environment) | load_track_file |
u32 m, m × 12 bytes | Macro references (zero on this disc) | FUN_00467db0 |
u32 t, t × (name, animated object) | Prop types: cone.dec, bidon.dec (drum), carton3.dec (box), pancarte.dec (sign), busdec.dec ... | FUN_00468cfc, FUN_0046a780 |
s + 1 lists | Placed props: a global list, then one per sector. Per list u32 count; per prop u32 type, u32 n, n words, position (3 × 16.16), rotation (3 × 3, 16.16) | FUN_00468cfc |
| name | alderon.lum, the lighting | load_track_file |
u32 c | Usually the sector count (HQ, Pompeii and Special differ) | FUN_00469f24 |
| u32, 3 × 16.16, 4 × u32, 3 × u32 | Global light: a value, a direction, four values (35, 65, 4096, 7192 on Alderon), a colour in 8.8 (255, 255, 255) | FUN_00469f24 |
c + 1 lists | Point lights per list: u32 count; per light u32 (2 on Beltane), 0x30 bytes, 4 × u32 (Beltane has 18). The 0x30 bytes hold a position (3 × 16.16, inside the bounds of the list's sector) and a 3 × 3 rotation in 16.16 (observed in the values; how the game uses them is not traced) | FUN_00469f24 |
All placed props lie within 5 to 30 units of the track's vertices on tracks 900 to 2,400 units across, and every rotation row has length 1 (measured), so positions and rotations read correctly. In POD, 44 of the 66 files have props and only Parking (.BL3 and .BL4) uses a per-sector list (measured, apps/viewer/src/editor/tests.rs).
An animated object (FUN_0046a780): name, texture block, u32, an object (cars.md), 0x1C bytes of bounds, u32, 12 bytes, u32, u32, u32 frame count, 64 bytes per frame (observed; what a frame holds is open). In .BL4 files a prop's texture pages are 128 × 128 RGB565 while the track's are 256 × 256, and its faces have no extra word (measured). Some props' faces have no material name: the face reader takes a flag from its caller. Until that is traced, a face that starts with the word 3 or 4 is read as unnamed (inferred; it reads every file).
.BL4 faces carry one more u32 before their flags (measured from face sizes in Alderon). WINPOD.EXE reads .BL3. Observed in WPOD3DFX.EXE by the 3D-card session (docs/hardware/3d-cards.md): its face reader FUN_004aa718 reads the word for track faces only and keeps its low byte as the face's mirror group (0 none, n a reflecting plane); the 16 words after the project name are one flag per track texture page, and the texture loader FUN_004a4414 halves a page to 128 × 128 when its flag is 1.
The animation library
read_track_animations (FUN_0046856c), after its name ("beltane.anc"). Observed, and measured on all 66 POD track files.
| Read | Content |
|---|---|
| macro references | read_macro_refs (FUN_00467db0): u32 count, 3 words each |
u32 n | animations (DAT_0060b564) |
n × | name ("wrongway.ani"); for "wrongway.ani" only, two u32 (read at 0x7020c8 and 0x7020ca; the second is the instance id of the wrong-way sign); then an animation (read_animation, below) |
| u32 | total instances (DAT_006948e0); measured: equals the sum of the lists |
s + 1 lists | the global list, then one per sector; per list u32 count, u32 word (kept at +0x1a), then the instances |
| per instance | u32 animation index; 4 × u32 (low halves kept at +0xf0..+0xf6); u32 m, m × (u32 macro reference or -1, u32 frame); position (3 × 16.16, 12 bytes); rotation (9 × 16.16, 0x24 bytes) |
An animation (read_animation, FUN_0046770c): u32 textured parts, u32 parts, u32 length (the frame count; the game stores it less one at +2). Then each part:
| Read | Content | Function |
|---|---|---|
| 4 × u32 | a word, the frame count, a word, the object count | read_animation |
| name, objects | the part's name, then its objects (read_object, FUN_00466898, flag 5; no extra face word in .BL4) | read_anim_objects (FUN_00462e80) |
| frames × objects | u32 flag; when it is not zero a rotation (0x24 bytes) and a position (12 bytes) | read_anim_keys (FUN_0046300c) |
Then each textured part: u32, u32 k, and read_anim_texture (FUN_00463148): u32 rectangles, u32, name, a texture block, rectangles × 5 u32, then k × (12 bytes, u32 c, c × (u32, 8 bytes, 8 bytes)). No animation on this disc has a textured part (measured).
Every track has "wrongway.ani": eight objects over 99 frames that end in two rows of five and three (inferred: the letters of WRONG WAY flying into place). Four tracks have one more: Nuke reacteur.ani (one object, 20 frames), Roc Parabole.ani (one object, 59 frames), Sewer helice.ani (two fan blades turning about the vertical axis, 59 frames). Each instance has one event, (0, last frame). The viewer plays the frames at 20 per second (a Deviation:; the game's animation clock is not traced).
Positioned records
read_track_points (FUN_00468508), after its name ("beltane.hp"): u32 n, n × 0x38 bytes into DAT_0057852c; the game clears the slots up to 10. Each record starts with a u32 (0 or 1) and a position (3 × 16.16); bytes +0x10..+0x1f hold leftover text ("tane.anc") and the rest is not traced. Measured: records whose first word is 0 repeat the same positions on several tracks (-725, -420, 14 on Bank, Beltane, Roc and Special), up to 1,100 units from any vertex; inferred: unused slots copied from a template. The NEANT path (FUN_00474d80) sets +0x34 of each to -1. The records are the track's loudspeakers (hp, haut-parleurs): +0x34 is the event the macro action "Son Localisé" fires for the record's point (an i16; bit 15 set: a number in the track's bank), and one of them is the CD music (observed in FUN_00475b18, measured on every POD track; docs/systems/sound.md, "Who starts and stops the music").
The sky
Read by load_track_file (observed); the meanings are inferred from the users of the values.
| Read | Kept at | Used by |
|---|---|---|
| u32 × 4 | DAT_0061d594, 598, 59c, 5a0 | the first two go to FUN_00433098 (from FUN_004339dc, FUN_00434848) |
| u32 | byte DAT_0060b315 | FUN_0049fba4 builds a "BkGround" picture name from it |
| u32 | byte DAT_0060b316 | FUN_0049c93c uses its low 3 bits as an index into 0x60b65c |
| name, block | horizon ("Beltfnt3") | FUN_0046a7bc with mode 3 |
| u32 × 8 | DAT_0060bb98, 9c, 6c, 64, 84, 90, 94, 8c | the cloud layer, FUN_0048fb50: 6c flags (bit 4 tested), 64 the layer's height (16.16), 90 clamped to ±7000, 8c added to 88 every frame (a scroll); 98 and 9c bound heights in FUN_00494430 |
| name, block | clouds ("Nbeltsky", sub-texture sky.tga, flag 1) | FUN_0046a7bc with mode 1 |
.BL4: 0x8000 bytes | light sprites | WPOD3DFX.EXE: FUN_004a4414 with mode 9 |
Before each block FUN_0046a610 checks the name against the texture sets already loaded; on a match the block is read with read_texture_block instead (observed). The blocks have the usual layout (textures.md); what differs is how the pages are drawn (measured by drawing them, decoders/track.py, and comparing the two files):
- The horizon is a 1024 × 64 panorama. In
.BL3the 256 × 256 page holds it column by column, each column from the bottom up. In.BL4it is four strips of 256 × 64, left to right, rows top down. Drawn this way the two files agree (sum of differences 17,273 on a sample against 177,493 for the next best layout). - The clouds are a tiling texture: 256 × 256 8-bit in
.BL3, 128 × 128 RGB565 in.BL4. Observed inWPOD3DFX.EXE: the track loaderFUN_00469584reads this block with mode 2 ofFUN_004a4414, 0x8000-byte pages (jump table at0x4a43e8). The Rust track reader keeps the decoded page together with its stored bytes and palette (Sky::cloud,cloud_bytes,cloud_palette); its test checks these against each file's cloud block across POD's 66.BL3and.BL4tracks undertarget/pod/disc/DATA. - The light sprites,
.BL4only: after the cloud page the 3dfx loader always callsFUN_004a4414with mode 9, which reads 0x8000 more bytes into one texture: a 128 × 128 RGB565 page of four 64 × 64 grey sprites (a halo, a ring, a disc, a soft spot). Inferred: sun and light flares.
Texture animations
After the sky: u32 (kept at DAT_0060bb74; FUN_0049a484 uses bits 8 to 15), then u32 n; the loop runs i = 0, 2, 4 ... while i < n (observed, add ebx, 2 at 0x46f3cb). Each turn reads a name: "ANIME SECTEUR" goes to FUN_004636a8, any other to read_texture_anims (FUN_00463c8c). On this disc n is 0 or 2 and the name is a ".ita" file, so FUN_004636a8 is never used (measured). Nine tracks have one, with 22 animations.
read_texture_anims (FUN_00463c8c), after its name ("Bank.ita"):
| Read | Content |
|---|---|
u32 k | animations |
k × | name ("bank0.cta"), then FUN_0046385c: u32 frames; per frame u32 page (counted from the track's first page, DAT_005761f0) and 8 × u32, four corners (u, v) on the 256 grid; u32 period; u32 m, m × (u32 time, u32 frame) |
| u32 | total faces; measured: equals the sum below |
u32 g | groups |
g × | u32 animation, u32 s sectors, per sector u32 sector and u32 f, f × 3 u32: a word, the corner count (3 or 4) and the face's index among the sector's faces with that many corners |
Times and the period are 16.16 (inferred: seconds; periods are 1.0 or 2.0). The sequence is sorted by time and its first time can be below zero. The game flags each listed face (|= 0x10 at +0x17 of a triangle, +0x31 of a quad) and keeps a pointer to it. The face numbering is measured: 112 of the 129 animated faces on the disc already hold one of their animation's frames (page and corners). Among them: HQ a monitor screen in six frames, Sewer slime in four, Pompeii a turning fan in two (drawn with pod-viewer export).
The race
From step 14 to the end of entry 0 (observed, and measured to end exactly at entry 1).
| Read | Content | Function |
|---|---|---|
| name ("BELTANE.lev") | unless NEANT: u32 n, n × 0x44 bytes (the allocation is named "Zones rep"), u32 | read_track_zones (FUN_0046765c) |
| 0x38 bytes | kept at DAT_00693a48; FUN_004b1d84 tests its words 0 and 3 | load_track_file |
| name ("beltane.bal") | unless NEANT: macro references; u32 n beacons (DAT_00502138), each 0x3c bytes and two u32 macro references (-1 for none); u32 k (DAT_0061d5d4), k × 0x20 bytes | read_track_beacons (FUN_004683d0) |
macro references; u32 n, n × (name, u32 macro reference): the events "Arrivee", "Charge", "Depart", "FinCourse" | read_track_events (FUN_00468308) | |
u32 n, n × 0x24 | start records: 8 on every track (measured); inferred: the grid | read_track_race (FUN_00467ca8) |
| name ("EASY") | the difficulty | read_track_race |
| name ("BELTANEE.lev") | sections, unless NEANT: u32 a, a × 12 bytes; u32 b, b × (u32 k, k × 4 u32) (the allocation is named "Troncons"); a name: after "PLANS CONTRAINTES", u32 n, n × 3 u32 | read_race_sections (FUN_00466c20) |
| name ("BELTANEE.lev") | the path graph, unless NEANT: u32, u32 nodes, u32 links, u32 c, u32 list total; nodes: position (3 × 16.16), u32, u32 (-1 or a c index), u32 a and a link numbers, u32 b and b link numbers; links: u32 from, u32 to (node numbers from 1), u32 k and k words, u32, u32 m and m words; c × 3 u32 | read_race_graph (FUN_00466f84) |
Measured: the list total equals the words in the nodes' and links' lists on every track, and the number of section lists equals the number of nodes. Inferred: the graph is the road network the opponents drive (nodes at junctions, links along the road), and the sections the road's cross-sections. Beltane has 43 beacons, 18 nodes and 20 links; Sewer the most, 44 beacons and 39 nodes. Five tracks have constraint planes.
Entries 1 to 8
All measured to read exactly to the start of the next entry (entries 1 to 7) on all 36 files.
- Entries 1, 2, 4, 5: a difficulty name and its paths, as the end of entry 0 (
read_race_sections,read_race_graph). Their names carry the difficulty's letter ("BELTANEM.lev", "BELTANEH.lev"). - Entry 3: the reverse track's 0x38 bytes, beacons, events, start records and EASY paths, as from step 15 of entry 0. Every name has its first letter replaced by
-("-eltane.bal", "-ELTANEE.lev"; on TEST "-EANT", so the game reads empty sections where entry 0 has NEANT). - Entries 6, 7, 8: a difficulty name, then
read_track_opponents(FUN_0046c580): name ("BELTANEE.lev"); unless NEANT, u32nand per opponent a driver name ("Maverick"), u32, u32, the car's number as text ("6", read with a format at0x4f17a1), u32kandk × 12bytes. Seven opponents on every track but TEST. The game takes as many as the race needs, at most 8 cars in all. - After entry 8 the last block holds 1.4 to 16 KB that no function reads (observed:
read_track_opponentsis the last read from the file). It is the writer's previous block, still encoded: every tail byte at stream offsetiequals the byte ati - 0x3FFCXORed with bytei mod 4of the key0x0F7E(measured on all 66 POD track files; found first on the.BI2and.BP3files by the small-formats session). A writer can rebuild it from the block before.
BL3 and BL4
Both have the same entry table and names. The BL4 files are 25 to 45% larger. Inferred: the two hold the same track with textures for different renderers or colour depths. The caller's param_2 (0, 1, 2 or 4) picks the file name through FUN_004ae91c.
What the game tolerates (the fan tracks, 2026-10-24)
The extra tracks of the POD download (docs/extras.md) showed where the game is laxer than our first readers were. The readers now follow the game:
- "NEANT" in any case.
load_track_filetests the environment, lighting, animation, points and beacon names against "NEANT" with the case-blind compareFUN_004c805c. POD's references to the string are at0x46a2f3,0x46a324,0x46a353,0x46a3e3and0x46a664(observed). The readers below it use the exact compareFUN_004c80a0. Forest writes "neant". - Zero bytes between entries. The game seeks every entry through the table and never checks where the previous one stopped. Arcade has 0x38 zero bytes after entry 0, and Forest has 0x38 after entry 2 (measured).
Entries::gapslists them. - A bad reverse track. Entries 3 to 5 are read only for a reverse race. In Forest, entry 3 has no 0x38-byte head (the 0x38 zero bytes sit before it), so a reverse race would read the name as the head (inferred).
Entries::reverse_errorsays why the entries did not read, and a forward race loads. - The texture animation total. POD's
0x463c8callocatestotal × 0x18bytes and writes one record per face it reads, with no bound (observed:add ecx, 0x18at0x463e8a). Three OEM tracks disagree with their total. CityOEM has 14 faces for 20, which leaves records unused. CanyOEM has 77 for 15 and PompOEM 27 for 17, so the game writes past its allocation (inferred: heap corruption; to be measured).TextureAnims::overruncounts the extra faces.
Open questions
- What a prop animation frame holds (
FUN_0046a780), how the game uses the light records' position and rotation, and the four words after them. - The byte per vertex.
- The macro table's data (
Arret Animholdspascal.anc) and its 24-byte difference. - What the second word after the version means (
0x81690e98inBELTANE.BL4, different inBELTANE.BL3). - The fields of the records kept as bytes: positioned records (0x38), zones (0x44), the 0x38 bytes before the beacons, beacons (0x3c), their 0x20 records, start records (0x24); and the four words of an animation instance.
- What the six words before the horizon mean beyond their users, and the cloud words
84,94,98,9c. FUN_004636a8("ANIME SECTEUR") andFUN_00463148(textured animation parts): in the code, unused on this disc.- The game's animation clock: how fast the library's frames and the texture animations run.
Writer
crates/pod-formats/src/track/layout.rs (measured on all 66 POD track files, tests/writers.rs): split cuts the stream at the reader's own boundaries (Part: head, texture block, sector count, each sector, visibility, environment, lighting, animations, points, sky, texture animations, race; then entries 1 to 8 with the unused bytes after entry 8). Layout::encode joins them, writes the entry table again (raw offsets, checksum words counted) and pads to whole blocks. An unchanged track writes back byte for byte. A part that grows moves the later entries: with one more texture page every track still reads to the end of every entry. The texture block is decoded (pages can be redrawn) and sector vertices can be moved (Layout::set_vertex); the other parts are kept as bytes until their fields are written from the decoded form. Layout::patch writes over bytes of entry 0 at an offset the reader gives (Face::page_at, Face::normal_at, Object::at, Placed::at, Light::at, Lighting::at): the track editor's edits go through it (apps/viewer/README.md). Modded by the editor: a sector's texture page, raised road and moved props, seen in the game (docs/modding.md, 2026-10-24). Modded: page 0 of BELTANE.BL3 in the game (docs/modding.md).