Before anything, get yourself Mesen 2. It is the most feature-complete and easy to use debugging emulator I know.
A debugger is a tool to see the computer run the code step-by-step, which is useful when you can't understand why some code doesn't work. Seeing what the computer does will fix erroneous assumptions you may have on how the code behaves.
We know that adding the call to deactivateAllEnemies
caused the crash, therefore, the game must crash either while or after deactivateAllEnemies
is called. Let's see how the computer runs code from the start of that routine onward.
First, we need to find the address (location) of the routine. Open the ROM using Mesen and go to “Debug > Memory Viewer”. In the memory viewer, make sure Memory Type is set to “GB - PRG ROM”, then open “Search > Go to Address” and paste in the routine's name. It will move your cursor to the starting byte of the function, and in the bottom-left corner of the memory viewer, you will see the location of the routine in ROM as some large hex number. Take note of this number and close the memory viewer.
Second, we need to create a breakpoint for that address so that the game will be paused upon reaching that routine. Go to “Debug > Debugger” and in the Disassebly settings of the Debugger, change Unidentified to “Disassemble”. You will see that the code in the debugger window is more or less identical to the code in the disassembly. In region $0000-$3FFF of Game Boy memory, bank 0 is always loaded, and in region $4000-$7FFF, one of the other banks is loaded. You can see which bank is loaded in the left half of the little memory diagram in the bottom. To add the breakpoint, you need to right-click on the Breakpoints sub-window in the debugger and choose “Add…”. In the window that appears, choose the Memory type of “GB - PRG ROM” and put the address you took note of in the address field. Click OK and you should see a new breakpoint appear in the Breakpoints sub-window. This breakpoint has a checkbox in the Enabled column that you can use to disable the breakpoint during moments where you don't want it to pause your game.
Normally, we could create a breakpoint simply by pasting the name of the routine into the address field of the new breakpoint window, but alas, Mesen will not accept this if the bank of the routine we want a breakpoint for is not currently loaded into Game Boy memory.
With this breakpoint enabled, start a new game and shoot the missile block with a missile. The breakpoint should now fire and pause your game. Now, using the “Step Into” button right next to the pause button in the top left corner, we can run each ASM instruction one by one, until we find a problem. While you do this, you should look at the Status sub-window to see how the values of the registers change with each instruction.
Here is my interpretation of how the computer runs deactivateAllEnemies
after shooting the block. Step through the code yourself and cross-reference with my interpretation to find where things go wrong.
a
is initialized to $FF, the status value for an enemy slot that is empty, as explained in “docs/Enemy Headers.md”. hl
is initialized to the first element in the enemyDataSlots
table. c
is initialized to ENEMY_SLOT_SIZE
, the size of an element in enemyDataSlots
. d
is initialized to $10, the quantity of elements in enemyDataSlots
.
Now we reach the deactivateAllEnemies.loop_A
. $FF is written to the status of the first enemy slot in enemyDataSlots
. bc
is added to hl
so that hl
now points to the second enemy slot in enemyDataSlots
. d
is decremented by one to update the number of elements left. Then since d
was not zero, we jump back to the start of .loop_A
.
The problem is that b
is not initialised along with c
, so when they are used together as bc
to be added to hl
, hl
leaves the bounds of the enemyDataSlots
table.
In our case, b
contains the value of the missile weapon type that was obtained with enemy_getSamusCollisionResults
, $08. Instead of increasing hl
by $0020 ($C600, $C620, $C640, $C660, $C680, $C6A0, $C6C0, $C6E0, $C700, $C720, $C740, $C760, $C780, $C7A0, $C7C0, $C7E0), we now increase hl
by $0820 ($C600, $CE20(unused wram), $D640(unused wram), $DE60(in mapUpdateBuffer
), $E680(echo ram), $EEA0(echo ram), $F6C0(echo ram), $FEE0(out of bounds oam), $0700(MBC1 SRAM disable), $0F20(MBC1 SRAM disable), $1740(MBC1 SRAM disable), $1F60(MBC1 SRAM disable), $2780(MBC1 bank switch)). The bank where the routine is (bank 2) is switched to bank F from right under the computer's feet. Bank F contains map data, so the computer continues running that map data as if it were the routine's code.
What a catastrophe.
This bug is untriggerable in vanilla Metroid II, because the only routine that calls deactivateAllEnemies
coincidentally happens to have a value of $00 for b
before the call.
This is quite easy to fix. Simply replacing the line ld c, ENEMY_SLOT_SIZE
with ld bc, ENEMY_SLOT_SIZE
will initialize b
properly. Compile and test. It crashes again, but in a different way. If you approach the missile block from the right side of the ship and shoot a missile from under it, the game resets to the title screen. However, if you roll under the ship and approach the missile block from the left side before shooting a missile from under it, the game will not crash. We're making progress. Let's use the debugger again to find out the cause of this latest crash.
Using the same breakpoint as last time, shoot the missile block in such a way that it will crash the game.
We can see that this time, deactivateAllEnemies
is able to complete execution without issue. As such, the enemy AI routine returns back to processEnemies.doneProcessingEnemy
. enemy_moveFromHramToWram
is called next, and also completes without issue. After this call, enemiesLeftToProcess
is decremented from $02 to $01, and processEnemies
goes on to look for the next enemy to process.
The issue is, since we deleted all enemies, there is no next enemy to process. So it leaves the bounds of the enemyDataSlots
table until it finds what it believes to be an enemy at $CB00. Unfortunately, $CB00 is just empty unused space filled with $00. So it does all the things it's supposed to do to an enemy but with blank data, and when it tries to run the blank data's AI routine, it jumps to $0000, which goes to the bootRoutine
, effectively resetting the game.
Challenge: Find a fix to this bug. You are only allowed to modify the missile block's AI routine, preferably by writing code after the call to deactivateAllEnemies
.
Now that you have fixed the bug, there are no more crashes, the POW Block is complete. You can shoot it with a missile, and it should delete the health refill in Samus's gunship, if it was loaded beforehand.