Compare commits

...

14 Commits

Author SHA1 Message Date
unknown
8a89cef721 Revert "test again"
This reverts commit 26ccf978de1942ea9a276cb558cd9916d4bfaeb2.
2025-11-01 18:04:41 +01:00
unknown
7b37ac2992 test again 2025-11-01 18:04:41 +01:00
unknown
aa8b4ccdbe do not move in return ups 2025-11-01 18:04:41 +01:00
unknown
365f4dfee8 mh, try to check for IsLinked to prevent crash on Linux 2025-11-01 18:04:41 +01:00
unknown
a065adbf94 fix header 2025-11-01 18:04:41 +01:00
unknown
6413de66ac fix closing games 2025-11-01 18:04:41 +01:00
unknown
336b9a806f don't hold handle all the time to the file 2025-11-01 18:04:41 +01:00
unknown
78b9b7808e try to fix access violation 2025-11-01 18:04:41 +01:00
unknown
b311c9c3f4 degrade patch logging 2025-11-01 18:04:41 +01:00
unknown
695ae6a71f fix version display + loading 2025-11-01 18:04:41 +01:00
unknown
809a1b4d06 add all versions to config 2025-11-01 18:04:41 +01:00
unknown
cd92452202 file watcher 2025-11-01 18:04:41 +01:00
unknown
6974dc2247 fix license header 2025-11-01 18:04:41 +01:00
unknown
5ba9d3fb9f [fs/core] initial external content without NAND install 2025-11-01 18:04:41 +01:00
19 changed files with 1059 additions and 92 deletions

View File

@@ -759,6 +759,7 @@ struct Values {
// Add-Ons
std::map<u64, std::vector<std::string>> disabled_addons;
std::vector<std::string> external_dirs;
};
extern Values values;

View File

@@ -54,6 +54,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

View File

@@ -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

View File

@@ -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

View File

