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.

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
sceNpTrophyUnlockTrophyneeds asysTrophyContext,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 itFFXTrophyPS3Backend). This also confirms that we’ve moved out of “OS code” and are in “game code”. - We’ve found the
fprintfequivalent! - 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
FFXTrophyPS3Backendstruct 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
FFXTrophyPS3Backendcontains a boolean that indicates whether the trophy setup thread was started, let’s call itflagTrophyThreadRegistered. - 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.

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:
- 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
-
A problem I am actively trying to solve. ↩︎
-
And isn’t that actually for the better, since it means I’m gonna learn stuff? ↩︎
-
Most Significant Byte, meaning Big Endian. ↩︎ -
Obviously, I bought the Cosmic Red version. ↩︎
-
In Ghidra, an XREF is a reference from another part of the binary. ↩︎
-
Actually my first console ever, which I acquired in 2008 in its Crisis Core limited edition. ↩︎
-
And I will only discover it exists while writing this article: the processor is 64-bit, but the PS3 uses 32-bit addressing. ↩︎
-
Therefore sparing me one of the sources of my ADHD. ↩︎
-
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. ↩︎
-
My greatest hit was writing a recursive
strlen(3), and it took me until this year to learn how this was a stupid idea. ↩︎ -
Struct size arithmetics being another nice C subject, but, like everything C and beautiful: deterministic! ↩︎
-
Except “Summon” which is exclusive to Yuna. ↩︎
