From 62fc35918922edb718e22ae6ed363130ce929a80 Mon Sep 17 00:00:00 2001 From: Maufeat Date: Wed, 7 Jan 2026 16:53:10 +0100 Subject: [PATCH] rework and rebase --- src/common/settings.h | 3 + src/core/CMakeLists.txt | 2 + src/core/file_sys/external_content_index.cpp | 313 ++++++++++++++++++ src/core/file_sys/external_content_index.h | 74 +++++ src/core/file_sys/patch_manager.cpp | 282 +++++++++++++--- src/core/file_sys/registered_cache.h | 4 + src/core/file_sys/vfs/vfs_real.cpp | 49 ++- src/core/file_sys/vfs/vfs_real.h | 4 + .../hle/service/filesystem/filesystem.cpp | 32 ++ src/core/hle/service/filesystem/filesystem.h | 6 + src/frontend_common/config.cpp | 21 ++ src/yuzu/configuration/configure_general.cpp | 66 +++- src/yuzu/configuration/configure_general.h | 8 + src/yuzu/configuration/configure_general.ui | 80 +++++ .../configure_per_game_addons.cpp | 107 +++++- .../configuration/configure_per_game_addons.h | 12 +- src/yuzu/game_list.cpp | 29 +- src/yuzu/game_list.h | 1 + src/yuzu/game_list_worker.cpp | 4 + 19 files changed, 1023 insertions(+), 74 deletions(-) create mode 100644 src/core/file_sys/external_content_index.cpp create mode 100644 src/core/file_sys/external_content_index.h diff --git a/src/common/settings.h b/src/common/settings.h index b523fb673b..d282999a06 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -750,6 +750,9 @@ struct Values { // Add-Ons std::map> disabled_addons; + // External Dirs + std::vector external_dirs; + // Per-game overrides bool use_squashed_iterated_blend; diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index a961eff8bf..323beb69fa 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -56,6 +56,8 @@ add_library(core STATIC file_sys/control_metadata.cpp file_sys/control_metadata.h file_sys/errors.h + file_sys/external_content_index.cpp + file_sys/external_content_index.h file_sys/fs_directory.h file_sys/fs_file.h file_sys/fs_filesystem.h diff --git a/src/core/file_sys/external_content_index.cpp b/src/core/file_sys/external_content_index.cpp new file mode 100644 index 0000000000..eff069c5c1 --- /dev/null +++ b/src/core/file_sys/external_content_index.cpp @@ -0,0 +1,313 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "core/file_sys/external_content_index.h" + +#include +#include +#include +#include +#include "common/fs/fs_util.h" +#include "common/hex_util.h" +#include "common/logging/log.h" +#include "core/file_sys/common_funcs.h" +#include "core/file_sys/content_archive.h" +#include "core/file_sys/nca_metadata.h" +#include "core/file_sys/registered_cache.h" +#include "core/file_sys/romfs.h" +#include "core/file_sys/submission_package.h" +#include "core/file_sys/vfs/vfs.h" +#include "core/loader/loader.h" + +namespace fs = std::filesystem; + +namespace FileSys { + +ExternalContentIndexer::ExternalContentIndexer(VirtualFilesystem vfs, + ManualContentProvider& provider, + ExternalContentPaths paths) + : m_vfs(std::move(vfs)), m_provider(provider), m_paths(std::move(paths)) {} + +void ExternalContentIndexer::Rebuild() { + m_provider.ClearAllEntries(); + m_updates_by_title.clear(); + m_all_dlc.clear(); + + for (const auto& dir : m_paths.update_dirs) { + IndexUpdatesDir(dir); + } + for (const auto& dir : m_paths.dlc_dirs) { + IndexDlcDir(dir); + } + + Commit(); +} + +static std::string ToLowerCopy(const std::string& s) { + std::string out; + out.resize(s.size()); + std::transform(s.begin(), s.end(), out.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + return out; +} + +void ExternalContentIndexer::IndexUpdatesDir(const std::string& dir) { + try { + const fs::path p = Common::FS::ToU8String(dir); + std::error_code ec; + if (!fs::exists(p, ec) || ec) + return; + + if (fs::is_directory(p, ec) && !ec) { + for (const auto& entry : fs::recursive_directory_iterator( + p, fs::directory_options::skip_permission_denied, ec)) { + if (entry.is_directory(ec)) + continue; + TryIndexFileAsContainer(Common::FS::ToUTF8String(entry.path().u8string()), true); + } + TryIndexLooseDir(Common::FS::ToUTF8String(p.u8string()), true); + } else { + TryIndexFileAsContainer(Common::FS::ToUTF8String(p.u8string()), true); + } + } catch (const std::exception& e) { + LOG_ERROR(Loader, "Error accessing update directory '{}': {}", dir, e.what()); + } +} + +void ExternalContentIndexer::IndexDlcDir(const std::string& dir) { + try { + const fs::path p = Common::FS::ToU8String(dir); + std::error_code ec; + if (!fs::exists(p, ec) || ec) + return; + + if (fs::is_directory(p, ec) && !ec) { + for (const auto& entry : fs::recursive_directory_iterator( + p, fs::directory_options::skip_permission_denied, ec)) { + if (entry.is_directory(ec)) + continue; + TryIndexFileAsContainer(Common::FS::ToUTF8String(entry.path().u8string()), false); + } + TryIndexLooseDir(Common::FS::ToUTF8String(p.u8string()), false); + } else { + TryIndexFileAsContainer(Common::FS::ToUTF8String(p.u8string()), false); + } + } catch (const std::exception& e) { + LOG_ERROR(Loader, "Error accessing DLC directory '{}': {}", dir, e.what()); + } +} + +void ExternalContentIndexer::TryIndexFileAsContainer(const std::string& path, bool is_update) { + const auto lower = ToLowerCopy(path); + if (lower.size() >= 4 && lower.rfind(".nsp") == lower.size() - 4) { + if (auto vf = m_vfs->OpenFile(path, OpenMode::Read)) { + ParseContainerNSP(vf, is_update); + } + } +} + +void ExternalContentIndexer::TryIndexLooseDir(const std::string& dir, bool is_update) { + fs::path p = Common::FS::ToU8String(dir); + std::error_code ec; + if (!fs::is_directory(p, ec) || ec) + return; + + for (const auto& entry : + fs::recursive_directory_iterator(p, fs::directory_options::skip_permission_denied, ec)) { + if (ec) + break; + if (!entry.is_regular_file(ec)) + continue; + const auto path = Common::FS::ToUTF8String(entry.path().u8string()); + const auto lower = ToLowerCopy(path); + if (lower.size() >= 9 && lower.rfind(".cnmt.nca") == lower.size() - 9) { + if (auto vf = m_vfs->OpenFile(path, OpenMode::Read)) { + ParseLooseCnmtNca( + vf, Common::FS::ToUTF8String(entry.path().parent_path().u8string()), is_update); + } + } + } +} + +void ExternalContentIndexer::ParseContainerNSP(VirtualFile file, bool is_update) { + if (file == nullptr) + return; + NSP nsp(file); + if (nsp.GetStatus() != Loader::ResultStatus::Success) { + LOG_WARNING(Loader, "ExternalContent: NSP parse failed"); + return; + } + + const auto title_map = nsp.GetNCAs(); + if (title_map.empty()) + return; + + for (const auto& [title_id, nca_map] : title_map) { + std::shared_ptr meta_nca; + for (const auto& [key, nca_ptr] : nca_map) { + if (nca_ptr && nca_ptr->GetType() == NCAContentType::Meta) { + meta_nca = nca_ptr; + break; + } + } + if (!meta_nca) + continue; + + auto cnmt_opt = ExtractCnmtFromMetaNca(*meta_nca); + if (!cnmt_opt) + continue; + const auto& cnmt = *cnmt_opt; + + const auto base_id = BaseTitleId(title_id); + + if (is_update && cnmt.GetType() == TitleType::Update) { + ParsedUpdate candidate{}; + // Register updates under their Update TID so PatchManager can find/apply them + candidate.title_id = FileSys::GetUpdateTitleID(base_id); + candidate.version = cnmt.GetTitleVersion(); + for (const auto& rec : cnmt.GetContentRecords()) { + const auto it = nca_map.find({cnmt.GetType(), rec.type}); + if (it != nca_map.end() && it->second) { + candidate.ncas[rec.type] = it->second->GetBaseFile(); + } + } + auto& vec = m_updates_by_title[base_id]; + vec.emplace_back(std::move(candidate)); + } else if (cnmt.GetType() == TitleType::AOC) { + const auto dlc_title_id = cnmt.GetTitleID(); + for (const auto& rec : cnmt.GetContentRecords()) { + const auto it = nca_map.find({cnmt.GetType(), rec.type}); + if (it != nca_map.end() && it->second) { + m_all_dlc.push_back( + ParsedDlcRecord{dlc_title_id, {}, it->second->GetBaseFile()}); + } + } + } + } +} + +void ExternalContentIndexer::ParseLooseCnmtNca(VirtualFile meta_nca_file, const std::string& folder, + bool is_update) { + if (meta_nca_file == nullptr) + return; + + NCA meta(meta_nca_file); + + if (!IsMeta(meta)) + return; + + auto cnmt_opt = ExtractCnmtFromMetaNca(meta); + if (!cnmt_opt) + return; + const auto& cnmt = *cnmt_opt; + + const auto base_id = BaseTitleId(cnmt.GetTitleID()); + + if (is_update && cnmt.GetType() == TitleType::Update) { + ParsedUpdate candidate{}; + // Register updates under their Update TID so PatchManager can find/apply them + candidate.title_id = FileSys::GetUpdateTitleID(base_id); + candidate.version = cnmt.GetTitleVersion(); + + for (const auto& rec : cnmt.GetContentRecords()) { + const auto file_name = Common::HexToString(rec.nca_id) + ".nca"; + const auto full = Common::FS::ToUTF8String( + (fs::path(Common::FS::ToU8String(folder)) / fs::path(file_name)).u8string()); + if (auto vf = m_vfs->OpenFile(full, OpenMode::Read)) { + candidate.ncas[rec.type] = vf; + } + } + + auto& vec = m_updates_by_title[base_id]; + vec.emplace_back(std::move(candidate)); + } else if (cnmt.GetType() == TitleType::AOC) { + const auto dlc_title_id = cnmt.GetTitleID(); + for (const auto& rec : cnmt.GetContentRecords()) { + const auto file_name = Common::HexToString(rec.nca_id) + ".nca"; + const auto full = Common::FS::ToUTF8String( + (fs::path(Common::FS::ToU8String(folder)) / fs::path(file_name)).u8string()); + if (auto vf = m_vfs->OpenFile(full, OpenMode::Read)) { + ParsedDlcRecord dl{dlc_title_id, {}, vf}; + m_all_dlc.push_back(std::move(dl)); + } + } + } +} + +std::optional ExternalContentIndexer::ExtractCnmtFromMetaNca(const NCA& meta_nca) { + if (meta_nca.GetStatus() != Loader::ResultStatus::Success) + return std::nullopt; + + const auto subs = meta_nca.GetSubdirectories(); + if (subs.empty() || !subs[0]) + return std::nullopt; + + const auto files = subs[0]->GetFiles(); + if (files.empty() || !files[0]) + return std::nullopt; + + CNMT cnmt(files[0]); + return cnmt; +} + +ExternalContentIndexer::TitleID ExternalContentIndexer::BaseTitleId(TitleID id) { + return FileSys::GetBaseTitleID(id); +} + +bool ExternalContentIndexer::IsMeta(const NCA& nca) { + return nca.GetType() == NCAContentType::Meta; +} + +void ExternalContentIndexer::Commit() { + // Updates: register all discovered versions per base title under unique variant TIDs, + // and additionally register the highest version under the canonical update TID for default + // usage. + size_t update_variants_count = 0; + for (auto& [base_title, vec] : m_updates_by_title) { + if (vec.empty()) + continue; + // sort ascending by version, dedupe identical versions (for NAND overlap, for example) + std::stable_sort(vec.begin(), vec.end(), [](const ParsedUpdate& a, const ParsedUpdate& b) { + return a.version < b.version; + }); + vec.erase(std::unique(vec.begin(), vec.end(), + [](const ParsedUpdate& a, const ParsedUpdate& b) { + return a.version == b.version; + }), + vec.end()); + + // highest version for canonical TID + const auto& latest = vec.back(); + for (const auto& [rtype, file] : latest.ncas) { + if (!file) + continue; + const auto canonical_tid = FileSys::GetUpdateTitleID(base_title); + m_provider.AddEntry(TitleType::Update, rtype, canonical_tid, file); + } + + // variants under update_tid + i (i starts at1 to avoid colliding with canonical) + for (size_t i = 0; i < vec.size(); ++i) { + const auto& upd = vec[i]; + const u64 variant_tid = FileSys::GetUpdateTitleID(base_title) + static_cast(i + 1); + for (const auto& [rtype, file] : upd.ncas) { + if (!file) + continue; + m_provider.AddEntry(TitleType::Update, rtype, variant_tid, file); + } + } + update_variants_count += vec.size(); + } + + // DLC: additive + for (const auto& dlc : m_all_dlc) { + if (!dlc.file) + continue; + m_provider.AddEntry(TitleType::AOC, ContentRecordType::Data, dlc.title_id, dlc.file); + } + + LOG_INFO(Loader, + "ExternalContent: registered updates for {} titles ({} variants), {} DLC records", + m_updates_by_title.size(), update_variants_count, m_all_dlc.size()); +} + +} // namespace FileSys diff --git a/src/core/file_sys/external_content_index.h b/src/core/file_sys/external_content_index.h new file mode 100644 index 0000000000..16b00f49c8 --- /dev/null +++ b/src/core/file_sys/external_content_index.h @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include "common/common_types.h" +#include "core/file_sys/registered_cache.h" +#include "core/file_sys/vfs/vfs.h" +#include "core/file_sys/nca_metadata.h" + +namespace FileSys { + + class ManualContentProvider; + class NCA; + + struct ExternalContentPaths { + std::vector update_dirs; + std::vector dlc_dirs; + }; + + class ExternalContentIndexer { + public: + ExternalContentIndexer(VirtualFilesystem vfs, + ManualContentProvider& provider, + ExternalContentPaths paths); + + void Rebuild(); + + private: + using TitleID = u64; + + struct ParsedUpdate { + TitleID title_id{}; + u32 version{}; + std::unordered_map ncas; + }; + + struct ParsedDlcRecord { + TitleID title_id{}; + NcaID nca_id{}; + VirtualFile file{}; + }; + + void IndexUpdatesDir(const std::string& dir); + void IndexDlcDir(const std::string& dir); + + void TryIndexFileAsContainer(const std::string& path, bool is_update); + void TryIndexLooseDir(const std::string& dir, bool is_update); + + void ParseContainerNSP(VirtualFile file, bool is_update); + void ParseLooseCnmtNca(VirtualFile meta_nca_file, const std::string& folder, bool is_update); + + static std::optional ExtractCnmtFromMetaNca(const NCA& meta_nca); + static TitleID BaseTitleId(TitleID id); + static bool IsMeta(const NCA& nca); + + void Commit(); + + private: + VirtualFilesystem m_vfs; + ManualContentProvider& m_provider; + ExternalContentPaths m_paths; + + std::unordered_map> m_updates_by_title; + + std::vector m_all_dlc; + }; + +} // namespace FileSys diff --git a/src/core/file_sys/patch_manager.cpp b/src/core/file_sys/patch_manager.cpp index 657172fb4d..42e098c738 100644 --- a/src/core/file_sys/patch_manager.cpp +++ b/src/core/file_sys/patch_manager.cpp @@ -8,6 +8,9 @@ #include #include #include +#include +#include +#include #include "common/hex_util.h" #include "common/logging/log.h" @@ -49,6 +52,30 @@ enum class TitleVersionFormat : u8 { FourElements, ///< vX.Y.Z.W }; +std::array ParseVersionComponents(std::string_view label) { + std::array out{0, 0, 0, 0}; + if (!label.empty() && (label.front() == 'v' || label.front() == 'V')) { + label.remove_prefix(1); + } + size_t part = 0; + size_t start = 0; + std::string s(label); + while (part < out.size() && start < s.size()) { + size_t dot = s.find('.', start); + auto token = s.substr(start, dot == std::string::npos ? std::string::npos : dot - start); + try { + out[part] = std::stoi(token); + } catch (...) { + out[part] = 0; + } + ++part; + if (dot == std::string::npos) + break; + start = dot + 1; + } + return out; +} + std::string FormatTitleVersion(u32 version, TitleVersionFormat format = TitleVersionFormat::ThreeElements) { std::array bytes{}; @@ -117,6 +144,82 @@ void AppendCommaIfNotEmpty(std::string& to, std::string_view with) { bool IsDirValidAndNonEmpty(const VirtualDir& dir) { return dir != nullptr && (!dir->GetFiles().empty() || !dir->GetSubdirectories().empty()); } + +std::vector EnumerateUpdateVariants(const ContentProvider& provider, u64 base_title_id, + ContentRecordType type) { + std::vector tids; + const auto entries = provider.ListEntriesFilter(TitleType::Update, type); + for (const auto& e : entries) { + if (GetBaseTitleID(e.title_id) == base_title_id) { + tids.push_back(e.title_id); + } + } + std::sort(tids.begin(), tids.end()); + tids.erase(std::unique(tids.begin(), tids.end()), tids.end()); + return tids; +} + +std::string GetUpdateVersionLabel(u64 update_tid, + const Service::FileSystem::FileSystemController& fs, + const ContentProvider& provider) { + PatchManager pm{update_tid, fs, provider}; + const auto meta = pm.GetControlMetadata(); + if (meta.first != nullptr) { + auto str = meta.first->GetVersionString(); + if (!str.empty()) { + if (str.front() != 'v' && str.front() != 'V') { + str.insert(str.begin(), 'v'); + } + return str; + } + } + const auto ver = provider.GetEntryVersion(update_tid).value_or(0); + return FormatTitleVersion(ver); +} + +std::optional ChooseUpdateVariant(const ContentProvider& provider, u64 base_title_id, + ContentRecordType type, + const Service::FileSystem::FileSystemController& fs) { + const auto& disabled = Settings::values.disabled_addons[base_title_id]; + + if (std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend()) { + return std::nullopt; + } + + const auto candidates = EnumerateUpdateVariants(provider, base_title_id, type); + if (candidates.empty()) { + return std::nullopt; + } + + // Sort candidates by numeric version descending, using meta version; fallback to0 + std::vector> ordered; // (version,uTid) + ordered.reserve(candidates.size()); + for (const auto tid : candidates) { + const u32 ver = provider.GetEntryVersion(tid).value_or(0); + ordered.emplace_back(ver, tid); + } + std::sort(ordered.begin(), ordered.end(), [](auto const& a, auto const& b) { + return a.first > b.first; // highest version first + }); + + // Pick the first candidate that is not specifically disabled via "Update vX.Y.Z" + for (const auto& [ver, tid] : ordered) { + const auto label = GetUpdateVersionLabel(tid, fs, provider); + const auto toggle_name = fmt::format("Update {}", label); + if (std::find(disabled.cbegin(), disabled.cend(), toggle_name) == disabled.cend()) { + return tid; + } + } + + // All variants disabled, do not apply any update + return std::nullopt; +} + +bool HasVariantPreference(const std::vector& disabled) { + return std::any_of(disabled.begin(), disabled.end(), [](const std::string& s) { + return s.rfind("Update v", 0) == 0; + }); +} } // Anonymous namespace PatchManager::PatchManager(u64 title_id_, @@ -141,13 +244,21 @@ VirtualDir PatchManager::PatchExeFS(VirtualDir exefs) const { std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); // Game Updates - const auto update_tid = GetUpdateTitleID(title_id); - const auto update = content_provider.GetEntry(update_tid, ContentRecordType::Program); + std::optional selected_update_tid; + if (!update_disabled) { + selected_update_tid = ChooseUpdateVariant(content_provider, title_id, + ContentRecordType::Program, fs_controller); + if (!selected_update_tid.has_value()) { + selected_update_tid = GetUpdateTitleID(title_id); + } + } - if (!update_disabled && update != nullptr && update->GetExeFS() != nullptr) { - LOG_INFO(Loader, " ExeFS: Update ({}) applied successfully", - FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0))); - exefs = update->GetExeFS(); + if (selected_update_tid.has_value()) { + const auto update = content_provider.GetEntry(*selected_update_tid, ContentRecordType::Program); + if (update != nullptr && update->GetExeFS() != nullptr) { + LOG_INFO(Loader, " ExeFS: Applying update \"{}\"", update->GetName()); + exefs = update->GetExeFS(); + } } // LayeredExeFS @@ -446,8 +557,11 @@ VirtualFile PatchManager::PatchRomFS(const NCA* base_nca, VirtualFile base_romfs auto romfs = base_romfs; // Game Updates - const auto update_tid = GetUpdateTitleID(title_id); - const auto update_raw = content_provider.GetEntryRaw(update_tid, type); + std::optional selected_update_tid = ChooseUpdateVariant(content_provider, title_id, type, fs_controller); + if (!selected_update_tid.has_value()) { + selected_update_tid = GetUpdateTitleID(title_id); + } + const auto update_raw = content_provider.GetEntryRaw(*selected_update_tid, type); const auto& disabled = Settings::values.disabled_addons[title_id]; const auto update_disabled = @@ -457,11 +571,33 @@ VirtualFile PatchManager::PatchRomFS(const NCA* base_nca, VirtualFile base_romfs const auto new_nca = std::make_shared(update_raw, base_nca); if (new_nca->GetStatus() == Loader::ResultStatus::Success && new_nca->GetRomFS() != nullptr) { - LOG_INFO(Loader, " RomFS: Update ({}) applied successfully", - FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0))); + const auto version = content_provider.GetEntryVersion(*selected_update_tid).value_or(0); + LOG_DEBUG(Loader, " RomFS: Update ({}) applied successfully", FormatTitleVersion(version)); romfs = new_nca->GetRomFS(); - const auto version = - FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0)); + } + } else if (!update_disabled && base_nca != nullptr) { + ContentRecordType alt_type = type; + + if (type == ContentRecordType::Program) { + alt_type = ContentRecordType::Data; + } else if (type == ContentRecordType::Data) { + alt_type = ContentRecordType::Program; + } + + if (alt_type != type) { + const auto alt_update_raw = + content_provider.GetEntryRaw(*selected_update_tid, alt_type); + if (alt_update_raw != nullptr) { + const auto new_nca = std::make_shared(alt_update_raw, base_nca); + if (new_nca->GetStatus() == Loader::ResultStatus::Success && + new_nca->GetRomFS() != nullptr) { + LOG_DEBUG(Loader, " RomFS: Update (fallback {}) applied successfully", + alt_type == ContentRecordType::Data ? "DATA" : "PROGRAM"); + romfs = new_nca->GetRomFS(); + } else { + LOG_WARNING(Loader, " RomFS: Update (fallback) NCA is not valid"); + } + } } } else if (!update_disabled && packed_update_raw != nullptr && base_nca != nullptr) { const auto new_nca = std::make_shared(packed_update_raw, base_nca); @@ -488,55 +624,92 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { std::vector out; const auto& disabled = Settings::values.disabled_addons[title_id]; - // Game Updates - const auto update_tid = GetUpdateTitleID(title_id); - PatchManager update{update_tid, fs_controller, content_provider}; - const auto metadata = update.GetControlMetadata(); - const auto& nacp = metadata.first; - - const auto update_disabled = - std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); - Patch update_patch = {.enabled = !update_disabled, - .name = "Update", - .version = "", - .type = PatchType::Update, - .program_id = title_id, - .title_id = title_id}; - - if (nacp != nullptr) { - update_patch.version = nacp->GetVersionString(); - out.push_back(update_patch); - } else { - if (content_provider.HasEntry(update_tid, ContentRecordType::Program)) { - const auto meta_ver = content_provider.GetEntryVersion(update_tid); - if (meta_ver.value_or(0) == 0) { - out.push_back(update_patch); + auto variant_tids = + EnumerateUpdateVariants(content_provider, title_id, ContentRecordType::Program); + { + auto data_tids = + EnumerateUpdateVariants(content_provider, title_id, ContentRecordType::Data); + variant_tids.insert(variant_tids.end(), data_tids.begin(), data_tids.end()); + auto control_tids = + EnumerateUpdateVariants(content_provider, title_id, ContentRecordType::Control); + variant_tids.insert(variant_tids.end(), control_tids.begin(), control_tids.end()); + std::sort(variant_tids.begin(), variant_tids.end()); + variant_tids.erase(std::unique(variant_tids.begin(), variant_tids.end()), + variant_tids.end()); + } + + if (!variant_tids.empty()) { + const auto update_disabled = + std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); + + out.push_back({.enabled = !update_disabled, + .name = "Update", + .version = "", + .type = PatchType::Update, + .program_id = title_id, + .title_id = title_id}); + + std::optional selected_variant_tid; + if (!update_disabled) { + const bool has_pref = HasVariantPreference(Settings::values.disabled_addons[title_id]); + if (has_pref) { + selected_variant_tid = ChooseUpdateVariant( + content_provider, title_id, ContentRecordType::Program, fs_controller); } else { - update_patch.version = FormatTitleVersion(*meta_ver); - out.push_back(update_patch); + selected_variant_tid = GetUpdateTitleID(title_id); } - } else if (update_raw != nullptr) { - update_patch.version = "PACKED"; - out.push_back(update_patch); + } + + std::vector> variant_labels; + variant_labels.reserve(variant_tids.size()); + + for (const auto tid : variant_tids) { + variant_labels.emplace_back(GetUpdateVersionLabel(tid, fs_controller, content_provider), + tid); + } + + std::sort(variant_labels.begin(), variant_labels.end(), + [this](auto const& a, auto const& b) { + const auto va = content_provider.GetEntryVersion(a.second).value_or(0); + const auto vb = content_provider.GetEntryVersion(b.second).value_or(0); + + if (va != vb) + return va > vb; + + const auto ca = ParseVersionComponents(a.first); + const auto cb = ParseVersionComponents(b.first); + + if (ca != cb) + return ca > cb; + + return a.first > b.first; + }); + + std::set seen_versions; + for (const auto& [label, tid] : variant_labels) { + std::string version = label; + if (!version.empty() && (version.front() == 'v' || version.front() == 'V')) { + version.erase(version.begin()); + } + if (seen_versions.find(version) != seen_versions.end()) { + continue; + } + const bool is_selected = + selected_variant_tid.has_value() && tid == *selected_variant_tid; + const bool variant_disabled = update_disabled || !is_selected; + out.push_back({.enabled = !variant_disabled, + .name = "Update", + .version = version, + .type = PatchType::Update, + .program_id = title_id, + .title_id = tid}); + seen_versions.insert(version); } } // General Mods (LayeredFS and IPS) const auto mod_dir = fs_controller.GetModificationLoadRoot(title_id); if (mod_dir != nullptr) { - for (auto const& f : mod_dir->GetFiles()) - if (auto const name = f->GetName(); name.starts_with("cheat_")) { - auto const mod_disabled = std::find(disabled.begin(), disabled.end(), name) != disabled.end(); - out.push_back({ - .enabled = !mod_disabled, - .name = name, - .version = "Cheats", - .type = PatchType::Mod, - .program_id = title_id, - .title_id = title_id - }); - } - for (const auto& mod : mod_dir->GetSubdirectories()) { std::string types; @@ -573,7 +746,8 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { if (types.empty()) continue; - const auto mod_disabled = std::find(disabled.begin(), disabled.end(), mod->GetName()) != disabled.end(); + 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, diff --git a/src/core/file_sys/registered_cache.h b/src/core/file_sys/registered_cache.h index a7fc556737..3d40e8353e 100644 --- a/src/core/file_sys/registered_cache.h +++ b/src/core/file_sys/registered_cache.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -208,6 +211,7 @@ enum class ContentProviderUnionSlot { UserNAND, ///< User NAND SDMC, ///< SD Card FrontendManual, ///< Frontend-defined game list or similar + External ///< External Updates/DLCs (not installed to NAND) }; // Combines multiple ContentProvider(s) (i.e. SysNAND, UserNAND, SDMC) into one interface. diff --git a/src/core/file_sys/vfs/vfs_real.cpp b/src/core/file_sys/vfs/vfs_real.cpp index 4199667171..57cec8ca4e 100644 --- a/src/core/file_sys/vfs/vfs_real.cpp +++ b/src/core/file_sys/vfs/vfs_real.cpp @@ -211,7 +211,8 @@ std::unique_lock RealVfsFilesystem::RefreshReference(const std::stri this->EvictSingleReferenceLocked(); reference.file = - FS::FileOpen(path, ModeFlagsToFileAccessMode(perms), FS::FileType::BinaryFile); + FS::FileOpen(path, ModeFlagsToFileAccessMode(perms), FS::FileType::BinaryFile, + FS::FileShareFlag::ShareReadWrite); if (reference.file) { num_open_files++; } @@ -236,6 +237,19 @@ void RealVfsFilesystem::DropReference(std::unique_ptr&& reference } } +void RealVfsFilesystem::CloseReference(FileReference& reference) { + std::scoped_lock lk{list_lock}; + if (!reference.file) { + return; + } + this->RemoveReferenceFromListLocked(reference); + reference.file.reset(); + if (num_open_files > 0) { + num_open_files--; + } + this->InsertReferenceIntoListLocked(reference); +} + void RealVfsFilesystem::EvictSingleReferenceLocked() { if (num_open_files < MaxOpenFiles || open_references.empty()) { return; @@ -256,6 +270,17 @@ void RealVfsFilesystem::EvictSingleReferenceLocked() { } void RealVfsFilesystem::InsertReferenceIntoListLocked(FileReference& reference) { + // Ensure the node is not already linked to any list before inserting. + if (reference.IsLinked()) { + // Unlink from the list it currently belongs to. + if (reference.file) { + open_references.erase(open_references.iterator_to(reference)); + } + + if (reference.IsLinked()) { + closed_references.erase(closed_references.iterator_to(reference)); + } + } if (reference.file) { open_references.push_front(reference); } else { @@ -264,9 +289,17 @@ void RealVfsFilesystem::InsertReferenceIntoListLocked(FileReference& reference) } void RealVfsFilesystem::RemoveReferenceFromListLocked(FileReference& reference) { + // Unlink from whichever list the node currently belongs to, if any. + if (!reference.IsLinked()) { + return; + } + + // Erase from the correct list to avoid cross-list corruption. if (reference.file) { open_references.erase(open_references.iterator_to(reference)); - } else { + } + + if(reference.IsLinked()) { closed_references.erase(closed_references.iterator_to(reference)); } } @@ -296,7 +329,8 @@ std::size_t RealVfsFile::GetSize() const { return *size; } auto lk = base.RefreshReference(path, perms, *reference); - return reference->file ? reference->file->GetSize() : 0; + const auto result = reference->file ? reference->file->GetSize() : 0; + return result; } bool RealVfsFile::Resize(std::size_t new_size) { @@ -318,6 +352,13 @@ bool RealVfsFile::IsReadable() const { } std::size_t RealVfsFile::Read(u8* data, std::size_t length, std::size_t offset) const { + if (length != 0 && data == nullptr) { + LOG_ERROR(Common_Filesystem, + "RealVfsFile::Read called with null buffer (len={}, off={}, path={})", + length, offset, path); + return 0; + } + auto lk = base.RefreshReference(path, perms, *reference); if (!reference->file || !reference->file->Seek(static_cast(offset))) { return 0; @@ -325,6 +366,7 @@ std::size_t RealVfsFile::Read(u8* data, std::size_t length, std::size_t offset) return reference->file->ReadSpan(std::span{data, length}); } + std::size_t RealVfsFile::Write(const u8* data, std::size_t length, std::size_t offset) { size.reset(); auto lk = base.RefreshReference(path, perms, *reference); @@ -334,6 +376,7 @@ std::size_t RealVfsFile::Write(const u8* data, std::size_t length, std::size_t o return reference->file->WriteSpan(std::span{data, length}); } + bool RealVfsFile::Rename(std::string_view name) { return base.MoveFile(path, parent_path + '/' + std::string(name)) != nullptr; } diff --git a/src/core/file_sys/vfs/vfs_real.h b/src/core/file_sys/vfs/vfs_real.h index a773fc375a..9b9638519a 100644 --- a/src/core/file_sys/vfs/vfs_real.h +++ b/src/core/file_sys/vfs/vfs_real.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -58,6 +61,7 @@ private: std::unique_lock RefreshReference(const std::string& path, OpenMode perms, FileReference& reference); void DropReference(std::unique_ptr&& reference); + void CloseReference(FileReference& reference); private: friend class RealVfsDirectory; diff --git a/src/core/hle/service/filesystem/filesystem.cpp b/src/core/hle/service/filesystem/filesystem.cpp index 95a32c1250..05445cf13a 100644 --- a/src/core/hle/service/filesystem/filesystem.cpp +++ b/src/core/hle/service/filesystem/filesystem.cpp @@ -23,6 +23,8 @@ #include "core/file_sys/vfs/vfs.h" #include "core/file_sys/vfs/vfs_offset.h" #include "core/hle/service/filesystem/filesystem.h" + +#include "core/file_sys/external_content_index.h" #include "core/hle/service/filesystem/fsp/fsp_ldr.h" #include "core/hle/service/filesystem/fsp/fsp_pr.h" #include "core/hle/service/filesystem/fsp/fsp_srv.h" @@ -716,6 +718,36 @@ void FileSystemController::CreateFactories(FileSys::VfsFilesystem& vfs, bool ove system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::SDMC, sdmc_factory->GetSDMCContents()); } + + if (external_provider == nullptr) { + external_provider = std::make_unique(); + system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::External, + external_provider.get()); + } + + RebuildExternalContentIndex(); +} + +void FileSystemController::RebuildExternalContentIndex() { + if (external_provider == nullptr) { + LOG_WARNING(Service_FS, "External provider not initialized, skipping re-index."); + return; + } + + if (!Settings::values.external_dirs.empty()) { + FileSys::ExternalContentPaths paths{}; + for (const auto& dir : Settings::values.external_dirs) { + if (dir.empty()) + continue; + paths.update_dirs.push_back(dir); + paths.dlc_dirs.push_back(dir); + } + FileSys::ExternalContentIndexer indexer{system.GetFilesystem(), *this->external_provider, + std::move(paths)}; + indexer.Rebuild(); + } else { + external_provider->ClearAllEntries(); + } } void FileSystemController::Reset() { diff --git a/src/core/hle/service/filesystem/filesystem.h b/src/core/hle/service/filesystem/filesystem.h index 718500385b..df1b7fc8a4 100644 --- a/src/core/hle/service/filesystem/filesystem.h +++ b/src/core/hle/service/filesystem/filesystem.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -8,6 +11,7 @@ #include "common/common_types.h" #include "core/file_sys/fs_directory.h" #include "core/file_sys/fs_filesystem.h" +#include #include "core/file_sys/vfs/vfs.h" #include "core/hle/result.h" @@ -120,6 +124,7 @@ public: // Creates the SaveData, SDMC, and BIS Factories. Should be called once and before any function // above is called. void CreateFactories(FileSys::VfsFilesystem& vfs, bool overwrite = true); + void RebuildExternalContentIndex(); void Reset(); @@ -141,6 +146,7 @@ private: std::unique_ptr gamecard; std::unique_ptr gamecard_registered; std::unique_ptr gamecard_placeholder; + std::unique_ptr external_provider; Core::System& system; }; diff --git a/src/frontend_common/config.cpp b/src/frontend_common/config.cpp index fdb7ac0833..f69e763b5e 100644 --- a/src/frontend_common/config.cpp +++ b/src/frontend_common/config.cpp @@ -299,6 +299,19 @@ void Config::ReadDataStorageValues() { ReadCategory(Settings::Category::DataStorage); + Settings::values.external_dirs.clear(); + const int num_dirs = BeginArray(std::string("external_dirs")); + Settings::values.external_dirs.reserve(num_dirs); + for (int i = 0; i < num_dirs; ++i) { + SetArrayIndex(i); + std::string dir = ReadStringSetting(std::string("path"), std::string("")); + if (!dir.empty()) { + Settings::values.external_dirs.emplace_back(std::move(dir)); + } + } + + EndArray(); + EndGroup(); } @@ -603,6 +616,14 @@ void Config::SaveDataStorageValues() { WriteCategory(Settings::Category::DataStorage); + BeginArray(std::string("external_dirs")); + for (std::size_t i = 0; i < Settings::values.external_dirs.size(); ++i) { + SetArrayIndex(static_cast(i)); + WriteStringSetting(std::string("path"), Settings::values.external_dirs[i], + std::make_optional(std::string(""))); + } + EndArray(); + EndGroup(); } diff --git a/src/yuzu/configuration/configure_general.cpp b/src/yuzu/configuration/configure_general.cpp index b2fe566a17..3e7c55e500 100644 --- a/src/yuzu/configuration/configure_general.cpp +++ b/src/yuzu/configuration/configure_general.cpp @@ -7,14 +7,18 @@ #include #include #include +#include +#include #include #include "common/settings.h" #include "core/core.h" +#include "core/hle/service/filesystem/filesystem.h" #include "ui_configure_general.h" #include "yuzu/configuration/configuration_shared.h" #include "yuzu/configuration/configure_general.h" #include "yuzu/configuration/shared_widget.h" #include "qt_common/config/uisettings.h" +#include "qt_common/util/game.h" ConfigureGeneral::ConfigureGeneral(const Core::System& system_, std::shared_ptr> group_, @@ -22,6 +26,20 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_, : Tab(group_, parent), ui{std::make_unique()}, system{system_} { ui->setupUi(this); + apply_funcs.push_back([this](bool) { + Settings::values.external_dirs.clear(); + for (int i = 0; i < ui->external_dirs_list->count(); ++i) { + QListWidgetItem* item = ui->external_dirs_list->item(i); + if (item) { + Settings::values.external_dirs.push_back(item->text().toStdString()); + } + } + auto& fs_controller = const_cast(system).GetFileSystemController(); + fs_controller.RebuildExternalContentIndex(); + QtCommon::Game::ResetMetadata(false); + UISettings::values.is_game_list_reload_pending.exchange(true); + }); + Setup(builder); SetConfiguration(); @@ -29,14 +47,25 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_, connect(ui->button_reset_defaults, &QPushButton::clicked, this, &ConfigureGeneral::ResetDefaults); + connect(ui->add_dir_button, &QPushButton::clicked, this, &ConfigureGeneral::OnAddDirClicked); + connect(ui->remove_dir_button, &QPushButton::clicked, this, + &ConfigureGeneral::OnRemoveDirClicked); + connect(ui->external_dirs_list, &QListWidget::itemSelectionChanged, this, + &ConfigureGeneral::OnDirSelectionChanged); + + ui->remove_dir_button->setEnabled(false); + if (!Settings::IsConfiguringGlobal()) { ui->button_reset_defaults->setVisible(false); + ui->DataDirsGroupBox->setVisible(false); } } ConfigureGeneral::~ConfigureGeneral() = default; -void ConfigureGeneral::SetConfiguration() {} +void ConfigureGeneral::SetConfiguration() { + LoadExternalDirs(); +} void ConfigureGeneral::Setup(const ConfigurationShared::Builder& builder) { QLayout& general_layout = *ui->general_widget->layout(); @@ -94,6 +123,7 @@ void ConfigureGeneral::ResetDefaults() { UISettings::values.reset_to_defaults = true; UISettings::values.is_game_list_reload_pending.exchange(true); reset_callback(); + SetConfiguration(); } void ConfigureGeneral::ApplyConfiguration() { @@ -114,3 +144,37 @@ void ConfigureGeneral::changeEvent(QEvent* event) { void ConfigureGeneral::RetranslateUI() { ui->retranslateUi(this); } + +void ConfigureGeneral::LoadExternalDirs() { + ui->external_dirs_list->clear(); + for (const auto& dir : Settings::values.external_dirs) { + ui->external_dirs_list->addItem(QString::fromStdString(dir)); + } +} + +void ConfigureGeneral::OnAddDirClicked() { + QString default_path = QDir::homePath(); + if (ui->external_dirs_list->count() > 0) { + default_path = ui->external_dirs_list->item(ui->external_dirs_list->count() - 1)->text(); + } + + QString dir = QFileDialog::getExistingDirectory(this, tr("Select Directory"), default_path); + if (!dir.isEmpty()) { + if (ui->external_dirs_list->findItems(dir, Qt::MatchExactly).isEmpty()) { + ui->external_dirs_list->addItem(dir); + } else { + QMessageBox::warning(this, tr("Directory already added"), + tr("The directory \"%1\" is already in the list.").arg(dir)); + } + } +} + +void ConfigureGeneral::OnRemoveDirClicked() { + for (auto* item : ui->external_dirs_list->selectedItems()) { + delete ui->external_dirs_list->takeItem(ui->external_dirs_list->row(item)); + } +} + +void ConfigureGeneral::OnDirSelectionChanged() { + ui->remove_dir_button->setEnabled(!ui->external_dirs_list->selectedItems().isEmpty()); +} diff --git a/src/yuzu/configuration/configure_general.h b/src/yuzu/configuration/configure_general.h index ada6526a6a..1bdcbb58e5 100644 --- a/src/yuzu/configuration/configure_general.h +++ b/src/yuzu/configuration/configure_general.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -45,6 +48,11 @@ private: void changeEvent(QEvent* event) override; void RetranslateUI(); + void LoadExternalDirs(); + void OnAddDirClicked(); + void OnRemoveDirClicked(); + void OnDirSelectionChanged(); + std::function reset_callback; std::unique_ptr ui; diff --git a/src/yuzu/configuration/configure_general.ui b/src/yuzu/configuration/configure_general.ui index a10e7d3a50..1bb190712b 100644 --- a/src/yuzu/configuration/configure_general.ui +++ b/src/yuzu/configuration/configure_general.ui @@ -46,6 +46,86 @@ + + + + Linux + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + Additional Directories (Updates, DLC, etc.) + + + + + + This list contains directories that will be searched for game updates and DLC. + + + true + + + QAbstractItemView::ExtendedSelection + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Add + + + + + + + Remove + + + + + + + + diff --git a/src/yuzu/configuration/configure_per_game_addons.cpp b/src/yuzu/configuration/configure_per_game_addons.cpp index ee2db55a5d..aa4e233dbe 100644 --- a/src/yuzu/configuration/configure_per_game_addons.cpp +++ b/src/yuzu/configuration/configure_per_game_addons.cpp @@ -64,6 +64,9 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p ui->scrollArea->setEnabled(!system.IsPoweredOn()); + connect(item_model, &QStandardItemModel::itemChanged, this, + &ConfigurePerGameAddons::OnItemChanged); + connect(item_model, &QStandardItemModel::itemChanged, [] { UISettings::values.is_game_list_reload_pending.exchange(true); }); } @@ -71,12 +74,26 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p ConfigurePerGameAddons::~ConfigurePerGameAddons() = default; void ConfigurePerGameAddons::ApplyConfiguration() { + bool any_variant_checked = false; + for (auto* v : update_variant_items) { + if (v && v->checkState() == Qt::Checked) { + any_variant_checked = true; + break; + } + } + if (any_variant_checked && default_update_item && + default_update_item->checkState() == Qt::Unchecked) { + default_update_item->setCheckState(Qt::Checked); + } + std::vector disabled_addons; 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) { + const auto key = item.front()->data(Qt::UserRole).toString(); + disabled_addons.push_back(key.toStdString()); + } } auto current = Settings::values.disabled_addons[title_id]; @@ -111,11 +128,18 @@ void ConfigurePerGameAddons::RetranslateUI() { ui->retranslateUi(this); } + void ConfigurePerGameAddons::LoadConfiguration() { if (file == nullptr) { return; } + // Reset model and caches to avoid duplicates + item_model->removeRows(0, item_model->rowCount()); + list_items.clear(); + default_update_item = nullptr; + update_variant_items.clear(); + const FileSys::PatchManager pm{title_id, system.GetFileSystemController(), system.GetContentProvider()}; const auto loader = Loader::GetLoader(system, file); @@ -126,21 +150,86 @@ void ConfigurePerGameAddons::LoadConfiguration() { const auto& disabled = Settings::values.disabled_addons[title_id]; for (const auto& patch : pm.GetPatches(update_raw)) { - const auto name = QString::fromStdString(patch.name); + const auto display_name = QString::fromStdString(patch.name); + const auto version_q = QString::fromStdString(patch.version); + + QString toggle_key = display_name; + const bool is_update = (patch.type == FileSys::PatchType::Update); + const bool is_default_update_row = is_update && patch.version.empty(); + if (is_update) { + if (is_default_update_row) { + toggle_key = QStringLiteral("Update"); + } else if (!patch.version.empty() && patch.version != "PACKED") { + toggle_key = QStringLiteral("Update v%1").arg(version_q); + } else { + toggle_key = QStringLiteral("Update"); + } + } auto* const first_item = new QStandardItem; - first_item->setText(name); + first_item->setText(display_name); first_item->setCheckable(true); + first_item->setData(toggle_key, Qt::UserRole); - const auto patch_disabled = - std::find(disabled.begin(), disabled.end(), name.toStdString()) != disabled.end(); + const bool disabled_match_key = + std::find(disabled.begin(), disabled.end(), toggle_key.toStdString()) != disabled.end(); + const bool disabled_all_updates = + is_update && + std::find(disabled.begin(), disabled.end(), std::string("Update")) != disabled.end(); + const bool patch_disabled = + disabled_match_key || (is_update && !is_default_update_row && disabled_all_updates); 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()); + auto* const second_item = new QStandardItem{version_q}; + + if (is_default_update_row) { + QList row{first_item, second_item}; + item_model->appendRow(row); + list_items.push_back(row); + default_update_item = first_item; + tree_view->expand(first_item->index()); + } else if (is_update && default_update_item != nullptr) { + QList row{first_item, second_item}; + default_update_item->appendRow(row); + list_items.push_back(row); + update_variant_items.push_back(first_item); + } else { + QList row{first_item, second_item}; + item_model->appendRow(row); + list_items.push_back(row); + } } + tree_view->expandAll(); tree_view->resizeColumnToContents(1); } + +void ConfigurePerGameAddons::OnItemChanged(QStandardItem* item) { + if (!item) + return; + const auto key = item->data(Qt::UserRole).toString(); + const bool is_update_row = key.startsWith(QStringLiteral("Update")); + if (!is_update_row) + return; + + if (item == default_update_item) { + if (default_update_item->checkState() == Qt::Unchecked) { + for (auto* v : update_variant_items) { + if (v && v->checkState() != Qt::Unchecked) + v->setCheckState(Qt::Unchecked); + } + } + return; + } + + if (item->checkState() == Qt::Checked) { + for (auto* v : update_variant_items) { + if (v && v != item && v->checkState() != Qt::Unchecked) + v->setCheckState(Qt::Unchecked); + } + if (default_update_item && default_update_item->checkState() == Qt::Unchecked) { + default_update_item->setCheckState(Qt::Checked); + } + } +} diff --git a/src/yuzu/configuration/configure_per_game_addons.h b/src/yuzu/configuration/configure_per_game_addons.h index 32dc5dde62..ed95db844d 100644 --- a/src/yuzu/configuration/configure_per_game_addons.h +++ b/src/yuzu/configuration/configure_per_game_addons.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -11,7 +14,7 @@ #include "core/file_sys/vfs/vfs_types.h" namespace Core { -class System; + class System; } class QGraphicsScene; @@ -21,7 +24,7 @@ class QTreeView; class QVBoxLayout; namespace Ui { -class ConfigurePerGameAddons; + class ConfigurePerGameAddons; } class ConfigurePerGameAddons : public QWidget { @@ -44,6 +47,8 @@ private: void LoadConfiguration(); + void OnItemChanged(QStandardItem* item); + std::unique_ptr ui; FileSys::VirtualFile file; u64 title_id; @@ -54,5 +59,8 @@ private: std::vector> list_items; + QStandardItem* default_update_item = nullptr; + std::vector update_variant_items; + Core::System& system; }; diff --git a/src/yuzu/game_list.cpp b/src/yuzu/game_list.cpp index 0c59f4cc5e..40f29fddac 100644 --- a/src/yuzu/game_list.cpp +++ b/src/yuzu/game_list.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "yuzu/game_list.h" +#include #include #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include "common/common_types.h" #include "common/logging/log.h" #include "core/core.h" @@ -25,8 +27,6 @@ #include "yuzu/game_list_worker.h" #include "yuzu/main_window.h" #include "yuzu/util/controller_navigation.h" -#include -#include GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent) : QObject(parent), gamelist{gamelist_} {} @@ -318,7 +318,8 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid : QWidget{parent}, vfs{std::move(vfs_)}, provider{provider_}, play_time_manager{play_time_manager_}, system{system_} { watcher = new QFileSystemWatcher(this); - connect(watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshGameDirectory); + connect(watcher, &QFileSystemWatcher::directoryChanged, this, + &GameList::OnWatchedDirectoryChanged); this->main_window = parent; layout = new QVBoxLayout; @@ -486,14 +487,21 @@ void GameList::DonePopulating(const QStringList& watch_list) { if (!watch_dirs.isEmpty()) { watcher->removePaths(watch_dirs); } + + QStringList all_watch_paths = watch_list; + for (const auto& dir : Settings::values.external_dirs) { + all_watch_paths.append(QString::fromStdString(dir)); + } + all_watch_paths.removeDuplicates(); + // Workaround: Add the watch paths in chunks to allow the gui to refresh // This prevents the UI from stalling when a large number of watch paths are added // Also artificially caps the watcher to a certain number of directories constexpr int LIMIT_WATCH_DIRECTORIES = 5000; constexpr int SLICE_SIZE = 25; - int len = (std::min)(static_cast(watch_list.size()), LIMIT_WATCH_DIRECTORIES); + int len = (std::min)(static_cast(all_watch_paths.size()), LIMIT_WATCH_DIRECTORIES); for (int i = 0; i < len; i += SLICE_SIZE) { - watcher->addPaths(watch_list.mid(i, i + SLICE_SIZE)); + watcher->addPaths(all_watch_paths.mid(i, i + SLICE_SIZE)); QCoreApplication::processEvents(); } tree_view->setEnabled(true); @@ -967,6 +975,17 @@ GameListPlaceholder::GameListPlaceholder(MainWindow* parent) : QWidget{parent} { GameListPlaceholder::~GameListPlaceholder() = default; +void GameList::OnWatchedDirectoryChanged(const QString& path) { + LOG_INFO(Frontend, "Change detected in watched directory {}. Reloading content.", + path.toStdString()); + + system.GetFileSystemController().RebuildExternalContentIndex(); + + QtCommon::Game::ResetMetadata(false); + + RefreshGameDirectory(); +} + void GameListPlaceholder::onUpdateThemedIcons() { image->setPixmap(QIcon::fromTheme(QStringLiteral("plus_folder")).pixmap(200)); } diff --git a/src/yuzu/game_list.h b/src/yuzu/game_list.h index 1115eadaca..74a14eb6ab 100644 --- a/src/yuzu/game_list.h +++ b/src/yuzu/game_list.h @@ -80,6 +80,7 @@ public: void LoadCompatibilityList(); void PopulateAsync(QVector& game_dirs); + void OnWatchedDirectoryChanged(const QString& path); void SaveInterfaceLayout(); void LoadInterfaceLayout(); diff --git a/src/yuzu/game_list_worker.cpp b/src/yuzu/game_list_worker.cpp index 4542b63100..f4c7877062 100644 --- a/src/yuzu/game_list_worker.cpp +++ b/src/yuzu/game_list_worker.cpp @@ -173,6 +173,10 @@ QString FormatPatchNameVersions(const FileSys::PatchManager& patch_manager, continue; } + if (is_update && patch.version.empty()) { + continue; + } + const QString type = QString::fromStdString(patch.enabled ? patch.name : "[D] " + patch.name);