Reconstructed Commander Keen 1-3 Source Code

Here is where to post about the latest Commander Keen fangame or modification you've finished, a new website you've made, or another Keen-related creation.
User avatar
K1n9_Duk3
Vorticon Elite
Posts: 906
Joined: Mon Aug 25, 2008 9:30
Location: Germany
Contact:

Re: Reconstructed Commander Keen 1-3 Source Code

Post by K1n9_Duk3 »

Okay, here are the steps I had to take to prepare my reconstructed Keen 1-3 source code for IMF music playback.

IDASM.ASM

1. Add the variables "fasttimecount" and "jerk" at the beginning of the first data segment part and make sure "jerk" is public. I added them before the "drawseg" variable. The code looks like this:

Code: Select all

DATASEG

fasttimecount	dw	0
jerk		dw	1
PUBLIC	jerk

drawseg		dw	0
(The three lines in the middle are what needs to be added.)

2. Add the following "VWL_WaitVBL" procedure before the "VidRefresh" procedure:

Code: Select all

PROC VWL_WaitVBL NEAR
	mov	bx, [fasttimecount]	;remember old value
	mov	dx,STATUS_REGISTER_1
@@waitvbl:
	sti     		;service interrupts
	jmp	$+2
	cli
	in	al,dx
	test	al,00001000b	;look for vertical retrace
	jnz	@@done
	mov	ax, [fasttimecount] ;get current value
	sub	ax, bx		;get difference from old value
	cmp	ax, 8		;don't wait longer than 8 full cycles @ 560 Hz
	jbe	@@waitvbl
@@done:
	ret
ENDP
Note that the "fasttimecount" variable will be updated by the IMF music playback code that we will add in step 5. You cannot use this VWL_WaitVBL procedure without the IMF playback code. Well... you technically can, but then the code will always keep waiting for a VBL signal and never abort if it missed the VBL due to an interrupt.

Also note that this is written primarily for VGA cards. VGA cards generally run the 16-color 320x200 pixel graphics mode at 70 Hz, while EGA cards run it at 60 Hz. (Some VGA-based laptops also use 60 Hz for the internal display.) For a 60 Hz display, you would need to wait for 10 cycles of the 560 Hz timer to avoid aborting too soon. But I expect most people to run Keen mods on VGA systems, and waiting too long could also have undesirable side effects. I opted to use the timing that is the best match for 70 Hz, since that's what most people will be using. I have never owned or used an EGA-only PC.

3. Modify the end of the "VidRefresh" procedure (from the "@@setscreen" label to the "ENDP" line) as follows:

Code: Select all

@@setscreen:

	mov	dx,STATUS_REGISTER_1

; K1n9_Duk3 mod: Simply wait for a display active signal before setting the new
; CRTC start address. Allow interrupts while we are waiting, to avoid missing
; timer interrupts on certain systems.

@@waitnovbl:
	sti     		;service interrupts
	jmp	$+2
	cli
	in	al,dx
	test	al,00001001b
	jnz	@@waitnovbl
	
; We should have more than enough time to set a new CRTC start address when the
; display is active, since the address only gets latched at the beginning of a
; VBL, and there will *always* be a sufficiently long delay between the end of
; the last display active signal and the start of the VBL. The only reason why
; some sort of synchronization is required for setting the CRTC start address
; itself is that two separate CRTC registers need to be written for this, which
; can lead to problems if the VBL begins in between the two necessary writes.
; The CRTC latching behavior *should* be identical on all EGA-compatible cards.

;
; set CRTC start
;
	mov	cx, [screenstart]
	mov	dx, CRTC_INDEX
	mov	al, CRTC_STARTHIGH
	out	dx, al
	inc	dx
	mov	al, ch
	out	dx, al
	dec	dx
	mov	al, CRTC_STARTLOW
	out	dx, al
	inc	dx
	mov	al, cl
	out	dx, al

	test	[jerk], 1	; K1n9_Duk3 mod
	jnz	@@setpan	; K1n9_Duk3 mod

