Episode 8: Faster Graphics and Better Player Movement
First things first, we left the code in a sorry state at the end of the last episode. Let's do some organizing.
We have some code that's specific to our program, such as the process_key
and draw_player
procedures. We also have some generic code that could be reused in other apps, such as the putpixel
and int_to_string
procedures. Let's separate them. The new directory structure will look like this:
std:
Folder containing generic, reusable procedures and macros320x200x16.asm
: Drawing routines for Video Mode 9, 320x200 16-colorstdio.mac
: Macros for our I/O and formatting routinesstdlib.asm
: Formatting routines
tools
: Folder containing third-party tools (We'll get to this in a future lesson)input.asm
: Program-specific stuff relating to user input (process_key)renderer.asm
: Program-specific stuff relating to drawing (draw_player)
Don't forget to update your Makefile and the '%include' statements in test.asm accordingly! Check the repo at Github for exactly what my structure looks like at this moment.
Let's make our drawing more efficient. There's no need to redraw the whole screen each time if only a couple pixels have changed. Instead, we can first "clean up" the changed area by redrawing the background there, then draw our player graphic in its new position. Our draw_player
function already draws an 8x8 square; we can reuse it to erase the player's previous location to the background color. Let's rename it to draw_rect
and have it use a variable color instead of a hardcoded color 10:
; Draws an 8x8 rectangle at location (player_x, player_y) in
; color [color_draw_rect]. Uses the putpixel routine from
; std/320x200x16.asm.
draw_rect:
mov cx, 8
.drawRow:
mov ax, 8
sub ax, cx
add ax, [player_y] ; AX = row (y)
push cx
mov cx, 8
.drawPixel:
mov bx, 8
sub bx, cx
add bx, [player_x] ; BX = col (x)
push ax
push cx
mov dl, [color_draw_rect]
call putpixel ; (BX, AX) = (x,y), DL = color
pop cx
pop ax
loop .drawPixel
pop cx
loop .drawRow
ret
Don't forget to define color_draw_rect
in your data section. Now we can set it to 10 and call draw_rect
to draw the green player square, but we can also use it to erase the player's previous location graphic before drawing at the new location:
mov dl, [color_bg] ; Paint the whole screen with the background color
call cls
game_loop:
mov dl, [color_player]
mov [color_draw_rect], dl
call draw_rect ; Draw the player graphic
xor ax, ax ; Call INT16h fn 0 to grab a key
int 16h
push ax
mov dl, [color_bg]
mov [color_draw_rect], dl
call draw_rect ; Erase at the player's previous position
pop ax
call process_key ; Do something with the key
cmp byte [is_running], 0 ; If still running (ESC key not pressed),
jne game_loop ; jump back to game_loop
Notice what we're doing here. We wait for a key with INT16h
, but before acting on that key we paint over the player's current graphic with the background color.
As you can see, only redrawing the parts of the screen that need it is much faster!
Now for the second part of the episode. Our cursor key movement is crap. When holding down a key we are subject to key delay and repeat rate settings. Works great for a word processor, not so much for a game. We need a lower-level keyboard processsor, one that will let us see individual key presses and releases. And that requires us learn about hardware interrupts and interrupt service routines, and how to install our own interrupt handlers.
Some Googling led me to INT 9h: an interrupt that is generated each time a key is pressed or released. That sounds perfect, but so far we've only seen interrupts we generate ourselves, such as INT 10h
and INT 21h
. When we do that, we're notifying the process or that we need some service -- we're "interrupting" the processor and forcing it to deal with us. Hardware does the same thing: When a key is pressed or released, the keyboard calls INT 9h
, and BIOS routines process the key and make it available to the system (via INT 16h
which we're familiar with). But we can intercept INT 9h
calls and redirect them to our own handler, or interrupt service routine (ISR), bypassing the BIOS's handling. Here's an example:
install_keyboard_handler:
cli ; Disable interrupts
xor ax, ax
mov es, ax ; Set ES to 0
mov dx, [es:9h*4] ; Copy the offset of the INT 9h handler
mov [oldInt9h], dx ; Store it in oldInt9h
mov dx, [es:9h*4+2] ; Then copy the segment
mov [oldInt9h+2], dx ; Store it in oldInt9h + 2
mov word [es:9h*4], handle_int9h ; Install the new handle - first the offset,
mov word [es:9h*4+2], cs ; Then the segment
sti ; Reenable interrupts
ret
In the BIOS memory area, there is a block of 4 bytes for each hardware interrupt. These 4 bytes hold the segment and the offset of an address containing code the BIOS uses to service that interrupt. If we overwrite these 4 bytes with the address of our own handler, those hardware interrupts will be redirected to us. The address for each handler is at the handler's number times 4, so the handler for INT 9h is pointed to by bytes 36 through 40. handle_int9h
is a procedure that we have defined. We write the offset of that procedure to bytes 36 and 37 (represented by [es:9h*4] where ES is 0) by just moving the address of the procedure into that location. Next we write the segment of the procedure (which is just CS, the code segment) into the the 2 following bytes.
An important thing to note is that before we do that, we save the address of the default INT 9h handler so we can reinstall it later. If we don't do that, when the user quits the program he will be left without keyboard input! Also, we disable hardware interrupts briefly while we're fiddling with the addresses – we wouldn't want an interrupt to come through while we're halfway through writing an address!
Here's a similar routine we will call right before exiting the program to restore normal keyboard processing:
restore_keyboard_handler:
cli
xor ax, ax
mov es, ax
mov dx, [oldInt9h]
mov word [es:9h*4], dx
mov dx, [oldInt9h+2]
mov word [es:9h*4+2], dx
sti
ret
Put those handlers in input.asm
, also define a dummy procedure for handle_int9h
, and allocate 4 bytes to a variable called oldInt9h
in the .bss section of your code. Now run the app. Not much happens. We've redirected the low-level keyboard input to our own handler, so there are no keys for INT 16H
to process. So let's implement handle_int9h
now.
A couple things to remember about interrupt service routines:
- Don't clobber registers! You never know when this handler is going to be called, or what your program is going to be doing at the time. At the beginning of the ISR, push any registers you plan to use and pop them before you return.
- At the end of your ISR, tell the interrupt controller that you're done processing this interrupt. This is done by sending value 0x20 to port 0x20.
- Use the special instruction
iret
to return from the ISR.
So let's build our INT9h interrupt handler. The basics:
handle_int9h:
cli
push ax
push bx
pushf
; TODO: Read and process key!
; Signal end-of-interrupt
mov al, 0x20
out 20h, al
; Restore state
popf
pop bx
pop ax
sti
iret
Next, read the scancode from the keyboard, determine if it is a key press or a release, and do something with it:
xor ax, ax
in al, 60h ; Read from keyboard
test al, 0x80 ; If high bit is set it's a key release
jnz .keyReleased
.keyPressed:
;;; ; TODO: Do something with AL
jmp .done
.keyReleased:
and al, 0x7f ; Remove key-released flag
;;; ; TODO: Do something with AL
jmp .done
.done:
We get a key by reading a byte from the keyboard controller port, 0x60. Then we have to determine if the key is being pressed or released by testing against 0x80 (the lower 7 bits are the scan code and the high-order bit indicates a key release if 1). If it's a key release, we strip off that high-order bit so we're left with the scancode.
The only thing left to do is figure out what we're going to do with the key. Let's store our keyboard state in a keyboard buffer which has room for every key and holds a 1 if the key is pressed and a 0 if it is not:
keyboardState: times 128 db 0
This is a 128-byte buffer, initialized to zeroes, where the scancode of the key represents the byte index into the buffer. We can mark the left cursor key (0x4b), for example, as on by addressing [keyboardState+0x4b]
. Or, if the keyboard scan code is in BL, [keyboardState+bx]
(we can't use AX for addressing due to the design of the 8086).
So in the keyboard handler, when a key is pressed, move its scancode into BX then move a 1 into [keyboardState+bx]
, and when a key is released, do the same but with a 0. I'll leave that as an exercise for you! If you have trouble check the repo at the end of this episode.
There's one more thing our handler needs to do, and it's specific to INT 9h keyboard handling. Just before acknowledging the interrupt to the PIC, we need to notify the keyboard that we've read the key by sending a couple bytes to the keyboard state port:
; Clear keyboard IRQ if pending
in al, 61h ; Grab keyboard state
or al, 0x80 ; Flip on acknowledgement bit
out 61h, al ; Send it
and al, 0x7f ; Restore previous value
out 61h, al ; Send it again
Let's slightly modify our process_key
procedure to read from keyboardState
. Add some convenient NASM macros so we don't have to hardcode scancodes:
%define key_esc 0x01
%define key_up 0x48
%define key_left 0x4b
%define key_right 0x4d
%define key_down 0x50
Then in the procedure, each time we were comparing AH to one of the scancodes, we compare the scancode's byte in the keyboard buffer instead – for example [keyboardState+key_down]
.
Our game loop gets a little cleaner because we no longer have to wait for keypresses. We just plow through and process whatever keys are in the buffer each time:
game_loop:
mov dl, [color_bg]
mov [color_draw_rect], dl
call draw_rect ; Erase at the player's previous position
call process_key ; Do something with the key
mov dl, [color_player]
mov [color_draw_rect], dl
call draw_rect ; Draw the player graphic
cmp byte [is_running], 0 ; If still running (ESC key not pressed),
jne game_loop ; jump back to game_loop
The result?
Keyboard input is much smoother now! No more delay and repeat rate; when the key is down we're moving, and when the key is released we stop. But continually erasing and redrawing the player square is causing some weird blinking. Perhaps next episode we'll figure out how to draw in between screen refreshes to minimize this flicker...
See all the code for this episode at Github.