Yellow MoonByte order
Thirteen, not twelve: how two swapped bytes changed a whole battle
Chasing a battle that played out differently on the PC port than on the GameCube, down to one table read in the wrong order.
This is the story of a battle that went wrong in the most annoying way possible: nothing crashed, nothing looked broken, and every random seed matched. The fight just… played out differently. And it took a log of every random number the game drew, in two programs at once, to find out why.
The setup
The way we check the port is to play the same inputs through it and through the Dolphin emulator running the real GameCube game, and compare the two frame by frame. A battle is a stern test of that, because battles are full of dice rolls: who goes first, whether a hit lands, which way the camera swoops. To make a fair comparison, we copy the port’s random seeds into Dolphin, so both games start every battle with the same dice.
On Alfonso’s ship, early in the game, there’s a random battle. With every seed matched, the port’s version of that battle took different turns from Dolphin’s, and ran 270 frames longer. Same seed, same inputs, different fight.
Logging every dice roll
Watching the two fights side by side only tells you they differ, not where they start to differ. So I went after the dice instead. If both games roll the same dice in the same order, they make the same decisions. That makes the dice the thing to watch: write down every roll, in both programs, and find the first place the two lists part ways. Everything after that point is just consequences.
The game’s random number generator is a classic linear congruential generator: each
call does state = state * 0x41C64E6D + 0x3039 and hands back part of the result. On
the port, a debugger breakpoint at the end of that function logs the value and who
called it. In Dolphin, a “log only” breakpoint on the function’s return instruction
does the same, at full speed.
First wrong turn: the log had holes. Dolphin’s JIT compiler, which translates GameCube code into PC code on the fly, likes to paste small functions straight into the code that calls them. When it did that, the breakpoint simply never fired. Over the first battle, 338 of 875 calls were missing from Dolphin’s log, and nothing said so.
The fix was to turn that optimisation off (JITFollowBranch=False). But finding out the
hard way made me decide not to trust any log again until I could prove it was
complete. Because each call’s state is just
the previous state pushed through that formula once, we can check every pair of
neighbouring log lines: does stepping this state give the next one? If every pair
checks out, nothing’s missing. With the optimisation off, every pair checked out.
Testing the tool before using it. Before pointing this at the broken battle, I pointed it at two battles I believed were fine: the two at the very start of the game. If my logs disagreed there, the problem would be my method, not the port. They agreed: 877 calls against 874, the same callers and values, frames within 2 of each other. (One call lands 11 frames later in Dolphin, and I still don’t know why. It’s on the list.) A “different camera” I’d spotted in one of those battles turned out to be the same camera cut, one frame apart. So the method worked, and those battles were not the problem.
Second wrong turn: the button presses. The test inputs press buttons at fixed frame numbers, and on Alfonso’s ship the random encounter started 3 frames earlier in Dolphin than in the port. So every press landed at a slightly different moment in the battle. Pressing A skips the battle’s intro countdown, so the port’s battle ran 3 frames ahead. Shifting Dolphin’s presses by the same 3 frames lined the two runs up again.
Call number 163
With clean logs and lined-up inputs, the two streams of random numbers agreed, value for value and caller for caller, all the way to call #163, 322 frames after the seed. There, the port made one extra call that Dolphin didn’t.
The extra call came from the battle camera. When nothing more specific is going on, the overview camera picks a random height to look from. In Dolphin, a special camera move (the game’s camera mode 11) was running at that moment instead, so the overview camera never rolled its dice. One extra roll on the port’s side, and from then on every later roll went to the wrong consumer: different hits, different turns, a longer fight.
So why did Dolphin’s game run camera mode 11 and the port’s didn’t? That camera move is asked for when a fighter is doing a particular kind of reaction. Forty frames earlier, enemy 3 had reacted to an attack. In Dolphin it took reaction 0x0D, a counter attack, which brings the camera in. In the port it took reaction 0x0C, a plain attack, which doesn’t.
The random roll behind that choice was identical on both sides. The table it picked from was not.
Thirteen, not twelve
The function that chooses a reaction (fn_8002EB4C) has two candidate moves stored in
a table, as two 16-bit numbers side by side: 0x000D, then 0x000C. The decompiled code
copies those four bytes into a 32-bit variable and then reads the first 16-bit half
through a pointer.
On the GameCube that’s perfectly fine. Its PowerPC CPU is big-endian: a number’s most significant byte comes first in memory. The first half of that 32-bit word is the first number in the table, 0x0D.
A PC (and an Apple Silicon Mac) is little-endian: the least significant byte comes first. Read the first half of the very same 32-bit word on the PC and you get the other number, 0x0C. So a roll of 0 meant “counter attack” on the GameCube and “plain attack” on the PC. One swapped pair of bytes, and the whole battle went another way.
The fix was small: the port’s version of that function declares the table as what it really is, two 16-bit numbers, so each one is loaded and read in the right order. After that, all 1,138 random calls in the battle matched Dolphin’s, caller for caller and value for value, and the battle ended on the same frame.
The bug had friends
A value stored at one width and read back at another is the kind of bug that never travels alone. Once we knew the shape, we went looking for it across the whole game and found seven more, then added an automatic check to the port’s build that flags this pattern wherever it appears, so the next one gets caught before anyone has to log a thousand dice rolls to find it.
My favourite part of this one is how far the effect travelled from its cause. The symptom was “the camera is different and the fight is longer”. The cause was a single choice between a counter attack and a plain attack, 40 frames and one camera decision earlier, decided by which end of a 32-bit word the CPU reads first.