# 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.

- Skill: `yuki001/fceux-lua` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add yuki001/fceux-lua`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yuki001/fceux-lua/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Product & Planning
- Author: Yuki001 (https://skillmd.com/u/yuki001)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yuki001/fceux-lua

---


# 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:

```bash
# 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

```lua
-- 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

```lua
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

```lua
-- 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)

```lua
-- 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

```lua
-- 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:

```lua
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:

```lua
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:

1. Open FCEUX → Debug → Code/Data Logger (opens the CDL window)
2. **Then** load ROM or press Hard Reset (logging must be active before first instruction executes)
3. Exercise code paths (navigate menus, trigger gameplay, etc.)
4. 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:
```bash
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:
```bash
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 |

