본문 바로가기
한글화

UnityPy를 이용한 bundle파일 내 Monobehaviour 일괄수정

by 레이슈 2024. 9. 12.
반응형

https://snowyegret.tistory.com/69

 

UnityPy를 이용한 bundle파일 내 Monobehaviour 일괄수정

텍스트가 모두 .bundle 파일 안에 들어있고, 대사 파일이 여기저기 파편화되어 있기에 방법을 찾아보다 UnityPy라는 모듈을 사용하게 되었다. 이 게시글에선 특정 방법만을 다룰 것이나, 쉽게 응용

snowyegret.tistory.com

텍스트가 모두 .bundle 파일 안에 들어있고, 대사 파일이 여기저기 파편화되어 있기에
방법을 찾아보다 UnityPy라는 모듈을 사용하게 되었다.
이 게시글에선 특정 방법만을 다룰 것이나, 쉽게 응용이 가능하다.

1. UnityPy

UnityPy github을 보면 사용법이 이런 식으로 되어있다.
( https://github.com/K0lb3/UnityPy )
번들 파일을 UnityPy.load()를 이용해 로드한 후, 오브젝트를 쭉 순회하며 .type.name이 MonoBehaviour인 것들에 대한 작업을 수행한다.

2. CSV 파일로 텍스트 추출

import UnityPy
import json
import os
import csv


def sanitize_text(text):
    return text.replace("\r\n", "\\r\\n").replace("\n", "\\n").replace("\r", "\\r")


# fmt: off
def work_export(filename):
    with open("text.csv", "a", encoding="utf-8", newline="") as f:
        writer = csv.writer(f, quoting=csv.QUOTE_ALL)
        clean_filename = os.path.splitext(os.path.basename(filename))[0]
        env = UnityPy.load(filename)
        for obj in env.objects:
            if obj.type.name == "MonoBehaviour" and obj.serialized_type.nodes:
                tree = obj.read_typetree()
                if tree.get('storyText') == None:
                    continue
                if tree.get('itemId') == None:
                    continue
                if tree.get('character') == None:
                    continue
                if tree.get('character').get('m_PathID') == None:
                    continue
                text = sanitize_text(tree.get('storyText'))
                item_id = tree.get('itemId')
                character_id = tree.get('character').get('m_PathID')
                writer.writerow([clean_filename, item_id, character_id, text, ""])
            

def export_bundle():
    with open("text.csv", "w", encoding="utf-8", newline="") as f:
        writer = csv.writer(f, quoting=csv.QUOTE_ALL)
        writer.writerow(["filename", "item_id", "character_id", "src", "dst"])
    filename_lst = [i for i in os.listdir("./StandaloneWindows64") if i.endswith(".bundle")]
    for filename in filename_lst:
        work_export(f"./StandaloneWindows64/{filename}")

if __name__ == "__main__":
    export_bundle()

개인 취향 문제로, \r이나 \n같은 이스케이프 문자를 모두 변환하여 한 줄로 만들었다.
또한, quoting 옵션을 csv.QUOTE_ALL로 주었다.
정상적으로 모든 텍스트가 추출된 것을 볼 수 있다.
이제 5번째 열 (dst 컬럼)에다가 번역하면 된다.

3. 번역한 텍스트를 번들에 삽입

일단, CSV를 이용해 딕셔너리를 만든다. 내가 번역문을 불러올 때 주로 사용하는 방법이다.
위 경우 i[0]이 번들 파일명, i[1]이 item_id, i[2]가 character_id, i[3]이 원문, i[4]가 번역문이다.
dict 구조로 보자면 아래 사진과 같이 될 것이다.
고유한 값인 번들 파일명과 고유한 값인 item_id를 기반으로 CSV에 있는 값들을 집어넣었다.

작업할 번들 파일 목록 작성도 좀 특이하게 진행해야 한다.
CSV에 번들 파일명이 기록되어 있으니, 작업하지 않아도 되는(CSV에 내용이 작성되어있지 않은) 파일은 패스할 수 있다.
이를 통해 작업시간을 단축할 수 있다.

import UnityPy
import json
import os
import csv
from pprint import pprint


def restore_text(text):
    return text.replace("\\r\\n", "\r\n").replace("\\n", "\n").replace("\\r", "\r")


# fmt: off
def work_import(filename, translate_dict):
    edit_switch = False
    clean_filename = os.path.splitext(os.path.basename(filename))[0]
    env = UnityPy.load(filename)
    for obj in env.objects:
        if obj.type.name == "MonoBehaviour" and obj.serialized_type.nodes:
            tree = obj.read_typetree()
            if tree.get('storyText') == None:
                continue
            if tree.get('itemId') == None:
                continue
            if tree.get('character') == None:
                continue
            if tree.get('character').get('m_PathID') == None:
                continue
            item_id = str(tree.get('itemId'))
            # 번역된 것이 없다면 건너뛰기
            if translate_dict[clean_filename][item_id]["dst"] == "":
                continue
            translated = restore_text(translate_dict[clean_filename][item_id]["dst"])
            tree["storyText"] = translated
            edit_switch = True
            obj.save_typetree(tree)
    if edit_switch:
        with open(f"./StandaloneWindows64_new/{clean_filename}.bundle", "wb") as f:
            f.write(env.file.save())
                

def import_bundle():
    with open("./text.csv", "r", encoding="utf-8", newline="") as f:
        reader = list(csv.reader(f))
        del reader[0]
        translate_dict = {}
        for i in reader:
            if i[0] not in translate_dict:
                translate_dict[i[0]] = {}
            translate_dict[i[0]][i[1]] = {
                "item_id": i[1],
                "character_id": i[2],
                "src": i[3],
                "dst": i[4],
            }
    filename_lst = [
        filename 
        for filename in os.listdir("./StandaloneWindows64") 
        if filename.endswith(".bundle") and os.path.splitext(filename)[0] in translate_dict
    ]
    for filename in filename_lst:
        work_import(f"./StandaloneWindows64/{filename}", translate_dict)

if __name__ == "__main__":
    import_bundle()

이것이 내가 최종적으로 사용한 전체 코드이다.
sanitize_text() 함수를 수정하여 수정했던 이스케이프 문자를 복구시키고, 
work_import() 함수를 수정하여 번역문을 불러오도록 하였다.
수정 이후 obj.save_typetree(tree)를 하여 수정한 dict 데이터가 저장되도록 하였고
f.write(env.file.save())를 하여 수정된 번들 데이터가 저장되도록 하였다.
edit_switch라는 bool 변수를 통해, 번들 파일이 실질적으로 수정된 경우에만 저장하도록 하였다.