diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d47c740 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + "configurations": [ + { + "name": "Python Debugger: Python File", + "type": "debugpy", + "request": "launch", + "program": "${file}" + }, + { + "name": "Run: struct", + "type": "debugpy", + "request": "launch", + "module": "app.structs.structs" + }, + { + "name": "Run: unpack.text_unpacker", + "type": "debugpy", + "request": "launch", + "module": "app.unpack.text_unpacker" + }, + { + "name": "Run: editor", + "type": "debugpy", + "request": "launch", + "module": "app.editor.save_editor" + }, + ] +} \ No newline at end of file diff --git a/app/deserializer/types.py b/app/deserializer/types.py index 1aecf30..5124fee 100644 --- a/app/deserializer/types.py +++ b/app/deserializer/types.py @@ -91,7 +91,7 @@ def copy_ctypes_to_struct(ctype_ins: ctypes.Structure, t: Type[T]) -> T: if is_primitive(t): return ctype_ins # type: ignore elif is_fixed_string(t): - return ctype_ins # type: ignore + return ctype_ins.decode('utf-8') # type: ignore elif is_fixed_array(t): elem_type = get_args(t)[0] return [copy_ctypes_to_struct(e, elem_type) for e in ctype_ins] # type: ignore diff --git a/app/editor/save_editor.py b/app/editor/save_editor.py new file mode 100644 index 0000000..7429ff2 --- /dev/null +++ b/app/editor/save_editor.py @@ -0,0 +1,146 @@ +import os +from dataclasses import dataclass +from enum import IntEnum + +from app.utils import find_game_path, find_save_path +from app.structs.structs import SystemData +from app.deserializer.types import Int32 +from app.unpack import TextUnpacker, TitleTextID, SaveTextID, Language + +class TitleId(IntEnum): + GS1 = 0 + GS2 = 1 + GS3 = 2 + +@dataclass +class SaveSlot: + time: str + progress: str + title: str + scenario: str + +class SaveEditor: + def __init__(self, language: Language = 'en') -> None: + game_path = find_game_path() + save_path = find_save_path() + if not game_path: + raise FileNotFoundError('Could not find game path') + if not save_path: + raise FileNotFoundError('Could not find save path') + self.game_path = game_path + self.save_path = save_path + + self.__system_data: SystemData|None = None + self.__text_unpacker = TextUnpacker(game_path, language) + + def load(self, save_file_path: str|None = None): + if not save_file_path: + save_file_path = os.path.join(self.save_path, 'systemdata') + with open(save_file_path, 'rb') as f: + self.__system_data = SystemData.from_bytes(f.read()) + + def save(self, save_file_path: str|None = None): + assert self.__system_data is not None, 'No data loaded' + if not save_file_path: + save_file_path = os.path.join(self.save_path, 'systemdata') + with open(save_file_path, 'wb') as f: + f.write(self.__system_data.to_bytes()) + + def set_account_id(self, account_id: int): + """ + 设置存档数据中保存的 Steam 账号 ID + """ + assert self.__system_data is not None, 'No data loaded' + self.__system_data.reserve_work_.reserve[1] = Int32(account_id) + + def get_account_id(self) -> int: + """ + 获取存档数据中保存的 Steam 账号 ID + """ + assert self.__system_data is not None, 'No data loaded' + return self.__system_data.reserve_work_.reserve[1] + + def get_slots(self) -> list[SaveSlot]: + """ + 获取所有存档槽位的信息 + """ + assert self.__system_data is not None, 'No data loaded' + slots = [] + for i in range(50, 60): + slot = self.__system_data.slot_data_.save_data_[i] + time = str(slot.time) + title = TitleId(slot.title) + scenario = int(slot.scenario) + + if time != '': + # title + title = self.__text_unpacker.get_text(TitleTextID.TITLE_NAME, slot.title) + + # scenario + if title == TitleId.GS2 and scenario >= 4: + scenario = '' + else: + if title == TitleId.GS1: + scenario = self.__text_unpacker.get_text(TitleTextID.GS1_SCENARIO_NAME, scenario) + elif title == TitleId.GS2: + scenario = self.__text_unpacker.get_text(TitleTextID.GS2_SCENARIO_NAME, scenario) + elif title == TitleId.GS3: + scenario = self.__text_unpacker.get_text(TitleTextID.GS3_SCENARIO_NAME, scenario) + else: + scenario = '' + episode = self.__text_unpacker.get_text(TitleTextID.EPISODE_NUMBER, slot.scenario) + scenario = f'{episode} {scenario}' + + # progress + def get_progress_text(in_title: int, in_progress: int): + in_text_id = 0 + if in_title != TitleId.GS1: + if in_title == TitleId.GS2: + in_text_id = 44 + in_progress + elif in_title == TitleId.GS3: + in_text_id = 66 + in_progress + else: + if in_progress in (17, 18): + in_text_id = 34 + elif in_progress in (19, 20): + in_text_id = 35 + elif in_progress == 21: + in_text_id = 36 + elif in_progress in (22, 23, 24): + in_text_id = 37 + elif in_progress == 25: + in_text_id = 38 + elif in_progress in (26, 27): + in_text_id = 39 + elif in_progress in (28, 29, 30): + in_text_id = 40 + elif in_progress == 31: + in_text_id = 41 + elif in_progress == 32: + in_text_id = 42 + elif in_progress in (33, 34): + in_text_id = 43 + else: + in_text_id = 17 + in_progress + + return self.__text_unpacker.get_text(SaveTextID(in_text_id), 0) + progress = get_progress_text(slot.title, slot.progress) + else: + title = '' + progress = '' + scenario = '' + + slots.append(SaveSlot( + time=time, + progress=progress, + title=title, + scenario=scenario, + )) + return slots + +if __name__ == '__main__': + from pprint import pprint + se = SaveEditor(language='hans') + se.load() + pprint(se.get_slots()) + \ No newline at end of file diff --git a/app/structs/structs.py b/app/structs/structs.py index 4c7ebce..d51758c 100644 --- a/app/structs/structs.py +++ b/app/structs/structs.py @@ -25,7 +25,11 @@ class SaveData(Struct): """存档时间。`YYYY/MM/DD\\nHH:MM:SS`""" title: ushort + """TextDataCtrl.TitleTextID.TITLE_NAME""" + scenario: ushort + """""" + progress: ushort in_data: ushort diff --git a/app/unpack/__init__.py b/app/unpack/__init__.py new file mode 100644 index 0000000..5b1700b --- /dev/null +++ b/app/unpack/__init__.py @@ -0,0 +1,3 @@ +from .decrypt import decrypt_bytes, decrypt_file +from .text_unpacker import TextUnpacker, Language +from .decompiled import TitleTextID, SaveTextID \ No newline at end of file diff --git a/app/unpack/decompiled.py b/app/unpack/decompiled.py new file mode 100644 index 0000000..7132cb8 --- /dev/null +++ b/app/unpack/decompiled.py @@ -0,0 +1,213 @@ +import struct +from typing import TypeAlias, Union, Literal +from enum import IntEnum + +Language: TypeAlias = Union[ + Literal['en'], + Literal['jp'], + Literal['hans'], + Literal['hant'], + Literal['fr'], + Literal['de'], + Literal['ko'], +] + +language_suffix: dict[Language, str] = { + 'en': '_u', + 'jp': '', + 'hans': '_s', + 'hant': '_t', + 'fr': '_f', + 'de': '_g', + 'ko': '_k', +} + +# Converted from decomplied code +class ConvertLineData: + class Data: + def __init__(self, id: int, text: list[str]): + self.id = id + self.text = text + + def __repr__(self) -> str: + return f'' + + def __init__(self, bytes: bytes, language: str): + self.data_: list[ConvertLineData.Data] = [] + num = 0 + while num + 2 <= len(bytes): + item = ConvertLineData.Data(0, []) + list2 = [] + item.id = struct.unpack_from(' list[str]: + for item in self.data_: + if item.id == id: + return item.text + return [] + + @property + def data(self): + return self.data_ + + +class TitleTextID(IntEnum): + NEW_GAME = 0 + CONTINUE = 1 + OPTION = 2 + PLAY_TITLE = 3 + PLAY_EPISODE = 4 + PLAY_CONFIRM = 5 + YES = 6 + NO = 7 + TITLE_NAME = 8 + EPISODE_NUMBER = 9 + GS1_SCENARIO_NAME = 10 + GS2_SCENARIO_NAME = 11 + GS3_SCENARIO_NAME = 12 + EXIT = 13 + EXIT_MESSAGE = 14 + START_INPUT = 19 + + +class SaveTextID(IntEnum): + SELECT_SLOT = 0 + SELECT_DATA_SW = 1 + SELECT_DATA = 2 + NO_DATA_SW = 3 + NO_DATA = 4 + SELECT_CONFIRM_SW = 5 + SELECT_CONFIRM = 6 + LOADDING_SW = 7 + LOADDING = 8 + OVERWRITE_SW = 9 + OVERWRITE = 10 + SAVING_SW = 11 + SAVING_XO = 12 + SAVING_PS4 = 13 + SAVING_STEAM = 14 + CLEAR_SAVE = 15 + ADD_NEW_EPISODE = 16 + GS1_SC0 = 17 + GS1_SC1_0 = 18 + GS1_SC1_1 = 19 + GS1_SC1_2 = 20 + GS1_SC1_3 = 21 + GS1_SC2_0 = 22 + GS1_SC2_1 = 23 + GS1_SC2_2 = 24 + GS1_SC2_3 = 25 + GS1_SC2_4 = 26 + GS1_SC2_5 = 27 + GS1_SC3_0 = 28 + GS1_SC3_1 = 29 + GS1_SC3_2 = 30 + GS1_SC3_3 = 31 + GS1_SC3_4 = 32 + GS1_SC3_5 = 33 + GS1_SC4_0 = 34 + GS1_SC4_1_0 = 35 + GS1_SC4_1_1 = 36 + GS1_SC4_2 = 37 + GS1_SC4_3_0 = 38 + GS1_SC4_3_1 = 39 + GS1_SC4_4 = 40 + GS1_SC4_5_0 = 41 + GS1_SC4_5_1 = 42 + GS1_SC4_5_2 = 43 + GS2_SC0_0 = 44 + GS2_SC0_1 = 45 + GS2_SC1_0 = 46 + GS2_SC1_1_0 = 47 + GS2_SC1_1_1 = 48 + GS2_SC1_2 = 49 + GS2_SC1_3_0 = 50 + GS2_SC1_3_1 = 51 + GS2_SC2_0 = 52 + GS2_SC2_1_0 = 53 + GS2_SC2_1_1 = 54 + GS2_SC2_2 = 55 + GS2_SC2_3_0 = 56 + GS2_SC2_3_1 = 57 + GS2_SC3_0_0 = 58 + GS2_SC3_0_1 = 59 + GS2_SC3_1_0 = 60 + GS2_SC3_1_1 = 61 + GS2_SC3_2_0 = 62 + GS2_SC3_2_1 = 63 + GS2_SC3_3_0 = 64 + GS2_SC3_3_1 = 65 + GS3_SC0_0 = 66 + GS3_SC0_1 = 67 + GS3_SC1_0 = 68 + GS3_SC1_1 = 69 + GS3_SC1_2 = 70 + GS3_SC1_3_0 = 71 + GS3_SC1_3_1 = 72 + GS3_SC2_0 = 73 + GS3_SC2_1_0 = 74 + GS3_SC2_2 = 75 + GS3_SC2_3_0 = 76 + GS3_SC2_3_1 = 77 + GS3_SC3_0_0 = 78 + GS3_SC3_0_1 = 79 + GS3_SC4_0_0 = 80 + GS3_SC4_0_1 = 81 + GS3_SC4_1_0 = 82 + GS3_SC4_1_1 = 83 + GS3_SC4_2_0 = 84 + GS3_SC4_2_1 = 85 + GS3_SC4_3_0 = 86 + GS3_SC4_3_1 = 87 + GS3_SC4_3_2 = 88 + LOAD_ERROR = 90 + DELETING = 91 + SAVE_ERROR = 92 + CREATE_ERROR = 93 diff --git a/app/unpack/decrypt.py b/app/unpack/decrypt.py new file mode 100644 index 0000000..1152cdc --- /dev/null +++ b/app/unpack/decrypt.py @@ -0,0 +1,48 @@ +import sys + +from Crypto.Cipher import AES +from Crypto.Protocol.KDF import PBKDF2 + + +def decrypt_bytes(encrypted_data, password="u8DurGE2", salt="6BBGizHE"): + # Derive key and IV using PBKDF2 + salt_bytes = salt.encode('utf-8') + key_iv = PBKDF2(password, salt_bytes, dkLen=32, count=1000) + key = key_iv[:16] + iv = key_iv[16:32] + + # Initialize AES cipher + cipher = AES.new(key, AES.MODE_CBC, iv) + + # Decrypt the data + decrypted_data = cipher.decrypt(encrypted_data) + + # Remove padding + pad_len = decrypted_data[-1] + decrypted_data = decrypted_data[:-pad_len] + + return decrypted_data + + +def decrypt_file(file_path, output_path, password="u8DurGE2", salt="6BBGizHE"): + # Read the encrypted file + with open(file_path, 'rb') as f: + encrypted_data = f.read() + + # Decrypt the data + decrypted_data = decrypt_bytes(encrypted_data, password, salt) + + # Write the decrypted data to the output file + with open(output_path, 'wb') as f: + f.write(decrypted_data) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: python decrypt.py ") + sys.exit(1) + + input_file = sys.argv[1] + output_file = sys.argv[2] + + decrypt_file(input_file, output_file) diff --git a/app/unpack/text_unpacker.py b/app/unpack/text_unpacker.py new file mode 100644 index 0000000..5541095 --- /dev/null +++ b/app/unpack/text_unpacker.py @@ -0,0 +1,66 @@ +import os +from typing import TypeAlias, Union, Literal, overload + +from .decompiled import * +from .decrypt import decrypt_bytes +from app.utils import find_game_path + + +class TextUnpacker: + def __init__( + self, + game_path: str|None = None, + language: Language = 'en' + ) -> None: + """ + :param game_path: 游戏安装路径。默认为自动搜索 + :param language: 语言。默认为英文 + """ + self.game_path = game_path or find_game_path() + if not self.game_path: + raise FileNotFoundError('Could not find game path') + + suffix = language_suffix[language] + + common_text_path = os.path.join(self.game_path, 'PWAAT_Data', 'StreamingAssets', 'menu', 'text', f'common_text{suffix}.bin') + title_text_path = os.path.join(self.game_path, 'PWAAT_Data', 'StreamingAssets', 'menu', 'text', f'title_text{suffix}.bin') + option_text_path = os.path.join(self.game_path, 'PWAAT_Data', 'StreamingAssets', 'menu', 'text', f'option_text{suffix}.bin') + save_text_path = os.path.join(self.game_path, 'PWAAT_Data', 'StreamingAssets', 'menu', 'text', f'save_text{suffix}.bin') + platform_text_path = os.path.join(self.game_path, 'PWAAT_Data', 'StreamingAssets', 'menu', 'text', f'platform_text{suffix}.bin') + system_text_path = os.path.join(self.game_path, 'PWAAT_Data', 'StreamingAssets', 'menu', 'text', f'system_text{suffix}.bin') + + self.title_text_data = self.__load_text(title_text_path, language) + self.save_text_data = self.__load_text(save_text_path, language) + + def __load_text(self, path: str, language: Language) -> ConvertLineData: + with open(path, 'rb') as f: + buff = f.read() + buff = decrypt_bytes(buff) + return ConvertLineData(buff, language) + + @overload + def get_text(self, id: TitleTextID, text_line: int = 0) -> str: ... + @overload + def get_text(self, id: SaveTextID, text_line: int = 0) -> str: ... + + def get_text(self, id: TitleTextID|SaveTextID, text_line: int = 0) -> str: + if isinstance(id, TitleTextID): + return self.title_text_data.get_text(id, text_line) + elif isinstance(id, SaveTextID): + return self.save_text_data.get_text(id, text_line) + else: + raise ValueError('Invalid id type') + + +if __name__ == '__main__': + from .decrypt import decrypt_bytes + import os + with open(r"F:\SteamLibrary\steamapps\common\Phoenix Wright Ace Attorney Trilogy\PWAAT_Data\StreamingAssets\menu\text\common_text_s.bin", 'rb') as f: + buff = f.read() + buff = decrypt_bytes(buff) + cld = ConvertLineData(buff, 'ENGLISH') + print(cld.get_text(0, 0)) + print(cld.get_texts(0)) + + tu = TextUnpacker(None, 'hans') + print(tu.get_text(TitleTextID.GS1_SCENARIO_NAME, 0)) \ No newline at end of file diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..b5c8a65 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,35 @@ +import os + +def find_game_path() -> str|None: + """ + 自动搜索游戏安装路径 + """ + drives = ['C:', 'D:', 'E:', 'F:', 'G:', 'H:', 'I:', 'J:', 'K:', 'L:', 'M:', 'N:', 'O:', 'P:', 'Q:', 'R:', 'S:', 'T:', 'U:', 'V:', 'W:', 'X:', 'Y:', 'Z:'] + for drive in drives: + steam_library = os.path.join(drive, 'SteamLibrary') + game_path = os.path.join(steam_library, 'steamapps', 'common', 'Phoenix Wright Ace Attorney Trilogy') + if os.path.exists(game_path): + return game_path + return None + +def find_save_path() -> str|None: + """ + 自动搜索存档路径 + """ + # 首先搜索 Steam 安装路径 + guesses = [ + 'C:\\Program Files (x86)\\Steam', + 'D:\\Program Files (x86)\\Steam', + 'E:\\Program Files (x86)\\Steam', + 'F:\\Program Files (x86)\\Steam', + 'G:\\Program Files (x86)\\Steam', + ] + guesses = [path for path in guesses if os.path.exists(path)] + if not guesses: + return None + steam_path = guesses[0] + # C:\Program Files (x86)\Steam\userdata\1082712526\787480\remote + user_path = os.path.join(steam_path, 'userdata', '1082712526', '787480', 'remote') + if os.path.exists(user_path): + return user_path + return None \ No newline at end of file