Chip8 emulator in C++

0 - Introduction

A common advice for those who want to get started with emulators development is to try implement a CHIP8 emulator.

This is a good advice, because the CHIP8 specification is quite small and simple to implement. And gives a very good overview of the basics on how processors work.

In this tutorial a CHIP8 emulator will be created using the C++ programming language and the SDL library for video output and keyboard inputs. The steps of this emulator were build and tested on Ubuntu 22.04.

Here you can find our GitHub repository if you want to download the full project code and also the ROMS for testing your own emulator.

CHIP-8 was created by RCA engineer Joe Weisbecker in 1977 for the COSMAC VIP microcomputer. It was intended as a simpler way to make small programs and games for the computer. Instead of using machine language for the VIP’s CDP1802 processor, you could type in hexadecimal instructions (with the VIP’s hex keypad) that resembled machine code, but which were more high-level, and interpreted on the fly by a small program (the CHIP-8 emulator/interpreter).

CHIP-8 soon spread to other computers, like the Finnish Telmac 1800, the Australian DREAM 6800, ETI-660 and MicroBee, and the Canadian ACE VDU.

By 1984 the interest in CHIP-8 petered out. However, in 1990 it had a renaissance on the HP48 graphing calculators with CHIP-48 and the now-famous SUPER-CHIP extension with higher resolution.

1 - Prerequisites

This tutorial focuses on building a CHIP-8 emulator, not on teaching programming fundamentals. Before you begin, it’s important to have a solid understanding of key concepts like arrays, file handling, and classes.

You will also need to have a basic understanding of the binary and hexadecimal number systems; I will write hexadecimal numbers like this: 0xB3. CHIP-8 programs are binary files, and your emulator will need to read these files and operate on the bytes.

2 - Specifications

CHIP8 as the following details:

  • Von Neumann architecture
  • Main memory is 4KB in size. [0x000, 0x200] to work as RAM (512 bytes) and [0x200, 0xFFF] for program (3584 bytes).
  • Programs should start at address 0x200. Basically, at program start set PC to 0x200.
  • A total of 16 general purpose 8-bit registers, V0 – VF (hexadeximal notation). VF “should” not be used by any program since it is used as a flag register by some instructions.
  • There is also the 16-bit I register, used to store memory addresses (only the lowest 12-bits are used since memory only needs that).
  • Two special purpose registers for delay and sound timers, both are 8-bit and are automatically decremented every 60 Hz when non-zero.
  • 16-bit PC and 8-bit (maybe) SP. The stack has 16 places, 16-bits each.
  • Hexadecimal keyboard, keys labeled 0-F. Just mapping these to respective keys on a modern keyboard should work.
  • 64×32 display buffer for the monochrome display, the origin is located on the top-left part of the screen (0, 0).
  • Fonts representing the keyboard input need to be stored as sprites in the RAM region.
  • The buzzer will sound when the ST register is non-zero, will stop when zero.

The memory should be 4 kB (4 kilobytes, ie. 4096 bytes) large. CHIP-8’s index register and program counter can only address 12 bits (conveniently), which is 4096 addresses.

The index register, program counter and stack entries are all actually 16 bits long. In theory, they could increment beyond 4 kB of memory addresses. In practice, no CHIP-8 games do that. The early computers running CHIP-8 usually had less than 4 kB of RAM anyway.

All the memory is RAM and should be considered to be writable. CHIP-8 games can, and do, modify themselves.

CHIP-8 programs you find online as binary files are often called “ROMs”, like game files for video game emulators, but unlike games on console cartridges they were not actually ROM (which means “read-only memory”).

The first CHIP-8 interpreter (on the COSMAC VIP computer) was also located in RAM, from address 000 to 1FF. It would expect a CHIP-8 program to be loaded into memory after it, starting at address 200 (512 in decimal). Although modern interpreters are not in the same memory space, you should do the same to be able to run the old programs; you can just leave the initial space empty, except for the font.

The CHIP-8 emulator should have a built-in font, with sprite data representing the hexadecimal numbers from 0x0 through 0xF. Each font character should be 4 pixels wide by 5 pixels tall. These font sprites are drawn just like regular sprites (see below).

You should store the font data in memory, because games will draw these characters like regular sprites: They set the index register I to the character’s memory location and then draw it. There’s a special instruction for setting I to a character’s address, so you can choose where to put it. Anywhere in the first 512 bytes (0x000–0x1FF) is fine. We placed them start at 0x000.

The display is 64 pixels wide and 32 pixels tall. Each pixel can be on or off. In other words, each pixel is a boolean value, or a bit.

The early computers used regular TVs as screens, so an “off” pixel was just black, and “on” was white. You can pick other colors.

Original interpreters updated the display at 60 Hz (ie. they had 60 FPS, to use modern terminology). How you do this is up to you, but depending on the framework you use, it might be a good idea to only redraw the screen when your emulator executes an instruction that modifies the display data (there are two), to run faster.

The details of the drawing instruction DXYN are found below, but in short, it is used to draw a “sprite” on the screen. Each sprite consists of 8-bit bytes, where each bit corresponds to a horizontal pixel; sprites are between 1 and 15 bytes tall. They’re drawn to the screen by treating all 0 bits as transparent, and all the 1 bits will “flip” the pixels in the locations of the screen that it’s drawn to. (You might recognize this as logical XOR.)

This method of drawing will inevitable cause some flickering objects; when a sprite is moved, it’s first erased from the screen (by simply drawing it again, flipping all its lit pixels) and then re-drawn in the new position, so it will disappear for a little while, often causing a flickering effect. If you want, you can try to think of ways to mitigate this. For example, pixels that are erased could fade out instead of disappearing completely, giving an old phosphorous CRT-style effect.

CHIP-8 has a stack (a common “last in, first out” data structure where you can either “push” data to it or “pop” the last piece of data you pushed). You can represent it however you’d like; a stack if your programming language has it, or an array. CHIP-8 uses it to call and return from subroutines (“functions”) and nothing else, so you will be saving addresses there; 16-bit (or really only 12-bit) numbers.

Early interpreters reserved some memory for the stack, and some programs would use that knowledge to operate the stack directly and save stuff there, but you don’t need to do that. You can just use a variable outside the emulated memory.

These original interpreters had limited space on the stack; usually at least 16 two-byte entries. You can limit the stack likewise, or just keep it unlimited. CHIP-8 programs usually don’t nest subroutine calls too much since the stack was so small originally, so it doesn’t really matter (unless you encounter a program with a bug that has an infinite call loop and causes a “stack overflow”).

There are two separate timer registers: The delay timer and the sound timer. They both work the same way; they’re one byte in size, and as long as their value is above 0, they should be decremented by one 60 times per second (ie. at 60 Hz). This is independent of the speed of the fetch/decode/execute loop below.

The sound timer is special in that it should make the computer “beep” as long as it’s above 0.

Even though it’s called the “delay” timer, your interpreter should run as normal while it’s being decremented (the same goes for the sound timer). The CHIP-8 game will check the value of the timer and delay itself if it wants.

The earliest computers that CHIP-8 were used with had hexadecimal keypads. These had 16 keys, labelled 0 through F, and were arranged in a 4×4 grid.

On the original COSMAC VIP, a sound (the same sound as the sound timer uses) would be heard while holding down a key. This might be a little obnoxious, though…

These keypads all had different layouts, but the COSMAC VIP used the following layout, which was re-used on the HP48 calculators, so that’s what everyone implements these days:
————–
1 | 2 | 3 | C
4 | 5 | 6 | D
7 | 8 | 9 | E
A | 0 | B | F
————–

For CHIP-8 emulators that run on modern PCs, it’s customary to use the left side of the QWERTY keyboard for this:

————–
1 | 2 | 3 | 4
Q | W | E | R
A | S | D | F
Z | X | C | V
————–

3 - Prepare the project

To build this project on ubuntu, some packages are needed and can be installed with the following commands:

sudo apt update
sudo apt install g++ build-essential libsdl2-dev

Now we can create the Makefile and the main.cpp of our project.

OUT_DIR=.
OUT_NAME=chip8

ELF=$(OUT_DIR)/$(OUT_NAME).elf
DIS=$(OUT_DIR)/$(OUT_NAME).dasm

#=====================================================================
# Compiler
CC=g++
#=====================================================================
# Project Paths

#=====================================================================
# Files to compile
ASMFILES=


CFILES=



CPPFILES= 	main.cpp				\
			
			


# Create obj files names from source files
ASMOBJS=$(patsubst %.S,%.o,$(ASMFILES))
COBJS=$(patsubst %.c,%.o,$(CFILES))
CPPOBJS=$(patsubst %.cpp,%.o,$(CPPFILES))

OBJS=$(ASMOBJS) $(COBJS) $(CPPOBJS)
# ==================================================================
# Header flags
CC_HEADERS= -I/usr/include/SDL2


# Compiler flags
CC_FLAGS= -g -std=c++20 -lSDL2


# Linker flags
LD_FLAGS=


#===================================================================
# all rule
all: directory $(ELF)

#===================================================================
#generate .elf file
$(ELF): $(OBJS)
	$(CC) $(LD_FLAGS) -o $@ $(OBJS) $(CC_HEADERS) $(CC_FLAGS)

%.o: %.S
	$(CC) $(CC_FLAGS) -c -o $@ $<

%.o: %.c
	$(CC) $(CC_FLAGS) -c -o $@ $<

%.o: %.cpp
	$(CC) $(CC_FLAGS) -c -o $@ $<

directory:
	mkdir -p $(OUT_DIR)

.PHONY: clean
clean:
	rm -f $(OBJS) $(ELF)

#=====================================================================
# Debug section
print:
	$(info    OBJS is $(OBJS))
	$(info    CPPOBJS is $(CPPOBJS))
	$(info    CC is $(CC))
#include <iostream>

int main(int argc, char** argv)
{
    std::cout << "CHIP8 basic program layout\n";

    return 0;
}

Then go to the directory where the Makefile is and type:

make
./chip8.elf

4 - Add log capabilities

For now we are going to add a class that will handle all the logs. You can create the files logger.h and logger.cpp.

#ifndef __LOGGER_H__
#define __LOGGER_H__

#include <iostream>
#include <fstream>
#include <memory>

enum class ELogLevel
{
    NONE = 0,
    ERROR = 1,
    WARN = 2,
    INFO = 3,
    DEBUG = 4
};

class TLogger
{
    ELogLevel m_logLevel;
    static std::shared_ptr<TLogger> m_loggerInstance;

public:
    TLogger();
    void log(std::string message, ELogLevel logLevel);
    void setLogLevel(ELogLevel logLevel);

    static std::shared_ptr<TLogger> getInstance();
};

#endif
#include <iostream>
#include <memory>
#include <string>
#include "logger.h"

std::shared_ptr<TLogger> TLogger::m_loggerInstance = nullptr;

TLogger::TLogger()
 : m_logLevel(ELogLevel::DEBUG)
{

}

void TLogger::log(std::string message, ELogLevel logLevel)
{
    std::string buff = "";

    if(logLevel <= m_logLevel)
    {
        switch(logLevel)
        {
            case ELogLevel::ERROR: buff += "\033[31m[ERROR] "; break;
            case ELogLevel::WARN: buff += "\033[33m[WARN] "; break;
            case ELogLevel::INFO: buff += "\033[32m[INFO] "; break;
            case ELogLevel::DEBUG: buff += "\033[0m[DEBUG] "; break;
            default: break;
        }

        buff += message + "\n";
        std::cout << buff;
    }
}

void TLogger::setLogLevel(ELogLevel logLevel)
{
    m_logLevel = logLevel;
}

std::shared_ptr<TLogger> TLogger::getInstance()
{
    if(m_loggerInstance == nullptr)
        m_loggerInstance = std::shared_ptr<TLogger>(new TLogger());

    return m_loggerInstance;
}

Now you need to to add the newly created file to the Makefile. And also add the test code to the main.cpp.

CPPFILES= 	main.cpp				\
		    logger.cpp				\
#include "logger.h"

int main(int argc, char** argv)
{
    std::shared_ptr<TLogger> logger = TLogger::getInstance();

    logger->log("This is an ERROR message!!!", ELogLevel::ERROR);
    logger->log("This is a WARNING message!!!", ELogLevel::WARN);
    logger->log("This is an INFO message!!!", ELogLevel::INFO);
    logger->log("This is a DEBUG message!!!", ELogLevel::DEBUG);

    return 0;
}

After adding all the changes, you can build the application with the command “make” or force a full rebuild with the command “make clean all”.

After the build run successfully you can run the produced application and you should get an output similar to the displayed bellow.

5 - Adding a command line parser

For our little emulator we are going to pass parameters from the command line to change the behavior of the application. For now we are going to create only two parameters. One to specify the path to the ROM file to be emulated and another parameter to control the desired log level.

For this we are going to create two new files called cmdLineParser.h and cmdLineParser.cpp.

#ifndef __CMDLINEPARSER_H__
#define __CMDLINEPARSER_H__

#include <iostream>

#include "logger.h"

class TCmdLineParser {

    // Logging
    std::shared_ptr<TLogger> m_logger;

    std::string m_romFileName;
    int m_logLevel;

    void printHelpMessage();
    void setRomFileName(std::string new_name);
    void setLogLevel(int logLevel);
public:
    TCmdLineParser();
    void parseCmdLine(int argc, char** argv);

    bool isRomFileNameSet();
    std::string getRomFileName();
    bool isLogLevelSet();
    int getLogLevel();
};

#endif
#include <cctype>

#include "cmdLineParser.h"

// Private functions
void TCmdLineParser::printHelpMessage()
{
    std::cout << "Usage: lcc [options] file..." << std::endl;
    std::cout << "Options:" << std::endl;
    std::cout << "   -h, --help              Display this help information" << std::endl;
    std::cout << "   -r, --romFileName       Set the rom file path to be used" << std::endl;
    std::cout << "   -l, --logLevel          Set the desired log level [NONE = 0, ERROR = 1, WARN = 2, INFO = 3, DEBUG = 4]" << std::endl;
}

void TCmdLineParser::setRomFileName(std::string new_name)
{
    this->m_romFileName = new_name;
}

void TCmdLineParser::setLogLevel(int logLevel)
{
    this->m_logLevel = logLevel;
}

// Public Functions
TCmdLineParser::TCmdLineParser()
    : m_romFileName("")
    , m_logLevel(-1)
{
    m_logger = TLogger::getInstance();
}

void TCmdLineParser::parseCmdLine(int argc, char** argv)
{
    // It starts at 1 because in linux the first argument is the runing program name 
    for(int i=1; i<argc; i++)
    {
        std::string auxStr(argv[i]);

        if(auxStr.at(0) == '-')
        {
            if(auxStr == "-h" || auxStr == "--help")
                this->printHelpMessage();
            else if(auxStr == "-r" || auxStr == "--romFileName")
            {
                i++;
                std::string auxName(argv[i]);        
                this->setRomFileName(auxName);
            }
            else if(auxStr == "-l" || auxStr == "--logLevel")
            {
                i++;
                std::string logLevel(argv[i]);
                if(!std::isdigit(logLevel.at(0)))
                {
                    m_logger->log("Parameter must be a number [0,4]", ELogLevel::ERROR);
                    exit(1);
                }    
                this->setLogLevel(stoi(logLevel));
            }
            else
            {
                std::string param(argv[i]);
                m_logger->log("Unkown parameter: " + param, ELogLevel::ERROR);
                exit(1);
            }
        }
        else
        {
            std::string param(argv[i]);
            m_logger->log("Unkown parameter: " + param, ELogLevel::ERROR);
            exit(1);
        }
    }
}

bool TCmdLineParser::isRomFileNameSet()
{
    return !(m_romFileName == "");
}

std::string TCmdLineParser::getRomFileName()
{
    return m_romFileName;
}

bool TCmdLineParser::isLogLevelSet()
{
    return !(m_logLevel == -1);
}

int TCmdLineParser::getLogLevel()
{
    return m_logLevel;
}

Now you need to to add the newly created files to the Makefile. And also add the test code to the main.cpp.

CPPFILES= 	main.cpp				\
		    logger.cpp				\
		    cmdLineParser.cpp		\
#include "cmdLineParser.h"
#include "logger.h"

int main(int argc, char** argv)
{
    TCmdLineParser cmdParser;
    std::shared_ptr<TLogger> logger = TLogger::getInstance();

    cmdParser.parseCmdLine(argc, argv);
    
    if(cmdParser.isLogLevelSet())
    {
        std::shared_ptr<TLogger> logger = TLogger::getInstance();
        switch (cmdParser.getLogLevel())
        {
            case 0: logger->setLogLevel(ELogLevel::NONE); break;
            case 1: logger->setLogLevel(ELogLevel::ERROR); break;
            case 2: logger->setLogLevel(ELogLevel::WARN); break;
            case 3: logger->setLogLevel(ELogLevel::INFO); break;
            case 4: logger->setLogLevel(ELogLevel::DEBUG); break;
        }
    }

    if(!cmdParser.isRomFileNameSet())
    {
        logger->log("No rom path provided", ELogLevel::ERROR);
        exit(1);
    }
    
    logger->log("ROM Path: "+cmdParser.getRomFileName(), ELogLevel::INFO);

    return 0;
}

In the main.cpp file we added the call for parsing the command line arguments and then checked if any of the supported flags were specified.

For the ROM file we made it so that if not provided, the program stops completely, because if no ROM is provided there isn’t any code to run on the emulator. The log level is optional, and if a non supported value is provided it is ignored and the configuration falls back to the default DEBUG level.

Now if you rebuild your code and run, you can test some scenarios and should get an output similar to the below image.

6 - Emulator component diagram

Before continuing writing code, let’s take a look at the components layout and how they connect with each other.

The CHIP8 class will be responsible to orchestrate all the steps necessary for the emulation to happen.

This class will hold the necessary data structures that needs to be shared between different components.

Then the ROM Loader as the name suggests will be responsible to read the data from the ROM file and place it on the emulator memory.

The CPU is the place where the instructions behavior will be emulated.

The keyboard and Display classes exist to abstract the implementation of how the keyboard events are grabbed and how the display is drawn. The idea is to be possible to easily use another library like OpenGL without any changes on the CHIP8 code itself.

7 - CHIP8 Class

At this point we are ready to create the CHIP8 class. This class will be created in steps to be easier to understand how it is built along the way. For now we will create the necessary data structures and the basic methods to function. As the tutorial will progress this class will be filled with the necessary code.

For now let’s create the chip8.h and chip8.cpp files.

#ifndef __CHIP8_H__
#define __CHIP8_H__

