luna is not just the test harness — it is OpenSNES's interactive debugger. Its headless CLI and its MCP server expose the full machine state (CPU, PPU, APU, DMA, every memory space) to scripts and AI agents, with no Lua layer and no external emulator required.
This guide shows the standard debugging workflows. Since luna v1.6.0 the debugger is feature-complete for these: first-class breakpoints (bp_add / run_until_break), native WLA-DX symbol support (load_symbols / resolve_symbol — debug by variable name), MCP-side disassembly, save/load states and CPU/memory traces.
The pinned binary is installed by the SDK:
Two ways to drive it:
The MCP session exposes the full debugger surface: run control (load_rom, reset, step, step_until_frame, run_until_pc, run_until_mem_write/read), breakpoints (bp_add, bp_remove, bp_list, bp_clear_all, run_until_break), symbols (load_symbols, resolve_symbol), memory (peek_memory, poke_memory, search_memory, peek_vram, peek_aram, peek_cgram), inspection (state, screenshot, disasm_cpu, disasm_spc, render_tilemap/vram_tiles/palette/sprite_sheet), traces (enable/take_cpu_trace, enable/take_mem_trace), save/load states and input (set_joypad, set_mouse, set_superscope, set_cpu_register).
Every build produces a wlalink .sym next to the ROM. Load it once per session (load_symbols over MCP) — every address-taking tool then accepts variable and function names directly: peek_memory on monster_x, bp_add on a function label. resolve_symbol answers one-off lookups, and disasm_cpu output is symbol-annotated. (The manual fallback stays a one-liner: grep -i ' monster_x$' game.sym.)
The JSON snapshot contains the full CPU registers, PPU state (scroll, mode, windows, complete CGRAM and OAM), DMA channels, scheduler (frame/NMI counts) — everything the Mesen2 watch panel showed, machine-readable. Over MCP: load_rom → step → state.
The classic corruption hunt, over MCP:
This replaces the old snesdbg dbg.watch() callback — the "callback" is simply whatever you (or the agent driving MCP) do at each stop.
bp_add on the function's label, run_until_break, then single-step with step and follow disasm_cpu / state.cpu to trace the execution path. Multiple breakpoints can be armed at once (bp_list shows them).
The SDK keeps a shadow OAM (oambuffer) that the NMI handler DMAs to the PPU. When a sprite misbehaves, compare the two sides:
This writes oam.json (the hardware side, parsed), the sprite sheet, the VRAM tile sheet, the four BG tilemaps, the CGRAM palette and the composited screen as PNGs. Read the shadow side with peek_memory at the oambuffer symbol and compare entry by entry — a mismatch means the OAM DMA didn't run (check oam_update_flag) or wrote stale data.
state also embeds the full parsed OAM (ppu.oam_full), so a pure-MCP comparison needs no file round-trip.
Test a hypothesis without rebuilding: poke_memory the variable, step_until_frame, screenshot. Example: force a player's X position and watch whether the sprite follows (if it doesn't, the shadow buffer isn't being flushed).
A single screenshot can't show a frame-to-frame blink:
captures strictly consecutive PPU frames, each tagged with its frame number and forced-blank state.
Nothing is required anymore: the workflows above cover the watch / breakpoint / OAM / poke use cases headlessly. Third-party GUI emulators (Mesen2, bsnes) remain fine for eyeballing gameplay, and Mesen2 stays cited in the hardware docs as the accuracy reference that settled some SDK behaviours (e.g. SA-1 SIWP polarity) — a historical role, not a dependency.