From ce42d1dd179783880b50b5bb3495c04710d7f5d8 Mon Sep 17 00:00:00 2001 From: Tellow Krinkle Date: Sat, 25 Aug 2018 15:14:31 -0500 Subject: [PATCH] Added EMIPGenerator and UnityTextModifier --- EMIPGenerator.py | 151 +++++++++++++++++++++++++++++++++++++++++++ UnityTextModifier.py | 121 ++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 EMIPGenerator.py create mode 100644 UnityTextModifier.py diff --git a/EMIPGenerator.py b/EMIPGenerator.py new file mode 100644 index 0000000..0873705 --- /dev/null +++ b/EMIPGenerator.py @@ -0,0 +1,151 @@ +import sys +import os +import re +from PIL import Image +from PIL import ImageOps +from unitypack.asset import Asset + +if len(sys.argv) < 4: + print("Usage: " + sys.argv[0] + " assetfile.assets inputFolder outputFile.emip\nInput folder should contain files whose names start with the object ID they want to replace.") + exit() + +if not os.path.isdir(sys.argv[2]): + print("Input folder " + sys.argv[2] + " must be a directory!") + exit() + +class AssetEdit: + def __init__(self, file, id, name, type): + self.file = file + self.id = id + self.name = name + self.type = type + self.shouldDecode = False + + @property + def filePath(self): + return sys.argv[2] + "/" + self.file + + def pngToTexture2D(self, pngData): + image = Image.open(self.filePath) + image = ImageOps.flip(image) + imageData = image.convert("RGBA").tobytes() + output = len(self.name).to_bytes(4, byteorder="little") + output += self.name.encode("utf-8") + output += b"\0" * ((4 - len(self.name)) % 4) + output += image.width.to_bytes(4, byteorder="little") + output += image.height.to_bytes(4, byteorder="little") + output += len(imageData).to_bytes(4, byteorder="little") + output += (4).to_bytes(4, byteorder="little") # m_TextureFormat + output += (1).to_bytes(4, byteorder="little") # m_MipCount + output += b"\0\x01\0\0" # Flags + output += (1).to_bytes(4, byteorder="little") # m_ImageCount + output += (2).to_bytes(4, byteorder="little") # m_TextureDimension + output += (2).to_bytes(4, byteorder="little") # m_FilterMode + output += (2).to_bytes(4, byteorder="little") # m_Aniso + output += (0).to_bytes(4, byteorder="little") # m_MipBias + output += (1).to_bytes(4, byteorder="little") # m_WrapMode + output += (0).to_bytes(4, byteorder="little") # m_LightmapFormat + output += (1).to_bytes(4, byteorder="little") # m_ColorSpace + output += len(imageData).to_bytes(4, byteorder="little") + output += imageData + if self.type > 0: + output += b"\0" * 12 # Empty Streaming Data + return output + + def loadTexture2DInfo(self, assets, bundle): + self.shouldDecode = True + obj = assets.objects[self.id] + data = bundle[obj.data_offset:(obj.data_offset + obj.size)] + length = int.from_bytes(data[0:4], byteorder='little') + paddedLength = length + (4 - length) % 4 + self.name = data[4:4+length].decode('utf-8') + + def getAssetInfo(self, assets, bundle): + if self.id == None: + for id, obj in assets.objects.items(): + if obj.type != self.type: continue + # UnityPack is broken and overreads its buffer if we try to use it to automatically decode things, so instead we use this sometimes-working thing to decode the name + data = bundle[obj.data_offset:(obj.data_offset + obj.size)] + length = int.from_bytes(data[0:4], byteorder='little') + paddedLength = length + (4 - length) % 4 + if length + 4 <= len(data): + if self.name == data[4:4+length].decode('utf-8'): + self.id = id + if obj.type == "Texture2D" and self.file[-4:] == ".png": + print(f"Will replace object #{id} with contents of {self.file} converted to a Texture2D") + self.shouldDecode = True + else: + print(f"Will replace object #{id} with contents of {self.file}") + break + else: + if self.file[-4:] == ".png": + self.loadTexture2DInfo(assets, bundle) + print(f"Will replace object #{self.id} with contents of {self.file} converted to a Texture2D") + else: + print(f"Will replace object #{self.id} with contents of {self.file}") + + if self.id == None: + print(f"Couldn't find object named {self.name} for {self.file}, skipping") + return + obj = assets.objects[self.id] + self.type = obj.type_id + + @property + def bytes(self): + out = (2).to_bytes(4, byteorder='little') # Unknown + out += b"\0" * 3 # Unknown + out += self.id.to_bytes(4, byteorder='little') # Unknown + out += b"\0" * 4 # Unknown + out += self.type.to_bytes(4, byteorder='little', signed=True) # Type + out += b"\xff" * 2 # Unknown + with open(self.filePath, "rb") as file: + fileBytes = file.read() + if self.shouldDecode: + fileBytes = self.pngToTexture2D(fileBytes) + out += len(fileBytes).to_bytes(4, byteorder='little') # Payload Size + out += b"\0" * 4 # Unknown + out += fileBytes # Payload + return out + +def generateHeader(numEdits): + header = b"EMIP" # Magic + header += b"\0" * 4 # Unknown + header += (1).to_bytes(4, byteorder='big') # Number of files + header += b"\0" * 4 # Unknown + if os.path.abspath(sys.argv[1])[1] == ":": # Windows paths will be read properly, UNIX paths won't since UABE will be run with wine, so use a relative path + path = os.path.abspath(sys.argv[1]).encode('utf-8') + else: + path = sys.argv[1].encode('utf-8') + header += len(path).to_bytes(2, byteorder='little') # Path length + header += path # File path + header += numEdits.to_bytes(4, byteorder='little') # Number of file changes + return header + +edits = [] + +for file in os.listdir(sys.argv[2]): + if file[0] == ".": continue + matches = re.match(r"^(\d+).*", file) + if matches: + edits.append(AssetEdit(file, int(matches.group(1)), None, None)) + else: + name = os.path.splitext(file)[0] + parts = name.split("_") + if len(parts) < 2: continue + edits.append(AssetEdit(file, None, "_".join(parts[:-1]), parts[-1])) + +with open(sys.argv[1], "rb") as assetsFile: + bundle = assetsFile.read() + assetsFile.seek(0) + assets = Asset.from_file(assetsFile) + for edit in edits: + edit.getAssetInfo(assets, bundle) + edits = [x for x in edits if x.id != None] + +edits = sorted(edits, key=lambda x: x.id) + +with open(sys.argv[3], "wb") as outputFile: + outputFile.write(generateHeader(len(edits))) + for edit in edits: + outputFile.write(edit.bytes) + diff --git a/UnityTextModifier.py b/UnityTextModifier.py new file mode 100644 index 0000000..a9027cf --- /dev/null +++ b/UnityTextModifier.py @@ -0,0 +1,121 @@ +import sys +import os +import json +import unitypack +from unitypack.asset import Asset + +if len(sys.argv) < 4: + print("Usage: " + sys.argv[0] + " assetfile.assets edits.json outputfolder\nEdits.json should be an array of objects with the fields 'CurrentEnglish', 'CurrentJapanese', 'NewEnglish', and 'NewJapanese'. An optional 'Discriminator' field can be added if multiple texts have the same English and Japanese values."); + exit() + +if not os.path.isdir(sys.argv[3]): + print("Output folder " + sys.argv[3] + " must be a directory!") + exit() + +class ScriptEdit: + def __init__(self, currentEnglish, currentJapanese, newEnglish, newJapanese, discriminator=None): + self.currentEnglish = currentEnglish + self.currentJapanese = currentJapanese + self.newEnglish = newEnglish + self.newJapanese = newJapanese + self.discriminator = discriminator + + @staticmethod + def fromJSON(json): + if "Discriminator" in json: + discriminator = json["Discriminator"] + else: + discriminator = None + return ScriptEdit(json["CurrentEnglish"], json["CurrentJapanese"], json["NewEnglish"], json["NewJapanese"], discriminator) + + @staticmethod + def bytesFromString(string): + strBytes = string.encode('utf-8') + out = len(strBytes).to_bytes(4, byteorder='little') + out += strBytes + out += b"\0" * ((4 - len(strBytes)) % 4) + return out + + @property + def expectedBytes(self): + return self.bytesFromString(self.currentEnglish) + self.bytesFromString(self.currentJapanese) + + @property + def newBytes(self): + return self.bytesFromString(self.newEnglish) + self.bytesFromString(self.newJapanese) + + def findInAssetBundle(self, bundle): + search = self.expectedBytes + offsets = [] + start = 0 + while True: + offset = bundle.find(search, start) + if offset == -1: + break + offsets.append(offset) + start = offset + 1 + if len(offsets) == 0: + raise IndexError(f"No asset found for {self.currentEnglish} / {self.currentJapanese}") + if self.discriminator == None: + if len(offsets) > 1: + raise IndexError(f"Multiple assets found for {self.currentEnglish} / {self.currentJapanese}, candidates are " + ", ".join(f"{index}: 0x{offset:x}" for index, offset in enumerate(offsets)) + ". Please select one and add a Discriminator tag for it.") + self.offset = offsets[0] + else: + if len(offsets) <= self.discriminator: + raise IndexError(f"Not enough offsets found for ${self.currentEnglish} / {self.currentJapanese} to meet request for #{self.discriminator}, there were only {len(offsets)}") + self.offset = offsets[self.discriminator] + + def checkObject(self, id, object, bundle): + if obj.data_offset <= self.offset and obj.data_offset + obj.size >= self.offset: + self.id = id + self.currentData = bundle[obj.data_offset:(obj.data_offset + obj.size)] + expectedBytes = self.expectedBytes + smallOffset = self.currentData.find(expectedBytes) + self.newData = self.currentData[:smallOffset] + self.newBytes + self.currentData[(smallOffset + len(expectedBytes)):] + print(f"Found {self.currentEnglish} / {self.currentJapanese} in object #{id}") + + def write(self, folder): + try: + self.newData + except: + print(f"Failed to find object id for {self.currentEnglish} / {self.currentJapanese}!") + return + filename = folder + "/" + str(self.id) + ".dat" + with open(filename, "wb") as outputFile: + outputFile.write(self.newData) + + def __repr__(self): + string = f"ScriptEdit(currentEnglish: {self.currentEnglish}, currentJapanese: {self.currentJapanese}, newEnglish: {self.newEnglish}, newJapanese: {self.newJapanese}" + if self.discriminator != None: + string += f", discriminator: {self.discriminator}" + try: string += f", offset: 0x{self.offset:x}" + except: pass + return string + ")" + + def __str__(self): + try: return f"" + except: return "" + + +with open(sys.argv[2]) as jsonFile: + edits = [ScriptEdit.fromJSON(x) for x in json.load(jsonFile)] + +with open(sys.argv[1], "rb") as assetsFile: + bundle = assetsFile.read() + newEdits = [] + for edit in edits: + try: + edit.findInAssetBundle(bundle) + newEdits.append(edit) + print(f"Found {edit.currentEnglish} / {edit.currentJapanese} at offset 0x{edit.offset:x}") + except IndexError as e: + print(e) + edits = newEdits + + assetsFile.seek(0) + assets = Asset.from_file(assetsFile) + for id, obj in assets.objects.items(): + for edit in edits: + edit.checkObject(id, obj, bundle) + for edit in edits: + edit.write(sys.argv[3])