Emulator Debugging: PIANOMAN 3.0

Early in development I was looking for a utility to test my crude PC speaker emulation.  I found a simple keyboard-piano program called PIANOMAN.  I'm not sure where I found it - there are lot of archives of old DOS software out there, after all. The version I downloaded was PIANOMAN 3.01. 


PIANOMAN is credited to a Neil J. Rubenking, who apparently is an author and long standing writer for PC Magazine

I'm going to stop shouting now, and just refer to the program as PianoMan. PianoMan is a fairly straightforward program, putting aside its sophisticated recording and playback facilities, the basic interface presents a view of a standard 83-key XT keyboard with various musical notes overlaid on the appropriate keys; hitting these keys produces the corresponding note.

The note is supposed to be played for as long as the key is held down, and silenced when the key is released.

On MartyPC currently, when a note is played it continues forever, at least until another note is played, which also plays forever, and so on. There's no way to make it shut up.

The first thing to do was to verify that this works right on real hardware - indeed, running on an original IBM 5150, it works as expected.  Great, an emulator bug. We love finding bugs.

Let's pop into DosBox-X, where it surely works correctly - Nope, no, the sounds play forever in DosBox as well. Let's try 86Box then - no, also no good. PCE? Nope.

Aha - it appears to work correctly in VirtualXT. That at least gives us a tool to debug with.

You can experience the problem for yourself over on the Internet Archive, which hosts a version of PianoMan 3.0 in an browser-based DosBox instance.

The PC Keyboard Interface

On the IBM PC/XT, the keyboard interface is fairly simple.  Incoming keystrokes generate IRQ1/INT9, whereupon you can go read the current value of the keyboard shift register via PPI port A. The value presented is the 'scancode' of the corresponding key.  You can see a list of PC/XT keyboard scancodes here.

Pressing and releasing a key generates two interrupts, and accordingly two scancodes can be read out.  The scancode corresponding to the key release is the scancode of the key press, but with its high-order bit #7 set. 

If you hold a key down long enough (~500ms) the keyboard will start sending repeated key-down scancodes, a feature that of course carries on through today. IBM called this 'typematic repeat'. 

That's it - the keyboard interface is entirely event-driven, and stateless. If you wanted to tell if a key is currently held down in some sort of asynchronous way, you would need to maintain an array of key states, and set/clear them as the various set and release scancodes come in, and hopefully you don't miss any. 

The BIOS provides a keyboard interface through several interrupt functions, and maintains a small keyboard buffer for use with its default Interrupt Service Routine (ISR) so that programs that use the BIOS keyboard can poll available keystrokes in a more leisurely way. That is, until the keyboard buffer fills up and the BIOS beeps rudely at you. 

Programs that rely anything but the simplest keyboard interface therefore commonly install their own ISR by installing a pointer in the correct slot in the Interrupt Vector Table (IVT) for the keyboard interrupt.  PianoMan does this, installing a routine at CS:3911 that handles reading the byte from the PPI and curiously, reuses the BIOS keyboard buffer as its own storage. There's a reason for that, as we'll see later.

Speaker Control

The PC speaker plays sounds via the Programmable Interval Timer (PIT) timer channel #2, the output of which is connected via some miscellaneous circuitry to the speaker.  It's commonly programmed for generation of square waves, which allows you to play a tone of a given frequency by programming the appropriate reload value into the timer and letting it run; no further intervention is required. 

The sound output can be turned on/off by either reprogramming the timer to stop it, toggling the gate pin of the timer channel #2, or by flipping another bit in PPI port B that is essentially run through an AND gate with the timer output to determine whether the speaker will generate a sound.  The gate is controlled by PPI PB0, and the 'Speaker Data' bit by PPI PB1, and it's common to just turn both off to turn off the speaker and turn them both back on to turn the speaker back on again. PianoMan does this - at least, it is supposed to.

We can identify the function in PianoMan that does this at 05AF:

set_speaker_off proc near
                in      al, 61h         ; Read PPI Port B
                and     al, 0FCh        ; Gate & Spk Data OFF
                out     61h, al         ; Write PPI Port B
                retn
set_speaker_off endp

The value of PPI Port B is read, PB0 and PB1 cleared via an AND masking operation, and then written back to the PPI. Simple enough. Scouring the code disassembly for all OUTs to port 61h, we don't find any other candidate locations where the speaker is clearly turned off like this.

Setting a breakpoint in MartyPC on this function, we can hit a few keys and notice that this function is never called.

Time to see what is supposed to happen.

Debugging in VirtualXT

VirtualXT provides debugging facilities via GDBstub - that is, it implements a GNU Debugger (GDB) server which we can connect to via the GDB client and issue commands.

I'll confess to not being terribly familiar with GDB, as I'm one of those wannabe programmers overly reliant on an IDE to do anything, but this is a good learning experience.

I compiled VirtualXT from source, enabled the GDB feature in the configuration file, opened GDB using the included script, and I was off.  The first order of business was to try to locate PianoMan's code segment - this was done via trial and error just breaking (via Alt-F12) back into GDB and continuing until we were somewhere that was not obviously DOS or ROM. We eventually landed in CS 2BDA.  

The segmented memory model is not supported in GDB commands, so we have to do a little calculation before we can set a breakpoint. Adding a segment and offset is a simple operation, just shifting the segment to the left by 4 and adding the offset.  Typing the segment into a hex calculator, we shift by simply typing an extra 0, then add the offset. Thus the flat address of our 'set_speaker_off' routine is 2BDA0 + 5AF or 2C34F. 

