Browse Source

rework and rebase

fs_external_dlcupdates
Maufeat 5 days ago
parent
commit
62fc359189
  1. 3
      src/common/settings.h
  2. 2
      src/core/CMakeLists.txt
  3. 313
      src/core/file_sys/external_content_index.cpp
  4. 74
      src/core/file_sys/external_content_index.h
  5. 282
      src/core/file_sys/patch_manager.cpp
  6. 4
      src/core/file_sys/registered_cache.h
  7. 49
      src/core/file_sys/vfs/vfs_real.cpp
  8. 4
      src/core/file_sys/vfs/vfs_real.h
  9. 32
      src/core/hle/service/filesystem/filesystem.cpp
  10. 6
      src/core/hle/service/filesystem/filesystem.h
  11. 21
      src/frontend_common/config.cpp
  12. 66
      src/yuzu/configuration/configure_general.cpp
  13. 8
      src/yuzu/configuration/configure_general.h
  14. 80
      src/yuzu/configuration/configure_general.ui
  15. 107
      src/yuzu/configuration/configure_per_game_addons.cpp
  16. 12
      src/yuzu/configuration/configure_per_game_addons.h
  17. 29
      src/yuzu/game_list.cpp
  18. 1
      src/yuzu/game_list.h
  19. 4
      src/yuzu/game_list_worker.cpp

3
src/common/settings.h

@ -750,6 +750,9 @@ struct Values {
// Add-Ons // Add-Ons
std::map<u64, std::vector<std::string>> disabled_addons; std::map<u64, std::vector<std::string>> disabled_addons;
// External Dirs
std::vector<std::string> external_dirs;
// Per-game overrides // Per-game overrides
bool use_squashed_iterated_blend; bool use_squashed_iterated_blend;

2
src/core/CMakeLists.txt

@ -56,6 +56,8 @@ add_library(core STATIC
file_sys/control_metadata.cpp file_sys/control_metadata.cpp
file_sys/control_metadata.h file_sys/control_metadata.h
file_sys/errors.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_directory.h
file_sys/fs_file.h file_sys/fs_file.h
file_sys/fs_filesystem.h file_sys/fs_filesystem.h

313
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 <algorithm>
#include <cctype>
#include <filesystem>
#include <string>
#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<char>(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<NCA> 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<CNMT> 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<u64>(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

74
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 <optional>
#include <string>
#include <unordered_map>
#include <vector>
#include <memory>
#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<std::string> update_dirs;
std::vector<std::string> 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<ContentRecordType, VirtualFile> 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<CNMT> 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<TitleID, std::vector<ParsedUpdate>> m_updates_by_title;
std::vector<ParsedDlcRecord> m_all_dlc;
};
} // namespace FileSys

282
src/core/file_sys/patch_manager.cpp

