Post

Exploring Spira's PS3 binary with Ghidra

Because you cannot hate chocobos enough

I finished Final Fantasy X more than one month ago, and I still haven’t written about how almost half of my quest didn’t happen in front of my PlayStation 3, but in Ghidra, trying to cheat some of the game’s most difficult trophies…

Cheating is cooler when you learn stuff

It’s okay: Final Fantasy X isn’t really a HARD game, and neither is the remaster that came out in 2013. Some parts of it are obviously going to take a while, but according to PSNProfiles Trophy Guide, it’s rather tame: 110 hours, and a 5 (out of 10) difficulty. So what’s the hard part? An optional super-boss? A gauntlet of fights without healing items? A low-level run? Worse than that: a chocobo race.

A Chocobo is a bipedal animal in the Final Fantasy franchise, kind of a bird that can be used like a horse. In this episode, they are used both as a means of transportation, and racing animals. They are also tied to three trophies:

  • Chocobo License (Bronze): Pass all chocobo training
    Actually, that one isn’t too hard, let’s not think about it.
  • Chocobo Rider (Bronze): Win a race with a catcher chocobo with a total time of 0:0:0
    Okay, just from the description, this one reads like bad news.
  • Chocobo Master (Silver): Get 5 treasure chests during the Chocobo Race at Remiem Temple and win the race
    This one reads easy but of course, there’s a reason it’s Master.

The first one already sounds CRAZY: you must finish a race with a total time of 0:0:0. While it sounds impossible, it can be achieved based on your final time in this race: the race itself takes around 30 seconds, and you can catch balloons that will each subtract 3 seconds from your final time. You can also be hit by hazards that will ADD 3 seconds to your final time. And of course, balloons and hazards are completely random (but will also affect your opponent!).

The second one requires you to follow a VERY precise path on a race track, collect treasures along the way, and just avoid touching the “walls” of the tracks to avoid losing some seconds there. It’s a test of skills, but not one riddled with randomness.

As I was making my way through the game, I got Chocobo License easily, and then tried to get Chocobo Rider. Once, twice,… maybe twenty times. Screw that!

It’s very easy to unlock trophies on the PS3, but I just thought of something else: why not look for a cheat that would set my time to zero, give me a thousand balloons, or make each balloon subtract a thousand seconds?

The state of cheating on the PS3

While there is a very good cheat engine available on the PS3, there is no proper (and multi-platform) solution to find new cheats1. And even though I could find a cheat, it worked only on the US release of the game: I was playing on the Japanese.

But maybe I could analyze it?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Time Chocobo Training
0
Hiei-YYH
0 00769D60 2C090014
0 00769D64 41820008
0 00769D68 48000018
0 00769D6C 3F800C00
0 00769D70 639C00B0
0 00769D74 7C1C2800
0 00769D78 40820008
0 00769D7C 3BE00000
0 00769D80 9BE30000
0 00769D84 4E800020
0 00339A9C 484302C5

First off, that seems like a lot of instructions, which is strange when you’re mostly used to Action Replay-style cheats: usually, after you’ve found where the value you’re interested in is stored in memory, you hook the cheat engine to it, and overwrite it with your value, and prevent further writes to it.

I also remember how my PS3 has crashed A LOT when using cheats, because I was using those similar “always-on” cheats, while “once” cheats, would always work. Maybe the PS3 isn’t powerful enough to run “always-on” memory hooks, but accepts writing arbitrary data straight into the game’s code to alter behavior?

So I asked an LLM for a first pass at what this code was doing. The answer came in the form of ASM, and spat out the following pseudo-C code, with registers:

1
2
3
4
5
6
7
8
9
10
11
12
void hook() {
  if (r9 == 0x14) {
    r28 = 0x0C0000B0;

    if (r5 == r28) {
      r31 = 0;
    }
  }

  *(uint8_t*)r3 = r31;
  return;
}

I’m not sure I get it all yet2. Those rX thingies are registers and there are at least 31 of them (what? where’s my RAX?), but since I’m here already, I might as well dig into the game’s binary.

Spira on my laptop

Two minutes later, I’ve got a 9.4MB file called ffx_phyreApp.self on my laptop. I’ve spent enough time around to understand it’s a “signed ELF” or, well, a SELF. The first part is easy to circumvent, thanks to RPCS3’s “Decrypt PS3 Binaries” to turn it into a ffx_phyreApp.elf file. And guess what? The ELF format is so standard, that any Unix worth its salt can understand it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
$ file ffx_phyreApp.elf
ffx_phyreApp.elf: ELF 64-bit MSB executable, 64-bit PowerPC or cisco 7500, Unspecified or Power ELF V1 ABI, version 1, statically linked, stripped

$ objdump --section-headers ffx_phyreApp.elf
ffx_phyreApp.elf:       file format elf64-powerpc

