Atrevida Game Programming Tutorial #17
Copyright 1998, Kevin Matz, All Rights Reserved.
Prerequisites:
In this short chapter, we'll examine the use of equates and macros to simplify our code. Then we'll take a look at assembler graphics programming using Mode 13h.
#define MaxSize 10 main () { int Array[MaxSize]; . . . }
When you compile a C/C++ program, the first compilation stage is the pre-processor stage. The compiler's pre-processor scans the source code listing, and when it sees a #define'd name, such as MaxSize, it removes that name and plugs in the value ("10", in this example) which was specified in the #define directive. So the above C/C++ listing would be converted to:
main () { int Array[10]; . . . }
An equate directive in assembler is very similar. Here's an example:
MaxSize EQU 10 DATASEG Array DW MaxSize DUP (0) CODESEG . . . MOV CX, MaxSize ADD AL, MaxSize . . .
"MaxSize" is equated to "10", and so every time the assembler sees the text "MaxSize" in a location where a value, expression or string is expected, it replaces it with the sequence of characters "10". The above assembler listing would become:
DATASEG Array DW 10 DUP (0) CODESEG . . . MOV CX, 10 ADD AL, 10 . . .
The equate makes it easy to change the constant. If we wanted a 20-element array, we could change the "MaxSize EQU 10" to "MaxSize EQU 20".
Here are a few notes about equates:
NumberOfPlayers EQU 5 . . . MOV AX, [NumberOfPlayers] ; Let AX equal the number of players
Well, let's figure out what the MOV instruction gets translated to:
MOV AX, [5]
The square brackets say "get the value at address 5", so the word at address DS:0005 will be copied to AX. That's probably not what we meant. We probably meant this:
MOV AX, NumberOfPlayers
That instruction gets translated to...
MOV AX, 5
...which is correct, because AX then equals the number of players, as the original comment indicates.
MOV CX, MaxSize ADD AL, MaxSize
Rows EQU 20 Columns EQU 30 TotalElements EQU Rows * Columns . . . MOV CX, TotalElements
What does TotalElements equate to? Note that the occurrences of "Rows" are changed to "20" and the occurrences of "Columns" are changed to "30". Then TotalElements equates to "20 * 30", and that is what is substituted for any occurrences of "TotalElements", so "MOV CX, TotalElements" becomes "MOV CX, 20 * 30". The assembler can figure out arithmetic expressions that evaluate to constant values, so that instruction is assembled as "MOV CX, 600".
PhraseA EQU "Hello " PhraseB EQU 'World' NewString DB PhraseA, PhraseB, 13, 10, "$"
Row EQU 20 Column EQU 30 TotalElements = Rows * Columns
Then "TotalElements" will be equated to "600", not "20 * 30".
One more useless fact: "=" equates can be "re-used" many times in a program, giving new values to the same name:
SomeNumber = 5 MOV AX, SomeNumber ; Now AX = 5 SomeNumber = 8 MOV AX, SomeNumber ; Now AX = 8
Macro definitions start with "MACRO" and end with "ENDM". Let's examine a sample macro:
MACRO DisplayCRLF PUSH AX PUSH DX ; Use INT 21h, Service 2 to display a Carriage Return character: MOV AH, 2 MOV DL, 13 ; CR character is ASCII 13 dec INT 21h ; Use INT 21h, Service 2 to display a Line Feed character: MOV DL, 10 ; LF character is ASCII 10 dec INT 21h POP DX POP AX ENDM
Now, in our code segment, whenever we want to print a carriage return and line feed (to make the cursor go to the beginning of the next line), we can simply use the following line:
DisplayCRLF
When the assembler sees the macro name DisplayCRLF, it replaces it with the text of the macro definition. So the following code listing...
DATASEG MyName DB "Elvis Presley", $ CODESEG . . . ; Display a string: MOV AH, 9 MOV DX, OFFSET MyName INT 21h DisplayCRLF ; More code... NOP NOP . . .
...gets turned into:
DATASEG MyName DB "Elvis Presley", $ CODESEG . . . ; Display a string: MOV AH, 9 MOV DX, OFFSET MyName INT 21h PUSH AX PUSH DX ; Use INT 21h, Service 2 to display a Carriage Return character: MOV AH, 2 MOV DL, 13 ; CR character is ASCII 13 dec INT 21h ; Use INT 21h, Service 2 to display a Line Feed character: MOV DL, 10 ; LF character is ASCII 10 dec INT 21h POP DX POP AX ; More code... NOP NOP . . .
The line "DisplayCRLF" is removed, and the macro definition is plugged into its place. Again, this is usually called inline expansion, and it's very much like a giant #define in C/C++.
Macros are useful: they save a lot of typing, and just like with a procedure, if we want to change how we do a task, we only have to change it in one place.
Macros offer slightly more speed than procedures, because there is no overhead (other than saving and restoring registers as necessary). When we call a procedure using CALL, the computer must put the return address on the stack, jump to the start of the procedure by changing the IP register (and CS if necessary), and then at the end of the procedure, the return address must be popped off so that execution can continue at the original place. If a procedure uses parameters or local variables, then there are more stack operations. When we use a macro, there is none of this overhead; no jumps are required because the code is right there in place. But this means the code is getting repeated every time we use the macro. (With a procedure, we only have one copy of the code, which we can jump to repeatedly.) So it's a trade-off between time and space -- we can occupy more space but go faster (using macros), or we can go slightly slower but take up less space (using procedures).
Can we use parameters with macros? Yes, and here is an example of a macro that takes one parameter:
MACRO DisplayCharacter CharToDisplay PUSH AX PUSH DX ; Use INT 21h, Service 2 to display a single character: MOV DL, CharToDisplay MOV AH, 2 INT 21h POP DX POP AX ENDM
Then, to use this macro, we can use a line such as:
DisplayCharacter 'W'
or
DisplayCharacter [MyChar]
The assembler expands the DisplayCharacter macro, and every time the assembler sees the text "CharToDisplay" in a place where a value is expected, it replaces it with the text of the parameter that was specified.
So, if we used "DisplayCharacter 'Q'", it would get replaced with:
PUSH AX PUSH DX ; Use INT 21h, Service 2 to display a single character: MOV DL, 'Q' MOV AH, 2 INT 21h POP DX POP AX
Here's an somewhat-useless example showing how you can use more than one parameter:
MACRO DisplayThreeChars Char1, Char2, Char3 PUSH AX PUSH DX PUSH DX PUSH DX ; Use INT 21h, Service 2 to display each character: MOV DL, Char1 MOV AH, 2 INT 21h POP DX MOV DL, Char2 INT 21h POP DX MOV DL, Char3 INT 21h POP DX POP AX ENDM
(We went through some contortions there which might seem unnecessary. We'll look at this a little later.)
There are two ways to use multiple-parameter macros: with commas or without. Most people do this:
DisplayThreeChars 'A', 'B', 'C'
But you can also omit the commas, like this:
DisplayThreeChars 'A' 'B' 'C'
Unfortunately, this leads to a problem. Look at this example:
DisplayThreeChars [BYTE DS:SI + 3], 'B', 'C'
The "[BYTE DS:SI + 3]" is legal; it refers to the byte at the address 3 bytes past the current DS:SI pointer. But when Turbo Assembler sees this line, it thinks "[BYTE" is the first parameter, "DS:SI" is the second parameter, and "+" is the third parameter. It thinks the spaces are separators. How do we solve this problem? You can use angle brackets (less-than and greater-than signs) to enclose a parameter that contains spaces, like this:
DisplayThreeChars <[BYTE DS:SI + 3]>, 'B', 'C'
This is ugly, but we'll have to live with it. If you forget the angle brackets, the assembler will give an error message.
Here's another point to watch out for when writing macros. Look at the following "DoSomething" macro:
DATASEG SomeNumber DW 0 AnotherNumber DW 123 MACRO DoSomething MyParameter MOV [SomeNumber], MyParameter ENDM
This macro is perfectly legal; we just have to be careful about our parameters. For example, it works great when used like this...
DoSomething AX
...because it gets translated into...
MOV [SomeNumber], AX
...which is okay because SomeNumber is word-sized, and a word-sized register can be copied to a word-sized variable.
But if we did this...
DoSomething [AnotherNumber]
...it would get translated to...
MOV [SomeNumber], [AnotherNumber]
...which is not okay -- you can't copy one memory location to another. You have to use a register as an intermediate. Similarly, you must be careful about the types (sizes) of parameters. For instance...
DoSomething CL
...would cause a problem because it would get translated to...
MOV [SomeNumber], CL
...which is a problem because CL is byte-sized and SomeNumber is word-sized.
Basically, be careful with your parameters. If the assembler finds a mismatch, it will produce an error message with a line number, so you can determine where the problem is. Of course, you can always try to make your macros as foolproof as possible. For example, the DoSomething macro could be changed to:
MACRO DoSomething MyParameter PUSH AX MOV AX, MyParameter MOV [SomeNumber], AX POP AX ENDM
That solves the "MOV mem, mem" problem, but it is less efficient if a register is the parameter. And it still doesn't prevent the problem caused by using a byte-sized parameter, because it will still "clash" with the word-sized AX.
Remember how DisplayThreeChars looked like it had a lot of unnecessary pushing and popping? Let's look at a simpler case. The following example macro has a rather hard-to-find bug:
MACRO DisplayCharacter_NoGood CharToDisplay PUSH AX PUSH DX MOV AH, 2 MOV DL, CharToDisplay INT 21h POP DX POP AX ENDM
It works fine, except when the parameter is AH. Why? Let's take a look. If we do this...
DisplayCharacter_NoGood AH
...then it gets replaced with:
PUSH AX PUSH DX MOV AH, 2 MOV DL, AH INT 21h POP DX POP AX
Notice that AH is getting overwritten! AH gets set to 2, overwriting the data that we wished to display!
This particular problem is relatively easy to fix. Do "MOV DL, AH" first, and then do "MOV AH, 2" next. This is how the original DisplayCharacter macro was presented. Here it is again:
MACRO DisplayCharacter CharToDisplay PUSH AX PUSH DX ; Use INT 21h, Service 2 to display a single character: MOV DL, CharToDisplay MOV AH, 2 INT 21h POP DX POP AX ENDM
If you look back to DisplayThreeChars, you'll see how the three "PUSH DX"'s are used. They save backup copies of DX in case DL is used as one or more of the parameters. Of course, if DL doesn't happen to be one of the parameters, then it's not as efficient as it could be.
Well, now you know all about macros. But don't forget about procedures! Macros are very convenient, if you're very careful about parameters, but macros are best for short sequences of code. Remember that every time a macro is used, the text of that macro gets expanded inline each time. Procedures are better for longer or more complex sections of code.
We'll need a way to set the video mode to Mode 13h. Should we use a procedure or a macro? I would choose the procedure option, because the mode won't be changed many times in a program, so the tiny, tiny amount of speed gained by using a macro isn't needed. For practice, however, let's try both:
PROC SetMode13h PUSH AX ; Use INT 10h, Service 0 to set the screen mode to Mode 13h: MOV AH, 0 MOV AL, 13h INT 10h POP AX RET ENDP
Or...
MACRO SetMode13h_Macro PUSH AX ; Use INT 10h, Service 0 to set the screen mode to Mode 13h: MOV AH, 0 MOV AL, 13h INT 10h POP AX ENDM
And to switch back to text mode, we'll do almost the same thing. I'll just show the procedure; it's easy to create a macro version.
PROC SetTextMode PUSH AX ; Use INT 10h, Service 0 to set the screen mode to text mode (Mode 3): MOV AH, 0 MOV AL, 3 INT 10h POP AX RET ENDP
Now, let's use some equates for some important constants...
VideoSegment EQU 0A000h Mode13h_ScreenWidth EQU 320 Mode13h_ScreenHeight EQU 200
You can rename these however you like. I put "Mode13h_" in front of the width and height constants because we might use other video modes in the future which have different screen widths and heights.
Now, the most important Mode 13h operation is setting a single pixel to a certain color. For our PutPixel routine, should we use a procedure or a macro? Actually, this is the perfect use for a macro. In a game, we might be drawing tens of thousands or even hundreds of thousands of pixels every second, so we can save a lot of time by using a macro. It's only a tiny amount of time that is wasted when a procedure is called, but when that time is multiplied by, say, a million or so, it really starts to add up. Of course, it wouldn't hurt to have a procedure version too.
Remember the formula for calculating the address of a pixel. The segment address for video memory is A000, and the offset for pixel (column, row) is calculated using "offset = (row * Mode13h_ScreenWidth) + column". Then, looking back to Chapter 8, we saw how this can be optimized to "offset = (row << 8) + (row << 6) + column". Of course, this assumes that Mode13h_ScreenWidth equals 320, but I'm willing to make this assumption for the extra speed gain.
So, our plan is to construct a pointer to the address of the pixel we want to modify. We'll use ES:DI for the pointer. ES will be set to VideoSegment (0A000h), and DI will be set to the offset that we calculate. Then we can write a byte, representing the color number, to the byte pointed to by ES:DI. Here's a macro version:
MACRO PutPixel Column, Row, Color PUSH AX PUSH CX PUSH ES PUSH DI PUSH AX ; "Immediate backups" PUSH DI ; Let DI equal the offset of the pixel. The formula is: ; Offset = (Row << 8) + (Row << 6) + Column MOV AX, Row ; Let AX = Row parameter MOV DI, AX ; Also let DI = Row parameter MOV CL, 8 SHL AX, CL ; Shift AX left by 8 DEC CL DEC CL ; Now CL = 6 SHL DI, CL ; Shift DI left by 6 ADD AX, DI ; AX += DI POP DI ; Retrieve the backup of DI, in ; case Column parameter is DI, ; b/c DI has been overwritten above MOV DI, Column ; Let DI = Column parameter ADD DI, AX ; DI += AX ; Let ES equal the video segment: MOV AX, VideoSegment ; (intermediate) MOV ES, AX ; Let ES = VideoSegment constant ; Now ES:DI points to the address of the pixel. Place the byte-sized ; color value at that address: POP AX ; Retrieve the backup of AX, in case ; parameter Color is AL or AH. MOV AL, Color ; Let AL = Color parameter MOV [ES:DI], AL ; Store AL at ES:DI POP DI POP ES POP CX POP AX ENDM
Admittedly, that's a lot of code just to draw one pixel!
A ReadPixel routine would be very similar, except that it would need to return a byte-sized value, for the pixel color. If you wish, you can try writing a ReadPixel macro or procedure yourself. I'll write a version in a later tutorial if a ReadPixel routine becomes necessary.
So, let's tie our routines together in a short example program. This will help demonstrate equates and macros. Type in, assemble and link, and run this program:
------- TEST13.ASM begins -------
%TITLE "Assembler Test Program 13 -- Pattern-Drawing Mode 13h Graphics Demo" IDEAL MODEL small STACK 256 LOCALS DATASEG VideoSegment EQU 0A000h Mode13h_ScreenWidth EQU 320 Mode13h_ScreenHeight EQU 200 StartColor EQU 15 CurrentColor DB StartColor x_Increment DW 1 y_Increment DW 1 LeftBoundary EQU 0 RightBoundary EQU Mode13h_ScreenWidth - 1 TopBoundary EQU 0 BottomBoundary EQU Mode13h_ScreenHeight - 1 CODESEG ; ------------------------------------------------------------------------- ; MACRO PutPixel ; ------------------------------------------------------------------------- ; Desc: Plots a pixel at (Column, Row) on the Mode 13h screen, using the ; color specified by Color. ; Pre: Column and Row must be word-sized; Color must be byte-sized. ; Post: Assuming Row and Column are within range, the pixel is plotted. ; No range checking is performed. ; ------------------------------------------------------------------------- MACRO PutPixel Column, Row, Color PUSH AX PUSH CX PUSH ES PUSH DI PUSH AX ; "Immediate backups" PUSH DI ; Let DI equal the offset of the pixel. The formula is: ; Offset = (Row << 8) + (Row << 6) + Column MOV AX, Row ; Let AX = Row parameter MOV DI, AX ; Also let DI = Row parameter MOV CL, 8 SHL AX, CL ; Shift AX left by 8 DEC CL DEC CL ; Now CL = 6 SHL DI, CL ; Shift DI left by 6 ADD AX, DI ; AX += DI POP DI ; Retrieve the backup of DI, in ; case Column parameter is DI, ; b/c DI has been overwritten above MOV DI, Column ; Let DI = Column parameter ADD DI, AX ; DI += AX ; Let ES equal the video segment: MOV AX, VideoSegment ; (intermediate) MOV ES, AX ; Let ES = VideoSegment constant ; Now ES:DI points to the address of the pixel. Place the byte-sized ; color value at that address: POP AX ; Retrieve the backup of AX, in case ; parameter Color is AL or AH. MOV AL, Color ; Let AL = Color parameter MOV [ES:DI], AL ; Store AL at ES:DI POP DI POP ES POP CX POP AX ENDM ; -------------------------------------------------------------------------- Start: ; Make data segment variables addressable: MOV AX, @data MOV DS, AX CALL SetMode13h XOR BX, BX ; X coordinate; set it to 0 XOR DX, DX ; Y coordinate; set it to 0 PatternDrawLoop: ; Draw a pixel at the current position: PutPixel BX, DX, [CurrentColor] ; Update coordinates and color: MOV AX, [x_Increment] ADD BX, AX ; Update X coordinate (BX) MOV AX, [y_Increment] ADD DX, AX ; Update Y coordinate (DX) INC [CurrentColor] ; Update color... AND [CurrentColor], 0Fh ; ...but restrict to 0..15 dec range ; Have we touched one of the edges of the screen? CMP BX, LeftBoundary JNE @@Bypass1 ; Reversed condition for long jump ; Left boundary was hit. Reverse the horizontal direction: MOV [x_Increment], 1 @@Bypass1: CMP BX, RightBoundary JNE @@Bypass2 ; Right boundary was hit. Reverse horizontal direction: MOV [x_Increment], -1 @@Bypass2: CMP DX, TopBoundary JNE @@Bypass3 ; Top boundary was hit. Reverse vertical direction: MOV [y_Increment], 1 @@Bypass3: CMP DX, BottomBoundary JNE @@Bypass4 ; Bottom boundary was hit. Reverse vertical direction: MOV [y_Increment], -1 @@Bypass4: ; Check: was a key pressed? Use INT 21h, Service 0Bh: MOV AH, 0Bh INT 21h ; If key is in keyboard buffer, AL ; will equal 0FFh; else AL == 0 CMP AL, 0FFh JE @@Finished JMP PatternDrawLoop ; Go back and draw the next pixel @@Finished: ; Read in the key that was pressed. Use INT 21h, Service 7: MOV AH, 7 INT 21h ; Ignore character that was placed ; in AL CALL SetTextMode ; Terminate program: MOV AX, 04C00h INT 21h ; -------------------------------------------------------------------------- PROC SetMode13h PUSH AX ; Use INT 10h, Service 0 to set the screen mode to Mode 13h: MOV AH, 0 MOV AL, 13h INT 10h POP AX RET ENDP PROC SetTextMode PUSH AX ; Use INT 10h, Service 0 to set the screen mode to text mode (Mode 3): MOV AH, 0 MOV AL, 3 INT 10h POP AX RET ENDP END
------- TEST13.ASM ends -------
This program does the job, although it was a little slower than I expected. Let's take a second to think of optimizations. Note that AX, CX, ES, and DI are all saved and restored unnecessarily. In this program, it would be okay to comment out the four pushes and four pops (but not the "immediate backups"), because the main program doesn't rely on those registers. But I wouldn't then use this modified PutPixel macro in another program, because the other program might rely on those registers.
Likewise, ES is getting set to the VideoSegment constant for each and every pixel drawn. In this program, ES isn't used anywhere else, so we could set ES once and leave it. More complex programs would probably need ES though.
And similarly, DI, the offset address of the pixel, is re-calculated from scratch for each pixel. In this program, we could set scrap the entire PutPixel routine: we could just set ES:DI once, and then to move one pixel to the right, we could increment DI; to move one row down, we could add Mode13h_ScreenWidth to DI, and so on.
I don't immediately see an optimization for all the comparisons in the main loop. I'd hope there is a clever way to speed this up. Also in the main loop, we might try using unused registers instead of variables for CurrentColor, x_Increment, and y_Increment. And so on.
If you want some practice, try writing some macros or procedures to draw graphics primitives. Start with a macro or procedure to draw a horizontal line. Then try a vertical line. Then try a box. When you're feeling confident, you might try a circle routine or a slanted-line routine. Flip back to Chapters 8 and 9 for information on graphics primitives.
We're getting closer to the end of the assembler chapters! In the remaining few assembler chapters, we'll learn about the string instructions (which can be very helpful for graphics programming in Mode 13h), and we'll find out how to interface our assembler code with C or C++ code.