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:
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:
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).