C Drawing Functions

Most of the game’s individual tile images are drawn with low-level assembly drawing functions. Higher-level functions that handle drawing groups of tiles, or larger areas of the screen, are implemented in C.

CopyTilesToEGA()

The CopyTilesToEGA() function reads solid tile image data from the memory pointed to by source, and installs it into a block of dest_length bytes of the EGA’s memory starting at dest_offset. Because the destination memory is planar, each byte of address space covered by dest_length consumes four bytes from source.

The behavior of this function is a little strange due to the planar memory of the EGA. Very briefly, each byte of address space from the CPU’s perspective maps to a position across four distinct memory planes within the EGA. When the CPU writes a byte to the EGA’s address space, this byte can be written to as many as four (and as few as zero) memory planes. The planes are selected by the EGA’s map mask parameter, which can be configured via writes to the EGA’s I/O ports.

In regular memory, each solid tile is 32 bytes long. In the EGA’s memory, each tile occupies eight bytes of address space, but these eight bytes must be written four times with a different map mask selected during each pass. Because of this planar nature, dest_length should be one-fourth the length of the source data – the destination address range will be written four times (once per plane) to compensate.

void CopyTilesToEGA(byte *source, word dest_length, word dest_offset)
{
    word i;
    word mask;
    byte *src = source;
    byte *dest = MK_FP(0xa000, dest_offset);

The pointer provided in source is copied to src for future use, and a call to MK_FP() constructs a pointer from the EGA’s base segment address (A000h) and the provided dest_offset value.

Note: EGA memory offsets 0h and 2000h are used for screen pages 0 and 1, respectively. Any data written to these blocks may be overwritten by drawing functions. If the intention is to provide long-term tile storage, dest_offset should be at least 4000h.

    for (i = 0; i < dest_length; i++) {
        for (mask = 0x0100; mask < 0x1000; mask = mask << 1) {
            outport(0x03c4, mask | 0x0002);

            *(dest + i) = *(src++);
        }
    }
}

The actual copy occurs here. The outermost for loop governs the total range of destination address space that is written, influenced by dest_length.

Inside that, a second for loop selects the plane mask to be written. This loop runs four times, generating a mask value of 1, 2, 4, and 8 to select the blue, green, red, and intensity planes (respectively). The mask value is stored in the high byte of a 16-bit word to save a shift operation later.

outport() sends two I/O bytes in one word-sized operation: Port 3C4h gets byte 2h, and port 3C5h gets the value in mask, where only the high byte has significant data. I/O port 3C4h is the EGA’s sequencer address register, which specifies the address (2h) that the sequencer’s data register should point to. I/O port 3C5h is that data register, and address index 2h refers to the “Map Mask” parameter. This receives the value in mask, which has the effect of limiting writes to just the one plane being serviced during this iteration.

The copy itself is straightforward. The byte at src is copied to the address i bytes above dest. This pattern of byte copying is necessary to deinterleave the solid tile image data – which is stored in blue-green-red-intensity byte order – into the planar format required by the EGA.

The src pointer is incremented, and the loops move on to their next iterations.

ClearScreen()

The ClearScreen() function overwrites the EGA memory for the current draw page with solid black tiles. The end result of this is a completely blank draw page.

void ClearScreen(void)
{
    word x, y;

    EGA_MODE_LATCHED_WRITE();

This function uses DrawSolidTile() to perform the low-level drawing of each individual tile, and that function requires the EGA to be placed into latched write mode. The call to the EGA_MODE_LATCHED_WRITE() macro achieves this.

    for (y = 0; y < 25 * 320; y += 320) {
        for (x = 0; x < 40; x++) {
            DrawSolidTile(TILE_EMPTY, y + x);
        }
    }
}

A pair of nested for loops causes drawing to iterate over every row/column combination available on the screen. The game runs in a 320 × 200 mode, and each tile drawn is 8 × 8. The total number of iterations required to traverse the entire screen is therefore 40 tile-widths in the horizontal direction, and 25 tile-heights vertically.

Due to the layout of the EGA’s planar memory, a one-byte change in memory offset results in an eight-pixel (or one-tile) displacement horizontally. Each pixel row of display memory occupies 40 bytes of address space, and an eight-row (or one-tile) vertical displacement requires a 320 byte change in offset. This is why the x variable increments by one, while the y variable uses increments of 320.

At each tile position on the screen (1,000 in total), a call to DrawSolidTile() draws the solid tile image TILE_EMPTY. This is an 8 × 8 tile of solid black, which overwrites whatever was present in that position of memory.