Sections:
Idx Name          Size     VMA              Type
  0               00000000 0000000000000000
  1               0000002c 0000000000010200 TEXT
  2               007298e0 0000000000010230 TEXT
  3               00000024 0000000000739b10 TEXT
  4               000028e0 0000000000739b34 TEXT
  5               00000188 000000000073c414 DATA
  6               0000051c 000000000073c59c DATA
  7               00000004 000000000073cab8 DATA
  8               00000004 000000000073cabc DATA
  9               00000004 000000000073cac0 DATA
 10               00000420 000000000073cac4 DATA
 11               00000004 000000000073cee4 DATA
 12               00079650 000000000073cef0 DATA
 13               000a4550 00000000007b6580 DATA
 14               00000028 000000000085aad0 DATA
 15               00000040 000000000085aaf8 DATA
 16               000003a4 0000000000860000 DATA
 17               00000304 00000000008603a4 DATA
 18               00000004 00000000008606a8 DATA
 19               00003a34 00000000008606b0 DATA
 20               0000051c 00000000008640e4 DATA
 21               0001d9b8 0000000000864600 DATA
 22               00003868 0000000000881fb8 DATA
 23               00000008 0000000000885820 DATA
 24               000001f0 0000000000885828 BSS
 25               0007f73c 0000000000885a80 DATA
 26               01d26d60 0000000000905200 BSS
 27               00003206 0000000000000000
 28               000004e0 0000000000000000
 29               0000012c 0000000000000000

Oh yeah, it works. My school taught me to use objdump and nm, and it was my first glimpse into the beauty of the System V ABI, so let’s see what else our good old, reliable toolbox can tell…

1
2
$ nm ffx_phyreApp.elf
ffx_phyreApp.elf: no symbols

Oh, fuck me.

Time to bring out the big guns and open Ghidra.

I love this tool and its approach: smart enough to detect straight away what I could have chosen myself. Remember our file output? All there: PowerPC, 64-bit, and MSB Executable3 so Big Endian, which maps to PowerPC:BE:64:default:default.

A few seconds later, the file is imported and I’m faced with a wall of hex. I run “Analyze” on the code a few times, and try to look up stuff that could be my code, like instances of 3 (remember? seconds to add or subtract to my time). Of course, no luck anywhere.

I need to start planning a better approach, because there’s no way I’m reading 10 Megs of assembly.

Pedal to the metal

Since I’ve acquired a PS Vita in Japan4, in 2017, I’ve been hanging around r/vitahacks. Sometimes, I would read the source of stuff published there (mainly that one time someone posted a “trophy_unlocker.vpk” app). The name is a mouthful, but I remember it from the community VitaSDK project: sceNpTrophyUnlockTrophy.

Since both consoles lived around the same time, chances are they both use the same approach. What is the PSL1GHT project telling me? Oh, it's got an FNID definition!

A “function NID” (sometimes called FNID) is the hashed name of a function. In our case, 0x8CEEDD21 is the NID for sceNpTrophyUnlockTrophy. It seems like Sony was one of the few to do that kind of stuff, and it’s got to do with dynamic linking, size reduction, and a bit of Security through obscurity. All we need to know is that I cannot look up sceNpTrophyUnlockTrophy, but I can look up 0x8CEEDD21. One result, perfect.

1
2
3
4
5
6
7
8
9
10
11
12
    XREF[1]:     0073cd40(*)  
0073c858 11 97 b5 2c     undefined4   // sceNpTrophyRegisterContext
0073c85c 1C 25 47 0D     undefined4   // sceNpTrophyCreateHandle
0073c860 37 01 36 FE     undefined4   // sceNpTrophyGetRequiredDiskSpace
0073c864 37 41 EC C7     undefined4   // sceNpTrophyDestroyContext
0073c868 39 56 77 81     undefined4   // sceNpTrophyInit
0073c86c 49 D1 82 17     undefined4   // sceNpTrophyGetGameInfo
0073c870 62 3C D2 DC     undefined4   // sceNpTrophyDestroyHandle
0073c874 8C EE DD 21     undefined4   // sceNpTrophyUnlockTrophy
0073c878 A7 FA BF 4D     undefined4   // sceNpTrophyTerm
0073c87c B3 AC 34 78     undefined4   // sceNpTrophyGetTrophyUnlockState
0073c880 E3 BF 9A 28     undefined4   // sceNpTrophyCreateContext

Can you believe that? I just hit the mother lode! Now let’s check on that XREF5 at 0x0073cd40.

1
2
3
4
5
0073cd38 00 00 00 00     addr
0073cd3c 00 73 c5 00     addr
0073cd40 00 73 c8 58     addr
0073cd44 00 86 44 28     addr
0073cd48 00 00 00 00     addr

Okay, pretty useless. What’s around at 0x0073c500? Another set of NIDs?

1
2
3
4
5
6
7
8
9
10
11
12
0073c500 73              ??         73h    s
0073c501 63              ??         63h    c
0073c502 65              ??         65h    e
0073c503 4e              ??         4Eh    N
0073c504 70              ??         70h    p
0073c505 54              ??         54h    T
0073c506 72              ??         72h    r
0073c507 6f              ??         6Fh    o
0073c508 70              ??         70h    p
0073c509 68              ??         68h    h
0073c50a 79              ??         79h    y
0073c50b 00              ??         00h