#include <cstdint>
#include <iostream>

#include "logger.h"

#define NUM_KEYS 16
#define TOTAL_RAM 4096
#define STACK_SIZE 16
#define FONTSET_SIZE 80
#define TIMER_MAX 255

#define CHIP8_RAM_START_ADDR 0x000
#define CHIP8_RAM_END_ADDR 0x1FF
#define PROGRAM_START_ADDR 0x200
#define PROGRAM_END_ADDR 0xFFF

#define SCREEN_WIDTH 64
#define SCREEN_HEIGHT 32

const static uint8_t FONTSET[] = { 
        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
    };

class TCpu;

class TChip8
{
    friend class TCpu;

    // screen
    uint8_t m_screen[SCREEN_HEIGHT][SCREEN_WIDTH];

    // keyboard
    uint8_t m_keys[NUM_KEYS];
    bool m_key_pressed;

    // memory
    uint8_t m_ram[TOTAL_RAM];
    uint16_t m_stack[STACK_SIZE];

    // timers
    uint8_t m_delay_timer;
    uint8_t m_sound_timer;

    // Control var
    bool m_emulatorRunning;
    
    // Logging
    std::shared_ptr<TLogger> m_logger;

public:
    TChip8();
    ~TChip8();

    void init(std::string rom_path);
    void run();
    void deinit();

};

#endif
#include "chip8.h"
#include "logger.h"

TChip8::TChip8()
{
    m_logger = TLogger::getInstance();
    m_emulatorRunning = true;
}

TChip8::~TChip8()
{

}

void TChip8::init(std::string rom_path)
{
	// Clear frame buffer
    for(auto i = 0; i < SCREEN_HEIGHT; i++)
        for(auto j = 0; j < SCREEN_WIDTH; j++)
            m_screen[i][j] = 0;

    // Clear stack
    for(auto i = 0; i < STACK_SIZE; i++)
        m_stack[i] = 0;

    // Clear RAM
    for(auto i = 0; i < TOTAL_RAM; i++)
        m_ram[i] = 0;

    // Load font set into memory
    for(auto i = 0; i < FONTSET_SIZE; i++)
        m_ram[i] = FONTSET[i];

    // Reset timers
    m_delay_timer = 0;
    m_sound_timer = 0;

    // Start keyboard state as all unpressed
    for (auto i = 0; i < NUM_KEYS; i++)
        m_keys[i] = 0;

    m_key_pressed = false;
}

void TChip8::run()
{

}

void TChip8::deinit()
{

}

Now you need to to add the newly created files to the Makefile. And also add the remaining needed code to the main.cpp.

CPPFILES= 	main.cpp				\
			logger.cpp				\
			cmdLineParser.cpp		\
			chip8.cpp				\
#include "chip8.h"
#include "cmdLineParser.h"
#include "logger.h"

int main(int argc, char** argv)
{
    TCmdLineParser cmdParser;
    std::shared_ptr<TLogger> logger = TLogger::getInstance();

    cmdParser.parseCmdLine(argc, argv);
    
    if(cmdParser.isLogLevelSet())
    {
        std::shared_ptr<TLogger> logger = TLogger::getInstance();
        switch (cmdParser.getLogLevel())
        {
            case 0: logger->setLogLevel(ELogLevel::NONE); break;
            case 1: logger->setLogLevel(ELogLevel::ERROR); break;
            case 2: logger->setLogLevel(ELogLevel::WARN); break;
            case 3: logger->setLogLevel(ELogLevel::INFO); break;
            case 4: logger->setLogLevel(ELogLevel::DEBUG); break;
        }
    }

    if(!cmdParser.isRomFileNameSet())
    {
        logger->log("No rom path provided", ELogLevel::ERROR);
        exit(1);
    }
    
    TChip8 emulator;    
    emulator.init(cmdParser.getRomFileName());
    emulator.run();
    emulator.deinit();

    return 0;
}

8 - ROM Loader

Moving forward, let’s add the ROM loader component and it’s functionality of reading the ROM file and load it into  the CHIP8 RAM.

Let’s start by creating two new files, romLoader.h and romLoader.cpp.

#ifndef __ROMLOADER_H__
#define __ROMLOADER_H__

#include <cstdint>
#include <iostream>

#include "logger.h"

class TRomLoader
{
    // Logging
    std::shared_ptr<TLogger> m_logger;
    
public:
    TRomLoader();
    ~TRomLoader();

    void loadRom(std::string& file_path, uint8_t* mem);
};

#endif
#include "romLoader.h"

TRomLoader::TRomLoader()
{
    m_logger = TLogger::getInstance();
}

TRomLoader::~TRomLoader()
{

}

void TRomLoader::loadRom(std::string& file_path, uint8_t* mem)
{
    m_logger->log("Loading ROM: " + file_path, ELogLevel::INFO);

    // Open the file in binary mode
    std::ifstream file(file_path, std::ios::binary);

    // Check if the file was opened successfully
    if (!file) 
    {
        m_logger->log("Couldn't open file: " + file_path, ELogLevel::ERROR);
        exit(1);
    }

    // Determine the rom size
    file.seekg(0, std::ios::end);
    std::streamsize size = file.tellg();
    file.seekg(0, std::ios::beg);  // Move back to the beginning of the file

    // Read the binary data into the buffer
    if (file.read(reinterpret_cast<char*>(mem), size))
        m_logger->log("File read successfully. Size: " + std::to_string(size) + " bytes.", ELogLevel::INFO);
    else
    {
        m_logger->log("Error during file read", ELogLevel::ERROR);
        exit(1);
    }

    // Close the file stream after reading
    file.close();
}

With the new files, we can now add the new files to the Makefile. We can also add a method to the CHIP8 class to use the ROMLoader from there.

For this we need to change the Makefile, chip8.h and chip8.cpp.

Note: From now on since in some files need code addition, and in order to try reduce the size of the code snippets, three dots would be placed in the code meaning that code should stay as it is. On the areas that need change, some more lines are added to give context, trying to ease the process of locating the code.

CPPFILES= 	main.cpp				\
			logger.cpp				\
			cmdLineParser.cpp		\
			chip8.cpp				\
			romLoader.cpp			\
#ifndef __CHIP8_H__
#define __CHIP8_H__

#include <cstdint>
#include <iostream>

#include "logger.h"
#include "romLoader.h"

#define NUM_KEYS 16
#define TOTAL_RAM 4096
#define STACK_SIZE 16
#define FONTSET_SIZE 80
#define TIMER_MAX 255

...

// Logging
    std::shared_ptr<TLogger> m_logger;
    
    // ROM Loader
    TRomLoader* m_loader;

public:
    TChip8();
    ~TChip8();
    
...
void TChip8::init(std::string rom_path)
{
    ...

    m_key_pressed = false;
    
    // Load ROM to the ram
    m_loader = new TRomLoader();
    m_loader->loadRom(rom_path, m_ram+PROGRAM_START_ADDR);
    delete m_loader;
}

With the changes added, you can rebuild the whole application and run it. You should get an output similar to the one below.

9 - CPU

From here, we are ready to start processing instructions. For this we are going to create the CPU component and have it ready to start executing instructions.

We are starting by setting up the instructions fetch and decode, and then place a mechanism to throw an error when an unknown instruction is found. This way we can incrementally implement the instructions and see the emulator come to life slowly.

Let’s start by creating the files cpu.h and cpu.cpp.

#ifndef __CPU_H__
#define __CPU_H__

#include <cstdint>

#include "chip8.h"
#include "logger.h"

#define NUM_V_REGISTERS 16
#define PC_START 0x200

class TCpu
{
    // registers
    uint8_t m_reg[NUM_V_REGISTERS];
    uint16_t m_ireg;
    uint16_t m_pcreg;
    uint16_t m_sp_reg;

    // helper variable
    uint16_t m_current_op;              // current opcode being executed
    uint16_t m_instruction;

    TChip8* m_machine;
    
    // Logging
    std::shared_ptr<TLogger> m_logger;

public:
    TCpu(TChip8* machine);
    ~TCpu();
    void init();
    void fetch();
    void decode();
    void execute();
    void deinit();
};

#endif
#include <cstdlib>
#include <ctime> 

#include "cpu.h"
#include "logger.h"

TCpu::TCpu(TChip8* machine)
{
    m_machine = machine;
    m_logger = TLogger::getInstance();
}

TCpu::~TCpu()
{

}

void TCpu::init()
{
    // Initialize cpu registers
    m_pcreg = PC_START;
    m_current_op = 0;
    m_sp_reg = 0;
    m_ireg = 0;
    m_instruction = 0;

    // Clear registers
    for(auto i = 0; i < NUM_V_REGISTERS; i++)
        m_reg[i] = 0;
}

void TCpu::fetch()
{
    m_current_op = ((uint16_t)m_machine->m_ram[m_pcreg] << 8) | m_machine->m_ram[m_pcreg+1];
    m_pcreg += 2;
}

void TCpu::decode()
{
    m_instruction = m_current_op >> 12;
}

void TCpu::execute()
{
    m_logger->log("INST " + std::to_string(m_instruction), ELogLevel::DEBUG);
    
    switch(m_instruction)
    {
        case 0x0:
        case 0x1: 
        case 0x2:
        case 0x3:
        case 0x4:
        case 0x5:
        case 0x6:
        case 0x7:
        case 0x8:
        case 0x9:
        case 0xA:
        case 0xB:
        case 0xC:
        case 0xD:
        case 0xE:
        case 0xF: m_logger->log("INSTRUCTION OPCODE NOT IMPLEMENTED!!!", ELogLevel::ERROR); exit(1); break;
	}
}

