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.

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:

  1. --data PATH (the image, or the folder), without the launcher;
  2. the release chosen in the launcher;
  3. on the first run, an .iso or a files/ folder next to the program (on macOS also next to the .app), in orig/ in the current folder, or in the save folder (and the place used before the launcher, datadir.txt); on Android in Android/data/io.github.alaxend0l.hwvx/files/ (or a files/ folder in it) and /sdcard/HWVX/ (the second needs "All files access", which the app asks for);
  4. 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

PathWhat
src/game/Game sources imported from the decomp, patched for portability
src/game/include/game_protos.hGenerated: 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.hFixed-width types, endian helpers, 32-bit handles
src/port/sdk.h, gx.hThe SDK API surface the game uses
src/port/gx_render.hRenderer interface; gx_gl.c (OpenGL) and gx_sdlgpu.c (SDL_GPU) implement it
src/port/net.h, net*.cOnline 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.hGenerated: 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 + offset into 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:

MacroMeaning
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:

HeaderWhat
Thing.hThing, the 0xAC-byte header of every game object (velocity, transform, flags, slaves, vtable, list links); ThingVSlot + THING_VFN for vtable calls; DebrisParams
Collision.hCollisionOBox (the pooled oriented box), CollisionInfo (a contact)
ItemThing.hpickups: ItemThing, ItemRecord, ItemParams
CarA.hcars, players, MotionState, cameras
World.h, Trigger.h, ThingManager.hthe world (tiles, teleporters, volumes, quads), triggers/actions, thing pools
AI.h, Nav.h, Creature.hAI drivers, nav points, creatures
Projectile.h, Debris.h, AttachThing.h, Destructible.h, KeyState.hweapons and effects
Interface.h, Particle.h, WildCard.hmenus/HUD (interfaces, their objects and definitions), particles, wildcard racks
Gfx.hrender 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 a Thing * is wanted.
  • The world transform is Vector4 mtxRows[4] inside Thing (a Matrix4 member would pad the struct to 0xB0); use THING_MTX(t) for the matrix and t->pos / t->axis[i] for its rows.
  • Vtable calls: THING_VFN(t, THING_V_MOVE, void (*)(Thing *))(t), not SLOT_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 with port: (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: leftover GETP(x->field) + 0x24 byte 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-zero catches those on a Mac).
  • tools/offsets_to_fields.py rewrites 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 at 19 + 4*n and its value at 20 + 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, steps applied at 1537-1568 (vars[step->var] += val[dir], or = when set[dir]; wrapped into the variable's min/max at 1561-1564), then variables whose code variable id is 0x1F6 are re-read from the game settings (1569-1590), then the record's events run (1607), then next[] 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, both val = {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 type 0xB plus a def of type 0x16/0x17 whose delta (-10/+10) changes the volume code variables 0xCF/0xD0).
  • events: event types 13/14 are the left/right presses and 15 is A; each event's def says what happens. Def type 0xB runs the action list its target points at, and IfaceActionList type 2 with an IfaceCodeVar writes 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, and SetCodeVar retargets 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: IfaceText with listMode 1 shows GETP(list->texts)[vars[list->var]] (clamped, 3349+), so a row can display names the port made up - Port_GfxOptionName - as long as the object is type = IFACE_DEF_TEXT, role = 9. The gate at 3349 wants the live kind == 5 (IFACE_KIND_TEXT, which InterfaceManager_AddObject gives a def of type 1) and listMode == 1; the string is only re-set when idx != 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 -> kind 0/2/1 by sub->type (plain image, geometry, texture anim); def type 1 -> kind = 5, data = sub, fontId = InterfaceManager_AddFont(sub->font) and the initial text from sub->strings. AddObject dereferences def->anim unconditionally (633) - a def with a null anim kills the interface. IFACE_DEF_* and IFACE_KIND_* are different enums; text is def 1 but kind 5.
  • the Music/SFX value objects (28/32) are type = 0 images with role 103/102, and for those roles the engine ignores text entirely: it fetches code var 0xD0/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's AnimCopy, 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 with tools/xgc_inspect.py over 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 three stateIndex = {-1 x6} and condition lists type = 1, count = 0 (no conditions: always visible), while the arrow defs (25/26/29/30/33/34) each carry one type = 1 condition whose value is 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