Felix Bernhardt edited this page Nov 24, 2017 · 9 revisions

ASM Space Invaders

In this Wiki I will highlight the project structure of the bootloader and the game. There will also be a list of all the resources I used to get started.

Some General Notes

While assembly looks incredibly intimidating at first sight, getting started is surprisingly easy. Its syntax is very straight forward and there is basically one single rule that applies to all of the instructions. I certainly benefited a lot from this project and can only recommend diving into assembly yourself if you are interested in understanding what is going on "under the hood".

Table Of Contents

Prerequisites

The project is written in x86 Assembly and can be build using the NASM assembler. I will not go into detail about how exactly assembly works. Check out these sources if you want to get an introduction to assembly:

The set of instructions I used is very small. Here is a short list of concepts and instructions necessary for this project:

  • First of all, you should be familiar with registers
  • The difference between physical memory addresses and their [segment:offset] representation
  • General instructions: mov, cmp, int, call, ret, jmp as well as conditional jumps
  • Arithmetic instructions: inc, dec, add, sub, sar

That is basically it. The int instruction is by far the most complex in this list. It let's you call existing BIOS functionality in order to access the display, the drives and IO devices. Here you can read more about interrupts in general.

Besides beeing able to read assembly there is also some knowledge required about how the booting process works. I will try to briefly explain the parts necessary for this project. If you are interested in this topic and want to read more, check out OSDev.org.

Coding Style

One of the most significant differences to higher level languages is the way of how to pass arguments to functions and how to return their results. Since the registers are the fastest option and are used by the interrupts as well, they naturally were my first choice.

Very early on I ran into serious problems, though. The set of registers is very limited and I ended up overwriting important data within nested function calls. As it turns out there already exist calling conventions to avoid exactly this problem. Sadly, I did only learn about those afterwards and ended up with my own hacky solution. But at least for this small scale project it worked pretty well.

Arguments And Return Value

For this I stayed consistent with the interrupts by passing arguments and the return value in registers. I tried to follow some general conventions:

  • SI, DI as source and destination pointers respectively
  • DX for positions
  • AX for return values
Preserving Registers Among Function Calls

To not accidentally invalidate registers I use the stack to preserve all registers I will modify within a function. You will find patterns like this throughout the project:

some_function:
  push ax
  push dx
  [modify ax, dx]
.done:
  pop dx
  pop ax
  ret

Bootloader

The bootloader is the very first program started by the BIOS after a bootable device is available and has been selected. Here is a more detailed description of this process. This first program is always loaded to the memory address 0x7c00 and is excatly 512 bytes long. To be considered bootable it has to have a fixed 2 byte signature at the very end:

000: 0x??
.
.
.
509: 0x??
510: 0x55
511: 0xaa

This leaves exactly 510 bytes for the actual bootloader code and all of its variables. Its only purpose is to load the game into the RAM and then pass execution to it. The loader is implemented in bootloader.asm. There are three essential parts for this to work.

Loading The Game
; the segment of the game
mov ax, 0x07e0

; setup the file-source
mov ch, 0x00    ; cylinder 0
mov cl, 0x02    ; sector 2 (skip first sector, which is the bootloader)
mov dh, 0x00    ; head 0
mov dl, 0x00    ; drive 0 (floppy disk)

; setup the destination
mov es, ax      ; segment starts directly after the bootloader (7c00 - 7dff)
mov bx, 0x0000

; copy data into RAM
read:
  mov al, 0x04    ; read four sectors
  mov ah, 0x02    ; int 13h subfunction 2 -> read sectors (512 bytes) from disk
  int 0x13        ; copy sectors to ES:BX
  jc read         ; carry-flag is set -> there was a read-error, retry

This loads 2 kb of data from the floppy disk and saves it to 0x7e00. The game is therefore placed into RAM immediately after the code of the bootloader. Here is a more detailed description of the used interrupt 0x13. Apparently floppy reads have a chance of random failures. The loader will therefore retry after a failed loading attempt. This solution is far from perfect, but for now I am just happy it works at all.

Starting The Game
; rebase segments for game execution
mov ax, 0x07e0
mov ds, ax  ; data segment
mov es, ax  ; additional segments
mov fs, ax
mov gs, ax
mov ss, ax  ; stack segment

