diff --git a/src/citron/configuration/configure_per_game_addons.cpp b/src/citron/configuration/configure_per_game_addons.cpp index 2f68a5b5d..360a393a1 100644 --- a/src/citron/configuration/configure_per_game_addons.cpp +++ b/src/citron/configuration/configure_per_game_addons.cpp @@ -8,6 +8,8 @@ #include #include +#include +#include #include #include #include @@ -210,13 +212,21 @@ void ConfigurePerGameAddons::LoadConfiguration() { } auto* const mod_item = new QStandardItem(full_name); - mod_item->setCheckable(true); + + // If it's a Tool, remove the checkbox entirely + if (patch.version == "Tool") { + mod_item->setCheckable(false); + // Explicitly strip the checkable flag to prevent the UI from drawing a box + mod_item->setFlags(mod_item->flags() & ~Qt::ItemIsUserCheckable); + mod_item->setForeground(QBrush(QColor(0, 120, 215))); // Keep it blue to show it's special + } else { + mod_item->setCheckable(true); + const auto patch_disabled = std::find(disabled.begin(), disabled.end(), patch.name) != disabled.end(); + mod_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked); + } 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)}; @@ -236,33 +246,57 @@ 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); + if (item->rowCount() > 0) { + // --- Folder Logic --- + 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); + }); + 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); } - } - }); + }); + } else { + // --- Individual Item Logic --- + QModelIndex v_idx = index.siblingAtColumn(1); + if (item_model->data(v_idx).toString() == QStringLiteral("Tool")) { + QAction* launch = context_menu.addAction(tr("Launch Tool")); + QString file_name = item->text(); + connect(launch, &QAction::triggered, this, [this, file_name] { + // 1. Check Global Safe Zone (ConfigDir) + std::filesystem::path tool_path = + Common::FS::GetCitronPath(Common::FS::CitronPath::ConfigDir) / "tools" / file_name.toStdString(); + + // 2. Fallback to Legacy/Game-specific folder + if (!std::filesystem::exists(tool_path)) { + tool_path = Common::FS::GetCitronPath(Common::FS::CitronPath::LoadDir) / + fmt::format("{:016X}", title_id) / "tools" / file_name.toStdString(); + } + + if (std::filesystem::exists(tool_path)) { + QString program = QString::fromStdString(tool_path.string()); + QString working_dir = QString::fromStdString(tool_path.parent_path().string()); + + LOG_INFO(Frontend, "Launching tool: {} with working directory: {}", + program.toStdString(), working_dir.toStdString()); + + // Start the process detached with an explicit working directory. + // This prevents the emulator from "cleaning up" the tool's temporary files. + QProcess::startDetached(program, {}, working_dir); + } else { + QMessageBox::critical(this, tr("Launch Error"), + tr("The tool executable could not be found. Please redownload it.")); + } + }); + } + } context_menu.exec(tree_view->viewport()->mapToGlobal(pos)); } diff --git a/src/citron/mod_manager/mod_downloader_dialog.cpp b/src/citron/mod_manager/mod_downloader_dialog.cpp index 34ada95a9..590a0845c 100644 --- a/src/citron/mod_manager/mod_downloader_dialog.cpp +++ b/src/citron/mod_manager/mod_downloader_dialog.cpp @@ -1,21 +1,20 @@ // SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later -#include -#include -#include #include #include #include +#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" @@ -23,45 +22,31 @@ 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; -} +ModDownloaderDialog::~ModDownloaderDialog() { delete ui; } void ModDownloaderDialog::SetupModList() { ui->treeWidget->setHeaderLabel(QStringLiteral("Version / Mod Name")); - for (auto const& [version, patches] : mod_info.version_patches) { 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); - - // Track names we've already added to this version to avoid duplicates in the UI - std::set seen_names; - + std::set seen; for (const auto& patch : patches) { - if (seen_names.find(patch.name) != seen_names.end()) { - continue; // Skip if we already listed this mod name - } - + if (seen.count(patch.name)) continue; 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); - - seen_names.insert(patch.name); + seen.insert(patch.name); } } ui->treeWidget->expandAll(); @@ -69,34 +54,46 @@ void ModDownloaderDialog::SetupModList() { void ModDownloaderDialog::OnDownloadClicked() { pending_downloads.clear(); + QString os_target; +#ifdef _WIN32 + os_target = QStringLiteral("exe"); +#elif __APPLE__ + os_target = QStringLiteral("zip"); +#else + os_target = QStringLiteral("AppImage"); +#endif for (int i = 0; i < ui->treeWidget->topLevelItemCount(); ++i) { QTreeWidgetItem* version_node = ui->treeWidget->topLevelItem(i); - QString version_str = version_node->text(0).replace(QStringLiteral("Update "), QStringLiteral("")); - + QString v_str = version_node->text(0).replace(QStringLiteral("Update "), QStringLiteral("")); 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); - const auto& patches = mod_info.version_patches[version_str]; - - // Find EVERY patch entry that matches this name - // (e.g., grabs both the 'exefs' and 'cheats' entries for "30FPS") - for (const auto& p : patches) { - if (p.name == mod_name) { - pending_downloads.push_back({p, version_str}); + if (mod_node->checkState(0) != Qt::Checked) continue; + const auto& patches = mod_info.version_patches[v_str]; + for (auto p : patches) { + if (p.name == mod_node->text(0)) { + if (p.type == QStringLiteral("tool")) { + QStringList filtered; + for (const QString& f : p.files) { + if (f.endsWith(os_target, Qt::CaseInsensitive)) filtered << f; + } + if (filtered.size() > 1) { + bool ok; + QString choice = QInputDialog::getItem(this, QStringLiteral("Select Architecture"), + QStringLiteral("Choose your system type:"), filtered, 0, false, &ok); + if (ok && !choice.isEmpty()) p.files = {choice}; + else continue; + } else { p.files = filtered; } } + pending_downloads.push_back({p, v_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(); @@ -104,51 +101,53 @@ void ModDownloaderDialog::OnDownloadClicked() { void ModDownloaderDialog::StartNextDownload() { if (current_download_index >= static_cast(pending_downloads.size())) { - QMessageBox::information(this, QStringLiteral("Success"), QStringLiteral("All selected mods have been installed.")); + QMessageBox::information(this, QStringLiteral("Success"), QStringLiteral("All items 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()) { + if (current_file_index >= task.patch.files.size()) { current_download_index++; current_file_index = 0; StartNextDownload(); return; } + QString file_val = task.patch.files[current_file_index]; + QUrl url = (task.patch.type == QStringLiteral("tool")) ? QUrl(file_val) : + QUrl(QStringLiteral("https://raw.githubusercontent.com/CollectingW/Citron-Mods/main/%1/%2") + .arg(task.patch.rel_path).arg(file_val)); - 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); + QString fileName = file_val.contains(u'/') ? file_val.split(u'/').last() : file_val; + current_reply = network_manager->get(QNetworkRequest(url)); - 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]() { + connect(current_reply, &QNetworkReply::finished, this, [this, task, 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(); + std::filesystem::path base = Common::FS::GetCitronPath(Common::FS::CitronPath::LoadDir); + std::filesystem::path final_path = base / 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(); + if (task.patch.type == QStringLiteral("tool")) { + final_path = Common::FS::GetCitronPath(Common::FS::CitronPath::ConfigDir) / "tools"; + } else { + final_path /= task.version.toStdString(); + final_path /= task.patch.name.toStdString(); + final_path /= task.patch.type.toStdString(); + } - std::error_code ec; - std::filesystem::create_directories(final_path, ec); - - QFile file(QString::fromStdString((final_path / fileName.toStdString()).string())); + std::filesystem::create_directories(final_path); + QString full_save_path = QString::fromStdString((final_path / fileName.toStdString()).string()); + QFile file(full_save_path); if (file.open(QIODevice::WriteOnly)) { file.write(current_reply->readAll()); file.close(); + if (fileName.endsWith(QStringLiteral(".zip"))) { + QProcess::execute(QStringLiteral("unzip"), {full_save_path, QStringLiteral("-d"), QString::fromStdString(final_path.string())}); + } +#ifndef _WIN32 + std::filesystem::permissions(final_path / fileName.toStdString(), + std::filesystem::perms::owner_exec | std::filesystem::perms::group_exec, std::filesystem::perm_options::add); +#endif } } - current_reply->deleteLater(); current_file_index++; ui->progressBar->setValue(((current_download_index + 1) * 100) / pending_downloads.size()); @@ -156,9 +155,6 @@ void ModDownloaderDialog::StartNextDownload() { }); } -void ModDownloaderDialog::OnCancelClicked() { - if (current_reply) current_reply->abort(); - reject(); -} +void ModDownloaderDialog::OnCancelClicked() { if (current_reply) current_reply->abort(); reject(); } } // namespace ModManager diff --git a/src/citron/mod_manager/mod_service.cpp b/src/citron/mod_manager/mod_service.cpp index 60c728205..137ba0f39 100644 --- a/src/citron/mod_manager/mod_service.cpp +++ b/src/citron/mod_manager/mod_service.cpp @@ -17,10 +17,17 @@ ModService::ModService(QObject* parent) : QObject(parent) { ModService::~ModService() = default; void ModService::FetchAvailableMods(const QString& title_id) { - QNetworkRequest request{QUrl(MANIFEST_URL)}; + const QStringList optimizer_supported = { + QStringLiteral("01006BB00C6F0000"), QStringLiteral("0100F2C0115B6000"), + QStringLiteral("01002B00111A2000"), QStringLiteral("01007EF00011E000"), + QStringLiteral("0100F43008C44000"), QStringLiteral("0100A3D008C5C000"), + QStringLiteral("01008F6008C5E000") + }; + + QNetworkRequest request((QUrl(MANIFEST_URL))); QNetworkReply* reply = network_manager->get(request); - connect(reply, &QNetworkReply::finished, this, [this, reply, title_id]() { + connect(reply, &QNetworkReply::finished, this, [this, reply, title_id, optimizer_supported]() { if (reply->error() != QNetworkReply::NoError) { emit Error(reply->errorString()); reply->deleteLater(); @@ -29,47 +36,59 @@ void ModService::FetchAvailableMods(const QString& title_id) { 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(); + // 1. Process standard mods from manifest + if (root.contains(tid_upper)) { + QJsonObject tid_obj = root.value(tid_upper).toObject(); + QJsonObject versions_obj = tid_obj.value(QStringLiteral("versions")).toObject(); + 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); } - - patches.push_back(patch); + info.version_patches[v_name] = patches; } - // Add this version and its mods to the map - info.version_patches[v_name] = patches; } - emit ModsAvailable(info); + // 2. Also Fetch NX-Optimizer if supported + if (optimizer_supported.contains(tid_upper)) { + QNetworkRequest github_req(QUrl(QStringLiteral("https://api.github.com/repos/MaxLastBreath/nx-optimizer/releases/latest"))); + github_req.setRawHeader("Accept", "application/vnd.github.v3+json"); + github_req.setRawHeader("User-Agent", "Citron-Emulator"); + QNetworkReply* github_reply = network_manager->get(github_req); + + connect(github_reply, &QNetworkReply::finished, this, [this, github_reply, info]() mutable { + if (github_reply->error() == QNetworkReply::NoError) { + QJsonObject release = QJsonDocument::fromJson(github_reply->readAll()).object(); + QJsonArray assets = release.value(QStringLiteral("assets")).toArray(); + ModPatch tool; + tool.name = QStringLiteral("NX-Optimizer by MaxLastBreath"); + tool.type = QStringLiteral("tool"); + for (const QJsonValue& asset : assets) { + tool.files << asset.toObject().value(QStringLiteral("browser_download_url")).toString(); + } + info.version_patches[QStringLiteral("Global Tools")].push_back(tool); + } + emit ModsAvailable(info); + github_reply->deleteLater(); + }); + } else { + emit ModsAvailable(info); + } reply->deleteLater(); }); } diff --git a/src/core/file_sys/patch_manager.cpp b/src/core/file_sys/patch_manager.cpp index af9457f62..87957da69 100644 --- a/src/core/file_sys/patch_manager.cpp +++ b/src/core/file_sys/patch_manager.cpp @@ -8,6 +8,8 @@ #include #include +#include +#include "common/fs/path_util.h" #include "common/hex_util.h" #include "common/logging/log.h" #include "common/settings.h" @@ -713,10 +715,60 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { list += fmt::format("{}", dlc_match.back().title_id & 0x7FF); const auto dlc_disabled = std::find(disabled.begin(), disabled.end(), "DLC") != disabled.end(); - // FIX: Saved under base title_id so logic finds it out.push_back({.enabled = !dlc_disabled, .name = "DLC", .version = std::move(list), .type = PatchType::DLC, .program_id = title_id, .title_id = title_id}); } + // Scan for Game-Specific Tools + const auto load_root = fs_controller.GetModificationLoadRoot(title_id); + if (load_root) { + if (auto tools_dir = load_root->GetSubdirectory("tools")) { + for (const auto& file : tools_dir->GetFiles()) { + FileSys::Patch tool_patch; + tool_patch.enabled = true; + tool_patch.name = file->GetName(); + tool_patch.version = "Tool"; + tool_patch.type = PatchType::Mod; + tool_patch.program_id = title_id; + tool_patch.title_id = title_id; + out.push_back(std::move(tool_patch)); + } + } + } + + // Scan for Global Tools (NX-Optimizer) + const std::vector optimizer_supported_ids = { + 0x01006BB00C6F0000, 0x0100F2C0115B6000, 0x01002B00111A2000, + 0x01007EF00011E000, 0x0100F43008C44000, 0x0100A3D008C5C000, 0x01008F6008C5E000 + }; + + if (std::find(optimizer_supported_ids.begin(), optimizer_supported_ids.end(), title_id) != optimizer_supported_ids.end()) { + auto global_tools_path = Common::FS::GetCitronPath(Common::FS::CitronPath::ConfigDir) / "tools"; + + if (std::filesystem::exists(global_tools_path)) { + for (const auto& entry : std::filesystem::directory_iterator(global_tools_path)) { + if (entry.is_regular_file()) { + const auto extension = entry.path().extension().string(); + + // Only allow actual executables to show up in the UI + bool is_tool = (extension == ".AppImage" || extension == ".exe"); + + if (!is_tool) { + continue; + } + + FileSys::Patch global_tool; + global_tool.enabled = true; + global_tool.name = entry.path().filename().string(); + global_tool.version = "Tool"; + global_tool.type = PatchType::Mod; + global_tool.program_id = title_id; + global_tool.title_id = title_id; + out.push_back(std::move(global_tool)); + } + } + } + } + return out; } @@ -761,7 +813,7 @@ PatchManager::Metadata PatchManager::GetControlMetadata() const { } } - // FIX: Only fetch the Update metadata if the user hasn't disabled it + // Only fetch the Update metadata if the user hasn't disabled it const auto update_disabled = std::find(disabled_for_game.begin(), disabled_for_game.end(), "Update") != disabled_for_game.end(); const auto update_tid = GetUpdateTitleID(title_id); diff --git a/src/video_core/shader_environment.cpp b/src/video_core/shader_environment.cpp index 4aea915a9..18ae76420 100644 --- a/src/video_core/shader_environment.cpp +++ b/src/video_core/shader_environment.cpp @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: Copyright 2021 yuzu Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include @@ -692,10 +693,17 @@ void LoadPipelines( } u32 num_envs{}; file.read(reinterpret_cast(&num_envs), sizeof(num_envs)); + + if (num_envs == 0 || num_envs > 64) { + LOG_ERROR(Common_Filesystem, "Corrupted shader cache detected: num_envs={}", num_envs); + throw std::ios_base::failure("Corrupted num_envs"); + } + std::vector envs(num_envs); for (FileEnvironment& env : envs) { env.Deserialize(file); } + if (envs.front().ShaderStage() == Shader::Stage::Compute) { load_compute(file, std::move(envs.front())); } else {