Decompiling Tenchu: Stealth Assassins part 12: when stuffing PlayStation code inside a Linux MIPS process is no longer worth the trouble
Previously in this series of articles, we’ve perfected the art of modifying the code of Tenchu: Stealth Assassins on its native platform as if it was made out of Lego™, thanks to the power of delinking. In this part, I finally went one step too far with my heresy against computer science and I’m forced to backtrack lest I go completely insane. As a cautionary tale, I’ll document here the dangers of succumbing to the dark side of delinking, as applied to this project.
The road to hell is paved with good intentions
As mentioned in the introduction of this series of articles, one of the objectives of this project was to make a working, native Linux MIPS port of Tenchu: Stealth Assassins by taking its PlayStation code and shoving it into a Linux process. The idea was to make a Linux port on the cheap as a stepping stone to a fully decompiled and portable version of the game. This “cheap” port concept relied on several assumptions, some of them turned out to be wrong.
I was so preoccupied with whether I could, I didn’t stop to think if I should. It turns out that there’s a price to pay for violating ABIs willy-nilly and I’ve severely underestimated what it would take to achieve this.
In other words, I knew just enough to get into a lot of trouble, but not enough to get out of it.
Non-standard instructions and direct hardware access
While qemu-mipsel
can emulate a MIPS CPU, it doesn’t support the exact CPU model as found on a PlayStation.
That’s not a problem except for the proprietary geometry transformation engine (or GTE), which is implemented as a coprocessor.
Trying to execute one of its opcodes there will lead to an illegal instruction exception instead of the expected result.
Similarly, qemu-mipsel
emulates a user-land process and not a PlayStation, so direct access to hardware registers will not work either.
Luckily, Tenchu: Stealth Assassins is not an optimized game that leverages PlayStation-specific features directly within its code, instead all of that is mediated through the Psy-Q SDK. Therefore, replacing that layer with a drop-in compatible replacement completely eliminates these issues, because the game code itself isn’t strongly tied to the PlayStation hardware. My delinking tooling has already shown this to be possible, even with statically-linked libraries as found in these games.
The fact that the game was written in C on top of the Psy-Q SDK without using any optimization tricks depending on the PlayStation hardware was the one assumption that turned out to be correct. Even if the game accessed the GTE or the hardware directly I can think of multiple ways to deal with that, but I haven’t investigated them due to a lack of need.
Code hostile to delinking
As discussed before, there are ways of writing code in a manner that resists delinking when compiling for the MIPS architecture.
This mostly revolves around casting integer constants as pointers, which tends to create instruction patterns (LUI
/ORI
) that do not match those used for relocations (LUI
/ADDIU
), meaning we can’t generate a relocation for it.
If you want your MIPS code to be harder to delink, cast raw integer constants as pointers whenever you can.
In particular, do not declare an external variable in your source code and then define the symbol with the linker, either though the linker script or with --defsym SYMBOL=EXPRESSION
, like you’re supposed to.
Normally, these can fixed by binary patching the sequence of instructions into a normalized pattern.
However, if the integer constant had its lower 16 bits set to zeroes then the compiler might just load the upper 16 bits with LUI
and omit the ORI
instruction.
Unless there’s a NOP instruction we can leverage, for example inside an unused branch delay slot, then this can’t be fixed in-place.
I’ve sorted out the remaining apparent delinking issues from the last part, so things like in-game music and cutscenes are working now with the relinked executables on the PlayStation. That being said, not all issues were actually fixed, for references with incorrect instruction patterns to the persistent state and the scratchpad remains.
The fact that the game’s code can survive a round trip through the delinker and still work on the PlayStation doesn’t mean it will work on Linux.
Position-independent code
The PlayStation executable code as built by the Psy-Q SDK is what we would call now -fno-pic
.
My previous experiments on running that code on Linux relied on building static programs, with position-dependent code and executables, in order to match the ABIs.
As long as I was building console applications, this was a workable solution.
Unfortunately, it is impractical for GUI applications on Linux to be statically linked. The PsyCross SDK depends on SDL, which itself depends on multiple system libraries, including OpenGL. Shared libraries on MIPS mandate position-independent code, which causes the following ABI mismatches:
No PIC | PIC | |
---|---|---|
t9 |
Caller-saved | Holds address of callee function |
gp |
Callee-saved | Caller-saved |
This can’t be solved with a simple one-way trampoline.
The gp
register must be restored when the function call returns, otherwise we might start using the small data pool of a different shared object than ours, with catastrophic results.
Furthermore, if we divert the execution flow through a thunk, we’d also have to preserve the ra
register, otherwise we wouldn’t know where to return to.
Right now I have a kludge of a thunk made up of Makefile magic, shell one-liners and assembly macros.
It saves the ra
and gp
registers on a shadow stack: it works, but it is not ABI compliant and GDB can’t follow the call chain through it when generating a backtrace.
Fixing this would require implementing a proper thunk mechanism, but doing that correctly and covering all of the edge cases would be a fair amount of work.
PsyCross is a Driver 2 runtime
Getting PsyCross to cross-compile for Linux MIPS was a bit of a chore, but actually using it was another story.
There are dozens of Psy-Q SDK functions that are missing and that prevents linking an executable with the game’s entire delinked code.
After stubbing all of this (and outright stealing libgs
from Psy-Q because PsyCross doesn’t provide it at all), what is implemented doesn’t match Tenchu’s expectations: libcd
for example is missing multiple important features that the game relies on in order to load data off the AFS archive.
PsyCross appears to have been originally created for a decompilation project of Tomb Raider: Chronicles before being leveraged for REDRIVER2, the decompilation project for Driver 2: Back on the Streets. It’s a reimplementation of Psy-Q that’s good enough for running a specific game rather than a drop-in compatible replacement for it.
Even though I can get the game executable to link and even technically run on Linux, I do not have a suitable implementation of Psy-Q for this platform to actually play the game. Simply put, PsyCross appears to be way off the mark from what the original game’s code expects and bridging the gap as-is would be a lot of work.
Terrible debugging experience
One feature I’ve always postponed for my Ghidra extension is the generation of debugging symbols, mostly because I expect this to be a lot of work even for a partial implementation. So far I’ve accepted a crappy, instruction-based debugging experience inside GDB, but my thunks manage to worsen it significantly.
On top of that, having to run the Frankenstein program through qemu-mipsel
adds another layer of pain because QEMU tends to hang and the GDB remote becomes unresponsive when my black magic implodes under its own weight.
This PlayStation-code-on-Linux endeavor requires copious amounts of debugging to sort out issues and I am not willing to entertain this level of development abuse.
Conclusion
With the game’s code on Linux currently stuck inside libcd
trying to load data off the AFS archive, it’s executing far enough to demonstrate that an unoptimized PlayStation game can probably be made to run inside a Linux MIPS process, if you abuse it hard enough.
However, the massive number of issues that remains to be solved means that turning this proof-of-concept into a working port or even a useful development environment is currently out of reach.
I’ve already spent two years perfecting the delinking technique as a prelude to this project and right now I’m just not motivated by the idea of going on another Moby-Dick quest to make this work, just to make a point on the Internet. The objective of crafting a Linux MIPS port out of the pieces of the original game’s code as laid out in the introduction is therefore abandoned for the time being.
The lesson learned here is that I need to wield delinking more responsibly: keeping the delinked code on the PlayStation is far easier than trying to shove it by force inside a Linux MIPS process. Given how grueling this investigation was, I’ll probably take a break from this project for now. I still want to reverse-engineer and decompile this game, but not at the cost of the remaining scraps of my sanity.