Skip to content

Click-wheel games

The iPod classic runs games, and they are real ARM binaries: written by outside studios, loaded into the running firmware, given a frame loop. Apple never published an SDK, yet EA, Namco, Ubisoft and PopCap all shipped titles from 2006. There was a private one, and the whole of its surface is still sitting in the firmware image.

Two pages carry the detail:

  • The eApp format: how a game binary is laid out, and how its imports are bound.
  • The games API: the eleven interfaces and 486 functions a game is bound against.

What ships on the device

The 2.0.1 firmware partition (the FAT16 IPODRESOURC volume) carries three bundles. The directory name is the iTunes store item id, not the title:

Games/games_RO/11004    iPod Quiz   Executables/TWA_n25_3178308.bin   500532 bytes
Games/games_RO/11010    Klondike    Executables/Klondike_3178311.bin  632804 bytes
Games/games_RO/12347    Vortex      Executables/Vortex_3178305.bin    640368 bytes

A preinstalled game is packaged exactly like a bought one: the same bundle shape iTunes syncs into iPod_Control/games_RO/, under the same store id. Each bundle holds its own artwork, fonts, localisation, level data and audio, and the audio is plain .m4a that goes through the same sm1 path as a track. The executables are FairPlay protected; the rest of the bundle is in the clear.

There is one binary per platform, named by the bundle's Manifest.plist, and the firmware picks the one matching the model it is running on.

The sandbox: four access domains

A game never sees a filesystem. It names a domain and a relative tail, and the firmware decides where that actually lands:

kind domain what it means
0 games_RO the game bundle itself, read only
1 gamedata_RW that game's own data, read/write
2 gamestats_WO write only
3 gamedata_ShareRW a shared area, read/write
4 games_RO the default arm, same as 0
graph LR
    G["a running game"]
    P["the path builder<br/>domain + relative tail"]
    RO["games_RO<br/>the bundle, read only"]
    RW["gamedata_RW<br/>its own data"]
    WO["gamestats_WO<br/><b>write only</b>"]
    SH["gamedata_ShareRW<br/>shared"]
    G -- "names a DOMAIN, never a path" --> P
    P --> RO
    P --> RW
    P --> WO
    P --> SH

The path builder strips a leading / before composing, so a game cannot escape by anchoring at the root, and it resolves the domain name itself instead of taking a path from the caller.

gamestats_WO is the interesting one. Write-only means a game can post a score and read nothing back, not even its own previous scores. Nobody designs a four-domain capability model with a one-way stats channel for code they wrote themselves; this was written for other people.

From the menu to a running game

The Games menu is an ordinary archive-driven list screen, three slots, and its SEVT record states the whole interaction:

list.chosen          -> navigator.PushScreen Game_Screen Game_Screen_Default screenpush
list.delayedselected -> HandleGameHilited

So the launch is a screen push the record carries, not code. What the archive cannot carry is the rows, because a game is a bundle iTunes put on the disk: the firmware scans the two domains and reads each Manifest.plist.

graph LR
    M["<b>Games_Menu_Screen</b><br/>rows from the bundle scan"]
    S["<b>games_app_state</b><br/>the highlighted row INDEX"]
    G["<b>Game_Screen</b> (TCGameScreen)<br/>pushed by the SEVT record"]
    L["<b>launcher</b><br/>read the executable into the arena"]
    E["<b>eApp loader</b><br/>bind 486 imports"]
    R["<b>runner task</b><br/>call entry[4] per frame"]
    ERR["Game_*_Error_Screen<br/>version · signing · memory · unknown"]
    M -- "list.delayedselected<br/>HandleGameHilited" --> S
    M -- "list.chosen · PushScreen" --> G
    S -. "read by the constructor" .-> G
    G -- "0x66000003 StartGame" --> L --> E --> R
    E -- "refusal" --> ERR

The menu never hands the launcher a game. It parks the highlighted row index in a shared app-state singleton and pushes the screen, and TCGameScreen's constructor picks the choice back up out of that same singleton.

Game_Screen is one screen with six layouts, and the layouts are the state machine. Its notification handler dispatches on a single event space:

0x66000003  StartGame            the launch
0x66000004  ShowVersionError     Game_Version_Error_Screen
0x66000005  ShowSigningError     Game_Signing_Error_Screen
0x66000006  ShowMemoryError      Game_Memory_Error_Screen
0x66000007  ShowUnknownError     Game_Unknown_Error_Screen

0x66xxxxxx is one event space for the whole subsystem. 0x01..0x07 is the screen's lifecycle, and the higher numbers, 0x6600000D..0x66000025, are a running game's audio commands - which is why the games API's Audio interface is asynchronous.

Where Chikuma stands

All three shipped titles boot, and none of them quits. Measured 2026-09-02: each launched from the Games menu on a cold boot, then watched for another minute with one select press halfway through.

game frames in the watch where it gets to
iPod Quiz 0x1100 its name entry: title, letter ring, the 1:00 timer
Klondike 0x2a00 its name entry, over the card-back art
Vortex 0x1400 ENTER NAME, with the alphabet ring

No fault, no refused allocation, no quit. The wheel reaches them too: the select press arrives as a key event and each game answers by asking for sound effects.

Two things had to be fixed to get there, both worth knowing if you read the code.

AsyncFileIO kind 6 loads a whole file and kind 7 stores one. The dispatcher's arm is open, transfer, close, and it reports the transfer's status rather than the close's. Chikuma answered it as a plain open for a while, so a game took the completion as "loaded" and parsed a buffer nothing had filled.

And a game's heap has to be the size the device's is. Klondike asks for 512 KiB while loading its card textures; the old eight-megabyte heap refused it, and a game that is told no sets its state to 5 and quits. The device sizes its heap from the DRAM range and hands a game 3-8 MiB of arena on top of that. Chikuma's is 14 MiB, which is what the resident blobs in this payload leave - the library pool, the CJK font, the archive blob, the video feed. Making it bigger means moving one of those.

What is still open

  • Sound. All three games ask for SoundEffect slots (13-15 distinct each), plus Audio, Users and miscTBD slots that are not implemented yet.
  • A game has no writable state. Vortex opens options and stats and both miss, because kind 7 is refused at the open: this port's FAT16 has no writer. That is the next piece of work, and the firmware's own FAT library has been read and named for it.
  • The family code. The firmware reads a halfword out of SysInfo that nothing in the image ever writes, so it is hardware data the emulator owes the guest, and the emulator currently zeroes it. It is not what stopped any of the three - RetailOS reads the same zeroed block in the same emulator
  • but it does change a step the manifest loader takes.

The implementation lives in chikuma/drivers/games/, with host tests that assert the original's behaviour rather than ours.