Once both loops run to completion, the draw page is blank.

FadeOutCustom()

The FadeOutCustom() function “fades” the screen image away by incrementally blanking the EGA’s palette to black, one entry at a time, pausing delay game ticks between each entry. This function blocks until the fade is complete. Once all 16 palette entries are blanked, the function returns.

When the palette is faded out in this way, the actual image data still exists in memory and can be brought back by restoring the palette to its original configuration. Typically the game will fade the screen out to black, then perform drawing functions to build a new screenful of data out of view, and finally perform a “fade in” to show the newly-drawn content.

void FadeOutCustom(word delay)
{
    int reg;

    for (reg = 0; reg < 16; reg++) {
        WaitHard(delay);
        SetPaletteRegister(reg, MODE1_BLACK);
    }
}

This function is simply a for loop that iterates over the 16 EGA palette entries, with the current palette index stored in reg. During each iteration, WaitHard() pauses execution for delay game ticks so the user can see the change as it progresses. SetPaletteRegister() then sets the palette index reg to the color MODE1_BLACK, which immediately blanks any pixels of that color that happen to be on the screen.

Once all 16 palette indexes have been set to black, nothing is visible on the screen and the function returns.

FadeInCustom()

The FadeInCustom() function “fades” the screen image into view by rebuilding the EGA’s default palette, one entry at a time, pausing delay game ticks between each entry. This function blocks until the fade is complete. Once all 16 palette entries are configured, the function returns. The effect of this fade is only apparent if the screen had previously been faded out using FadeOutCustom() or a similar palette-blanking function.

void FadeInCustom(word delay)
{
    word reg;
    word skip = 0;

    for (reg = 0; reg < 16; reg++) {
        if (reg == 8) skip = 8;

        SetPaletteRegister(reg, reg + skip);
        WaitHard(delay);
    }
}

The essence of this function is a for loop that iterates over the 16 EGA palette register entries, with the current register index in reg. For each index, the color value is reset to the EGA’s default palette for video mode Dh. In this default palette, indexes 0–7 should have color values 0–7, and indexes 8–15 should have color values 16–23. (The linked page explains more about why the palette is constructed this way.) The inclusion of the skip value creates the necessary discontinuity.

With a palette index and color value pair in hand, the call to SetPaletteRegister() writes the change to the video hardware and the new color becomes visible immediately. WaitHard() is then called, pausing execution for delay game ticks and allowing time for the effect to be perceived by the user.

The function returns once all 16 palette indexes have been set to their default color values.

FadeOut()

The FadeOut() function calls FadeOutCustom() with a fixed delay of three game ticks per palette entry. This fade takes about one-third of a second to complete.

void FadeOut(void)
{
    FadeOutCustom(3);
}

FadeIn()

The FadeIn() function calls FadeInCustom() with a fixed delay of three game ticks per palette entry. This fade takes about one-third of a second to complete.

void FadeIn(void)
{
    FadeInCustom(3);
}

FadeWhiteCustom()

The FadeWhiteCustom() function “fades” the screen image away by incrementally blanking the EGA’s palette to white, one entry at a time, pausing delay game ticks between each entry. This function blocks until the fade is complete. Once all 16 palette entries are blanked, the function returns.

void FadeWhiteCustom(word delay)
{
    word reg;

    for (reg = 0; reg < 16; reg++) {
        SetPaletteRegister(reg, MODE1_WHITE);
        WaitHard(delay);
    }
}

This function is essentially identical to FadeOutCustom(), except for the different ordering of SetPaletteRegister()/WaitHard() and the fact that the color here is MODE1_WHITE instead of black. The behavior is the same, but here the screen is left in a state where it is showing solid white.

The screen can be restored using one of the “fade in” functions.

DrawFullscreenImage()

The DrawFullscreenImage() function loads and displays the full-screen image identified by image_num, fading the screen contents between what has already been drawn and the new image. If the requested image_num is anything other than IMAGE_TITLE or IMAGE_CREDITS, any playing music is stopped.

image_num should be one of the available IMAGE_* values.

