Part 5 : Take control of the Amiga hardware
Amiga has a fairly sophisticated operating system that provides the programmer with all the tools to use the graphics and sound capabilities. However, historically, very few games have made use of the operating system (O.S.). Virtually all commercial games that were developed for the Amiga bypassed the operating system and took direct control of the hardware and were written in assembly language. The reason is simple: in this way it is possible to get the maximum performance from the hardware. Since we want to create a video game in assembly, programming the Amiga hardware directly, we have to take control of the Amiga hardware, stopping the O.S. in a controlled manner. Before starting to write code, we will introduce some theoretical concepts necessary for a full understanding of the code we will write.
An interrupt is a signal that tells the CPU to stop executing the current program and execute an interrupt handling routine. At the end of this routine, the program resumes where it was interrupted. Interrupts are usually generated by external devices such as: custom chip, disk, serial ports etc ... It is a useful mechanism for communicating asynchronously. In its absence, the cpu should do a loop in which it checks the status of external devices (polling). The MC68000 cpu has 7 interrupt levels, ranging from 1 (lowest priority) to 7 (highest priority). The Amiga chipset uses only the lowest 6 levels. An interrupt routine can itself be interrupted by a higher level (and higher priority) interrupt. How does the cpu know which interrupt handling routine to call? There is a table containing pointers to these routines, based on interrupt levels. Pointers to interrupt handling routines are called autovectors (they have a different meaning from the mathematical one). The structure of the interrupt autovectors table is as follows:
Offset | Description |
---|---|
$64 | Level 1 Interrupt |
... | ... |
$78 | Level 6 Interrupt |
The base address of this table is given by the VBR register of the cpu. The original MC68000 cpu doesn't have a VBR register, so the base address is zero. When an interrupt is generated, the interrupt controller will set a flag in a register called INTREQ. If the corresponding interrupt level is enabled in another register called INTENA, then the controller will continue processing the interrupt and will call the interrupt handler routine. This routine will read the INTREQR register to determine which interrupt needs to be serviced. The handler will then clear the interrupt by clearing the interrupt's bit int the INTREQ register. The action of clearing the interrupt informs the controller that the interrupt is being handled. The following table summarizes the interrupt registers:
Name | Type | Offset | Function |
---|---|---|---|
INTENAR | R | $01C | Interrupt enable read |
INTREQR | R | $01E | Interrupt request read |
INTENA | W | $09A | Interrupt enable |
INTREQ | W | $09C | Interrupt reques |
All the four registers use the format shown below:
BIT# | Name | Description |
---|---|---|
15 | SET/CLR | Set to 1 to set bits, se to 0 to clear bits (INTENA and INTREQ only) |
14 | INTEN | When 0 all interrupts are disabled (INTENA and INTREQ only) |
13 | EXTER | External Interrupts |
12 | DSKSYN | Disk sync pattern matched |
11 | RBF | Serial receiver buffer full |
10 | AUD3 | Finished reading of a block of data from audio channel 3 |
9 | AUD2 | Finished reading of a block of data from audio channel 2 |
8 | AUD1 | Finished reading of a block of data from audio channel 1 |
7 | AUD0 | Finished reading of a block of data from audio channel 0 |
6 | BLIT | Indicates that the Blitter has finished |
5 | VERTB | Indicates that the raster beam has reached line 0 (vertical blank) |
4 | COPER | Interrupt generated by Copper |
3 | PORTS | Interrupt generated by the I/O ports |
2 | SOFT | Software generated interrupt |
1 | DSKBLK | End of transferring a block of data from disk |
0 | TBE | Serial port buffer empty |
DMA is an acronym for "Direct Memory Access". We know that both the custom chips and the cpu can access the Amiga chip memory. To increase the level of parallelism of the Amiga, the designers have created channels that allow the custom chips to access the memory directly, without the help of the cpu, leaving it free to do other things. This is one of the strengths of the Amiga architecture. Access to the DMA channels is regulated by a "DMA Controller" present in the Agnus chip. DMA channels can be enabled or disabled via the write-only DMACON register ($dff096). There is a DMACONR read-only register ($dff002) to read the status of these channels. The following table shows the meaning of the bits of these registers:
BIT# | Name | Description |
---|---|---|
15 | SET/CLR | If it is 1 then the bits at 1 indicate enabling, if it is 0 then they indicate disabling |
14 | BlitBusy | read-only, indicates that the Blitter is busy |
13 | BlitZero | |
12 | X | not used |
11 | X | not used |
10 | BlitPri | Blitter Priority |
9 | Master | General switch for enabling all DMA channels |
8 | BPLEN | DMA channel for bitplanes |
7 | COPEN | Copper DMA channel |
6 | BLTEN | Blitter DMA channel |
5 | SPREN | Sprites DMA channel |
4 | DSKEN | Disk DMA channel |
3 | AUD3EN | DMA channel for voice 3 of the audio |
2 | AUD2EN | DMA channel for voice 2 of the audio |
1 | AUD1EN | DMA channel for voice 1 of the audio |
0 | AUD0EN | DMA channel for voice 0 of the audio |
The O.S. of Amiga, called AmigaOS and contained in a ROM memory called Kickstart, is composed of various libraries, which are not all present in memory at the same time, but are loaded dynamically. Its main component is called Exec and performs the functions of task scheduler, memory management, interrupt management, inter-process communication through messages, loading of dynamic libraries. At memory location $4, called ExecBase, there is the pointer to the library functions. In order to use a dynamic library, it must first be opened using the Exec's OpenLibrary function. This function returns a pointer to the base address of the library, which must be used to call all functions of the library itself. When you stop using a library, you need to close it using the CloseLibrary function.
Before you start writing some code, you need to define a structure that will be followed throughout the entire tutorial series. To facilitate the reuse and maintainability of the code, it is advisable to divide it into separate files, which will contain the routines to manage a certain functionality. Then we will have a main file which will contain the main loop and will call the various modules. In addition, the definition of constants, macros is grouped into include files with the extension ".i".
When using include files for constants, it may happen that you include the same file multiple times. To avoid multiple inclusions, with consequent assembler errors, we use the IFND assembler directive which assembles the following code only if the constant specified as a parameter has not already been defined. The second line defines this HARDWARE_I constant so that, at a second inclusion of the file, the code following IFND is not included. At the end of the file must be inserted the ENDC instruction which indicates the end of the code subject to the IFND clause. An example of an include file is as follows:
IFND HARDWARE_I
HARDWARE_I SET 1
; constants and macros definitions
ENDC
A typical library module will have the following structure:
- inclusion of constants, macros, data structures
- definition of variables
- routines exposed publicly
- private routines, called only internally to the module
It is a good idea to properly document the source code. To do this we will define some conventions. Each section will be delimited by a heading made like this:
;***************************************************************************
; SECTION NAME
;***************************************************************************
Each routine will be preceded by the following header:
;***************************************************************************
; Routine explanation
;
; Input:
; <register.size> = parameter description
;
; Output:
; <register.size> = value
;***************************************************************************
routine_name:
istructions ; comment
rts
The comments explaining the operations performed will be placed on the same line as the instructions, separated by a tab.
We will now write a routine that allows our program to take complete control of the Amiga hardware. It's the first routine to call in a video game, we'll call it take_system. What should this routine do? The main goal is to allow the exclusive use of hardware resources to our program, in particular the cpu, by stopping the operating system in a controlled way. This means that when our game will end, the control will return to Workbench without crashing the Amiga. We summarize the operations to be performed by the routine that takes control of the hardware in the following pseudo-code:
take_system:
reset video mode
disable O.S. multitasking
disable O.S. interrupts
disable all system interrupts
disable all DMA channels
The first operation we want to do is reset the video mode, in order to have a "clean" state of the registers of the custom chips. To do this, we will use the Amiga operating system. Before any function of Amiga O.S. can be used, the corresponding library must be loaded into memory, in this case we will use the "graphics.library". This operation is carried out with the "OpenLibrary" Exec function, which requires as input a string with the name of the library in a1. In output it returns a pointer to the base address of the library opened in d0. We will save the base address of the graphics.library in a variable for future use. The functions of the Exec are called by specifying offsets with respect to a base, which is located at the memory address $4, called EXEC_BASE.
move.l EXEC_BASE,a6 ; base address of Exec library
lea gfx_name(PC),a1 ; name of the library to open
jsr OpenLibrary(a6) ; opens graphics.library of O.S.
move.l d0,gfx_base ; saves base address of graphics.library
To reset the video mode, we need to call the function LoadView. As a parameter it expects a pointer to a structure that describes the view in a1. In case you want to reset the video mode, a1 must be zero. Before resetting the video mode, it is necessary to save the current value of the view in a variable, so that it can then be restored when the program is exited. After resetting the video mode, we wait for a couple of vertical blanks, using the WaitOf function, to be sure that the view is active. The vertical blank is the signal generated when the raster beam has finished drawing the screen.
move.l gfx_base(PC),a6 ; base address of graphics.library in a6
move.l $22(a6),wb_view ; saves current view
sub.l a1,a1 ; null view to reset video mode
jsr LoadView(a6) ; resets video mode
jsr WaitOf(a6) ; waits a vertical blank
jsr WaitOf(a6)
The second operation to do is to disable multitasking and interrupts of the operating system. Multitasking is a technique that splits the cpu time between multiple processes. An operating system component, called Scheduler, assigns the CPU to processes for a given amount of time. This gives the user the impression that it is possible to run multiple processes simultaneously. Since we are making a video game, we want to avoid giving the cpu to other processes, for this reason we disable the multitasking functionality of the operating system. But it is still not enough, in fact the cpu could be stolen from our game also by the execution of the operating system interrupts. To avoid this, we disable the interrupts generated by the operating system. To do this we use the functions of Exec Forbid, which disables multitasking and Disable, which disables interrupts. In this way we are sure that our video game will use all the available CPU time, avoiding giving it to other processes or interrupts management routines.
move.l EXEC_BASE,a6 ; base address of Exec library
jsr Forbid(a6) ; disable O.S. multitasking
jsr Disable(a6) ; disable O.S. interrupts
The third operation to do is the most critical one, in fact it is the one that disables all system interrupts and all DMA channels. Disabling system interrupts prevents the game from any interruptions that could steal CPU cycles. Disabling DMA channels avoids wasting DMA cycles for unwanted functions. We will see that in the initialization phase of the game we will have to re-enable only the DMA channels that we will use. Before disabling anything, let's save the state of the registers in appropriate variables: old_intena, old_intreq, old_adkcon, old_dma. So we disable the interrups by writing to the INTENA register. And then we disable the DMA channels by writing to the DMACON register. The following code snippet implements the above:
lea CUSTOM,a5 ; base address of custom chips registers
move.w INTENAR(a5),old_intena ; save interrupts state
move.w INTREQR(a5),old_intreq
move.w ADKCONR(a5),old_adkcon ; save ADKCON
move.w #$7fff,INTENA(a5) ; disable all interrupts
move.w #$7fff,INTREQ(a5)
move.w DMACONR(a5),old_dma ; saves state of DMA channels
move.w #$7fff,DMACON(a5) ; disables all DMA channels
Now let's see how to define some variables in assembly language. We will start by defining a variable of type string containing the name of the library we want to load, that is the "graphics.library". To do this, we use the Assembler directive dc.b, which defines a memory block containing byte-type constants, that is a string. Be careful that an assembly string must always be terminated by byte 0. Furthermore, the MC68000 cpu needs addresses aligned to 16 or 32 bits and therefore the addresses of the variables must be aligned to 16 bits. For this we use the EVEN directive which ensures that the next label will start to an even address, adding bytes cleared to zero. To define variables of the word or long type, it is sufficient to use the dc.w (for words) or dc.l (for longs) directive of the Assembler, which defines a variable of the word or long type, at 16 or 32 bits , and allows you to assign its initial value. The following code snippet shows the definition of the variables:
;***************************************************************************
; VARIABLES
;***************************************************************************
gfx_name dc.b "graphics.library",0 ; name of graphics.library of Amiga O.S.
even
gfx_base dc.l 0 ; base address of graphics.library
old_dma dc.w 0 ; saved state of DMACON
old_intena dc.w 0 ; saved value of INTENA
old_intreq dc.w 0 ; saved value of INTREQ
old_adkcon dc.w 0 ; saved value of ADKCON
return_msg dc.l 0
wb_view dc.l 0
At the end of our program, we will have to return control of the hardware to the Amiga O.S., so that you can return to the Workbench without locking the machine. To do this we will write a second routine, which we will call release_system. Let's see what this routine will have to do. In practice, it will have to carry out the reverse of the operations performed by take_system.
We summarize the operations that our routine must perform in the following pseudo-code:
release_system:
restore saved DMA channels
restore saved interrupts state
enable O.S. multitasking
enable O.S. interrupts
restore saved view
restore system copperlists
close graphics.library
Above we have introduced the concept of copperlist, which is nothing more than a program for the Copper coprocessor, containing instructions for displaying the screen. Since our game will use its own copperlist, which we will define in the future, at the end we have to restore the system ones.
The first step is to restore the DMA channels to their previous state. To set the DMA channels, bit 15 of the DMACON register must be set. This is done with the OR statement. At this point it is possible to write the value previously saved in the DMACON register.
lea CUSTOM,a5 ; base address of custom chips registers
or.w #$8000,old_dma ; sets bit 15
move.w old_dma,DMACON(a5) ; restores saved DMA state
The second operation is to restore the state of the interrupts. Before modifying the interrupt handling registers, they must all be disabled. Then we set bit 15 in the saved values, in order to be able to set these values in the write registers. Then we restore the values saved in the black variables INTENA, INTREQ and ADKCON registers.
move.w #$7fff,INTENA(a5) ; disable all interrupts
move.w #$7fff,INTREQ(a5)
move.w #$7fff,ADKCON(a5) ; clears ADKCON
or.w #$8000,old_intena ; sets bit 15
or.w #$8000,old_intreq
or.w #$8000,old_adkcon
move.w old_intena,INTENA(a5) ; restores saved interrupts state
move.w old_intreq,INTREQ(a5)
move.w old_adkcon,ADKCON(a5) ; restores old value of ADKCON
At this point we re-enable multitasking by recalling the Exec Permit function and subsequently the interrupts of the S.O. via the Enable function.
move.l EXEC_BASE,a6
jsr Permit(a6) ; enables O.S. multitasking
jsr Enable(a6) ; enables O.S. interrupts
Now, we just have to restore the view to the state prior to resetting the video mode. To do this we set the pointer to the view saved in a1 and call the LoadView function of the graphics.library. Be careful to load the pointer to the base address of the graphics.library in a6.
move.l gfx_base,a6 ; base address of graphics.library
move.l wb_view,a1 ; saved workbench view
jsr LoadView(a6) ; restores the workbench view
The next step is to restore the system copperlists. The default values are found in the data structure of the graphics.library itself, at the offsets given by the constants sys_cop1 ($26) and sys_cop2 ($32). These values must be entered in the COP1LC and COP2LC registers of the custom chips, paying attention to load a5 with the base address of the CUSTOM custom registers ($dff000).
move.l gfx_base,a1 ; base address of graphics.library
move.l sys_cop1(a1),COP1LC(a5) ; restores the system copperlist 1
move.l sys_cop2(a1),COP2LC(a5) ; restores the system copperlist 2
The last step is to close the graphics.library using the Exec's CloseLibrary function.
jsr CloseLibrary(a6) ; closes graphics.library
The main flow of our program will be implemented in the "main.s" file. We will initially find the include files. So there will be an initialization section where there will be the call to take_system to take control of the hardware. At this point we will enter the "main loop" that is the main cycle of the program. For the moment we will just check if the left mouse button is pressed, in which case it exits the loop. At the exit of the loop we will call the release_system to give back control of the hardware to the S.O. and we will finish the program. At the end we find the inclusion of the various modules, for the moment only the "hw_takeover.s" module. Below is the source code of the main.s module:
;***************************************************************************
; MAIN
;***************************************************************************
include "hardware.i"
main:
lea CUSTOM,a5 ; base address of custom chips
bsr take_system
mainloop:
btst #6,CIAAPRA ; if left mouse button is pressed, exits
bne.s mainloop
bsr release_system
rts
include "hw_takeover.s"
At this point you can try to assemble the source code. If we are using AsmOne or AsmPro, just type the command "a". If there are no compilation errors, you can run the code. To do this, with AsmOne / AsmPro we will use the "j" command. We will only see a black screen. Pressing the left mouse button will take you back to the assembler screen. The result of so much programming effort isn't very exciting at the moment, but everything works as expected. In the next installments we will start adding code to implement our video game and the result of the code execution will start to be more rewarding, because images will be displayed and then you can interact with them.
The full source code is available here.