Secure77

Techblog and Security

Tainted Grail – FoA – Unlock The Hidden Secrets

Researches

The journey of recovering my lost arm.

Background

Back in the bad weather season, I was looking for a single-player game that I could enjoy alongside my usual daily routine. I noticed the release of Oblivion remastered Edition and considered buying it, but the reviews shortly after release weren’t particularly promising, so I decided to look for something else. One day, Steam suggested the game Tainted Grail – The Fall of Avalon, which was developed by a small Polish team and looked quite similar to the “game style” of Elder Scrolls.

The positive reviews convinced me, and I decided to buy it. I played for about 25 hours and really enjoyed the game. So far, so good. Somewhere in part 2 of the game, the developers included a funny game mechanic where you can lose an arm.

That’s pretty funny, and I was pretty sure there had to be a quest somewhere where you could get your lost arm back, because with only one arm, you’re really limited in some important game mechanics. For example, you can’t use two-handed weapons (obviously) such as bows or greatswords, but mining and digging are also no longer possible, which means you miss out on many collectibles and items. I kept playing and playing, but after playing for a few hours and visiting a few larger cities without finding a way to get the lost arm back, I got nervous and searched for this topic.

Surprisingly, this seems to be a lasting consequence (at least it was, but more on that later).

After discovering this, I was a little demotivated. I didn’t want to continue playing the game with only one arm, which meant I had to load an old save file and lose several hours of gameplay.

Since I was tired of playing through the same story again, I decided to take a closer look at the game to see if there was perhaps a way to restore the arm in some unintended way.


Reversing the Game

The game is created in Unity, which is always beneficial (from a reverse engineering perspective). It is converted to C++ via IL2CPP, but there are some good dumpers available, allowing us to retrieve the DLLs, symbols, and offsets. I will refrain from providing a more detailed explanation, as there are already many resources available on this topic but I will recommend this dumper.

After loading the dumped DLLs in DnSpy, we can quickly identify our desired location, the actual game code is located in the TG.Main.dll.

I looked around and tried to find a method or property that stores our “behavior when arm is lost.” I thought about “lost arm,” “only one arm,” “crippled,” and so on and also but I couldn’t find anything that sounded like that. I also checked all the hero statuses, properties etc, but again, I couldn’t find anything that gave me a direct clue or setting. But while browsing through the different classes, I discovered something else interesting.

It seems as though the developers have built in many different cheats and debugging features. I tried to figure out how to open this “Quantum” console by pressing all the usual console buttons in the game, and I also googled it, but it seems as though the developers have kept this to themselves.

Switching Target – Awake the Quantum of Marvin

Since I was a little bit stuck in finding a way to restore my arm, I decided to figure out how to activate the Marvin Quantum console, as this apparently offers many interesting tools.

I found many promising properties that, if we change them, might make it work. So I set breakpoints on the following getters:

  • IsCheatsEnabled
  • EnableCheats
  • Debugger IsVisible

I restarted the game and loaded a save file, tried all the different console buttons again, but none of my breakpoints were triggered.

I decided to take a different approach and found this InitQuantumConsole() method:

which is not called by default, but I thought maybe I could find the condition that determines whether or not it is called. So I looked up this function in x64dbg at offset 0x6A2EF50 and searched for all references to this address, we got one hit at file offset 0x6A2ECF7

I checked the above assembler instructions but could not find any conditional jump. So we have to reverse further. A renewed search for references to the start address of this function gives us three results.

Investigating each of these, we can see there are conditional jumps in front of the calls. I placed breakpoints in all three function right before the conditional jmps and restarted the game.

We got a hit on offset: 0x6A2E917

After forcing this jmp (change it from a jne to a jn) and continue the game, we can already identify in the main menu that this has some effect.

