Reading Time: 8 minutes

Sonic the Hedgehog on the Game Gear never shipped with a level select. Adding one meant rebuilding the cartridge from source and then finding room for it in a ROM with no room left.

Sonic The Hedgehog (World) (Rev 1)

Press Up, Down, Left, Right at the title screen and “PRESS START BUTTON” becomes a level picker. Every one of the game's 18 levels is reachable from it.

A cartridge with 1,092 bytes to spare

The usual way to cheat an old game is to find the instruction you dislike and overwrite it with NOP. It works, and earlier passes at this ROM did exactly that a “fly mode” built from five byte pokes, an infinite-lives hack built from two.

That approach runs out fast. A level select is not a disabled branch; it needs a menu loop, input handling, letters that don't exist yet, and somewhere to keep its state. Scanning all 262,144 bytes of the cartridge for unused space turns up exactly one usable run: $14D00, 1,092 bytes of zeroes, and nothing else in the entire image.

So the job was never “patch the ROM”. It was: get back to source code, rebuild the cartridge from it, and only then start changing things.

Proving the source describes the game

There is a community disassembly of this exact revision — Ravenfreak's, a work in progress since 2017. Its current revision does not assemble; a bank overflows. But the history is short, so building each commit backwards in turn eventually lands on one that assembles cleanly, links, and produces a file whose checksum is identical to the cartridge.

That commit became the source tree. And the check that found it became the permanent gate on the whole project:

build.py retail

$ python build.py retail
[retail] sonic_base.gg  262,144 bytes  md5 b1de7027824c434ce8de59782705f5c9
[retail] verified byte-identical to the retail ROM

This matters more than it looks. Every claim that follows rests on it: the source demonstrably describes this game, byte for byte, so a change to the source is a change to the game and nothing else. The same tree builds a second configuration with one flag flipped:

build.py all

$ python build.py all
[retail]      sonic_base.gg         262,144 bytes  md5 b1de7027…
[retail]      verified byte-identical to the retail ROM
[levelselect] sonic_levelselect.gg  262,144 bytes  md5 d032128b…
[levelselect] 516 bytes differ from retail (0.20% of the ROM)

The assembler is WLA-DX v9.5, the build the disassembly shipped with in 2019 — not the current v10.7, which is not a drop-in for source this old.

How the game starts a level

Before writing any assembly, it's worth knowing whether the idea works at all. The engine keeps the current level in a single byte at $D238. It is zeroed just before the title screen runs, and not read again until after you press Start.

Which means a level select doesn't need to hook the moment Start is pressed. It only has to keep that byte equal to the selection. That was testable in minutes by driving an emulator from a script sit at the title screen, write a value into $D238, press Start, and see what loads.

The game's map screen showing an island with the label BRIDGE. The game names the zone for you. Poking values 0–17 and reading the map screen confirms the layout: zone = id ÷ 3, act = id mod 3, six zones of three acts, with $12 reserved for the ending.

Eighteen levels, one byte, and a mechanism that needs no second hook anywhere in the engine. The rest is user interface.

The title screen knows ten letters

Writing “ZONE 1 ACT 1” assumes the letters exist. They don't. Dumping the video memory while the title screen is up and rendering every character tile gives the game's entire title-screen alphabet:

Tiles $E3–$EC: P R E S T A B O U N. Precisely the ten distinct letters in “PRESS START BUTTON”, and not one more. No Z, no C, no L, no digits at all.

So the glyphs had to be drawn. But look closely at the existing ones and there's a gift hiding in them, the font is shaded by row, not by shape. Every letter uses colour 4 on its top and bottom row, colour C on rows 1, 2, 5 and 6, and colour D on rows 3 and 4. Always. Regardless of the letter.

That means a new glyph doesn't need 32 bytes of four-bitplane tile data. It needs an 8-byte silhouette, and a routine that paints each row in the colour that row is supposed to be:

levelselect.asm, expanding a 1bpp mask into four bitplanes

; Colour index for each of the eight rows - this is the whole
; trick, lifted straight off the game's own letters.
LS_RowColour:
    .db $04, $0C, $0C, $0D, $0D, $0C, $0C, $04

LS_RowLoop:
    ld   a,(de)          ; a = colour index for this row
    inc  de
    ld   c,a
    rr   c               ; carry = does plane 0 want this row?
    call LS_Plane
    rr   c
    call LS_Plane
    rr   c
    call LS_Plane
    rr   c
    call LS_Plane
    inc  hl              ; next row of the silhouette
    djnz LS_RowLoop

; Push the row's mask if the plane is set, otherwise push nothing.
LS_Plane:
    ld   a,$00
    jr   nc,LS_PlaneOut
    ld   a,(hl)
LS_PlaneOut:
    out  ($BE),a         ; VDP data port
    ret

Eighteen glyphs, a space, Z O N E A C T, and the digits fit in 144 bytes and come out matching the retail font exactly, because they're shaded by the game's own rule.

They're uploaded into tiles $180–$191, a block of video memory the title screen leaves empty. Addressing tiles above $FF needs the ninth bit of the tile number, which lives in the tilemap's attribute byte, so the level select sets that bit and reuses the game's own text-drawing routine unchanged.

Nowhere to put the hook

The routine lives in the 1,092-byte hole. But that hole is in bank 5, which isn't mapped into the processor's address space while the title screen is running, and the title screen's own code lives in bank 1, which is 100% full. Not “nearly full”. There is not one spare byte in it.

