Viper Racing (1998, Monster Games / Sierra) — Reverse-Engineered File Format Reference
Purpose: a from-source technical catalog of every resource type shipped with Viper Racing, built for
modders and tool authors — direct binary reverse-engineering of a redump-verified, untouched retail copy of
the game (USA, Rev 1 — redump.info/disc/106242), covering every
.trk/.car/.res archive in Data/, verified byte-exact against the raw archive bytes. Every claim
below is tagged with a confidence level so you know what to trust blind and what to verify yourself before
building on it.
This intentionally covers original, unmodified retail game data only. Modernization installers, existing mod tools, and modding pipelines are deliberately out of scope for this document — this is a pure file-format reference.
Confidence key
| Tag | Meaning |
|---|---|
| CONFIRMED | Verified by direct binary inspection against multiple sample files, with byte-exact or near-byte-exact accounting for the data. |
| 🟡 WELL-SUPPORTED | Header fields/structure confirmed by inspection; some payload semantics inferred from strong circumstantial evidence (naming, value ranges, cross-references) but not byte-exact verified. |
| ⚪ HYPOTHESIS | Educated guess from file size/naming/position in the pipeline only. Unconfirmed. |
| ❓ UNKNOWN | Not investigated. |
1. The big picture
Viper Racing's engine (built by Monster Games) stores almost everything — meshes, textures, AI paths,
camera definitions, object placement, lighting configs — in one universal binary envelope (§2), then
groups a track's or car's full set of these resource files into a package archive (§3: .trk/.car/
.res) for shipping.
2. The universal container: "0SER" resources CONFIRMED
Every native binary resource file examined — .mod, .ili/.ild, .ccs, .tab, .obt, .tex,
.stp, .grf, .sol, .bpp, .bsp — opens with the identical 20-byte envelope:
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0x00 | 4 | "0SER" |
Fixed magic, literally "0SER" ASCII, identical in every file of every type sampled. Almost certainly short for "serialized [object]". |
| 0x04 | 4 | type tag | 4-byte FourCC identifying the resource type (see below). |
| 0x08 | 4 | int32 version | Small integer, 0–3 across the sample set; differs by type, constant within a type. |
| 0x0C | 4 | (reserved) | Always 00 00 00 00 in every sample. |
| 0x10 | 4 | "!IGM" |
Fixed secondary marker in every file, every type. |
| 0x14+ | — | type-specific payload | See §4 for each type. |
The type tag is byte-reversed on disk. The engine is written in C on little-endian x86; a 4-character
constant like 'MINF' declared in source (x = 'MINF') is packed MSB-first into a 32-bit int
('M'<<24 | 'I'<<16 | 'N'<<8 | 'F'), and when that int is fwrite'd on a little-endian machine, the bytes
land on disk in reverse order — F N I M. Reversing the 4 bytes of any tag you find in an
unfamiliar file recovers the mnemonic the original programmer typed. Confirmed tags recovered this way:
| Raw bytes on disk | Reversed (mnemonic) | Extension(s) | Meaning |
|---|---|---|---|
FNIM |
MINF |
.mod |
Mesh INFo |
NILI |
ILIN |
.ili, .ild, .ilg |
"ILI" + pad letter (see below) |
0SCC |
CCS0 |
.ccs |
"CCS" + version digit baked into the tag itself |
BATS |
STAB |
.tab, .obt |
Simple TABle (shared by both extensions, §4.3) |
XET |
TEX |
.tex |
TEXture (space-padded) |
PMTS |
STMP |
.stp |
STaMP (2D sprite) |
FARG |
GRAF |
.grf |
GRAph/GRAphics File |
LBOS |
SOLB |
.sol |
"SOL" + pad letter |
TPPB |
BPPT |
.bpp |
"BPP" + pad letter |
TPSB |
BSPT |
.bsp |
"BSP" + pad letter |
rDIA |
AIDr |
.adr |
AI Driver |
TNDA |
ADNT |
.dnt |
"DNT" + pad letters |
SGPU |
UPGS |
.ugs |
UPGS — upgrade-shaped mnemonic |
XFSE |
ESFX |
.ens |
Engine SFX |
0XFS |
SFX0 |
.sfx |
"SFX" + version digit baked into the tag |
FRAC |
CARF |
.cf |
CAR File |
The pattern is consistent: where a real short word fit in 4 characters (MINF, GRAF, STAB, STMP,
TEX, AIDr, ESFX, CARF), they used it; otherwise it's the file extension in caps plus one or two
extra padding characters (CCS0, SOLB, BPPT, BSPT, ILIN, ADNT, SFX0). Any new file type you
encounter that starts with 0SER can be identified this way even with zero other context.
The !IGM marker reverses to MGI! — almost certainly a signature: Monster Games Inc.,
the studio that built the engine, with an exclamation point. It's a nice confirmation that the whole
family really is one in-house serialization layer rather than several unrelated formats that happen to
share a magic number.
3. The package layer: .res / .car / .trk CONFIRMED
Above the individual 0SER resource files sits an archive layer — a flat container that groups a track's,
car's, or shared-resource bundle's full member set into one file. Verified byte-exact against the real,
untouched retail files — all 26 archives in Data/ (8 .trk, 5 .car, 13 .res) reconstruct
member-for-member from the raw bytes with zero mismatches, including a 1,220-member archive
(drivers.res) checked entry-by-entry.
0x00 "0TSR" archive magic (a distinct top-level family from the per-resource "0SER" envelope)
0x04 int32 entryCount
0x08 int32 coreCount -- see formula below
0x0C int32 coreBoundary -- byte offset where "bulk" payload data begins; see formula below
0x10 entryCount × 36-byte directory entries:
name[16] null-padded filename, no path
tag[4] same reversed-FourCC convention as the resource envelope (§2)
version[4]
payloadSize[4] size *excluding* the resource's normal 20-byte 0SER envelope
reserved[8] = 0 in every entry sampled
→ then entryCount payload blocks, back-to-back, in the SAME order as the directory:
reserved[4] = 0, marker "!IGM"[4], then raw payload[payloadSize]
A standalone member file is normally "0SER"+tag+version+reserved+"!IGM"+payload — the 20-byte envelope
from §2, then payload. The archive just splits that envelope in half: tag+version live in the
directory entry, reserved+!IGM sit immediately before the payload. Reconstructing a standalone member
is literally "stitch the two halves back together" — confirmed by reconstructing members of bemidji.trk,
viper.car, and drivers.res this way in Python and diffing byte-for-byte against known-good extracted
copies, with zero mismatches.
The two header ints, solved: every archive splits its members into a "core" group and a "bulk"
group. Bulk is a fixed set of three tag types — TEX (textures), NILI (AI-path variants:
.ili/.ild/.ilg), and TNDA (.dnt per-tier driver tuning) — whichever of those are present in a
given archive, regardless of how many instances of each actually appear. (An earlier pass at this
described the rule as "whichever tag has the highest occurrence count," which happened to fit the handful
of archives spot-checked at the time but doesn't generalize: several .car/.res archives have a
non-bulk tag with far more instances than any bulk tag present, and it's still correctly excluded from
"core." Brute-force testing every possible tag combination against all 26 real header values confirms the
fixed three-tag set is what actually reproduces every archive — verified end-to-end by building a working
unpack/repack tool around it and round-tripping all 26 archives byte-exact.) Given that:
coreCount= number of entries whose tag is not one of the three bulk tagscoreBoundary=(16 + 36×entryCount)+8×coreCount+sum(payload sizes of core entries)— the byte offset marking the end of the core resource block / start of the bulk block
Verified exactly this way across all 26 retail archives — zero exceptions.
Ordering requirement, found via a real crash: coreBoundary's formula only describes the file's real
layout if every core-tagged entry's payload physically precedes every bulk-tagged entry's payload in the
directory/payload order — true of all 26 retail archives (confirmed directly: none of them interleave a
core entry after a bulk one), but not something a tool is free to ignore when adding a new entry.
Appending a new core-tagged member after an archive's existing bulk section (e.g. adding an owned override
of a previously shared/inherited part) computes a coreBoundary that no longer matches where the file's
bulk data actually starts — confirmed to crash the retail game, reading texture data tens of KB past its
real offset. Fix: an archive builder must stable-partition entries (core group first, bulk group after,
each group's own relative order preserved) before computing the header, not merely trust whatever order
its entries happen to arrive in.
The loader also accepts a second layout, so a writer must preserve what it finds. Alongside the split
above, archives exist whose header declares no bulk section at all — coreCount == entryCount and
coreBoundary == file size — with bulk-tagged entries left interleaved wherever they fall. The retail game
loads both without complaint. Stated generally, one rule covers each:
coreCount= the number of entries preceding the first bulk-tagged entrycoreBoundary= that entry's payload-start offset (= EOF when there is no bulk section)
What the loader does not tolerate is a coreBoundary matching neither, which is exactly what naively
appending to an already-split archive produces. So a tool should read whichever convention an archive
already uses and write it back unchanged, rather than always recomputing the tag-based split — doing so
reproduces every archive tested byte-exactly, while force-normalising one layout into the other rewrites a
large fraction of the file for no benefit and destroys the byte-exactness that makes an editing tool
auditable. (Files using the second layout are not retail; see §9.)
The car's own filename is load-bearing, found via a second real crash. Nearly every member of a .car
archive is named <prefix><suffix> — Viper0.mod, viper0.sfx, viper.cf, vipere.ens, viperL.tab,
etc. — where <prefix> is the car's short identifier (viper, exotic, sedan, sports, ...). The handful of
exceptions are fixed, car-agnostic names: cockpit.tab, Needle.mod, and the shared-default assets
(ball.mod, horn.sfx, shift1.sfx, squeal.sfx — see §5). Confirmed directly from a real crash log:
saving an edited copy of viper.car to a different filename (viper_edited.car, chosen specifically to
never overwrite the original) while leaving the archive's own members named Viper0.mod etc. made the game
panic — ResourceGet("viper_edited0.mod") returning NULL! / Couldn't load ModelInfo viper_edited0.mod.
The game derives which internal member names to look up from the .car file's own filename on disk,
not by reading anything stored inside the archive — renaming the file without renaming every
<prefix><suffix>-named member to match leaves the game looking for members that were never created.
The prefix has a hard length limit: 10 characters — but 9 is the safe cap. The archive's 16-byte name
field has to fit <prefix> and the longest real suffix in the same car (up to 6 bytes — Viperd1.tex's
d1.tex). At a 10-char prefix the longest member (<prefix>d1.tex) is exactly 16 bytes, filling the field
with no null terminator — a state no shipped archive ever reaches (the longest member name in any retail
.car/.res/.trk is 12 bytes), so whether the engine's own filename lookup tolerates an unterminated
16-byte name is untested. A prefix of ≤ 9 keeps every member name ≤ 15 bytes, i.e. always null-terminated,
which is why the toolkit's car-fork / "Save as new car" cap the prefix at 9. No retail car exceeds 6.
Renaming the prefix has to reach inside .mod payloads too, not just the archive's directory table.
.mod meshes reference their own textures by material name (§4.2), and that name is matched literally
(case-insensitively) against a real archive entry — so a .mod still saying VIPERD1.tex after the archive
entry itself was renamed to (say) viper_md1.tex no longer resolves to anything. The car's own body-paint
material follows the identical prefix convention and is never a real archive entry either way (§5.3's
placeholder-swapped-at-runtime paint slot) — viper.car's is literally VIPER.tex, exotic.car's is
Exotic.tex — strongly suggesting the same dynamic-prefix mechanism that crashed the game over
<prefix>0.mod also governs which material the runtime paint-swap looks for, which would explain a renamed
car's body rendering as a flat fallback color rather than the player's chosen paint. A renaming tool needs
to re-parse and rewrite every .mod's own material names in step with the archive-entry renames, not just
the directory table.
A third, harder-to-fix dependency: paintkit.res's per-car-shape paint canvases. That shared archive
holds a dedicated <prefix>.cvs canvas for exotic, sedan, and sports — but not one for viper,
despite viper being the game's primary, title car. The simplest reading: viper is hardcoded as the
default case (using paintN.cvs, §5.3's player-selected paint slots, directly), while every other car shape
needs its own dedicated canvas to paint onto. Confirmed in-game: a renamed viper.car (viper_edited/
viper_m/etc.) opens the Paint Kit to a solid black canvas — there's no <new-name>.cvs for it to fall back
to, and it no longer matches the hardcoded viper special case either. Unlike the .mod material-name
issue above, there's no known fix that stays within a renamed copy — it would require synthesizing a whole
new .cvs canvas (262,180 bytes, presumably body-shape-specific UV-mapped pixel data) for a car identity
that never had one.
Given three independent identity-tied dependencies found by three separate real bugs/crashes (archive
member names, .mod paint-slot material names, and now the Paint Kit canvas) — and no guarantee a fourth
isn't still out there — the more robust strategy for a tool that edits a car in place is to not rename it at
all. Back up the original file, then write every edit back under the car's own real, unchanged filename.
Every one of these three problems disappears at once, rather than being patched individually as each new one
surfaces.
A directory-listing manifest (a flat, whitespace-separated list of member filenames, no paths or offsets/sizes) is sometimes seen alongside an unpacked archive's contents — real sample:
aidef.ccs bemidji.ccs camera.tab track.bpp track.bsp track.grf track.obt
track.sol Trackmap.stp asph.tex default.ili ... (etc.)
It's not itself embedded inside the compiled archive format in a form you need to parse — everything needed to reconstruct the archive is in the directory table above.
.trk = track archive — on the retail disc: bemidji, dundas, hastings, heaven, kenyon, limbo,
nfield, uptown (named after internal track-zone identifiers, not the marketing names shown in the
game's track-select menu — see the confirmed mapping below). .car = packed car (exotic, plane,
sedan, sports, viper). .res = shared resource bundle (common, drivers, ui, race,
postrace, paintkit, easy/medium/hard, career1–4 — the difficulty and career-stage .res
files turned out to just be repackaged sets of the 7 Viper LOD textures).
In-game track name → .trk filename, CONFIRMED (read directly from english.lng's
Tracks:<Slot>:Name keys — see §5.5, which
lists the 8 built-in tracks by their in-game menu names):
| In-game name | .trk file |
|---|---|
| Bemidji | bemidji.trk |
| Castlegreen | heaven.trk |
| Silverdale | uptown.trk |
| Ridge Valley | hastings.trk |
| Dayton | limbo.trk |
| Dundas | dundas.trk |
| Sunset Mesa | nfield.trk |
| Rock Island | kenyon.trk |
Only bemidji.trk and dundas.trk happen to share their in-game name — every other file's internal name
is unrelated to what the game actually calls that track in the menu. Anyone editing a specific track by
its familiar name needs this table to find the right file.
3.5 Runtime asset resolution & shared assets CONFIRMED
Sections 2–3 describe how a resource sits inside an archive. This section covers how the engine finds a resource at load time — which matters the moment a car or track mod is actually raced, because it explains several behaviours that look like mod bugs but aren't.
The resolution rule. When the engine needs a member by name it looks in the car's own
archive first, then the shared resource archives (race.res, common.res, postrace.res,
paintkit.res, ui.res, in that order). Per-car members are addressed by a
<carname>.car/<member> path — i.e. keyed by the car's own filename. Two consequences:
- A car that ships its own copy of a shared asset overrides it; one that doesn't falls back to the shared default.
- Because per-car members are keyed by the car, two cars may contain identically-named
members without colliding — each loads from its own archive. (Proof: every kart add-on
names its textures
driver.tex/kart.tex/plate.tex, yet each renders as a different character — impossible with a global name cache.)
Shared default meshes (race.res)
| Member | Role |
|---|---|
ball.mod |
horn ball — the runtime exception, see below |
fwheel_1/2/3.mod, wheel_1/2/3.mod |
front / rear wheel meshes (style variants) |
arm_ll/lr/sl/sr/ul/ur.mod |
visible suspension control arms |
spin_l/spin_r.mod |
spinning-wheel effect |
brakelt.mod, diskglow.mod |
brake light, disc glow |
Xray.mod |
X-ray view mesh |
Shared default textures
| Member | Archive | Role |
|---|---|---|
ucar.tex |
race.res |
generic fallback body skin |
wheels.tex |
race.res |
wheels |
effects.tex, effectsx.tex |
race.res |
brake lights / FX |
envmap.tex |
common.res |
reflection / environment map |
damage.tex, skid.tex, splash.tex, Xray.tex |
race.res |
damage / skids / splash / X-ray |
Shared default sounds (horn.sfx, squeal.sfx, shift1.sfx, crash1-3.sfx, road1/2.sfx, …)
live in race.res under the same override rule.
When assets overlap — or clash
- Overlap (by design, safe). Every shared file above is a default: a car's own version wins, otherwise the shared one is used. Clean per-car fallback, no collision.
- No collision for per-car assets. Bodies, cockpits, wheels, driver textures and skins are all keyed by car, so identical names in different cars don't clash.
What a car can override — measured, September 2026 ✅
The rule above was written from what third-party cars happened to ship. Tested directly by giving one car a deliberately unmistakable copy and leaving a second car untouched as a control, the answer is broader: assume a car can override any shared asset. Every candidate tried resolved from the car's own archive, including ones with no plausible car context at the moment they are used.
| Asset | Result | How it was established |
|---|---|---|
road1.sfx |
✅ per-car | Car's own copy held 4s of 440/880 Hz beeps while race.res's held music: that car beeped, the control played music. |
arm_*.mod |
✅ resolves — but GLOBAL | Suspension arms scaled 2x. They render doubled on every car in the field, not just the one carrying them. |
rpm.stp |
✅ resolves | A HUD sprite, drawn by the UI with no car attached. Stock 4x4cos.car already ships its own. |
crash1.sfx |
✅ resolves | Beeps on impact — and it is the horn ball's impact sound, not only car-on-wall. |
go.sfx |
✅ resolves | Beeps when the start light goes green, a moment with no particular car in scope. |
ucar.tex |
✅ resolves — GLOBAL, and the supplying car need not even use it | A magenta copy was put in one car. That car showed nothing (its own meshes do not reference ucar.tex), but every stock Viper in the race rendered a magenta undercarriage. |
The distinction that matters is not whether an override works, but how far it leaks.
road1.sfx is genuinely per-car — the control car was unaffected. arm_*.mod and
ucar.tex are supplied by a car and then apply to everything, the same "loaded once,
globally" pattern already documented for ball.mod in multiplayer. For rpm.stp,
crash1.sfx and go.sfx the override is confirmed but the leak is not yet tested;
the test is to switch to a car that does not carry the override and see whether the
effect persists.
ucar.tex is the sharpest case and the one that should worry a tool author. The car
carrying the magenta texture displayed none of it — none of its meshes reference
ucar.tex — yet it repainted the undercarriage of every stock Viper on track. So a car
can override an asset it does not itself use, purely by carrying a file with the
right name, and the damage shows up on other people's cars. A mod whose textures happen
to collide with a shared name will silently restyle the rest of the field, and nothing in
the mod, its own appearance, or the game will say so.
The working model this batch supports: sounds resolve per-car, meshes and textures are
cached once globally by bare name. road1.sfx was per-car with a control; arm_*.mod
and ucar.tex leak. That is a model, not a proven rule — crash1.sfx and go.sfx would
confirm or break the sound half of it.
That difference is what any tool exposing these has to communicate. "Your car plays its own road noise" and "your car changes the suspension geometry for the entire field" are very different things to put behind the same Import button.
The mod manager now does communicate it, per asset rather than as a blanket caution:
vrmod/car.py's SHARED_ASSET_SCOPE holds this table in code — per-car, global, or
unknown — and the Parts, Sound and Textures drawers badge each shared row from it.
road1.sfx gets no badge at all, deliberately: a blanket "shared assets affect other
cars" would be false there, and a warning that is wrong on the one asset people actually
use is a warning everyone learns to click past. Filling in an unknown is one car-switch
each; move it in that table and the drawers follow.
Absence of precedent proved nothing, twice. Not one of 160 third-party cars ships a
road*.sfx or an arm_*.mod; both work. Nobody had tried.
ftrack/rtrack are physics, not just placement CONFIRMED IN GAME
The toolkit derives wheel POSITION from wheelbase/ftrack/rtrack (see
car.py's WHEEL POSITIONING note), and it was an open question whether those
fields do anything beyond putting the wheels somewhere — a renderer-only reading
would have made track free to set for looks alone.
It is not free. A converted car given a deliberately narrow rear track, to tuck its wheels inside a body that necks in at the back, would not turn — driven, not inferred. So track carries roll stiffness and lateral load transfer the way its name suggests, and any tool that sets it from the bodywork is trading grip for appearance whether it says so or not.
This matters for converted cars specifically. A body built for another game has no obligation to be wide where an axle needs to be, so "the wheels sit inside the bodywork" and "the car corners" can be in direct conflict, and only one of them is visible in a screenshot.
- ⚠ The horn ball is the exception.
ball.modis not resolved per-car — the engine hardcodes the bare nameball.modincreate_ball, which builds the ball thehorn_ballhack tosses, and loads it once, globally. In a multiplayer session there is effectively one horn ball for everyone, not each player's own — the single case where two cars both namedball.modtruly collide. - ⚠ AI-field paint (a limitation, not a clash). The whole AI field drives the primary car
(
viper.car's identity). Stock viper's body uses a paint slot the engine recolours per AI slot (Viperd<N>.tex) — a field of varied colours. A mod made the primary car brings its own body texture, so the paint slot is bypassed and every AI car shows one identical skin. Re-tagging the body back to the paint slot restores per-AI colours, but those paints are UV-mapped for the viper body and map incorrectly onto a differently-shaped mod mesh.
Why this is in a format reference. These are exactly the behaviours a modder meets the first time they race their car against a full field or online — identical AI cars, one shared horn ball, garbled palette paint — and assumes they broke something. They didn't: it's how the engine resolves shared vs. per-car assets.
4. Per-resource-type catalog
4.1 FNIM — .mod 3D mesh — CONFIRMED
Used for every rigid mesh in the game: car body LODs (Viper0.mod…Viper7.mod, one file per detail
level), car sub-parts (Viperw.mod = wheel, Viperb.mod, Viperc.mod, vipers.mod, Needle.mod =
speedometer needle, all verified to carry the identical FNIM tag), and track props (checkpt1.mod,
ground.mod).
0x00 "0SER" + "FNIM" + int32 version(=1) + reserved + "!IGM" -- 20-byte envelope, see §2
0x14 int32 vertex count (V)
0x18 int32 (stale in-memory pointer; byte-identical junk across unrelated files — ignore)
0x1C int32 material count (M)
0x20 int32 (stale pointer — ignore)
0x24 int32 face count (F)
0x28 int32 x4 (stale pointers/reserved — ignore)
0x3C V × 32-byte vertex records: float32 x,y,z, nx,ny,nz, u,v
M × 32-byte material records: null-terminated texture filename, then — at a FIXED offset
from the END of the 32-byte record, regardless of how long the filename is — 4 ×
int16: vertex_start, vertex_end, face_start, face_end (see below)
F × 8-byte face records: int16 a,b,c (CCW winding) + int16 pad(=0 always)
Per-face material assignment is real, and is stored as contiguous ranges, not a per-face index.
An earlier pass at this format found no per-face material index and assumed none existed. It's stored
in what was assumed to be junk trailing bytes on each material record instead: the last 8 bytes of
every 32-byte material record decode as 4 × int16 — vertex_start, vertex_end, face_start, face_end,
a half-open [start, end) range of vertex/face indices that material owns. These chain across a
.mod's material list: material i's vertex_end/face_end equal material i+1's vertex_start/
face_start, and the last material's vertex_end/face_end equal V/F exactly. Verified byte-exact
against real files: every face's actual vertex indices were checked against its material's declared
vertex range across three real multi-material meshes (6, 3, and 7 materials) — zero violations across
all 389+45+363 faces checked. This is what makes the material list genuinely usable for splitting a
mesh into per-material groups (e.g. for OBJ export) without guessing.
No per-face material index exists as a separate field — the ranges above are the actual mechanism, and the face "pad" field is always zero.
⚠️ The int16 fields impose two hard format ceilings on mesh size, independent of any engine limit.
Face records store their three corner indices as int16, so a mesh can address at most 32,767 vertices —
a larger mesh simply cannot be represented. And each material record's vertex_end/face_end are int16
too, capping a single material at 32,767 faces; since an ordinary quad-tessellated surface has ~2 faces
per vertex, that ceiling is reached at only ~16k vertices unless the topology is leaner (a triangle strip is
~1 face per vertex and reaches the full ~32k). These are properties of the file format, not of a particular
race.bin. Stacked below that format cap is a separate, lower engine limit — and it has now been
found, measured, and confirmed in-game (see §5.2.4 for the full disassembly). The community-quoted figure
of "20,000 vertices" for v1.2.5 is wrong: the real per-object ceiling is 30,000 vertices. The
mechanism is a single fixed vertex scratch buffer the engine mallocs once at startup — malloc(960,000)
at VA 0x45031a, pointer kept in global 0x4dc72c — into which the per-object transform loop copies each
vertex at index × 32 bytes. 960,000 ÷ 32 = 30,000, so an object with more than 30,000 vertices writes
past the buffer and the game crashes with an access violation. (This is why the earlier "no 20000
constant, no large static buffer" observation was correct yet pointed the wrong way: the sizing constant
is the malloc byte size 0xEA600 = 960,000, not the vertex count, and the buffer is on the heap, so it
never showed up as static-data growth.) The buffer is allocated once and reused per object, so the cap
is strictly per single mesh, and enlarging it costs a flat +88 KB of RAM regardless of how many cars are on
track. Confirmed empirically: a 32,000-vertex test mesh crashes at the copy instruction 0x4576be, and
after the one constant is raised to 0x100000 (1,048,576 B) the identical mesh renders — lifting the
ceiling to 32,768 vertices, the int16 format cap, beyond which face indices cannot reach anyway. Retail
1.1's buffer is 32,000 bytes = exactly 1,000 vertices — also below its widely-quoted "1,200". Both builds'
sites are found by the same allocation-context pattern (retail at 0x44f57a, v1.2.5 at 0x45031a), so the
patch is build-independent; it ships as the opt-in vrmod patch --max-verts N (default off, since the stock
cap suits every stock car).
Vertex positions are in meters, for cars and track props alike — cars and tracks are drawn in one
shared world, so a single scale applies throughout. Viper0.mod measures 4.43 × 1.92 × 1.10 against the
real Viper GTS's 4.45 × 1.92 × 1.12 m. No conversion is needed to bring car parts into track space.
4.2 NILI — .ili / .ild / .ilg AI driving line — CONFIRMED
default.ili = the AI racing line; rdefault.ili = reverse-direction variant; track.ild = a third line
variant (checkpoint/pit line). All three share the format — as does a fourth extension, .ilg, found in
drivers.resmakes the AI drive the WRONG track on any add-on, and the community fix is to empty it — CONFIRMED. The shipped file is 6,334,252 bytes holding 1,220 members: 524.ilgAI lines, 695.dnttunings andaidriver.adr. Those.ilglines are baked per track, so a new track installed into a stock slot inherits the original track's AI line — the AI swerves off the road at the start, or the game crashes outright on some tracks.Sucahyo's fix, circulated from 2009, is a
drivers.rescontaining nothing at all: 16 bytes,30 54 53 52plus three zero counts, which is byte-for-byte whatarchive.to_bytes([])produces. With no baked lines to prefer, the AI falls back to the track's owndefault.ili/track.ild.This is a prerequisite for any generated or imported track, and it is invisible until AI cars are on track: geometry, collision, timing and the player's own car all behave correctly without it.
drivers.res on the retail disc: one AI-line variant per AI driver skill tier per track section
(000vipr.ilg … 6xxvipr.ilg, paired with a same-tier .dnt file, §4.11 — hundreds of them, all
confirmed NILI-tagged and structurally identical to .ili/.ild).
0x00 "0SER" + "NILI" + int32 version + reserved + "!IGM" -- envelope, see §2
0x14 int32 x2 (unused by the extractor; likely point-count/flags)
0x20 N × 68-byte records (17 × float32):
field[1] = X (world space, meters)
field[2] = Z (world space, meters) — same sign as the mesh data; see the note below
field[5] = corridor half-width, meters — `track.ild` only; see below
field[6] = target speed, m/s (a flat 100.0 in every `track.ild`)
field[9] = cumulative arc-length distance from record 0 (meters)
field[0] = NOT a float: the runtime `next` POINTER — see below
field[10] = NOT a float: `0xFF<kind><index16>`; `kind` names the LINE, and
the index is zeroed on load
field[12] = distance to a block boundary ahead (meters) — engine never reads it
field[13] = signed distance to a block boundary ahead (meters) — debug draw only
field[16] = NOT a float: `0xFEEDBEEF`, the pool allocator's guard word
4.2.1 The file is a raw memory dump of a circular linked list — CONFIRMED
This one fact explains most of the format's oddities, and it comes straight off
MutableIdealLine::save in the symbolised 1998 build:
push 0x494c494e ; "NILI", version 3
call IdealLine::segloop_count
push 0xc ; 12-byte header: ..., ..., count
call FileWrite
loop:
push 0x44 ; 68 bytes — the ILSeg node, verbatim
push edi
call FileWrite
mov edi, [edi] ; follow ILSeg::next
cmp [esi+0x2c], edi ; until back at the head
jne loop
The writer never serialises anything. It walks a circular linked list and dumps each
node's raw 68 bytes, next pointer included. So:
- Field 0 is that
nextpointer. In shipped files it holds real 1998 heap addresses — bemidji'sdefault.iliruns0x00302968,0x003029ac,0x003029f0, exactly0x44apart, and the last record points back to0x00302924, the head. This is why X sits at field 1 rather than field 0.fixup_resrelinks every node at load, so whatever is stored there is discarded — vrmod may write zeros. - Field 16 is the pool guard,
0xFEEDBEEFin every record of every shipped file. - Field 10's low 16 bits are zeroed on load (
fixup_resends by clearing the low word of every record), so the index is scratch space, not input. Thekindbyte is0throughdefault.iliand1throughrdefault.ili, but intrack.ildit runs1,2,3in contiguous blocks — bemidji is1for records 0–31,2for 32–71,3for 72–90. Those are the lap's sectors, which is whatCenterLine::get_next_checkpointwalks.fixup_resconfirms the reading: when it reverses a line it renumbers this byte, and it guards the renumbering withcmp edx, 5, so the format allows at most four sectors. (vrmod'sili.generate(sectors=True)already writes it this way.)
fixup_res also holds the whole reverse-line construction, which is why rdefault.ili
never needed separate authoring: it reverses the record order, shifts field 8 by one
record, re-accumulates field 9, negates the direction vector in fields 3 and 4, and
renumbers the kind byte.
4.2.2 Fields 12 and 13 — the engine does not read them — CONFIRMED(by exhaustion)
These were the last two unexplained fields, and the answer is that nothing consumes them. Every function that walks the line was disassembled from the symbolised build:
| function | ILSeg fields it reads |
|---|---|
ILSeg::QuickEval |
0, 1, 2 (lerps position toward next) |
ILSeg::QuickTan |
0, 3, 4 (lerps the tangent, then normalises) |
IdealLine::get_rabbit_position |
3, 4, 5, 6, 8 |
IdealLine::advance_bead |
3, 4, 6, 7, 8 |
IdealLine::get_nearest_bead |
8 |
ILSeg::__Curvature |
3, 4 |
Fields 12 and 13 appear in none of them. Scanning the entire .text section for x87
access to an ILSeg's +0x30/+0x34 returns exactly one hit on a line segment:
ILSeg::__DrawLine, which compares field 13 against 0x80000000 and picks one of two
colour globals — a debug-draw decision about the sign, nothing more. (The other 78 hits
are unrelated structs; +0x30 is an unremarkable offset in Wheel, Engine,
SphereVolume and a dozen others.)
What they nevertheless are. Both are distances in metres that fall by exactly the step
(field 8) each record, over a shared, irregular block structure. Field 13 crosses zero and
goes negative; field 12 stays positive throughout. field[12] - field[13] is constant
within a block and changes at each boundary. Those boundaries are corners: mean
curvature at a boundary runs 1.5×–3.5× the track mean on every shipped centreline
(bemidji 1.58×, dundas 2.30×, hastings 1.66×, heaven 3.52×, kenyon 1.73×, nfield 2.02×,
uptown 1.50×). What the per-block constant is remains open — across 288 blocks it
matches neither the block's own length, its neighbours' lengths, nor the distance to the
block's curvature peak.
⚠️ An earlier reading in this project had these as "distance to the next and previous checkpoint". The "previous" half is wrong: both look forward, and they share one block structure rather than bracketing the record.
Why this matters for generated tracks. AI pacing is carried by field 6 (target speed) and field 7 (curvature), both of which the follower does read. Fields 12 and 13 cannot be the reason a generated track's AI brakes differently from a shipped one, and filling them approximately costs nothing.
Does this apply to the game people run? The analysis is from the 1998 symbolised build, so it was checked against both shipped
race.binbuilds. With absolute data addresses excluded (they move between builds), the bodies ofQuickEval,get_rabbit_position,copy_res_to_loop,save,__DrawLineandfixup_resare byte-identical in retail v1.1 and community v1.2.5.MutableIdealLine::copy_res_to_loopdoes a flatrep movsdof all 17 dwords, so no field is filtered on the way in either.A 1998 build carries the game's own linker map
ai-tweaker.exe— circulated as an "AI speed tweaker", run with Ctrl-A to tune each AI car per track per difficulty — is not a utility. It is a 1998-dated build of the game itself (Oct 21 1998 08:49:56, 2,404,451 bytes against the shippingrace.bin's 1,314,816), and it has an MSVC linker map embedded in it: 10,414 symbols across 296 object files, with C++ mangled names and the source object each came from. The crash handler's familiar "No mapfile present" line is the shipping build looking for exactly this.This names structures this document had only measured. A sample against its open questions:
symbol object what it tells us parse_wobble(char const*)world:world.objthe .obtwobble record has a dedicated parserWobbleObject::WobbleObject(WobbleData*)world:wob.objwobbles are built from a WobbleDatastructWobble::ResolveExternalImpulsephysics:obstacle.objand they respond to being hit IdealLine::load_res,nearest_node_to,get_nearest_bead,segloop_countai:ideal.objthe .ilireader; its records areILSeg, a position along one isILinePosSphereVolume,CubeVolume,MoveableSphereVolume,CollisionVolume::ApplyForcephysics:volume.objthe .solprimitive classes — and some are moveableBPPFinder::bpp_find(bpp_node*),point_in_poly,test_polyworld:bpp.objthe .bpptraversal, overbpp_tri/bpp_nodeGrafLoad,fixup_graf,fixup_ptr,fixup_objs,cleanup_objs,draw_treeworld:graf.objthe .grfnode graph — these solved it outright (§4.7)GrafLookupDynoModel(int)world:graf.objwhat obj wobble's integer reaches: a model by id (§4.3)PhysTaskFindStaticObject(int),Wobble::Updatephysics:what a wobble's collider is, and how it turns (§4.3) collide_sphere_boxphysics:volume.objasserts "Colliding a dynamic box--not supported": a wobble must be a tube (§4.3)TubeVolume::Setup,BoxVolume::BoxVolumephysics:volume.objthe .solTUBE and BOX fields (§4.8)The addresses are that build's, not
race.bin's, so they do not transfer directly — but the names, the class layouts they imply, and the module boundaries do. Anything in this document still marked unsolved (the.solspatial tail, the.bppwriter) now has a named function to work from rather than a hex dump. That is not hypothetical: the.grfnode graph (§4.7) andobj wobble's integer (§4.3) were both solved exactly this way, by disassembling theworld:graf.objfunctions a crash trace named.The map is not reproduced here: it is derived from a copyrighted binary, and anyone holding a copy can extract it with a regex for
^\s*0001:[0-9a-f]{8}over the file's printable strings.
What the AI actually does with these lines, from the readme of Sucahyo's trkaitweaker —
the only first-hand account of the AI's behaviour recovered so far:
"AI in viper racing driving using path defined either by some ilg or default.ili or rdefault.ili (reverse). This file define path, speed, and direction."
Its two pairs of controls describe the model:
- mult & add — "decide how fast the AI do on straight or on tight corner": the speed field is computed from the path's curvature through a multiplier and an offset.
- forward lookup & backward lookup — "I limit the AI speed based from this two parameter. The AI action will be using the slowest speed needed on this range. For instance, if my algorithm detected a tight corner 20 meter ahead, then it will use that corner speed right now (as brake) if the forward lookup is more than 20 meter. Increase forward lookup if AI braking too late… increase backward lookup if AI accelerate too soon on corner exit."
That is a direct description of the shape fields 12 and 13 carry: a distance ahead and a distance
behind, measured from each station, resetting in blocks. This document had them recorded as
"distances to the boundaries of a segmentation whose rule is not known" — the rule is a braking
lookahead, and the blocks are the stretches over which one corner governs the speed. vrmod
approximates them with distance-to-next-checkpoint, which is why generated lines drive but do not
brake like a shipped track.
He also notes the author-side tool: "AI-tweaker (also known as debug version race.bin ctrl-A)",
which writes aidriver.adr — the single member inside drivers.res that is not an .ilg or .dnt.
Reported, partly corroborated: the same readme says
track.solholds pit markers as the int32 values 1000 (pit_entry), 1002 (pit_exit) and 1004 (pit_reentry), "usually at begining or end of file". Searching the shipped files finds them clustered in the tail region past the primitives on heaven (1×1000, 2×1002, 2×1004 around offset 120k) and nfield (2×1000, 4×1002 around 143k), but not at all on bemidji, hastings, uptown or Kyalami — so the values are real but not universal. He also records that he never worked out how to edit.sol, and thattrack.txtcarries apit_sidevariable.
Field 5 is a corridor half-width, not a sentinel — CONFIRMEDin game. In default.ili and
rdefault.ili it is -20000.0 in every record of every track that shipped with the game, which reads
like an obvious "unset" marker and was recorded here as one. It is not. In track.ild it is never
-20000: it is a per-record distance in metres, either side of the line, inside which the game considers
the car to be on the track.
| Track | track.ild field 5 |
Track | track.ild field 5 |
|
|---|---|---|---|---|
| Kyalami | 12 | jumper, limbo, maxi, Rainbow, Telly, dundas, hastings | 20 | |
| nfield, sunretx | 15 | bemidji, uptown | 30 | |
| kenyon | 25, dipping to 17 for 3 records | heaven | 25, dipping to 15 for 18 records |
The two tracks that vary it do so at a pinch point, which is what identifies the field: it is a driving corridor, not the edge of the asphalt.
The corridor is twice the road's half-width — i.e. the road's full width. Measuring each track's road
texture against its own track.ild gives this cleanly on the two tracks whose asphalt texture covers the
road and nothing else: Kyalami, corridor 12.0 against a 6.14 m half-width (ratio 1.95), and kenyon, 25.0
against 13.20 m (1.89). The remaining tracks reuse their road texture on pit aprons and paddock, so the
same measurement there describes the paving rather than the road and the ratio falls apart (bemidji reads
83 m).
Sizing it too generously is not harmless. The game treats everything inside the corridor as track, so a corridor of 20 m over a 12 m road leaves 14 m of grass either side that the game considers on-track, and a reset drops the car there — on the grass, beside the road, exactly where the game thinks it belongs.
Writing -20000 into track.ild gives every station a negative corridor, so the car is outside it
wherever it stands. The symptom is not subtle and does not look like a line problem: the track loads,
renders and drives, but "press space to reset" appears within seconds of the green flag and resetting
teleports the car off the map. Nothing in the geometry, collision, checkpoints or object table is wrong —
the game is correctly reporting that a corridor of width −20000 contains nothing.
Worth recording as a method note: the
-20000reading came from surveying only the tracks that shipped with the retail game, where the AI lines really are uniform. The community tracks (Kyalami, jumper, maxi, Rainbow, Telly) were built withmkiliccand carry different values in several fields — field 5 is12/20there even in the AI lines, field 7 is sometimesNaN, and field 3/4 is a unit tangent rather than the half central-difference bemidji uses. A constant across one publisher's tracks is a house style, not a format rule.
The stored cumulative-distance field was checked against the actual point-to-point 2D distance across all 21 sample files (7 tracks × 3 line variants at the time) with zero mismatches. Waypoints share the mesh's own coordinate space directly: raycasting path points down onto the track geometry lands the overwhelming majority on the road surface with no axis flip applied.
Treat any claim that Z must be negated "to match exported geometry" with suspicion — that describes a mirroring exporter, not this format. Negating a single axis on export is a reflection, and it is unusually easy to miss: round-trips through the same tool stay exact, and mirrored geometry looks entirely plausible on symmetric subjects like an oval circuit. The reliable tell is rendered text — a mirrored scene renders trackside signage backwards.
The track length the game displays is derived from track.ild, CONFIRMED. There is no stored
length field anywhere in a track archive — <track>.ccs holds AI/handling tuning, english.lng carries
only the unit labels (Miles, Mi, MPH), and no value in any member correlates with track size. The
game computes it, and this reproduces the Track Info screen's figure exactly for all 8 stock tracks:
displayed miles = round( arcLength(track.ild) * 1.0151 / 1609.34 , 1 )
track.ild — not either racing line — is the length-defining one, which identifies it as the
timing/centre line. Solving each track independently for the scale factor that rounds to its displayed
figure leaves a single consistent window of [1.0127, 1.0175) across all eight, so this is one real
relationship rather than a curve fit; the ~1.5% is the true centreline running marginally longer than the
stored waypoints.
| Track | track.ild |
×1.0151 | Game shows |
|---|---|---|---|
| Bemidji | 1.444 mi | 1.47 | 1.5 |
Silverdale (uptown) |
1.854 | 1.88 | 1.9 |
Rock Island (kenyon) |
2.913 | 2.96 | 3.0 |
Dayton (limbo) |
2.964 | 3.01 | 3.0 |
Ridge Valley (hastings) |
3.235 | 3.28 | 3.3 |
| Dundas | 3.710 | 3.77 | 3.8 |
Castlegreen (heaven) |
3.784 | 3.84 | 3.8 |
Sunset Mesa (nfield) |
4.318 | 4.38 | 4.4 |
This doubles as the strongest confirmation that world units are meters: read as feet, Bemidji's lap would be 0.43 miles against the 1.5 the game itself reports.
4.2.3 Some segment must claim the world origin, or the race crashes at the start — CONFIRMED
A generated stadium oval crashed the game when the race began, before any car had moved:
EXCEPTION: Task "BGTask" @ 00422550 : EXCEPTION_ACCESS_VIOLATION
trace: byte 0x40 of "?update@CenterLine@@QAEEABUPoint2D@@H@Z"
trace: byte 0xfc of "?UpdateCar@RaceDeity@@UAEXHPAVCar@@@Z"
The chain, from the symbolised build:
RaceDeity::register_carcallsreset_car, which callsCenterLine::reset. That callsIdealLine::reset_bead_positionfor the point the line last saw the car at. No car has been seen yet, so that point is still (0, 0).reset_bead_positionclears the position's segment pointer and asksget_nearest_pairfor one. A segment qualifies only if the point lies strictly between the perpendiculars through its two ends:dot(tangent, p − start) > 0anddot(next tangent, p − end) < 0, the tangents being fields 3 and 4. Of the qualifying segments, the one nearest by its closer endpoint wins. If nothing qualifies, the pointer stays null.- The next
CenterLine::updatereads[segment + 0x2a](the sector byte) through that null pointer, before anything could repair it.
Strict on both ends means a point exactly on a boundary belongs to neither neighbour. The oval had waypoints every 10 m, straights parallel to the x axis, and a waypoint at x = 0. That put the origin exactly on a boundary of every straight segment, and the curved segments' bands don't reach it. Sliding the loop 5 m along x fixed it. Every stock line claims the origin (checked on all eight tracks), because none is laid out on so exact a grid.
ili.origin_is_claimed() reproduces the lookup, and trackbuild.assemble() refuses to write a
line that fails it.
4.3 STAB — .tab / .obt generic ASCII-record table — 🟡 WELL-SUPPORTED
Both extensions share the identical STAB tag and outer structure — this is one generic "array of
text-encoded records" format used for two different purposes:
0x00 "0SER" + "BATS"(on disk) + int32 version(=0) + reserved + "!IGM" -- envelope
0x14 int32 recordCount (varies per file/track)
0x18 int32 fieldsPerRecord
0x1C int32 (reserved, =0)
0x20 records begin — each record is human-readable ASCII, not raw binary numbers
Confirmed by extracting printable strings directly from the payload:
camera.tab(the track's TV cameras):fieldsPerRecord = 7— a type keyword then six space-padded ASCII numbers. Now fully solved; seecamera.tab— the TV cameras at the end of this section. The earlier reading of the last three fields as "FOV / zoom-speed / hold-time" was a guess and is wrong: for aFIXEDcamera they are an axis-angle rotation in degrees.PAN_ZOOM 185 0.4 -160 0.8 5.0 0.0
The complete .obt grammar — CONFIRMEDfrom race.bin's own parser. The game's format
strings spell out every record it accepts, which is the only documentation this feature has: no
community tool or tutorial mentions it, and wobble appears nowhere outside the track archives and
the executable itself.
obj car <x>, <z>
obj checkpoint <mesh> <x>,<z> <x>,<z> -- mesh is checkpt1.mod
obj obstacle <ball|cube|prism> <mesh.mod> <x>,<z>:<yaw> <mass> -- e.g. ball magma.mod ...:0 10000
obj static <box> <x,y,z> <x,y,z> <x,y,z>
obj wobble <pole|flap> <int>
The obstacle record's comma pair is a GROUND position, and the colon introduces the YAW — x,z:yaw,
the same ground convention as obj checkpoint's two points. The record carries no height at all
(✅ read from parse_obstacle, race.exe 0x463870, and Obstacle::Obstacle, 0x43cd90):
- Height: the engine finds it itself.
TerrainGetHeight(x, z)casts a ray down the collision mesh (.bpp) from y = 1000, and the obstacle is placed at that height, minus the mesh's lowest point, plus 4.0 m. Every obstacle is dropped 4 m at race start, onto whatever the collision surface is at that spot (0 if the ray misses it). - Yaw: the number after the colon is multiplied by π/180 into
MatrixMakeYaw, so it is degrees. Pitch and roll are always 0.
This was misread as a height until 2026-09-24. Heights of 150–170 written there only spun the objects,
which a ball never shows. Three knockable monk cutouts placed on a narrow crater rim then made it
obvious: dropped 4 m onto a crest that tilts toward the crater, they were all in the lava before the
player reached them. Stand an obstacle on ground that is level for a few metres all round, in the
collision mesh and not just the terrain function it was built from (a generator that eases terrain
toward the road can tilt it), and prefer cube for anything that shouldn't roll away. The drop and
the collision box both come from the mesh's extents, so a wide invisible base (a quad mapped to a
keyed-out texel) lets a tall, thin cutout land upright. ✅ Confirmed in game 2026-09-24: two-panel monk
cutouts built that way, as cube at mass 200 on a level pad dished 0.3 m, land and stay put. They also
hop up and down without ever stopping, which the sleep rule below explains.
Written with the ground pair swapped, the engine still parses the line without complaint and builds
the object (the number of objects was %d line rises by exactly the record count), but the object
lands off the map sideways: invisible, intangible, and silent in the log. That cost three in-game
tests to find.
The physics object types it builds from these: Ball, PhobStatic, Obstacle, Wobble,
CheckPoint, PlayCar, AICar, NetCar, GhostCar. How many a track can hold is covered below
(How many objects a track can hold): the Too many wobjects allocated--increase MAX_OBJECTS
message is a check the release build compiles out.
Three things follow that are not visible from the shipped data:
obstacleplaces a named mesh at a position, with a MASS — CONFIRMED IN GAME2026-09-15 and 2026-09-24. Per-instance physics with arbitrary geometry, authored in a filevrmodalready writes: no.sol, no MKWORLD, no scene graph. A generated track carrying 40obj obstacle ball cow.modrecords spawns 40 knockable cows that a car can shove around. It builds anObstacle(physics:obstacle.obj), a free rigid body that drops under gravity.ballgives it a sphere to collide with, and so it rolls;cubeandprismgive it a box and a wedge. So it suits things meant to tumble away, not things rooted in the ground. The mesh is resolved BY NAME from any loaded archive, and a.modmember added to the track's own archive (tagFNIM, version 1, like every other.mod) resolves fine; it must sit at its own origin, since the record supplies the position.
The last number is the MASS, not a radius, and this was misread for a week. parse_obstacle
(race.exe 0x463870) scans obj obstacle %s %s %f,%f:%f %f, stores the last value in the phob's mass
field (+0x08), and builds the three rotational inertias as size² × 10.75 × that value. The
size comes from the mesh (mrModelGetExtents), never from the record. It was first written as a
radius (1.4, 2.0, then 4.0), and in game those objects behaved like beach balls: they rolled, and
bounced away from any car that touched them. Written as 10,000, 8 m magma boulders rolled down a
volcano and crushed the cars they met: a giant horn ball, placed by the track. For scale, the horn
ball's own record (create_ball) gives it a mass of 3000, in the same field.
- static is NOT counted among the objects and produced nothing in the same test, where the
obstacles in the same table worked. Still unexercised.
- flap is a second wobble subtype that nothing ships. Every obj wobble record in every track
is pole.
How an obstacle lives — CONFIRMED IN GAME2026-09-24/25. Read from Obstacle::Update
(race.exe 0x43d2e0) and tested on generated tracks:
- It only simulates while it moves. Any tick where its speed tops 0.5 m/s (|v|² > 0.25), or a
hit hard enough to reach
ApplyExternalForcewith a value ≥ 100, callsPerturb, which restarts a timer. One second after the lastPerturbit zeroes its velocities and stops updating, gravity included, until something hits it hard. So an obstacle that slows down in mid-air hangs there. A tall, thincubeon flat ground never settles below 0.5 m/s, never sleeps, and hops for the whole race (the burning monks on the Burninator track). - It rests on the mesh's lowest point. Both the spawn (ground − lowest point + 4 m) and where it
comes to rest key on the lowest vertex, used by a face or not (
mrModelGetExtents,0x456ec0, reads every vertex). One unused vertex 34 m below a 16 m ball spawned it 30 m up, and it fell until that vertex touched the floor and then stayed there, standing on it like a stilt. That was the same at masses of 40, 400 and 4000; heavier ones only got there faster. So a track cannot raise the drop — the only lever is the 4.0 constant itself (0x4dd0c8), shared by every obstacle on every track. The stilt is useful on its own terms: a prop that hovers at any height and can still be knocked off. - The collision sphere is half the mesh's Z extent (
Obstacle::Obstaclebuilds itsSphereVolumefrom PhobData+0x2c× 0.5), not the Y extent, so it follows the model, not the stilt. - Overlapping spawns are safe. Pairs overlapping 25% and 75%, clumps of ten packed within 0.4 m, and two balls at exactly the same spot all burst apart at race start like billiard balls, with no crash — a free burst effect.
- It moves before the green light. Physics runs from the moment the race loads.
RaceDeity::Reset(0x443070) sets the green at the physics clock + 4.8 s, but in game (a boulder timed on a generated track, 2026-09-25) the green came about 7.9 s after load, so the pre-race intro evidently runs the physics too. Anything on a slope is already rolling when the cars start: time chutes from load, not from the green. - A restart can leave it frozen.
Obstacle::Reset(0x43d3c0) puts the obstacle back at its spawn and zeroes its speed, but leaves the awake flag (+0x4ac) and thePerturbtimer (+0x4a8) as they were. The physics clock (physics_tick) is set only inPhysicsBegin, so it keeps running across a restart. An obstacle that had been at rest for over a second is therefore reset asleep: it hangs at its spawn, 4 m up, until something hits it. Only one still moving within the last second falls again. A fresh load always works, because the constructor callsPerturb. In code the two functions sit side by side,Resetendingret; mov edi, edidirectly beforePerturb, so NOPing those 3 bytes makes every reset fall through into a wake-up. ✅ Confirmed in game. - Obstacles in the race path can crash the game when AI cars race. Three generated tracks have crashed partway through a race with AI cars:
- a ball pit with 200 balls across the whole arena;
- a test track of 30,000 lb boulders rolling down the AI's lane;
- a boulder canyon, around its ambush chutes, where cars were being knocked about and reset.
All three came through the same path, where known: advance_bead +0x43 ← update_car_info ←
AICar::update_line_info ← AICar::Update, an access violation. It unwinds like this:
1. When an AI car is reset, update_car_info (0x4214c0) calls reset_bead_position. That sets the
car's place on its racing line (the "bead", IdealLine +4) to NULL, then asks get_nearest_bead
to find it again.
2. get_nearest_pair (0x4215e0) keeps a segment only if the car lies between that segment's two
node planes. On a closed loop every real position passes. A NaN position fails every
comparison, so nothing is found and the bead stays NULL.
3. The next frame, advance_bead (0x421840) reads through the NULL segment (mov ecx, [esi] at
0x421883) and the game dies.
The car's position comes straight from its physics object (+0x5c/+0x64), which has the same
300 m/s cap as any obstacle. So the NaN must come from a single bad calculation, most likely a
zero-length collision normal, not from a runaway speed. Mass doesn't matter: the ball pit's balls
weighed 40 lb. Without an engine fix, publish such tracks as solo.
The fix: have advance_bead put a NULL bead back at the head of the line (+0x2c, t = 0), and the
AI then finds its place again over the next frames. ✅ Confirmed in game: with it, the boulder canyon
ran with AI and didn't crash. On the v1.0 race.exe the guard fits in place of the function's
32-byte profiler preamble. The race.bin builds (1.0 disc, 1.1, 1.2.4 beta, 1.2.5, 1.2.6) were compiled
without the profiler, so there its first two loads become a jump to a 26-byte stub in the .text slack.
Both forms, and the restart fix above, ship in the patch set (vrmod/enginefix.py).
How an obstacle ball rolls — READ FROM CODEand CONFIRMED IN GAME 2026-09-25.
From SphereVolume::CollideGround (0x4324d0), PhobDyno::Update (0x445210), the PhobDyno and
Obstacle constructors (0x4449c0, 0x43cd90) and parse_obstacle (0x463870):
- The mass is in pounds.
PhobDynostores the record's value × 0.4545 as its mass in kg (+0x1f0) and converts the inertias from lb·ft² (× 0.04228). The inertia works out to mass (kg) × the product of two mesh extents. For a ball that is its diameter squared, so I = 4 m r², ten times a real solid sphere's (0.4 m r²). A ball is very hard to spin up. - The ground is a spring with capped sliding friction. Each tick
CollideGroundfinds how far the sphere has sunk into the collision mesh (TerrainGetSphereIntersection) and applies, at the contact point: - a normal force of
16.35 × record mass(about 36 × kg) per metre sunk, so a ball rides about 0.27 m into flat ground whatever it weighs; - a tangential force of −1000 N·s/m × the contact point's slip, capped at 0.5 × that normal force;
- a normal impulse (
get_impulse_magnitude). - Nothing else slows it down.
PhobDyno::Updateadds gravity and integrates, with no air drag, no rolling resistance and no damping. It caps spin at 10 rad/s and speed at 300 m/s. - The surface makes no difference.
CollideGroundpasses the surface code toApplyForce, andPhobDyno::ApplyExternalForce(0x445970) drops it. The one exception is code 14 (water), which switches to a separate floating path (CollideWater, then a buoyancy force). "Pave the World" only changes the cars' tyre contact. - Nothing spawns moving. The record's third field sets only the yaw (
Obstacle::Obstaclebuilds pitch and roll as 0), andPhobDyno::reset(0x444b90) zeroes every velocity, spin and force. There is no field for an initial velocity or spin.
Because the friction is a fixed 1000 N·s/m and doesn't grow with mass, mass decides how a ball moves down a slope:
- A light ball rolls. The friction easily spins it up, and rolling with I = 4 m r² costs it most of the slope's pull: it accelerates at only about g·sin(slope) / 5.
- A heavy ball skids. The same friction barely turns it, so it slides at close to g·sin(slope), as if frictionless.
- The switch comes at a lower mass on a steeper slope, because the friction needed to keep a ball rolling grows with weight × sin(slope).
- On the flat, friction spins a skidding ball up until it rolls, at roughly a fifth of its speed. That is the slowdown seen on flat ground. It takes longer the heavier the ball, and a ball that is already rolling keeps its speed.
A 1-D model of those rules gives, for an 8 m ball from rest after 400 m of constant slope (mph):
| Mass (record) | 5° | 10° | 15° | 20° |
|---|---|---|---|---|
| 1,000 | 25 | 35 | 43 | 49 |
| 3,000 | 25 | 35 | 43 | 50 |
| 10,000 | 26 | 38 | 47 | 56 |
| 30,000 | 32 | 51 | 67 | 81 |
| 100,000 | 47 | 71 | 89 | 104 |
| 300,000 | 54 | 78 | 97 | 112 |
| no friction | 59 | 83 | 101 | 116 |
Then 300 m of flat takes the 30,000 ball from 51 to 25 mph at 10°, 100,000 from 71 to 57, and 300,000 from 78 to 74. How that lines up with the Boulder Lab test tracks (2026-09-25):
- Lab 1 (masses 1,000 to 30,000 on one 10° slope) showed heavier boulders rolling faster.
- Lab 2 (30,000 on 5°, 10°, 15° and 20° lanes, each dropping the same 120 m, then flat) showed the steep lanes' boulders slowing on the flat while a shallower one, still on its slope, arrived first. The model predicts the 10° boulder arriving first (the tester was unsure whether it was 5° or 10°).
- Lab 3 (the Lab 2 track at 100,000) was built to test the model's one surprising prediction: at that mass the flat barely slows a boulder, so the order should FLIP to steepest-first. It did. The boulders arrived in order of slope, the shallow ones slow to start. The 20° boulder still had good speed at the end of its lane and hit the end wall, and each boulder tossed any car it met.
So for a fast boulder: make it heavy, keep its route descending, and put flat ground only where it should stop.
There is no mass limit. parse_obstacle and both constructors store the value and derive from it
without a single comparison, so any 32-bit float is accepted:
- Too heavy: the inertia (record mass × extent² × 10.75) overflows only past about 10³⁵ for an 8 m
ball.
- Zero or negative: PhobDyno takes 1/mass unchecked, so 0 gives an infinite inverse mass and a
negative value reverses the ball's response to every contact. Neither has been tried.
In practice the speed stops improving at about 300,000, where the ball is already skidding almost frictionlessly. Heavier still only makes it harder to stop: the horn ball (mass 3000) and cars can barely shift it.
How many objects a track can hold — CONFIRMED IN GAME2026-09-25. There are three fixed-size lists, and none of them is checked:
| List | Size | Holds | Past the end |
|---|---|---|---|
physics objects (PhysTaskBegin, 0x426850: MemAlloc(0x800)) |
512 | every car, checkpoint (CHKP), obstacle, wobble and obj static |
writes over the heap: a crash in the physics thread |
world objects (WorldAddWob, 0x462800, at 0x55129c) |
1,024 | every world object | runs onto its own count at 0x5522d0, so the next sort (obj_sort_f, from WorldBeginCommon) reads garbage |
graphics objects (WorldAddGob, 0x4627c0) |
1,024 | every drawn object | — |
The "Too many (w)objects allocated" messages are ASSERT_MSG calls, and in the release build
ASSERT_MSG (0x462750) is a bare ret. The physics list fills first, so it's the one that sets
the budget. A white room with 8 cars, 4 checkpoints and a field of beach-ball obstacles:
| Balls | Physics objects | Result |
|---|---|---|
| 100–500 | up to 512 | fine |
| 510, 520 | 522, 532 | crash |
| 600 | 612 | crash in the physics thread (BGTask, executing at a heap address) |
| 1000 | 1,012 | crash in obj_sort_f, the world list having overflowed too |
So cars + checkpoints + obstacles + wobbles + statics must not pass 512, and even ten over is
fatal. .sol primitives are not in that array: the 500-ball room also carried 62 wall boxes
and ran, where counting them would have put it 62 over. Keep one spare for the horn ball: create_ball goes through the same PhysicsCreate path, so
a track filled to exactly 512 may overflow when someone honks (not tested). For a full field of 8
cars and 4 checkpoints, that leaves 499 obstacles and wobbles combined.
obj wobble's integer names the .sol TUBE with that id — CONFIRMEDfrom the shipped data.
Every tube carries an id at +0x30: -1 on ordinary ones, and 0…n-1 on exactly n of them, where
n is that track's wobble count. Measured across all six tracks that ship wobbles — hastings 15/15,
uptown 47/47, kenyon 59/59, heaven 72/72, nfield 50 ids among 299 tubes, dundas 170 among 349 — with a
perfect one-to-one match every time. The game confirms the binding from the other side: a generated
track carrying wobble records builds WobbleObjects from them rather than rejecting them.
A wobble's MODEL is a type-4 .grf node — CONFIRMED IN GAME2026-09-15. The binding runs end
to end, and every link of it is read off the loader rather than inferred:
obj wobble pole N -> WobbleData +0x18 = N
-> WobbleObject::WobbleObject calls GrafLookupDynoModel(N)
-> returns dyno_model_table[N] (512 entries, at 0x559168)
-> stored at WobbleObject +0x3c
-> WobbleObject::Draw calls mrModelDraw(handle, Frame at this+4)
That table is filled at load by fixup_objs (§4.7), which registers every node that is type 4 and
carries 4 at +0x38, filing its built handle under the id at +0x48. A duplicate id is fatal
("two models with same id"), as is one outside 0..511 ("wrong facing id"). So a wobble needs three
things agreeing on one integer: a .sol TUBE with id N, an obj wobble pole N record, and a
registered type-4 node with id N. Its centre equals the tube's position exactly — 0.000 m apart on
all 15 of hastings' — and the model hangs upward from it in local −z.
So a reader has to stand it up. The corners are stored in the model's own frame, lying on its back, and
the engine turns it upright to draw it: world offset = (x, −z, y) for a stored (x, y, z), about the centre.
grf.parse does this for every registered facing, and grf.to_bytes turns it back. Until 2026-09-23 it
didn't, so the viewer, the track thumbnails and the gallery drew every wobble already knocked over.
The records it rotates are exactly the registered ones, which is the wobble count on every stock track
(check_track_formats.py). Uptown's four 3 m posts sit about 0.18 m below their pivot, planted on a
sloping road; that is authored, not a misread.
Checked against the shipped data: registered type-4 nodes equal the wobble count on every track that
has them — hastings 15, nfield 50 (of 284 type-4 nodes; the other 234 carry 2 at +0x38 and are
never registered), uptown 47 of 48. Then checked from the other side by building one: a generated
track carrying eight authored wobbles loads, drives, and the cows topple.
Both earlier theories failed for one underlying reason, and are now explained rather than merely
refuted. "The model is any chunk with a non-zero centre" (bemidji has 51 such chunks and no wobbles)
and "placed models are the type-2 records" (hastings has 27 against 15 wobbles) were artefacts of
locating records by resync scanning, which lands 24 bytes early on a type-4 node and reports its
ptr2 slot as its centre. Type 2 is not a placed model at all: it is an LOD node, whose two floats
at +0x18/+0x1c the loader squares in place at load (with -1.0f as a skip sentinel) so range tests
can avoid a square root.
Two model styles both work. A real 3D mesh (the 328-vertex SoSC cow) and a flat billboard each
build and topple. The largest facing in any shipped track is 28 vertices, but that is chevrons being
chevrons rather than an engine limit — the same mrModelBuildLit path builds 1,200–20,000-vertex car
models. A flat board is the more forgiving style where a wobble should flatten as well as fall; a 3D
mesh stays rigid.
Two traps worth naming. The static scenery at a wobble's position must be removed: left in, a solid mesh stands in the same spot and body-blocks its own wobble, so there is nothing to hit. And ids are not decoration — hand them only to wobble tubes, as every shipped track does, since a tube claiming id N with no wobble N is a dangling claim on that slot.
obj obstacle remains the simpler route where an object should tumble away rather than stay rooted: it
needs no .grf node, no .sol tube, and no id.
How a wobble moves — CONFIRMED IN GAME2026-09-17. Read from Wobble::Wobble and Wobble::Update
(race.exe 0x43d400 / 0x43d4a0, in the symbolised 1998 build, so they won't match race.bin),
then tested with purpose-built targets on a generated track:
Wobble::Wobble PhysTaskFindStaticObject(N) -- scans EVERY .sol primitive for id N at +0x30,
whatever its type
copies that primitive's frame (matrix + position) as its rest pose
points the primitive's collision volume at the wobble's own frame and body
Wobble::Update runs only while touched: a count at +0x470 (contacts, by its use) arms a 20-step timer
zeroes the linear velocity and puts the position back every step
multiplies the angular velocity by 0.95 every step; zeroes it when the timer ends
stops the tilt where the model's up axis is 85 degrees from vertical
So a wobble never moves, it only turns, about its collider's centre, which is also the model's origin and the facing's centre. Four consequences, with what the game showed for each:
- The pivot can be anywhere, including in mid-air. Stock always puts it at the foot, so the collider is half buried. A target head hinged 1.4 m up on a scenery stick tips back off the stick.
- Only a hit away from the pivot can turn it. The push goes along the contact normal, and the sideways part is thrown away. A head pivoted at its own centre on a near-spherical collider was rigid in game: on a sphere every contact normal passes through the centre, so no hit has any leverage. A foot-pivoted post works because a hit up the post is well above the pivot.
- It stops where the hit leaves it. It turns while its spin decays and freezes when the timer runs
out. Gravity can't pull it on over.
PhobDyno::Updateapplies gravity as a plain downward force (mass × −9.81) at the body's origin, which is the wobble's pivot, so it has no leverage, and the wobble then discards all movement.TubeVolume::CollideGroundis a bareret, so a wobble never touches the ground either. The hinge test's knocked head stopped part-way over. - The collider must be a TUBE. The lookup would bind a BOX (it doesn't check the type), but
collide_sphere_boxopens withASSERT_MSG("Colliding a dynamic box--not supported")and applies its impulse to the sphere only. In this build the assert does nothing, so the collision runs and the box just stands like a wall. Tried in game: square panels on box colliders drew and blocked, and never fell. Tubes (collide_sphere_cylinder) push both bodies.
Stock signs are posts. Every wobble on nfield (Sunset Mesa), hastings and kenyon is a TUBE, 124 in
all. The collider is sized to the post, not the sign, and a sign that faces some other way is rotated
in its vertices. The tube's frame is always TUBE_MATRIX_WOBBLE:
| stock sign | drawn | collider radius, reach |
|---|---|---|
nfield's 2 m square signs (1/2/3.tex) |
2.00 wide × 2.02 tall | 0.25 m post, 2.28 m |
chevrons (Rtbig.tex) |
1 m board, 2 m tall | 0.5 m, 2.5 m |
| pole-mounted signs | 1 m board on a 3.2–4.7 m pole | 0.05 m, the pole's top |
A car finds a post easily. A horn ball has to hit the post itself, 0.5 m across under a 2 m sign, and the face of the sign lets it straight through.
Three ways to build a knock-down sign, all driven in game with a 1.2 m head on a stick:
| design | the wobble is | collider | in game |
|---|---|---|---|
| stick | head + stick, pivot at the foot | a 0.1 m-radius post up to the head's top | head and stick fall together; only the stick is solid |
| wide | head + stick, pivot at the foot | as wide as the head, ground to top | easy to hit, including the face; the air beside the stick is solid too |
| hinge | the head only, pivot at its bottom edge; the stick is render-only scenery | as wide as the head, reaching its top | knocks back off the stick, and the stick stays up |
The hinge's capsule is symmetric about the hinge, so it is as solid below the head as it is tall. A low hit there should tip the head over toward the hitter; that follows from the geometry and has not been tried. A head pivoted at its own centre (the fourth variant) is the rigid case above.
Fitting targets to the horn ball. The ball is thrown level along the car's own forward axis, from 0.5 m above the car's origin, so on flat ground it can only hit a band of height a little under that (runtime.md §3). Measured, it arrives 0.6–0.8 m off the road. A 2 m-high sign is out of reach without a wheelie or a crest: heads 1.4–2.6 m up needed one.
A hinged head works at any hinge from 0.2 m to 1.0 m (driven in game, 1.2 m heads, collider from the ground to the head's top). Hinged below the ball's path, a head tips back when hit; hinged above it, the ball strikes the capsule below the hinge and the head tips forward. Either way it visibly goes over, which is what a target needs. The direction tells you where the ball was.
Size for the throw speed, too. Physics runs in 16 ms ticks and the ball jumps between them, so a fast ball can pass through a thin collider without touching it (runtime.md §3). A 1.2 m head is safe head-on up to about 4.2× stock throw speed from a standstill, and less once the car is moving. Bigger colliders take faster throws, and so would a bigger ball: its 0.457 m collision radius is a constant in the engine (runtime.md §3). A bigger ball spawned clear of the road flies higher, so fit the targets to it: its centre must cross their colliders' straight side, well above the hinge.
track.obt(placed-object table):fieldsPerRecord = 1in every sample (i.e. one big text field per record),recordCountmatched the number ofobj ...string occurrences exactly. Real extracted records frombemidji/track.obt— this is the starting grid and a checkpoint gate, verbatim:obj car 204.725693,-152.139893 obj car 194.410416,-150.993958 obj car 204.890503,-141.454208 obj car 194.507629,-122.365044 obj car 194.615616,-141.775482 obj car 204.594894,-131.746994 obj car 194.791122,-131.580704 obj car 204.299088,-122.033829 obj checkpoint flag 163.682877,-169.747467 233.451126,-166.574600 obj checkpoint flag -159.395172,-190.721024 -229.369766,-192.606476Also present:out\track.txt,map test.map,ai test.vf— these read like leftover provenance/ comment strings from whatever internal tool authored the original per-track source data, not consumed data. Records are stored in a fixed-size padded buffer (~257–261 bytes per record across every track sampled — remarkably consistent for what's ostensibly a variable-length string, suggesting a fixedchar[256]-ish struct field rather than a packed/length-prefixed string).
This is genuinely one of the more modder-friendly formats in the game — the payload is literally text.
<prefix>L.tab(per-car LOD table): anotherSTABuse,fieldsPerRecord = 3, one record per LOD level. This is what drives the car'sViper0.mod…Viper7.moddetail chain (§4.1). Loaded bysub_00466E70, which builds the name"%sL.tab"(0x4DF594), reads it, storesrecordCounton the ModelInfo and parses each record's fields (atofon field 0). If it can't be read the game logsCan't load LOD table %s(0x4DF59C). The three fields per record are [switch distance (metres), a numeric flag, a render-options string]. RealviperL.tab(v1.2.5), extracted verbatim — 8 records for the 8 meshes:
LOD0 Viper0 10 0 alpha spec <- highest detail; alpha + specular on
LOD1 Viper1 15 0 alpha spec
LOD2 Viper2 20 0 alpha spec
LOD3 Viper3 60 0 <- alpha/spec dropped from here out
LOD4 Viper4 80 1
LOD5 Viper5 100 1
LOD6 Viper6 200 x <- flag "x" on the two farthest levels
LOD7 Viper7 1000 x <- last level; beyond this the car is not drawn
The distances are the FAR bound of each level, in metres (the game's one world scale, §6). Selection is
by camera distance, not by which screen you are on: the same distance test governs every 3D context
that draws the car through the standard object path — so a car 5 m ahead in a chase cam is LOD0-1 and the
same car 300 m down the straight is LOD7, regardless of view. A global "LOD Factor" scalar scales the
whole curve (default 1.0); a hidden debug overlay exposes it (?LOD Factor: %4.1f / [=down ]=up
'=1.0, at 0x40D3D0), so the effective switch distance is table_distance × LOD_factor. The flag and
options fields are read but their exact effect (mip/shadow toggle; alpha spec gates alpha-blend +
specular on the near levels) has not been byte-traced here. The alpha spec reading is independently
corroborated by the scene, though, which is why it sits at ✅ rather than 🟡 — see the note below.
Making a car shiny at distance — Sucahyo's finding. The community worked this out from the other
end. A note preserved with e86mrsL.tab records what Sucahyo posted on the VRgt forum:
"Edit your
carnameL.taband add alpha spec (transparent and specular/shiny) numbers on all view distance… The structure ofcarnameL.tabis rather similar with [NASCAR Heat's]: view distance (feet), tire model used (0 or X), what effect activated."
His own e86mrsL.tab shows the technique, and reading it beside the stock table makes the effect plain:
stock viperL.tab Sucahyo's e86mrsL.tab
10 0 alpha spec 10 0 alpha spec
15 0 alpha spec 50 0 alpha spec
20 0 alpha spec 250 0 alpha spec
60 0 <- dropped 1250 0 alpha spec
80 1 5000 x
100 1
200 x
1000 x
Stock cars drop alpha spec beyond the third level, so a Viper goes matte past about 20 m. Putting
it on every drawn level keeps the specular highlight all the way out. That is a second, independent
reading of the same field — he calls it "specular/shiny", arrived at by experiment rather than by
disassembly — and it agrees with what the loader appears to do.
Tried in game, and the result was inconclusive. A control install and one with
alpha spec on all eight levels were built and compared (vrmod can rewrite the field:
the STAB header gives record stride 0x43 and field offsets 0x11/0x22, so only 5
fields change and the archive repacks byte-identical apart from that member). On a
modern GPU and display no clear difference was visible at distance. That does not
refute Sucahyo — the effect is a 1998 specular path, and what was obvious on period
hardware may simply not survive modern drivers and rendering. Recorded so the next
person does not repeat the experiment expecting a dramatic result.
Two incidental points from his note. The record count is genuinely per car, not fixed at 8: his
table has five records for a five-mesh conversion. And he reads the distance column as feet — but
that is an assumption carried over from NASCAR Heat, which his note says the format resembles. This
document reads metres, and that is the better-supported figure: §6 establishes one shared world
scale in metres across meshes, track geometry and AI paths, confirmed by raycasting path points onto
the road surface. The bands also match in-game measurement (runtime reference §1). So: metres, and
his feet is inherited from the wrong game. Modding note: the
stock chain is real reduced-detail meshes; the old community "LOD hack" of copying Viper0 into all of
Viper1..7 satisfies the loader but gives zero performance benefit, since every level is then
full-detail. Genuine decimation (see vrmod moddecimate) is what actually helps a heavy custom car in
the near-camera pack.
camera.tab — the TV cameras — CONFIRMED IN GAME
Read by load_tv_cameras() (physics:phystask.obj), which parses the file as a generic
StringTable and fills a static TVCamera[32] at 0x520cb8, count at 0x521084.
header int32 recordCount, int32 fieldsPerRecord (=7),
int32 x 7 the byte offset of each field within a record
int32 x 9 zero
int32 record size (67), at header offset 72
records begin at 76
record field 0 type name, 13 bytes
fields 1-6 numbers, 9 bytes each, space-wrapped and NUL-padded
TVCamera +0x00 int32 type +0x04 x +0x08 y +0x0c z +0x10 a1 +0x14 a2 +0x18 a3
The type is matched case-insensitively against fixed, pan, pan_zoom, chase —
anything else is LogPanic("Unknown camera type %s"). Across all eight shipped tracks:
99 pan_zoom, 10 fixed, 2 pan, and no chase at all. chase parses but
update_camera has no branch for it (cmp 2 / cmp 1 / else skip), so it falls through
with an identity matrix — parser-supported and unimplemented, like obj static and the
flap wobble.
Fields 1–3 are a position in the GAME frame, NOT flipped. That is the trap: obj
checkpoint and obj car in the .obt — the same STAB family, often the same archive —
store negated coordinates, and these do not. Measured across all eight tracks, cameras
sit a median 20–34 m from the racing line with 111 of 111 inside the track's bounding
box; negated they scatter hundreds of metres out.
Fields 4–6 are an axis-angle rotation in degrees, for fixed only. update_camera
multiplies all three by π/180, takes the vector's length as the rotation angle and its
direction as the axis, and builds a Rodrigues matrix — so they are not three Euler
angles. Forward is column 2 of that matrix, which for the near-vertical axis every
shipped camera uses reduces to (sin yaw, 0, cos yaw): a compass heading with 0° looking
+Z and 90° looking +X. A zero triple takes the VectorLength < 0.0001 path to
MatrixMakeIdentity, so it looks exactly +Z.
Confirmed three independent ways: read off the disassembly; checked against all ten
shipped fixed cameras, which aim at their own racing line to a 6.7 m median (worst
13.3 m — about half a road width, where you would aim to frame a car); and in game, where
a camera built with yaw +90 looks along +X and one with (0,0,0) looks along +Z.
Free-roam readings taken from the engine's own HUD (§8 of runtime.md) confirm the
axis-angle reading directly — holding a heading near +90° while pitching moves fields 1
and 3 in equal and opposite amounts, which is an axis swinging out of vertical, not a
pitch field changing:
( 0, 90, 0) -> heading +90.0 pitch 0.0
( 33, 86, -33) -> heading +90.6 pitch -42.1
(-37, 87, 37) -> heading +93.4 pitch +47.1
For pan and pan_zoom the last three fields mean something else entirely. A
tracking camera derives its aim from the car, so update_camera uses +0x10 as a gain
and +0x14 as a distance threshold: it measures how far the car lies along the view axis,
subtracts the threshold, multiplies by the gain and dollies the camera along its own aim
vector by that much. +0x18 is never read for these types, and is 0.0 in all 101 of
them. pan skips the distance term and applies the gain alone. Nearly every shipped
pan_zoom carries (0.8, 5.0, 0.0); only nfield varies it.
Selection scans from index 1, keeping the nearest camera by squared distance — so
slot 0 is never chosen automatically, and every shipped track puts a fixed camera
there. And the 32-slot array is not bounds-checked by the loader, which trusts
StringTableNumRows; more than 32 records would overrun it. nfield ships the most, at 20.
vrmod/camtab.py reads and writes these, with aim() for the awkward part — computing
the rotation that points a camera at a target, height included. Note a direction does not
determine a rotation uniquely (any roll about the view axis aims the same), so aim()
returns the minimal rotation and the game's own free-roam numbers will differ while
looking at the same thing.
4.4 CCS0 — .ccs — CONFIRMED— a car setup
Revised, and the earlier reading was wrong. This section previously called the semantics unconfirmed and guessed the filenames "read like nicknamed track sections/corners". They are not track data at all.
race.exe's only reference to the0SCCtag is insideCarFileLoadSetupRes(char const*) -> CarSetupData*(world:carfile.obj), which checksversion == 2andsize == 0x8cand thenrep movsdall 35 dwords straight into aCarSetupData. So.ccsis a saved car setup,288g_sim/lanc_sim/vipe_simare cars, andaidef.ccsis the setup the AI runs at that track — which is exactly why it varies track to track while every car ships an identical one.
The values are slider positions, not physical quantities. CarFileCombine consumes the struct
with the same shape about twenty times over:
fld [cf + HIGH] ; a maximum from the car's .cf
fsub [cf + LOW] ; minus its minimum
fmul [setup + N] ; times .ccs field N <- 0.0 .. 1.0
fadd [cf + LOW]
fstp [CarData + ..]
That is lerp(min, max, field). It explains why every value except the gearbox sits in 0.0–1.0, and
it means a setup is meaningless without the car it was made for: the same 0.5 is a different
spring rate on a different .cf. The garage shows some fields as the raw value × 100 and others as
the interpolated physical value — both conventions appear on screen.
0x00 "0SER" + "0SCC"(on disk) + int32 version(=2) + reserved + "!IGM" -- envelope
0x14 35 × float32 -- the payload the loader copies verbatim, 0x8c bytes
| # | meaning | shown as |
|---|---|---|
| 0 | final drive | lerp(rear_end_ratio1..2) |
| 1 | drivetrain scale — 1.0 in every shipped file |
— |
| 2 | brake bias | (v − 0.5) × 100 with an F/R suffix |
| 3, 5, 7, 9, 11 | not physics — see below | never displayed |
| 4, 6 | bump, front / rear | × 100 |
| 8, 10 | rebound, front / rear | × 100 |
| 12, 13 | anti-roll, front / rear | × 100 |
| 14, 15 | springs, front / rear | × 100 |
| 16, 17 | ride height, front / rear | lerp(*ground_clearance1..2) |
| 18, 19 | spoiler size, front / rear | × 100 |
| 20–25 | gear ratios 1–6 | as stored |
| 26 | a 7th gear slot — 0.0 everywhere; nothing ships 7 gears |
— |
| 27 | an 8th gear slot, 2.66 in every shipped file; the physics never reads it (§5.4, gear_ratio8) |
— |
| 28 | aero kit, an int enum: 0 Adjustable · 1 Low Drag · 2 High Downforce | radio buttons |
| 29 | fuel load | × fuel_capacity |
| 30, 31 | camber, front / rear | lerp(*camber1..2) |
| 32, 33 | toe, front / rear | lerp(*toe1..2) |
| 34 | wheel lock | × wheel_lock |
Fields 3, 5, 7, 9 and 11 never reach the car. CarFileCombine never reads them and
export_setup never prints them; the only code that touches them is ChassisControl::Draw, which
loads them beside the literals 40, ±25.0 and ±1000.0 — a plot of 40 samples over those axes.
They are the shock-curve graph's own state. They do vary between shipped tracks, so they are live
data, but changing one cannot alter how a car behaves.
How this was confirmed — three independent ways. The field list above came from disassembling
CarFileCombine; it was then checked against export_setup (world:mcar.obj), which writes a
labelled HTML table and independently agrees on every offset; and finally against a real saved setup,
with all 25 displayed rows reproduced exactly from the raw bytes through the map and the car's
own .cf. The garage's four tabs corroborate 26 of the 35 directly.
.csu — a saved setup — is the same 35 floats, unwrapped. Config\setups\<track>.csu is 212
bytes: the 140-byte payload, int32 version(=1), int32 size(=212), then 64 bytes for a description
string (blank in a default save). That trailing description is the difference between CarSetup and
CarSetupData in the symbol names. export_setup also writes a human-readable
Config\setups\<track>.html beside it, which is the single best oracle for this format.
Still true from the earlier pass, and worth keeping: all 5 car archives ship a byte-identical
payload (retail never differentiates a car's default setup); the per-track aidef.ccs files are all
distinct; and Kenyon is the only track shipping more than one named setup — four of them, which now
reads as four car setups rather than four corners.
The aero kit is where the shipped setups disagree, and it reads as deliberate. Four player setups
carry a non-default kit — bemidji, hastings and uptown on high downforce, dundas on
low drag — while all eight aidef.ccs files and every car default stay on adjustable. Dundas
is the fastest circuit in the set and the one that gets low drag; the three on high downforce are the
tighter ones. That is the field doing exactly what its name says, on evidence entirely independent of
the garage screen.
A trap worth recording. An earlier pass called fields 26–29 "hard constants … likely schema/version markers". That came from unpacking all 35 slots as
float32. Field 28 is an int32, and read as a float its values 1 and 2 are denormals that print as0.000— so a field that varies across four shipped tracks looked constant.scripts/check_ccs.pynow asserts its domain is{0, 1, 2}rather than asserting it never changes.
0x00 "0SER" + "0SCC"(on disk) + int32 version(=2) + reserved + "!IGM" -- envelope
0x14 35 × float32 -- payload; all values fall in ~0.0–3.3 range
Confirmed structural facts (decoded by directly diffing real payload bytes across every retail archive):
- All 5 car archives (
viper/sedan/sports/exotic/plane) ship a byte-identical.ccspayload — every one of the 35 fields matches exactly across every car. Retail never differentiates this file by car. - The shared per-track
aidef.ccsgenuinely varies, track to track — all 8 are distinct. - Kenyon (
kenyon.trk, in-game "Rock Island") is the only track with more than one zone-named.ccsfile — it has 4 (288g_sim.ccs,kenyon.ccs,lanc_sim.ccs,vipe_sim.ccs) versus 1 for every other track. - Fields 20–25 (a contiguous 6-value block) form a strictly monotonically decreasing sequence in
every real sample checked — the shared car-default payload, all 8
aidef.ccsfiles, and all 4 of Kenyon's zone files (13/13). Some tracks reuse one specific sequence identical to the car-default payload (Kenyon among them); others (Bemidji, Dundas, Hastings, Heaven, Limbo, Nfield, Uptown) each carry their own distinct values in that block. - A handful of fields (indices 1, 3, 26, 27, 28, 29) are hard constants across every retail sample checked — likely schema/version markers rather than live data.
Individual field semantics are not decoded. In-game differential testing (swapping .trk-bundled
.ccs variants and diffing visible behavior) was inconclusive: the eight retail tracks differ in too many
fields at once for any single field's effect to be isolated. Further work here is deferred until
purpose-built test tracks and tooling make controlled, single-variable comparisons possible.
4.5 TEX — .tex texture — CONFIRMED(opaque, colorkey, mip chain layout; full-alpha 🟡 well-supported)
The header, as race.exe reads it — CONFIRMEDfrom the v1.0 loader.
txLoadSystem(0x45fd10),txSelect(0x4601d0) andload_texmap_mip(0x460820) are the only readers, and every retail texture agrees with them. Two corrections to earlier readings of this section: the wrap mode is byte 0x16, not 0x17 — 0x17 is the de-res priority — and format 0x03 is two textures in one file, not a four-byte pixel format.Format 0x03 is a dual file. A keyed RGB565 payload is followed by a whole second payload in ARGB4444, whose own header says format 0x04. On a card with alpha textures the loader skips to the second half (
load_file, 0x461790:payload + size/2); otherwise it uses the first. That is why 0x03 is exactly twice the size of the other formats (it also never ships above 128×128):
base size formats 0x00 / 0x01 / 0x02 format 0x03 16 748 1,496 32 2,796 5,592 64 10,988 21,976 128 43,756 87,512 256 174,828 never ships ARGB4444 on its own is 0x02. Writing one ARGB4444 payload under 0x03 makes the game read the "second half's" format byte from the middle of the pixels, and it panics with
tmap: unknown texture format— a load-time crash, not a visual fault.vrmodwrites 0x00, 0x01 and 0x02; it cannot write the dual layout.Wrap 0 never caused that panic. The rule "a new texture needs wrap 1, wrap 0 makes the game reject it" was recorded when the alpha encoder still wrote 0x03; the fix that seemed to cure it also switched opaque images to format 0x00. A later track with alpha textures hit the same panic, it persisted after their wrap byte was changed, and writing format 0x02 cured it. What
vrmodcalled wrap was byte 0x17, the de-res priority, where 0 is the commonest retail value. New textures still get 0x16 = 0 and 0x17 = 1 — the combination confirmed in game, and the oneasph.texcarries.Byte 0x17 across a full install, by format: 0x00 takes 0 (×563) or 1 (×118); 0x01 takes 0 (×95) or 2 (×1); 0x02 takes 0 (×18) or 1 (×2); 0x03 takes 0 only (×78).
Two limits that are not in the file format, and both fail silently.
Names are 8.3. All 244 distinct texture names in a full install are at most 12 characters — eight plus
.tex(asphalth.tex,pine3o15.tex,bboard01.tex) — and not one contains an underscore. This is not the archive's limit: a.tradirectory entry has a 16-byte name field, and a 16-character texture name packs, lists and reads back correctly through every tool here. In game the surface using it renders as flat untextured colour, with no crash, no warning and no placeholder.Nothing is larger than 256. Across those same 880 textures the sizes are 16, 32, 64, 128 and 256, and nothing above. An oversized texture does not look wrong or load slowly — it does not draw.
Both were found the same way, and only after a track that raced perfectly rendered in flat colour: by surveying what the game ships rather than validating what a writer produced. A texture that round-trips byte-exactly through
tex.parse/tex.encode_to_texcan still be one the game will not draw.
0x00 "0SER" + " XET"(on disk) + int32 version(=3) + reserved + "!IGM" -- envelope
0x14 byte format (vrmod calls it `flags`; it is an enum, not a bitfield):
0 opaque RGB565, 1 colour-keyed RGB565, 2 ARGB4444, 3 dual (see above),
4 the ARGB4444 half of a dual file. Anything else: "tmap: unknown texture format".
0x15 byte use mips: 0 loads the top level only (still full size), non-zero loads the whole
chain. 0 on sky1-4, the career-menu Viper1-7, envmap, uptown's 2dtele/oo1-4 and
the Viper's damage textures (Viperd*, Viperw, viperd); 1 on everything else.
0x16 byte wrap mode, per axis: 0 wrap/wrap, 1 wrap U + clamp V, 2 clamp U + wrap V,
3 clamp/clamp; anything else panics "unknown wrap mode" when drawn. Retail: 0 except
27 keyed textures with 1 (oo1-4, frt1-5, round, won, cactus, 3ter, heads …).
0x17 byte de-res priority: non-zero keeps one more mip level when the game runs out of
texture memory and de-rezes; 2 also drops the top level on cards with under 8 MB.
Retail: 0, 1 (asph, check, strpy and other tiling road surfaces, the Viper1-7 skins,
effects) or 2 (Viperd1-4, Viperw, wheels).
0x18 u16 **the COLOUR KEY** — the exact raw texel value to render as transparent; only
formats 1 and 3 use it. Arbitrary in retail: in 44 of 79 keyed files it is not the
1×1 texel. See the note below.
0x1A u16 zero
0x1C int32 mip level count n; base size = 1 << (n − 1) (9 → 256px, 8 → 128px, 7 → 64px)
0x20+ the mip chain, smallest first. Every level below 4×4 sits in a 32-byte slot with a
4-texel row pitch: the 1×1 texel is at 0x20 (the rest of its slot is zero), the 2×2
level's slot follows at 0x40, then 4×4 at 0x60 and each larger level tight-packed,
the base (full-resolution) level LAST.
There is no separate width/height field. An earlier pass at this treated the bytes at 0x14 as a
packed int16 w, int16 h pair, since the two 256×256 samples checked at the time (asph.tex,
strpy.tex) happened to share the same 4-byte value there — that was a coincidental fit for a square
texture, not a real field. They are four separate bytes: asph.tex is format 0x00, use mips 1,
wrap mode 0, de-res priority 1. Width/height come from the level count instead.
Base-level pixel format, CONFIRMEDbyte-exact: 16-bit little-endian RGB565, row-major, with the
base (full-resolution) mip level stored last in the chain, at payload offset
payloadLength − width×height×2. Confirmed by producing a full conversion from a real .tex file and
diffing it byte-for-byte against a reference decode of the same file — whole-file identical,
all 65,536 pixels exact. The 8-bit channel expansion has a specific quirk worth calling out: it's plain
left-shift, not the more common bit-replication —
r8 = r5 << 3
g8 = (g6 & 0x3E) << 2 -- note: the low bit of the 6-bit green sample is unused; green is
effectively 5 significant bits, like red/blue, just packed in a 6-bit slot
b8 = b5 << 3
That last detail (green's dropped low bit) only became clear by empirically deriving the raw-value →
output-value mapping from real pixel data — the more "obvious" bit-replication formula
((g6<<2)|(g6>>4)) gets every pixel close but never exact.
Full-alpha pixel format, 🟡 WELL-SUPPORTED: format 0x02 textures, and the second half of a dual
0x03 file, use the same 16-bit-per-pixel budget, but packed as ARGB4444 instead of RGB565:
a8 = a4 << 4
r8 = r4 << 4
g8 = g4 << 4
b8 = b4 << 4
Confirmed by round-tripping a synthetic test image (solid color, a linear 8-step alpha ramp) through the
reference encoder in alpha mode and decoding the result: every R/G/B nibble matched channel8 >> 4
exactly, every A nibble matched the input ramp exactly, and the whole mip chain — including the 1×1
texel at 0x20 — downsamples by consistent pairwise averaging at every level.
Most retail alpha textures are dual (0x03) rather than 0x02 — the encoder would not produce one from
/a plus /t, falling back to colour-key-only (0x01). Decoding a dual file as ARGB4444 from its end
works because the base level found there is the alpha half's: ddg.tex and vip.tex come out clean
(a Dodge logo over a mesh grille; a Viper script logo). The remaining 🟡 is only that the synthetic
round trip is the sole byte-exact check of the packing.
Colorkey-only textures (flags 0x01, bit0 set / bit1 clear), CONFIRMED: plain RGB565, byte-for-byte
identical to the opaque encoding, with exactly one special case — raw value 0x0000 is reserved as the
"this pixel is transparent" signal. Any real (opaque) pixel that would naturally quantize to 0x0000
(any color whose R/G/B all round down to 0, not just literal black — e.g. RGB (1,2,3)) gets nudged to
0x0040 instead (the smallest possible perturbation: green's LSB) so it doesn't collide with the
transparency marker.
Important correction —
0x0000is the encoder's convention, not the format's rule. That is what the reference encoder emits in colorkey mode, and it is why an implementation that hardcodes0x0000appears to work. Real retail content does not follow it: shipped track textures carry an arbitrary transparent value in the header at 0x18, and it matches the image's dominant background exactly —3ter.tex0x526A(covering 51% of the image),bnch.tex0x7B2A,fount.tex0x8410,orange.tex0x62A8,redwh.tex0x6B6D. A decoder that only treats0x0000as transparent leaves those backgrounds fully opaque, so every cutout billboard — trees, banners, fences, signs — renders as a solid rectangle (3ter.texalone is used by 191 chunks of one track). Read the colorkey from 0x18 and treat both it and0x0000as transparent.Modding gotcha — black reads as transparent in game even in an opaque texture (flags
0x00). Confirmed in game (2026-09-07), and the behaviour is not gated on the colorkey flag. Both0x0000(source RGB0,0,0) and0x0020(source RGB7,7,7) render invisible, while0x0841(RGB8,8,8) is solid.Predicate narrowed (2026-09-13). This section originally read the rule as "5-bit red AND 5-bit blue are both zero — a non-zero green does not save it", from three solid-colour samples. That is too wide.
0x0040also has red and blue at zero, and 1,162,454 texels across a full install were moved to0x0040and confirmed solid in game — cockpit gauges, dark car panels and dark signage all rendering correctly afterwards. The rule that fits every observation is simply:A texel is keyed when its DECODED colour is exactly black. Green occupies a 6-bit field whose low bit is not significant (the decoder masks it off, see above), so exactly two raw values qualify:
0x0000and0x0020.0x0040— the next green step, RGB(0, 8, 0)— is not black and is not keyed.This matters for anything that tries to fix it: sweeping only
0x0000leaves 75,839 texels of the other kind behind in a full install, every one of which the marker still catches.Whether you see it at all depends on the graphics card. The same files render blacks differently on AMD and Nvidia — reported from running the game on both. The bytes are identical either way, so the divergence is downstream of the file: either the driver keying black unconditionally, or the game leaving a legacy Direct3D colour-key render state enabled and the two vendors emulating it differently. Not resolved which. The practical consequence is that this is not a data fault you can find by reading files, and equally not something to write off as unfixable in data — the driver decides whether black is keyed, the file decides whether any texel is black for it to key. See runtime §7 for the in-game symptoms and
vrmod dekey, which lifts every such texel to0x0040across an install, leaves colorkey and alpha textures alone, and is reversible.Two consequences for tools and modders: - Hazard: a region that quantizes to black will vanish unless you want a hole. Lifting any one channel off zero is enough — RGB
(0, 8, 0)→0x0040is the minimum and is whatdekeyuses; RGB8,8,8→0x0841is the safer, equally invisible choice if you would rather not depend on the green field's masking.vrmoddoes this automatically when it synthesizes a flat color from an OBJ material'sKd(a near-blackKdwould otherwise import as an invisible part). - Intentional use: conversely, painting a region pure0x0000black is a valid, flag-independent way to punch a transparent hole through a piece — the same effect shipped content gets via the header colorkey, but available with no mode/flag change. Importing a texture keeps its real pixels, so this is unaffected by theKd-swatch nudge above.
Confirmed cleanly: encoded a 16-color test image in colorkey mode covering
red/green/blue/white/black/gray/magenta/cyan/yellow and several near-zero colors, and diffed it
pixel-for-pixel against a plain (non-colorkey) encode of the identical image. All 16 pixels matched
exactly except the two that quantize to 0x0000 in the plain version, both nudged to 0x0040 in the
colorkey version — 16/16 predicted vs. actual, then independently re-verified by building a working
decoder+encoder from the rule and round-tripping the same test image. (An earlier, messier test that
varied a source alpha channel instead of RGB color produced a confusing, position-correlated pattern
unrelated to this rule — /t behaves differently, or extra, when the source has an alpha channel at
all; not investigated further. A plain 24-bit, no-alpha source is the reliable case.)
Full mip chain layout, CONFIRMED: the earlier "~50-byte unexplained gap" between where a naive tight-packed pixel-count sum would place the base level and where it actually starts is fully solved. The chain isn't uniformly tight-packed:
[start] 28 bytes, always zero — reserved/unused. The 1×1 mip level is NOT stored here or
anywhere in the chain; its only storage is the duplicated header fields above
(payload offsets 0x18 and 0x20).
the 2×2 mip level, padded into a 4×4-pixel-sized slot (32 bytes): the 4 real pixels
occupy the top-left 2×2 of what would be a 4×4 image (row-stride 4 pixels), the
other 12 pixel slots zero-filled.
every level from 4×4 up to (but not including) the base level, tight-packed, in
ascending size order.
[end] the base (full-resolution) level, tight-packed, LAST.
This reconciles the gap exactly, algebraically, for any image size: naive tight-packing assumes
1×1(2B)+2×2(8B)+⟨tight 4×4-and-up⟩ = 10B + ⟨tight 4×4-and-up⟩ before the base level; the real layout is
28B(reserved)+32B(padded 2×2 slot)+⟨the same tight 4×4-and-up⟩ = 60B + ⟨same⟩ — a constant +50 bytes
regardless of texture size or mip count. Verified against four samples spanning 8×8 to 256×256 (a
synthetic 8×8 file plus real 64×64/128×128/256×256 textures) — the predicted vs. actual base-level offset
matched exactly in every case, and this layout was then used to build a working encoder and verify it
against a real .tex file (see below), which is a much stronger confirmation than offset arithmetic alone.
Encoding (pixels → .tex), CONFIRMEDfunctionally correct: re-encoding asph.tga (itself derived
from asph.tex) reproduces a file the same size as the real original, with the base level decoding to
pixel-for-pixel identical colors. It is not literally byte-identical — about half the raw 16-bit
samples differ from the real file by exactly one bit (green's low/"don't-care" bit, the same one the
decode formula already masks off, §4.5 above) — the real encoder sets that bit via some rounding rule
that provably has zero effect on the decoded color, and wasn't chased further. Box-filter (simple
average) downsampling was used for the smaller mip levels; this matches the real encoder's box-average
values everywhere it could be checked, though the exact rounding/dithering choices at the very smallest
levels aren't independently confirmed byte-exact the way the base level is. The full-alpha (ARGB4444)
encode direction round-trips within the expected 4-bit quantization tolerance.
4.5.1 sky1-4.tex — a track's sky — CONFIRMED(layout, from the bytes) / 🟡 rendering behaviour observed in game
Every track archive carries exactly four sky textures, sky1.tex … sky4.tex. They are ordinary
TEX resources (§4.5) with nothing structurally special about them — opaque, no colorkey, wrap = 0,
with a full mip chain. Size is not uniform: all eight stock retail tracks use 128×128 tiles (a
512×128 strip), while most community add-on tracks use 256×256 — which is itself evidence the game
accepts more than the stock content uses. What is worth recording is how the game uses them, because
it is not what the file layout suggests.
They are four consecutive slices of one panoramic strip, not four independent backdrops. Seam
continuity between adjacent slices — including sky4 → sky1, which closes the loop — measures a mean
edge difference of only 1–7 out of 255 across every stock track. Composited in order they give a
1024×256 image whose bottom edge is the horizon.
The strip spans roughly 90° and the game repeats it FOUR TIMES around the horizon. It is not one full turn, despite closing seamlessly as though it were. Two independent confirmations:
- Observed in game: turning a full circle shows four copies of any unique feature in the strip. Note this is a behavioural observation, not a fact recoverable from the files — nothing in the archive records how many times the strip is wrapped. It was seen on a patched install using an add-on track, because that track's sky carries an unmistakable landmark to count; re-checking it on an unpatched retail install would upgrade this from observed to confirmed.
- From the art alone: a strip feature that is nearly twice as wide as it is tall renders round in game. A perspective camera preserves angular aspect, so a single 360° wrap would require the strip to stand ~148° tall — past the zenith, which is impossible. Four repeats resolve it.
The practical consequence for authoring a sky: what you draw is a 90° quarter-view that will be seen four times. It must tile seamlessly left-to-right, and anything distinctive in it — a sun, a landmark — appears four times around the horizon. The stock skies are built accordingly.
Vertical placement is the one part not pinned down numerically. The strip's bottom edge sits at the horizon, but its top edge angle could not be measured reliably from screenshots, because nearer terrain occludes the strip's base — so the visible ground/sky boundary is not the horizon, and landmark heights measured against it come out wrong. Matching a known feature's rendered aspect is the workable approach; it survives unknown field-of-view and unknown resolution, where absolute positions do not.
4.6 STMP — .stp 2D sprite/"stamp" — CONFIRMED(uncompressed variant; a second, compressed variant remains ❓ UNKNOWN)
The game's general-purpose 2D image format, and by count one of the most common resources on the disc
(~211 instances). It carries all UI art (ui.res, common.res, race.res, postrace.res,
paintkit.res), every track's in-game minimap (Trackmap.stp), the per-track menu screenshot shown on
the track-select screen (bemidji.stp, dundas.stp, … inside ui.res), and chekbg.stp/chekfg.stp (the
checkpoint banner, which lives inside dundas.trk).
0x00 "0SER" + "PMTS"(on disk) + int32 version(=3) + reserved + "!IGM" -- envelope
0x14 int32 width
0x18 int32 height -- PER FRAME, not the height of the whole stored image
0x1C int32 hotspot X (cursors only; 0 otherwise — e.g. ball.stp is 12×12 with hotspot 6,6)
0x20 int32 hotspot Y
0x24 int32 frame count -- frames are stacked VERTICALLY, so the decoded image is
width × (height × frameCount). `awards.stp` is 32×32 with 6 frames,
i.e. a 32×192 strip.
0x28 256 × int16 RGB555 palette
0x228 256 × int16 RGB565 palette -- the same palette again in the other 16-bit display format of
the era. Either decodes correctly; pick one and expand 5→8 bits by
left-shifting 3 (which yields 248, not 255, for full white).
... an unidentified region (1,512 bytes for a 180×120 stamp) — does not need to be understood
to decode the image, because:
END the pixel data occupies the LAST `height × frameCount × rowStride(width)` bytes of the payload,
i.e. it is anchored to the END of the file, not to a fixed offset. 8-bit palette indices,
stored TOP-DOWN.
Row layout — the part that takes real care. A scanline is not a flat run of width bytes. There is a
pad byte at every row-stream offset that is a multiple of 128 (offsets 0, 128, 256, …):
rowStride(w) = w + 1 + w/128 (integer division)
offsetOf(column k) = k + 1 + (k+1)/128
So a 180-wide row occupies 182 bytes, laid out as pad + 127 indices + pad + 53 indices.
Two failure modes here are worth calling out because both look like success:
- Decoding with no leading pad at all produces an image that is recognisable but wrong at column 0 and the last few columns.
- Decoding with only the leading pad (missing the every-128-bytes rule) produces an image that is visually perfect except for a single bright vertical stripe at column 127, and moves the mean error against a reference decode by so little (10.5 → 4.5 on a 0–765 scale) that an automated check will not flag it. This was caught by eye, not by metric.
The compressed variant — ❓ UNKNOWN. 149 of the 211 stamps on the disc decode with the layout above.
The other 62 — including Trackmap.stp and most UI buttons — have payloads far too small for it
(Trackmap.stp is 300×151 = 45,300 pixels in an 11,674-byte payload, ~0.26 bytes/pixel), so a second,
compressed encoding exists. There is no flag distinguishing the two in the header fields above; the
practical test is whether the computed pixel-block start is plausible.
Loose .stp files. The engine will also read a .stp as a loose file in Data/ rather than from an
archive, provided it is truncated so the file begins at !IGM — i.e. the 20-byte envelope reduced to
its last 4 bytes. A loose 180×120 screenshot is therefore 24,400 bytes (4 + 24,396). Add-on tracks use
this to supply a menu screenshot (§9).
4.7 GRAF — .grf track/world geometry — CONFIRMED(99.95% of the payload accounted for)
Revised. This section previously called the scene-graph header ❓ UNKNOWN and treated
.grfas the one format whose structure was opaque — the reason the writer is a patcher rather than a builder. That was too pessimistic. Measuring a freshly compiled.grfaccounts for 790,272 of 790,676 payload bytes, and what remains is 404 bytes of per-chunk header that reads cleanly. The detail is under The payload is almost entirely accounted for below.
The main track mesh + material file, and one of the largest per-track resources (259 KB–749 KB in the
sample set). Envelope is 0SER/FARG/version=3/!IGM.
A .grf is a sequence of chunks, and each chunk is structurally a .mod mesh (§4.1): vertices, then
material records carrying sub-ranges, then faces. Verified byte-for-byte against independent ground truth —
per-object reference meshes extracted from the same archives, which give each scenery object as a
standalone .mod and so allow every field below to be checked against real position/UV/face data rather
than inferred.
CHUNK = center record + N corner records + M material records + F face records
center record (16 bytes) -- the object's placement/pivot in world space; (0,0,0) for an object
modelled at its final position, non-zero for a template instanced at
many spots (e.g. the repeated crowd billboards)
+0 float32 × 3 center X, Y, Z
+12 int32 usually 0, but NOT reliably (real tail chunks carry e.g. 54 — do not validate on it)
corner record (32 bytes) -- one per face-corner, NOT deduplicated (a quad's two triangles yield 4)
+0 float32 × 3 position MINUS center
+12 int32 0 (constant)
+16 uint32 baked vertex colour -- RGB VARIES per real shading; only the alpha byte (0xFF) is constant
+20 int32 0xFF000000 (constant)
+24 float32 × 2 U, V -- this corner's own, un-shifted, NOT v-flipped
material record (32 bytes) -- identical to .mod's
+0 null-terminated texture name (may be EMPTY: a leading NUL is legitimate and occurs in real
files, e.g. b"\x00exy.tex" — treat the name as a label, never as a validity test)
+24 int16 × 4 vertexStart, vertexEnd, faceStart, faceEnd
face record (8 bytes)
+0 int16 × 3 corner indices, LOCAL to the chunk, same winding as .mod
+6 int16 0 pad
The material chain is the authoritative validator. Material i's *_end equals material i+1's
*_start, and the last one's vertexEnd equals the chunk's corner count. That property both proves a
candidate chunk is real and computes its exact end, so chunk boundaries are derived rather than searched
for. A chunk routinely carries several materials; assuming one texture per chunk silently discards
every secondary material's geometry, which is enough to make whole categories of scenery invisible.
The main header is a scene graph, and it is now mapped — CONFIRMEDfrom the loader. GrafLoad
calls fixup_graf(GrafNode*, void* base, unsigned char), which runs two passes over the loaded image:
fixup_ptr turns stored offsets into real pointers, then fixup_objs builds each node's model. Both were
disassembled from the build carrying the embedded linker map (§4.2), so the fields below are read rather
than inferred.
every node
+00 int32 type -- 0, 1, 2, 3, 4 and 7 occur
+04 int32 FIRST CHILD -- offset from payload start; fixup_ptr RECURSES into it
+08 int32 NEXT SIBLING -- same; fixup_ptr ITERATES it in a loop
+0c int32 model handle -- -1 in the file; the loader overwrites it
type 2 -- an LOD node
+18 float switch distance, SQUARED IN PLACE at load
+1c float second distance, likewise; -1.0f (0xbf800000) means "skip"
types 3 and 4 -- geometry. +10 begins an mrModelInfo: a .mod payload (§4.1), field for field
+10 count, +14 ptr vertices, 32 bytes each
+18 count, +1c ptr materials, 32 bytes each
+20 count, +24 ptr faces, 8 bytes each
+28 count, +2c ptr 16 bytes each -- 0 in every chunk measured
+30 count, +34 ptr (last)
every ptr is 0 in the file; the loader fills it, packing the arrays
consecutively from +38 (type 3) or +4c (type 4)
type 4 only -- a "facing": a model the engine registers and can draw by id
+38 int32 4 registers it; 2 does not
+3c float×3 centre, world space
+48 int32 id, 0..511
A registered facing's handle is filed in a 512-entry table at 0x559168, which GrafLookupDynoModel(int)
reads — the mechanism behind obj wobble (§4.3). cleanup_objs mirrors it, destroying the handle at
+0c for types 3 and 4 only. Node types 0, 1 and 7 are walked by the same child/sibling threading but
their payloads are still undecoded; nothing yet needed them.
Practical consequence — writing. Editing values in place needs none of this and remains the safe
default: chunk extents are computed exactly, parsing reaches end-of-payload precisely, and an unmodified
round-trip is byte-identical on all 8 stock tracks and every add-on tested (§9). But nodes can now be
appended. vrmod writes new facing models into a generated .grf by building the node and hanging it
off the root's +08 — the field the loader iterates rather than recurses, so any number of them costs
no stack. Getting that wrong is instructive: an early attempt put a face count at +08, and the loader
added the load base to it, called the result a node, and died dereferencing it inside fixup_ptr.
The resync trap. Locating records by scanning for a plausible centre-and-corner pattern lands 24 bytes early on a type-4 node, reporting its
ptr2slot as its centre. Two theories about wobble models (§4.3) were built on that error and both were wrong. Walk the chain by pointer instead — our own writer's output is walkable directly, with no scanning at all.Negative zero. Real corner records store
-0.0offsets. Since parsing computesposition = center + offset, a-0.0yieldsposition == center, and writing backposition - centergives+0.0— the same number with different bits, and hundreds of spurious diff bytes per file. An in-place writer must compare by value and skip unchanged fields rather than comparing bytes.
track.grf is not always the whole visible scene. Some tracks carry standalone .mod entries in the
same archive that must also be drawn (dundas has a ground.mod; four tracks carry a checkpt1.mod). Note
checkpt1.mod references .stp materials rather than .tex, and dundas's ground.mod has junk in the
head of its face array — since .mod face indices are read as signed int16, junk values above 32767
come back negative and sail past an upper-bound-only range check.
The payload is almost entirely accounted for
Measured against a track compiled today, so these are current numbers rather than 1998 ones:
| region | bytes | share |
|---|---|---|
| corners — 19,754 × 32 | 632,128 | 79.9% |
| faces — 19,740 × 8 | 157,920 | 20.0% |
| materials — 7 × 32 | 224 | 0.03% |
| remaining | 404 | 0.05% |
The 32-byte corner record decodes completely. Only the leading position was previously identified, which made the other 20 bytes per vertex look like opaque scene-graph data — and at 19,754 corners that alone accounted for "half the file being unknown". It is not scene-graph data:
+00 float3 position, relative to the chunk centre
+12 u32 0
+16 BGRA vertex colour -- baked lighting
+20 u32 00 00 00 ff, constant
+24 float2 UV
The colour reads as colour and not as a float because of how it varies: a generated track whose geometry
is flat has exactly one distinct value across all 19,754 corners (ff ff ff ff, white), while bemidji
has 36 distinct values and dundas 46 — greys like fd fd fd ff and b3 b3 b3 ff, always with alpha
ff. That is baked vertex shading, and it is why a generated track can simply write white.
The per-chunk header is 56 bytes and carries the counts. For the first chunk of a track whose chunk 0 holds 2,822 corners and 2,820 faces:
+00 i32 3 chunk type/version
+04 i32 225916 size or offset of what follows
+0c i32 -1
+10 i32 2822 corner count
+18 i32 1
+20 i32 2820 face count
+24..+38 zero
Both counts appear verbatim. The file header at payload +0 has the same shape, with the first chunk header beginning at +8.
✅ .grf can now be written from nothing — grf.build(). The 404 bytes were the per-chunk header,
and correlating it across 553 chunks of a compiled track resolves every field. The one that mattered is
+04: it holds the offset of the next chunk header, terminated by 0. The chunks are a linked list.
+00 i32 3 chunk type
+04 i32 offset of the NEXT chunk header, 0 at the end
+08 i32 0
+0c i32 -1
+10 i32 corner count
+14 i32 0
+18 i32 1
+1c i32 0
+20 i32 face count
+24 3f chunk centre -- zero in every chunk nhmkworld writes
A chunk is that header, then corners (32 bytes each), then its material record (32), then faces (8 each:
three u16 indices local to the chunk, then a zero). The material record is the texture name NUL-padded,
with the corner and face counts repeated at +26 and +30. The file header is i32 0, i32 0, i32 12 — 12
being the offset of the first chunk.
Checked against nhmkworld's own output for the same 553 meshes: the same chunk count, the same payload
size to the byte (915,668), and an identical vertex set of 21,896 positions. It is not byte-identical
and will not be — nhmkworld reorders vertices within a chunk, so the same geometry lands in a different
order — so the test asserts the geometry rather than the bytes. ✅ A track carrying a .grf built this
way loads and drives in game, which is the check that counts.
⚠️ One limitation. The chain built here is flat: every chunk points at the next. That is what nhmkworld emits for a track made of many small uniform meshes, and such a track renders correctly. The shipped tracks are not flat — bemidji has 446 chunks but only 2 in its chain — so there is a nesting this does not reproduce. It has not been needed for generated tracks, but a general-purpose writer would have to understand it.
4.8 SOLB — .sol — CONFIRMED— the track's collision solids (one enum field aside)
Envelope 0SER/LBOS/version=2/!IGM. Not a mesh: an array of collision primitives — boxes, spheres
and tubes — which is the complement to .bpp's triangle soup (§4.9). Static world surfaces live in the BSP;
discrete objects live here.
header (20 bytes)
+00 u32 n_primitives
+04 u32 n1
+08 u32 0 +0c u32 0
+10 float?
primitive x n_primitives (224 bytes each)
+00 float3x3 orientation
+24 float3 position
+30 i32 id -1 throughout the stock tracks
+34 FourCC type 'BOX ', 'SPHR' or 'TUBE'
+38 ptr runtime, repaired at load
+3c ptr a REAL VTABLE ADDRESS, serialised into the file
+48 float 50000.0 class default, identical in every record
+4c float 0.2 class default, identical in every record
+50 FourCC the type again
+54 i32 100
+58..+64 four floats -- extents, type-specific (below)
+68 i32 0..3 for BOX
+6c..+dc zero on BOX and SPHR; a TUBE's two cap spheres (below)
n1 x 2 bytes -- primitive-index list (see below)
tail -- spatial index (partially mapped)
Per-type record layouts (profiled across all eight tracks; +00..+2f is the same
orientation-matrix + centre preamble for every type, and +48/+4c are the compiled-in
50000.0/0.2 class defaults in all three):
BOX (1,739 records) +58 +5c +60 half-extents along local x, y, z
+64 the LARGEST of the three
+68 i32 0..3 (small enum, meaning open; 3 dominates, 1,382 of 1,739)
SPHR (87 records) +00..+20 identity orientation (a sphere needs none)
+24..+2c centre +58 radius (0.56 .. 6.6)
TUBE (931 records) +14,+1c +-1 orientation terms (axis-aligned)
+24..+2c centre
+58 radius (0.05 .. 5)
+5c HALF-length (1 .. 30); the cylinder runs -h..+h along local z
+60, +a0 two whole cap SphereVolumes, 64 bytes each:
+00 vtable +0c/+10 the 50000.0/0.2 defaults +14 'SPHR'
+18 100 +1c radius (= the tube's)
+20 local centre (0, 0, +h) / (0, 0, -h)
+2c a cached world centre, set to the tube's own position
Solids work at any height and angle — CONFIRMED IN GAME2026-09-25. A primitive is a centre,
half-extents and a full orientation matrix, so nothing ties a box to the ground. sol.box_from_segment
with a long segment 7 m up, a 1 m "height" and a 26 m "thickness" makes a flat slab, and appended to a
generated track's .sol (then build_spatial_index again), it works as a ceiling. A car drives
underneath it; a car sent up with the wheelie hack crashes into it from below. It isn't a drivable
surface turned over: .bpp holds one surface per point (§4.9), so a surface above the floor isn't
possible there, and a solid carries no surface type. Whether a car can drive on top of one, as a
bridge, is untested. The tyres probe .bpp, so probably not properly.
Every one of these is a serialised CollisionVolume starting at +0x3c, which is how the fields were
read: the volume's +0x1c onward is the file's +0x58 onward. BoxVolume's constructor (race.exe 0x433990)
takes three half-extents and also stores their maximum. That's why a wall's half length "appears twice":
along is a wall's longest side, and it holds on every stock box. So +0x58 is a wall's half thickness,
0.05 m (10 cm walls) on every hastings box. TubeVolume::Setup (race.exe 0x433830) writes the radius and
half-length into the cylinder and each cap in one go, and every one of the 931 shipped tubes agrees
with it. A tube is a capsule: collide_sphere_tube tests the cylinder, then both caps.
An earlier vrmod read +0x5c as a tube's radius. Wobbles it built were posts with the donor tube's
radius (0.23 m from bemidji), capped 5.75 m above and below. They were hittable by a car and hard for a
ball. sol.tube_at now writes every field a tube's geometry depends on, and check_sol.py holds both
layouts against every stock track.
Read from the loader at 0x42FEE0, which is where the sizes come from rather than from guessing:
lea edx,[esi+0x14] gives the 20-byte header; shl ecx,3 / sub ecx,eax / shl ecx,5 computes
n_primitives * 7 * 32 = 224-byte records; lea ecx,[edx+eax*2] steps a 2-byte table; and the
per-record loop advances by add ebx, 0xe0 — 224, confirmed from the loop itself.
Section 2 — the primitive-index list (n1 × u16). Every entry is a valid primitive index (all in
[0, n_primitives), all primitives referenced, many-to-one — bemidji has 382 entries over 135 primitives).
This is the broadphase reference list: the spatial cells name the solids they contain by index here.
Confirmed against a freshly compiled .sol, not just shipped ones. Running MKWORLD over a scene
with 1,210 inline wall quads produced 1,201 BOX primitives — very nearly one per quad, so the compiler is
not merging colinear walls the way one might assume. Every field above read correctly at the documented
offsets, which is the strongest available check on §4.8: the layout was derived from the loader, and it
holds for a file written today rather than in 1998.
The same run says something new about the tail spatial index, and it rules out the obvious guess:
| track | primitives | n1 |
tail bytes |
|---|---|---|---|
| bemidji | 135 | 382 | 10,248 |
| kenyon | 170 | 403 | 12,808 |
| heaven | 440 | 1,838 | 32,648 |
| dundas | 602 | 1,498 | 26,984 |
| fresh compile | 1,201 | 1,899 | 10,952 |
The freshly compiled track has more primitives than any shipped track and nearly the smallest tail. So the tail is not sized by primitive count — it is sized by how the solids are distributed. That fits a spatial subdivision: our walls run in a thin ribbon along a single path and occupy few cells, while heaven's are spread across the map. Anyone finishing this section should treat it as an occupancy structure over space, not an array over primitives.
These are serialised C++ objects. The file contains live vtable pointers from the machine that built it,
which is why the loader re-establishes them per type: it dispatches on the FourCC to 0x42D9D0 (BOX),
0x42C360 (SPHR) or 0x42D760 (TUBE), each of which writes the current build's vtable, then calls through
it. An unrecognised tag is not rejected — the record is disabled by setting +0x30 = -1.
⚠️ The physical properties are compiled in per type, not stored per object. The BOX constructor passes
constants 1.0 and 0.6 to a shared initialiser; +0x48 (50000.0) and +0x4c (0.2) are class defaults
that got serialised, and they are byte-identical across 2,670 records in every stock and community track
examined. So you cannot make an individual solid heavier or bouncier by editing .sol. Making one
knock-over-able is done elsewhere: an obj wobble N record in the .obt claims the TUBE whose id is
N and drives it as a rigid body (§4.3). Only a TUBE can take this: the engine refuses to push a box.
Section 3 — a loose quadtree over the XZ plane CONFIRMED(from the walker at 0x421180). An array of
8-byte nodes:
+0 u16 first -- start index into section 2
+2 u16 last -- end index (exclusive); the node's primitives are section2[first:last]
+4 u16 child -- index of the FIRST of four contiguous child nodes; 0 = leaf
+6 u16 (unused, always 0)
The query walks it point-first: at each node it tests the query against every primitive in
section2[first:last] (via that primitive's vtable), then, if child != 0, picks one of the four children
child+0..child+3 by which side of the region's X and Z midpoints the point lies, and descends. It is
"loose" because a node may hold primitives and have children — a solid too big for one quadrant sits in the
parent. This is the broadphase that keeps a collision query from testing all n_primitives solids.
Verified across four tracks: from the root, 100% of nodes are reachable, none malformed, every range lies
within section 2, and the node count is exactly 4 × internal + 1 — the signature of a quadtree where every
internal node has four children. That relationship, plus last reaching exactly n1, means the whole .sol
file is now accounted for: header, primitives, the section-2 index list, and this tree.
⚪ Only one field remains unexplained: BOX's +0x68 small enum (0..3).
4.9 BPPT — .bpp — CONFIRMED— the track's collision BSP
The single largest file in every track (1.0 MB–2.4 MB, dwarfing even .grf) and, until this pass,
completely unexplained. It is a triangle soup plus a 2D BSP tree over the XZ plane — the structure the
physics uses to answer "what is under / in front of the car". Envelope 0SER/TPPB/version=2/!IGM.
header (20 bytes)
+00 u32 n_triangles
+04 u32 n_nodes
+08 u32 0
+0c u32 0
+10 u32 root node index
triangle × n_triangles (56 bytes each)
+00 float3 normal unit length
+0c float d plane, with the convention n·v + d = 0
+10 float3 v0
+1c float3 v1
+28 float3 v2
+34 u32 flag per-triangle surface bits (see below)
node × n_nodes (28 bytes each)
+00 float a
+04 float b splitting line in the XZ plane: a·x + b·z + c
+08 float c
+0c i32 tri0 triangle index, -1 = none
+10 i32 tri1 triangle index, -1 = none
+14 i32 less child node index, -1 = null
+18 i32 greater child node index, -1 = null
The layout accounts for every byte of all eight stock tracks, exactly — 20 + 56·n_triangles +
28·n_nodes == filesize, with no slack and no padding:
| Track | Triangles | Nodes | Root | Size |
|---|---|---|---|---|
| bemidji | 10,431 | 34,712 | 4,561 | 1,556,092 |
| dundas | 11,120 | 37,020 | 1,920 | 1,659,300 |
| hastings | 12,694 | 41,334 | 11,690 | 1,868,236 |
| heaven | 7,299 | 22,449 | 3,429 | 1,037,336 |
| kenyon | 13,640 | 43,577 | 10,531 | 1,984,016 |
| limbo | 10,153 | 34,171 | 3,525 | 1,525,376 |
| nfield | 16,525 | 53,378 | 15,507 | 2,420,004 |
| uptown | 12,092 | 43,078 | 11,250 | 1,883,356 |
The triangle record verifies as an identity, not a correlation. Across bemidji's 10,431 records the
normal is unit to six decimal places — min and max both 1.000000 — and all 31,293 vertices satisfy
n·v + d = 0 to a maximum residual of 0.00006. The opposite sign convention (n·v − d) gives a mean
error of 124, so the sign is settled too.
✅ Round-trips byte for byte. vrmod/bpp.py parses and rebuilds all eight stock .bpp files to
identical bytes — the same standard every other confirmed format in this document is held to. tri0/tri1
and the flag ride along as opaque integers, so the round-trip holds whether or not their meaning is known.
vrmod bppinfo <track> summarises one; vrmod bpp2obj <track> <out.obj> exports the collision mesh;
vrmod bppsurface <track> lists the surface codes, and vrmod bppsurface <track> --set 10=20 rewrites them
(here grass→dirt), optionally within an XZ --region. Because the round-trip is byte-exact, retagging
touches only the one flag field per triangle — the collision shape is untouched, so it cannot introduce
the invisible-wall problem above; it only changes how the car behaves on contact (drag, dust, float).
The triangle flag is a SURFACE CODE, and the original build tools name the values. The community track-tools archive ships the Monster Games pipeline with worked example inputs, and those inputs set the codes in plain text:
modobject(road3.mod, 0, 0, 0, 0)
modobject(grassin3.mod, 0, 0, 10, 0)
modobject(grasswayout3.mod, 0, 0, 16, 0)
modobject(roads.mod, 0, 0, 0, 0) ;driveable on
modobject(grasses-stikky.mod, 0, 0, 10, 0) ;driveable on
modobject(grassout-bumpy-not-stikky.mod, 0, 0, 16, 0) ;driveable on
modobject(2dplanet2x.mod, 3, 0, 0, 0) ;not hittable-code3
The third parameter is the surface code written into every collision triangle the object generates, and it
matches the values measured in the shipped .bpp files exactly:
| code | meaning | stock triangles | stock tracks |
|---|---|---|---|
| 0 | asphalt / road ✅ (confirmed from engine code) | 72,103 | 8/8 |
| 10 | grass, "stikky" (grippy); also infield | 19,081 | 8/8 |
| 14 | water ✅ (confirmed from engine code) | 251 | ridge-valley, rock-island, silverdale |
| 16 | rumble strip / side apron — bumpy, low grip | 737 | castle, silverdale |
| 20 | 🟡 dirt / loose surface (strongly indicated, not proven) | 1,033 | dundas, ridge-valley, silverdale |
| 22 | ⚪ unknown | 225 | ridge-valley, silverdale |
| 12 | ⚪ unknown (own handler) | 192 | sunset-mesa only |
| 23 | 🟡 shares code 20's handler | 328 | sunset-mesa only |
| 3 | ⚪ unknown (as a surface code) | 4 | ridge-valley only |
| 13 | 🟡 grass variant (shares grass's handler) | — | none; one community track only |
Corroborated against 160 community tracks
A community corpus of 160 tracks (Val's collection, 459,535 collision triangles) was profiled for surface codes. It uses the same palette and adds exactly one code the shipped tracks never use (13, in a single track), which is itself evidence that the code space is small and fixed.
Code 14 is water, identified geometrically rather than from a tool, because water is the one surface that must be flat and at a single height. In silverdale its 30 triangles are 100% horizontal across just two distinct heights; rock-island's 132 are 88.6% horizontal, ridge-valley's 89 are 83.1%. Road, by contrast, is 4–27% horizontal over thousands of distinct heights. It appears in exactly three shipped tracks — ridge-valley (confirmed in game: the car bobs and floats), rock-island (an island), silverdale — and the community corpus settles it beyond argument. Ten community tracks use code 14, and the names alone tell the story: Oceans9, X-Isle, Carrier1, Airfields, Dawn, High12, Lucas, Redvers, DDhigh13, RT-high14. Oceans9 is 99.9% code 14 — a boat-racing track made almost entirely of water, whose readme complains that "the other racers' boats seem to sit at the start line". X-Isle's readme credits an "island raceway idea".
✅ The engine confirms 0 and 14, via the "Pave the World" hack
The Options → Hacks menu has a Pave the World toggle which removes the drag that grass and dirt apply to the car. Following it settles two codes from the engine itself rather than from tooling.
The config key pave_the_world (0x4D3140) registers the variable 0x4EF838; 0x40D170 is the accessor
hacks_enabled && pave_the_world; and it has exactly one caller, inside the contact-point physics at
0x443148:
cmp dword [edi+0x19c], 0xE ; is this contact's surface code 14?
je skip ; if so, leave it alone
call is_pave_the_world
test al, al
je skip
mov dword [edi+0x19c], ebx ; otherwise overwrite it (ebx = 0, from xor ebx,ebx at 0x44308D)
So [edi+0x19c] is the surface code of a contact point, carried from the .bpp triangle into the
physics. Three things follow directly:
- 0 is asphalt. It is what "paving" writes, and the same function initialises the field to 0 at
0x442EF0. - 14 is water, confirmed from code. It is the single value the hack refuses to overwrite — you cannot pave water, and the car must keep floating. The engine special-cases exactly the code the geometry and the community corpus both pointed at.
- The surface code is the mechanism behind the "stikky" naming. Paving is implemented by rewriting the surface code to asphalt, which is why the hack removes the drag from every off-track surface at once rather than adjusting a friction constant.
✅ The engine's full surface-code table
0x4435A0, in the same contact-point physics, dispatches on the surface code through a two-level table:
mov ecx, [edi+0x19c] ; the surface code
cmp ecx, 0x17 ; 23 -- the maximum valid code
ja default
mov al, byte [ecx + 0x444368] ; 24-entry table: code -> case
jmp dword [eax*4 + 0x444344] ; 9-entry table: case -> handler
The code space is 0..23 and closed — which is why the whole 160-track community corpus never produced a value outside it. Nine cases resolve to six distinct handlers, and the grouping answers several open codes by showing which behave identically:
| Handler | Codes | Effect type | Drag A | Drag B |
|---|---|---|---|---|
0x4435E9 |
0 (asphalt) | 0 | — | — |
0x443622 |
10, 11, 13, 15 | 5 | 0.8 | 2.5 |
0x44364E |
12 | 5 | 0.9 | 0.1 |
0x443667 |
20, 23 | 5 | 0.9 | 0.1 |
0x443693 |
21 | 5 | 0.9 | 0.5 |
0x4435BA (default) |
1–9, 14, 16, 17–19, 22 | 5 | 0.9 | 0.1 |
- 13 is a grass variant — it shares grass's handler exactly. That resolves the one code the community corpus used which the shipped tracks never do.
- 23 shares a handler with 20, so sunset-mesa's second mystery code behaves identically to the dirt candidate. If 20 is dirt, 23 is a dirt variant.
- 14 (water) and 16 (rumble) fall to the default path, which is consistent: floating is handled elsewhere (the pave-the-world check special-cases 14), and a rumble strip is bumpy through geometry rather than through drag.
- 11, 15, 17–19, 21 are implemented but appear in no track, shipped or community.
"Stikky" is literal, and grass is the stickiest surface in the game. At 0x4433C0:
if surface == 10 (grass): drag *= 9.0
elif surface != 0: drag *= 3.0
else (asphalt): drag = 0
drag *= 0.3
Grass drags three times harder than any other off-track surface, and asphalt is exactly zero — which is precisely what Pave the World achieves by rewriting the code to 0. Asphalt is also the only surface with effect type 0; every other surface uses type 5, the dust and debris thrown up off-track.
🟡 Code 20 is most likely dirt or another loose surface. It is a primary driving surface, not runoff:
8track is 81.1% code 20 against 18.9% asphalt, JCsSnow — built for a snowmobile car mod — is 33.4% code
20 with no asphalt at all, and Rallye22 uses it for a quarter of its surface. A figure-8 oval, a snow
track and a rally track all racing on the same code points at loose ground; in the shipped road courses it
would be the dirt runoff. No tool or text names it, so this stays a hypothesis.
vrtrackmaker.exe, the community track maker, emits only codes 0, 10 and 16 and contains no water
vocabulary at all — which is why most community tracks use just asphalt, grass and rumble. Authors who wanted
water or dirt had to write the modobject codes by hand, since the pipeline's input is plain text — which
is exactly what the ten water tracks and ten dirt tracks in the corpus did. Its own
templates name them: asphalt.mod → 0, grassl/infill.mod → 10, rumbll/sidel.mod → 16.
⚠️ Code 3 in the FIRST parameter means "generates no collision geometry" and is unrelated to the surface
code in the third. vrtrackmaker writes modobject(wallali.mod, 3, 0, 0, 0) for every wall, and a
hand-written example uses it for scenery with the comment ;not hittable-code3. Walls are drawn but produce
no .bpp triangles because their collision is authored separately as .sol solids.
How a track is actually built
The commands below are the back half. The front half — how geometry and a centreline become those two text files in the first place — is documented under The modern workflow, end to end further down, and it is the part no recovered document describes.
The build is two batch files, not one: make-track.bat compiles, then hands off to
compile-track.bat which packs. Both survive, and both are reproduced verbatim below — this is the
critical link in the chain, and the step that determines whether a track works in Viper at all.
@echo off
color 0a
rmdir /s /q out
rmdir /s /q tra
del /s /q track.flt
del /s /q mtrack.flt
ECHO PAUSING FOR 2 SECONDS
PING 1.1.1.1 -n 1 -w 2000 >NUL
md out
md tra
mkfltoa.exe foolandsurface.txt track.flt -viper
MKWORLD.EXE
echo IF YOU SEE NO ERRORS - CONTINUE
echo IF YOU DO - CAREFULLY READ THE DETAILS CAUSING THE ERROR
echo THEN "x" OUT OF THIS BATCH FILE UNTIL YOU FIX IT
pause
mkilicc -nolat track.ili tra\track.ild
mkilicc -nolat track-ai.ili tra\default.ili
mkilicc -nolat track-ai-reverse.ili tra\rdefault.ili
copy out\track.sol tra\track.sol
copy out\track.obt tra\track.obt
copy out\track.bsp tra\track.bsp
mkfltoa.exe foolandgraphic.txt mtrack.flt
nhmkworld.exe mtrack.flt
echo IF YOU SEE NO ERRORS - CONTINUE
echo IF YOU DO - CAREFULLY READ THE DETAILS CAUSING THE ERROR
echo THEN "x" OUT OF THIS BATCH FILE UNTIL YOU FIX IT
pause
copy out\track.bpp tra\track.bpp
copy out\track.grf tra\track.grf
copy camera.tab tra
copy aidef.ccs tra
copy trackmap.stp tra
xcopy /e /y mkresfiles tra
cd tra
CALL compile-track.bat
…and mkresfiles\compile-track.bat, which produces the shipping archive:
:: REPLACE THE "trackname" BELOW TO THE NAME OF THE TRACK YOU ARE PUTTING TOGETHER.
mkres trackname.tra @reslist.txt
Several things follow from this that were not visible in the summary form:
- The AI lines are compiled, and there are three of them.
mkilicc -nolatruns three times over three separate hand-authored inputs — and this settles whatrdefault.iliis:track-ai-reverse.iliis the reverse-direction racing line.track.ilicompiles totrack.ild(the line the track map draws),track-ai.ilitodefault.ili(the forward AI line). - The surface pass must fully succeed before the graphic pass runs. The two
pauseblocks are not decoration — the author put a hard stop between the passes precisely because a silent failure in the first produces a track that compiles and then misbehaves. MKWORLD.EXEtakes no arguments — it picks uptrack.fltimplicitly, which is why themkfltoaoutput filename is fixed.nhmkworld.exedoes take its.fltexplicitly.- Not everything is generated.
camera.tab,aidef.ccs,trackmap.stpand every.texare authored separately and copied in. Only.sol/.obt/.bsp/.grf/.bppand the three.ili-derived files come out of the compiler. - The
.trais built bymkres, the same packer as.carand.res, driven by areslist.txtthat fixes the member order:
aidef.ccs camera.tab default.ili rdefault.ili track.bpp track.bsp
track.grf track.ild track.obt track.sol Trackmap.stp
sky1-4.tex grass.tex asphaltv.tex pine3o15.tex bboard01.tex
Which matches what vrmod list reports for a real track, and confirms the archive format documented at
the top of this file is exactly what the original tools emit.
.bpp and .grf are generated together, from the same source file. The collision BSP is derived from
the visible geometry rather than authored separately — which is exactly why editing a compiled .grf
without regenerating .bpp leaves collision behind. .sol comes from the surface file instead, whose
;Walls block holds wall geometry written out inline as object(wall.tga,1,0) blocks of four vert()
and one quad() — not, as previously guessed here, BOX/SPHR/TUBE solid primitives.
mkfltoa converts to OpenFlight (.flt), a standard interchange format, so track authoring goes
through ordinary 3D tools rather than anything bespoke.
⚠️ Every driveable object must be declared in BOTH source files, under the same name. This is the
constraint that makes the two-file split workable, and it is stated in the shipped example source itself —
eg-foolandsurface.txt, lines 10–11, the same file this document already quotes for its modobject
examples:
;NOTE- ONLY DRIVEABLE SURFACE mod FILES ARE LISTED IN THE modobject LINES
;THEY MUST BE THE SAME AS THE DRIVEABLE NAMES IN THE foolandgraphic.txt
The tutorial says the same thing in a red annotation over a screenshot of the author's Notepad window,
which is where this was first noticed here — "MAKE SURE YOU ALSO PUT […] IN YOUR foolandgraphic.txt",
beside modobject(road1.mod, 0, 0, 0, 0) … modobject(water1pond.mod, 0, 0, 14, 0), whose matching codes
in both files are what extends the rule from names to surface codes. But the comment above is the
authoritative source and was always in plain text; the screenshot only corroborates it.
The two files share a syntax but are not the same document. Running the generator (below) settles their actual shapes:
foolandsurface.txt |
foolandgraphic.txt |
|
|---|---|---|
path(center) |
absent | 1 block, 358 verts |
marker(checkN) |
2, each with 2 verts | 1, empty |
modobject() |
9 — driveable only | 13 — driveable + 4 walls |
| wall geometry | inline object(wall.tga,1,0) blocks |
referenced as .mod |
| size | 247 KB | 14 KB |
The nine driveable entries are identical in both files, name and surface code alike — which is the rule above, confirmed from the generator's own output rather than from a comment about it. The difference is what each adds around them: the surface file carries the checkpoint gate lines and the collision walls, the graphic file carries the centreline and the visible wall meshes. Divergence between the two copies is the classic authoring failure where a track looks right but the car drives through or over the wrong thing.
The same screenshots corroborate the surface-code table from a direction independent of both the engine
code and the geometry: an author naming a mesh water1pond.mod and tagging it 14 is the third
confirmation that 14 is water, and the most direct kind. path(center) is also confirmed as the track
centreline the rest of the build derives from — the annotation beside it reads "JUST A NOTE ABOUT THESE
'PATH' VERTS… YOURS WILL BE A LOT LONGER THAN THIS EXAMPLE IS."
The modern workflow, end to end
Everything above was reconstructed from recovered 1990s–2000s material. The question it leaves open is whether a track can still be built today, on current tools — and it can. This is a working pipeline, documented by HerbFargus from tracks actually built with it:
Bob's Track Builder Pro model the track -> .dof
Zmodeler import .dof, replace textures -> .mod + .tex the LOOKS
-> .3ds (on to Max)
3DS Max import .3ds, draw the centreline -> .ase
vrTrackMaker import .ase -> fooland*.txt the DRIVING
.ili x4, aispeed.txt
make-track.bat mkfltoa -> MKWORLD / nhmkworld -> .sol .obt .bsp .grf .bpp
compile-track.bat mkres @reslist.txt -> <track>.tra
This is a hybrid, and that is the thing to understand about it. Two branches run from the same BTB
geometry and converge at make-track.bat: Zmodeler produces what the track looks like, and
vrTrackMaker — fed only a centreline — produces how it drives. Neither tool can do the other's job.
BTB knows nothing about Viper's surface codes, .sol walls or .ili racing lines; vrTrackMaker sweeps a
single fixed six-band cross-section and calculates no normals, so it can make a passable oval and nothing
resembling a real circuit.
The join is the .ase centreline, and it explains several things this document had recorded separately
without connecting:
path(center), the centreline block at the top of both source files, is that Max spline after vrTrackMaker has written it out. The "yours will be a lot longer than this example" annotation is describing a spline export, which is why the vert lists run to hundreds of entries.- vrTrackMaker emits only surface codes 0, 10 and 16 (documented above). Now it is clear why authors who wanted water or dirt had to hand-edit: those codes are not reachable from the generator, so the text it produces has to be edited after the fact — and per the rule above, edited in both files, or the track will look right and drive wrong.
- Zmodeler produces the
.modmeshes and the.3dsin the same pass, which is what keeps mesh names consistent between the geometry and themodobject()lines that must reference them.
Two of these steps are the same commercial tools the original community used — 3DS Max, and Zmodeler for
the .dof import. vrmod's mod2obj / obj2mod already covers the looks branch with free tools: any
modeller that can export OBJ can produce the meshes. What has no counterpart here is the driving
branch — centreline in, fooland*.txt plus the .ili set out. That is the concrete, bounded shape of any
future track-authoring work, and it is now specified on both ends: the input is an ordinary polyline, and
the output is a text format this document describes in full, with a reference implementation's exact
output to check against.
vrTrackMaker — the driving model, not the track
Sucahyo's "VR simple Track Maker" turns a bare spline into the MKWORLD source set. Read in isolation it looks like a track generator, and it can be used as one — but that is not its role in the modern pipeline, and mistaking the one for the other misreads the whole workflow.
It is used as a hybrid. The visible track is built in BTB and carried through Zmodeler into .mod
meshes and textures. That same geometry then goes to 3DS Max, where the centreline is drawn and exported
as .ase, and that is what vrTrackMaker consumes — so what it is really being asked to produce is the
driving model: the collision surfaces with their material codes, the walls, the checkpoint gates, the
AI racing lines and the speed profile. The wiki workflow makes this explicit by structure rather than by
statement: Zmodeler emits the game meshes and the .3ds for Max. If vrTrackMaker were generating the
track, those Zmodeler meshes would have nothing to do.
Its own design says the same. It sweeps one fixed six-band cross-section, calculates no normals, and by
its readme will "only create asphalt, wall, side, rumble and small part of grass" — adequate for a simple
oval, nowhere near a BTB-quality circuit. What it is good at is the part BTB knows nothing about: Viper
Racing's surface codes, .sol walls, and .ili racing lines.
The practical consequence is that its generated meshes are a fallback, not the deliverable. In a
hybrid build the author keeps its foolandsurface.txt / foolandgraphic.txt, the .ili set and
aispeed.txt, and points the modobject() lines at their own BTB-derived meshes — at which point the
both-files rule above stops being trivia and becomes the thing that makes or breaks the track, since the
names now have to be edited consistently across two files that the generator wrote for different meshes
entirely.
What it does. It sweeps a parameterised cross-section along the spline. The bands are fixed and named
in the UI — Road, Wall, Side, Rumble1, Rumble2, Grass — each with a distance to centre and a
height, which is exactly the vocabulary the surface codes use (Road→0, Side/Rumble→16, Grass→10) and
why the tool can only ever emit those three. Its own readme says so outright: it will "only create
asphalt, wall, side, rumble and small part of grass." AI path generation is built in — with forward
and backward lookup distances in metres — which is where track-ai.ili comes from; the readme notes that
trkaitweaker.exe is no longer needed as a result.
Its shipped defaults, read from the running application (a Delphi TFmain; the labels are TLabels, so
they are drawn rather than exposed as controls):
| band | distance to centre | height | UV multiplier |
|---|---|---|---|
| Road | 6.000 | — | 1.000 |
| Wall | 0.010 | 1.000 | 4.000 |
| Side | 1.000 | −0.200 | 2.000 |
| Rumble1 | 0.300 | 0.100 | — |
| Rumble2 | 0.600 | −0.100 | 3.000 |
| Grass | 20.000 | 0.000 | 1.000 |
Banking multiplier 0.300, max banking 5°, "No banking" checked. Wall type is a four-way choice —
all / corner / corner outside (the default) / none. Thresholds: simplify 0.00001, corner
0.100, wall 1, rumble 1. AI path: forward lookup 30 m, backward lookup 5 m, mult 0.15, add
0.005.
Process file fills a grid whose columns are x, y, z, dirangle, bankangle, length — the simplified
centreline with a per-segment heading, bank angle and run length. Comparing that grid against the input
.ase shows the tool applies a coordinate change on the way in: a knot at
(−7.5317, 186.3278, 0.0000) in the .ase appears as (7.532, 0.000, −186.328), i.e.
x→−x, y→−z, z→y — the 3DS Max Z-up frame converted to Viper's Y-up left-handed one. The
path(center) verts in foolandgraphic.txt are in the original .ase frame, so this conversion
happens for display and for the geometry it generates, not in the text it writes.
⚠️ Line endings matter, and this has already damaged an archived file. The tool is a Delphi program
that splits its input on CRLF. The copy of Sucahyo's own test spline path10.ASE held in the
legacy-tools archive has been
normalised to bare LF somewhere in its archival history — 3,581 LF and zero CRLF — and in that state
the tool reads several lines as one value and dies with
'0.0000
*SHAPE_VERTEX_KNOT 1 -6.4834 186.3442 0.0000
...' is not a valid floating point value.
Converting back to CRLF makes it parse cleanly. This is a preservation defect rather than a tool defect,
and it is a general hazard: any text file in these archives that passed through a line-ending–normalising
step may be unusable by the original tools even though it looks correct in an editor. .ase splines,
fooland*.txt sources, reslist.txt and the .bat files are all exposed to it.
For the record, path10.ASE is a 3DS Max ASCII export dated 13 March 2008 from a scene named
kyalamimain.max, holding one closed *SHAPEOBJECT named linepath with 3,759 knots. Its first knot is
(−7.5317, 186.3278, 0.0000) — identical to the first vert() of the foolandgraphic.txt shown in the
tutorial screenshots, so the archived test spline and the tutorial's worked example come from the same
source track, decimated with different thresholds.
What it demands of the spline, all of which will silently ruin a track if violated:
- the path must run clockwise, or polygons come out reversed;
- it must go in one direction only — a backward segment punches a hole, fixed by deleting vertex lines
from the end of the
.ase; - it must be dense: normalise to 50.0 if needed, then to 1.0;
- the start/finish line must be straight;
- the path must never cross or overlap itself; there is no overlap detection, and an overlap becomes a hole;
- export with 4-decimal precision and
.as the decimal separator — the readme warns to change regional settings first, since a comma separator makes the tool error out.
The two-button workflow. Process file reads the .ase and builds the path and its bank-angle table
— "by all means, DO NOT DELETE THE TABLE RECORD", since only the bank-angle column feeds the next stage.
Write model emits foolandsurface.txt. If the wall count exceeds 2,000, the readme's instruction is to
redo with a higher simplify threshold, keep that foolandsurface.txt, and re-run normally for
everything else.
⚠️ Engine limits this readme records, which appear nowhere else:
| Limit | Value |
|---|---|
| walls per track | 2,000 |
| vertices per track | 65,000 |
| polygons per track | 65,000 |
The 65,000-polygon ceiling is worth reading against the race.bin table in
MODDING_HISTORY.md: Sucahyo — who wrote both this tool and the 1.2.4-beta engine
patch — raised track support to 95,000 polygons in that patch. These figures are stated without a
version, so read them as the constraint the tool was written against rather than a measurement of any
particular binary.
Two smaller notes from the same readme: normals are not calculated by the tool, and wall compilation
draws on foolandsurface.txt plus any .mod whose name carries a wall suffix. Its output track is
always named generic.tra — renaming happens afterwards, which is consistent with compile-track.bat
shipping with trackname as a literal placeholder to be edited.
What vrTrackMaker actually emits
Observed, not inferred: Sucahyo's own test spline path10.ASE was run through the tool and it wrote
21 files into the working directory. This is the set make-track.bat then consumes.
The two scene sources — foolandsurface.txt (247 KB) and foolandgraphic.txt (14 KB); shapes as
tabulated earlier.
Fourteen meshes, and their names are exactly the ones the shipped example sources reference, which confirms those examples were themselves generated by this tool:
| meshes | surface code | texture |
|---|---|---|
asphalt.mod |
0 | asphalt.tex |
sidel.mod, sider.mod |
16 | side.tex |
rumbll.mod, rumblr.mod |
16 | rumble.tex |
grassl.mod, grassr.mod |
10 | grass.tex |
infill.mod, infilr.mod |
10 | grass.tex |
wallcli/clo/cri/cro.mod |
— (first param 3) | wall.tex |
Left/right pairs throughout, an infil* inner pair for the infield, and four corner walls (inner/outer,
left/right). The walls are emitted as modobject(wallcli.mod, 3, 0, 0, 0) — first parameter 3, meaning
they generate no collision geometry, exactly as documented above; their collision comes from the inline
object(wall.tga) quads in the surface file instead. Only codes 0, 10 and 16 appear, confirming from
the generator itself why water and dirt tracks required hand-editing.
The meshes reference side.tex, rumble.tex and wall.tex, which the shipped mkresfiles does not
contain — but that is not a gap. reslist.txt is a per-track artifact, not a fixed manifest:
rescrack.exe writes one out when it unpacks an archive, ResClean.exe maintains it, and mkres packs
from whatever it lists. The copy in mkresfiles is the demo track's. An author brings their own textures
and their reslist names them.
Five AI/racing-line outputs — track.ili, track-ai.ili, track-ai-reverse.ili, track-reverse.ili
(15,864 bytes each, all four with different hashes, so four genuinely distinct lines) plus
aispeed.txt, 396 lines of distance, speed pairs. The .ili files are already 0SER/NILI archives;
mkilicc compiles them to the .ild/.ili members that ship inside the .tra.
Worth noting: make-track.bat uses only three of the four .ili files. track-reverse.ili and
aispeed.txt are generated but never referenced by the batch — either vestigial, or intended for a
hand-driven step the shipped batch does not perform.
✅ Every generated mesh parses with this toolkit. vrmod modinfo reads all fourteen, with vertex
counts from 660 to 3,695 — comfortably inside the 30,000-vertex per-object ceiling. That is a two-way
check: it validates our .mod parser against a generator we did not write, and validates that the
generator's output is well-formed.
The shipped tracks are codenamed
The filenames inside Data/ are not the names players see. Confirmed by matching .bpp payload hashes
against the community decompiles:
| Name in game | File in Data/ |
Name in game | File in Data/ |
|
|---|---|---|---|---|
| bemidji | bemidji |
ridge-valley | hastings |
|
| castle | heaven |
rock-island | kenyon |
|
| dayton | limbo |
silverdale | uptown |
|
| dundas | dundas |
sunset-mesa | nfield |
Worth knowing before comparing tracks by name, and it makes the odd flags legible: sunset-mesa — the desert track — is the only one using codes 12 and 23.
The tree verifies by traversal. Walking less/greater from the header's root index gives zero
revisits on every track, reaching 98–99.7% of all nodes at a maximum depth of 16–21 where a balanced tree
over that many nodes would be ~15. The unreached remainder is consistent with nodes retained but not linked.
Read from the code, not inferred
Three routines in race.bin confirm the layout directly (v1.2.5 addresses):
| VA | What it settles |
|---|---|
0x468AB0 |
the loader. lea eax,[esi+0x14] — the header is 20 bytes. mov ecx,[esi] / shl ecx,3 / sub ecx,edx / lea eax,[esi+ecx*8+0x14] — the first section is n_triangles × 7 × 8 = 56-byte records. It then converts the header's root index into a pointer and stores it back over +0x10. |
0x468B80 fixup_tree |
mov ecx, 0x1c / idiv — 28-byte nodes. Rewrites +0x14 and +0x18 from indices into pointers (-1 → null), recurses on the first and iterates on the second, and touches nothing else — which is what proves +0x00…+0x10 are values rather than relocatable indices. |
0x468CB0 |
the traversal test: fld [edx+4] · point.z + fld [edx] · point.x + [edx+8], compared against a constant — i.e. a·x + b·z + c, a line in the XZ plane. |
The function names itself: its diagnostics are "fixup_tree: bogus data?" and
"node %d: less is outside range (%d < 0 | …)", alongside the loader's "bad bpp version" and
"bpp not found: %s".
❌ The earlier hypotheses were all wrong and are withdrawn — baked lightmap atlas, per-vertex paint buffer, heightfield sample grid. The size that made them plausible is simply what a per-triangle plane plus a full BSP costs: 56 bytes per collision triangle and 28 per node adds up faster than the render geometry it shadows. The note that "two header int32s don't obviously factor into the file size" was the whole answer sitting in plain sight — they factor exactly, at 56 and 28 bytes.
⚠️ .grf and .bpp are independent, and that is a real modding trap. Nothing links a collision
triangle to a render triangle. Delete an object from .grf and it vanishes visually while its collision
triangles stay in the .bpp — the car then hits something that is no longer drawn. This is a known
community experience (cacti removed from a track's mesh, invisible collision left behind) and it follows
directly from the structure: any tool that edits track geometry must edit both files or it produces
invisible walls.
vrmod collisioncheck detects it, since both files share one world coordinate frame (verified: their
bounding boxes coincide). It matches each triangle in one mesh against the other by centroid proximity via a
voxel hash, and reports two failure modes: collision without nearby geometry (invisible walls) and
geometry without nearby collision (drive-through props).
- Single-track scan. A clean track has a large, expected baseline of one-sided triangles — off-track
terrain has collision but no render, cosmetic detail has render but no collision — so the raw counts are
not the signal. What is flagged is a cluster shaped like a standing object: a compact horizontal
footprint (
xz_radius ≤ 10 m) with vertical extent (≥ 2 m) and at least 6 triangles. Those thresholds are calibrated so that all ~167 collision-only terrain clusters across the eight stock tracks are excluded — a hit on a real track is a genuine mismatch, not terrain. The limit: a deleted prop whose collision footprint is only a few triangles is geometrically indistinguishable from a terrain bump on a single track, so this finds larger orphaned objects reliably but can miss a tiny one. - Diff against the original (
--against) removes that limit. Because the two versions share identical terrain, the baseline cancels and only the edit remains, so it catches a change of any size. Validated by deleting one prop's render geometry from a stock track: the diff flagged the resulting invisible wall 0 m from where the prop had been, and a track diffed against itself reports nothing.
⚠️ This also reframes .bsp. That file is a 108-byte near-static stub in every track (§4.10), so it is
not the track's spatial partition — .bpp is. .bpp and .sol (§4.8) together are the collision layer.
⚪ Open: whether tri0/tri1 are two coplanar triangles, a triangle plus a sibling link, or the ends of
a range. fixup_tree leaves them alone, so they are consumed as indices at query time; settling it means
reading the collision query rather than the loader.
4.10 BSPT — .bsp — CONFIRMED(a near-static stub; not the spatial partition)
⚠️ Despite the name, this is not where the track's spatial partition lives. It is a fixed 128 bytes in
every stock track — 108 of payload under the 20-byte envelope — while .bpp (§4.9) carries a full BSP tree
of tens of thousands of nodes. Anyone hunting for the collision structure should start there.
Fixed 128 bytes in every sample across all 8 tracks, and byte-for-byte identical in 7 of the 8 —
only castle/track.bsp differs, by a single nibble (one header field reads 2 where every other track
reads 3). The tail of the file decodes as float32 values on the order of ±100,000, i.e. bounding-box-
sized numbers for these open outdoor tracks (meters, consistent with everything else). The overwhelming
likelihood is that this is a single-node/degenerate BSP root — essentially "here's the world bounding
box" with no actual subdivision ever written out — rather than a real spatial tree per track. If true, that
implies the real spatial partitioning/culling structure the engine uses at runtime lives inside .grf
and/or .sol instead, and .bsp may be a vestigial/unused format from an earlier engine design. Treat
.bsp as low-priority for modding purposes — there doesn't appear to be meaningful per-track data in it.
4.11 rDIA — .adr AI driver definitions — 🟡 WELL-SUPPORTED (identity only)
Found in drivers.res as a single file, aidriver.adr (69,664-byte payload) — the "core" resource that
every per-tier .dnt/.ilg pair (§4.12) presumably indexes into or supplements. Reversed tag reads
"AIDr" — a clean match for "AI Driver." Header envelope confirmed (0SER/rDIA/version=2/!IGM); payload
not decoded. Given it's the one singleton in an archive otherwise made of hundreds of near-duplicate
per-tier records, this is likely the master driver roster/behavior table the tiered files parameterize.
4.12 TNDA — .dnt per-tier driver tuning — 🟡 WELL-SUPPORTED (identity only)
The bulk of drivers.res: ~695 files named <3-char tier code>vipr.dnt, each paired with a same-tier
.ilg AI-line file (§4.2). Reversed tag "ADNT" doesn't read as a clean word, but the naming and pairing
strongly suggest per-AI-skill-tier driving/tuning parameters (aggression, braking points, cornering
behavior) matched to that tier's own recorded line. Sizes cluster tightly by tier (e.g. ~1,200–1,800 bytes)
rather than varying with track size, consistent with a small fixed-shape parameter block rather than
geometry. Header envelope confirmed (0SER/TNDA/version=7 — the highest version number seen anywhere in
the catalog/!IGM); payload not decoded.
4.13 SGPU — .ugs car upgrade data — CONFIRMED(effects traced; shop fields read from data)
Found in viper.car as viper.ugs (5,948-byte payload), and in no other car. Reversed tag reads
"UPGS"; envelope 0SER/SGPU/version=1/!IGM. Traced in the v1.0 race.exe: CarFileGetUpgradeSet
loads it as a CarUpgradeSet (panicking on any version but 1), and CarFileLoad(CarFile*, name, flags)
opens <car>.ugs after reading the .cf and calls apply_upgrade for each upgrade whose flag byte is set.
0x000 int32 count -- 33 in viper.ugs
0x004 int32 offset[256] -- record i at 0x404 + offset[i]; unused entries 0
CarUpgrade:
+0x00 char[32] name -- "AirInduction", "SportsTires", ...
+0x20 char[32] second name -- a family ("Tires", "RollBars", "Springs", "Aero") or another
part ("ExhaustSystem" -> "GlassPackMuffler"); not traced
+0x40 32 bytes, zero in every record
+0x60 int32, int32, int32 -- read as category, tier 1-4, price ($); not traced
+0x6c int32 n
+0x70 n x (int32 .cf offset, 4-byte value)
An upgrade is a patch to the .cf. apply_upgrade writes each value straight into the loaded
CarFile at its offset, so upgrades can change any .cf field, and applying them in order means a
later one overrides an earlier one on the same field. Record 0, StockSetup, writes the stock suspension
ranges, gear ratios and more. The rest are shop parts: tyres set fgrip_scale/ftyre_stiffness_scale
etc. (1.03 → 1.12), springs and roll bars set the suspension ranges, Bushings zero the compliance, and each
engine part and weight reduction fills its own slot in the .cf's upgrade block (§5.4:
power_add, mass_add, gear_ratio, the aero-kit spoilers). RacingBrake's list is empty.
Upgrades are career-only, and Viper-only. The flag array reaches the car loader in two ways, and both
are career-gated. parse_car passes the global CareerUpgrades for the player's car (car-list type 0)
and NULL for every other car. CareerUpgrades is NULL in the data section, and only career_main
writes it, pointing it at the career save's flags on entry and clearing it on exit. The pre-race garage
gets its flags from PreRaceDo's third argument, which GameDoSingle (a single race) passes as 0,
while do_event and do_race (both career.obj) pass the save's flags. The career menu loads the set
by the literal name viper.ugs, and no other car ships one. So a single race, and every AI car, drives
the bare .cf.
4.14 0XFS → SFX0 — .sfx sound effect — CONFIRMED(PCM path; ADPCM path partially understood)
Car engine loops, horn, shift, and shared crash/UI cues (race.res/common.res/ui.res also carry many
non-car .sfx, e.g. crash1.sfx, click.sfx). Reverse-engineered by round-tripping controlled synthetic
WAV files through a reference encoder for the format, and cross-checking the result against all 4 real
samples in viper.car.
Format: the standard 0SER envelope wraps a 20-byte WAV-fmt-chunk-shaped sub-header —
| Offset | Field | Notes |
|---|---|---|
0x00 |
int32 data_size |
length of the real sample data, not including the trailing pad below |
0x04 |
int16 format_tag |
1 = PCM, 2 = Microsoft ADPCM (same codes as Windows WAVEFORMATEX.wFormatTag) |
0x06 |
int16 channels |
1 (mono) in every real sample seen |
0x08 |
int32 sample_rate |
44100 for the RPM-sweep engine loops, 22050 for the idle sample |
0x0C |
int32 byte_rate |
sample_rate × block_align for PCM |
0x10 |
int16 block_align |
2 for 16-bit mono PCM |
0x14 |
int16 bits_per_sample |
16 in every real PCM sample seen |
— followed by 2 bytes at a fixed offset, then data_size bytes of raw sample data (PCM: signed 16-bit
little-endian mono — already exactly what a standard WAV data chunk would hold), then a fixed
4096-byte trailing pad regardless of content length. Confirmed identical in size (not content) across
all 4 real viper.car samples and two synthetic test files of very different lengths (40 and 6,000 bytes
of real data). In at least one synthetic sample the pad was confirmed to be uninitialized memory rather than meaningful
content — unrelated ASCII text turned up inside it.
Those 2 bytes were originally reported as a fixed literal "da" marker based on the 4 viper.car samples
alone. A broader check against race.res's 16 shared UI/gameplay .sfx files (the fallback horn.sfx/
shift1.sfx/etc. every car uses unless it ships its own — see below) disproved that: 12 of the 16 do have
"da" there, but the 4 impact-type sounds (crash1.sfx/crash2.sfx/crash3.sfx/squeal.sfx) all have
\x00\x00 instead, with every other header field — and the actual audio — still perfectly valid. A
consistent split (4/4 impact sounds vs. 0/12 everything else, not scattered) rather than noise, but its
real meaning isn't understood; not decode-relevant either way, since nothing needs it to extract the
sample data. Downgraded here from CONFIRMEDto unverified-but-harmless.
Real samples, all PCM: viper0.sfx/viper1.sfx/viper2.sfx at 44,100 Hz and viperi.sfx (idle) at
22,050 Hz, all mono/16-bit — plus, from race.res, the 16 shared UI/gameplay sounds referenced above
(horn.sfx, shift1.sfx, squeal.sfx, crash1-3.sfx, road1-2.sfx, scrape.sfx, splash.sfx,
go.sfx/ready.sfx countdown cues, cboth/cclear/cleft/cright.sfx spotter-voice cues), all also
22,050 Hz mono/16-bit PCM. A car's own sound set is named <prefix>0/1/2/i.sfx: i.sfx
is the idle loop; 0.sfx is the AI/other-player engine sound covering idle→9000 RPM; 1.sfx is the local
player's own engine sound over the same range; 2.sfx is a high-RPM-only layer (4000→9000 RPM, also usable
for a turbo hiss). horn.sfx/shift1.sfx/squeal.sfx are confirmed per-car overridable in practice too —
several third-party cars ship their own, distinct from race.res's shared default, resolved the same
own-archive-first way as the horn ball mesh. road1.sfx is per-car overridable as well CONFIRMED
IN GAME (2026-09-14) — and this one had no third-party precedent to lean on: across 188 community car
archives, not one ships a road*.sfx. Tested directly by giving a single car its own road1.sfx
holding four seconds of alternating 440/880 Hz beeps while race.res's copy held a long music track;
that car beeped, a second untouched car played the music. It matters because road1.sfx is the only
shared sound that plays continuously while driving and accepts arbitrary length — .sfx carries a
30 MB clip without complaint — so a car can carry its own soundtrack. It pitch-shifts with road speed. On the authoring side, samples must be 16-bit mono, resampled
to the target rate, and trimmed to a zero-crossing loop point to avoid pops.
ADPCM (format_tag=2) is not fully solved. No real sample in the retail data uses it
(all 4 are plain PCM), but a synthetic test confirmed real, substantial compression (~4:1, matching
bits_per_sample's implied 16→4 bit reduction) and exposed two real quirks: the data_size
field is unreliable for ADPCM output (it reports the original uncompressed size, not the true compressed
byte count — the real compressed length has to be derived as payload_size − 22 − 4096), and
block_align=2 doesn't fit real Microsoft ADPCM's block structure (which needs at least a 7-byte block
header before any compressed nibbles), meaning this is either a simplified/non-standard 4-bit codec that
merely reuses Microsoft's format_tag constant, or block_align doesn't carry its usual meaning here. The
actual compressed-sample bit layout is unsolved.
4.15 XFSE — .ens engine sound crossfade parameters — 🟡 WELL-SUPPORTED (structure solved, field semantics inferred)
Found in every car archive as <prefix>e.ens (228-byte payload — the smallest confirmed-identity format
in the catalog). Reversed tag reads "ESFX." Structure fully solved by cross-car comparison (no reference
tool's own field labels have surfaced for this format the way they did for .cf, so this was worked out
from the raw bytes alone): the payload is a fixed-size buffer, always exactly 228 bytes regardless of
content — int32 recordCount followed by a fixed 8-slot array of 7-float32 records (28 bytes each;
4 + 8×28 = 228). Only the first recordCount slots hold real data; the rest is uninitialized memory left
over from whatever tool originally wrote the file — confirmed exactly the same way as .sfx's trailing pad
above: the byte math is exact for both a recordCount=3 sample (real data ending at byte 88) and a
recordCount=2 sample (ending at byte 60), and the "padding" bytes genuinely differ between two different
cars' .ens files while the real leading records are stable.
recordCount matches, record-for-record in order, the number of non-idle .sfx files (§4.14) that car
actually has (<prefix>0/1/2.sfx) — confirmed directly against real data: every sampled car with all three
0/1/2.sfx files has recordCount=3; a car confirmed to be missing <prefix>2.sfx specifically has
recordCount=2. Every field WITHIN a 7-float record is inferred from context and cross-car comparison
only, not confirmed: [0] is identical between a car's 0.sfx and 1.sfx records (plausibly a
pitch-reference "base RPM"); [1]/[2] are two small 0–2-range scalars (plausibly volume/pitch-scale);
[3]/[4] are an ascending RPM pair that varies per record/car (plausibly that layer's own crossfade
range); [5] sits close to (not exactly at) that car's redline; [6] is 10000.0 in every record on
every car sampled, including cars whose other fields differ, and reads as an engine-wide constant rather
than real per-car data.
Header envelope confirmed (0SER/XFSE/version=1/!IGM).
5. Plain (non-0SER) formats
5.1 .tga — Truecolor Targa — CONFIRMED
Every .tex ships with a same-named .tga source/editable counterpart in the sample set. Standard
uncompressed truecolor Targa, 24- or 32-bit, no RLE — including correct handling of the origin-corner
descriptor bits (top/bottom and left/right flips per the TGA image-descriptor byte).
5.2 The game executable — CONFIRMED(video mode, VRAM check, viewport/FOV, rasteriser crash, display scaling)
Viper Racing.exe (388,608 bytes) is only a launcher. The engine is race.bin — a complete PE image
(MZ, ImageBase 0x400000, sections .text / .rdata / .data / .idata / .rsrc) carrying the
renderer, the resource loader, the physics and every diagnostic string. Two builds exist in the wild:
| Build | Size | Notes |
|---|---|---|
| Retail 1.1 (1998) | 1,300,480 | Ships on the disc |
| Community v1.2.5 (2016) | 1,314,816 | Raises polygon/vertex limits, fixes audio, breaks the built-in Benchmark |
v1.2.5 is a rebuild, not a patch: only 5.3% of bytes match at the same offset, .text grows 192 bytes,
.data 5,632, and .idata is merged. It cannot be reproduced as an edit to the retail build.
Every address below is a retail 1.1 virtual address. The two builds lay the same code out differently —
the resolution label table alone moves from file offset 0xde9a4 to 0xe2520 — so anything built on this
section must locate its sites by byte pattern, never by offset.
Applying all of this is one command. The patches below interact and their order matters — the symbol map
is generated by scanning .text, so it has to be built after every byte of code has settled — so
vrmod/patchset.py fixes the order once and always rebuilds from a pristine snapshot rather than layering:
vrmod patch <Data> --mode 1920x1080 # startup, rasteriser, FOV, resolution, engine fixes, symbols
vrmod patch <Data> --status # what is installed, including the DPI setting
vrmod patch <Data> --revert # back to the snapshot taken on the first run
Re-running with the same arguments produces byte-identical output, which is the property that matters: it
makes a patched binary reproducible instead of an artefact of the order someone happened to apply things in.
The DPI setting of §5.2.0 is --dpi-aware, kept opt-in because it is a Windows per-application setting
rather than a change to any game file. The engine fixes (vrmod/enginefix.py) are always on: obstacles
wake on a race restart, and the AI survives losing its racing line (both under "How an obstacle lives").
Each is found by signature in every known build: v1.0 race.exe, the v1.0-disc and v1.1 race.bin, and
1.2.4 beta, 1.2.5 and 1.2.6. scripts/check_enginefix.py checks them against each.
5.2.0 ⚠️ The display-scaling trap — read this before measuring anything from a screenshot CONFIRMED
On a high-DPI display the game does not get the surface it asks for, and every screenshot-based measurement in this section is affected by it.
race.bin is DPI-unaware, as any 1998 binary is, so Windows reports the virtualised desktop size to it. On
a 3840 × 2160 panel at 250% scaling that is 1536 × 864. The resolution patch (§5.2.2) can still tell the
game to use 1920 × 1080 and the game will comply in every respect it can observe: it sets
vp2 0 0 1920 1080, centres its projection on x = 960, places the tachometer at y ≈ 1000. But the surface it
actually draws into is 1536 × 864. Everything it draws right of x = 1536 or below y = 864 never appears.
The symptoms read convincingly as engine bugs:
| Symptom | Actual cause |
|---|---|
| The 3D view sits well right of centre | The projection centre, x = 960, lands at 960/1536 = 62.5% of the visible width |
| The tachometer is missing entirely | It is drawn at y ≈ 1000, past the bottom of an 864-row surface |
| The whole picture looks magnified | The 1536 × 864 surface is upscaled to the capture size |
How to detect it. Compare a viewport rectangle the game logged against where it actually lands in a
screenshot. The rear-view mirror is ideal because the game logs its rect and it is small enough to measure
precisely. With vp2 768 64 384 72 and a 1920-wide capture, the mirror measured at x 959–1442, y 79–171 —
exactly 1.25× on all four edges, and 1920 / 1.25 = 1536, the virtualised desktop width. Two independent
measurements agreed: the axis of symmetry between the front wheels sat at 0.6253 of frame width, against
960/1536 = 0.6250 predicted.
The fix is a Windows setting, not a patch. On the executable the build actually runs: Properties → Compatibility → Change high DPI settings → tick Override high DPI scaling behavior, Scaling performed by: Application. The process then sees the real desktop and can get the surface it asked for.
⚠️ Setting the registry value is NOT equivalent to using the dialog, despite writing the same bytes. Writing
~ HIGHDPIAWAREunderHKCU\…\AppCompatFlags\Layerscan leave the game still rendering DPI-unaware — shifted right and down, tachometer gone — and then ticking the box in the dialog fixes it immediately. Both routes were compared after the manual fix worked and the stored value was byte-for-byte identical (~ HIGHDPIAWARE), so the difference is not what is written: the dialog also refreshes the AppCompat shim cache, and a bare registry write does not. Reproduced more than once, on both the combined value (~ HIGHDPIAWARE DWM8And16BitMitigation) and the bare one.Consequence for tooling: a tool reading that value back can only report the setting as recorded, never as effective.
vrmod doctorsays exactly that and points at the dialog rather than claiming the fix is done. ✅ Verified on hardware: with the override set, the same build renders centred at 1920 × 1080 with a full HUD — measured axis 959.5 against a centre of 960.0 — where before it was cropped to the top-left 1536 × 864 of its own output.Rule for anyone continuing this work: before treating a high-resolution rendering symptom as a game bug, check whether the requested mode exceeds the virtualised desktop size. To recover the game's own coordinates from a screenshot taken without the override, divide by
capture_width / 1536.
5.2.1 The video-memory check — why the 1998 build won't start on a modern GPU CONFIRMED
At 0x44EF66, before comparing the card's memory against its own tiers:
add dword [esp+4], 0x96000 ; 81 44 24 04 00 60 09 00
cmp dword [esp+4], 0x1E8480 ; 2,000,000
jae ok
xor eax, eax ; -> failure
0x96000 is 614,400 = 640 × 480 × 2 — exactly one 16-bit framebuffer, the game allowing for its own front
buffer on top of what DirectDraw reported. Harmless in 1998. On a card reporting close to 4 GB the addition
wraps past 2³², the 2 MB comparison fails, and the game exits claiming a video-card error.
The fix is to replace those eight bytes with NOPs. The comparisons are unsigned (jae), so a large
figure passes easily, and the framebuffer allowance is meaningless on a card with gigabytes. This is
precisely what v1.2.5 does — those eight bytes are the only difference at this site. ✅ Verified on
hardware: a stock 1998 race.bin patched this way starts and races on a 4 GB card.
The same function classifies video memory into tiers 2 / 4 / 8 / 16 MB at 0x50AF70, using thresholds
2,000,000 / 4,000,000 / 8,000,000 / 16,000,000, and the tier then disables resolutions on small cards.
16 is the maximum tier and cannot simply be raised: a five-entry jump table is indexed by tier − 2
behind a range check cmp ecx, 0xE, and anything outside prints a message and calls exit. The startup log
line vid: %d meg card (reported:%f) reports both the tier and the true figure, e.g.
vid: 16 meg card (reported:4095.937500).
5.2.2 Screen resolution — the menu index carries the dimensions, not the label CONFIRMED
The game offers four modes, identified internally by a menu index 1–4:
| Index | Stock mode | Label slot | Availability flag |
|---|---|---|---|
| 1 | 512 × 384 | 3 | 0x50AF95 |
| 2 | 640 × 480 | 2 | 0x50AF96 |
| 3 | 800 × 600 | 1 | 0x50AF97 |
| 4 | 1024 × 768 | 0 | 0x50AF98 |
The labels are 12-byte NUL-padded ASCII strings ("1024 x 768") in descending order, so
slot = 4 − index. A fifth whitelist entry, 320 × 200 (0x50AF99), has no menu index.
The label is display-only. The dimensions are compiled in as immediate operands at four separate sites, and a resolution is only genuinely changed when all four agree:
| Site | VA | Form |
|---|---|---|
| DirectDraw enumeration whitelist | 0x44F140 |
cmp ecx, W / cmp dword [eax+8], H → mov byte [0x50AF94+index], 1 |
| Dimension getter (jump table on index) | 0x44E960 |
mov eax, W / mov ecx, H, then the mode-set call with esi = 0x10 (16 bpp) |
| Globals chain A | 0x447D74 |
mov dword [0x50AD8C], W / mov dword [0x50AD6C], H |
| Globals chain B | 0x447E97 |
The same pair again |
There are two globals chains because the game keeps two independent video modes — one for the frontend
and one for racing. Both are driven from a single Config/options.cfg key, video_mode, holding a
menu index 1–4, and the race mode is announced in the log as setting driving video mode = N.
⚠️ video mode — with a space — is NOT a second key. This section previously documented it as the
frontend's key holding a label-table slot (0–3), paired against video_mode as a menu index, and warned
about the "different numbering". That is wrong, and the RC's own symbols say so. The parser splits every
line at the first space:
load_options +0x159 call strchr(line, ' ') ; the FIRST space
+0x174 mov byte [edi], 0 ; the name ends there
+0x186 inc edi ; the value is everything after it
+0x187 call find_item(section, name)
So the line video mode 4 does not parse as video mode = 4. It parses as an option named video with
the string value "mode 4" — and nothing reads an option called video. The name does not occur
anywhere in the binary: of the 93 option names passed to Options{Get,Set}, the only video one is
video_mode. find_item is blunter still — it asserts that a name must not contain one:
find_item: %s:%s has a space in it
MGI's own shipped options.def trips their own rule. The line is malformed, the parser turns it into junk,
and the junk round-trips through every save. Both real consumers read the underscore key:
OptionsBegin +0x17d |
registers "video_mode" with its default |
ViewPreBegin +0x6 |
OptionsGet("video_mode") → VidIsModeSupported() → falls back to 2 → gxChangeMode() |
The value reaches gxChangeMode with no arithmetic applied, and the fallback is index 2 — the 640×480
startup gate — which confirms the 1–4 menu-index reading directly.
⚠️ Why the wrong reading survived. slot = 4 − index, so index 2 is slot 2. Every sample anyone
had read 2, the single value where both interpretations agree. It took a config written by a player racing
at 1920×1080 — video mode 4, impossible for a 0–3 slot — to separate them.
Two practical consequences for tooling. Anchor the key: an unanchored video[ _]mode matches the junk line
first, because it comes first in the file, so a tool asking "what will I race at" silently answers with a
value nothing reads. And read Config/options.cfg, not options.def — see §5.2.2c.
The enumeration callback compares only width and height — never the pixel format — and silently
discards any mode not matching one of its five hardcoded pairs, however willing the driver was to provide
it. That, not the driver, is what confines the stock game to its four resolutions. Availability is read
back by 0x44E720, which is just mov al, byte [ecx + 0x50AF94] (plus a VRAM-tier check for index 2). The
Options menu lists every index whose flag is set; on modern drivers 512 × 384 is not enumerated, so three
entries appear.
⚠️ Index 2 is the startup gate. cmp byte [0x50AF96], 0 is what prints
video card cannot do 640x480x16! and exits, so repointing index 2 at a mode the driver does not offer
stops the game booting entirely. Repoint index 4 instead — an unavailable mode there merely drops out
of the menu and the game still runs.
Results, verified on hardware (patched onto index 4). Modes marked ⚠️ were measured before §5.2.0 was understood, on a machine whose real surface was 1536 × 864 — "renders" is trustworthy for those, but their HUD observations were measuring the cropped surface and have since been overturned:
| Mode | Pixels | Result |
|---|---|---|
| 1280 × 720 | 0.92 M | ✅ renders natively |
| 1280 × 1024 | 1.31 M | ✅ |
| 1600 × 900 | 1.44 M | ✅ |
| 1680 × 1050 | 1.76 M | ✅ ⚠️ recorded as "the practical maximum" — it is not |
| 1600 × 1200 | 1.92 M | ✅ ⚠️ recorded as "HUD near its limit" — there is no such limit |
| 1920 × 1080 | 2.07 M | ✅ after the needle fix (§5.2.4); crashed on every attempt before it |
| 1920 × 1200 | 2.30 M | ✅ full HUD, dial at stock placement — re-measured with the DPI override |
| 2048 × 1536 | 3.15 M | ✅ full HUD including the needle — the practical maximum, 4.9× stock |
| 2560 × 1440 | 3.69 M | ❌ create device → DDERR_INVALIDOBJECT |
The ceiling is a surface width of 2048, not a pixel count. 2560 × 1440 fails while 2048 × 1536 succeeds
at more pixels, which rules out any total-size cap in that range and fits the classic legacy Direct3D
surface-dimension bound. The failure is Direct3D device creation, and the display mode itself is fine — the
log shows try triple buffer / vid: triple buffer on succeeding first.
⚠️ The dialog that appears is misleading. This game requires DirectX 5 or 6 lives in
Viper Racing.exe (0x48c7), not in race.bin: it is the launcher's generic catch-all for any engine
startup failure and says nothing about DirectX versions. The real cause is always in C:\log.log.
So the game will render at roughly 4.9× its stock maximum pixel count.
A mode the driver does not enumerate simply vanishes from the menu, with no error — the whitelist flag is
never set, the log has no setting driving video mode line, and the game exits cleanly. 2048 × 1152 behaved
exactly this way. Before probing a limit, check the mode is actually offered:
Get-CimInstance CIM_VideoControllerResolution |
Select-Object HorizontalResolution, VerticalResolution -Unique
1920 × 1080 used to be the exception, and its story is worth keeping because it misleads so effectively. Every property of that mode tests fine in isolation — width 1920 (1920 × 1200 works), height 1080 (1200 works), 2.07 M pixels (2.30 M works), 16:9 (1280 × 720 and 1600 × 900 work), enumeration and refresh-rate profile (identical to modes that work), colour depth (Windows reports 32 bpp for every mode, working ones included), and the 16 MB surface budget (forcing double buffering changed nothing). Six hypotheses were formed from those facts and all six were false. The mode was never the problem: it merely positions the tachometer needle across row 1024, where a rasteriser bug smashes the stack (§5.2.4). With that fixed, 1920 × 1080 races normally.
The remaining limit is cosmetic: the HUD's stamps stop being drawn past roughly 1.77 M pixels — see §5.2.3.
5.2.2c Where options.cfg actually lives — it depends on the pressing CONFIRMED
Config\ is a relative path (§5.1's write-path patch is what makes it one), and Win32 resolves a
relative path against the process's current working directory — which, for anyone starting the game the
normal way, is the folder holding the executable they started. The two pressings put that folder in
different places relative to Data:
| Pressing | What you start | Working directory | So Config\ is |
|---|---|---|---|
| 1.0 | race.exe, inside the Data contents |
the install folder | <data_dir>/Config |
| 1.1 | Viper Racing.exe, one level up |
the game folder | <data_dir>/../Config |
A tool that assumes only the 1.1 shape does not fail loudly on a 1.0 install — it finds nothing at
<data_dir>/../Config, falls through to options.def, and reports the shipped factory default as if it
were the player's current setting. vrmod's doctor did exactly this: it told a player who had just raced at
1920×1080 that they were at 640×480, because options.def still said video_mode 2.
Worse on a 1.0 install, <data_dir>/../ is merely whatever folder happens to contain the install, so if
two installs sit side by side the search can read a different copy of the game's settings.
So: check the data_dir layout first, and exhaust every options.cfg candidate before trying any
options.def. vrmod.writepaths.options_file() is the single implementation.
5.2.2b Draw distance — the stored value is a fraction, not a distance CONFIRMED
draw_distance in Config/options.cfg ships as 0.500000, which reads like half of something rather
than a distance, and it is. The string appears once in race.bin; the code that reads it stores to
0x540344, and the transform applied immediately afterwards is:
fld dword [0x540344] ; the value as written in options.cfg
fmul dword [1700.0]
fadd dword [300.0]
fstp dword [0x540344] ; what the renderer uses
so
effective = value * 1700 + 300
draw_distance |
Effective | What it is |
|---|---|---|
0.500000 |
1,150 | The shipped default (options.def) |
1.000000 |
2,000 | The in-game slider pushed all the way right |
100.000000 |
170,300 | What the View Extender writes — 85× the slider |
Nothing clamps it. This is the whole reason the extender works, and it is visible by comparison:
detail_level is read through the same path a few instructions later and is floored —
cmp [detail_level], 0x3f000000 ; 0.5f
jge ... ; else force it to 0.5
— while draw_distance gets no such treatment. Any positive value is accepted and scaled, so the
useful range extends far past what the menu can express. The floor is not 0 but 300 units: the
fadd happens after the multiply, so nothing below that is reachable however small the value.
⚠️ The game rewrites options.cfg on exit. Change this with the game closed, and expect touching the
graphics tab in-game to put the slider's value back. The View Extender's instructions say the same
thing in capitals.
⚠️ The old tool edited line 92 by number, and the key is not on line 92. The 2016 batch file
(and the .exe beside it) does a literal if %%a equ 92 line-number substitution. On every install
in this project's sample set the key is on line 91 or 83. Run against a real v1.2.5 config it
overwrites effects yes and leaves this:
line 91: draw_distance 0.500000 <- the real setting, untouched
line 92: draw_distance 100.000000 <- written over "effects yes"
— a duplicated key, the first occurrence still at the default, and one graphics setting silently
gone. vrmod drawdistance finds the key by name and rewrites only that line's number, preserving the
file's CRLF endings and every other byte.
5.2.2a Widescreen field of view — CONFIRMED(Vert−, and fixable)
All of the game's aspect handling lives in one place: the D3DVIEWPORT2 it hands to
IDirect3DViewport2::SetViewport2 (vtable slot 0x44). The builder — 0x459640 on v1.2.5 — is short
enough to read whole:
fild dword [esp+0x18] ; dwHeight
mov dword [esp+8], 0x2c ; dwSize = 44 -> D3DVIEWPORT2, not D3DVIEWPORT
mov dword [esp+0x1c], -1.0 ; dvClipX
fidiv dword [esp+0x14] ; / dwWidth -> r = height / width
mov dword [esp+0x24], 2.0 ; dvClipWidth
mov dword [esp+0x2c], 0.0 ; dvMinZ
mov dword [esp+0x30], 1.0 ; dvMaxZ
fst dword [esp+0x20] ; dvClipY = r
fmul dword [2.0]
... ; two pushes shift esp by 8 before the last store
fstp dword [esp+0x30] ; dvClipHeight = 2r
call dword [eax+0x44] ; SetViewport2
giving a clip window of
dvClipX = -1.0 dvClipWidth = 2.0 <- both hard-coded immediates
dvClipY = r dvClipHeight = 2r <- r = dwHeight / dwWidth
⚠️ Correction to an earlier reading in this section. The ratio is not an overall FOV scalar.
dvClipWidth / dvClipHeight = 1/r = dwWidth / dwHeight, so the clip window's aspect always equals the
pixel viewport's aspect and pixels stay square at any resolution. The horizontal field is pinned by the
constant dvClipWidth = 2.0 and never changes; it is the vertical field that shrinks as the screen
widens. Stock widescreen is textbook Vert−: 1920 × 1080 shows exactly as much left-to-right as
640 × 480 did, and less top-to-bottom. The exaggeration test that suggested an overall scalar — substituting
2.0 and watching the camera pull back — is consistent with both readings and never distinguished them.
The projection matrix is not involved. Logging every SetTransform (IDirect3DDevice2, slot 0x68)
through a race shows it is a per-camera constant with _11 == _22 and _31 == 0: 1.274141 racing,
1.400415 in menus, ±2.300737 for the mirror passes, unchanged by resolution.
The ratio comes from the viewport, not the screen, and the two differ: the HUD banner takes the top 64
rows, so the logged viewports are 0 64 640 416 and 0 64 1920 1016, giving r = 0.65 and 0.529.
The predictor is the aspect ratio, never the resolution: 1600 × 1200 is 2.9× the stock pixel count and frames the car as 640 × 480 does, while 1280 × 720 has fewer pixels than 1280 × 1024 and crops it.
Fix — true Hor+. Keep the original vertical field and widen the clip window to match the screen:
R = max(R0, r) <- R0 = 0.65, the original
dvClipY = R dvClipHeight = 2R
dvClipX = -R/r dvClipWidth = 2R/r
dvClipWidth / dvClipHeight is still exactly 1/r, so pixels stay square — the patch only ever changes the
clip window's size, never its shape or its centre.
The max is what makes it safe, and clamping rather than pinning is the whole trick. This builder serves
every viewport the game sets, not just the race camera, and the rear-view mirror is a narrow one —
167 × 129, r = 0.77. Pinning R to 0.65 would zoom the mirrors in by 19% at every resolution, 640 × 480
included. With the clamp, any viewport at least as tall as the design aspect keeps R = r and comes out
byte-identical to stock:
| Viewport | r | Result |
|---|---|---|
| 640 × 416 main | 0.65 | every field stock — a provable no-op at the original resolution |
| 167 × 129 mirror | 0.77 | untouched, at every resolution |
| 1600 × 1200 main | 0.71 | untouched |
| 1920 × 1016 main | 0.53 | clip widened to 2.457 — 23% more world sideways |
66 bytes, file size unchanged, implemented pattern-located in vrmod/aspectfix.py; both constants are found
by value in .rdata, or written into .rdata's mapped slack when absent (v1.2.5 places 0.65 at
0x4CE664). The region is byte-identical between the retail and community builds apart from three operands.
The HUD is unaffected — it is drawn in screen space from a 640 × 480-era layout and still does not scale
(§5.2.3).
⚠️ An earlier version of this patch was wrong in an instructive way. It replaced the computed r with
the constant 0.65 and left dvClipX / dvClipWidth at their hard-coded −1.0 / 2.0. That fixes
dvClipHeight at 1.3 while the pixel viewport is 1.89∶1, so the clip window no longer matches the surface
and the image is stretched horizontally by 23% instead of showing more world. Everything grows wider and
anything away from the middle is pushed further out — which reads as the view drifting off-centre, but is a
stretch about a centre that never moved.
✅ The "unexplained horizontal shift" recorded in earlier revisions of this section was not a game bug at all — it was display scaling (§5.2.0). The render pipeline is centred at every stage, verified four independent ways:
| Stage | Measured | Centred? |
|---|---|---|
| View matrix | _31 ≈ -0.000009 (floating-point noise off the car's heading) |
yes |
| Projection matrix | _31 = 0.000000, _11 == _22 |
yes |
| Viewport rect | dwX = 0, dwWidth = 1920 |
yes |
| Clip volume | dvClipX = -dvClipWidth/2 by construction |
yes |
The viewport-centre globals 0x50D488 / 0x50D48C and the half-dimension pair 0x50D498 / 0x50D49C have
exactly one reference each in .text — the write inside set_viewport (0x44DD90) — so they are dead
and can be ruled out. And the scene really is transformed by Direct3D: the draw calls pass vertex type 2
= D3DVT_LVERTEX, untransformed and pre-lit. A separate D3DVT_TLVERTEX path at 0x4537F5, where the game
supplies screen-space coordinates itself and bypasses all of the above, is worth remembering for HUD work.
Method note worth keeping. Three successive claims about this "shift" were made from eyeballed screenshot positions, and all three were wrong. The estimates were mutually contradictory — near-field objects appeared to grow as a fraction of the frame while far-field ones shrank, which is impossible for a single camera, and that contradiction was the signal that the measurements were noise. What settled it was measuring a landmark whose true coordinates the game itself had logged.
5.2.3 The in-race HUD — 640 × 480 stamp art, clipped against the render target CONFIRMED(mechanism); the "pixel budget" is RETRACTED
⚠️ Read §5.2.0 first. This subsection previously described a HUD "pixel budget" of ~1.77 M pixels. It does not exist. Every measurement behind it was taken before the display-scaling trap was understood, on a machine whose real surface was 1536 × 864 while the game believed it was drawing at 1920 × 1200. With the DPI override in place the dial renders at stock placement at 1920 × 1200 and at 2048 × 1536 — 3.15 M pixels, nearly double the supposed cap. The mechanism below is read from code and stands; the numbers that were fitted to it are retracted in full further down.
The in-race HUD is built from stamps (§4.6) in race.res, drawn at native pixel size and never
scaled — which is why the tachometer measures ~128 px at every resolution:
| Stamp | Size | Role |
|---|---|---|
rpm.stp |
128 × 128 | Tachometer |
mph.stp |
128 × 128 | Speedometer |
status.stp |
640 × 64 | Top banner — authored full-width for a 640 × 480 screen |
start.stp |
200 × 78, 6 frames | Starting lights |
They are loaded at 0x403454ff into globals — rpm 0x4EC798, mph 0x4EC69C, status 0x4EC804, start
0x4EC6C8 — and drawn through 0x44C350, whose signature is draw(stamp, x, y, frame, flags) (cdecl).
Placement is resolution-relative and correct. The tacho is x = 16, y = height − 144, bottom-left
anchored; the gear digits sit at height − 0x49 and height − 0x50. The whole HUD block is skipped when
the screen is narrower than 640 (cmp dword [0x50AD8C], 0x280 / jl).
But every stamp is clipped against a render-target rectangle rather than against the screen. 0x448B70
resets that rectangle from the surface's own dimensions:
[ecx+0x14] = 0 ; left
[ecx+0x18] = 0 ; top
[ecx+0x1C] = [ecx+8] ; right = surface width
[ecx+0x20] = [ecx+0xC] ; bottom = surface height
and 0x44C350 clips with visible_rows = min(bottom − y, stamp_height), clamped to zero — so an element
positioned past bottom disappears silently, with no error and no log line.
❌ RETRACTED: there is no pixel budget. Everything this subsection previously concluded past this point
was measuring the cropped surface of §5.2.0 rather than the game. Re-measured on hardware with the DPI
override in place, dial at stock placement (y = height − 144), nothing patched:
| Mode | Pixels | Dial | Old model's prediction |
|---|---|---|---|
| 1920 × 1200 | 2.30 M | ✅ renders | usable height budget/1920 = 960 → nothing |
| 2048 × 1536 | 3.15 M | ✅ renders | usable height budget/2048 = 864 → nothing |
Both render. The old model is falsified twice over, at the exact mode it was fitted to. There is no budget, no clip-rect ceiling, and nothing to raise — the stamps were falling off a 1536 × 864 surface the whole time, and a dial anchored near the bottom of a 1200- or 1536-row screen lands well past row 864.
The retracted material claimed a cap of roughly 1.77 – 1.84 M pixels (≈3.5 MB at 16 bpp), derived by
relocating the tachometer through six placements at 1920 × 1200 and 1680 × 1050 and bisecting the clip
bottom to (940, 960]. It was internally consistent, reproduced across two tracks of very different
geometry, and retrodicted every observation then available. It was still wrong: every one of those runs
was cropped identically, so the "cap" was just the surface edge dressed up as a ratio.
⚪ The failed constant hunt is worth keeping, because its failure was the clue. 0x384000 and its pixel
form 0x1C2000 occur zero times in the file; every 0x380000 / 0x1C0000 hit is an unaligned
coincidence inside a VGA palette ramp or float data. Also searched with no result: the value in dwords
(0xE0000), in KB (0xE00 / 0xE10), in 64 KB blocks, and as an immediate in any push/mov/cmp/add form.
The search was exhaustive and it found nothing because there was nothing to find. A thorough negative
result against a well-specified target is evidence the target is wrong, not evidence the constant is
computed at runtime — which is the interpretation that was reached at the time, and it sent the
investigation on to hunting for whatever "re-sets the clip rect" instead of questioning the measurement.
✅ What survives is the mechanism, because it was read from code rather than inferred from screenshots:
stamps are clipped against a render-target rectangle, that rectangle is initialised from the surface's own
dimensions, and an element positioned past bottom disappears silently with no error and no log line. That
is exactly what a too-small surface will do to you, and it is why the symptom was so convincing.
✅ There is now no HUD limit at all. The last one was the rasteriser's 1024-entry edge table (§5.2.4) — ours, not the game's — which bounded out the tachometer needle above roughly 1104 screen rows. Doubling the tables lifted it, and 2048 × 1536 renders dial, needle and banner together. The only ceiling left on the whole resolution patch is the driver's 2048-wide surface cap.
Two further facts constrain where it comes from: the surface allocator (§ above) stores the height
unclamped, so the cap is applied upstream of it; and the back buffer is wrapped at 0x44E60F using
[0x50AF80] / [0x50AFA4] — the same globals the resolution patch writes — so the back buffer really is
full size and the HUD must be drawing to some other surface, not yet identified. Raising the HUD ceiling
is consequently not a one-value edit; it needs the render target identified in a live process, with a
debugger or by instrumenting race.bin to log its clip rect. The static constant search is exhaustive and
negative — it should not be repeated.
5.2.4 The rasteriser crash, and other limits found in race.bin
- Textures are limited to 256 px, and the limit is a fixed buffer layout, not just a size check (this
cap is present in both retail 1.1 and v1.2.5 — the community rebuild did not lift it).
get_mip_levelat0x45BBD4dispatches on size withsub ecx, 0x10; cmp ecx, 0xF0; ja panic(valid 16…256), thenmov dl, [ecx+0x45BCD0](a 241-byte size→case index table) andjmp [edx*4+0x45BCB8](a 6-entry jump table). A second identical dispatcher sits at0x45BFE4. A 512 texture aborts withget_mip_level: can't handle this size dest: %d.
But the handlers reveal the real constraint. Each is a three-line accessor, not an allocator — the 256
case is add esi, 0xAAEC; mov [eax], 0x100; ret. The five offsets are compile-time constants forming a
smallest-first mip pyramid inside a fixed per-texture buffer:
| level | offset | delta = size²×2 |
|---|---|---|
| 16 | 0x000EC |
— |
| 32 | 0x002EC |
512 |
| 64 | 0x00AEC |
2,048 |
| 128 | 0x02AEC |
8,192 |
| 256 | 0x0AAEC |
32,768 |
The 256 level ends at 0x2AAEC = 174,828 bytes, which is the size of the fixed texture-buffer slot
every texture occupies regardless of its actual dimensions (the offsets are literals, not computed).
Scoping 512 (measured, superseding an earlier "~280 byte" estimate that counted only the dispatch):
because the pyramid is smallest-first, a 512 level slots in after the 256 level at 0x2AAEC and the
existing offsets do not move — so the ~10 sites that bake in 0xAAEC/0x2AEC/0xAEC keep working. The
engine-side patch is ~8 pattern-located sites: widen both cmp ecx,0xF0 → 0x1F0; relocate+extend both
241→497-byte index tables into .text slack; add a 512 handler (add esi,0x2AAEC; mov [eax],0x200) and
jump entry at both; and grow the buffer allocation 0x2AAEC → 0xAAAEC at its 2 sites. Two real
catches, though: (1) the slot is fixed-size, so every texture's footprint quadruples 175 KB → 699 KB,
which is wasteful and wants VRAM-tier testing on hardware; and (2) this is only the engine accessor — a
real 512 texture also needs the .tex on-disk format and loader to carry the larger pyramid, and
vrmod/tex.py to stop downscaling to 256 (it currently fits imports to the cap). ⚪ Feasible but a
multi-day mini-project with on-hardware validation, not a quick win; not attempted.
- ✅ The per-object vertex limit is a fixed heap buffer — found, measured, and raised in-game. The
community figure of "20,000 vertices" for v1.2.5 is wrong; the real ceiling is 30,000. At startup the
engine allocates one vertex scratch buffer:
0045031a push 0xEA600 ; 960,000 bytes
00450325 call 0x413fc0 ; malloc
0045032d mov [0x4dc72c], eax ; keep the pointer in a global
Four call sites in the mesh loader (near the FNIM loader at 0x450120) pass that global as the destination
to the per-object transform loop sub_00456e00 (real entry 0x456e20; 0x456e00 is a separate stub). The
loop reads a material's int16 vertex_start/vertex_end (from [rec+0x18]/[rec+0x1a], exactly the
§4.1 fields) and copies each vertex into the buffer at index × 32 bytes — 32 being the .mod vertex
stride. So the buffer holds 960,000 ÷ 32 = 30,000 vertices, and vertex 30,000 writes at byte 960,000,
one record past the end. The fault is the record-copy store:
004576b5 mov eax,[edi+0x18] / mov [ebx+0x18],eax
004576bb mov ecx,[edi+0x1c]
004576be mov [ebx+0x1c],ecx ; <-- EXCEPTION_ACCESS_VIOLATION when ebx runs off the buffer
004576c1 fld [edi+0xc] / fmul [0x510a24] ... ; then transforms the copied vertex
This is why the earlier "no 20000 constant, no static-buffer growth" observation was right yet misleading:
the sizing constant is the malloc byte size 0xEA600, not the vertex count, and the buffer is on the heap.
It is allocated once and reused per object, so the cap is per single mesh and enlarging it costs a flat
+88 KB regardless of car count. Confirmed on hardware: a 32,000-vertex test mesh crashed at 0x4576be;
after 0xEA600 → 0x100000 (1,048,576 B) the identical mesh rendered, lifting the ceiling to 32,768
vertices — the int16 face-index cap of §4.1, the true maximum since faces cannot address more.
This ships as the opt-in vrmod patch --max-verts N (default off): it finds the sole push imm32
whose immediate is a multiple of 32 in a plausible vertex range and is immediately followed by
call/mov [glob],eax (the malloc-and-store), so it locates the site on both builds without hard-coded
offsets. Retail 1.1's buffer is 32,000 B = exactly 1,000 vertices (site 0x44f57a), also below its
quoted 1,200. ⚠️ A test mesh must reference a texture that exists in the
target car — a missing texture makes this engine deref NULL and crash (ResourceGet(...) returning NULL!
then an access violation), which is not "renders white" and briefly masqueraded as the vertex crash here.
One downstream consideration if this raise is exposed as an option (and why it may still be opt-in):
more high-vertex cars — e.g. AI cars chosen via AIcarman — cost rendering throughput on this 1998
pipeline, worst in the near-camera pack, mitigated by the LOD chain (Viper0.mod…Viper7.mod). But
crucially, that cost is graceful slowdown, not a crash — traced and confirmed: rendering is
per-object immediate mode with no scene-wide geometry buffer. The object render loop at 0x450db0
does, per object, mov eax,[0x4dc72c] (this same vertex buffer) → call 0x456e00 (transform into it) →
call 0x453650 (draw) → next object, reusing the buffer. 0x453650 ends in call [eax+0x78] on the D3D
device — IDirect3DDevice2::DrawIndexedPrimitive (slot 0x78; push 4 = D3DPT_TRIANGLELIST, push 2 =
D3DVT_LVERTEX). So every object is transformed-then-drawn before the next overwrites the buffer; nothing
accumulates all visible geometry, so a full grid of heavy cars cannot overflow a shared buffer — it just
runs slower. The only hard geometry cap is this per-object buffer (per single mesh). The "95,000-polygon
track" figure is a separate track-load-time cap on the track's own geometry (loaded once, not a per-frame
car budget), and is not even a literal constant (no 95000 appears in either binary — it is computed or
approximate).
- The built-in Benchmark is broken on v1.2.5 (the only community build tested — earlier ones are
untested, so treat the affected range as unknown). It plays Data/bench.rpl, which is the 1998
replay shipped on the disc; the replay index format changed, so it reports
Bad replay version (idx calculation method changed), and the failure path then unloads resources that
are still referenced ("Viper0.mod" still used by 8) and dies with EXCEPTION_ACCESS_VIOLATION. This
removes the obvious way to measure fill rate.
- ✅ The tall-resolution crash: a stack overflow in the triangle rasteriser — solved and fixed. The
filled-triangle routine (0x44A280) carves two edge tables from a 0x2000 __chkstk stack frame, at
[esp+0x10] and [esp+0x1010]. At four bytes per scanline each holds exactly 1024 entries.
Two separate places index them by scanline, and both bound the index against the render surface's height
instead of against the table.
The edge walker (0x44A420) records one x per scanline:
test ebx, ebx / jl skip ; y >= 0
mov eax, [render_target]
cmp dword [eax+0xC], ebx ; y < SURFACE HEIGHT
jle skip
mov dword [edi + ebx*4], eax ; indexes a 1024-entry table
The fill loop (0x44A3C7) reads one x from each table per scanline:
test edi, edi / jl next ; y*4 >= 0
mov eax, [render_target]
cmp dword [eax+0xC], esi ; y < SURFACE HEIGHT
jle next
mov ecx, [esp + edi + 0x10] ; table A[y]
mov eax, [esp + edi + 0x1010] ; table B[y]
No shipped mode exceeded 768 rows, so neither ever mattered. Past row 1024 they diverge from the tables in different ways, and they need different fixes.
Overflowing the WRITE crashes. It runs off the stack frame: overflowing the lower table spills
harmlessly into the upper one, while overflowing the upper one destroys saved registers and return
addresses — after which execution jumps through the wreckage. That is why the reported EIP is a small
value like 0x4E that changes between runs: it is garbage, not an address in the program. The game's own
trace confirms it — trace: Can't find 0x4e in mapfile.
Overflowing the READ is harmless to memory but not to the picture. At y ≥ 1024 table A's index reaches past its own 4096 bytes into table B, so the span is built from two unrelated x values and the row is filled edge to edge. The symptom is long horizontal streaks in the triangle's colour across the whole screen — red, because the triangle in question is the tachometer needle, and they clear as soon as you drive because the needle swings up out of that band. Fixing only the write turns the crash into these.
The trigger is the tachometer needle, drawn as geometry rather than as a stamp, centred at
y = height − 0x50 with radius 0x32, so it reaches about height − 30. At 1920 × 1080 that is row 1050.
Confirmed by prediction rather than by fitting: with the pivot forced to 960 the needle reaches 1010 and
the game runs; at 980 it reaches 1030 and it crashes. Width is irrelevant — the same geometry crashes at
1600 wide too. Modes that appear to survive worse overruns (1600 × 1200 reaches row 1170) do so by stack
layout luck, which is characteristic of this kind of corruption.
The fix bounds both sites by the table as well as by the surface.
The edge walker has no room for a second test, so its surface check is replaced outright — eight bytes, size unchanged:
cmp ebx, 0x400
jge skip
Safe because it can only ever refuse to record an edge, and drawing is clipped independently: 0x449A00
tests every pixel against all four clip-rect fields and the plot routines honour it, so nothing is drawn
out of bounds just because an edge was not recorded.
The fill loop is different — dropping its surface check would let it hand rows past the bottom of a short
surface to the span drawer, a wider bet than this patch needs to make. So its ten bytes become a jump to a
28-byte stub in .text slack that keeps both tests:
0044a3cb jmp stub
stub: cmp esi, 0x400 ; the table bound
jge skip
mov eax, [render_target] ; the surface bound, kept
cmp dword [eax+0xC], esi
jle skip
jmp 0x44a3d5 ; draw the span
skip: jmp 0x44a3f4 ; next scanline
Rows past the bound therefore contribute no edges and are not filled. Both sites are pattern-located in
vrmod/needlefix.py, the bound is a parameter, and the patch is idempotent per site.
✅ Verified on hardware: 1920 × 1080 crashed on every prior attempt, races with a full HUD after the
first half, and is free of streaks after the second.
- ✅ The 1024-entry limit itself is liftable, and has been lifted. Bounding by the table makes tall
resolutions safe, but nothing below row 1024 is drawn — and the needle pivots at
height − 0x50, so above roughly 1104 screen rows it vanished entirely: at 2048 × 1536 the dial rendered and the pointer did not.vrmod/tablefix.pydoubles the frame instead. The layout, once the prologue has run, is
base+0x0000 pushed ebp, edi, esi, ebx 16 bytes
base+0x0010 edge table A 4096 = 1024 entries
base+0x1010 edge table B 4096 = 1024 entries
base+0x2010 the return address
base+0x2014 arguments
so 0x2000 covers exactly the two tables, and growing it to 0x4000 gives 2048 entries each — enough
for any screen the 2048-wide surface cap allows. That means moving table B and every argument:
| Site | Change |
|---|---|
mov eax, 0x2000 |
→ 0x4000 (the __chkstk request) |
add esp, 0x2000 |
→ 0x4000 (the epilogue) |
[esp+0x1010], [esp+0x1028] |
→ +0x1000 (table B, 6 references) |
[esp+0x20xx] |
→ +0x2000 (arguments, 15 references) |
Table A stays at [esp+0x10]. No instruction changes length — every displacement that moves is already
disp32 — so the function keeps its 409 bytes and every branch inside it stays correct. Cost: 8 KB more
stack per call, on a 1 MB stack. ✅ Verified on hardware at 2048 × 1536: dial, needle and banner all
present, no streaks.
⚠️ The hard part is knowing which displacements are arguments. esp moves constantly here — pushes
before each call, add esp after — so a raw displacement means nothing on its own, and tracking esp
through the function does not work either, because it branches and a linear walk desynchronises. What
makes it tractable is that the displacements fall into two bands with a 0xff8 gap: everything
frame-local is at most 0x1028, every argument is at least 0x2020. The patch asserts that gap, and the
exact 21-reference multiset, before writing a byte.
- ⚠️ Register allocation differs between the two builds, and it has broken two patterns here. The two builds are independent compiles, so the same code uses different registers:
| v1.2.5 | Retail 1.1 | |
|---|---|---|
| Fill loop's index register | test edi, edi (SIB 0x3c) |
test ebp, ebp (SIB 0x2c) |
A pattern matching test edi, edi finds the fill loop on one build and nothing on the other — which is
exactly what happened, and the fill-loop fix silently did not apply to retail until it was caught. The
same issue in the SIB byte found 20 of 21 stack references instead of 21. Match on shape, never on
registers. needlefix now identifies its two sites by a property that survives register allocation: the
edge walker tests and compares the same register, the fill loop does not.
-
The crash handler can be given symbols, and it is worth doing. It looks for a map appended to its own file: it calls
GetModuleFileName, opens and memory-maps itself, walks the section headers to find where the last section ends, and treats everything beyond that as the map. Stockrace.binis exactly 1,300,480 bytes and its last section ends there — zero trailing bytes, henceNo mapfile present. The format is an MSVC 4.0 public-symbols line parsed withsscanf(line, " 0001:%x %s %x", &secoff, name, &addr), all three conversions required, parsing stopping at a line containingFIXUPS. Lookup keeps the nearest preceding symbol and printstrace: byte 0x%x of "%s", so a small byte offset means the address really is inside that function.vrmod/mapfile.pygenerates one by treating every target of a directcall rel32in.textas a function start (~2,950 of them) and appends it; the image is untouched, so every patch site and the loader are unaffected. This turned an unreadable trace into a named frame in one step and is what made the bug above tractable. -
Audio pitch tracks frame rate. The engine sound becomes warbly and high-pitched as the renderer falls behind, which makes it a usable informal performance signal while testing resolutions.
- Retail 1.1 has a constant DirectSound crackle on modern Windows; v1.2.5 fixed it in code. Distinct from
the pitch-tracks-framerate effect above — this is a persistent crackle/stutter present at every resolution
(confirmed at 640×480, where the renderer is not behind at all) and on a bare retail 1.1 (VRAM-startup fix
only, no other patches), so it is inherent to the retail build, not introduced by any patch here. The game
drives its
.sfxeffects through DirectSound (init_directsound, a primary + secondary buffer, thenPlay); legacy DirectSound is emulated since Windows Vista, and retail's buffer-streaming trips it. Comparing retail 1.1'sWaveBeginagainst v1.2.5's, the initialisation is near-identical — same API sequence (DirectSoundCreate → SetCooperativeLevel(4) → GetCaps → CreateSoundBuffer(primary) → SetFormat(primary) → CreateSoundBuffer(secondary) → Play), same cooperative level, byte-identical secondaryDSBUFFERDESC, same rate builder — the only visible difference is that v1.2.5 adds a 44.1 kHz path retail lacks (retail offers only 11,025 / 22,050 Hz). Since the sample rate is not the culprit (11,025 crackles the same as 22,050), v1.2.5's actual fix lives in its continuous mixer-feed code (DSoundMixer), not in setup — which is why it cannot be ported as a small byte patch across the two independent compiles. The clean, general fix (confirmed in game on BOTH pressings): a drop-in DirectSound wrapper.DSOUND.dllis a static import —DirectSoundCreate, confirmed in the import table of v1.1'srace.binand of v1.0'srace.exealike (theirWINMMimports are onlytimeGetTime/timeKillEvent, i.e. timing, not audio) — and Windows resolves static imports app-directory-first (it is not a KnownDLL), so a localdsound.dllshadows the system one with no binary edit. Dropping dsoal (dsound.dll+dsoal-aldrv.dll) beside the engine reimplements DirectSound on OpenAL Soft / WASAPI and eliminates the crackle — verified on retail 1.1 and on retail 1.0. It is non-invasive, reversible (delete the two DLLs), and build-agnostic.
⚠️ It must be the 32-bit (Win32 / x86) build of BOTH DLLs. Viper Racing is a 32-bit process, and Windows will not load a 64-bit DLL into one — it refuses outright and silently falls back to the system
dsound.dll. The crackle is then completely unchanged while the folder looks exactly as though the fix were installed, which is the single most likely way this goes wrong. Observed in practice.vrmod doctornow reads the PE machine type of both files and reports a 64-bit wrapper as an error rather than as "wrapper present".
Where the DLLs go is the engine's folder, which differs by pressing. On v1.1 that is Data\,
beside race.bin. On v1.0 there is no launcher and Data's contents are the install directory, so
they sit beside race.exe — the same folder as the .res and .trk files. See RUNTIME §5.
So the practical answer is either use v1.2.5 (its audio is already fixed) or drop the 32-bit
dsoal wrapper in; the in-engine port is unnecessary.
- Command-line switches. The two binaries carry separate switch parsers — see §5.2.5, which lays out
the launcher→engine flow. The crash handler prints No mapfile present; supplying a race.map (or the
appended map of §5.2.4) makes it symbolise stack traces.
5.2.5 The launcher, the engine, and the "canary" tether CONFIRMED
The game is two programs, each with its own command-line parser, and only one thread binds them.
Viper Racing.exe (the launcher)
├─ parses its OWN switches: -autoplay -no3dfx -nocanary -safe
├─ checks the CD (GetDriveTypeA; "You must have the %s CD inserted to play.")
├─ creates the canary semaphore ("MGI Viper Racing 1998 Canary")
├─ chooses the engine at 0x401000: debug.bin if a debug flag is set, else race.bin
└─ spawns it, forwarding its command line via the format string "%s%s %s"
( <data-dir>\<engine> <args> )
race.bin (the engine)
└─ parses its OWN switches: -Bob -DEBUG -DZV -FLIP -Line -TIM -dedicated
-effect -hlV -impulse -tPV -truncating -volume
So the switches are not one namespace. The four launcher switches are consumed by the launcher and never
reach the engine; the thirteen engine switches are parsed by race.bin itself. Because the launcher
forwards its command line (%s%s %s = directory, engine name, arguments), an engine switch appended to
the launcher rides through to the engine — which is how they are used in practice, since players start the
launcher rather than the engine.
Two details fall out of the spawn path:
debug.bin. The engine name is chosen at0x401000:debug.binwhen a debug flag is set, otherwiserace.bin. The debug engine did not ship, but it is whyrace.binstill carries-DEBUGand its cluster of diagnostic switches — they were driven from this launcher during development.- To default a switch in a patch, patch the parser that owns it. An engine switch is patched in
race.bin(self-contained); a launcher switch in the launcher. No tether is involved either way — the single exception being-nocanary, which sits on the protection boundary below.
The canary is the one tether. The launcher enforces the disc, then passes a token to the engine so the engine knows it came through that check. The token is a named semaphore, not a file or registry key:
- The launcher checks the CD, creates the semaphore
"MGI Viper Racing 1998 Canary", and spawns the engine. race.binat0x411FA0callsCreateSemaphoreA(NULL, 0, 1, "MGI Viper Racing 1998 Canary")thenGetLastError(). It requiresERROR_ALREADY_EXISTS(0xB7) — it insists something else already created that name. If nothing did, it shows a message box titledMGIreadingSneaky user! Where is my canary!and refuses to run.
-nocanary is a launcher switch that skips creating the semaphore — a developer switch for exercising the
failure path, since with it set the engine's own check fails and the game will not start. The semaphore
carries no disc data itself; it is purely a "you came through the front door" flag.
⚠️ This is the game's copy protection, so this section documents the mechanism, not a way around it. The legitimate path is the intended one — a real disc (or a mounted image of your own copy) satisfies the launcher, the launcher makes the canary, and the engine runs. Patching the engine to stop requiring the canary would let it run without the launcher's disc check, which is circumvention; it is also pointless once the ordinary launcher path works.
5.2.6 The physics task and "phobs" CONFIRMED(architecture)
Physics runs as a separate task communicating over shared memory: the strings PhysTask,
shm_PhysicsShmem, phob_list sit together in .data, and a watchdog prints
Physics has not dropped off a packet in %d seconds. The simulated world is a list of phobs — physics
objects — and every dynamic thing in a race is one.
A factory at 0x4228F0 constructs a phob from a FourCC type tag, dispatching to a per-type constructor;
an unrecognised tag prints Unknown phob type: %x. The nine types (tag read big-endian, as written):
| Tag | Meaning |
|---|---|
BALL |
the horn ball (the HornBall hack's object) |
ACAR GCAR NCAR PCAR |
car variants — AI / ghost / network / player |
CHKP |
checkpoint gate |
OBST |
obstacle |
STAT |
static, fixed collider (the PhobStatic string) |
WOBL |
wobble — a movable, knock-over object |
Phobs are not stored in track files — no tag appears in any track archive. They are deserialised from a
byte stream (0x422840 reads the tag and record through the buffered readers at 0x4c63xx/0x4c64xx),
which is the replay/network path: PhysReplay, physrepl::replay_frame and the shared-memory packets are
how the physics task's object set is saved and synchronised. The static world geometry the car drives on
lives in .bpp/.sol; the phobs are the live, moving population layered on top.
WOBL is the knock-over mechanism, and its constructor proves the point: a STAT phob allocates
0x6c = 108 bytes (shape and transform — a fixed collider), while a WOBL phob allocates
0x4b4 = 1,204 bytes, eleven times larger — the room a rigid body needs for velocity, orientation and
integration state so it can topple and settle. This is the "a sign knocks over instead of stopping you dead"
behaviour: such props are WOBL phobs, as opposed to STAT phobs. The body lives only at runtime, but
it is spawned from the track files and wears one of their solids (✅ confirmed 2026-09-17, §4.3). An
obj wobble N record in track.obt builds a Wobble. That wobble claims the .sol TUBE with id N as its
collider and drives it, and draws the .grf facing with id N. The 1,204 bytes fit what Wobble::Update
touches, up to its 20-step timer at +0x4b0. Mass is still not a field you can edit, but shape and size
are: they are the tube's radius and length. obj obstacle records spawn Obstacle phobs the same way,
and theirs DO take a mass: the record's last number (§4.3).
5.3 Audio
.modin this game is not an Amiga/ProTracker tracker module — every.modfile sampled, including ones at the root ofreference-files/that could plausibly be confused for one, carries the0SER/FNIMmesh envelope (§4.1; explicitly re-verified forNeedle.mod,Viperb.mod,Viperc.mod,Viperw.mod,vipers.modin this session — all meshes, not music). This is a real naming collision worth flagging loudly for anyone approaching this game with prior modding experience elsewhere.- Game audio/music itself was not present in the sample set and not investigated.
.sfx(sound effects) and.ens(per-car engine sound crossfade parameters) are both fully identified0SERformats with real confirmed structure — see §4.14 and §4.15 respectively, promoted out of this section once their internals were solved.
5.4 Car config
.cf— compiled car physics/stats config, tagFRAC→ reversedCARF, version=6. ✅ CONFIRMED — field layout solved. A fixed-layout struct of namedfloat32fields (plus oneint32,num_gears), payload-relative and 4-byte aligned: mass and centre of mass, dimensions and track widths, engine torque curve, transmission ratios, per-corner suspension, brakes, and drag/aero. 85 fields recovered byte-exactly by cross-referencing an independent field-name/value listing against the raw bytes against the raw bytes ofviper.cf— every field matched a unique aligned offset with no ambiguity, once ties between identically-valued fields were broken by declaration order and surrounding contiguous offsets. Not every byte is named: several stretches (notably right afterfuel_capacity, and a short run beforecm_height) remain unidentified and must be preserved untouched when rebuilding.- Tyre sizes, 0x1A0–0x1B4 — ✅ identified later, bringing the count to 91. Six
float32s afterwheel_lock, as printed on a sidewall:ftyre_width(mm),ftyre_aspect,ftyre_rim(inches), then the same three for the rear. Pinned againstviper.cf, which reads 275/40 R17 front and 335/35 R17 rear — the real 1996 Viper GTS's tyres. The other stock cars agree with that reading: exotic 280/35 R18 on 330/40 R18, sedan 245/35 R18 all round, sports identical to the Viper, plane 225/50 R15 front on 225/50 R10 rear. What the physics does with them is not yet tested; the Car Configs editor does not show them. - Compliance, tyre scales and static rolling resistance — ✅ traced from the v1.0
race.exe, bringing the count to 100. Unlike everything above, these were not matched against a name/value listing: they were followed through the decompilation, whereWheel::Setupreads each fromCarDataandCarFileCombine(§4.4) fills thatCarDatafrom these.cfoffsets. All arefloat32; F/R = front/rear axle.
| Offset | Fields | Meaning |
|---|---|---|
| 0x188 / 0x18C | ftoe_compliance / rtoe_compliance |
toe change under cornering load, degrees per 1000 lbf of lateral tyre force |
| 0x190 / 0x194 | fcamber_compliance / rcamber_compliance |
camber change under cornering load, same units |
| 0x1B8 / 0x1BC | fgrip_scale / rgrip_scale |
multiplies the tyre's lateral and longitudinal friction |
| 0x1C0 / 0x1C4 | ftyre_stiffness_scale / rtyre_stiffness_scale |
multiplies the tyre's cornering and longitudinal stiffness |
| 0x1DC | static_rolling_resistance |
a rolling drag that stays constant once the wheel is turning (it fades in below 10 rad/s); the speed-dependent one is rolling_resistance at 0x1D8 |
The engine turns compliance into radians per newton and multiplies it by a low-passed copy of the
wheel's lateral force, so it steers and cambers the wheel in proportion to how hard it is cornering.
Across every .cf in the stock and community cars on hand (58 copies), the scales are 1.0 and static rolling
resistance is 0.0 everywhere. Only plane.cf sets compliance, at toe 0.09 / 0.2 and camber
0.6 / 0.6. None of these has been tested by editing it in game, and the Car Configs editor does not show
them.
- Upgrade slots, fixed gearbox and aero-kit spoilers — ✅ traced from the v1.0 race.exe; every one of
the payload's 138 dwords is now named. All from CarFileCombine, with meanings pinned by what
viper.ugs writes into each slot (§4.13: an upgrade is a list of .cf offset/value pairs poked into the
loaded .cf). Every retail .cf leaves all of them 0; they exist to be filled by upgrades.
| Offset | Fields | Meaning |
|---|---|---|
| 0x60–0x74 | power_mult1–power_mult6 |
multiply power and torque; 0 counts as 1.0 |
| 0x78–0xA4 | power_add1–power_add12 |
horsepower added by engine parts |
| 0xA8–0xC4 | mass_add1–mass_add8 |
pounds added to mass (negative = weight reduction) |
| 0xE8–0x104 | gear_ratio1–gear_ratio8 |
a fixed gearbox; see below |
| 0x218 / 0x21C | fspoiler_low_drag / rspoiler_low_drag |
spoiler values for aero kit 1, Low Drag |
| 0x220 / 0x224 | fspoiler_downforce / rspoiler_downforce |
spoiler values for aero kit 2, High Downforce |
Power. Both power_max and torque_max are multiplied by drivetrain scale (setup field 1) × every
non-zero power_mult × (1 + Σpower_add / power_max). Each Viper engine part owns one power_add
slot: AirInduction +8 hp (1), GlassPackMuffler +5 or ExhaustSystem +22 (2, the same slot, so the
better part replaces the lesser), Headers +31, ThrottleBody +22, PowerControlModule +17, PortedHeads +40,
PushRodKit +30, CustomTiming +45, FuelInjectors +35 (3–9). WeightReduction 1–4 own mass_add1–4
(−150, −203, −324, −300 lb). No upgrade uses a power_mult, power_add10–12 or mass_add5–8.
Gearbox. If gear_ratio1 is non-zero, gear_ratio1–8 replace the setup's gear sliders; if it is
0 (every shipped .cf) the gears come from the setup. num_gears then zeroes gears 5–7 on cars with
fewer. CloseRatioGearbox writes a fixed set, and RacingGearbox writes zeros, which is how it makes the
gears adjustable. gear_ratio8 is never read by the physics (reverse is gear 1 negated), yet
viper.ugs's StockSetup writes 2.90 there, the real 1996 Viper's reverse ratio: most likely a reverse
ratio that was never wired up.
Aero kit. The setup's aero-kit enum (field 28) picks the spoiler values: Adjustable lerps
fspoiler1..2 by the spoiler slider; Low Drag and High Downforce use these fixed pairs instead,
unless the pair is 0. The spoiler value is then added to front_lift/rear_lift, and its magnitude ×
fspoiler_drag/rspoiler_drag becomes extra drag. Only the front value is tested. Every retail car ships
zeros, so all three kits drive the same slider. Many community cars ship −0.001 / −0.001 (Low Drag) and
−0.9 / −0.9 (High Downforce), and the SportsAeroKit upgrade rewrites all four.
Nothing here has been tested by editing it in game.
5.5 The remaining loose files in Data/
Inventoried from the retail Data/ folder. Several have since been opened — race.bin most substantially,
which turned out to be the engine itself and now has its own section (§5.2) — and the rest are flagged here
so they aren't forgotten:
| File | Size | Guess |
|---|---|---|
race.bin |
1.3 MB | ✅ Opened — it is the game engine, a full PE executable, not a container. Viper Racing.exe is only a launcher. See §5.2 for the video-mode selection, the VRAM check, the viewport/FOV handling and the texture limit. |
bench.rpl |
149 KB | ✅ Identified — the benchmark replay, dated September 1998 and shipped on the disc. Its index format predates v1.2.4, so the community builds cannot load it and the in-game Benchmark crashes (§5.2.4). Internal structure not mapped. |
english.lng |
37 KB | 🟡 Partly opened — it is a UI string table, holding dotted key/value pairs in plain ASCII, e.g. key Tracks:Bemidji:Name followed by its value Bemidji, then Tracks:Bemidji:Description. Values are editable in place (overwrite, space-pad to the original width) and the game accepts the result; the length/terminator encoding is not yet mapped, so values cannot safely be grown. This is how the eight track slots' in-game display names are set — see below. |
options.def / tune.def |
2.1 KB / 271 B | ✅ Plain ASCII key/value lines, one setting per line (sky_texture yes, video mode 2). options.def holds the shipped defaults; the live settings the game reads and writes are Config/options.cfg in the same format — a distinction worth respecting, since reading the .def reports the factory value forever. |
intro.avi |
45.6 MB | Standard video container, out of scope for this catalog. |
Track slot names. The game ships exactly eight track slots, and the internal filename does not
match the name shown in-game — a persistent source of confusion when working with track files. The
mapping, read from english.lng's Tracks:<Slot>:Name keys:
.trk file |
In-game name |
|---|---|
bemidji |
Bemidji |
dundas |
Dundas |
hastings |
Ridge Valley |
heaven |
Castlegreen |
kenyon |
Rock Island |
limbo |
Dayton |
nfield |
Sunset Mesa |
uptown |
Silverdale |
Each slot's menu screenshot is a separate 180×120 .stp named after the file (bemidji.stp, …) stored
inside ui.res, while its minimap is the Trackmap.stp inside the track archive itself (§4.6).
6. Coordinate systems & units cheat sheet
| Domain | Units | Notes |
|---|---|---|
Everything: car meshes, track geometry, AI paths (.ili/.ild), object placement (.obt) |
meters | One shared scale — cars and tracks are drawn in the same world, so they cannot differ. Viper0.mod measures 4.43 × 1.92 × 1.10 against the real Viper GTS's 4.45 × 1.92 × 1.12 m. Cross-checked against the game's own Track Info screen: it reports Bemidji as 1.5 miles, and that track's three stored driving lines measure 2,268 / 2,113 / 2,324 units — 1.41 / 1.31 / 1.44 miles read as meters (a centreline runs slightly longer than any driving line). Read as feet the same lap would be 0.43 miles, which the game's own figure rules out. No conversion between car and track space. |
| AI path Z-axis | same as the mesh data — no flip | Confirmed by raycasting path points onto the track geometry, which lands them on the road surface unflipped. A flip is only ever needed to compensate an exporter that mirrors an axis. |
| LOD levels | Viper0.mod (highest detail) … Viper7.mod (lowest) |
8 levels total, one file per LOD. Switch distances live in the per-car <prefix>L.tab (§4.3): viper's are 10/15/20/60/80/100/200/1000 m, scaled by a global LOD Factor. Selection is by camera distance, not by screen. |
| Texture color depth | 16-bit RGB565 (base level, §4.5) | ✅ Confirmed byte-exact. |
| Sky strip angular width | ~90°, tiled 4× around (§4.5.1) | The four sky*.tex are one strip, not one full turn. Confirmed by counting four copies of a unique feature while turning a full circle in game. |
7. Suggested next steps, roughly in order of value
The package layer (§3) is now fully solved — the biggest gap from the previous pass — so the priority list has shifted almost entirely to payload internals.
- Disassemble
race.bin's resource loader. ⚠️ Note the correction:Viper Racing.exeis only a launcher — the engine, and everything worth disassembling, israce.bin(§5.2). A disassembly pass has already solved the video-mode, VRAM and HUD paths there, so the approach is proven: load it as a normal PE at ImageBase0x400000, find a diagnostic string, scan for the 4-byte little-endian VA that references it, and disassemble backwards from the reference. Every remaining ⚪/❓ item in §4 (.sol,.adr,.dnt,.ugs,.stp's compressed variant) is parsed by exactly this binary at load time — and.bpp(§4.9) is now a worked example of exactly this method paying off — searching for the"0SER"/"0TSR"strings and the FourCC constants from §2 as call-site anchors should get you to the real struct layouts fast, far faster than continued black-box guessing. Also worth it specifically for.tex: confirming whether realflags=0x03alpha textures truly use the same ARGB4444 packing a syntheticflags=0x02test confirmed (currently only visually, not byte-exactly, verified). (.ensand.sfx's PCM path no longer need this — both solved black-box, see §4.14/§4.15.) - ~~
.grf's scene-graph header~~ — SOLVED (§4.7), by exactly the loader-reading template that cracked.bppand.sol. Nodes can now be appended, not just patched in place, andvrmoduses that to author wobble models (§4.3). What remains is much narrower: node types 0, 1 and 7 are walked but their payloads are undecoded, and the fourth and fifthmrModelInfoarrays (+28,+30) are zero in every chunk measured, so they are unexercised rather than understood. .ccsfield mapping — deferred until custom test tracks and tooling exist to isolate single-field changes with a controlled before/after comparison; ad hoc in-game and cross-track diffing so far has been inconclusive or confounded..dnt/.adrfield mapping — now that we have the fulldrivers.resdirectory (§4.11/4.12), cross- referencing.dntvalues against observable AI driver behavior (aggression, braking, tier-to-tier speed differences) at different skill levels could crack this fairly quickly with the game running.- Finish
english.lng's string table (§5.5) — confirmed to be a dotted key/value ASCII table and editable in place, but the length/terminator encoding is unmapped, so values can't be lengthened. Mapping it would lift the current cap on in-game track names. - Audit the rest of the drawing code for the same shape (§5.2.4). Three separate bugs in this one
rasteriser came from the pattern "bounded by the surface, indexes a fixed-size table", and the third was
found only because the second left visible streaks. Nothing else in the drawing code has been checked for
it, and
0x449A00's clip test is the natural next place to look. - 512 × 512 textures (§5.2.4) — feasible but a multi-day mini-project, not the "~280-byte" quick win an
earlier estimate suggested. The engine side is ~8 pattern-located sites (both
get_mip_leveldispatchers, both index tables, the buffer allocation), but the 256 limit is a fixed per-texture buffer layout, so it quadruples every texture's footprint (175 KB → 699 KB) and needs the.texformat, loader, andtex.py(which currently downscales to 256) to carry the larger pyramid too. Wants on-hardware VRAM testing. It is the one enhancement neither retail nor v1.2.5 provides, which is what makes it worth considering despite the cost. .sfx's ADPCM path (format_tag=2) — the compressed-sample bit layout itself, not just the header (§4.14). Genuinely deferred rather than solved: no real sample in the retail data uses it, so there's no real-world payoff, only a synthetic test case to validate against.
9. Sources and method
Primary source. Every format claim above was derived from a redump-verified, untouched retail copy of
the game (USA, Rev 1), by direct binary inspection of the .trk/.car/.res archives in Data/. Where a
claim is marked CONFIRMEDit is backed by byte-exact accounting against those raw bytes — typically a
full parse→rebuild round-trip reproducing the original file byte-for-byte.
One documented claim is behavioural, not structural. The sky strip's 4× wrap (§4.5.1) cannot be recovered from the files at all — nothing in an archive records it — so it rests on watching the running game instead, and is labelled accordingly. The layout half of that section (four tiles forming one continuous strip, and their seam continuity) is measured from retail bytes like everything else.
§5.2 was derived differently, and its provenance is worth stating plainly. The executable section rests
on two legs: static disassembly of race.bin (capstone, x86-32, PE mapped at ImageBase 0x400000), and
running the patched game on real hardware and photographing the result. Claims marked ✅ there are ones
where a patch was applied and the outcome observed in a race — not ones that merely look right in a
listing. Print Screen is the measuring instrument.
⚠️ That instrument is not trustworthy by default, and this was the single most expensive mistake in the section. The assumption above — "an exclusive-fullscreen game switches the display mode, so a capture comes back at the true render size" — is false on a high-DPI display, where the game silently draws into a virtualised surface and the capture shows that surface upscaled (§5.2.0). It produced an entire phantom finding (a horizontal shift in the projection) plus a probably-phantom one (the HUD pixel budget), and it survived three rounds of measurement because the numbers were consistent — consistently measuring the wrong thing. Calibrate the instrument before trusting it: find something whose true screen rectangle the game logs, measure where it actually lands, and only then measure anything else.
Two methodological lessons are embedded in that section and are worth repeating, because both cost real time here. First, a control that cannot fail proves nothing — early "successful" resolution tests were stock modes that had never been edited, which made a patch that changed nothing look like it worked. Second, when several tests fail, check whether they share an input before theorising about the environment: three consecutive failures turned out to be the same menu index rather than three independent limits, and two hypotheses (colour depth, aspect ratio) were built on that mistake before it was caught. The measurements that finally settled the HUD budget worked because each run changed exactly one variable while holding a known-good value on the other axis.
A third lesson, learned expensively. The executable work in §5.2 produced a long run of confident,
plausible, wrong explanations — colour depth, aspect ratio, a height cap, a memory budget, a DirectDraw
failure, a text-renderer bug, and two different versions of the rasteriser story. Every one was reasoned
from a correct reading of real code, and every one was false. What actually solved it was bisection on
the running game: disable half the suspect code, race, keep the half that still crashes, repeat. Nine runs
took it from "1920 × 1080 is unexplained" to a single call, then a parametric sweep of one coordinate took
it to a single constant. Two further hazards are worth naming because both produced convincing fiction:
disassembling from an address without checking instruction alignment (x86 self-synchronises into
readable garbage — a "function" that begins shr ebp, 1 is a misalignment, not a discovery), and
disassembling the wrong build (crash logs come from the installed v1.2.5; most static analysis here is
retail 1.1, and the same address is unrelated code in each). When a crash is reproducible and the suspect
region is bounded, bisect the binary rather than read it.
Independent cross-checks. Several conclusions were additionally checked against outside implementations of the same format. These were used to corroborate conclusions already reached from the bytes, never as a source of format facts, and none of them is required to reproduce anything documented here. They're named so the provenance of those specific claims is auditable.
| Cross-check | Corroborated |
|---|---|
| Per-object mesh extraction (3DSimED) | §4.7's .grf chunk record layout — each scenery object dumped as a standalone .mod to compare position/UV/face data against. |
A reference .tex encoder (mktex.exe) |
§4.5's colorkey nudge rule, the ARGB4444 alpha packing, and mip-chain downsampling. |
A reference .stp encoder/decoder (MKSTAMP.EXE, Stp2Tga.exe) |
§4.6's palette and row layout. Note the encoder only ever emits the uncompressed variant, so it cannot reveal the compressed one. |
A reference .sfx encoder (mksfx.exe) |
§4.14's PCM path and the ADPCM observations. |
A car-renaming utility (vrcarrenamer.exe) |
§3's 10-character car-prefix ceiling, which it hard-codes in its own validation. |
A .cf-to-text converter |
§5.4's 85 field names and their real-world values. |
| A track-management utility | The slot-to-display-name table in §3, which is otherwise read from english.lng directly. |
Formats used outside the retail data. These are not game data and are not covered by this reference; they're defined here only so that passing mentions elsewhere resolve.
| Name | What it is |
|---|---|
.tra |
Add-on track distribution format used by the modding community. Byte-for-byte the same 0TSR container as .trk (§3), differing only in using the no-split header layout. Installed by swapping it over one of the eight retail track slots. |
Loose <track>.stp |
A menu screenshot shipped beside an add-on track, in the truncated !IGM form described in §4.6, rather than packed into an archive. |
Third-party .car packs |
Vehicle conversions repackaged with community tools. Their only significance here is that they are the files exhibiting §3's no-split archive layout. |
8. File type index (quick reference)
| Ext | Tag (on disk → reversed) | Contains | Confidence |
|---|---|---|---|
.mod |
FNIM → MINF |
3D mesh (car parts, track props), all LODs | ✅ solved |
.ili / .ild / .ilg |
NILI → ILIN |
AI driving line / checkpoint line / per-AI-tier line | ✅ solved |
.tab |
BATS → STAB |
Broadcast camera definitions (ASCII records) | 🟡 well-supported |
.obt |
BATS → STAB |
Placed objects: grid slots, checkpoint gates (ASCII records) | 🟡 well-supported |
.ccs |
0SCC → CCS0 |
Config, 35 floats, fields undecoded; identical across all cars, varies per track | 🟡 header only |
.tex |
XET → TEX |
Compiled texture — opaque and colorkey round-trip (decode+encode) and full mip chain layout solved; full-alpha well-supported | ✅ / 🟡 mixed |
.stp |
PMTS → STMP |
2D sprite ("stamp"): all UI art, track minimaps, track-select screenshots, cursors, multi-frame strips | ✅ decoded (149/211 files); a 2nd compressed variant ❓ |
.grf |
FARG → GRAF |
Track/world geometry + material bindings | ✅ read, in-place write, and build from nothing (grf.build); confirmed in game (§4.7) |
.sol |
LBOS → SOLB |
Collision solids — BOX/SPHR/TUBE primitives (§4.8) | 🟡 well-supported |
.bpp |
TPPB → BPPT |
Collision BSP — triangle soup + 2D BSP over XZ. Largest per-track file (§4.9) | ✅ confirmed |
.bsp |
TPSB → BSPT |
Near-static 128-byte stub; likely vestigial | ✅ identified as stub |
.adr |
rDIA → AIDr |
AI driver definitions (master/core record) | 🟡 identity only |
.dnt |
TNDA → ADNT |
Per-AI-tier driver tuning parameters | 🟡 identity only |
.ugs |
SGPU → UPGS |
Car upgrade slot/stat data | 🟡 identity only |
.tga |
(none — plain Targa) | Editable texture source | ✅ solved |
.res / .car / .trk |
0TSR |
Archive container — directory + payload blocks | ✅ solved |
.cf |
FRAC → CARF |
Compiled car physics/stats — 85 named fields, read+write | ✅ layout solved (some reserved runs unnamed) |
.sfx |
0XFS → SFX0 |
Sound effect: envelope + WAV-fmt-shaped header + raw PCM + fixed 4096-byte pad, PCM path solved; ADPCM header understood, sample bit layout not | ✅ / 🟡 mixed |
.ens |
XFSE → ESFX |
Per-car engine sound crossfade parameters: fixed 228-byte buffer, recordCount + up to 8 7-float records, structure solved, field semantics inferred | 🟡 well-supported |
.bin |
MZ (PE executable) |
race.bin — the game engine itself, not a container; Viper Racing.exe is only a launcher. Video-mode selection, VRAM check, viewport/FOV handling, texture limit (§5.2) |
✅ / ⚪ mixed |
.def / .cfg |
(none — plain ASCII) | options.def / tune.def shipped defaults; Config/options.cfg the live settings the game reads and writes |
✅ solved |
.lng |
(none — plain ASCII) | english.lng — dotted key/value UI string table, editable in place; length encoding unmapped so values can't be grown (§5.5) |
🟡 well-supported |
.rpl |
(unknown) | bench.rpl — the Sept 1998 benchmark replay; identified, internals not mapped. the index format changed at some point before v1.2.5, which crashes loading it (§5.2.4) |
🟡 identity only |
Compiled from direct binary analysis of two sample sets: a redump-verified, untouched retail copy of the
game (USA, Rev 1 — redump.info/disc/106242), covering all 26
.trk/.car/.res archives from its Data/ folder, verified byte-exact against the raw archive bytes;
and an earlier extracted sample set (reference-files/: 8 tracks — bemidji, castlegreen, dayton, dundas,
ridge-valley, rock-island, silverdale, sunset-mesa — plus the stock Viper car).