Skip to content

Running it in the emulator

You do not need an iPod to work on Chikuma. The machine in qemu-ipod/ is a QEMU model of the S5L8702 that boots both Apple's RetailOS and Chikuma, against the same disk image, which is the whole reason claims in this project are checkable.

The two commands

cd qemu-ipod
./retailos.sh                                   # Apple's firmware, in a window
./run.sh --payload ../chikuma/payload.elf   # Chikuma instead

retailos.sh is run.sh with the device's identity and the savestate conveniences added. Go to run.sh directly for a payload or an unusual configuration. --help lists everything.

Headless, for a single frame:

./run.sh --headless --shot out.ppm

It reaches the Language screen in about 5 seconds.

Booting through the real chain

run.sh --payload loads the ELF straight in, which is a fresh reset and skips everything before the OS. To boot the way the device does - reproduced boot ROM, C ONB, signature check, hand-off - use ./run_secure_boot.sh. That, and the macOS companion app that drives the machine as an iPod, are on The whole chain, and Tomodachi.

The two ONB gates run the same models without a window: gates/onb_integ_gate.py boots a bare-metal payload that drives one ported module and takes its verdict from the process exit code, and gates/onb_display_gate.py checks the panel's pixels for the display trio.

Driving it

Live, in the window: two-finger scroll or a mouse wheel turns the click wheel, Up / Down step it, Enter or Space is SELECT, Left / Right are PREVIOUS / NEXT, M or Esc is MENU, P is PLAY/PAUSE. Left and right click are SELECT and MENU. The pointer is absolute, so the window never grabs your mouse.

Scripted, from Python: tools/ipodmachine.py is the interface, and tools/drive.py is a thin CLI on top of it. Import it rather than re-implementing the QMP handshake yourself.

from ipodmachine import Machine

with Machine(audio="coreaudio", wav="/tmp/out.wav", debug="sm1") as m:
    m.wait_for_screen()      # boot, then the first frame with real content
    m.press("select")        # a real 120 ms press
    m.scroll(24)             # a dozen notches move one menu row
    m.shot("/tmp/menu.png")
    print(m.uart())          # what the guest printed

Sockets, logs and the gdb port are per process, so a probe never disturbs an emulator you already have running, and two probes can run at once.

Three things that will catch you

A press must have a duration

Sending down and up in one batch is a zero-length press, and the firmware's debounce never sees it. About 120 ms of guest time is a tap; a second is a press-and-hold. The monitor's sendkey gets this right on its own.

The wheel is coarse, and its gain is not fixed

A menu row costs somewhere between six and a dozen notches depending on the list, so an open-loop scroll(N) that works on one screen overshoots on the next. Drive to a known row by screenshotting and checking, not by counting notches. tools/menu_walk.py walks a path by row for exactly this reason.

--warp breaks interaction

icount keeps the instruction count exact, and by default virtual time is tied to real time. With --warp (sleep=off) QEMU skips the idle and the guest clock runs about 35× fast: 100 ms press arrives as a three-second hold, and the firmware's 150-second idle sleep lands about four seconds after boot. Use --warp only for a batch boot that takes no input.

Which disk you boot decides what is there to play

The library lives on the disk image. The default (chikuma/build/ipod_apm.img) is a small test volume. A larger one with movies and podcasts is used for the media work and is passed explicitly.

If the library reads as empty (menus present, songs absent), the FireWire GUID is wrong: the retail database is bound to the GUID it was synced with, and the machine has to present the same one.

Debugging

tools/hook.sh attaches gdb hooks using the Ghidra project's own function names:

./tools/hook.sh 'Hook("volume_probe_and_register")' 200

For anything where the cadence is the thing under test, a gdb breakpoint is the wrong instrument: its RSP round trip per hit changes what you are measuring. tools/pccount_plugin.c is a TCG plugin that counts executions of given runtime addresses and dumps them to a file, which is how a per-cover-change hitch in Cover Flow was pinned on 100 texture uploads per flick.

The frames themselves are a measurement too. The pipe model writes one file per published frame, so their timestamps give the real update cadence - a screenshot loop samples at its own rate and misses the hitches entirely. drive.py --frames DIR dumps every frame of a run, and frames <dir> as a scripted action starts and stops the dump mid-run, so a boot's frames need not be kept.

--debug <subsystem> (sm1, i2c, pmu, …) turns on the machine's own traffic logs, and that is often the whole experiment: --debug i2c on our payload printing the same two codec writes the original produces is what closed the volume curve.

Savestates are worth the habit. Anything that takes an hour of guest time to reach should be frozen just before the interesting moment; one such savestate turned a 90-minute end-of-track experiment into a three-minute one.

The retired emulator

emu/ was a Python + Unicorn machine, the oracle until 2026-08-09. It was deleted 2026-08-25, together with the Unicorn-only tooling that imported it (the *_entry_test.py UI tests, the raster and transform oracles, and the kernel task-table scraper). Their frozen outputs stay in the tree.

It was retired over interrupt timing. Unicorn delivers interrupts only at slice boundaries; QEMU raises the line the instant a device asserts it, as the hardware does. An interrupt landing in the middle of a kernel service faulted on every QEMU boot and passed the Unicorn gate every time, and a model that cannot produce the hardware's timing cannot be the oracle for behaviour that depends on it.

What emu/ was: the record. The measured comments its device models carried now live where the QEMU port kept them - in qemu-ipod/devices/'s own comments and in the reports (whose citations to the old emu/... paths are historical).