void DrawFullscreenImage(word image_num)
{
    byte *destbase = MK_FP(0xa000, 0);

The destbase pointer is set up to point to the beginning of the EGA’s memory at address A000:0000 by a call to MK_FP(). This is the first byte of screen page 0.

    if (image_num != IMAGE_TITLE && image_num != IMAGE_CREDITS) {
        StopMusic();
    }

Typically, this function is called during significant changes to game state (during “scene changes” in a sense). Typically such changes would warrant stopping the music, but not always. If the requested image_num is either IMAGE_TITLE or IMAGE_CREDITS, the game is currently cycling through the title loop and the main menu music should not be interrupted.

Otherwise, StopMusic() is called to silence any current music that is playing.

    if (image_num != miscDataContents) {
        FILE *fp = GroupEntryFp(fullscreenImageNames[image_num]);

        miscDataContents = image_num;

        fread(miscData, 32000, 1, fp);
        fclose(fp);
    }

This section reads the image data from disk and stores it in the miscData memory block. It is wrapped in a most-recently-used cache check: If miscDataContents matches the value in image_num, this data has already been loaded by a previous call and we can skip loading it again.

Otherwise, the fullscreenImageNames[] array is consulted to translate the numeric image_num into a group file entry name. This name is passed to a GroupEntryFp() call to locate the data. This is returned in the file stream pointer fp. miscDataContents is updated to maintain the most-recently-used cache.

fread() loads 32,000 bytes of data from fp into miscData. Once this is done, fp is closed with fclose().

    EGA_MODE_DEFAULT();
    EGA_BIT_MASK_DEFAULT();
    FadeOut();
    SelectDrawPage(0);

With the image data loaded into a staging area in main memory, the EGA hardware is programmed to receive it. EGA_MODE_DEFAULT() resets its read and write modes into their default states, reverting any possible changes to these modes that may have occurred during the course of drawing. EGA_BIT_MASK_DEFAULT() further normalizes the hardware state by resetting the bit mask, allowing all pixels positions on the screen to be changed.

FadeOut() is the first visible effect of this function, which fades the screen contents to black. With the hardware in this state, no changes to the screen contents can be seen – every combination of memory contents produces a solid black screen.

The call to SelectDrawPage() is not important. Normally this is used to influence the behavior of the assembly drawing functions, but none of them are used here. In this specific case, initializing destbase to point directly at segment A000h is what selects page 0 for drawing.

    {  /* for scope */
        register word srcbase;
        register int i;
        word mask = 0x0100;

        for (srcbase = 0; srcbase < 32000; srcbase += 8000) {
            outport(0x03c4, 0x0002 | mask);

            for (i = 0; i < 8000; i++) {
                *(destbase + i) = *(miscData + i + srcbase);
            }

            mask <<= 1;
        }
    }

Here the image data in main memory is installed into the EGA’s display memory. The full-screen image data is stored in screen-planar format: 8,000 bytes of blue pixel data, followed by another 8,000 bytes of green, then red, and finally intensity. The EGA memory is similar, but it only exposes 8,000 bytes of address space – this must be written four times with differing map mask values to target each memory plane in turn. This selection is stored in the high byte of mask, which is initialized to 1 to start with the memory plane for blue.

The outer for loop controls the base offset in the source data. This runs four times, producing a srcbase of 0, 8,000, 16,000, and 24,000. This is the offset to the zeroth byte of source data for the current plane being operated on.

Within each plane, outport() is used to program the EGA’s map mask value. This is done by writing two bytes with a single word-sized I/O operation: I/O port 3C4h is the EGA’s sequencer address register, which specifies the address (2h) that the sequencer’s data register should point to. I/O port 3C5h is that data register, and address index 2h refers to the “Map Mask” parameter. This receives the high byte in mask, which has the effect of limiting writes to just the one plane being written during this iteration.

The inner for loop executes 8,000 times, once for each eight-pixel span on the screen. The source data byte is the miscData memory block, plus the srcbase offset to the current plane being read, plus the offset in i. The destination data byte is the video memory destbase plus the offset in i. Copying a byte from the former to the latter writes eight pixels of data for a single memory plane.

Once all pixel positions in the plane have been written, the value in mask is shifted one bit position to the left. This prepares a subsequent iteration of the outer for loop to operate on the next memory plane in sequence.

    SelectActivePage(0);
    FadeIn();
}

SelectActivePage() configures the video hardware to show screen page 0. All of the previous operations manipulated page 0, and this ensures the correct page will be sent to the display. The palette is still blanked, so the change does not become visible to the user until FadeIn() runs to completion, restoring the palette registers to their normal state.

At this point, the image data has been drawn and is visible, so the function returns.

DrawFullscreenText()

