CS 181-AR

Challenge 7 — Starting on a ZX Spectrum Emulator

Due: Monday, March 10 at 4:15 p.m. (in class)
More Due: Monday, March 31 at 4:15 p.m. (in class)

Your task for this week is to create the bones on a ZX Spectrum emulator.

UPDATE: Monday, March 31

At this point, you ought to have a working ZX Spectrum emulator that can boot and load a snapshot. If you don't, or if you want to contrast your working code against a working example, head to this page:

Before you start…

  • If you want stay with the same team (or solo) arrangement as Challenge 1 or 2, you use your existing repository and create a Challenge07 directory in it.
  • If you want to work with a new group (or switch to solo work) Accept the Challenge on GitHub Classroom.
  • There is no starter code in the repository, so don't worry if you don't see anything much there. It's up to you to add material to the repository (in the Challenge07 directory) and then push your changes to GitHub.

You may find the following libraries useful for your project:

  • SDL2: A cross-platform library for graphics and sound (SDL3 also came out recently, but you're likely to find more examples and tutorials for SDL2)
  • SDL2 is recommended as Prof. Melissa has experience with it, but other similar libraries are available, including:
    • SDL3: The most recent version of SDL. Differences between SDL3 and SDL2 can be found here.
    • SFML: Another cross-platform library for graphics and sound
    • Allegro: A cross-platform library for graphics and sound
  • CHIPS Z80 CPU, although you can skip this documentation and just download the header file if you prefer, as it includes sufficient documentation inside.]
    • See also the Visual Z80 Remix project for a visual representation of the Z80 CPU executing code.

Note that although you can and should use the CHIPS Z80 CPU (and the handy Z80 disassembler) for the Z80 CPU emulation, the project also includes full emulators for a variety of systems (showcased here) which you may not copy from.

Choosing an Implementation Language

The implementation language needs to meet the following requirements:

  • Fast enough to emulate a ZX Spectrum at a reasonable speed
  • Able to interface with SDL2 or a similar library for graphics and sound
  • Able to interface with the CHIPS Z80 CPU library
  • A language you're suffiently comfortable with
  • A language you can get help with if you get stuck

Python is not recommended for this project, as it is likely to be too slow for the task. Neither is ZX Spectrum BASIC, as Inception-like as that might be.

Possible languages include:

  • C++ (recommended) — fast, good for interfacing with SDL2, and you can use the CHIPS Z80 CPU library directly. Prof. Melissa has extensive experience with C++ and can help you with issues. Students in the Mudd CS major will know C++ from taking CS 70.
  • C - similar to C++, but with less features and more complexity in some areas. If you're more comfortable with C than C++, this is a good choice.
  • Rust - a modern language that is fast and safe, but with a steeper learning curve than C or C++. If you're already into coding in Rust, this could be a good choice.
  • Go - a modern language that is fast and easy to learn, but with less control over memory management than C or C++. If you're already into coding in Go, this could be a good choice.
  • Java - a language that is fast enough for this task, but with more overhead than C or C++. If you're more comfortable with Java than C or C++, this might be an okay choice.

In general, if you want a straightforward path to success, C++ is the best choice as its a language Prof. Melissa knows well and can help you with and has already written a ZX Spectrum emulator in.

For this the challenge, you should:

Compile a “Hello SDL2” Program

Our first goal is to a simple program that uses SDL2 to open a window and a test pattern on the screen. This will help you get started with SDL2 and make sure you have it set up correctly.

Installing SDL2

To begin, ensure you have SDL installed. On a Mac with homebrew installed, you can do this with:

brew install sdl2

(if you don't have homebrew installed, you can get it from brew.sh; it's the easiest way to install open-source software on a Mac)

On Ubuntu, you can do this with:

sudo apt-get install libsdl2-dev

Whereas on Windows, you can download the SDL2 development libraries from the SDL GitHub releases page. Prof. Melissa does not run Windows, if you have issues you may need to look for help from your classmates or online.

Developing a Short Test Program

It's always good to begin with “working code” and enhance from there, so it's good to start with a simple program that uses SDL2. You can ask your favorite LLM to whip up a simple program for you, or you can use the following code as a starting point:

// paltv.cpp
#include <SDL.h>
#include <stdexcept>
#include <iostream>
#include <cstdint>
#include <cstdlib>
#include <random>
#include <limits>

// -----------------------------------------------------------------------------
// Display class: Handles window creation, rendering, texture updates, and frame
// generation.
class Display {
 public:
    static constexpr int WIDTH = 720;
    static constexpr int HEIGHT = 576;

    Display(const char* title) {
        window = SDL_CreateWindow(title, SDL_WINDOWPOS_CENTERED,
                                  SDL_WINDOWPOS_CENTERED, WIDTH, HEIGHT,
                                  SDL_WINDOW_SHOWN);
        if (!window)
            throw std::runtime_error(std::string("SDL_CreateWindow Error: ")
                                     + SDL_GetError());

        renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);
        if (!renderer)
            throw std::runtime_error(std::string("SDL_CreateRenderer Error: ")
                                     + SDL_GetError());

        texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_ARGB8888,
                                    SDL_TEXTUREACCESS_STREAMING, WIDTH, HEIGHT);
        if (!texture)
            throw std::runtime_error(std::string("SDL_CreateTexture Error: ")
                                     + SDL_GetError());

        pixels = new uint32_t[WIDTH * HEIGHT];
    }

    ~Display() {
        delete[] pixels;
        if (texture) SDL_DestroyTexture(texture);
        if (renderer) SDL_DestroyRenderer(renderer);
        if (window) SDL_DestroyWindow(window);
    }

    // Updates the frame: regenerates the display with color bars, scanlines,
    // and noise.
    void update() {
        generateFrame();
        SDL_UpdateTexture(texture, nullptr, pixels, WIDTH * sizeof(uint32_t));
        SDL_RenderClear(renderer);
        SDL_RenderCopy(renderer, texture, nullptr, nullptr);
        SDL_RenderPresent(renderer);
    }

 private:
    SDL_Window* window = nullptr;
    SDL_Renderer* renderer = nullptr;
    SDL_Texture* texture = nullptr;
    uint32_t* pixels = nullptr;

    // Random number generator; mt19937 produces 32-bit values.
    std::mt19937 rng{std::random_device{}()};
    static_assert(std::numeric_limits<std::mt19937::result_type>::max()
                      == 0xFFFFFFFF,
                  "mt19937 does not produce 32-bit values");

    // Generates a frame with classic color bars, a scanline effect, and some
    // noise.
    void generateFrame() {
        // 7 color bars (ARGB): white, yellow, cyan, green, magenta, red, blue.
        uint32_t colorBars[7] = {
            0xFFFFFFFF,  // White
            0xFFFFFF00,  // Yellow
            0xFF00FFFF,  // Cyan
            0xFF00FF00,  // Green
            0xFFFF00FF,  // Magenta
            0xFFFF0000,  // Red
            0xFF0000FF   // Blue
        };

        for (int y = 0; y < HEIGHT; ++y) {
            float brightness =
                (y % 2 == 0) ? 0.75f : 1.0f;  // Scanline dimming.
            for (int x = 0; x < WIDTH; ++x) {
                int barIndex = (x * 7) / WIDTH;
                uint32_t baseColor = colorBars[barIndex];

                // Extract ARGB components and apply brightness.
                uint8_t a = (baseColor >> 24) & 0xFF;
                uint8_t r = static_cast<uint8_t>(((baseColor >> 16) & 0xFF)
                                                 * brightness);
                uint8_t g = static_cast<uint8_t>(((baseColor >> 8) & 0xFF)
                                                 * brightness);
                uint8_t b =
                    static_cast<uint8_t>((baseColor & 0xFF) * brightness);
                uint32_t color = (a << 24) | (r << 16) | (g << 8) | b;

                // Generate a mask to add noise. 1s in the high bits (and
                // the alpha channel), random noise in the low order bits.
                uint32_t noise = rng() | 0xFFC0C0C0;
                color &= noise;

                pixels[y * WIDTH + x] = color;
            }
        }
    }
};

// -----------------------------------------------------------------------------
// System class: Manages the main event loop.
class System {
 public:
    System() : display("Retro PAL TV Simulation with Exceptions") {
    }

    void run() {
        bool quit = false;
        SDL_Event event;
        while (!quit) {
            while (SDL_PollEvent(&event)) {
                if (event.type == SDL_QUIT || event.type == SDL_KEYDOWN)
                    quit = true;
            }
            display.update();
            SDL_Delay(16);  // ~60 FPS
        }
    }

 private:
    Display display;
};

// -----------------------------------------------------------------------------
// Main: Initializes SDL, runs the system, and handles any exceptions.
int main(int argc, char* argv[]) {
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        std::cerr << "SDL_Init Error: " << SDL_GetError() << std::endl;
        return 1;
    }

    try {
        System system;
        system.run();
    } catch (const std::exception& ex) {
        std::cerr << "Exception caught: " << ex.what() << std::endl;
        SDL_Quit();
        return 1;
    }

    SDL_Quit();
    return 0;
}

Compiling and Running Your Program

On a Mac, run:

clang++ -Wall -O3 -std=c++20 -o paltv paltv.cpp $(sdl2-config --cflags --libs)

On Ubuntu, run:

g++ -Wall -O3 -std=c++20 -o paltv paltv.cpp $(sdl2-config --cflags --libs)

On Windows, do whatever Windows users do to compile C++ programs and link them with the necessary libraries; perhaps Visual Studio might be involved? Apparently one option is to use cmake. No doubt your favorite LLM can help you with this. Good luck!

Once compiled, on a Mac or Ubuntu, you can run your program with:

./paltv

Reviewing the Code

Take a look at the code and make sure you understand what it's doing. If you have any questions, check in with your classmates, Prof. Melissa, or your favorite LLM.

You do not need to use this code as the starting point for your ZX Spectrum emulator, but it does help show some of the fundamentals of working with SDL2.

Gathering Information

In order to create a ZX Spectrum emulator, you'll need to know how the ZX Spectrum works. For any simulation, time is a key factor—it is important that everything happens at the correct moment in time. The ZX Spectrum is (almost) entirely deterministic and many demos and games rely on this determinism to have precisely timed effects.