@@ -8,6 +8,9 @@
#include <array>
#include <cstddef>
#include <cstring>
#include <set>
#include <string>
#include <vector>
#include "common/hex_util.h"
#include "common/logging/log.h"
@@ -64,6 +67,30 @@ std::string FormatTitleVersion(u32 version,
return fmt::format("v{}.{}.{}", bytes[3], bytes[2], bytes[1]);
}
static 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;
}
// Returns a directory with name matching name case-insensitive. Returns nullptr if directory
// doesn't have a directory with name.
VirtualDir FindSubdirectoryCaseless(const VirtualDir dir, std::string_view name) {
@@ -117,6 +144,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<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;
}
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<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;
}
static 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
PatchManager::PatchManager(u64 title_id_,
@@ -141,13 +245,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<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: Update applied successfully");
exefs = update->GetExeFS();
}
}
// LayeredExeFS
@@ -316,7 +429,8 @@ bool PatchManager::HasNSOPatch(const BuildID& build_id_, std::string_view name)
return !CollectPatches(patch_dirs, build_id).empty();
}
std::vector<Core::Memory::CheatEntry> PatchManager::CreateCheatList(const BuildID& build_id_) const {
std::vector<Core::Memory::CheatEntry> 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 +439,19 @@ std::vector<Core::Memory::CheatEntry> 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(); });
// <mod dir> / <folder> / cheats / <build id>.txt
std::vector<Core::Memory::CheatEntry> 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 +461,17 @@ std::vector<Core::Memory::CheatEntry> 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<u8> 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<const char*>(data.data()), data.size()));
auto const res = parser.Parse(
std::string_view(reinterpret_cast<const char*>(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 +567,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<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 update_disabled =
@@ -458,18 +582,45 @@ VirtualFile PatchManager::PatchRomFS(const NCA* base_nca, VirtualFile base_romfs
const auto new_nca = std::make_shared<NCA>(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_DEBUG(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 && 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) {
const auto new_nca = std::make_shared<NCA>(packed_update_raw, base_nca);
if (new_nca->GetStatus() == Loader::ResultStatus::Success &&
new_nca->GetRomFS() != nullptr) {
LOG_INFO(Loader, " RomFS: Update (PACKED) applied successfully");
LOG_DEBUG(Loader, " RomFS: Update (PACKED) applied successfully");
romfs = new_nca->GetRomFS();
} else {
LOG_WARNING(Loader, " RomFS: Update (PACKED) NCA is not valid");
}
}
@@ -489,36 +640,86 @@ std::vector<Patch> PatchManager::GetPatches(VirtualFile update_raw) const {
std::vector<Patch> 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;
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());
}
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 (!variant_tids.empty()) {
const auto update_disabled =
std::find(disabled.cbegin(), disabled.cend(), "Update") != disabled.cend();
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);
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 {
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);
}
}

View File

@@ -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.

View File

@@ -203,7 +203,7 @@ std::unique_lock<std::mutex> RealVfsFilesystem::RefreshReference(const std::stri
FileReference& reference) {
std::unique_lock lk{list_lock};
// Temporarily remove from list.
// Temporarily remove from list (regardless of the current list).
this->RemoveReferenceFromListLocked(reference);
// Restore file if needed.
@@ -211,7 +211,8 @@ std::unique_lock<std::mutex> 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++;
}
@@ -226,7 +227,7 @@ std::unique_lock<std::mutex> RealVfsFilesystem::RefreshReference(const std::stri
void RealVfsFilesystem::DropReference(std::unique_ptr<FileReference>&& reference) {
std::scoped_lock lk{list_lock};
// Remove from list.
// Remove from list if present.
this->RemoveReferenceFromListLocked(*reference);
// Close the file.
@@ -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() {
if (num_open_files < MaxOpenFiles || open_references.empty()) {
return;
@@ -256,6 +270,18 @@ 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 +290,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,13 +330,19 @@ 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;
lk.unlock();
base.CloseReference(*reference);
return result;
}
bool RealVfsFile::Resize(std::size_t new_size) {
size.reset();
auto lk = base.RefreshReference(path, perms, *reference);
return reference->file ? reference->file->SetSize(new_size) : false;
const bool ok = reference->file ? reference->file->SetSize(new_size) : false;
lk.unlock();
base.CloseReference(*reference);
return ok;
}
VirtualDir RealVfsFile::GetContainingDirectory() const {
@@ -318,20 +358,37 @@ bool RealVfsFile::IsReadable() const {
}
std::size_t RealVfsFile::Read(u8* data, std::size_t length, std::size_t offset) const {
auto lk = base.RefreshReference(path, perms, *reference);
if (!reference->file || !reference->file->Seek(static_cast<s64>(offset))) {
if (length != 0 && data == nullptr) {
LOG_ERROR(Common_Filesystem,
"RealVfsFile::Read called with null buffer (len={}, off={}, path={})",
length, offset, path);
return 0;
}
return reference->file->ReadSpan(std::span{data, length});
auto lk = base.RefreshReference(path, perms, *reference);
if (!reference->file || !reference->file->Seek(static_cast<s64>(offset))) {
lk.unlock();
base.CloseReference(*reference);
return 0;
}
const auto read = reference->file->ReadSpan(std::span{data, length});
lk.unlock();
base.CloseReference(*reference);
return read;
}
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);
if (!reference->file || !reference->file->Seek(static_cast<s64>(offset))) {
lk.unlock();
base.CloseReference(*reference);
return 0;
}
return reference->file->WriteSpan(std::span{data, length});
const auto written = reference->file->WriteSpan(std::span{data, length});
lk.unlock();
base.CloseReference(*reference);
return written;
}
bool RealVfsFile::Rename(std::string_view name) {

View File

@@ -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<std::mutex> RefreshReference(const std::string& path, OpenMode perms,
FileReference& reference);
void DropReference(std::unique_ptr<FileReference>&& reference);
void CloseReference(FileReference& reference);
private:
friend class RealVfsDirectory;

View File

@@ -1,7 +1,11 @@
// 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
#include <utility>
#include <filesystem>
#include "common/assert.h"
#include "common/fs/fs.h"
@@ -12,6 +16,7 @@
#include "core/file_sys/card_image.h"
#include "core/file_sys/control_metadata.h"
#include "core/file_sys/errors.h"
#include "core/file_sys/external_content_index.h"
#include "core/file_sys/patch_manager.h"
#include "core/file_sys/registered_cache.h"
#include "core/file_sys/romfs_factory.h"
@@ -713,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<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() {

View File

@@ -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
@@ -5,6 +8,7 @@
#include <memory>
#include <mutex>
#include <core/file_sys/registered_cache.h>
#include "common/common_types.h"
#include "core/file_sys/fs_directory.h"
#include "core/file_sys/fs_filesystem.h"
@@ -121,6 +125,8 @@ public:
// above is called.
void CreateFactories(FileSys::VfsFilesystem& vfs, bool overwrite = true);
void RebuildExternalContentIndex();
void Reset();
private:
@@ -141,6 +147,7 @@ private:
std::unique_ptr<FileSys::XCI> gamecard;
std::unique_ptr<FileSys::RegisteredCache> gamecard_registered;
std::unique_ptr<FileSys::PlaceholderCache> gamecard_placeholder;
std::unique_ptr<FileSys::ManualContentProvider> external_provider;
Core::System& system;
};

View File

@@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
@@ -283,6 +286,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();
}
@@ -591,6 +607,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<int>(i));
WriteStringSetting(std::string("path"), Settings::values.external_dirs[i],
std::make_optional(std::string("")));
}
EndArray();
EndGroup();
}

View File

@@ -7,14 +7,18 @@
#include <functional>
#include <utility>
#include <vector>
#include <QFileDialog>
#include <QListWidgetItem>
#include <QMessageBox>
#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<std::vector<ConfigurationShared::Tab*>> group_,
@@ -22,21 +26,45 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_,
: Tab(group_, parent), ui{std::make_unique<Ui::ConfigureGeneral>()}, 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<Core::System&>(system).GetFileSystemController();
fs_controller.RebuildExternalContentIndex();
QtCommon::Game::ResetMetadata(false);
UISettings::values.is_game_list_reload_pending.exchange(true);
});
Setup(builder);
SetConfiguration();
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();
@@ -109,6 +137,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() {
@@ -129,3 +158,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());
}

