HWVX-SDL
HWVX-SDL is a PC port of Hot Wheels: Velocity X for Windows, Linux, macOS, and Android that uses SDL3 and OpenGL or SDL_GPU.
- Type: PC port
- Game: Hot Wheels: Velocity X
- Runs on Windows, Linux, macOS and Android
- By Alaxend0l
- Latest release 0.2.0, 2026-11-06
- Source: https://github.com/Alaxend0l/HWVX-SDL
README
HWVX-SDL
Turning the GC Decompilation of HWVX into a portable codebase for modding and porting!
The game code comes from the HWVX-GC-Decomp submodule. The GameCube SDK it was written against (OS, GX, DVD, PAD, CARD, AX...) is replaced by a platform layer on SDL3 (window, input, files, threads) and OpenGL 3.3 or SDL_GPU (Vulkan, Metal, Direct3D 12) for graphics; sound goes through SDL3's audio streams. Everything is C, built with GCC.
Building
Requirements: GCC, CMake ≥ 3.20, Ninja (or Make), SDL3 development files.
cmake -S . -B build -G Ninja
ninja -C build
Packages for Windows, macOS, Linux (AppImage, Flatpak, bare program; x86_64
and aarch64) and Android are built by the scripts in packaging/, and by CI
on every push (artifacts) and version tag (GitHub Release): see
packaging/README.md.
Game data
Not included. Use your own copy of the game: the GameCube disc (an .iso;
Dolphin can convert a .gcz/.rvz to one: right-click the game → Convert
File…, or its extracted files: Dolphin → Properties → Filesystem → Extract
Entire Disc, which makes a files/ folder), the PC release (its install
folder, or its CD as an .iso or .cue/.bin), or the PlayStation 2 disc's
files (the folder with XPS/ENGLISH/, or its .iso).
The launcher (src/port/launcher.c) lists the releases you have imported
and which one plays; the PS2 prototype (beta) can be imported too, for its
beta content later. It opens every time the game starts, unless you tick
"Start the game without this window"; then the game boots straight into the
chosen release, and the launcher only opens when that data has gone, or when
asked: --launcher, or run hwvx-launcher (a copy of the program the build
makes; app bundles use --launcher). Mouse, keyboard and gamepad all work (d-pad or
stick, A, B; Start plays). Importing the PC CD copies its packs (out of
HWVX.cab) and its movies, which the PC game only ever read from the disc,
into a pc folder in the save folder; an installed copy may have no movies.
Importing a PS2 disc image copies its XPS/ENGLISH into ps2 (or ps2beta
for the prototype's).
Your choices are kept in sources.cfg in the save folder. On start the game
uses, in order:
--data PATH(the image, or the folder), without the launcher;- the release chosen in the launcher;
- on the first run, an
.isoor afiles/folder next to the program (on macOS also next to the.app), inorig/in the current folder, or in the save folder (and the place used before the launcher,datadir.txt); on Android inAndroid/data/io.github.alaxend0l.hwvx/files/(or afiles/folder in it) and/sdcard/HWVX/(the second needs "All files access", which the app asks for); - on desktop, the launcher, to import one.
./build/hwvx --data "/path/to/Hot Wheels - Velocity X.iso"
./build/hwvx --data /path/to/files
The other releases' data works too, as a folder (not a disc image): the PC
release's install folder (the .xpc packs next to HW_share.xpc), or the PS2
disc's files (the folder holding XPS/ENGLISH/, or XPS/ENGLISH/ itself; the
.xps packs). Each pack is converted to the one layout the port draws as it
loads (src/game/Port_Data.c; PS2: Port_PS2.c and Port_Geometry.c):
the PS2's geometry is drawn without back-face culling (some of its strips are
wound the other way), its shadow volumes are off, and its movies (.pss) are
not played.
Options: --data PATH, --scale N (window = 640×480×N), --window WxH
(any size and shape), --fullscreen or --windowed (either overrides
graphics.cfg), --renderer NAME.
Resolution: the game renders at the window's size and shape, and the window
can be resized. On a screen wider than 4:3 the 3D view widens to fill it while
the HUD and menus keep their 4:3 layout in the middle (taller screens work the
same way vertically); split screen stays 4:3. Options → Graphics (a menu the
port adds) picks a resolution from the display's modes and windowed or
fullscreen; it is applied when you leave that menu and kept in graphics.cfg
in the save folder. It also has Gamma and FOV bars, which take effect at once. Command-line options override it.
The menus match across the three releases' data: every main menu has One Player,
Multi-Player, Load Game, Save Game, Options, Extras and a way out with a confirm
screen - "Quit Game" (the game's own Confirm Quit screen) on GameCube and PS2 data,
the PC's own "Exit to Desktop". Options has Graphics everywhere, and Multi-Player
opens Online or Local. The PC release has no multi-player screens at all; the port
builds them from its One Player screens, with a two-player split car select made
of two strips of its car select. The PC's data has no multi-player drag race
worlds, so on it Multi-Player offers Battle (and Online Joyride) only. All of it
is built from the loaded release's own menu data at load time, nothing taken from
another release (src/game/Port_Menu.c; docs/menus/README.md in the editor
repository has what each release's menus are).
mainmenu=original in graphics.cfg (or HWVX_ORIGINAL_MAINMENU=1) keeps the
main menu exactly as the release has it - on GameCube and PS2 data without Quit
Game (close the window to quit), on PC data without Multi-Player, and with
Multi-Player going straight to the game's own screen; the other menu changes stay.
Saves: there's no memory card on PC, so the GameCube's card screens are skipped: the startup card check, the Slot A/B picker (saves always go to the save file), the "do not touch" loading/saving notices and the card warnings (error screens still appear if loading or saving fails). The most recent save loads at startup, and leaving Options after changing a setting autosaves over the save in use (or makes one) instead of reminding you to save.
Renderers (--renderer): through SDL_GPU d3d12 (the default on Windows),
metal (the default on macOS) and vulkan (the default on Linux and
Android), and opengl (OpenGL 3.3 core). When the default can't start (no
driver for it, an old GPU), the game falls back to OpenGL, except on
Android. CMake's HWVX_DEFAULT_RENDERER builds in another default.
F11 or Alt+Enter toggles fullscreen. Saves go to SDL's per-user preferences
folder (memcardA/*.sav).
Controls: any SDL-supported gamepad (A/B/X/Y by position, triggers = L/R,
right shoulder = Z), or the keyboard as player 1, with the PC release's
controls (recovered from its executable, see src/game/Port_Keyboard.c):
Up accelerate, Down brake/reverse, Left/Right steer, W/S stunt flip
forward/backward, A/D strafe/stunt roll, Shift power slide, Space fire,
Ctrl change weapon, ` (grave) change camera, Escape pause. In menus: arrows,
Enter to accept, Escape to go back, X for the X button (stats).
Layout
| Path | What |
|---|---|
src/game/ | Game sources imported from the decomp, patched for portability |
src/game/include/game_protos.h | Generated: one prototype per game function |
src/game/include/Thing.h, Collision.h, ... | Shared game structs (see "Game structs") |
src/port/ | Platform layer replacing the Dolphin SDK |
src/port/port.h | Fixed-width types, endian helpers, 32-bit handles |
src/port/sdk.h, gx.h | The SDK API surface the game uses |
src/port/gx_render.h | Renderer interface; gx_gl.c (OpenGL) and gx_sdlgpu.c (SDL_GPU) implement it |
src/port/net.h, net*.c | Online play transport (see "Online play"); nothing in the game calls it yet |
src/port/shaders/ | Shader sources (GLSL), shared by every renderer |
src/port/gx_shaders.h | Generated: the shaders as GLSL, SPIR-V, MSL and DXIL |
tools/ | Import and rewrite scripts (below) |
Porting notes
64-bit hosts: pointers in 4-byte slots
The game stores pointers in 4-byte fields everywhere: asset files are loaded
as raw blobs with 32-bit pointers patched in place, and code walks structs by
byte offset (*(u8 **)(car + 0x2C)). On 64-bit hosts a pointer no longer fits,
so such slots hold a handle instead:
- memory from the game heap:
0x80000000 + offsetinto the port's arena (so alignment arithmetic on the value still works, as on the GameCube); - anything else (statics, functions, host objects): an entry in a registry, allocated on first use.
Conversions are explicit in the code:
| Macro | Meaning |
|---|---|
PTR(h) / HND(p) | handle → pointer / pointer → handle |
P(Type, h) | typed PTR |
SLOT_LOAD(T *, addr) | read a pointer from the 4-byte slot at addr (was *(T **)addr) |
SLOT_STORE(addr, p) | write a pointer into the slot at addr |
PTR32(T), FPTR32(R, (A)) | a pointer field in a game struct (4 bytes) |
GETP(x->f) / SETP(x->f, p) | read / write such a field as a typed pointer |
PTR32 fields are unions, so any use that bypasses GETP/SETP (pointer
arithmetic, comparison, assignment) is a compile error rather than silent
integer math on a handle.
The build makes -Wint-to-pointer-cast, -Wpointer-to-int-cast and
-Wint-conversion errors, so every place that squeezes a pointer into an
integer, or the reverse, has to go through these.
Game structs instead of offsets
The decomp reaches into objects by byte offset (*(u32 *)(thing + 0x74)).
Those are being replaced by structs in src/game/include/, one shared
definition per object, with _Static_assert on every known offset so a
wrong layout fails to compile:
| Header | What |
|---|---|
Thing.h | Thing, the 0xAC-byte header of every game object (velocity, transform, flags, slaves, vtable, list links); ThingVSlot + THING_VFN for vtable calls; DebrisParams |
Collision.h | CollisionOBox (the pooled oriented box), CollisionInfo (a contact) |
ItemThing.h | pickups: ItemThing, ItemRecord, ItemParams |
CarA.h | cars, players, MotionState, cameras |
World.h, Trigger.h, ThingManager.h | the world (tiles, teleporters, volumes, quads), triggers/actions, thing pools |
AI.h, Nav.h, Creature.h | AI drivers, nav points, creatures |
Projectile.h, Debris.h, AttachThing.h, Destructible.h, KeyState.h | weapons and effects |
Interface.h, Particle.h, WildCard.h | menus/HUD (interfaces, their objects and definitions), particles, wildcard racks |
Gfx.h | render window and state, geometry, queued 2D rectangles (GDLRect) |
Conventions:
- An object kind embeds the header as its first member:
Thing base;(Destructible,ItemThing,TracerThing...); pass&x->base(or the object itself) where aThing *is wanted. - The world transform is
Vector4 mtxRows[4]insideThing(aMatrix4member would pad the struct to 0xB0); useTHING_MTX(t)for the matrix andt->pos/t->axis[i]for its rows. - Vtable calls:
THING_VFN(t, THING_V_MOVE, void (*)(Thing *))(t), notSLOT_LOAD(void *, vtable + 0xC). - Name a field only from what the code does with it; keep
unkXX/padXX(with the offset in the comment) when that isn't clear. Note quirks of the original rather than fixing them, and mark deliberate deviations withport:(e.g. a value the original read from an uninitialised register). - After changing signatures, regenerate
game_protos.h(tools/gen_protos.py). - After giving a pointer field a struct type, run
tools/check_ptr_offsets.py: leftoverGETP(x->field) + 0x24byte offsets would now step whole structs (this caused an integer division by zero, SIGFPE on x86 Linux, that ARM64 silently ignores;-fsanitize=integer-divide-by-zerocatches those on a Mac). tools/offsets_to_fields.pyrewrites a function's byte offsets into field accesses from the layouts clang computes (-Xclang -fdump-record-layouts); read its diff, since it trusts the variable types it is given.
Status: every game file uses structs. tools/check_ptr_offsets.py --bytes
lists the byte offsets still used as struct accesses; it reports only
data_gen.c (generated tables) and Port_Swap.c (which walks raw big-endian
pack data on purpose). Some globals are still declared as byte arrays in
data_gen.c and given their type by a #define in the function using them.
Shared layouts live in the headers: Thing.h, CarA.h (cars, players),
Motion.h (animated models and MotionState), World.h, Collision.h,
AI.h, Creature.h, Gfx.h (render window and state, geometry),
Shader.h, Voice.h, IT.h, SoundSequence.h, Interface.h, and the
rest of common.h. Before writing a local typedef struct view of an
object, check whether one of them already has it.
Restructured functions no longer match the decomp text, so
import_decomp.py --merge will conflict on them; resolve those by hand.
Big-endian data
GameCube data is big-endian. Loaders byte-swap known formats on load
(BE16/BE32, Port_SwapArray* in port.h). Sound samples are kept in
host order whichever release they come from: the GameCube's PCM16 sound
effect and song-bank samples are swapped as their pack loads, the PS2's
SPU-ADPCM ones decoded to PCM16 (src/game/Port_Data.c, Port_PS2.c), and
the mixer reads plain s16 from ARAM.
Threads
GameCube threads are cooperative on one CPU and the game relies on that. Each OS thread is an SDL thread, but game code runs under one "giant lock" that is released only where a GameCube thread would block (vsync, DVD, messages), so the streaming loader runs while the main thread waits for retrace, as before.
Menu rows, options and code variables
Menus are data: an InterfaceHeader with its objects, variables and events
lives in a pack, and InterfaceManager runs it (src/game/include/Interface.h
documents the structs, InterfaceManager_Update is the frame loop). A port
screen is a copy of that table made when the interface is created
(Port_MenuPatch ← InterfaceManager_Create, src/game/Port_Menu.c): the
pack is mapped read-only, nothing may be edited there.
A selectable row (Difficulty, Sound Mode, Music, SFX, ...):
- objects
label,value,arrow left,arrow right; in the Options screen (40 objects) they start at 19, 23, 27 and 31, so row n is at19 + 4*nand its value at20 + 4*n. Difficulty/Sound are label text + value text; Music/SFX are label image + value image. record(IfaceRecord) drives selection:id,next[4]= up/down/left/right (0: stop there, the port re-rings these when it respaces rows),steps/numSteps,events.- d-pad in
InterfaceManager_Update: presses at 1532-1535,stepsapplied at 1537-1568 (vars[step->var]+=val[dir], or = whenset[dir]; wrapped into the variable's min/max at 1561-1564), then variables whose code variable id is0x1F6are re-read from the game settings (1569-1590), then the record's events run (1607), thennext[]moves the selection (1624+). Left and right never move the selection on this screen: they change the row's value, either through a step (Difficulty:var 0, Sound:var 3, bothval = {0,0,-1,1},set = {0,0,0,0}) or through the event itself (Music/SFX: no steps, four events - 13 and 14 each have a def of type0xBplus a def of type0x16/0x17whosedelta(-10/+10) changes the volume code variables0xCF/0xD0). - events: event types 13/14 are the left/right presses and 15 is A; each
event's def says what happens. Def type
0xBruns the action list itstargetpoints at, andIfaceActionListtype 2 with anIfaceCodeVarwrites the row's variable into a game code variable - which is how the Graphics rows apply their setting. - variables (
IfaceVarList): Options has 4 - 0 difficulty (code var 200/0xC8, min 0 max 3), 1 the selected row (type 3, min 1 max 6), 2 type 1 / update 0 (code var 106/0x6A), 3 sound mode (code var 214/0xD6, min 0 max 1). The port appends 4 = vsync and 5 = max fps by cloning 3, andSetCodeVarretargets the code var id and min/max - every row whose value is a variable needs that, not only the row whose events are rewritten, because both the creation sync (1491-1531) and the step wrap read it. - the value shown:
IfaceTextwithlistMode1 showsGETP(list->texts)[vars[list->var]](clamped, 3349+), so a row can display names the port made up -Port_GfxOptionName- as long as the object istype = IFACE_DEF_TEXT,role = 9. The gate at 3349 wants the livekind == 5(IFACE_KIND_TEXT, whichInterfaceManager_AddObjectgives a def of type 1) andlistMode == 1; the string is only re-set whenidx != obj->listIndex, which starts at -1, so the first update always shows the list entry. - how an object comes to life (
InterfaceManager_AddObject, 591-752, run for every def of the header including hidden ones): def type 0 ->kind0/2/1 bysub->type(plain image, geometry, texture anim); def type 1 ->kind = 5,data = sub,fontId = InterfaceManager_AddFont(sub->font)and the initial text fromsub->strings.AddObjectdereferencesdef->animunconditionally (633) - a def with a null anim kills the interface.IFACE_DEF_*andIFACE_KIND_*are different enums; text is def 1 but kind 5. - the Music/SFX value objects (28/32) are
type = 0images with role 103/102, and for those roles the engine ignores text entirely: it fetches code var0xD0/0xCF(3269-3275) and uses it as the animation delta that stretches the bar. To make such an object show text, copy the Display row's value object (24:role = 9,type = 1) - anim (in the port'sAnimCopy, with the position key raised to the new row height),stateIndex,states,anchor/anchorX/anchorY- or the text lands in the wrong place and colour. The pack's own shapes (dumped withtools/xgc_inspect.pyover the blob): 24 role 9 text, active 0, 2 states (type 0 and 1),anchorX = 1; 28 role 103 image, 1 state, a 2-key scale track whose first key is(0.0, 16.0)- the bar starts at width 0 and the engine stretches it by the volume -anchorX = -1; 32 role 102 image, 2 states (type 0 and 2); all threestateIndex = {-1 x6}and condition liststype = 1, count = 0(no conditions: always visible), while the arrow defs (25/26/29/30/33/34) each carry onetype = 1condition whosevalueis the row's record id - arrows that only show for the selected row.
Pack offsets, if the tables need re-deriving: menu pack HW_IPack.xgc entry 5,
main menu header at blob 0x6ECC60 (26 objects), Options header at blob
0x85BB40 (40 objects, vars at 0x85DE20); find them again with
tools/xgc_inspect.py by searching for a header with 40/26 objects whose
object 16/10's text is "Controller"/"Main Menu".
The Gamma and FOV rows keep the volume look instead: AddBarRow copies the pack's
Music row (objects 27-30) to objects 40-47 of the Graphics screen, with its bar.
The bar's role (PORT_ROLE_GAMMA_BAR, PORT_ROLE_FOV_BAR) says which code variable
stretches it, as 102/103 do for the volumes; the 0x16 events become
PORT_EVENT_SLIDER, whose id is that code variable (delta stays -10/+10, clamped
to 0..100); and the arrows' conditions get the row's own record id, or they would
only show on Music's. The bars take effect at once and are saved in graphics.cfg
with the rest. Gamma is a power on each colour in the display copy
(present.frag, GL_DrawGamma), so it covers everything but HWVX_DUMP's frames;
FOV scales the view of the driving camera, Camera_UpdatePosition mode 30, leaving
scripted shots alone.
The volume fix for the two cloned rows lives in SetActions: every event def
of type 0xB, 0x16 or 0x17 is retyped to 0xB with delta = 0 and
target the port's own action list, a row whose copied record had no step gets
a port-owned IfaceStep (var = 4/5, val = {0,0,-1,1}, set all 0), and
SetCodeVar covers vars 4/5 (PORT_CODEVAR_VSYNC/PORT_CODEVAR_MAXFPS,
max = option count - 1), so left/r