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
SoundEffectslots (13-15 distinct each), plusAudio,UsersandmiscTBDslots that are not implemented yet. - A game has no writable state. Vortex opens
optionsandstatsand 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.