mirror of
https://github.com/KytyPS5/KytyPS5.git
synced 2026-10-20 10:56:58 +02:00
add remote cheats launcher
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
#ifndef CHEAT_FILE_H
|
||||
#define CHEAT_FILE_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
namespace Cheats {
|
||||
|
||||
[[nodiscard]] bool IsSupportedTitleId(const QString& title_id);
|
||||
[[nodiscard]] QString PlanPath(const QString& title_id);
|
||||
|
||||
// Local files retain their existing defaults; remote imports receive additional validation.
|
||||
[[nodiscard]] QString ParseDocument(const QByteArray& data, QJsonDocument* document);
|
||||
[[nodiscard]] QString ValidateImport(const QJsonDocument& document, const QString& title_id,
|
||||
const QString& version, const QString& process);
|
||||
[[nodiscard]] QJsonDocument WithSelection(const QJsonDocument& document,
|
||||
const QList<bool>& enabled);
|
||||
// Refuse to replace a file changed since it was loaded; empty data also permits creation.
|
||||
[[nodiscard]] QString SaveDocument(const QString& path, const QJsonDocument& document,
|
||||
const QByteArray& expected_data);
|
||||
|
||||
} // namespace Cheats
|
||||
|
||||
#endif // CHEAT_FILE_H
|
||||
@@ -0,0 +1,66 @@
|
||||
#ifndef CHEAT_REPOSITORY_H
|
||||
#define CHEAT_REPOSITORY_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QList>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class QNetworkReply;
|
||||
|
||||
namespace Cheats {
|
||||
|
||||
struct RemoteFile {
|
||||
QString name;
|
||||
QString title;
|
||||
QString version;
|
||||
QString format;
|
||||
|
||||
[[nodiscard]] bool IsSupported() const;
|
||||
[[nodiscard]] QUrl Url() const;
|
||||
};
|
||||
|
||||
struct CatalogResult {
|
||||
QList<RemoteFile> files;
|
||||
QString error;
|
||||
};
|
||||
|
||||
[[nodiscard]] CatalogResult ParseIndex(const QByteArray& data, const QString& format,
|
||||
const QString& title_id);
|
||||
|
||||
class CheatRepository final: public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit CheatRepository(QObject* parent = nullptr);
|
||||
void Load(const QString& title_id);
|
||||
void Fetch(const RemoteFile& file);
|
||||
void CancelFetch();
|
||||
|
||||
signals:
|
||||
void CatalogLoaded(QList<Cheats::RemoteFile> files, QString error);
|
||||
void FileLoaded(QByteArray data, QString error);
|
||||
|
||||
private:
|
||||
QNetworkReply* Request(const QUrl& url, qint64 limit,
|
||||
std::function<void(QByteArray, QString)> complete);
|
||||
|
||||
QNetworkAccessManager m_network;
|
||||
QList<QNetworkReply*> m_catalog_replies;
|
||||
QPointer<QNetworkReply> m_file_reply;
|
||||
QList<RemoteFile> m_files;
|
||||
QStringList m_errors;
|
||||
int m_catalog_generation = 0;
|
||||
int m_fetch_generation = 0;
|
||||
int m_pending = 0;
|
||||
};
|
||||
|
||||
} // namespace Cheats
|
||||
|
||||
#endif // CHEAT_REPOSITORY_H
|
||||
@@ -1,30 +1,51 @@
|
||||
#ifndef PATCHES_DIALOG_H
|
||||
#define PATCHES_DIALOG_H
|
||||
|
||||
#include "cheatRepository.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDialog>
|
||||
#include <QJsonDocument>
|
||||
#include <QString>
|
||||
|
||||
class Configuration;
|
||||
class QLabel;
|
||||
class QListWidget;
|
||||
class QPushButton;
|
||||
class QTabWidget;
|
||||
class QTreeWidget;
|
||||
|
||||
class PatchesDialog final: public QDialog {
|
||||
public:
|
||||
explicit PatchesDialog(const Configuration& game, QWidget* parent = nullptr);
|
||||
~PatchesDialog() override = default;
|
||||
|
||||
[[nodiscard]] static bool IsSupportedTitleId(const QString& title_id);
|
||||
[[nodiscard]] static QString PatchPlanPath(const QString& title_id);
|
||||
|
||||
private:
|
||||
void Load();
|
||||
void Save();
|
||||
void LoadRemote();
|
||||
void PreviewRemote();
|
||||
void ImportRemote();
|
||||
|
||||
QString m_title_id;
|
||||
QListWidget* m_patches = nullptr;
|
||||
QLabel* m_status = nullptr;
|
||||
QPushButton* m_apply = nullptr;
|
||||
QString m_title_id;
|
||||
QString m_version;
|
||||
QString m_process;
|
||||
QByteArray m_local_data;
|
||||
QJsonDocument m_local_document;
|
||||
QJsonDocument m_remote_document;
|
||||
bool m_local_readable = false;
|
||||
Cheats::CheatRepository* m_repository = nullptr;
|
||||
QList<Cheats::RemoteFile> m_remote_files;
|
||||
QTabWidget* m_tabs = nullptr;
|
||||
QTreeWidget* m_files = nullptr;
|
||||
QListWidget* m_preview = nullptr;
|
||||
QLabel* m_remote_status = nullptr;
|
||||
QLabel* m_preview_status = nullptr;
|
||||
QPushButton* m_refresh = nullptr;
|
||||
QPushButton* m_import = nullptr;
|
||||
QListWidget* m_patches = nullptr;
|
||||
QLabel* m_status = nullptr;
|
||||
QPushButton* m_apply = nullptr;
|
||||
};
|
||||
|
||||
#endif // PATCHES_DIALOG_H
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#include "cheatFile.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QRegularExpression>
|
||||
#include <QSaveFile>
|
||||
|
||||
namespace Cheats {
|
||||
|
||||
namespace {
|
||||
|
||||
QString Tr(const char* text) {
|
||||
return QCoreApplication::translate("Cheats", text);
|
||||
}
|
||||
|
||||
QString ValidateDocument(const QJsonDocument& document) {
|
||||
if (!document.isObject()) {
|
||||
return Tr("Invalid cheat JSON.");
|
||||
}
|
||||
const auto mods = document.object().value(QStringLiteral("mods"));
|
||||
if (!mods.isArray()) {
|
||||
return Tr("Cheat JSON has no \"mods\" array.");
|
||||
}
|
||||
for (const auto& value: mods.toArray()) {
|
||||
const auto mod = value.toObject();
|
||||
const auto name = mod.value(QStringLiteral("name"));
|
||||
const auto enabled = mod.value(QStringLiteral("enabled"));
|
||||
if (!value.isObject() || !name.isString() || name.toString().isEmpty() ||
|
||||
(!enabled.isUndefined() && !enabled.isBool())) {
|
||||
return Tr("Invalid cheat entry.");
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool IsSupportedTitleId(const QString& title_id) {
|
||||
static const QRegularExpression pattern(QStringLiteral("\\APPSA[0-9]{5}\\z"));
|
||||
return pattern.match(title_id.trimmed().toUpper()).hasMatch();
|
||||
}
|
||||
|
||||
QString PlanPath(const QString& title_id) {
|
||||
if (!IsSupportedTitleId(title_id)) {
|
||||
return {};
|
||||
}
|
||||
return QDir(QCoreApplication::applicationDirPath())
|
||||
.filePath(QStringLiteral("_Patches/%1.json").arg(title_id.trimmed().toUpper()));
|
||||
}
|
||||
|
||||
QString ParseDocument(const QByteArray& data, QJsonDocument* document) {
|
||||
const auto parsed = QJsonDocument::fromJson(data);
|
||||
const auto error = ValidateDocument(parsed);
|
||||
*document = error.isEmpty() ? parsed : QJsonDocument();
|
||||
return error;
|
||||
}
|
||||
|
||||
QString ValidateImport(const QJsonDocument& document, const QString& title_id,
|
||||
const QString& version, const QString& process) {
|
||||
const auto parse_error = ValidateDocument(document);
|
||||
if (!parse_error.isEmpty()) {
|
||||
return parse_error;
|
||||
}
|
||||
const auto root = document.object();
|
||||
if (!IsSupportedTitleId(title_id) ||
|
||||
root.value(QStringLiteral("id"))
|
||||
.toString()
|
||||
.compare(title_id.trimmed(), Qt::CaseInsensitive) != 0) {
|
||||
return Tr("The cheat file does not match this game's title ID.");
|
||||
}
|
||||
if (version.isEmpty() || root.value(QStringLiteral("version")).toString() != version) {
|
||||
return Tr("The cheat file does not match this game's version (%1).").arg(version);
|
||||
}
|
||||
if (process.isEmpty() ||
|
||||
root.value(QStringLiteral("process")).toString().compare(process, Qt::CaseInsensitive) !=
|
||||
0) {
|
||||
return Tr("The cheat file does not match this game's executable (%1).").arg(process);
|
||||
}
|
||||
static const QRegularExpression offset_pattern(
|
||||
QStringLiteral("\\A(?:0[xX])?[0-9a-fA-F]{1,16}\\z"));
|
||||
static const QRegularExpression bytes_pattern(QStringLiteral("\\A(?:[0-9a-fA-F]{2})+\\z"));
|
||||
const auto mods = root.value(QStringLiteral("mods")).toArray();
|
||||
if (mods.isEmpty()) {
|
||||
return Tr("The cheat file contains no cheats.");
|
||||
}
|
||||
if (root.contains(QStringLiteral("master"))) {
|
||||
return Tr("This JSON uses master codes, which are not supported yet.");
|
||||
}
|
||||
for (const auto& value: mods) {
|
||||
const auto mod = value.toObject();
|
||||
const auto memory = mod.value(QStringLiteral("memory"));
|
||||
if (mod.contains(QStringLiteral("module_name"))) {
|
||||
return Tr("This JSON uses module-specific cheats, which are not supported yet.");
|
||||
}
|
||||
if (!memory.isArray() || memory.toArray().isEmpty()) {
|
||||
return Tr("Unsupported memory entries in cheat \"%1\".")
|
||||
.arg(mod.value(QStringLiteral("name")).toString());
|
||||
}
|
||||
for (const auto& entry: memory.toArray()) {
|
||||
const auto write = entry.toObject();
|
||||
const auto offset = write.value(QStringLiteral("offset")).toString();
|
||||
const auto off = write.value(QStringLiteral("off")).toString();
|
||||
const auto on = write.value(QStringLiteral("on")).toString();
|
||||
const auto absolute = write.value(QStringLiteral("absolute"));
|
||||
const auto expected = write.value(QStringLiteral("expected"));
|
||||
if ((!absolute.isUndefined() && (!absolute.isBool() || absolute.toBool())) ||
|
||||
(!expected.isUndefined() &&
|
||||
(!expected.isString() ||
|
||||
expected.toString().compare(off, Qt::CaseInsensitive) != 0)) ||
|
||||
write.contains(QStringLiteral("section")) ||
|
||||
write.contains(QStringLiteral("sectionName")) ||
|
||||
write.contains(QStringLiteral("sectionProtection")) ||
|
||||
write.contains(QStringLiteral("logicalOffset")) ||
|
||||
write.contains(QStringLiteral("sectionOffset"))) {
|
||||
return Tr("This JSON uses memory addressing features that are not supported yet.");
|
||||
}
|
||||
if (!entry.isObject() || !offset_pattern.match(offset).hasMatch() ||
|
||||
!bytes_pattern.match(off).hasMatch() || !bytes_pattern.match(on).hasMatch() ||
|
||||
off.size() != on.size()) {
|
||||
return Tr("Unsupported memory entries in cheat \"%1\".")
|
||||
.arg(mod.value(QStringLiteral("name")).toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QJsonDocument WithSelection(const QJsonDocument& document, const QList<bool>& enabled) {
|
||||
auto root = document.object();
|
||||
auto mods = root.value(QStringLiteral("mods")).toArray();
|
||||
if (mods.size() != enabled.size()) {
|
||||
return {};
|
||||
}
|
||||
for (qsizetype index = 0; index < mods.size(); ++index) {
|
||||
auto mod = mods[index].toObject();
|
||||
mod.insert(QStringLiteral("enabled"), enabled[index]);
|
||||
mods[index] = mod;
|
||||
}
|
||||
root.insert(QStringLiteral("mods"), mods);
|
||||
return QJsonDocument(root);
|
||||
}
|
||||
|
||||
QString SaveDocument(const QString& path, const QJsonDocument& document,
|
||||
const QByteArray& expected_data) {
|
||||
if (path.isEmpty() || !document.isObject()) {
|
||||
return Tr("Invalid cheat file.");
|
||||
}
|
||||
QFile input(path);
|
||||
if (input.exists()) {
|
||||
if (!input.open(QIODevice::ReadOnly)) {
|
||||
return Tr("Could not read cheat file: %1").arg(input.errorString());
|
||||
}
|
||||
const auto current = input.readAll();
|
||||
if (input.error() != QFile::NoError) {
|
||||
return Tr("Could not read cheat file: %1").arg(input.errorString());
|
||||
}
|
||||
if (current != expected_data) {
|
||||
return Tr("Cheat file changed; reload the dialog.");
|
||||
}
|
||||
} else if (!expected_data.isEmpty()) {
|
||||
return Tr("Cheat file changed; reload the dialog.");
|
||||
}
|
||||
input.close();
|
||||
if (!QDir().mkpath(QFileInfo(path).absolutePath())) {
|
||||
return Tr("Could not create the local cheats directory.");
|
||||
}
|
||||
QSaveFile output(path);
|
||||
const auto data = document.toJson();
|
||||
if (!output.open(QIODevice::WriteOnly) || output.write(data) != data.size() ||
|
||||
!output.commit()) {
|
||||
return Tr("Could not save cheat file: %1").arg(output.errorString());
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace Cheats
|
||||
@@ -0,0 +1,200 @@
|
||||
#include "cheatRepository.h"
|
||||
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace Cheats {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr qint64 kIndexLimit = 2 * 1024 * 1024;
|
||||
constexpr qint64 kFileLimit = 8 * 1024 * 1024;
|
||||
|
||||
const QString kBaseUrl = QStringLiteral(
|
||||
"https://raw.githubusercontent.com/TeeKay87/HEN-Cheats-Collection/master/cheats/");
|
||||
|
||||
bool IsFormat(const QString& format) {
|
||||
return format == QStringLiteral("json") || format == QStringLiteral("mc4") ||
|
||||
format == QStringLiteral("shn");
|
||||
}
|
||||
|
||||
QRegularExpressionMatch MatchName(const QString& name) {
|
||||
static const QRegularExpression pattern(QStringLiteral(
|
||||
"\\A([A-Z]{4}[0-9]{5})_([0-9]+(?:\\.[0-9]+)+)_[A-Za-z0-9_.-]+\\.(json|mc4|shn)\\z"));
|
||||
return pattern.match(name);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool RemoteFile::IsSupported() const {
|
||||
return format == QStringLiteral("json") && !Url().isEmpty();
|
||||
}
|
||||
|
||||
QUrl RemoteFile::Url() const {
|
||||
const auto match = MatchName(name);
|
||||
if (!IsFormat(format) || !match.hasMatch() || match.captured(3) != format) {
|
||||
return {};
|
||||
}
|
||||
return QUrl(kBaseUrl + format + QLatin1Char('/') + name);
|
||||
}
|
||||
|
||||
CatalogResult ParseIndex(const QByteArray& data, const QString& format, const QString& title_id) {
|
||||
CatalogResult result;
|
||||
if (!IsFormat(format) || data.isEmpty() || data.size() > kIndexLimit) {
|
||||
result.error = CheatRepository::tr("Invalid cheat index.");
|
||||
return result;
|
||||
}
|
||||
|
||||
const auto id = title_id.trimmed().toUpper();
|
||||
QSet<QString> seen;
|
||||
int invalid = 0;
|
||||
auto text = QString::fromUtf8(data);
|
||||
if (text.startsWith(QChar(0xfeff))) {
|
||||
text.remove(0, 1);
|
||||
}
|
||||
for (const auto& line: text.split(QLatin1Char('\n'))) {
|
||||
const auto trimmed = line.trimmed();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
const auto separator = trimmed.indexOf(QLatin1Char('='));
|
||||
const auto name = trimmed.left(separator);
|
||||
const auto match = MatchName(name);
|
||||
if (separator < 0 || !match.hasMatch() || match.captured(3) != format) {
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
if (match.captured(1) != id || seen.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(name);
|
||||
result.files.append(
|
||||
{name, trimmed.mid(separator + 1).trimmed(), match.captured(2), format});
|
||||
}
|
||||
if (invalid != 0) {
|
||||
result.error = CheatRepository::tr("Skipped %1 invalid cheat index entries.").arg(invalid);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
CheatRepository::CheatRepository(QObject* parent): QObject(parent), m_network(this) {}
|
||||
|
||||
QNetworkReply* CheatRepository::Request(const QUrl& url, qint64 limit,
|
||||
std::function<void(QByteArray, QString)> complete) {
|
||||
QNetworkRequest request(url);
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::SameOriginRedirectPolicy);
|
||||
request.setRawHeader("User-Agent", "Kyty-Launcher");
|
||||
request.setTransferTimeout(15000);
|
||||
auto* reply = m_network.get(request);
|
||||
reply->setReadBufferSize(limit + 1);
|
||||
connect(reply, &QNetworkReply::readyRead, this, [reply, limit]() {
|
||||
if (reply->bytesAvailable() > limit) {
|
||||
reply->setProperty("too_large", true);
|
||||
reply->abort();
|
||||
}
|
||||
});
|
||||
QTimer::singleShot(30000, reply, [reply]() {
|
||||
if (!reply->isFinished()) {
|
||||
reply->setProperty("timed_out", true);
|
||||
reply->abort();
|
||||
}
|
||||
});
|
||||
connect(reply, &QNetworkReply::finished, this,
|
||||
[this, reply, limit, complete = std::move(complete)]() {
|
||||
QString error;
|
||||
if (reply->property("too_large").toBool() || reply->bytesAvailable() > limit) {
|
||||
error = tr("Cheat download exceeds the size limit.");
|
||||
} else if (reply->property("timed_out").toBool()) {
|
||||
error = tr("Cheat download timed out.");
|
||||
} else if (reply->error() != QNetworkReply::NoError) {
|
||||
error = reply->errorString();
|
||||
} else if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() !=
|
||||
200) {
|
||||
error = tr("Unexpected response from the cheat repository.");
|
||||
}
|
||||
const auto data = error.isEmpty() ? reply->readAll() : QByteArray();
|
||||
m_catalog_replies.removeAll(reply);
|
||||
if (m_file_reply == reply) {
|
||||
m_file_reply.clear();
|
||||
}
|
||||
reply->deleteLater();
|
||||
complete(data, error);
|
||||
});
|
||||
return reply;
|
||||
}
|
||||
|
||||
void CheatRepository::Load(const QString& title_id) {
|
||||
const auto generation = ++m_catalog_generation;
|
||||
for (auto* reply: std::exchange(m_catalog_replies, {})) {
|
||||
reply->abort();
|
||||
}
|
||||
CancelFetch();
|
||||
m_files.clear();
|
||||
m_errors.clear();
|
||||
m_pending = 3;
|
||||
for (const auto& format:
|
||||
{QStringLiteral("json"), QStringLiteral("mc4"), QStringLiteral("shn")}) {
|
||||
auto* reply =
|
||||
Request(QUrl(kBaseUrl + format + QStringLiteral(".txt")), kIndexLimit,
|
||||
[this, generation, format, title_id](const QByteArray& data, QString error) {
|
||||
if (generation != m_catalog_generation) {
|
||||
return;
|
||||
}
|
||||
if (error.isEmpty()) {
|
||||
auto result = ParseIndex(data, format, title_id);
|
||||
m_files.append(result.files);
|
||||
error = result.error;
|
||||
}
|
||||
if (!error.isEmpty()) {
|
||||
m_errors.append(format.toUpper() + QStringLiteral(": ") + error);
|
||||
}
|
||||
if (--m_pending == 0) {
|
||||
std::sort(m_files.begin(), m_files.end(),
|
||||
[](const RemoteFile& left, const RemoteFile& right) {
|
||||
if (left.version != right.version) {
|
||||
return left.version > right.version;
|
||||
}
|
||||
if (left.format != right.format) {
|
||||
return left.format < right.format;
|
||||
}
|
||||
return left.name < right.name;
|
||||
});
|
||||
emit CatalogLoaded(m_files, m_errors.join(QLatin1Char('\n')));
|
||||
}
|
||||
});
|
||||
m_catalog_replies.append(reply);
|
||||
}
|
||||
}
|
||||
|
||||
void CheatRepository::Fetch(const RemoteFile& file) {
|
||||
CancelFetch();
|
||||
if (!file.IsSupported()) {
|
||||
emit FileLoaded({}, tr("Only JSON cheat files are supported."));
|
||||
return;
|
||||
}
|
||||
const auto generation = m_fetch_generation;
|
||||
m_file_reply = Request(file.Url(), kFileLimit,
|
||||
[this, generation](const QByteArray& data, const QString& error) {
|
||||
if (generation == m_fetch_generation) {
|
||||
emit FileLoaded(data, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void CheatRepository::CancelFetch() {
|
||||
m_fetch_generation++;
|
||||
if (m_file_reply != nullptr) {
|
||||
auto* reply = m_file_reply.data();
|
||||
m_file_reply.clear();
|
||||
reply->abort();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Cheats
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "configurationListWidget.h"
|
||||
|
||||
#include "cheatFile.h"
|
||||
#include "common/archive.h"
|
||||
#include "compatibilityDatabase.h"
|
||||
#include "configuration.h"
|
||||
@@ -1015,7 +1016,7 @@ void ConfigurationListWidget::show_context_menu(const QPoint& pos) {
|
||||
}
|
||||
});
|
||||
action_patches->setVisible(item != nullptr &&
|
||||
PatchesDialog::IsSupportedTitleId(item->GetInfo().title_id));
|
||||
Cheats::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()));
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#include "mainDialog.h"
|
||||
|
||||
#include "cheatFile.h"
|
||||
#include "configuration.h"
|
||||
#include "configurationItem.h"
|
||||
#include "configurationListWidget.h"
|
||||
#include "controllerLightbar.h"
|
||||
#include "gameContent.h"
|
||||
#include "patchesDialog.h"
|
||||
#include "updateChecker.h"
|
||||
|
||||
#include <QApplication>
|
||||
@@ -285,7 +285,7 @@ static QStringList CreateEmulatorArgs(const Configuration& info) {
|
||||
}
|
||||
args << "--game" << game;
|
||||
|
||||
const auto patch_plan = PatchesDialog::PatchPlanPath(info.title_id);
|
||||
const auto patch_plan = Cheats::PlanPath(info.title_id);
|
||||
if (QFileInfo::exists(patch_plan)) {
|
||||
args << "--game-patch" << patch_plan;
|
||||
}
|
||||
|
||||
+270
-101
@@ -1,140 +1,309 @@
|
||||
#include "patchesDialog.h"
|
||||
|
||||
#include "cheatFile.h"
|
||||
#include "configuration.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QHeaderView>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QSaveFile>
|
||||
#include <QSplitter>
|
||||
#include <QStringList>
|
||||
#include <QTabWidget>
|
||||
#include <QTreeWidget>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
void ShowMods(QListWidget* list, const QJsonDocument& document, bool checkable) {
|
||||
list->clear();
|
||||
for (const auto& value: document.object().value(QStringLiteral("mods")).toArray()) {
|
||||
const auto mod = value.toObject();
|
||||
auto* item = new QListWidgetItem(mod.value(QStringLiteral("name")).toString(), list);
|
||||
if (checkable) {
|
||||
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
|
||||
item->setCheckState(mod.value(QStringLiteral("enabled")).toBool(true) ? Qt::Checked
|
||||
: Qt::Unchecked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QLabel* StatusLabel(QWidget* parent) {
|
||||
auto* label = new QLabel(parent);
|
||||
label->setWordWrap(true);
|
||||
label->setTextFormat(Qt::PlainText);
|
||||
return label;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PatchesDialog::PatchesDialog(const Configuration& game, QWidget* parent)
|
||||
: QDialog(parent), m_title_id(game.title_id.trimmed().toUpper()) {
|
||||
: QDialog(parent), m_title_id(game.title_id.trimmed().toUpper()), m_version(game.gameVersion),
|
||||
m_process(QFileInfo(game.elf.isEmpty() ? QStringLiteral("eboot.bin") : game.elf).fileName()) {
|
||||
setAttribute(Qt::WA_DeleteOnClose);
|
||||
setWindowTitle(tr("Cheats (experimental) - %1").arg(game.name));
|
||||
resize(640, 480);
|
||||
resize(800, 640);
|
||||
|
||||
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);
|
||||
auto* layout = new QVBoxLayout(this);
|
||||
auto* game_label = StatusLabel(this);
|
||||
game_label->setText(tr("%1 · Installed version: %2")
|
||||
.arg(m_title_id, m_version.isEmpty() ? tr("Unknown") : m_version));
|
||||
layout->addWidget(game_label);
|
||||
m_tabs = new QTabWidget(this);
|
||||
layout->addWidget(m_tabs);
|
||||
|
||||
m_status->setWordWrap(true);
|
||||
layout->addWidget(m_patches);
|
||||
layout->addWidget(m_status);
|
||||
auto* remote = new QWidget(m_tabs);
|
||||
auto* remote_layout = new QVBoxLayout(remote);
|
||||
m_tabs->addTab(remote, tr("Remote"));
|
||||
auto* credits = new QLabel(
|
||||
tr("Cheats by <a href=\"https://github.com/TeeKay87/HEN-Cheats-Collection\">TeeKay87 / HEN "
|
||||
"Cheats Collection</a> and its contributors."),
|
||||
remote);
|
||||
credits->setOpenExternalLinks(true);
|
||||
credits->setWordWrap(true);
|
||||
remote_layout->addWidget(credits);
|
||||
auto* support = StatusLabel(remote);
|
||||
support->setText(tr("Only JSON cheats are supported. MC4 and SHN files are shown disabled. "
|
||||
"Select a JSON file to preview its cheats."));
|
||||
remote_layout->addWidget(support);
|
||||
auto* split = new QSplitter(Qt::Vertical, remote);
|
||||
m_files = new QTreeWidget(split);
|
||||
m_files->setHeaderLabels({tr("Version"), tr("Format"), tr("File")});
|
||||
m_files->setRootIsDecorated(false);
|
||||
m_files->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
m_files->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
|
||||
m_files->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents);
|
||||
m_files->header()->setSectionResizeMode(2, QHeaderView::Stretch);
|
||||
m_preview = new QListWidget(split);
|
||||
remote_layout->addWidget(split, 1);
|
||||
m_remote_status = StatusLabel(remote);
|
||||
m_preview_status = StatusLabel(remote);
|
||||
remote_layout->addWidget(m_remote_status);
|
||||
remote_layout->addWidget(m_preview_status);
|
||||
auto* remote_buttons = new QDialogButtonBox(remote);
|
||||
m_refresh = remote_buttons->addButton(tr("Refresh"), QDialogButtonBox::ActionRole);
|
||||
m_import = remote_buttons->addButton(tr("Import to Local"), QDialogButtonBox::ActionRole);
|
||||
m_import->setEnabled(false);
|
||||
remote_layout->addWidget(remote_buttons);
|
||||
|
||||
auto* buttons = new QDialogButtonBox(this);
|
||||
buttons->addButton(m_apply, QDialogButtonBox::ActionRole);
|
||||
buttons->addButton(close, QDialogButtonBox::RejectRole);
|
||||
auto* local = new QWidget(m_tabs);
|
||||
auto* local_layout = new QVBoxLayout(local);
|
||||
m_tabs->addTab(local, tr("Local"));
|
||||
m_patches = new QListWidget(local);
|
||||
m_status = StatusLabel(local);
|
||||
local_layout->addWidget(m_patches, 1);
|
||||
local_layout->addWidget(m_status);
|
||||
auto* local_buttons = new QDialogButtonBox(local);
|
||||
auto* reload = local_buttons->addButton(tr("Reload"), QDialogButtonBox::ActionRole);
|
||||
m_apply = local_buttons->addButton(tr("Apply selection"), QDialogButtonBox::ActionRole);
|
||||
local_layout->addWidget(local_buttons);
|
||||
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::close);
|
||||
connect(reload, &QPushButton::clicked, this, &PatchesDialog::Load);
|
||||
connect(m_apply, &QPushButton::clicked, this, &PatchesDialog::Save);
|
||||
connect(close, &QPushButton::clicked, this, &QDialog::close);
|
||||
connect(m_refresh, &QPushButton::clicked, this, &PatchesDialog::LoadRemote);
|
||||
connect(m_import, &QPushButton::clicked, this, &PatchesDialog::ImportRemote);
|
||||
connect(m_files, &QTreeWidget::currentItemChanged, this, &PatchesDialog::PreviewRemote);
|
||||
|
||||
m_repository = new Cheats::CheatRepository(this);
|
||||
connect(
|
||||
m_repository, &Cheats::CheatRepository::CatalogLoaded, this,
|
||||
[this](QList<Cheats::RemoteFile> files, const QString& error) {
|
||||
std::stable_sort(files.begin(), files.end(), [this](const auto& a, const auto& b) {
|
||||
return (a.version == m_version) > (b.version == m_version);
|
||||
});
|
||||
m_remote_files = std::move(files);
|
||||
for (qsizetype index = 0; index < m_remote_files.size(); ++index) {
|
||||
const auto& file = m_remote_files[index];
|
||||
auto* item = new QTreeWidgetItem(
|
||||
m_files, {file.version == m_version ? tr("%1 (installed)").arg(file.version)
|
||||
: file.version,
|
||||
file.format.toUpper(), file.name});
|
||||
item->setData(0, Qt::UserRole, index);
|
||||
for (int column = 0; column < 3; ++column) {
|
||||
item->setToolTip(column, file.IsSupported()
|
||||
? file.title
|
||||
: tr("%1 — %2 is not supported.")
|
||||
.arg(file.title, file.format.toUpper()));
|
||||
}
|
||||
if (!file.IsSupported()) {
|
||||
item->setFlags(item->flags() & ~(Qt::ItemIsEnabled | Qt::ItemIsSelectable));
|
||||
}
|
||||
}
|
||||
auto status =
|
||||
m_remote_files.isEmpty() && error.isEmpty()
|
||||
? tr("No remote cheats found for %1.").arg(m_title_id)
|
||||
: tr("%1 remote file(s) for %2.").arg(m_remote_files.size()).arg(m_title_id);
|
||||
if (!error.isEmpty()) {
|
||||
status = tr("%1\nCould not load the complete collection: %2\nUse Refresh to retry.")
|
||||
.arg(status, error);
|
||||
}
|
||||
m_remote_status->setText(status);
|
||||
m_refresh->setEnabled(true);
|
||||
});
|
||||
connect(
|
||||
m_repository, &Cheats::CheatRepository::FileLoaded, this,
|
||||
[this](const QByteArray& data, QString error) {
|
||||
if (error.isEmpty()) {
|
||||
error = Cheats::ParseDocument(data, &m_remote_document);
|
||||
}
|
||||
if (!error.isEmpty()) {
|
||||
m_preview_status->setText(
|
||||
tr("Could not preview this file: %1\nSelect another file or refresh to retry.")
|
||||
.arg(error));
|
||||
return;
|
||||
}
|
||||
ShowMods(m_preview, m_remote_document, false);
|
||||
error = Cheats::ValidateImport(m_remote_document, m_title_id, m_version, m_process);
|
||||
const auto root = m_remote_document.object();
|
||||
QStringList creators;
|
||||
for (const auto& value: root.value(QStringLiteral("credits")).toArray()) {
|
||||
if (value.isString()) {
|
||||
creators.append(value.toString());
|
||||
}
|
||||
}
|
||||
const auto metadata =
|
||||
tr("%1\nCredits: %2 · Version: %3")
|
||||
.arg(root.value(QStringLiteral("name")).toString(),
|
||||
creators.isEmpty() ? root.value(QStringLiteral("author")).toString()
|
||||
: creators.join(QStringLiteral(", ")),
|
||||
root.value(QStringLiteral("version")).toString());
|
||||
m_preview_status->setText(
|
||||
metadata + QStringLiteral("\n\n") +
|
||||
(error.isEmpty() ? tr("Import to Local, then select the cheats to enable. Changes "
|
||||
"take effect on the next game launch.")
|
||||
: error));
|
||||
m_import->setEnabled(error.isEmpty());
|
||||
});
|
||||
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()));
|
||||
LoadRemote();
|
||||
}
|
||||
|
||||
void PatchesDialog::Load() {
|
||||
QFile file(PatchPlanPath(m_title_id));
|
||||
m_patches->clear();
|
||||
m_local_data.clear();
|
||||
m_local_document = {};
|
||||
m_local_readable = false;
|
||||
m_apply->setEnabled(false);
|
||||
const auto path = Cheats::PlanPath(m_title_id);
|
||||
if (path.isEmpty()) {
|
||||
m_status->setText(tr("Unsupported title ID."));
|
||||
return;
|
||||
}
|
||||
QFile file(path);
|
||||
if (!file.exists()) {
|
||||
m_local_readable = true;
|
||||
m_status->setText(tr("No local cheat file: %1").arg(path));
|
||||
return;
|
||||
}
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
m_status->setText(tr("No local cheat file: %1").arg(file.fileName()));
|
||||
m_apply->setEnabled(false);
|
||||
m_status->setText(tr("Could not read cheat file: %1").arg(file.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto document = QJsonDocument::fromJson(file.readAll());
|
||||
if (document.isNull() || !document.isObject()) {
|
||||
m_status->setText(tr("Invalid cheat JSON in %1.").arg(file.fileName()));
|
||||
m_apply->setEnabled(false);
|
||||
m_local_data = file.readAll();
|
||||
if (file.error() != QFile::NoError) {
|
||||
m_status->setText(tr("Could not read cheat file: %1").arg(file.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto root = document.object();
|
||||
if (!root.value(QStringLiteral("mods")).isArray()) {
|
||||
m_status->setText(tr("Cheat JSON has no \"mods\" array in %1.").arg(file.fileName()));
|
||||
m_apply->setEnabled(false);
|
||||
m_local_readable = true;
|
||||
const auto error = Cheats::ParseDocument(m_local_data, &m_local_document);
|
||||
if (!error.isEmpty()) {
|
||||
m_status->setText(tr("%1\n%2").arg(error, path));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto mods = root.value(QStringLiteral("mods")).toArray();
|
||||
for (const auto& value: mods) {
|
||||
if (!value.isObject()) {
|
||||
m_status->setText(tr("Invalid cheat entry in %1.").arg(file.fileName()));
|
||||
m_apply->setEnabled(false);
|
||||
m_patches->clear();
|
||||
return;
|
||||
}
|
||||
const auto mod = value.toObject();
|
||||
const auto name = mod.value(QStringLiteral("name"));
|
||||
const auto enabled = mod.value(QStringLiteral("enabled"));
|
||||
if (!name.isString() || name.toString().isEmpty() ||
|
||||
(!enabled.isUndefined() && !enabled.isBool())) {
|
||||
m_status->setText(tr("Invalid cheat entry in %1.").arg(file.fileName()));
|
||||
m_apply->setEnabled(false);
|
||||
m_patches->clear();
|
||||
return;
|
||||
}
|
||||
auto* item = new QListWidgetItem(name.toString(), m_patches);
|
||||
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
|
||||
item->setCheckState(enabled.toBool(true) ? Qt::Checked : Qt::Unchecked);
|
||||
}
|
||||
|
||||
m_apply->setEnabled(!mods.isEmpty());
|
||||
m_status->setText(tr("Loaded %1 cheat(s) from %2.").arg(mods.size()).arg(file.fileName()));
|
||||
ShowMods(m_patches, m_local_document, true);
|
||||
m_apply->setEnabled(m_patches->count() > 0);
|
||||
m_status->setText(tr("Loaded %1 cheat(s) from %2.").arg(m_patches->count()).arg(path));
|
||||
}
|
||||
|
||||
void PatchesDialog::Save() {
|
||||
const auto path = PatchPlanPath(m_title_id);
|
||||
QFile input(path);
|
||||
if (!input.open(QIODevice::ReadOnly)) {
|
||||
QList<bool> enabled;
|
||||
for (int index = 0; index < m_patches->count(); ++index) {
|
||||
enabled.append(m_patches->item(index)->checkState() == Qt::Checked);
|
||||
}
|
||||
const auto document = Cheats::WithSelection(m_local_document, enabled);
|
||||
const auto error = Cheats::SaveDocument(Cheats::PlanPath(m_title_id), document, m_local_data);
|
||||
if (!error.isEmpty()) {
|
||||
m_status->setText(error);
|
||||
return;
|
||||
}
|
||||
|
||||
auto document = QJsonDocument::fromJson(input.readAll());
|
||||
input.close();
|
||||
const auto changed_message = tr("Cheat file changed; reload the dialog.");
|
||||
if (!document.isObject()) {
|
||||
m_status->setText(changed_message);
|
||||
return;
|
||||
}
|
||||
auto root = document.object();
|
||||
auto mods = root.value(QStringLiteral("mods")).toArray();
|
||||
if (mods.size() != m_patches->count()) {
|
||||
m_status->setText(changed_message);
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < mods.size(); index++) {
|
||||
auto mod = mods[index].toObject();
|
||||
mod.insert(QStringLiteral("enabled"), m_patches->item(index)->checkState() == Qt::Checked);
|
||||
mods[index] = mod;
|
||||
}
|
||||
root.insert(QStringLiteral("mods"), mods);
|
||||
document.setObject(root);
|
||||
|
||||
QSaveFile output(path);
|
||||
if (!output.open(QIODevice::WriteOnly) || output.write(document.toJson()) < 0 ||
|
||||
!output.commit()) {
|
||||
qWarning() << "Could not save cheat file:" << output.errorString();
|
||||
m_status->setText(tr("Could not save cheat file: %1").arg(output.errorString()));
|
||||
return;
|
||||
}
|
||||
m_status->setText(tr("Cheat selection saved."));
|
||||
m_local_document = document;
|
||||
m_local_data = document.toJson();
|
||||
m_status->setText(tr("Cheat selection saved. Changes take effect on the next game launch."));
|
||||
}
|
||||
|
||||
void PatchesDialog::LoadRemote() {
|
||||
m_repository->CancelFetch();
|
||||
m_files->clear();
|
||||
m_remote_files.clear();
|
||||
m_preview->clear();
|
||||
m_remote_document = {};
|
||||
m_import->setEnabled(false);
|
||||
m_refresh->setEnabled(false);
|
||||
m_preview_status->clear();
|
||||
m_remote_status->setText(tr("Loading remote cheats…"));
|
||||
m_repository->Load(m_title_id);
|
||||
}
|
||||
|
||||
void PatchesDialog::PreviewRemote() {
|
||||
m_repository->CancelFetch();
|
||||
m_preview->clear();
|
||||
m_remote_document = {};
|
||||
m_import->setEnabled(false);
|
||||
m_preview_status->clear();
|
||||
const auto* item = m_files->currentItem();
|
||||
if (item == nullptr) {
|
||||
return;
|
||||
}
|
||||
const auto index = item->data(0, Qt::UserRole).toInt();
|
||||
if (index < 0 || index >= m_remote_files.size() || !m_remote_files[index].IsSupported()) {
|
||||
return;
|
||||
}
|
||||
m_preview_status->setText(tr("Loading cheat preview…"));
|
||||
m_repository->Fetch(m_remote_files[index]);
|
||||
}
|
||||
|
||||
void PatchesDialog::ImportRemote() {
|
||||
const auto error = Cheats::ValidateImport(m_remote_document, m_title_id, m_version, m_process);
|
||||
if (!error.isEmpty()) {
|
||||
m_preview_status->setText(error);
|
||||
return;
|
||||
}
|
||||
if (!m_local_readable) {
|
||||
m_preview_status->setText(
|
||||
tr("Could not read the local cheat file. Open Local and reload it before importing."));
|
||||
return;
|
||||
}
|
||||
const auto path = Cheats::PlanPath(m_title_id);
|
||||
if (QFile::exists(path) &&
|
||||
QMessageBox::question(this, tr("Replace local cheats?"),
|
||||
tr("Replace the local cheat file and selections for %1? Imported "
|
||||
"cheats will start unchecked.")
|
||||
.arg(m_title_id),
|
||||
QMessageBox::Yes | QMessageBox::No,
|
||||
QMessageBox::No) != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
const auto mods = m_remote_document.object().value(QStringLiteral("mods")).toArray();
|
||||
const auto document = Cheats::WithSelection(m_remote_document, QList<bool>(mods.size(), false));
|
||||
const auto save_error = Cheats::SaveDocument(path, document, m_local_data);
|
||||
if (!save_error.isEmpty()) {
|
||||
m_preview_status->setText(save_error);
|
||||
return;
|
||||
}
|
||||
Load();
|
||||
m_status->setText(
|
||||
tr("Imported %1 cheat(s). Select cheats and apply your selection for the next game launch.")
|
||||
.arg(mods.size()));
|
||||
m_tabs->setCurrentIndex(1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user