Added DSi title support and began filling out database

This commit is contained in:
2024-05-08 14:25:44 -04:00
parent 32af83f476
commit 93e21023ea
5 changed files with 448 additions and 52 deletions

211
NUSGet.py
View File

@@ -7,6 +7,7 @@ import pathlib
from importlib.metadata import version
import libWiiPy
import libTWLPy
from PySide6.QtWidgets import QApplication, QMainWindow, QMessageBox, QTreeWidgetItem, QHeaderView, QStyle
from PySide6.QtCore import QRunnable, Slot, QThreadPool, Signal, QObject
@@ -14,7 +15,8 @@ from PySide6.QtCore import QRunnable, Slot, QThreadPool, Signal, QObject
from qt.py.ui_MainMenu import Ui_MainWindow
regions = {"World": ["41"], "USA/NTSC": ["45"], "Europe/PAL": ["50"], "Japan": ["4A"], "Korea": ["4B"]}
regions = {"World": ["41"], "USA/NTSC": ["45"], "Europe/PAL": ["50"], "Japan": ["4A"], "Korea": ["4B"], "China": ["43"],
"Australia/NZ": ["55"]}
# Signals needed for the worker used for threading the downloads.
@@ -54,11 +56,13 @@ class MainWindow(QMainWindow, Ui_MainWindow):
self.log_text = ""
self.threadpool = QThreadPool()
self.ui.download_btn.clicked.connect(self.download_btn_pressed)
self.ui.pack_wad_chkbox.clicked.connect(self.pack_wad_chkbox_toggled)
self.ui.pack_archive_chkbox.clicked.connect(self.pack_wad_chkbox_toggled)
# noinspection PyUnresolvedReferences
self.ui.wii_title_tree.header().setSectionResizeMode(QHeaderView.ResizeToContents)
# noinspection PyUnresolvedReferences
self.ui.vwii_title_tree.header().setSectionResizeMode(QHeaderView.ResizeToContents)
# noinspection PyUnresolvedReferences
self.ui.dsi_title_tree.header().setSectionResizeMode(QHeaderView.ResizeToContents)
# Basic intro text set to automatically show when the app loads. This may be changed in the future.
libwiipy_version = "v" + version("libWiiPy")
self.ui.log_text_browser.setText(f"NUSGet v1.0\nDeveloped by NinjaCheetah\nPowered by libWiiPy "
@@ -69,11 +73,13 @@ class MainWindow(QMainWindow, Ui_MainWindow):
# Add console entries to dropdown and attach on change signal.
self.ui.console_select_dropdown.addItem("Wii")
self.ui.console_select_dropdown.addItem("vWii")
self.ui.console_select_dropdown.addItem("DSi")
self.ui.console_select_dropdown.currentIndexChanged.connect(self.selected_console_changed)
# Title tree building code.
wii_tree = self.ui.wii_title_tree
vwii_tree = self.ui.vwii_title_tree
self.trees = [[wii_tree, wii_database], [vwii_tree, vwii_database]]
dsi_tree = self.ui.dsi_title_tree
self.trees = [[wii_tree, wii_database], [vwii_tree, vwii_database], [dsi_tree, dsi_database]]
for tree in self.trees:
self.tree_categories = []
global regions
@@ -115,14 +121,17 @@ class MainWindow(QMainWindow, Ui_MainWindow):
except AttributeError:
return
for tree in self.trees:
for title in tree[1][category]:
# Check to see if the current title matches the selected one, and if it does, pass that info on.
if item.parent().parent().text(0) == (title["TID"] + " - " + title["Name"]):
selected_title = title
selected_version = item.text(0)
selected_region = item.parent().text(0)
self.ui.console_select_dropdown.setCurrentIndex(self.ui.platform_tabs.currentIndex())
self.load_title_data(selected_title, selected_version, selected_region)
try:
for title in tree[1][category]:
# Check to see if the current title matches the selected one, and if it does, pass that info on.
if item.parent().parent().text(0) == (title["TID"] + " - " + title["Name"]):
selected_title = title
selected_version = item.text(0)
selected_region = item.parent().text(0)
self.ui.console_select_dropdown.setCurrentIndex(self.ui.platform_tabs.currentIndex())
self.load_title_data(selected_title, selected_version, selected_region)
except KeyError:
pass
def update_log_text(self, new_text):
# This function primarily exists to be the handler for the progress signal emitted by the worker thread.
@@ -149,8 +158,11 @@ class MainWindow(QMainWindow, Ui_MainWindow):
# Load the WAD name, assuming it exists. This shouldn't ever be able to fail as the database has a WAD name
# for every single title, regardless of whether it can be packed or not.
try:
wad_name = selected_title["WAD Name"] + "-v" + selected_version + ".wad"
self.ui.wad_file_entry.setText(wad_name)
if self.ui.console_select_dropdown.currentText() == "DSi":
archive_name = selected_title["TAD Name"] + "-v" + selected_version + ".tad"
else:
archive_name = selected_title["WAD Name"] + "-v" + selected_version + ".wad"
self.ui.archive_file_entry.setText(archive_name)
except KeyError:
pass
# Same idea for the danger string, however this only exists for certain titles and will frequently be an error.
@@ -170,7 +182,7 @@ class MainWindow(QMainWindow, Ui_MainWindow):
def download_btn_pressed(self):
# Throw an error and make a message box appear if you haven't selected any options to output the title.
if (self.ui.pack_wad_chkbox.isChecked() is False and self.ui.keep_enc_chkbox.isChecked() is False and
if (self.ui.pack_archive_chkbox.isChecked() is False and self.ui.keep_enc_chkbox.isChecked() is False and
self.ui.create_dec_chkbox.isChecked() is False):
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Icon.Critical)
@@ -186,18 +198,21 @@ class MainWindow(QMainWindow, Ui_MainWindow):
self.ui.tid_entry.setEnabled(False)
self.ui.version_entry.setEnabled(False)
self.ui.download_btn.setEnabled(False)
self.ui.pack_wad_chkbox.setEnabled(False)
self.ui.pack_archive_chkbox.setEnabled(False)
self.ui.keep_enc_chkbox.setEnabled(False)
self.ui.create_dec_chkbox.setEnabled(False)
self.ui.use_local_chkbox.setEnabled(False)
self.ui.use_wiiu_nus_chkbox.setEnabled(False)
self.ui.pack_vwii_mode_chkbox.setEnabled(False)
self.ui.wad_file_entry.setEnabled(False)
self.ui.archive_file_entry.setEnabled(False)
self.ui.console_select_dropdown.setEnabled(False)
self.log_text = ""
self.ui.log_text_browser.setText(self.log_text)
# Create a new worker object to handle the download in a new thread.
worker = Worker(self.run_nus_download)
if self.ui.console_select_dropdown.currentText() == "DSi":
worker = Worker(self.run_nus_download_dsi)
else:
worker = Worker(self.run_nus_download_wii)
worker.signals.result.connect(self.check_download_result)
worker.signals.progress.connect(self.update_log_text)
self.threadpool.start(worker)
@@ -234,27 +249,27 @@ class MainWindow(QMainWindow, Ui_MainWindow):
msg_box.setWindowTitle("Ticket Not Available")
msg_box.setText("No Ticket is Available for the Requested Title!")
msg_box.setInformativeText(
"A ticket could not be downloaded for the requested title, but you have selected "
"\"Pack WAD\" or \"Create Decrypted Contents\". These options are not available "
"for titles without a ticket. Only encrypted contents have been saved.")
"A ticket could not be downloaded for the requested title, but you have selected \"Pack installable "
" archive\" or \"Create decrypted contents\". These options are not available for titles without a "
" ticket. Only encrypted contents have been saved.")
msg_box.exec()
# Now that the thread has closed, unlock the UI to allow for the next download.
self.ui.tid_entry.setEnabled(True)
self.ui.version_entry.setEnabled(True)
self.ui.download_btn.setEnabled(True)
self.ui.pack_wad_chkbox.setEnabled(True)
self.ui.pack_archive_chkbox.setEnabled(True)
self.ui.keep_enc_chkbox.setEnabled(True)
self.ui.create_dec_chkbox.setEnabled(True)
self.ui.use_local_chkbox.setEnabled(True)
self.ui.use_wiiu_nus_chkbox.setEnabled(True)
self.ui.console_select_dropdown.setEnabled(True)
if self.ui.pack_wad_chkbox.isChecked() is True:
self.ui.wad_file_entry.setEnabled(True)
if self.ui.pack_archive_chkbox.isChecked() is True:
self.ui.archive_file_entry.setEnabled(True)
# Call the dropdown callback because this will automagically handle setting console-specific settings based
# on the currently selected console, and saves on duplicate code.
self.selected_console_changed()
def run_nus_download(self, progress_callback):
def run_nus_download_wii(self, progress_callback):
# Actual NUS download function that runs in a separate thread.
tid = self.ui.tid_entry.text()
# Immediately knock out any invalidly formatted Title IDs.
@@ -267,7 +282,7 @@ class MainWindow(QMainWindow, Ui_MainWindow):
except ValueError:
title_version = None
# Set variables for these two options so that their state can be compared against the user's choices later.
pack_wad_enabled = self.ui.pack_wad_chkbox.isChecked()
pack_wad_enabled = self.ui.pack_archive_chkbox.isChecked()
decrypt_contents_enabled = self.ui.create_dec_chkbox.isChecked()
# Check whether we're going to be using the (faster) Wii U NUS or not.
wiiu_nus_enabled = self.ui.use_wiiu_nus_chkbox.isChecked()
@@ -384,8 +399,8 @@ class MainWindow(QMainWindow, Ui_MainWindow):
title.wad.set_cert_data(libWiiPy.download_cert(wiiu_endpoint=wiiu_nus_enabled))
# Use a typed WAD name if there is one, and auto generate one based on the TID and version if there isn't.
progress_callback.emit("Packing WAD...")
if self.ui.wad_file_entry.text() != "":
wad_file_name = self.ui.wad_file_entry.text()
if self.ui.archive_file_entry.text() != "":
wad_file_name = self.ui.archive_file_entry.text()
if wad_file_name[-4:] != ".wad":
wad_file_name = wad_file_name + ".wad"
else:
@@ -398,18 +413,146 @@ class MainWindow(QMainWindow, Ui_MainWindow):
# This is where the variables come in. If the state of these variables doesn't match the user's choice by this
# point, it means that they enabled decryption or WAD packing for a title that doesn't have a ticket. Return
# code 1 so that a warning popup is shown informing them of this.
if ((not pack_wad_enabled and self.ui.pack_wad_chkbox.isChecked()) or
if ((not pack_wad_enabled and self.ui.pack_archive_chkbox.isChecked()) or
(not decrypt_contents_enabled and self.ui.create_dec_chkbox.isChecked())):
return 1
return 0
def run_nus_download_dsi(self, progress_callback):
# Actual NUS download function that runs in a separate thread, but DSi flavored.
tid = self.ui.tid_entry.text()
# Immediately knock out any invalidly formatted Title IDs.
if len(tid) != 16:
return -1
# An error here is acceptable, because it may just mean the box is empty. Or the version string is nonsense.
# Either way, just fall back on downloading the latest version of the title.
try:
title_version = int(self.ui.version_entry.text())
except ValueError:
title_version = None
# Set variables for these two options so that their state can be compared against the user's choices later.
pack_tad_enabled = self.ui.pack_archive_chkbox.isChecked()
decrypt_contents_enabled = self.ui.create_dec_chkbox.isChecked()
# Create a new libTWLPy Title.
title = libTWLPy.Title()
# Make a directory for this title if it doesn't exist.
title_dir = pathlib.Path(os.path.join(out_folder, tid))
if not title_dir.is_dir():
title_dir.mkdir()
# Announce the title being downloaded, and the version if applicable.
if title_version is not None:
progress_callback.emit("Downloading title " + tid + " v" + str(title_version) + ", please wait...")
else:
progress_callback.emit("Downloading title " + tid + " vLatest, please wait...")
progress_callback.emit(" - Downloading and parsing TMD...")
# Download a specific TMD version if a version was specified, otherwise just download the latest TMD.
try:
if title_version is not None:
title.load_tmd(libTWLPy.download_tmd(tid, title_version))
else:
title.load_tmd(libTWLPy.download_tmd(tid))
title_version = title.tmd.title_version
# If libTWLPy returns an error, that means that either the TID or version doesn't exist, so return code -2.
except ValueError:
return -2
# Make a directory for this version if it doesn't exist.
version_dir = pathlib.Path(os.path.join(title_dir, str(title_version)))
if not version_dir.is_dir():
version_dir.mkdir()
# Write out the TMD to a file.
tmd_out = open(os.path.join(version_dir, "tmd." + str(title_version)), "wb")
tmd_out.write(title.tmd.dump())
tmd_out.close()
# Use a local ticket, if one exists and "use local files" is enabled.
if self.ui.use_local_chkbox.isChecked() is True and os.path.exists(os.path.join(version_dir, "tik")):
progress_callback.emit(" - Parsing local copy of Ticket...")
local_ticket = open(os.path.join(version_dir, "tik"), "rb")
title.load_ticket(local_ticket.read())
else:
progress_callback.emit(" - Downloading and parsing Ticket...")
try:
title.load_ticket(libTWLPy.download_ticket(tid))
ticket_out = open(os.path.join(version_dir, "tik"), "wb")
ticket_out.write(title.ticket.dump())
ticket_out.close()
except ValueError:
# If libTWLPy returns an error, then no ticket is available. Log this, and disable options requiring a
# ticket so that they aren't attempted later.
progress_callback.emit(" - No Ticket is available!")
pack_tad_enabled = False
decrypt_contents_enabled = False
# Load the content record from the TMD, and download the content it lists. DSi titles only have one content.
title.load_content_records()
content_file_name = hex(title.tmd.content_record.content_id)[2:]
while len(content_file_name) < 8:
content_file_name = "0" + content_file_name
# Check for a local copy of the current content if "use local files" is enabled, and use it.
if self.ui.use_local_chkbox.isChecked() is True and os.path.exists(os.path.join(version_dir,
content_file_name)):
progress_callback.emit(" - Using local copy of content")
local_file = open(os.path.join(version_dir, content_file_name), "rb")
content = local_file.read()
else:
progress_callback.emit(" - Downloading content (Content ID: " + str(title.tmd.content_record.content_id) +
", Size: " + str(title.tmd.content_record.content_size) + " bytes)...")
content = libTWLPy.download_content(tid, title.tmd.content_record.content_id)
progress_callback.emit(" - Done!")
# If keep encrypted contents is on, write out each content after its downloaded.
if self.ui.keep_enc_chkbox.isChecked() is True:
enc_content_out = open(os.path.join(version_dir, content_file_name), "wb")
enc_content_out.write(content)
enc_content_out.close()
title.content.content = content
# If decrypt local contents is still true, decrypt each content and write out the decrypted file.
if decrypt_contents_enabled is True:
try:
progress_callback.emit(" - Decrypting content (Content ID: " + str(title.tmd.content_record.content_id)
+ ")...")
dec_content = title.get_content()
content_file_name = hex(title.tmd.content_record.content_id)[2:]
while len(content_file_name) < 8:
content_file_name = "0" + content_file_name
content_file_name = content_file_name + ".app"
dec_content_out = open(os.path.join(version_dir, content_file_name), "wb")
dec_content_out.write(dec_content)
dec_content_out.close()
except ValueError:
# If libWiiPy throws an error during decryption, return code -3. This should only be possible if using
# local encrypted contents that have been altered at present.
return -3
# If pack TAD is still true, pack the TMD, ticket, and content into a TAD.
if pack_tad_enabled is True:
# Get the TAD certificate chain, courtesy of libTWLPy.
progress_callback.emit(" - Building certificate...")
title.tad.set_cert_data(libTWLPy.download_cert())
# Use a typed TAD name if there is one, and auto generate one based on the TID and version if there isn't.
progress_callback.emit("Packing TAD...")
if self.ui.archive_file_entry.text() != "":
tad_file_name = self.ui.archive_file_entry.text()
if tad_file_name[-4:] != ".tad":
tad_file_name = tad_file_name + ".tad"
else:
tad_file_name = tid + "-v" + str(title_version) + ".tad"
# Have libTWLPy dump the TAD, and write that data out.
file = open(os.path.join(version_dir, tad_file_name), "wb")
file.write(title.dump_tad())
file.close()
progress_callback.emit("Download complete!")
# This is where the variables come in. If the state of these variables doesn't match the user's choice by this
# point, it means that they enabled decryption or TAD packing for a title that doesn't have a ticket. Return
# code 1 so that a warning popup is shown informing them of this.
if ((not pack_tad_enabled and self.ui.pack_archive_chkbox.isChecked()) or
(not decrypt_contents_enabled and self.ui.create_dec_chkbox.isChecked())):
return 1
return 0
def pack_wad_chkbox_toggled(self):
# Simple function to catch when the WAD checkbox is toggled and enable/disable the file name entry box
# Simple function to catch when the WAD/TAD checkbox is toggled and enable/disable the file name entry box
# accordingly.
if self.ui.pack_wad_chkbox.isChecked() is True:
self.ui.wad_file_entry.setEnabled(True)
if self.ui.pack_archive_chkbox.isChecked() is True:
self.ui.archive_file_entry.setEnabled(True)
else:
self.ui.wad_file_entry.setEnabled(False)
self.ui.archive_file_entry.setEnabled(False)
def selected_console_changed(self):
# Callback function to enable or disable console-specific settings based on the selected console.
@@ -421,11 +564,13 @@ class MainWindow(QMainWindow, Ui_MainWindow):
if __name__ == "__main__":
app = QApplication(sys.argv)
# Load the database file, this will work for both the raw Python file and compiled standalone/onefile binaries.
# Load the database files, this will work for both the raw Python file and compiled standalone/onefile binaries.
database_file = open(os.path.join(os.path.dirname(__file__), "data/wii-database.json"))
wii_database = json.load(database_file)
database_file = open(os.path.join(os.path.dirname(__file__), "data/vwii-database.json"))
vwii_database = json.load(database_file)
database_file = open(os.path.join(os.path.dirname(__file__), "data/dsi-database.json"))
dsi_database = json.load(database_file)
# If this is a compiled build, the path needs to be obtained differently than if it isn't. The use of an absolute
# path here is for compatibility with macOS .app bundles, which require the use of absolute paths.
try: