CS 181-AR

Challenge 8 — Make Your ZX Spectrum Emulator Usable

Due: Monday, April 7 at 4:15 p.m. (in class)
More Due: Monday, April 14 at 4:15 p.m. (in class)

UPDATE: Wednesday, April 9

At this point, you ought to have a working ZX Spectrum emulator with keyboard support that can run a game. If you don't, or if you want to contrast your working code against a working example, head to this page:

Your task this week is to wrap up your emulator so that you can give a “final” demo of it in class on Monday.

Before you start…

We'll assume you're continuing in the same team arrangement as Challenge 7. If you want to work with a new group (or switch to solo work), chat to Prof. Melissa.

We'll also assume you have working code from Challenge 7. If you don't, or if you want to contrast your working code against a working example, head to this page:

Make sure you also have interrupt support working. If you don't, you can check out the code in the sample code above for guidance.

Adding Keyboard Support to Your ZX Spectrum Emulator

Now that you have your ZX Spectrum emulator booting and displaying output, let's make it interactive by adding keyboard support. This will allow you to type BASIC programs, play games, and fully interact with the emulated machine.

Understanding the ZX Spectrum Keyboard

The ZX Spectrum uses a keyboard matrix arrangement that's different from modern keyboards. Instead of each key having its own dedicated input, the keys are arranged in a matrix of 8 rows with 5 keys per row. This design minimizes the number of I/O lines needed to read the keyboard and kept costs down for the machine.

Keyboard Matrix Layout

In the ZX Spectrum's keyboard matrix:

  • Each key belongs to one of 8 rows (numbered 0-7)
  • Within each row, keys are represented by one of 5 bit positions (values 1, 2, 4, 8, or 16)
  • When a key is pressed, its corresponding bit in its row is cleared (set to 0)
  • When not pressed, bits are set to 1

Here's the standard layout of the ZX Spectrum keyboard matrix:

Row Bit 0 (1) Bit 1 (2) Bit 2 (4) Bit 3 (8) Bit 4 (16)
0 CAPS-SHIFT Z X C V
1 A S D F G
2 Q W E R T
3 1 2 3 4 5
4 0 9 8 7 6
5 P O I U Y
6 ENTER L K J H
7 SPACE SYM-SHIFT M N B

Implementation Approach

To add keyboard support, you'll need to accomplish these key tasks:

  1. Represent the keyboard state - Design a way to store the current state of each key in the matrix
  2. Create a mapping - Define how modern keyboard keys map to ZX Spectrum keys
  3. Respond to keyboard events - Process key press and release events from SDL.
  4. Integrate with the ULA's I/O system - Have the ULA return the correct values when the Z80 reads keyboard ports

Let's consider each of these challenges:

Representing Keyboard State

You'll need a data structure to track the state of the ZX Spectrum's keyboard matrix. Some questions to consider:

  • How will you represent 8 rows with 5 bits each?
  • What interface will you provide for the ULA to read keyboard row states?
  • How will you update this state when keys are pressed or released?

Remember that in the ZX Spectrum, key-down is represented by a 0 bit, and key-up by a 1 bit.

Mapping Modern Keys to Spectrum Keys

Your users will press keys on a modern keyboard, but you need to translate these to the corresponding ZX Spectrum keys. Consider:

  • Which modern key should map to each ZX Spectrum key?
  • How will you represent the relationship between a modern key code and its ZX row/bit position?
  • Some keys like SYMBOL SHIFT have no direct modern equivalent - what will you map to those? (Typically, you can use the left CTRL key for this.)

Suggested Key Mapping Table

Here's a comprehensive mapping between modern keys and ZX Spectrum key positions that you can use as a starting point:

Key on Modern Keyboard ZX Row ZX Column Bit
SHIFT (Left or Right) 0 1
Z 0 2
X 0 4
C 0 8
V 0 16
A 1 1
S 1 2
D 1 4
F 1 8
G 1 16
Q 2 1
W 2 2
E 2 4
R 2 8
T 2 16
1 3 1
2 3 2
3 3 4
4 3 8
5 3 16
0 4 1
9 4 2
8 4 4
7 4 8
6 4 16
P 5 1
O 5 2
I 5 4
U 5 8
Y 5 16
RETURN/ENTER 6 1
L 6 2
K 6 4
J 6 8
H 6 16
SPACE 7 1
Left CTRL (Symbol) 7 2
M 7 4
N 7 8
B 7 16