void TCpu::deinit()
{

}

With the CPU class created is now time to add it to CHIP8 in order to be possible to execute CHIP8 instructions. Let’s add the necessary code to Makefile, chip8.h and chip8.cpp.

CPPFILES= 	main.cpp				\
			logger.cpp				\
			cmdLineParser.cpp		\
			chip8.cpp				\
			romLoader.cpp			\
			cpu.cpp					\
// chip8.h
...

class TCpu;

class TChip8
{
	friend class TCpu;

	// CPU handler
    TCpu* m_cpu;
    
...
// chip8.cpp

#include <iostream>
#include <chrono>
#include <thread>

#include "chip8.h"
#include "cpu.h"
#include "logger.h"

TChip8::TChip8()
{
    m_logger = TLogger::getInstance();
    m_cpu = new TCpu(this);
    m_emulatorRunning = true;
}

...

void TChip8::init(std::string rom_path)
{
    ...
    
    m_key_pressed = false;
    
    // Init cpu registers
    m_cpu->init();
    
    // Load ROM to the ram
    m_loader = new TRomLoader();
    m_loader->loadRom(rom_path, m_ram+PROGRAM_START_ADDR);
    delete m_loader;
}

void TChip8::run()
{
	while(m_emulatorRunning)
    {
    	m_cpu->fetch();
        m_cpu->decode();
        m_cpu->execute();
    }
}

void TChip8::deinit()
{
	m_cpu->deinit();
}

With all the changes in place, you can rebuild the application and run it. You should get an output similar to the image below.

Note: If you use other ROM you can have the error message pointing to a different instruction.

10 - Instructions

In order to implement the instructions you can check the CHIP8 specification that can be found for example here.

If you are doing this project to practice emulator development I highly encourage you to try implement the instructions from the specification before looking into the code that I will present at the end of this chapter. In here I will also add a simple explanation for each instruction and the code snippet that implement it.

00E0 - Clear screen

void TCpu::clear_screen()
{
    for(int i=0; i<SCREEN_HEIGHT; i++)
        for(int j=0; j<SCREEN_WIDTH; j++)
            m_machine->m_screen[i][j]=0;
}

00EE - Return from a subroutine.

void TCpu::return_from_subroutine()
{
    m_sp_reg--;
    m_pcreg = m_machine->m_stack[m_sp_reg];
}

1NNN - Jump to address

void TCpu::jump_to()
{
    m_pcreg = m_current_op & 0x0FFF;
}

2NNN - Call subroutine

void TCpu::call_subroutine()
{
    uint16_t nnn = m_current_op & 0x0FFF;

    m_machine->m_stack[m_sp_reg] = m_pcreg;
    m_sp_reg++;
    m_pcreg = nnn;
}

3XKK - Skip next instruction if Vx == KK

void TCpu::skip_next_instruction_eq()
{
    uint8_t value = m_current_op & 0xFF;
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    if(m_reg[reg] == value)
        m_pcreg += 2;
}

4XKK - Skip next instruction if Vx != KK

void TCpu::skip_next_instruction_ne()
{
    uint8_t value = m_current_op & 0xFF;
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    if(m_reg[reg] != value)
        m_pcreg += 2;
}

5XY0 - Skip next instruction if Vx == Vy

void TCpu::skip_next_instruction_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    if(m_reg[reg_x] == m_reg[reg_y])
        m_pcreg += 2;
}

6XKK - Set register to costant value

void TCpu::register_set()
{
    uint8_t value = m_current_op & 0xFF;
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    m_reg[reg] = value;
}

7XKK - Add constant to register

void TCpu::add_reg_imm()
{
    uint8_t value = m_current_op & 0xFF;
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    m_reg[reg] += value;
}

8XY0 - Load Vx with Vy

void TCpu::move_vy_to_vx()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    m_reg[reg_x] = m_reg[reg_y];
}

8XY1 - OR of Vx with Vy

void TCpu::or_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    m_reg[reg_x] |= m_reg[reg_y];
}

8XY2 - AND of Vx with Vy

void TCpu::and_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    m_reg[reg_x] &= m_reg[reg_y];
}

8XY3 - XOR of Vx with Vy

void TCpu::xor_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    m_reg[reg_x] ^= m_reg[reg_y];
}

8XY4 - ADD Vx with Vy

void TCpu::add_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;
    uint16_t add = m_reg[reg_x] + m_reg[reg_y];

    if(add > 0xFF)
        m_reg[0xF] = 1;
    else
        m_reg[0xF] = 0;

    m_reg[reg_x] = add & 0xFF;
}

8XY5 - Subtract Vy from Vx

void TCpu::sub_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    if(m_reg[reg_x] > m_reg[reg_y])
        m_reg[0xF] = 1;
    else
        m_reg[0xF] = 0;

    m_reg[reg_x] -= m_reg[reg_y];
}

8XY6 - Vx divided by 2

void TCpu::shift_right_reg()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    if(m_reg[reg] % 2 == 1 )
        m_reg[0xF] = 1;
    else
        m_reg[0xF] = 0;

    m_reg[reg] >>= 1;
}

8XY7 - Subtract Vx from Vy

void TCpu::subn_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    if(m_reg[reg_y] > m_reg[reg_x])
        m_reg[0xF] = 1;
    else
        m_reg[0xF] = 0;

    m_reg[reg_x] = m_reg[reg_y] - m_reg[reg_x];
}

8XYE - Vx multiplied by 2

void TCpu::shift_left_reg()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    if(m_reg[reg] & 0x80 == 1 )
        m_reg[0xF] = 1;
    else
        m_reg[0xF] = 0;

    m_reg[reg] <<= 1;
}

9XY0 - Skip next instruction if Vx != Vy

void TCpu::skip_next_instruction_vx_vy_ne()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    if(m_reg[reg_x] != m_reg[reg_y])
        m_pcreg += 2;
}

ANNN - Load I register with constant

void TCpu::set_index_register()
{
    m_ireg = m_current_op & 0x0FFF;
}

BNNN - Jump to location NNN+V0

void TCpu::jump_with_v0()
{
    uint16_t nnn = m_current_op & 0x0FFF;

    m_pcreg = nnn + m_reg[0];
}

CXKK - Generate random number

void TCpu::generate_random_number()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    uint8_t kk = m_current_op & 0xFF;
    uint8_t randNum = rand() % 256;

    m_reg[reg] = randNum & kk;
}

DXYN - Draw sprite

void TCpu::draw_sprite()
{
    uint8_t v_reg_x = (m_current_op & 0x0F00) >> 8;
    uint8_t v_reg_y = (m_current_op & 0x00F0) >> 4;   
    uint8_t sprite_height = m_current_op & 0x000F;
    uint8_t x_location = m_reg[v_reg_x];
    uint8_t y_location = m_reg[v_reg_y];

    // Reset colision register
    m_reg[0xF] = 0;
    for(int y_coordinate = 0; y_coordinate < sprite_height; y_coordinate++)
    {
        uint8_t pixel = m_machine->m_ram[m_ireg + y_coordinate];
        for(int x_coordinate = 0; x_coordinate < 8; x_coordinate++)
        {
            if((pixel & (0x80 >> x_coordinate)) != 0)
            { 
                if(m_machine->m_screen[y_location + y_coordinate][x_location + x_coordinate] == 1)
                    m_reg[0xF] = 1;
                
                m_machine->m_screen[y_location + y_coordinate][x_location + x_coordinate] ^= 0x1;
            }
        }
    }
}

EX9E - Skip next instruction if Vx key is pressed

void TCpu::skip_next_inst_if_key_pressed()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    uint8_t val = m_reg[reg];

    if(m_machine->m_keys[val] != 0)
        m_pcreg += 2;
}

EXA1 - Skip next instruction if Vx key isn't pressed

void TCpu::skip_next_inst_if_key_pressed()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    uint8_t val = m_reg[reg];

    if(m_machine->m_keys[val] != 0)
        m_pcreg += 2;
}

FX07 - Set register with delay timer

void TCpu::load_reg_with_delay_timer()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    m_reg[reg] = m_machine->m_delay_timer;
}

FX0A - Wait for a key press

void TCpu::wait_key_press()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    bool key_pressed = false;

    for(int i=0; i<NUM_KEYS; i++)
    {
        if(m_machine->m_keys[i] != 0)
        {
            m_reg[reg] = i;
            key_pressed = true;
        }
    }

    if(!key_pressed)
        m_pcreg -= 2;
}

FX15 - Set delay timer with register

void TCpu::load_delay_timer_with_reg()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    m_machine->m_delay_timer = m_reg[reg];
}

FX18 - Set sound timer with register

void TCpu::load_sound_timer_with_reg()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    m_machine->m_sound_timer = m_reg[reg];
}

FX1E - Add Vx to I register

void TCpu::add_ireg_with_reg()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    m_ireg += m_reg[reg];
}

FX29 - Set I register with sprite location

void TCpu::load_font_from_vx()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    m_ireg = m_reg[reg] * 0x5;
}

FX33 - Store BCD representation of Vx

