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!