Hot Wheels Mechanix - the post-spec baseline: a validated implementation, what the specification got wrong, and a playable starting point
  • C 66.9%
  • Python 30%
  • Shell 1.5%
  • PowerShell 1.1%
  • Makefile 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Alex Scott 4cbfd0cf09 Stop baking one machine into a repository other people will read
Two paths and a hostname that were fine while this was local and are not
once it is published.

The OMT toolkit location is looked for in three places now -- OMT_TOOLKIT, a
gitignored one-line tools/omt_path.txt for a fixed local checkout, then a
sibling directory beside the repo -- instead of a hardcoded path on one backup
drive.  The game directory is the same: pass it on the command line as the
README already says, or set MECHANIX_GAME, and the fallback is a plain `game`
directory rather than somebody's home.

Also drops the machine name from PROGRESS.md.  It said which desktop the work
was done on, which is nobody's business and no help to anyone reproducing it.

Verified after: every tool parses, the census runs, and a full extraction of
level 1 comes out identical.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 20:49:08 -04:00
src A garage, nineteen cars with parts, and a README written for whoever picks it up 2026-08-24 20:33:34 -04:00
tools Stop baking one machine into a repository other people will read 2026-08-24 20:49:08 -04:00
traces A headless Mechanix simulation written from the spec, and what the spec did not say 2026-08-24 14:52:56 -04:00
.gitattributes mxplay: all seven levels, the level's music, gauges, and wheels the right way up 2026-08-24 16:56:21 -04:00
.gitignore Stop baking one machine into a repository other people will read 2026-08-24 20:49:08 -04:00
CONTRIBUTING.md CONTRIBUTING: what is awefan's, and what is not 2026-08-24 20:41:51 -04:00
HANDOFF.md A garage, nineteen cars with parts, and a README written for whoever picks it up 2026-08-24 20:33:34 -04:00
Makefile Sound, and the write-up for both passes 2026-08-24 19:20:53 -04:00
PROGRESS.md Stop baking one machine into a repository other people will read 2026-08-24 20:49:08 -04:00
README.md CONTRIBUTING: what is awefan's, and what is not 2026-08-24 20:41:51 -04:00
SPEC-GAPS.md SPEC-GAPS 27: the heading still described a band that no longer exists 2026-08-24 19:57:40 -04:00

mechanix-baseline — the post-spec baseline

This is a starting point, not a finished engine, and not a statement about what the game should be. Take it, build on it, change whatever needs changing.

Nothing here is a claim on the design. Where it says a wall is 80 degrees or a lap counts on a gate crossing, that is what the measurements and the owner's reading of the original came to on this pass — not a ruling. If it does not match the game you want to build, change it; the parts that are actually measured against captures are marked as such throughout, and everything else says openly that it is a choice.

A working implementation of the Hot Wheels Mechanix vehicle and race simulation: a deterministic C99 core validated frame-by-frame against captures from the original game, plus mxplay, which drives that same core in real time with the game's own levels, cars, sounds and HUD.

It was written from the published specification in mechanix-re — and the point of writing it was to find out where that specification is silent, ambiguous or wrong. There are thirty entries, and they are all written down.

Why this exists at all

Partly for the gaps list. Partly for a reason that has nothing to do with code: so that the mistakes in the specification are the spec author's to carry, and not yours.

The spec set was derived from the original's disassembly. Building an implementation from it, driving that against fresh captures and writing down every place it turned out to be wrong is work that has to touch the disassembly side to be checked — and the whole point of the clean-room arrangement is that the person building the engine does not. So it was done here instead. What crosses the wall is this repository and SPEC-GAPS.md: measurements and prose, findings and corrected numbers, and none of the analysis that produced them.

Read that as an invitation rather than an instruction. You did not write the spec, you are not answerable for its errors, and you are not bound by the readings this repository settled on.

