In Part 2.1 we dug into the memory layout of the 6502 and discovered why loading programs at 0x0000 was a bad idea. The first two pages have special roles, zero page (0x0000–0x00FF) for fast variable access, and page one (0x0100–0x01FF) for the hardware stack. That understanding led to a simple but important change: the emulator now asks for a load address and defaults to 0x0200, keeping our program safely out of the way.

With the memory layout sorted, I wanted to push the emulator further. Stepping through instructions one at a time and watching registers change was satisfying, but it wasn't enough to debug anything non-trivial. What I really needed were the kinds of features you'd find in a proper debugger like GDB, breakpoints, memory inspection, the ability to poke values into registers on the fly. And since this whole project started because I wanted to understand how these old machines actually worked, adding a framebuffer felt like the natural way to bring it full circle.

Interactive Debugger

Breakpoints

The first thing I wanted was breakpoints. For any program with loops or branches, manually stepping through every instruction until you reach the one you care about gets tedious fast. The concept is straightforward, a breakpoint is just a memory address, and during execution the emulator pauses whenever the PC matches one.

My first implementation only supported a single breakpoint, but I quickly expanded it to handle an unlimited number. One subtlety worth mentioning: since 6502 instructions aren't a fixed size (they can be 1, 2, or 3 bytes), a breakpoint address might land in the middle of an instruction's operand rather than at the start of an instruction. The emulator only triggers a breakpoint when the PC lands exactly on the address, if the PC skips past it because the breakpoint doesn't align with an instruction boundary, it won't pause. This felt like the right behaviour, since pausing mid-instruction wouldn't make much sense.

Memory View

The 6502 doesn't just change registers and flags, it reads and writes to memory constantly. To see what's actually happening, I implemented a hex/ASCII memory viewer. I went with a 16×16 grid layout, which shows exactly 256 bytes, one full page. The m command lets you specify a start address, so you can inspect any region of the 64 KiB address space. The view updates with each step, so you can watch memory change in real time as instructions execute.

I also expanded the instruction preview to show the next 5 decoded instructions from the current PC, including their memory addresses. This makes it much easier to figure out where to place breakpoints, you can see exactly what's coming up and pick the right address.

Memory and Register Editing

Once I could see memory and registers, the obvious next step was being able to edit them. I added an e command that takes two parameters: a destination (either a register name, PC, S, X, Y, A, or a 16-bit memory address) and the new value to write. This turned out to be surprisingly useful for testing "what if" scenarios, changing the accumulator before a comparison, or poking a value into a memory location to see how the program reacts.

Framebuffer

What good is a computer without a screen? Most 6502-based systems used memory-mapped video, a region of RAM where each byte (or bit) corresponds to pixels on the display. Writing to that memory region draws to the screen, no special graphics hardware needed.

I wanted to keep things simple, so I went with a 160×120 pixel display at a 4:3 aspect ratio, with each pixel represented by a single bit. That works out to 19,200 bits, 2,400 bytes, just over 2 KiB. The framebuffer starts at 0xE000 by default, well away from the program code at 0x0200. Any writes to memory within the framebuffer region show up as pixels in the display window. Since 160×120 is quite small on a modern monitor, I doubled up the pixels when rendering.

There's something genuinely satisfying about writing a value to a memory address and watching a pixel appear on screen. It makes the connection between code and hardware feel tangible in a way that higher-level programming rarely does.

The Finished Emulator

Here's the emulator running our original test program from Part 1, now with all the debugging features in place:

Welcome to the 6502 CPU Emulator!
This emulator loads a binary file at a specified address and executes 6502 instructions.

Enter binary file path: test.bin
Enter load address (hex, e.g., 0x8000 or 8000) [default: 0x0200]: 
Using default load address: 0x0200

Emulator initialized successfully!
Binary file: test.bin
Load address: 0x0200
Execution starts at: 0x0200

6502 CPU Emulator - Interactive Mode
Commands: s/n (step), c (continue), r (run), p (pause), m (memory), b (breakpoint), f (framebuffer), q (quit)


┌─────────────────────────────────────────────────────────┐
│ PC: 0x0200  A: 0x00  X: 0x00  Y: 0x00  SP: 0x01FF       │
│ Flags: NV-BDIZC                                         │
│        00100000                                         │
│        --------                                         │
│                                                                                                       │
│ Next Instructions:                                      │
│   > 0x0200: LDA 42                                     │
│     0x0202: LDX 10                                     │
│     0x0204: LDY 20                                     │
│     0x0206: TAX                                         │
│     0x0207: TAY                                         │
└─────────────────────────────────────────────────────────┘

Instructions: 0

Memory View (0x0000-0x00FF):
0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0060 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0090 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00A0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00B0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00C0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00D0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00E0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00F0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................

Breakpoints: None
Framebuffer: None

Wrapping Up the Series

This has been a genuinely rewarding project. When I started this series, I wanted to understand how a vintage 8-bit processor actually works, not just at the "it has an accumulator and a program counter" level, but deeply enough to build something that executes real machine code. Building the emulator forced me to engage with every detail: the instruction set, the addressing modes, the memory layout, the flag behaviour, the stack mechanics. There's no hand-waving when you have to make every opcode actually do the right thing.

It was also a great excuse to spend more time with Rust. The language turned out to be an excellent fit for emulator work, enums for instructions and addressing modes, pattern matching for the decoder, and the type system catching mistakes that would have been runtime bugs in other languages. I learned a lot about structuring a Rust project along the way.

If you want to explore the code, tinker with it, or use it as a starting point for your own 6502 adventures, the full source is available: 6502 Rust Emulator source code