Skip to content

The games API

Eleven interfaces, 486 functions, imported by slot index. This is the surface a shipped game was built against, and the only copy of it anywhere is the one in the firmware.

OpenGLES     170        Metadata     169        Audio         54
SoundEffect   42        AsyncFileIO  17         miscTBD       13
Users         10        DebugUtil     5         MemoryAlloc    3
InputEvents    2        Settings      1

One of them is called miscTBD, which tells you roughly how finished this header was when it went out to studios.

The spine: one platform object

Every interface reaches its service the same way: fetch the platform singleton, then read a member of it. The singleton is a C++ function-local static, with the usual shape - test bit 0 of a guard word, and if it is clear, acquire the guard, construct, register the destructor, release. The Itanium ABI trio got named alongside it.

Twenty-five functions go through it, and the member offset says which service you are looking at: one offset is the block arena behind MemoryAlloc, another the file service behind AsyncFileIO.

Reading an interface slot by slot is the slow way in. Read the offset each slot loads and the method it forwards into, and the interface collapses to a single class.

graph LR
    SLOT["a slot<br/>(15 of the 486)"]
    PLAT["<b>the platform object</b><br/>a C++ function-local static,<br/>guarded and constructed once"]
    ARENA["+0x294 the block arena<br/><i>MemoryAlloc</i>"]
    FILE["+0x298 the file service<br/><i>AsyncFileIO</i>"]
    SLOT --> PLAT
    PLAT --> ARENA
    PLAT --> FILE

Every slot has a state

A table with all 486 rows is generated, and each row carries one of four states:

NAMED     266   its behaviour is recovered from its body
RESOLVED  216   the implementing function is known; what it MEANS is not
INFERRED    4   a candidate class, with the reason it is not proof written down
OPEN        0

RESOLVED is not a synonym for done. A slot that dispatches through a vtable offset of the track-reference class has a concrete address and no more meaning than it had before; what it buys you is one function to open instead of a search.

The four INFERRED rows are the artwork list. Its class cannot be measured here: the list was empty in every dump taken, and the only slot that inserts into it takes the object from its caller, so those objects come in through the API rather than being built in this image.

Sorting the slots by shape is what makes 486 of them tractable:

own-body           334   its own code; has to be read
list+vtable        116   fetch from a list, dispatch through a vtable
forwarder           16   a single branch
platform-service    15   fetch a service off the platform object and forward into it
post-event           5   build an event and post it

What the interfaces are

MemoryAlloc is three slots - allocate, free, reallocate - over an arena that is not a plain heap. Allocate refuses anything larger than the arena's capacity, then splits by size: below the threshold it goes to a general allocator, above it comes out of a table of preallocated blocks tracked by a bitmask. Free mirrors that: find the pointer in the table and clear its bit, or tail-branch to the general free. The arena is the game's own request capped at 5 MiB plus 3 MiB always, so 3 to 8 MiB.

What bounds a game is therefore the arena's size, not how many times it allocates.

DebugUtil is four file operations over a ten-entry handle table, plus a formatted print. The print goes out over ARM semihosting, character by character, to whatever is debugging the device - which is where the interface gets its name, and it is how a stuck game can be made to explain itself.

InputEvents has two slots that go opposite ways. The game reads input, with the wheel arriving as a delta rather than a position: the accumulator minus the consumed mark, then the mark advances and the pending byte clears, so a movement is delivered exactly once. And the game posts events into the firmware's own queue, after a two-byte descriptor is validated.

Settings is a single slot, a lookup by name over three records - TimeFormat, Language, HoldSwitch - matched exactly, so Time does not find TimeFormat. Each record's handler carries the signature, and the count argument is in-out: capacity going in, length written coming out. There are five distinct error codes, and the "buffer too small" one is checked before anything is written.

The length field in each record is zero in the image and filled at init from the strings, which is worth knowing before you conclude from the static data that no name can ever match.