void TCpu::store_binary_code_decimal_representation()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    m_machine->m_ram[m_ireg] = m_reg[reg] / 100;
    m_machine->m_ram[m_ireg+1] = (m_reg[reg] / 10) % 10;
    m_machine->m_ram[m_ireg+1] = (m_reg[reg] % 100) % 10;
}

FX55 - Store registers V0 through Vx in memory starting at location I

void TCpu::load_memory_from_regs()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    for(int i=0; i<=reg; i++)
        m_machine->m_ram[m_ireg + i] = m_reg[i];

    m_ireg += (reg + 1);
}

FX65 - Read registers V0 through Vx from memory starting at location I

void TCpu::load_regs_from_memory()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    for(int i=0; i<=reg; i++)
        m_reg[i] = m_machine->m_ram[m_ireg + i];

    m_ireg += (reg + 1);
}

The final version for cpu.h and cpu.cpp looks like the following:

#ifndef __CPU_H__
#define __CPU_H__

#include <cstdint>

#include "chip8.h"
#include "logger.h"

#define NUM_V_REGISTERS 16
#define PC_START 0x200

class TCpu
{
    // registers
    uint8_t m_reg[NUM_V_REGISTERS];
    uint16_t m_ireg;
    uint16_t m_pcreg;
    uint16_t m_sp_reg;

    // helper variable
    uint16_t m_current_op;              // current opcode being executed
    uint16_t m_instruction;

    TChip8* m_machine;

    // Instructions implementation
    void decode_0_instruction();                        // 0ZZZ
    void clear_screen();                                // 0ZE0
    void return_from_subroutine();                      // 0ZEE
    void jump_to();                                     // 1NNN
    void call_subroutine();                             // 2NNN
    void skip_next_instruction_eq();                    // 3XNN
    void skip_next_instruction_ne();                    // 4XNN
    void skip_next_instruction_vx_vy();                 // 5XYZ
    void register_set();                                // 6XNN
    void add_reg_imm();                                 // 7XNN
    void decode_8_instruction();                        // 8XYZ
    void move_vy_to_vx();                               // 8XY0
    void or_vx_vy();                                    // 8XY1
    void and_vx_vy();                                   // 8XY2
    void xor_vx_vy();                                   // 8XY3
    void add_vx_vy();                                   // 8XY4
    void sub_vx_vy();                                   // 8XY5
    void shift_right_reg();                             // 8XY6
    void subn_vx_vy();                                  // 8XY7
    void shift_left_reg();                              // 8XYE
    void skip_next_instruction_vx_vy_ne();              // 9XYZ
    void set_index_register();                          // ANNN
    void jump_with_v0();                                // BNNN
    void generate_random_number();                      // CXKK
    void draw_sprite();                                 // DXYN
    void decode_E_instruction();                        // EZZZ
    void skip_next_inst_if_key_pressed();               // EX9E
    void skip_next_inst_if_not_pressed();               // EXA1
    void decode_F_instruction();                        // FZZZ
    void load_reg_with_delay_timer();                   // FX07
    void wait_key_press();                              // FX0A
    void load_delay_timer_with_reg();                   // FX15
    void load_sound_timer_with_reg();                   // FX18
    void add_ireg_with_reg();                           // FX1E
    void load_font_from_vx();                           // FX29
    void store_binary_code_decimal_representation();    // FX33
    void load_memory_from_regs();                       // FX55
    void load_regs_from_memory();                       // FX65

    // Logging
    std::shared_ptr<TLogger> m_logger;

public:
    TCpu(TChip8* machine);
    ~TCpu();
    void init();
    void fetch();
    void decode();
    void execute();
    void deinit();
};

#endif
#include <cstdlib>
#include <ctime> 

#include "cpu.h"
#include "logger.h"

TCpu::TCpu(TChip8* machine)
{
    m_machine = machine;
    m_logger = TLogger::getInstance();
}

TCpu::~TCpu()
{

}

void TCpu::init()
{
    // Initialize cpu registers
    m_pcreg = PC_START;
    m_current_op = 0;
    m_sp_reg = 0;
    m_ireg = 0;
    m_instruction = 0;

    // Clear registers
    for(auto i = 0; i < NUM_V_REGISTERS; i++)
        m_reg[i] = 0;

    // Initialize the random generator
    srand(time(0));
}

void TCpu::fetch()
{
    m_current_op = ((uint16_t)m_machine->m_ram[m_pcreg] << 8) | m_machine->m_ram[m_pcreg+1];
    m_pcreg += 2;
}

void TCpu::decode()
{
    m_instruction = m_current_op >> 12;
}

void TCpu::execute()
{
    m_logger->log("INST " + std::to_string(m_instruction), ELogLevel::DEBUG);

    switch(m_instruction)
    {
        case 0x0: decode_0_instruction(); break;
        case 0x1: jump_to(); break;
        case 0x2: call_subroutine(); break;
        case 0x3: skip_next_instruction_eq(); break;
        case 0x4: skip_next_instruction_ne(); break;
        case 0x5: skip_next_instruction_vx_vy(); break;
        case 0x6: register_set(); break;
        case 0x7: add_reg_imm(); break;
        case 0x8: decode_8_instruction(); break;
        case 0x9: skip_next_instruction_vx_vy_ne(); break;
        case 0xA: set_index_register(); break;
        case 0xB: jump_with_v0(); break;
        case 0xC: generate_random_number(); break;
        case 0xD: draw_sprite(); break;
        case 0xE: decode_E_instruction(); break;
        case 0xF: decode_F_instruction(); break;
        default: m_logger->log("IMPOSSIBLE INSTRUCTION" + std::to_string(m_instruction), ELogLevel::ERROR);
    }
}

void TCpu::deinit()
{

}

// Private functions
/*****************************
 * 0ZZZ
 *****************************/
void TCpu::decode_0_instruction()
{
    switch(m_current_op & 0xFF)
    {
        case 0xE0: clear_screen(); break;
        case 0xEE: return_from_subroutine(); break;
        default: m_logger->log("Instruction 0 with code " + std::to_string(m_current_op & 0xFF), ELogLevel::ERROR);
    }
}

/*****************************
 * 0ZE0
 *****************************/
void TCpu::clear_screen()
{
    for(int i=0; i<SCREEN_HEIGHT; i++)
        for(int j=0; j<SCREEN_WIDTH; j++)
            m_machine->m_screen[i][j]=0;
}

/*****************************
 * 0ZEE
 *****************************/
void TCpu::return_from_subroutine()
{
    m_sp_reg--;
    m_pcreg = m_machine->m_stack[m_sp_reg];
}

/*****************************
 * 1NNN
 *****************************/
void TCpu::jump_to()
{
    m_pcreg = m_current_op & 0x0FFF;
}

/*****************************
 * 2NNN
 *****************************/
void TCpu::call_subroutine()
{
    uint16_t nnn = m_current_op & 0x0FFF;

    m_machine->m_stack[m_sp_reg] = m_pcreg;
    m_sp_reg++;
    m_pcreg = nnn;
}

/*****************************
 * 3XNN
 *****************************/
void TCpu::skip_next_instruction_eq()
{
    uint8_t value = m_current_op & 0xFF;
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    if(m_reg[reg] == value)
        m_pcreg += 2;
}

/*****************************
 * 4XNN
 *****************************/
void TCpu::skip_next_instruction_ne()
{
    uint8_t value = m_current_op & 0xFF;
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    if(m_reg[reg] != value)
        m_pcreg += 2;
}

/*****************************
 * 5XYZ
 *****************************/
void TCpu::skip_next_instruction_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    if(m_reg[reg_x] == m_reg[reg_y])
        m_pcreg += 2;
}

/*****************************
 * 6XNN
 *****************************/
void TCpu::register_set()
{
    uint8_t value = m_current_op & 0xFF;
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    m_reg[reg] = value;
}

/*****************************
 * 7XNN
 *****************************/
void TCpu::add_reg_imm()
{
    uint8_t value = m_current_op & 0xFF;
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    m_reg[reg] += value;
}

/*****************************
 * 8XYZ
 *****************************/
void TCpu::decode_8_instruction()
{
    switch(m_current_op & 0xF)
    {
        case 0x0: move_vy_to_vx(); break;
        case 0x1: or_vx_vy(); break;
        case 0x2: and_vx_vy(); break;
        case 0x3: xor_vx_vy(); break;
        case 0x4: add_vx_vy(); break;
        case 0x5: sub_vx_vy(); break;
        case 0x6: shift_right_reg(); break;
        case 0x7: subn_vx_vy(); break;
        case 0xE: shift_left_reg(); break;
        default: m_logger->log("Instruction 8 with code " + std::to_string(m_current_op & 0xF), ELogLevel::ERROR);
    }
}

/*****************************
 * 8XY0
 *****************************/
void TCpu::move_vy_to_vx()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    m_reg[reg_x] = m_reg[reg_y];
}

/*****************************
 * 8XY1
 *****************************/
void TCpu::or_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    m_reg[reg_x] |= m_reg[reg_y];
}

/*****************************
 * 8XY2
 *****************************/
void TCpu::and_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    m_reg[reg_x] &= m_reg[reg_y];
}

/*****************************
 * 8XY3
 *****************************/
void TCpu::xor_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    m_reg[reg_x] ^= m_reg[reg_y];
}

/*****************************
 * 8XY4
 *****************************/
void TCpu::add_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;
    uint16_t add = m_reg[reg_x] + m_reg[reg_y];

    if(add > 0xFF)
        m_reg[0xF] = 1;
    else
        m_reg[0xF] = 0;

    m_reg[reg_x] = add & 0xFF;
}