So the hook cannot add a byte. It can only replace one thing with something else of identical size. The title loop already calls the routine that draws “PRESS START BUTTON”, and a call is three bytes either way:

Sonic1.asm — the entire hook, byte for byte

        xor     a
        ld      ($d20f),a
-       call    _LABEL_5ED_132      ; draw "PRESS START BUTTON"
+       call    LS_Stub             ; ...and then draw over it
        ld      hl,$d210
        dec     (hl)

The displaced call isn't lost, the level select makes it itself, first thing, before drawing anything of its own. Net change to bank 1: zero bytes.

That leaves LS_Stub, which has to live somewhere permanently mapped. Bank 0 qualifies, and it is also full, except for the padding between the processor's restart vectors, which this game only partly uses:

$0000–$000BReset — power-on entry point12 B

$000C–$0017unused padding12 B

$0018–$001ARST $18 — used by the sound engine3 B

$001B–$001Funused padding → LS_Restore5 B

$0020–$0022RST $20 — used3 B

$0023–$0027unused padding5 B

$0028–$002ARST $28 — used3 B

$002B–$0037unused padding → LS_Stub13 B

$0038–Interrupt handler—Bank 0's restart-vector region. Only $18, $20, $28 and $38 are used anywhere in the game, which makes the gaps between them genuinely dead space, the classic place to hang a hook.

Thirteen bytes at $002B. What the stub has to do is page bank 5 into the processor's third slot and jump to it. Written as tightly as it goes, that is exactly thirteen bytes:

Sonic1.asm $002B, and not a byte further

LS_Stub:
    di                   ; 1   the interrupt handler restores both bank
    ld   a,$05           ; 2   registers from their RAM mirrors, so a
    ld   ($FFFF),a       ; 3   fire in the middle of this would unmap
    ld   ($D230),a       ; 3   bank 5 out from under the running code
    ei                   ; 1
    jp   LS_Entry        ; 3   -> the routine, now at $8D00
                         ; ---
                         ; 13  bytes, $002B through $0037

It jumps rather than calls, because there's no room left to come back to. The routine returns through the five-byte fragment at $001B instead, which puts the bank register back and returns to the original caller. The restore has to finish somewhere still mapped after bank 5 is gone, which is the whole reason it's a separate fragment rather than part of the same routine.

Reading a pad that reads backwards

The controller sits at $D203, and like most hardware of the era it is active low — a bit is clear when the button is held. A cheat code needs the moment a button goes down, not whether it's down, so the routine keeps last frame's reading and works out the difference.

In Z80 that's six instructions, and it's rather neat: complement the current reading and mask it against the previous one, and you're left with exactly the buttons that changed from released to pressed.

levelselect.asm edge detection

    ld   a,(LS_PAD)      ; $D203, active low
    ld   c,a
    cpl                  ; a = buttons held right now
    ld   hl,LS_PREV
    and  (hl)            ; ...that were NOT held last frame
    ld   (hl),c          ; remember this frame for the next one
    and  $0F             ; keep the d-pad, drop the rest
    ld   b,a             ; b = buttons that went down this frame

Match that against Up, Down, Left, Right in order and the code is armed. A wrong direction resets the sequence — though a stray Up starts a fresh attempt, so you never have to wait to try again.

The six bytes of state it all needs live at $DFF0. That address isn't arbitrary: the stack is initialised to $DFF0 and grows downward, and the bytes just above it are shadowed by the cartridge's bank registers at $FFFC. The dozen bytes in between are the only ones in the console's 8K that nothing else can reach.

Eighteen levels, 516 bytes

The whole interaction, at real speed. Four taps to arm it, arrows to choose, Start to go.

The interesting part of verification isn't that the cheat works, it's proving that nothing else changed. Both ROMs were booted from reset and run for 2,600 frames through the title screen and into the attract demo, hashing every frame:

516bytes changed of 262,144

0.20%of the cartridge

581bytes still free in the hole

2599/2600frames pixel identical to retail

One frame in 2,600 differs, by 36 pixels across two scanlines during the attract demo, a raster-timing artefact from the couple of hundred processor cycles the hook costs per title-screen frame. It's invisible, it doesn't compound, and it is unavoidable for any code added to that loop.

Every one of the eighteen levels was then selected, entered and played far enough to confirm Sonic moves. Including the last one, which is not a place the game normally lets you visit early:

Sky Base Zone, Act 3 — the final level — about ninety seconds after switching the console on.

What this does and doesn't unlock

The pipeline can now change anything the game does. Physics constants, cheats as build flags, debug overlays, a randomiser, custom text anywhere the font system reaches, all of it is ordinary work now, with 581 bytes still spare and the option to grow the cartridge if that isn't enough.

What it can't do yet is change what the game contains. Level layouts, object placement and tile graphics are still opaque binary blobs in the disassembly, carried along byte for byte because nobody has reverse engineered their formats. No moved rings, no redrawn sprites, no new level design until that happens.

There's one loose thread worth pulling, though. The level header pointer table holds 37 entries, but ordinary play only ever reaches eighteen of them plus the ending. Everything above that is a level description sitting in the cartridge that the game will not load — alternate entry points, unused rooms, or something else entirely. The level select is one constant away from finding out.

Built from Ravenfreak's Sonic 1 Game Gear disassembly with WLA-DX 9.5. Verified against the Gearsystem core driven in-process from Python, and against BizHawk. All screenshots are unretouched emulator output at the console's native 160 × 144.

Youtube Video showing final product https://www.youtube.com/watch?v=f4l9FbKBcfo&t=4s