diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 987a2f5..69c5a32 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ jobs: - name: Install Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" - name: Install requirements run: | @@ -49,7 +49,7 @@ jobs: - name: Install Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" - name: Install requirements run: | diff --git a/.github/workflows/check-python.yml b/.github/workflows/check-python.yml index db6f080..fb4b2a5 100644 --- a/.github/workflows/check-python.yml +++ b/.github/workflows/check-python.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10"] + python-version: ["3.12"] steps: - uses: actions/checkout@v2 diff --git a/readme.md b/readme.md index e5205ea..876c9d1 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # Deep Rock Galactic Save Editor -This is a DRG save editor written in python (3.10.14) using PyQt5 (5.15.10) and PySide2 (5.15.2). +This is a DRG save editor written in python (3.12) using PyQt6 (6.7.0) and PySide6 (6.7.2). ## There are likely to be bugs, see the Known Issues and Troubleshooting section @@ -12,7 +12,7 @@ That said, I'm in college right now and likely won't have time to implement many if you are using the releases, you shouldn't need to worry about this section -any computer with __git__ (and/or a browser) to download the code, __pip__ to download the required python packages, __Python3__ (version<3.11, PySide2 doesn't have a python 3.11 version working yet) to run the code, and a display should be able to make use of this project +any computer with __git__ (and/or a browser) to download the code, __pip__ to download the required python packages, __Python3__ (version>=3.9) to run the code, and a display should be able to make use of this project ## Installation @@ -29,12 +29,18 @@ any computer with __git__ (and/or a browser) to download the code, __pip__ to do - clone the repo: `git clone https://github.com/AnthonyMichaelTDM/DRG-Save-Editor.git` (or download the source code from github) - open your terminal in the base directory of the project -- ensure you have python 3.10 installed (`python --version` should return a version number starting with 3.10) +- run `python --version` to ensure you have python 3.10 or later installed +- run `python -m venv venv` to create a virtual environment +- activate the virtual environment with the following command: + - for windows: `.\venv\Scripts\activate` + - for linux: `source ./venv/bin/activate` - install the required packages with pip using the following command: `pip install -r ./requirements.txt` - start the program with the `python ./src/main/python/main.py` command if these instructions are unclear a member of the community, [NerdyJosh1](https://github.com/NerdyJosh1), has made a [video tutorial for windows users](https://www.youtube.com/watch?v=2h2-nZ2ptRo&ab_channel=NerdyJosh) +- Note: this tutorial was made for version 1.7.0/1.8.0 of the editor, some things have changed since then + ## Known Issues - The editor works by looking for specific values in the raw data of the save, it doesn't decode the data into a nice, neat python object. As a result if certain things aren't present in the save (e.g., >0 forged overclocks, certain resources) the editor will malfunction and give nonsensical results. The solution is to acquire at least one of the resources in game _then_ use the editor. diff --git a/requirements.txt b/requirements.txt index 03d642f..879c5c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -PyQt5 -PySide2 \ No newline at end of file +PyQt6 +PySide6 diff --git a/src/main/python/main.py b/src/main/python/main.py index e140317..69f619c 100755 --- a/src/main/python/main.py +++ b/src/main/python/main.py @@ -1,37 +1,39 @@ import json -from re import Match import struct import sys from copy import deepcopy +from re import Match from sys import platform from typing import Any -from PySide2.QtCore import QFile, QIODevice, Slot, Qt -from PySide2.QtGui import QCursor, QFocusEvent -from PySide2.QtUiTools import QUiLoader -from PySide2.QtWidgets import ( - QAction, - QApplication, - QFileDialog, - QLineEdit, - QListWidgetItem, - QMenu, - QTreeWidgetItem, - QWidget, -) - from definitions import ( GUID_RE, + LATEST_SEASON, MAX_BADGES, PROMO_RANKS, RANK_TITLES, RESOURCE_GUIDS, SEASON_GUIDS, - LATEST_SEASON, XP_PER_SEASON_LEVEL, XP_PER_WEAPON_LEVEL, XP_TABLE, ) +from PySide6.QtCore import QCoreApplication, QFile, QIODevice, Qt, Slot +from PySide6.QtGui import QAction, QFocusEvent +from PySide6.QtUiTools import QUiLoader +from PySide6.QtWidgets import ( + QApplication, + QComboBox, + QFileDialog, + QGroupBox, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QPushButton, + QTreeWidget, + QTreeWidgetItem, +) if platform == "win32": import winreg @@ -206,7 +208,138 @@ def update_rank() -> None: except: title = "Lord of the Deep" - widget.classes_group.setTitle(f"Classes - Rank {rank+1} {rem}/3, {title}") + widget.classes_group.setTitle(f"Classes - Rank {rank + 1} {rem}/3, {title}") + + +# we use dependency injection to pass the widget to the EditorUI class +class EditorUI: + def __init__(self): + # specify and open the UI + ui_file_name = "editor.ui" + ui_file = QFile(ui_file_name) + if not ui_file.open(QIODevice.ReadOnly): # type: ignore + print("Cannot open {}: {}".format(ui_file_name, ui_file.errorString())) + sys.exit(-1) + ui_file.close() + + # load the UI and do a basic check + loader = QUiLoader() + loader.registerCustomWidget(TextEditFocusChecking) + widget = loader.load(ui_file, None) + if not widget: + print(loader.errorString()) + sys.exit(-1) + + # set the inner widget to the loaded UI + self.inner = widget + + # define the widget's custom attributes for type hinting + + self.actionOpen_Save_File: QAction = self.inner.actionOpen_Save_File # type: ignore[attr-defined] + self.actionReset_to_original_values: QAction = self.inner.actionReset_to_original_values # type: ignore[attr-defined] + self.actionSave_changes: QAction = self.inner.actionSave_changes # type: ignore[attr-defined] + self.actionAdd_overclock_crafting_materials: QAction = self.inner.actionAdd_overclock_crafting_materials # type: ignore[attr-defined] + self.actionSet_All_Classes_to_25: QAction = self.inner.actionSet_All_Classes_to_25 # type: ignore[attr-defined] + self.actionMax_all_available_weapons: QAction = self.inner.actionMax_all_available_weapons # type: ignore[attr-defined] + + self.bismor_text: QLabel = self.inner.__getattribute__("bismor_text") + self.enor_text: QLabel = self.inner.__getattribute__("enor_text") + self.jadiz_text: QLabel = self.inner.__getattribute__("jadiz_text") + self.croppa_text: QLabel = self.inner.__getattribute__("croppa_text") + self.magnite_text: QLabel = self.inner.__getattribute__("magnite_text") + self.umanite_text: QLabel = self.inner.__getattribute__("umanite_text") + + self.barley_text: QLineEdit = self.inner.__getattribute__("barley_text") + self.malt_text: QLineEdit = self.inner.__getattribute__("malt_text") + self.starch_text: QLineEdit = self.inner.__getattribute__("starch_text") + self.yeast_text: QLineEdit = self.inner.__getattribute__("yeast_text") + + self.error_text: QLineEdit = self.inner.__getattribute__("error_text") + self.core_text: QLineEdit = self.inner.__getattribute__("core_text") + self.credits_text: QLineEdit = self.inner.__getattribute__("credits_text") + self.perk_text: QLineEdit = self.inner.__getattribute__("perk_text") + self.data_text: QLineEdit = self.inner.__getattribute__("data_text") + self.phazy_text: QLineEdit = self.inner.__getattribute__("phazy_text") + + self.classes_group: QGroupBox = self.inner.__getattribute__("classes_group") + self.driller_xp: TextEditFocusChecking = self.inner.driller_xp # type: ignore[attr-defined] + self.driller_lvl_text: TextEditFocusChecking = self.inner.driller_lvl_text # type: ignore[attr-defined] + self.driller_xp_2: TextEditFocusChecking = self.inner.driller_xp_2 # type: ignore[attr-defined] + self.driller_promo_box: QComboBox = self.inner.driller_promo_box # type: ignore[attr-defined] + self.engineer_xp: TextEditFocusChecking = self.inner.engineer_xp # type: ignore[attr-defined] + self.engineer_lvl_text: TextEditFocusChecking = self.inner.engineer_lvl_text # type: ignore[attr-defined] + self.engineer_xp_2: TextEditFocusChecking = self.inner.engineer_xp_2 # type: ignore[attr-defined] + self.engineer_promo_box: QComboBox = self.inner.engineer_promo_box # type: ignore[attr-defined] + self.gunner_xp: TextEditFocusChecking = self.inner.__getattribute__("gunner_xp") + self.gunner_lvl_text: TextEditFocusChecking = self.inner.gunner_lvl_text # type: ignore[attr-defined] + self.gunner_xp_2: TextEditFocusChecking = self.inner.gunner_xp_2 # type: ignore[attr-defined] + self.gunner_promo_box: QComboBox = self.inner.gunner_promo_box # type: ignore[attr-defined] + self.scout_xp: TextEditFocusChecking = self.inner.__getattribute__("scout_xp") + self.scout_lvl_text: TextEditFocusChecking = self.inner.scout_lvl_text # type: ignore[attr-defined] + self.scout_xp_2: TextEditFocusChecking = self.inner.scout_xp_2 # type: ignore[attr-defined] + self.scout_promo_box: QComboBox = self.inner.__getattribute__("scout_promo_box") + + self.season_group: QGroupBox = self.inner.__getattribute__("season_group") + self.season_xp: TextEditFocusChecking = self.inner.__getattribute__("season_xp") + self.season_lvl_text: TextEditFocusChecking = self.inner.season_lvl_text # type: ignore[attr-defined] + self.scrip_text: QLineEdit = self.inner.__getattribute__("scrip_text") + self.season_box: QComboBox = self.inner.__getattribute__("season_box") + + self.overclock_tree: QTreeWidget = self.inner.__getattribute__("overclock_tree") + self.combo_oc_filter: QComboBox = self.inner.__getattribute__("combo_oc_filter") + self.add_cores_button: QPushButton = self.inner.add_cores_button # type: ignore[attr-defined] + self.unforged_list: QListWidget = self.inner.__getattribute__("unforged_list") + self.remove_selected_ocs: QPushButton = self.inner.remove_selected_ocs # type: ignore[attr-defined] + self.remove_all_ocs: QPushButton = self.inner.__getattribute__("remove_all_ocs") + + # connect file opening function to menu item + self.actionOpen_Save_File.triggered.connect(open_file) + # set column names for overclock treeview + self.overclock_tree.setHeaderLabels(["Overclock", "Status", "GUID"]) + + # populate the promotion drop downs + promo_boxes = [ + self.driller_promo_box, + self.gunner_promo_box, + self.engineer_promo_box, + self.scout_promo_box, + ] + for i in promo_boxes: + for j in PROMO_RANKS: + i.addItem(j) + + # for k,v in season_guids.items(): + # widget.season_picker.addItem(f'Season {v}') + + # populate the filter drop down for overclocks + sort_labels: list[str] = ["All", "Unforged", "Forged", "Unacquired"] + for i in sort_labels: + self.combo_oc_filter.addItem(i) + + # connect functions to buttons and menu items + self.actionSave_changes.triggered.connect(save_changes) + self.actionSet_All_Classes_to_25.triggered.connect(set_all_25) + self.actionAdd_overclock_crafting_materials.triggered.connect(add_crafting_mats) + self.actionReset_to_original_values.triggered.connect(reset_values) + self.actionMax_all_available_weapons.triggered.connect( + max_all_available_weapon_maintenance + ) + self.combo_oc_filter.currentTextChanged.connect(filter_overclocks) + self.season_box.currentTextChanged.connect(update_season_data) + # widget.overclock_tree.customContextMenuRequested.connect(oc_ctx_menu) + self.add_cores_button.clicked.connect(add_cores) + self.remove_all_ocs.clicked.connect(remove_all_ocs) + self.remove_selected_ocs.clicked.connect(remove_selected_ocs) + self.driller_promo_box.currentIndexChanged.connect(update_rank) + self.engineer_promo_box.currentIndexChanged.connect(update_rank) + self.gunner_promo_box.currentIndexChanged.connect(update_rank) + self.scout_promo_box.currentIndexChanged.connect(update_rank) + + def show(self): + self.inner.show() + + def setWindowTitle(self, title: str) -> None: + self.inner.setWindowTitle(title) @Slot() # type: ignore @@ -259,14 +392,14 @@ def open_file() -> None: widget.overclock_tree.clear() overclock_tree = widget.overclock_tree.invisibleRootItem() build_oc_tree(overclock_tree, guid_dict) - widget.overclock_tree.sortItems(0, Qt.AscendingOrder) + widget.overclock_tree.sortItems(0, Qt.SortOrder.AscendingOrder) # populate list of unforged ocs unforged_list = widget.unforged_list populate_unforged_list(unforged_list, unforged_ocs) -def populate_unforged_list(list_widget, unforged) -> None: +def populate_unforged_list(list_widget: QListWidget, unforged: dict) -> None: # populates the list on acquired but unforged overclocks (includes cosmetics) list_widget.clear() for k, v in unforged.items(): @@ -303,7 +436,7 @@ def update_season_data() -> None: reset_season_data(stats["season-changes"]) -def get_season_data(save_bytes, season_guid) -> dict[str, int]: +def get_season_data(save_bytes: bytes, season_guid: str) -> dict[str, int]: # scrip_marker = bytes.fromhex("546F6B656E73") season_xp_marker: bytes = bytes.fromhex(season_guid) season_xp_offset = 169 @@ -322,7 +455,7 @@ def get_season_data(save_bytes, season_guid) -> dict[str, int]: return {"xp": season_xp, "scrip": scrip} -def get_resources(save_bytes) -> dict[str, int]: +def get_resources(save_bytes: bytes) -> dict[str, int]: # extracts the resource counts from the save file # print('getting resources') # resource GUIDs @@ -409,7 +542,7 @@ def xp_total_to_level(xp: int) -> tuple[int, int]: return (25, 0) -def get_credits(save_bytes): +def get_credits(save_bytes: bytes): marker = b"Credits" offset = 33 pos = save_bytes.find(marker) + offset @@ -418,7 +551,7 @@ def get_credits(save_bytes): return money -def get_perk_points(save_bytes): +def get_perk_points(save_bytes: bytes): marker = b"PerkPoints" offset = 36 if save_bytes.find(marker) == -1: @@ -430,8 +563,8 @@ def get_perk_points(save_bytes): return perk_points -def build_oc_dict(guid_dict): - overclocks = dict() +def build_oc_dict(guid_dict: dict[str, Any]) -> dict[str, Any]: + overclocks: dict[str, Any] = dict() for v in guid_dict.values(): try: @@ -454,7 +587,7 @@ def build_oc_dict(guid_dict): return overclocks -def build_oc_tree(tree, source_dict) -> None: +def build_oc_tree(tree: QTreeWidgetItem, source_dict: dict[str, Any]) -> None: oc_dict = build_oc_dict(source_dict) # entry = QTreeWidgetItem(None) for char, weapons in oc_dict.items(): @@ -471,7 +604,9 @@ def build_oc_tree(tree, source_dict) -> None: oc_entry.setText(2, uuid) -def get_overclocks(save_bytes, guid_source): +def get_overclocks( + save_bytes: bytes, guid_source: dict[str, Any] +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: search_term = b"ForgedSchematics" search_end = b"SkinFixupCounter" pos = save_bytes.find(search_term) @@ -490,12 +625,9 @@ def get_overclocks(save_bytes, guid_source): # print(f'pos: {pos}, end_pos: {end_pos}') # print(f'owned_pos: {owned}, diff: {owned-pos}') - # unforged = True if oc_data.find(b'Owned') else False - if oc_data.find(b"Owned") > 0: - unforged = True - else: - unforged = False - # print(unforged) # bool + # has_unforged = True if oc_data.find(b'Owned') else False + has_unforged = oc_data.find(b"Owned") > 0 + # print(has_unforged) # bool num_forged = struct.unpack("i", save_bytes[pos + 63 : pos + 67])[0] forged = dict() # print(num_forged) @@ -526,7 +658,7 @@ def get_overclocks(save_bytes, guid_source): pass # print('after forged extraction') - if unforged: + if has_unforged: unforged = dict() # print('in unforged loop') num_pos = save_bytes.find(b"Owned", pos) + 62 @@ -612,18 +744,17 @@ def filter_overclocks() -> None: oc.setHidden(True) -@Slot() # type: ignore -def oc_ctx_menu(pos) -> None: - # oc_context_menu = make_oc_context_menu() - # global oc_context_menu - ctx_menu = QMenu(widget.overclock_tree) - add_act = ctx_menu.addAction("Add Core(s) to Inventory") - global_pos = QCursor().pos() - action: QAction = ctx_menu.exec_(global_pos) - if action == add_act: - add_cores() - - # add_act.triggered.connect(add_cores()) +# @Slot() # type: ignore +# def oc_ctx_menu(pos) -> None: +# # oc_context_menu = make_oc_context_menu() +# # global oc_context_menu +# ctx_menu = QMenu(widget.overclock_tree) +# add_act = ctx_menu.addAction("Add Core(s) to Inventory") +# global_pos = QCursor().pos() +# action: QAction = ctx_menu.exec_(global_pos) +# if action == add_act: +# add_cores() +# # add_act.triggered.connect(add_cores()) @Slot() # type: ignore @@ -1284,7 +1415,7 @@ def remove_selected_ocs() -> None: remove_ocs(items_to_remove) -def remove_ocs(oc_list) -> None: +def remove_ocs(oc_list: list[str]) -> None: global unforged_ocs global unacquired_ocs global guid_dict @@ -1319,34 +1450,23 @@ def remove_all_ocs() -> None: # global variable definitions -forged_ocs = dict() # type: ignore -unforged_ocs = dict() # type: ignore -unacquired_ocs = dict() # type: ignore -stats: dict[str, Any] = dict() # type: ignore +forged_ocs: dict[str, Any] = dict() +unforged_ocs: dict[str, Any] = dict() +unacquired_ocs: dict[str, Any] = dict() +stats: dict[str, Any] = dict() weapon_stats: dict[int, list[int, int, bool]] | None = None # type: ignore file_name: str = "" save_data: bytes = b"" season_selected: int = LATEST_SEASON if __name__ == "__main__": - if hasattr(Qt, "AA_EnableHighDpiScaling"): - QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) - - if hasattr(Qt, "AA_UseHighDpiPixmaps"): - QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) - + QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) # print(os.getcwd()) - # specify and open the UI - ui_file_name = "editor.ui" app = QApplication() - ui_file = QFile(ui_file_name) - if not ui_file.open(QIODevice.ReadOnly): - print("Cannot open {}: {}".format(ui_file_name, ui_file.errorString())) - sys.exit(-1) # load reference data with open("guids.json", "r") as g: - guid_dict = json.loads(g.read()) + guid_dict: dict[str, Any] = json.loads(g.read()) try: # find the install path for the steam version @@ -1359,56 +1479,10 @@ def remove_all_ocs() -> None: except: steam_path = "." - # load the UI and do a basic check - loader = QUiLoader() - loader.registerCustomWidget(TextEditFocusChecking) - widget: QWidget = loader.load(ui_file, None) - ui_file.close() - if not widget: - print(loader.errorString()) - sys.exit(-1) - - # connect file opening function to menu item - widget.actionOpen_Save_File.triggered.connect(open_file) - # set column names for overclock treeview - widget.overclock_tree.setHeaderLabels(["Overclock", "Status", "GUID"]) - - # populate the promotion drop downs - promo_boxes = [ - widget.driller_promo_box, - widget.gunner_promo_box, - widget.engineer_promo_box, - widget.scout_promo_box, - ] - for i in promo_boxes: - for j in PROMO_RANKS: - i.addItem(j) - - # populate the filter drop down for overclocks - sort_labels: list[str] = ["All", "Unforged", "Forged", "Unacquired"] - for i in sort_labels: - widget.combo_oc_filter.addItem(i) - - # connect functions to buttons and menu items - widget.actionSave_changes.triggered.connect(save_changes) - widget.actionSet_All_Classes_to_25.triggered.connect(set_all_25) - widget.actionMax_all_available_weapons.triggered.connect( - max_all_available_weapon_maintenance - ) - widget.actionAdd_overclock_crafting_materials.triggered.connect(add_crafting_mats) - widget.actionReset_to_original_values.triggered.connect(reset_values) - widget.combo_oc_filter.currentTextChanged.connect(filter_overclocks) - widget.season_box.currentTextChanged.connect(update_season_data) - # widget.overclock_tree.customContextMenuRequested.connect(oc_ctx_menu) - widget.add_cores_button.clicked.connect(add_cores) - widget.remove_all_ocs.clicked.connect(remove_all_ocs) - widget.remove_selected_ocs.clicked.connect(remove_selected_ocs) - widget.driller_promo_box.currentIndexChanged.connect(update_rank) - widget.engineer_promo_box.currentIndexChanged.connect(update_rank) - widget.gunner_promo_box.currentIndexChanged.connect(update_rank) - widget.scout_promo_box.currentIndexChanged.connect(update_rank) + # load the UI + widget: EditorUI = EditorUI() # actually display the thing widget.show() - exit_code = app.exec_() + exit_code = app.exec() sys.exit(exit_code)