July 28, 2021
Modding with Harmony
Monster Train
Title:
My Monster Train Custom Unit Synthesis Patch
By:
Steven lilley (Me)
Description:
Link to my github page leading to my patch for adding custom units and syntheses to Monster Train.
Overview
Monster Train modding suffered from a lot of issues when the latest large DLC was made, the Last Divinity. Trainworks, a modding API created to help with modding Monster Train, was created before this DLC, and its addition broke many of its features.
One of the large additions from the DLC itself was unit synthesis, which let you break a character down into a unique upgrade for another character. This however was impossible to implement with Trainworks since it encountered new bugs because of unforeseen interactions with the new content. Originally, it would not even run if using new content, but a few updates to Trainworks at least made using them possible. However, unit syntheses continued to be an issue that did not work.
The existing custom units did not have any essences as they were created before the DLC. Trying to synthesize them into another unit would then create a softlock in the game. Being such a crucial aspect of the game, I investigated the error in an effort to remove it either on my own or through additional information to improve future iterations of Trainworks.
Issue and Monster Train’s UnitSynthesisMapping Class
CollectMappingData Method
The new DLC added a class within Monster Train’s base code named UnitSynthesisMapping which is responsible for creating the list of unit syntheses and holding that data for use in game. It has a method named CollectMappingData, which searches through the AllGameData for a list of all the CharacterData and all the CardUpgradeData in the game. It creates an initial list from every CharacterData in the game (to make sure every character has one, and only one upgrade).
While this appeared to be a perfect point to approach this issue, it was a bit indirect. This class was contained within a Unity Scriptable object, and most of the properties and methods contained within were private and very encapsulated. My best guess is that this Scriptable Object exists for the Monster Train team within the Unity Inspector and they actually call the CollectMappingData method with a button in the Inspector. This is because that method specifically has a Unity attribute called Context Menu which lets it be called from within the Inspector, outside of running the game. This threw me off for a while since I was trying to track when this method was called, and it turns out it never gets called naturally during the running of the game.
CollectMappingData Code from Monster Train
[ContextMenu("Collect Mapping Data")] private void CollectMappingData() { this.editorMappings.Clear(); foreach (CardData cardData in this.allGameData.GetAllCardData()) { if (!cardData.IsArchived && cardData.IsSpawnerCard() && !cardData.GetSpawnCharacterData().IsChampion()) { this.editorMappings.Add(new UnitSynthesisMapping.MappingEntry(cardData.GetSpawnCharacterData(), null)); } } this.editorMappings.Sort((UnitSynthesisMapping.MappingEntry a, UnitSynthesisMapping.MappingEntry b) => a.character.name.CompareTo(b.character.name)); foreach (CardUpgradeData cardUpgradeData in this.allGameData.GetAllCardUpgradeData()) { if (cardUpgradeData.IsUnitSynthesisUpgrade()) { foreach (UnitSynthesisMapping.MappingEntry mappingEntry in this.editorMappings) { if (mappingEntry.character == cardUpgradeData.GetSourceSynthesisUnit()) { mappingEntry.cardUpgrade = cardUpgradeData; } } cardUpgradeData.InternalSetLinkedPactDuplicateRarity(CollectableRarity.Rare); } } }
Calling Private Methods with Harmony and Reverse Patch
I covered this in a previous blog post, but it did turn out that this method worked well for accessing a calling a private method from within the base game’s code when I wanted to. Since I just wanted to forcibly call that private method, CollectMappingData, I did not need any other extra logic to run at the time. This makes the code for it very tight as follows:
[HarmonyPatch(typeof(UnitSynthesisMapping), "CollectMappingData", new Type[] { })] public class RecallingCollectMappingData { [HarmonyReversePatch] public static void MyTest(object instance) { // It's a stub so it has no initial content } }
The hardest part of implementing this was that it would work easiest with the single existing instance of the UnitSynthesisMapping class during the runtime of the game. We could instantiate our own instance to supply to this method, but without the other data it did not work at all. I was finally able to find a route to getting a reference to the actual existing instance through Trainworks actually.
Trainworks has a way to access the AllGameData class instance in the game, which gave me an entry point into a lot of data in the game. Through this, I was able to access the BalanceData class, which then gave me direct access to the existing UnitSynthesisMapping instance. This could then be passed into my Reverse Patch to call the method just as if it had been called from within that specific instance, which has all the access to the game’s data and existing lists if needed. This example path is shown below:
public static void FindUnitSynthesisMappingInstanceToStub() { // Gets a reference to AllGameData with Trainworks AllGameData testData = ProviderManager.SaveManager.GetAllGameData(); CustomUnitSynthesisPatcher.Log("Got reference to AllGameData: " + testData.name); // Use AllGameData to get access to BalanceData BalanceData balanceData = testData.GetBalanceData(); CustomUnitSynthesisPatcher.Log("Got reference to BalanceData: " + balanceData.name); // Use BalanceData to get access to the current instance of the UnitSynthesisMapping UnitSynthesisMapping mappingInstance = balanceData.SynthesisMapping; if (mappingInstance == null) { CustomUnitSynthesisPatcher.Log("Failed to find a mapping instance."); } else { CustomUnitSynthesisPatcher.Log("Able to find mapping instance: " + mappingInstance.GetID()); // Test to see if this is a different instance } // Calls CollectMappingData method RecallingCollectMappingData.MyTest(mappingInstance); CustomUnitSynthesisPatcher.Log("Called CollectMappingData."); }
Key String Tests for this Approach: CardEffectData and CardUpgradeData
Renaming the EffectStateName of the CardEffectData to Override Trainworks Default
It turns out two of the main hiccups I ran into with this approach were simply because of string checks not finding exactly what they wanted (or accidentally finding too many strings that were exactly the same). The first issue was in the CollectMappingData method’s first foreach loop, which includes in its if statement that cardData.IsSpawnerCard(). Somewhere along the lines it checks that the card has a CardEffectData specifically named “CardEffectSpawnMonster”. This was not being picked up initially however because Trainworks sets that property of your card to a very long string that also included “CardEffectSpawnMonster”, but all that information kept that part of the check from returning true. So simply making sure to rename that specific CardEffectData object’s EffectStateName to “CardEffectSpawnMonster” after setting EffectStateType (since this includes methods for creating its own EffectStateName) gets through this issue.
Setting the UpgradeTitleKey of your Synthesis CardUpgradeData to Override Trainworks Default
It just so happens that Trainworks default string options for some of these do not match the exact needs of this new Monster Train class (as they were made before these existed), and this was another case of that (although it made more sense). In the synthesis you build, if an UpgradeTitleKey is not set, a fairly empty default one is made for your CardUpgradeData. If you do not set this for several CardUpgradeData objects you make, then they will all have this SAME default UpgradeTitleKey. As a duplication check however, when adding a new CardUpgradeData to the overall list in the game, Trainworks checks by UpgradeTitleKey whether that name already exists or not, and if it does, it removes it from the list and adds the new one. As one would expect, if you have a bunch of objects with the same UpgradeTitleKey then, they all continually get replaced until only the last one remains (as it never gets replaced).
This is easy enough to solve by simply making sure to set a unique UpgradeTitleKey for each of your unit synthesis CardUpgardeDataBuilder objects. This is all that is required to make sure they do not keep deleting themselves in the current iteration of Trainworks (and is just a good practice in general).
via Blogger http://stevelilleyschool.blogspot.com/2021/07/modding-monster-train-patch-to-allow.html