To keep it short: Programs compiled with Turbo Vision (including their IDE itself) exhibit a bug in QEMU and dosbox.

The bug manifests in that certain keypresses are duplicated, like arrow keys or PgUp and PgDown. The keys in specific are keys that use a two-byte scancode instead of a single byte. The bug could only be reproduced on emulators, like QEMU and DOSBox, but not on real hardware.

Another user from the FreeDOS channel provided me with assembly source of keyboard handling code that seems to be inserted into every compiled program. I translated the source from the assembly dialect it was it (probably TASM) to the NASM syntax, and was able to successfully reproduce this issue.

The keyboard controller of the IBM AT and up sits at IO ports 0x60 and up, reading from IO port 0x60 will fetch the byte that is currently in the keyboard receive buffer. The Borland code hooks into the IRQ 1 (Interrupt 9h) and does some sort of special handling for certain key combinations. To do this handling, it reads the value of the keyboard receive buffer to detect whether a key requires special handling. After that, it proceeds to the original BIOS handler, which reads the IO port again to do regular keyboard processing.

On real hardware, the Borland code and BIOS will read the same keycode, so the BIOS is able to properly place the keydown word in the Bios Data Area.

In emulators, reading from port 0x60 has the side effect of advancing the read pointer for the emulated keyboard controller buffer. Reading the IO port twice results in different byte values, meaning that the BIOS will not be able to see certain bytes.

The details on how the BIOS is unable to correctly handle this situations is unclear, but via experimentation i found out that the prefix for key-up events (which add another byte to the 1- or 2-byte sequences) gets lost to the BIOS. This resulted in the KeyUp event being mistakenly interpreted as a KeyDown event. KeyDown and KeyUp makes two, thats where the duplication comes from.

Since the Borland code only added special handling for 5 key combinations (Alt+Space, and Alt+ and Ctrl+ for Del and Ins each), a trivial workaround was possible:

By blocking the request to replace interrupt vector 9, the interrupt chaining does not take place, and the BIOS handles the keycodes alone without interference. This does not create additional hassle since the compiled programs still use the regular keyboard buffer from the BIOS to read the keyboard input.

Related discussions:

The NASM source for the TSR program that prevents interrupt 9 modification is following.

		org 0x100

		jmp setup

int21handler:	; check if program wants to replace int 9
		cmp ax, 0x2509
		jne chain
		; just return to program, do nothing
chain:		; other syscall, jump to DOS
		; addr is overwritten by set-up
		jmp 0xFFFF:0x0000

setup:		; get old vector
		mov ax, 0x3521
		int 0x21
		; bail out if we detect ourselves
		cmp bx, int21handler
		je .ret
		; vector is now in ES:BX, save it
		mov [chain+1], bx
		mov [chain+1+2], es
		; install our own handler
		mov ah, 0x25
		mov dx, int21handler
		int 0x21
		; print our banner
		mov dx, banner
		mov ah, 9
		int 0x21
		; terminate and stay resident
		mov ax, 0x3100
		mov dx, 0x11 ; PSP + 16 bytes
		int 0x21
.ret:		ret

banner:		db "kbd interrupt locked",0x0A,0x0D,"$"

The code for the reproduction program is following here:

		org 0x100

main:		mov word [outpos], 0

		; get old vector
		mov ax, 0x3509
		int 0x21
		; vector is now in ES:BX, save it
		mov [oldint9], bx
		mov [oldint9+2], es
		; install our own handler
		mov ah, 0x25
		mov dx, int9handler
		int 0x21

		; loop reading data
read:		xor ax, ax
		int 0x16

		cmp ax, 0x011B
		je end

		push ax
		call printf
		db "From buffer: ",2,0x0A,0x0D,0

		call flush

		jmp read

end:		; restore keyb handler
		lds dx, [cs:oldint9]
		mov ax, 0x2509
		int 0x21

section .bss

.ax:	resw 1
.cx:	resw 1
.dx:	resw 1
.bx:	resw 1

section .text

; Stack contents
; ret-addr arg1 arg2 arg3 ...

printf:		mov [], ax
		mov [], cx
		mov [cs:printr.dx], dx
		mov [cs:printr.bx], bx
		pop bx

.loop:		mov al, [cs:bx]
		inc bx

		cmp al, 0
		je .end
		cmp al, 1
		je .byte
		cmp al, 2
		je .word

		call pputc

		jmp .loop

.end:		push bx
		mov ax, []
		mov cx, []
		mov dx, [cs:printr.dx]
		mov bx, [cs:printr.bx]

.word:		pop dx
		call pdx
		jmp printf.loop

.byte:		pop dx
		mov dh, dl
		call pdx.l1
		jmp printf.loop

pdx:		; this double-call is essentially a 4 times repeating loop
		call .l1