;
; wait for a vertical retrace to set pel panning
;

	call	VWL_WaitVBL	; K1n9_Duk3 mod

;
; set horizontal panning
;

@@setpan:			; K1n9_Duk3 mod

	mov	dx,ATR_INDEX
	mov	al,ATR_PELPAN or 20h
	out	dx,al
	jmp	$+2
	mov	ax,[screenpan]
	out	dx,al

	test	[jerk], 1	; K1n9_Duk3 mod
	jz	@@done		; K1n9_Duk3 mod
;
; wait for a vertical retrace to avoid flickering
;

	call	VWL_WaitVBL	; K1n9_Duk3 mod

@@done:
	sti			; enable interrupts again

	ret
ENDP
4. Add the following variables at the end of the sound-related data segment variables, in between the "PUBLIC SndPriority,SoundData" line and the "CODESEG" line:

Code: Select all

PUBLIC SndPriority,SoundData

AdLibDetected	dw	0

imfData		dd	0
imfStart	dw	0
imfSize		dw	0
imfSizeLeft	dw	0
imfPlaying	dw	0
imfDelay	dw	0
imfIntCount	dw	0

PUBLIC AdLibDetected

        CODESEG
5. Add the following procedures right before the "StartupSound" procedure (immediately after the "CODESEG" line mentioned above):

Code: Select all

;========
;
; alOut
; Sends a register/value pair to the OPL chip
; Expects reg in AL, val in AH -- destroys AL and DX
;
;=========

PROC	alOut

	mov	dx, 388h
	pushf
	cli
	out	dx, al
rept 6
	in	al, dx
endm
	inc	dx
	mov	al, ah
	out	dx, al
	popf
	dec	dx
rept 35
	in	al, dx
endm
	ret
	
ENDP

;=========================================================================

;========
;
; DetectAdLib
; Checks if an AdLib-compatible card is present
; Returns AX = 1 on success, 0 otherwise
;
;=========

PROC	DetectAdLib
PUBLIC	DetectAdLib

	mov	ax, 6004h	; reset timers 1 and 2
	call	alOut
	mov	ax, 8004h	; clear IRQ flag
	call	alOut
	
; If bits 5, 6 and 7 of the status byte aren't all set to 0 after resetting
; the timers and IRQ flags, the AdLib detection has already failed and we
; don't need to try running the rest of the detection code. Most systems
; that don't have an AdLib card will already fail this test, since reading
; the status port will most likely return the value 0xFF.

	and	al, 0E0h
	jnz	@@failed
	
; To check if an AdLib (or compatible) card is present, we need to start
; one of the card's internal timers and wait for it to overflow and
; signal that the timer is done. Timer 1 is the faster one, incrementing
; its counter every 80 microseconds. If we set up the counter value for
; timer 1 to be 1 increment away from an overflow, we only need to wait
; 80 microseconds, otherwise we would need to wait longer.

	mov	ax, 0FF02h	; Set timer 1 counter to largest possible value
	call	alOut
	mov	ax, 2104h	; Start timer 1
	call	alOut
	