View File

@@ -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<void()> reset_callback;
std::unique_ptr<Ui::ConfigureGeneral> ui;

View File

@@ -73,6 +73,59 @@
</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>
<spacer name="verticalSpacer">
<property name="orientation">

View File

@@ -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<Ui::ConfigurePerGameAddons>()}, 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<std::string> 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<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);
}
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);
}
}
}

View File

@@ -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::ConfigurePerGameAddons> ui;
FileSys::VirtualFile file;
u64 title_id;
@@ -54,5 +59,8 @@ private:
std::vector<QList<QStandardItem*>> list_items;
QStandardItem* default_update_item = nullptr;
std::vector<QStandardItem*> update_variant_items;
Core::System& system;
};

View File

@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#include "yuzu/game_list.h"
#include <regex>
#include <QApplication>
#include <QDir>
#include <QFileInfo>
@@ -13,20 +13,21 @@
#include <QMenu>
#include <QThreadPool>
#include <QToolButton>
#include <fmt/ranges.h>
#include "common/common_types.h"
#include "common/logging/log.h"
#include "core/core.h"
#include "core/file_sys/patch_manager.h"
#include "core/file_sys/registered_cache.h"
#include "qt_common/util/game.h"
#include "core/hle/service/filesystem/filesystem.h"
#include "qt_common/config/uisettings.h"
#include "qt_common/util/game.h"
#include "yuzu/compatibility_list.h"
#include "yuzu/game_list.h"
#include "yuzu/game_list_p.h"
#include "yuzu/game_list_worker.h"
#include "yuzu/main.h"
#include "yuzu/util/controller_navigation.h"
#include <fmt/ranges.h>
#include <regex>
GameListSearchField::KeyReleaseEater::KeyReleaseEater(GameList* gamelist_, QObject* parent)
: QObject(parent), gamelist{gamelist_} {}
@@ -318,7 +319,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 +488,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<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) {
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);
@@ -619,26 +628,32 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
emit RemoveInstalledEntryRequested(program_id, QtCommon::Game::InstalledEntryType::Update);
});
connect(remove_dlc, &QAction::triggered, [this, program_id]() {
emit RemoveInstalledEntryRequested(program_id, QtCommon::Game::InstalledEntryType::AddOnContent);
emit RemoveInstalledEntryRequested(program_id,
QtCommon::Game::InstalledEntryType::AddOnContent);
});
connect(remove_gl_shader_cache, &QAction::triggered, [this, program_id, path]() {
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::GlShaderCache, path);
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::GlShaderCache,
path);
});
connect(remove_vk_shader_cache, &QAction::triggered, [this, program_id, path]() {
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::VkShaderCache, path);
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::VkShaderCache,
path);
});
connect(remove_shader_cache, &QAction::triggered, [this, program_id, path]() {
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::AllShaderCache, path);
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::AllShaderCache,
path);
});
connect(remove_custom_config, &QAction::triggered, [this, program_id, path]() {
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::CustomConfiguration, path);
emit RemoveFileRequested(program_id,
QtCommon::Game::GameListRemoveTarget::CustomConfiguration, path);
});
connect(set_play_time, &QAction::triggered,
[this, program_id]() { emit SetPlayTimeRequested(program_id); });
connect(remove_play_time_data, &QAction::triggered,
[this, program_id]() { emit RemovePlayTimeRequested(program_id); });
connect(remove_cache_storage, &QAction::triggered, [this, program_id, path] {
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::CacheStorage, path);
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::CacheStorage,
path);
});
connect(dump_romfs, &QAction::triggered, [this, program_id, path]() {
emit DumpRomFSRequested(program_id, path, DumpRomFSTarget::Normal);
@@ -665,8 +680,8 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
connect(properties, &QAction::triggered,
[this, path]() { emit OpenPerGameGeneralRequested(path); });
connect(ryujinx, &QAction::triggered, [this, program_id]() { emit LinkToRyujinxRequested(program_id);
});
connect(ryujinx, &QAction::triggered,
[this, program_id]() { emit LinkToRyujinxRequested(program_id); });
};
void GameList::AddCustomDirPopup(QMenu& context_menu, QModelIndex selected) {
@@ -830,8 +845,7 @@ QStandardItemModel* GameList::GetModel() const {
return item_model;
}
void GameList::PopulateAsync(QVector<UISettings::GameDir>& game_dirs)
{
void GameList::PopulateAsync(QVector<UISettings::GameDir>& game_dirs) {
tree_view->setEnabled(false);
// Update the columns in case UISettings has changed
@@ -848,12 +862,8 @@ void GameList::PopulateAsync(QVector<UISettings::GameDir>& game_dirs)
item_model->removeRows(0, item_model->rowCount());
search_field->clear();
current_worker = std::make_unique<GameListWorker>(vfs,
provider,
game_dirs,
compatibility_list,
play_time_manager,
system);
current_worker = std::make_unique<GameListWorker>(vfs, provider, game_dirs, compatibility_list,
play_time_manager, system);
// Get events from the worker as data becomes available
connect(current_worker.get(), &GameListWorker::DataAvailable, this, &GameList::WorkerEvent,
@@ -882,8 +892,7 @@ const QStringList GameList::supported_file_extensions = {
QStringLiteral("nso"), QStringLiteral("nro"), QStringLiteral("nca"),
QStringLiteral("xci"), QStringLiteral("nsp"), QStringLiteral("kip")};
void GameList::RefreshGameDirectory()
{
void GameList::RefreshGameDirectory() {
if (!UISettings::values.game_dirs.empty() && current_worker != nullptr) {
LOG_INFO(Frontend, "Change detected in the games directory. Reloading game list.");
PopulateAsync(UISettings::values.game_dirs);
@@ -967,6 +976,17 @@ GameListPlaceholder::GameListPlaceholder(GMainWindow* 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));
}

View File

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

View File

@@ -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);