/*****************************
 * 8XY5
 *****************************/
void TCpu::sub_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    if(m_reg[reg_x] > m_reg[reg_y])
        m_reg[0xF] = 1;
    else
        m_reg[0xF] = 0;

    m_reg[reg_x] -= m_reg[reg_y];
}

/*****************************
 * 8XY6
 *****************************/
void TCpu::shift_right_reg()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    if(m_reg[reg] % 2 == 1 )
        m_reg[0xF] = 1;
    else
        m_reg[0xF] = 0;

    m_reg[reg] >>= 1;
}

/*****************************
 * 8XY7
 *****************************/
void TCpu::subn_vx_vy()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    if(m_reg[reg_y] > m_reg[reg_x])
        m_reg[0xF] = 1;
    else
        m_reg[0xF] = 0;

    m_reg[reg_x] = m_reg[reg_y] - m_reg[reg_x];
}

/*****************************
 * 8XYE
 *****************************/
void TCpu::shift_left_reg()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    if(m_reg[reg] & 0x80 == 1 )
        m_reg[0xF] = 1;
    else
        m_reg[0xF] = 0;

    m_reg[reg] <<= 1;
}

/*****************************
 * 9XYZ
 *****************************/
void TCpu::skip_next_instruction_vx_vy_ne()
{
    uint8_t reg_x = (m_current_op >> 8) & 0x0F;
    uint8_t reg_y = (m_current_op >> 4) & 0x0F;

    if(m_reg[reg_x] != m_reg[reg_y])
        m_pcreg += 2;
}

/*****************************
 * ANNN
 *****************************/
void TCpu::set_index_register()
{
    m_ireg = m_current_op & 0x0FFF;
}

/*****************************
 * BNNN
 *****************************/
void TCpu::jump_with_v0()
{
    uint16_t nnn = m_current_op & 0x0FFF;

    m_pcreg = nnn + m_reg[0];
}

/*****************************
 * CXKK
 *****************************/
void TCpu::generate_random_number()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    uint8_t kk = m_current_op & 0xFF;
    uint8_t randNum = rand() % 256;

    m_reg[reg] = randNum & kk;
}

/*****************************
 * DXYN
 *****************************/
void TCpu::draw_sprite()
{
    uint8_t v_reg_x = (m_current_op & 0x0F00) >> 8;
    uint8_t v_reg_y = (m_current_op & 0x00F0) >> 4;   
    uint8_t sprite_height = m_current_op & 0x000F;
    uint8_t x_location = m_reg[v_reg_x];
    uint8_t y_location = m_reg[v_reg_y];

    // Reset colision register
    m_reg[0xF] = 0;
    for(int y_coordinate = 0; y_coordinate < sprite_height; y_coordinate++)
    {
        uint8_t pixel = m_machine->m_ram[m_ireg + y_coordinate];
        for(int x_coordinate = 0; x_coordinate < 8; x_coordinate++)
        {
            if((pixel & (0x80 >> x_coordinate)) != 0)
            { 
                if(m_machine->m_screen[y_location + y_coordinate][x_location + x_coordinate] == 1)
                    m_reg[0xF] = 1;
                
                m_machine->m_screen[y_location + y_coordinate][x_location + x_coordinate] ^= 0x1;
            }
        }
    }
}

/*****************************
 * EZZZ
 *****************************/
void TCpu::decode_E_instruction()
{
    switch(m_current_op & 0xFF)
    {
        case 0x009E: skip_next_inst_if_key_pressed(); break;
        case 0x00A1: skip_next_inst_if_not_pressed(); break;
        default: m_logger->log("Instruction E with code " + std::to_string(m_current_op & 0xFF), ELogLevel::ERROR);
    }
}

/*****************************
 * EX9E
 *****************************/
void TCpu::skip_next_inst_if_key_pressed()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    uint8_t val = m_reg[reg];

    if(m_machine->m_keys[val] != 0)
        m_pcreg += 2;
}

/*****************************
 * EXA1
 *****************************/
void TCpu::skip_next_inst_if_not_pressed()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    uint8_t val = m_reg[reg];

    if(m_machine->m_keys[val] == 0)
        m_pcreg += 2;
}

/*****************************
 * FZZZ
 *****************************/
void TCpu::decode_F_instruction()
{
    switch(m_current_op & 0xFF)
    {
        case 0x0007: load_reg_with_delay_timer(); break;
        case 0x000A: wait_key_press(); break;
        case 0x0015: load_delay_timer_with_reg(); break;
        case 0x0018: load_sound_timer_with_reg(); break;
        case 0x001E: add_ireg_with_reg(); break;
        case 0x0029: load_font_from_vx(); break;
        case 0x0033: store_binary_code_decimal_representation(); break;
        case 0x0055: load_memory_from_regs(); break;
        case 0x0065: load_regs_from_memory(); break;
        default: m_logger->log("Instruction F with code " + std::to_string(m_current_op & 0xFF), ELogLevel::ERROR);
    }
}

/*****************************
 * FX07
 *****************************/
void TCpu::load_reg_with_delay_timer()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    m_reg[reg] = m_machine->m_delay_timer;
}

/*****************************
 * FX0A
 *****************************/
void TCpu::wait_key_press()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    bool key_pressed = false;

    for(int i=0; i<NUM_KEYS; i++)
    {
        if(m_machine->m_keys[i] != 0)
        {
            m_reg[reg] = i;
            key_pressed = true;
        }
    }

    if(!key_pressed)
        m_pcreg -= 2;
}

/*****************************
 * FX15
 *****************************/
void TCpu::load_delay_timer_with_reg()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    m_machine->m_delay_timer = m_reg[reg];
}

/*****************************
 * FX18
 *****************************/
void TCpu::load_sound_timer_with_reg()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    m_machine->m_sound_timer = m_reg[reg];
}

/*****************************
 * FX1E
 *****************************/
void TCpu::add_ireg_with_reg()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    m_ireg += m_reg[reg];
}

/*****************************
 * FX29
 *****************************/
void TCpu::load_font_from_vx()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;
    m_ireg = m_reg[reg] * 0x5;
}

/*****************************
 * FX33
 *****************************/
void TCpu::store_binary_code_decimal_representation()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    m_machine->m_ram[m_ireg] = m_reg[reg] / 100;
    m_machine->m_ram[m_ireg+1] = (m_reg[reg] / 10) % 10;
    m_machine->m_ram[m_ireg+1] = (m_reg[reg] % 100) % 10;
}

/*****************************
 * FX55
 *****************************/
void TCpu::load_memory_from_regs()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    for(int i=0; i<=reg; i++)
        m_machine->m_ram[m_ireg + i] = m_reg[i];

    m_ireg += (reg + 1);
}

/*****************************
 * FX65
 *****************************/
void TCpu::load_regs_from_memory()
{
    uint8_t reg = (m_current_op >> 8) & 0x0F;

    for(int i=0; i<=reg; i++)
        m_reg[i] = m_machine->m_ram[m_ireg + i];

    m_ireg += (reg + 1);
}

11 - Display

For the display it was decided to use a design pattern called strategy in order to try abstract the specific implementation of the class that will handle the image on the host.

So we are starting to create the abstraction class for display. For that we are going to create a new files, display.h.

#ifndef __DISPLAY_H__
#define __DISPLAY_H__

#include <cstdint>

class TDisplay
{
public:
    virtual void init() = 0; 
    virtual void draw(uint8_t framebuffer[][64], uint16_t width, uint16_t height) = 0;
    virtual void update() = 0;
    virtual void deinit() = 0;
};

#endif

So after we defined the interface class, we can now define the the actual display class that will be dependent from the used library. Since we are using SDL in this guide, we are going to create two new files, displaysdl.h and displaysdl.cpp.

#ifndef __DISPLAYSDL_H__
#define __DISPLAYSDL_H__

#include <cstdint>
#include <SDL2/SDL.h>

#include "display.h"
#include "logger.h"

class TDisplaySDL : public TDisplay
{
    SDL_Window* m_window;
    SDL_Surface* m_surface;

    // Logging
    std::shared_ptr<TLogger> m_logger;

public:
    TDisplaySDL();
    ~TDisplaySDL();
    
    virtual void init() override; 
    virtual void draw(uint8_t framebuffer[][64], uint16_t width, uint16_t height) override;
    virtual void update() override;
    virtual void deinit() override;
};

#endif
#include <cstdint>
#include <SDL2/SDL.h>

#include "displaysdl.h"

TDisplaySDL::TDisplaySDL()
{
    m_logger = TLogger::getInstance();
}

TDisplaySDL::~TDisplaySDL()
{

}
    
void TDisplaySDL::init()
{
    // Initialize SDL
    if (SDL_Init(SDL_INIT_VIDEO) != 0) 
    {
        std::string errorSdl(SDL_GetError());
        m_logger->log("SDL Initialization Error: " + errorSdl, ELogLevel::ERROR);
        exit(1);
    }

    // Create a window
    m_window = SDL_CreateWindow("TMVTech chip8",
                                SDL_WINDOWPOS_UNDEFINED,
                                SDL_WINDOWPOS_UNDEFINED,
                                1280, 720, SDL_WINDOW_SHOWN);

    if(!m_window)
    {
        std::string errorWindow(SDL_GetError());
        m_logger->log("Window creation Error: " + errorWindow, ELogLevel::ERROR);
        exit(1);
    }

    m_surface = SDL_GetWindowSurface(m_window);
}