I loaded a save game and tried again different classic console keys, finally the ` key opens the Marvin Console 😊😊🎉🎉

It offers us many options and we can do a lot with it, and it’s more than just cheating! We can also use it to fix some things. For example: I had the problem that when running the game on my VM, the textures were missing (unless I was in a dungeon).

I discovered when we are in the Display Runtime Rendering Debugger, and disable the Custom Pass setting, the textures are displayed again. This option is not found in the game’s graphics settings, but is only available via the Marvin console.

For anybody else, who want to activate the console, you can search for the following byte sequence: FA 0A 83 B4 00 00 00 75 2A 48 8B and change it to FA 0A 83 B4 00 00 00 74 2A 48 8B in the gameassembly.dll (replacing the 75 to a 74)

I created also a little patcher with powershell, this should work also for newer versions. You can find it in the repository project.


What about my lost arm?

However, Marvin also don’t offer a setting to restore my lost arm 😒

Come to think of it, there must be a property or flag that tells the game whether my arm has been lost or not. Since this depends on your saved game, this information must be stored in the saved game files. We can find the saved games at AppData\LocalLow\Questline\Fall of Avalon\(SteamID), but the files are in binary format and we cannot simply read or edit them.

I searched for a way how I can deserialize these files but was not able to get it working, however I found this website which can convert your save game to at least some kind of readable format.

I uploaded two save games, one with the lost arm and one before I lost the arm and compared these two files. Finally I identified a potential object HeroCutOffhand which is only included in the save game where the arm is lost.

Since the website also offers an option to convert your saved game back to a binary format, I removed that part from the saved game and tried to load it, but it causes the game to crash. It seems that the online editor cannot serialize the files back correctly. Even if you try to convert an unmodified saved game, it has a different file size.

I am very interested in how this website desrialize the save game files into a readable format. If anyone has any ideas about this, please leave a comment or contact me.

It seems there is no shortcut by simply editing the save game, instead we have to fix the problem while the game is running. Back in DnSpy, I looked up the class and set breakpoints for various methods.

I couldn’t find an easily adjustable option such as “IsHandCutOff” or similar, where I could simply set the return value to “false“, also OnInitialize() get only called in the moment when Sharg will cut off your arm, and Deserialize will not called at all.

I decided to use the magic of the Marvin console. There is this handy “Models debug” menu that allows you to search and examine all loaded objects and also customize them or call methods from them.

I searched for HeroCutOffhand and when looking at the methods, we can see a lot more then DnSpy shows us:

I quickly found a method called “Restore” that sounds promising, but when you call it, nothing changes. I set a breakpoint on the OnRestore method (because it was available in DnSpy) and found that it is called when you load a save game without an arm, but not when you load a save game with both arms. I also discovered that the entire HeroCutOffhand object does not exist if you “have” both arms, which make sense as it also not present in our save game.

This means Restore in this context does not mean that your arm is restored, but that this object is restored. I tried to return directly to the caller without executing the contents of the OnRestore() method, but it seems to be too late, as the object has already been loaded and skipping OnRestore() cannot prevent this object from being loaded.

But why there is a OnRestore() method available in DnSpy but no Restore() or any of the other methods, which are shown in Marvin?

The answer is: HeroCutOffHand is a class of Hero, which is a Element , which is a Model and so on. Marvin shows you all methods which are inherent of the parent classes.

Lets sum up what we figured out so far: If your hand get cut off, the object HeroCutOffHand get initialized, this model will then be serialized and saved in your save game. By loading the save game it gets deserialized and restored.

So when we prevent the model from being loaded or just „destroy“ it, we should get back our arm, right?

We can test this by call the Discard method in Marvin, and indeed, our arm is back 🎉😊

But wait a minute, we still can’t equip a secondary weapon 🤨 That makes sense, since HeroCutOffHand is just a model that shows or hides our arm, so we need to keep searching. After searching for “locked,” I found this class called “HeroLoadoutSlotLocker.”

Upon searching for these objects again in the Marvin console, we can confirm that 4 objects (representing the 4 locked item slots) are present when the arm is lost, otherwise these objects do not exist.

We also have to discard of all these objects (again via Marvin) then save and reload the game to get our item slots back. Finally, we have our arm back, including all the abilities that a second arm gives you. 🎉

You need to switch the camera into third person and back to see the second arm and weapon in first person view, even better is to save and reload the game.

Side note: in theory its enough to only discard the HeroLoadoutSlotLocker objects to give you back the original game mechanics but without removing the model, it looks a bit „off“


Create a Mod

Well, it’s been a long journey to get here, but I was able to restore my arm manually. removing these objects by hand requires many steps and Marvin’s help (it felt a bit like a real surgery). Now that we’ve figured out what we need to do, there must be a way to automate this.

Meanwhile and during writing this blog post Questline released a new update for the game

and guess what is in the release notes

However, I was already hooked to create my first mod for game, so I decided to ignore this update and stick on my old game version so I can continue my journey.

As editing the save file is not possible (or at least I don’t know how) there are a couple of other ways which comes into my mind:

  1. During the load game process, hook into the „Restore“ or „Deserialization“ of our target objects and block them.
  2. During the game, call the discard method for these objects (like we did it via the Marvin Console).
  3. During the save game process, hook into the „Serialization“ of our target objects and block them.

Everyone faces a major challenge: these superordinate methods (discard, restore, (de)serialize) are used by many objects in the game. So simply calling or blocking these methods is not possible, first we need to find the correct object and scope. It turned out, this is (as usually in Reverse Engineering) the most time consuming part.


Select an approach

When it comes to some more advanced game manipulations, like interacting with objects it is a good idea to use some toolings as scanning the memory and manually resolving structs and pointer chains will be very cumbersome, even when you are not sure which method is the correct one to hook.

For IL2cpp there are many tools available which helps you to mod a game, for example:

I decided to follow the Il2cpp scaffolding approach together with miniHook. As this blog post is already long enough I moved the setup and basic concept of Il2Cpp Scaffold Injections to another blog post, which you can find here: Il2Cpp Injection and Hooking via Scaffolding


Where is my object?

As I didn’t know how to find my object by enumeration I installed a hook on the HeroOffHandCutOff.OnRestore() function, as we now that this function will be called, this way we can retrieve our desired instance

void Hooked_HeroOffHandCutOff_OnRestore(HeroOffHandCutOff* __this, MethodInfo* method)
{
    const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);

    auto output = Model_get_ID(reinterpret_cast<Model*>(__this), const_cast<MethodInfo*>(mi));
    auto targetModel = il2cppi_to_string(output);

    std::cout << "[onRestore] Captured HeroOffHandCutOff: " << targetModel << " at: " << __this << std::endl;
    g_HeroInstance = __this;  // store instance globally

    if (orig_HeroOffHandCutOff_OnRestore) {
        orig_HeroOffHandCutOff_OnRestore(__this, method);
    }

    std::cout << "[onRestore] original OnRestore call finished" << std::endl;

}

However, every time when I tired to call the discard method for this instance Model_Discard() the game crashed. I hooked a lot of different function in the hope to capture the desired objects on the creation and prevent them from loading by just skipping the call of the original function but without any luck 😢

Trying all of these hooks costs me a few evenings, because just installing the hook is not enough, you also need to make sure that you can cast the hooked instance object to an Model so you can read the ID of it.

Finally I found the ModelElement_ReInit function, which holds our desired objects as an parameter, however, skipping the ReInit is again to late to prevent the model from loading. As a last resort I tried to discard the object directly during this call (and not by pressing a Key) and it worked, the object was discarded (during the loading screen) and not present in the game any more. 🎉🎉

The ReInit() function also holds all of our HeroLoadoutSlotLocker objects, so we can discard them as well during the hook.

void Hooked_genericHook(ModelElements* __this, Model* owner, MethodInfo* method)
{
    const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);
         
    std::string targetModel = "no model";
   
     Model* castElement = owner;

     if (castElement) {
         auto output = Model_get_ID(castElement, const_cast<MethodInfo*>(mi));
         if (output) {
             targetModel = il2cppi_to_string(output);
         }  
     }

    std::regex pattern(R"(Hero:0:HeroItems:0:HeroLoadout:[0-4]:HeroLoadoutSlotLocker:[0-4])");

    if (targetModel == "Hero:0:HeroOffHandCutOff:0" || std::regex_match(targetModel, pattern)) {

        std::cout << "[Reinit] found target Model in genericHook: " << targetModel << std::endl;
 
        const MethodInfo* mi = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "Discard", 0);

        Model_Discard(owner, const_cast<MethodInfo*>(mi));
        std::cout << "[Reinit] Discarded.. please dont crash" << std::endl;
    }
  
    if (orig_genericHook) {
        orig_genericHook(__this, owner, method);
    }
    
}

Later i figured out, that the reason why the Model_Disard() method was causes the game to crash, was that I called it from another thread. ChatGPT gave me this solution and I had no clued that this could cause some issues (which costs me a lot of time)


Do it without Hooking!

Well, now we can improve our Mod a little bit so we don’t need to install any hook and just Discard the Elements after we have enumerate these. Again we need to find the correct methods for this but after some try and error I figured out that the Model.AllElements() method will give us all direct child elements.

As HeroOffHandCutOff is a direct member we only the Hero Model (Hero:0:HeroOffHandCutOff:0) we only need to call this function one time for the Hero object to find the correct one.

HeroLoadoutSlotLocker is a child item of HeroLoadout and HeroLoadout is a child item of HeroItems so we need to enumerate through these hierarchies for every node (Hero:0:HeroItems:0:HeroLoadout:[0-4]:HeroLoadoutSlotLocker:[0-4]) also we need to take care that the ID will count up for every item of the same type in one hierarchy level.

I wrote a little function to for enumerating child items, so we can reuse this to clean up the code a little bit. This is the final code to search for these objects and delete (discard) them.

Show Code
#include "pch-il2cpp.h"

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <iostream>
#include "il2cpp-appdata.h"
#include "helpers.h"
#include <regex>
#include <list>


using namespace app;

// Set the name of your log file here
extern const LPCWSTR LOG_FILE = L"C:\\Users\\admin\\Desktop\\il2cpp-log_elements.txt";

std::list<Model*> searchElement(Model* parentModel, std::regex searchPattern, bool lastItem = false) {


	const MethodInfo* mi_AllElements = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "AllElements", 0);
	const MethodInfo* mi_getID = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);

	auto parentModelID = Model_get_ID(parentModel, const_cast<MethodInfo*>(mi_getID));

	auto allElements_List = Model_AllElements(parentModel, const_cast<MethodInfo*>(mi_AllElements));

	std::list<Model*> foundElements;


	if (allElements_List && allElements_List->fields._items->max_length > 0) {

		for (size_t i = 0; i < allElements_List->fields._items->max_length; ++i) {

			auto elementItem = allElements_List->fields._items->vector[i];
			auto elementModel = reinterpret_cast<Model*>(elementItem);

			if (elementModel) {
				auto elementModel_ID = Model_get_ID(elementModel, const_cast<MethodInfo*>(mi_getID));
				if (elementModel_ID) {
					std::string elementModel_Name = il2cppi_to_string(elementModel_ID);

					//il2cppi_log_write(elementModel_Name);

					if (std::regex_match(elementModel_Name, searchPattern)) {

						if (lastItem) {
							std::cout << "[*] Found Object " << elementModel_Name << " at " << elementItem << std::endl;
						}
						foundElements.insert(foundElements.begin(), elementModel);
					}
				}
			}
		}
	}
	return foundElements;

}


void Run()
{
	// Initialize thread data - DO NOT REMOVE
	il2cpp_thread_attach(il2cpp_domain_get());

	// (Create a console window using Il2CppInspector helper API)
	il2cppi_new_console();

	std::cout << "\n[*] DLL Injected! Load a save Game and press F2 to restore your arm!" << std::endl;

	//initHook_HeroOffHandCutOff_OnRestore();

	while (true)
	{
		if (GetAsyncKeyState(VK_F2) & 0x8000)  // F2 key
		{

			// MethodInfos
			const MethodInfo* mi_getCurrent = il2cpp_class_get_method_from_name((Il2CppClass*)*Hero__TypeInfo, "get_Current", 0);
			const MethodInfo* mi_getID = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "get_ID", 0);
			const MethodInfo* mi_discard = il2cpp_class_get_method_from_name((Il2CppClass*)*Model__TypeInfo, "Discard", 0);
			
			
			// get Hero Object
			Hero* myHero = Hero_get_Current(const_cast<MethodInfo*>(mi_getCurrent));

			if (myHero) {
					
				// cast myHero to an Model
				Model* heroModel = reinterpret_cast<Model*>(myHero);

				// printout Hero ID
				String* myHeroID = Model_get_ID(heroModel, const_cast<MethodInfo*>(mi_getID));
				std::cout << "\n[*] Hero Object ID: " << il2cppi_to_string(myHeroID) << " at " << myHero << std::endl;

				// define regex patterns
				std::regex heroOffHandCutOff_pattern(R"(Hero:0:HeroOffHandCutOff:0)");
				std::regex heroItems_pattern(R"(Hero:0:HeroItems:0)");
				std::regex heroLoadout_pattern(R"(Hero:0:HeroItems:0:HeroLoadout:[0-4])");
				std::regex heroLoadoutSlotLocker_pattern(R"(Hero:0:HeroItems:0:HeroLoadout:[0-4]:HeroLoadoutSlotLocker:[0-4])");


				std::list<Model*> itemsToDestroy;

				// get all HeroOffHandCutOff elements
				std::cout << "\n[*] Enumerate all child Elements of: " << il2cppi_to_string(myHeroID) << ":\n" << std::endl;
				std::list<Model*> heroOffHandCutOffItems = searchElement(heroModel, heroOffHandCutOff_pattern, true);

				itemsToDestroy.merge(heroOffHandCutOffItems);


				//Get all heroItems of Hero
				std::list<Model*> heroItems = searchElement(heroModel, heroItems_pattern);


				for (Model* heroItem : heroItems) {

					// get all heroLoadouts of heroItems
					std::list<Model*> heroLoadouts = searchElement(heroItem, heroLoadout_pattern);

					for (Model* heroLoadout : heroLoadouts) {
						// get all HeroLoadoutSlotLocker Items
						std::list<Model*> heroLoadoutSlotLockers = searchElement(heroLoadout, heroLoadoutSlotLocker_pattern, true);
						itemsToDestroy.merge(heroLoadoutSlotLockers);
					}
				}

				// Delete them
				std::cout << "\n\n[*] Start discarding objects:\n" << std::endl;
				for (Model* itemToDestroy : itemsToDestroy) {
		
					String* itemToDestroy_ID = Model_get_ID(itemToDestroy, const_cast<MethodInfo*>(mi_getID));
					Model_Discard(itemToDestroy, const_cast<MethodInfo*>(mi_discard));
					std::cout << "[*] Object: " << il2cppi_to_string(itemToDestroy_ID) << " discarded!" << std::endl;
				}
				std::cout << "\n\n[*] All Objects removed, you should have a second arm again :)" << std::endl;
			}
			else {
				std::cout << "[*] No Hero object found, first load a save game!" << std::endl;
			}

			Sleep(200);
		}

		Sleep(10);
	}
}


Inject and run the Mod

finally we only need to inject our DLL, I wrote a little injector so we don’t need to use a external tool.

You can find all of the code on Github.


Final Words

Since the developer has already released an update for the game, I would not recommend continuing to use this mod (however, the patch to activate the console should still work), as there may now be open quests or at least elements for quests that could cause problems when triggered. It was quite a lot of work, but also a really nice story to immerse myself in the world of Il2cpp and Awaken Space. 😄


Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert