# 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](../../dev/mos-6502/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](../../dev/mos-6502/Makefile)): `gcc -Wall -Wextra -O3 -Iinclude`, globs `src/*.c` → `build/*.o` → `bin/mos6502`. `make clean` wipes `build/` and `bin/`. New `.c` files under `src/` are picked up automatically. - **Memory module** ([include/memory.h](../../dev/mos-6502/include/memory.h), [src/memory.c](../../dev/mos-6502/src/memory.c)): `Memory` is `uint8_t data[65536]`; `mem_read`, `mem_write`, `mem_reset` (memset zero) are direct array operations. - **CPU module** ([include/cpu.h](../../dev/mos-6502/include/cpu.h), [src/cpu.c](../../dev/mos-6502/src/cpu.c)): `CPU6502` struct with A, X, Y, SP, PC, P; flag masks `FLAG_C/Z/I/D/B/U/V/N`; `cpu_reset` zeroes A/X/Y, sets `SP=0xFD`, `P=FLAG_U`, `PC=0x0000` (does **not** load reset vector); `cpu_print_state` dumps registers. - **Addressing context stub** ([include/addressing.h](../../dev/mos-6502/include/addressing.h)): defines `addr_ctx_t` with `read8`, `read16`, `userdata` — a callback-based bus abstraction so addressing logic never touches `Memory` directly. `src/addressing.c` is empty. - **Opcode module**: `src/opcode.c` is empty; `include/opcode.h` exists but is empty. - **Entry point** ([src/main.c](../../dev/mos-6502/src/main.c)): `mem_reset` → `cpu_reset` → `cpu_print_state` → exit. No fetch–decode–execute loop. ### Bugs / discrepancies to carry into Phase 1 1. **`CPU6502.PC` is `uint8_t`** at [include/cpu.h:12](../../dev/mos-6502/include/cpu.h#L12). Must be `uint16_t`. 2. **`addr_ctx_t.read16` returns `void`** at [include/addressing.h:9](../../dev/mos-6502/include/addressing.h#L9). Must return `uint16_t`. 3. **SP reset value**: design doc says `0xFF`, code says `0xFD`. Real silicon converges on `0xFD` after the boot sequence — keep `0xFD`, treat the doc line as imprecise. 4. **Reset vector not loaded** — `cpu_reset` leaves `PC=0x0000` instead of fetching `$FFFC/$FFFD`. --- ## Phase 1 — Foundation Fixes Correct types and reset semantics. No new behaviour. **Files**: [include/cpu.h](../../dev/mos-6502/include/cpu.h), [include/addressing.h](../../dev/mos-6502/include/addressing.h), [src/cpu.c](../../dev/mos-6502/src/cpu.c), [src/main.c](../../dev/mos-6502/src/main.c). **Changes** - `uint8_t PC` → `uint16_t PC`. - `read16` callback → `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](../../dev/mos-6502/include/addressing.h), [src/addressing.c](../../dev/mos-6502/src/addressing.c), new `src/bus.c`, [src/main.c](../../dev/mos-6502/src/main.c). **Additions** - `addr_ctx_t` gains `void (*write8)(uint16_t, uint8_t, void*)` and a `CPU6502 *cpu` field (resolvers need PC). - `src/bus.c`: `bus_read8`, `bus_write8`, `bus_read16` — concrete `Memory*` 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.h` provides: - `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 0xCD` and continue, summing pass/fail at end. - `tests/run_tests.c` is the entry point that calls every registered test. Each module gets a `tests/test_.c` (cpu, memory, addressing, opcode_loads, opcode_alu, …). **Makefile additions** - `bin/run_tests` target: compiles `src/*.c` *minus* `main.c`, plus `tests/*.c`, with `-Itests`. - `make test` → builds and runs `bin/run_tests`, exits non-zero if any test fails. - `make` (default `all`) builds both `bin/mos6502` and `bin/run_tests`. **Initial tests** (verify Phases 1–2) - `test_memory.c`: round-trip `mem_write` → `mem_read`; `mem_reset` zeroes everything. - `test_cpu_reset.c`: reset vector at `$FFFC/D` is 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](../../dev/mos-6502/include/addressing.h), [src/addressing.c](../../dev/mos-6502/src/addressing.c), `tests/test_addressing.c`. **API** ```c 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 `addr` and `page_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](../../dev/mos-6502/include/opcode.h), [src/opcode.c](../../dev/mos-6502/src/opcode.c), [src/main.c](../../dev/mos-6502/src/main.c). **API** ```c 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_.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.bin` runs 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](../../dev/mos-6502/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 in `tests/`). Reference: design.md §"Advanced Decimal Mode (BCD)". --- ## Phase 8 — Interrupts (NMI, IRQ, BRK, RTI) **Files**: [include/cpu.h](../../dev/mos-6502/include/cpu.h) (add `nmi_pending`, `irq_line`), [src/opcode.c](../../dev/mos-6502/src/opcode.c). **Sequence per design.md §"Interrupt Sequence Logic"** 1. Push PCH then PCL. 2. Push P (BRK pushes `B|U` set; hardware push has B clear, U set). 3. Set `FLAG_I`. 4. Load PC from vector (`$FFFA/B` NMI, `$FFFE/F` IRQ/BRK). 5. 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](../../dev/mos-6502/src/opcode.c). - `static const uint8_t base_cycles[256]` (canonical table). - `+1` if `page_crossed` for **read** ops on ABSX/ABSY/INY (LDA/LDX/LDY/EOR/AND/ORA/ADC/SBC/CMP/BIT). Stores never get the penalty. - Branches: `+1` taken, `+1` more if page crossed. **Tests** (`tests/test_cycles.c`) - `LDA #$xx`=2; `LDA $xxxx`=4; `LDA $xxxx,X` page-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](../../dev/mos-6502/src/opcode.c), `tests/test_undocumented.c`. **Stable opcodes to support** - `LAX` (LDA + LDX combined load) - `SAX` (store `A & 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 [--success-pc ]`), Makefile targets. **Loader** - `int load_binary(Memory*, const char *path, uint16_t load_addr)` — fread into `mem->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 report `success` if PC matches `--success-pc`, else `trap at 0xXXXX` with 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_write8` checks for `addr == 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.c` that, after each step, formats the CPU state in the canonical `nestest.log` form (`PC OP OPERANDS A:.. X:.. Y:.. P:.. SP:.. CYC:..`) and diffs against `nestest.log` line-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 bench` writes a single-line CSV row to `bench.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** ```c 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 (returns `rand() & 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;m `). - Input via `termios` raw mode + non-blocking `read(STDIN)`; latest key written to `$FF`. **SDL2 frontend** (optional) - Same framebuffer, rendered via `SDL_CreateTexture` at scale 16x. - `SDL_PollEvent` → keyboard scancode → `$FF`. - Build with `make FRONTEND=sdl` (skip silently if `pkg-config --exists sdl2` fails). **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** 1. Pull the public-domain `snake.asm` from Easy 6502 (https://skilldrick.github.io/easy6502/#snake) into `games/snake.asm`. 2. Assemble with `vasm6502_oldstyle` (or `cc65`'s `ca65`/`ld65`) → `games/snake.bin` (origin `$0600`). 3. Add a Make target `make snake`: builds emulator, assembles ROM, runs `bin/mos6502 --frontend=term games/snake.bin --start=0x0600`. 4. Reset vector convention: emulator writes `$0600` to `$FFFC/D` if `--start` is 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.bin` from 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](../../dev/mos-6502/include/cpu.h) - [include/memory.h](../../dev/mos-6502/include/memory.h) - [include/addressing.h](../../dev/mos-6502/include/addressing.h) - [include/opcode.h](../../dev/mos-6502/include/opcode.h) - [src/cpu.c](../../dev/mos-6502/src/cpu.c) - [src/memory.c](../../dev/mos-6502/src/memory.c) - [src/addressing.c](../../dev/mos-6502/src/addressing.c) - [src/opcode.c](../../dev/mos-6502/src/opcode.c) - [src/main.c](../../dev/mos-6502/src/main.c) - [Makefile](../../dev/mos-6502/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: ```sh 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.