gba-kit includes a headless Node.js runtime (@gba-kit/gba-node) that lets you write scripts to automate emulator actions. For example: navigate menus, capture screenshots, dump memory, manage save states, and assert on game state. Scripts run in a sandboxed VM context with top-level await support.
Some use cases for scripting:
- Automated testing and regression checks
- Automatic research and reverse engineering using LLM agents
import { HeadlessRuntime } from '@gba-kit/gba-node';
const runtime = await HeadlessRuntime.create({
romPath: './game.gba',
outputDir: './output',
logFn: console.log,
});
await runtime.executeScript(`
await wait({ frames: 120 });
await takeScreenshot({ name: 'title' });
await press('start');
await wait({ frames: 60 });
await takeScreenshot({ name: 'menu' });
`);const runtime = await HeadlessRuntime.create({
romPath: './game.gba', // Required — path to .gba ROM file
loadSavePath: './savestate.json', // Optional — restore a save state on startup
outputDir: './output', // Required — directory for screenshots, snapshots, save states
logFn: console.log, // Required — receives console.log output from scripts
});The CPU is initialized in post-boot state (System mode, SP initialized, PC at 0x08000000).
All functions below are available as globals inside executeScript(). Async functions must be awaited.
Advances the emulator until a condition is met.
Wait for a fixed number of frames:
await wait({ frames: 60 }); // Run 60 frames (~1 second at 59.7 Hz)Wait for a memory value:
// Wait until byte at address equals a value
await wait({
memory: { address: 0x03000010, equals: 0x01 },
timeout: 300, // Max frames to wait (default: 600)
});
// Other comparisons
await wait({ memory: { address: 0x03000010, lessThan: 5 } });
await wait({ memory: { address: 0x03000010, greaterThan: 100 } });
await wait({ memory: { address: 0x03000010, bitSet: 0x80 } }); // Check if bit 7 is setA numeric address is read as a single byte. When the runtime is created with an elfPath, address may instead be a symbol or symbol.field path, resolved through the DWARF and read at the field's full width, with bitfields decoded:
await wait({ memory: { address: 'game_sm.state', equals: 5 }, timeout: 300 });
await wait({ memory: { address: 'g_game_vars.rng_info.seed', greaterThan: 0 } }); // nested fields tooA path throws (before running any frames) if debug info isn't loaded, the path can't be resolved, or the field is wider than 4 bytes.
Wait for a screen pixel to match a color:
// Wait until pixel at (120, 80) becomes black (e.g., fade-to-black transition)
await wait({
pixel: { x: 120, y: 80, r: 0, g: 0, b: 0 },
timeout: 300,
});Wait for the program counter to reach an address:
await wait({
pc: 0x08001234,
timeout: 600,
});Throws an error if the condition isn't met within the timeout.
Presses one or more buttons, holds them for a number of frames, then releases.
// Press A for 1 frame (default)
await press('a');
// Hold Start for 5 frames
await press('start', { hold: 5 });
// Press multiple buttons simultaneously
await press(['a', 'b'], { hold: 3 });Valid button names: a, b, select, start, right, left, up, down, r, l
Executes a series of timed inputs in one call. Each entry is [buttons, frames] where buttons is a +-separated string (or null for no input).
await pressSequence([
['right', 30], // walk right for 30 frames
['a', 5], // press A
[null, 20], // wait 20 frames (no buttons)
['right+b', 20], // jump right (simultaneous)
[null, 10], // wait at apex
['b', 5], // double jump
['right', 25], // drift right to land
]);This replaces many press() / wait() calls with a single compact expression.
Immediately releases a button. Only needed if you're managing button state manually outside of press().
release('a');Saves the current framebuffer as a 240x160 PNG.
await takeScreenshot({ name: 'boss_fight' });
// Output: <outputDir>/screenshot-boss_fight.pngStarts capturing frames during script execution and writes them as a single sprite sheet PNG — all frames tiled in a grid.
const { stopRecording } = record({
name: 'gameplay', // Output: <outputDir>/screenshot-gameplay.png
interval: 4, // Capture every 4th frame (default: 1)
columns: 8, // Frames per row in the grid (default: 10)
});
// All frames between record() and stopRecording() are captured
await press('right', { hold: 60 });
await press(['right', 'b'], { hold: 20 });
await wait({ frames: 30 });
await stopRecording(); // Writes the sprite sheet PNGThe sprite sheet tiles each 240x160 GBA frame left-to-right, top-to-bottom. Use interval to control file size. Example: interval: 4 captures every 4th frame, reducing a 300-frame recording from 300 to 75 tiles.
Dump a named memory region:
await takeMemorySnapshot({ name: 'work_ram', region: 'iwram' });
// Output: <outputDir>/memory-work_ram.jsonAvailable regions: iwram, ewram, vram, oam, palette, io, sram
Dump a custom address range:
await takeMemorySnapshot({
name: 'player_data',
address: 0x03001000,
length: 64,
});The output JSON contains { address, length, data: [...] } with byte values as a number array.
Returns an object with all ARM7TDMI registers.
const regs = getRegisters();
console.log(regs.r0); // General-purpose registers r0-r15
console.log(regs.r15); // Program counter (PC)
console.log(regs.cpsr); // Current program status registerReturns a Uint8Array of bytes from the given address.
const data = getMemory(0x03000000, 16);
console.log(data[0]); // First byteRead a 16-bit or 32-bit value from any address, using the system bus's proper alignment and region handling.
const funcPtr = read32(0x08116620); // read a ROM function pointer
const entityX = read16(0x03002922); // read an entity X coordinateDisassembles ARM or Thumb instructions at a given address. Returns an array of { address, instruction, bytes }. Auto-detects Thumb mode from CPSR if mode is omitted.
const instrs = disassemble(0x0803b074, 5, 'thumb');
for (const i of instrs) {
console.log(`0x${i.address.toString(16)}: ${i.instruction}`);
}
// 0x803b074: push {r4, lr}
// 0x803b076: movs r4, #0x0
// ...Like disassemble() but automatically detects the function end by scanning for return instructions (bx lr, pop {pc}).
const fn = disassembleFunction(0x0800043c, 'thumb');
// Returns: [ { address, instruction, bytes }, ... ] until bx lr
console.log(fn.length + ' instructions'); // e.g., "5 instructions"Reads bytes from memory until a null terminator, returning a string. Default max length: 256.
const title = readString(0x080000a0, 12); // ROM header title
console.log(title); // e.g., "POKEMON_EMER"Returns the color of a pixel on the GBA screen (240x160).
const { r, g, b } = getPixel(120, 80); // Center of screen
console.log(`Color: rgb(${r}, ${g}, ${b})`);Returns an RGBA Uint8Array for a rectangular area of the screen. Useful for comparing regions or computing simple hashes to detect state changes.
// Read the top HUD strip
const hud = getScreenRegion(0, 0, 240, 16);
console.log(hud.length); // 240 * 16 * 4 = 15360 bytesScans IWRAM and/or EWRAM for all addresses holding a given value. Returns an array of matching addresses. This is step 1 of the classic cheat-device workflow.
// Find all locations holding the value 3 (e.g., 3 hearts)
let matches = searchMemory({ value: 3 });
console.log(matches.length); // e.g., 830 candidates
// Options:
searchMemory({ value: 3, size: 8 }); // 8-bit (default)
searchMemory({ value: 1000, size: 16 }); // 16-bit
searchMemory({ value: 0x08000000, size: 32 }); // 32-bit
searchMemory({ value: 3, region: 'iwram' }); // IWRAM only
searchMemory({ value: 3, region: 'ewram' }); // EWRAM only
searchMemory({ value: 3, region: 'both' }); // Both (default)Registers a write watchpoint over a memory range. Every time a write commits to the range, a hit is appended to the returned handle's hits array, recording which code performed the write — a CPU instruction, or a DMA channel.
const w = watchMemory({ address: 0x03005220 }); // watch 1 byte
await press('right', { hold: 30 }); // make the value change
w.stop(); // remove the watchpoint
for (const h of w.hits) {
// h.instructionAddress is the responsible instruction (pc-2 in Thumb, pc-4 in ARM)
const dis = disassemble(h.instructionAddress, 1, h.thumb ? 'thumb' : 'arm')[0];
console.log(`${h.source} wrote ${h.value} at 0x${h.instructionAddress.toString(16)}: ${dis.instruction}`);
}Each hit has: pc, instructionAddress, address, value, size, thumb, and source ('cpu' or 'dma0'..'dma3'). For a DMA write, instructionAddress is the instruction that started the DMA, so a watchpoint on a DMA-filled buffer (VRAM, palette, OAM) points at the code that kicked off the copy.
Options:
length— watch a multi-byte range (default 1).filter(hit)— record only matching hits, so you can watch a wide region without thehitsarray exploding.maxHits— cap recorded hits (keeps the first N).
watchMemory({
address: 0x03000000,
length: 0x8000, // all of IWRAM
filter: (h) => h.source === 'cpu' && (h.value & 0xff) <= 6,
maxHits: 1000,
});clearWatchpoints() removes the watchpoints you created.
Takes addresses from a previous searchMemory call and keeps only those matching a new value. This is step 2+: change the game state, then filter for the new value.
// Step 1: 3 hearts → search for 3
let matches = searchMemory({ value: 3 }); // 830 candidates
// Step 2: Take damage (now 2 hearts) → filter for 2
matches = filterMemory(matches, { value: 2 }); // 2 candidates
// Step 3: Take damage again (1 heart) → filter for 1
matches = filterMemory(matches, { value: 1 }); // 1 candidate — found it!
// Use the discovered address with existing APIs
const healthAddr = matches[0];
await wait({ memory: { address: healthAddr, equals: 0 }, timeout: 600 });Parses the GBA's 128-entry Object Attribute Memory into structured sprite data.
const sprites = readOAM();
const active = sprites.filter((s) => s.enabled);
for (const s of active) {
console.log(`Sprite #${s.index} at (${s.x},${s.y}) tile=${s.tileId} ${s.width}x${s.height}`);
}
// Sprite #0 at (32,88) tile=0 32x32
// Sprite #1 at (61,88) tile=296 32x32Each entry has: index, x, y, tileId, width, height, palette, priority, hFlip, vFlip, enabled, mode.
Returns the scroll register values for a background layer (0–3). Most games use this to scroll the camera with the player.
const scroll = readBgScroll(1); // BG1 is often the main game layer
console.log(scroll.x, scroll.y); // e.g., 240, 0Reads the background tilemap from VRAM as a 2D grid of tile entries. Reveals level geometry — solid tiles vs. empty space.
const tm = readBgTilemap(0);
console.log(`${tm.width}x${tm.height} tiles, tileSize=${tm.tileSize}`);
// Check if a specific tile position is empty
const idx = row * tm.width + col;
console.log(tm.tiles[idx].id === 0 ? 'empty' : 'solid');Each tile entry has: id (10-bit tile index), hFlip, vFlip, palette.
Parses the DISPCNT register to reveal which background layers, sprites, and windows are currently enabled.
const dc = readDisplayControl();
console.log(`Mode ${dc.mode}, sprites=${dc.obj}`);
console.log(
`Active layers: ${dc.bg
.map((on, i) => (on ? 'BG' + i : null))
.filter(Boolean)
.join(', ')}`,
);
// Mode 1, sprites=true
// Active layers: BG0, BG1, BG2Computes a 32-bit FNV-1a hash of a screen rectangle. Much cheaper than comparing full pixel data.
const before = hashRegion(0, 0, 240, 160);
await press('right', { hold: 30 });
const after = hashRegion(0, 0, 240, 160);
console.log(before === after ? 'STUCK' : 'MOVED');Registers a function called after every emulated frame during wait(), press(), and pressSequence(). Pass null to unregister.
const scrollLog = [];
onFrame(() => {
scrollLog.push(readBgScroll(1).x);
});
await press('right', { hold: 60 });
onFrame(null);
// scrollLog now has 60 entries showing camera X per frameSerializes the complete emulator state (CPU, memory, PPU, APU, timers, DMA, scheduler) to a JSON file.
await saveState({ name: 'before_boss' });
// Output: <outputDir>/savestate-before_boss.jsonLoads a previously saved state from a JSON file.
await loadState('./output/savestate-before_boss.json');Throws an error if the condition is not met.
Assert a memory value:
assert({
memory: { address: 0x03000010, equals: 42 },
});As with wait({ memory }), address may be a numeric address (single byte) or a symbol/symbol.field path (full width, bitfields decoded):
assert({ memory: { address: 'g_game_vars.score', equals: 1000 } });Assert a register value:
assert({
register: { name: 'r0', equals: 0x1000 },
});Prints messages via the runtime's logFn.
console.log('Current HP:', getMemory(0x03001020, 1)[0]);| Address Range | Region | Size | Description |
|---|---|---|---|
0x00000000–0x00003FFF |
BIOS | 16 KB | System ROM (read-protected) |
0x02000000–0x0203FFFF |
EWRAM | 256 KB | External work RAM |
0x03000000–0x03007FFF |
IWRAM | 32 KB | Internal work RAM (fast) |
0x04000000–0x040003FE |
I/O | ~1 KB | Hardware registers (MMIO) |
0x05000000–0x050003FF |
Palette | 1 KB | Color palette RAM |
0x06000000–0x06017FFF |
VRAM | 96 KB | Video RAM |
0x07000000–0x070003FF |
OAM | 1 KB | Sprite attribute memory |
0x08000000–0x09FFFFFF |
ROM | up to 32 MB | Game Pak ROM |
0x0E000000–0x0E00FFFF |
SRAM | 64 KB | Game Pak save RAM |
await runtime.executeScript(`
// Wait for intro to finish
await wait({ frames: 300 });
await takeScreenshot({ name: '01-title' });
// Press Start to go to main menu
await press('start');
await wait({ frames: 60 });
await takeScreenshot({ name: '02-menu' });
// Navigate to "New Game"
await press('down');
await wait({ frames: 10 });
await press('a');
await wait({ frames: 120 });
await takeScreenshot({ name: '03-intro' });
`);await runtime.executeScript(`
// Start the game
await press('start');
// Wait for the game state byte to indicate "in-game"
await wait({
memory: { address: 0x03000000, equals: 0x03 },
timeout: 600,
});
// Verify player health is initialized to 3
const hp = getMemory(0x03001020, 1)[0];
console.log('Player HP:', hp);
assert({ memory: { address: 0x03001020, equals: 3 } });
// Save a checkpoint
await saveState({ name: 'game_start' });
`);await runtime.executeScript(`
await wait({ frames: 120 });
await saveState({ name: 'checkpoint' });
console.log('State saved');
`);
// Later, in another script or run:
await runtime.executeScript(`
await loadState('./output/savestate-checkpoint.json');
console.log('State restored');
await takeScreenshot({ name: 'restored' });
`);await runtime.executeScript(`
// Enter a cheat code: Up, Up, Down, Down, Left, Right, Left, Right, B, A
const code = ['up', 'up', 'down', 'down', 'left', 'right', 'left', 'right', 'b', 'a'];
for (const btn of code) {
await press(btn);
await wait({ frames: 5 });
}
await press('start');
`);For more control, you can use HeadlessRuntime directly in your own Node.js code instead of writing inline scripts:
import { HeadlessRuntime } from '@gba-kit/gba-node';
const runtime = await HeadlessRuntime.create({
romPath: './game.gba',
outputDir: './output',
logFn: console.log,
});
// Access the underlying ScriptingEngine
const engine = runtime.engine;
await engine.wait({ frames: 60 });
await engine.press('a');
await engine.takeScreenshot({ name: 'test' });
// Access the GBA emulator directly
const gba = runtime.gba;
const byte = gba.bus.read8(0x03000000);
// Write a final save state when done
await runtime.writeFinalSaveState();
// Output: <outputDir>/final_save.jsonAll output is written to the outputDir specified when creating the runtime:
| File Pattern | Source |
|---|---|
screenshot-{name}.png |
takeScreenshot() or record() (sprite sheet) |
memory-{name}.json |
takeMemorySnapshot() |
savestate-{name}.json |
saveState() |
final_save.json |
runtime.writeFinalSaveState() |
- Sandboxed VM context: Scripts can't access the file system, network, or Node.js APIs directly. All I/O goes through the scripting API.
- Async API functions: All async API functions must be awaited. Top-level
awaitis supported. - Frame timing: The GBA runs at ~59.7 Hz.
wait({ frames: 60 })is roughly 1 second. - Button press lifecycle:
press()automatically releases after the hold duration. You only needrelease()for manual button state management. - Timeouts:
wait()with memory or PC conditions defaults to a 600-frame (~10 second) timeout and throws if not met. - Assertions:
assert()throws anErrorwith a descriptive message on failure, including expected and actual values in both decimal and hex.