Oh. Oh. Oh. That’s a very pretty C String there. Let’s go back to 0x0073cd3c and follow the other path to 0x00864428.

1
2
3
4
5
6
7
8
9
10
11
12
00864428 00 73 ad 34     addr      XREF[2]:     0073ad3C(R), 0073cd44(*)  
0086442c 00 73 ad 54     addr      XREF[1]:     0073ad5c(R)
00864430 00 73 ad 74     addr      XREF[1]:     0073ad7c(R)
00864434 00 73 ad 94     addr      XREF[1]:     0073ad9c(R)
00864438 00 73 ad b4     addr      XREF[1]:     0073adbc(R)  
0086443c 00 73 ad d4     addr      XREF[1]:     0073addc(R)
00864440 00 73 ad f4     addr      XREF[1]:     0073adfc(R)
00864444 00 73 ae 14     addr      XREF[1]:     0073ae1c(R)
00864448 00 73 ae 34     addr      XREF[1]:     0073ae3c(R)  
0086444c 00 73 ae 54     addr      XREF[1]:     0073ae5c(R)
00864450 00 73 ae 74     addr      XREF[1]:     0073ae7c(R)

Now that is..interesting? They all point to something at 0x0073Axx4 and are XREF at 0x0073AxxC?

Let’s check that, plus along the way, Ghidra (and ChatGPT) have been nice enough to help me to spot functions boundaries in assembly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
0073adf4 39 80 00 00     li         r12,0x0
0073adf8 65 8c 00 86     oris       r12,r12,0x86
0073adfc 81 8c 44 40     lwz        r12,offset sceNpTrophy_func_address_table[6](r)
0073ae00 f8 41 00 28     std        r2,local_res28(r1)
0073ae04 80 0c 00 00     lwz        r0,0x0(r12)=>LAB_0073adf4
0073ae08 80 4c 00 04     lwz        r2,0x4(r12)=>LAB_0073adf8
0073ae0c 7c 09 03 a6     mtspr      CTR,r0
0073ae10 4e 80 04 20     bctr

0073ae14 39 80 00 00     li         r12,0x0
0073ae18 65 8c 00 86     oris       r12,r12,0x86
0073ae1c 81 8c 44 44     lwz        r12,offset sceNpTrophy_func_address_table[7](r)
0073ae20 f8 41 00 28     std        r2=>TOC_BASE,local_res28(r1)
0073ae24 80 0c 00 00     lwz        r0,0x0(r12)=>LAB_0073ae14
0073ae28 80 4c 00 04     lwz        r2,0x4(r12)=>LAB_0073ae18
0073ae2c 7c 09 03 a6     mtspr      CTR,r0
0073ae30 4e 80 04 20     bctr

I check once, twice, three times, yell “WTF” a few times, think of a meme like “this code duplication could have been a function argument”, but I’m looking at assembly, and furthermore, it’s game console assembly, so maybe there’s a logic here that eludes me.

I ask my LLM coworker for its opinion: these are import stub / trampoline functions. They are inserted at link time to provide a jump from the game code to the real function implementation. I’m still not entirely sure why this is required: I’ve heard a ton about linking, but zilch about stubs.

Until I remember something else: on the PSP6, the homebrew plugins are often distributed as .prx files, and the PS3 homebrew scene often provides .sprx files. Maybe this isn’t just a homebrew thing, but a console thing, the local equivalents of .dll and .so for dynamically loaded code. Wouldn’t that explain why I’ve only found eleven NIDs related to trophies, and not the full seventeen referenced in the SDK?

I dig a bit further and find the answer: there are indeed a few np_trophy_*.sprx files.

So that explains everything: the binary contains a list of imported NIDs, and a function table. In the static file, those table entries point back to tiny trampoline functions (yes, it’s a loop, I know, it makes me crazy too). At runtime, the loader resolves the requested NIDs from the relevant SPRX modules and replaces those table entries with pointers to the real function descriptors. The stubs then load the real address from there and jump to it.

Me, when I finally understand what the fuck that mess was.

Okay, but seriously, even if I found a trail for trophy unlocking, I’m not really in the mood to annotate all the NIDs one by one, and my limited knowledge of the PS3 might lead me to some very questionable interpretations.

Time to pull more guns.

From the shoulders of giants

I had actually stumbled on the Ps3GhidraScripts repository earlier, but a part of me felt too lazy to even look at it. Which, in hindsight, is a good thing: it saved me from skipping all the discoveries above, and it saved you from missing out on my brilliant prose.

That said.

It will then parse the information sections and define imports/exports and name the ones it can from the nids file, and then set the TOC.

Rinse, repeat. I removed the file from the project, and reimported it. At this point, I realized Ghidra actually had a more fitting language spec for the PS3: PowerPC:BE:64:A2ALT, meaning 64-bit PowerPC, big-endian, with AltiVec support. There is actually an EVEN MORE fitting one, but I won’t understand that it is needed7 before reversing at least a dozen structs. But that will be a problem for future me, let’s run those scripts and see the results.

