From c1664e28da4e5f5d43d739f86e9a393729b4bdad Mon Sep 17 00:00:00 2001 From: Collecting Date: Fri, 16 Jan 2026 08:26:30 +0000 Subject: [PATCH 01/10] CMakeLists: Add Mod Downloader Files Signed-off-by: Collecting --- src/citron/CMakeLists.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/citron/CMakeLists.txt b/src/citron/CMakeLists.txt index d2ae1286b..b8690a436 100644 --- a/src/citron/CMakeLists.txt +++ b/src/citron/CMakeLists.txt @@ -284,6 +284,17 @@ if (CITRON_USE_AUTO_UPDATER) target_link_libraries(citron PRIVATE Qt6::Network) endif() +# Mod Manager functionality for downloading patches +target_sources(citron PRIVATE + mod_manager/mod_service.cpp + mod_manager/mod_service.h + mod_manager/mod_downloader_dialog.cpp + mod_manager/mod_downloader_dialog.h + mod_manager/mod_downloader_dialog.ui +) + +target_link_libraries(citron PRIVATE Qt6::Network) + if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") target_compile_definitions(citron PRIVATE $<$,15>:CANNOT_EXPLICITLY_INSTANTIATE> From 8ab4d172d61b7a24cab5019cf053021850a0f709 Mon Sep 17 00:00:00 2001 From: Collecting Date: Fri, 16 Jan 2026 08:29:34 +0000 Subject: [PATCH 02/10] create: mod_service.h Signed-off-by: Collecting --- src/citron/mod_manager/mod_service.h | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/citron/mod_manager/mod_service.h diff --git a/src/citron/mod_manager/mod_service.h b/src/citron/mod_manager/mod_service.h new file mode 100644 index 000000000..23d7619bc --- /dev/null +++ b/src/citron/mod_manager/mod_service.h @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QNetworkAccessManager; + +namespace ModManager { + +struct ModPatch { + QString name; + QString type; // "exefs" or "romfs" + QString rel_path; + QStringList files; +}; + +struct ModUpdateInfo { + QString title_id; + // Maps Version String (e.g. "2.0.0") to its list of patches + std::map> version_patches; +}; + +class ModService : public QObject { + Q_OBJECT +public: + explicit ModService(QObject* parent = nullptr); + ~ModService(); + + // Removed the version parameter so it fetches everything + void FetchAvailableMods(const QString& title_id); + +signals: + void ModsAvailable(const ModUpdateInfo& info); + void Error(const QString& message); + +private: + std::unique_ptr network_manager; + const QString MANIFEST_URL = QStringLiteral("https://raw.githubusercontent.com/CollectingW/Citron-Mods/main/manifest.json"); +}; + +} // namespace ModManager From b66c634983d0150594f61f8fe489cc2445122230 Mon Sep 17 00:00:00 2001 From: Collecting Date: Fri, 16 Jan 2026 08:30:13 +0000 Subject: [PATCH 03/10] create: mod_service.cpp Signed-off-by: Collecting --- src/citron/mod_manager/mod_service.cpp | 77 ++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/citron/mod_manager/mod_service.cpp diff --git a/src/citron/mod_manager/mod_service.cpp b/src/citron/mod_manager/mod_service.cpp new file mode 100644 index 000000000..60c728205 --- /dev/null +++ b/src/citron/mod_manager/mod_service.cpp @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include "citron/mod_manager/mod_service.h" + +namespace ModManager { + +ModService::ModService(QObject* parent) : QObject(parent) { + network_manager = std::make_unique(this); +} + +ModService::~ModService() = default; + +void ModService::FetchAvailableMods(const QString& title_id) { + QNetworkRequest request{QUrl(MANIFEST_URL)}; + QNetworkReply* reply = network_manager->get(request); + + connect(reply, &QNetworkReply::finished, this, [this, reply, title_id]() { + if (reply->error() != QNetworkReply::NoError) { + emit Error(reply->errorString()); + reply->deleteLater(); + return; + } + + QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + QJsonObject root = doc.object(); + + // Convert title_id to uppercase to match your folder/manifest structure + QString tid_upper = title_id.toUpper(); + if (!root.contains(tid_upper)) { + emit Error(QStringLiteral("No mods found for this game in the repository.")); + reply->deleteLater(); + return; + } + + ModUpdateInfo info; + info.title_id = title_id; + + QJsonObject tid_obj = root.value(tid_upper).toObject(); + QJsonObject versions_obj = tid_obj.value(QStringLiteral("versions")).toObject(); + + // Loop through every version found for this game (e.g., 1.0.0, 2.0.0) + for (auto it = versions_obj.begin(); it != versions_obj.end(); ++it) { + QString v_name = it.key(); + QJsonObject v_data = it.value().toObject(); + QJsonArray patches_array = v_data.value(QStringLiteral("patches")).toArray(); + + std::vector patches; + for (const QJsonValue& val : patches_array) { + QJsonObject p_obj = val.toObject(); + ModPatch patch; + patch.name = p_obj.value(QStringLiteral("name")).toString(); + patch.type = p_obj.value(QStringLiteral("type")).toString(); + patch.rel_path = p_obj.value(QStringLiteral("rel_path")).toString(); + + QJsonArray files_arr = p_obj.value(QStringLiteral("files")).toArray(); + for (const QJsonValue& f : files_arr) { + patch.files << f.toString(); + } + + patches.push_back(patch); + } + // Add this version and its mods to the map + info.version_patches[v_name] = patches; + } + + emit ModsAvailable(info); + reply->deleteLater(); + }); +} + +} // namespace ModManager From 3d6f5c843c4251626c306f97273a6458ca062619 Mon Sep 17 00:00:00 2001 From: Collecting Date: Fri, 16 Jan 2026 08:30:36 +0000 Subject: [PATCH 04/10] Add src/citron/mod_manager/mod_downloader_dialog.h Signed-off-by: Collecting --- .../mod_manager/mod_downloader_dialog.h | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/citron/mod_manager/mod_downloader_dialog.h diff --git a/src/citron/mod_manager/mod_downloader_dialog.h b/src/citron/mod_manager/mod_downloader_dialog.h new file mode 100644 index 000000000..222d85f5c --- /dev/null +++ b/src/citron/mod_manager/mod_downloader_dialog.h @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include "citron/mod_manager/mod_service.h" + +namespace Ui { +class ModDownloaderDialog; +} + +namespace ModManager { + +// Helper to keep track of what version a patch belongs to during download +struct DownloadTask { + ModPatch patch; + QString version; +}; + +class ModDownloaderDialog : public QDialog { + Q_OBJECT + +public: + explicit ModDownloaderDialog(const ModUpdateInfo& info, QWidget* parent = nullptr); + ~ModDownloaderDialog() override; + +private slots: + void OnDownloadClicked(); + void OnCancelClicked(); + +private: + void SetupModList(); + void StartNextDownload(); + + ::Ui::ModDownloaderDialog* ui; + ModUpdateInfo mod_info; + + class QNetworkAccessManager* network_manager; + class QNetworkReply* current_reply; + + std::vector pending_downloads; + int current_download_index = -1; + int current_file_index = 0; +}; + +} // namespace ModManager From 3a881782ad8532c9d4d73cc7240506f6dbc93cd0 Mon Sep 17 00:00:00 2001 From: Collecting Date: Fri, 16 Jan 2026 08:31:21 +0000 Subject: [PATCH 05/10] Add src/citron/mod_manager/mod_downloader_dialog.cpp Signed-off-by: Collecting --- .../mod_manager/mod_downloader_dialog.cpp | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/citron/mod_manager/mod_downloader_dialog.cpp diff --git a/src/citron/mod_manager/mod_downloader_dialog.cpp b/src/citron/mod_manager/mod_downloader_dialog.cpp new file mode 100644 index 000000000..028f68396 --- /dev/null +++ b/src/citron/mod_manager/mod_downloader_dialog.cpp @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "citron/mod_manager/mod_downloader_dialog.h" + +// Generated header from uic +#include "ui_mod_downloader_dialog.h" + +#include "common/fs/path_util.h" +#include "common/logging/log.h" + +namespace ModManager { + +ModDownloaderDialog::ModDownloaderDialog(const ModUpdateInfo& info, QWidget* parent) + : QDialog(parent), mod_info(info), current_reply(nullptr) { + + ui = new ::Ui::ModDownloaderDialog(); + ui->setupUi(this); + + network_manager = new QNetworkAccessManager(this); + + SetupModList(); + + connect(ui->buttonDownload, &QPushButton::clicked, this, &ModDownloaderDialog::OnDownloadClicked); + connect(ui->buttonCancel, &QPushButton::clicked, this, &ModDownloaderDialog::OnCancelClicked); +} + +ModDownloaderDialog::~ModDownloaderDialog() { + delete ui; +} + +void ModDownloaderDialog::SetupModList() { + ui->treeWidget->setHeaderLabel(QStringLiteral("Version / Mod Name")); + + // Iterate through the map of versions we fetched + for (auto const& [version, patches] : mod_info.version_patches) { + // Create the Parent (The Version Folder) + QTreeWidgetItem* version_item = new QTreeWidgetItem(ui->treeWidget); + version_item->setText(0, QStringLiteral("Update %1").arg(version)); + version_item->setCheckState(0, Qt::Unchecked); + version_item->setFlags(version_item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); + + // Create the Children (The Mods) + for (const auto& patch : patches) { + QTreeWidgetItem* mod_item = new QTreeWidgetItem(version_item); + mod_item->setText(0, patch.name); + mod_item->setCheckState(0, Qt::Unchecked); + mod_item->setFlags(mod_item->flags() | Qt::ItemIsUserCheckable); + + // Store the patch data in the item so we can find it later + mod_item->setData(0, Qt::UserRole, version); // Store which version it belongs to + } + } + ui->treeWidget->expandAll(); +} + +void ModDownloaderDialog::OnDownloadClicked() { + pending_downloads.clear(); + + // Loop through the Tree headers (The Versions) + for (int i = 0; i < ui->treeWidget->topLevelItemCount(); ++i) { + QTreeWidgetItem* version_node = ui->treeWidget->topLevelItem(i); + + // Extract the version name from "Update 2.0.0" -> "2.0.0" + QString version_str = version_node->text(0).replace(QStringLiteral("Update "), QStringLiteral("")); + + // Loop through the mods inside that version + for (int j = 0; j < version_node->childCount(); ++j) { + QTreeWidgetItem* mod_node = version_node->child(j); + + if (mod_node->checkState(0) == Qt::Checked) { + QString mod_name = mod_node->text(0); + + // Find the data for this mod in our info map + const auto& patches = mod_info.version_patches[version_str]; + for (const auto& p : patches) { + if (p.name == mod_name) { + // Add to our task list + pending_downloads.push_back({p, version_str}); + } + } + } + } + } + + if (pending_downloads.empty()) return; + + ui->buttonDownload->setEnabled(false); + ui->treeWidget->setEnabled(false); + ui->progressBar->setVisible(true); + + current_download_index = 0; + current_file_index = 0; + StartNextDownload(); +} + +void ModDownloaderDialog::StartNextDownload() { + if (current_download_index >= static_cast(pending_downloads.size())) { + QMessageBox::information(this, QStringLiteral("Success"), QStringLiteral("All selected mods have been installed.")); + accept(); + return; + } + + const auto& task = pending_downloads[current_download_index]; + const ModPatch& patch = task.patch; + QString version_str = task.version; // We use this to create the folder structure + + if (current_file_index >= patch.files.size()) { + current_download_index++; + current_file_index = 0; + StartNextDownload(); + return; + } + + QString fileName = patch.files[current_file_index]; + QString urlBase = QStringLiteral("https://raw.githubusercontent.com/CollectingW/Citron-Mods/main/%1/%2"); + QString finalUrl = urlBase.arg(patch.rel_path).arg(fileName); + QUrl url(finalUrl); + + LOG_INFO(Frontend, "Downloading: {}", url.toString().toStdString()); + + QNetworkRequest request(url); + current_reply = network_manager->get(request); + + connect(current_reply, &QNetworkReply::finished, this, [this, patch, version_str, fileName]() { + if (current_reply->error() == QNetworkReply::NoError) { + std::filesystem::path load_dir = Common::FS::GetCitronPath(Common::FS::CitronPath::LoadDir); + std::filesystem::path tid_path = load_dir / mod_info.title_id.toStdString(); + + // This creates the hierarchy: load / [TID] / [Version] / [ModName] / [exefs/romfs] + std::filesystem::path final_path = tid_path / version_str.toStdString() / + patch.name.toStdString() / patch.type.toStdString(); + + std::error_code ec; + std::filesystem::create_directories(final_path, ec); + + QFile file(QString::fromStdString((final_path / fileName.toStdString()).string())); + if (file.open(QIODevice::WriteOnly)) { + file.write(current_reply->readAll()); + file.close(); + } + } + + current_reply->deleteLater(); + current_file_index++; + ui->progressBar->setValue(((current_download_index + 1) * 100) / pending_downloads.size()); + StartNextDownload(); + }); +} + +void ModDownloaderDialog::OnCancelClicked() { + if (current_reply) current_reply->abort(); + reject(); +} + +} // namespace ModManager From 825c66d409d6e255f3b41ab5ced419bee49613a4 Mon Sep 17 00:00:00 2001 From: Collecting Date: Fri, 16 Jan 2026 08:31:45 +0000 Subject: [PATCH 06/10] Add src/citron/mod_manager/mod_downloader_dialog.ui Signed-off-by: Collecting --- .../mod_manager/mod_downloader_dialog.ui | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/citron/mod_manager/mod_downloader_dialog.ui diff --git a/src/citron/mod_manager/mod_downloader_dialog.ui b/src/citron/mod_manager/mod_downloader_dialog.ui new file mode 100644 index 000000000..2b44e1a58 --- /dev/null +++ b/src/citron/mod_manager/mod_downloader_dialog.ui @@ -0,0 +1,70 @@ + + + ModDownloaderDialog + + + + 0 + 0 + 450 + 350 + + + + Citron Mod Downloader + + + + + + Select the mods you wish to install for this game version: + + + + + + + + Available Enhancements + + + + + + + + 0 + + + false + + + + + + + + + Qt::Horizontal + + + + + + + Download Selected + + + + + + + Cancel + + + + + + + + From 18904f20081359b95a8952c8719fccafa8dcb1e8 Mon Sep 17 00:00:00 2001 From: Collecting Date: Fri, 16 Jan 2026 08:32:46 +0000 Subject: [PATCH 07/10] feat: Add ModManager Context menu & service Signed-off-by: Collecting --- src/citron/configuration/configure_per_game_addons.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/citron/configuration/configure_per_game_addons.h b/src/citron/configuration/configure_per_game_addons.h index 3e6971c8f..5ae41f184 100644 --- a/src/citron/configuration/configure_per_game_addons.h +++ b/src/citron/configuration/configure_per_game_addons.h @@ -11,6 +11,7 @@ #include #include "core/file_sys/vfs/vfs_types.h" +#include "citron/mod_manager/mod_service.h" namespace Core { class System; @@ -40,12 +41,15 @@ public: void SetTitleId(u64 id); private: + void OnContextMenu(const QPoint& pos); void changeEvent(QEvent* event) override; void RetranslateUI(); void LoadConfiguration(); std::unique_ptr ui; + ModManager::ModService* mod_service; + ModManager::ModUpdateInfo cached_mod_info; FileSys::VirtualFile file; u64 title_id; From 4846101587096e2f082b98e4e2692e31a92cd2b5 Mon Sep 17 00:00:00 2001 From: Collecting Date: Fri, 16 Jan 2026 08:33:22 +0000 Subject: [PATCH 08/10] feat(mod_manager): Add Installer Logic Signed-off-by: Collecting --- .../configure_per_game_addons.cpp | 167 +++++++++++++++--- 1 file changed, 146 insertions(+), 21 deletions(-) diff --git a/src/citron/configuration/configure_per_game_addons.cpp b/src/citron/configuration/configure_per_game_addons.cpp index 969d725f7..2f68a5b5d 100644 --- a/src/citron/configuration/configure_per_game_addons.cpp +++ b/src/citron/configuration/configure_per_game_addons.cpp @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2016 Citra Emulator Project +// SPDX-FileCopyrightText: 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include @@ -11,6 +12,8 @@ #include #include #include +#include +#include #include "common/fs/fs.h" #include "common/fs/path_util.h" @@ -23,10 +26,17 @@ #include "citron/configuration/configure_per_game_addons.h" #include "citron/uisettings.h" +#include "citron/mod_manager/mod_service.h" +#include "citron/mod_manager/mod_downloader_dialog.h" + ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* parent) - : QWidget(parent), ui{std::make_unique()}, system{system_} { +: QWidget(parent), ui{std::make_unique()}, system{system_} { ui->setupUi(this); + mod_service = new ModManager::ModService(this); + + ui->button_download_mods->setVisible(false); + layout = new QVBoxLayout; tree_view = new QTreeView; item_model = new QStandardItemModel(tree_view); @@ -39,7 +49,8 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p tree_view->setSortingEnabled(true); tree_view->setEditTriggers(QHeaderView::NoEditTriggers); tree_view->setUniformRowHeights(true); - tree_view->setContextMenuPolicy(Qt::NoContextMenu); + tree_view->setContextMenuPolicy(Qt::CustomContextMenu); + connect(tree_view, &QTreeView::customContextMenuRequested, this, &ConfigurePerGameAddons::OnContextMenu); item_model->insertColumns(0, 2); item_model->setHeaderData(0, Qt::Horizontal, tr("Patch Name")); @@ -49,8 +60,6 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p tree_view->header()->setSectionResizeMode(0, QHeaderView::ResizeMode::Stretch); tree_view->header()->setMinimumSectionSize(150); - // We must register all custom types with the Qt Automoc system so that we are able to use it - // with signals/slots. In this case, QList falls under the umbrella of custom types. qRegisterMetaType>("QList"); layout->setContentsMargins(0, 0, 0, 0); @@ -58,9 +67,33 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p layout->addWidget(tree_view); ui->scrollArea->setLayout(layout); - ui->scrollArea->setEnabled(!system.IsPoweredOn()); + // 2. BACKGROUND FETCH: When the manifest is received + connect(mod_service, &ModManager::ModService::ModsAvailable, this, [this](const ModManager::ModUpdateInfo& info) { + if (!info.version_patches.empty()) { + // Save the info and show the button because mods actually exist + this->cached_mod_info = info; + ui->button_download_mods->setVisible(true); + } + }); + + // 3. SILENT ERROR: If no mods found, just keep the button hidden (don't show a popup) + connect(mod_service, &ModManager::ModService::Error, this, [](const QString& message) { + // Do nothing, button remains invisible + }); + + // 4. BUTTON CLICK: Since we already have the data, just open the dialog + connect(ui->button_download_mods, &QPushButton::clicked, this, [this] { + auto* dialog = new ModManager::ModDownloaderDialog(cached_mod_info, this); + + connect(dialog, &QDialog::accepted, this, [this] { + this->LoadConfiguration(); + }); + + dialog->show(); + }); + connect(item_model, &QStandardItemModel::itemChanged, [] { UISettings::values.is_game_list_reload_pending.exchange(true); }); } @@ -72,13 +105,17 @@ void ConfigurePerGameAddons::ApplyConfiguration() { for (const auto& item : list_items) { const auto disabled = item.front()->checkState() == Qt::Unchecked; - if (disabled) - disabled_addons.push_back(item.front()->text().toStdString()); + if (disabled) { + // Get the internal full name we stored in UserRole + QString internal_name = item.front()->data(Qt::UserRole).toString(); + disabled_addons.push_back(internal_name.toStdString()); + } } auto current = Settings::values.disabled_addons[title_id]; std::sort(disabled_addons.begin(), disabled_addons.end()); std::sort(current.begin(), current.end()); + if (disabled_addons != current) { Common::FS::RemoveFile(Common::FS::GetCitronPath(Common::FS::CitronPath::CacheDir) / "game_list" / fmt::format("{:016X}.pv.txt", title_id)); @@ -94,6 +131,10 @@ void ConfigurePerGameAddons::LoadFromFile(FileSys::VirtualFile file_) { void ConfigurePerGameAddons::SetTitleId(u64 id) { this->title_id = id; + + // Trigger the background check as soon as we know which game we are looking at + QString tid_str = QString::fromStdString(fmt::format("{:016X}", title_id)); + mod_service->FetchAvailableMods(tid_str); } void ConfigurePerGameAddons::changeEvent(QEvent* event) { @@ -109,35 +150,119 @@ void ConfigurePerGameAddons::RetranslateUI() { } void ConfigurePerGameAddons::LoadConfiguration() { - if (file == nullptr) { - return; - } + if (file == nullptr) return; - const FileSys::PatchManager pm{title_id, system.GetFileSystemController(), - system.GetContentProvider()}; + item_model->removeRows(0, item_model->rowCount()); + list_items.clear(); + + const FileSys::PatchManager pm{title_id, system.GetFileSystemController(), system.GetContentProvider()}; const auto loader = Loader::GetLoader(system, file); - FileSys::VirtualFile update_raw; loader->ReadUpdateRaw(update_raw); const auto& disabled = Settings::values.disabled_addons[title_id]; + const auto all_patches = pm.GetPatches(update_raw); - for (const auto& patch : pm.GetPatches(update_raw)) { - const auto name = QString::fromStdString(patch.name); + // --- PASS 1: SYSTEM ITEMS (Update, DLC, etc.) --- + // We add these directly to the top of the list + for (const auto& patch : all_patches) { + // Skip folder-based mods for this pass + if (patch.type == FileSys::PatchType::Mod) continue; auto* const first_item = new QStandardItem; - first_item->setText(name); + first_item->setText(QString::fromStdString(patch.name)); first_item->setCheckable(true); - const auto patch_disabled = - std::find(disabled.begin(), disabled.end(), name.toStdString()) != disabled.end(); + first_item->setData(QString::fromStdString(patch.name), Qt::UserRole); + const auto patch_disabled = std::find(disabled.begin(), disabled.end(), patch.name) != disabled.end(); first_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked); - list_items.push_back(QList{ - first_item, new QStandardItem{QString::fromStdString(patch.version)}}); - item_model->appendRow(list_items.back()); + QList row; + row << first_item << new QStandardItem{QString::fromStdString(patch.version)}; + item_model->appendRow(row); + list_items.push_back(row); } + // --- PASS 2: FOLDER-BASED MODS (The Tree View) --- + std::map groups; + for (const auto& patch : all_patches) { + // ONLY process mods in this pass + if (patch.type != FileSys::PatchType::Mod) continue; + + QString full_name = QString::fromStdString(patch.name); + QStandardItem* parent_to_add_to = nullptr; + + if (full_name.contains(QStringLiteral("/"))) { + QStringList parts = full_name.split(QStringLiteral("/")); + QString group_name = parts[0]; + QString mod_display_name = parts[1]; + + if (groups.find(group_name) == groups.end()) { + auto* group_item = new QStandardItem(group_name); + group_item->setCheckable(false); + group_item->setEditable(false); + item_model->appendRow(group_item); // Group folder goes at the bottom of the current list + groups[group_name] = group_item; + } + parent_to_add_to = groups[group_name]; + full_name = mod_display_name; + } + + auto* const mod_item = new QStandardItem(full_name); + mod_item->setCheckable(true); + + mod_item->setData(QString::fromStdString(patch.name), Qt::UserRole); + + const auto patch_disabled = std::find(disabled.begin(), disabled.end(), patch.name) != disabled.end(); + mod_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked); + + QList row; + row << mod_item << new QStandardItem{QString::fromStdString(patch.version)}; + + if (parent_to_add_to) { + parent_to_add_to->appendRow(row); + } else { + item_model->appendRow(row); + } + list_items.push_back(row); + } + + tree_view->expandAll(); tree_view->resizeColumnToContents(1); } + +void ConfigurePerGameAddons::OnContextMenu(const QPoint& pos) { + QModelIndex index = tree_view->indexAt(pos); + if (!index.isValid()) return; + + // Get the item that was clicked + QStandardItem* item = item_model->itemFromIndex(index); + + // We only want to show the menu if the item has children (it's a folder/group) + if (item->rowCount() == 0) return; + + QMenu context_menu; + + // Create "Check All" action + QAction* check_all = context_menu.addAction(tr("Check All Mods in Folder")); + connect(check_all, &QAction::triggered, this, [item] { + for (int i = 0; i < item->rowCount(); ++i) { + if (auto* child = item->child(i, 0)) { + child->setCheckState(Qt::Checked); + } + } + }); + + // Create "Uncheck All" action + QAction* uncheck_all = context_menu.addAction(tr("Uncheck All Mods in Folder")); + connect(uncheck_all, &QAction::triggered, this, [item] { + for (int i = 0; i < item->rowCount(); ++i) { + if (auto* child = item->child(i, 0)) { + child->setCheckState(Qt::Unchecked); + } + } + }); + + context_menu.exec(tree_view->viewport()->mapToGlobal(pos)); +} From cb403a3a3b05394b25e3c7ec10453802af5f9efc Mon Sep 17 00:00:00 2001 From: Collecting Date: Fri, 16 Jan 2026 08:33:53 +0000 Subject: [PATCH 09/10] Add Mod Installer Logic Signed-off-by: Collecting --- .../configure_per_game_addons.ui | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/citron/configuration/configure_per_game_addons.ui b/src/citron/configuration/configure_per_game_addons.ui index f9cf6f2c3..d5714217f 100644 --- a/src/citron/configuration/configure_per_game_addons.ui +++ b/src/citron/configuration/configure_per_game_addons.ui @@ -22,16 +22,17 @@ true - - - - 0 - 0 - 380 - 280 - - - + + + + + + + Citron uses third-party created mods that are verified safe to use. All credits for mods go to their respective creators. + + + Download Enhancement Mods for Game + From bf17270fd6a0d70e7b13e0eda17ef42be2ca3b9b Mon Sep 17 00:00:00 2001 From: Collecting Date: Fri, 16 Jan 2026 08:34:31 +0000 Subject: [PATCH 10/10] Add Mod Installer Folder Logic Signed-off-by: Collecting --- src/core/file_sys/patch_manager.cpp | 139 ++++++++++++++++++++++------ 1 file changed, 112 insertions(+), 27 deletions(-) diff --git a/src/core/file_sys/patch_manager.cpp b/src/core/file_sys/patch_manager.cpp index 924958882..3403669bc 100644 --- a/src/core/file_sys/patch_manager.cpp +++ b/src/core/file_sys/patch_manager.cpp @@ -107,9 +107,28 @@ void AppendCommaIfNotEmpty(std::string& to, std::string_view with) { } } +std::string GetInternalModName(const VirtualDir& mod_dir, const VirtualDir& load_root) { + if (!mod_dir || !load_root) return ""; + auto parent = mod_dir->GetParentDirectory(); + // If the parent is the root load folder, it's a flat mod (e.g., "Reduce Bloom") + if (parent == load_root) { + return mod_dir->GetName(); + } + // If there is a parent in between, it's a hierarchical mod (e.g., "2.0.0/Reduce Bloom") + return parent->GetName() + "/" + mod_dir->GetName(); +} + bool IsDirValidAndNonEmpty(const VirtualDir& dir) { return dir != nullptr && (!dir->GetFiles().empty() || !dir->GetSubdirectories().empty()); } + +bool IsAnyModFolder(const VirtualDir& dir) { + if (!dir) return false; + return FindSubdirectoryCaseless(dir, "exefs") != nullptr || + FindSubdirectoryCaseless(dir, "romfs") != nullptr || + FindSubdirectoryCaseless(dir, "romfslite") != nullptr || + FindSubdirectoryCaseless(dir, "cheats") != nullptr; +} } // Anonymous namespace PatchManager::PatchManager(u64 title_id_, @@ -174,8 +193,16 @@ VirtualDir PatchManager::PatchExeFS(VirtualDir exefs) const { const auto sdmc_load_dir = fs_controller.GetSDMCModificationLoadRoot(title_id); std::vector patch_dirs = {sdmc_load_dir}; if (load_dir != nullptr) { - const auto load_patch_dirs = load_dir->GetSubdirectories(); - patch_dirs.insert(patch_dirs.end(), load_patch_dirs.begin(), load_patch_dirs.end()); + for (const auto& top_dir : load_dir->GetSubdirectories()) { + if (!top_dir) continue; + if (IsAnyModFolder(top_dir)) { + patch_dirs.push_back(top_dir); + } else { + for (const auto& sub_dir : top_dir->GetSubdirectories()) { + if (IsAnyModFolder(sub_dir)) patch_dirs.push_back(sub_dir); + } + } + } } std::sort(patch_dirs.begin(), patch_dirs.end(), [](const VirtualDir& l, const VirtualDir& r) { @@ -191,7 +218,8 @@ VirtualDir PatchManager::PatchExeFS(VirtualDir exefs) const { std::vector layers; layers.reserve(patch_dirs.size() + 1); for (const auto& subdir : patch_dirs) { - if (!subdir || std::find(disabled.begin(), disabled.end(), subdir->GetName()) != disabled.end()) + const std::string internal_name = GetInternalModName(subdir, load_dir); + if (!subdir || std::find(disabled.begin(), disabled.end(), internal_name) != disabled.end()) continue; auto exefs_dir = FindSubdirectoryCaseless(subdir, "exefs"); if (exefs_dir != nullptr) @@ -222,8 +250,10 @@ std::vector PatchManager::CollectPatches(const std::vector out; out.reserve(patch_dirs.size()); + const auto load_dir = fs_controller.GetModificationLoadRoot(title_id); for (const auto& subdir : patch_dirs) { - if (!subdir || std::find(disabled.cbegin(), disabled.cend(), subdir->GetName()) != disabled.cend()) + const std::string internal_name = GetInternalModName(subdir, load_dir); + if (!subdir || std::find(disabled.cbegin(), disabled.cend(), internal_name) != disabled.cend()) continue; auto exefs_dir = FindSubdirectoryCaseless(subdir, "exefs"); if (exefs_dir != nullptr) { @@ -276,7 +306,17 @@ std::vector PatchManager::PatchNSO(const std::vector& nso, const std::st LOG_ERROR(Loader, "Cannot load mods for invalid title_id={:016X}", title_id); return nso; } - auto patch_dirs = load_dir->GetSubdirectories(); + std::vector patch_dirs; + for (const auto& top_dir : load_dir->GetSubdirectories()) { + if (!top_dir) continue; + if (IsAnyModFolder(top_dir)) { + patch_dirs.push_back(top_dir); + } else { + for (const auto& sub_dir : top_dir->GetSubdirectories()) { + if (IsAnyModFolder(sub_dir)) patch_dirs.push_back(sub_dir); + } + } + } std::sort(patch_dirs.begin(), patch_dirs.end(), [](const VirtualDir& l, const VirtualDir& r) { return l->GetName() < r->GetName(); }); const auto patches = CollectPatches(patch_dirs, build_id); @@ -313,7 +353,17 @@ bool PatchManager::HasNSOPatch(const BuildID& build_id_, std::string_view name) LOG_ERROR(Loader, "Cannot load mods for invalid title_id={:016X}", title_id); return false; } - auto patch_dirs = load_dir->GetSubdirectories(); + std::vector patch_dirs; + for (const auto& top_dir : load_dir->GetSubdirectories()) { + if (!top_dir) continue; + if (IsAnyModFolder(top_dir)) { + patch_dirs.push_back(top_dir); + } else { + for (const auto& sub_dir : top_dir->GetSubdirectories()) { + if (IsAnyModFolder(sub_dir)) patch_dirs.push_back(sub_dir); + } + } + } std::sort(patch_dirs.begin(), patch_dirs.end(), [](const VirtualDir& l, const VirtualDir& r) { return l->GetName() < r->GetName(); }); return !CollectPatches(patch_dirs, build_id).empty(); @@ -332,7 +382,8 @@ std::vector PatchManager::CreateCheatList( [](const VirtualDir& l, const VirtualDir& r) { return l->GetName() < r->GetName(); }); std::vector out; for (const auto& subdir : patch_dirs) { - if (!subdir || std::find(disabled.cbegin(), disabled.cend(), subdir->GetName()) != disabled.cend()) { + const std::string internal_name = GetInternalModName(subdir, load_dir); + if (!subdir || std::find(disabled.cbegin(), disabled.cend(), internal_name) != disabled.cend()) { continue; } auto cheats_dir = FindSubdirectoryCaseless(subdir, "cheats"); @@ -361,7 +412,16 @@ static void ApplyLayeredFS(VirtualFile& romfs, u64 title_id, ContentRecordType t const auto& disabled = Settings::values.disabled_addons[title_id]; std::vector patch_dirs; if (load_dir) { - patch_dirs = load_dir->GetSubdirectories(); + for (const auto& top_dir : load_dir->GetSubdirectories()) { + if (!top_dir) continue; + if (IsAnyModFolder(top_dir)) { + patch_dirs.push_back(top_dir); + } else { + for (const auto& sub_dir : top_dir->GetSubdirectories()) { + if (IsAnyModFolder(sub_dir)) patch_dirs.push_back(sub_dir); + } + } + } } if (std::find(disabled.cbegin(), disabled.cend(), "SDMC") == disabled.cend()) { patch_dirs.push_back(sdmc_load_dir); @@ -379,7 +439,8 @@ static void ApplyLayeredFS(VirtualFile& romfs, u64 title_id, ContentRecordType t layers.reserve(patch_dirs.size() + 1); layers_ext.reserve(patch_dirs.size() + 1); for (const auto& subdir : patch_dirs) { - if (!subdir || std::find(disabled.cbegin(), disabled.cend(), subdir->GetName()) != disabled.cend()) { + const std::string internal_name = GetInternalModName(subdir, load_dir); + if (!subdir || std::find(disabled.cbegin(), disabled.cend(), internal_name) != disabled.cend()) { continue; } auto romfs_dir = FindSubdirectoryCaseless(subdir, "romfs"); @@ -693,28 +754,52 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { scan_autoloader_content("DLC", PatchType::DLC); } - // --- 3. General Mods (from original code) --- + // --- 3. General Mods (Recursive Scan) --- const auto mod_dir = fs_controller.GetModificationLoadRoot(title_id); if (mod_dir != nullptr) { - for (const auto& mod : mod_dir->GetSubdirectories()) { - std::string types; - const auto exefs_dir = FindSubdirectoryCaseless(mod, "exefs"); - if (IsDirValidAndNonEmpty(exefs_dir)) { - bool ips = false, ipswitch = false, layeredfs = false; - for (const auto& file : exefs_dir->GetFiles()) { - if (file->GetExtension() == "ips") ips = true; - else if (file->GetExtension() == "pchtxt") ipswitch = true; - else if (std::find(EXEFS_FILE_NAMES.begin(), EXEFS_FILE_NAMES.end(), file->GetName()) != EXEFS_FILE_NAMES.end()) layeredfs = true; + for (const auto& top_mod : mod_dir->GetSubdirectories()) { + if (!top_mod) continue; + + // Helper lambda to process a directory and return the patch types found + auto get_mod_types = [this](const VirtualDir& dir) -> std::string { + std::string types; + const auto exefs_dir = FindSubdirectoryCaseless(dir, "exefs"); + if (IsDirValidAndNonEmpty(exefs_dir)) { + bool ips = false, ipswitch = false, layeredfs = false; + for (const auto& file : exefs_dir->GetFiles()) { + if (file->GetExtension() == "ips") ips = true; + else if (file->GetExtension() == "pchtxt") ipswitch = true; + else if (std::find(EXEFS_FILE_NAMES.begin(), EXEFS_FILE_NAMES.end(), file->GetName()) != EXEFS_FILE_NAMES.end()) layeredfs = true; + } + if (ips) AppendCommaIfNotEmpty(types, "IPS"); + if (ipswitch) AppendCommaIfNotEmpty(types, "IPSwitch"); + if (layeredfs) AppendCommaIfNotEmpty(types, "LayeredExeFS"); + } + if (IsDirValidAndNonEmpty(FindSubdirectoryCaseless(dir, "romfs")) || IsDirValidAndNonEmpty(FindSubdirectoryCaseless(dir, "romfslite"))) AppendCommaIfNotEmpty(types, "LayeredFS"); + if (IsDirValidAndNonEmpty(FindSubdirectoryCaseless(dir, "cheats"))) AppendCommaIfNotEmpty(types, "Cheats"); + return types; + }; + + // 1. Check if the top-level folder is a mod (Existing behavior) + std::string top_types = get_mod_types(top_mod); + if (!top_types.empty()) { + const std::string internal_name = GetInternalModName(top_mod, mod_dir); + const auto mod_disabled = std::find(disabled.begin(), disabled.end(), internal_name) != disabled.end(); + out.push_back({.enabled = !mod_disabled, .name = top_mod->GetName(), .version = top_types, .type = PatchType::Mod, .program_id = title_id, .title_id = title_id}); + } + // 2. If not a mod, check one level deeper (Grouped behavior) + else { + for (const auto& sub_mod : top_mod->GetSubdirectories()) { + std::string sub_types = get_mod_types(sub_mod); + if (sub_types.empty()) continue; + + // We store the name as "FolderName/ModName" + std::string hierarchical_name = top_mod->GetName() + "/" + sub_mod->GetName(); + const std::string internal_name = GetInternalModName(sub_mod, mod_dir); + const auto mod_disabled = std::find(disabled.begin(), disabled.end(), internal_name) != disabled.end(); + out.push_back({.enabled = !mod_disabled, .name = hierarchical_name, .version = sub_types, .type = PatchType::Mod, .program_id = title_id, .title_id = title_id}); } - if (ips) AppendCommaIfNotEmpty(types, "IPS"); - if (ipswitch) AppendCommaIfNotEmpty(types, "IPSwitch"); - if (layeredfs) AppendCommaIfNotEmpty(types, "LayeredExeFS"); } - if (IsDirValidAndNonEmpty(FindSubdirectoryCaseless(mod, "romfs")) || IsDirValidAndNonEmpty(FindSubdirectoryCaseless(mod, "romfslite"))) AppendCommaIfNotEmpty(types, "LayeredFS"); - if (IsDirValidAndNonEmpty(FindSubdirectoryCaseless(mod, "cheats"))) AppendCommaIfNotEmpty(types, "Cheats"); - if (types.empty()) continue; - const auto mod_disabled = std::find(disabled.begin(), disabled.end(), mod->GetName()) != disabled.end(); - out.push_back({.enabled = !mod_disabled, .name = mod->GetName(), .version = types, .type = PatchType::Mod, .program_id = title_id, .title_id = title_id}); } } const auto sdmc_mod_dir = fs_controller.GetSDMCModificationLoadRoot(title_id);