Play the classic game little bee 40 years ago again. This time, the source code is cleared

This article is suitable for friends with C language foundation

This is from Hello GitHub Explaining open source projects Series, this issue explains the childhood memories of the post-80s and post-90s. si78c, a C language reprint of the classic arcade game space invader, also known as "little bee", was born in 1978.

Project: https://github.com/loadzero/si78c

This game was all the rage at that time. I believe many friends played it when they were young. Now grown up, I don't know how many friends are interested in its source code!

The original space invader was written in about 2k lines of 8080 assembly code, but the assembly language is too low to read. The open source project si78c explained today was rewritten in C language according to the original assembly code, and restored the interrupt and cooperation logic of the original arcade hardware to the greatest extent, When running, its memory state is almost the same as that of the original version, almost reaching a perfect replica, which really brightens my eyes!

Now, please follow Hello GitHub to run this open source project, read the source code, and experience the subtlety of game design 40 years ago through history!

1, Quick start

The experimental environment for this article is Ubuntu 20.04 LTS, and the GCC version is greater than GCC 3

1. Preparation

First si78c use SDL2 to draw the game window, so you need to install dependencies:

$ sudo apt-get install libsdl2-dev

Then download the source code from the warehouse:

$ git clone https://github.com/loadzero/si78c.git

In addition, the project will extract the pictures and fonts of the original game from the original ROM, so you also need to download the original ROM file

Original ROM file of space invader: https://archive.org/download/MAME_078/invaders.zip

2. Document structure

Create new folders named inv1 and bin in the si78c source folder

$ cd si78c-master
$ mkdir inv1 bin

Then unzip the contents of invaders.zip into inv1. The final directory structure is as follows:

si78c-master
├── bin
├── inv1
│   ├── invaders.e
│   ├── invaders.f
│   ├── invaders.g
│   └── invaders.h
├── Makefile
├── README.md
├── si78c.c
└── si78c_proto.h

3. Compilation and operation

Compile with make:

$ make

After that, an executable file will be generated in the bin folder, and the game can be started by running:

$ ./bin/si78c 

The game control buttons are as follows:

a   LEFT((shift left)
d   RIGHT((shift right)
1   1P((single person)
2   2P((double)
j   FIRE((shooting)
5   COIN(coin-operated)
t   TILT(End game)

2, Pre knowledge

2.1 introduction

The original code of space invader runs on the 8080 processor, and its contents are all written by assembly code and involve some hardware operations. In order to simulate the logic and effect of the original arcade code, si78c converts the assembly code into C language as much as possible, and uses a Mem structure to simulate the hardware of the original arcade, Therefore, some codes are strange or even unimaginable from the perspective of pure software, but due to space reasons, the author cannot post all the codes into the article for explanation, so please read this article with my detailed comments.

2.2 what is a collaborative process

si78c uses the co process of ucontex library to simulate the process scheduling and interrupt operation of the original arcade.

Coroutine: coroutine is lighter, faster and saves resources. Coroutine is equivalent to thread to process.

ucontext provides getcontext(), makecontext(), swapcontext(), and setcontext() functions to create and switch coroutines. The initialization function in si78c is init_thread. Let's look directly at the examples in the source code:

If this is not intuitive enough, you can look at the following state transition diagram, and the combination of graphics and text is more intuitive.

Code 2-1

// Intermediate variable used when switching coprocedures
static ucontext_t frontend_ctx;
// Main logical processes of the game
static ucontext_t main_ctx;
// Game interrupt logic
static ucontext_t int_ctx;


// Used to switch two co processes
static ucontext_t *prev_ctx;
static ucontext_t *curr_ctx;

// Initialize the game collaboration
static void init_threads(YieldReason entry_point)
{
    // Gets the current context and stores it in main_ In CTX
    int rc = getcontext(&main_ctx);
    assert(rc == 0);

    // Specify stack space
    main_ctx.uc_stack.ss_sp = main_ctx_stack;
    // Specify stack space size
    main_ctx.uc_stack.ss_size = STACK_SIZE;
    // Set successor context
    main_ctx.uc_link = &frontend_ctx;

    // Modify main_ The CTX context points to run_main_ctx function
    makecontext(&main_ctx, (void (*)())run_main_ctx, 1, entry_point);

    /** The above content is equivalent to creating a new one called main_cxt collaboration, run run_main_ctx function, frontend_ctx is the successor context
     * (run_main_ctx After running, frontend will be run again_ CTX record context)
     * For a thread, a coroutine is equivalent to a thread for a process
     * But the cost of CO process switching is smaller and easier to use
     */

    // Gets the current context stored in init_ In CTX
    rc = getcontext(&int_ctx);

    // Specify stack space
    int_ctx.uc_stack.ss_sp = &int_ctx_stack;
    // Specify stack space size
    int_ctx.uc_stack.ss_size = STACK_SIZE;
    // Set successor context
    int_ctx.uc_link = &frontend_ctx;

    // Modify the context to point to run_init_ctx function
    makecontext(&int_ctx, run_int_ctx, 0);

    /** The above content is equivalent to creating a new one called int_ctx co process, run_int_ctx function, frontend_ctx is the successor context
     * (run_int_ctx After running, frontend will be run again_ CTX record context)
     * For a thread, a coroutine is equivalent to a thread for a process
     * But the cost of CO process switching is smaller and easier to use
     */

    // To pre_ The initial value of CTX can be switched to main when timeslice() is called for the first time_ CTX operation
    prev_ctx = &main_ctx;
    // To curr_ctx initial value, frontend_ctx is still empty
    // frontend_ctx will be used to save the state of the last collaboration during context switching
    curr_ctx = &frontend_ctx;
}

After that, every time yield() is called, swapcontext() will be used to switch between the two coroutines:

Code 2-2

static void yield(YieldReason reason)
{
    // Scheduling reason
    yield_reason = reason;
    // Dispatch to another process
    switch_to(&frontend_ctx);
}

// Coprocess switching function
static void switch_to(ucontext_t *to)
{
    // Give co_switch wraps a layer, simplifying the amount of code
    co_switch(curr_ctx, to);
}

// Coprocess switching function
static void co_switch(ucontext_t *prev, ucontext_t *next)
{
    prev_ctx = prev;
    curr_ctx = next;

    // Switch to the context pointed to by next and save the current context in prev
    swapcontext(prev, next);
}

See the following text for specific usage

Due to the limited space of the article, the following only shows the key source code. More detailed source code line by line Chinese Notes:

Address: https://github.com/AnthonySun256/easy_games

2.3 analog hardware

As mentioned earlier, si78c is a pixel level reproduction of the original arcade game, and even most of the memory data are equal. In order to do this, si78c simulates part of the hardware of the arcade: RAM, ROM and video memory, which are encapsulated into a large structure called Mem in the code. The memory allocation is as follows:

  • 0000-1FFF 8K ROM
  • 2000-23FF 1K RAM
  • 2400-3FFF 7K Video RAM
  • 4000- RAM mirror

It can be seen that the RAM of the machine was only a poor 1kb in size, and each bit was precious, which needed careful planning by the program. Here is a RAM allocation table, more details

2.4 from analog video memory to screen

Before explaining the principle of game animation display in detail, we need to understand how game materials are stored:

Figure 2-1

The picture comes from the interpretation of arcade assembly code

In the original ROM of the arcade, the game material is directly saved in the memory in binary format, in which each binary represents whether the current pixel is black or white

For example, the memory data at 0x1BA0 shown in Figure 2-1 is 00 03 04 78 14 08 1A 3D 68 FC 68 3D 1A 00. When arranged in eight bits in a row, it is a picture of an alien with an inverted letter "Y" (the content in the picture seems to be rotated 90 degrees, because the picture is stored column by column, and every 8 bit s represents a column of pixels).

The author of si78c directly exchanges the X and Y axes when displaying the picture to achieve the effect of rotating the picture.

We can find the structure named Mem, in which m.vram (0x2400 to 0x3FFF) simulates the video memory of the arcade. Each bit represents the black (0) and white (1) of a pixel, which is rendered from the lower left corner to the upper right corner. The corresponding relationship is shown in Figure 2-2:

Figure 2-2

All the codes related to animation drawing in the game are modifying the data of this part of the area, such as DrawChar(), ClearPlayField(), DrawSimpSprite(), etc. So how to make the simulated existing content displayed on the player's screen? Note that the render() function is called at the end of the loop in code 3-1. It is responsible for reading the contents of the analog video memory one by one and rendering a pixel block where there are pixel blocks on the window.

When you think about it carefully, it is not difficult to find that this method of modifying the analog memory first and then unified drawing is not much easier, even a little strange. This is because si78c simulates the display process of arcade Hardware: modify the corresponding video memory, and then the hardware will automatically display the contents of the video memory on the screen.

2.5 key detection

The input() function in code 3-1 is responsible for detecting and storing the user's key information, and its bottom layer depends on the SDL library.

3, First start

Like all C programs, si78c runs from the main() function:

Code 3-1

int main(int argc, char **argv)
{
    // Initialize SDL and game window
    init_renderer();
    // Initialize game
    init_game();
    int credit = 0;
    size_t frame = -1;
    // Start game co process scheduling and simulation trigger interrupt
    while (1)
    {
        frame++;
        // Process key input
        input();
        // If the exit flag is set, launch the cycle to clean up the game memory
        if (exited)
            break;
        // preserves timing compatibility with MAME
        // Retain timing compatibility with MAME (an arcade)
        if (frame == 1)
            credit--;
        /**
         *  Time to execute other processes
         * (Why is this number? I don't know. It should be an estimate)
         * (The original author also said that this timing method is not very accurate, but it does not affect the game effect)
         */
        credit += CRED1;
        loop_core(&credit);
        // Set the interrupt flag bit in the middle of the field, in the loop below_ Switch to int in core()_ CTX executes once and then clears the flag bit
        irq(0xcf);
        // The reason is the same as above
        credit += CRED2;
        loop_core(&credit);
        // Set the vertical blanking interrupt flag bit to loop in the next cycle_ Switch to int in core()_ CTX executes once and then clears the flag bit
        irq(0xd7);
        // Draw game interface
        render();
    }
    fini_game();
    fini_renderer();
    return 0;
}

The startup process is shown in the figure:

Figure 3-1

The original game code (8080 assembly) uses interrupt driver (this programming method is related to hardware, and you can understand what interrupt is by yourself) to cooperate with collaborative multitasking operation. In order to simulate the original game logic, the author takes the large loop in main() as the hardware Behavior Simulation Center (realizing interrupt management, CO process switching and screen rendering). About one third of the game's time is spent running the main thread. The main thread will be preempted by two interrupts, midscreen and vblank. The two irq() in code 3-1 realize the simulation of interrupts (set the corresponding variables as flag bits).

Enter loop for the first time_ The process of core() is as follows:

Figure 3-2

Because yield_ The rason variable is of type static and its default value is zero

Code 3-2

// Switch to the corresponding context according to the game status flag
static int execute(int allowed)
{
    int64_t start = ticks;
    ucontext_t *next = NULL;
    switch (yield_reason)
    {
    // Yield at start-up_ Reason is 0, indicating year_ INIT
    case YIELD_INIT:
    // When a delay is needed, it will call timeslice() to yield_reason switch to YIELD_TIMESLICE
    // Simulate the rotation of time slice. At this time, it will switch back to the last running task (two collaborative processes in total) to realize the rotation of time slice
    case YIELD_TIMESLICE:
        next = prev_ctx;
        break;
    case YIELD_INTFIN:
        // After processing the interrupt, let int_ctx hibernates and runs main again_ ctx
        next = &main_ctx;
        break;
    // Player death, waiting to start, alien invasion status
    case YIELD_PLAYER_DEATH:
    case YIELD_WAIT_FOR_START:
    case YIELD_INVADED:
        init_threads(yield_reason);
        enable_interrupts();
        next = &main_ctx;
        break;
    // Exit the game
    case YIELD_TILT:
        init_threads(yield_reason);
        next = &main_ctx;
        break;
    default:
        assert(FALSE);
    }
    yield_reason = YIELD_UNKNOWN;
    // If an interrupt occurs
    if (allowed && interrupted())
    {
        next = &int_ctx;
    }
    switch_to(next);
    return ticks - start;
}

It should be noted that the co process is switched in execute(). At this time, the running state of execute() is saved in the variable frontend_ In CTX, pointer prev_ctx updated to point to frontend_ctx, pointer curr_ctx is updated to point to main_ctx, the process is shown in the figure:

Figure 3-3

See code 2-2 for implementation explanation

When execute() returns, it will return to loop according to the normal execution process_ Core (), as if it had never been paused.

Watch main carefully_ We can find that the main loop in init calls the timeslice() function many times (for example, in OneSecDelay()), and we can implement main through this function_ CTX and frontend_ The time slice rotation operation between CTX is as follows:

Figure 3-4

In main_init() mainly does the following:

The game will rely on main before players put in coins_ Init() loop animation to attract players

If you just look at main_ We will find that the functions in init () do not involve much game logic in the code, such as alien movement, shooting, player coin check and so on. It seems that they do not exist at all. More often, they are manipulating memory and setting flag bits. So where are the functions related to game logic processing? This part will be revealed below.

4, Analog interrupt

Loop in code 3-1_ The core () function is separated by two irq(). We mentioned earlier that the large loop in main() is essentially simulating the hardware behavior of the arcade. On the real machine, the interrupt will be executed only when triggered, but on si78c, we can only use the loop_ Call irq() between core () to simulate the generation of an interrupt, and poll the interrupt status in execute() to determine whether to enter the interrupt processing function. The process is as follows:

At this time, its collaboration status is as follows:

There are two types of interrupts: midscreen_int() and vblank_int() the two interrupts take turns.

Code 4-1

// Functions that handle interrupts
static void run_int_ctx()
{
    while (1)
    {
        // 0xcf = RST 1 opcode (call 0x8)
        // 0xd7 = RST 2 opcode (call 0x16)
        if (irq_vector == 0xcf)
            midscreen_int();
        else if (irq_vector == 0xd7)
            vblank_int();
        // Enable interrupt
        enable_interrupts();
        yield(YIELD_INTFIN);
    }
}

Let's look at midscreen first_ int():

Code 4-2

/**
 * It is triggered by an interrupt when the light is about to hit the middle of the screen (which should simulate the realistic principle of an old arcade)
 * It mainly deals with the detection, update and rendering of game objects such as movement, fire and collision (see functions GameObj0 to 4 for details)
 * And determine which alien will be drawn next, and detect whether the alien invasion is successful
 */
static void midscreen_int()
{
    // Update vblank flag bit
    m.vblankStatus = BEAM_MIDDLE;
    // If there is no moving GameObject, return
    if (m.gameTasksRunning == 0)
        return;
    // In the welcome interface and not in the demo mode, return (only continue to run in the game mode and demo mode)
    if (!m.gameMode && !(m.isrSplashTask & 0x1))
        return;
    // Run game objects but skip the first entry (player)
    RunGameObjs(u16_to_ptr(PLAYER_SHOT_ADDR));
    // Determine the next alien to be drawn
    CursorNextAlien();
}

In this part, the RunGameObjs() function basically includes the player's movement and drawing, the movement, collision detection and drawing of player bullets and alien bullets, and all game logic processing. CursorNextAlien() finds the next living alien to be drawn, sets the flag bit to wait for drawing, and detects whether the alien spacecraft touches the bottom of the screen.

After running, it will return to run_int_ctx() continues to run until yield(YIELD_INTFIN) indicates that the coroutine is switched back to execute(), and set next to main again in execute()_ CTX make main_init() can continue to run (see code 3-2 for details).

Next is vblank_int():

Code 4-3

/** 
 * Triggered when the light hits the last point of the screen (simulating the principle of old arcade)
 * It mainly deals with the end of the game, coin, various events in the game, and playing demonstration animation
 */
static void vblank_int()
{
    // Update flag bit
    m.vblankStatus = BEAM_VBLANK;
    // Timer decrease
    m.isrDelay--;
    // See if the game is over
    CheckHandleTilt();
    // Let's see if it's a coin
    vblank_coins();
    // If the game task is not running, return to
    if (m.gameTasksRunning == 0)
        return;
    // If in the game
    if (m.gameMode)
    {
        TimeFleetSound();
        m.shotSync = m.rolShotHeader.TimerExtra;
        DrawAlien();
        RunGameObjs(u16_to_ptr(PLAYER_ADDR));
        TimeToSaucer();
        return;
    }
    // If the coin is too high
    if (m.numCoins != 0)
    {
        // xref 005d
        if (m.waitStartLoop)
            return;
        m.waitStartLoop = 1;
        // Switch the coroutine to wait for the start of the cycle
        yield(YIELD_WAIT_FOR_START);
        assert(FALSE); // Will not return
    }
    // If none of the above happens, play the demo animation
    ISRSplTasks();
}

One of its main functions is to detect whether the player wants to quit the game or makes a coin operation. If it is already in the game mode, play the fleet sound in turn and draw it on the midscreen_int(), run RunGameObjs() to handle the firing and moving events of players and aliens, and TimeToSaucer() to randomly generate mysterious UFOs. If it is not in game mode, enter ISRSplTasks() to adjust the animation that should be played on the current screen.

We can note that if the player makes a coin, it will enter if (m.numcoins! = 0) and call yield(YIELD_WAIT_FOR_START). After that, it will be prompted that this function will not return. There are such hints in many places in the code of si78c. It is not a simple call to a function that will not return.

Observation Code 3-2 can be found in YIELD_PLAYER_DEATH,YIELD_WAIT_FOR_START,YIELD_INVADED,YIELD_ Init is called in all four branches of tilt_ Threads (yield_reason). Int will be reset in this function_ CTX and main_ctx stack and rebind to call run_ main_ The parameter for CTX is yield_ Reason, so run at the next execution_ main_ctx will jump to the appropriate branch according to the instruction of the interrupt.

5, Cleverly save RAM

It was mentioned at the beginning that the RAM of the arcade was only a poor 1kb. Such a small place must not allow us to store the information of each object on the screen, but the location of players, the location of aliens, their bullets and the damage of shields on the screen will be updated in real time. How to do this?

I found that the content distribution in the space invader game area is still very regular. Special ships (flying saucers) will only appear on the top of the screen, and the positions of shields and players will not change. Only the position of bullets is difficult to grasp. Therefore, after carefully studying the code, it can be seen from DrawSpriteGeneric(), the game's collision detection is just a simple way to judge whether pixel blocks coincide, When judging what the player's bullet hit, the PlayerShotHit() function only needs to judge the vertical coordinates (Y coordinates) of the bullet. If > = 216, it hit the top, > = 206, it hit the mysterious UFO, and others hit the shield or alien submunitions. And because the alien spacecraft move together in groups, you only need to remember the position of one of them to calculate the coordinates of each alien spacecraft as a whole.

In this way, the program only needs to save the survival state of the alien spacecraft, the relative moving position of the current fleet, and the information of players and alien bullets. When it is necessary to detect the collision, read the pixel information in the video memory for comparison, and then deduce which two objects collided at the current time. This method saves a lot of resources compared with storing the information of each object.

6, Conclusion

si78c is different from other codes. It is essentially a simulation of hardware and assembly code. I hope that through the source code explanation of this article, more people can see the difficulties of programmers in making excellent games with limited resources and the subtlety of code design.

Finally, I would like to thank the author of this project for all he has done. Without his efforts, there would be no this article. If you think this article is good, welcome to share it with more people.

HelloGitHub official account was updated at the first time.

There are more open source projects and treasure projects waiting for you to find.

Keywords: github Next.js sdl HelloGitHub

Added by johnie on Wed, 01 Dec 2021 17:07:14 +0200