The stubs are back!

True to its documentation, the script set up the function stubs for the trophies, and for other dynamically-linked modules like sceNpManager and sceNpTus. Completely useless to me. The syscalls are a bit funnier to look at, since most of them got the prototype you would expect if you spent enough time coding in Unix C.

Oh well, who cares? We’re hunting trophies now!

There’s only one function calling sceNpTrophyUnlockTrophy, so let’s follow it and call it UnlockTrophyWrapper. Thanks to the Ghidra disassembly pseudo-C, we can extract a few interesting informations:

1
2
3
4
5
6
7
8
9
10
11
12
13
s32 FFXTrophyPS3Backend::UnlockWrapper(FFXTrophyPS3Backend *t, s32 trophy_id) {
  s32 result;
  sysTrophyId platinum_id = -1;
  
  sys_lwmutex_lock(trophy_mutex, 0);

  result = sceNpTrophyUnlockTrophy(t->context, t->handle, trophy_id,&platinum_id);
  if (result < 0) {
    error_printf(s_Error:_sceNpTrophyUnlockTrophy()_00755a98,result);
  }
  sys_lwmutex_unlock(trophy_mutex);
  return result;
}
  • While sceNpTrophyUnlockTrophy needs a sysTrophyContext, sysTrophyHandle, and a trophy ID, this function takes a single structure, and a trophy ID. So we can know the game passes around a struct of its own (let’s call it FFXTrophyPS3Backend). This also confirms that we’ve moved out of “OS code” and are in “game code”.
  • We’ve found the fprintf equivalent!
  • There are mutexes surrounding the trophy unlock, so the game is handling it and not relying on the system? Funny.
  • Omitted from the sample, the FFXTrophyPS3Backend struct contains a bit map of the already-unlocked trophies? Can’t the system handle that on its own?

That was very interesting. Now let’s see who’s calling that, I’m sure I’ll find a call passing the trophy ID I’m looking for down the chain!

Or not:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void ReceiverThread(FFXTrophyPS3Backend *t) {
  while (true) {
    FFXTrophyEvent event;

    int ret = sys_event_queue_receive(t->eventQueue, &event);
    if (ret != 0) {
      sys_ppu_thread_exit(ret);
    }

    switch (event.command) {
      case UNLOCK_TROPHY:
        UnlockWrapper(t, event.trophy_id);
        break;

      case EXIT:
        sys_ppu_thread_exit(0);
        break;

      default:
        error_printf("Unknown event command");
        break;
    }
  }
}

While I do want to cry (at carefully synchronized intervals, one eye at a time, to be thread-safe), this gives me two new leads:

  • There’s something somewhere sending events to this thread, telling it to unlock trophies.
  • There’s a setup somewhere that sets up this thread loop.

Following the setup isn’t really complicated, except that it spans a lot of functions, since it has to set up the sysTrophyContext and the sysTrophyHandle, plus handle all the related error cases. After around eight functions of setup, I get to the function that spawns the trophy initialization (and would you believe it? It’s another thread!).

That gives me two new insights:

  • The struct FFXTrophyPS3Backend contains a boolean that indicates whether the trophy setup thread was started, let’s call it flagTrophyThreadRegistered.
  • The struct also contains a boolean that, if set to false, will just not launch the trophy setup thread8.

Wait, what.

I check Wikipedia: the PS3 got trophies in 2008, and that remaster came out in 2013. The Switch version, the only achievement-less platform, came out in 2019.

Could this mean that, from the start, the remaster wasn’t a PlayStation-centric port, but was built to support multiple achievement backends? Since the Windows/Steam version came out in 2016, and the Xbox One version in 2019, that feels like both overengineering and careful planning.

Anyway, going further up that path gets me in game engine setup territory, even finding out some localization backend stuff. I don’t need many languages to find out that I suck at chocobo riding, I need my trophies!

Finding our first trophies

Actually, a bit around that time, I had advanced in the game, and while I was running some stuff from my laptop9, I managed to legitimately obtain Chocobo Rider. But things were too much fun to stop now, plus maybe I could leverage all the things I found at some other point.

Ports and queues

So, let’s get back: we know there’s an event queue somewhere, as sys_event_queue_receive told us. Next step is easy, just look up sys_event_queue_send, right? Well, as you can see from the screenshot, not exactly, and that’s where I’m glad that a script had already mapped the syscalls for me, so I could easily find sys_event_port_send.

While it is smart, in hindsight, to send events from multiple sources on a port to receive them from a queue, that’s the kind of API design that makes you understand why Mark Cerny spent the PS4 generation trying to win back the developers.

But still, there are only seven uses of sys_event_port_send, and right at the second one:

1
2
3
4
5
6
7
8
9
10
s32 LAB_001af1e0(undefined4 *param_1, long param_2)
{
  s32 result;
  
  result = sys_event_port_send((char *)param_1 + 0x2c, 0, param_2, 0);
  if (result < 0) {
    error_printf(s_Error_:_sys_event_port_send()_fa_00755884,(long)result);
  }
  return result;
}