The row numbers correspond to the ZX Spectrum ports as follows:

Port Row Number
0xFEFE 0
0xFDFE 1
0xFBFE 2
0xF7FE 3
0xEFFE 4
0xDFFE 5
0xBFFE 6
0x7FFE 7

Notice that the low byte of the port number is always 0xFE, and the high byte has the bit corresponding to the row number cleared. For example, row 4 corresponds to port 0xEFFE, where the 0xFE is the low byte and the 0xEF is the high byte with bit 4 cleared.

If a read is made to a port address with multiple high bits cleared, for example, 0xEEFE, the ULA will read all rows indicated by the cleared bits and combine them.

A Keyboard Class

You will need a class to represent the current state of the ZX Spectrum keyboard. This class should:

  • Store the current state of each key in the matrix
  • Provide methods to press and release keys
    • Probably it will be easiest to have this be done by passing in the SDL scancode for the key (e.g., SDL_SCANCODE_A) and then mapping that to the corresponding ZX Spectrum row and bit position
  • Provide a method to read the current state of a row

Internally, it will need some mechanism to store the state of each key (or each row) in the matrix. It will also need to have the mapping between the SDL scancode and the ZX Spectrum row and bit position.

SDL Event Handling

You'll need to set up SDL to handle keyboard events. Your emulator already has an SDL event loop, but now you'll need to add code to handle keyboard events. This will involve acting on SDL's SDL_KEYDOWN and SDL_KEYUP events.

Inside an SDL event, you can check for key events like this (using SDL scancodes):

if (event.type == SDL_KEYDOWN) {
    auto key = event.key.keysym.scancode;
    keyboard_.press(key);    // Update your keyboard matrix
} else if (event.type == SDL_KEYUP) {
    auto key = event.key.keysym.scancode;
    keyboard_.release(key);  // Update your keyboard matrix
}

ULA Integration

The ULA responds to all I/O addresses where bit zero is clear, but it is typically accessed with the low byte of the address set to 0xFE. When reading from the keyboard, the high byte of the address indicates which row to read. The ULA will read the state of the keyboard matrix and return the value of the row requested.

Testing Your Keyboard Implementation

Once you've implemented keyboard support, you can test it by using the keyboard to enter a simple BASIC program. Remember that you need to be aware of the ZX Spectrum keyboard layout, so for example

  • To enter a quote character, you'll need to press SYMBOL SHIFT and P.
  • To backspace, you need to hold CAPS SHIFT and press 0.
  • The cursor keys are CAPS SHIFT and the numbers 5-8 (left, up, down, right).

You can also use this test program to test your keyboard implementation. It will display the current state of the keyboard matrix on the screen, so you can see if your implementation is working correctly.

Celebrate by Playing a Game

With a working keyboard, you can load the Chuckie Egg snapshot and play the game!

Adding Sound Support to Your ZX Spectrum Emulator

The Beeper on the ZX Spectrum is a simple piezoelectric speaker that generates sound by toggling the voltage on and off. This is controlled via bit 4 of port 0xFE. When this bit is set to 1, the speaker is turned on, and when it is set to 0, the speaker is turned off. The frequency of the sound is determined by the timing of these toggles.

The value of this bit can change at any clock cycle, although in practice the fastest Z80 OUT instruction takes 11 clock cycles (and most OUT instructions take 12 clock cycles). In principle, the Z80 can make sound at 318 kHz (3.5MHz / 11), which seems to be outside the range of human hearing, but in fact, when combined with Pulse Width Modulation (PWM), it can allow the ZX Spectrum to seem to produce non-square-wave sounds from the perspective of the human ear.

Your emulator will likely be sampling sound at 48 kHz, which is a different rate than the ZX Spectrum's clock rate. Thus, every K clock cycles, you will need to create a sound sample, based on the proportion those K clock cycles the speaker was on. This will give you a sound sample that is the correct frequency.

