From 809a1b4d06036d614771f88435d9c3e530d6ceae Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 28 Oct 2025 19:55:42 +0100 Subject: [PATCH] add all versions to config --- src/core/file_sys/external_content_index.cpp | 138 ++++++++---- src/core/file_sys/external_content_index.h | 2 +- src/core/file_sys/patch_manager.cpp | 210 +++++++++++++++--- .../configure_per_game_addons.cpp | 107 ++++++++- .../configuration/configure_per_game_addons.h | 8 + 5 files changed, 370 insertions(+), 95 deletions(-) diff --git a/src/core/file_sys/external_content_index.cpp b/src/core/file_sys/external_content_index.cpp index 18e3706b59..16461f86b0 100644 --- a/src/core/file_sys/external_content_index.cpp +++ b/src/core/file_sys/external_content_index.cpp @@ -4,20 +4,20 @@ #include "core/file_sys/external_content_index.h" #include +#include #include #include -#include +#include "common/fs/fs_util.h" #include "common/hex_util.h" #include "common/logging/log.h" -#include "common/fs/fs_util.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/romfs.h" #include "core/file_sys/registered_cache.h" -#include "core/file_sys/vfs/vfs.h" -#include "core/file_sys/content_archive.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" -#include "core/file_sys/common_funcs.h" namespace fs = std::filesystem; @@ -30,7 +30,7 @@ ExternalContentIndexer::ExternalContentIndexer(VirtualFilesystem vfs, void ExternalContentIndexer::Rebuild() { m_provider.ClearAllEntries(); - m_best_update_by_title.clear(); + m_updates_by_title.clear(); m_all_dlc.clear(); for (const auto& dir : m_paths.update_dirs) { @@ -46,7 +46,8 @@ void ExternalContentIndexer::Rebuild() { 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)); }); + std::transform(s.begin(), s.end(), out.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); return out; } @@ -58,8 +59,10 @@ void ExternalContentIndexer::IndexUpdatesDir(const std::string& dir) { 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; + 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); @@ -79,8 +82,10 @@ void ExternalContentIndexer::IndexDlcDir(const std::string& dir) { 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; + 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); @@ -94,7 +99,7 @@ void ExternalContentIndexer::IndexDlcDir(const std::string& dir) { 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 (lower.size() >= 4 && lower.rfind(".nsp") == lower.size() - 4) { if (auto vf = m_vfs->OpenFile(path, OpenMode::Read)) { ParseContainerNSP(vf, is_update); } @@ -104,23 +109,29 @@ void ExternalContentIndexer::TryIndexFileAsContainer(const std::string& path, bo 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; + 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; + 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); + ParseLooseCnmtNca( + vf, Common::FS::ToUTF8String(entry.path().parent_path().u8string()), is_update); } } } } void ExternalContentIndexer::ParseContainerNSP(VirtualFile file, bool is_update) { - if (file == nullptr) return; + if (file == nullptr) + return; NSP nsp(file); if (nsp.GetStatus() != Loader::ResultStatus::Success) { LOG_WARNING(Loader, "ExternalContent: NSP parse failed"); @@ -128,7 +139,8 @@ void ExternalContentIndexer::ParseContainerNSP(VirtualFile file, bool is_update) } const auto title_map = nsp.GetNCAs(); - if (title_map.empty()) return; + if (title_map.empty()) + return; for (const auto& [title_id, nca_map] : title_map) { std::shared_ptr meta_nca; @@ -159,24 +171,23 @@ void ExternalContentIndexer::ParseContainerNSP(VirtualFile file, bool is_update) candidate.ncas[rec.type] = it->second->GetBaseFile(); } } - - auto& best = m_best_update_by_title[base_id]; - if (best.title_id ==0 || candidate.version > best.version) { - best = std::move(candidate); - } + 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()}); + 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) { +void ExternalContentIndexer::ParseLooseCnmtNca(VirtualFile meta_nca_file, const std::string& folder, + bool is_update) { if (meta_nca_file == nullptr) return; @@ -186,7 +197,8 @@ void ExternalContentIndexer::ParseLooseCnmtNca(VirtualFile meta_nca_file, const return; auto cnmt_opt = ExtractCnmtFromMetaNca(meta); - if (!cnmt_opt) return; + if (!cnmt_opt) + return; const auto& cnmt = *cnmt_opt; const auto base_id = BaseTitleId(cnmt.GetTitleID()); @@ -199,21 +211,21 @@ void ExternalContentIndexer::ParseLooseCnmtNca(VirtualFile meta_nca_file, const 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()); + 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& best = m_best_update_by_title[base_id]; - if (best.title_id ==0 || candidate.version > best.version) { - best = std::move(candidate); - } + 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()); + 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)); @@ -247,25 +259,55 @@ bool ExternalContentIndexer::IsMeta(const NCA& nca) { } void ExternalContentIndexer::Commit() { - // Updates: for now just register the highest version per DLC - for (auto& [base_title, upd] : m_best_update_by_title) { - for (const auto& kv : upd.ncas) { - const auto rec_type = kv.first; - const auto& file = kv.second; - if (!file) continue; - // Use the Update TitleID for provider registration so core queries by update_tid succeed - const auto update_tid = FileSys::GetUpdateTitleID(base_title); - m_provider.AddEntry(TitleType::Update, rec_type, update_tid, file); + // 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: additiv + + // DLC: additive for (const auto& dlc : m_all_dlc) { - if (!dlc.file) continue; + if (!dlc.file) + continue; m_provider.AddEntry(TitleType::AOC, ContentRecordType::Data, dlc.title_id, dlc.file); } - LOG_INFO(Loader, "ExternalContent: registered {} titles with updates, {} DLC records", - m_best_update_by_title.size(), m_all_dlc.size()); + 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} // namespace FileSys \ No newline at end of file +} // namespace FileSys \ No newline at end of file diff --git a/src/core/file_sys/external_content_index.h b/src/core/file_sys/external_content_index.h index 47994b5e38..bb3f005a67 100644 --- a/src/core/file_sys/external_content_index.h +++ b/src/core/file_sys/external_content_index.h @@ -66,7 +66,7 @@ private: ManualContentProvider& m_provider; ExternalContentPaths m_paths; - std::unordered_map m_best_update_by_title; + std::unordered_map> m_updates_by_title; std::vector m_all_dlc; }; diff --git a/src/core/file_sys/patch_manager.cpp b/src/core/file_sys/patch_manager.cpp index a28fdf9056..c1a0f23131 100644 --- a/src/core/file_sys/patch_manager.cpp +++ b/src/core/file_sys/patch_manager.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include "common/hex_util.h" #include "common/logging/log.h" @@ -117,6 +118,83 @@ void AppendCommaIfNotEmpty(std::string& to, std::string_view with) { bool IsDirValidAndNonEmpty(const VirtualDir& dir) { return dir != nullptr && (!dir->GetFiles().empty() || !dir->GetSubdirectories().empty()); } + +static 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; +} + +static 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); +} + +static 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; +} + +static 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 +219,22 @@ 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: Update applied successfully"); + exefs = update->GetExeFS(); + } } // LayeredExeFS @@ -316,7 +403,8 @@ bool PatchManager::HasNSOPatch(const BuildID& build_id_, std::string_view name) return !CollectPatches(patch_dirs, build_id).empty(); } -std::vector PatchManager::CreateCheatList(const BuildID& build_id_) const { +std::vector PatchManager::CreateCheatList( + const BuildID& build_id_) const { const auto load_dir = fs_controller.GetModificationLoadRoot(title_id); if (load_dir == nullptr) { LOG_ERROR(Loader, "Cannot load mods for invalid title_id={:016X}", title_id); @@ -325,16 +413,19 @@ std::vector PatchManager::CreateCheatList(const BuildI const auto& disabled = Settings::values.disabled_addons[title_id]; auto patch_dirs = load_dir->GetSubdirectories(); - std::sort(patch_dirs.begin(), patch_dirs.end(), [](auto const& l, auto const& r) { return l->GetName() < r->GetName(); }); + std::sort(patch_dirs.begin(), patch_dirs.end(), + [](auto const& l, auto const& r) { return l->GetName() < r->GetName(); }); // / / cheats / .txt std::vector out; for (const auto& subdir : patch_dirs) { if (std::find(disabled.cbegin(), disabled.cend(), subdir->GetName()) == disabled.cend()) { - if (auto cheats_dir = FindSubdirectoryCaseless(subdir, "cheats"); cheats_dir != nullptr) { + if (auto cheats_dir = FindSubdirectoryCaseless(subdir, "cheats"); + cheats_dir != nullptr) { if (auto const res = ReadCheatFileFromFolder(title_id, build_id_, cheats_dir, true)) std::copy(res->begin(), res->end(), std::back_inserter(out)); - if (auto const res = ReadCheatFileFromFolder(title_id, build_id_, cheats_dir, false)) + if (auto const res = + ReadCheatFileFromFolder(title_id, build_id_, cheats_dir, false)) std::copy(res->begin(), res->end(), std::back_inserter(out)); } } @@ -344,14 +435,17 @@ std::vector PatchManager::CreateCheatList(const BuildI auto const patch_files = load_dir->GetFiles(); for (auto const& f : patch_files) { auto const name = f->GetName(); - if (name.starts_with("cheat_") && std::find(disabled.cbegin(), disabled.cend(), name) == disabled.cend()) { + if (name.starts_with("cheat_") && + std::find(disabled.cbegin(), disabled.cend(), name) == disabled.cend()) { std::vector data(f->GetSize()); if (f->Read(data.data(), data.size()) == data.size()) { const Core::Memory::TextCheatParser parser; - auto const res = parser.Parse(std::string_view(reinterpret_cast(data.data()), data.size())); + auto const res = parser.Parse( + std::string_view(reinterpret_cast(data.data()), data.size())); std::copy(res.begin(), res.end(), std::back_inserter(out)); } else { - LOG_INFO(Common_Filesystem, "Failed to read cheats file for title_id={:016X}", title_id); + LOG_INFO(Common_Filesystem, "Failed to read cheats file for title_id={:016X}", + title_id); } } } @@ -447,8 +541,12 @@ 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 = @@ -458,11 +556,12 @@ 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 ver_num = content_provider.GetEntryVersion(*selected_update_tid).value_or(0); + LOG_INFO(Loader, " RomFS: Update ({}) applied successfully", + FormatTitleVersion(ver_num)); romfs = new_nca->GetRomFS(); - const auto version = - FormatTitleVersion(content_provider.GetEntryVersion(update_tid).value_or(0)); + } else { + LOG_WARNING(Loader, " RomFS: Update 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); @@ -470,6 +569,8 @@ VirtualFile PatchManager::PatchRomFS(const NCA* base_nca, VirtualFile base_romfs new_nca->GetRomFS() != nullptr) { LOG_INFO(Loader, " RomFS: Update (PACKED) applied successfully"); romfs = new_nca->GetRomFS(); + } else { + LOG_WARNING(Loader, " RomFS: Update (PACKED) NCA is not valid"); } } @@ -489,11 +590,10 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { std::vector out; const auto& disabled = Settings::values.disabled_addons[title_id]; - // Game Updates + // Game Updates (default latest) 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(); @@ -504,22 +604,60 @@ std::vector PatchManager::GetPatches(VirtualFile update_raw) const { .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); - } else { - update_patch.version = FormatTitleVersion(*meta_ver); - out.push_back(update_patch); - } - } else if (update_raw != nullptr) { - update_patch.version = "PACKED"; - out.push_back(update_patch); + out.push_back(update_patch); + + 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 { + selected_variant_tid = GetUpdateTitleID(title_id); + } + } + + const auto variant_tids = + EnumerateUpdateVariants(content_provider, title_id, ContentRecordType::Program); + 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; + return a.first < b.first; + }); + std::set seen_versions; + if (!out.empty() && !out.back().version.empty()) { + auto ver = out.back().version; + if (!ver.empty() && (ver.front() == 'v' || ver.front() == 'V')) { + ver.erase(ver.begin()); } + seen_versions.insert(std::move(ver)); + } + 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 auto toggle_name = fmt::format("Update {}", label); + 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) diff --git a/src/yuzu/configuration/configure_per_game_addons.cpp b/src/yuzu/configuration/configure_per_game_addons.cpp index ee2db55a5d..90bd39bda7 100644 --- a/src/yuzu/configuration/configure_per_game_addons.cpp +++ b/src/yuzu/configuration/configure_per_game_addons.cpp @@ -21,10 +21,10 @@ #include "core/file_sys/patch_manager.h" #include "core/file_sys/xts_archive.h" #include "core/loader/loader.h" +#include "qt_common/config/uisettings.h" #include "ui_configure_per_game_addons.h" #include "yuzu/configuration/configure_input.h" #include "yuzu/configuration/configure_per_game_addons.h" -#include "qt_common/config/uisettings.h" ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* parent) : QWidget(parent), ui{std::make_unique()}, system{system_} { @@ -64,6 +64,8 @@ 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 +73,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]; @@ -116,6 +132,12 @@ void ConfigurePerGameAddons::LoadConfiguration() { 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 +148,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..d266a75e5a 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 @@ -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; };