Source-Level Debugging for the Atari Lynx
I work on several Atari Lynx projects, a handheld from 1989 with a 65C02 running at 4MHz and 64KB of RAM. One of the toolchains is cc65, which means you write C, it transpiles that to 6502 assembly, that gets compiled, and then… you debug it by staring at that assembly. Or you print things to the screen. Or other painful things. So I built a debugger on top of Gearlynx’s debugger that allows for full C-source debugging rather than assembly only.
Gearlynx Debugger is a VSCode extension that gives you source-level debugging for Lynx games: breakpoints in your C file, real local variables, the call stack, the hardware state, and the game running live inside VSCode.

The Starting Point
Gearlynx is Ignacio Sanchez’s Lynx emulator, and it’s excellent. It already ships a very capable debugger: a run-ahead disassembler, CPU and memory breakpoints, a memory editor, and hardware viewers for Mikey and Suzy.
But it is a 6502 debugger. Everything is an address. If you want to break on game_handle_input(), you go find its address in the map file, type it in, and then read disassembly. When you stop, you’re looking at registers and raw memory, not at varibles with names like x and y and gameState.
Meanwhile, VSCode already knows how to be a debugger UI. Breakpoints, call stacks, watch expressions, hover evaluation, the hex editor, and the disassembly view are all built in and driven by the Debug Adapter Protocol (DAP). cc65 already emits everything needed to map addresses back to source, if you pass the right flags. The missing piece was a way for those two to talk to each other.
Two Halves
The project ended up being two separate things:
- A debug-monitor server inside Gearlynx: C++, a TCP server that exposes emulator state and execution control over a simple JSON protocol. This got upstreamed and ships in Gearlynx as of 1.2.15.
- The VSCode extension: TypeScript, a DAP adapter that speaks that protocol on one side and VSCode on the other, plus all the cc65 debug info parsing.
Splitting it this way means the emulator doesn’t know anything about VSCode, cc65, or DAP. It answers questions about registers, memory, and breakpoints. Everything that requires understanding your source code lives in the extension.
The Gearlynx MCP Server Did the Hard Part
Ignacio added Model Context Protocol support back in December 2025, so an AI agent could drive the emulator: set breakpoints, read memory, inspect Mikey and Suzy, step the CPU. Making that work meant giving the MCP server a programmatic handle on everything the ImGui debugger could already do, which is his DebugAdapter class: a plain C++ facade over the emulator core and the debugger’s own state that hands back structs and JSON instead of drawing widgets. Execution control, breakpoints, memory areas, disassembly, hardware status, controller input, and trace logging.
That facade is exactly what a debug monitor needs, so I didn’t write another one. The debug monitor is a second transport over Ignacio’s adapter, and almost every command in it is a thin translation:
m_debug_adapter = new DebugAdapter(core);
...
std::vector<DisasmLine> lines = m_debug_adapter->GetDisassembly(start, end, true);
It doesn’t speak MCP since an agent and a DAP client want different things. But the two servers sit side by side in emu.cpp, get initialized identically, and get pumped from the same place in the emulator loop:
emu_mcp_pump_commands();
emu_debug_monitor_pump_commands();
The interesting part is that the AI tooling work paid off in a completely non-AI way. Building the MCP server meant giving every debugger capability a name, a signature, and a return type that wasn’t a UI widget. Once that boundary existed, hanging a second protocol off it was mostly plumbing, which is why the C++ side of this project is the small half.
VSCode Gearlynx
+-------------------+ TCP/JSON +---------------------+
| Gearlynx Debugger | <------------> | Debug Monitor |
| (DAP adapter) | port 6502 | Server |
+-------------------+ +---------------------+
| | TCP/binary | Framebuffer |
| Screen Viewer | <------------> | Server |
| (webview panel) | port 6503 | (60fps RGBA stream) |
+-------------------+ +---------------------+
| Emulator Core |
+---------------------+
The Wire Protocol
The debug monitor uses Content-Length: <n>\r\n\r\n framing followed by UTF-8 JSON, which is the same framing DAP itself uses. Requests look like this:
{
"id": 12,
"cmd": "registers_get"
}
And responses echo the id back:
{
"id": 12,
"success": true,
"data": {
"pc": 577,
"a": 2,
"x": 0,
"y": 0
}
}
Unsolicited state changes come back as events, using id: 0 and an event field instead of a response id:
{
"id": 0,
"event": "stopped",
"data": {
"reason": "breakpoint",
"pc": 512,
"seq": 47
}
}
There are three of those (stopped, resumed, and terminated), and each carries a monotonically increasing seq so the client can tell a stale event from a current one.
There are about 20 commands covering the categories you’d expect:
| Category | Commands |
|---|---|
| Registers | registers_get, registers_set |
| Memory | memory_get, memory_set, memory_areas |
| Breakpoints | breakpoint_set, breakpoint_delete, breakpoint_list |
| Execution | continue, step_in, reset, rewind_step_back |
| Inspection | status, disassembly_get, call_stack, hardware_status |
| Misc | handshake, load_rom, controller_button, trace_log_set, trace_log_get |
The protocol is versioned by a single integer negotiated via handshake on connect. On a mismatch the extension warns you but still tries to work, which felt like the right call for something two independently-released projects have to agree on.
Making cc65 Debug Info Useful
This is where the actual work was.
Build with cl65 -t lynx -g --dbgfile game.dbg and cc65 produces a .dbg file containing symbols, scopes, spans, C symbols (csym), segments, and line info. This file isn’t particularly well documented. There is no published description of the .dbg format anywhere in the cc65 docs. The ld65 manual documents the flag and then warns you off:
Please note that debug information generation is currently being developed, so the format of the file and its contents are subject to change without further notice.
The cc65 wiki’s Debug info overview gives you the history and then points at src/dbginfo, a C reference parser that ships with the source. It’s the answer if you’re writing in C, but the extension is TypeScript. Fortunately the file is plain line-oriented text: a version line, an info line with counts so a reader can preallocate, then tab-separated records like sym, scope, span, line, and csym, each a comma-separated list of key=value pairs. (src/dbginfo also includes dbgsh, a small interactive shell for querying a .dbg file, which is handy for seeing what’s actually in yours.)
A few of the more interesting problems:
Switch Statements Lie
cc65 maps the jump-table dispatch code for a switch to the closing brace of the switch block. So if you step over switch(gameState), you land on the } at the bottom, and then the next step takes you into the matching case.
Overlays Make the Address Space Ambiguous
The Lynx cart isn’t mapped into the address space at all. There’s no banking and no window you switch between. The cart is a serial stream, and everything the CPU executes has to be loaded off the cart into RAM first. With 64KB total, a game of any size can’t hold all of its code at once.
So cc65 gives you overlay segments: multiple code segments linked to the same RAM address range, loaded on demand, one resident at a time. Grogger, the game in the screenshot above, has TITLE_CODE, BONUS_CODE, and GAME_CODE all built for the same addresses. Which one is actually sitting there depends entirely on what the game last chose to load.
That means “what source line is at $A400?” has no single answer, and the debug info can’t tell you either, since it describes all of them equally. The extension detects overlay groups from the debug info and lets you pick which one to treat as resident, from the debug toolbar or the Overlays panel, and source resolution respects that choice. Data-only overlays are filtered out of the picker, since there’s nothing in them to step through.
Zero Page Symbols Resolving to the Wrong File
This one was a real bug, fixed in 0.2.5. Lynx builds have EXEHDR and DIRECTORY segments that both alias to address 0, same as the ZEROPAGE and EXTZP segments. Every zero-page symbol was resolving its “source location” to lynxhdr.s or directory.s, which is technically an address match and completely useless. Now they resolve to the file that actually declares them wherever cc65’s debug info allows it.

