한글패치 관련 짧은 글들

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"