Making my Mega Man 2 ROM Hack

February 3, 2024 (Last modified Sun Feb 4 00:24 -0500)

Introduction

A couple years back I decided to try my hand at making a romhack, something I’ve always had a passive interest in. I had a cursory knowledge of assembly code from college going in, but the complexity of romhacking always felt a bit too intimidating for me. Naturally, for a newcomer like me the best approach was to take a simple, easily digestible game to begin with.

I did not do that. Instead, I decided to look at Mega Man 2, a fairly involved game that’s been a part of my life almost as long as I can remember. I figured that a good starting point would be to implement a feature from later Mega Man games that the NES run of games always sorely lacked, the Energy Balancer.

An example of the Energy Balancer in Mega Man X.

In the GIF above, you can see how it works in Mega Man X. If you pick up an energy refill while your default weapon is equipped, it automatically sends that refill towards the least-filled weapon you have unlocked, without making the player wait for the bar to refill. (The same happens if you pick up an energy refill while using a non-default weapon with full energy).

In the NES games, however, the energy refill is simply wasted if the weapon currently equipped does not need it. So, I decided to change that.

As I said I’m by no means an expert on this stuff, so this isn’t going to be an extremely deep look at the game. If you want more informative breakdowns of game programming, I’d highly recommend Retro Game Mechanics Explained and Behind the Code, two of my favorite YouTube series and a good part of my inspiration for this project as a whole.


Research

Now that I had my goal in mind, it was time to start researching the game. Admittedly a good amount of my knowledge was based on sources like the Data Crystal page on Mega Man 2, as well as a healthy amount of my own research.

The function which handles the logic for collecting items exists at $382E5, and looks something like this (labels and comments added by me):

CollectItem:
$82D5   SEC
$82D6   LDA $AD                           ;Get item ID
$82D8   SBC #$76                          ;Subtract offset of #$76 from item ID
$82DA   TAY                               ;Transfer item ID to Y for indexing
$82DB   LDA #$00
$82DD   STA $AD                           ;Clear out collected item address
$82DF   LDA ItemSubroutineAddressesLow,Y  ;ItemSubroutineAddressesLow = $84DC
$82E2   STA $08
$82E4   LDA ItemSubroutineAddressesHigh,Y ;ItemSubroutineAddressesHigh = 8435
$82E7   STA $09
$82E9   JMP ($0008)                       ;Execute subroutine for item

The ID of the collected item is found at address $AD, and 0x76 is subtracted to convert the ID into an offset for a pair of address lookup tables. After getting the ID, we clear out $AD again by writing 0 to it.

Then we look up a 2-byte address from the tables ItemSubroutineAddressesLow and ItemSubroutineAddressesHigh, found at $384EC and $384F5 respectively.

ItemSubroutineAddressesLow:  EC F0 27 2B 6F 7D 8B 23 A3
ItemSubroutineAddressesHigh: 82 82 83 83 83 83 83 84 84

Those addresses are then stored at $08 and $09, and then we jump to the appropriate statement with the command JMP ($0008).

This is the code which handles the two ammo refill items:

CollectItemAmmoLarge:
$8327   LDA #$0A                 ;Large pickups restore 10 points
$8329   BNE CollectItemAmmo

CollectItemAmmoSmall:
$832B   LDA #$02                 ;Small pickups restore 2 points

CollectItemAmmo:
$832D   STA $FD                  ;Write restore amount to $FD
$832F   LDA EquippedWeapon       ;EquippedWeapon = $A9
$8331   BEQ DoneCollecting       ;Skip if EquippedWeapon == 0 (Mega Buster)
$8333   LDX EquippedWeapon
$8335   LDA UnlockedItems,X      ;Get current ammo for weapon (UnlockedItems = $9B)
$8337   CMP #$1C                 ;Skip if already full
$8339   BEQ DoneCollecting

$833B   LDA #$07
$833D   STA $AA
$833F   LDX EquippedWeapon
$8341   LDA UnlockedItems,X

RestoreAmmo:
$8343   CMP #$1C                 ;Checking the weapon energy again
$8345   BCS WeaponFull
$8347   LDA FrameCounter         ;FrameCounter = $1C
$8349   AND #$07                 ;Once every 7 frames:
$834B   BNE SkipThisFrame
$834D   DEC $FD                  ;Decrement $FD
$834F   BMI WeaponFull           ;If < 0, we're done

$8351   INC UnlockedItems,X      ;Add 1 to weapon ammo
$8353   LDA #$28
$8355   JSR PlaySound            ;Play item collection sound

SkipThisFrame:
$8358   JSR $CC77
$835B   JSR $C07F
$835E   JMP RestoreAmmo

WeaponFull:
$8361   LDA #$00                 ;Clearing out $FD and $AA.
$8363   STA $FD
$8365   STA $AA
$8367   LDA #$03
$8369   STA $2C                  ;Forcing the player to stop movement.
$836B   JSR $D3A8

DoneCollecting:
$836E   RTS

A few extra notes:

  • $FD seems to be a general purpose counting byte, which is used in a few other places, such as the countdown before movement can begin at the start of a level, transitioning in/out of the pause menu, as well as storing the currently selected option while on the 7pause menu.
  • $AA controls whether or not the player and/or enemies can move. From my observations:
    • If the value is between 1 and 3 inclusive, the player can move and enemies cannot. This value is used when the player activates Flash Stopper. (Incidentally, it also causes enemy explosion animations to become stuck, which is not normally seen since you can’t shoot while Flash Stopper is active.)
    • If the third bit is set to 1 ($AA & 0x4 == 1), the player is unable to move, and enemies are unaffected.
    • The game stores a value of 3 when unpausing. Functionally it seems to have the same effect as storing 1, so I’m not sure what the significance of storing 3 is.
    • At $3CD36 and $3CD7E, the game checks bit 2 (0x2). Again, I’m not sure what the significance is.
  • I’m not sure what the JSRs to $CC77, $C07F, and $D3A8 do, but those are outside the scope of what I’m covering here.

