A modern interpreter for the classic virtual machine
| 00E0 | CLS | Clear screen |
| 00EE | RET | Return from sub |
| 1nnn | JP nnn | Jump to nnn |
| 2nnn | CALL nnn | Call sub at nnn |
| 3xkk | SE Vx, kk | Skip if Vx == kk |
| 4xkk | SNE Vx, kk | Skip if Vx != kk |
| 5xy0 | SE Vx, Vy | Skip if Vx == Vy |
| 9xy0 | SNE Vx, Vy | Skip if Vx != Vy |
| Bnnn | JP V0, nnn | Jump nnn + V0 |
| 6xkk | LD Vx, kk | Set Vx = kk |
| 7xkk | ADD Vx, kk | Vx = Vx + kk |
| 8xy0 | LD Vx, Vy | Vx = Vy |
| 8xy1 | OR Vx, Vy | Vx |= Vy |
| 8xy2 | AND Vx, Vy | Vx &= Vy |
| 8xy3 | XOR Vx, Vy | Vx ^= Vy |
| 8xy4 | ADD Vx, Vy | Vx += Vy (VF=C) |
| 8xy5 | SUB Vx, Vy | Vx -= Vy (VF=B) |
| 8xy6 | SHR Vx | Vx >> 1 |
| 8xyE | SHL Vx | Vx << 1 |
| Cxkk | RND Vx, kk | Vx = Rand & kk |
| Annn | LD I, nnn | Set I = nnn |
| Dxyn | DRW Vx,Vy,n | Draw Sprite |
| Ex9E | SKP Vx | Skip if Key Down |
| ExA1 | SKNP Vx | Skip if Key Up |
| Fx07 | LD Vx, DT | Vx = Delay Timer |
| Fx0A | LD Vx, K | Wait for key |
| Fx1E | ADD I, Vx | I += Vx |
| Fx29 | LD F, Vx | Set I to Font |
| Fx33 | LD B, Vx | Store BCD at I |
| Fx55 | LD [I], Vx | Reg Dump to RAM |
| Fx65 | LD Vx, [I] | Load Reg from RAM |
git clone https://github.com/arpitchakladar/chip-8.git
cd chip-8
# Download Go module dependencies
go mod download
# Build the binary
go build -o chip-8 ./cmd/chip-8./chip-8 run <path-to-rom>./chip-8 assemble <path-to-asm1> <path-to-asm2> ..../chip-8 assemble -o output.ch8 <file.asm> // Package emulator provides the core CHIP-8 emulator functionality.
// It coordinates the CPU, memory, display, keyboard, and audio subsystems.
package emulator
import (
"context"
"fmt"
"os"
"sync"
"time"
"github.com/arpitchakladar/chip-8/internal/emulator/audio"
"github.com/arpitchakladar/chip-8/internal/emulator/cpu"
"github.com/arpitchakladar/chip-8/internal/emulator/display"
"github.com/arpitchakladar/chip-8/internal/emulator/keyboard"
"github.com/arpitchakladar/chip-8/internal/emulator/memory"
)
// ProgramStart is the memory address where CHIP-8 programs begin (0x200).
const ProgramStart = 0x200
// Emulator represents a complete CHIP-8 virtual machine.
// It coordinates the CPU, memory, display, keyboard, and audio subsystems.
type Emulator struct {
CPU *cpu.CPU
Memory *memory.Memory
Display display.Display
Keyboard keyboard.Keyboard
Audio audio.Audio
ClockSpeed uint32
memoryLock sync.Mutex
running bool
runLock sync.Mutex
cancelRunner context.CancelFunc
}
// LoadROM loads a CHIP-8 ROM into memory starting at ProgramStart (0x200).
func (e *Emulator) LoadROM(romData []byte) error {
for i, b := range romData {
if err := e.Memory.Write(ProgramStart+uint16(i), b); err != nil {
return err
}
}
return nil
}
// Run starts the emulator main loop.
// It initializes the display and audio subsystems, then runs the CPU and display loops.
// The function blocks until the emulator is closed or an error occurs.
func (e *Emulator) Run(parentContext context.Context) error {
e.runLock.Lock()
if e.running {
e.runLock.Unlock()
return fmt.Errorf("emulator is already running")
}
e.running = true
e.runLock.Unlock()
if err := e.Display.Init(); err != nil {
e.runLock.Lock()
e.running = false
e.runLock.Unlock()
return fmt.Errorf("failed to init display: %w", err)
}
if err := e.Audio.Init(); err != nil {
fmt.Printf("Warning: Audio failed to init: %v\n", err)
}
defer func() {
if err := e.Display.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Error closing display: %v\n", err)
}
if err := e.Audio.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Error closing audio device: %v\n", err)
}
}()
runEmulatorContext, cancelRunEmulatorContext := context.WithCancel(
parentContext,
)
e.cancelRunner = cancelRunEmulatorContext
errChan := make(chan error, 1)
defer cancelRunEmulatorContext()
go e.runCPU(runEmulatorContext, errChan)
// Cannot be goroutine as SDL2 wants* to be on the main thread
e.runDisplay(runEmulatorContext, errChan)
err := <-errChan
e.runLock.Lock()
e.running = false
e.runLock.Unlock()
return err
}
// IsRunning returns true if the emulator is currently running.
func (e *Emulator) IsRunning() bool {
e.runLock.Lock()
isRunning := e.running
e.runLock.Unlock()
return isRunning
}
// runDisplay handles the display update loop at 60Hz.
// It polls for keyboard events, updates timers, and renders the display.
func (e *Emulator) runDisplay(
runEmulatorContext context.Context,
errChan chan<- error,
) {
uiClock := time.NewTicker(time.Second / 60)
defer uiClock.Stop()
for {
select {
case <-runEmulatorContext.Done():
return
case <-uiClock.C:
e.Keyboard.PollEvents()
e.memoryLock.Lock()
timerErr := e.updateTimers()
displayErr := e.Display.Present()
e.memoryLock.Unlock()
if timerErr != nil {
errChan <- timerErr
return
}
if displayErr != nil {
errChan <- displayErr
return
}
}
}
}
// runCPU runs the CPU execution loop at the configured ClockSpeed.
// It uses a fixed ticker at MaxTickRate and executes ClockSpeed/MaxTickRate
// instructions per tick to achieve the target clock speed.
func (e *Emulator) runCPU(
runEmulatorContext context.Context,
errChan chan<- error,
) {
batchSize := max(int(e.ClockSpeed/MaxTickRate), 1)
cpuClock := time.NewTicker(time.Second / MaxTickRate)
defer cpuClock.Stop()
for {
select {
case <-runEmulatorContext.Done():
return
case <-cpuClock.C:
e.memoryLock.Lock()
for range batchSize {
err := e.tick()
if err != nil {
e.memoryLock.Unlock()
errChan <- err
return
}
}
e.memoryLock.Unlock()
}
}
}
// tick performs one CPU fetch-decode-execute cycle.
func (e *Emulator) tick() error {
hi, err := e.Memory.Read(e.CPU.ProgramCounter)
if err != nil {
return err
}
lo, err := e.Memory.Read(e.CPU.ProgramCounter + 1)
if err != nil {
return err
}
opcode := uint16(hi)<<8 | uint16(lo)
e.CPU.ProgramCounter += 2
return e.CPU.Execute(opcode, e.Memory, e.Display, e.Keyboard)
}
// updateTimers decrements the delay and sound timers at 60Hz.
func (e *Emulator) updateTimers() error {
if e.CPU.SoundTimer > 0 {
if err := e.Audio.Play(); err != nil {
return err
}
e.CPU.SoundTimer--
} else {
if err := e.Audio.Pause(); err != nil {
return err
}
}
if e.CPU.DelayTimer > 0 {
e.CPU.DelayTimer--
}
return nil
}
// Stops the runner (CPU and Display goroutine) threads.
func (e *Emulator) Destroy() {
e.runLock.Lock()
defer e.runLock.Unlock()
e.running = false
e.cancelRunner()
}
Just like a real machine, our emulator will have a CPU, Memory, Display, Keyboard and Audio components. Each performing the task inferred by their name.
Running the cpu in a goroutine and display/timer on the main function for parallelism.
Fetch-decode-execute cycle logic with batch processing for systems where the max tick frequency is less than the configured CPU clock speed.
Rendering and timer countdowns happen at 60Hz only, so there is a benefit of running them in parallel, as the 60Hz constraint won't throttle the CPU.
// Package cpu provides CPU execution and opcode handling for the CHIP-8 emulator.
package cpu
// The CPU is responsible for identifying and running
// each opcode.
type CPU struct {
// Registers are the 16 general-purpose 8-bit registers.
// Historically referred to as V0 through VF.
Registers [16]byte
// IndexRegister stores memory addresses for use in operations.
// Historically referred to as the 'I' register.
IndexRegister uint16
// ProgramCounter stores the memory address of the next instruction to be executed.
ProgramCounter uint16
// StackPointer points to the current top of the stack.
StackPointer uint8
// Stack is used to store the return addresses when subroutines are called.
// It allows for up to 16 levels of nested function calls.
Stack [16]uint16
// DelayTimer is used for game events; it decrements at a rate of 60Hz.
DelayTimer byte
// SoundTimer decrements at 60Hz and triggers a buzz as long as the value is > 0.
SoundTimer byte
}
// New initializes a CPU with the standard entry point for Chip-8 programs.
func New() *CPU {
return new(CPU)
}
Only noteable difference with other CPU architectures is that VF acts both as general purpose registor as well as a carry/borrow flag for arithmetic operations.
These are some of the special purpose registors that exist more or less in any architecture.
Now these registors are very much CHIP-8 centric (specially the Sound Timer).
package cpu
import (
"math/rand"
"github.com/arpitchakladar/chip-8/internal/emulator/display"
"github.com/arpitchakladar/chip-8/internal/emulator/keyboard"
"github.com/arpitchakladar/chip-8/internal/emulator/memory"
)
// Execute decodes and performs the operation specified by the 16-bit opcode.
// It handles all CHIP-8 instructions by decoding the opcode into its component parts
// and executing the appropriate logic.
//
// Opcode format (16 bits): F | X | Y | N
// - F: 4-bit function code (for 8xyN instructions)
// - X: 4-bit register index (Vy reference)
// - Y: 4-bit register index (Vy reference)
// - N: 4-bit constant (or nibble)
//
// Additional decoded values:
// - nnn: 12-bit address (lower 12 bits of opcode)
// - kk: 8-bit constant (lower 8 bits of opcode)
//
// Opcode groups:
// - 0x0000: System and subroutine operations (CLS, RET)
// - 0x1000: JP - Jump to address
// - 0x2000: CALL - Call subroutine
// - 0x3000: SE Vx, byte - Skip if equal
// - 0x4000: SNE Vx, byte - Skip if not equal
// - 0x5000: SE Vx, Vy - Skip if registers equal
// - 0x6000: LD Vx, byte - Load constant
// - 0x7000: ADD Vx, byte - Add constant
// - 0x8000: ALU operations (OR, AND, XOR, SUB, SHR, SUBN, SHL)
// - 0x9000: SNE Vx, Vy - Skip if registers not equal
// - 0xA000: LD I, addr - Load address into I
// - 0xB000: JP V0, addr - Jump with offset
// - 0xC000: RND - Random number
// - 0xD000: DRW - Draw sprite
// - 0xE000: Keyboard operations (SKP, SKNP)
// - 0xF000: Miscellaneous (timers, memory, BCD)
//
// Returns:
// - nil: on successful execution
// - *InvalidOpcodeError: if the opcode is not recognized
// - *StackError: if stack overflow/underflow occurs
// - *MemorySyncError: if memory read/write fails
type opcodeHandler func(*CPU, uint16, *memory.Memory, display.Display, keyboard.Keyboard, uint8, uint8, uint16, byte, byte) error
var opcodeHandlers = map[uint16]opcodeHandler{
0x0000: handleGroup0,
0x1000: handleGroup1,
0x2000: handleGroup2,
0x3000: handleGroup3,
0x4000: handleGroup4,
0x5000: handleGroup5,
0x6000: handleGroup6,
0x7000: handleGroup7,
0x8000: handleGroup8,
0x9000: handleGroup9,
0xA000: handleGroupA,
0xB000: handleGroupB,
0xC000: handleGroupC,
0xD000: handleGroupD,
0xE000: handleGroupE,
0xF000: handleGroupF,
}
func (c *CPU) Execute(
opcode uint16,
mem *memory.Memory,
disp display.Display,
keyb keyboard.Keyboard,
) error {
x := uint8((opcode & 0x0F00) >> 8)
y := uint8((opcode & 0x00F0) >> 4)
nnn := opcode & 0x0FFF
kk := byte(opcode & 0x00FF)
n := byte(opcode & 0x000F)
group := opcode & 0xF000
handler, ok := opcodeHandlers[group]
if !ok {
return c.handleInvalidOpcode(opcode)
}
return handler(c, opcode, mem, disp, keyb, x, y, nnn, kk, n)
}
func handleGroup0(
c *CPU,
opcode uint16,
_ *memory.Memory,
disp display.Display,
_ keyboard.Keyboard,
_, _ uint8,
_ uint16,
_ byte,
_ byte,
) error {
return c.handle0xxx(opcode, disp)
}
func handleGroup1(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
_, _ uint8,
nnn uint16,
_ byte,
_ byte,
) error {
c.ProgramCounter = nnn
return nil
}
func handleGroup2(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
_, _ uint8,
nnn uint16,
_ byte,
_ byte,
) error {
return c.handle2xxx(nnn)
}
func handleGroup3(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
x uint8,
_ uint8,
_ uint16,
kk byte,
_ byte,
) error {
if c.Registers[x] == kk {
c.ProgramCounter += 2
}
return nil
}
func handleGroup4(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
x uint8,
_ uint8,
_ uint16,
kk byte,
_ byte,
) error {
if c.Registers[x] != kk {
c.ProgramCounter += 2
}
return nil
}
func handleGroup5(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
x, y uint8,
_ uint16,
_ byte,
_ byte,
) error {
if c.Registers[x] == c.Registers[y] {
c.ProgramCounter += 2
}
return nil
}
func handleGroup6(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
x uint8,
_ uint8,
_ uint16,
kk byte,
_ byte,
) error {
c.Registers[x] = kk
return nil
}
func handleGroup7(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
x uint8,
_ uint8,
_ uint16,
kk byte,
_ byte,
) error {
c.Registers[x] += kk
return nil
}
func handleGroup8(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
x, y uint8,
_ uint16,
_ byte,
n byte,
) error {
return c.handle8xxx(x, y, n)
}
func handleGroup9(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
x, y uint8,
_ uint16,
_ byte,
_ byte,
) error {
if c.Registers[x] != c.Registers[y] {
c.ProgramCounter += 2
}
return nil
}
func handleGroupA(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
_, _ uint8,
nnn uint16,
_ byte,
_ byte,
) error {
c.IndexRegister = nnn
return nil
}
func handleGroupB(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
_, _ uint8,
nnn uint16,
_ byte,
_ byte,
) error {
c.ProgramCounter = nnn + uint16(c.Registers[0])
return nil
}
func handleGroupC(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
_ keyboard.Keyboard,
x uint8,
_ uint8,
_ uint16,
kk byte,
_ byte,
) error {
c.Registers[x] = byte(rand.Intn(256)) & kk
return nil
}
func handleGroupD(
c *CPU,
_ uint16,
mem *memory.Memory,
disp display.Display,
_ keyboard.Keyboard,
x, y uint8,
_ uint16,
_ byte,
n byte,
) error {
return c.handleDxxx(x, y, n, mem, disp)
}
func handleGroupE(
c *CPU,
_ uint16,
_ *memory.Memory,
_ display.Display,
keyb keyboard.Keyboard,
x uint8,
_ uint8,
_ uint16,
kk byte,
_ byte,
) error {
return c.handleExxx(x, kk, keyb)
}
func handleGroupF(
c *CPU,
_ uint16,
mem *memory.Memory,
_ display.Display,
keyb keyboard.Keyboard,
x uint8,
_ uint8,
_ uint16,
kk byte,
_ byte,
) error {
return c.handleFxxx(x, kk, mem, keyb)
}
func (c *CPU) handleInvalidOpcode(opcode uint16) error {
return &InvalidOpcodeError{
Opcode: opcode,
ProgramCounter: c.ProgramCounter - 2,
}
}
func (c *CPU) handle0xxx(opcode uint16, disp display.Display) error {
switch opcode {
case 0x00E0:
disp.Clear()
case 0x00EE:
if c.StackPointer == 0 {
return &StackError{
IsOverflow: false,
ProgramCounter: c.ProgramCounter - 2,
}
}
c.StackPointer--
c.ProgramCounter = c.Stack[c.StackPointer]
default:
return &InvalidOpcodeError{
Opcode: opcode,
ProgramCounter: c.ProgramCounter - 2,
}
}
return nil
}
func (c *CPU) handle2xxx(nnn uint16) error {
if c.StackPointer >= 16 {
return &StackError{
IsOverflow: true,
ProgramCounter: c.ProgramCounter - 2,
}
}
c.Stack[c.StackPointer] = c.ProgramCounter
c.StackPointer++
c.ProgramCounter = nnn
return nil
}
func (c *CPU) handle8xxx(x, y uint8, n byte) error {
switch n {
case 0x0:
c.Registers[x] = c.Registers[y]
case 0x1:
c.Registers[x] |= c.Registers[y]
case 0x2:
c.Registers[x] &= c.Registers[y]
case 0x3:
c.Registers[x] ^= c.Registers[y]
case 0x4:
return c.handleAddCarry(x, y)
case 0x5:
return c.handleSub(x, y, true)
case 0x6:
return c.handleShiftRight(x)
case 0x7:
return c.handleSubReverse(x, y)
case 0xE:
return c.handleShiftLeft(x)
}
return nil
}
func (c *CPU) handleAddCarry(x, y uint8) error {
sum := uint16(c.Registers[x]) + uint16(c.Registers[y])
c.Registers[0xF] = 0
if sum > 255 {
c.Registers[0xF] = 1
}
c.Registers[x] = byte(sum & 0xFF)
return nil
}
func (c *CPU) handleSub(x, y uint8, normal bool) error {
c.Registers[0xF] = 1
if c.Registers[x] < c.Registers[y] {
c.Registers[0xF] = 0
}
if normal {
c.Registers[x] -= c.Registers[y]
} else {
c.Registers[x] = c.Registers[y] - c.Registers[x]
}
return nil
}
func (c *CPU) handleSubReverse(x, y uint8) error {
c.Registers[0xF] = 1
if c.Registers[y] < c.Registers[x] {
c.Registers[0xF] = 0
}
c.Registers[x] = c.Registers[y] - c.Registers[x]
return nil
}
func (c *CPU) handleShiftRight(x uint8) error {
c.Registers[0xF] = c.Registers[x] & 0x1
c.Registers[x] >>= 1
return nil
}
func (c *CPU) handleShiftLeft(x uint8) error {
c.Registers[0xF] = (c.Registers[x] & 0x80) >> 7
c.Registers[x] <<= 1
return nil
}
func (c *CPU) handleDxxx(
x, y uint8,
n byte,
mem *memory.Memory,
disp display.Display,
) error {
c.Registers[0xF] = 0
for row := range uint16(n) {
spriteByte, err := mem.Read(c.IndexRegister + row)
if err != nil {
return &MemorySyncError{
Opcode: 0xD000 | (uint16(x) << 8) | (uint16(y) << 4) | uint16(
n,
),
ProgramCounter: c.ProgramCounter - 2,
Child: err,
}
}
for col := range uint16(8) {
if (spriteByte & (0x80 >> col)) != 0 {
posX := (c.Registers[x] + uint8(col)) % 64
posY := (c.Registers[y] + uint8(row)) % 32
collision, _ := disp.SetPixel(posX, posY)
if collision {
c.Registers[0xF] = 1
}
}
}
}
return nil
}
func (c *CPU) handleExxx(x uint8, kk byte, keyb keyboard.Keyboard) error {
switch kk {
case 0x9E:
if keyb.IsKeyPressed(c.Registers[x]) {
c.ProgramCounter += 2
}
case 0xA1:
if !keyb.IsKeyPressed(c.Registers[x]) {
c.ProgramCounter += 2
}
}
return nil
}
func (c *CPU) handleFxxx(
x uint8,
kk byte,
mem *memory.Memory,
keyb keyboard.Keyboard,
) error {
switch kk {
case 0x07:
c.Registers[x] = c.DelayTimer
case 0x0A:
return c.handleKeyWait(x, keyb)
case 0x15:
c.DelayTimer = c.Registers[x]
case 0x18:
c.SoundTimer = c.Registers[x]
case 0x1E:
c.IndexRegister += uint16(c.Registers[x])
case 0x29:
c.IndexRegister = uint16(c.Registers[x]) * 5
case 0x33:
return c.handleBCD(x, mem)
case 0x55:
return c.handleStoreRegs(x, mem)
case 0x65:
return c.handleLoadRegs(x, mem)
}
return nil
}
func (c *CPU) handleKeyWait(x uint8, keyb keyboard.Keyboard) error {
if key, pressed := keyb.AnyKeyPressed(); pressed {
c.Registers[x] = key
} else {
c.ProgramCounter -= 2
}
return nil
}
func (c *CPU) handleBCD(x uint8, mem *memory.Memory) error {
val := c.Registers[x]
opcode := 0x3000 | (uint16(x) << 8)
if err := mem.Write(c.IndexRegister, val/100); err != nil {
return &MemorySyncError{
Opcode: opcode,
ProgramCounter: c.ProgramCounter - 2,
Child: err,
}
}
if err := mem.Write(c.IndexRegister+1, (val/10)%10); err != nil {
return &MemorySyncError{
Opcode: opcode,
ProgramCounter: c.ProgramCounter - 2,
Child: err,
}
}
if err := mem.Write(c.IndexRegister+2, val%10); err != nil {
return &MemorySyncError{
Opcode: opcode,
ProgramCounter: c.ProgramCounter - 2,
Child: err,
}
}
return nil
}
func (c *CPU) handleStoreRegs(x uint8, mem *memory.Memory) error {
opcode := 0xF000 | (uint16(x) << 8) | 0x55
for i := uint8(0); i <= x; i++ {
if err := mem.Write(c.IndexRegister+uint16(i), c.Registers[i]); err != nil {
return &MemorySyncError{
Opcode: opcode,
ProgramCounter: c.ProgramCounter - 2,
Child: err,
}
}
}
return nil
}
func (c *CPU) handleLoadRegs(x uint8, mem *memory.Memory) error {
opcode := 0xF000 | (uint16(x) << 8) | 0x65
for i := uint8(0); i <= x; i++ {
val, err := mem.Read(c.IndexRegister + uint16(i))
if err != nil {
return &MemorySyncError{
Opcode: opcode,
ProgramCounter: c.ProgramCounter - 2,
Child: err,
}
}
c.Registers[i] = val
}
return nil
}
Using bit masking to extract register indices (x, y), 12-bit address (nnn), 8-bit constant (kk), and 4-bit nibble (n).
Find the group that the opcode belongs to and dispatch the appropriate handler.
The CPU instruction execution is just one large switch statement that decides what handler to run. Here however we are using a map for cleanliness.
// Package memory provides memory management for the CHIP-8 emulator.
// Handles 4KB of RAM with read/write operations and font set loading.
package memory
// Memory provides the read/write interface for CHIP-8 system memory.
// It manages 4096 bytes of RAM with font set loading and write protection.
const (
// Size is the total memory size in bytes (4KB, standard CHIP-8).
Size = 4096
)
// Memory represents the 4096 bytes of RAM in a CHIP-8 system.
type Memory struct {
// RAM is the physical storage for all memory operations.
// Memory layout:
// - 0x000-0x1FF (512 bytes): Reserved for font set and system use
// - 0x200-0xFFF (3840 bytes): Program ROM and work RAM
RAM [Size]byte
}
// New creates a blank 4KB Memory instance with all bytes initialized to zero.
// The font set must be loaded using LoadFontSet() before the emulator can run.
func New() *Memory {
return new(Memory)
}
// Reset clears all 4096 bytes of memory to zero.
// Note: After resetting, you must call LoadFontSet() to restore the font data,
// otherwise the display will not render characters correctly.
func (m *Memory) Reset() {
m.RAM = [Size]byte{}
}
// Read returns the byte stored at the given memory address.
//
// Parameters:
// - address: 16-bit memory address (0x000 to 0xFFF)
//
// Returns:
// - byte: the value stored at the address
// - error: *BoundsError if address is out of range (>= 4096)
func (m *Memory) Read(address uint16) (byte, error) {
if address >= Size {
return 0, &BoundsError{Address: address, Max: 4095}
}
return m.RAM[address], nil
}
// Write stores a byte at the given memory address.
//
// Parameters:
// - address: 16-bit memory address (0x000 to 0xFFF)
// - value: the byte to write
//
// Returns:
// - nil on success
// - *BoundsError if address is out of range (>= 4096)
// - *WriteProtectedError if attempting to write to font area (0x000-0x1FF)
//
// The first 512 bytes (0x000-0x1FF) are write-protected because they
// contain the font set. Standard programs should not write to this area.
func (m *Memory) Write(address uint16, value byte) error {
// Check physical bounds
if address >= Size {
return &BoundsError{Address: address, Max: 4095}
}
// Check for protected area (font set location)
// Standard ROMs should never overwrite the font set
if address < 0x200 {
return &WriteProtectedError{Address: address}
}
m.RAM[address] = value
return nil
}
// LoadFontSet populates the first 80 bytes of memory (0x000-0x04F)
// with the standard 4x5 pixel font set for hexadecimal characters 0-F.
//
// Each character is encoded as 5 bytes, with each byte representing
// a row of 8 pixels (only the lower 4 bits are used):
//
// Byte 0: Top row (bits 4-7 used)
// Byte 1: Second row
// Byte 2: Third row
// Byte 3: Fourth row
// Byte 4: Bottom row
//
// Font data is indexed by character: address = character * 5
// e.g., character '0' at 0x000, '1' at 0x005, 'A' at 0x0050, etc.
func (m *Memory) LoadFontSet() {
fontSet := []byte{
0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
0x20, 0x60, 0x20, 0x20, 0x70, // 1
0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3
0x90, 0x90, 0xF0, 0x10, 0x10, // 4
0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5
0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6
0xF0, 0x10, 0x20, 0x40, 0x40, // 7
0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8
0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9
0xF0, 0x90, 0xF0, 0x90, 0x90, // A
0xE0, 0x90, 0xE0, 0x90, 0xE0, // B
0xF0, 0x80, 0x80, 0x80, 0xF0, // C
0xE0, 0x90, 0x90, 0x90, 0xE0, // D
0xF0, 0x80, 0xF0, 0x80, 0xF0, // E
0xF0, 0x80, 0xF0, 0x80, 0x80, // F
}
// Copy the 80 bytes of font data into the start of RAM
copy(m.RAM[0:80], fontSet)
}
Very little RAM fixed at 4kb. Providing the biggest constraint to developers only second to the display size.
The memory is just a BIG (yes) byte array.
CHIP-8 has a inbuilt method to display characters for hexadecimal (0-F).
package display
// Display represents the display subsystem for the CHIP-8 emulator.
// Implement this interface to provide display output for different platforms
// (e.g., SDL2, JavaScript/Canvas, etc.).
type Display interface {
// Init initializes the display subsystem and creates any necessary windows or surfaces.
// Returns an error if initialization fails.
Init() error
// Clear clears the entire display buffer to all pixels off.
// clears the display buffer without affecting the screen.
Clear()
// SetPixel toggles a pixel at the specified coordinates using XOR mode.
// If a pixel is already on, drawing it again turns it off.
// Coordinates are wrapped to stay within bounds (0-63 for x, 0-31 for y).
// Returns true if the pixel was already on (collision), false otherwise.
// Returns an error if coordinates are invalid.
SetPixel(x, y uint8) (bool, error)
// Present renders the current display buffer to the screen.
// This should be called at 60Hz.
Present() error
// Close releases display resources.
// Should be safe to call multiple times.
Close() error
}
A go interface that defines how the emulator talks to different display implementations.
Initializes the display. This is where the window is created (for SDL2).
Clears the display. Sets all pixels to black.
Sets a pixel at x,y using XOR mode (toggles). Returns true if there was pixel collision.
Renders the display buffer to screen. Called at 60Hz.
Releases display resources.
//go:build !wasm || !js
// Package display provides an SDL2-compatible display implementation for the CHIP-8 emulator.
package display
// SDLDisplay manages the display output for the CHIP-8 emulator.
// It maintains a pixel buffer and renders it to an SDL2 window.
import (
"fmt"
"github.com/veandco/go-sdl2/sdl"
)
const (
// Scale is the scaling factor for rendering pixels to screen.
// Each CHIP-8 pixel will be rendered as Scale x Scale on screen.
Scale = 15
)
// SDLDisplay maintains the CHIP-8 display state and SDL2 rendering resources.
type SDLDisplay struct {
// buffer is the display buffer for pixel storage.
buffer *DisplayBuffer
// window is the SDL2 window handle.
window *sdl.Window
// renderer is the SDL2 renderer for drawing to the window.
renderer *sdl.Renderer
}
// New creates a new, cleared SDLDisplay instance.
// The pixel buffer is initialized to all zeros (black).
// Call Init() before use to create the SDL window.
func WithSDL() *SDLDisplay {
return &SDLDisplay{buffer: NewDisplayBuffer()}
}
// Init initializes the SDL2 subsystem and creates the window and renderer.
// It initializes all SDL2 subsystems, creates a centered window at 64*Scale x 32*Scale
// pixels, and creates an accelerated renderer for the window.
//
// Returns:
// - nil on success
// - *SDLError if SDL initialization, window creation, or renderer creation fails
func (d *SDLDisplay) Init() error {
// Initialize all SDL2 subsystems
if err := sdl.Init(uint32(sdl.INIT_EVERYTHING)); err != nil {
return &SDLError{Subsystem: "Initialization", Child: err}
}
// Create the emulator window
window, err := sdl.CreateWindow(
"Chip-8 Emulator",
int32(sdl.WINDOWPOS_CENTERED), int32(sdl.WINDOWPOS_CENTERED),
int32(Width*Scale), int32(Height*Scale),
uint32(sdl.WINDOW_SHOWN),
)
if err != nil {
return &SDLError{Subsystem: "Window Creation", Child: err}
}
// Create the renderer (hardware accelerated preferred)
dr, err := sdl.CreateRenderer(window, -1, uint32(sdl.RENDERER_ACCELERATED))
if err != nil {
return &SDLError{Subsystem: "Renderer Creation", Child: err}
}
d.window = window
d.renderer = dr
return nil
}
// Clears the entire pixel buffer to black (all zeros).
// This is equivalent to turning off all pixels.
// It is called by the CLS (0x00E0) opcode to clear the display.
// Note: This only clears the in-memory buffer, not the actual screen.
func (d *SDLDisplay) Clear() {
d.buffer.Clear()
}
// SetPixel toggles a pixel at the specified coordinates using XOR mode.
// CHIP-8 uses XOR drawing: if a pixel is already on, drawing it again turns it off.
//
// Coordinates are wrapped to stay within bounds (standard CHIP-8 behavior):
// - x wraps to 0-63 (64 pixels wide)
// - y wraps to 0-31 (32 pixels tall)
//
// Parameters:
// - x: X coordinate (0-63)
// - y: Y coordinate (0-31)
//
// Returns:
// - bool: true if the pixel was already on (collision), false otherwise
// - error: *CoordinateError if coordinates are out of bounds (before wrapping),
// or nil if coordinates are valid (even after wrapping)
func (d *SDLDisplay) SetPixel(x, y uint8) (bool, error) {
return d.buffer.SetPixel(x, y)
}
// Present renders the current pixel buffer to the SDL window.
// It clears the screen to black, then draws all pixels that are set (value 1)
// as white rectangles. Each pixel is scaled according to the Scale constant.
//
// The rendering order is:
// 1. Clear screen to black
// 2. Set draw color to white
// 3. Draw all "on" pixels as rectangles
// 4. Present (flip) the screen
//
// Returns:
// - nil on success
// - *SDLError if renderer is not initialized or drawing fails
func (d *SDLDisplay) Present() error {
if d.renderer == nil {
return &SDLError{
Subsystem: "Renderer",
Child: fmt.Errorf("renderer not initialized"),
}
}
// Clear screen to black
if err := d.renderer.SetDrawColor(0, 0, 0, 255); err != nil {
return &SDLError{Subsystem: "SetDrawColor (Background)", Child: err}
}
if err := d.renderer.Clear(); err != nil {
return &SDLError{Subsystem: "Clear", Child: err}
}
// Set pixel color to white
if err := d.renderer.SetDrawColor(255, 255, 255, 255); err != nil {
return &SDLError{Subsystem: "SetDrawColor (Pixel)", Child: err}
}
// Draw each "on" pixel as a scaled rectangle
for i, val := range d.buffer.Pixels {
if val == 1 {
// Calculate pixel position
x := int32(i % Width)
y := int32(i / Width)
// Create scaled rectangle for pixel
rect := sdl.Rect{
X: x * Scale,
Y: y * Scale,
W: Scale,
H: Scale,
}
if err := d.renderer.FillRect(&rect); err != nil {
return &SDLError{Subsystem: "FillRect", Child: err}
}
}
}
// Present the rendered frame to the screen
d.renderer.Present()
return nil
}
// Close releases all display resources.
// It destroys the renderer first, then the window, and finally calls
// sdl.Quit(). If either destroy operation fails, the error is returned
// (preferring the renderer error).
//
// This function is safe to call multiple times (subsequent calls will be no-ops).
//
// Returns:
// - nil on success
// - *SDLError containing the last error encountered during cleanup
func (d *SDLDisplay) Close() error {
lastErr := error(nil)
// Destroy renderer first
if d.renderer != nil {
if err := d.renderer.Destroy(); err != nil {
lastErr = &SDLError{Subsystem: "Renderer Destruction", Child: err}
}
}
// Then destroy window
if d.window != nil {
if err := d.window.Destroy(); err != nil {
lastErr = &SDLError{Subsystem: "Window Destruction", Child: err}
}
}
// Clean up SDL subsystems
sdl.Quit()
return lastErr
}
Holds the pixel buffer, SDL window, and renderer.
Creates a new SDLDisplay with an initialized empty buffer.
Initializes SDL2, creates a centered window at 64*15 x 32*15 pixels.
Clears the pixel buffer to all zeros (black).
Delegates to the buffer for XOR drawing with collision detection.
Clears screen, draws all 'on' pixels as white rectangles, then Present() to flip.
Destroys renderer, window, and calls sdl.Quit(). Safe to call multiple times.
// Package keyboard provides keyboard input implementations for the CHIP-8 emulator.
// Implement the Keyboard interface to provide input handling for different platforms.
package keyboard
// Keyboard represents the keyboard input subsystem for the CHIP-8 emulator.
// CHIP-8 has 16 keys (0-F). Implement this interface to provide input handling
// for different platforms (e.g., SDL2, JavaScript, etc.).
type Keyboard interface {
// IsKeyPressed checks if a specific CHIP-8 key is currently pressed.
// Key should be 0-15. Returns true if pressed, false otherwise.
IsKeyPressed(key byte) bool
// AnyKeyPressed checks if any CHIP-8 key is currently pressed.
// Returns the key index (0-15) and true if any key is pressed,
// or 0 and false if no keys are pressed.
AnyKeyPressed() (byte, bool)
// SetKey sets the pressed state of a CHIP-8 key.
// Key should be 0-15.
SetKey(key byte, pressed bool)
// PollEvents processes platform-specific input events.
// This should be called periodically (e.g., at 60Hz).
// For SDL: polls and processes SDL keyboard events.
// For Web: can be a no-op if using event-driven updates.
PollEvents()
}
A go interface for handling input. CHIP-8 has 16 keys (0-F).
Check if a specific key (0-15) is currently pressed.
Returns the first pressed key. Used by LD Vx, K instruction for waiting on key input.
Sets the state of a key (pressed/release).
Processes input events. Called at 60Hz.
//go:build !wasm || !js
// Package keyboard provides an SDL2-compatible keyboard implementation for the CHIP-8 emulator.
package keyboard
// SDLKeyboard tracks the state of the 16 CHIP-8 keys.
// It maintains a map of which keys are currently pressed and provides
// methods for checking key state, used by CPU opcodes for input handling.
import "github.com/veandco/go-sdl2/sdl"
// SDLKeyboard tracks the state of the 16 CHIP-8 keys.
type SDLKeyboard struct {
// Keys stores the pressed state of each CHIP-8 key.
// Index 0-15 corresponds to CHIP-8 keys 0x0-0xF.
// true = pressed, false = released
Keys [16]bool
}
// New creates a new SDLKeyboard instance with all keys initialized to released.
func WithSDL() *SDLKeyboard {
return new(SDLKeyboard)
}
// IsKeyPressed checks if a specific CHIP-8 key is currently pressed.
// It is used by the SKP (0xEx9E) and SKNP (0xExA1) opcodes.
//
// Parameters:
// - key: the CHIP-8 key index (0-15, corresponds to Vx register values)
//
// Returns:
// - true if the key is currently pressed
// - false if the key is released OR if key is out of range (>15)
func (kb *SDLKeyboard) IsKeyPressed(key byte) bool {
return key <= 15 && kb.Keys[key]
}
// AnyKeyPressed checks if any CHIP-8 key is currently pressed.
// It is used by the LD Vx, K (0xFx0A) opcode to wait for key input.
//
// When no key is pressed, the CPU uses this to implement a blocking wait:
// it repeatedly executes this instruction until a key is pressed.
//
// Returns:
// - byte: the key index (0-15) of the first pressed key found
// - bool: true if any key is pressed, false if no keys are pressed
func (kb *SDLKeyboard) AnyKeyPressed() (byte, bool) {
for i, isPressed := range kb.Keys {
if isPressed {
return byte(i), true
}
}
return 0, false
}
// SetKey sets the pressed state of a CHIP-8 key.
func (kb *SDLKeyboard) SetKey(key byte, pressed bool) {
if key <= 15 {
kb.Keys[key] = pressed
}
}
// PollEvents processes SDL keyboard events.
// Call this at 60Hz to update the keyboard state.
func (kb *SDLKeyboard) PollEvents() {
for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
if kbdEvent, ok := event.(*sdl.KeyboardEvent); ok {
kb.HandleKeyboard(kbdEvent)
}
}
}
// HandleKeyboard updates the keyboard state based on an SDL keyboard event.
// It maps PC keyboard keys to CHIP-8 hex keys (0-F) and tracks press/release state.
//
// The key mapping follows the standard CHIP-8 layout:
//
// PC Key | CHIP-8
// --------|--------
// 1 2 3 4 | 1 2 3 C
// q w e r | 4 5 6 D
// a s d f | 7 8 9 E
// z x c v | A 0 B F
//
// Parameters:
// - event: pointer to an SDL KeyboardEvent (key press or release)
func (kb *SDLKeyboard) HandleKeyboard(event *sdl.KeyboardEvent) {
keyCode := event.Keysym.Sym
// Determine if this is a key press (true) or release (false)
isPressed := event.Type == sdl.KEYDOWN
// Map PC keyboard keys to CHIP-8 key indices
mapping := map[sdl.Keycode]byte{
sdl.Keycode(sdl.K_1): 0x1, sdl.Keycode(sdl.K_2): 0x2, sdl.Keycode(sdl.K_3): 0x3, sdl.Keycode(sdl.K_4): 0xC,
sdl.Keycode(sdl.K_q): 0x4, sdl.Keycode(sdl.K_w): 0x5, sdl.Keycode(sdl.K_e): 0x6, sdl.Keycode(sdl.K_r): 0xD,
sdl.Keycode(sdl.K_a): 0x7, sdl.Keycode(sdl.K_s): 0x8, sdl.Keycode(sdl.K_d): 0x9, sdl.Keycode(sdl.K_f): 0xE,
sdl.Keycode(sdl.K_z): 0xA, sdl.Keycode(sdl.K_x): 0x0, sdl.Keycode(sdl.K_c): 0xB, sdl.Keycode(sdl.K_v): 0xF,
}
// Update the key state if the PC key maps to a CHIP-8 key
if chipKey, ok := mapping[keyCode]; ok {
kb.Keys[chipKey] = isPressed
}
}
Holds an array of 16 booleans representing the pressed state of CHIP-8 keys 0-F.
Creates a new SDLKeyboard with all keys initialized to released.
Returns true if the specified key (0-15) is currently pressed.
Returns the first pressed key. Used by LD Vx, K for blocking key wait.
Sets the state of a key. Validates key is 0-15.
Polls SDL for keyboard events and calls HandleKeyboard for each.
Maps PC keys (1,2,3,4,Q,W,E,R,...) to CHIP-8 keys (1,2,3,C,4,5,6,D,...) and updates state.
// Package audio provides audio output implementations for the CHIP-8 emulator.
// Implement the Audio interface to provide sound for different platforms.
package audio
// Audio represents the audio subsystem for the CHIP-8 emulator.
// Implement this interface to provide audio output for different platforms
// (e.g., SDL2, JavaScript/WebAudio, etc.).
type Audio interface {
// Init initializes the audio subsystem.
// Returns an error if initialization fails.
Init() error
// Play starts/resumes audio playback.
Play() error
// Pause stops audio playback.
Pause() error
// Close releases audio resources.
// Should be safe to call multiple times.
Close() error
}
A go interface for audio output. CHIP-8 has a simple beep sound.
Initializes the audio subsystem.
Start or stop the beep. Called based on the sound timer.
Releases audio resources.
//go:build !wasm || !js
package audio
import (
"unsafe"
"github.com/veandco/go-sdl2/sdl"
)
const (
// SampleRate is the audio sample rate in Hz.
SampleRate = 44100
// Frequency is the beep frequency in Hz (440Hz = standard A4 pitch).
Frequency = 440.0
)
// SDLAudio manages sound output for the CHIP-8 emulator.
// It generates square wave beeps using SDL2 audio devices.
type SDLAudio struct {
// Device is the SDL audio device ID used for sound output.
Device sdl.AudioDeviceID
}
// New creates a new SDLAudio instance with an uninitialized audio device.
// Call Init() before use to open the audio device.
func WithSDL() *SDLAudio {
return new(SDLAudio)
}
// Init opens the default audio device and configures it for sound output.
// It sets up mono audio at 44.1kHz with 16-bit signed samples and 2048 sample buffer.
// Returns an error if the audio device cannot be opened.
//
// Note: On systems without audio hardware, this may return an error but the
// emulator should continue to run without sound.
func (a *SDLAudio) Init() error {
spec := &sdl.AudioSpec{
Freq: SampleRate,
Format: sdl.AUDIO_S16SYS,
Channels: 1,
Samples: 2048,
}
// Open the default audio device
dev, err := sdl.OpenAudioDevice("", false, spec, nil, 0)
if err != nil {
return err
}
a.Device = dev
return nil
}
// Play unpauses the audio device to resume sound output.
// Use this when the sound timer is greater than 0 to start/beep.
func (a *SDLAudio) Play() error {
if err := a.generateBeep(); err != nil {
return err
}
sdl.PauseAudioDevice(a.Device, false)
return nil
}
// Pause pauses the audio device to stop sound output.
// Use this when the sound timer reaches 0 to silence the beep.
func (a *SDLAudio) Pause() error {
sdl.PauseAudioDevice(a.Device, true)
return nil
}
// Close stops and releases the audio device resources.
// It first silences the device by pausing, then closes the SDL audio device,
// and finally resets the device ID to 0. Safe to call multiple times.
func (a *SDLAudio) Close() error {
if a.Device != 0 {
sdl.PauseAudioDevice(a.Device, true) // Silence first
sdl.CloseAudioDevice(a.Device)
a.Device = 0 // Reset ID
}
return nil
}
// generateBeep generates a 440Hz square wave tone and queues it to the audio buffer.
// The tone plays for approximately 1 second (one full cycle at SampleRate).
// It returns early if there's already sufficient audio queued to avoid buffering overflow.
//
// A square wave alternates between positive and negative amplitude:
// - First half of period: +3000 (high)
// - Second half of period: -3000 (low)
func (a *SDLAudio) generateBeep() error {
// Check if sufficient audio is already queued
if sdl.GetQueuedAudioSize(a.Device) >= 4096 {
return nil
}
// Generate samples for 1 second of audio
length := SampleRate
data := make([]int16, length)
period := SampleRate / int(Frequency)
// Generate square wave: alternate between +3000 and -3000
for i := range length {
if i%period < (period / 2) {
data[i] = 3000
} else {
data[i] = -3000
}
}
// Convert int16 samples to bytes for SDL
byteLen := len(data) * 2
byteData := unsafe.Slice((*byte)(unsafe.Pointer(&data[0])), byteLen)
// Queue the audio data for playback
if err := sdl.QueueAudio(a.Device, byteData); err != nil {
return err
}
return nil
}
44.1kHz sample rate, 440Hz beep frequency (standard A4 pitch).
Holds the SDL audio device ID.
Creates the SDLAudio for Audio interface for SDL2.
Opens default audio device with mono, 16-bit, 2048 sample buffer.
Calls generateBeep() to create audio data, then unpauses the device.
Pauses the audio device to stop sound.
Pauses, closes the device, resets ID. Safe to call multiple times.
Generates a square wave at 440Hz: alternates between +3000 and -3000 amplitude.
// Package assembler provides a two-pass assembler for converting CHIP-8 assembly source code into executable bytecode.
package assembler
import (
"github.com/arpitchakladar/chip-8/internal/assembler/lexer"
"github.com/arpitchakladar/chip-8/internal/assembler/parser"
)
// Assembler converts CHIP-8 assembly source code into executable bytecode.
// It uses a two-pass pipeline: lexer scans for labels, then parser generates opcodes.
type Assembler struct {
// Source contains the raw assembly source code.
Source string
// Labels maps label names to their memory addresses.
Labels map[string]uint16
// ProgramCounter tracks the current address during assembly.
ProgramCounter uint16
}
// New creates a new Assembler with the given source code.
// The ProgramCounter starts at 0x200 (CHIP-8 program start address).
// The Labels map is initialized empty and will be populated by the lexer during Assemble().
func New(source string) *Assembler {
return &Assembler{
Source: source,
Labels: make(map[string]uint16),
ProgramCounter: 0x200, // Program start address
}
}
// Assemble processes the source code and returns the compiled bytecode.
// It performs a two-pass assembly:
//
// First pass (lexer):
// - Scans for labels and builds a label-to-address map
// - Collects all instruction lines
// - Validates that __START and __END markers are present
//
// Second pass (parser):
// - Converts each instruction to its binary opcode
// - Resolves label references to their addresses
// - Validates register indices and immediate values
//
// Returns:
// - []byte: the compiled bytecode ready to be written to a .ch8 file
// - error: if either pass fails (lexer or parser error)
func (a *Assembler) Assemble() ([]byte, error) {
// First pass: Lexer scans for labels
lexer := lexer.New(a.Source, a.ProgramCounter)
labels, lines, err := lexer.Lex()
if err != nil {
return nil, err
}
// Initialize the program bytecode
var program []byte
// Second pass: Parser converts instructions to opcodes
parser := parser.New(labels)
// Process each instruction line from the lexer
for _, line := range lines {
opcode, err := parser.Parse(line.Mnemonic, line.Args, line.LineNumber)
if err != nil {
return nil, err
}
program = append(program, opcode...)
}
return program, nil
}
Read the assembly file and get all the labels along with their addresses, and also each line stripping all comments.
We create the parser by passing the labels along with their corresponding addresses. The parser needs access to them for each line.
Now we can pass each line to the parser and it will give us a byte array, containing the opcode (in case of instruction) or raw binary data (in case of DB or DW).
We then use the byte array returned by the parser and just append it to our program, which is also a byte array.
// Package lexer provides tokenization and label resolution for the CHIP-8 assembler.
// It performs the first pass of assembly, scanning for labels and building a label-to-address map.
package lexer
import "strings"
// Lexer tokenizes CHIP-8 assembly source code and performs the first pass of assembly.
// It scans for labels and collects them into a map for the parser to use.
// It holds the state for tokenizing assembly source code.
type Lexer struct {
// Source is the raw assembly source code.
Source string
// CurrentAddr tracks the current address during scanning.
CurrentAddr uint16
}
// Line represents a single instruction line parsed from the source.
type Line struct {
// Mnemonic is the instruction name (e.g., "LD", "JP", "ADD").
Mnemonic string
// Args contains the instruction arguments.
Args []string
// Address is the memory address where this instruction will be placed.
Address uint16
// LineNumber is the original source line number (for error reporting).
LineNumber uint16
}
// New creates a new Lexer with the given source code and starting address.
func New(source string, currentAddr uint16) *Lexer {
return &Lexer{
Source: source,
CurrentAddr: currentAddr,
}
}
// Lex performs the first pass of assembly.
// It scans the source code for labels and builds a map of label names to their addresses.
// It also collects all instruction lines for the parser to process in the second pass.
//
// The function enforces the following rules:
// - __START must be defined before any instructions
// - __END must be defined after all instructions
// - Both __START and __END markers are required
//
// Returns:
// - labels: map of label name -> memory address
// - program: list of instruction lines to be parsed
// - error: if any validation fails
func (l *Lexer) Lex() (map[string]uint16, []Line, error) {
labels := make(map[string]uint16)
var program []Line
i := uint16(0)
seenStart := false
seenEnd := false
for raw := range strings.SplitSeq(l.Source, "\n") {
i++
content := strings.Split(raw, ";")[0]
content = strings.TrimSpace(content)
if content == "" {
continue
}
if label, found := strings.CutSuffix(content, ":"); found {
seenStart, seenEnd = l.processLabel(
label,
seenStart,
seenEnd,
labels,
)
continue
}
if !seenStart {
return nil, nil, &StartAfterCodeError{LineNumber: i}
}
if seenEnd {
return nil, nil, &EndAfterCodeError{LineNumber: i}
}
parts := strings.Fields(strings.ReplaceAll(content, ",", " "))
if len(parts) > 0 {
mnemonic := strings.ToUpper(parts[0])
program = append(program, Line{
Mnemonic: mnemonic,
Args: parts[1:],
Address: l.CurrentAddr,
LineNumber: i,
})
if mnemonic != "DB" {
l.CurrentAddr += 2
} else {
l.CurrentAddr++
}
}
}
if !seenStart {
return nil, nil, &MissingStartLabelError{}
}
if !seenEnd {
return nil, nil, &MissingEndLabelError{}
}
return labels, program, nil
}
func (l *Lexer) processLabel(
label string,
seenStart, seenEnd bool,
labels map[string]uint16,
) (bool, bool) {
switch label {
case "__START":
labels[label] = l.CurrentAddr
return true, seenEnd
case "__END":
labels[label] = l.CurrentAddr
return seenStart, true
default:
labels[label] = l.CurrentAddr
return seenStart, seenEnd
}
}
The lexer returns a map of labels to addresses and a slice of Line structs containing the mnemonic and arguments.
Everything after ; is treated as a comment and stripped away before processing.
Lines ending with : are labels. The current address is stored for that label.
For non-label lines, we split by whitespace and comma to get the mnemonic and arguments.
Each line becomes a Line struct with mnemonic, args, address, and line number for error reporting.
Most instructions are 2 bytes, but DB (define byte) is only 1 byte. The address counter is incremented accordingly.
We are using __START and __END for denoting the starting and ending of our assembly code. This is done as we are not using a linker.
// Package encoder provides opcode encoding utilities for the CHIP-8 assembler.
// It builds 16-bit opcodes from instruction components using bit masks.
package encoder
// Encoder builds CHIP-8 opcodes from instruction components.
// It provides methods for encoding different instruction formats using bit masks.
// Instruction represents a 16-bit CHIP-8 opcode.
type Instruction uint16
// Encoder builds CHIP-8 opcodes from their component parts.
type Encoder struct{}
// New creates a new instance of the Encoder.
func New() *Encoder {
return new(Encoder)
}
// Instruction Masks (Constants associated with the Encoder logic).
const (
MaskCLS uint16 = 0x00E0
MaskRET uint16 = 0x00EE
MaskJP uint16 = 0x1000
MaskCALL uint16 = 0x2000
MaskSE uint16 = 0x3000
MaskSNE uint16 = 0x4000
MaskSER uint16 = 0x5000
MaskLD uint16 = 0x6000
MaskADD uint16 = 0x7000
MaskALU uint16 = 0x8000
MaskSNER uint16 = 0x9000
MaskLDI uint16 = 0xA000
MaskJPV0 uint16 = 0xB000
MaskRND uint16 = 0xC000
MaskDRW uint16 = 0xD000
MaskKEY uint16 = 0xE000
MaskMISC uint16 = 0xF000
)
// Addr handles instructions with a 12-bit address (nnn).
// Used by: JP (1nnn), CALL (2nnn), LD I (Annn), JP V0 (Bnnn).
func (e *Encoder) Addr(prefix uint16, addr uint16) uint16 {
return prefix | (addr & 0x0FFF)
}
// RegImm handles instructions with a register and an 8-bit immediate (xkk).
// Used by: SE (3xkk), SNE (4xkk), LD (6xkk), ADD (7xkk).
func (e *Encoder) RegImm(prefix uint16, vx uint8, valueByte uint8) uint16 {
return prefix | (uint16(vx&0xF) << 8) | uint16(valueByte)
}
// RegReg handles instructions with two registers (xy).
// Used by: SE (5xy0), LD (8xy0), ALU operations (8xy1-E), SNE (9xy0).
func (e *Encoder) RegReg(
prefix uint16,
vx uint8,
vy uint8,
suffix uint16,
) uint16 {
return prefix | (uint16(vx&0xF) << 8) | (uint16(vy&0xF) << 4) | (suffix & 0xF)
}
// RegNibble handles instructions with two registers and a 4-bit nibble (xyn).
// Used by: DRW (Dxyn).
func (e *Encoder) RegNibble(prefix uint16, vx uint8, vy uint8, n uint8) uint16 {
return prefix | (uint16(vx&0xF) << 8) | (uint16(vy&0xF) << 4) | uint16(
n&0xF,
)
}
// RegOnly handles instructions that only specify one register (x).
// Used by: SKP (Ex9E), SKNP (ExA1), etc.
func (e *Encoder) RegOnly(prefix uint16, vx uint8, suffix uint16) uint16 {
return prefix | (uint16(vx&0xF) << 8) | (suffix & 0xFF)
}
// Raw returns instructions that have no variables.
// Used by: CLS (00E0), RET (00EE).
func (e *Encoder) Raw(opcode uint16) uint16 {
return opcode
}
The encoder defines constant masks for each instruction type. These are the fixed bits that identify the opcode.
Used by JP, CALL, LD I, JP V0 - the address is masked to 12 bits (0x0FFF) and OR'd with the prefix.
Used by SE, SNE, LD, ADD - the register goes in bits 8-11, the immediate value in bits 0-7.
Used by ALU ops and register-register comparisons - Vx in bits 8-11, Vy in bits 4-7, suffix in bits 0-3.
DRW uses Vx, Vy, and a 4-bit nibble (n) for sprite height.
Used by SKP, SKNP, and various LD variants - register in bits 8-11, suffix/constant in lower byte.
CLS and RET have no variables - the mask is the entire opcode.
// Package parser provides opcode generation for the CHIP-8 assembler.
// It performs the second pass of assembly, converting instructions to binary opcodes.
package parser
import (
"encoding/binary"
"strconv"
"strings"
"github.com/arpitchakladar/chip-8/internal/assembler/encoder"
)
// Parser converts CHIP-8 assembly instructions into binary opcodes.
// It uses the label map from the lexer to resolve label references.
type Parser struct {
// Labels maps label names to their memory addresses (from lexer).
Labels map[string]uint16
// Encoder builds the binary opcode representations.
Encoder *encoder.Encoder
}
// New creates a new Parser with the given label map.
func New(labels map[string]uint16) *Parser {
return &Parser{
Labels: labels,
Encoder: encoder.New(),
}
}
// Parse converts a single assembly instruction into its binary opcode representation.
// It resolves labels to their addresses and validates arguments.
//
// The function handles all CHIP-8 opcodes:
// - Flow control: CLS, RET, JP, CALL
// - Conditional: SE, SNE
// - Arithmetic: ADD, SUB, SUBN, AND, OR, XOR, SHR, SHL
// - Memory: LD (various forms)
// - Display: DRW
// - Input: SKP, SKNP
// - Random: RND
// - Data: DB (1-byte), DW (2-byte)
//
// Arguments:
// - mnemonic: the instruction name (e.g., "LD", "JP")
// - args: the instruction arguments (e.g., ["V0", "0x10"])
// - line: the source line number for error reporting
//
// Returns:
// - []byte: the binary opcode (2 bytes for most instructions, 1 for DB)
// - error: if the mnemonic is unknown, arguments are invalid, or values are out of range
func (p *Parser) Parse(
mnemonic string,
args []string,
line uint16,
) ([]byte, error) {
upperMnemonic := strings.ToUpper(mnemonic)
if mask, ok := simpleHandlers[upperMnemonic]; ok {
return p.toBinary(p.Encoder.Raw(mask)), nil
}
return p.dispatchParse(upperMnemonic, args, line)
}
var simpleHandlers = map[string]uint16{
"CLS": encoder.MaskCLS,
"RET": encoder.MaskRET,
}
type parseHandler func(*Parser, string, []string, uint16) ([]byte, error)
var mnemonicHandlers = map[string]parseHandler{
"JP": (*Parser).handleJP,
"CALL": (*Parser).handleCall,
"SE": (*Parser).handleSkipOp,
"SNE": (*Parser).handleSkipOp,
"ADD": (*Parser).handleAdd,
"OR": (*Parser).handleALU,
"AND": (*Parser).handleALU,
"XOR": (*Parser).handleALU,
"SUB": (*Parser).handleALU,
"SHR": (*Parser).handleALU,
"SUBN": (*Parser).handleALU,
"SHL": (*Parser).handleALU,
"LD": (*Parser).handleLoad,
"RND": (*Parser).handleRND,
"DRW": (*Parser).handleDRW,
"SKP": (*Parser).handleKeySkip,
"SKNP": (*Parser).handleKeySkip,
"DW": (*Parser).handleData,
"DB": (*Parser).handleData,
}
func (p *Parser) dispatchParse(
upperMnemonic string,
args []string,
line uint16,
) ([]byte, error) {
if handler, ok := mnemonicHandlers[upperMnemonic]; ok {
return handler(p, upperMnemonic, args, line)
}
return nil, p.parseErr(
upperMnemonic,
args,
line,
&UnknownMnemonicError{upperMnemonic, line},
)
}
func (p *Parser) handleJP(
mnemonic string,
args []string,
line uint16,
) ([]byte, error) {
switch len(args) {
case 2:
if strings.ToUpper(args[0]) == "V0" {
addr, err := p.resolveValue(args[1], mnemonic, line, 12)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
return p.toBinary(p.Encoder.Addr(encoder.MaskJPV0, addr)), nil
}
fallthrough
case 1:
addr, err := p.resolveValue(args[0], mnemonic, line, 12)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
return p.toBinary(p.Encoder.Addr(encoder.MaskJP, addr)), nil
default:
return nil, p.parseErr(
mnemonic,
args,
line,
&WrongArgCountError{mnemonic, line, 1, len(args)},
)
}
}
func (p *Parser) handleCall(
mnemonic string,
args []string,
line uint16,
) ([]byte, error) {
if len(args) != 1 {
return nil, p.parseErr(
mnemonic,
args,
line,
&WrongArgCountError{mnemonic, line, 1, len(args)},
)
}
addr, err := p.resolveValue(args[0], mnemonic, line, 12)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
return p.toBinary(p.Encoder.Addr(encoder.MaskCALL, addr)), nil
}
func (p *Parser) handleSkipOp(
mnemonic string,
args []string,
line uint16,
) ([]byte, error) {
immBase, regBase := encoder.MaskSE, encoder.MaskSER
if mnemonic == "SNE" {
immBase, regBase = encoder.MaskSNE, encoder.MaskSNER
}
res, err := p.handleSkip(immBase, regBase, mnemonic, args, line)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
return res, nil
}
func (p *Parser) handleAdd(
mnemonic string,
args []string,
line uint16,
) ([]byte, error) {
if len(args) != 2 {
return nil, p.parseErr(
mnemonic,
args,
line,
&WrongArgCountError{mnemonic, line, 2, len(args)},
)
}
if args[0] == "I" {
vx, err := p.parseReg(args[1], mnemonic, line)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x1E)), nil
}
if p.isRegister(args[1]) {
return p.handleRegReg(
encoder.MaskALU,
mnemonic,
args,
0x4,
line,
)
}
vx, err := p.parseReg(args[0], mnemonic, line)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
val, err := p.resolveValue(args[1], mnemonic, line, 8)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
return p.toBinary(p.Encoder.RegImm(encoder.MaskADD, vx, uint8(val))), nil
}
func (p *Parser) handleALU(
mnemonic string,
args []string,
line uint16,
) ([]byte, error) {
suffixes := map[string]uint16{
"OR": 0x1, "AND": 0x2, "XOR": 0x3,
"SUB": 0x5, "SHR": 0x6, "SUBN": 0x7, "SHL": 0xE,
}
suffix, ok := suffixes[mnemonic]
if !ok {
return nil, p.parseErr(
mnemonic,
args,
line,
&UnknownMnemonicError{mnemonic, 0},
)
}
return p.handleRegReg(encoder.MaskALU, mnemonic, args, suffix, line)
}
func (p *Parser) handleRND(
mnemonic string,
args []string,
line uint16,
) ([]byte, error) {
if len(args) != 2 {
return nil, p.parseErr(
mnemonic,
args,
line,
&WrongArgCountError{mnemonic, line, 2, len(args)},
)
}
vx, err := p.parseReg(args[0], mnemonic, line)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
val, err := p.resolveValue(args[1], mnemonic, line, 8)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
return p.toBinary(p.Encoder.RegImm(encoder.MaskRND, vx, uint8(val))), nil
}
func (p *Parser) handleDRW(
mnemonic string,
args []string,
line uint16,
) ([]byte, error) {
if len(args) != 3 {
return nil, p.parseErr(
mnemonic,
args,
line,
&WrongArgCountError{mnemonic, line, 3, len(args)},
)
}
vx, err := p.parseReg(args[0], mnemonic, line)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
vy, err := p.parseReg(args[1], mnemonic, line)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
n, err := p.resolveValue(args[2], mnemonic, line, 4)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
return p.toBinary(
p.Encoder.RegNibble(encoder.MaskDRW, vx, vy, uint8(n)),
), nil
}
func (p *Parser) handleKeySkip(
mnemonic string,
args []string,
line uint16,
) ([]byte, error) {
if len(args) != 1 {
return nil, p.parseErr(
mnemonic,
args,
line,
&WrongArgCountError{mnemonic, line, 1, len(args)},
)
}
vx, err := p.parseReg(args[0], mnemonic, line)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
var opcode uint16
if mnemonic == "SKNP" {
opcode = 0xA1
} else {
opcode = 0x9E
}
return p.toBinary(p.Encoder.RegOnly(encoder.MaskKEY, vx, opcode)), nil
}
func (p *Parser) handleData(
mnemonic string,
args []string,
line uint16,
) ([]byte, error) {
if len(args) != 1 {
return nil, p.parseErr(
mnemonic,
args,
line,
&WrongArgCountError{mnemonic, line, 1, len(args)},
)
}
bits := 8
if mnemonic == "DW" {
bits = 16
}
val, err := p.resolveValue(args[0], mnemonic, line, bits)
if err != nil {
return nil, p.parseErr(mnemonic, args, line, err)
}
if bits == 16 {
return p.toBinary(val), nil
}
return []byte{byte(val)}, nil
}
// --- Helper Handlers ---
// handleLoad processes LD (load) instructions with various source/destination combinations.
func (p *Parser) handleLoad(
_ string,
args []string,
line uint16,
) ([]byte, error) {
if len(args) != 2 {
return nil, &WrongArgCountError{"LD", line, 2, len(args)}
}
dst, src := args[0], args[1]
if dst == "I" {
addr, err := p.resolveValue(src, "LD", line, 12)
if err != nil {
return nil, err
}
return p.toBinary(p.Encoder.Addr(encoder.MaskLDI, addr)), nil
}
if p.isRegister(dst) {
return p.handleLoadVxSrc(dst, src, line)
}
if p.isRegister(src) {
return p.handleLoadDstVx(dst, src, line)
}
return nil, &InvalidLoadError{line, dst, src}
}
func (p *Parser) handleLoadVxSrc(dst, src string, line uint16) ([]byte, error) {
vx, err := p.parseReg(dst, "LD", line)
if err != nil {
return nil, err
}
switch {
case src == "DT":
return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x07)), nil
case src == "K":
return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x0A)), nil
case p.isRegister(src):
vy, err := p.parseReg(src, "LD", line)
if err != nil {
return nil, err
}
return p.toBinary(p.Encoder.RegReg(encoder.MaskALU, vx, vy, 0x0)), nil
case src == "[I]":
return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x65)), nil
default:
val, err := p.resolveValue(src, "LD", line, 8)
if err != nil {
return nil, err
}
return p.toBinary(p.Encoder.RegImm(encoder.MaskLD, vx, uint8(val))), nil
}
}
func (p *Parser) handleLoadDstVx(dst, src string, line uint16) ([]byte, error) {
vx, err := p.parseReg(src, "LD", line)
if err != nil {
return nil, err
}
switch dst {
case "DT":
return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x15)), nil
case "ST":
return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x18)), nil
case "F":
return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x29)), nil
case "B":
return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x33)), nil
case "[I]":
return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x55)), nil
}
return nil, &InvalidLoadError{line, dst, src}
}
// handleSkip processes SE (skip if equal) and SNE (skip if not equal) instructions.
// It handles both register-to-immediate and register-to-register comparisons.
func (p *Parser) handleSkip(
immBase, regBase uint16,
mnemonic string,
args []string,
line uint16,
) ([]byte, error) {
if len(args) != 2 {
return nil, &WrongArgCountError{mnemonic, line, 2, len(args)}
}
vx, err := p.parseReg(args[0], mnemonic, line)
if err != nil {
return nil, err
}
if p.isRegister(args[1]) {
vy, err := p.parseReg(args[1], mnemonic, line)
if err != nil {
return nil, err
}
return p.toBinary(p.Encoder.RegReg(regBase, vx, vy, 0x0)), nil
}
val, err := p.resolveValue(args[1], mnemonic, line, 8)
if err != nil {
return nil, err
}
return p.toBinary(p.Encoder.RegImm(immBase, vx, uint8(val))), nil
}
// handleRegReg processes two-register arithmetic/logic instructions (OR, AND, XOR, SUB, etc.).
func (p *Parser) handleRegReg(
base uint16,
mnemonic string,
args []string,
suffix uint16,
line uint16,
) ([]byte, error) {
if len(args) != 2 {
return nil, &WrongArgCountError{mnemonic, line, 2, len(args)}
}
vx, err := p.parseReg(args[0], mnemonic, line)
if err != nil {
return nil, err
}
vy, err := p.parseReg(args[1], mnemonic, line)
if err != nil {
return nil, err
}
return p.toBinary(p.Encoder.RegReg(base, vx, vy, suffix)), nil
}
// --- Utility Functions ---
// toBinary converts a 16-bit opcode to a 2-byte slice in big-endian format.
func (p *Parser) toBinary(opcode uint16) []byte {
buf := make([]byte, 2)
binary.BigEndian.PutUint16(buf, opcode)
return buf
}
// isRegister returns true if the string looks like a register (e.g., "V0", "VF").
func (p *Parser) isRegister(s string) bool {
s = strings.ToUpper(s)
return len(s) >= 2 && s[0] == 'V'
}
// parseReg parses a register string (Vx) and returns the register index.
// It returns an error if the string is not a valid register.
func (p *Parser) parseReg(
s string,
mnemonic string,
line uint16,
) (uint8, error) {
if !p.isRegister(s) {
return 0, &InvalidRegisterError{mnemonic, line, s}
}
val, err := strconv.ParseUint(s[1:], 16, 8)
if err != nil || val > 0xF {
return 0, &InvalidRegisterError{mnemonic, line, s}
}
return uint8(val), nil
}
// resolveValue resolves a string to a numeric value.
// It first checks the label map, then tries to parse as a literal (hex or decimal).
// The bits parameter specifies the maximum bit width for range checking.
func (p *Parser) resolveValue(
s string,
mnemonic string,
line uint16,
bits int,
) (uint16, error) {
var val uint16
var ok bool
// 1. Try Labels
if val, ok = p.Labels[s]; !ok {
// 2. Try Literal (Hex or Dec)
clean := strings.ReplaceAll(
strings.ReplaceAll(strings.ReplaceAll(s, "0X", ""), "0x", ""),
"$",
"",
)
base := 10
if strings.Contains(strings.ToUpper(s), "0X") ||
strings.Contains(s, "$") {
base = 16
}
v64, err := strconv.ParseUint(clean, base, 16)
if err != nil {
// If it's not a number and not in labels, it's an unresolved label
// (assuming labels don't start with numbers)
if base == 10 && (s[0] < '0' || s[0] > '9') {
return 0, &UnresolvedLabelError{mnemonic, line, s}
}
return 0, &InvalidImmediateError{mnemonic, line, s, err}
}
val = uint16(v64)
}
// 3. Range Check
maxValue := (uint32(1) << bits) - 1
if uint32(val) > maxValue {
return 0, &ImmediateOutOfRangeError{mnemonic, line, s, val, bits}
}
return val, nil
}
// parseErr wraps a child error into a ParseError with context about the failing instruction.
func (p *Parser) parseErr(
mnemonic string,
args []string,
line uint16,
child error,
) error {
// If it's already a ParseError, don't double wrap
if _, ok := child.(*ParseError); ok {
return child
}
return &ParseError{
Mnemonic: mnemonic,
Args: args,
LineNumber: line,
Child: child,
}
}
The Parse method takes a mnemonic and arg and dispatches to the appropriate handler.
A big switch statement routes each mnemonic to its handler.
const canvas = document.getElementById("canvas");
const vm = await chip_8.Emulator(canvas, 500);
const response = await fetch("snake.asm");
const asmCode = await response.text();
const assembler = await chip_8.Assembler(asmCode);
const romData = await assembler.assemble();
await vm.loadROM(romData);
vm.run();
// To make the emulator handle keyboard inputs automatically
await vm.handleKeyboard();
Creates the emulator with a JavaScript Canvas.
Fetching the assembly source code.
Creating an assembler passing the source. And then having it assemble out final ROM binary.
Load the ROM into the VM and run.
The emulator will automatically capture the keyboard and listen to keyboard events if told to do so. It can also me made to stop handing keyboard events.
//go:build wasm && js
// Package emulator provides the core CHIP-8 emulator functionality.
package emulator
import (
"sync"
"syscall/js"
"github.com/arpitchakladar/chip-8/internal/emulator/audio"
"github.com/arpitchakladar/chip-8/internal/emulator/cpu"
"github.com/arpitchakladar/chip-8/internal/emulator/display"
"github.com/arpitchakladar/chip-8/internal/emulator/keyboard"
"github.com/arpitchakladar/chip-8/internal/emulator/memory"
)
const (
// MaxTickRate is limited to 250Hz in WASM due to browser timer resolution.
// runCPU uses batch execution to achieve higher effective clock speeds.
MaxTickRate = 250
)
// WithWASM creates a new Emulator configured for WebAssembly/JavaScript execution.
// It uses HTML5 Canvas for display and Web Audio API for sound via the WASM implementations.
//
// Parameters:
// - canvas: A JavaScript canvas element reference for rendering graphics
// - clockSpeed: CPU instructions per second (e.g., 100000 for 100kHz)
//
// Returns a configured Emulator ready to load and run CHIP-8 ROMs.
func WithWASM(canvas js.Value, clockSpeed uint32) *Emulator {
e := &Emulator{
CPU: cpu.New(),
Memory: memory.New(),
Display: display.WithWASM(canvas),
Keyboard: keyboard.WithWASM(),
Audio: audio.WithWASM(),
memoryLock: sync.Mutex{},
ClockSpeed: clockSpeed,
}
e.Memory.LoadFontSet()
e.CPU.ProgramCounter = ProgramStart
return e
}
WithWASM takes a canvas element and clock speed and creates the emulator with WASM-specific display, keyboard, and audio.
WASM has a MaxTickRate of 250Hz due to browser timer resolution limitations. The CPU loop uses batch execution to achieve higher effective clock speeds.
//go:build wasm && js
// Package display provides a WebAssembly-compatible display implementation
// using the HTML5 Canvas API.
package display
import (
"fmt"
"syscall/js"
)
// WASMDisplay implements the Display interface for WebAssembly/JS environments.
// It uses an HTML5 Canvas element to render CHIP-8 graphics through the 2D context.
type WASMDisplay struct {
buffer *DisplayBuffer
Canvas js.Value
ctx js.Value
imageData js.Value
data []byte
}
// WithWASM creates a new Display that uses an HTML5 Canvas for rendering.
// The canvas parameter should be a JavaScript canvas element reference.
func WithWASM(canvas js.Value) Display {
return &WASMDisplay{
buffer: NewDisplayBuffer(),
Canvas: canvas,
}
}
// Init initializes the WASM display.
// It gets the 2D rendering context, sets the canvas to native 64x32 resolution,
// preserves the original canvas dimensions via CSS, and pre-allocates the pixel buffer.
func (d *WASMDisplay) Init() error {
if d.Canvas.IsNull() || d.Canvas.IsUndefined() {
return nil
}
d.ctx = d.Canvas.Call("getContext", "2d")
if d.ctx.IsNull() || d.ctx.IsUndefined() {
return nil
}
canvasWidth := d.Canvas.Get("width").Int()
canvasHeight := d.Canvas.Get("height").Int()
d.Canvas.Set("width", Width)
d.Canvas.Set("height", Height)
style := d.Canvas.Get("style")
style.Set("width", js.ValueOf(fmt.Sprintf("%dpx", canvasWidth)))
style.Set("height", js.ValueOf(fmt.Sprintf("%dpx", canvasHeight)))
style.Set("image-rendering", js.ValueOf("pixelated"))
d.imageData = d.ctx.Call("createImageData", Width, Height)
d.data = make([]byte, Width*Height*4)
return nil
}
func (d *WASMDisplay) Clear() {
d.buffer.Clear()
}
// SetPixel delegates to the display buffer for XOR pixel drawing.
// Returns true if the pixel was already on (collision detection for sprites).
func (d *WASMDisplay) SetPixel(x, y uint8) (bool, error) {
return d.buffer.SetPixel(x, y)
}
// Present renders the current display buffer to the HTML5 Canvas.
// Uses ImageData for fast pixel rendering.
func (d *WASMDisplay) Present() error {
if d.ctx.IsNull() || d.ctx.IsUndefined() {
return nil
}
pixels := d.buffer.GetPixels()
for i, val := range pixels {
offset := i * 4
if val == 1 {
d.data[offset] = 255
d.data[offset+1] = 255
d.data[offset+2] = 255
d.data[offset+3] = 255
} else {
d.data[offset] = 0
d.data[offset+1] = 0
d.data[offset+2] = 0
d.data[offset+3] = 255
}
}
jsData := d.imageData.Get("data")
js.CopyBytesToJS(jsData, d.data)
d.ctx.Call("putImageData", d.imageData, 0, 0)
return nil
}
func (d *WASMDisplay) Close() error {
return nil
}
func (d *WASMDisplay) GetPixels() []byte {
return d.buffer.GetPixels()
}
Holds pixel buffer, cached 2D context, ImageData, and pre-allocated byte slice for RGBA data.
Takes a canvas element from JavaScript and creates the display.
Gets 2D context, sets canvas to native 64x32, preserves dimensions via CSS, pre-allocates pixel buffer.
Writes RGBA to byte slice, uses js.CopyBytesToJS to copy to ImageData, calls putImageData once per frame (fast).
//go:build wasm && js
// Package keyboard provides a WebAssembly-compatible keyboard implementation.
package keyboard
// WASMKeyboard implements the Keyboard interface for WebAssembly/JS environments.
// Key state is managed through SetKey() which should be connected to JavaScript
// keydown/keyup event handlers. Supports CHIP-8's standard 16-key layout (0-F).
type WASMKeyboard struct {
Keys [16]bool // State of each of the 16 keys (true = pressed)
}
// WithWASM creates a new Keyboard implementation for WebAssembly.
// Returns a WASMKeyboard that must be connected to DOM event handlers.
func WithWASM() Keyboard {
return &WASMKeyboard{}
}
// IsKeyPressed returns true if the specified key is currently pressed.
// The key parameter should be a value from 0-15 representing CHIP-8 keys.
func (kb *WASMKeyboard) IsKeyPressed(key byte) bool {
return key <= 15 && kb.Keys[key]
}
// AnyKeyPressed returns the first pressed key and true if any key is pressed.
// Used for CHIP-8's wait-for-key instruction (FX0A).
// Returns (0, false) if no keys are pressed.
func (kb *WASMKeyboard) AnyKeyPressed() (byte, bool) {
for i, isPressed := range kb.Keys {
if isPressed {
return byte(i), true
}
}
return 0, false
}
// SetKey updates the pressed state of a key.
// The key parameter should be 0-15, pressed should be true for keydown, false for keyup.
func (kb *WASMKeyboard) SetKey(key byte, pressed bool) {
if key <= 15 {
kb.Keys[key] = pressed
}
}
// PollEvents is a no-op for WASM keyboard since events are pushed directly via SetKey.
// Kept for interface compatibility with SDL keyboard implementation.
func (kb *WASMKeyboard) PollEvents() {}
Simple array of 16 booleans for key states.
Creates a new WASMKeyboard (must be connected to JS event handlers).
Same logic as SDL keyboard implementation.
JavaScript calls this from keydown/keyup event handlers to update state.
Events are pushed via SetKey, not polled. Interface compatibility only.
//go:build wasm && js
// Package audio provides a WebAssembly-compatible audio implementation
// using the Web Audio API.
package audio
import (
"syscall/js"
)
// WASMAudio implements the Audio interface for WebAssembly/JS environments.
// It uses the Web Audio API to generate square wave tones for CHIP-8 sound.
type WASMAudio struct {
AudioContext js.Value // Web Audio API AudioContext
Oscillator js.Value // Active oscillator node (if playing)
GainNode js.Value // Gain node for volume control
playing bool // Track whether audio is currently playing
}
// WithWASM creates a new Audio implementation for WebAssembly.
// Initializes the Web Audio API AudioContext for generating sound.
func WithWASM() Audio {
ctx := js.Global().Get("AudioContext")
if ctx.IsUndefined() || ctx.IsNull() {
// Safari fallback
ctx = js.Global().Get("webkitAudioContext")
}
var audioCtx js.Value
if !ctx.IsUndefined() && !ctx.IsNull() {
audioCtx = ctx.New()
}
return &WASMAudio{
AudioContext: audioCtx,
}
}
func (a *WASMAudio) Init() error {
// Lazily initialize AudioContext on first use (browser requires user gesture)
if a.AudioContext.IsUndefined() || a.AudioContext.IsNull() {
a.AudioContext = js.Global().Get("AudioContext").New()
}
return nil
}
// Play starts playing a square wave tone using the Web Audio API.
// Creates an oscillator and gain node, connects them to the audio destination.
// Safe to call multiple times; returns early if already playing.
func (a *WASMAudio) Play() error {
if a.playing {
return nil
}
ctx := a.AudioContext
if ctx.IsUndefined() || ctx.IsNull() {
ctx = js.Global().Get("AudioContext").New()
a.AudioContext = ctx
}
// Resume context (must be user-triggered!)
if ctx.Get("state").String() == "suspended" {
ctx.Call("resume")
}
osc := ctx.Call("createOscillator")
gain := ctx.Call("createGain")
// Correct parameter setting
osc.Set("type", "square")
osc.Get("frequency").Set("value", 440)
gain.Get("gain").Set("value", 0.1)
// Connect nodes
osc.Call("connect", gain)
gain.Call("connect", ctx.Get("destination"))
osc.Call("start")
a.Oscillator = osc
a.GainNode = gain
a.playing = true
return nil
}
// Pause stops the currently playing sound by stopping the oscillator.
// Safe to call multiple times; returns early if not playing.
func (a *WASMAudio) Pause() error {
if !a.playing {
return nil
}
if !a.Oscillator.IsUndefined() && !a.Oscillator.IsNull() {
a.Oscillator.Call("stop")
}
a.Oscillator = js.Value{}
a.GainNode = js.Value{}
a.playing = false
return nil
}
// Close stops any playing audio and releases resources.
// Implements the io.Closer interface.
func (a *WASMAudio) Close() error {
return a.Pause()
}
Holds AudioContext, oscillator, and gain node references.
Creates AudioContext from Web Audio API (with Safari fallback).
Lazy initialization (browser requires user gesture to start audio).
Creates square wave oscillator at 440Hz, connects to gain node, then to destination.
Calls stop() on the oscillator.
__START:
CALL INITIALIZE
CALL GENERATE_AND_DRAW_FOOD
CALL DRAW_SNAKE
LOOP:
LD V0, 10 ; Delay for ~166ms (10/60 seconds)
LD DT, V0
WAIT_LOOP:
CALL CHECK_INPUT
LD V0, DT
SNE V0, 0 ; Wait for timer to hit zero
JP TRIGGER_MOVE
JP WAIT_LOOP
TRIGGER_MOVE:
CALL MOVE_SNAKE
JP LOOP
INITIALIZE:
LD V0, 1 ; Vel X
LD V1, 0 ; Vel Y
LD V2, 4 ; Length
; Save these initial values to our RAM labels
LD I, SNAKE_VEL_X
LD [I], V2 ; Stores V0, V1, V2, V3, and V4 into RAM
LD I, SNAKE_BODY_DATA
LD V0, 32 ; Initial X axis of snake head
LD V1, 16 ; Initial Y axis of snake head
LD V3, 0 ; Loop counter
LD V4, 2
LD V5, 1
INITIALIZE_SNAKE_BODY_LOOP:
SUB V0, V5 ; Decrement the X position by 1 (for the next body part)
LD [I], V1
ADD V3, 1
ADD I, V4
SE V3, V2 ; Add as many bodies as length of snake
JP INITIALIZE_SNAKE_BODY_LOOP
RET
REMOVE_OLD_FOOD:
LD I, FOOD_X
LD V1, [I] ; Read coordinates from memory
LD I, SPRITE_DOT
DRW V0, V1, 1 ; Remove food
RET
GENERATE_AND_DRAW_FOOD:
RND V0, 0x3F ; Generate random X coordinate
RND V1, 0x1F ; Generate random Y coordinate
LD I, FOOD_X
LD [I], V1 ; Save coordinates to memory
LD I, SPRITE_DOT
DRW V0, V1, 1 ; Draw food
RET
CHECK_INPUT:
; --- Check Key 2 (UP, key 2) ---
LD V0, 0x02
SKNP V0 ; If Key 2 is pressed, don't skip
CALL SET_UP
; --- Check Key 8 (DOWN, key s) ---
LD V0, 0x08
SKNP V0
CALL SET_DOWN
; --- Check Key 4 (LEFT, key q) ---
LD V0, 0x04
SKNP V0
CALL SET_LEFT
; --- Check Key 6 (RIGHT, key e) ---
LD V0, 0x06
SKNP V0
CALL SET_RIGHT
RET
; --- Direction Setters ---
; We update the VelX (V0) and VelY (V1) and save them to RAM
SET_UP:
LD I, SNAKE_VEL_X
LD V1, [I] ; Load current VelY into V2
SNE V1, 1 ; If VelY == 1 (moving DOWN), skip the RET
RET ; Already moving down — ignore
LD V0, 0
LD V1, 0xFF
LD I, SNAKE_VEL_X
LD [I], V1
RET
SET_DOWN:
LD I, SNAKE_VEL_X
LD V1, [I]
SNE V1, 0xFF ; If VelY == 0xFF (moving UP), skip the RET
RET ; Already moving up — ignore
LD V0, 0
LD V1, 1
LD I, SNAKE_VEL_X
LD [I], V1
RET
SET_LEFT:
LD I, SNAKE_VEL_X
LD V1, [I]
SNE V0, 1 ; If VelX == 1 (moving RIGHT), skip the RET
RET ; Already moving right — ignore
LD V0, 0xFF
LD V1, 0
LD I, SNAKE_VEL_X
LD [I], V1
RET
SET_RIGHT:
LD I, SNAKE_VEL_X
LD V1, [I]
SNE V0, 0xFF ; If VelX == 0xFF (moving LEFT), skip the RET
RET ; Already moving left — ignore
LD V0, 1
LD V1, 0
LD I, SNAKE_VEL_X
LD [I], V1
RET
MOVE_SNAKE:
LD I, SNAKE_LEN
LD V0, [I]
LD V3, V0 ; Get snake length
ADD V3, V0 ; Get snake length (*2 for the fact each body is 2 bytes)
LD V4, 2 ; for decrementing counter
CHECK_COLLISION_WITH_BODY:
; Load position of the snake head
LD I, SNAKE_BODY_DATA
LD V1, [I]
LD V5, V0
LD V6, V1
LD V7, V3
CHECK_COLLISION_WITH_BODY_LOOP:
SUB V7, V4
; Load coordinates of snake body piece
LD I, SNAKE_BODY_DATA
ADD I, V7
LD V1, [I]
; Check if the corrdinates are same
SE V0, V5
JP CHECK_COLLISION_WITH_BODY_LOOP_continue
SE V1, V6
JP CHECK_COLLISION_WITH_BODY_LOOP_continue
JP STOP_GAME
CHECK_COLLISION_WITH_BODY_LOOP_continue:
SE V7, 2
JP CHECK_COLLISION_WITH_BODY_LOOP
CHECK_COLLISION_WITH_FOOD:
; Load position of food
LD I, FOOD_X
LD V1, [I]
LD V5, V0
LD V6, V1
; Load position of snake head
LD I, SNAKE_BODY_DATA
LD V1, [I]
SE V5, V0
JP REMOVE_TAIL
SNE V6, V1 ; Collision of head with food
JP PRESERVE_TAIL
JP REMOVE_TAIL
PRESERVE_TAIL: ; Make the snake grow
; Prevent the tail from getting deleted
ADD V3, V4
; Increase length of snake
LD I, SNAKE_LEN
LD V0, [I]
ADD V0, 1
LD [I], V0
; Play sound
CALL PLAY_BEEP
; Draw new food
CALL REMOVE_OLD_FOOD
CALL GENERATE_AND_DRAW_FOOD
JP START_MAKE_SNAKE_LOOP
REMOVE_TAIL:
SUB V3, V4 ; Get the index of last body part
; Draw over the last tail to remove it
LD I, SNAKE_BODY_DATA
ADD I, V3
LD V1, [I]
LD I, SPRITE_DOT
DRW V0, V1, 1 ; Reset the tail of the sna
; Reset the registors
ADD V3, V4
JP START_MAKE_SNAKE_LOOP ; make (to make it move forward)
START_MAKE_SNAKE_LOOP:
MOVE_SNAKE_LOOP:
; Decrement the index counter for snake body to get the
; body just ahead of this one
; V3 gets subtracted by 2 (each snake body is 2 bytes)
SUB V3, V4
; Loading the position of the current snake body
LD I, SNAKE_BODY_DATA
ADD I, V3
LD V1, [I]
; Getting the position of the current snake body piece
; Add 2 for reseting the changes used to get previous body
ADD V3, V4
LD I, SNAKE_BODY_DATA
ADD I, V3
LD [I], V1
; Finally decrementing the counter
SUB V3, V4
SE V3, 0
JP MOVE_SNAKE_LOOP
LD I, SNAKE_VEL_X
LD V1, [I]
LD V2, V0
LD V3, V1
LD I, SNAKE_BODY_DATA
LD V1, [I]
ADD V0, V2
ADD V1, V3
LD V4, 0x3F ; Mask for 63 (Width - 1)
AND V0, V4 ; If V0 was 64, it now becomes 0
LD V4, 0x1F ; Mask for 31 (Height - 1)
AND V1, V4 ; If V1 was 32, it now becomes 0
LD [I], V1
LD I, SPRITE_DOT
DRW V0, V1, 1 ; Create the new head
RET
DRAW_SNAKE:
; 1. Load snake information from RAM back into registers
LD I, SNAKE_LEN
LD V0, [I]
LD V2, V0
LD V3, 0
LD V4, 2
DRAW_SNAKE_BODY_LOOP:
LD I, SNAKE_BODY_DATA
ADD I, V3
ADD I, V3 ; Add V3 twice because the equation is ith body = I + V3 * 2
LD V1, [I] ; Fills V0 through V4 with the saved data
; 2. Draw the head using V0 (X) and V1 (Y)
LD I, SPRITE_DOT
DRW V0, V1, 1
ADD V3, 1
SE V3, V2
JP DRAW_SNAKE_BODY_LOOP
; 3. Draw the body (The Loop)
RET
PLAY_BEEP:
LD V0, 20
LD ST, V0
RET
STOP_GAME:
CLS ; Clear the screen
CALL PLAY_BEEP
; ── Row 1: "GAME" at y=8 ──────────────────────────────────
LD V1, 8
LD V0, 20
LD I, SPR_GO_G
DRW V0, V1, 8
LD V0, 26
LD I, SPR_GO_A
DRW V0, V1, 8
LD V0, 32
LD I, SPR_GO_M
DRW V0, V1, 8
LD V0, 38
LD I, SPR_GO_E
DRW V0, V1, 8
; ── Row 2: "OVER" at y=20 ─────────────────────────────────
LD V1, 20
LD V0, 20
LD I, SPR_GO_O
DRW V0, V1, 8
LD V0, 26
LD I, SPR_GO_V
DRW V0, V1, 8
LD V0, 32
LD I, SPR_GO_E
DRW V0, V1, 8
LD V0, 38
LD I, SPR_GO_R
DRW V0, V1, 8
STOP_GAME_HALT:
JP STOP_GAME_HALT ; Freeze here forever
RET
; --- Data Section ---
; Aligning labels so they don't overlap
SPRITE_DOT:
DB 0x80
; G
SPR_GO_G:
DB 0x70
DB 0x80
DB 0x80
DB 0xB8
DB 0x88
DB 0x78
DB 0x00
DB 0x00
; A
SPR_GO_A:
DB 0x70
DB 0x88
DB 0x88
DB 0xF8
DB 0x88
DB 0x88
DB 0x00
DB 0x00
; M
SPR_GO_M:
DB 0x88
DB 0xD8
DB 0xA8
DB 0x88
DB 0x88
DB 0x88
DB 0x00
DB 0x00
; E
SPR_GO_E:
DB 0xF8
DB 0x80
DB 0x80
DB 0xF0
DB 0x80
DB 0xF8
DB 0x00
DB 0x00
; O
SPR_GO_O:
DB 0x70
DB 0x88
DB 0x88
DB 0x88
DB 0x88
DB 0x70
DB 0x00
DB 0x00
; V
SPR_GO_V:
DB 0x88
DB 0x88
DB 0x88
DB 0x50
DB 0x50
DB 0x20
DB 0x00
DB 0x00
; R
SPR_GO_R:
DB 0xF0
DB 0x88
DB 0x88
DB 0xF0
DB 0xA0
DB 0x90
DB 0x00
DB 0x00
; We use enough space to store the snake's state
; LD [I], V4 needs 5 bytes of space (V0, V1, V2, V3, V4)
SNAKE_VEL_X: ; Stores Vel X (V2)
DB 0x00
SNAKE_VEL_Y: ; Stores Vel Y (V3)
DB 0x00
SNAKE_LEN: ; Stores Length (V4)
DB 0x00
FOOD_X:
DB 0x00
FOOD_Y:
DB 0x00
SCORE_STORAGE:
DB 0x00
DB 0x00
DB 0x00
SNAKE_BODY_DATA:
; Each snake body has 2 bytes for X and Y coordinates
; This doesn't include the snake head
__END:
Uses delay timer to create ~166ms tick. Waits for DT to reach zero before each move.
Sets initial velocity (1, 0), length (4), and builds initial body by placing segments at decreasing X positions.
Checks keys 2, 8, 4, 6. SKNP (skip if not pressed) is used - calls direction setter only if key is pressed.
The core: checks collision with body and food, handles wrapping, then shifts all body segments forward.
When head matches food position: don't remove tail (snake grows), play beep, generate new food.
Clears screen, draws 'GAME OVER' text using 8×8 sprite data, then infinite loop to freeze.