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/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 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.