.l1:		call .l2
.l2:		; set up cl for bit shifts
		mov cl, 4
		; grab highest nibble from dx
		mov al, dh
		; remove highest nibble from dx
		shl dx, cl
		; shift away second-highest nibble that we accidentally copied
		shr al, cl
		; map 0-9 to ascii codes for '0' to '9'
		add al, 0x30
		; if result is larger than '9', ...
		cmp al, 0x3a
		jl pputc
		; ... add 7 so we continue at 'A'
		add al, 7
pputc:		jmp putc

section .bss

outpos:		resb 2 ; l=in, h=out
outbuf:		resb 256

section .text

flush:		push bx
.loop:		mov bx, word [outpos]
		; return if buffer empty
		cmp bl, bh
		je .ret
		; read char
		xchg bh, bl
		mov bh, 0
		mov al, [outbuf+bx]
		; update read ptr
		inc bl
		mov [outpos+1], bl
		; send char to bios
		mov ah, 0x0e
		xor bx, bx
		int 0x10
		jmp .loop
.ret:		pop bx

putc:		push bx
		mov bx, word [cs:outpos]
		; skip if buffer full
		inc bl
		cmp bl, bh
		je .ret
		; store character
		mov bh, 0
		dec bl
		mov [cs:outbuf+bx], al
		inc bl
		; write back updated ptr
		mov byte [cs:outpos], bl
.ret		pop bx

putnib:		push ax
		and al, 0xF
		cmp al, 0xA
		jc .noadj
		add al, 7
.noadj:		add al, 0x30
		call putc
		pop ax

putbyte:	push ax
		push cx
		mov cl, 4
		shr ax, cl
		pop cx
		call putnib
		pop ax
		call putnib

putword:	xchg ah, al
		call putbyte
		xchg ah, al
		call putbyte

section .rodata

scSpaceKey:	equ	0x39
scInsKey:	equ	0x52
scDelKey:	equ	0x53

kbShiftKey:	equ	0x03
kbCtrlKey:	equ	0x04
kbAltKey:	equ	0x08

KeyConvertTab:	db scSpaceKey,kbAltKey
		dw 0x0200
		db scInsKey,kbCtrlKey
		dw 0x0400
		db scInsKey,kbShiftKey
		dw 0x0500
		db scDelKey,kbCtrlKey
		dw 0x0600
		db scDelKey,kbShiftKey
		dw 0x0700

KeyConvertCnt:	equ ((KeyConvertTab.end-KeyConvertTab)/4)

section .bss

oldint9:	resb 4

section .text

KeyFlags:	equ 0x17
KeyBufHead:	equ 0x1A
KeyBufTail:	equ 0x1C
KeyBufOrg:	equ 0x1E
KeyBufEnd:	equ 0x3E

int9handler:	push ds
		push di
		push ax
		; set bios segment
		mov ax, 0x40
		mov ds, ax
		; save current tail ptr of buffer
		; we later check if it has changed
		mov di, word [KeyBufTail]
		; read the value from hw
		in al, 0x60

		push ax
		call printf
		db "Int9: 0x60 read AL=",1,0x0A,0x0D,0

		; second read for debugging
		; feel free to comment out
		in al, 0x60
		push ax
		call printf
		db "Int9: secondary 0x60 read AL=",1,0x0A,0x0D,0

		mov ah, byte [KeyFlags]
		; simulate interrupt to upstream handler
		call far [cs:oldint9]
		; ignore key-ups
		test al, 0x80
		jne .l09

		push si
		push cx
		; load ptr and length of key table
		mov si,[cs:KeyConvertTab]
		mov cx,KeyConvertCnt

		; search for key in keytab
		; compare scancode
.l01:		cmp al, byte [cs:si]
		jne .l02
		; bit-test modifier key flags
		test ah, [cs:si+1]
		jnz .l03
.l02:		; go forward in loop, count down CX
		add si, 4
		loop .l01
		; jump out after exhausting CX
		jmp short .l08
.l03:		; we copied KeyBuftail contents earlier
		; if its changed, it means that bios inserted a key
		; and we can replace the value written by bios
		cmp di, [KeyBufTail]
		jne .l05
		; test if we are at end of keyboard buffer
		mov ax, di
		inc ax
		inc ax
		cmp ax, KeyBufEnd
		jne .l04
		; revert to start of buffer if we are
		mov ax, KeyBufOrg
.l04:		; bail out of head == tail, keyboard buffer full
		cmp ax, [KeyBufHead]
		je .l08
		; update keyboard in ptr
		mov [KeyBufTail],ax
		mov di, ax
		; insert our keycode into buffer
.l05:		mov ax, [cs:si+2]
		mov [ds:di], ax
		; restore registers
.l08:		pop cx
		pop si
.l09:		pop ax
		pop di
		pop ds