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.

Log 016 min read

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.

Random number calls logged in the port and in Dolphin. Calls 1 to 162 match. At call 163 the port makes one extra call from the battle camera, and every later call goes to a different consumer. PORTDOLPHIN call #163: the overview camera rolls a die calls 1–162: same value, same caller after: every roll feeds the wrong thing 322 frames after the seed
Both logs, call by call. Up to call #162 they agree on every value and every caller. The port’s extra call at #163 shifts every later roll onto the wrong consumer.

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 32-bit word 0x000D000C in memory. Big-endian stores 00 0D 00 0C, so its first half is 0x000D. Little-endian stores 0C 00 0D 00, so its first half is 0x000C. THE TABLE'S TWO MOVES, COPIED INTO ONE 32-BIT WORD 0x000D000C counter attack, then plain attack GAMECUBE · BIG-ENDIAN 00 0D 00 0C first half read through a 16-bit pointer 0x000D = 13 · counter attack PC AND MAC · LITTLE-ENDIAN 0C 00 0D 00 same pointer, same first half 0x000C = 12 · plain attack memory addresses increase to the right: +0, +1, +2, +3
The same four bytes, read through the same 16-bit pointer, on each kind of CPU.

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.