This looks very unassuming, and it would be easy to dismiss it, but there’s just something about it that screams at me: “You got it bro!”, and it’s because of assembly and structs.

See, while I’ve written some assembly10 for fun, I’ve never done more than small functions, and never used anything beyond basic types. So I had always wondered: how does assembly deal with struct members?

This reverse-engineering endeavor is where I got the answer: it doesn’t. It just passes the struct’s base address, plus the member’s offset11. And that + 0x2c? I’ve seen it before, when I first defined the FFXTrophyPS3Backend struct (which got split with FFXTrophyContext as its base)!

Of course, it could be another structure with a similar layout, but the five last candidates all use very different offsets (as for the first candidate, it’s a trophy context teardown function). So let’s assume this one is our FFXTrophyPS3Backend::PostEvent function, and move up the two XREFs. For funsies, let’s take the second one, since it’s the one I went with, and my first foray into the game code.

The prototype looks promising: param_1 is obviously our FFXTrophyContext, and param_2 is an ID, then there’s a loop. A loop that only runs five times. The game got 34 trophies, so that cannot be here, but let’s keep digging what happens during those five iterations: first, param_2 is compared to a global g_0075291c + loop variable, and if equal, a trophy event is sent with g_0075291c + loop + 0x4 as the trophy ID. That’s crazy, what the hell is in g_0075291c?

1
2
3
4
5
6
7
8
9
10
0075291c 00 00 01 14
00752920 00 00 00 1f
00752924 00 00 01 58
00752928 00 00 00 1e
0075292c 00 00 00 82
00752930 00 00 00 0a
00752934 00 00 00 b0
00752938 00 00 00 20
0075293c 00 00 00 83
00752940 00 00 00 0b

So, first value is the check value, and the second would be the trophy being unlocked. Let’s see, if I convert these to decimal, I get: 31, 30, 10, 32, 11. And assuming the Platinum trophy is zero, those would map to trophies…

  • 31: Overcoming the Nemesis: Defeat Nemesis
  • 30: Perseverance: Defeat Penance
  • 10: Overcoming the Past: Defeat Yunalesca
  • 32: The Eternal Calm: Defeat Yu Yevon
  • 11: The Destination of Hatred: Defeat Seymour Omnis

I quickly make a new struct FFXTrophyForBoss { s32 enemyId; sysTrophyId trophyId; };, map it to the variable, and check the code. The disassembly is a bit wonky on indexes, but the result is telling:

1
2
3
4
5
6
7
8
9
10
11
12
s32 ffxTrophyUnlock::DefeatEnemy(FFXTrophyContext *gameObject,int enemyId)
{
  for (int i = 0; i < 5; i++) {
    if (enemyId == g_ffxTrophyForBosses[i].enemyId) {
      s32 trophyId = g_ffxTrophyForBosses[i].trophyId;
      FFXTrophyPS3Backend::PostEvent(
        &gameObject->trophyObject,
        trophyId);
      break;
    }
  }
}

Even after a second and a third look at the trophy list, indeed, only those five are related to a “Defeat X” goal. Which means I have found an entry into the combat system. In retrospect, calling it enemyId feels wrong, and I’m 99% sure this ID is closer to being an encounterID.

Okay, I don’t feel like going all the way up the combat system, so let’s go back a step and check the other routes that set those trophies!

At this point, there are two separate functions acting as gates, one checking that a trophy backend is enabled, and the other checking whether the target trophy has been unlocked, which feels sub-optimal, since those are checked in a million places already. Give me back those cycles!

After a few more misdirections, we get to a function being XREF eighteen times. Now THAT looks promising, since we only have twenty-eight more trophies to find!

More functions, more options

Along the way, I’ve looked into the existing cheats for my version of the game to map more stuff. For instance, I now know where items are located. Could that be useful? Yes!

ffxCombatEffect::Steal

The first function is 157 instructions long, full of conditions, but at some point, even with Ghidra’s strange disassembly, it’s easy to understand:

1
2
3
4
5
6
7
g_02059010 = g_02059010 + 1;
if ((*(param_2 + 0xded) == '\x06') && (199 < g_02059010)) {
  ffxTrophyTryPostIfGateOpen(4);
}
FFX::InventoryItem::ModifyInventoryRow(
  *(FFX::InventoryItem::InventoryRowItem *)(param_4 + 10),
  (int)*(short *)(param_4 + 8));

There are two trophies where the number 200 is relevant, but there’s only one that modifies your inventory: A Talent for Acquisition: Steal successfully with Rikku 200 times. So we can map that function, and we know where g_ffxTrophyUnlockRikkuStealCount is located. We can also assume very easily that param_2 is a kind of struct PlayerCharacter with 0xded being the character ID, which will become very interesting on the next function.

ffxCombatEffect::Lancet

Next up is 632 instructions, 28 local variables, and yet…