We can then set a breakpoint in GDB, continue, and hit a key to see if our 'set_speaker_off' function gets called:


And it does. Stepping through the routine to see where we return to, we can see we arrive at 3AFAD. Doing our segment/offset math in reverse, that works out to 2BDA:F20D. 

Here's the disassembly:

loc_F1FE:
                mov     al, byte_102D5
                xor     ah, ah          ; AH=0
                xor     al, 1
                jnz     short _turn_speaker_off
                jmp     loc_1F28F
                
_turn_speaker_off:
                call    set_speaker_off

It looks like there's a variable checked that determines whether the speaker should be turned off or not. That could be the cause, but before we get too excited, let's see if we even reach F1FE in MartyPC when we release a key by setting a breakpoint. 

No, we do not.

The function F1FE is in is a rather enormous function that starts at ED17. A breakpoint there never triggers. Looking at the call stack for the program in MartyPC explains why: we don't seem to ever return from this function, indicating it might be the main loop of the program.


At least we've narrowed things down to a single - if enormous - function.

Looking at the start of this function, we begin in what appears to be a polling loop:

 _main_loop0:                            
                                         
                 call    call_func_ptr_00
                 jnz     short _got_nz_return_code ; this function pointer seems to always point to check_key.
                                                   ; so we loop while waiting for a key press.
                 jmp     _main_loop0

 _got_nz_return_code:                              ; got a key press
                 mov     di, 14Ah        

call_func_ptr_00 simply calls a function via a pointer, like it says on the tin.  Let's skip ahead to the actual function called, which I named check_key:

check_key
                cmp     byte ptr last_read_scancode, 0
                mov     al, 0FFh
                jnz     short _no_char
                mov     ah, 1
                int     16h             ; KEYBOARD - CHECK BUFFER, DO NOT CLEAR
                                        ; Return: ZF clear if character in buffer
                                        ; AH = scan code, AL = character
                                        ; ZF set if no character in buffer
                mov     al, 0
                jz      short _no_char
                dec     al              ; set AL==FF if have key. this is masked to one bit

_no_char:
                                        ; check_key+F↑j
                and     ax, 1           ; return 1 if have key
                                        ; return 0 if no key
                retn    1

Nothing too unusual here - we are calling BIOS int16h to check if there is a pending keystroke. The BIOS keyboard buffer is a ring buffer - to check if there is a byte to be read, the BIOS compares the head index to to the tail index - if they are not equal, there's been a scancode stuffed into the buffer that has not yet been read out.

There's just one problem - int16h will normally tell you if a key has been pressed, but not if a key is released. Sure enough, on MartyPC, when we release a key, this function returns 0.

At this point, after musing with phix, the author of VirtualXT, he suggested the answer already, and I felt silly for not seeing the obvious thing staring me in the face. The key question is why PianoMan re-uses the BIOS keyboard buffer in its own keyboard ISR in the first place.

Normally only key-down scancodes are placed into the BIOS keyboard buffer. But the PianoMan ISR puts both key-down and key-up scancodes in the buffer. This means that when PianoMan later calls int16h, the BIOS will see the key-up scancode like any other scancode, and clear the ZF flag indicating that a key is available.  He's essentially extending the int16h functionality!

Except, this is an extremely risky play - he's no longer in control of all the logic used to return keystrokes. Something like, say, a future BIOS revision, could throw a wrench in the works...

Introducing the Enhanced Keyboard

In 1985, IBM released the IBM Enhanced Keyboard, otherwise known as the famous Model M.  Expanding the number of keys to 102, the keyboard also had a few new nifty features, such as an internal keyboard buffer, and extended, two-byte scancodes that started with 0xF0.

In 1986, IBM updated the BIOS of the IBM XT to include support for this new keyboard. Correspondingly, the INT16h handler was changed to support these extended scancodes.  Now there is a processing routine done when we check for a key:

;------ ROUTINE TO TRANSLATE SCAN CODE PAIRS FOR STANDARD CALLS

K10_S_XLAT:
        CMP AH,0E0h         ; IS IT KEYPAD ENTER OR / ?
        JNE K10_S2          ; NO, CONTINUE
        CMP AL,0Dh          ; KEYPAD ENTER CODE?
        JE  K10_S1          ; YES, MASSAGE A BIT
        CMP AL,0Ah          ; CTRL KEYPAD ENTER CODE?
        JE  K10_S1          ; YES, MASSAGE THE SAME
        MOV AH,35h          ; NO, MUST BE KEYPAD /
        JMP K10_USE         ; GIVE TO CALLER
K10_S1: MOV AH,1Ch          ; CONVERT TO COMPATIBLE OUTPUT
        JMP K10_USE         ; GIVE TO CALLER

K10_S2: CMP AH,84h          ; IS IT ONE OF THE EXTENDED ONES?
        JA  K10_DIS         ; YES, THROW AWAY AND GET ANOTHER CHAR

Notice the second to last line.  If the scancode is above 84h, we throw it away. 

Since key-up scancodes are the same their corresponding key-down scancodes with the high bit set (80h), this comparison will almost always be true, and our key-up scancodes are rejected by the BIOS. 

Thus the peril of attempting to extend BIOS functionality. 

Using a different BIOS, like say, the 1982 revision - long before the extended keyboard existed, now allows PianoMan to function as intended.

VirtualXT ships with an open source BIOS. Mystery solved.

To Neil's credit, this issue is fixed in PIANOMAN 4.0. 












Comments

Popular posts from this blog

Hacking the Book8088 for Better Accuracy

Bus Sniffing the IBM 5150: Part 1

The Complete Bus Logic of the Intel 8088