MSB
All posts

Writing a CHIP-8 Emulator to Learn Where Bugs Actually Live

A C17 emulator split into a deterministic core and an SDL2 shell — an exercise in making the interesting part testable and being honest about the parts that are not finished.

Why an emulator

CHIP-8 is a virtual machine from the 1970s: sixteen registers, a 64×32 monochrome display, thirty-five instructions. It is the standard first emulator, and it is small enough that you cannot hide behind a framework. You either decode the instruction correctly or the game draws garbage.

I wanted the practice in the parts of C that matter — fixed-width integers, bit manipulation, memory layout — and in a structural habit I care about more: keeping the logic that can be reasoned about away from the code that talks to the operating system.

How it works

The project is two pieces. chip8_core is the machine: memory, registers, stack, timers, and the instruction decoder. It knows nothing about SDL, windows, or input devices, and it never calls a clock or a random-number generator directly — randomness is injected, so the same program with the same seed executes identically every run. chip8 is the shell around it: SDL2 for rendering, audio and keyboard, running the core at 700 cycles per second with timers at 60Hz.

That split is the whole point. The core is a library you can drive from a test: load a program, step one instruction, assert on registers. If display and input were tangled into the decoder, that would take a window and a person.

The build is CMake with strict warnings and sanitizers in debug, clang-format, and GitHub Actions running it on Linux and Windows. Command-line flags cover the display scale, the clock speed and vsync. There is a debugger's worth of controls in the emulator itself: pause, single-step one instruction, reset and reload the ROM, and a snapshot dump of every register and the top of the stack.

What works

  • The core is deterministic and testable, which is the design decision everything else rests on.
  • Single-stepping and the snapshot dump turn "the game renders garbage" into "at instruction 412, register V3 holds the wrong value" — the difference between guessing and debugging.
  • It builds clean on two platforms with sanitizers on, which for C is most of the battle: use-after-free and out-of-bounds reads are the bugs that eat the time.
  • 700Hz with 60Hz timers matches how the original behaved closely enough that ordinary ROMs play at a sensible speed.

Where it falls short

The quirk flags are accepted and ignored. CHIP-8 has a handful of instructions that the original hardware and later interpreters implement differently — whether a shift reads one register or two, whether memory load increments the index register. Programs depend on those differences, so the usual fix is a flag per quirk. My command line accepts --delay-quirk and --mem-quirk, and the core ignores them and always uses the original semantics. A flag that silently does nothing is worse than no flag at all: it tells a user their problem is configured away when it is not.

The tests are scaffolding, not a suite. Unity is wired up, CI runs it, and there is a sample test. The whole reason for making the core deterministic was to write tests against it — and then I stopped at the point where it became possible. The obvious next move is a test per instruction, plus one of the public CHIP-8 test ROMs run to a known screen hash.

--log is reserved and does almost nothing, and no ROM compatibility list exists, so I cannot tell you which games run correctly beyond the ones I tried.

What I would do next

Implement the quirks the flags promise, then write the per-instruction tests the core was built for and run a public test ROM in CI against a known hash. After that, extensions are easy — SUPER-CHIP's larger display is a small step once the core is honest about its semantics.

Key takeaways

  • Separating the machine from the window is what makes an emulator testable at all.
  • Injecting randomness turns an untestable instruction into an ordinary one.
  • Accepting a flag you do not implement is a small lie that a README should not tell quietly.