Making my Mega Man 2 ROM Hack, Part 1: The Energy Balancer
February 3, 2024 (Last modified Tue Aug 6 9:18 PM -0400)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.
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
JSR
s 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.