Source-Line Stepping
DAP wants “step over one line.” The emulator offers “step one 6502 instruction.” Bridging those means stepping instructions in a loop until the source line changes, which sounds trivial, but isn’t.
A single line of C can be dozens of instructions. A line with a function call needs the step to run through the whole call and come back, not stop inside it. A line the compiler split across non-contiguous spans needs to not look like it changed when it didn’t. And there has to be an iteration cap, because if the mapping is wrong somewhere, you do not want the debugger to sit there single-stepping forever.
There’s a traceSteps launch option that logs every stepping decision to the Debug Console, which exists purely because I needed it to get this working and figured someone else might too.
The Screen Viewer
Gearlynx can run --headless, and the framebuffer server streams RGBA frames over raw TCP on port 6503 (an 8-byte header of width, height, and size, then pixels). The extension renders that into a dockable webview panel at 60fps, with integer scaling from 1x to 5x, and forwards keyboard events back to the emulator as Lynx button presses.
The result is that you never leave the editor. Breakpoint on the left, source in the middle, game running on the right (or at least that’s how I like it).
What’s In The Box
The full feature list, briefly:
- Breakpoints: source, conditional (
A == 0,$FC00 > 5), hit count, logpoints with expression interpolation, data watchpoints, function breakpoints, and raw instruction breakpoints - Stepping: in, over, out, continue, pause, plus frame-level step back using Gearlynx’s rewind
- Variables: registers with individual flag bits, locals, globals, a Zero Page scope with live values, and hardware status (Mikey timers, audio channels, LCD, cart)
- Symbol Table panel: every symbol with kind, address, segment, and source location; sortable, filterable, click to navigate, right-click a function to set a breakpoint
- Memory Map: canvas view of the whole address space, with overlay segments stacked in parallel columns over the range they share
- Trace Logger, Loaded Sources, and memory editing through VSCode’s built-in hex editor
The Screen, Overlays, and Symbols panels all work without an active debug session, populated from your launch.json, which turns the extension into a decent static analysis tool for a ROM even when you’re not running it.
Getting Started
- Install Gearlynx 1.2.15 or later and configure a Lynx BIOS image in it
- Install Gearlynx Debugger from the VS Marketplace
- Point
gearlynxDebug.gearlynxPathat your Gearlynx executable - Build with debug info:
cl65 -t lynx -g --dbgfile game.dbg -o game.lnx main.c - Press F5
With no launch.json, the extension scans your workspace for a .lnx/.lyx ROM, auto-detects the matching .dbg or .sym next to it, and just starts debugging headless. If you want to customize things (a different port, stopOnEntry, extra sourceRoots), write a launch.json and it’ll use that instead.
If you’re doing assembly-only projects with no .dbg file, it falls back to .sym files. You lose source-line mapping and locals, but you keep symbol names, which beats nothing.
Thanks and Links
Huge thanks to Ignacio Sanchez for Gearlynx itself, for the MCP server and DebugAdapter that the debug monitor is built on, and for taking the debug-monitor work upstream so the extension can run against stock releases instead of a fork.
- Gearlynx Debugger on the VS Marketplace
- gearlynx-vscode on GitHub
- Gearlynx emulator
- Debug-monitor protocol documentation
If you’re writing Lynx code using this and something’s broken, open an issue or ping me on Bluesky.