From 31c97e5aac3ae2d2243b75df25b65d46efbdb9ee Mon Sep 17 00:00:00 2001
From: XcantloadX <3188996979@qq.com>
Date: Mon, 29 Jul 2024 12:02:56 +0800
Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=20wxPython=20=E7=89=88=20UI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.vscode/launch.json | 7 +
README.md | 35 ++
app/deserializer/types.py | 14 +-
app/editor/save_editor.py | 177 ++++--
app/entry_native.py | 8 +
app/native_ui/__init__.py | 0
app/native_ui/form.py | 250 ++++++++
app/native_ui/implement.py | 194 ++++++
app/structs/steam.py | 2 +-
app/unpack/text_unpacker.py | 4 +-
app/utils.py | 36 --
form.fbp | 1162 +++++++++++++++++++++++++++++++++++
make.ps1 | 3 +-
requirements.txt | 3 +-
res/icons/open.png | Bin 0 -> 360 bytes
res/icons/save.png | Bin 0 -> 402 bytes
16 files changed, 1809 insertions(+), 86 deletions(-)
create mode 100644 README.md
create mode 100644 app/entry_native.py
create mode 100644 app/native_ui/__init__.py
create mode 100644 app/native_ui/form.py
create mode 100644 app/native_ui/implement.py
create mode 100644 form.fbp
create mode 100644 res/icons/open.png
create mode 100644 res/icons/save.png
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 9d4f98a..53bda09 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -49,5 +49,12 @@
"module": "app.entry",
"args": [ "--debug" ]
},
+ {
+ "name": "Entry Native (Debug Mode)",
+ "type": "debugpy",
+ "request": "launch",
+ "module": "app.native_ui.entry",
+ "args": [ "--debug" ]
+ },
]
}
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6948d79
--- /dev/null
+++ b/README.md
@@ -0,0 +1,35 @@
+# PWAAT-Save-Editor 逆转裁判 123 存档修改器
+
+## 功能
+* 导入、导出存档
+* Steam 与 Xbox 存档互相转换
+* 解锁章节
+* 修改法庭内血量
+
+## 使用教程
+### 下载
+1. 在 [Release 页](https://github.com/XcantloadX/PWAAT-Save-Editor/releases)下载最新版本的编辑器
+2. 解压后运行 `PWAAT Save Editor.exe`
+
+### 导入/导出存档
+Steam 转 Xbox:菜单栏 → 转换 → “Xbox ← Steam”
+
+Xbox 转 Steam:菜单栏 → 转换 → “Xbox → Steam”
+
+导入 Steam 存档:菜单栏 → 转换 → “Steam ← 文件...”
+
+导出 Steam 存档:菜单栏 → 转换 → “Steam → 文件...”
+
+### 解锁章节
+1. 关闭游戏
+2. 在“文件”菜单内打开任意存档
+3. 按需求调整“解锁章节”框内的设置
+4. “文件” → “保存”
+5. 启动游戏
+
+### 修改血量
+1. 在“文件”菜单内打开任意存档
+2. 检查“选择存档”处自动选中的存档是否为想要修改的存档,若不是,改为想要修改的存档
+3. 调整血量
+4. “文件” → “保存”
+5. 在游戏内重新读取该存档
\ No newline at end of file
diff --git a/app/deserializer/types.py b/app/deserializer/types.py
index d7a328f..9da105c 100644
--- a/app/deserializer/types.py
+++ b/app/deserializer/types.py
@@ -1,6 +1,6 @@
import ctypes
from dataclasses import is_dataclass, fields, dataclass
-from typing import TypeVar, TypeAlias, NewType, Annotated, Generic, List, Text, Literal, Type, Any, Iterator
+from typing import TypeVar, TypeAlias, NewType, Annotated, Generic, List, Text, Literal, Type, Any, Iterator, TypeGuard
from typing import get_type_hints, get_args, get_origin, cast
from typing_extensions import Self
from abc import ABC
@@ -41,9 +41,11 @@ def __iter__(self) -> Iterator[T]: ...
def __next__(self) -> T: ...
def __contains__(self, item: T) -> bool: ...
-class FixedString(Generic[Len]): pass
+class FixedString(Generic[Len]):
+ def decode(self, encoding: str = "utf-8", errors: str = "strict") -> str: ...
class Bytes(Generic[Len]): pass
+
# 类型判断
def is_primitive(t: type) -> bool:
return any(t == pt for pt in (
@@ -85,6 +87,8 @@ def from_bytes(cls: type[Self], data: bytes) -> Self:
"""
# assert is_dataclass(dataclass) # type: ignore
Struct_ = to_ctypes(cls)
+ if ctypes.sizeof(Struct_) != len(data):
+ raise ValueError(f"Data size mismatch: {ctypes.sizeof(Struct_)} != {len(data)}")
ctype_ins = Struct_.from_buffer_copy(data)
return cast(Self, ctype_ins)
@@ -213,4 +217,8 @@ def _convert_type(py_type: Any, dependencies: dict[Type, Type] = {}) -> Any:
elif py_type == int:
raise TypeError(f"Unsupported built-in type: {py_type}, please use Int8, Int16, Int32, Int64, UInt8, UInt16, UInt32, UInt64 instead.")
else:
- raise TypeError(f"Unsupported type: {py_type}")
\ No newline at end of file
+ raise TypeError(f"Unsupported type: {py_type}")
+
+StructType = TypeVar('StructType', bound=Struct)
+def is_struct(ins: Any, t: type[StructType]) -> TypeGuard[StructType]:
+ return isinstance(ins, to_ctypes(t))
\ No newline at end of file
diff --git a/app/editor/save_editor.py b/app/editor/save_editor.py
index 246af8b..6e2d790 100644
--- a/app/editor/save_editor.py
+++ b/app/editor/save_editor.py
@@ -1,12 +1,16 @@
import os
from dataclasses import dataclass
from enum import IntEnum
+from typing import TypeGuard
-from app.utils import find_game_path, find_save_path
from app.structs.steam import PresideData
-from app.deserializer.types import Int32, Int16
+from app.structs.xbox import PresideDataXbox
+from app.deserializer.types import Int32, Int16, is_struct
from app.unpack import TextUnpacker, TitleTextID, SaveTextID, Language
from app.deserializer.types import UInt8
+from app.editor.locator import STEAM_SAVE_LENGTH, XBOX_SAVE_LENGTH
+import app.editor.locator as locator
+from app.structs.conventor import xbox2steam, steam2xbox
from logging import getLogger
logger = getLogger(__name__)
@@ -16,12 +20,42 @@ class TitleId(IntEnum):
GS2 = 1
GS3 = 2
+class SaveType(IntEnum):
+ UNKNOWN = -1
+ STEAM = 0
+ XBOX = 1
+
+class NoOpenSaveFileError(Exception):
+ pass
+
@dataclass
class SaveSlot:
time: str
progress: str
+ """天数/回数(e.g. 第一回 法庭前篇)"""
title: str
+ """游戏名称(e.g. 逆转裁判 1)"""
+ title_number: int
+ """游戏编号(e.g. 1)"""
scenario: str
+ """章节名称(e.g. 第四章 再会了,逆转)"""
+ scenario_number: int
+ """章节编号(e.g. 4)"""
+
+ @property
+ def short_str(self) -> str:
+ if self.time == '':
+ return '空'
+ else:
+ time = self.time.replace('\n', ' ')
+ return f'{self.title_number}-{self.scenario_number} {self.progress} {time}'
+
+ @property
+ def long_str(self) -> str:
+ if self.time == '':
+ return '空'
+ else:
+ return f'{self.title} {self.scenario} {self.progress} {self.time}'
class SaveEditor:
def __init__(
@@ -30,85 +64,125 @@ def __init__(
default_save_path: str|None = None,
language: Language = 'en'
) -> None:
- game_path = game_path or find_game_path()
+ game_path = game_path or locator.steam_game_path or locator.xbox_game_path
+ logger.debug(f'game_path: {game_path}')
if not game_path:
raise FileNotFoundError('Could not find game path')
self.game_path = game_path
- default_save_path = default_save_path or find_save_path()
- if not default_save_path:
- raise FileNotFoundError('Could not find default save path')
- self.default_save_path = default_save_path
self.__save_path: str|None = None
- self.__preside_data: PresideData|None = None
+ self.__preside_data: PresideData|PresideDataXbox|None = None
self.__language: Language = language
- # self.__text_unpacker = None
self.__text_unpacker = TextUnpacker(self.game_path, self.__language)
def init(self):
pass
+ def __check_save_loaded(self, _ = None) -> TypeGuard[PresideData|PresideDataXbox]:
+ if self.__preside_data is None:
+ raise NoOpenSaveFileError('No data loaded')
+ return True
+
+ @property
+ def preside_data(self) -> PresideData|PresideDataXbox:
+ assert self.__check_save_loaded(self.__preside_data)
+ return self.__preside_data
+
+ @property
+ def save_type(self) -> SaveType:
+ assert self.__check_save_loaded(self.__preside_data)
+ if is_struct(self.__preside_data, PresideData):
+ return SaveType.STEAM
+ elif is_struct(self.__preside_data, PresideDataXbox):
+ return SaveType.XBOX
+ else:
+ return SaveType.UNKNOWN
+
+ @property
+ def opened(self) -> bool:
+ return self.__preside_data is not None
+
def get_save_path(self) -> str|None:
return self.__save_path
- def load(self, save_file_path: str|None = None):
+ def load(self, save_file_path: str):
"""
加载存档数据
- :param save_file_path: 存档文件路径,默认为系统存档。
+ :param save_file_path: 存档文件路径
"""
- self.__save_path = save_file_path or self.default_save_path
- with open(self.__save_path, 'rb') as f:
- self.__preside_data = PresideData.from_bytes(f.read())
+ self.__save_path = save_file_path
+ # 判断存档类型
+ size = os.path.getsize(self.__save_path)
+ if size == STEAM_SAVE_LENGTH:
+ self.__preside_data = PresideData.from_file(self.__save_path)
+ elif size == XBOX_SAVE_LENGTH:
+ self.__preside_data = PresideDataXbox.from_file(self.__save_path)
+ else:
+ raise ValueError('Invalid save file')
def save(self, save_file_path: str|None = None):
- assert self.__preside_data is not None, 'No data loaded'
+ assert self.__check_save_loaded(self.__preside_data)
if not save_file_path:
- save_file_path = self.default_save_path
- with open(save_file_path, 'wb') as f:
- f.write(PresideData.to_bytes(self.__preside_data))
+ save_file_path = self.__save_path
+ if save_file_path is None:
+ raise NoOpenSaveFileError('No save file path')
+ if is_struct(self.__preside_data, PresideData):
+ PresideData.to_file(self.__preside_data, save_file_path)
+ elif is_struct(self.__preside_data, PresideDataXbox):
+ PresideDataXbox.to_file(self.__preside_data, save_file_path)
+ else:
+ raise ValueError('Invalid save data')
+
+ def convert(self, target: SaveType):
+ assert self.__check_save_loaded(self.__preside_data)
+ if target == SaveType.STEAM:
+ if is_struct(self.__preside_data, PresideDataXbox):
+ self.__preside_data = xbox2steam(self.__preside_data)
+ else:
+ raise ValueError('Expected Xbox save data, got Steam save data')
+ elif target == SaveType.XBOX:
+ if is_struct(self.__preside_data, PresideData):
+ self.__preside_data = steam2xbox(self.__preside_data)
+ else:
+ raise ValueError('Expected Steam save data, got Xbox save data')
def set_account_id(self, account_id: int):
"""
设置存档数据中保存的 Steam 账号 ID
"""
- assert self.__preside_data is not None, 'No data loaded'
+ assert self.__check_save_loaded(self.__preside_data)
self.__preside_data.system_data_.reserve_work_.reserve[1] = Int32(account_id)
def get_account_id(self) -> int:
"""
获取存档数据中保存的 Steam 账号 ID
"""
- assert self.__preside_data is not None, 'No data loaded'
+ assert self.__check_save_loaded(self.__preside_data)
return self.__preside_data.system_data_.reserve_work_.reserve[1]
- def set_account_id_from_system(self):
- """
- 从系统存档中获取 Steam 账号 ID 并设置到当前存档中
- """
- assert self.__preside_data is not None, 'No data loaded'
- se = SaveEditor()
- se.load()
- self.set_account_id(se.get_account_id())
-
-
def get_slots(self) -> list[SaveSlot]:
"""
获取所有存档槽位的信息
"""
- assert self.__preside_data is not None, 'No data loaded'
+ assert self.__check_save_loaded(self.__preside_data)
slots = []
# TODO 存档槽位的开始位置似乎与游戏语言有关
for i in range(50, 60):
slot = self.__preside_data.system_data_.slot_data_.save_data_[i]
- time = str(slot.time)
+ time = slot.time.decode()
title = TitleId(slot.title)
+ title_number = 0
scenario = int(slot.scenario)
+ scenario_number = 0
if time != '':
# title
title = self.__text_unpacker.get_text(TitleTextID.TITLE_NAME, slot.title)
+ # title number
+ title_number = slot.title + 1
+
# scenario
if slot.title == TitleId.GS2 and slot.scenario >= 4:
scenario = ''
@@ -124,6 +198,9 @@ def get_slots(self) -> list[SaveSlot]:
episode = self.__text_unpacker.get_text(TitleTextID.EPISODE_NUMBER, slot.scenario)
scenario = f'{episode} {scenario}'
+ # scenario number
+ scenario_number = slot.scenario + 1
+
# progress
def get_progress_text(in_title: int, in_progress: int):
in_text_id = 0
@@ -168,6 +245,8 @@ def get_progress_text(in_title: int, in_progress: int):
progress=progress,
title=title,
scenario=scenario,
+ title_number=title_number,
+ scenario_number=scenario_number
))
return slots
@@ -177,7 +256,7 @@ def set_court_hp(self, slot_number: int, hp: int):
:param slot_number: 游戏内存档槽位号,范围 [0, 9]
:param hp: 血量值,范围 [0, 80]
"""
- assert self.__preside_data is not None, 'No data loaded'
+ assert self.__check_save_loaded(self.__preside_data)
slot_number = self.__get_real_slot_number(slot_number)
self.__preside_data.slot_list_[slot_number].global_work_.gauge_hp = Int16(hp)
self.__preside_data.slot_list_[slot_number].global_work_.gauge_hp_disp = Int16(hp)
@@ -187,7 +266,7 @@ def get_court_hp(self, slot_number: int) -> int:
获取法庭日血量值。
:param slot_number: 游戏内存档槽位号,范围 [0, 9]
"""
- assert self.__preside_data is not None, 'No data loaded'
+ assert self.__check_save_loaded(self.__preside_data)
slot_number = self.__get_real_slot_number(slot_number)
return self.__preside_data.slot_list_[slot_number].global_work_.gauge_hp
@@ -197,32 +276,46 @@ def set_unlocked_chapters(self, game_number: int, chapter_count: int):
:param game_number: 游戏编号。范围 [1, 3]
:param chapter_counts: 解锁的章节数量。
"""
- assert self.__preside_data is not None, 'No data loaded'
- if chapter_count <= 0 or chapter_count >= 4:
+ assert self.__check_save_loaded(self.__preside_data)
+ if chapter_count <= 0 or chapter_count > 5:
raise ValueError('Invalid chapter count.')
sce_data = self.__preside_data.system_data_.sce_data_
enable_data = UInt8((16 & 0b1111) | (chapter_count << 4))
if game_number == 1:
sce_data.GS1_Scenario_enable = enable_data
- if chapter_count >= 5:
- raise ValueError('Invalid chapter count.')
elif game_number == 2:
sce_data.GS2_Scenario_enable = enable_data
+ if chapter_count > 4:
+ raise ValueError('Invalid chapter count.')
elif game_number == 3:
sce_data.GS3_Scenario_enable = enable_data
- if chapter_count >= 5:
- raise ValueError('Invalid chapter count.')
+
+ def get_unlocked_chapters(self, game_number: int) -> int:
+ """
+ 获取解锁的章节数量。
+ :param game_number: 游戏编号。范围 [1, 3]
+ """
+ assert self.__check_save_loaded(self.__preside_data)
+ sce_data = self.__preside_data.system_data_.sce_data_
+ if game_number == 1:
+ return sce_data.GS1_Scenario_enable >> 4
+ elif game_number == 2:
+ return sce_data.GS2_Scenario_enable >> 4
+ elif game_number == 3:
+ return sce_data.GS3_Scenario_enable >> 4
+ else:
+ raise ValueError('Invalid game number.')
def __get_real_slot_number(self, slot_number: int) -> int:
"""
获取实际存档槽位号
"""
- assert self.__preside_data is not None, 'No data loaded'
+ assert self.__check_save_loaded(self.__preside_data)
return slot_number + 50
if __name__ == '__main__':
from pprint import pprint
se = SaveEditor(language='hans')
- se.load()
+ # se.load()
pprint(se.get_slots())
\ No newline at end of file
diff --git a/app/entry_native.py b/app/entry_native.py
new file mode 100644
index 0000000..23154cb
--- /dev/null
+++ b/app/entry_native.py
@@ -0,0 +1,8 @@
+import app.native_ui.implement
+# 让 PyInstaller 收集下面这些模块
+import winrt.windows.foundation
+import winrt.windows.foundation.collections
+import winrt.windows.management
+import winrt.windows.management.deployment
+import winrt.windows.storage
+import winrt.windows.applicationmodel
\ No newline at end of file
diff --git a/app/native_ui/__init__.py b/app/native_ui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/native_ui/form.py b/app/native_ui/form.py
new file mode 100644
index 0000000..4b63abe
--- /dev/null
+++ b/app/native_ui/form.py
@@ -0,0 +1,250 @@
+# -*- coding: utf-8 -*-
+
+###########################################################################
+## Python code generated with wxFormBuilder (version 4.0.0-0-g0efcecf)
+## http://www.wxformbuilder.org/
+##
+## PLEASE DO *NOT* EDIT THIS FILE!
+###########################################################################
+
+import wx
+import wx.xrc
+
+###########################################################################
+## Class FrameMain
+###########################################################################
+
+class FrameMain ( wx.Frame ):
+
+ def __init__( self, parent ):
+ wx.Frame.__init__ ( self, parent, id = wx.ID_ANY, title = u"逆转裁判 123 存档工具", pos = wx.DefaultPosition, size = wx.Size( 500,400 ), style = wx.DEFAULT_FRAME_STYLE|wx.TAB_TRAVERSAL )
+
+ self.SetSizeHints( wx.DefaultSize, wx.DefaultSize )
+ self.SetBackgroundColour( wx.SystemSettings.GetColour( wx.SYS_COLOUR_WINDOW ) )
+
+ bSizer1 = wx.BoxSizer( wx.VERTICAL )
+
+ self.m_panel2 = wx.Panel( self, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL )
+ bSizer4 = wx.BoxSizer( wx.VERTICAL )
+
+ self.m_notebook1 = wx.Notebook( self.m_panel2, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, 0 )
+ self.m_pnl_common = wx.Panel( self.m_notebook1, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL )
+ bSizer2 = wx.BoxSizer( wx.VERTICAL )
+
+ sbSizer_chapters = wx.StaticBoxSizer( wx.StaticBox( self.m_pnl_common, wx.ID_ANY, u"解锁章节" ), wx.VERTICAL )
+
+ fgSizer1 = wx.FlexGridSizer( 3, 2, 0, 20 )
+ fgSizer1.AddGrowableCol( 0 )
+ fgSizer1.AddGrowableCol( 1 )
+ fgSizer1.SetFlexibleDirection( wx.BOTH )
+ fgSizer1.SetNonFlexibleGrowMode( wx.FLEX_GROWMODE_SPECIFIED )
+
+ self.m_staticText1 = wx.StaticText( sbSizer_chapters.GetStaticBox(), wx.ID_ANY, u"逆转裁判 1", wx.DefaultPosition, wx.DefaultSize, 0 )
+ self.m_staticText1.Wrap( -1 )
+
+ fgSizer1.Add( self.m_staticText1, 0, wx.ALL, 5 )
+
+ m_chc_gs1Choices = [ u"第一章 最初的逆转", u"第二章 逆转姐妹", u"第三章 逆转的大将军", u"第四章 逆转,然后再见", u"第五章 复苏的逆转" ]
+ self.m_chc_gs1 = wx.Choice( sbSizer_chapters.GetStaticBox(), wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, m_chc_gs1Choices, 0 )
+ self.m_chc_gs1.SetSelection( 0 )
+ fgSizer1.Add( self.m_chc_gs1, 0, wx.ALL, 5 )
+
+ self.m_staticText2 = wx.StaticText( sbSizer_chapters.GetStaticBox(), wx.ID_ANY, u"逆转裁判 2", wx.DefaultPosition, wx.DefaultSize, 0 )
+ self.m_staticText2.Wrap( -1 )
+
+ fgSizer1.Add( self.m_staticText2, 0, wx.ALL, 5 )
+
+ m_chc_gs2Choices = [ u"第一章 失落的逆转", u"第二章 再会,然后逆转", u"第三章 逆转马戏团", u"第四章 再见,逆转" ]
+ self.m_chc_gs2 = wx.Choice( sbSizer_chapters.GetStaticBox(), wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, m_chc_gs2Choices, 0 )
+ self.m_chc_gs2.SetSelection( 0 )
+ fgSizer1.Add( self.m_chc_gs2, 0, wx.ALL, 5 )
+
+ self.m_staticText3 = wx.StaticText( sbSizer_chapters.GetStaticBox(), wx.ID_ANY, u"逆转裁判 3", wx.DefaultPosition, wx.DefaultSize, 0 )
+ self.m_staticText3.Wrap( -1 )
+
+ fgSizer1.Add( self.m_staticText3, 0, wx.ALL, 5 )
+
+ m_chc_gs3Choices = [ u"第一章 回忆的逆转", u"第二章 遭窃的逆转", u"第三章 逆转的秘方", u"第四章 初始的逆转", u"第五章 华丽的逆转" ]
+ self.m_chc_gs3 = wx.Choice( sbSizer_chapters.GetStaticBox(), wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, m_chc_gs3Choices, 0 )
+ self.m_chc_gs3.SetSelection( 0 )
+ fgSizer1.Add( self.m_chc_gs3, 0, wx.ALL|wx.EXPAND, 5 )
+
+
+ sbSizer_chapters.Add( fgSizer1, 1, 0, 5 )
+
+
+ bSizer2.Add( sbSizer_chapters, 1, wx.EXPAND, 5 )
+
+ sbSizer_ingame = wx.StaticBoxSizer( wx.StaticBox( self.m_pnl_common, wx.ID_ANY, u"游戏内" ), wx.VERTICAL )
+
+ bSizer5 = wx.BoxSizer( wx.VERTICAL )
+
+ bSizer61 = wx.BoxSizer( wx.HORIZONTAL )
+
+ self.m_staticText5 = wx.StaticText( sbSizer_ingame.GetStaticBox(), wx.ID_ANY, u"存档选择", wx.DefaultPosition, wx.DefaultSize, 0 )
+ self.m_staticText5.Wrap( -1 )
+
+ bSizer61.Add( self.m_staticText5, 0, wx.ALL, 5 )
+
+ m_chc_savesChoices = []
+ self.m_chc_saves = wx.Choice( sbSizer_ingame.GetStaticBox(), wx.ID_ANY, wx.DefaultPosition, wx.Size( 350,-1 ), m_chc_savesChoices, 0 )
+ self.m_chc_saves.SetSelection( 0 )
+ bSizer61.Add( self.m_chc_saves, 0, wx.ALL, 5 )
+
+
+ bSizer5.Add( bSizer61, 0, wx.EXPAND, 5 )
+
+ bSizer6 = wx.BoxSizer( wx.HORIZONTAL )
+
+ self.m_staticText4 = wx.StaticText( sbSizer_ingame.GetStaticBox(), wx.ID_ANY, u"法庭内血量", wx.DefaultPosition, wx.DefaultSize, 0 )
+ self.m_staticText4.Wrap( -1 )
+
+ bSizer6.Add( self.m_staticText4, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5 )
+
+ self.m_sld_hp = wx.Slider( sbSizer_ingame.GetStaticBox(), wx.ID_ANY, 0, 0, 80, wx.DefaultPosition, wx.Size( 150,-1 ), wx.SL_LABELS )
+ bSizer6.Add( self.m_sld_hp, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5 )
+
+
+ bSizer5.Add( bSizer6, 0, wx.EXPAND, 5 )
+
+
+ sbSizer_ingame.Add( bSizer5, 1, wx.EXPAND, 5 )
+
+
+ bSizer2.Add( sbSizer_ingame, 1, wx.EXPAND, 5 )
+
+
+ self.m_pnl_common.SetSizer( bSizer2 )
+ self.m_pnl_common.Layout()
+ bSizer2.Fit( self.m_pnl_common )
+ self.m_notebook1.AddPage( self.m_pnl_common, u"常用设置", True )
+
+ bSizer4.Add( self.m_notebook1, 1, wx.EXPAND, 5 )
+
+
+ self.m_panel2.SetSizer( bSizer4 )
+ self.m_panel2.Layout()
+ bSizer4.Fit( self.m_panel2 )
+ bSizer1.Add( self.m_panel2, 1, wx.EXPAND |wx.ALL, 5 )
+
+
+ self.SetSizer( bSizer1 )
+ self.Layout()
+ self.m_menubar = wx.MenuBar( 0 )
+ self.m_menu_file = wx.Menu()
+ self.m_mi_open = wx.MenuItem( self.m_menu_file, wx.ID_ANY, u"打开..."+ u"\t" + u"Ctrl+O", wx.EmptyString, wx.ITEM_NORMAL )
+ self.m_mi_open.SetBitmap( wx.NullBitmap )
+ self.m_menu_file.Append( self.m_mi_open )
+
+ self.m_mi_open_steam = wx.MenuItem( self.m_menu_file, wx.ID_ANY, u"打开 Steam 存档", wx.EmptyString, wx.ITEM_NORMAL )
+ self.m_menu_file.Append( self.m_mi_open_steam )
+
+ self.m_mi_open_xbox = wx.MenuItem( self.m_menu_file, wx.ID_ANY, u"打开 Xbox 存档", wx.EmptyString, wx.ITEM_NORMAL )
+ self.m_menu_file.Append( self.m_mi_open_xbox )
+
+ self.m_menu_file.AppendSeparator()
+
+ self.m_mi_save = wx.MenuItem( self.m_menu_file, wx.ID_ANY, u"保存"+ u"\t" + u"Ctrl+S", wx.EmptyString, wx.ITEM_NORMAL )
+ self.m_mi_save.SetBitmap( wx.NullBitmap )
+ self.m_menu_file.Append( self.m_mi_save )
+
+ self.m_mi_save_as = wx.MenuItem( self.m_menu_file, wx.ID_ANY, u"另存为...", wx.EmptyString, wx.ITEM_NORMAL )
+ self.m_menu_file.Append( self.m_mi_save_as )
+
+ self.m_menu_file.AppendSeparator()
+
+ self.m_mi_show_debug = wx.MenuItem( self.m_menu_file, wx.ID_ANY, u"显示调试信息", wx.EmptyString, wx.ITEM_NORMAL )
+ self.m_menu_file.Append( self.m_mi_show_debug )
+
+ self.m_menubar.Append( self.m_menu_file, u"文件" )
+
+ self.m_menu_convert = wx.Menu()
+ self.m_mi_xbox2steam = wx.MenuItem( self.m_menu_convert, wx.ID_ANY, u"Xbox → Steam", wx.EmptyString, wx.ITEM_NORMAL )
+ self.m_menu_convert.Append( self.m_mi_xbox2steam )
+
+ self.m_mi_steam2xbox = wx.MenuItem( self.m_menu_convert, wx.ID_ANY, u"Xbox ← Steam", wx.EmptyString, wx.ITEM_NORMAL )
+ self.m_menu_convert.Append( self.m_mi_steam2xbox )
+
+ self.m_menu_convert.AppendSeparator()
+
+ self.m_mi_steam2file = wx.MenuItem( self.m_menu_convert, wx.ID_ANY, u"Steam → 文件...", wx.EmptyString, wx.ITEM_NORMAL )
+ self.m_menu_convert.Append( self.m_mi_steam2file )
+
+ self.m_mi_file2steam = wx.MenuItem( self.m_menu_convert, wx.ID_ANY, u"Steam ← 文件...", wx.EmptyString, wx.ITEM_NORMAL )
+ self.m_menu_convert.Append( self.m_mi_file2steam )
+
+ self.m_menubar.Append( self.m_menu_convert, u"转换" )
+
+ self.SetMenuBar( self.m_menubar )
+
+
+ self.Centre( wx.BOTH )
+
+ # Connect Events
+ self.m_chc_gs1.Bind( wx.EVT_CHOICE, self.chc_gs1_on_choice )
+ self.m_chc_gs2.Bind( wx.EVT_CHOICE, self.chc_gs2_on_choice )
+ self.m_chc_gs3.Bind( wx.EVT_CHOICE, self.chc_gs3_on_choice )
+ self.m_chc_saves.Bind( wx.EVT_CHOICE, self.chc_savs_on_choice )
+ self.m_sld_hp.Bind( wx.EVT_SCROLL_CHANGED, self.sld_hp_on_scroll_changed )
+ self.Bind( wx.EVT_MENU, self.mi_open_on_select, id = self.m_mi_open.GetId() )
+ self.Bind( wx.EVT_MENU, self.mi_open_steam_on_select, id = self.m_mi_open_steam.GetId() )
+ self.Bind( wx.EVT_MENU, self.mi_open_xbox_on_select, id = self.m_mi_open_xbox.GetId() )
+ self.Bind( wx.EVT_MENU, self.mi_save_on_select, id = self.m_mi_save.GetId() )
+ self.Bind( wx.EVT_MENU, self.mi_save_as_on_select, id = self.m_mi_save_as.GetId() )
+ self.Bind( wx.EVT_MENU, self.mi_show_debug_on_select, id = self.m_mi_show_debug.GetId() )
+ self.Bind( wx.EVT_MENU, self.mi_xbox2steam_on_choice, id = self.m_mi_xbox2steam.GetId() )
+ self.Bind( wx.EVT_MENU, self.mi_steam2xbox_on_choice, id = self.m_mi_steam2xbox.GetId() )
+ self.Bind( wx.EVT_MENU, self.mi_steam2file_on_choice, id = self.m_mi_steam2file.GetId() )
+ self.Bind( wx.EVT_MENU, self.mi_file2steam_on_choice, id = self.m_mi_file2steam.GetId() )
+
+ def __del__( self ):
+ pass
+
+
+ # Virtual event handlers, override them in your derived class
+ def chc_gs1_on_choice( self, event ):
+ event.Skip()
+
+ def chc_gs2_on_choice( self, event ):
+ event.Skip()
+
+ def chc_gs3_on_choice( self, event ):
+ event.Skip()
+
+ def chc_savs_on_choice( self, event ):
+ event.Skip()
+
+ def sld_hp_on_scroll_changed( self, event ):
+ event.Skip()
+
+ def mi_open_on_select( self, event ):
+ event.Skip()
+
+ def mi_open_steam_on_select( self, event ):
+ event.Skip()
+
+ def mi_open_xbox_on_select( self, event ):
+ event.Skip()
+
+ def mi_save_on_select( self, event ):
+ event.Skip()
+
+ def mi_save_as_on_select( self, event ):
+ event.Skip()
+
+ def mi_show_debug_on_select( self, event ):
+ event.Skip()
+
+ def mi_xbox2steam_on_choice( self, event ):
+ event.Skip()
+
+ def mi_steam2xbox_on_choice( self, event ):
+ event.Skip()
+
+ def mi_steam2file_on_choice( self, event ):
+ event.Skip()
+
+ def mi_file2steam_on_choice( self, event ):
+ event.Skip()
+
+
diff --git a/app/native_ui/implement.py b/app/native_ui/implement.py
new file mode 100644
index 0000000..e0aae8a
--- /dev/null
+++ b/app/native_ui/implement.py
@@ -0,0 +1,194 @@
+import sys
+import traceback
+import datetime
+
+import wx
+
+from .form import FrameMain
+from app.editor.save_editor import SaveEditor, NoOpenSaveFileError, SaveType
+import app.editor.locator as locator
+
+def _excepthook(type, value, tb):
+ if type == NoOpenSaveFileError:
+ # 不直接调用是因为弹出的消息框会导致原来的焦点控件多次聚焦,
+ # 形成多次值改变事件,进而弹出多个消息框
+ wx.CallAfter(wx.MessageBox, '请先打开任意存档文件。', '错误', wx.OK | wx.ICON_ERROR)
+ else:
+ # 提示错误
+ wx.MessageBox(''.join(traceback.format_exception(type, value, tb)), '错误', wx.OK | wx.ICON_ERROR)
+ traceback.print_exception(type, value, tb)
+sys.excepthook = _excepthook
+
+class Dialog(wx.Dialog):
+ def __init__(self, parent, title, text):
+ super().__init__(parent, title=title, style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
+
+ sizer = wx.BoxSizer(wx.VERTICAL)
+
+ text_ctrl = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_READONLY)
+ text_ctrl.SetValue(text)
+ sizer.Add(text_ctrl, proportion=1, flag=wx.EXPAND | wx.ALL, border=10)
+
+ button = wx.Button(self, label="确认")
+ button.Bind(wx.EVT_BUTTON, self.on_confirm)
+ sizer.Add(button, flag=wx.ALIGN_CENTER | wx.ALL, border=10)
+
+ self.SetSizerAndFit(sizer)
+ self.SetSize(600, 400)
+
+ def on_confirm(self, event):
+ self.EndModal(wx.ID_OK)
+
+class FrameMainImpl(FrameMain):
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.m_sld_hp.SetLineSize(8)
+
+ self.editor = SaveEditor(language='hans') # TODO: i18n
+
+
+ def sld_hp_on_scroll_changed(self, event):
+ # 强制步进 8
+ value = self.m_sld_hp.Value
+ remainder = value % 8
+ if remainder != 0:
+ value -= remainder
+ self.m_sld_hp.Value = value
+
+ # 处理事件
+ self.editor.set_court_hp(self.m_chc_saves.GetSelection(), self.m_sld_hp.Value)
+
+ def mi_open_on_select(self, event):
+ with wx.FileDialog(self, "打开存档文件", wildcard="存档文件 (*.*)|*.*", style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as fileDialog:
+ if fileDialog.ShowModal() == wx.ID_CANCEL:
+ return
+ path = fileDialog.GetPath()
+ self.editor.load(path)
+ self.load_ui()
+
+ def mi_open_steam_on_select(self, event):
+ _, steam_path = locator.system_steam_save_path[0]
+ self.editor.load(steam_path)
+ self.load_ui()
+
+ def mi_open_xbox_on_select(self, event):
+ xbox_path = locator.system_xbox_save_path[0]
+ self.editor.load(xbox_path)
+ self.load_ui()
+
+ def mi_save_on_select(self, event):
+ self.editor.save()
+ wx.MessageBox('保存成功', '提示', wx.OK | wx.ICON_INFORMATION)
+
+ def mi_xbox2steam_on_choice(self, event):
+ if not wx.MessageBox('此操作将会覆盖当前 Steam 存档文件,是否继续?', '警告', wx.YES_NO | wx.ICON_WARNING) == wx.YES:
+ return
+ xbox_path = locator.system_xbox_save_path[0]
+ steam_id, steam_path = locator.system_steam_save_path[0]
+ self.editor.load(xbox_path)
+ self.editor.set_account_id(int(steam_id))
+ self.editor.convert(SaveType.STEAM)
+ self.editor.save(steam_path)
+ wx.MessageBox('转换成功', '提示', wx.OK | wx.ICON_INFORMATION)
+
+ def mi_steam2xbox_on_choice(self, event):
+ if not wx.MessageBox('此操作将会覆盖当前 Xbox 存档文件,是否继续?', '警告', wx.YES_NO | wx.ICON_WARNING) == wx.YES:
+ return
+ xbox_path = locator.system_xbox_save_path[0]
+ _, steam_path = locator.system_steam_save_path[0]
+ self.editor.load(steam_path)
+ self.editor.convert(SaveType.XBOX)
+ self.editor.save(xbox_path)
+ wx.MessageBox('转换成功', '提示', wx.OK | wx.ICON_INFORMATION)
+
+ def mi_save_as_on_select(self, event):
+ with wx.FileDialog(self, "保存存档文件", wildcard="存档文件 (*.*)|*.*", style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) as fileDialog:
+ if fileDialog.ShowModal() == wx.ID_CANCEL:
+ return
+ path = fileDialog.GetPath()
+ self.editor.save(path)
+ wx.MessageBox('保存成功', '提示', wx.OK | wx.ICON_INFORMATION)
+
+ def mi_show_debug_on_select(self, event):
+ msg = 'Steam 路径: ' + repr(locator.steam_path) + '\n'
+ msg += 'Steam 游戏路径: ' + repr(locator.steam_game_path) + '\n'
+ msg += 'Steam 存档路径: ' + repr(locator.system_steam_save_path) + '\n'
+ msg += 'Xbox 游戏路径: ' + repr(locator.xbox_game_path) + '\n'
+ msg += 'Xbox 存档路径: ' + repr(locator.system_xbox_save_path) + '\n'
+ if self.editor.opened:
+ msg += '存档类型: ' + repr(self.editor.save_type) + '\n'
+ msg += 'Steam ID: ' + repr(self.editor.get_account_id()) + '\n'
+ # 显示信息
+ dialog = Dialog(self, '调试信息', msg)
+ dialog.ShowModal()
+ dialog.Destroy()
+
+ def load_ui(self):
+ # 解锁章节
+ gs1 = self.editor.get_unlocked_chapters(1)
+ self.m_chc_gs1.SetSelection(gs1 - 1)
+ gs2 = self.editor.get_unlocked_chapters(2)
+ self.m_chc_gs2.SetSelection(gs2 - 1)
+ gs3 = self.editor.get_unlocked_chapters(3)
+ self.m_chc_gs3.SetSelection(gs3 - 1)
+
+ # 存档选择
+ self.m_chc_saves.Clear()
+ slots = self.editor.get_slots()
+ for i, slot in enumerate(slots, 1):
+ self.m_chc_saves.Append(f'{i:02d}: ' + slot.short_str)
+ newest = max(slots, key=(
+ lambda slot:
+ datetime.datetime.strptime(slot.time, '%Y/%m/%d %H:%M:%S') if slot.time
+ else datetime.datetime(1, 1, 1)
+ ))
+ self.m_chc_saves.SetSelection(slots.index(newest))
+
+ # 触发选择存档事件
+ self.chc_savs_on_choice(None)
+
+ def chc_savs_on_choice(self, event):
+ slot_number = self.m_chc_saves.GetSelection()
+ if slot_number == wx.NOT_FOUND:
+ return
+ self.m_sld_hp.Value = self.editor.get_court_hp(slot_number)
+
+ def mi_file2steam_on_choice(self, event):
+ with wx.FileDialog(self, "打开文件", wildcard="存档文件 (*.*)|*.*", style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as fileDialog:
+ if fileDialog.ShowModal() == wx.ID_CANCEL:
+ return
+ if not wx.MessageBox('此操作将会覆盖当前 Steam 存档文件,是否继续?', '警告', wx.YES_NO | wx.ICON_WARNING) == wx.YES:
+ return
+ path = fileDialog.GetPath()
+ self.editor.load(path)
+ steam_id, steam_path = locator.system_steam_save_path[0]
+ self.editor.set_account_id(int(steam_id))
+ self.editor.save(steam_path)
+ wx.MessageBox('导入成功', '提示', wx.OK | wx.ICON_INFORMATION)
+
+ def mi_steam2file_on_choice(self, event):
+ # TODO 自动检测存档类型
+ with wx.FileDialog(self, "保存文件", wildcard="存档文件 (*.*)|*.*", style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) as fileDialog:
+ if fileDialog.ShowModal() == wx.ID_CANCEL:
+ return
+ path = fileDialog.GetPath()
+ _, steam_path = locator.system_steam_save_path[0]
+ self.editor.load(steam_path)
+ if wx.MessageBox('是否擦除存档中 Steam ID 信息?', '提示', wx.YES_NO | wx.ICON_QUESTION) == wx.YES:
+ self.editor.set_account_id(111111111)
+ self.editor.save(path)
+ wx.MessageBox('导出成功', '提示', wx.OK | wx.ICON_INFORMATION)
+
+ def chc_gs1_on_choice(self, event):
+ self.editor.set_unlocked_chapters(1, self.m_chc_gs1.GetSelection() + 1)
+
+ def chc_gs2_on_choice(self, event):
+ self.editor.set_unlocked_chapters(2, self.m_chc_gs2.GetSelection() + 1)
+
+ def chc_gs3_on_choice(self, event):
+ self.editor.set_unlocked_chapters(3, self.m_chc_gs3.GetSelection() + 1)
+
+app = wx.App()
+frame = FrameMainImpl(None)
+frame.Show()
+app.MainLoop()
diff --git a/app/structs/steam.py b/app/structs/steam.py
index 9e593ad..3515c14 100644
--- a/app/structs/steam.py
+++ b/app/structs/steam.py
@@ -161,7 +161,7 @@ class GlobalWork(Struct):
gauge_rno_1: byte
gauge_hp: short
"""
- 血量(?),满血为 80\n
+ 血量(?),满血为 80,总共有 10 格,一格血量为 8。\n
若修改血量,需要同时修改 `gauge_hp` 和 `gauge_hp_disp。`
"""
gauge_hp_disp: short
diff --git a/app/unpack/text_unpacker.py b/app/unpack/text_unpacker.py
index 5541095..4433bc5 100644
--- a/app/unpack/text_unpacker.py
+++ b/app/unpack/text_unpacker.py
@@ -3,7 +3,7 @@
from .decompiled import *
from .decrypt import decrypt_bytes
-from app.utils import find_game_path
+import app.editor.locator as locator
class TextUnpacker:
@@ -16,7 +16,7 @@ def __init__(
:param game_path: 游戏安装路径。默认为自动搜索
:param language: 语言。默认为英文
"""
- self.game_path = game_path or find_game_path()
+ self.game_path = game_path or locator.steam_game_path or locator.xbox_game_path
if not self.game_path:
raise FileNotFoundError('Could not find game path')
diff --git a/app/utils.py b/app/utils.py
index b05c6dc..21b405d 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -1,37 +1 @@
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', 'systemdata')
- if os.path.exists(user_path):
- return user_path
- return None
\ No newline at end of file
diff --git a/form.fbp b/form.fbp
new file mode 100644
index 0000000..d55bde1
--- /dev/null
+++ b/form.fbp
@@ -0,0 +1,1162 @@
+
+
+
+
+
diff --git a/make.ps1 b/make.ps1
index ef78785..d75ba9a 100644
--- a/make.ps1
+++ b/make.ps1
@@ -14,7 +14,8 @@ cd ..
--add-data "res;res" `
--noconfirm `
--name "PWAAT Save Editor" `
- app\entry.py
+ .\app\entry_native.py
+ # app\entry.py
$bat = @"
@echo off
diff --git a/requirements.txt b/requirements.txt
index ec713c4..1da17f8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,4 +7,5 @@ winrt-Windows.Foundation==2.1.0
winrt-Windows.Foundation.Collections==2.1.0
winrt-Windows.Management==2.1.0
winrt-Windows.Management.Deployment==2.1.0
-winrt-Windows.Storage==2.1.0
\ No newline at end of file
+winrt-Windows.Storage==2.1.0
+wxPython==4.2.1
\ No newline at end of file
diff --git a/res/icons/open.png b/res/icons/open.png
new file mode 100644
index 0000000000000000000000000000000000000000..8b34c6c3e5408ffd7ebb597f8e2606fadaabe19c
GIT binary patch
literal 360
zcmV-u0hj)XP)~;&oB55N5_Z%oPb*#LRgq)JW62r%|8jUG($S6=G`k~`w3p}
z9d=ECF=iGxjssy^;G7}8ZDFIah=+k93C+zXY@J+Sxf9|`k1!2MNOr!vh9pU6C%Aqb
zVt;c5AKe4!{wXf^)?NMQuIw+^
zWjIXO4wW%50fqEET^vI=t|uonFs3C77ziaKloj%pa(ad}7~SDnz$4C~EYi!!mSTKh
znw0c`SVjghO-b&9@1z$1wW^l5MwFx^mZVxG7o`Fz1|tI_6I}yyT_cMS0}Cq?3oBzo
zZ36=<1B0BKb8AsFgculF8JSxd8bCBmUi9D;P=f~ChLX(O
j)Z&uF+yXR9OpUAz%piKMixyP@^)Pt4`njxgN@xNAb@g&*
literal 0
HcmV?d00001