| name | fceux-lua |
| description | Write and run FCEUX Lua scripts for NES ROM automation — screenshots, PPU memory dumps, automated input, code execution tracing, and API discovery. Use when the user wants to automate FCEUX, capture ROM data, dump CHR/nametable/palette, trace code execution, or script NES emulation tasks. Triggers on mentions of FCEUX, Lua scripting for NES, ROM analysis automation, ppu.readbyte, memory.registerexec, gui.savescreenshotas, joypad.set. |
FCEUX Lua Scripting
Write Lua scripts for FCEUX (fceux64.exe -lua script.lua rom.nes) to automate NES ROM analysis tasks.
FCEUX binary
The skill bundles FCEUX 2.6.6 for Windows at bin/fceux-2.6.6-win64.zip.
If the project already has FCEUX installed (e.g. tools/fceux/fceux64.exe), use that. If not, extract the skill's bundled zip:
unzip .claude/skills/fceux-lua/bin/fceux-2.6.6-win64.zip -d tools/fceux/
Expand-Archive .claude/skills/fceux-lua/bin/fceux-2.6.6-win64.zip -DestinationPath tools/fceux/
The zip contains fceux64.exe and required DLLs (lua5.1.dll, 7z_64.dll, etc.).
Critical Lua syntax for FCEUX
memory.registerexec(0x8000, 0x80FF, my_callback)
memory.registerexec(0x8000, 0x80FF, "my_callback")
local log = io.open("output.txt", "w")
log:write("message\n"); log:flush()
Script template
local log = io.open("script_log.txt", "w")
local function p(msg) log:write(msg .. "\n"); log:flush() end
p("Done. Total frames: " .. emu.framecount())
log:close()
emu.exit()
Run: ./fceux64.exe -lua script.lua "../../path/to/rom.nes"
Common Tasks
1. Screenshots at specific frames
local target = 120
while emu.framecount() < target do emu.frameadvance() end
gui.savescreenshotas("screenshot.png")
emu.exit()
local shots = {120, 240, 360, 480, 600, 720}
for _, frame in ipairs(shots) do
while emu.framecount() < frame do emu.frameadvance() end
gui.savescreenshotas("shot_" .. math.floor(frame/60) .. "s.png")
end
Adjust the frame count based on the ROM's boot time. For ROMs with long intro animations, capture up to 900+ frames (15 seconds).
2. Dump PPU memory (CHR-RAM, nametables, palettes)
for i = 1, 600 do emu.frameadvance() end
local function dump(ppu_addr, len, filename)
local f = io.open(filename, "wb")
for a = ppu_addr, ppu_addr + len - 1 do
f:write(string.char(ppu.readbyte(a)))
end
f:close()
end
dump(0x0000, 8192, "chr_ram.bin")
dump(0x2000, 1024, "nt0.bin")
dump(0x2400, 1024, "nt1.bin")
dump(0x3F00, 32, "palette.bin")
emu.exit()
Note: ppu.readbyte() may not exist on older FCEUX builds. Use memory.readbyte() for CPU-addressable ranges as fallback.
For CHR-ROM carts (non-CHR-RAM), read from the ROM directly with rom.readbyterange().
3. Automated input simulation
for i = 1, 480 do emu.frameadvance() end
local keys = {"D","D","D","D","D","U","U","U","R","D","D","L","U"}
for _, key in ipairs(keys) do
joypad.set(1, {[key]=true})
for i = 1, 4 do emu.frameadvance() end
joypad.set(1, {[key]=false})
for i = 1, 3 do emu.frameadvance() end
end
emu.exit()
Valid key strings: "A", "B", "Select", "Start", "U" (Up), "D" (Down), "L" (Left), "R" (Right).
Use joypad.set(2, ...) for player 2 controller. Use joypad.getdown(1) to read which buttons were just pressed (useful for verifying input took effect).
4. API Discovery
To find what Lua APIs are available in the current FCEUX build:
local log = io.open("api_log.txt", "w")
local function p(msg) log:write(msg .. "\n"); log:flush() end
for i = 1, 300 do emu.frameadvance() end
local namespaces = {
"emu", "gui", "joypad", "memory", "ppu", "sound",
"debugger", "rom", "cdl", "movie", "zapper", "input"
}
for _, ns in ipairs(namespaces) do
local ok, tbl = pcall(function() return _G[ns] end)
if ok and tbl and type(tbl) == "table" then
p("=== " .. ns .. " ===")
for k, v in pairs(tbl) do
local vt = type(v)
if vt == "function" then
p(" " .. k .. "()")
elseif vt == "number" or vt == "string" or vt == "boolean" then
p(" " .. k .. " = " .. tostring(v))
end
end
end
end
log:close()
emu.exit()
Run this whenever you need to check API availability on an unfamiliar FCEUX version. See references/api-reference.md for a snapshot of FCEUX 2.6.4 Windows x64.
5. Code Execution Tracing with memory.registerexec
Track which CPU addresses are executed. Use a sparse bitmap in the callback — any I/O or string formatting inside the callback will make it unusably slow:
local exec = {}
function on_code(addr)
exec[addr] = true
end
for page = 0x80, 0xFF do
memory.registerexec(page * 256, page * 256 + 255, on_code)
end
for i = 1, 120 do emu.frameadvance() end
local f = io.open("executed.txt", "w")
for addr, _ in pairs(exec) do
f:write(string.format("$%04X\n", addr))
end
f:close()
emu.exit()
Performance: Per-instruction callbacks are extremely slow. Expect 30-120 second runtimes even for 120-600 frames. Keep the callback body minimal — no function calls, no string ops, no I/O. Write results only after emulation stops.
Mapper compatibility: Address-based bank switching mappers (where writes to PRG-ROM space trigger bank changes) may prevent registerexec from firing on the $8000-$BFFF range. If $8000 range gets zero hits but $C000 range works, this is the likely cause. For these mappers, trace only $C000-$FFFF or use CDL (Task 6) instead.
6. CDL (Code/Data Logger) for Perfect Code/Data Separation
CDL marks every PRG-ROM byte as CODE, DATA, PCM audio, or unaccessed. Cannot be programmatically enabled from Lua — requires manual GUI interaction:
- Open FCEUX → Debug → Code/Data Logger (opens the CDL window)
- Then load ROM or press Hard Reset (logging must be active before first instruction executes)
- Exercise code paths (navigate menus, trigger gameplay, etc.)
- Close ROM — CDL auto-saves to
romname.cdl (requires autosaveCDL 1 in fceux.cfg)
CDL file format:
- 1 byte per PRG-ROM byte (no header — file size = PRG-ROM size)
- Bit 0 ($01): CODE (byte was fetched as instruction)
- Bit 1 ($02): DATA (byte was read as data)
- Bit 2 ($04): PCM audio
- All zeros = unaccessed during session
Check CDL quality:
python3 -c "
with open('rom.cdl', 'rb') as f:
cdl = f.read()
print(f'Size: {len(cdl)} bytes')
print(f'Code bytes: {sum(1 for b in cdl if b & 1)}')
print(f'Data bytes: {sum(1 for b in cdl if b & 2)}')
print(f'Total non-zero: {sum(1 for b in cdl if b)}')
"
Use CDL with retrodisasm for perfect code/data separation:
retrodisasm -s nes -cdl rom.cdl -o output.asm rom.nes
Scoping tip: If you only care about specific PRG banks (e.g. the fixed bank and one switchable bank), verify those banks have non-zero CDL entries. Banks that are all zero were never mapped during the session. You may need multiple CDL sessions to cover everything.
Mapper limitation: CDL maps to physical ROM bytes. With bank switching mappers, verify the CDL covers all banks you need. Some mappers may cause phantom CDL entries (single-byte DATA markers at regular intervals across every bank) — these are mapper noise, not real accesses. Real code/data shows concentrated blocks of entries.
Common Pitfalls
| Issue | Cause | Fix |
|---|
registerexec callback never fires | Function passed as string "name" | Pass function reference directly: on_exec |
$8000-$BFFF range gets zero hits | Address-based mapper writes interfere with exec detection | Trace only $C000-$FFFF or use CDL |
| CDL all zeros after session | CDL Logger window not opened before ROM execution | Open GUI window first, then Hard Reset |
| CDL has only phantom entries (single bytes every ~251 bytes) | CDL logging was not active; phantom is noise | Ensure CDL Logger was open DURING code execution |
print() produces no output | FCEUX suppresses Lua stdout | Write to file: io.open("log.txt","w") |
Lua syntax error at for x, y, z in ... | Lua uses ipairs(), not Python-style tuple iteration | for _, r in ipairs(t) do local x,y,z = r[1],r[2],r[3] |
| Screenshot is black screen | Not enough frames for PPU warmup + ROM init | Wait 300+ frames minimum; complex ROMs may need 600+ |
| Non-ASCII text garbled on Windows | Terminal uses GBK encoding | Use ASCII labels in Lua; Python: sys.stdout.reconfigure(encoding='utf-8') |
| FCEUX hangs with registerexec | Too many callbacks per frame | Reduce registered range, or reduce frame count |