Unfortunately, 48000 does not evenly divide into 3500000, which means that K is either not a whole number, or it needs to be a whole number that varies from sample to sample. There is a cool algorithm called Bresenham’s algorithm, which is usually used for drawing lines on a pixel grid, but can be repurposed in this context.

Here's some sample code for inspiration:

def bresenham_sampler(cpu_rate, sample_rate, buffer_size=1024):
    """
    Implements Bresenham's algorithm for sampling audio at a specific rate
    from a CPU running at a different clock rate.

    Parameters:
    cpu_rate -- CPU clock rate in Hz (e.g., 3.5 MHz = 3,500,000 Hz)
    sample_rate -- Desired audio sampling rate in Hz (e.g., 48 kHz = 48,000 Hz)
    buffer_size -- Size of the output buffer

    Returns:
    A list of CPU clock cycle indices at which to sample audio
    """
    samples = []
    error = 0
    sample_index = 0

    # Calculate the ratio for Bresenham's algorithm
    dx = cpu_rate
    dy = sample_rate

    for cpu_cycle in range(int(cpu_rate * buffer_size / sample_rate)):
        error += dy
        if error >= dx:
            # Take a sample at this CPU cycle
            samples.append(cpu_cycle)
            error -= dx
            sample_index += 1
            if sample_index >= buffer_size:
                break

    return samples

# Example usage with 3.5 MHz CPU and 48 kHz sampling rate
CPU_RATE = 3_500_000  # 3.5 MHz
SAMPLE_RATE = 48_000  # 48 kHz

# Get the CPU cycles at which to sample
sample_points = bresenham_sampler(CPU_RATE, SAMPLE_RATE, 10)

# Print the first 10 sample points
print(f"CPU cycles at which to sample (first 10):")
for i, cycle in enumerate(sample_points):
    print(f"Sample {i}: CPU cycle {cycle}")

# Calculate average cycles between samples
if len(sample_points) > 1:
    diffs = [sample_points[i+1] - sample_points[i] for i in range(len(sample_points)-1)]
    avg_cycles = sum(diffs) / len(diffs)
    print(f"\nAverage CPU cycles between samples: {avg_cycles}")
    print(f"Theoretical cycles between samples: {CPU_RATE / SAMPLE_RATE}")

Adding Tape Loading and Saving to Your ZX Spectrum Emulator

The ZX Spectrum expected the user to have a cassette player connected to the computer, and the user would load programs from the cassette. While it would be possible to also have the emulator sample audio input to load programs, generally this is not very convenient.

Tape Files

Instead, emulators use special files called tape files. These files contain the actual data (not in audio format) that make loading and saving programs much easier. The most common format for these files is the .TAP format, which is a simple binary file format that contains the raw data of the tape.

The format of a tap file is a sequence of blocks in the following format:

Offset Length Description
0 2 Block Length (in bytes)
2 1 Block Type (0x00 = Header, 0xFF = Data)
3 Actual Data

The last byte of the data is actually a checksum, which is the bitwise XOR of all the (other) bytes in the data block.

Edge Loading

One approach would be to turn the TAP file back into sound and have the original ROM code load the program from the tape. This is called "edge loading" and is the approach you saw in the Retro Virtual Machine emulator. However, this is a somewhat needless complication, and it is not the recommended approach.

Flash Loading

The recommended approach is to load the data directly from the TAP file into memory. This is called "flash loading" and is the approach used by most emulators. The advantage of this approach is that it is much faster and simpler than edge loading.

The trick is that you need to intercept the ROM code that loads from tape and replace it with your own code that loads from the TAP file. When you detect that an instruction fetch is happening on the first instruction of the LD_BYTES routine at 0x556 in the ROM or the first instruction of the SA_BYTES routine at 0x4C2 in the ROM, you will need to replace it with your own code.

  • Inspect the registers to determine what the ROM code would be trying to do (e.g., load a header or a data block) and where in memory it would be trying to load the data
  • Read the data from the TAP file and load it into memory
  • Adjust the registers to give the same result values as the ROM code would have done
  • Change the program counter to point to a RET instruction so that the called routine returns to the calling code

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.)