In Part 1, we used Dolphin’s debugger to find the location of Player 1’s score in RAM. In this article, our goal is to hack the score calculation routine so that the bonus goals do not award so many points.

Levels in Super Monkey Ball can have up to three separate exits: the normal Blue exit, a Green exit, and a Red exit. If you manage to get to a Green or Red exit, the game will let you skip a number of upcoming levels (e.g. skip the next 3 levels). In addition, it multiplies the score for completing the level by the number of levels skipped (e.g. you gain 3 times as many points as you would have with the normal exit). This is a totally bonkers amount of points, especially for levels which you can complete in only a few seconds and so also get the time bonus multiplier. In the below screenshot, the normal Blue exit would have awarded 3,386 points. Instead the game awards 130,158 points because I completed the level with the Red exit! There is some fun in a risk/reward calculation, but not when it’s this lopsided!

It really incentivizes you to skip levels, which means you play less of the game, which… sucks. If we eliminate the level skip bonus entirely, then it becomes a more interesting trade-off. You can either skip a few levels and so be less likely to lose lives but lose out on score, or you can choose to risk completing those levels and gain all the points from those levels you would have skipped. It’s a much more interesting mechanic than always choosing the Red exit to get that massive score dump.

So let’s start by finding the code that alters your score upon level completion. As in part 1, we set a memory watch breakpoint on Player 1’s score, then complete the level. As the score tally displays, Dolphin breaks and we have our score routine.

There’s a couple interesting things to notice in this screenshot. First, you can see the current instruction is storing the updated score into our player’s struct, which is what we expected/hoped to find. Just before that is where it adds the new points into the existing score. You can see in r4 and r5 are the new score value to be added and the player’s current score, respectively. So this really looks like the routine we wanted to find. Finally, notice the LR value, which is where we jumped from to get into this routine. Let’s head to that code and see what called this routine. More specifically, we want to know where the beginning of this routine is, since we’ve paused execution somewhere in the middle of it.

That tells us where the score calculation routine actually begins. Let’s go see what happens there at the start of this function.

Well that… sure is some PowerPC code. I can read assembly if I have to, but that looks like a real chore to try to understand. Notice the start of the routine is something like 500 bytes away from where the actual store happens. I don’t want to try to understand that much assembly.

Well luckily, just a few years ago the NSA (yes, that NSA) made their internal software reverse-engineering (SRE) tool Ghidra public. Among many other things, it is capable of disassembling binaries into C source. I figured it probably was capable of handling PPC binaries (it is), but I wondered if anyone had specifically tried it with a GameCube binary. Turns out somebody had: I found a blog post about using a Ghidra plugin for the GameCube’s PPC variant to decompile Harvest Moon. I also found a Ghidra plugin specifically for loading GameCube binaries dumped from ISOs. It’s pretty painless to install. Once that’s done, you can use Dolphin to dump the game’s executable to disk, to load into Ghidra. Extract the game’s system data using Dolphin, and it will dump some binaries into a directory.

The one we’re interested in is main.dol, which is the game’s main executable. Loading that into Ghidra will prompt you to analyze the binary. On my machine, that only took about a minute to complete. Now I was ready to see if Ghidra could turn that PPC code into readable C code. So I jumped to the start of the score routine and…

It worked! Without any symbol names, the code is hard to read, but it’s legible enough. This was so exciting to see!! I’ve been wanting to hack this score code for at least a decade. And look at those if-statements! They’re comparing something against ‘G‘ and ‘R‘. Green and Red exits, perhaps? I couldn’t wait to figure out what this code was doing.

I’ve inserted the code in text format here, with some interesting lines highlighted for discussion below.


void FUN_8004c7d4(int param_1,int param_2)

