Here’s a summary of a quick and fun weekend project. For tl:dr people : Download the IPS file and run it together with your School Fighter ROM dump.
@inobscurity wanted to try the unlicensed HK game School Fighter for the GBC, but the ROM dump would hang before the first level. I had just set up my new workspace, so I decided to a peek into the situation.
Upon pressing start / passing the attract mode, the screen is stuck on a white screen, but it doesn’t show any symptom of crashing (no warnings, etc). A quick trip to our GBC debugger of choice shows the culprit:
ROM3:4045 21 62 41 ld hl,4162 // load HL with 4162 (...) // below this is the part we are interested ROM3:4058 21 0A C6 ld hl,C60A // load HL with C60A ROM3:405B CB 34 swap h // turn it into 6C0A ROM3:405D 7C ld a,h // A = 6C ROM3:405E E6 F0 and a,F0 // A = 60 ROM3:4060 67 ld h,a // HL = 600A ROM3:4061 CB 35 swap l // turn HL into 60A0 ROM3:4063 7D ld a,l // A = A0 ROM3:4064 E6 F0 and a,F0 // A = A0 ROM3:4066 D6 20 sub a,20 // A - 20 = 80 ROM3:4068 6F ld l,a // HL = 6080 ROM3:4069 3E 65 ld a,65 // A = 65 ROM3:406B 22 ldi (hl),a // "store" 65 in 6080 (HL increases by 1) ROM3:406C 2D dec l // HL - 1= 6080 ROM3:406D C6 3B add a,3B // A = 65 + 3B = A0 ROM3:406F 67 ld h,a // HL = A080 ROM3:4070 2A ldi a,(hl) // load A from A080 ROM3:4071 E6 0F and a,0F // A AND 0F ROM3:4073 FE 05 cp a,05 // if A != 05... ROM3:4075 20 CE jr nz,4045 // ...loop to 4045
You can see that the code (after some obfuscation) attempts to write to the memory position 0x6080, and then to read data from 0xA080. These values are uncommon for two reasons:
- 0x6080 is a location in ROM, so writing there “does nothing”
- 0xA080 is a SRAM location, but this cart does not declare SRAM or has a save feature. As such, reading from it is undetermined (but let’s assume it’s 0xFF)
What the code does seem to point is that it’s attempting to write a value that then reading at another location (apparently the same value or at least the last 4 bits of it). Leaving that for now, this results in a infinite loop because A != 0x05. We assume this loop can safely be removed as there is no reason to call the “game related” part of the loop more than once. Sure enough, if we skip that jump instruction we get into the actual game. Or maybe not. Once you move the character forward the game then engages in a bit of voluntary resetting. Let’s have look at what is happening before the reset:
ROM0:09F4 21 8E 6F ld hl,6F8E // load HL with 6F8E ROM0:09F7 7D ld a,l // A = 8E ROM0:09F8 E6 F0 and a,F0 // A AND F0 = 80 ROM0:09FA 6F ld l,a // HL= 4180 ROM0:09FB 7C ld a,h // A = 41 ROM0:09FC 3E 60 ld a,60 // A = 60 ROM0:09FE 67 ld h,a // HL = 6080 ROM0:09FF 3E 62 ld a,62 // A = 62 ROM0:0A01 77 ld (hl),a // "store" 65 in 6080 ROM0:0A02 26 A0 ld h,A0 // HL = A080 ROM0:0A04 2A ldi a,(hl) // load A from A080 (increase HL) ROM0:0A05 E6 0F and a,0F // A AND 0F ROM0:0A07 C6 12 add a,12 // A = A + 12 ROM0:0A09 00 nop ROM0:0A0A 00 nop ROM0:0A0B CD CC 00 call 00CC // subroutine call
Our old friend 0x6080 is back, but no loop here. This is where things move into copy protection antics. Let’s check that call 00cc – it’s rather simple:
ROM0:00CC F5 push af ROM0:00CD F6 00 or a,00 ROM0:00CF EA 80 20 ld (2080),a ROM0:00D2 F1 pop af ROM0:00D3 C9 ret
It’s a pretty humdrum code: an OR opcode to clear flags, and then a write to 0x2080. Writing to the address 0x20XX is special to the GameBoy hardware – it signals what part of the ROM (split in “pages”) is visible on the 0x4000 – 0x7FFF memory portion. So this will set the page to whatever the value is returned from.
Here’s what is interesting: going back to the code, the value of A is read from 0xA080. Then that value suffers some operations and is used to set the page. As stated before, reading from that memory position is undefined (let’s say 0xFF). So the page is set to 0x0F + 0x12 = 0x21 (this is actually page 0x01 because of gameboy hardware minutia). A bit ahead, there is a read from a 0x4000 memory position:
(BC = 0x04 but it's a very long sequence so it's skipped) ROM0:0A3A 21 00 40 ld hl,4000 // HL = 4000 ROM0:0A3D 09 add hl,bc // HL = HL + BC = 4004 ROM0:0A3E 2A ldi a,(hl) // load A from 4004 (HL increases) ROM0:0A3F 66 ld h,(hl) // load H from 4005 ROM0:0A40 6F ld l,a // L = A ROM0:0A41 11 46 0A ld de,0A46 // DE = 0A46 ROM0:0A44 D5 push de // store DE in the stack ROM0:0A45 E9 jp hl // jump code to whatever value we loaded from 4004 ROM0:0A46 E1 pop hl // HL = 0A46
Without any modification the jump here goes to 0xFFFF, an invalid address that triggers a reset of the cart. The possibility of skipping the jump like before seems like a good one, but by having the wrong page we also misplaced half of the game code, and the game will crash for some other reason.
To solve this we have to look again to the first loop. The intention is to check if 0xA080 holds a value ended in 5 if you write 0x65. So let’s say that the cart has some “memory” that stores that value. We alter the code so that the game stores and retrieves the value in Game Boy memory – the WRAM value 0xCB80 was not in use, so let’s go with it:
ROM3:4058 21 0A CA ld hl,BC0A // load HL with BC0A ROM3:405B CB 34 swap h // turn it into CB0A ROM3:405D 7C ld a,h // A = CB ROM3:405E E6 FF and a,FF // A = CB ROM3:4060 67 ld h,a // HL = CB0A ROM3:4061 CB 35 swap l // turn HL into CBA0 ROM3:4063 7D ld a,l // A = A0 ROM3:4064 E6 F0 and a,F0 // A = A0 ROM3:4066 D6 20 sub a,20 // A - 20 = 80 ROM3:4068 6F ld l,a // HL = 6080 ROM3:4069 3E 65 ld a,65 // A = 65 ROM3:406B 22 ldi (hl),a // "store" 65 in 6080 (HL increases by 1) ROM3:406C 2D dec l // HL - 1 = 6080 ROM3:406D C6 3B add a,66 // A = 65 + 66 = CB ROM3:406F 67 ld h,a // HL = CB80 ROM3:4070 2A ldi a,(hl) // load A from CB80 ROM3:4071 E6 0F and a,0F // A AND 0F ROM3:4073 FE 05 cp a,05 // if A != 05... ROM3:4075 20 CE jr nz,4045 // ...loop to 4045
Since the value is now written in RAM, we can read the value 0x65 and hence no loop happens. So, let’s do the same with the later code:
ROM0:09F4 21 8E 6F ld hl,6F8E // load HL with 6F8E ROM0:09F7 7D ld a,l // A = 8E ROM0:09F8 E6 F0 and a,F0 // A AND F0 = 80 ROM0:09FA 6F ld l,a // HL= 6F80 ROM0:09FB 7C ld a,h // A = 6F ROM0:09FC 3E CB ld a,CB // A = CB ROM0:09FE 67 ld h,a // HL = CB80 ROM0:09FF 3E 62 ld a,62 // A = 62 ROM0:0A01 77 ld (hl),a // "store" 62 in CB80 ROM0:0A02 26 A0 ld h,CB // HL = CB80 ROM0:0A04 2A ldi a,(hl) // load A from CB80 (increase HL) ROM0:0A05 E6 0F and a,0F // A AND 0F ROM0:0A07 C6 12 add a,12 // A = A + 12 ROM0:0A09 00 nop ROM0:0A0A 00 nop ROM0:0A0B CD CC 00 call 00CC // subroutine call
This way, the value 0x62 is saved in that RAM byte, and then loaded back, and 0x02 + 0x12 means we would access page 14. But surprise, the jump is again to the FFFF address! This isn’t the right page at all!
So, we have a non-standard “memory” write that would lock up a Game Boy, a lot of attempts to obscure the actual values written to, and now the value we thought it should retrieve isn’t! Yep, this is a copy protection method. Luckily the way it was made allows us to reverse engineer it too.
As I mentioned, the key part is that the “page” in memory is correct. The code grabs the last 4 bits of a value (so a maximum of 0x0F), then adds 0x12 to it, and this means that there are only 16 possible options (0x12+0x00 to 0x21). The reality is that we can ignore 0x20 and 0x21 (because of reasons mentioned above), so 14 values we can manually check. By changing the page until the game advanced, it was found that the proper call is to page 0x13, and hence the value retrieved should be 0x01. As we already went to the work of writing the value 0x62 to RAM, we can fix it by writing 0x61 instead!
ROM0:09FF 3E 61 ld a,61 // A = 61
Phew! This lets us move ahead to the fighting and hitting people part, but there is yet another loop hidden (triggered once you are knocked out). By this point, the author was much less inventive:
ROM0:3ABF 26 CB ld h,60 ROM0:3AC1 2E 80 ld l,80 // HL = 6080 ROM0:3AC3 3E 4A ld a,4A ROM0:3AC5 77 ld (hl),a // write 4A to 6080 ROM0:3AC6 26 CB ld h,A0 // HL = A080 ROM0:3AC8 2A ldi a,(hl) // read from A080 ROM0:3AC9 E6 0F and a,0F ROM0:3ACB FE 0A cp a,0A // if A != 0 ROM0:3ACD 20 E3 jr nz,3AB2 // loop to 3AB2
Short and to the point. Those are easily fixed by changing the h register writes to CB. This is then repeated in a few more other locations, with some minor changes to the results.
So, first let’s remember this is a unlicensed game, made at low cost and dating back to the start of the XXIst century. It’s unlikely the company building the carts wanted to use a lot of resources doing their carts, but wanted to have _some_ custom hardware to prevent copying. So the copy protection actually adds 5-6 logic gates and a buffer to create a “key” that returns different values to what it’s written for. Given some patience (and if I ever get a machine that runs GBC games), I’ll get back to it and fully reverse engineer it – might be possible that other “bad” dumps from the same developer are use the same sort of protection.