한글패치 관련 짧은 글들
UnityPy를 이용한 유니티 게임 MonoBehaviour 특정 텍스트 필드 추출/삽입
Snowyegret
2025. 3. 4. 22:54
기록용으로 작성.
UnityPy의 TypeTree 모듈이 완벽하지 않으므로 일부가 추출되지 않거나 미지원되는 경우가 있습니다.
사용법:
1. 게임의 루트 디렉토리(exe가 있는 폴더)에 해당 .py파일 혹은 .exe파일을 놓는다.
2. 명령어를 통해 .py파일 혹은 exe 파일을 실행한다.
명령어
MonoBehaviour_bulkedit_tool.exe --mode <Export/Import> --classname <클래스명> --fieldname <필드명> --csvname <추출/삽입에 사용할 CSV이름> [--forcereplace] [--filternumber]
forcereplace는 에셋의 PathID가 바뀌었을 경우 (업데이트 등)을 대응하기 위한 옵션이며, 원문 텍스트가 같을 경우 번역문을 삽입합니다.
filternumber는 텍스트가 숫자로만 이루어져 있을 경우, 필터링하여 추출/삽입되지 않게 합니다.
환경
- 파이썬 3.12.9
- UnityPy 1.21.1
- TypeTreeGeneratorAPI 0.0.5
컴파일된 바이너리와 bat파일
비밀번호: snow
https://drive.google.com/file/d/17-nOHXqIAVQSpFvgHK2pR4XaJUp8uM0n/view?usp=sharing
MonoBehaviour_bulkedit_tool.zip
drive.google.com
코드
import UnityPy
import argparse
import sys
import os
import csv
from UnityPy.helpers.TypeTreeGenerator import TypeTreeGenerator
from UnityPy.helpers import TypeTreeHelper
TypeTreeHelper.read_typetree_boost = False
def sanitize_text(text: str) -> str:
return text.replace("\n", "").replace("\r", "").replace("\t", "").replace(" ", "").replace("", "")
def validate_mode_argument(input_value: str) -> str:
lower_value = input_value.lower()
if lower_value in ("import", "i"):
return "Import"
elif lower_value in ("export", "e"):
return "Export"
else:
raise argparse.ArgumentTypeError('Invalid mode. Use "import"/"i" or "export"/"e".')
def detect_unity_version(game_data_path: str) -> str:
resources_directory = os.path.join(game_data_path, "Resources")
default_resources = os.path.join(resources_directory, "unity default resources")
asset_env = UnityPy.load(default_resources)
return asset_env.objects[0].assets_file.unity_version
def parse_translation_csv(csv_file_path: str, force_replace_mode: bool) -> dict:
translation_data: dict = {}
with open(csv_file_path, "r", encoding="utf-8", newline="") as csv_file:
csv_reader = list(csv.reader(csv_file))
if csv_reader:
csv_reader.pop(0)
for entry in csv_reader:
if len(entry) < 4 or sanitize_text(entry[3]) == "":
continue
if force_replace_mode:
translation_data.setdefault(entry[0], {})[sanitize_text(entry[2])] = entry[3]
else:
translation_data.setdefault(entry[0], {})[entry[1]] = {"Source": entry[2], "Target": entry[3]}
return translation_data
def export_translation_data(
root_dir: str, asset_file_name: str, csv_writer, type_tree_generator, class_name: str, field_name: str, filter_numeric: bool
):
try:
asset_env = UnityPy.load(os.path.join(root_dir, asset_file_name))
asset_env.typetree_generator = type_tree_generator
except Exception as load_error:
return
for asset_object in asset_env.objects:
if asset_object.type.name == "MonoBehaviour":
try:
mono_behavior_data = asset_object.read(check_read=False)
script_reference = mono_behavior_data.m_Script.read()
type_tree = asset_object.read_typetree()
except Exception as read_error:
continue
if script_reference.m_ClassName == class_name:
if not type_tree.get(field_name):
continue
cleaned_text: str = sanitize_text(type_tree[field_name])
if cleaned_text:
if filter_numeric and cleaned_text.isdigit():
continue
csv_writer.writerow([asset_file_name, asset_object.path_id, type_tree[field_name], ""])
print(f"File: {asset_file_name}, PathID: {asset_object.path_id}, Text: {type_tree[field_name]}")
def import_translation_data(
root_dir: str,
asset_file_name: str,
translation_map: dict,
type_tree_generator,
class_name: str,
field_name: str,
force_replace_mode: bool,
filter_number: bool,
) -> None:
if asset_file_name not in translation_map:
return
try:
asset_env = UnityPy.load(os.path.join(root_dir, asset_file_name))
asset_env.typetree_generator = type_tree_generator
except Exception as load_error:
print(f"Error loading {asset_file_name}")
return
for asset_object in asset_env.objects:
if asset_object.type.name == "MonoBehaviour":
try:
mono_behavior_data = asset_object.read(check_read=False)
script_reference = mono_behavior_data.m_Script.read()
type_tree = asset_object.read_typetree()
except Exception as read_error:
continue
if script_reference.m_ClassName == class_name:
if force_replace_mode:
if not type_tree.get(field_name):
continue
cleaned_source: str = sanitize_text(type_tree[field_name])
if not cleaned_source:
continue
translated_text: str = None
if cleaned_source in translation_map[asset_file_name]:
translated_text = translation_map[asset_file_name][cleaned_source]
else:
for filename in translation_map:
if cleaned_source in translation_map[filename]:
translated_text = translation_map[filename][cleaned_source]
break
if translated_text:
if filter_number and (translated_text.isdigit() or cleaned_source.isdigit()):
continue
original_text: str = type_tree[field_name]
type_tree[field_name] = translated_text
asset_object.save_typetree(type_tree)
print(f"File: {asset_file_name}, PathID: {asset_object.path_id}, Text: {original_text} -> {translated_text}")
else:
path_id_str: str = str(asset_object.path_id)
if path_id_str not in translation_map[asset_file_name]:
continue
if not type_tree.get(field_name):
continue
original_text: str = type_tree[field_name]
translated_text: str = translation_map[asset_file_name][path_id_str]["Target"]
if filter_number and (original_text.isdigit() or translated_text.isdigit()):
continue
type_tree[field_name] = translated_text
asset_object.save_typetree(type_tree)
print(f"File: {asset_file_name}, PathID: {path_id_str}, Text: {original_text} -> {translated_text}")
modified_asset_data = asset_env.file.save()
with open(os.path.join(root_dir, asset_file_name), "wb") as asset_file:
asset_file.write(modified_asset_data)
def main():
# fmt:off
argument_parser = argparse.ArgumentParser(description="TextMeshProUGUI tool by Snowyegret, version: 1.0")
argument_parser.add_argument("-m", "--mode", type=validate_mode_argument, help="(Required) Specify the mode: Import|I or Export|E)", default="Export")
argument_parser.add_argument("--classname", type=str, help="(Requied) Specify the class name to extract/insert", default="TextMeshProUGUI")
argument_parser.add_argument("--fieldname", type=str, help="(Requied) Specify the field name to extract/insert", default="m_text")
argument_parser.add_argument("--csvname", type=str, help="(Required) The name of the CSV file", default="TextMeshProUGUI.csv")
argument_parser.add_argument("--forcereplace", action="store_true", help="(Optional) Insert translations by text comparison, not by PathID [Default: False]", default=False)
argument_parser.add_argument("--filternumber", action="store_true", help="(Optional) filter numbers when export csv [Default: True]", default=True)
args = argument_parser.parse_args()
script_directory: str = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(__file__)
game_data_dir: str = os.path.join(script_directory, [i for i in os.listdir(script_directory) if i.endswith("_Data")][0])
managed_assemblies_dir: str = os.path.join(game_data_dir, "Managed")
il2cpp_binary_path: str = os.path.join(script_directory, "GameAssembly.dll")
metadata_path: str = os.path.join(game_data_dir, "il2cpp_data", "Metadata", "global-metadata.dat")
csv_file_path: str = os.path.join(script_directory, args.csvname)
if not game_data_dir:
print(f"Data folder not found at {script_directory}")
print("Make sure the program is in the game's root folder")
input("Press Enter to exit...")
sys.exit(0)
unity_version: str = detect_unity_version(game_data_dir)
type_tree_generator = TypeTreeGenerator(unity_version)
if os.path.exists(managed_assemblies_dir):
type_tree_generator.load_local_game(script_directory)
elif os.path.exists(il2cpp_binary_path) and os.path.exists(metadata_path):
with open(il2cpp_binary_path, "rb") as f:
il2cpp_binary = f.read()
with open(metadata_path, "rb") as f:
il2cpp_metadata = f.read()
type_tree_generator.load_il2cpp(il2cpp_binary, il2cpp_metadata)
else:
print("Failed to load game assemblies")
input("Press Enter to exit...")
sys.exit(0)
#fmt: off
print(f"CSV File: {args.csvname}, Mode: {args.mode}, ClassName: {args.classname}, FieldName: {args.fieldname}, Forcereplace: {args.forcereplace}, FilterNumber: {args.filternumber}")
print(f"Unity version: {unity_version}")
if args.mode == "Export":
with open(csv_file_path, "w", encoding="utf-8", newline="") as csv_file:
csv_writer = csv.writer(csv_file, quoting=csv.QUOTE_ALL)
csv_writer.writerow(["File", "PathID", "Source", "Target"])
for root_dir, _, file_list in os.walk(script_directory):
for filename in file_list:
export_translation_data(root_dir, filename, csv_writer, type_tree_generator, args.classname, args.fieldname, args.filternumber)
elif args.mode == "Import":
translation_map: dict = parse_translation_csv(csv_file_path, args.forcereplace)
for root_dir, _, file_list in os.walk(script_directory):
for filename in file_list:
import_translation_data(root_dir, filename, translation_map, type_tree_generator, args.classname, args.fieldname, args.forcereplace, args.filternumber)
if __name__ == "__main__":
main()
빌드 명령어
python -m PyInstaller --onefile --console --add-data "C:\Program Files\Python312\Lib\site-packages\UnityPy\resources\uncompressed.tpk;UnityPy/resources" MonoBehaviour_bulkedit_tool.py --add-data "C:\Program Files\Python312\Lib\site-packages\TypeTreeGeneratorAPI\TypeTreeGeneratorAPI.dll;TypeTreeGeneratorAPI"