Skip to content

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

$$$$ a^n + b^n = c^n | n>2 $$$$

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 &#124; n&gt;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.