void setPixel(SDL_Surface *surface, int x, int y, Uint32 color) {
    if (x >= 0 && x < surface->w && y >= 0 && y < surface->h) {
        Uint32 *pixels = (Uint32 *)surface->pixels;
        pixels[(y * surface->w) + x] = color;
    }
}

void TDisplaySDL::draw(uint8_t framebuffer[][64], uint16_t width, uint16_t height)
{
    uint8_t zoomFactor = 10;

    if(SDL_MUSTLOCK(m_surface))
        SDL_LockSurface(m_surface);

    // Clear the screen
    Uint32 color = SDL_MapRGB(m_surface->format, 0x00, 0x00, 0x00); // Black

    // Fill the entire surface with the black color
    SDL_FillRect(m_surface, NULL, color);

    Uint32 white = SDL_MapRGB(m_surface->format, 255, 255, 255);  // White color
    Uint32 green = SDL_MapRGB(m_surface->format, 0, 255, 0);  // Green color

    for(auto i=0; i<height; i++)
    {
        for(auto j=0; j<width; j++)
        {
            if(framebuffer[i][j] == 1)
            {
                for (auto x = 0; x < zoomFactor; x++)
                {
                    for (auto y = 0; y < zoomFactor; y++)
                    {
                        setPixel(m_surface, j*zoomFactor+x, i*zoomFactor+y, green);
                    }
                }
            }
        }   
    }
        

    if(SDL_MUSTLOCK(m_surface))
        SDL_UnlockSurface(m_surface);
}

void TDisplaySDL::update()
{
    // Update the window with the surface
    SDL_UpdateWindowSurface(m_window);

    // Check events
    SDL_Event e;
    if (SDL_PollEvent(&e) != 0) 
    {
        // User requests quit
        if (e.type == SDL_QUIT) {
            m_logger->log("CLOSING: ", ELogLevel::ERROR);
        }
    }
}

void TDisplaySDL::deinit()
{
    SDL_DestroyWindow(m_window);
    SDL_Quit();
}

Now that we have the sdldisplay class added we need to add the new file to the Makefile, and also add the necessary code into the chip8.h, chip8.cpp and main.cpp.

CPPFILES= 	main.cpp				\
			logger.cpp				\
			cmdLineParser.cpp		\
			chip8.cpp				\
			romLoader.cpp			\
			cpu.cpp					\
			displaysdl.cpp			\
// chip8.h

#ifndef __CHIP8_H__
#define __CHIP8_H__

#include <cstdint>
#include <iostream>

#include "display.h"
#include "logger.h"
#include "romLoader.h"

...

    // ROM Loader
    TRomLoader* m_loader;
    
    // Display
    TDisplay* m_display;
    
...

    void init(std::string rom_path);
    void run();
    void deinit();

	void setDisplay(TDisplay* display);
};

#endif
// chip8.cpp

...

TChip8::TChip8()
{
    m_logger = TLogger::getInstance();
    m_cpu = new TCpu(this);
    m_emulatorRunning = true;
    m_display = nullptr;
}

...

    // Load ROM to the ram
    m_loader = new TRomLoader();
    m_loader->loadRom(rom_path, m_ram+PROGRAM_START_ADDR);
    delete m_loader;
    
    // Initialize display lib
    m_display->init();
}

...

void TChip8::run()
{
	while(m_emulatorRunning)
    {
    	m_cpu->fetch();
        m_cpu->decode();
        m_cpu->execute();
        
        m_display->draw(m_screen, SCREEN_WIDTH, SCREEN_HEIGHT);
        m_display->update();
    }
}

void TChip8::deinit()
{
	m_cpu->deinit();
	
	// Cleanup display resources
    m_display->deinit();
}

void TChip8::setDisplay(TDisplay* display)
{
    m_display = display;
}
// main.cpp

#include "chip8.h"
#include "displaysdl.h"
#include "cmdLineParser.h"

...

int main(int argc, char** argv)
{
    ...
    
    TDisplaySDL display;
    TChip8 emulator;
    emulator.setDisplay(&display);
    emulator.init(cmdParser.getRomFileName());
    emulator.run();
    emulator.deinit();

    return 0;
}

At this point if you build and run the program it should pop up a windows with some display there depending on what ROM you loaded.

We won’t launch it just now, because at the moment the emulator is refreshing the SDL library to fast making it take an unreasonable amount of resources making you computer super slow. So we will fix the timings before we launch the emulator. Let’s go for the next step.

12 - Fix the emulation timing

So in the TChip8::run() method we can see that in every iteration we are calling the function update from the display. This behaviour is not desired. So at this step we are taking care of slowing down this cycle to resemble the CHIP8 cycle time.

Since the CHIP8 specifications don’t state an amount of instructions per second, we are using a value of 1000 instructions per second. This means that the main while loop should iterate every 1mS.

Using the 1mS per iteration also helps to calculate another timing for the timers that should tick at a rate of 60Hz (every 20mS).

void TChip8::run()
{
	using clock = std::chrono::high_resolution_clock;
    clock::time_point start, end;
    const std::chrono::milliseconds desired_cycle_time(1);

    int display_update_delay_time = 0;

	while(m_emulatorRunning)
    {
    	start = clock::now();
    	
    	m_cpu->fetch();
        m_cpu->decode();
        m_cpu->execute();
        
        if(display_update_delay_time >= 20)
        {
        	display_update_delay_time = 0;
        	m_display->draw(m_screen, SCREEN_WIDTH, SCREEN_HEIGHT);
        	m_display->update();
        
        	// Update timers ~60Hz
        	if(m_delay_timer > 0)
            	m_delay_timer--;

        	if(m_sound_timer > 0)
            	m_sound_timer--;
        }
        
        end = clock::now();
        
        std::chrono::duration<double, std::micro> loop_time = end - start;
        // Calculate the elapsed time in milliseconds
        auto elapsed_time = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
        // Calculate the remaining time to reach the desired cycle time
        auto sleep_time = desired_cycle_time - elapsed_time;

        // If the loop logic took less than the desired time, wait for the remaining time
        if (sleep_time.count() > 0) {
            std::this_thread::sleep_for(sleep_time);
        }

        // update tick counter
        display_update_delay_time++;
    }
}

With the changes above, we calculate the cycle time and try to sleep the remaing time to be aproximaly 1mS. We also introduced a variable that increments every iteration of the loop, and since we tried to make every iteration of the loop 1mS when this variable reaches 20, we can update the display data without over consuming system resources.

Now if you recompile and run the emulator you should see a new window that looks like the image below.

You may have noticed that if you try to press X to exit the window it doesn’t do anything. That’s a issue that we will tackle in the next step when we also add the keyboard input.

13 - Keyboard

For the keyboard we are going to use the same design pattern that we used for the display.

So we are going to start by defining the abstraction class for the keyboard. For that we are going to create the file keyboard.h.

#ifndef __KEYBOARD_H__
#define __KEYBOARD_H__

#include <cstdint>

class TKeyboard
{
public:
    virtual void init() = 0;
    virtual void update(uint8_t* key_map, bool* running) = 0;
    virtual void deinit() = 0;
};

#endif

And then create the SDL specific class to handle the keyboard keys and the window events. For this we are going to create two new files keyboardsdl.h and keyboardsdl.cpp.

#ifndef __KEYBOARDSDL_H__
#define __KEYBOARDSDL_H__

#include <cstdint>

#include "keyboard.h"
#include "logger.h"

class TKeyboardSDL : public TKeyboard
{
    // Logging
    std::shared_ptr<TLogger> m_logger;

public:
    TKeyboardSDL();
    ~TKeyboardSDL();

    virtual void init() override;
    virtual void update(uint8_t* key_map, bool* running) override;
    virtual void deinit() override;
};

#endif
#include <cstdint>
#include <SDL2/SDL.h>

#include "keyboardsdl.h"

TKeyboardSDL::TKeyboardSDL()
{
    m_logger = TLogger::getInstance();
}

TKeyboardSDL::~TKeyboardSDL()
{

}

void TKeyboardSDL::init()
{

}

void TKeyboardSDL::update(uint8_t* key_map, bool* running)
{
    SDL_Event e;
    if (SDL_PollEvent(&e) != 0)
    {
        if (e.type == SDL_KEYDOWN)
        {
            switch (e.key.keysym.sym) 
            {
                case SDLK_1: key_map[0x1] = 1; break;
                case SDLK_2: key_map[0x2] = 1; break;
                case SDLK_3: key_map[0x3] = 1; break;
                case SDLK_4: key_map[0xC] = 1; break;
                case SDLK_q: key_map[0x4] = 1; break;
                case SDLK_w: key_map[0x5] = 1; break;
                case SDLK_e: key_map[0x6] = 1; break;
                case SDLK_r: key_map[0xD] = 1; break;
                case SDLK_a: key_map[0x7] = 1; break;
                case SDLK_s: key_map[0x8] = 1; break;
                case SDLK_d: key_map[0x9] = 1; break;
                case SDLK_f: key_map[0xE] = 1; break;
                case SDLK_z: key_map[0xA] = 1; break;
                case SDLK_x: key_map[0x0] = 1; break;
                case SDLK_c: key_map[0xB] = 1; break;
                case SDLK_v: key_map[0xF] = 1; break;
            }
        }
        else if(e.type == SDL_KEYUP)
        {
            switch (e.key.keysym.sym) 
            {
                case SDLK_1: key_map[0x1] = 0; break;
                case SDLK_2: key_map[0x2] = 0; break;
                case SDLK_3: key_map[0x3] = 0; break;
                case SDLK_4: key_map[0xC] = 0; break;
                case SDLK_q: key_map[0x4] = 0; break;
                case SDLK_w: key_map[0x5] = 0; break;
                case SDLK_e: key_map[0x6] = 0; break;
                case SDLK_r: key_map[0xD] = 0; break;
                case SDLK_a: key_map[0x7] = 0; break;
                case SDLK_s: key_map[0x8] = 0; break;
                case SDLK_d: key_map[0x9] = 0; break;
                case SDLK_f: key_map[0xE] = 0; break;
                case SDLK_z: key_map[0xA] = 0; break;
                case SDLK_x: key_map[0x0] = 0; break;
                case SDLK_c: key_map[0xB] = 0; break;
                case SDLK_v: key_map[0xF] = 0; break;
            }
        }
        else if (e.type == SDL_QUIT) 
            *running = false;
            
    }
}