And in the other direction: none of this runs without awefan's work. The OMT parsers that decode every level, car, texture and sound here are his, out of years of modding AWE Games titles, and his own Mechanix engine was the oracle that settled four things in this extractor that would otherwise have been guesses. CONTRIBUTING.md sets out exactly which parts are whose, and which mistakes are not his.

Why "post-spec"

The specification was derived from the disassembly and never run. Implementing it and driving the result against real captures turned up a long list of things it does not say, says ambiguously, or says incorrectly — and a few things the earlier passes of this repository got wrong too, which are corrected in place rather than quietly edited.

The list is SPEC-GAPS.md, and it is the primary deliverable. Thirty entries. A sample of what changes if you build straight from the spec:

the spec says what the game does
§4 covers throttle and brake it says nothing about coasting, and a car with no key held decays at exactly 0.5 * speed per second (#6)
§13.2 gives |v| from speed reverse is a different law entirely|v| = |speed|, no 1.7 multiplier (#8)
§4 says turbo raises the cap "while held" the key is not the gate. Held for 1148 frames, the cap moved for 0 of them; and while it is engaged, |v| = 1.9 * speed and the turn rate is ×1.5 (#3)
§13.4's yaw uses the steering angle it uses the angle after this frame's integration, not before (#2)
§16 says the path nodes are the vpointp markers they are not. 29 nodes against 20 markers, and no node lands on one (#21)
§16/§17 treat checkpoints as positions they are quads you cross, two triangles each, on all seven levels (#25)
a face's material is a texture key it is also what the face is — water, slowed ground, a sound trigger, a marker (#24)

And the corrections this repository made to itself: every captured trace turns out to be from level 2, not level 1, which voids one earlier finding outright and re-keys another (#30).

What the spec got right is listed too, at the bottom of that file — including the parts that came out bit-exact, because a gaps list that only records failures misrepresents the document.

What you get

mxsim headless. Replays a trace, emits per-frame state, and tools/mxcheck.py reports per-law residuals against the capture. Steering is exact to the bit over 22,792 steps; position is exact to float32.
mxplay the same core with a clock, a software rasteriser, all seven levels, nineteen cars with bolt-on parts, a garage menu, sound and the original's HUD art.

mx_car.c, mx_race.c and mx_trig.c are shared between them, unchanged. tools/crosscheck.sh builds both toolchains and compares all eight traces byte for byte, so the physics on screen is the physics in the residual table. Anything mxplay adds that the specification does not cover — water drag, wall sliding, suspension, body lean, audio — is applied after mx_dynamics, in mx_play.c, never inside the model. If you change the model, run tools/crosscheck.sh; if it stops passing, the table stops describing the game.

Where it is honest about not knowing

Every chosen number says so where it is defined, and SPEC-GAPS.md says why it could not be measured. The water drag, the wall scrub, the lean gain and the HUD layout are reconstructions. The AI opponents are a stand-in — §19 says the original's AI is its own routine and that synthetic key presses will diverge, and synthetic key presses are exactly what these are. Two effects were measured and deliberately not implemented — the cornering speed loss and the gradient term — because their coefficients came out of a regression, and fitting a number that makes one trace pass is the specific mistake this project has retracted before.


Provenance, and a boundary that has since moved

This started as dirty-side work: whoever wrote it has read the original's disassembly, because the specification it was written from was derived from that disassembly by the same project. mechanix-re's PROCESS/CLEAN-ROOM.md sets the rule for that case — its own repository, no code copied into the spec, and the independent implementer neither given it nor reading it.

The owner has since directed that the three strands merge: the specification (mechanix-re), this baseline, and awefan's engine are to be combined and built on. That is the owner's call to make, and it retires the "must not be given it" half of the rule. What has not changed:

  • no code from here is copied into mechanix-re. The spec carries measurements and prose, and SPEC-GAPS.md is the thing that crosses;
  • no game assets are committed here. out/track1.mxt and out/car.mxm are derived from the game's own containers and are built from your copy, not shipped.

Where this repository has used awefan's published data, it has used it as an oracle rather than a source: the tools/check_*.py scripts compare, and nothing of his was copied in. CONTRIBUTING.md has the full account — what is his, what those checks settled and with what numbers, and what is emphatically not his.

Getting started

Nothing derived from the game is committed here — no meshes, no textures, no audio. The repository is code and measurements; the assets are built from your own copy of the game in one command, and stay on your machine.

You need: a copy of Hot Wheels Mechanix, Python 3 with Pillow and NumPy, the OMT asset toolkit (awefan's -- these tools import it rather than reimplementing its material resolution, which the naive lookup gets wrong for most meshes), and a C99 compiler. mxplay is Win32 and builds with mingw; mxsim, the headless one, builds with anything.

# 1. tell the tools where the OMT toolkit is (only if it is not in the default place)
export OMT_TOOLKIT=/path/to/omt_asset_toolkit

# 2. build the assets from YOUR game directory -- the one with level1.omt in it
sh tools/extract_all.sh   /path/to/game     # 7 levels, 7 skies, 19 cars, 13 parts
sh tools/extract_audio.sh /path/to/game     # music per level, 102 effects
python3 tools/extract_hud.py /path/to/game out/hud.mxs    # the HUD artwork

# 3. build and run
make play          # -> mxplay.exe   (i686-w64-mingw32-gcc)
./mxplay.exe

That drops you in the garage. Arrow keys move up and down the list and change the value on a row; Enter starts the race; Esc quits.

row what it does
LEVEL all seven, by name — City, Tiki, Beach, Construction, Moon, Pluto, Asteroid
CAR all nineteen out of Vehicles.omt
WHEELS the four tyre sets. The car re-fits itself around them: the radius and the mounts are recomputed
ENGINE / WING / NITRO the bolt-on parts, or none
RATING 25300. This one is real — SPEC 3 turns it into accel, cap and the turbo cap, and the line under the menu shows what you are about to get
LAPS 112
AUTOPILOT let it drive itself, which is the quickest way to see a whole circuit

In the race: arrow keys drive, X turbo, A toggles the autopilot, C cycles the chase camera, R respawns, 17 jump straight to another level, M music, S effects, W wireframe, B shows the faces the collision test treats as walls, P pause, F1 hides the help, F2 back to the garage, Esc quits.

If a level will not load, mxplay says which file is missing and which command builds it. --race skips the garage and --level N --autopilot goes straight to a self-driving lap, which is what the screenshots in this README were taken with.

What to look at first

If you are here for the reverse engineering rather than the driving, SPEC-GAPS.md is the point of the repository and HANDOFF.md is what is still open. The single most useful finding for anyone building on the level data is #24: a face's material says what the face is — not just which texture it wears — and tools/material_census.py prints that vocabulary for every level.

Building by hand

C99, no dependencies beyond libm. make play is the one you want for the game; the rest is the headless side.

make

On the machine the traces were captured on there is no native compiler, so the build goes through mingw in WSL and produces a 32-bit Windows binary — fitting, since the original is 32-bit x86:

make win        # -> mxsim.exe, via i686-w64-mingw32-gcc

-ffp-contract=off is not optional. With contraction on, a compiler may fuse a multiply-add and change the rounding, and §5's steering is checked for bit-exactness.

Run

# replay a trace's (elapsed, input) sequence, free-running from frame 0
mxsim replay traces/s8_lap.csv -o out/s8_closed.csv

# one-step predictions with each law fed only recorded inputs -- this is what a
# per-law residual means, and what the table below is measured in
mxsim replay traces/s8_lap.csv --law -o out/s8_law.csv

# the same, with the corrections this exercise measured switched OFF, so the
# difference they make is measurable rather than asserted
mxsim replay traces/s8_lap.csv --law --spec-literal -o out/s8_literal.csv

# SPEC 17's race accounting against a full six-lap race
mxsim race traces/race01.csv --laps 6 -o out/race01_check.csv

Validate

python tools/mxcheck.py traces/s8_lap.csv out/s8_law.csv     # per-law residuals
python tools/mxcheck.py traces/s8_lap.csv --self-test        # instrument check
python tools/sweep.py                                        # every trace, one table

tools/mxcheck.py contains no model. It compares two CSVs — the frozen trace and mxsim's output. A checker that restates the laws in Python is testing a second implementation of the same belief, and this project has a recorded history of instruments that agreed with whatever they were built alongside. --self-test compares a trace with itself and must produce exactly zero everywhere; run it whenever the checker changes, because a clean pass on a negative is a claim about the instrument until it has failed on a positive.


Residuals

python tools/sweep.py, one row per scenario. free is frames with no contact, not airborne, not at the cap, no mid-frame parameter change, and speed above 200 (where §13.4 states its measurements apply); straight is those of them with the steering centred, which is the population §4's longitudinal law is a claim about.

trace frames free straight steer max pos med speed med |v| med yaw med speed med, turning
s1_accel 2500 304 304 0 0 0.287 0.362 0
s2_corner 2500 1502 71 0 0 0.104 0.138 0 9.25
s4_air 3000 422 422 0 0 0.112 0.062 0
s5_oob 3000 636 636 0 0 0.339 0.339 0
s6_brake 2600 1944 1944 0 0 0.232 0.578 0
s7_turbo 2600 142 142 0 0 0 0.000064 0
s8_lap 4000 1359 470 0 0 0.837 4.32 0 12.25
s9_coast 2600 260 260 0 0 0.245 0.223 0
trace01 1500 305 170 0 0 0.372 1.01 0 9.69

Against the targets this was to meet — the ones the specification's own laws already achieve:

law target achieved
steering integration exact (0.00000) 0, all 22,792 steps of all eight traces
position advance ≤ 0.001 units median 0 — exact to float32 on free-running frames
speed update ≤ 1.0 median, free-running 0 0.84
velocity magnitude ≤ 1.0 median, free-running 0 1.01, except s8_lap at 4.32

s8_lap's velocity residual is the one number above target. It is not a modelling error: the same law applied to the trace's own recorded speed gives 3.12 on the same frames, so 3.12 of the 4.32 is the specification's law disagreeing with the game, not this implementation disagreeing with the law. s8_lap is the fastest trace (speed to 2228, five different ratings, turbo engaged) and the disagreement grows with speed.

The turning column is the headline finding rather than a defect: §4's longitudinal law has no steering term, and the original loses a great deal of speed while cornering. Ten to twelve units of median error where a straight frame gives 0.1 is the size of what is missing (SPEC-GAPS.md #7).

What the measured corrections buy

--spec-literal implements SPEC/VEHICLE-MODEL.md as written; the default adds the three corrections this exercise measured (SPEC-GAPS.md #2, #3, #6, #8). The difference:

correction population spec-literal corrected
#2 yaw uses the post-integration steering angle s8_lap, 1479 frames where the steering is ramping yaw median 0.0778° 0
#3 |v| = 1.9 * speed while boosted s8_lap, free-running straight |v| median 5.92 4.32
#6 speed -= dt*0.5*speed when coasting s9_coast, free-running straight speed median 0.539 0.245
#6 (same) s6_brake speed median 0.348 0.232
#8 |v| = |speed| in reverse s6_brake, free-running straight |v| median 208.5 0.578

Correction #2 costs nothing while the steering sits on its ±45 clamp, which is most of a key-held corner — which is exactly why it is easy to miss.

SPEC-GAPS.md #11, the float32 rounding of intermediates, is not switchable: it is a statement about arithmetic rather than about behaviour, and without it nothing else can be judged, because it puts a one-ulp floor under every residual in the table.

Free-running replay

Fed only frame 0 and then the (elapsed, input) sequence, the model diverges — as it must, with an unmodelled cornering loss, an unmodelled gradient term and no visibility of walls. Frames until horizontal position error exceeds:

trace 1 unit 10 units 100 units
s1_accel 233 (2.4 s) 272 (2.9 s) 477 (5.4 s)
s9_coast 162 (2.1 s) 203 (2.6 s) 461 (5.3 s)
s8_lap 33 (0.4 s) 50 (0.6 s) 91 (1.1 s)
s2_corner 1 (0.2 s) 4 (0.2 s) 24 (0.5 s)

s2_corner diverges immediately because it opens with the car pinned against a wall, which the model cannot see. This table is reported because it is the honest answer to "does it drive like the original", and the answer is: law by law yes, over a lap no, and SPEC-GAPS.md says why.

Determinism across toolchains

sh tools/crosscheck.sh

builds with both toolchains and compares the output byte for byte. All eight traces come out identical between a 32-bit x87 mingw build and a 64-bit SSE gcc build. That is the point of storing state as float and computing intermediates in double with every product forced through mx_f32(): without it, an x87 build keeps excess precision in registers and the two diverge — which is exactly the failure §20 names as a risk to exact replay.

Race rules

mxsim race traces/race01.csv --laps 6, driving §17's accounting from the checkpoint transitions observed in a complete six-lap race:

race rules over 5779 samples: lap 0 wrong, cp_next 1 wrong,
                              lap_time 149 off by >0.25s, best_lap 0 off by >0.25s

The one cp_next miss is the seeded first sample. The 149 lap-time samples are the crossings themselves, where a 20 Hz log locates the line up to 50 ms late. Lap numbering ran 1→7 with the finish on 7, exactly as §17 says.


mxplay — the same core, in real time

Build and controls are under Getting started above. What follows is what it is made of and what it is not.

The garage is the front end: nineteen cars, four tyre sets, three engines, three wings, three nitros, all seven levels, and a rating slider. The car re-fits itself around whatever is chosen — the wheel radius comes from the tyre mesh and the mounts are scaled from the measured Catapult track by the body's own bounding box, because a Shadow Jet is 108 units across where a Catapult is 71. Where an engine, wing or nitro mounts is not recovered from anything; nothing in the container says, so it is placed off the body's bounds and is a reconstruction.

The rating is the part with evidence behind it. §3 turns it into the whole envelope — Acc = R*4.5 + 1800, accel = R + 600, cap = Acc*0.66 — verified exact at all five ratings observed in one race, and the line under the menu shows what you are about to get. What the game uses to set the rating from parts is not recovered, and rather than invent a table the menu keeps them separate. SPEC-GAPS.md #1 is the related open question: the rating changes during a race.

All seven levels load. Every one has exactly one cstart and three ccheck gates, so §17's four-checkpoint cycle is universal rather than the level-1 fact §17 hedges it as. The music containers name their single track after the level — urbanmix, tikimix, beachmix, constructmix, moonmix, plutomix, asteroidmix — so level N wants musicN.

It links mx_car.c, mx_race.c and mx_trig.c unchanged — the same objects the residual table above is measured against. tools/crosscheck.sh still passes, so the physics on screen is the physics in the table. Everything mxplay adds sits outside the model, applied after mx_dynamics rather than inside it:

src/mx_track.c level geometry, a uniform grid over xz, and the queries §20 says cannot be recovered from the specification: ground height, the blocked-axis mask §14's contact response needs, the water surface, and the gate crossings laps are counted from
src/mx_play.c QueryPerformanceCounter for the continuous float seconds §8 insists on, a software rasteriser with a z-buffer and perspective-correct texturing, §6's chase camera, four-strut suspension, and a driver that follows a resampled racing line
src/mx_sound.c a waveOut mixer, sixteen voices, fed from the frame loop so it can never stall the game. The game's 102 effects are all mono 11025 Hz, so there is no resampler. The engine note is nine loops indexed by revs — CarLoop100..CarLoop450 — not one loop pitched, and the higher ones are shorter because they are the same cycles recorded faster
tools/extract_track.py reads your containers, and classifies each face by what its material says it is — see below

The level says what its own surfaces are

The single most productive thing in this repository's second pass. A face's material is not only a texture key: it is how the level declares what the face is. tools/material_census.py lists every material on every face of all seven levels, and five families fall out that a texture-only reading exports as ordinary solid, drawn world — 21 sound trigger volumes, the water sheets, the slowed beds beneath them, twenty-five unrecognised marker families, and per-level checkpoint boxes. SPEC-GAPS.md #24 has the table and #25#29 the consequences: laps counted by crossing a gate quad, water you drive through, a wall defined as steeper than 80°, a glancing contact that is not a full stop, and a driver that follows a line instead of a scatter of points.

On level 2 with the autopilot that is worth two clean laps, a 56.5 s best and zero rescues in 115 seconds. Before it, the car left the track once a lap and no lap was ever counted.

What it is not yet

No menus and no vehicle select — the cars are whichever meshes the --car / AI paths point at. The three opponents are a stand-in: §19 says the original's AI is its own routine and that synthetic key presses will diverge, and synthetic key presses are exactly what these are (SPEC-GAPS.md #18). Placement ranks by lap, then position on the line — defensible and unverified, because §17 does not say what the original ranks on (#19). The MPH figure is a display mapping, not a measurement — nothing in the spec relates the internal speed scalar to the original's dial and no trace records both, so the cap simply reads 100.

The HUD draws the original's own artwork, and that artwork answered a question along the way: HUD.omt holds no meshes at all, 65 canvases, and among them are eight speedometer frames that differ only in one more arc segment being lit. The original's speed gauge is not a needle over a dial. Where on the screen the plates go is still a reconstruction — a canvas carries no position.

Six levels have no measured vertical offset and no node capture. Only level 2 has either. That matters far less for playing than it sounds — the offset shifts geometry and markers together, so a level at the wrong offset is still self-consistent — but the node capture matters a great deal: with the real 29-node set level 2 laps cleanly, and on the 20 vpointp markers the others scrape scenery, because those are a different and coarser point set (#21). One screen run per level fixes it; HANDOFF.md has the procedure.

Layout

src/mx.h         types, constants, and the two-halves frame model
src/mx_car.c     the vehicle model  -- SPEC 4, 5, 13, 14
src/mx_trig.c    OMT's 14-bit angles -- SPEC 10 -- and float32 rounding
src/mx_race.c    race rules         -- SPEC 17
src/mx_trace.c   CSV reading, columns addressed by name
src/mx_main.c    the headless CLI
src/mx_track.c   level geometry and the ground / wall queries -- mxplay only
src/mx_play.c    the real-time driver: window, rasteriser, camera, HUD
src/mx_sound.c   a waveOut mixer  -- mxplay only, and no part of the model
tools/mxcheck.py the checker
tools/sweep.py   every trace, one markdown table
traces/          the frozen captures -- evidence, never regenerated in place

The traces

Captured from Mechanixsw.exe with an execute breakpoint at the game tick, recording the elapsed the engine actually received, the game's own key array, and the car object. Breaking every frame slows the game but does not invalidate the trace: the simulation is a pure function of the (elapsed, input) sequence, so a perturbed-but-self-consistent record replays exactly.

trace frames what it is for
s1_accel 2500 standing start to the cap, then a head-on wall for 1700 frames
s2_corner 2500 sustained cornering both ways, 2374° of total heading change
s4_air 3000 six forced launches and landings; 33% of frames airborne
s5_oob 3000 five forced out-of-bounds events and respawns
s6_brake 2600 brake through zero into reverse, to the reverse floor
s7_turbo 2600 turbo key held with the boost never engaging (see SPEC-GAPS.md #3a)
s8_lap 4000 a driven lap: five ratings, turbo engaged, a lap crossing, all four checkpoints
s9_coast 2600 throttle released at speed — the only clean coasting data
race01 5779 a complete six-lap race sampled at 20 Hz, to the finish
trace01 1500 the specification's own published trace, for comparability

Every one was checked for liveness before use — a trace of a car that did not move proves nothing, and this project has been caught by that repeatedly.