Thursday, January 30, 2020

Reading keyboard input in DOS w/ Turbo C++ and assembly (Jill of the Jungle, Wolf 3D)

It's fairly easy to find a working copy of Turbo C++ 3.0, and it compiles and links .EXEs without issue in DOSBOX. However a couple problems are immediately apparent.

The CLK_TCK constant is set at install time, and is set to 18.2 on my copy. CLK_TCK being accurate is necessary for clock() to get an appropriate reading -- which it cannot. Since it's impossible to determine a safe value, there needs to be a better way, across all CPU speeds, to measure time at a higher resolution.

Obviously there has to be at the machine level, but there are no higher resolution time-monitor-thingies in TC++3, so that leaves a raw assembly block. HOORAY!! This *IS* actually good news. DOS machines have a standardized BIOS, and DOS runs the x86 instruction set. Any DOS machine anywhere will be able to run our machine language routines.

This is way easier than writing assembly routines that work across all Windows and UNIX systems. Blegh.


Cycle rate in DOSBOX is how many instructions to attempt to perform *every millisecond*. This is not exactly analgous to cycles/second. If you are targeting a certain CPU, like I am (a 16MHz 386 sounds appropriate), then you should look up how many MIPS that CPU can perform and divide it by 1000. For my purposes, 2000 cycles (or approximately 2 MIPS) is roughly the 16MHz 386DX target -- the top CPU of 1985. In my DOSBOX config, I set CPU type to 386 and cycles to fixed 2000.

Now that my CPU speed is set properly, let's spit out a quick program that will hook into the keyboard interrupt -- note this is THE ONLY ACCEPTABLE WAY to read keyboard input!!

#include <iostream.h>
#include <dos.h>

unsigned char KEYSCAN;

void interrupt ( *oldkb)(...);

void interrupt newKb(...)


int main()
        cout << KEYSCAN;
        if (KEYSCAN == 0x01) { return 0; }
    return 0;

Now, what we want to do is detect whenever a key is pressed or released. The keyboard controller will send an interrupt (in DOS this IRQ is vectored at 09h), and we are expecting a value of 0-255 (the keyboard scan code sent by the peripheral control chip), which we will output using cout as a test. 

DOS standard uses (apparently) either the 8255 PPI, or in the case of PS/2, the Intel 8042. 
I don't know when or what software would actually address the 8042 natively - all I can find regarding DOS keyboard input doesn't seem to ever use 64h (the PS/2 port), only 60/61h (for the 8255). 

Desiring more information, I began to disassemble Jill of the Jungle to figure out how EPIC did keyboard input back in the day. They do indeed use an IRQ hook - they check if the scancode is between 01h and e0h, if the high bit is set (ie a keyup scan code), convert it to a word and store it in memory.

The routine looks really nasty, and it's almost certainly generated assembly (here's a third of it, by hand so it isn't perfect):
    mov bx, bp      ; 8e dd
    sti             ; fb
    xor ax, ax      ; 33 c0=xor Gv Ev ax ax

;5050h:  e4 60 3c e0 75 09 c7 06 60 3a 00 01 eb 24 90 a8
    in al, 60h
    cmp al, e0h     ; 3c=cmp al,[*]
    jnz +09h        ; 75=jnz [r*]
                    ; c7=mov Ev,Iv
    mov ax, [3a60h] ;  6=00|000|110 ax imm
    jmp +24h        ; eb=jmp [r*]
    cbw             ; 90=cbw
    test al, 80h    ; a8=test al,[*]

The bytes E460 are the only possible way to read in from port 60h, that's how I tracked this in the exe. E464 (in the case of PS/2) doesn't exist, nor does E661 or E664 - meaning nothing is ever written to the PPI, only read. Indeed, at 5091h there's
    mov al, 20h
    out 20h, al 
which is the IRQ acknowledgement, followed by 8 POPs (haha, generated code).

While this is still confusing, its clear that the preferred way to read keyboard input for gaming (care of Epic Megagames, 1992) is to write your own interrupt and track the keyboard state on your own. 

So how do we do this? Well, C++'s DOS.H includes exactly what we need. The small program above is the minimum to hook into the keyboard, but we need to be careful with our new assembly routine. With a little inspiration from the Wolfenstein 3D source, we can now write what we need. This goes in the asm {} block:

    in al, 60h
    mov KEYSCAN, al    // Read the KB and store it in KEYSCAN

    in al, 61h
    or al, 80h
    out 61h, al        // flip the top bit of port 61h
    xor al, 80h        //  on and off. This is the keyboard
    out 61h, al        //  "acknowledge" signal

    mov al, 20h
    out 20h, al        // Write 20h to port 20h (IRQ acknowledge)

(asm { } is all that's needed for inline assembly. It just works.) 

Now, in main(), we need to re-orient the BIOS's read key function to our new one. These two lines at the beginning will do what we need:

    oldkb = getvect(0x09);
    setvect(0x09, newKb);

And that's it! 09h points to the address of the DOS BIOS routine we want to overwrite, so we can save that vector for later if we need it with getvect(). setvect() will change that same vector to the interrupt-type method we pass to it, which we've named newKb.

That's it!!! Perfect frame-independant keyboard input obtained.

Now all you have to do is check whether the KEYSCAN is a PRESS event (0-7f) or RELEASE (80-ff) and store it in your input methods. 

Full .cpp file here.

Other sources:
MS-DOS kb scan codes
8086 Opcode chart
MS-DOS EXE file format
8086 MOD-R/M byte information / [2] / byte prefix info

No comments:

Post a Comment