Right before the game exits, the DrawFullscreenText() function is typically called to draw a page of B800 text to the screen above the DOS prompt. This function loads the entry identified by entry_name from a group file and copies it to text mode video memory. It then moves the cursor down to ensure that any future interactions with DOS will not interfere with what was just drawn.

void DrawFullscreenText(char *entry_name)
{
    FILE *fp = GroupEntryFp(entry_name);
    byte *dest = MK_FP(0xb800, 0);

The function begins by calling GroupEntryFp() to load the group file data specified by entry_name. A file stream pointer to this data is returned in fp. Next, a pointer to the destination video memory is constructed using MK_FP() to point dest at the memory address B800:0000.

Unlike EGA modes (like Dh, the graphical mode for this game), most of the text modes store video data in the B800h segment of memory. This disparity dates back to the earliest graphics adapters for the IBM PC (the MDA and the CGA), and ostensibly permits a user to install both adapters into the system simultaneously. In fact, with careful configuration, it is possible to run a dual-display IBM PC with graphics output on one screen and text on the other. These differing memory segments are an important piece of that functionality.

Segment B800h marks the beginning of a 4,000 byte block of data – 2,000 bytes of 80 × 25 character data, interleaved with 2,000 bytes of attribute data. (Attributes control the color and blinking in these video modes.) The group file entry data is encoded in exactly this format, so displaying it is a relatively simple matter of copying that data straight into the video memory.

    fread(backdropTable, 4000, 1, fp);
    movmem(backdropTable, dest, 4000);

fread() copies 4,000 bytes of data from the file stream fp into a scratch buffer in main memory (backdropTable[]). The Borland-specific movmem() then copies that same data into the video memory at dest. There isn’t really any technical requirement for the data to pass through backdropTable[] on its way to video memory. This might be a vestige of an earlier implementation.

Once the copy completes, the entire text screen has been overwritten with the data from the group file entry.

    printf("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n");
}

When the video hardware is switched from graphics mode back to text mode, most of the hardware registers and relevant BIOS memory areas are reset to the default values for that video mode. Among those values are the cursor position, which is zeroed to return the cursor to the top-leftmost position. This means that, the next time a program writes something to the screen, it will do so at the top-left of the screen.

This situation is not ideal, because there is now content on the screen that BIOS is not aware of. If the hardware were left in this state, the DOS prompt and any user input would be overlaid on top of the text page that was just drawn, creating a mishmash of content that is hard to read. Even more unfortunately, the attribute bytes are not usually rewritten while DOS is in control of the console output, meaning that anything the user types (and anything DOS writes to the screen) will appear in whatever color the text content left the memory in. It looks weird:

Sample a B800 text screen without cursor repositioning.

To combat this, printf() is called to write 22 newline characters to the console. Since there is no printable content in the string, nothing in display memory is actually changed. The only effect here is that the cursor position is advanced by 22 lines, placing it on the last line of significant content in the text page. When the program exits, DOS emits one additional newline and the command prompt appears at the desired position in the blank area of the screen.

AnimatePalette()

During each frame of gameplay, AnimatePalette() is called to cycle through any palette animations that have been requested by the map. If palette animation is necessary, this function determines the color to display.

