Thursday, September 12, 2024

PROPERLY reading keyboard input in DOS environments - IRQ Hook

 There are by my estimation THREE different ways of reading keyboard input in DOS, using C. 

The first is getch().

// getch
#include <iostream.h>
#include <stdio.h>
#include <conio.h>
/* this does not give keyup event */
void main(int argn, char** argc){
int ch;
while(ch != 1){
ch = getch();
switch (ch)
{
case 0:
case 0xE0: /* extended key */
switch (ch = getch())
{
case 0x48: cout << "up arrow\n"; break;
case 0x4B: cout << "left arrow\n"; break;
case 0x4D: cout << "right arrow\n"; break;
case 0x50: cout << "down arrow\n"; break;
default: cout << "extended key " << ch << "\n";
}
break;
case 9: cout << "backspace\n"; break;
case 0xd: cout << "return\n"; break;
case ' ': cout << "space\n"; break;
default: cout << "normal key " << (char)ch << "\n";
}
}
}


From what I can tell
, most compilers interpret this function as the DOS BIOS' 0h INT16 call (which is get key from buffer). The problem with this is the usual - you must deal with the operating system's manipulation of the keyboard buffer to give you keys read; which is simply one interpretation of the keyboard's state. You can modify the terminal / TTY settings of stdin/stdout, but at the end it is not appropriate for games. getch() does not return any keyup events, as these are handled on the OS layer, so you will never get a value from getch() with bit 7 set (presumably).

The second is reading the keyboard's I/O ports directly:

// Be careful: without an interrupt, key events can be missed
unsigned char read_key();
// 8255 PPI
// After getting state, you must flip-flop the PB
// in 0x61, && 0b1xxxxxxx, out 0x61
// reset 0b0xxxxxxx, out 0x61
#pragma aux read_key = \
"in al, 0x60"\
"mov ah, al" \
"in al, 0x61"\
"or al, 0x80"\
"out 0x61, al"\
"xor al, 0x80"\
"out 0x61, al"\
value [ah] \
modify [ al ];

The 8255 PPI is legacy-emulated on pretty much every keyboard nowadays, by the way! USB converts its own signal to PS/2 which is by definition compatible with IBM/PC.

Anyway, this assembly works great! And it's super fast!

Unfortunately, as you can see by the first line comment, it is possible (and happens frequently) that if you are not constantly monitoring the keyboard, you will miss a keyup event. This is fine for probably 80% of purposes, but for action games in particular, a stuck key means death.

I tried a large variety of things to get around this limitation, but unfortunately, the only thing that works is clearing out every other "pressed" key state you have in memory when a new key is pressed. The code may be simple, but no two keys can be pressed at a time! Argh!!

 The third method is using the DOS INT16 0h and 1h calls to check for and retrieve keys from the system's keyboard buffer. This isn't very difficult to implement, so I won't waste time on it here, but the effect is nearly identical to default getch() and stdin. You don't get super fast access to the PPI and you can still miss a keyup event.

After a bunch of hemming and hawing, it was time to do more research.

So, operating systems don't "scan" a keyboard the way older micro-computers did. On virtually every 8-bit system I've programmed for, you can get the state of any key on the keyboard at any time - with exceptions, depending on how things are wired.

What is done nowadays is the keyboard is hooked into a hardware interrupt. And unfortunately, I seemed to be in a situation where that was the only option of getting rid of keyboard input bugs. 

So I did my research, but wouldn't you know it, that kind of thing doesn't have a lot of information. There are some StackOverflow posts here and there, but nothing particularly clear and definitive. There wasn't even a clear list of where the interrupt vector address was!

After some frustration and tweaking of my search keywords, I discovered this wonderful post, which had some information I couldn't find elsewhere:

    1. The DOS keyboard interrupt vector is 09h
    2. Watcom C/++ has a getvect() and setvect() macro, which took me to being able to find out that the vector table for MS-DOS is 255 entries of two words, one containing the segment address and one the instruction pointer (code offset). This means 0024h holds the vector address!

