20 KiB
MOS 6502 Emulator — Comprehensive Implementation Plan
Context
The repository at /home/ian/dev/mos-6502 is a prototype MOS 6502 emulator in C. The skeleton compiles and a basic Memory + CPU6502 exists, but the fetch–decode–execute loop, addressing modes, and instruction set are absent. The authoritative architecture spec lives at docs/design.md.
Goal: build a cycle-accurate NMOS 6502 emulator that passes the Klaus Dormann functional, decimal, and interrupt test suites, plus the nestest CPU log, and finally runs a real 6502 game (the canonical "6502 Snake" by Nick Morgan) end-to-end on a minimal memory-mapped framebuffer + keyboard.
The plan is organised into phases that ship and verify independently. A future Claude instance should execute one phase at a time and only proceed once that phase's verification step passes.
Phase 0 — Already Implemented (status snapshot)
The repo skeleton compiles. What is in place:
- Build system (Makefile):
gcc -Wall -Wextra -O3 -Iinclude, globssrc/*.c→build/*.o→bin/mos6502.make cleanwipesbuild/andbin/. New.cfiles undersrc/are picked up automatically. - Memory module (include/memory.h, src/memory.c):
Memoryisuint8_t data[65536];mem_read,mem_write,mem_reset(memset zero) are direct array operations. - CPU module (include/cpu.h, src/cpu.c):
CPU6502struct with A, X, Y, SP, PC, P; flag masksFLAG_C/Z/I/D/B/U/V/N;cpu_resetzeroes A/X/Y, setsSP=0xFD,P=FLAG_U,PC=0x0000(does not load reset vector);cpu_print_statedumps registers. - Addressing context stub (include/addressing.h): defines
addr_ctx_twithread8,read16,userdata— a callback-based bus abstraction so addressing logic never touchesMemorydirectly.src/addressing.cis empty. - Opcode module:
src/opcode.cis empty;include/opcode.hexists but is empty. - Entry point (src/main.c):
mem_reset→cpu_reset→cpu_print_state→ exit. No fetch–decode–execute loop.
Bugs / discrepancies to carry into Phase 1
CPU6502.PCisuint8_tat include/cpu.h:12. Must beuint16_t.addr_ctx_t.read16returnsvoidat include/addressing.h:9. Must returnuint16_t.- SP reset value: design doc says
0xFF, code says0xFD. Real silicon converges on0xFDafter the boot sequence — keep0xFD, treat the doc line as imprecise. - Reset vector not loaded —
cpu_resetleavesPC=0x0000instead of fetching$FFFC/$FFFD.
Phase 1 — Foundation Fixes
Correct types and reset semantics. No new behaviour.
Files: include/cpu.h, include/addressing.h, src/cpu.c, src/main.c.
Changes
uint8_t PC→uint16_t PC.read16callback →uint16_t (*read16)(uint16_t addr, void *userdata);.cpu_reset(CPU6502 *cpu, Memory *mem)— after register init,cpu->PC = mem_read(mem,0xFFFC) | (mem_read(mem,0xFFFD)<<8);.P = FLAG_U | FLAG_I(I disabled at reset).
Verify: Write 0x34, 0x12 to $FFFC/D in main.c, run, confirm PC: 0x1234.
Reference: design.md §"Memory Map and Reserved Vectors", §"The Reset Sequence".
Phase 2 — Memory Bus Abstraction
Every CPU access flows through addr_ctx_t. After this, swapping Memory for an MMIO bus is one line.
Files: include/addressing.h, src/addressing.c, new src/bus.c, src/main.c.
Additions
addr_ctx_tgainsvoid (*write8)(uint16_t, uint8_t, void*)and aCPU6502 *cpufield (resolvers need PC).src/bus.c:bus_read8,bus_write8,bus_read16— concreteMemory*callbacks.addr_ctx_init(addr_ctx_t*, CPU6502*, Memory*).
Verify: grep -n 'mem_read\|mem_write' src/cpu.c src/opcode.c returns nothing (CPU never touches Memory directly). Reset-vector smoke test still passes through the ctx.
Reference: design.md §"Memory Access Implementation".
Phase 3 — Test Harness Foundation (NEW)
Stand up an in-tree minimal test harness before writing the instruction set, so every later phase has assert-style verification. No external dependency — a header-only assert(cond, msg) is enough.
Files: new tests/ directory, new tests/test_framework.h, new tests/run_tests.c, Makefile additions.
Test framework
tests/test_framework.hprovides:TEST(name)— macro that registers a function pointer in a static table.EXPECT_EQ(actual, expected),EXPECT_HEX8,EXPECT_HEX16,EXPECT_FLAG_SET(p,f),EXPECT_FLAG_CLEAR(p,f).- On failure, print
file:line — got 0xAB, expected 0xCDand continue, summing pass/fail at end.
tests/run_tests.cis the entry point that calls every registered test. Each module gets atests/test_<module>.c(cpu, memory, addressing, opcode_loads, opcode_alu, …).
Makefile additions
bin/run_teststarget: compilessrc/*.cminusmain.c, plustests/*.c, with-Itests.make test→ builds and runsbin/run_tests, exits non-zero if any test fails.make(defaultall) builds bothbin/mos6502andbin/run_tests.
Initial tests (verify Phases 1–2)
test_memory.c: round-tripmem_write→mem_read;mem_resetzeroes everything.test_cpu_reset.c: reset vector at$FFFC/Dis honoured; SP=0xFD; flags =U|I.
Verify: make test passes both initial tests.
Phase 4 — Addressing Mode Resolvers
One resolver per mode, each returning effective address + page-cross flag.
Files: include/addressing.h, src/addressing.c, tests/test_addressing.c.
API
typedef struct { uint16_t addr; uint8_t page_crossed; } addr_result_t;
addr_result_t am_imm (addr_ctx_t *c);
addr_result_t am_zp (addr_ctx_t *c);
addr_result_t am_zpx (addr_ctx_t *c);
addr_result_t am_zpy (addr_ctx_t *c);
addr_result_t am_abs (addr_ctx_t *c);
addr_result_t am_absx(addr_ctx_t *c);
addr_result_t am_absy(addr_ctx_t *c);
addr_result_t am_ind (addr_ctx_t *c); // emulate $xxFF page-wrap bug
addr_result_t am_inx (addr_ctx_t *c); // ZP wraps
addr_result_t am_iny (addr_ctx_t *c); // ZP wraps; sets page_crossed
int8_t am_rel (addr_ctx_t *c);
NMOS quirks baked in now
JMP ($xxFF)reads high byte from$xx00, not$(xxFF+1).- ZP-wrap on ZPX/ZPY/INX/INY: pointer math stays in
$00–$FF.
Tests (tests/test_addressing.c)
- Each mode: set up memory + registers, call resolver, assert
addrandpage_crossed. - Edge cases: ZPX wrap (
zp=0xFF, X=0x02 → 0x01); INY page-cross (base=0x12FF, Y=0x01 → 0x1300, page_crossed=1); JMP indirect at$10FF.
Verify: make test — addressing suite green.
Reference: design.md §"Addressing Mode Architecture".
Phase 5 — Fetch-Decode-Execute Skeleton
Runnable loop, 256-case switch, default: traps.
Files: include/opcode.h, src/opcode.c, src/main.c.
API
int cpu_step(CPU6502 *cpu, addr_ctx_t *ctx); // cycles used, -1 illegal
void cpu_run (CPU6502 *cpu, addr_ctx_t *ctx); // until trap or self-loop
main.c switches from one-shot dump to cpu_run.
Tests: hand-assemble LDA #$42; BRK (A9 42 00) at $0400, expect A=0x42 after one step (BRK is a sentinel until Phase 8).
Reference: design.md §"Opcode Decoding and Instruction Dispatch".
Phase 6 — Instruction Set in Waves
Each wave is a separately verifiable sub-phase with its own tests/test_opcode_<wave>.c.
6a — Loads / Stores
LDA, LDX, LDY, STA, STX, STY. Loads update Z/N; stores do not.
6b — Register Transfers
TAX, TAY, TXA, TYA, TSX, TXS. TXS does not set flags.
6c — Logic
AND, ORA, EOR. BIT: bit 7→N, bit 6→V, Z from A&M.
6d — Shifts / Rotates
ASL, LSR, ROL, ROR — accumulator and memory variants. C from outgoing bit.
6e — Increments / Decrements
INC, DEC, INX, INY, DEX, DEY.
6f — Branches
BPL, BMI, BVC, BVS, BCC, BCS, BNE, BEQ. Reserve cycle accounting 2 + taken + page_crossed.
6g — Jumps / Subroutines
JMP abs, JMP indirect (with page-wrap bug), JSR, RTS. JSR pushes PC-1; RTS pops and PC++. Stack at 0x0100 | SP.
6h — Stack
PHA, PLA, PHP, PLP. PHP pushes B|U set; PLP ignores B, forces U. Static helpers push8/pull8/push16/pull16.
6i — Flag Set/Clear
CLC, SEC, CLI, SEI, CLD, SED, CLV.
6j — Compares
CMP, CPX, CPY. C set if R >= M; N/Z from low byte of R-M.
6k — ADC / SBC (binary only)
ADC: s=A+M+C; C=s>0xFF; V=(~(A^M)&(A^s)&0x80)!=0; A=s&0xFF. SBC = ADC with M^0xFF. Assert !(P&FLAG_D) until Phase 7.
6l — NOP
0xEA. All other unimplemented opcodes still trap.
Per-wave verification
- Each wave ships ≥ 6 micro-tests covering happy-path + flag edge cases + addressing-mode coverage.
- After 6k,
bin/mos6502 6502_functional_test.binruns far into the suite (will trap in BCD or interrupts — expected).
Reference: design.md §"Implementation of the Instruction Set", §"The Arithmetic Logic Unit", §"Control Flow, Branches, and the Stack".
Phase 7 — BCD Mode (Bruce Clark)
ADC/SBC honour the D flag.
Files: src/opcode.c, tests/test_bcd.c.
Split into adc_binary/adc_decimal/sbc_binary/sbc_decimal; public ADC/SBC selects on P&FLAG_D.
NMOS specifics
- N, V, Z computed from the binary intermediate; only C reflects decimal carry-out.
- SBC decimal: nibble subtract with borrow → adjust by
-6/-0x60.
Tests
49 + 51 = 00 C=1;12 - 03 = 09;99 + 01 = 00 C=1;00 - 01 = 99 C=0.- Smoke-run
6502_decimal_test.bin(loader from Phase 11; for now wire a one-shot intests/).
Reference: design.md §"Advanced Decimal Mode (BCD)".
Phase 8 — Interrupts (NMI, IRQ, BRK, RTI)
Files: include/cpu.h (add nmi_pending, irq_line), src/opcode.c.
Sequence per design.md §"Interrupt Sequence Logic"
- Push PCH then PCL.
- Push P (BRK pushes
B|Uset; hardware push has B clear, U set). - Set
FLAG_I. - Load PC from vector (
$FFFA/BNMI,$FFFE/FIRQ/BRK). - BRK skips one byte (push
PC+2).
RTI pops P (force U set, B clear) then PCL then PCH; no +1 (unlike RTS).
Step-boundary priority: NMI > IRQ (only if !I) > normal fetch.
Tests
- NMI: set
nmi_pending, step once, assert push order on stack and PC reload. - BRK + RTI round-trip restores PC and flags (modulo B/U canonicalisation).
- IRQ masked while
I=1, fires when cleared.
Reference: design.md §"Interrupts and Hardware Interactions".
Phase 9 — Cycle Counting
cpu_step returns accurate cycle counts.
Files: src/opcode.c.
static const uint8_t base_cycles[256](canonical table).+1ifpage_crossedfor read ops on ABSX/ABSY/INY (LDA/LDX/LDY/EOR/AND/ORA/ADC/SBC/CMP/BIT). Stores never get the penalty.- Branches:
+1taken,+1more if page crossed.
Tests (tests/test_cycles.c)
LDA #$xx=2;LDA $xxxx=4;LDA $xxxx,Xpage-crossed=5;JSR=6; taken+page-cross branch=4.
Reference: design.md §"Cycle-Level Timing Accuracy".
Phase 10 — Undocumented (Illegal) Opcodes
Klaus Dormann's functional test does not require these, but nestest and many real games do. Implement the stable subset.
Files: src/opcode.c, tests/test_undocumented.c.
Stable opcodes to support
LAX(LDA + LDX combined load)SAX(storeA & X)DCP(DEC + CMP)ISB/ISC(INC + SBC)SLO(ASL + ORA)RLA(ROL + AND)SRE(LSR + EOR)RRA(ROR + ADC)ANC,ALR,ARR,AXS(immediate-mode oddballs)- All unstable / "magic constant" opcodes (
SHA,SHX,SHY,TAS,LAS,XAA) → log + treat as NOP for now (rarely used, behaviour varies by die).
Verify: dedicated unit tests + nestest log compare in Phase 11.
Phase 11 — Validation Suites
Drive the emulator against published test ROMs. Each is a separate Make target so failures are bisectable.
Files: new src/loader.c + include/loader.h, tests/run_suite.c (CLI: bin/run_suite <bin> <load_addr> <start_pc> [--success-pc <pc>]), Makefile targets.
Loader
int load_binary(Memory*, const char *path, uint16_t load_addr)— fread intomem->data + load_addr.
Trap detection (extends cpu_run)
- If the same opcode at the same PC executes twice in a row and is a self-branch (
BNE *,JMP *), halt and reportsuccessif PC matches--success-pc, elsetrap at 0xXXXXwith last 8 PCs as breadcrumbs.
Suites integrated
| Suite | Source | Load | Start PC | Success PC | Make target |
|---|---|---|---|---|---|
6502_functional_test.bin |
Klaus Dormann | $0000 |
$0400 |
$3469 |
make test-functional |
6502_decimal_test.bin |
Bruce Clark | $0200 |
$0200 |
self-loop on ERROR=0 |
make test-decimal |
6502_interrupt_test.bin |
Klaus Dormann | $000A |
$0400 |
documented in suite | make test-interrupts |
nestest.nes (CPU-only mode) |
Kevitivity | $C000 |
$C000 |
log compare | make test-nestest |
Interrupt-test MMIO shim
bus_write8checks foraddr == 0xBFFC: bit 0 →cpu->irq_line, bit 1 →cpu->nmi_pending. This is what the Dormann interrupt test uses to assert IRQ/NMI lines.
nestest log compare
- A separate
tests/nestest_runner.cthat, after each step, formats the CPU state in the canonicalnestest.logform (PC OP OPERANDS A:.. X:.. Y:.. P:.. SP:.. CYC:..) and diffs againstnestest.logline-by-line. First mismatch fails the test.
ROMs: documented under tests/roms/README.md with attribution and download URLs (do not commit binaries unless licensing allows; provide a make fetch-roms target).
Verify: make test-all runs every suite; all pass before moving to Phase 12.
Reference: design.md §"Validation through the Klaus Dormann Test Suite".
Phase 12 — Performance Benchmark
Quantify MIPS so regressions are visible.
Files: tests/bench.c, Makefile target make bench.
- Run a tight loop (e.g.
LDA #$01; CLC; ADC #$01; BNE -4) for N seconds, count instructions, print MIPS. - Compare to the design doc's expected range (150–500 MIPS on a 3 GHz host, design.md §"Performance Calculation").
make benchwrites a single-line CSV row tobench.csv(timestamp, git sha, MIPS) for tracking over time.
Phase 13 — Memory-Mapped I/O Bus (Game-Ready)
Move from "Memory is the bus" to a real bus that supports MMIO. This is what makes a game runnable.
Files: rewrite src/bus.c to a region-table dispatch, new include/bus.h.
Bus design
typedef struct {
uint16_t start, end;
uint8_t (*read )(uint16_t, void*);
void (*write)(uint16_t, uint8_t, void*);
void *userdata;
} bus_region_t;
typedef struct {
bus_region_t regions[16];
int n;
} bus_t;
void bus_map(bus_t*, uint16_t start, uint16_t end, ...);
uint8_t bus_read8 (uint16_t, void*); // dispatches to region
void bus_write8(uint16_t, uint8_t, void*);
Default mapping
$0000–$BFFF: RAM (48 KiB).$FE00–$FE00: RNG read (returnsrand() & 0xFF) — required by 6502 Snake.$FF00–$FF00: keyboard read (latest scancode, non-blocking).$0200–$05FF: framebuffer write-through to RAM, mirrored to a renderer.
Verify: rerun the test suites against the new bus — all still pass.
Phase 14 — Display + Input Frontend (NEW)
Render the framebuffer and capture input. Two backends, picked at compile time.
Files: new src/frontend_sdl.c, src/frontend_term.c, Makefile flags FRONTEND=sdl|term (default term).
Terminal frontend (zero-dependency)
- 32×32 framebuffer at
$0200–$05FF. Each byte = 4-bit colour index. - After every
cpu_step(or every N cycles), redraw via ANSI 256-colour blocks (\x1b[48;5;<idx>m). - Input via
termiosraw mode + non-blockingread(STDIN); latest key written to$FF.
SDL2 frontend (optional)
- Same framebuffer, rendered via
SDL_CreateTextureat scale 16x. SDL_PollEvent→ keyboard scancode →$FF.- Build with
make FRONTEND=sdl(skip silently ifpkg-config --exists sdl2fails).
Verify: bin/mos6502 --frontend=term --rom=tests/roms/colorbars.bin renders a static 4×4 colour grid by hand-assembling 16 STA $0200,X; INX writes.
Phase 15 — Run a 6502 Game (capstone)
Run 6502 Snake by Nick Morgan (the canonical Easy 6502 demo program). It uses exactly the MMIO map from Phase 13.
Files: games/snake.asm (source), games/snake.bin (assembled), games/README.md documenting controls.
Flow
- Pull the public-domain
snake.asmfrom Easy 6502 (https://skilldrick.github.io/easy6502/#snake) intogames/snake.asm. - Assemble with
vasm6502_oldstyle(orcc65'sca65/ld65) →games/snake.bin(origin$0600). - Add a Make target
make snake: builds emulator, assembles ROM, runsbin/mos6502 --frontend=term games/snake.bin --start=0x0600. - Reset vector convention: emulator writes
$0600to$FFFC/Dif--startis passed.
Verify: snake spawns, WASD moves it, eats apples, dies on self-collision.
Stretch goals (optional follow-ups, not part of the capstone)
- Port a second game:
games/tetris.binfrom a known 6502 implementation. - Apple I emulation: map a 6850-style ACIA at
$D010–$D013, load Wozmon at$FF00, get a working\prompt. - Commodore PET BASIC ROM (requires sourcing a legally-redistributable image).
Sequencing Summary
Phase 0 (done) → 1 → 2 → 3 (test harness) → 4 (modes) → 5 (loop)
→ 6a..6l (instruction waves, each ships green tests)
→ 7 (BCD) → 8 (interrupts) → 9 (cycles) → 10 (illegal ops)
→ 11 (validation suites — gate before moving on)
→ 12 (bench)
→ 13 (MMIO bus) → 14 (frontend) → 15 (snake game)
- Phases 1–5 strictly linear.
- Phase 6 waves are independent (order is suggested, not required).
- Phase 7 needs 6k; Phase 8 needs 6g+6h; Phase 9 can be folded in alongside 6 if convenient.
- Hard gate: Phase 11 must be fully green before starting Phase 13. Don't build a game on an emulator that fails Klaus Dormann.
- Phase 14 is independent of the bus rewrite as long as the framebuffer region exists.
Critical Files
- include/cpu.h
- include/memory.h
- include/addressing.h
- include/opcode.h
- src/cpu.c
- src/memory.c
- src/addressing.c
- src/opcode.c
- src/main.c
- Makefile
- New:
src/bus.c,src/loader.c,src/frontend_term.c,src/frontend_sdl.c - New:
tests/test_framework.h,tests/test_*.c,tests/run_tests.c,tests/run_suite.c,tests/bench.c - New:
games/snake.asm,games/snake.bin
End-to-End Verification
After Phase 15 the following must all succeed from a clean checkout:
make # builds bin/mos6502 and bin/run_tests
make test # all unit tests green
make fetch-roms # downloads validation ROMs into tests/roms/
make test-functional # Klaus Dormann functional — success at $3469
make test-decimal # Bruce Clark BCD — error count = 0
make test-interrupts # Klaus Dormann interrupt — success
make test-nestest # nestest CPU log diff — zero mismatches
make bench # prints MIPS, appends to bench.csv
make snake # opens snake game in terminal, playable
All green = emulator is functionally complete and validated against the standard 6502 test corpus, performant, and demonstrably running real software.