Vital Constants

Click the link below to be taken to an exercise that will help you understand the some key constants that your emulator will require:

Calculating Screen Addresses

The ZX Spectrum technical documentation is not so great on describing the layout of video memory, but there are numerous resources online that can help you understand how the screen memory is laid out. You will need to understand how to calculate the address of a particular column of eight pixels on a particular scan line of the screen area.

In a language of your choice (even Python!) write:

  • calculateDisplayAddress(screenline, column)
  • calculateAttributeAddress(screenline, column)

And check your work.

Design Sketch

In many simulations, we would capture the time-dependent aspects using an event queue. This is a data structure that holds a list of events that are scheduled to happen at a particular time. The simulation then advances time to the next event, processes it, and then schedules any new events that result from the processing.

In the case of our ZX Spectrum Emulator, there is enough going on that a simpler approach is both practical and efficient. We can simply mirror the ZX Spectrum's clock (which runs at 3.5MHz) and advance the simulation by one clock cycle at a time. Thus, key classes will have a tick() method that advances the simulation by one clock cycle. (Note that because your computer is likely to be much faster than a ZX Spectrum, you'll need to use SDL_Delay to slow things down to a reasonable speed.)

Basic Structure

You will likely need classes (or the equivalent) for the following components:

  • Video Display — This is not the part of the ZX Spectrum that reads the Spectrum's memory and generates the video signal, this is the lower level video output circuitry that takes the video signal and displays it on the screen. This part should mirror the behaviors of the TV display that the ZX Spectrum was designed to work with. It will interface with SDL2 to display the video signal in a window on the host computer.
  • The Memory — This is pretty basic, but you might add some extra features like loading and saving memory from a file.
  • The ULA — The ULA is the heart of the ZX Spectrum, it reads the memory and generates the video signal. It also reads the keyboard and generates the audio signal. The memory reads used to generate the video signal happen at very well-defined times. Your answers in the Constants exercise will help you understand when these reads happen.
  • The System — This is the top-level class that ties everything together. It handles the SDL event loop, the timing, and the interaction between the various components. It calls tick() on the various components at the appropriate times.
  • Duck speaking

    Wait, what about the Z80 CPU?

You will of course eventually also need to add the CPU, but just getting the video display working is a big enough task for now.

Key Milestones

  • Milestone 1: Get SDL2 Working — You should have a simple SDL2 program that opens a window and displays a test pattern.
  • Milestone 2: Get the ZX Spectrum Video Display Working — Rather than having a test pattern directly in the buffer for the SDL2 window, now write a test pattern into the ZX Spectrum's memory and have the video display read that memory and display the pattern.
  • Milestone 3: Add .scr file loading - A .scr file is a file that contains the contents of the ZX Spectrum's screen memory. It is 6912 bytes long, and should be loaded into memory at address 0x4000. The video display should then read this memory and display the screen. A sample .scr file can be downloaded here.
  • Milestone 4: Load the ROM - The ZX Spectrum has a ROM that contains the system's basic routines. This ROM should be loaded into memory at address 0x0000. The ROM can be downloaded here. Your memory class should ignore writes to the ROM area, as it is read-only. (This “load a file into memory” functionality should be essentially identical to the .scr file loading functionality so should be very quick to implement.)

This is sufficient for Monday's class. If you get this far, you're doing well! However, it's a small step to go just a little further:

  • Milestone 5: Add the Z80 CPU - If the ULA is the beating heart of the ZX Spectrum, the Z80 CPU is the brain. Wrap the CHIPS Z80 CPU in a class that can be ticked by the system. The CPU should read instructions from memory and execute them. The ZX Spectrum should now boot to the classic “© 1982 Sinclair Research Ltd” message. Hopefully seeing this will bring a smile to your face!
  • Milestone 6: Add Snapshot Loading — The .sna format is a common format for saving and loading the state of a ZX Spectrum. You should be able to load a .sna file and have the ZX Spectrum start running from that state. A sample .sna file can be downloaded here. It's likely that code will create a maze and show it on the screeen, but then hang.
    • Note: Location 0x0072 in the ZX Spectrum ROM contains a RETN instuction, which may be useful in implementing snapshot loading.
  • Milestone 7: Add Interrupts — The above snapshot will hang because it relies on the ZX Spectrum's interrupt system for frame sync for smooth animation of filling the maze with color. When you have interrupts working, the maze will fill with color and show multiple mazes.
  • Milestone 8: Add Keyboard Input — The ZX Spectrum's keyboard is a bit weird, but the ZX Spectrum technical documentation will help. Once you have that going, you can load the Chuckie Egg snapshot and play the game! (Or try _Hey Beeper and see the ZX Spectrum play a tune!)

There is still much left to do, including keyboard input, sound, and the ability to load tape files (like Hey Beeper!), but these milestones will give you a good start.

Log Your Progress…

Follow the instructions above to complete the challenge. You'll know you're done when you've done all of the following:

Did you do anything else? If so, please describe it here:

(When logged in, completion status appears here.)