I couldn’t let it go, or at least my brain couldn’t. After decoding the SSI-IMG file format for the EGA/VGA assets I had planned to leave it at that. The dark corners of my brain, however, decided that I was going to have to decode the CGA variant as well. As a result this post will be about diving into the CGA framebuffer, and applying it to what we see in the CGA version of the IMG file format.
Sometimes my brain won’t shut off, once an idea gets into it… this is one of those days. In my last post we didn’t really get into the organization of the EGA framebuffer, as at the time we didn’t realize that that is basically what we were looking at until we were much further down the road. After discovering that the game was written using Borland development tools, namely Turbo Pascal, and utilized the Borland Graphics Library (BGI) it is pretty clear that the IMG format is just that of the form used by getimage() and putimage(). Which essentially means the data is raw framebuffer data. So to start, we’ll recap by going over the EGA framebuffer and then moving onto the CGA framebuffer, before diving into the particulars of the CGA variant of the IMG file format.
The EGA Framebuffer
For EGA 16 colour graphics modes, the image is spread across 4 parallel planes. Each plane holds one bit of the 4 bit colour value. This was done because at the time memory wasn’t fast enough to keep up with the requirements of generating an image on a display. So the hardware designers at IBM got creative, and made the graphics controller access 4 banks of memory at the same time in parallel, effectively cutting the memory access speed requirements by 4. While we could simultaneously write to any of the 4 planes at the same time via a plane masking register, only one EGA plane can be read from code at a time over the bus. (There is a colour compare mode that does read all 4 at a time, but we do not get the composite colour result) As a result to read out an image from the framebuffer, we must select one plane at a time and read out the contents for the dimensions of the display mode. This is what the BGI getimage() call effectively does. This in turn puts it in a format for putimage() to be able to quickly dump an image into the EGA framebuffer. As a result for the 640×200 resolution of Red Lightning, 64000 bytes are needed to hold the image, (4 planes at 16000 bytes each). (with the width and height prefix values removed)
The CGA Framebuffer
The memory arrangement for CGA is a bit different. For CGA the pixels are packed, but the lines are interleaved. meaning the image is split into 2 regions. The first region holds all the even number scanlines, and the second the odd scannlines. For CGA graphics are either 640×200 2 colour (1 bit per pixel) or 320×200 4 colour (2 bits per pixel). In both cases the memory required is 16000 bytes, with each region requiring 8000 bytes. (note there was a 16 colour “graphics” mode with a resolution of 160×100, but this was really clever use of text mode under the hood) Unlike EGA, the CGA framebuffer is fully accessible to the host CPU, with the first region of lines starting at an offset of 0 into the 16kb framebuffer window, and the 2nd region starting at an offset of 8192 (power of 2 boundary). As a result there is a small gap of 192 bytes between the end of the first region and the beginning of the second. The BGI Library in CGA mode simply reads/writes the entire 16K region of memory, rather than doing two separate constrained reads/writes to just the active area. As a result the file for a CGA image would be 16384 bytes in size for a full screen capture. (with the width and height prefix value removed)
The CGA Version of the SSI-IMG File Format
Just like in my last post we will start off by looking at some screenshots of the game so we have an idea of what we’re looking at. From below we can determine that in CGA, the game is using black, red, green, and yellow, Which means it is using palette 0, high Intensity. This will help us with rendering. We can also determine that the game is running in the 320×200 graphics mode, with that we know how the pixels will be arranged.


