The eApp format¶
A game binary is an eApp image: a header, a linked list of interface records, and a vector of entry
points. Not ELF, not Mach-O. It was recovered from the loader alone, since the shipped executables
are FairPlay protected and the eapp magic appears nowhere else in the firmware, so every field
below comes from the code that reads it. The first decrypted image agreed with all of it.
The header¶
+0x00 u32 'eapp' 0x70706165 rejected with -2 otherwise
+0x04 u32 version, must be <= 0x10001000
+0x08 u32 nentry, must be <= 5
+0x0c u32 not read by the loader
+0x10 ptr the FIRST interface record, or 0
+0x14 u32 entry[nentry]
0x10001000 reads as a packed 16.16 version ceiling. The refusals are -2 (bad magic, or a record
that fails validation), -3 (the named interface is not provided by this OS) and -0x3E9 (version
or count out of range).
The pointer at +0x10 is absolute. It is bounds-checked against [base, base+size) and then used
directly, never added to the base, and the loader relocates nothing at all: it patches imports and
that is the whole of it. An eApp image is therefore linked at the fixed address of the launcher's
game arena, which anything you build to run there has to live with.
The entry vector¶
The five words are copied into the app object, and entry[4] must be non-zero or the launch fails.
What each is for, measured at the call sites:
entry[0] called immediately after the load succeeds
entry[1] called on teardown, before the state goes to 3
entry[2] not seen called
entry[3] not seen called
entry[4] THE ENTRY: called on the game thread, once per frame, as f(A, B)
entry[4] is per frame, not once: the runner calls it in a loop and checks its own stop condition
after every call.
Interface records, and how imports are bound¶
The image carries a singly-linked list of the interfaces it wants:
+0x00 char name[0x20] NUL-terminated, matched against the host's
+0x20 u8 guid[0x10] the interface VERSION identity, compared byte for byte
+0x30 u32 nfunc must equal the host's count for that interface
+0x34 ptr next, or 0 absolute, bounds-checked like +0x10
+0x38 nfunc import stubs, then a sentinel word
The list ends with a record whose name is
Fermat's last theorem, 31 characters and a NUL, which is exactly 0x20 - a name nothing else is
going to collide with. Reaching it is what makes the load succeed.
The stub area is ldr pc,[pc,#imm] veneers followed by their literal pool, and the loader fills
the pool. For each run it reads the first stub, takes the low 12 bits as the immediate and computes
n = imm/4 + 2, which is exactly the number of words from an ldr pc,[pc,#imm] to its literal when
the literals follow n stubs. Then it copies n host function pointers into the slots and moves on.
Compiled game code calls bl stub; the stub loads the patched literal into pc. Nothing else in the
image is touched.
graph TD
H["<b>header</b><br/>'eapp' · version · nentry · entry[5]"]
R1["<b>interface record</b><br/>name · guid · nfunc · next"]
S1["<b>import stubs</b><br/>ldr pc,[pc,#imm] × n"]
P1["<b>literal pool</b><br/>n empty words"]
R2["interface record ..."]
F["<b>terminator record</b><br/>$$$$ a^n + b^n = c^n | n>2 $$$$"]
HOST["the OS interface list<br/>name · nfunc · function pointers · guids"]
H -- "+0x10, absolute, bounds-checked" --> R1
R1 --> S1 --> P1
R1 -- "next" --> R2 -- "next" --> F
HOST -- "matched by name + guid + count,<br/>then COPIED into the pool" --> P1
A game bakes in no firmware address¶
Names and version guids are matched, counts are checked, and every call into the OS goes through a slot the loader fills at load time. A game imports by slot index, so the order of an interface is the contract; the names are only ever for us.
Mismatches are caught rather than tolerated. A wrong guid or a wrong function count fails the load with its own code, and the firmware has a screen for each failure.
The host side¶
The OS publishes its interfaces as a walkable list at a fixed address, each descriptor carrying a
name, a function count, the pointers themselves, and one version record per accepted guid. That
table is the third-party SDK's surface: eleven interfaces, 486 functions, versioned, and one of them
called miscTBD. It reads like somebody's internal header, which is what it was.
The layout also predicts where the next descriptor starts, so a bad capture announces itself: an
early truncated dump parsed nine interfaces, Audio did not fit its own declared count, and the
arithmetic said two were missing. The complete image has eleven and Audio parses.
See the games API for what is in them.
Reproducing it¶
chikuma/tools/eapp_format.py asserts every constant above against the image and fails if any of
them moves. Chikuma's own loader is in chikuma/drivers/games/.
The registry is generated rather than typed, because the loader refuses a game whose record does not match the host's name, guid and function count exactly. A tool walks the firmware's own list and emits all eleven interfaces with their 486 slots, with the slots themselves NULL: an address in Apple's image means nothing here, and what a game actually needs is Chikuma's implementation. The set, the order and the counts are what have to be right.
The import side is generated too, which closes the loop. An image assembled from the generator - header, chained records, trampolines and their pool - loads through Chikuma's loader with 486 of 486 imports bound, and so do the shipped games.
The trampoline encoding falls out of the geometry rather than being copied: for a run of N imports,
every veneer encodes the same displacement, N*4 - 8. That is why each shipped interface shows one
immediate throughout - seventeen imports give ldr pc,[pc,#60], and a single import gives
ldr pc,[pc,#-4], the negative case the loader's sign test is there for.
The negative controls are bytes changed in a real image: corrupt the magic, raise the version past the ceiling, flip one guid byte, move the function count. Each has to come back with the original's own refusal code.