Well, hold on. Trying to write the address of my replacer function to 0024h directly isn't working, even with interrupts off. So, I did what every sane person does: Disassembled a Watcom produced .exe that uses the setvect function to see what it does!

                                                           FUN_1000_0008:1000:0082(c)  
       1000:0174 52              PUSH       DX
       1000:0175 89 da           MOV        DX,BX
       1000:0177 1e              PUSH       DS
       1000:0178 8e d9           MOV        DS,CX
       1000:017a b4 25           MOV        AH,0x25
       1000:017c cd 21           INT        0x21
       1000:017e 1f              POP        DS
       1000:017f 5a              POP        DX
       1000:0180 c3              RET

Oh - they don't do anything at all. They use the DOS service routine 25h to do it for them! No wonder. 

So, copying this method, we can PROPERLY replace their IRQ service routine with our own, based on the post above, which will read every key's scancode into a 0 or 1 buffer.

Marking it as __interrupt will (we hope!) cause it to save all registers properly. We only need it to flip a bit in the char keys array, so I simplified it down a bit and removed almost all of the compiler-dependant code and wrote ASM macros for Watcom. 

In the end, the final, interrupt-based, flawless method looks like this: 

#include <iostream.h> // cout
#include <graph.h> // settextposition and clearscreen

typedef unsigned char u8;
typedef unsigned char bool;
typedef unsigned short u16;

// Entire keyboard scan is held here, including extended keys:
u8 keys[192];

void far * old_kb;
static void interrupt kb_int();

void write_port(u8 p, u8 v);
#pragma aux write_port = \
"mov dh, 0"\
"out dx, al"\
parm [dl] [al]\
modify [dx];

u8 read_port(u8 p);
#pragma aux read_port = \
"mov dh, 0"\
"in al, dx"\
parm [dl]\
value [al];

void set_irq_vector(u8 iq, u16 segment, u16 offset);
#pragma aux set_irq_vector = \
"push ds" \
"mov ah, 0x25"\
"mov ds, bx"\
"int 0x21"\
"pop ds"\
parm [al] [bx] [dx]\
modify [ah];

void far * get_irq_vector(u8 iq);
#pragma aux get_irq_vector = \
"mov ah, 0x35"\
"int 0x21"\
parm [al] \
modify [ah] \
value [es bx];

#define segment(a) ((u16)((unsigned long)(void __far*)(a) >> 16))
#define offset(a) (u16)(a) // dont need to & 0xffff because casting down does this

int main() {

old_kb = get_irq_vector(9); // save old vector
set_irq_vector(9, segment(kb_int), offset(kb_int));
// Key display loop taken from sample code
_clearscreen(0);
while(!keys[1]) { // normal key 1 == ESC
for(int y = 1; y < 5; y++){
int i;
_settextposition(y, 0); // y, x
for (i = 0; i < 0x30; i++) {
cout << (int)keys[i + ((y-1)*0x30)];
}
}
}
//
set_irq_vector(9, segment(old_kb), offset(old_kb));
return 0;
}

static void interrupt kb_int() {
static u8 buffer;
u8 inc = read_port(0x60); /* get byte from port 60h */
bool on_off = !(inc & 0x80); /* bit 7: 0 = on, 1 = off */
u8 scancode = inc & 0x7F; // bits 0-6 are the key

// First, we check our last buffer to see if it has an E0 byte...
if (buffer == 0xE0) { // was our last byte E0?
if (scancode < 0x60) // if so, extended key
keys[scancode + 0x60] = on_off;
buffer = 0;
} else if (buffer >= 0xE1 && buffer <= 0xE2) {
buffer = 0; /* ingore these cases... */
} else if (inc >= 0xE0 && inc <= 0xE2) {
buffer = inc; // store it in static var for next loop!
} else if (scancode < 0x60) {
keys[scancode] = on_off;
}

write_port(0x20, 0x20); // ack IRQ, needed
}

Three full days of work! Phew! (And I didn't even do the hard part myself!)

Astute viewers may have noticed the static buffer char inside the static kb_init() function. This is explicitly to save the previous interrupt's E0, E1 or E2 character in the case of an EXTENDED character code. All we need to do is check this buffer char *first* to see if we are an extended key scancode or not. If the buffer HAS one of those Ex keys, we clear for the next key. 

This looks longer than it should be because of the assembly macros, but it is quite simple if you take the time to look at it. 

Done! This will get the immediate state of every key on the keyboard, through a properly handled interrupt - perfect for game development!

If this helped you, or you have anything in reply, please leave a comment!




No comments:

Post a Comment