├── README.md ├── editAssets.py ├── treeDumper.py └── typetrees.json /README.md: -------------------------------------------------------------------------------- 1 | # Translation patch for Hololive ERROR 2 | 3 | ## How do I install the patch? 4 | Grab the Translation.zip file for the latest version from the [releases page](https://github.com/lugia19/ErrorPatcher/releases), and unzip it in the same folder as HololiveERROR.exe. 5 | You now should have a folder called TranslationPatch. 6 | 7 | If you're on windows, simply run patchGame.bat, select the language you want and you're done. 8 | 9 | If you run into trouble, you can contact me via discord at lugia19#1189 10 | 11 | ### What if I'm not on Windows? 12 | Download editAssets.py, put it in the TranslationPatch folder, install the required libraries (UnityPy and Pillow) and run it. 13 | 14 | UnityPy might give you some trouble as it requires pythonnet 3, which can't currently install normally via pip. 15 | Just run `pip install git+https://github.com/pythonnet/pythonnet.git` (Requires git to be installed). 16 | 17 | 18 | ## How do I make a translation for another language? 19 | 20 | The short version is: 21 | - Download the ResourcesForTranslators.zip file from the releases page (it has its own README with more details) 22 | - Create a folder inside of TranslationPatch with the language name, with a textureOverrides subfolder 23 | - Edit the subtitles.csv to include your own translation 24 | - Run the csvToJSON script on it to create a subtitles.json file, and put it in the newly created folder. 25 | - Use the provided PSD files to edit the various textures, and put the resulting PNGs in the textureOverrides folder 26 | - Done! Feel free to send it to me and I'll include it in the download. 27 | 28 | ## Special Thanks 29 | Special thanks to Tungsten for making the texture edits and darktossgen for the English translation, as well as putting up with all my rants. 30 | 31 | This program leverages the [UnityPy](https://pypi.org/project/UnityPy/) library to modify the game assets. Special thanks to its developer for helping me figure out how to use it properly. 32 | If you'd like to know more about how the patch itself works, as well as how to make something similar for other games, I made [this video](https://www.youtube.com/watch?v=FTaMpUAeU9Y) (Warning: BOOORING) 33 | -------------------------------------------------------------------------------- /editAssets.py: -------------------------------------------------------------------------------- 1 | import os 2 | import UnityPy 3 | from collections import Counter 4 | import zipfile 5 | import json 6 | import re 7 | import PIL as PIL 8 | from UnityPy.enums import ClassIDType 9 | from UnityPy.helpers.TypeTreeHelper import TypeTreeNode 10 | from UnityPy.enums.TextureFormat import TextureFormat 11 | 12 | STRUCTS_PATH = "" 13 | TYPES = [ 14 | # Images 15 | # "Sprite", 16 | "Texture2D", 17 | # Text (filish) 18 | # "TextAsset", 19 | "MonoBehaviour", 20 | # Font 21 | # "Font", 22 | # Audio 23 | # "AudioClip", 24 | ] 25 | 26 | STRUCTS = {} 27 | MB_JSON = {} 28 | T2D_JSON = {} 29 | SPECIAL_OVR = {} 30 | tlDebug = False 31 | dataFolder = "" 32 | # Credit to K0lb3 for the code I based this off of, as well as the unityPy library in general: 33 | # https://github.com/K0lb3/Romancing-SaGa-Re-univerSe-asset-downloader/blob/24388c660c3ed40665d840b6cf46bbbd48d63b8e/lib/asset.py 34 | def main(): 35 | rootDirectory = os.getcwd() 36 | while not (os.path.isfile((os.path.join(rootDirectory, "GameAssembly.dll")))): 37 | if os.path.dirname(rootDirectory) == rootDirectory: 38 | input("GameAssembly.dll could not be found in any above directory!") 39 | return 1 40 | else: 41 | rootDirectory = os.path.dirname(rootDirectory) 42 | # Move up the directory tree, one directory at a time 43 | TL_path = os.path.join(rootDirectory, "TranslationPatch") 44 | if not (os.path.isdir(TL_path)): 45 | input("TranslationPatch folder not found!") 46 | return 1 47 | global dataFolder 48 | for dirName in os.listdir(rootDirectory): 49 | if os.path.isdir(os.path.join(rootDirectory, dirName)): 50 | if "_Data" in dirName: 51 | dataFolder = os.path.join(rootDirectory, dirName) 52 | break # We've got our _Data directory 53 | # print(src) 54 | if not (os.path.isdir(dataFolder)): 55 | input("Data folder not found!") 56 | return 57 | if os.path.isfile(os.path.join(TL_path, "tlDebug.txt")): 58 | print("Would you like the subtitle IDs to be printed alongside the text?") 59 | answerTemp = input("y/n\n") 60 | while answerTemp.lower() != "y" and answerTemp.lower() != "n": 61 | print("Not a valid answer. \n") 62 | answerTemp = input("y/n\n") 63 | global tlDebug 64 | 65 | if answerTemp.lower() == "y": 66 | tlDebug = True 67 | else: 68 | tlDebug = False 69 | subFolders = [f.path for f in os.scandir(TL_path) if (f.is_dir() and f.path[f.path.rfind("\\") + 1::] != "exeData" 70 | and f.path[f.path.rfind("\\") + 1::] != "specialOverrides")] 71 | i = 1 72 | for sf in subFolders: 73 | print(str(i) + ") " + sf[sf.rfind("\\") + 1::]) 74 | i = i + 1 75 | if os.path.isfile(os.path.join(dataFolder, "sharedassets0.assets_JP")): 76 | print(str(i) + ") Restore original language") 77 | 78 | fileList = [] # These are the files that require editing. 79 | i = 0 80 | while os.path.isfile(os.path.join(dataFolder, "level" + str(i))): 81 | fileList.append("level" + str(i)) 82 | i = i + 1 83 | i = 0 84 | while os.path.isfile(os.path.join(dataFolder, "sharedassets" + str(i) + ".assets")): 85 | fileList.append("sharedassets" + str(i) + ".assets") 86 | i = i + 1 87 | # if os.path.isfile(os.path.join(dataFolder, "globalgamemanagers.assets")): 88 | # fileList.append("globalgamemanagers.assets") 89 | if os.path.isfile(os.path.join(dataFolder, "resources.assets")): 90 | fileList.append("resources.assets") 91 | 92 | TL_num = -1 93 | while not (0 <= TL_num - 1 <= len(subFolders)): 94 | try: 95 | TL_num = int(input("Please select which translation you would like to use.\n")) 96 | except: 97 | print("Not a valid number.") 98 | 99 | global SPECIAL_OVR 100 | global T2D_JSON 101 | 102 | if os.path.isdir(os.path.join(TL_path, "specialOverrides")): 103 | debugFileList = [f.path for f in os.scandir(os.path.join(TL_path, "specialOverrides")) if 104 | (f.is_file() and (f.name.endswith(".json")))] 105 | for f in debugFileList: 106 | SPECIAL_OVR[f[f.rfind("#") + 1:f.rfind("."):]] = f 107 | 108 | TL_num = TL_num - 1 109 | if TL_num == len(subFolders): 110 | # User wants to restore japanese language. Do that and exit. 111 | for resFile in fileList: 112 | curFile = os.path.join(dataFolder, resFile) 113 | if os.path.isfile(curFile + "_JP"): # Only do this is there's a corresponding JP version. 114 | if os.path.isfile(curFile): os.remove(curFile) 115 | os.rename(curFile + "_JP", curFile) 116 | return 117 | TL_path = subFolders[TL_num] 118 | 119 | if os.path.isdir(os.path.join(TL_path, "textureOverrides")): 120 | textureOverrides = [f.path for f in os.scandir(os.path.join(TL_path, "textureOverrides")) if 121 | (f.is_file() and (f.name.endswith(".png")))] 122 | for f in textureOverrides: 123 | T2D_JSON[f[f.rfind("#") + 1:f.rfind("."):]] = f 124 | 125 | global STRUCTS_PATH 126 | STRUCTS_PATH = os.path.join(TL_path[:TL_path.rfind("\\"):], "exeData", "typetrees.json") 127 | 128 | if not (os.path.isfile(STRUCTS_PATH)): 129 | input("Error, file is not alongside typetrees.json, did you change the working directory?") 130 | return 1 131 | for resFile in fileList: 132 | curFile = os.path.join(dataFolder, resFile) 133 | if not os.path.isfile(curFile + "_JP"): # If JP file doesn't exist, move the original one. 134 | if os.path.isfile(curFile): 135 | os.rename(curFile, curFile + "_JP") 136 | else: 137 | input("Error, resource file " + curFile + " not found.") 138 | return 139 | edit_asset(curFile + "_JP", TL_path) 140 | 141 | input("All done! Press Enter to exit.\n") 142 | 143 | 144 | def edit_asset(inp, TL_path): 145 | env = UnityPy.load(inp) 146 | env.files["globalgamemanagers.assets"] = env.load_file(os.path.join(dataFolder, "globalgamemanagers.assets")) 147 | print("Modifying file " + inp) 148 | # make sure that Texture2Ds are at the end 149 | objs = sorted( 150 | (obj for obj in env.objects if obj.type.name in TYPES), 151 | key=lambda x: 1 if x.type == ClassIDType.Texture2D else 0, 152 | ) 153 | # check how the path should be handled 154 | if len(objs) == 1 or ( 155 | len(objs) == 2 and objs[0].type == ClassIDType.Sprite and objs[1].type == ClassIDType.Texture2D 156 | ): 157 | overwrite_obj(objs[0], os.path.dirname(TL_path), True) 158 | else: 159 | used = [] 160 | for obj in objs: 161 | if obj.path_id not in used: 162 | used.extend(overwrite_obj(obj, TL_path, True)) 163 | 164 | # Save patched file to directory. 165 | with open(inp[0:inp.rfind("_")], "wb") as f: 166 | f.write(env.file.save()) 167 | 168 | 169 | def csvToJsonData(TSVfileLocation): 170 | data = {} 171 | with open(TSVfileLocation, encoding="utf8") as f: 172 | lines = f.readlines() 173 | last = lines[-1] 174 | lineIndex = 0 175 | for line in lines: 176 | lineIndex = lineIndex + 1 177 | if lineIndex == 1: 178 | try: 179 | # Check if the first cell is an integer. 180 | int(line[0:line.index("\t"):]) 181 | except ValueError: 182 | # It's not, assume these are the headers. 183 | continue 184 | try: 185 | currentID = line[0:line.index("\t"):] 186 | tlText = line[line.rfind("\t") + 1:len(line):].rstrip() 187 | except: 188 | print("Error! Subtitles files is not formatted correctly, please check line " + str(lineIndex)) 189 | input("Press enter to exit.") 190 | raise Exception("Incorrect formatting") 191 | data[currentID] = tlText 192 | return data 193 | 194 | 195 | def overwrite_obj(obj, TL_path: str, append_name: bool = False) -> list: 196 | global MB_JSON 197 | if obj.type.name not in TYPES: 198 | return [] 199 | if not STRUCTS: 200 | with open(STRUCTS_PATH, "rt", encoding="utf8") as f: 201 | STRUCTS.update(json.load(f)) 202 | 203 | if not MB_JSON: 204 | JSONdata = csvToJsonData(os.path.join(TL_path, "subtitles.tsv")) 205 | if os.path.isfile(os.path.join(TL_path, "subtitles.tsv")): 206 | MB_JSON = csvToJsonData(os.path.join(TL_path, "subtitles.tsv")) 207 | if os.path.isfile(os.path.join(TL_path, "subtitles.csv")): 208 | MB_JSON = csvToJsonData(os.path.join(TL_path, "subtitles.csv")) 209 | if not MB_JSON: 210 | print("Error! Could not find subtitles tsv/csv file.") 211 | input("Press enter to exit.") 212 | raise Exception("Subtitles not found") 213 | 214 | data = obj.read() 215 | 216 | if obj.type == ClassIDType.TextAsset: 217 | print("TextAsset found!") 218 | input(data.script) 219 | 220 | # streamlineable types 221 | export = None 222 | if obj.type == ClassIDType.MonoBehaviour: 223 | 224 | # if TLDebug is set, modify all available monobehaviours 225 | if not str(obj.path_id) in MB_JSON: 226 | if not str(obj.path_id) in SPECIAL_OVR: 227 | return [obj.path_id] 228 | # Only edit if the path id is in the dict 229 | # The data structure of MonoBehaviours is custom 230 | # and is stored as nodes 231 | if obj.serialized_type.nodes: 232 | # This piece of code is never going to run, but I might as well leave it in. Better safe than sorry. 233 | export = json.dumps( 234 | obj.read_typetree(), indent=4, ensure_ascii=False 235 | ) 236 | 237 | i = export.rfind("m_Text\": ") + len("m_Text\": ") 238 | JPtext = export[i + 1:export.index("\n", i) - 1] 239 | export = export.replace(JPtext, MB_JSON[str(obj.path_id)]) 240 | 241 | export = json.loads(export) 242 | obj.save_typetree(export) 243 | else: 244 | for i in [1]: 245 | # abuse that a finished loop calls else 246 | # while a broken one doesn't 247 | script = data.m_Script 248 | if not script: 249 | print("Script not found!") 250 | continue 251 | script = script.read() 252 | try: 253 | nodes = [TypeTreeNode(x) for x in STRUCTS[script.m_AssemblyName][script.m_ClassName]] 254 | except KeyError as e: 255 | print("Key not found for: ") 256 | print(e) 257 | continue 258 | # adjust the name 259 | name = ( 260 | f"{script.m_ClassName}-{data.name}" 261 | if data.name 262 | else script.m_ClassName 263 | ) 264 | 265 | print("Patching MonoBehaviour with ID : " + str(obj.path_id)) 266 | try: 267 | currJSON = obj.read_typetree(nodes) 268 | except Exception as e: 269 | print(e, obj.path_id, script.m_AssemblyName, script.m_ClassName) 270 | return [obj.path_id] 271 | 272 | # currJSON = obj.read_typetree(nodes) 273 | if str(obj.path_id) in SPECIAL_OVR: 274 | print("Found a special override, apply it.") 275 | # Replace the entire JSON with the modified version. 276 | # This way we can still apply translations as normal. 277 | # Was a debug-only feature, now I use it for special cases so I don't have to hardcode them in. 278 | with open(SPECIAL_OVR[str(obj.path_id)], "rt", encoding="utf8") as f: 279 | currJSON = json.loads(f.read()) 280 | 281 | textItem = "" 282 | if "m_text" in currJSON: 283 | textItem = "m_text" 284 | elif "m_Text" in currJSON: 285 | textItem = "m_Text" 286 | if textItem != "": 287 | newText = "" 288 | if tlDebug: 289 | newText = "#" + str(obj.path_id) + " " 290 | if MB_JSON[str(obj.path_id)] == "": 291 | newText = newText + currJSON[textItem] 292 | else: 293 | newText = newText + MB_JSON[str(obj.path_id)] 294 | # This reverse escapes special characters. 295 | currJSON[textItem] = newText.encode('raw_unicode_escape').decode('unicode_escape') 296 | 297 | obj.save_typetree(currJSON, nodes) 298 | break 299 | elif obj.type == ClassIDType.Texture2D: 300 | from PIL import Image 301 | if str(obj.path_id) in T2D_JSON: # Check if ID is in JSON. 302 | print("Patching Texture2D with ID : " + str(obj.path_id)) 303 | img_data = Image.open(os.path.join(TL_path, "textureOverrides", T2D_JSON[str(obj.path_id)])) 304 | data.set_image(img_data, mipmap_count=data.m_MipCount) 305 | data.save() 306 | 307 | # elif obj.type == ClassIDType.Sprite: 308 | # if "idou" == data.name: 309 | # return [obj.path_id] 310 | return [obj.path_id] 311 | 312 | 313 | class Fake: 314 | def __init__(self, **kwargs) -> None: 315 | self.__dict__.update(**kwargs) 316 | 317 | 318 | if __name__ == "__main__": 319 | main() 320 | -------------------------------------------------------------------------------- /treeDumper.py: -------------------------------------------------------------------------------- 1 | # py3 2 | # requirements: 3 | # pythonnet 3+ 4 | # pip install git+https://github.com/pythonnet/pythonnet/ 5 | # TypeTreeGenerator 6 | # https://github.com/K0lb3/TypeTreeGenerator 7 | # requires .NET 5.0 SDK 8 | # https://dotnet.microsoft.com/download/dotnet/5.0 9 | # 10 | # pythonnet 2 and TypeTreeGenerator created with net4.8 works on Windows, 11 | # so it can do without pythonnet_init, 12 | # all other systems need pythonnet 3 and either .net 5 or .net core 3 and pythonnet_init 13 | 14 | 15 | ############################ 16 | # 17 | # Warning: This example isn't for beginners 18 | # 19 | ############################ 20 | 21 | import os 22 | import UnityPy 23 | from typing import Dict 24 | import json 25 | 26 | 27 | ROOT = os.getcwd() 28 | TYPETREE_GENERATOR_PATH = os.path.join(ROOT, "l19-EN-patcher","TypeTreeGenerator") 29 | 30 | 31 | def main(): 32 | # dump the trees for all classes in the assembly 33 | 34 | dll_folder = os.path.join(ROOT, "hololiveERROR_Data", "il2cpp_dump", "DummyDll") 35 | print(dll_folder) 36 | asset_path = os.path.join(ROOT, "hololiveERROR_Data") 37 | print(asset_path) 38 | tree_path = os.path.join(ROOT, "typetrees.json") 39 | trees = dump_assembly_trees(dll_folder, tree_path) 40 | # by dumping it as json, it can be redistributed, 41 | # so that other people don't have to setup pythonnet3 42 | # People who don't like to share their decrypted dlls could also share the relevant structures this way. 43 | 44 | export_monobehaviours(asset_path, trees) 45 | 46 | 47 | def export_monobehaviours(asset_path: str, trees: dict): 48 | for r, d, fs in os.walk(asset_path): 49 | for f in fs: 50 | try: 51 | env = UnityPy.load(os.path.join(r, f)) 52 | except: 53 | continue 54 | for obj in env.objects: 55 | if obj.type == "MonoBehaviour": 56 | d = obj.read() 57 | if obj.serialized_type.nodes: 58 | tree = obj.read_typetree() 59 | else: 60 | if not d.m_Script: 61 | print("Script not found!") 62 | continue 63 | # RIP, no referenced script 64 | # can only dump raw 65 | script = d.m_Script.read() 66 | # on-demand solution without already dumped tree 67 | # nodes = generate_tree( 68 | # g, script.m_AssemblyName, script.m_ClassName, script.m_Namespace 69 | # ) 70 | if script.m_ClassName not in trees: 71 | # class not found in known trees, 72 | # might have to add the classes of the other dlls 73 | continue 74 | nodes = FakeNode(**trees[script.m_ClassName]) 75 | tree = obj.read_typetree(nodes) 76 | 77 | # save tree as json whereever you like 78 | 79 | 80 | def dump_assembly_trees(dll_folder: str, out_path: str): 81 | # init pythonnet, so that it uses the correct .net for the generator 82 | pythonnet_init() 83 | # create generator 84 | g = create_generator(dll_folder) 85 | 86 | # generate a typetree for all existing classes in the Assembly-CSharp 87 | # while this could also be done dynamically for each required class, 88 | # it's faster and easier overall to just fetch all at once 89 | bigJson = {} 90 | for file in os.listdir(dll_folder): 91 | filename = os.fsdecode(file) 92 | if filename.endswith(".dll"): 93 | bigJson[filename] = generate_tree(g, filename, "", "") 94 | trees = generate_tree(g, "Assembly-CSharp.dll", "", "") 95 | # bigJson["Assembly-CSharp.dll"] = trees 96 | if out_path: 97 | with open("typetrees.json", "wt", encoding="utf8") as f: 98 | json.dump(bigJson, f, ensure_ascii=False) 99 | return trees 100 | 101 | 102 | def pythonnet_init(): 103 | """correctly sets-up pythonnet for the typetree generator""" 104 | # prepare correct runtime 105 | from clr_loader import get_coreclr 106 | from pythonnet import set_runtime 107 | print(os.path.join(TYPETREE_GENERATOR_PATH, "TypeTreeGenerator.runtimeconfig.json")) 108 | rt = get_coreclr( 109 | os.path.join(TYPETREE_GENERATOR_PATH, "TypeTreeGenerator.runtimeconfig.json") 110 | ) 111 | 112 | set_runtime(rt) 113 | 114 | 115 | def create_generator(dll_folder: str): 116 | """Loads TypeTreeGenerator library and returns an instance of the Generator class.""" 117 | # temporarily add the typetree generator dir to paths, 118 | # so that pythonnet can find its files 119 | import sys 120 | 121 | sys.path.append(TYPETREE_GENERATOR_PATH) 122 | 123 | # 124 | import clr 125 | 126 | clr.AddReference("TypeTreeGenerator") 127 | 128 | # import Generator class from the loaded library 129 | from Generator import Generator 130 | 131 | # create an instance of the Generator class 132 | g = Generator() 133 | # load the dll folder into the generator 134 | g.loadFolder(dll_folder) 135 | return g 136 | 137 | 138 | class FakeNode: 139 | """A fake/minimal Node class for use in UnityPy.""" 140 | 141 | def __init__(self, **kwargs): 142 | self.__dict__.update(**kwargs) 143 | 144 | 145 | def generate_tree( 146 | g: "Generator", 147 | assembly: str, 148 | class_name: str, 149 | namespace: str, 150 | unity_version=[2021, 1, 20, 1], 151 | ) -> Dict[str, Dict]: 152 | """Generates the typetree structure / nodes for the specified class.""" 153 | # C# System 154 | from System import Array 155 | 156 | unity_version_cs = Array[int](unity_version) 157 | 158 | # fetch all type definitions 159 | def_iter = g.getTypeDefs(assembly, class_name, namespace) 160 | 161 | # create the nodes 162 | trees = {} 163 | for d in def_iter: 164 | try: 165 | nodes = g.convertToTypeTreeNodes(d, unity_version_cs) 166 | except Exception as e: 167 | # print(d.Name, e) 168 | continue 169 | trees[d.Name] = [ 170 | { 171 | "level": node.m_Level, 172 | "type": node.m_Type, 173 | "name": node.m_Name, 174 | "meta_flag": node.m_MetaFlag, 175 | } 176 | for node in nodes 177 | ] 178 | return trees 179 | 180 | 181 | if __name__ == "__main__": 182 | main() 183 | 184 | --------------------------------------------------------------------------------