fceux-lua — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited fceux-lua (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Write Lua scripts for FCEUX (fceux64.exe -lua script.lua rom.nes) to automate NES ROM analysis tasks.
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.).
-- 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.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 cleanlyRun: ./fceux64.exe -lua script.lua "../../path/to/rom.nes"
-- 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")
endAdjust the frame count based on the ROM's boot time. For ROMs with long intro animations, capture up to 900+ frames (15 seconds).
-- 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().
-- 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).
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.
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.
CDL marks every PRG-ROM byte as CODE, DATA, PCM audio, or unaccessed. Cannot be programmatically enabled from Lua — requires manual GUI interaction:
romname.cdl (requires autosaveCDL 1 in fceux.cfg)CDL file format:
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.nesScoping 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.
| 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 |
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.