Rendering the ZX Spectrum Screen

Using C# to convert the ZX Spectrum screen to a pixel based format for rending on modern devices.

The Colours

The ZX Spectrum display comprises of 256 x 192 pixels. However not every pixel had it's own colour; a maximum of two colours, 'ink' and 'paper', could be used in each 8 x 8 square of pixels, covering 32 columns and 24 rows. The Spectrum has 8 basic colours, black, blue, red, magenta, green, cyan, yellow and white. Each of those could be displayed normally or 'bright' to give a total of 16 colours, however on normal Spectrums black and bright black looked exactly the same. In each 8 x 8 square both colours have to be normal or bright, you cannot mix the two.

The colours for a single 8 x 8 square are referred to as 'attributes' and are stored in a single byte:

Bit 7 6 5 4 3 2 1 0
Description Flash Bright Paper Ink

'Flash' was used to alternate the ink and paper colours on a timer; I don't care about flash for my purposes so I'll be ignoring it. So how do we extract the colours?

Firstly I'm going to define an enum for the colours. My converted image format will be an array of these colours representing one for each of the 256 x 192 pixels. This will work well for my purposes and can easily be converted to whatever format your favourite image library uses. I'll be treating black and bright black separately as it will make the code a lot simpler.

public enum PixelColour : byte
{
    Black = 0,
    Blue = 1,
    Red = 2,
    Magenta = 3,
    Green = 4,
    Cyan = 5,
    Yellow = 6,
    White = 7,
    BrightBlack = 8,
    BrightBlue = 9,
    BrightRed = 10,
    BrightMagenta = 11,
    BrightGreen = 12,
    BrightCyan = 13,
    BrightYellow = 14,
    BrightWhite = 15
}

Normally of course I'd use an int for the backing store but I'm using a byte here to make it easier to convert to/from bytes. Extracting bright is easy; use a mask to isolate the bit. We can then shift it to bit 3 which will give us the value of 8 for bright, or 0 otherwise:

var bright = (attribute & 0b01000000) >> 3;

Extracting the ink and paper is also straightforward; isolate the relevant bits with a mask and then shift into position for paper, giving us a number 0 - 7. We can then add the bright value and cast to our enum to give us the final ink and paper colours:

var ink = (PixelColour)((attribute & 0b00000111) + bright);
var paper = (PixelColour)(((attribute & 0b00111000) >> 3) + bright);

The Pixels

Now we need to get the pixels and map them to the relevant colours. This is where the screen layout gets funky...

Each byte in the pixel area stores 8 pixels in a horizontal line. A 0 bit indicates we should use the paper colour, a 1 indicates ink. The next byte moves onto the next 8 pixel column in the screen, and so on for the row of 32 columns.

Now you might reasonably expect the next byte to move onto the second line in the display. However instead it covers the first line of the second row in the display, i.e. at (8,0) in pixel co-ordinates. We then get the first line for the entirety of the second row, before moving onto the first line of the third row. This happens until the eighth row is completed. We don't then move onto the first line of the ninth row - that would be far too simple. Instead we move back to the second line of the first row. Then the second line of the third row, and so on. Following this pattern we eventually build up all lines in the first eight rows of the screen, i.e. the top third of the screen. The pattern then reproduces for the middle third of the screen and lastly the bottom third.

Confused? Yeah it's confusing. And my explanation is not the best; I suggest reading the excellent series of articles by David Black for a much better explanation.

So that's the format, we have to decode it. I want to proceed an 8 x 8 block at a time so I can just decode the attributes for a block once, fill out the pixels in the output and then move on. Luckily within a row it's easy to move to the next line in the block. The second line will be after 8 rows of line one, and there are 32 columns, so we just add 8 x 32 = 256. Remembering our result is a single-dimensional array representing 256 x 192 we can also skip down a row of pixels by adding 256. So how do we decode a line? For our 8 pixels in a line we can use a bit mask to extract the left most bit. From there we can pick ink or paper. Once that's done we move onto the next pixel in the line by shifting left one, move to the next pixel in the output by adding one, and repeat for each pixel. Something like this:

for (var bit = 0; bit < 8; bit++)
{
    result[resultIndex] = (pixels & 0b10000000) != 0 ? ink : paper;
    resultIndex++;
    pixels <<= 1;
}

Once we've done our line we can move to the next line in the block. Once the block is done we can move to the next block in the row. That is done by restoring our indices to their positions before the block, then simply adding one to the index for the Spectrum screen and 8 to move the output onto the next block. Once we've done a row we move on to the next row until we've done a third, then we move on and do the remaining two thirds. The final code looks something like this:

[Pure]
public static PixelColour[] Convert(ReadOnlySpan<byte> screen)
{
    var attributes = screen[6144..];
    var result = new PixelColour[256 * 192];

    ConvertThird(result, 0, screen, attributes);

    screen = screen[2048..];
    attributes = attributes[256..];
    
    ConvertThird(result, 64 * 256, screen, attributes);

    screen = screen[2048..];
    attributes = attributes[256..];
    
    ConvertThird(result, 128 * 256, screen, attributes);

    return result;
}

private static void ConvertThird(PixelColour[] result, int resultIndex, ReadOnlySpan<byte> screen, ReadOnlySpan<byte> attributes)
{
    for (var row = 0; row < 8; row++)
    {
        ConvertRow(result, resultIndex, screen, attributes);
        screen = screen[32..];
        attributes = attributes[32..];
        resultIndex += 8 * 256;
    }
}

private static void ConvertRow(PixelColour[] result, int resultIndex, ReadOnlySpan<byte> screen, ReadOnlySpan<byte> attributes)
{
    // Get the index in the screen to read from.
    var screenIndex = 0;

    // Loop over each column in the row.
    for (var column = 0; column < 32; column++)
    {
        var attribute = attributes[column];

        // Extract the bright attribute and then shift right by 3 to get the number 8 for bright, 0 otherwise.
        var bright = (attribute & 0b01000000) >> 3;

        // Extract the ink and paper values then add bright to get a PixelColour value.
        var ink = (PixelColour)((attribute & 0b00000111) + bright);
        var paper = (PixelColour)(((attribute & 0b00111000) >> 3) + bright);

        // Loop over each line in the attribute.
        for (var line = 0; line < 8; line++)
        {
            // Grab the 8 pixels for the line.
            var pixels = screen[screenIndex];

            // Loop over the pixels in the line.
            for (var bit = 0; bit < 8; bit++)
            {
                // Take the high bit and use it to determine ink or paper.
                result[resultIndex] = (pixels & 0b10000000) != 0 ? ink : paper;

                // Move the result index to the next pixel.
                resultIndex++;

                // Shift the pixels so the next pixel becomes the high bit.
                pixels <<= 1;
            }

            // Move our indices by one row; this is 256 for screen due to the Spectrum's funky layout, and 8 * 32 pixels for the result, minus the
            // 8 we've already moved.
            screenIndex += 256;
            resultIndex += 8 * 32 - 8;
        }

        // Move our indices to the next attribute. This will be 1 byte on from our position for the loop for the screen and 8 bytes on for the result.
        screenIndex = screenIndex - 256 * 8 + 1;
        resultIndex = resultIndex - 256 * 8 + 8;
    }
}

There are probably a few tweaks we could to speed it up a bit at the cost of making it uglier but it's not too bad as it is. But I'm not too worried about speed because in the next post I'm going to vectorize it to give us a speed boost.