2
\$\begingroup\$

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.

toolic
15.2k5 gold badges29 silver badges213 bronze badges
asked Jan 20, 2023 at 1:24
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

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
answered Sep 2 at 14:10
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.