First look at the CGA IMG file
The first thing I noticed is the file size of the IMG file 16384 bytes. Which aligns with what we expect for a BGI compatible image. We know from above the resolution is 320×200 and using palette 0, with high intensity, that means the game is using graphics mode 04h. Next let’s look at the file itself, to see if it aligns with what we would expect. (4 pixels packed to a byte, 2 bits per pixel).
File: CGAHEXES.IMG [16384 bytes]
Offset x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF Decoded Text
0000000x: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC · · · · · · · · · · · · · · · ·
0000001x: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC · · · · · · · · · · · · · · · ·
0000002x: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC · · · · · · · · · · · · · · · ·
0000003x: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC · · · · · · · · · · · · · · · ·
0000004x: CC 00 00 00 00 00 00 00 00 00 00 00 00 00 00 03 · · · · · · · · · · · · · · · ·
0000005x: C0 00 00 00 0C C0 00 00 00 0C C0 00 55 40 0C C0 · · · · · · · · · · · · U @ · ·
0000006x: 00 00 00 0C C0 00 2E 00 0C C0 00 55 40 0C C0 00 · · · · · · . · · · · U @ · · ·
0000007x: 2E 00 0C C0 00 00 00 0C C0 00 00 00 0C C0 10 00 . · · · · · · · · · · · · · · ·
The data looks reasonable, I was expecting to see 00 here like in the EGA file, but I guess they have a pattern written instead. We’ll know for sure when we render the file, regardless the data does not look unreasonable. Next thing I would expect is to find no data in the last 192 bytes of the first 8Kb of the file.
File: CGAHEXES.IMG [16384 bytes]
Offset x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF Decoded Text
00001F4x: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 · · · · · · · · · · · · · · · ·
00001F5x: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 · · · · · · · · · · · · · · · ·
⋮ ⋮
00001FEx: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 · · · · · · · · · · · · · · · ·
00001FFx: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 · · · · · · · · · · · · · · · ·
And we do indeed see a block of all 00 for the last 192 bytes of the first half (8Kb) of the file. As we can see below, it is immediately followed by data starting at the top of the second half of the file.
File: CGAHEXES.IMG [16384 bytes]
Offset x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF Decoded Text
0000200x: 30 00 00 00 33 30 00 00 00 33 30 00 55 40 33 30 0 · · · 3 0 · · · 3 0 · U @ 3 0
0000201x: 00 00 00 33 30 00 22 00 33 30 00 55 40 33 30 00 · · · 3 0 · " · 3 0 · U @ 3 0 ·
0000202x: 22 00 33 30 00 00 00 33 30 00 00 00 33 30 00 00 " · 3 0 · · · 3 0 · · · 3 0 · ·
0000203x: 00 33 30 00 00 00 33 30 00 00 00 33 30 00 22 00 · 3 0 · · · 3 0 · · · 3 0 · " ·
0000204x: 33 00 CC 00 00 44 00 00 00 00 00 00 00 00 00 03 3 · · · · D · · · · · · · · · ·
0000205x: 30 00 00 00 03 30 00 00 00 03 30 00 55 51 53 30 0 · · · · 0 · · · · 0 · U Q S 0
0000206x: 00 00 00 03 30 00 22 00 03 30 00 55 40 03 30 00 · · · · 0 · " · · 0 · U @ · 0 ·
0000207x: 22 00 03 30 00 00 00 03 30 00 00 00 03 30 10 00 " · · 0 · · · · 0 · · · · 0 · ·
Once again the data looks reasonable. And finally to confirm the expected pattern, the last 192 bytes of the file should also be 00. Which we do indeed see as shown below.
File: CGAHEXES.IMG [16384 bytes]
Offset x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF Decoded Text
00003F4x: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 · · · · · · · · · · · · · · · ·
00003F5x: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 · · · · · · · · · · · · · · · ·
⋮ ⋮
00003FEx: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 · · · · · · · · · · · · · · · ·
00003FFx: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 · · · · · · · · · · · · · · · ·
With that I think we have confirmed that the data is indeed raw framebuffer data to be compatible with the BGI getimage() and putimage() calls. Had this been some deliberate format on the part of SSI, I would have expected to see something more common between the two. we do see on later versions of the game they appear to move away from this format to using a commercial file format (.LBM). Not surprised as the way this is it would have been a pain to manage assets across the game for various platforms and graphics capabilities. Had SSI created their own format similar to what MicroProse did with PIC, this might not have been necessary.
Coding for the CGA IMG format
Since we have a good idea of the format at this point we don’t need to try to look at things visually to figure them out, like we did last time. Instead I think we can get right into coding this up. The first thing we need is to unpack and de-interleave the file. Taking our code from the last time, we can replace the deplaning step with the de-interleave step.
void lace2lin(memstream_buf_t *dst, memstream_buf_t *src,
uint16_t width, uint16_t height) {
width /= 4; // we expect 4 pixels per byte
height /= 2; // we always expect lines to be in interleved pairs
int even_pos = 0;
int odd_pos = src->len / 2; // 1/2
for(int y = 0; y < height; y++) {
// even line
for(int x = 0; x < width; x++) {
uint16_t pbuf = src->data[even_pos++];
for(int b = 0; b < 4; b++) { // 4 pixels per byte
pbuf <<= 2; // shift in the pixel
uint8_t px = (pbuf >> 8) & 0x03; // move it to position and mask
if(dst->pos < dst->len) dst->data[dst->pos++] = px;
}
}
// odd line
for(int x = 0; x < width; x++) {
uint16_t pbuf = src->data[odd_pos++];
for(int b = 0; b < 4; b++) { // 4 pixels per byte
pbuf <<= 2; // shift in the pixel
uint8_t px = (pbuf >> 8) & 0x03; // move it to position and mask
if(dst->pos < dst->len) dst->data[dst->pos++] = px;
}
}
}
}
We also need to re-order a few things in our main code, as we can no longer use the resolution of the image to calculate the size of the input buffer. So instead we’ll get the file size and use that to allocate the buffer. We also need to remap our colours from the CGA colour indices to our EGA equivalents.
// remaps the CGA colour indices to the EGA equivalents for each of the palettes
// background is assumed to be black, though in reality it can be programmed to
// any of the 16 colours
uint8_t cga2ega[6][4] = {
{0,2,4,6}, // m:04h pal:0, low [black, dark green, dark red, brown]
{0,10,12,14}, // m:04h pal:0, high [black, light green, light red, yellow]
{0,3,5,7}, // m:04h pal:1, low [black, dark cyan, dark magenta, light grey]
{0,11,13,15}, // m:04h pal:1, high [black, light cyan, light magenta, white]
{0,3,4,7}, // m:05h low [black, dark cyan, dark red, light gray]
{0,11,12,15} // m:05h high [black, light cyan, light red, white]
};
#define SSIPAL (1) // which cga2ega map to use for the SSI image
void cga_remap(memstream_buf_t *buf) {
buf->pos=0;
do {
uint8_t px = buf->data[buf->pos];
buf->data[buf->pos++] = cga2ega[SSIPAL][px];
} while(buf->pos < buf->len);
}
With that we should be good to go, let’s see what we get.

