Loading bars simulator - Part three: Drawing the image, non-linear framebuffer layout, and adding colour
Material covered in this part
Reading and processing image data
Simulating the non-linear framebuffer layout
Drawing the image on the framebuffer
Adding color to the image
Reading and processing image data
The first image file that we need to process is a 256 × 192 pixel, 1-bit monochrome image in pbm format. Later we will also read a 24-bit image of the same dimensions. The pbm header would usually be 10 characters, "P4 256 192", followed by a newline. We could simply check for this particular string at the beginning of the file and assume that the bitmap data starts at byte offset 11. However, the header can include extra spaces, tabs, newlines, and even comments, so good programming practice is to parse it properly.
The following function will do this for both of the files that we will eventually parse:
/* Check for a valid PBM or PPM format header with the desired dimensions such as "P4 256 192 ", followed by data. */
/* Call with an open filehandle, and the expected file type, 4 or 6. */
/* Returns offset to the beginning of pixel data, or 0 if no valid header is found. */
int check_header(int f, unsigned char format)
{
unsigned char * buffer;
int n;
int commentflag=0;
buffer=malloc(4096);
read (f,buffer,4096);
if (*buffer!='P' || *(buffer+1)!=format) {
printf ("Invalid header, doesn't begin with P%c. Assuming raw pixel data!\n",format);
printf ("Invalid header, maximum color value is not 255 @ %d. Assuming raw pixel data!\n",n);
free (buffer);
return (0);
}
printf ("Valid ppm header, start of RGB pixel data at %d\n",n+4);
free (buffer);
return (n+4);
}
We call check_header with the filehandle of the previously opened pbm file, and the character '4', to indicate that this is the type of file we are expecting. This function as written is only intended for this specific use-case, so we hard-code the expected dimensions and color depth into it. We also make an arbitrary check that the header doesn't exceed 512 bytes, which would be unusual and might indicate a mal-formed pbm file or another file that had by chance had an otherwise valid pbm header.
Notes on coding style
There is a degree of code duplication in this function, which would usually prompt me to try to abstract some of it into it's own function or a compiler macro.
However in this case, I think that the unrolled code is actually the better approach. There are subtle differences between the checks that we make against each part of the header, the first field is two characters and the others are three, comments are valid after all but the last field, which is the third field in the case of the type 4 pbm file, and the fourth field in the case of the type 6 ppm file. The last field can also only be followed by a single space, tab, or newline, whereas the other fields can be followed by an arbitrary number of these characters. A generalised function that handled so many different variations would likely be just as long as the code above, and be more difficult to follow.
If a valid header is found, check_header returns the byte offset to the pixel data. If not, it returns zero. This conveniently allows us to process raw, headerless image files as well as pbm or ppm files if we decide not to check the return value for error. Obviously, assuming that a headerless file contains valid raw data is not a general programming recommendation, but in this case since we are only using the input to plot bitmap graphics it's not a problem.
To actually read the bitmap data into memory, we just need to add the following to the main function:
int fd_in_bitmap;
int bitmaskB, bitmaskC;
unsigned char *input_bitmap;
fd_in_bitmap=open(INPATH "screen.pbm", O_RDONLY);
if (fd_in_bitmap==-1) { printf ("Unable to open input file.\n"); return (2); }
input_bitmap=malloc(8192);
if (input_bitmap==NULL) { printf ("Unable to allocate memory for input bitmap buffer.\n"); return (1); }
Whilst we are adding code to the main function, we should also add another call to video_leader after the out of the final white frames to produce the pilot tone for the data block, and also write the 0xff flag byte, as well as setting the checksum to 0xff:
beampos=video_leader(framebuffer,beampos,2);
/* A data block begins with a byte set to 255. Header blocks begin with a byte set to 0. */
beampos=video_byte_out(framebuffer,beampos,255);
checksum=255;
Now we have the bitmap data in memory in the usual linear format. The next step is to read it out byte by byte, in the non-linear format that we need to simulate the layout of the ZX Spectrum framebuffer.
Simulating the non-linear framebuffer layout
The code to do this is surprisingly simple, as the only change we need to make is to swap bits 5-7 of the offset into the framebuffer with bits 8-10.
/* Now we output the supplied PBM file as bitmap in the layout of the spectrum screen memory. */
for (n=0;n<6144;n++) {
/* To convert between a linear layout and the non-linear layout a spectrum bitmap, we need to swap bits 5-7 with bits 8-10. */
Most of this should be fairly self-explanatory, as we are simply performing the bit swap and calling the video_byte_out function as we did for the bytes of the header. Whereas the value of variable 'n' is a simple linear counter from 0 to 6143, the value of m counts 0-31, 256-287, 512-543, and so on. In hex, the pattern is a lot clearer, being: 0x0000-0x001f, 0x0100-0x011f, 0x0200-0x021f, etc.
This is enough to read the correct bytes and create the corresponding raster bar waveform, but we obviously won't see the bitmap image appearing on-screen. For that, we need to write another function to plot the pixels in the centre of the framebuffer as we read them. This is what the call to framebuffer_set_pixels does, and we will look at that function in the next section.
For now, though, if we remove the call to framebuffer_set_pixels, and add two lines to the end of the main function, the program will run to completion and generate approximately 2000 frames with the correct raster bar sequence for the image being read.
Each byte of the pbm file contains data for eight pixels, with 0 representing white, and 1 representing black. This might be the opposite to what you would expect, given that in the ppm format the largest value represents maximum color intensity, but it's actually quite convenient as it is the same mapping that that ZX Spectrum uses when the color attribute bytes are set to their default values. This is why we were able to simply pass the byte value directly to video_byte_out in the code above, without doing any inversion or bit shuffling.
Displaying the image in sync with the bytes being processed is fairly trivial. All we need to do is to calculate the co-ordinate values on our 384 × 288 pixel framebuffer, that correspond to the offset into the pbm file that we're reading data from. There is no bit-swapping of the offset value here, because both the input pbm file and our framebuffer are linear. The bit-swapping in the main function that calls framebuffer_set_pixels makes sure that we plot the pixels in the correct non-linear order. Each bit of the input gets expanded to three identical bytes on the output, either 0, 0, 0, or 255, 255, 255.
/* Set or unset a group of eight pixels at the given offset in the centre image area based on the value of byte. */
int framebuffer_set_pixels(unsigned char * framebuffer, int offset, int byte)
{
#define fby (8*offset/256+48)
int fbx;
int z;
int bit;
fbx=(8*offset)%256+64;
for (z=0; z<8; z++) {
bit=(byte & 128) >> 7;
*(framebuffer+3*(fby*384+fbx))=255*bit;
*(framebuffer+1+3*(fby*384+fbx))=255*bit;
*(framebuffer+2+3*(fby*384+fbx))=255*bit;
fbx++;
byte=byte<<1;
}
return (0);
}
Note carefully that this time we need to invert the data bit because otherwise we would be plotting black pixels from the pbm file with RGB values 255, 255, 255, and white pixels with all zeros. That's why the call to framebuffer_set_pixels in the previous section included an xor operation with 255, to invert the bits:
At this point the program will generate a sequence of ppm files that reproduce the raster bar waveforms, and the plotting of the monochrome image data, as they would be seen during the loading of data into the monochrome bitmap part of the ZX spectrum framebuffer memory.
All that remains now is to add color to the image, and create an audio waveform to combine with the ppm sequence to create the full effect.
Adding color to the image
As previously explained, after the bitmap data come 768 bytes of color attributes, one for every block of 8 × 8 pixels. Since these bytes effectively define a color palette for each block, the visual effect whilst they are being loaded from the tape is to see the color image gradually overlaying the monochrome bitmap.
Re-creating this effect is easy, as we just need to write the corresponding RGB values into each block of 64 pixels in our output framebuffer. Since we are not limited to a fixed 15-color pallette within each block, though, we can create a more artistic video effect by overwriting the full on or full off RGB values that we just wrote, with real 24-bit RGB data. If we do this 64 pixels at a time, we will get the same visual, 'overlaying', effect, but with full 24-bit color.
However, doing this raises the question of what data we should use to generate the waveforms corresponding to the 768 attribute bytes.
You might think that we could just use random data, but this won't produce a particularly authentic looking waveform, and when we come to add audio to the program, it will sound very wrong too.
The main reason for this is that the most significant bit of each attribute byte, which controls the 'flash' function, is almost always set to zero, and if it is used, it's usually only in small areas of the screen. This means that there is almost always a regular short pulse every 8 bits, and since bit 6, which controls the 'brightness' function is also commonly set to zero, there are frequently two short pulses together at the beginning of each byte.
Visually, this is somewhat noticeable as a change in the pattern of the raster bars after the end of the bitmap data, when the attributes start loading. Probably more noticeable is the 'rasping' effect it has on the audio, which changes abruptly between bitmap and attribute data. Using random data won't reproduce this effect. Additionally, large areas of the same color create a repeating waveform, and this effect would also be lost if we simply replaced the attribute bytes with random data.
Instead, we'll average the red, green, and blue content in the 64 pixels of the original 24-bit image, and store them each as 2-bit values in the lower 6 bits of the corresponding attribute byte. We'll set the upper two bits to zero, and this should solve both of the issues just mentioned, in that we'll get the regular short pulses, and that areas of the image with similar average colors, will produce the same waveform.
Thinking ‘outside the box’:
We don't even have to use a 24-bit image that is a color version of the 1-bit monochrome one, we could use an entirely different image. This would obviously be completely un-authentic, but might be an interesting way to create a special effect.
More subtly, though, we can improve the quality of the low resolution monochrome image if we consider that in our case the color data isn't really being overlayed on top of the monochrome bitmap, but rather replacing it. We can therefore use dithering on the monochrome image to make it look nicer, without that dithering being present in the 24-bit version.
Loading the 24-bit ppm file is straightforward as our check_header function was written with support for this format:
int fd_in_bitmap;
unsigned char false_attribute;
fd_in_rgb=open(INPATH "screen.ppm", O_RDONLY);
if (fd_in_rgb==-1) { printf ("Unable to open input file.\n"); return (2); }
We now need to write two more functions, one to average the pixel colors in each 8 × 8 pixel block, and another to copy RGB data 64 bytes at a time from the 24-bit input data to our framebuffer.
The following function calculates the average pixel color, and returns a single byte to use as the fake attribute byte:
/* Calculate the average of all of the 8-bit RGB values in an 8x8 pixel block numbered offset. */
/* Then reduce each of these to a 2-bit value and store them in a single byte as 00RRGGBB. */
/* This is purely to give a visual and audio waveform that at least "follows" in some way the area of the the RGB image being drawn, */
/* since we obviously have no real attribute bytes. */
int average_block_color(unsigned char * framebufferrgb, int offset)
{
int x,y;
int red=0,green=0,blue=0;
for (y=8*(offset/32); y<8*(offset/32)+8 ; y++) {
for (x=8*(offset%32); x<(8*(offset%32))+8 ; x++) {
red=red+*(framebufferrgb+3*(y*256+x));
green=green+*(framebufferrgb+1+3*(y*256+x));
blue=blue+*(framebufferrgb+2+3*(y*256+x));
}
}
red=(red>>8) & 48;
green=(green>>10) & 12;
blue=(blue>>12) & 3;
return (red | green | blue);
}
Here we just sum each of the red, green, and blue values for the 64 pixels to get a value between 0 and 16320, then reduce this to a two-bit value between 0 and 3, and finally move it to the correct bits in the output byte.
There are various ways that we could improve this algorithm, for example setting bit 6 if the overall RGB value is over a certain brightness threshold. We could also test for the two most common colors, and set two separate 3-bit values in the lower 6 bits of the attribute byte. However, the simple method of just making sure that the lower 6 bits somehow relate to the RGB content seems to work well enough to look and sound convincing.
Copying the block of RGB data is even easier:
/* Copy RGB data from the RGB input framebuffer to the output framebuffer, for the 8x8 pixel block at the supplied offset. */
int framebuffer_set_block(unsigned char * framebuffer, unsigned char * framebufferrgb, int offset)
{
int x,y;
for (y=8*(offset/32); y<8*(offset/32)+8 ; y++) {
for (x=8*(offset%32); x<(8*(offset%32))+8 ; x++) {
Now we have the functions that we need to produce the desired effect, all we need to do is to add code to the main function to cycle through the attribute bytes and process them one by one:
/* In a real spectrum screen image, attribute data follows the bitmap data. */
/* Since we are going to draw the 24-bit RGB image from the supplied PPM file into the framebuffer, there is obviously no real attribute data. */
/* Rather than just using random data, we generate bytes that are based on the average RGB color values of the pixels in each 8x8 pixel block. */
/* This at least makes the sound and visual waveforms relate to the 24-bit RGB image being drawn in some way. */
Obviously, this block of code needs to go immediately after the code that reads out the bitmapped data, and before the output of the checksum byte and the final white border frames.
Now we can generate the full visual loading sequence, with our 24-bit color image overlay at the end!
Summary so far
In this third part, we've seen how to process the image data to simulate the display layout of the ZX Spectrum, and how to write bitmap data to our framebuffer. We've also seen a way to create a novel effect of overlaying a 24-bit image, whilst keeping an authentic-looking raster bar waveform.
In the final part, we'll add sound to the project so that we can hear the same waveform that creates the raster bars, as audio tones.