Adrian Raudaschl
Adrian Raudaschl

Sonic R Mod Maxxing

Reverse-engineering Sonic R (1997) on Apple Silicon. A tank-controls fix, a from-scratch track-format parser, and a working terrain and texture editor built across a few evenings. An ongoing log.

Technologies
PythonC++GhidraPyGhidraQt6OpenGLReverse Engineering
Sonic R Mod Maxxing

Maxxing out a 1997 racing game one weekend at a time. A reverse-engineering, modding, and level-editing playground for Sonic R on Apple Silicon.

Status: In progress · Last updated 3 May 2026

Sonic R has lived in my head since I was a teenager. In April 2026 I started seeing how far I could push it on modern hardware. This page is the running log: each weekend's work added as a new entry, oldest first.

25 April 2026 - Getting it running, fixing the controls

Got Sonic R running natively on Apple Silicon, traced the physics through Ghidra, and patched out the tank-like turning that every review has complained about for two decades.

A native macOS setup. CrossOver runs the original 1997 Win98 binary on Apple Silicon. The open-source Sonic R Mod Loader acts as a d3d9.dll shim that lets you inject custom DLLs and binary patches into the running game.

Sonic R running natively in a Wine window on Apple Silicon
A custom skybox completely changes the feel of the zone. Same Radical City geometry, hand-swapped parallax sky, running natively in a Wine window on Apple Silicon (the Move/Size dropdown is Wine, not the game).

A tank-controls fix. Every review of Sonic R for twenty-five years has complained about the cars turning like Soviet tractors. I traced the per-frame physics tick to a function at FUN_0042c430 and found a per-character table of ten scalars at 0x45e300. Field +0x08 is the turn rate. Multiplied by 1.5x across the board, per-character ratios preserved. The whole patch is two bytes per character. Tails Doll remains the worst character in the game by design.

Resort Island at race start
A brighter custom skybox on Resort Island shifts the time of day from midday to early sunset. Same geometry and decorations, different mood, swapped via the parallax import pipeline.

A track-format parser. Sonic R's .srt files are undocumented. I sat with a hex editor for a couple of evenings and built a Python parser and writer that round-trips all five tracks byte-for-byte. Track parts have integer XYZ vertices at twelve bytes each plus face indices; deco parts use u8 RGB vertex colours; track parts use i16 RGB tints with a -200 "ignore tint" sentinel. Once you can round-trip you can mod.

Generic texture export and import. Sonic R stores textures as raw RGB24 with no header. The engine looks up dimensions by file size. A small auto-detecting exporter pulls all 224 PNGs in one pass; the importer slots higher-resolution variants back in. I fed a few through Nano Banana Pro to upscale and re-style them and the engine doesn't notice.

A reverse-engineering pipeline. Ghidra in headless mode driven from PyGhidra, plus six small scripts I keep reaching for: find functions that reference an address, find users of a string, decompile by entry point. The map I built up so far includes a Players[] array hard-coded to 5 slots, a giant screen-state switch in MainGameLoop at 0x43AA60, sin/cos lookup tables, and MIN_FOG_Z/MAX_FOG_Z floats that genuinely extend the draw distance when bumped.

3 May 2026 - Cracking the rest, building the editor

Decoded the last opaque file formats, found a vestigial section every track has shipped for twenty-eight years, and built a working level editor in one evening.

The remaining file formats. I'd flagged .ply and .map as opaque collision formats. Turned out they're sister formats and have nothing to do with collision: they're playfield textures. .map is a 128×128 grid of one-byte tile indices; .ply is a 320×384 RGB image laid out as 120 tiles you reference by index. The same byte simultaneously drives rendering and a sub-pixel collision check. A 1996 Saturn engineer was clearly counting bytes. I tested the writer by painting all the water tiles in Resort Island cyan; whole lake turned neon.

The seven mystery SRT sections. Cross-referencing per-track entry counts gave huge hints. Section 4 is always exactly 360 entries (a camera spline for the race intro/outro flyby). Section 8 is the rank-checkpoint loop. Section 9 is always 51 entries (a 50-frame animation curve, probably the start-grid rev animation).

Section 3 is vestigial. This was the fun one. 543 entries of beautifully smooth XYZ trajectory data on Resort Island. The loader copies it into memory every time you load a level. Nothing reads it. Ever. Some Saturn engineer in 1996 used it; the 1998 PC port didn't strip it; the 2004 retail rebuild is still shipping dead bytes in every track. Twenty-eight years of nobody noticing.

Hidden command-line flags. Grepping the binary for uppercase strings turned up an argv walker. SonicR.exe UNLOCKGAME unlocks the full secret roster. IGNORECD skips the disc check. WINDOWED, ALLOWMODESWAP. There's also a complete software renderer code path still compiled in, and a BIN/OBJECTS/old/ folder of pre-release character animations dated September 1997 that retail just never cleaned up.

A level editor. A single Qt6 window: paint playfield textures by clicking cells, sculpt terrain with a 3D brush (dome, cone, plateau, crater, ridge), stamp from 234 existing scenery objects, race the result. Both the collision mesh and the visible mesh get lifted in matched falloff so hills are walkable AND visible. About 1500 lines of Python, written between 8pm and midnight.

The level editor in motion: 2D tile painter on the left, 3D terrain preview on the right. Paint, sculpt, stamp decorations, race the result.

The longest bug of the night. Stamped decorations rendered fine from some camera angles and disappeared from others. Each deco's file header has TWO XYZ slots. Bytes 0 to 11 are a frustum-cull reference point ("is this on-screen?"); bytes 16 to 27 are the render origin ("draw it here"). I was updating only the second one. The engine was asking "is the original location visible?" while drawing the new copy at the new spot. Most existing decos have those two slots set to the same value, which is why naive cloning usually works.

Pipeline

A few hundred lines of Python plus one C++ DLL:

  • texture_io.py - generic raw export and import with format auto-detection
  • srt_roundtrip.py - track format parser and writer
  • parallax_io.py - sky textures, with stretch/cover/fit/tile import modes
  • mod-src/AutoBoot.cpp - loader DLL with all the binary patches
  • ghidra-scripts/*.py - the PyGhidra recipes I keep reaching for
  • editor/ - the Qt6 + OpenGL editor app

Next

Original track from scratch. The writer needs more than round-trip support: a way to lay down a brand-new layout, sample a centre line, place track parts, paint the playfield, generate an AI racing line. Nobody on PC has hooked DirectInput to give Sonic R proper analog stick support either, which is the other thing on the list.

See also