1
2
3
4
if ((g_02058fcc >> 8 == 0xff) &&
    ((g_02058fce & 0xf) == 0xf)) {
  ffxTrophyTryPostIfGateOpen(0x19);
}

Found it: Learning!: Learn to use all enemy abilities. So if we check the code, we can see the first global check covers 8 bits, and the second covers 4 more, for a total of 12 flags, which matches the 12 Ronso Rages. Perfect.

This leads to an interesting bit of trophy design. By the end of Final Fantasy X, characters can learn nearly every ability, and very few things remain exclusive12. You can use Steal with Kimahri, and Lancet with Rikku. Yet the stealing trophy specifically checks for Rikku, while the Lancet trophy only cares that all enemy abilities were learned, regardless of who triggered the final check.

ffxCombatEffect::Bribe

Let’s go: 1029 instructions, 54 local variables, and the answer is right in front of our eyes again:

1
2
3
4
g_02059014 = g_02059014 + g_ffxEncounterBaseStruct.field11_0x118._5288_4_;
if ((DAT_0205e748 == 0x302a) && (99999 < g_02059014)) {
  ffxTrophyTryPostIfGateOpen(0x13);
}

Since that trophy is Under the Table: Spend 100,000 gil or more in bribes, it makes sense to call this global g_ffxTrophyUnlockBribeTotal and not bother about those strange structures we cannot make sense of. Who would have thought it would be this easy?

ffxTrophyUnlock::JechtSpheres

By this point, I don’t even bother to read the rest of the code anymore

1
2
3
4
5
6
7
8
if (g_ffxTrophyUnlockJechtSpheresCount == '\b') {
  g_ffxTrophyUnlockJechtSpheresIsObtained = false;
}
else if ((g_ffxTrophyUnlockJechtSpheresCount == '\t') &&
        (g_ffxTrophyUnlockJechtSpheresIsObtained == false)) {
  g_ffxTrophyUnlockJechtSpheresIsObtained = true;
  ffxTrophyTryPostIfGateOpen(0x14);
}

Yes, the hardest part is to count in hexadecimal to map 0x14 to the correct trophy: Messenger from the Past: Obtain all Jecht Spheres. At this point, I’ll just remember that '\t' is 9 in the ASCII table and call it a day. What tedious work!

FUN_00365344

However, some parts remain cryptic:

1
2
3
4
5
6
7
8
if (g_ffxTrophyUnlockDamage9999NotObtained != 0) {
  ffxTrophyTryPostIfGateOpen(0x12);
  g_ffxTrophyUnlockDamage9999NotObtained = 0;
}
if (g_ffxTrophyUnlockDamage99999NotObtained != 0) {
  ffxTrophyTryPostIfGateOpen(0x15);
  g_ffxTrophyUnlockDamage99999NotObtained = 0;
}

Two more: Power Strike: Do 9999 damage or more in a single attack and Mega Strike: Deal 99999 damage with one attack. The logic is obvious, but the only XREFs to these globals are right here. So clearly, something else is setting those two flags to true, but I haven’t mapped that part yet.

ffxTrophyUnlock::Aeons

At that point, I had made good progress in the game, but despite following a walkthrough, I had trouble obtaining the Magus Sisters Aeons, because I was trying to obtain them before Yojimbo. It took another guide to find out I had to obtain all other Aeons before.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (uVar1 == 0x11) {
  /* Trophy 15: Delta Attack! (Obtain Magus Sisters) */
  ffxTrophyTryPostIfGateOpen(0xf);
  /* Trophy 26: Summon Master (Obtain all Aeons) */
  ffxTrophyTryPostIfGateOpen(0x1a);
}
else if (uVar1 == 0xd) {
  /* Trophy 13: Feel the Pain (Obtain Anima) */
  ffxTrophyTryPostIfGateOpen(0xd);
}
else if (uVar1 == 0xe) {
  /* Trophy 14: It's All About the Money (Obtain Yojimbo) */
  ffxTrophyTryPostIfGateOpen(0xe);
}

The code confirmed what my guide wasn’t telling: Unlocking the Magus Sisters implicitly meant all Aeons had been obtained.

ffxTrophyUnlock::TheaterSphere

No idea what that g_FFXGlobalState even is, but I looked at it a few times.

1
2
3
4
5
6
7
if ((((g_FFXGlobalState == 0x9e) && (g_ffxTrophyUnlockTheaterSpheresMovies == '2')) &&
    (g_ffxTrophyUnlockTheaterSpheresMusic == 'G')) &&
    (g_ffxTrophyUnlockTheaterSpheresNotObtained == true)) {
  g_ffxTrophyUnlockTheaterSpheresNotObtained = false;
  /* 16: Theater Enthusiast (Buy every sphere at the Luca Theater) */
  ffxTrophyTryPostIfGateOpen(0x10);
}

And once again, the ASCII table comes to the rescue: '2' is 50, and 'G' is 71, matching the number of movie and music spheres listed in every guide.

ffxTrophyUnlock::EternalCalmMovie

An easy one:

1
2
3
4
5
if (DAT_008f5a18 == 0x3a) {
  /* Trophy 33: A Journey's Catalyst (View "Eternal Calm") */
  ffxTrophyTryPostIfGateOpen(0x21);
  DAT_008f5a18 = -1;
}

No idea what those globals are.

ffxTrophyUnlock::AlBhedPrimers

This one was actually the first one I mapped out and it was a LOT of fun:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
s32 ffxTrophyUnlock::AlBhedPrimers(int alBhedPrimerIndex) {
  if (alBhedPrimerIndex == -1) {
    g_ffxAlBhedBitmap = 0;
    alBhedPrimerIndex = 0;
  }
  else if ((alBhedPrimerIndex < 0) || (0x19 < alBhedPrimerIndex)) {
    if (alBhedPrimerIndex == 0xff) {
      g_ffxAlBhedBitmap = 0x3ffffff;
      alBhedPrimerIndex = ffxTrophyTryPostIfGateOpen(0x1c);
    }
  }
  else {
    g_ffxAlBhedBitmap = g_ffxAlBhedBitmap | 1 << (alBhedPrimerIndex & 0x3fU);
    /* Trophy 01: Speaking in Tongues (Find 1 Al Bhed Primer) */
    ffxTrophyTryPostIfGateOpen(1);
    alBhedPrimerIndex = g_ffxAlBhedBitmap;
    if (g_ffxAlBhedBitmap == 0x3ffffff) {
      /* Trophy 28: Master Linguist (Find all 26 Al Bhed Primers) */
      alBhedPrimerIndex = ffxTrophyTryPostIfGateOpen(0x1c);
    }
  }
  return alBhedPrimerIndex;
}

There are 26 “Al Bhed Primer” items in the game, which maps perfectly to a 26-bit bitmap.

Everything in this function is crystal clear:

  • There is a “reset” path.
  • The normal path is triggered only if the index is below 26 (indexes start at 0): the bitmap is updated, the Speaking in Tongues trophy is always triggered, and then the bitmap is checked against a full 26-bit mask for the Master Linguist trophy.
  • There is a debug path that gives out Master Linguist, but not the Speaking in Tongues, which could lead to a very funny outcome of getting Master Linguist before Speaking in Tongues, which should be impossible.

This is one of the rare places where the game logic is both simple and perfectly aligned with the data model, which isn’t the case everywhere…

ffxTrophyUnlock::SphereGrid

While this function is short, it’s full of if and other strange control flow I’d rather not touch, so let’s only look at the relevant parts:

1
2
3
4
5
6
7
if ((int)unaff_r28 < 1) {
  /* Trophy 23: Sphere Master (Complete a Sphere Grid for one character) */
  ffxTrophyTryPostIfGateOpen(0x17);
}
// ...
/* Trophy 29: Perfect Sphere Master (Complete the Sphere Grids for all main characters) */
sVar9 = ffxTrophyTryPostIfGateOpen(0x1d);

That was easy to map, and easier to discard.

FUN_003103f8

Strangely, this one is one level higher in the call chain, and calls ffxTrophyTryPostById while all others above used ffxTrophyTryPostIfGateOpen. The end result is the same, since both paths will call ffxTrophyGateCheck, but it’s still surprising that the code would be structured that way:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
if (DAT_008e55cc != DAT_008e55c8) {
  DAT_008e55cc = DAT_008e55c8;
  if (DAT_008e55c8 == 0x933) {
    sVar7 = ffxTrophyGateCheck();
    if (sVar7 != 0) {
      /* Trophy 06: Heartstrings (View the "Underwater Date" scene) */
      ffxTrophyTryPostById(6);
    }
  }
  else if (DAT_008e55c8 == 0x43d) {
    sVar7 = ffxTrophyGateCheck();
    if (sVar7 != 0) {
      /* Trophy 05: All Together (All party members come together) */
      ffxTrophyTryPostById(5);
    }
  }
  else if ((DAT_008e55c8 == 0xa4) && (sVar7 = ffxTrophyGateCheck(), sVar7 != 0)) {
    /* Trophy 03: The Right Thing (Clear the Besaid Cloister of Trials) */
    ffxTrophyTryPostById(3);
  }
}
// ...
else if ((DAT_02055e99 == 0x7f) && (g_obtainedAllCelestialWeaponsTrophy == 0)) {
  g_obtainedAllCelestialWeaponsTrophy = 1;
  sVar7 = ffxTrophyGateCheck();
  if (sVar7 != 0) {
    /* Trophy 27: Weapon Master (Obtain all Celestial Weapons) */
    ffxTrophyTryPostById(0x1b);
  }
}

The first three are story-related beats, while the last one is clearly a bitmask check against the set of obtained Celestial Weapons.

Dude, where’s my Chocobo?

