Skip to content

Commit

Permalink
完成 wxPython 版 UI
Browse files Browse the repository at this point in the history
XcantloadX committed Jul 29, 2024
1 parent c77cdc5 commit 31c97e5
Showing 16 changed files with 1,809 additions and 86 deletions.
7 changes: 7 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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" ]
},
]
}
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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. 在游戏内重新读取该存档
14 changes: 11 additions & 3 deletions app/deserializer/types.py
Original file line number Diff line number Diff line change
@@ -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}")
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))
177 changes: 135 additions & 42 deletions app/editor/save_editor.py
Original file line number Diff line number Diff line change
@@ -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())

8 changes: 8 additions & 0 deletions app/entry_native.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added app/native_ui/__init__.py
Empty file.
250 changes: 250 additions & 0 deletions app/native_ui/form.py
Original file line number Diff line number Diff line change
@@ -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()


194 changes: 194 additions & 0 deletions app/native_ui/implement.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion app/structs/steam.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions app/unpack/text_unpacker.py
Original file line number Diff line number Diff line change
@@ -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')

36 changes: 0 additions & 36 deletions app/utils.py
Original file line number Diff line number Diff line change
@@ -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
1,162 changes: 1,162 additions & 0 deletions form.fbp

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion make.ps1
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -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
winrt-Windows.Storage==2.1.0
wxPython==4.2.1
Binary file added res/icons/open.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added res/icons/save.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 31c97e5

Please sign in to comment.