by Richard F. Drushel (drushel@apk.net)
Hello, patient readers, my end-of-semester/Christmas/start-of-semester hiatus is over. With this issue of TWWMCA, I'm resuming my weekly schedule. The next possible hiatus is at the end of April/beginning of May, which is the end of the Spring 1997 semester here at CWRU.
I've been busy with my ADAM over Christmas break, so I have a full buffer of topics for these articles. I hope you enjoy them over the coming weeks. And finally, a belated "Happy New Year" to everyone!
My kids spent a fair amount of their Christmas break playing ColecoVision games on their new gift from Santa Claus, a Telegames DYNA ColecoVision-compatible video game system. (I'm planning a formal review of the DYNA system for a future issue of TWWMCA, so stay tuned.) One of the games they had trouble with was Pitfall by Activision: they kept getting killed and using up their 3 lives less than 2 minutes into the game. While all games have a learning curve, Pitfall's seemed steeper than most, and none of my kids seemed to get any better with repeated playing.
So, I decided it would be an interesting project to try to hack Pitfall so that, no matter how many times you got killed, you never ran out of lives--"immortality", if you will. Of course, since the game itself is in ROM, unless I wanted to burn new ROMs for the cartridge, my hacked version would have to run either from disk on an ADAM, or else under Marcel de Kogel's ColecoVision or ADAM emulators. For ease of debugging, I decided to use the COLEMDOS emulator. (I know that the COLEMDOS project has been absorbed into Marcel's ADAM emulator, but I haven't gotten around to obtaining the latter and COLEMDOS works fine for my purposes.)
To decide how I wanted to go about hacking into Pitfall, I had to consider a number of issues:
At some point, I was going to have to disassemble the Pitfall 16K binary to see how the code worked. But that 16K binary would be a jumble of code, image data, and sound data. Sorting those out would be a formidable task, and *then* the code would have to be traced out to see what it did. Brute-force begin-at-the-beginning-and-go-until-the-end would be very hard and take a long time, so I would have to try to limit the search space somehow. I didn't want to figure out more about the game than I needed to in order to add the immortality hack.
I needed to understand the behavior of the game (from a player's perspective), to know what actions caused you to die, to know what graphics, music, etc. occurred when you died, and when you ran out of lives. These would be things to look for in the disassembled code.
In Pitfall, many things cause you to die: falling into any of the lakes (lake with alligators, lake with Tarzan rope, or appearing/disappearing lake), or touching various hazards (snake, fire, scorpion). When you die, the theme from the TV cop show "Dragnet" is played (DUMMM DA-DUM DUM DUMMM), and either (a) a new life is granted (if you have any left), or (b) the game ends (if that was your last life).
How would a typical programmer be likely to keep track of lives in a game? If I could guess the method, I could look for it in the disassembled code.
Putting (2) and (3) together, I hypothesized that the relevant code would have to look something like this:
MY_START_GAME:
...
LD A,3 ;initial number of lives
LD (NUMBER_OF_LIVES),A ;save it in workspace RAM
...
YOU_DIED:
CALL PLAY_DEATH_SONG ;DUMM DA-DUM DUM DUMMM
...
LD HL,NUMBER_OF_LIVES ;point to number of lives
DEC (HL) ;one less life; any left?
RET NZ ;yes, so keep playing
JP GAME_OVER ;no, so game over
This is not much to go on. This kind of code is commonplace, and the helpful labels given here are not going to show up in a disassembly listing:
J$81A0:
...
LD A,03H ;A=3
LD (I.721C),A ;save something
...
C$A42F:
CALL C$AC02 ;do something
...
LD HL,I.721C ;point to something
DEC (HL) ;one less, is it zero yet?
RET NZ ;no, so return
JP J$95A9 ;yes, so go somewhere
Also, there's no guarantee that it was even written in this straightforward a manner. For example, all the RAM workspace data areas could be initialized by copying a single block of data from ROM to RAM, rather than setting the RAM variables directly. In this case, the variable you care about is buried anonymously in the block of data:
LD HL,I.B07E ;ROM source
LD DE,I.7200 ;RAM destination
LD BC,27H ;39 bytes to copy
LDIR ;copy them
...
I.B07E:
DB 00H,00H,05H,0AH,00H,14H,29H,00H
DB 08H,08H,00H,32H,11H,09H,00H,05H
DB 0FFH,0EH,0FFH,0EFH,66H,55H,54H,09H
DB 0C8H,0C8H,05H,00H,03H,01H,08H,10H
; ^^^
; ^^^ ROM $B09A --> RAM $721C
;
DB 28H,33H,00H,0F8H,0F6H,00H,01H
I.B0A5:
Considering all these possibilities, however, it seemed certain that, whether they were contained in the same subroutine or not, there had to be some linkage between playing the "Dragnet" death theme and decrementing the count of lives remaining. Thus, my final plan of attack was:
Disassemble Pitfall enough so that the code and data areas are resolved. I don't care if I don't know what the data areas mean, just so they are all separate from the code areas.
Find the subroutine which plays the "Dragnet" theme.
Find all code which references the call to play the "Dragnet" theme, and look for anything which looks like it's decrementing a life count.
Patch the Pitfall binary to bypass the life count decrement, and play the patched version to see if "immortality" has been achieved.
Early in my ADAM career, I wrote a SmartBASIC program to disassemble code in memory, with the memory contents also displayed in hexadecimal, called UNASMHEX. I modified this to write the disassembly to disk. (By reading said disk in an IBM-PC disk drive, I could move the listing to a PC for further editing using better editors than SmartWriter; but that's a different story.) I further ported the program to Microsoft BASIC and modified it to disassemble from a file; with the last version of this program, UNASM2F9.BAS, I could disassemble to my heart's content, so long as I had managed to move the appropriate binary from the ADAM to the PC (usually by a null-modem transfer). This was all I had when I did my original disassemblies of EOS-5, EOS-7, SmartBASIC 1.0, SmartBASIC 2.0, PowerPaint, and ADAMlink IVa; and for simple disassembly to a list file, it was sufficient. Here's a sample (with some comments added after the fact). Note that everything not in the dump field is in decimal.
**********************************************************************
First 2 blocks of ADAMcalc binary file BASICPGM.
(named BASICPGM so that SmartBASIC can't INIT it by accident)
disassembled by Richard F. Drushel 1 August 1993
**********************************************************************
This is loaded by the cold boot/block 0 routine.
On entry, A=default device #, and addresses 0-25 contain the directory
entry for the ADAMcalc binary program BASICPGM.
12288 F3 DI ;disable maskable interrupts
12289 3100E0 LD SP,57344 ;set top of stack to bottom of EOS
12292 F5 PUSH AF ;save default device # (from boot)
12293 219C37 LD HL,14236 ;address of data
12296 ED5B0D00 LD DE,(13) ;get start block from directory entry
12300 13 INC DE ;skip to 3rd block
12301 13 INC DE ;(first 2 are already loaded)
12302 010000 LD BC,0 ;
12305 CDA2FC CALL 64674 ;start read 1 block
12308 CD0632 CALL 12806 ;VDP register setup using data table.
**********************************************************************
VDP register setup data. For awful, EOS-bypassing VRAM write routine.
12311 07 ;7
12312 07 ;6
12313 7F ;5
12314 03 ;4
12315 FF ;3
12316 06 ;2
12317 C2 ;1
12318 02 ;0
**********************************************************************
12319 CD1432 CALL 12820 ;some VRAM writing
12322 AF XOR A ;A=0
12323 110018 LD DE,6144 ;# bytes to fill
12326 210000 LD HL,0 ;start address
12329 CD26FD CALL 64806 ;fill VRAM
Subsequently, as I have had need not only to disassemble a program to figure out how it works, but also to make substantial changes to it, I have needed a disassembler which can produce a listing which is assembler-ready. That is, I can use the disassembly output as the direct basis for program changes, and run it through an assembler to generate a new binary. Nothing in my UNASM family of disassemblers was designed to do this.
Fortunately, Kenneth Gielow's Z80DIS version 2.2, which runs under CP/M (and TDOS, too), disassembles a binary file to re-assemblable ASM source. Z80DIS is an "intelligent" disassembler: it can often recognize the breaks between code area and data areas (especially if the data consist of ASCII text strings), and you make it repeatedly analyze the binary, each cycle taking into account what it learned previously. Z80DIS creates what it calls a "break table", which is a sequential listing of start addresses for the different areas of the program. These breaks can be due to instructions (I), data bytes (B), data words (W), address tables (T), ASCII text (A), or empty space (S). Here is the equivalent break table for the fragment of ADAMcalc shown above:
I3000
B3017
I301F
Note that the addresses in the break table are given in hexadecimal.
Typically, you let Z80DIS automatically make its best guess at where the breaks should be, cycling over and over until there is no further "improvement". At the end of each cycle, Z80DIS lists a table of "anomalies", which are places that don't seem to make sense based upon the current break table. When the anomaly table reaches a minimum, you exit the analysis mode and let Z80DIS write out the regenerated source ASM file. Now you have to look at the regenerated source and decide if you agree with Z80DIS's choices of breaks. If you don't, you have to write a new break table, feed that to Z80DIS, and let it write out the source ASM again.
The large advantage of Z80DIS is that its regenerated source file contains only as many reference labels as are necessary. In the UNASM output above, note that every statement is prefaced with a label (the RAM address in decimal). I could easily hack UNASM to omit the hex dump field and write A12288 (a label) instead of 12288 (a number), and the output would be nominally acceptable to an assembler. The problem is, the vast majority of the labels would be unnecessary, because there are no CALLs or JPs to those addresses. Assemblers have a finite space to generate a symbol table, and giving every line a symbol would cause the assembler to rapidly run out of space. In the above code fragment, Z80DIS would generate *no* start-of-line labels, because *none* of it is the target of a CALL or JP anywhere else in the program (as I recall). (The data area is referenced by a "trick": the CALL 12806 pushes the return address 12311 on the stack, which the 12806 routine immediately retrieves and uses as a pointer to the data; at the end, the correct return address of 12319 is put back on the stack before the RET, so execution resumes directly after the data table.)
The regenerated source file can be many hundreds of kilobytes in size. I believe that the popular CP/M editor VDE can't deal with a single file greater than 64K or so. In any case, if you're running Z80DIS under native CP/M, you'll need an editor capable of handling huge ASM files. I know that WordStar 3.3 can do it; if there are other such editors, especially if they are freeware/shareware, I'd like to hear about them.
Because I don't have WordStar 3.3 for CP/M and because I've never done much with ADAM CP/M 2.2 or TDOS (even though I have a 10MB partition for the latter on my ADAM hard drive), I prefer to use the text editors I have available on my PC system under MS-DOS, and to run Z80DIS and other CP/M programs under a DOS-based emulator. While Simeon Cran's MYZ80 is currently the "best" CP/M emulator around, I don't use it because it can't read/write files in the DOS filesystem--you have to create a virtual disk file, and use a special utility to import/export DOS files to/from the virtual disk. Instead, I use a more primitive and clunky emulator, Joan Riff's Z80MU. Despite many defects compared to MYZ80, Z80MU can directly read/write DOS files. Thus, simply keeping a /Z80 subdirectory on my PC hard drive, along with Z80MU.EXE and any CP/M .COM files I want, lets me easily switch back and forth between CP/M (for disassembly and assembly) and DOS (for editing).
z80dis22.lbr, Kenneth Gielow's Z80 disassembler (CP/M)
z80mub2b.zip, Joan Riff's CP/M emulator (DOS)
myz80111.zip, Simeon Cran's CP/M emulator (DOS)
My final disassembly procedure was:
First-order disassembly of PITFALL.ROM using Z80DIS under Z80MU emulator. Automatic mode until anomaly table did not change; then write out PITFALL.ASM.
Examine PITFALL.ASM with WordPerfect 5.0 (DOS). Revise break table as seems appropriate.
Second-order disassembly, using revised break table (no automatic mode).
Repeat (2) and (3) until happy.
Reassemble PITFALL.ASM with SLR's Z80ASM+ (a commercial assembler) to create PITFALL0.ROM.
Use MS-DOS FC.EXE (File Compare) to make sure that PITFALL0.ROM is identical to PITFALL.ROM. This verifies that I got back what I started with.
On a couple of occasions, further inspection of the code much later, during the cleanup process (see below), demanded some additional improvements to the break table (such as finding address tables buried inside byte data tables), which required re-disassembly to generate an improved PITFALL.ASM. This allowed the final verification of totally resolved disassembly:
Delete a couple bytes of unused, padded-out space from the beginning of the code, put it back at the end to keep the total bytecount the same, reassemble the new code, and play the game to make sure that everything still works. If there was a vector table still hidden in the data areas, moving the code will cause it not to point at the correct place any more, and the game will crash or have scrambled graphics, etc.
Now that I had Pitfall completely disassembled, I had to start to figure out how it worked--at least enough to find the "Dragnet" death music player. But before actually beginning to interpret the code, there was one final cleanup step to perform: substituting the official OS7 global symbol names wherever possible in PITFALL.ASM.
In the code samples given above in III, you can see what a difference meaningful symbol names bring to otherwise meaningless code. Except in the highly unlikely case that Activision completely bypassed the operating system and wrote directly to the hardware, Pitfall must be full of OS7 function calls and references to OS7 global data structures. There is no way that Z80DIS could know what these symbols were when it did its disassembly, so I would have to put them in manually, using the search/replace functions of my editor (WordPerfect 5.0 for DOS) and a listing of the OS7 global symbols. This list, OS7SYM.ASM, which I derived from the ColecoVision Programmer's Manual, is available for download.
os7sym.asm, ColecoVision OS7 global symbols
The immediate effect of substituting the global symbol names was to make the code somewhat readable, even knowing nothing else about Pitfall. If you tracked down anything which had to do with sprites, for example, eventually you could find out which of the data areas had sprite pattern generators (i.e., the bitmaps for individual sprites) and colors. Similarly, if you tracked down anything which had to do with sounds (such as PLAY_IT or PLAY_SONGS), you could find out where the music and sound effects were stored. You can guess what I did next...
I searched through PITFALL.ASM for any references to the following OS7 function calls dealing with sounds:
PLAY_IT enable the playing of the song in B PLAY_SONGS actually write data to sound chip (low level routine) SOUND_INIT initialize the sound data areas SOUND_MAN manage note pointers for songs TURN_OFF_SOUND turns off 3 voices and noise channel
I thus discovered a set of routines with the following form:
LD B,number ;song number in B
CALL PLAY_IT ;play the song
JR common_exit ;exit
These had to be routines to play individual songs and sound effects. Working backwards, I found that these were linked by a common routine, which I have named PLAY_SOUND_IN_A:
PLAY_SOUND_IN_A:
;Play sound (or take sound action) specified by A. On entry,
;A=index of sound action to perform (0=turn off sound, 1=init
;sounds, 2-10 are sounds to play). On exit, the desired action
;is performed. Aborts if A>10, which is weird because the
;SOUND_VECTOR_TABLE has an entry for A=11.
CP 0BH ;is it less than 11?
RET NC ;no, so abort
;
PUSH BC
PUSH DE
PUSH HL
PUSH IX
PUSH IY
LD E,A
LD D,00H ;DE=index
LD HL,SOUND_VECTOR_TABLE ;point to vector table
ADD HL,DE ;offset twice
ADD HL,DE
LD A,(HL) ;get vector
INC HL
LD H,(HL)
LD L,A ;into HL
JP (HL) ;go there
;
; -----------------
SOUND_VECTOR_TABLE:
DW MY_TURN_OFF_SOUNDS ;turns off all sounds
DW MY_INIT_SOUNDS ;initializes the sound manager
DW PLAY_SOUND_01H ;
DW PLAY_SOUND_02H ;
DW PLAY_SOUND_03H ;
DW PLAY_SOUND_04H ;
DW PLAY_SOUND_05H ;
DW PLAY_SOUNDS_06H_07H ;
DW PLAY_SOUNDS_08H_09H ;
DW PLAY_SOUND_0CH ;
DW PLAY_SOUND_0DH ;
DW PLAY_SOUNDS_0AH_0BH ;
; -----------------
;
P_S_I_A_COMMON:
POP IY
POP IX
POP HL
POP DE
POP BC
RET
All but the first 2 vectors in SOUND_VECTOR_TABLE point to routines with the LD B,number / CALL PLAY_IT / JR common_exit form (3 of them play 2 songs instead of 1). The first two vectors are for specialized functions: turning off all sounds, and initializing the sound manager. Note the interesting bit of orphaned code: there are 12 vectors in the table, but the routine only allows access to 11. We'll come back to what PLAY_SOUNDS_0AH_0BH actually does later.
The code segments of Pitfall are filled with
LD A,number
CALL PLAY_SOUND_IN_A
and clearly some value of A will result in the playing of the "Dragnet" death theme. But which one? There's no easy way to tell.
Let's try find where the sound data is actually stored. The routine MY_INIT_SOUNDS gives us a clue:
MY_INIT_SOUNDS:
LD HL,MY_SOUND_TABLE
LD B,04H
CALL SOUND_INIT
;
JR P_S_I_A_COMMON
MY_SOUND_TABLE:
DW DATA_SOUND_01H,I.72E0 ;
DW DATA_SOUND_02H,I.72E0 ;
DW DATA_SOUND_03H,I.72E0 ;
DW DATA_SOUND_04H,I.72E0 ;
DW DATA_SOUND_05H,I.72E0 ;
DW DATA_SOUND_06H,I.72E0 ;
DW DATA_SOUND_07H,I.72EA ;
DW DATA_SOUND_08H,I$72F4 ;
DW DATA_SOUND_09H,I.72EA ;
DW DATA_SOUND_0AH,I.72E0 ;
DW DATA_SOUND_0BH,I.72EA ;
DW DATA_SOUND_0CH,I.72E0 ;
DW DATA_SOUND_0DH,I.72E0 ;
; -----------------
Each 2-word record in MY_SOUND_TABLE contains a pointer to the actual data for a sound, and a pointer to a RAM workspace area used by the OS7 sound routines. Here are typical examples of the raw sound data:
DATA_SOUND_01H:
DB 0C1H,0FFH,41H,0AH,11H,0E0H,30H,0C3H
DB 90H,0D1H,08H,15H,56H,0F9H,15H,10H
DATA_SOUND_02H:
DB 03H,00H,66H,04H,00H,00H,75H,11H
DB 10H
Again, there's no way to tell from simple inspection of the raw sound data what kind of sound it's supposed to make.
How can you find out what any of these sounds sound like? One possibility would be to write a test program in Z80 assembler, to run on an ADAM under SmartBASIC (by POKEing it into RAM, or maybe BLOADing it from disk and then CALLing it). This was too much work for me...so I thought of an alternative:
If I changed all but the first two vectors in the SOUND_VECTOR_TABLE to the same vector, then played the altered game, then *every* *sound* would be the same sound. If I did this systematically, first all one vector, then all the next vector, etc., eventually I would know what vector actually made what sound. And knowing *that* information would tell me which A argument to PLAY_SOUND_IN_A corresponded to the "Dragnet" death theme. And *that* would narrow down very precisely which code I'd have to study to find the life-decrementing routine.
I used the Norton Utilities Disk Editor to make the changes to a backup copy of PITFALL.ROM. SOUND_VECTOR_TABLE begins at byte offset 36F4 hex, 14068 decimal (0-based). Remember that Z80 addresses are stored in lobyte/hibyte form, so the bytes will be swapped compared to their appearance in the table below. The table lists the absolute value of the vectors in hex, and gives (in the comment field) the corresponding value of A for the PLAY_SOUND_IN_A call, and a description of the sound produced by each vector.
SOUND_VECTOR_TABLE:
DW B716 ; 0 turns off all sounds
DW B71B ; 1 initializes the sound manager
;start changing vectors here
DW B759 ; 2 [bwip!] jumping
DW B760 ; 3 [pfft!] running
DW B767 ; 4 [bwiyiyiyip!] hitting a barrel
DW B76E ; 5 [eeeuuuuublump!] falling down a shaft
DW B775 ; 6 ["Dragnet" theme] death
DW B77C ; 7 ["Tarzan" yell] swinging on a vine
DW B788 ; 8 ["Charge!" theme] getting treasure
DW B795 ; 9 [eeuuwop!] releasing a vine
DW B79D ;10 [tic!] running into a wall
DW B7A5 ;11 [dah-di] gallop; orphaned
Note the "orphaned" sound A=11, which is not used in the actual game. It is a sort of low-pitched, fast gallopping sound. Either it was abandoned, or else it was a temporary sound for use until the other running sound was implemented; there is no way to know from the available evidence.
I encourage the adventurous to repeat my sound-substitution experiment: it's spooky to play the game and have every action result in the same sound over and over and over...
On the basis of my sound experiments, I now knew that any code which said
LD A,06H
CALL PLAY_SOUND_IN_A
was playing the "Dragnet" death theme. Using the search functions of my editor, I located three subroutines which played the "Dragnet" death theme, and a fourth which called one of the first three. I named them (in order of appearance in the code) DEATH_1, DEATH_2, DEATH_4, and DEATH_3, respectively. I'm not sure what DEATH_2 does; I think it handles the case of death when the 20:00 minute game timer runs out. DEATH_4 is called by DEATH_1 and DEATH_3; and looking at DEATH_4, it's what we were hoping to find:
DEATH_4:
LD A,(LIVES_LEFT) ;number of lives remaining
SUB 01H ;one less life
LD (LIVES_LEFT),A ;save back new total
RET NC ;still lives remaining, so keep playing
;
LD HL,D.72AC ;sorry, no lives left
LD (HL),01H ;I
LD IX,I.7236 ; don't
LD HL,C.A2FC ; know
LD (IX+18),L ; what
LD (IX+19),H ; this
RES 5,(IX) ; stuff
RES 0,(IX) ; does
RES 0,(IX+6) ; .
LD A,03H ; .
LD (D.71A0),A ; .
LD A,SOUND_05H ;["Dragnet" theme] death
CALL PLAY_SOUND_IN_A ;play it
;
POP DE ;get rid of old return address
RET ;and exit one level higher
This code isn't exactly the same as that which I hypothesized above that I'd find, but it does what I expected it would. On entry, you have just died. The life counter in (LIVES_LEFT) is decremented and the new value is saved. If any lives are left, you return to the caller, who plays the "Dragnet" death theme and returns to the game. Otherwise, the "Dragnet" death theme is played here, then the old return address is popped off the stack and you exit one level higher (presumably to a routine which waits for you to press a keypad button to begin a new game).
Since the "you-still-have-lives-left" exit condition is NC after the SUB, the entry value of (LIVES_LEFT) which causes the NC to fail is *0*, not 1. (1-1=0 no carry flag; 0-1=255 carry set.) This suggests that the initial value of (LIVES_LEFT) at the start of the game is *2*, not 3 (as it would be if NZ were the test condition):
death NZ test NC test
1 3-1=2 NZ 2-1=1 NC ;2 lives left
2 2-1=1 NZ 1-1=0 NC ;1 life left
3 1-1=0 Z 0-1=255 C ;game over
Sure enough, in one of the subroutines called during game initialization, is found the following code:
LD A,02H ;initial number of extra lives
LD (LIVES_LEFT),A ;save it
Based upon the code in DEATH_4, the best way to patch Pitfall to add "immortality" is to change SUB 01H to SUB 00H. If (LIVES_LEFT) is never decremented, the game will never end; after each death, you'll get another life. This patch has the additional merit of being only one byte, an 01H to an 00H.
I again used the Norton Utilities Disk Editor to make this patch (byte offset 09F8 hex, 2552 decimal (0-based)). I'm happy to state that this patched version of Pitfall does indeed render you "immortal"--when you die, another man just drops down from the upper left corner of the screen, and off you go.
When I told Herman Mason (aa337@po.cwru.edu) about this entire patching endeavor, his only comment was, "You should have done it for Burger Time!" Fortunately for Herman, the hacking principles described here are applicable to Burger Time or any other ColecoVision game.
There are a number of interesting fossils and features in Pitfall. I have noted some of them in my final disassembly listing, PITFALL.ASM. I encourage the interested to take a look. I may talk about some of these discoveries in a future TWWMCA article.
pitfall2.zip, regenerated source for Pitfall (revised)
See you next week!
*Rich*
Next Article
Previous Article
TWWMCA Archive Main Page