; enter the game code -> set CS:IP
jmp 0x07e0:0x0000

After the game is successfully loaded the segment pointers are redefined to point to the game's base address. The bootloader then calls the game's first instruction at the very beginning of the file.

Creating A Bootable Program
; spacing and signature
times 510 - ($ - $$) db 0
dw 0xaa55

In order to be interpreted as bootable the bootloader includes the necessary signature in the last two bytes.

Space Invaders

The main file of the game is space-invaders.asm. It includes all additional files and contains the program's main loop. Also all the variables are still located here.

I used the NASM pre-processor to %include the files located in /src. Notice, that the pre-processor replaces every %include with the included files content. The resulting, single file is then interpreted by the assembler. In contrast to e.g. multiple C files, this does not require any additional linking.

After doing some initializations, like calculating the position of the centered game screen, the program jumps to its main function.

main:
  mov ah, [program_state]
  cmp ah, 1
  je .game
  cmp ah, 2
  je .end
.intro:
  call intro
  jmp main
.game:
  call game
  jmp main
.end:
  call end
  jmp main

This function will never return and just calls the subroutine that corresponds to the current program state.

Program States

The game is always in one of the three states intro, game or end. Each state function only returns if the state has been changed.

Intro

This is the initial state that is only entered after the game started. Once left, the program alternates between the states game and end. It just prints some strings and waits for a key to be pressed.

intro:
  call clear_screen
  mov ax, intro_string_t
  mov bx, intro_string_o
  call print_window
.wait:
  call get_key
  mov al, [key_pressed]
  cmp al, ' '
  je .game
  jmp .wait
.game:
  mov byte [program_state], 1
  ret

After the message is presented the function waits for SPACE to be pressed. The state is then changed to game and the function returns.

End

This state does exactly the same as the intro state besides that it changes the presented string corresponding to the winner of the previous game.

Game

This state needs to reset the game at the start and implement a nonblocking loop in order to move and render the game. The general structure looks like this:

game:
  call init_game
.loop:
  ; check whether a key has been pressed
  ; check whether player or invaders have won
.execute:
  ; move the game
  ; render the game
  ; sleep
  ; repeat loop
.done:
  mov byte [program_state], 2
  ret

All the necessary logic for these steps is implemented in the files in /src.

Utility Files

This class of files contains keyboard.asm and display.asm. The purpose of these is to wrap most of the explicit interrupt calls and provide some general purpose features.

Game

The file game.asm contains the logic to reset the game state and determine whether the game is still running.

Additionaly, the sleep and move functions are defined in here.

Invaders

invaders.asm contains move_invaders and render_invaders.

Within move_invaders an internal counter is increased on every call. When this counter is equal to INVADERS_MOVE_CYCLES, the invaders are moved and the counter is reset. Within the function an additional counter is compared to INVADERS_SHOOT_CYCLE. The general structure is as follows:

move_invaders:
  ; check move counter
.move:
  ; loop over all invaders
.loop:
  ; skip destroyed invaders
  ; move invader
  ; check for bullet collisions
  ; check shoot counter
.shoot:
  ; invader shoots a bullet
.done:
  ret

The function render_invaders essentially just loops over all invaders and prints them to the screen.

Player

The file player.asm basically has the same logic and structere as invaders.asm.

Bullets

bullets.asm contains the logic to create, remove, move and render bullets as well as to check for collisions.

It contains a special help function _iterate_bullets which expects a pointer to a loop function at DI. This function is then called in every iteration with SI pointing to the current invader.

_iterate_bullets:
  push si
  mov si, bullet_list
.loop:
  cmp si, [bullet_list_end]
  je .done
  call di
  add si, 3
  jmp .loop
.done:
  pop si
  ret
Bullet List

The list of bullets starts at the very end of the binary:

; space-invaders.asm

  bullet_list resw 1
  bullet_list_start resb 1

The pointer bullet_list_end points to the first byte after the list. The list itself starts at the last byte of the binary and then grows into uninitialized RAM. This implementation is still from the time when I tried to put everything into the bootloader.

Resources

Assembly
NASM
Miscellaneous
Clone this wiki locally
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.