A 260,000-parameter language model (karpathy's stories260K) running on a 16.78 MHz ARM7TDMI with no FPU, 256KB of work RAM, and a 16-bit cartridge bus. It generates TinyStories-style text at ~2.4 tok/s (fresh context) to ~1.3 tok/s (context nearly full). Press A for a new story (reseeded from button timing), B to stop one early.
Built on a from-scratch bare-metal scaffold β no devkitPro/libgba, just
arm-none-eabi-gcc and code you can read top to bottom.
make # produces build/game.gba (~294KB ROM)
make run # builds and launches it in mGBA
make host # native build of the same engine -> build/host_llm (fast testing)
make model # regenerate model_data.c/h from model/*.bin (tools/export_model.py)
./build/host_llm [steps] [temperature] [seed] runs the exact engine code
natively; temperature 0 is greedy decoding, which is what we diff against
karpathy's float run.c to validate the fixed-point math.
The ARM7TDMI has no FPU, and software floats would be brutally slow, so
source/llm.c is a llama-2-style transformer written entirely in integer
math (the same file compiles for host and GBA):
- Weights (
source/model_data.c, generated): int8, quantized per output row at export time, with Q24 fixed-point row scales. 259KB of ROM. - Activations: Q16.16 fixed point in int32. Each matmul dynamically quantizes its input vector to int8, does int8Γint8βint32 dot products, then rescales via two 64-bit multiplies per output element.
- KV cache: int8 with per-vector Q16 scales β 160KB, the main EWRAM tenant (total .bss is ~194KB of the 256KB).
- Nonlinearities: RMSNorm uses a bitwise 64-bit integer sqrt; softmax and SiLU use a 256-entry exp2 LUT with interpolation; RoPE sin/cos come from Q14 tables precomputed at export.
- Hot loops (
matmul,attn_head,exp_q16,quantize) are compiled as ARM (not Thumb) and copied into IWRAM at boot (~1KB) β 32-bit bus, zero waitstates.REG_WAITCNT = 0x4317(3/1 + prefetch) speeds up streaming the 259KB of weights from ROM every token.
Greedy decoding matches the float reference for the first ~17 tokens before tiny logit gaps flip a close argmax β story quality is indistinguishable.
Nothing left to do: make output boots on real hardware. Everything is
hardware-honest (timers, DMA, waitstates, no emulator shortcuts), and the
build's final step runs gbafix (vendored from devkitPro's gba-tools in
tools/gbafix.c) to embed the Nintendo logo bitmap and fix the header
checksums that the hardware BIOS verifies at boot. Copy build/game.gba
to a flashcart and go.
CPU. An ARM7TDMI (the same core used in a lot of late-90s phones), with two instruction sets: 32-bit ARM (more capable, bigger code) and 16-bit Thumb (denser, usually faster on this CPU because of its narrow memory bus). We build C code as Thumb and only the very first boot instruction as ARM, because the CPU always starts in ARM state.
Memory map. There's no OS, no filesystem, no malloc-by-default β just address ranges:
| Region | Address | Size | Notes |
|---|---|---|---|
| BIOS | 0x00000000 | 16KB | Nintendo's code; provides a few system calls (SWIs) |
| EWRAM | 0x02000000 | 256KB | "External" work RAM β general storage, slower bus |
| IWRAM | 0x03000000 | 32KB | "Internal" work RAM β on-chip, fast; stack lives here |
| I/O | 0x04000000 | - | Memory-mapped hardware registers (video, input, sound, timers...) |
| PALRAM | 0x05000000 | 1KB | Color palettes |
| VRAM | 0x06000000 | 96KB | Video RAM β tiles, bitmaps, backgrounds |
| OAM | 0x07000000 | 1KB | Object Attribute Memory β sprite positions/state |
| ROM | 0x08000000 | β€32MB | The cartridge β where your code and assets live |
There's no "print to console" or "malloc" β you make things happen by
writing to specific addresses in the I/O region. include/gba.h names the
few we use so far (REG_DISPCNT, REG_VCOUNT, REG_KEYINPUT). Add more
as needed β the GBA has dozens (background scroll registers, sprite/OAM
control, sound channels, timers, DMA, interrupts).
Boot sequence (source/crt0.s). The BIOS jumps to 0x08000000 in ARM
mode and expects, in order: one branch instruction, then a fixed 192-byte
header (a Nintendo logo bitmap it checksums, plus title/game code/maker
bytes). crt0.s builds that header, sets up a stack pointer for IRQ and
System CPU modes (the ARM7TDMI banks registers per mode), copies
initialized globals (.data) from ROM into EWRAM, zeroes uninitialized
globals (.bss), then jumps into main().
The Nintendo logo bytes are zeroed in
crt0.s; the build's finalgbafixstep (tools/gbafix.c, vendored from devkitPro) patches the real bitmap and header checksums intobuild/game.gba, so the shipped ROM boots on real hardware. mGBA doesn't verify the logo, but the hardware BIOS does.
Linker script (gba.ld). Tells the linker to place code/read-only
data in ROM, and put initialized/uninitialized globals in EWRAM. This is
also where the symbols crt0.s uses (__data_start, __bss_end, etc.)
come from.
Video. main.c uses "Mode 3": a single 240Γ160 framebuffer, one
16-bit color per pixel (5 bits each for R/G/B), written directly into
VRAM β simplest mode to reason about, closest to "just draw pixels."
Real GBA games mostly use Mode 0: tiled backgrounds (reusable 8Γ8
tile graphics referencing a palette, arranged on a tilemap) plus
hardware sprites (independently positioned objects drawn from OAM),
because tiles are dramatically cheaper on VRAM and CPU than redrawing a
full bitmap every frame. Once you have sprite/tile art, that's the next
thing to add here β happy to build the Mode 0 + sprite path when you have
assets to test it against.
Timing. The screen draws one scanline at a time, 228 lines per frame
(160 visible + 68 vblank), giving a ~59.7Hz frame rate. main.c polls
REG_VCOUNT to detect vblank (the safe window to update VRAM without
tearing) by busy-waiting. The more efficient real-world approach is to
enable the vblank hardware interrupt and call the BIOS VBlankIntrWait
SWI, which lets the CPU idle instead of spinning β worth adding once the
game loop needs the CPU time back.
Input. REG_KEYINPUT is inverted (0 = pressed) for historical
reasons; key_poll() in gba.h flips it so KEY_LEFT etc. read
naturally.
- Tiles/sprites/backgrounds: convert PNGs to GBA tile format with
grit(part of devkitPro, or usable standalone) β outputs.c/.harrays you#includeand DMA into VRAM/palette memory. - Audio: the GBA has no built-in synth worth using raw; real projects use a tracker/driver (Maxmod is standard with devkitPro). We haven't wired anything audio-related yet β the register-poking approach doesn't scale well here, so this is a good place to actually pull in a library.
- If any of this gets tedious in raw C, Butano (a C++ engine on top of devkitARM) wraps sprites/backgrounds/audio/scenes in a much friendlier API, at the cost of not seeing the registers directly. Worth reconsidering once the hardware model above feels familiar.
source/crt0.s boot code, CPU mode setup, ROM header, IWRAM/.data copy
source/main.c generation loop, status line, mGBA debug logging
source/llm.c the integer-only transformer engine (host + GBA)
source/console.c mode 3 text console (font8x8, DMA scrolling)
source/model_data.c generated: int8 weights, tables, tokenizer (make model)
include/gba.h hardware register definitions
include/llm.h engine API + IWRAM placement of hot code
host/test_llm.c native test harness for the engine
model/ stories260K.bin + tok512.bin (fp32 originals)
tools/export_model.py quantizes the model into source/model_data.c
gba.ld linker script (ROM/EWRAM/IWRAM layout)
tools/gbafix.c devkitPro's header fixer (logo + checksums), run by make