launcher: add local game patches (#111)

launcher: add game patches
This commit is contained in:
nmzik
2026-07-27 16:19:31 +02:00
committed by GitHub
parent 31ea0081a9
commit f60f80b631
14 changed files with 497 additions and 14 deletions
@@ -183,6 +183,11 @@
<string>Serial</string>
</property>
</column>
<column>
<property name="text">
<string>Game Version</string>
</property>
</column>
<column>
<property name="text">
<string>Firmware Version</string>
+2
View File
@@ -73,6 +73,7 @@ public:
QString name;
QString title_id; /* Serial / title id from sce_sys/param.json */
QString gameVersion; /* appVersion / contentVersion from sce_sys/param.json */
QString firmwareVer; /* requiredSystemSoftwareVersion from sce_sys/param.json */
QString basedir; /* Game base directory */
QString game_path; /* Launcher-unique game path */
@@ -117,6 +118,7 @@ public:
void CopyFrom(const Configuration& other) {
name = other.name;
title_id = other.title_id;
gameVersion = other.gameVersion;
firmwareVer = other.firmwareVer;
basedir = other.basedir;
game_path = other.game_path;
+34
View File
@@ -0,0 +1,34 @@
#ifndef PATCHES_DIALOG_H
#define PATCHES_DIALOG_H
#include "common.h"
#include <QDialog>
#include <QString>
class Configuration;
class QLabel;
class QListWidget;
class QPushButton;
class PatchesDialog final: public QDialog {
public:
explicit PatchesDialog(const Configuration& game, QWidget* parent = nullptr);
~PatchesDialog() override = default;
KYTY_QT_CLASS_NO_COPY(PatchesDialog);
[[nodiscard]] static bool IsSupportedTitleId(const QString& title_id);
[[nodiscard]] static QString PatchPlanPath(const QString& title_id);
private:
void Load();
void Save();
QString m_title_id;
QListWidget* m_patches = nullptr;
QLabel* m_status = nullptr;
QPushButton* m_apply = nullptr;
};
#endif // PATCHES_DIALOG_H
+12 -2
View File
@@ -24,6 +24,7 @@ namespace {
enum Column {
NameColumn,
SerialColumn,
GameVersionColumn,
FirmwareVersionColumn,
PathColumn,
StatusColumn,
@@ -85,6 +86,9 @@ QString GetDisplayText(const Configuration& info) {
if (!info.title_id.isEmpty()) {
lines.append(QStringLiteral("Serial ID: %1").arg(info.title_id));
}
if (!info.gameVersion.isEmpty()) {
lines.append(QStringLiteral("Game version: %1").arg(info.gameVersion));
}
if (!info.firmwareVer.isEmpty()) {
lines.append(QStringLiteral("Firmware version: %1").arg(info.firmwareVer));
}
@@ -169,6 +173,8 @@ void ConfigurationItem::Update() {
setText(NameColumn, m_info->name);
setText(SerialColumn, m_info->title_id);
setText(GameVersionColumn,
m_info->gameVersion.isEmpty() ? QStringLiteral("\u2014") : m_info->gameVersion);
setText(FirmwareVersionColumn,
m_info->firmwareVer.isEmpty() ? QStringLiteral("\u2014") : m_info->firmwareVer);
setText(PathColumn, path);
@@ -198,9 +204,13 @@ bool ConfigurationItem::operator<(const QTreeWidgetItem& other) const {
case StatusColumn:
return GetStatusText(m_info->game_status) <
GetStatusText(other_item->m_info->game_status);
case GameVersionColumn:
case FirmwareVersionColumn: {
const auto& version = m_info->firmwareVer;
const auto& other_version = other_item->m_info->firmwareVer;
const auto& version = column == GameVersionColumn ? m_info->gameVersion
: m_info->firmwareVer;
const auto& other_version = column == GameVersionColumn
? other_item->m_info->gameVersion
: other_item->m_info->firmwareVer;
if (version.isEmpty() || other_version.isEmpty()) {
return version.isEmpty() && !other_version.isEmpty();
}
+22 -4
View File
@@ -1,5 +1,6 @@
#include "configurationListWidget.h"
#include "patchesDialog.h"
#include "common.h"
#include "compatibilityDatabase.h"
#include "configuration.h"
@@ -55,10 +56,11 @@ constexpr char SAVE_DATA_DIR[] = "_SaveData";
constexpr int GAME_NAME_COLUMN = 0;
constexpr int GAME_SERIAL_COLUMN = 1;
constexpr int GAME_FIRMWARE_VERSION_COLUMN = 2;
constexpr int GAME_PATH_COLUMN = 3;
constexpr int GAME_STATUS_COLUMN = 4;
constexpr int GAME_COMMENT_COLUMN = 5;
constexpr int GAME_VERSION_COLUMN = 2;
constexpr int GAME_FIRMWARE_VERSION_COLUMN = 3;
constexpr int GAME_PATH_COLUMN = 4;
constexpr int GAME_STATUS_COLUMN = 5;
constexpr int GAME_COMMENT_COLUMN = 6;
static QString NormalizeGameDirectory(const QString& dir) {
const auto trimmed = dir.trimmed();
@@ -239,6 +241,7 @@ ConfigurationListWidget::ConfigurationListWidget(QWidget* parent)
m_ui->cfgs_list->setSortingEnabled(true);
m_ui->cfgs_list->setColumnWidth(GAME_NAME_COLUMN, 320);
m_ui->cfgs_list->setColumnWidth(GAME_SERIAL_COLUMN, 110);
m_ui->cfgs_list->setColumnWidth(GAME_VERSION_COLUMN, 120);
m_ui->cfgs_list->setColumnWidth(GAME_FIRMWARE_VERSION_COLUMN, 150);
m_ui->cfgs_list->setColumnWidth(GAME_PATH_COLUMN, 320);
m_ui->cfgs_list->setColumnWidth(GAME_STATUS_COLUMN, 150);
@@ -403,6 +406,7 @@ static Configuration* CloneConfiguration(const Configuration& source) {
struct GameMetadata {
QString title_name;
QString title_id;
QString gameVersion;
QString firmwareVer;
};
@@ -488,6 +492,10 @@ static GameMetadata GetGameMetadata(const QString& param_file, const QString& fa
}
ret.title_id = GetJsonString(root, QStringLiteral("titleId"));
ret.gameVersion = GetJsonString(root, QStringLiteral("appVersion"));
if (ret.gameVersion.isEmpty()) {
ret.gameVersion = GetJsonString(root, QStringLiteral("contentVersion"));
}
ret.firmwareVer = GetFirmwareVersion(root);
return ret;
@@ -501,6 +509,7 @@ static void SetGameFiles(Configuration& info, const QString& game_dir, const QSt
info.basedir = game.absolutePath();
info.name = metadata.title_name;
info.title_id = metadata.title_id;
info.gameVersion = metadata.gameVersion;
info.firmwareVer = metadata.firmwareVer;
if (info.name.isEmpty()) {
@@ -866,6 +875,15 @@ void ConfigurationListWidget::show_context_menu(const QPoint& pos) {
style()->standardIcon(QStyle::SP_FileDialogContentsView), tr("View trophies..."));
connect(action_view_trophies, &QAction::triggered, this,
&ConfigurationListWidget::ViewTrophies);
QAction* action_patches = menu.addAction(tr("Patches (experimental)..."));
connect(action_patches, &QAction::triggered, this, [this, item]() {
if (item != nullptr) {
auto* dialog = new PatchesDialog(item->GetInfo(), this);
dialog->show();
}
});
action_patches->setVisible(item != nullptr &&
PatchesDialog::IsSupportedTitleId(item->GetInfo().title_id));
QAction* action_remove_save_data =
menu.addAction(style()->standardIcon(QStyle::SP_DialogDiscardButton),
tr("Remove save data..."), this, SLOT(remove_save_data()));
+6
View File
@@ -4,6 +4,7 @@
#include "configuration.h"
#include "configurationItem.h"
#include "configurationListWidget.h"
#include "patchesDialog.h"
#include <QApplication>
#include <QByteArray>
@@ -228,6 +229,11 @@ static QStringList CreateEmulatorArgs(const Configuration& info) {
}
args << "--game" << game;
const auto patch_plan = PatchesDialog::PatchPlanPath(info.title_id);
if (QFileInfo::exists(patch_plan)) {
args << "--game-patch" << patch_plan;
}
return args;
}
+103
View File
@@ -0,0 +1,103 @@
#include "patchesDialog.h"
#include "configuration.h"
#include <QCoreApplication>
#include <QDialogButtonBox>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLabel>
#include <QListWidget>
#include <QPushButton>
#include <QSaveFile>
#include <QVBoxLayout>
PatchesDialog::PatchesDialog(const Configuration& game, QWidget* parent)
: QDialog(parent), m_title_id(game.title_id.trimmed().toUpper()) {
setAttribute(Qt::WA_DeleteOnClose);
setWindowTitle(tr("Patches (Experimental) - %1").arg(game.name));
resize(640, 480);
auto* layout = new QVBoxLayout(this);
m_patches = new QListWidget(this);
m_status = new QLabel(this);
m_apply = new QPushButton(tr("Apply selection"), this);
auto* close = new QPushButton(tr("Close"), this);
m_status->setWordWrap(true);
layout->addWidget(m_patches);
layout->addWidget(m_status);
auto* buttons = new QDialogButtonBox(this);
buttons->addButton(m_apply, QDialogButtonBox::ActionRole);
buttons->addButton(close, QDialogButtonBox::RejectRole);
layout->addWidget(buttons);
connect(m_apply, &QPushButton::clicked, this, &PatchesDialog::Save);
connect(close, &QPushButton::clicked, this, &QDialog::close);
Load();
}
bool PatchesDialog::IsSupportedTitleId(const QString& title_id) {
return title_id.trimmed().toUpper().startsWith(QStringLiteral("PPSA"));
}
QString PatchesDialog::PatchPlanPath(const QString& title_id) {
return QDir(QCoreApplication::applicationDirPath())
.filePath(QStringLiteral("_Patches/%1.json").arg(title_id.trimmed().toUpper()));
}
void PatchesDialog::Load() {
QFile file(PatchPlanPath(m_title_id));
if (!file.open(QIODevice::ReadOnly)) {
m_status->setText(tr("No local patch file: %1").arg(file.fileName()));
m_apply->setEnabled(false);
return;
}
const auto patches = QJsonDocument::fromJson(file.readAll())
.object()
.value(QStringLiteral("patches"))
.toArray();
for (const auto& value: patches) {
const auto patch = value.toObject();
auto* item = new QListWidgetItem(patch.value(QStringLiteral("name")).toString(), m_patches);
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
item->setCheckState(patch.value(QStringLiteral("enabled")).toBool(true) ? Qt::Checked
: Qt::Unchecked);
}
m_apply->setEnabled(!patches.isEmpty());
m_status->setText(tr("Loaded %1 patch(es) from %2.").arg(patches.size()).arg(file.fileName()));
}
void PatchesDialog::Save() {
const auto path = PatchPlanPath(m_title_id);
QFile input(path);
if (!input.open(QIODevice::ReadOnly)) {
return;
}
auto document = QJsonDocument::fromJson(input.readAll());
input.close();
auto root = document.object();
auto patches = root.value(QStringLiteral("patches")).toArray();
for (int index = 0; index < patches.size(); index++) {
auto patch = patches[index].toObject();
patch.insert(QStringLiteral("enabled"),
m_patches->item(index)->checkState() == Qt::Checked);
patches[index] = patch;
}
root.insert(QStringLiteral("patches"), patches);
document.setObject(root);
QSaveFile output(path);
if (output.open(QIODevice::WriteOnly) && output.write(document.toJson()) >= 0 &&
output.commit()) {
m_status->setText(tr("Patch selection saved."));
}
}