Out of 33 trophies, I have now mapped out how to obtain 25 of them, and still no chocobo in sight. Let’s recap the eight remaining trophies:

  • 02 Teamwork!: Win a blitzball match
  • 07 Show Off!: Win a blitzball tournament
  • 08 Striker: Learn the Jecht shot
  • 09 Chocobo License: Pass all chocobo training
  • 10 Lightning Dancer Dodge 200 lightning strikes and obtain the reward
  • 17 Chocobo Rider: Win a race with a catcher chocobo with a total time of 0:0:0
  • 22 Chocobo Master: Get 5 treasure chests during the Chocobo Race at Remiem Temple and win the race
  • 24 Blitzball Master: Unlock all slot reels

None of those seems to match any of the previous trophy categories, and we have three new categories: blitzball minigame, chocobo minigames, and that 200 lightning strikes nightmare. And only one call to ffxTrophyTryPostIfGateOpen still unmapped:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
s32 ffxTrophyRemapAndTryPost(void *param_1, uint32_t param_2, void *param_3, uint32_t param_4) {
  s32 trophyId;
  trophyId = FUN_00337f30(param_1, param_2, param_3, param_4);
  if (trophyId != 0x16) {
    if (trophyId == 0x17) {
      trophyId = 0x16;
    }
    else if (trophyId == 0x19) {
      trophyId = 0x18;
    }
    ffxTrophyTryPostIfGateOpen(trophyId);
  }
  return trophyId;
}

That is… a very strange bit of code. If the computed trophy ID is 22, it does nothing. If it is 23, it remaps it to 22. If it is 25, it remaps it to 24. Every other value goes through unchanged, and the final result is passed to ffxTrophyTryPostIfGateOpen.

By this point, I’m hoping I can just go up the chain, straight to my minigames, and my hopes are crushed: I end up in a TOC pointer, with a single XREF, and no way to find out who’s calling it without mapping more of the code. And looking at the pseudo-code sends a chill down my spine: I may be looking at a virtual machine.

In spite of everything, I give it one more try and decide to go for even bigger guns: other humans! I find a repository dedicated to a FFX game randomizer, and even a Discord dedicated to reverse-engineering the full game, who nicely give me access to their work and findings on the Steam and Switch versions of the game.

Unfortunately, my worst fears are confirmed: there is a scripting language in there, and a script engine to run it.

Learning to give up

By this point, I’m starting to realize it’s the second day in a row I’m going to bed at 6 AM. I can recognize the prologue and epilogue of a function at a glance. I even had a weird dream about assembly, where I made assumptions about code that I then confirmed when I woke up. And I haven’t actually played the game in almost a week.

I’m at a crossroads.

Going through the game’s code has been a ton of fun. I’ve learned how C++ vtables actually work with structs, assembly, and inheritance. I’ve even discovered the game uses intrusive linked lists, just like the Linux kernel.

On the other hand, we’re talking about a 9.4 MB binary: that’s 2,352,212 instructions. Even ignoring the .bss segment and system stubs, it’s still a massive undertaking. And at this point, I’m not even sure why I’m doing it anymore. Or rather: is cheating my way to one last trophy really worth all that much work?

As I ponder the question, I notice the rules to the Discord I just joined:

  1. Don’t stress yourself about contributing to modding/reverse engineering.
    If your current situation doesn’t allow it it’s no big deal. Always put your well being first.

I’m not stressing myself, but I know a part of me could keep going on and on over this binary, down to its last byte, until I burn myself out.

Which would have been stupid, since I got the trophy the regular way less than two days later. Turns out, the answer is in the classics: winning is knowing when not to play.

Thanks

Huge thanks to:

  • The Ghidra team for making reverse-engineering actually fun

  • The RPCS3 team for making this whole mess possible

  • The Ps3GhidraScripts contributors for saving me hours of setting up NIDs

  • The FFX reverse-engineering Discord, Cid’s Salvage Ship, for giving me access to their knowledge, and telling me to touch grass

  • My step-brother for gifting me his PS3 on a whim twelve years ago

  • My sister for dealing with my live commentary over WhatsApp for three weeks


  1. A problem I am actively trying to solve↩︎

  2. And isn’t that actually for the better, since it means I’m gonna learn stuff? ↩︎

  3. Most Significant Byte, meaning Big Endian↩︎

  4. Obviously, I bought the Cosmic Red version. ↩︎

  5. In Ghidra, an XREF is a reference from another part of the binary. ↩︎

  6. Actually my first console ever, which I acquired in 2008 in its Crisis Core limited edition. ↩︎

  7. And I will only discover it exists while writing this article: the processor is 64-bit, but the PS3 uses 32-bit addressing. ↩︎

  8. Therefore sparing me one of the sources of my ADHD. ↩︎

  9. The reason it’s a laptop is because it’s ALWAYS on my lap, even when I’m in front of my TV playing console games. ↩︎

  10. My greatest hit was writing a recursive strlen(3), and it took me until this year to learn how this was a stupid idea. ↩︎

  11. Struct size arithmetics being another nice C subject, but, like everything C and beautiful: deterministic! ↩︎

  12. Except “Summon” which is exclusive to Yuna. ↩︎

This post is licensed under CC BY 4.0 by the author.