diff --git a/src/launcher/include/cheatFile.h b/src/launcher/include/cheatFile.h new file mode 100644 index 000000000..ebc6a16a6 --- /dev/null +++ b/src/launcher/include/cheatFile.h @@ -0,0 +1,26 @@ +#ifndef CHEAT_FILE_H +#define CHEAT_FILE_H + +#include +#include +#include +#include + +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& 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 diff --git a/src/launcher/include/cheatRepository.h b/src/launcher/include/cheatRepository.h new file mode 100644 index 000000000..18d8c4ccc --- /dev/null +++ b/src/launcher/include/cheatRepository.h @@ -0,0 +1,66 @@ +#ifndef CHEAT_REPOSITORY_H +#define CHEAT_REPOSITORY_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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 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 files, QString error); + void FileLoaded(QByteArray data, QString error); + +private: + QNetworkReply* Request(const QUrl& url, qint64 limit, + std::function complete); + + QNetworkAccessManager m_network; + QList m_catalog_replies; + QPointer m_file_reply; + QList 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 diff --git a/src/launcher/include/patchesDialog.h b/src/launcher/include/patchesDialog.h index 91d68f9c8..165e18b81 100644 --- a/src/launcher/include/patchesDialog.h +++ b/src/launcher/include/patchesDialog.h @@ -1,30 +1,51 @@ #ifndef PATCHES_DIALOG_H #define PATCHES_DIALOG_H +#include "cheatRepository.h" + +#include #include +#include #include 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 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 diff --git a/src/launcher/src/cheatFile.cpp b/src/launcher/src/cheatFile.cpp new file mode 100644 index 000000000..1c1445468 --- /dev/null +++ b/src/launcher/src/cheatFile.cpp @@ -0,0 +1,180 @@ +#include "cheatFile.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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& 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 diff --git a/src/launcher/src/cheatRepository.cpp b/src/launcher/src/cheatRepository.cpp new file mode 100644 index 000000000..93ed9b56a --- /dev/null +++ b/src/launcher/src/cheatRepository.cpp @@ -0,0 +1,200 @@ +#include "cheatRepository.h" + +#include +#include +#include +#include +#include + +#include +#include + +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 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 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 diff --git a/src/launcher/src/configurationListWidget.cpp b/src/launcher/src/configurationListWidget.cpp index 39eaa44b8..a2665cdee 100644 --- a/src/launcher/src/configurationListWidget.cpp +++ b/src/launcher/src/configurationListWidget.cpp @@ -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())); diff --git a/src/launcher/src/mainDialog.cpp b/src/launcher/src/mainDialog.cpp index dbc6d4e00..72f8d78d6 100644 --- a/src/launcher/src/mainDialog.cpp +++ b/src/launcher/src/mainDialog.cpp @@ -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 @@ -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; } diff --git a/src/launcher/src/patchesDialog.cpp b/src/launcher/src/patchesDialog.cpp index 71ecad952..7ed3d1de4 100644 --- a/src/launcher/src/patchesDialog.cpp +++ b/src/launcher/src/patchesDialog.cpp @@ -1,140 +1,309 @@ #include "patchesDialog.h" +#include "cheatFile.h" #include "configuration.h" -#include -#include #include -#include #include +#include +#include #include -#include #include #include #include +#include #include -#include +#include +#include +#include +#include #include +#include +#include + +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 TeeKay87 / HEN " + "Cheats Collection 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 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 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(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); }