void AnimatePalette(void)
{
    static byte lightningState = 0;

#ifdef EXPLOSION_PALETTE
    if (paletteAnimationNum == PAL_ANIM_EXPLOSIONS) return;
#endif

lightningState is a private variable that holds the state of the lightning effect, if the map uses it. Since it is declared static, it retains its value between calls.

Episode three of the game has an EXPLOSION_PALETTE feature. If requested by the map, the palette is changed in response to explosions that occur during gameplay. If the map’s paletteAnimationNum matches PAL_ANIM_EXPLOSIONS, this feature is active for the current map. That is handled elsewhere (DrawExplosions()), so return early in this case.

    switch (paletteAnimationNum) {
    case PAL_ANIM_LIGHTNING:
        if (lightningState == 2) {
            lightningState = 0;
            SetPaletteRegister(PALETTE_KEY_INDEX, MODE1_DARKGRAY);
        } else if (lightningState == 1) {
            lightningState = 2;
            SetPaletteRegister(PALETTE_KEY_INDEX, MODE1_LIGHTGRAY);
        } else if (rand() < 1500) {
            SetPaletteRegister(PALETTE_KEY_INDEX, MODE1_WHITE);
            StartSound(SND_THUNDER);
            lightningState = 1;
        } else {
            SetPaletteRegister(PALETTE_KEY_INDEX, MODE1_BLACK);
            lightningState = 0;
        }
        break;

The remainder of the function is a large switch statement that handles each defined paletteAnimationNum value. If the map uses PAL_ANIM_LIGHTNING, a random lightning effect is drawn via calls to SetPaletteRegister() with accompanying thunder sound effects from StartSound().

The lifecycle of lightning is as follows: Most of the time, lightningState is 0 and there is no active lightning occurring. Whenever the system’s random number generator satisfies the precondition, the palette key color is set to white and a thunder sound effect is started – this is lightningState 1. On the subsequent frame, the palette key color changes to light gray and lightningState becomes 2. During the next frame, the color changes to dark gray and lightningState returns to 0. On the next frame, the palette color is cleaned up and the black color is restored.

In terms of implementation, there isn’t much to comment on. In Turbo C, rand() returns a value between 0 and 32,767, so there is roughly a 1-in-22 chance of a lightning strike on any given idle frame. The else block executes during every idle frame and continually rewrites the key color to black.

When this path is taken, the function returns when break is reached.

    case PAL_ANIM_R_Y_W:
        {
            static byte rywTable[] = {
                RED, RED, LIGHTRED, LIGHTRED, YELLOW, YELLOW, WHITE, WHITE,
                YELLOW, YELLOW, LIGHTRED, LIGHTRED, END_ANIMATION
            };

            StepPalette(rywTable);
        }
        break;

If the map instead uses PAL_ANIM_R_Y_W, a much simpler “red-yellow-white” repeating palette animation is needed. The rywTable[] array contains a sequence of enum COLORS values that define the pattern to use. This pattern is terminated with an END_ANIMATION marker to indicate the loop point.

The color pattern is passed to StepPalette(), which handles the actual cycling behavior and palette configuration.

As with the lightning animation, the function returns when break is reached.

    case PAL_ANIM_R_G_B:
        {
            static byte rgbTable[] = {
                BLACK, BLACK, RED, RED, LIGHTRED, RED, RED,
                BLACK, BLACK, GREEN, GREEN, LIGHTGREEN, GREEN, GREEN,
                BLACK, BLACK, BLUE, BLUE, LIGHTBLUE, BLUE, BLUE,
                END_ANIMATION
            };

            StepPalette(rgbTable);
        }
        break;

    case PAL_ANIM_MONO:
        {
            static byte monoTable[] = {
                BLACK, BLACK, DARKGRAY, LIGHTGRAY, WHITE, LIGHTGRAY,
                DARKGRAY, END_ANIMATION
            };

            StepPalette(monoTable);
        }
        break;

    case PAL_ANIM_W_R_M:
        {
            static byte wrmTable[] = {
                WHITE, WHITE, WHITE, WHITE, WHITE, WHITE, RED, LIGHTMAGENTA,
                END_ANIMATION
            };

            StepPalette(wrmTable);
        }
        break;
    }
}

The previous implementation is repeated three more times, for “red-green-blue”, monochrome, and “white-red-magenta” color patterns.

In the event that the paletteAnimationNum doesn’t match any of the defined case labels, this function returns without doing anything.

StepPalette()

If the current map calls for a simple looping palette animation, AnimatePalette() calls StepPalette() to handle that. During each frame of gameplay, this function steps through the elements of the passed palette table pal_table and sets the palette key color accordingly, repeating once the END_ANIMATION marker has been reached. This function expects an array containing one or more enum COLORS values followed by the end marker.

void StepPalette(byte *pal_table)
{
    paletteStepCount++;
    if (pal_table[(word)paletteStepCount] == END_ANIMATION) {
        paletteStepCount = 0;
    }

This function uses the global paletteStepCount variable to keep track of its position within the palette table. For reasons that are not clear, this was originally declared as a 32-bit doubleword, but all operations treat it as a 16-bit word. This cast is made explicit to call attention to the fact and to silence compiler warnings.

paletteStepCount is incremented during each call. If the pal_table element at the new position is END_ANIMATION, the count resets to zero.

    SetPaletteRegister(
        PALETTE_KEY_INDEX,
        pal_table[(word)paletteStepCount] < 8 ?
        pal_table[(word)paletteStepCount] :
        pal_table[(word)paletteStepCount] + 8
    );
}

The remainder of the function is a simple SetPaletteRegister() call to reprogram the key color with the current color from pal_table. The ternary operator converts the enum COLORS-type numbering scheme (0–15) into the enum MODE1_COLORS scheme (0–7; 16–23) the palette requires.