Files
ui-editing-scripts/scripts/EMIPGenerator.py
drojf 33d24e3f38 Remove requirement of using Python 3.10 and below
- I added a 'legacy mode' flag to revert https://github.com/HearthSim/UnityPack/pull/101 as it caused problems on Rei
 - I tested the new setup (using custom unitypack) vs old one, and the output .asset files were bit-for-bit identical
2024-01-26 10:30:43 +11:00

200 lines
7.7 KiB
Python

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, unityVersion):
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)
if unityVersion[0] == 2019:
output += (4).to_bytes(4, byteorder="little") # m_ForcedFallbackFormat
output += (0).to_bytes(4, byteorder="little") # m_DownscaleFallback
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
if unityVersion[0] == 2019:
output += (0).to_bytes(4, byteorder="little") # m_StreamingMipmapsPriority
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
if unityVersion[0] == 5:
output += (1).to_bytes(4, byteorder="little") # m_WrapMode
elif unityVersion[0] == 2017 or unityVersion[0] == 2019:
output += (1).to_bytes(4, byteorder="little") * 3 # m_wrap{U,V,W}
else:
sys.stderr.write("Warning: Unrecognized Unity version: " + str(unityVersion[0]))
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.hasStream:
output += b"\0" * 12 # Empty Streaming Data
return output
def loadTexture2DInfo(self, assets, bundle):
self.shouldDecode = True
print(f'Writing {self.file} with id {self.id}')
if self.id not in assets.objects:
raise Exception(f"ERROR: When loading Texture2D info for file {self.file} with id {self.id}, the id was not found. Are you sure the ID {self.id} is correct/exists in the sharedassets?")
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')
arrayPos = paddedLength + 4 * 15
arrayLength = int.from_bytes(data[arrayPos:arrayPos+4], byteorder="little")
paddedArrayLength = arrayLength + (4 - arrayLength) % 4
if arrayPos + 4 + paddedArrayLength == len(data):
self.hasStream = False
elif arrayPos + 4 + paddedArrayLength <= len(data) - 12:
self.hasStream = True
else:
print(f"Couldn't figure out if {self.name} has a stream or not. Comparing {arrayPos + 4 + paddedArrayLength} to {len(data)}")
self.hasStream = True
def getAssetInfo(self, assets, bundle):
if self.id is None:
for id, obj in assets.objects.items():
try:
objType = obj.type
if objType != self.type: continue
except:
# Special case handling for newer files that fail to read type id
if self.type == "TextMeshProFont" and obj.type_id < 0:
objType = self.type
pass
else:
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)]
name = None
try:
name = obj.read()["m_Name"]
except:
length = int.from_bytes(data[0:4], byteorder='little')
# Things often store their length in the beginning of the file
# Note: the `len(self.name) * 4` is the maximum length the string `self.name` could be if it used high unicode characters
if length >= len(self.name) and length + 4 <= len(data) and length < len(self.name) * 4:
name = data[4:4+length].decode('utf-8')
# TextMeshPro assets store their name here
elif len(data) > 32:
length = int.from_bytes(data[28:32], byteorder='little')
if length + 4 <= len(data) and length < 40:
name = data[32:32+length].decode('utf-8')
if name is not None:
if self.name == name:
self.id = id
if objType == "Texture2D" and self.file[-4:] == ".png":
print(f"Will replace object #{id} with contents of {self.file} converted to a Texture2D")
self.loadTexture2DInfo(assets, bundle)
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
def bytes(self, unityVersion):
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, unityVersion)
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
print(f"Running EMPIGenerator in directory [{sys.argv[2]}]")
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()
unityVersion = [int(x) for x in bundle[20:28].decode("utf-8").rstrip("\0").split(".")[:2]]
assetsFile.seek(0)
assets = Asset.from_file(assetsFile, legacy_mode=True)
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(unityVersion))