{
  char cVar1;
  int iVar2;
  int iVar3;
  uint uVar4;
  uint uVar5;
  int iVar6;
  undefined1 *puVar7;
  int iVar8;
  int iVar9;
  int iVar10;
  char *pcVar11;
  
  iVar2 = DAT_802f1f18;
  iVar8 = DAT_801eec4c;
  if ((param_1 < 4) && (1 < param_1)) {
    iVar6 = (int)DAT_801f3a7a;
    iVar9 = *(int *)(*(int *)(DAT_802f1f30 + 0xc) + 0x40);
    iVar3 = (DAT_801f3a74 * 100) / 0x3c + (DAT_801f3a74 * 100 >> 0x1f); //[3]
    iVar3 = iVar3 - (iVar3 >> 0x1f); //[3]
    uVar5 = 0;
    iVar10 = iVar9;
    if (((DAT_801f3a64._0_2_ != 0) && (iVar10 = iVar9 + 0x14, DAT_801f3a64._0_2_ != 1)) &&
       (iVar10 = iVar9 + 0x28, DAT_801f3a64._0_2_ != 2)) {
      iVar10 = iVar9 + 0x3c;
    }
    if (*(char *)(iVar10 + 0x12) == 'G') {
      uVar5 = 4; //[4]
      iVar3 = iVar3 + 10000;
    }
    else {
      if (*(char *)(iVar10 + 0x12) == 'R') {
        uVar5 = 8; //[4]
        iVar3 = iVar3 + 20000;
      }
    }
    if (iVar6 != 1) {
      uVar5 = uVar5 | 2; //[4]
    }
    if (DAT_801f3a5e >> 1 < DAT_801f3a5c) {
      uVar5 = uVar5 | 1; //[4]
      iVar6 = iVar6 << 1;
    }
    uVar4 = iVar3 * iVar6; //[2]
    if (param_1 == 3) {
      FUN_8007f368(iVar3,uVar4,uVar5);
    }
    else {
      if (DAT_801eec48 == 1) {
        puVar7 = &DAT_80205e60;
        iVar8 = 0;
        pcVar11 = DAT_80205994;
        while (iVar8 < DAT_80205990) {
          if (*pcVar11 != '\0') {
            cVar1 = puVar7[0x2f];
            if (cVar1 == '\x02') {
              *(uint *)(puVar7 + 0x7c) =
                   *(int *)(puVar7 + 0x7c) +
                   ((int)uVar4 >> 1) + (uint)((int)uVar4 < 0 && (uVar4 & 1) != 0);
            }
            else {
              if (cVar1 < '\x02') {
                if ('\0' < cVar1) {
                  *(uint *)(puVar7 + 0x7c) = *(int *)(puVar7 + 0x7c) + uVar4;
                }
              }
              else {
                if (cVar1 < '\x04') {
                  *(int *)(puVar7 + 0x7c) = *(int *)(puVar7 + 0x7c) + (int)uVar4 / 3;
                }
              }
            }
            if (999999999 < *(int *)(puVar7 + 0x7c)) {
              *(undefined4 *)(puVar7 + 0x7c) = 999999999;
            }
          }
          iVar8 = iVar8 + 1;
          puVar7 = puVar7 + 0x1a4;
          pcVar11 = pcVar11 + 1;
        }
      }
      else {
        if (DAT_801eec48 < 1) {
          if (-1 < DAT_801eec48) {
            (&DAT_80205edc)[DAT_801eec4c * 0x69] = (&DAT_80205edc)[DAT_801eec4c * 0x69] + uVar4; //[1]
            if ((int)(&DAT_80205edc)[iVar8 * 0x69] < 1000000000) {
              return;
            }
            (&DAT_80205edc)[iVar8 * 0x69] = 999999999;
            return;
          }
        }
        else {
          if (DAT_801eec48 < 3) {
            *(uint *)(DAT_802f1f18 + 0x7c) = *(int *)(DAT_802f1f18 + 0x7c) + uVar4;
            if (999999999 < *(int *)(iVar2 + 0x7c)) {
              *(undefined4 *)(iVar2 + 0x7c) = 999999999;
            }
            if (*(int *)(iVar2 + 0x7c) <= DAT_802f1cac) {
              return;
            }
            if (0 < DAT_802f1cac) {
              DAT_802f1ca8 = 1;
            }
            DAT_802f1cac = *(undefined4 *)(iVar2 + 0x7c);
            return;
          }
        }
        *(uint *)(DAT_802f1f18 + 0x7c) = *(int *)(DAT_802f1f18 + 0x7c) + uVar4;
        if (999999999 < *(int *)(iVar2 + 0x7c)) {
          *(undefined4 *)(iVar2 + 0x7c) = 999999999;
        }
      }
    }
  }
  else {
    *(int *)(DAT_802f1f18 + 0x7c) = *(int *)(DAT_802f1f18 + 0x7c) + param_2;
    if (999999999 < *(int *)(iVar2 + 0x7c)) {
      *(undefined4 *)(iVar2 + 0x7c) = 999999999;
    }
  }
  return;
}

Even just looking at the raw code, you can see some interesting stuff. At [1] is our current PC, that stw instruction which we broke on back at the start of the article. You can see it’s adding the value of uVar4 into the player’s score location. Scrolling up to [2], you see where uVar4 is calculated, as the product of iVar3 and iVar6. I bet one of those is the base score, and the other is the bonus multiplier. Going further up to [3], you’ll see iVar3 does indeed seem to be derived from some global value. I bet that global is the current time, and these calculations are converting the internal time representation into points and maybe doing some rounding on the result. Looking around for more interesting stuff, the lines marked with [4] look a lot like bit flags. From the code flow, I bet that’s used for deciding which parts of the score calculation to display on screen when tallying the score.

So with some of those guesses, I used Ghidra to rename some of the local variables to be more descriptive. Here’s what I ended up with for everything that goes into calculating uVar4 (I trimmed all the later stuff).

  iVar2 = DAT_802f1f18;
  iVar4 = DAT_801eec4c;
  if ((param_1 < 4) && (1 < param_1)) {
    score_multiplier = (int)DAT_801f3a7a; //[4]
    iVar5 = *(int *)(*(int *)(DAT_802f1f30 + 0xc) + 0x40);
    time_score = (DAT_801f3a74 * 100) / 0x3c + (DAT_801f3a74 * 100 >> 0x1f);
    time_score = time_score - (time_score >> 0x1f);
    out_flags = 0;
    exit_data = iVar5;
    if (((DAT_801f3a64._0_2_ != 0) && (exit_data = iVar5 + 0x14, DAT_801f3a64._0_2_ != 1)) &&
       (exit_data = iVar5 + 0x28, DAT_801f3a64._0_2_ != 2)) {
      exit_data = iVar5 + 0x3c;
    }
    if (*(char *)(exit_data + 0x12) == 'G') {
      out_flags = 4;
      time_score = time_score + 10000; //[1]
    }
    else {
      if (*(char *)(exit_data + 0x12) == 'R') {
        out_flags = 8;
        time_score = time_score + 20000; //[1]
      }
    }
    if (score_multiplier != 1) {
      out_flags = out_flags | 2;
    }
    if (DAT_801f3a5e >> 1 < DAT_801f3a5c) { //[2]
      out_flags = out_flags | 1;
      score_multiplier = score_multiplier << 1; //[2]
    }
    score_accum = time_score * score_multiplier;
    if (param_1 == 3) {
      FUN_8007f368(time_score,score_accum,out_flags); //[3]
    }

Now it’s quite readable. One thing really surprised me at the lines marked [1]. I didn’t know the game gives an additional score bonus, pre-multiplier, for achieving the Green and Red goals. I’ve played this game hundreds of times over two decades and had no idea that happened. You can even see it in the screenshot at the top of this article! The score ought to have been 1693, but it was 21693, which was then multiplied by six, giving an additional 120,000 points on top of the already crazy huge bonus! (Perhaps this is the actual source of the problem…?)

A little further, at [2], you can see the par time bonus calculation. If you beat the level in less than half of the allotted time, it gives you a x2 multiplier. That logic is all very clearly spelled out right there (bitshifting left or right by one is the same as multiplying or dividing by two). One last interesting thing is at [3]. The called function is indeed the score display routine, and it’s the only place where out_flags is used. This tells us we also need to avoid setting those flags when we eliminate those bonuses, so we don’t render them when tallying the score.

Now that we understand how this function works, we can figure out how to hack it to do what we want. Our goal is to eliminate the score bonus and multipliers for the Green and Red exits. I’m not an expert at assembly hacking, so I wanted to make the smallest changes possible. Looking at our annotated C code, we can get rid of the bonus by simply changing the hard-coded 10000 and 20000 values each to 0. For the multiplier, notice that the initial multiplier value is loaded from some global at [4], which presumably contains the number of levels to skip. Instead of doing that, we could just hard-code it to 1, as it would be for the normal Blue exit.

Another really cool feature in Ghidra is that you can highlight the lines of C code on the right pane, and it will show you the assembly that corresponds to those lines of code. Using that, we can easily find where those hard-coded values are in the game’s code.

As an aside, I’ve found PPC hacking to be much more pleasant than the little bit of 6502 and x86 hacking I’ve done. PPC is big-endian, so it’s easy to read and write the raw bytes. Also every instruction is exactly four bytes long, so you don’t have to fret about adding NOPs or starting your decode in the middle of an instruction. Super nice to work with.

Anyway, I’ll give an example of how I hacked the score multiplier, as it’s the most complicated task. Here’s the original code, which corresponds to the score_multiplier assignment line above at [4].

8004c814  a8 e6 00 22  lha r7,0x22(r6)

Instead of loading the value from memory, we want to just load the hard-coded value 1 into r7. I studied some PPC docs and found this is what we want instead:

8004c814  38 e0 00 01  li r7,1

The other changes were all simpler, just altering the hard-coded values. In total, here’s the patch to apply all the changes we want, with offsets into the original ISO file:

Eliminate stage skip score bonuses:
offs     | was       | now       | purpose
00060734 | a8e6 0022 | 38e0 0001 | eliminate stage skip bonus
00060790 | 60a5 0004 | 60a5 0000 | don't display green exit bonus multiplier
000607a0 | 3884 2710 | 3884 0000 | eliminate green exit bonus points
000607b0 | 60a5 0008 | 60a5 0000 | don't display red exit bonus multiplier
000607b4 | 3884 4e20 | 3884 0000 | eliminate red exit bonus points

Now to test it out…

Perfect. The level skip still functions, and so does the time bonus multiplier, but the Red exit multiplier and bonus are gone. Even if my friend and I decide this isn’t the right solution to this problem, just understanding how this routine works is a huge step forward for finding the solution we want.

We have some other ideas for more enhancements: setting custom par times per level (the current par times are a bit uneven); saving completion times and/or high scores per level, in a viewable table; a level skip button combination; awarding bonus points for finishing the game with extra lives. But that’s all quite a bit more complicated than just fiddling with a couple bits in a simple routine, so we’ll see.