| name | mgba-scripting | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| description | Lua scripting for mGBA emulator - game automation, memory hacking, cheats, callbacks, and ROM manipulation | ||||||||||||
| metadata |
|
Lua scripting for mGBA emulator.
Starting with version 0.10, mGBA has built-in scripting capabilities. To use scripting, click "Scripting..." from the Tools menu. Currently, only Lua scripting is supported.
Key Features:
- Full memory access (ROM, RAM, MMIO)
- Input manipulation (button presses)
- Save state management
- Callbacks for frame/events
- TCP socket networking
- Console output
- Screenshot capture
Tools → Scripting...
This opens a console where you can load and run Lua scripts.
-- emu: CoreAdapter instance (available when game loaded)
-- C: Exported constants
-- callbacks: CallbackManager instance
-- console: Console instance
-- util: Basic utility library
-- socket: TCP socket libraryconsole.log("Info message")
console.warn("Warning message")
console.error("Error message")
-- Create text buffer
buffer = console.createBuffer("My Buffer")
buffer:print("Text in buffer")
buffer:clear()-- Expand bitmask to list
bits = util.expandBitmask(0xFF) -- {0,1,2,3,4,5,6,7}
-- Make bitmask from list
mask = util.makeBitmask({0, 3, 5}) -- 0x29-- Load ROM file
success = emu.loadFile("path/to/rom.gba")
-- Get game info
title = emu.getGameTitle() -- "POKEMON FIRE"
code = emu.getGameCode() -- "AGB-P-FE"
size = emu.romSize() -- ROM size in bytes
platform = emu.platform() -- 0=GBA, 1=GB
checksum = emu.checksum() -- CRC32-- Save to file
emu.saveStateFile("path/to/state.state")
-- Load from file
emu.loadStateFile("path/to/state.state")
-- Save to slot (0-9)
emu.saveStateSlot(0)
emu.loadStateSlot(0)
-- Save/load buffer
buffer = emu.saveStateBuffer()
emu.loadStateBuffer(buffer)
-- Flags: SCREENSHOT=1, SAVEDATA=2, CHEATS=4, RTC=8, METADATA=16
-- ALL = 31
emu.saveStateSlot(0, 31) -- Save everything-- Run one frame
emu.runFrame()
-- Run one instruction
emu.step()
-- Get current frame number
frame = emu.currentFrame()
-- Get cycle info
cycles = emu.frameCycles() -- Cycles per frame
freq = emu.frequency() -- Cycles per second-- Set all keys at once
emu.setKeys(0x0000) -- No keys
-- Add keys (OR with existing)
emu.addKeys(C.GBA_KEY.A + C.GBA_KEY.R)
-- Clear keys
emu.clearKeys(C.GBA_KEY.B)
-- Check key state
if emu.getKey(C.GBA_KEY.UP) == 1 then
print("UP is pressed")
end
-- Get all pressed keys
keys = emu.getKeys()C.GBA_KEY.A = 0
C.GBA_KEY.B = 1
C.GBA_KEY.SELECT = 2
C.GBA_KEY.START = 3
C.GBA_KEY.RIGHT = 4
C.GBA_KEY.LEFT = 5
C.GBA_KEY.UP = 6
C.GBA_KEY.DOWN = 7
C.GBA_KEY.R = 8
C.GBA_KEY.L = 9C.GB_KEY.A = 0
C.GB_KEY.B = 1
C.GB_KEY.SELECT = 2
C.GB_KEY.START = 3
C.GB_KEY.RIGHT = 4
C.GB_KEY.LEFT = 5
C.GB_KEY.UP = 6
C.GB_KEY.DOWN = 7-- Read 8/16/32 bit values
value8 = emu.read8(address)
value16 = emu.read16(address)
value32 = emu.read32(address)
-- Read range
data = emu.readRange(address, length)
-- Read from memory domain
rom = emu.memory["cart0"]
value = rom:read8(offset)
data = rom:readRange(offset, length)-- Write 8/16/32 bit values
emu.write8(address, value)
emu.write16(address, value)
emu.write32(address, value)-- Available memory domains
emu.memory["bios"] -- BIOS (0x00000000)
emu.memory["wram"] -- EWRAM (0x02000000)
emu.memory["iwram"] -- IWRAM (0x03000000)
emu.memory["io"] -- MMIO (0x04000000)
emu.memory["palette"] -- Palette (0x05000000)
emu.memory["vram"] -- VRAM (0x06000000)
emu.memory["oam"] -- OAM (0x07000000)
emu.memory["cart0"] -- ROM (0x08000000)
emu.memory["cart1"] -- ROM WS1 (0x0a000000)
emu.memory["cart2"] -- ROM WS2 (0x0c000000)emu.memory["cart0"] -- ROM Bank ($0000)
emu.memory["vram"] -- VRAM ($8000)
emu.memory["sram"] -- SRAM ($a000)
emu.memory["wram"] -- WRAM ($c000)
emu.memory["oam"] -- OAM ($fe00)
emu.memory["io"] -- MMIO ($ff00)
emu.memory["hram"] -- HRAM ($ff80)-- Read register
value = emu.readRegister("r0")
emu.readRegister("pc")
emu.readRegister("sp")
emu.readRegister("lr")
-- Write register
emu.writeRegister("r0", 0x12345678)
emu.writeRegister("pc", 0x08000000)-- General purpose: r0-r12
-- Special: sp (r13), lr (r14), pc (r15), cpsr-- Add callback (returns callback ID)
id = callbacks.add("frame", function()
-- Called every frame
end)
id = callbacks.add("start", function()
-- Called when emulation starts
end)
id = callbacks.add("reset", function()
-- Called when emulation resets
end)
id = callbacks.add("shutdown", function()
-- Called when emulation stops
end)
-- Remove callback
callbacks.remove(id)-- alarm - In-game alarm went off
-- crashed - Emulation crashed
-- frame - Frame finished
-- keysRead - About to read key input
-- reset - Emulation reset
-- savedataUpdated - Save data modified
-- sleep - Entered low-power mode
-- shutdown - Powered off
-- start - Started
-- stop - Voluntarily shut down-- Auto-press A every 10 frames
local counter = 0
callbacks.add("frame", function()
counter = counter + 1
if counter >= 10 then
emu.addKeys(C.GBA_KEY.A)
counter = 0
else
emu.clearKeys(C.GBA_KEY.A)
end
end)-- Create socket
sock = socket.tcp()
-- Connect (blocking!)
err = sock:connect("192.168.1.100", 1234)
if err then
print("Error: " .. socket.ERRORS[err])
end
-- Bind for server
server = socket.tcp()
server:bind(nil, 8080)
server:listen(1)
client = server:accept()
-- Send data
sock:send("Hello, world!")
-- Receive data
data = sock:receive(1024)
-- Check if data available
if sock:hasdata() then
data = sock:receive(256)
end
-- Close
sock:close()-- Add event callback
id = sock:add("received", function(data)
print("Received: " .. data)
end)
id = sock:add("error", function(err)
print("Error: " .. err)
end)
-- Poll manually
sock:poll()
-- Remove callback
sock:remove(id)C.PLATFORM.NONE = -1
C.PLATFORM.GBA = 0
C.PLATFORM.GB = 1C.SAVESTATE.SCREENSHOT = 1
C.SAVESTATE.SAVEDATA = 2
C.SAVESTATE.CHEATS = 4
C.SAVESTATE.RTC = 8
C.SAVESTATE.METADATA = 16
C.SAVESTATE.ALL = 31C.SOCKERR.OK = 0
C.SOCKERR.AGAIN = 1
C.SOCKERR.ADDR_IN_USE = 2
C.SOCKERR.CONN_REFUSED = 3
C.SOCKERR.DENIED = 4
C.SOCKERR.FAILED = 5
C.SOCKERR.NETWORK_UNREACHABLE = 6
C.SOCKERR.NOT_FOUND = 7
C.SOCKERR.NO_DATA = 8
C.SOCKERR.OUT_OF_MEMORY = 9
C.SOCKERR.TIMEOUT = 10
C.SOCKERR.UNSUPPORTED = 11-- Infinite health (example address)
local healthAddr = 0x02001234
callbacks.add("frame", function()
-- Always write max health
emu.write16(healthAddr, 999)
end)-- Press A every 60 frames
callbacks.add("frame", function()
local frame = emu.currentFrame()
if frame % 60 == 0 then
emu.addKeys(C.GBA_KEY.A)
else
emu.clearKeys(C.GBA_KEY.A)
end
end)-- Watch specific RAM addresses
callbacks.add("frame", function()
local hp = emu.read16(0x02001234)
local mp = emu.read16(0x02001236)
console.log(string.format("HP: %d, MP: %d", hp, mp))
end)-- Auto-save every 5 seconds (300 frames at 60fps)
local frameCount = 0
local saveSlot = 0
callbacks.add("frame", function()
frameCount = frameCount + 1
if frameCount >= 300 then
emu.saveStateSlot(saveSlot)
console.log("Auto-saved to slot " .. saveSlot)
frameCount = 0
end
end)-- Mash A button as fast as possible
callbacks.add("frame", function()
emu.addKeys(C.GBA_KEY.A)
emu.clearKeys(C.GBA_KEY.A)
end)-- Advance RNG for shiny hunting
-- Example: GBA games often use LCG at specific address
local rngAddr = 0x02001234 -- Replace with actual address
callbacks.add("frame", function()
local rng = emu.read32(rngAddr)
-- Simple LCG: new = (old * 0x41C64E6D + 0x6073) & 0xFFFFFFFF
local newRng = (rng * 0x41C64E6D + 0x6073) & 0xFFFFFFFF
emu.write32(rngAddr, newRng)
end)-- Capture screenshot every 1000 frames
callbacks.add("frame", function()
if emu.currentFrame() % 1000 == 0 then
local filename = string.format("screenshot_%04d.png", emu.currentFrame())
emu.screenshot(filename)
end
end)-- Bad: Keys stay pressed
callbacks.add("frame", function()
emu.addKeys(C.GBA_KEY.A)
end)
-- Good: Clear after use
callbacks.add("frame", function()
emu.addKeys(C.GBA_KEY.A)
emu.clearKeys(C.GBA_KEY.A)
end)-- Input should be handled in frame callback
callbacks.add("frame", function()
if btnp(6) then -- UP pressed this frame
-- Handle input
end
end)callbacks.add("crashed", function()
console.error("Emulation crashed!")
-- Save state before exit
emu.saveStateFile("crash.state")
end)-- Clear any previous state when loading
emu.clearKeys(0xFFFF)
callbacks.remove(cbid) -- Remove old callbacks-- Some games use different RAM locations
-- Use mGBA's cheat search or memory viewer to find correct addresses-- Some games poll keys differently
-- Try using addKeys instead of setKeys
emu.addKeys(C.GBA_KEY.A) -- OR with existing-- Socket connect is blocking!
-- Use connect with timeout or run in separate thread
-- For async, use callbacks and poll()Memory domains provide direct access to specific memory regions (ROM, RAM, etc.).
-- Get domain info
local rom = emu.memory["cart0"]
local baseAddr = rom:base() -- Base address
local boundAddr = rom:bound() -- End address (exclusive)
local size = rom:size() -- Size in bytes
local name = rom:name() -- Human-readable name
-- Read from domain
value8 = rom:read8(offset)
value16 = rom:read16(offset)
value32 = rom:read32(offset)
data = rom:readRange(offset, length)
-- Write to domain (RAM only, not ROM)
local wram = emu.memory["wram"]
wram:write8(offset, 0xFF)
wram:write16(offset, 0xFFFF)
wram:write32(offset, 0xFFFFFFFF)-- Read ROM header
local rom = emu.memory["cart0"]
-- Nintendo logo starts at 0x04
local logo = rom:readRange(0x04, 156)
-- Game title at 0xA0 (12 bytes)
local title = rom:readRange(0xA0, 12)
console.log("Game: " .. title)
-- Game code at 0xAC (4 bytes)
local code = rom:readRange(0xAC, 4)
console.log("Code: " .. code)
-- Maker code at 0xB0 (2 bytes)
local maker = rom:readRange(0xB0, 2)
console.log("Maker: " .. maker)Create custom text buffers for displaying information.
-- Create buffer
local buf = console.createBuffer("My Buffer")
-- Set size
buf:setSize(80, 25) -- 80 columns, 25 rows
-- Get dimensions
local cols = buf:cols()
local rows = buf:rows()
-- Print text
buf:print("Hello, World!")
-- Move cursor
buf:moveCursor(10, 5) -- x=10, y=5
-- Get cursor position
local x = buf:getX()
local y = buf:getY()
-- Advance cursor
buf:advance(5) -- Move 5 columns right
-- Clear buffer
buf:clear()
-- Set visible name
buf:setName("Stats Display")local statsBuf = console.createBuffer("Game Stats")
statsBuf:setSize(40, 10)
statsBuf:setName("Live Stats")
callbacks.add("frame", function()
statsBuf:clear()
local hp = emu.read16(0x02001234)
local mp = emu.read16(0x02001236)
local gold = emu.read32(0x02001238)
statsBuf:moveCursor(0, 0)
statsBuf:print("=== GAME STATS ===\n")
statsBuf:print(string.format("HP: %5d\n", hp))
statsBuf:print(string.format("MP: %5d\n", mp))
statsBuf:print(string.format("Gold: %5d\n", gold))
statsBuf:print("==================")
end)For Game Boy (DMG/CGB) emulation.
-- 8-bit registers
emu.readRegister("a") -- Accumulator
emu.readRegister("f") -- Flags
emu.readRegister("b")
emu.readRegister("c")
emu.readRegister("d")
emu.readRegister("e")
emu.readRegister("h")
emu.readRegister("l")
-- 16-bit register pairs
emu.readRegister("bc")
emu.readRegister("de")
emu.readRegister("hl")
emu.readRegister("af")
emu.readRegister("pc") -- Program counter
emu.readRegister("sp") -- Stack pointercallbacks.add("frame", function()
-- Watch GB registers
local a = emu.readRegister("a")
local hl = emu.readRegister("hl")
local pc = emu.readRegister("pc")
console.log(string.format("A=%02X HL=%04X PC=%04X", a, hl, pc))
end)-- Find all occurrences of a value in RAM
function searchMemory(value, size)
local wram = emu.memory["wram"]
local results = {}
for i = 0, wram:size() - size, size do
local v
if size == 1 then
v = wram:read8(i)
elseif size == 2 then
v = wram:read16(i)
else
v = wram:read32(i)
end
if v == value then
table.insert(results, i)
end
end
return results
end
-- Usage
local addresses = searchMemory(100, 2) -- Find 100 as 16-bit
for _, addr in ipairs(addresses) do
console.log(string.format("Found at 0x%08X", addr))
end-- Simple cheat engine with multiple cheats
local cheats = {
{ name = "Infinite HP", addr = 0x02001234, value = 999, size = 2, enabled = true },
{ name = "Max Gold", addr = 0x02001238, value = 999999, size = 4, enabled = true },
{ name = "All Items", addr = 0x02002000, value = 0xFF, size = 1, enabled = false },
}
callbacks.add("frame", function()
for _, cheat in ipairs(cheats) do
if cheat.enabled then
if cheat.size == 1 then
emu.write8(cheat.addr, cheat.value)
elseif cheat.size == 2 then
emu.write16(cheat.addr, cheat.value)
else
emu.write32(cheat.addr, cheat.value)
end
end
end
end)
-- Toggle cheat
function toggleCheat(name)
for _, cheat in ipairs(cheats) do
if cheat.name == name then
cheat.enabled = not cheat.enabled
console.log(name .. ": " .. (cheat.enabled and "ON" or "OFF"))
end
end
end-- Frame counter and IGT (In-Game Time) tracker
local startFrame = nil
local lastSplit = nil
local splits = {}
callbacks.add("start", function()
startFrame = emu.currentFrame()
splits = {}
end)
function split(name)
local currentFrame = emu.currentFrame()
local frameTime = currentFrame - (lastSplit or startFrame)
lastSplit = currentFrame
table.insert(splits, {
name = name,
frame = frameTime,
total = currentFrame - startFrame
})
local seconds = frameTime / 60
local totalSeconds = (currentFrame - startFrame) / 60
console.log(string.format("%s: %.2fs (Total: %.2fs)", name, seconds, totalSeconds))
end
function printSplits()
console.log("=== SPLITS ===")
for _, s in ipairs(splits) do
console.log(string.format("%s: %.2fs", s.name, s.frame / 60))
end
console.log("==============")
end-- Record and playback inputs
local recording = {}
local isRecording = false
local isPlaying = false
local playbackFrame = 0
function startRecording()
recording = {}
isRecording = true
console.log("Recording started...")
end
function stopRecording()
isRecording = false
console.log("Recording stopped. " .. #recording .. " frames recorded.")
end
function startPlayback()
isPlaying = true
playbackFrame = 0
console.log("Playback started...")
end
function stopPlayback()
isPlaying = false
console.log("Playback stopped.")
end
callbacks.add("frame", function()
if isRecording then
table.insert(recording, emu.getKeys())
elseif isPlaying then
playbackFrame = playbackFrame + 1
if playbackFrame <= #recording then
emu.setKeys(recording[playbackFrame])
else
isPlaying = false
console.log("Playback complete.")
end
end
end)-- Sync game state over network
local server = nil
local clients = {}
function startSyncServer(port)
server = socket.tcp()
server:bind(nil, port or 8080)
server:listen(5)
server:add("received", function()
local client = server:accept()
table.insert(clients, client)
console.log("Client connected!")
end)
console.log("Sync server started on port " .. (port or 8080))
end
function broadcastState()
if not server then return end
local state = {
frame = emu.currentFrame(),
keys = emu.getKeys(),
-- Add more state as needed
}
local data = string.format("%d,%d\n", state.frame, state.keys)
for i, client in ipairs(clients) do
local err = client:send(data)
if err then
table.remove(clients, i)
end
end
end
callbacks.add("frame", broadcastState)| Method | Description |
|---|---|
loadFile(path) |
Load ROM file |
getGameTitle() |
Get ROM title |
getGameCode() |
Get ROM code |
romSize() |
Get ROM size |
platform() |
Get platform (GBA=0, GB=1) |
checksum(type) |
Get ROM checksum |
reset() |
Reset emulation |
runFrame() |
Run one frame |
step() |
Run one instruction |
currentFrame() |
Get frame number |
frameCycles() |
Cycles per frame |
frequency() |
Cycles per second |
screenshot(filename) |
Save screenshot |
| Method | Description |
|---|---|
read8(addr) |
Read 8-bit value |
read16(addr) |
Read 16-bit value |
read32(addr) |
Read 32-bit value |
readRange(addr, len) |
Read byte range |
write8(addr, val) |
Write 8-bit value |
write16(addr, val) |
Write 16-bit value |
write32(addr, val) |
Write 32-bit value |
readRegister(name) |
Read CPU register |
writeRegister(name, val) |
Write CPU register |
| Method | Description |
|---|---|
setKeys(mask) |
Set key bitmask |
addKeys(mask) |
Add keys to current |
clearKeys(mask) |
Remove keys from current |
addKey(key) |
Add single key |
clearKey(key) |
Clear single key |
getKey(key) |
Get key state |
getKeys() |
Get all keys as mask |
| Method | Description |
|---|---|
saveStateFile(path, flags) |
Save to file |
loadStateFile(path, flags) |
Load from file |
saveStateSlot(slot, flags) |
Save to slot |
loadStateSlot(slot, flags) |
Load from slot |
saveStateBuffer(flags) |
Save to buffer |
loadStateBuffer(buf, flags) |
Load from buffer |
autoloadSave() |
Load associated save |
- Official Documentation: https://mgba.io/docs/scripting.html
- mGBA GitHub: https://github.com/mgba-emu/mgba
- Forums: https://forums.mgba.io/
- Discord: https://discord.gg/em2M2sG
- Scripting API Reference: https://mgba.io/docs/scripting.html