@ -8,6 +8,9 @@
#include <array> #include <array>
#include <cstddef> #include <cstddef>
#include <cstring> #include <cstring>
#include <set>
#include <string>
#include <vector>
#include "common/hex_util.h" #include "common/hex_util.h"
#include "common/logging/log.h" #include "common/logging/log.h"
@ -49,6 +52,30 @@ enum class TitleVersionFormat : u8 {
FourElements, ///< vX.Y.Z.W FourElements, ///< vX.Y.Z.W
}; };
std::array<int, 4> ParseVersionComponents(std::string_view label) {
std::array<int, 4> 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, std::string FormatTitleVersion(u32 version,
TitleVersionFormat format = TitleVersionFormat::ThreeElements) { TitleVersionFormat format = TitleVersionFormat::ThreeElements) {
std::array<u8, sizeof(u32)> bytes{}; std::array<u8, sizeof(u32)> bytes{};
@ -117,6 +144,82 @@ void AppendCommaIfNotEmpty(std::string& to, std::string_view with) {
bool IsDirValidAndNonEmpty(const VirtualDir& dir) { bool IsDirValidAndNonEmpty(const VirtualDir& dir) {
return dir != nullptr && (!dir->GetFiles().empty() || !dir->GetSubdirectories().empty()); return dir != nullptr && (!dir->GetFiles().empty() || !dir->GetSubdirectories().empty());
} }
std::vector<u64> EnumerateUpdateVariants(const ContentProvider& provider, u64 base_title_id,
ContentRecordType type) {
std::vector<u64> 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<u64> 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<std::pair<u32, u64>> 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<std::string>& disabled) {
return std::any_of(disabled.begin(), disabled.end(), [](const std::string& s) {
return s.rfind("Update v", 0) == 0;
});
}
} // Anonymous namespace } // Anonymous namespace
PatchManager::PatchManager(u64 title_id_, PatchManager::PatchManager(u64 title_id_,
@ -141,13 +244,21 @@ VirtualDir PatchManager::PatchExeFS(VirtualDir exefs) const {
std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend(); std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend();
// Game Updates // Game Updates
const auto update_tid = GetUpdateTitleID(title_id);
const auto update = content_provider.GetEntry(update_tid, ContentRecordType::Program);
std::optional<u64> 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 // LayeredExeFS
@ -446,8 +557,11 @@ VirtualFile PatchManager::PatchRomFS(const NCA* base_nca, VirtualFile base_romfs
auto romfs = base_romfs; auto romfs = base_romfs;
// Game Updates // Game Updates
const auto update_tid = GetUpdateTitleID(title_id);
const auto update_raw = content_provider.GetEntryRaw(update_tid, type);
std::optional<u64> 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& disabled = Settings::values.disabled_addons[title_id];
const auto update_disabled = 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<NCA>(update_raw, base_nca); const auto new_nca = std::make_shared<NCA>(update_raw, base_nca);
if (new_nca->GetStatus() == Loader::ResultStatus::Success && if (new_nca->GetStatus() == Loader::ResultStatus::Success &&
new_nca->GetRomFS() != nullptr) { 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(); 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<NCA>(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) { } else if (!update_disabled && packed_update_raw != nullptr && base_nca != nullptr) {
const auto new_nca = std::make_shared<NCA>(packed_update_raw, base_nca); const auto new_nca = std::make_shared<NCA>(packed_update_raw, base_nca);
@ -488,55 +624,92 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
std::vector<Patch> out; std::vector<Patch> out;
const auto& disabled = Settings::values.disabled_addons[title_id]; 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<u64> 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 { } 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<std::pair<std::string, u64>> 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<std::string> 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) // General Mods (LayeredFS and IPS)
const auto mod_dir = fs_controller.GetModificationLoadRoot(title_id); const auto mod_dir = fs_controller.GetModificationLoadRoot(title_id);
if (mod_dir != nullptr) { 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()) { for (const auto& mod : mod_dir->GetSubdirectories()) {
std::string types; std::string types;
@ -573,7 +746,8 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
if (types.empty()) if (types.empty())
continue; 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, out.push_back({.enabled = !mod_disabled,
.name = mod->GetName(), .name = mod->GetName(),
.version = types, .version = types,

4
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-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -208,6 +211,7 @@ enum class ContentProviderUnionSlot {
UserNAND, ///< User NAND UserNAND, ///< User NAND
SDMC, ///< SD Card SDMC, ///< SD Card
FrontendManual, ///< Frontend-defined game list or similar 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. // Combines multiple ContentProvider(s) (i.e. SysNAND, UserNAND, SDMC) into one interface.

49
src/core/file_sys/vfs/vfs_real.cpp

@ -211,7 +211,8 @@ std::unique_lock<std::mutex> RealVfsFilesystem::RefreshReference(const std::stri
this->EvictSingleReferenceLocked(); this->EvictSingleReferenceLocked();
reference.file = reference.file =
FS::FileOpen(path, ModeFlagsToFileAccessMode(perms), FS::FileType::BinaryFile);
FS::FileOpen(path, ModeFlagsToFileAccessMode(perms), FS::FileType::BinaryFile,
FS::FileShareFlag::ShareReadWrite);
if (reference.file) { if (reference.file) {
num_open_files++; num_open_files++;
} }
@ -236,6 +237,19 @@ void RealVfsFilesystem::DropReference(std::unique_ptr<FileReference>&& 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() { void RealVfsFilesystem::EvictSingleReferenceLocked() {
if (num_open_files < MaxOpenFiles || open_references.empty()) { if (num_open_files < MaxOpenFiles || open_references.empty()) {
return; return;
@ -256,6 +270,17 @@ void RealVfsFilesystem::EvictSingleReferenceLocked() {
} }
void RealVfsFilesystem::InsertReferenceIntoListLocked(FileReference& reference) { 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) { if (reference.file) {
open_references.push_front(reference); open_references.push_front(reference);
} else { } else {
@ -264,9 +289,17 @@ void RealVfsFilesystem::InsertReferenceIntoListLocked(FileReference& reference)
} }
void RealVfsFilesystem::RemoveReferenceFromListLocked(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) { if (reference.file) {
open_references.erase(open_references.iterator_to(reference)); open_references.erase(open_references.iterator_to(reference));
} else {
}
if(reference.IsLinked()) {
closed_references.erase(closed_references.iterator_to(reference)); closed_references.erase(closed_references.iterator_to(reference));
} }
} }
@ -296,7 +329,8 @@ std::size_t RealVfsFile::GetSize() const {
return *size; return *size;
} }
auto lk = base.RefreshReference(path, perms, *reference); 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) { 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 { 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); auto lk = base.RefreshReference(path, perms, *reference);
if (!reference->file || !reference->file->Seek(static_cast<s64>(offset))) { if (!reference->file || !reference->file->Seek(static_cast<s64>(offset))) {
return 0; 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}); return reference->file->ReadSpan(std::span{data, length});
} }
std::size_t RealVfsFile::Write(const u8* data, std::size_t length, std::size_t offset) { std::size_t RealVfsFile::Write(const u8* data, std::size_t length, std::size_t offset) {
size.reset(); size.reset();
auto lk = base.RefreshReference(path, perms, *reference); 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}); return reference->file->WriteSpan(std::span{data, length});
} }
bool RealVfsFile::Rename(std::string_view name) { bool RealVfsFile::Rename(std::string_view name) {
return base.MoveFile(path, parent_path + '/' + std::string(name)) != nullptr; return base.MoveFile(path, parent_path + '/' + std::string(name)) != nullptr;
} }

4
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-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -58,6 +61,7 @@ private:
std::unique_lock<std::mutex> RefreshReference(const std::string& path, OpenMode perms, std::unique_lock<std::mutex> RefreshReference(const std::string& path, OpenMode perms,
FileReference& reference); FileReference& reference);
void DropReference(std::unique_ptr<FileReference>&& reference); void DropReference(std::unique_ptr<FileReference>&& reference);
void CloseReference(FileReference& reference);
private: private:
friend class RealVfsDirectory; friend class RealVfsDirectory;

32
src/core/hle/service/filesystem/filesystem.cpp

@ -23,6 +23,8 @@
#include "core/file_sys/vfs/vfs.h" #include "core/file_sys/vfs/vfs.h"
#include "core/file_sys/vfs/vfs_offset.h" #include "core/file_sys/vfs/vfs_offset.h"
#include "core/hle/service/filesystem/filesystem.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_ldr.h"
#include "core/hle/service/filesystem/fsp/fsp_pr.h" #include "core/hle/service/filesystem/fsp/fsp_pr.h"
#include "core/hle/service/filesystem/fsp/fsp_srv.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, system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::SDMC,
sdmc_factory->GetSDMCContents()); sdmc_factory->GetSDMCContents());
} }
if (external_provider == nullptr) {
external_provider = std::make_unique<FileSys::ManualContentProvider>();
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() { void FileSystemController::Reset() {

6
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-FileCopyrightText: Copyright 2018 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -8,6 +11,7 @@
#include "common/common_types.h" #include "common/common_types.h"
#include "core/file_sys/fs_directory.h" #include "core/file_sys/fs_directory.h"
#include "core/file_sys/fs_filesystem.h" #include "core/file_sys/fs_filesystem.h"
#include <core/file_sys/registered_cache.h>
#include "core/file_sys/vfs/vfs.h" #include "core/file_sys/vfs/vfs.h"
#include "core/hle/result.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 // Creates the SaveData, SDMC, and BIS Factories. Should be called once and before any function
// above is called. // above is called.
void CreateFactories(FileSys::VfsFilesystem& vfs, bool overwrite = true); void CreateFactories(FileSys::VfsFilesystem& vfs, bool overwrite = true);
void RebuildExternalContentIndex();
void Reset(); void Reset();
@ -141,6 +146,7 @@ private:
std::unique_ptr<FileSys::XCI> gamecard; std::unique_ptr<FileSys::XCI> gamecard;
std::unique_ptr<FileSys::RegisteredCache> gamecard_registered; std::unique_ptr<FileSys::RegisteredCache> gamecard_registered;
std::unique_ptr<FileSys::PlaceholderCache> gamecard_placeholder; std::unique_ptr<FileSys::PlaceholderCache> gamecard_placeholder;
std::unique_ptr<FileSys::ManualContentProvider> external_provider;
Core::System& system; Core::System& system;
}; };

21
src/frontend_common/config.cpp

@ -299,6 +299,19 @@ void Config::ReadDataStorageValues() {
ReadCategory(Settings::Category::DataStorage); 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(); EndGroup();
} }
@ -603,6 +616,14 @@ void Config::SaveDataStorageValues() {
WriteCategory(Settings::Category::DataStorage); 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<int>(i));
WriteStringSetting(std::string("path"), Settings::values.external_dirs[i],
std::make_optional(std::string("")));
}
EndArray();
EndGroup(); EndGroup();
} }

66
src/yuzu/configuration/configure_general.cpp

@ -7,14 +7,18 @@
#include <functional> #include <functional>
#include <utility> #include <utility>
#include <vector> #include <vector>
#include <QFileDialog>
#include <QListWidgetItem>
#include <QMessageBox> #include <QMessageBox>
#include "common/settings.h" #include "common/settings.h"
#include "core/core.h" #include "core/core.h"
#include "core/hle/service/filesystem/filesystem.h"
#include "ui_configure_general.h" #include "ui_configure_general.h"
#include "yuzu/configuration/configuration_shared.h" #include "yuzu/configuration/configuration_shared.h"
#include "yuzu/configuration/configure_general.h" #include "yuzu/configuration/configure_general.h"
#include "yuzu/configuration/shared_widget.h" #include "yuzu/configuration/shared_widget.h"
#include "qt_common/config/uisettings.h" #include "qt_common/config/uisettings.h"
#include "qt_common/util/game.h"
ConfigureGeneral::ConfigureGeneral(const Core::System& system_, ConfigureGeneral::ConfigureGeneral(const Core::System& system_,
std::shared_ptr<std::vector<ConfigurationShared::Tab*>> group_, std::shared_ptr<std::vector<ConfigurationShared::Tab*>> group_,
@ -22,6 +26,20 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_,
: Tab(group_, parent), ui{std::make_unique<Ui::ConfigureGeneral>()}, system{system_} { : Tab(group_, parent), ui{std::make_unique<Ui::ConfigureGeneral>()}, system{system_} {
ui->setupUi(this); 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<Core::System&>(system).GetFileSystemController();
fs_controller.RebuildExternalContentIndex();
QtCommon::Game::ResetMetadata(false);
UISettings::values.is_game_list_reload_pending.exchange(true);
});
Setup(builder); Setup(builder);
SetConfiguration(); SetConfiguration();
@ -29,14 +47,25 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_,
connect(ui->button_reset_defaults, &QPushButton::clicked, this, connect(ui->button_reset_defaults, &QPushButton::clicked, this,
&ConfigureGeneral::ResetDefaults); &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()) { if (!Settings::IsConfiguringGlobal()) {
ui->button_reset_defaults->setVisible(false); ui->button_reset_defaults->setVisible(false);
ui->DataDirsGroupBox->setVisible(false);
} }
} }
ConfigureGeneral::~ConfigureGeneral() = default; ConfigureGeneral::~ConfigureGeneral() = default;
void ConfigureGeneral::SetConfiguration() {}
void ConfigureGeneral::SetConfiguration() {
LoadExternalDirs();
}
void ConfigureGeneral::Setup(const ConfigurationShared::Builder& builder) { void ConfigureGeneral::Setup(const ConfigurationShared::Builder& builder) {
QLayout& general_layout = *ui->general_widget->layout(); QLayout& general_layout = *ui->general_widget->layout();
@ -94,6 +123,7 @@ void ConfigureGeneral::ResetDefaults() {
UISettings::values.reset_to_defaults = true; UISettings::values.reset_to_defaults = true;
UISettings::values.is_game_list_reload_pending.exchange(true); UISettings::values.is_game_list_reload_pending.exchange(true);
reset_callback(); reset_callback();
SetConfiguration();
} }
void ConfigureGeneral::ApplyConfiguration() { void ConfigureGeneral::ApplyConfiguration() {
@ -114,3 +144,37 @@ void ConfigureGeneral::changeEvent(QEvent* event) {
void ConfigureGeneral::RetranslateUI() { void ConfigureGeneral::RetranslateUI() {
ui->retranslateUi(this); 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());
}

8
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-FileCopyrightText: 2016 Citra Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -45,6 +48,11 @@ private:
void changeEvent(QEvent* event) override; void changeEvent(QEvent* event) override;
void RetranslateUI(); void RetranslateUI();
void LoadExternalDirs();
void OnAddDirClicked();
void OnRemoveDirClicked();
void OnDirSelectionChanged();
std::function<void()> reset_callback; std::function<void()> reset_callback;
std::unique_ptr<Ui::ConfigureGeneral> ui; std::unique_ptr<Ui::ConfigureGeneral> ui;

80
src/yuzu/configuration/configure_general.ui

@ -46,6 +46,86 @@
</layout> </layout>
</widget> </widget>
</item> </item>
<item>
<widget class="QGroupBox" name="LinuxGroupBox">
<property name="title">
<string>Linux</string>
</property>
<layout class="QVBoxLayout" name="LinuxVerticalLayout_1">
<item>
<widget class="QWidget" name="linux_widget" native="true">
<layout class="QVBoxLayout" name="LinuxVerticalLayout_2">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="DataDirsGroupBox">
<property name="title">
<string>Additional Directories (Updates, DLC, etc.)</string>
</property>
<layout class="QVBoxLayout" name="DataDirsVerticalLayout">
<item>
<widget class="QListWidget" name="external_dirs_list">
<property name="toolTip">
<string>This list contains directories that will be searched for game updates and DLC.</string>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="DataDirsHorizontalLayout">
<item>
<spacer name="spacer_dirs_left">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="add_dir_button">
<property name="text">
<string>Add</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="remove_dir_button">
<property name="text">
<string>Remove</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item> <item>
<spacer name="verticalSpacer"> <spacer name="verticalSpacer">
<property name="orientation"> <property name="orientation">

107
src/yuzu/configuration/configure_per_game_addons.cpp

@ -64,6 +64,9 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
ui->scrollArea->setEnabled(!system.IsPoweredOn()); ui->scrollArea->setEnabled(!system.IsPoweredOn());
connect(item_model, &QStandardItemModel::itemChanged, this,
&ConfigurePerGameAddons::OnItemChanged);
connect(item_model, &QStandardItemModel::itemChanged, connect(item_model, &QStandardItemModel::itemChanged,
[] { UISettings::values.is_game_list_reload_pending.exchange(true); }); [] { UISettings::values.is_game_list_reload_pending.exchange(true); });
} }
@ -71,12 +74,26 @@ ConfigurePerGameAddons::ConfigurePerGameAddons(Core::System& system_, QWidget* p
ConfigurePerGameAddons::~ConfigurePerGameAddons() = default; ConfigurePerGameAddons::~ConfigurePerGameAddons() = default;
void ConfigurePerGameAddons::ApplyConfiguration() { 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<std::string> disabled_addons; std::vector<std::string> disabled_addons;
for (const auto& item : list_items) { for (const auto& item : list_items) {
const auto disabled = item.front()->checkState() == Qt::Unchecked; 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]; auto current = Settings::values.disabled_addons[title_id];
@ -111,11 +128,18 @@ void ConfigurePerGameAddons::RetranslateUI() {
ui->retranslateUi(this); ui->retranslateUi(this);
} }
void ConfigurePerGameAddons::LoadConfiguration() { void ConfigurePerGameAddons::LoadConfiguration() {
if (file == nullptr) { if (file == nullptr) {
return; 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(), const FileSys::PatchManager pm{title_id, system.GetFileSystemController(),
system.GetContentProvider()}; system.GetContentProvider()};
const auto loader = Loader::GetLoader(system, file); const auto loader = Loader::GetLoader(system, file);
@ -126,21 +150,86 @@ void ConfigurePerGameAddons::LoadConfiguration() {
const auto& disabled = Settings::values.disabled_addons[title_id]; const auto& disabled = Settings::values.disabled_addons[title_id];
for (const auto& patch : pm.GetPatches(update_raw)) { 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; auto* const first_item = new QStandardItem;
first_item->setText(name);
first_item->setText(display_name);
first_item->setCheckable(true); 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); first_item->setCheckState(patch_disabled ? Qt::Unchecked : Qt::Checked);
list_items.push_back(QList<QStandardItem*>{
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<QStandardItem*> 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<QStandardItem*> row{first_item, second_item};
default_update_item->appendRow(row);
list_items.push_back(row);
update_variant_items.push_back(first_item);
} else {
QList<QStandardItem*> row{first_item, second_item};
item_model->appendRow(row);
list_items.push_back(row);
}
} }
tree_view->expandAll();
tree_view->resizeColumnToContents(1); 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);
}
}
}

12
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-FileCopyrightText: 2016 Citra Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -11,7 +14,7 @@
#include "core/file_sys/vfs/vfs_types.h" #include "core/file_sys/vfs/vfs_types.h"
namespace Core { namespace Core {
class System;
class System;
} }
class QGraphicsScene; class QGraphicsScene;
@ -21,7 +24,7 @@ class QTreeView;
class QVBoxLayout; class QVBoxLayout;
namespace Ui { namespace Ui {
class ConfigurePerGameAddons;
class ConfigurePerGameAddons;
} }
class ConfigurePerGameAddons : public QWidget { class ConfigurePerGameAddons : public QWidget {
@ -44,6 +47,8 @@ private:
void LoadConfiguration(); void LoadConfiguration();
void OnItemChanged(QStandardItem* item);
std::unique_ptr<Ui::ConfigurePerGameAddons> ui; std::unique_ptr<Ui::ConfigurePerGameAddons> ui;
FileSys::VirtualFile file; FileSys::VirtualFile file;
u64 title_id; u64 title_id;
@ -54,5 +59,8 @@ private:
std::vector<QList<QStandardItem*>> list_items; std::vector<QList<QStandardItem*>> list_items;
QStandardItem* default_update_item = nullptr;
std::vector<QStandardItem*> update_variant_items;
Core::System& system; Core::System& system;
}; };

29
src/yuzu/game_list.cpp

@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
#include "yuzu/game_list.h" #include "yuzu/game_list.h"
#include <fmt/ranges.h>
#include <QApplication> #include <QApplication>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
@ -13,6 +14,7 @@
#include <QMenu> #include <QMenu>
#include <QThreadPool> #include <QThreadPool>
#include <QToolButton> #include <QToolButton>
#include <regex>
#include "common/common_types.h" #include "common/common_types.h"
#include "common/logging/log.h" #include "common/logging/log.h"
#include "core/core.h" #include "core/core.h"
@ -25,8 +27,6 @@
#include "yuzu/game_list_worker.h" #include "yuzu/game_list_worker.h"
#include "yuzu/main_window.h" #include "yuzu/main_window.h"
#include "yuzu/util/controller_navigation.h" #include "yuzu/util/controller_navigation.h"
#include <fmt/ranges.h>
#include <regex>
GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent) GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent)
: QObject(parent), gamelist{gamelist_} {} : QObject(parent), gamelist{gamelist_} {}
@ -318,7 +318,8 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid
: QWidget{parent}, vfs{std::move(vfs_)}, provider{provider_}, : QWidget{parent}, vfs{std::move(vfs_)}, provider{provider_},
play_time_manager{play_time_manager_}, system{system_} { play_time_manager{play_time_manager_}, system{system_} {
watcher = new QFileSystemWatcher(this); watcher = new QFileSystemWatcher(this);
connect(watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshGameDirectory);
connect(watcher, &QFileSystemWatcher::directoryChanged, this,
&GameList::OnWatchedDirectoryChanged);
this->main_window = parent; this->main_window = parent;
layout = new QVBoxLayout; layout = new QVBoxLayout;
@ -486,14 +487,21 @@ void GameList::DonePopulating(const QStringList& watch_list) {
if (!watch_dirs.isEmpty()) { if (!watch_dirs.isEmpty()) {
watcher->removePaths(watch_dirs); 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 // 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 // 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 // Also artificially caps the watcher to a certain number of directories
constexpr int LIMIT_WATCH_DIRECTORIES = 5000; constexpr int LIMIT_WATCH_DIRECTORIES = 5000;
constexpr int SLICE_SIZE = 25; constexpr int SLICE_SIZE = 25;
int len = (std::min)(static_cast<int>(watch_list.size()), LIMIT_WATCH_DIRECTORIES);
int len = (std::min)(static_cast<int>(all_watch_paths.size()), LIMIT_WATCH_DIRECTORIES);
for (int i = 0; i < len; i += SLICE_SIZE) { 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(); QCoreApplication::processEvents();
} }
tree_view->setEnabled(true); tree_view->setEnabled(true);
@ -967,6 +975,17 @@ GameListPlaceholder::GameListPlaceholder(MainWindow* parent) : QWidget{parent} {
GameListPlaceholder::~GameListPlaceholder() = default; 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() { void GameListPlaceholder::onUpdateThemedIcons() {
image->setPixmap(QIcon::fromTheme(QStringLiteral("plus_folder")).pixmap(200)); image->setPixmap(QIcon::fromTheme(QStringLiteral("plus_folder")).pixmap(200));
} }

1
src/yuzu/game_list.h

@ -80,6 +80,7 @@ public:
void LoadCompatibilityList(); void LoadCompatibilityList();
void PopulateAsync(QVector<UISettings::GameDir>& game_dirs); void PopulateAsync(QVector<UISettings::GameDir>& game_dirs);
void OnWatchedDirectoryChanged(const QString& path);
void SaveInterfaceLayout(); void SaveInterfaceLayout();
void LoadInterfaceLayout(); void LoadInterfaceLayout();

4
src/yuzu/game_list_worker.cpp

@ -173,6 +173,10 @@ QString FormatPatchNameVersions(const FileSys::PatchManager& patch_manager,
continue; continue;
} }
if (is_update && patch.version.empty()) {
continue;
}
const QString type = const QString type =
QString::fromStdString(patch.enabled ? patch.name : "[D] " + patch.name); QString::fromStdString(patch.enabled ? patch.name : "[D] " + patch.name);

Loading…
Cancel
Save