Well, now we know where the code we want to update is! Time to get to work.


Hacking

The code I changed was way too large to fit into the existing space, so I decided to instead clear out a few commands and insert a jump to a new subroutine. I added my new jump just after CollectItemAmmo, which looks like this:

;===OLD CODE===
CollectItemAmmo:
$832D   STA $FD                  ;Write restore amount to $FD
$832F   LDA EquippedWeapon       ;EquippedWeapon = $A9
$8331   BEQ DoneCollecting       ;Skip if EquippedWeapon == 0 (Mega Buster)
$8333   LDX EquippedWeapon
$8335   LDA UnlockedItems,X      ;Get current ammo for weapon (UnlockedItems = $9B)

;===NEW CODE===
CollectItemAmmo:
$832D   STA $FD
$832F   JSR TryRefillImmediate
$8332   BNE DoneCollecting
$8334   NOP
$8335   LDA UnlockedItems,X

Of course, my code still needs to work as expected when the Energy Balancer isn’t used. For that, I just have to ensure that after I run it, X contains the value in EquippedWeapon. I also have to replicate the original branch logic, which exited the subroutine if EquippedWeapon was 0, i.e. the Mega Buster.

And here’s the new subroutine I added, starting at 3F304:

TryRefillImmediate:
$F2F4   PHA                      ;Pushing energy level to stack
$F2F5   LDX EquippedWeapon       ;Check currently equipped weapon
$F2F7   BEQ IsMegaBuster         ;If 0 (mega buster), skip this bit
$F2F9   LDA UnlockedItems,X      ;Check ammo for current weapon
$F2FB   CMP #$1C
$F2FD   BNE IsFullAlready        ;If < max, we can't refill any other weapons, so return.

IsMegaBuster:
$F2FF   LDX #$01
$F301   LDY #$1C
$F303   STY $12                  ;Storing current highest weapon ammo level found
$F305   STX $13                  ;Storing the index of the weapon to refill

NextWeapon:
$F307   LDA UnlockedItems,X      ;For each weapon...
$F309   CMP $12                  ;Check if < current max
$F30B   BPL WeaponWasFull        ;If not, skip
$F30D   STA $12                  ;Updating $12 and $13 to reflect the new candidate for refilling
$F30F   STX $13

WeaponWasFull:
$F311   INX
$F312   CPX #$0C
$F314   BNE NextWeapon           ;Looping until we're out of weapons
$F316   LDX $13
$F318   CPX EquippedWeapon       ;Is the weapon selected to refill currently equipped?
$F31A   BNE RefillWeapon         ;If not, prepare to refill

IsFullAlready:
$F31C   PLA                      ;Retrieving energy level from stack
$F31D   STX $13
$F31F   LDY #$00
$F321   RTS

RefillWeapon:
$F322   STX $13
$F324   LDY #$00
$F326   PLA
$F327   JSR RefillAmmoImmediate  ;In retrospect, this could have just been a JMP command.
$F32A   RTS

RefillAmmoImmediate:
$F32B   LDY UnlockedItems,X      ;Get weapon to refill
$F32D   CPY #$1C
$F32F   BEQ RefillDone           ;If ammo full, abort
$F331   ADC UnlockedItems,X      ;Adding energy to weapon's ammo
$F333   CMP #$1C                 ;Check if weapon is full
$F335   BCC Refill_PlaySound     ;If not, play normal refill sound
$F337   LDA #$1C
$F339   STA UnlockedItems,X      ;Cap ammo to 1C
$F33B   LDA #$37
$F33D   JSR PlaySound            ;Play atomic fire max charge sound (to indicate weapon is full)

RefillDone:
$F340   RTS

Refill_PlaySound:
$F341   STA UnlockedItems,X
$F343   LDA #$28
$F345   JSR PlaySound            ;Play refill sound
$F348   RTS

First, I need to push the size of the energy refill to the stack, since I need to do some other logic using a few different registers.

After that, I check to see if 1. the currently equipped weapon is not the Mega Buster, and 2. if that weapon’s ammo is less than full. If that’s the case, then we can’t fill any other weapons, and so normal refill logic resumes.

Then, the code loops through each of the weapons (8 Robot Master weapons, plus the 3 Items), and keeps a record of the lowest ammo found in $12, and the weapon index in $13.

If a candidate weapon was found, we’ll pull the energy size from the stack (if not, we need to pull that value off the stack anyway so we can jump back to the correct address). In either case we clear the value in $13, add the refill amount to the current weapon’s energy, and store it in that weapon’s ammo address, ensuring it’s capped at 0x1C.

I also added logic here to play a sound effect to indicate the refill just happened: the normal refill sound if it was only a partial refill, or a portion of Atomic Fire’s charge sound if that weapon is now full (in other games, the Energy Balancer has distinct sounds for both states, so I figured it would make sense to do so here as well).

Now that we’re done, X contains the value from EquippedWeapon, the value at $13 has been cleared, the stack is clean, and normal execution can resume once more.


Closing Thoughts

It would have been fun to take a cue from Behind the Code and convert my changes to Game Genie codes, but…

It’ll take more than a few codes to represent all the changes above.

Some of my other changes were quite a bit smaller, so maybe it would be more feasible for things like changing the max number of lives/E-Tanks. In any case, I have a lot more changes to document, so keep an eye out for more devlogs in the future.