Looks to be a success, though perhaps a touch hard on the eyes. IBM really made some awful choices on the CGA palettes from the 16 colours that were available. Though I suspect the limitations of NTSC colour had something to do with those choices.
Putting it back together
Now that we’ve successfully decoded the image, lets see if we can reverse the process. Though with that I’m going to make a few more changes first. Instead of remapping the colour indices to EGA, I’m going to remap the palette for the file to use the indicated palette, instead of the EGA palette. This way the indices stay intact. To do that, we need to modify our save_bmp() function to accept an additional parameter for the palette.
int save_bmp(const char *fn, memstream_buf_t *src,
uint16_t width, uint16_t height,
bmp_palette_entry_t *pal) {
int rval = 0;
FILE *fp = NULL;
uint8_t *buf = NULL; // line buffer, also holds header info
// do some basic error checking on the inputs
if((NULL == fn) || (NULL == src) || (NULL == src->data)) {
rval = -1; // NULL pointer error
goto bmp_cleanup;
}
// try to open/create output file
if(NULL == (fp = fopen(fn,"wb"))) {
rval = -2; // can't open/create output file
goto bmp_cleanup;
}
// stride is the bytes per line in the BMP file, which are padded
// out to 32 bit boundaries
// we get 2 pixels per byte for being 16 colour
uint32_t stride = ((width + 3) & (~0x0003)) / 2;
uint32_t bmp_img_sz = (stride) * height;
// allocate a buffer to hold the header and a single scanline of data
// this could be optimized if necessary to only allocate the larger of
// the line buffer, or the header + padding as they are used at mutually
// exclusive times
if(NULL == (buf = calloc(1, HDRBUFSZ + stride + 2))) {
rval = -3; // unable to allocate mem
goto bmp_cleanup;
}
// signature starts after padding to maintain 32bit alignment
// for the rest of the header
bmp_signature_t *sig = (bmp_signature_t *)&buf[stride + 2];
// bmp header starts after signature
bmp_header_t *bmp = (bmp_header_t *)&buf[stride + 2 + sizeof(bmp_signature_t)];
// setup the signature and DIB header fields
*sig = BMPFILESIG;
size_t palsz = sizeof(bmp_palette_entry_t) * 16;
bmp->dib.image_offset = HDRBUFSZ + palsz;
bmp->dib.file_size = bmp->dib.image_offset + bmp_img_sz;
// setup the bmi header fields
bmp->bmi.header_size = sizeof(bmi_header_t);
bmp->bmi.image_width = width;
bmp->bmi.image_height = height;
bmp->bmi.num_planes = 1; // always 1
bmp->bmi.bits_per_pixel = 4; // 16 colour image
bmp->bmi.compression = 0; // uncompressed
bmp->bmi.bitmap_size = bmp_img_sz;
bmp->bmi.horiz_res = BMP96DPI;
bmp->bmi.vert_res = BMP96DPI;
bmp->bmi.num_colors = 16; // palette has 16 colours
bmp->bmi.important_colors = 0; // all colours are important
// write out the header
int nr = fwrite(sig, HDRBUFSZ, 1, fp);
if(1 != nr) {
rval = -4; // unable to write file
goto bmp_cleanup;
}
// we're using our global palette here, wich is already in BMP format
// write out the palette
nr = fwrite(pal, palsz, 1, fp);
if(1 != nr) {
rval = -4; // can't write file
goto bmp_cleanup;
}
// now we need to output the image scanlines. For maximum
// compatibility we do so in the natural order for BMP
// which is from bottom to top. For 16 colour/4 bit image
// the pixels are packed two per byte, left most pixel in
// the most significant nibble.
// start by pointing to start of last line of data
uint8_t *px = &src->data[src->len - width];
// loop through the lines
for(int y = 0; y < height; y++) {
memset(buf, 0, stride); // zero out the line in the output buffer
// loop through all the pixels for a line
// we are packing 2 pixels per byte, so width is half
for(int x = 0; x < ((width + 1) / 2); x++) {
uint8_t sp = *px++; // get the first pixel
sp <<= 4; // shift it to make room for the next one
if((x * 2 + 1) < width) { // test for odd pixel end
sp |= (*px++) & 0x0f; // get the next pixel
}
buf[x] = sp; // write it to the line buffer
}
nr = fwrite(buf, stride, 1, fp); // write out the line
if(1 != nr) {
rval = -4; // unable to write file
goto bmp_cleanup;
}
px -= (width * 2); // move back to start of previous line
}
bmp_cleanup:
fclose_s(fp);
free_s(buf);
return rval;
}
With that done we no longer need the cga_remap() function we created earlier, instead we will allocate a new palette and copy in the entries we want. As BMP doesn’t support 2 bits per pixel, we will still need to provide a palette with 16 entries, we will fill the extra entries with black.
// allocate a buffer for the palette, pre-fill with all 0's (black)
if(NULL == (pal = calloc(16, sizeof(bmp_palette_entry_t)))) {
printf("Unable to allocate memory\n");
goto CLEANUP;
}
// copy over the EGA palette entries corresponding to the CGA palette
for(int p = 0; p < 4; p++) {
pal[p] = ega_pal[cga2ega[SSIPAL][p]];
}
With the BMP image now updated to preserve the indices it’s time to focus on reversing the process. The same basic change are required here, first we need to replace the conversion from linear to planar with a conversion from liner to interlaced.
void lin2lace(memstream_buf_t *dst, memstream_buf_t *src,
uint16_t width, uint16_t height) {
width /= 4; // we expect 4 pixels per byte
height /= 2; // we always expect lines to be in interleaved pairs
int even_pos = 0;
int odd_pos = dst->len / 2; // 1/2
for(int y = 0; y < height; y++) {
// even line
for(int x = 0; x < width; x++) {
uint8_t px = 0;
for(int b = 0; b < 4; b++) { // 4 pixels per byte
px <<= 2; // make room for the next pixel
px |= src->data[src->pos++] & 0x03;
}
dst->data[even_pos++] = px;
}
// odd line
for(int x = 0; x < width; x++) {
uint8_t px = 0;
for(int b = 0; b < 4; b++) { // 4 pixels per byte
px <<= 2; // make room for the next pixel
px |= src->data[src->pos++] & 0x03;
}
dst->data[odd_pos++] = px;
}
}
}
With that we are just about ready to convert back to an IMG file. The only remaining consideration is that when we allocate the buffer to store the generated IMG file, it needs to be 16384 bytes, instead of being based on the resolution of the BMP file. In this case I’m going to make the CGA export utility it’s own program separate from the EGA one, and rename the EGA one. With the import utility we have to specify the resolution and can therefor specify if it is CGA or EGA, we don’t have that same luxury with the export tool. Down the road I will likely switch to using argp or getopt and then we can look at recombining the two as a single tool, specifying which output based on a command line option. But for now I want to keep it simple. But for now to minimize changes we’ll keep them separate.
% img2bmp 320x200c EGAHEXES.IMG EGAHEXES-TEST.BMP
SSI-IMG to BMP image converter
Resolution: 320 x 200 CGA
Opening IMG File: CGAHEXES.IMG File Size: 16384
Creating BMP File: CGAHEXES-TEST.BMP
Done
% bmp2img-cga CGAHEXES-TEST.BMP CGAHEXES-TEST.IMG
BMP to SSI-IMG image converter
Loading BMP File: CGAHEXES-TEST.BMP
Resolution: 320 x 200
Creating IMG File: CGAHEXES-TEST.IMG
Done
% md5 CGAHEXES.IMG CGAHEXES-TEST.IMG
MD5 (CGAHEXES.IMG) = c79b57fa2cea64396124ea7ec37a2ced
MD5 (CGAHEXES-TEST.IMG) = c79b57fa2cea64396124ea7ec37a2ced
I’d call that a success, And with that I think our adventure with the SSI IMG file format is now complete. I will update the code repo on github within the next 24 hours or so to reflect the changes we made here. I will likely update the code down the road to consolidate the two bmp2img programs, though I likely won’t blog about it, as it will just be an exercise in refactoring the code, and not decoding anything new. I’ve already seen the results of some modding of Red Lightning from my first release of this code, I look forward to seeing more.
Leave a comment