void TKeyboardSDL::deinit()
{
    
}

With the new files in place we can now add the necessary entries in the Makefile, chip8.h, chip8.cpp and main.cpp.

CPPFILES= 	main.cpp				\
			logger.cpp				\
			cmdLineParser.cpp		\
			chip8.cpp				\
			romLoader.cpp			\
			cpu.cpp					\
			displaysdl.cpp			\
			keyboardsdl.cpp			\
// chip8.h

#ifndef __CHIP8_H__
#define __CHIP8_H__

#include <cstdint>
#include <iostream>

#include "display.h"
#include "keyboard.h"
#include "logger.h"
#include "romLoader.h"

...

// Display
    TDisplay* m_display;
    
    // Keyboard
    TKeyboard* m_keyboard;
    
    ...
    
    void setDisplay(TDisplay* display);
	void setKeyboard(TKeyboard* keyboard);
// chip8.cpp

TChip8::TChip8()
{
    m_logger = TLogger::getInstance();
    m_cpu = new TCpu(this);
    m_emulatorRunning = true;
    m_display = nullptr;
    m_keyboard = nullptr;
}

void TChip8::init(std::string rom_path)
{
    ...
    
    // Initialize display lib
    m_display->init();

    // Initialize keyboard lib
    m_keyboard->init();
}

void TChip8::run()
{
    ...
    
    m_keyboard->update(m_keys, &m_emulatorRunning);

    end = clock::now();
    
    ...
}

void TChip8::deinit()
{
	m_cpu->deinit();
	
    m_display->deinit();
    
    m_keyboard->deinit();
}

void TChip8::setKeyboard(TKeyboard* keyboard)
{
    m_keyboard = keyboard;
}
// main.cpp

#include "chip8.h"
#include "displaysdl.h"
#include "cmdLineParser.h"
#include "keyboardsdl.h"
#include "logger.h"

...

    TDisplaySDL display;
    TKeyboardSDL keyboard;
    TChip8 emulator;
    emulator.setDisplay(&display);
    emulator.setKeyboard(&keyboard);
    emulator.init(cmdParser.getRomFileName());
    emulator.run();
    emulator.deinit();
    
...

With all the necessary changes in place, if you rebuild the emulator and relaunch it with a ROM from a game like Pong you should be able to press keyboard keys and see your platform moving. Also if you press the window X to close it shoud work now.

Below is the image of the game pong running:

14 - Sound

For the sound implementation we are going to use the same design pattern strategy. So we need to start by defining the abstract class of sound, like the following:

#ifndef __SOUND_H__
#define __SOUND_H__

class TSound
{
public:
    virtual void init() = 0;
    virtual void playTune() = 0;
    virtual void pauseTune() = 0;
    virtual void deinit() = 0;
};

#endif

After the interface class is defines, we  can now create and implement the actual class that will handle the sound. In this case we are using the SDL library again, so we are going to create two new files, soundsdl.h and soundsdl.cpp.

#ifndef __SOUNDSDL_H__
#define __SOUNDSDL_H__

#include <SDL2/SDL.h>

#include "logger.h"
#include "sound.h"

class TSoundSDL : public TSound
{
    SDL_AudioSpec m_audioSpec;

    // Logging
    std::shared_ptr<TLogger> m_logger;

public:
    TSoundSDL();
    ~TSoundSDL();

    virtual void init() override;
    virtual void playTune() override;
    virtual void pauseTune() override;
    virtual void deinit() override;
};

#endif
#include <SDL2/SDL.h>

#include "soundsdl.h"


// Constants
const int SAMPLE_RATE = 44100; // Sample rate
const int AMPLITUDE = 28000;    // Amplitude
const double FREQUENCY = 440.0; // Frequency of the sine wave (A4)

// Audio callback function
void audioCallback(void* userdata, Uint8* stream, int len) {
    static double phase = 0.0;
    double increment = (2.0 * M_PI * FREQUENCY) / SAMPLE_RATE;

    for (int i = 0; i < len; i += 2) {
        short sample = static_cast<short>(AMPLITUDE * sin(phase));
        phase += increment;

        if (phase > 2.0 * M_PI) {
            phase -= 2.0 * M_PI;
        }

        // Write the sample to the audio stream
        stream[i] = sample & 0xFF;       // Low byte
        stream[i + 1] = (sample >> 8) & 0xFF; // High byte
    }
}

TSoundSDL::TSoundSDL()
{
    m_logger = TLogger::getInstance();
}

TSoundSDL::~TSoundSDL()
{

}

void TSoundSDL::init()
{
    if (SDL_Init(SDL_INIT_AUDIO) < 0)
    {
        std::string errorSdl(SDL_GetError());
        m_logger->log("SDL Sound Initialization Error: " + errorSdl, ELogLevel::ERROR);
        exit(1);
    }

    m_audioSpec.freq = SAMPLE_RATE;
    m_audioSpec.format = AUDIO_S16SYS; // Signed 16-bit samples
    m_audioSpec.channels = 1;           // Mono audio
    m_audioSpec.samples = 4096;         // Buffer size
    m_audioSpec.callback = audioCallback;
    m_audioSpec.userdata = nullptr;

    if (SDL_OpenAudio(&m_audioSpec, nullptr) < 0)
    {
        std::string errorSdl(SDL_GetError());
        m_logger->log("Failed to open audio device: " + errorSdl, ELogLevel::ERROR);
        exit(1);
    }
}

void TSoundSDL::playTune()
{
    SDL_PauseAudio(0);
}

void TSoundSDL::pauseTune()
{
    SDL_PauseAudio(1);
}

void TSoundSDL::deinit()
{
    SDL_CloseAudio();
    SDL_Quit();
}

With the new files in place we can now add the necessary entries in the Makefile, chip8.h, chip8.cpp and main.cpp.

CPPFILES= 	main.cpp				\
			logger.cpp				\
			cmdLineParser.cpp		\
			chip8.cpp				\
			romLoader.cpp			\
			cpu.cpp					\
			displaysdl.cpp			\
			keyboardsdl.cpp			\
			soundsdl.cpp			\
// chip8.h
...

#include "romLoader.h"
#include "sound.h"

#define NUM_KEYS 16

...

    // Keyboard
    TKeyboard* m_keyboard;

    // Sound
    TSound* m_sound;

public:
    TChip8();
    ~TChip8();
    
...

    void setKeyboard(TKeyboard* keyboard);
    void setSound(TSound* sound);
};

#endif
// chip8.cpp
TChip8::TChip8()
{
    m_logger = TLogger::getInstance();
    m_cpu = new TCpu(this);
    m_emulatorRunning = true;
    m_display = nullptr;
    m_keyboard = nullptr;
    m_sound = nullptr;
}

void TChip8::init(std::string rom_path)
{
    ...
    
    // Initialize display lib
    m_display->init();

    // Initialize sound lib
    m_sound->init();
}

void TChip8::run()
{
    ...
    
            // Update timers ~60Hz
        	if(m_delay_timer > 0)
            	m_delay_timer--;

        	if(m_sound_timer > 0)
            {
                m_sound_timer--;
                m_sound->playTune();
            }
            else
                m_sound->pauseTune();
                
    ...
}

void TChip8::deinit()
{
	m_cpu->deinit();
	
    m_display->deinit();
    
    m_keyboard->deinit();

    m_sound->deinit();
}

void TChip8::setSound(TSound* sound)
{
    m_sound = sound;
}
// main.cpp
...
#include "soundsdl.h"

...

int main(int argc, char** argv)
{
    ...
    
    TDisplaySDL display;
    TKeyboardSDL keyboard;
    TSoundSDL sound;
    TChip8 emulator;
    emulator.setDisplay(&display);
    emulator.setKeyboard(&keyboard);
    emulator.setSound(&sound);
    emulator.init(cmdParser.getRomFileName());
    
    ...
}

With all the changes added, you can now rebuild the application and launch it with a game like Pong. In the pong game you should be able to hear sound whenever a point is scored and when the ball bounce successfully.

 

And that’s all. You now have a fully working CHIP8 emulator that you can play around. You can also check our full code on GitHub by clicking here.

As always, thanks for reading and stay tuned for more tech insights and tutorials. Until next time, keep exploring the world of tech!