AsyncFileIO is not seventeen separate functions. Twelve slots fetch the file service and forward into a method that gates on a kind byte in the request, with the slot index selecting the kind; the rest of the interface is the workers behind them.

Audio is a command API - fifty-three distinct targets for fifty-four slots, each allocating a small event, stamping it with its own class pointer and posting it. A slot's identity is the event class it stamps, and the interface is asynchronous by construction: a game asks, then waits. The events are the same 0x66 family the game screen's lifecycle uses.

OpenGLES, at 170 slots, is the Vincent ES 1.1 the firmware already carries, handed to games wholesale, and its tail is EGL rather than GL. Working out which slot is which needed a different method from the rest: fingerprinting them by the enum values they accept.

Some slots return a constant and nothing else - one is a bare bx lr, another returns a fixed number, five GL slots return 0 and two return 1. The original does nothing there either, so they are not gaps.

The frame contract

The runner fills two lists before every call to entry[4], and clears both when the frame returns:

A+0x2C   the finished ASYNC IO requests
A+0x30   the input events

A completion node, read from the game's own walk of that list:

+0x00  next            the link the OS overwrites
+0x04  in-flight byte  the walker clears it
+0x08  callback        the game's own function pointer
+0x0C  context         handed to it

The platform's job is to deliver the node; the game calls its own callback. And the runner's stop condition, checked after every frame, is the game's own state word: keep going while the game has not written 5 or 6 into it. Writing 5 is a game quitting, so a port that only stops when a frame returns negative will keep calling into a game that has already torn itself down.

sequenceDiagram
    participant G as the game
    participant H as the runner
    participant S as the file service
    G->>S: submit a request (kind byte selects the work)
    S-->>G: 1 = QUEUED (any non-zero reads as queued)
    Note over G: marks its request in flight
    S->>H: the finished request, linked through request+0
    H->>G: entry[4](A, B) with A+0x2C = completions, A+0x30 = input
    Note over G: walks A+0x2C, clears the in-flight byte,<br/>calls its OWN callback at node+0x08
    H->>H: clear both lists, then test:<br/>continue iff the game has not written 5 or 6

Three things about this contract are easy to get subtly wrong, and all three were wrong here at some point:

  • A submit answers "queued", not "OK". Any non-zero answer means the request is in flight, so a refusal code read as success leaves a game waiting for a completion that will never arrive.
  • A+0x2C has to be published. If it is always zero, no submit can ever complete, and a game sits in its loading state while its frames keep running perfectly happily.
  • A slot that answers a pointer must answer a pointer. One metadata slot returns the element - an object pointer - or zero when out of range. Answering index + 1 hands a game a small non-null integer to dereference; it is kept as a refcounted object, released through bx, and the low bit of the garbage pointer picks the instruction set. If there is nothing real to return, return the failure the device returns.

Tracing what a game actually calls

A shipped game is unmodified, so a table entry pointing at the wrong function is entirely possible and nothing else would catch it. The registry can be built with a naked trampoline per slot: push the argument registers and the link register, log the interface and slot, pop them back, and branch through the real table. The callee sees exactly the registers and stack it would have seen.

The steady-state frame, from that trace:

GL nop . context attribute . glClear . a query . attribute . nop . eglSwapBuffers .
timer x2 . input poll

Two build notes, if you turn it on: ARMv5 has no movw, so the interface and slot go into two 8-bit immediates rather than a literal, and 486 trampolines put a literal pool out of reach, so each one needs its own .ltorg.

The trace is also what turns "the game runs" into something specific. It read, at one point, as a game allocating, uploading exactly one texture, then clearing and presenting an empty frame for ever, with glDrawArrays at zero across a whole launch. The arena was refusing it memory. Once that was fixed, 105 SDK calls a launch became 200,745 and the title screen arrived on the panel.

Where the implementation is

chikuma/drivers/games/, with host tests that assert the original's behaviour: a bounded arena that refuses rather than eating the firmware's heap and ignores foreign pointers, a ten-handle ceiling, a wheel delta consumed on read, and the five settings error codes staying distinct.