-------------Bubble Bobble------------- A 4am crack 2017-08-23 --------------------------------------- Name: Bubble Bobble Genre: arcade Year: 1988 Credits: Chris Eisnaugle, NovaLogic Publisher: Taito America Media: double-sided 5.25-inch floppy OS: custom ~ Chapter 0 In Which Various Automated Tools Fail In Interesting Ways COPYA no errors on either side, but copy hangs with the drive motor off Locksmith Fast Disk Backup ditto EDD 4 bit copy (no sync, no count) ditto Copy ][+ nibble editor nothing suspicious Disk Fixer T00 is the DOS 3.3 bootloader/RWTS, but T00,S01 code is custom and there's no sign of the rest of DOS on tracks 1 and 2 no disk catalog on track $11 or any other track Why didn't any of my copies work? probably a protection check in early boot Next steps: 1. Trace the boot 2. Disable the protection check 3. Declare victory (*) (*) go to the gym ~ Chapter 1 Boot Trace and Chill Since my non-working copy fails pretty quickly, I suspect I can just trace the boot and find the copy protection check fairly quickly. [S6,D1=original disk (side A)] [S5,D1=my work disk] ]CALL -151 ; copy slot 6 drive firmware to lower ; memory so we can patch it *9600 *B700L B700- A9 00 LDA #$00 B702- 85 F1 STA $F1 B704- A9 14 LDA #$14 B706- 85 F2 STA $F2 B708- A9 00 LDA #$00 B70A- 85 F0 STA $F0 B70C- A5 F0 LDA $F0 B70E- 8D 05 0A STA $0A05 B711- A5 F2 LDA $F2 B713- 8D 04 0A STA $0A04 B716- A9 00 LDA #$00 B718- 8D 08 0A STA $0A08 B71B- A9 98 LDA #$98 B71D- 18 CLC B71E- 65 F1 ADC $F1 B720- 8D 09 0A STA $0A09 ; Not shown, but this constructs the ; rest of an RWTS parameter table at ; $0A00 then calls the standard $B7B5 ; entry point to read a single sector. ; So we're reading T14,S00 into $9800. B723- 20 3E B7 JSR $B73E ; read 6 sectors B726- E6 F1 INC $F1 B728- A5 F1 LDA $F1 B72A- C9 06 CMP #$06 B72C- F0 0D BEQ $B73B ; into consecutive memory ($9900, &c.) B72E- E6 F0 INC $F0 B730- A5 F0 LDA $F0 B732- C9 10 CMP #$10 B734- D0 D6 BNE $B70C B736- E6 F2 INC $F2 B738- 4C 08 B7 JMP $B708 ; jump to the code we just read B73B- 4C 00 98 JMP $9800 Let's capture it. *C500G ... *9600 *9800L 9800- 20 C5 98 JSR $98C5 *98C5L ; slow IIgs to 1 MHz 98C5- AD 36 C0 LDA $C036 98C8- 29 7F AND #$7F 98CA- 8D 36 C0 STA $C036 ; will explore this in a moment 98CD- A9 00 LDA #$00 98CF- 8D 4C 99 STA $994C 98D2- 20 EE 98 JSR $98EE ; check a flag 98D5- AD 4D 99 LDA $994D 98D8- F0 03 BEQ $98DD ; is this failure or success? 98DA- 4C 1A 9C JMP $9C1A ; since we initially did a JSR to get ; here, I'm guessing "exit gracefully" ; is the success path, but we'll see 98DD- 60 RTS My intuition tells me that $9C1A is not a good place to end up. *9C1AL ; text mode 9C1A- AD 51 C0 LDA $C051 9C1D- AD 4C 99 LDA $994C 9C20- 4A LSR 9C21- 4A LSR 9C22- 4A LSR 9C23- 4A LSR 9C24- AA TAX 9C25- BD DE 98 LDA $98DE,X ; put a character in the upper left ; corner of the screen 9C28- 8D 00 04 STA $0400 9C2B- AD 4C 99 LDA $994C 9C2E- 29 0F AND #$0F 9C30- AA TAX 9C31- BD DE 98 LDA $98DE,X ; and another 9C34- 8D 01 04 STA $0401 ; and a space 9C37- A9 A0 LDA #$A0 9C39- 8D 02 04 STA $0402 ; now wipe all of main memory up to ; this routine 9C3C- A9 3B LDA #$3B 9C3E- A2 00 LDX #$00 9C40- 86 50 STX $50 9C42- A2 02 LDX #$02 9C44- 86 51 STX $51 9C46- A0 00 LDY #$00 9C48- A9 A0 LDA #$A0 9C4A- 91 50 STA ($50),Y 9C4C- E6 50 INC $50 9C4E- D0 02 BNE $9C52 9C50- E6 51 INC $51 9C52- A6 51 LDX $51 9C54- E0 9C CPX #$9C 9C56- 90 F0 BCC $9C48 ; this will end up being an infinite ; loop 9C58- C9 13 CMP #$13 9C5A- F0 02 BEQ $9C5E 9C5C- D0 FA BNE $9C58 9C5E- C5 52 CMP $52 9C60- D0 F6 BNE $9C58 I suspect this is where my non-working copy ends up. Let's see what it takes not to end up there. ~ Chapter 2 Self-Modifying Is Best Modifying Whatever is setting the flag at $994D, it's happening inside the routine at $98EE. *98EEL ; seek to track $21 (not shown) 98EE- A9 21 LDA #$21 98F0- 8D 04 0A STA $0A04 98F3- 20 86 98 JSR $9886 ; clear a few bytes of memory, ; including the one that the caller ; checks ($994D) 98F6- A9 00 LDA #$00 98F8- 8D 4E 99 STA $994E 98FB- 8D 4D 99 STA $994D 98FE- A0 0F LDY #$0F 9900- 99 4F 99 STA $994F,Y 9903- 88 DEY 9904- 10 FA BPL $9900 9906- 20 0A 99 JSR $990A 9909- 60 RTS *990AL 990A- 20 60 99 JSR $9960 *9960L ; clear another $200 bytes of memory 9960- A9 00 LDA #$00 9962- A0 00 LDY #$00 9964- 99 1A 9A STA $9A1A,Y 9967- 99 1A 9B STA $9B1A,Y 996A- C8 INY 996B- D0 F7 BNE $9964 996D- 60 RTS Continuing from $990D... ; increment the all-important flag! ah! 990D- EE 4D 99 INC $994D ; don't know what these are 9910- A9 19 LDA #$19 9912- 8D D0 99 STA $99D0 9915- 8D DE 99 STA $99DE 9918- 20 6E 99 JSR $996E *996EL ; reset data latch (slot is hard-coded ; to slot 6, even though the underlying ; OS would boot from any slot -- very ; common in copy protection code) 996E- A2 60 LDX #$60 9970- DD 8E C0 CMP $C08E,X ; Death Counters? 9973- A9 C0 LDA #$C0 9975- 8D F4 99 STA $99F4 9978- 8D F5 99 STA $99F5 997B- EE F4 99 INC $99F4 997E- D0 08 BNE $9988 9980- EE F5 99 INC $99F5 9983- D0 03 BNE $9988 ; failure path? 9985- 4C EC 99 JMP $99EC *99ECL ; clear those $200 bytes of memory ; again 99EC- 20 60 99 JSR $9960 ; turn off drive motor (not shown) 99EF- 20 FF 99 JSR $99FF ; set carry and exit 99F2- 38 SEC 99F3- 60 RTS OK, if this subroutine fails, it sets the carry bit and exits. This is the same convention as DOS uses. But I still haven't found any "real" code yet that could constitute a copy protection check. Continuing from $9988... ; match "D5 AA 96" (address prologue) 9988- BD 8C C0 LDA $C08C,X 998B- 10 FB BPL $9988 998D- C9 D5 CMP #$D5 998F- D0 EA BNE $997B 9991- BD 8C C0 LDA $C08C,X 9994- 10 FB BPL $9991 9996- C9 AA CMP #$AA 9998- D0 E1 BNE $997B 999A- BD 8C C0 LDA $C08C,X 999D- 10 FB BPL $999A 999F- C9 96 CMP #$96 99A1- D0 D8 BNE $997B ; fetch and decode the next address ; field value (this is the disk volume) 99A3- 20 05 9A JSR $9A05 ; and again (this is the track) 99A6- 20 05 9A JSR $9A05 99A9- C9 21 CMP #$21 99AB- 8D 4C 99 STA $994C 99AE- D0 CB BNE $997B ; and again (this is the sector) 99B0- 20 05 9A JSR $9A05 ; sector = #$0F 99B3- C9 0F CMP #$0F ; otherwise loop back 99B5- D0 C4 BNE $997B Now we're positioned after the address field of T21,S0F. ; match "D5 AA" 99B7- BD 8C C0 LDA $C08C,X 99BA- 10 FB BPL $99B7 99BC- C9 D5 CMP #$D5 99BE- D0 F7 BNE $99B7 99C0- BD 8C C0 LDA $C08C,X 99C3- 10 FB BPL $99C0 99C5- C9 AA CMP #$AA 99C7- D0 F3 BNE $99BC ; read a set of $100 nibbles 99C9- A0 00 LDY #$00 99CB- BD 8C C0 LDA $C08C,X 99CE- 10 FB BPL $99CB 99D0- 19 1A 9A ORA $9A1A,Y 99D3- 99 1A 9A STA $9A1A,Y 99D6- C8 INY 99D7- D0 F2 BNE $99CB ; read another $100 nibbles, "OR" them ; with the first set 99D9- BD 8C C0 LDA $C08C,X 99DC- 10 FB BPL $99D9 99DE- 19 1A 9B ORA $9B1A,Y 99E1- 99 1A 9B STA $9B1A,Y 99E4- C8 INY 99E5- D0 F2 BNE $99D9 ; turn off drive, clear carry, and exit 99E7- 20 FF 99 JSR $99FF 99EA- 18 CLC 99EB- 60 RTS Oh, hey, wait, hold up, OK, um... let's revisit a little bit of code in the caller that I didn't understand at the time. ``'-.,_,.-'``'-.,_,.='``'-.,_,.-'``'-., ``'-.,_,.-'``'-.,_,.='``'-.,_,.-'``'-., `` ., `` 9910- A9 19 LDA #$19 ., `` 9912- 8D D0 99 STA $99D0 ., `` 9915- 8D DE 99 STA $99DE ., `` ., ``'-.,_,.-'``'-.,_,.='``'-.,_,.-'``'-., ``'-.,_,.-'``'-.,_,.='``'-.,_,.-'``'-., Those memory locations, $99D0 & $99DE, and in the middle of this subroutine. #$19 is the 6502 opcode for "ORA", and those locations were the "ORA" instructions in the loops that read raw nibbles from the disk and stored them at $9A1A and $9B1A. I mean, I still don't know what's going on, but that's interesting, right? ~ Chapter 3 The Impossible Just Takes Longer Popping the stack and continuing from $991B... ; if that subroutine failed (because we ; couldn't find the right sector or ; whatever), give up now 991B- B0 2D BCS $994A ; check a single nibble in the middle ; of the range of raw nibbles we read 991D- AD 75 9B LDA $9B75 9920- C9 99 CMP #$99 ; if it's not a known value, give up 9922- D0 26 BNE $994A ; self-modifying code alert! All of ; those "ORA" opcodes are now "EOR" 9924- A9 59 LDA #$59 9926- 8D D0 99 STA $99D0 9929- 8D DE 99 STA $99DE ; call the same routine again, but now ; with "EOR" 992C- 20 6E 99 JSR $996E For illustration purposes, I'm going to relist the subroutine as it now stands, with two (apparently important) opcodes replaced. *99D0:59 *99DE:59 *99CBL ; read $100 raw nibbles (the same $100 ; as before -- the subroutine has ; already repositioned itself at the ; same point in T21,S0F) 99CB- BD 8C C0 LDA $C08C,X 99CE- 10 FB BPL $99CB ; but now XOR them with the values we ; got before ?!?!? 99D0- 59 1A 9A EOR $9A1A,Y 99D3- 99 1A 9A STA $9A1A,Y 99D6- C8 INY 99D7- D0 F2 BNE $99CB ; read the second set of $100 nibbles 99D9- BD 8C C0 LDA $C08C,X 99DC- 10 FB BPL $99D9 ; again, XOR them with the values we ; got before 99DE- 59 1A 9B EOR $9B1A,Y 99E1- 99 1A 9B STA $9B1A,Y 99E4- C8 INY 99E5- D0 F2 BNE $99D9 This makes no sense at all. We're going out of our way to read a set of nibbles from disk, twice, them XOR'ing the ones we got the second time with the ones we got the first time. But nibbles don't change. Any value XOR itself is always zero. So, like, WTF? Continuing from $992F, after calling this routine with the altered opcodes and -- as far as I can tell -- zeroing out $200 bytes of memory in the most roundabout way possible. 992F- A2 00 LDX #$00 9931- A0 0F LDY #$0F ; look at a subset of the XOR'd-with- ; itself buffer (interesting -- we're ; looking just after the location we ; checked earlier, at $9919) 9933- B9 75 9B LDA $9B75,Y ; if it's zero, skip next instruction 9936- F0 01 BEQ $9939 ; if it's not zero (impossible), ; increment X 9938- E8 INX 9939- 88 DEY 993A- 10 F7 BPL $9933 ; increment one of the bytes we cleared ; way back in the beginning 993C- FE 4F 99 INC $994F,X ; if X < 3, skip the next three lines 993F- E0 03 CPX #$03 9941- 90 07 BCC $994A ; decrement $994D (back to 0) 9943- CE 4D 99 DEC $994D ; increment a different flag 9946- EE 4E 99 INC $994E ; exit to caller either way 9949- 60 RTS 994A- 60 RTS The success path in the caller verifies that $994D is 0. It's incremented from 0 to 1 ($990D), then decremented back to 0 (at $9943), but only if X >= 3. The only way X is >= 3 is if we read 512 nibbles from disk, twice, and 3 or more of them change the second time we read them. This is the key point: the data being read from track $21 is non-repeatable. It's different every time it's read. How is that possible? The value at $9B75 (must be #$99) looks important, but it's not. The important part is what comes after it: nothing. Because that's what is actually on the original disk: nothing. When we say a "zero bit," we really mean "the lack of a magnetic state change." If the Disk II doesn't see a state change in a certain period of time, it calls that a "0". If it does see a change, it calls that a "1". But the drive can only tolerate a lack of state changes for so long -- about as long as it takes for two bits to go by. Fun fact(*): this is why you need to use nibbles as an intermediate on-disk format in the first place. No valid nibble contains more than two zero bits consecutively, when written from most- significant to least-significant bit. (*) not guaranteed, actual fun may vary So what happens when a drive doesn't see a state change after the equivalent of two consecutive zero bits? The drive thinks the disk is weak, and it starts increasing the amplification to try to compensate, looking for a valid signal. But there is no signal. There is no data. There is just a yawning abyss of nothingness. Eventually, the drive gets desperate and amplifies so much that it starts returning random bits based on ambient noise from the disk motor and the magnetism of the Earth. Seriously. Returning random bits doesn't sound very useful for a storage medium, but it's exactly what the developer wanted, and that's exactly what this code is checking for. It's finding and reading the same sequence of nibbles from the disk, twice, and counting how many of the nibbles changed. Bit copiers will never duplicate the long sequence of zero bits, because that's not what they read. Whatever randomness they get when they read the original disk will essentially get "frozen" onto the copy. Those nibbles at $9B75 will always be the same, the first time and second time around, and the XOR of any number with itself is always zero. God, I hate physical objects. ~ Chapter 4 One Byte To Rule Them All Popping the stack all the way back to $98D2, we see this code that calls the copy protection routine and checks a flag afterwards: 98D2- 20 EE 98 JSR $98EE 98D5- AD 4D 99 LDA $994D 98D8- F0 03 BEQ $98DD 98DA- 4C 1A 9C JMP $9C1A 98DD- 60 RTS There is no attempt to obfuscate this code; it's stored on disk exactly as I found it. There are no anti-tamper checks. Despite multiple flags being set and reset, in the end only one of them matters: $994D. Also, one of the first things the real game code does it overwrite all this memory with sprite data, so there's no lingering side effects or delayed protection checks. It's self-contained, unintegrated with the rest of the game. I can change the "JSR $98EE" to "RTS" and bypass the entire thing. T14,S00,$D2: 20 -> 60 Quod erat liberandum. --------------------------------------- A 4am crack No. 1381 ------------------EOF------------------