Recently I was asked if I could add achievements to Baldr Sky. For anyone not aware, there's 0 source code, any changes must be done directly to the compiled exe. Since I did this for Funbag Fantasy 2 and it was interesting, figured I'd go through the steps involved.
First off, we use a dll since doing it all in assembly would suck. I built a really simple c++ project, linked it to the steam api, and copied some of the steam sdk example stuff for achievements over. Then it's a matter of exposing it so it can be loaded by the game.
On the exe side of things, all the patching is done using fasm assembler macros I inherited from SLC (who worked on rance), but who knows where they originate from. I built a small utility to set up an exe for patching, which is on github: https://github.com/Doddler/ExePatchPreparer
There's three steps to doing our steam integration. First we need to load our dll and call the initialization function, and then we need to ensure the steam update callbacks are called each frame. Lastly, we need to actually issue our achievement unlocks from the script.
For the former, we want our dll loaded and called early, so in ghidra we find the entry point, and then find some suitable spot where we can call our own code. I picked this GetCommandLineA command, since it doesn't mess with nearby registers and easy to transplant the commands.
Next, in our patch macro, we patch out this block and call our own function. Our function needs to do what the original commands we overwrote did, and then we're free to do what we want. I set it so we load the dll and call our dll InitSteam function if successful.
To load our dll, we'll need a few bits of information. First we need pointers to LoadLibraryA and GetProcAddress, which we can find easily with ghidra. Then we'll also need a place to store the pointers we get back so we can use it later.
The actual act of loading our dll is pretty straight forward. We push our dll name and call _LoadLibraryA and store the resulting pointer, and then for each function you push the function name, dll pointer, and call GetProcAddress for each function.
I recently learned that you can include some proceedure call macros in fasm, so we can simplify it a bit by making a procedure to load each dll function, and then call it for each of our functions we want to load.
As I mentioned earlier, once we have the function pointers stored, we can simply make calls to our dll directly. With an steam_appid.txt file in the directory (since we aren't running through steam yet) and calling our InitSteam function, we see steam loads correctly!
There's one more step before we can add achievements though. We need to ensure steam callbacks are called each frame. Best way here is to find the main game loop and inject there. Fastest way to find that is to look for windows message loop functions, like PeekMessageA.
Like most windows apps, it's looping game code until it sees a windows event with PeekMessageA, and then drops down to handle that message. The game code is run through an indirect call, but it's easy enough with a debugger to find the address. It's in EAX in this case.
Just as we did before, we find a suitably simple piece of code without a lot of side effects, and modify it to jump to our code. We use jumps to call/return here because the original code puts stuff on the stack and we don't want a return pointer messing up the stack.
Now steam is being loaded and updating each frame, but how are we going to actually unlock an achievement? There's a number of approaches, but what we'll do is find a scripting function we can 'overload' to also handle our achievement unlocking.
It so happens that while working on other stuff I found the code that handles the script command "SetReplayFlg". You can see here it checks if you're trying to set a flag beyond the array bounds, and if it is, it calls InvalidParameter, which terminates the game.
For the curious, there is room for 50 (ids 0-49) scene replay unlock flags. Trying to assign a value to id 50 will cause the game to close. So what we'll do is instead of closing, we'll pass that to our achievement unlock function instead, which will unlock achievement id-50.
We do our thing as we've done before, we patch the call to the invalid parameter function to call our code instead, convert it to the proper achievement id, pass it off to steam, and then return. Pretty simple all things told.
Here we find a suitable looking spot and put our script command. It's worth mentioning that this script representation is decompiled using a tool I wrote from the raw binary scripts (VM code). Like the exe, there was no code or tools for working with the scripts themselves.
Lastly, we recompile our scripts, build our exe, pack the archives, and start it up. I didn't get it to work on the first try, or even the 20th try, but after enough messing around, we get here, a proper achievement unlock. We did it!
I'll end saying the baldr sky project means a lot to me, it's been a wild ride since it started as a fantl many years ago. At the start I was terrible without any idea how to do any of this, it's fair to say I learned most of this stuff just to make this project possible.