Fceux lua
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.From its SKILL.md
npx -y skills add Yuki001/nes-skills --skill fceux-luaAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
9.6 KB, ~2.7k tokens by cl100k_base, as published. Nobody here has run it
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:
# Linux/WSL
unzip .claude/skills/fceux-lua/bin/fceux-2.6.6-win64.zip -d tools/fceux/
# Windows (PowerShell)
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
-- FCEUX uses LuaJIT. Key gotchas:
-- 1. Callbacks take FUNCTION REFERENCES, not strings:
memory.registerexec(0x8000, 0x80FF, my_callback) -- CORRECT
memory.registerexec(0x8000, 0x80FF, "my_callback") -- WRONG: bad argument #3, need function
-- 2. Print goes to console, often invisible. Write to files instead:
local log = io.open("output.txt", "w")
log:write("message\n"); log:flush()
-- 3. Non-ASCII text may crash on Windows GBK terminal. Use ASCII labels.
-- 4. Callback functions must be global, not local.
Script template
local log = io.open("script_log.txt", "w")
local function p(msg) log:write(msg .. "\n"); log:flush() end
-- ... script body ...
p("Done. Total frames: " .. emu.framecount())
log:close()
emu.exit() -- always exit cleanly
Run: ./fceux64.exe -lua script.lua "../../path/to/rom.nes"
Common Tasks
1. Screenshots at specific frames
-- Single screenshot after 2 seconds
local target = 120 -- 60fps * 2
while emu.framecount() < target do emu.frameadvance() end
gui.savescreenshotas("screenshot.png")
emu.exit()
-- Multiple screenshots during animation
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)
-- Wait for ROM to reach stable state (adjust frame count as needed)
for i = 1, 600 do emu.frameadvance() end
-- Helper: dump byte range to file
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") -- CHR-RAM 8KB (pattern tables)
dump(0x2000, 1024, "nt0.bin") -- Nametable 0
dump(0x2400, 1024, "nt1.bin") -- Nametable 1 (horizontal mirroring)
dump(0x3F00, 32, "palette.bin") -- Palette 32B
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
-- Wait for ROM to reach interactive state
for i = 1, 480 do emu.frameadvance() end
-- Press sequence with hold/release timing
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 -- hold 4 frames
joypad.set(1, {[key]=false})
for i = 1, 3 do emu.frameadvance() end -- release 3 frames
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 = {} -- exec[cpu_addr] = true
-- Callback must be a GLOBAL function
function on_code(addr)
exec[addr] = true
end
-- Register per 256-byte page
for page = 0x80, 0xFF do -- $8000-$FFFF
memory.registerexec(page * 256, page * 256 + 255, on_code)
end
-- Run (use fewer frames than usual — callbacks are expensive)
for i = 1, 120 do emu.frameadvance() end
-- Write results (do this after emulation, not inside callback)
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(requiresautosaveCDL 1infceux.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 |
What ships with it: 2 files
4638.2 KB alongside SKILL.md
bin/
- fceux-2.6.6-win64.zip4634.0 KB
references/
- api-reference.md4.2 KB