; Now we need to wait AT LEAST 80 microseconds before it's safe to say
; that no AdLib or compatible sound card is present. It won't hurt to
; wait a little longer, though. The AdLib detection was successful if we
; read a status byte that has bits 6 and 7 set to 1 (bit 7 means one of
; the timers has overflowed if it's set, and bit 6, if set, means timer
; 1 has overflowed) and bit 5 set to 0 (bit 5 is the timer 2 status bit
; and we disabled timer 2 at the beginning and cleared the status bits,
; so that bit shouldn't be set to 1 now).
;
; So we basically just need to wait until we read a status byte that has
; the bits set in the correct combination.
;
; To avoid waiting forever when no AdLib card is present, we need some
; method of checking how much time has elapsed. I will use the 18.2 Hz
; BIOS timer for that, just to erase any dependencies on other interrupt
; handlers. This timer gets increased roughly every 55 milliseconds, which
; is MUCH longer than the 80 microseconds that we need to wait, but that
; doesn't really matter because most systems that don't have an AdLib
; (-compatible) sound card will already fail the first test above.
; Since we can never be sure how close we were to the next BIOS timer
; interrupt when we started the AdLib's timer, we need to wait for at
; least two BIOS timer interrupts to avoid aborting too soon.

	mov	ax, 40h
	mov	es, ax
	mov	bx, [es:6Ch]	; read BIOS timer value
	
@@waitloop:
	in	al, dx		; read status bits
	and	al, 0E0h
	cmp	al, 0C0h	; if timer 1 is done
	je	@@success
	mov	ax, [es:6Ch]	; read BIOS timer value
	sub	ax, bx		; calculate number of 18.2 Hz ticks elapsed
	cmp	ax, 2		; AdLib detection fails after 2 tics
	jb	@@waitloop
	
@@failed:
	xor	ax, ax		; AdLib detection failed
	ret

; When we get here, the AdLib detection was successful. Time to turn off
; the AdLib's internal timers again:

@@success:
	mov	ax, 6004h	; reset timers 1 and 2
	call	alOut
	mov	ax, 8004h	; clear IRQ flag
	call	alOut

; And now we need to put the AdLib into a well-defined state by setting
; all registers to 0:

	mov	cx, 0F4h
@@initloop:
	mov	ax, cx
	call	alOut
	loop	@@initloop
	
; Note: Many IMF music tracks depend on Waveform Selection (WSE) being
; enabled, but don't contain the necessary instruction to enable it,
; which is why the next alOut call is necessary. Some sound effects may
; depend on this as well.
		
	mov	ax, 2001h	; Set WSE=1
	call	alOut
	
	mov	ax, 1		; AdLib detection was successful
	ret

ENDP

;=========================================================================

;========
;
; PauseMusic
; Pauses music playback and turns all notes off
;
;=========

PROC	PauseMusic
PUBLIC	PauseMusic

	mov	[imfPlaying], 0
	
	mov	ax, 00BDh
	call	alOut
	
	mov	cx, 00B0h
@@noteoff:
	mov	ax, cx
	call	alOut
	inc	cx
	cmp	cx, 00B9h
	jbe	@@noteoff
	ret

ENDP

;=========================================================================

;========
;
; PlayMusic
; Starts playback of a block of music data
;
;=========

PROC	PlayMusic data:DWORD, len:WORD
PUBLIC	PlayMusic

	cmp	[AdLibDetected], 0
	jz	@@done
	
	call	PauseMusic
	
	mov	ax, [len]
	shr	ax, 1
	shr	ax, 1
	mov	[imfSize], ax
	mov	[imfSizeLeft], ax
	
	mov	ax, [WORD data]
	mov	dx, [WORD data+2]
	mov	[WORD imfData+2], dx
	mov	[WORD imfData], ax
	mov	[imfStart], ax
	mov	[imfDelay], 0
	
	mov	[imfPlaying], 1
	
@@done:
	ret

ENDP

;=========================================================================

;========
;
; CleanAdLib
; Totally shuts down the AdLib card
;
;=========

PROC	CleanAdLib
PUBLIC	CleanAdLib

	cmp	[AdLibDetected], 0
	jz	@@done
	
	mov	[imfPlaying], 0
	
	mov	cx, 0F4h
@@initloop:
	mov	ax, cx
	call	alOut
	loop	@@initloop
	
@@done:
	ret

ENDP

;=========================================================================

;========
;
; UpdateIMF
; interrupt handler for IMF music playback
;
;=========

IMF_TIMER = 852h	; timer constant for 560 Hz
SPK_TIMER = 2000h	; timer constant that UpdateSPKR expects (about 145 Hz)

PROC	UpdateIMF FAR
PUBLIC	UpdateIMF

	push	ax
	push	dx
	push	si
	push	ds
	push	es
	cld
	
	mov	ax, @Data
	mov	ds, ax
	
	cmp	[imfPlaying], 0
	jz	@@timing

	cmp	[imfDelay], 0
	jnz	@@next
	les	si, [imfData]
@@dataloop:
	lods	[WORD es:si]	; read reg/val pair
	call	alOut
	lods	[WORD es:si]	; read delay value
	dec	[imfSizeLeft]
	jz	@@ended
	add	[imfDelay], ax
	jz	@@dataloop
	mov	[WORD imfData], si
@@next:
	dec	[imfDelay]
	jmp	short @@timing
	
@@ended:
	mov	ax, [imfStart]
	mov	[WORD imfData], ax
	mov	ax, [imfSize]
	mov	[imfSizeLeft], ax
	mov	[imfDelay], 0
	
@@timing:
	inc	[fasttimecount]
	add	[imfIntCount], IMF_TIMER
	cmp	[imfIntCount], SPK_TIMER
	jb	@@ack
	
	sub	[imfIntCount], SPK_TIMER
	pushf
	call	FAR PTR UpdateSPKR
	jmp	short @@done
	
@@ack:
	mov	al, 20h
	out	20h, al

@@done:
	
	pop	es
	pop	ds
	pop	si
	pop	dx
	pop	ax
	iret

ENDP

;=========================================================================

6. Modify the StartupSound procedure as follows to hook up UpdateIMF and set the timer to 560 Hz:

Code: Select all

PROC StartupSound
	PUBLIC	StartupSound

	test	[dontplay],0ffffh
	je	@@dowork
	ret
@@dowork:
	test	[SPKactive],0FFFFh	;see if library is active
	jne	@@started		;library was allready started

@@start:
	call	NEAR PTR StopSound 	;make sure nothing is playing

	mov	ax,3508h	;call bios to get int 8
	int	21h
	mov	[WORD oldint8],bx
	mov	ax,es
	mov	[WORD oldint8+2],ax
	mov	ax, 8
	mov	[intcount], al
	
; ALWAYS hook up UpdateIMF instead of UpdateSPKR as the int 8 handler:

	call	near ptr DetectAdLib
	mov	[AdLibDetected], ax
	push	ds

	push	cs
	pop	ds
	lea	dx,[UpdateIMF]
	mov	ax,2508h	;call bios to set int 8
	int	21h

	pop	ds

	;mov	bx,2000h
	mov	bx, IMF_TIMER	; run UpdateIMF at 560 Hz
	cli
	mov	al,36h		;tell the timer chip we are going to
	out	43h,al		;change the speed of timer 0
	mov	al,0
	mov	al,bl
	out	40h,al		;low
	mov	al,bh
	out	40h,al		;high
	sti

	inc	[SPKactive]	;sound routines are now active

@@started:
	mov	ax,1
	mov	[soundmode],ax ;set soundmode to SPKR
	ret

ENDP
7. Add a call to the CleanAdLib procedure right after the "@@dowork" label in the ShutdownSound procedure, like this:

Code: Select all

PROC	ShutdownSound
	PUBLIC ShutdownSound

	test	[dontplay],0ffffh
	je	@@dowork
	ret
@@dowork:
	call	CleanAdLib	; K1n9_Duk3 mod
	
	cli
	mov	al,36h		;tell the timer chip we are going to
	out	43h,al		;change the speed of timer 0
	mov	al,0		;system expects 0000 for rate
	out	40h,al		;low
	out	40h,al		;high
	sti

IDLIB.C

For the most basic music playback, you need the following variable and two C functions to start and stop music playback:

Code: Select all

static Uint16 lastmusic = ~0;
static void far *musicalloc;

void StopMusic(void)
{
	PauseMusic();
	lastmusic = ~0;
	
	if (musicalloc)
	{
		farfree(musicalloc);
	}
}

void StartMusic(Uint16 num)
{
	char musicfile[16];
	Uint16 far *buffer;
	
	if (AdLibDetected && num != lastmusic)
	{
		StopMusic();
		
		itoa(num, musicfile, 10);
		strcat(musicfile, ".imf");
		
		buffer = (Uint16 far *)bloadin(musicfile);
		if (buffer)
		{
			musicalloc = lastparalloc;	// save it so that the music can be freed correctly later
			
			// Note: This is for playing "Type 1" IMF files. The first two bytes
			// of these files contain the size of the IMF data.
			
			PlayMusic(buffer+1, *buffer);
			lastmusic = num;
		}
	}
}

The StartMusic function expects the number of the music track to play. For example, the function call "StartMusic(0)" will try to load and play the file "0.imf".

I designed the code so that calling StartMusic will do nothing if the program is already playing the music that StartMusic would have started. This is necessary for the default use case I had in mind (see below). If you want to force the code to restart the music even if the desired music is already playing, you can call StopMusic before calling StartMusic.

IDLIB.H

And to make all of this functionality safely available to the rest of the code, you need to insert the following prototypes and extern declarations into the appropriate header file (IDLIB.H, after the other sound-related declarations, for example):

Code: Select all

void PauseMusic(void);
void PlayMusic(void far *data, Uint16 size);
extern Uint16 AdLibDetected;

void StopMusic(void);
void StartMusic(Uint16 num);
How To Let The Music Play:

Now you have the tools, but you won't hear any music play unless you add StartMusic calls to your game code. One relatively easy way to do that would be to modify the ReadLevel function in KEENMAIN.C and make it load the music based on the current level number. All you would have to do is to insert the line

Code: Select all

StartMusic(number);
before the end of that function.

This use case is why I designed StartMusic to do nothing when the desired music number is already being played. Since the DrawPicFile function corrupts the level data, some levels like the menu/intro level and the ending sequence level need to be re-loaded several times, which would also cause the StartMusic function to be called again. You probably don't want the music to restart in that case.

By the way, this is pretty similar to what TSRMUSIC does. It also monitors the level number variable and loads a new piece of music when that number changes. The main difference is that this implementation uses a fixed naming scheme ("0.imf", "1.imf", ... "90.imf") for the music files instead of using a music list file. This means that if you intend to use the same music for several levels, you would need to have several copies of the same music file in your game directory. This can waste quite a bit of drive space, but that aspect should be negligable for most mods. If you need to, you can always write your own code to make things more flexible. I was trying to keep the code small and simple to leave enough space for whatever else you want to add to the code in your mod.

Another difference between this code and TSRMUSIC is that this uses dynamic memory allocation. That means the music can be up to 64 kilobytes in size (TSRMUSIC only supports up to 32 kilobytes), but the downside is that the game will crash with an "Out of memory!" error if there is not enough memory available to load the music file into memory. There are ways to avoid crashes, but I was trying to keep the code small.

Keep in mind that you don't have to implement it like this. You are free to place your StartMusic and StopMusic calls elsewhere in the code. There might be much better ways to handle this, depending on what you are trying to achieve.

Fix Jerky Motion:

Let's get back to the "jerk" variable that we added and made public in step 1, but haven't actually done anything with outside the assembly code yet. If you want to give users of actual DOS machines the option to switch between the two methods of applying the panning value, you need to add a parameter check in your "main" function in KEENMAIN.C and let it set the "jerk" variable to 0 when a certain parameter is used.

But to access that variable, you need an extern declaration for it first. You can put it in a header file (like IDLIB.H) if you want, but you can also add it right before the variable is accessed to keep it simple if the variable isn't going to be used anywhere else in the C code.

I would add the parameter check in between the video card type check and loading the graphics, mainly because I thought it would make sense to have the associated text message show up after the EGA/VGA "card detected" message. It could look like this:

Code: Select all

	for (i=1; i<argc; i++)
	{
		if (stricmp(argv[i], "/JERK") == 0)
		{
			extern Sint16 jerk;

			puts("Jerky motion fix active");
			jerk = 0;
			break;
		}
	}
The "puts" call is just there to let the user see that the program has found the "/JERK" parameter. You can remove it if you want to save some memory. Unless you use compressed EGA graphics, the message will probably be gone before the user can read it anyway.
Hail to the K1n9, baby!
http://k1n9duk3.shikadi.net
User avatar
TheBigV
Vortininja
Posts: 74
Joined: Mon Oct 21, 2019 13:43
Location: LXTerminal
Contact:

Re: Reconstructed Commander Keen 1-3 Source Code

Post by TheBigV »

K1n9_Duk3 wrote: Fri May 08, 2026 8:00 Okay, here are the steps I had to take to prepare my reconstructed Keen 1-3 source code for IMF music playback.

[...]
Thank you! This looks to be a better implementation of IMF music than what I did, too. I'll try to get this implemented in my mod soon (and modify it where I need to).
User avatar
K1n9_Duk3
Vorticon Elite
Posts: 906
Joined: Mon Aug 25, 2008 9:30
Location: Germany
Contact:

Re: Reconstructed Commander Keen 1-3 Source Code

Post by K1n9_Duk3 »

Some minor corrections:
  • The "popf" instruction in the alOut procedure should be moved to the end of the proc, directly before the "ret" instruction.
  • The code should use "mov cx, 0F5h" instead of "mov cx, 0F4h" before the two initloop labels (in DetectAdLib and CleanAdLib).
  • PauseMusic should use "cmp cx, 00B8h" instead of "cmp cx, 00B9h".
The first one could be problematic if a timer interrupt fires immediately after the "popf" instruction in alOut. The repeated "in al, dx" instructions in the alOut procedure are there to make sure the AdLib has enough time to actually write the new value to the specified internal register. If an interrupt fires before all those port reads are executed, and the interrupt handler also calls the alOut proc, it might prevent the previous value from being written correctly. This should never happen in the code as it is now, since the only procs that call alOut outside of the interrupt handler are DetectAdLib, PauseMusic and CleanAdLib. DetectAdLib should only be called a single time at startup, when the interrupt handler hasn't been hooked up yet. And the other two stop music playback before using alOut. That means even if a timer interrupt occurs in alOut, the interrupt handler will not be playing music and therefore won't call alOut. But it could become a problem if you add more code that uses alOut.

The second one should generally be no problem, since every IMF song should write a value to register 0xF5 before playing a note that uses this setting. And when quitting to DOS, all the notes still get turned off correctly, so it shouldn't matter if register 0xF5 is set to 0 or not.

The third one caused the code to write a 0 to register 0xB9 when pausing/stopping the music. That was pointless because only the registers 0xB0 to 0xB8 are used by the OPL chip. Register 0xB9 has no effect, so writing a 0 to that register is just wasting time.

These minor issues shouldn't have caused any noticeable problems with the music playback. After all, the Keen 4-6 and Wolf3D sound code has similar issues. But they were technically incorrect and I thought I should address these issues.
Hail to the K1n9, baby!
http://k1n9duk3.shikadi.net
User avatar
K1n9_Duk3
Vorticon Elite
Posts: 906
Joined: Mon Aug 25, 2008 9:30
Location: Germany
Contact:

Re: Reconstructed Commander Keen 1-3 Source Code

Post by K1n9_Duk3 »

I have updated the source code once more.

Download (same URL as before)

This update adds support for:
  • Keen 2 v1.1
  • Keen 3 v1.1
  • Keen 2 v1.32
  • Keen 3 v1.32
These are basically the counterparts to Keen 1 v1.3 and Keen 1 v1.34, which were already supported in the previous release.

A couple of source code changes were necessary to recreate the v1.32 code, mainly because Keen 2 v1.32 doesn't use jump optimization anymore. But there were a few little post-1.31 changes that I hadn't applied to the Keen 2 and 3 code yet (because I hadn't seen any post-1.31 executables of these games before).

The code changes are all extremely minor ones. After all, the C code still leads to exactly the same machine code in the other versions that use better optimization settings. If you are working on a project that was based on the previous version of this code (January 2026), there is absolutely no need to incorporate any of these changes into your code.
Hail to the K1n9, baby!
http://k1n9duk3.shikadi.net
Post Reply