I'm developing a Xenoblade Chronicles 3 save data editor in Python with PySide6. My experience in Python has, over the years, been terminal scripts or web applications, so GUI scripting is completely new to me. I feel like I'm over-complicating what I'm attempting to do and am looking for guidance.
I apologize if I'm dumping a lot of code, but I'd just like to be thorough in how I'm attempting to approach this.
First I have my primary window, which was initially generated by Qt Creator, and I modified from there to include loading the widget values from a class defined elsewhere (detailed below):
./ui/window.py:
# Framework
from PySide6.QtCore import QCoreApplication, QRect
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QCheckBox, QGridLayout, QGroupBox, QLabel, QMenu, QMenuBar, QSpinBox, QTabWidget, QWidget
# Application
from core.data import Data
from core import actions
class MainWindow(object):
def setupUi(self, mainWindow):
if not mainWindow.objectName():
mainWindow.setObjectName(u'mainWindow')
mainWindow.setFixedSize(800, 600)
self.menuBar = QMenuBar(mainWindow)
self.menuBar.setObjectName(u'menuBar')
self.menuBar.setGeometry(QRect(0, 0, 800, 43))
self.menuFile = QMenu(self.menuBar)
self.menuFile.setObjectName(u'menuFile')
self.menuBar.addAction(self.menuFile.menuAction())
self.actionOpen = QAction(mainWindow)
self.actionOpen.setObjectName(u'actionOpen')
self.actionOpen.triggered.connect(actions.openFile)
self.actionOpen.triggered.connect(self.loadData)
self.menuFile.addAction(self.actionOpen)
self.actionSave = QAction(mainWindow)
self.actionSave.setObjectName(u'actionSave')
self.actionSave.setDisabled(True)
self.actionSave.triggered.connect(self.saveData)
self.actionSave.triggered.connect(actions.saveFile)
self.menuFile.addAction(self.actionSave)
self.menuFile.addSeparator()
self.actionExit = QAction(mainWindow)
self.actionExit.setObjectName(u'actionExit')
self.menuFile.addAction(self.actionExit)
self.menuHelp = QMenu(self.menuBar)
self.menuHelp.setObjectName(u'menuHelp')
self.menuBar.addAction(self.menuHelp.menuAction())
self.actionAbout = QAction(mainWindow)
self.actionAbout.setObjectName(u'actionAbout')
self.menuHelp.addAction(self.actionAbout)
mainWindow.setMenuBar(self.menuBar)
self.centralWidget = QWidget(mainWindow)
self.centralWidget.setObjectName(u'centralWidget')
mainWindow.setCentralWidget(self.centralWidget)
self.gridLayout = QGridLayout(self.centralWidget)
self.gridLayout.setObjectName(u'gridLayout')
self.tabWidget = QTabWidget(self.centralWidget)
self.tabWidget.setObjectName(u'tabWidget')
self.gridLayout.addWidget(self.tabWidget, 0, 0, 1, 1)
self.tabGeneral = QWidget()
self.tabGeneral.setObjectName(u'tabGeneral')
self.tabWidget.addTab(self.tabGeneral, 'General')
self.groupBoxCurrency = QGroupBox(self.tabGeneral)
self.groupBoxCurrency.setObjectName(u'groupBoxCurrency')
self.groupBoxCurrency.setGeometry(QRect(20, 0, 241, 171))
self.labelGold = QLabel(self.groupBoxCurrency)
self.labelGold.setObjectName(u'labelGold')
self.labelGold.setGeometry(QRect(20, 40, 150, 16))
self.spinBoxGold = QSpinBox(self.groupBoxCurrency)
self.spinBoxGold.setObjectName(u'spinBoxGold')
self.spinBoxGold.setGeometry(QRect(20, 60, 150, 22))
self.spinBoxGold.setRange(0, 99999999)
self.labelEther = QLabel(self.groupBoxCurrency)
self.labelEther.setObjectName(u'labelEther')
self.labelEther.setGeometry(QRect(20, 110, 150, 16))
self.spinBoxEther = QSpinBox(self.groupBoxCurrency)
self.spinBoxEther.setObjectName(u'spinBoxEther')
self.spinBoxEther.setGeometry(QRect(20, 130, 75, 22))
self.spinBoxEther.setRange(0, 99)
self.groupBoxFlags = QGroupBox(self.tabGeneral)
self.groupBoxFlags.setObjectName(u'groupBoxFlags')
self.groupBoxFlags.setGeometry(QRect(280, 0, 241, 81))
self.completedCheckBox = QCheckBox(self.groupBoxFlags)
self.completedCheckBox.setObjectName(u'checkBox')
self.completedCheckBox.setGeometry(QRect(20, 40, 150, 20))
self.tabCharacters = QWidget()
self.tabCharacters.setObjectName(u'tabCharacter')
self.tabWidget.addTab(self.tabCharacters, 'Characters')
self.tabInventory = QWidget()
self.tabInventory.setObjectName(u'tabInventory')
self.tabWidget.addTab(self.tabInventory, 'Inventory')
self.tabSkills = QWidget()
self.tabSkills.setObjectName(u'tabSkills')
self.tabWidget.addTab(self.tabSkills, 'Skills')
self.translateUi(mainWindow)
def translateUi(self, mainWindow):
mainWindow.setWindowTitle(QCoreApplication.translate('mainWindow', u'Xenoblade 3 Save Editor', None))
self.actionOpen.setText(QCoreApplication.translate('mainWindow', u'Open...', None))
self.actionSave.setText(QCoreApplication.translate('mainWindow', u'Save', None))
self.actionExit.setText(QCoreApplication.translate('mainWindow', u'Exit', None))
self.actionAbout.setText(QCoreApplication.translate('mainWindow', u'About', None))
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tabGeneral), QCoreApplication.translate('mainWindow', u'General', None))
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tabCharacters), QCoreApplication.translate('mainWindow', u'Characters', None))
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tabInventory), QCoreApplication.translate('mainWindow', u'Inventory', None))
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tabSkills), QCoreApplication.translate('mainWindow', u'Skills', None))
self.groupBoxCurrency.setTitle(QCoreApplication.translate('mainWindow', u' Currency ', None))
self.labelGold.setText(QCoreApplication.translate('mainWindow', u'Gold', None))
self.labelEther.setText(QCoreApplication.translate('mainWindow', u'Ether', None))
self.groupBoxFlags.setTitle(QCoreApplication.translate('mainWindow', u' Flags ', None))
self.completedCheckBox.setText(QCoreApplication.translate('mainWindow', u'New Game+', None))
self.menuFile.setTitle(QCoreApplication.translate('mainWindow', u'File', None))
self.menuHelp.setTitle(QCoreApplication.translate('mainWindow', u'Help', None))
def loadData(self):
self.actionSave.setDisabled(False)
if Data.completed:
self.completedCheckBox.setChecked(True)
self.spinBoxGold.setValue(Data.gold)
self.spinBoxEther.setValue(Data.ether)
def saveData(self):
if self.completedCheckBox.isChecked():
Data.completed = True
Data.gold = self.spinBoxGold.value()
Data.ether = self.spinBoxEther.value()
Then I went to work on how to read and write data from my dumped save file by listing some locations in the data I'm aware of in a dictionary. Then a class to hold variables so that once the data is loaded, it can be persistently accessed while the application is open. Finally, there are two functions to do the reading from and writing to the data.
./core/data.py:
# Python
import binascii, functools, struct
LOCATIONS = {
'gold': {
'address': 0x20,
'length': 0x4,
'type': '<L'
},
'ether': {
'address': 0x53CAC,
'length': 0x1,
'type': '<B'
},
'completed': {
'address': 0xA6B,
'length': 0x1,
'type': '<?'
}
}
class Data:
filepath = ''
data = None
# Game Flags
completed = False
# Currency
gold = 0
ether = 0
def read_value(data, start, length, type):
raw = binascii.unhexlify(data[start * 2:(start + length) * 2])
unpacked = struct.unpack(type, raw)
value = functools.reduce(lambda result, data: result * 10 + data, (unpacked))
return value
def write_value(data, value, start, length, type):
packed = binascii.hexlify(struct.pack(type, value))
data = data[:start * 2] + packed + data[(start + length) * 2:]
return data
Finally I have my GUI actions definitions where I load all the data into the Data
class variables and prepare them for reading to/writing from the GUI:
./core/actions.py:
# Python
import binascii
from pathlib import Path
# Framework
from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QFileDialog, QMessageBox
# Application
from core.data import LOCATIONS, Data, read_value, write_value
def exitApplication():
QCoreApplication.exit()
def openFile():
dialog = QFileDialog.getOpenFileName(None, 'Open File', '', 'SAV files (*.sav)')
if dialog[0] == '':
return
if Path(dialog[0]).stat().st_size == 1826816:
file = Path(dialog[0]).read_bytes()
data = binascii.hexlify(file)
Data.filepath = Path(dialog[0])
Data.data = data
Data.completed = read_value(Data.data, LOCATIONS['completed']['address'], LOCATIONS['completed']['length'], LOCATIONS['completed']['type'])
Data.gold = read_value(Data.data, LOCATIONS['gold']['address'], LOCATIONS['gold']['length'], LOCATIONS['gold']['type'])
Data.ether = read_value(Data.data, LOCATIONS['ether']['address'], LOCATIONS['ether']['length'], LOCATIONS['ether']['type'])
else:
error = QMessageBox()
error.setWindowTitle('Invalid Save File')
error.setText("The file you've selected isn't recognized as a valid save file. Either it's not a Xenoblade 3 save data file, or you're attempting to load a file from an unsupported version of the game.")
error.exec_()
def saveFile():
Data.data = write_value(Data.data, Data.completed, LOCATIONS['completed']['address'], LOCATIONS['completed']['length'], LOCATIONS['completed']['type'])
Data.data = write_value(Data.data, Data.gold, LOCATIONS['gold']['address'], LOCATIONS['gold']['length'], LOCATIONS['gold']['type'])
Data.data = write_value(Data.data, Data.ether, LOCATIONS['ether']['address'], LOCATIONS['ether']['length'], LOCATIONS['ether']['type'])
Path(Data.filepath).write_bytes(binascii.unhexlify(Data.data))
The general lifecycle of use is:
- Open the save file with the `openFile()` action - Load the raw, hexified data into `Data.data` - Translate the data in `Data.data` to individual, human-readable variables - The `loadData()` action is called after the file is loaded to display the data - You modify it to your pleasure - Clicking on Save, triggering `saveFile()` then does everything in reverse order
I only start seeing problems when trying to tackle loading data for characters, and I can easily see the code ballooning to an extravagant (and ugly) size as I move forward.
While this works, I can't help but feel I'm approaching loading, modifying, and saving the data from the wrong direction and that there's an easier, less intense way of handling things. I value anyone's opinions, as this is my first foray into working with PySide6/GUI scripting in general.
Feel free to browse the code repository if you wish, since it's publicly available on GitLab.
1 Answer 1
Documentation
The PEP 8 style guide recommends adding docstrings for classes and functions.
Describe the purpose of the class:
class MainWindow(object):
"""
Why you created the class
Add details of what it contains and what it does
"""
The same for functions:
def loadData(self):
"""
Describe the kind of data
Specify where it is loaded
"""
Simpler
This line:
class MainWindow(object):
is simpler without object
:
class MainWindow():
Large integers are easier to read using underscores. Change:
99999999
to:
99_999_999
Naming
PEP 8 recommends snake_case for function and variable names.
setupUi
would be setup_ui
mainWindow
would be main_window
This is a vague name for a class:
class Data:
It would be better to rename it based on the type of data that it represents.
The same is true for the data
variable in that class.
The variable named type
in the read_value
function is the same name as a Python built-in function.
This can be confusing. To eliminate the confusion, rename the variable
as something more specific. The first clue is that "type" has special
coloring (syntax highlighting) in the question, as it does when I copy the code
into my editor.
Tools
You could run code development tools to automatically find some style issues with your code.
ruff
suggests that you split these imports:
import binascii, functools, struct
onto separate lines. isort
can even be used to do that for you:
import binascii
import functools
import struct