diff --git a/src/common/settings.h b/src/common/settings.h index b5c8db5cec..cfffe47df8 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -759,6 +759,7 @@ struct Values { // Add-Ons std::map> disabled_addons; + std::vector external_dirs; }; extern Values values; diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 4bf1f851c0..9e5dce97cc 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -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 diff --git a/src/core/file_sys/external_content_index.cpp b/src/core/file_sys/external_content_index.cpp new file mode 100644 index 0000000000..18e3706b59 --- /dev/null +++ b/src/core/file_sys/external_content_index.cpp @@ -0,0 +1,271 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "core/file_sys/external_content_index.h" + +#include +#include +#include +#include +#include "common/hex_util.h" +#include "common/logging/log.h" +#include "common/fs/fs_util.h" +#include "core/file_sys/nca_metadata.h" +#include "core/file_sys/romfs.h" +#include "core/file_sys/registered_cache.h" +#include "core/file_sys/vfs/vfs.h" +#include "core/file_sys/content_archive.h" +#include "core/file_sys/submission_package.h" +#include "core/loader/loader.h" +#include "core/file_sys/common_funcs.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_best_update_by_title.clear(); + m_all_dlc.clear(); + + for (const auto& dir : m_paths.update_dirs) { + IndexUpdatesDir(dir); + } + for (const auto& dir : m_paths.dlc_dirs) { + IndexDlcDir(dir); + } + + Commit(); +} + +static std::string ToLowerCopy(const std::string& s) { + std::string out; + out.resize(s.size()); + std::transform(s.begin(), s.end(), out.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + return out; +} + +void ExternalContentIndexer::IndexUpdatesDir(const std::string& dir) { + try { + const fs::path p = Common::FS::ToU8String(dir); + std::error_code ec; + if (!fs::exists(p, ec) || ec) + return; + + if (fs::is_directory(p, ec) && !ec) { + for (const auto& entry : fs::recursive_directory_iterator(p, fs::directory_options::skip_permission_denied, ec)) { + if (entry.is_directory(ec)) continue; + TryIndexFileAsContainer(Common::FS::ToUTF8String(entry.path().u8string()), true); + } + TryIndexLooseDir(Common::FS::ToUTF8String(p.u8string()), true); + } else { + TryIndexFileAsContainer(Common::FS::ToUTF8String(p.u8string()), true); + } + } catch (const std::exception& e) { + LOG_ERROR(Loader, "Error accessing update directory '{}': {}", dir, e.what()); + } +} + +void ExternalContentIndexer::IndexDlcDir(const std::string& dir) { + try { + const fs::path p = Common::FS::ToU8String(dir); + std::error_code ec; + if (!fs::exists(p, ec) || ec) + return; + + if (fs::is_directory(p, ec) && !ec) { + for (const auto& entry : fs::recursive_directory_iterator(p, fs::directory_options::skip_permission_denied, ec)) { + if (entry.is_directory(ec)) continue; + TryIndexFileAsContainer(Common::FS::ToUTF8String(entry.path().u8string()), false); + } + TryIndexLooseDir(Common::FS::ToUTF8String(p.u8string()), false); + } else { + TryIndexFileAsContainer(Common::FS::ToUTF8String(p.u8string()), false); + } + } catch (const std::exception& e) { + LOG_ERROR(Loader, "Error accessing DLC directory '{}': {}", dir, e.what()); + } +} + +void ExternalContentIndexer::TryIndexFileAsContainer(const std::string& path, bool is_update) { + const auto lower = ToLowerCopy(path); + if (lower.size() >=4 && lower.rfind(".nsp") == lower.size() -4) { + if (auto vf = m_vfs->OpenFile(path, OpenMode::Read)) { + ParseContainerNSP(vf, is_update); + } + } +} + +void ExternalContentIndexer::TryIndexLooseDir(const std::string& dir, bool is_update) { + fs::path p = Common::FS::ToU8String(dir); + std::error_code ec; + if (!fs::is_directory(p, ec) || ec) return; + + for (const auto& entry : fs::recursive_directory_iterator(p, fs::directory_options::skip_permission_denied, ec)) { + if (ec) break; + if (!entry.is_regular_file(ec)) continue; + const auto path = Common::FS::ToUTF8String(entry.path().u8string()); + const auto lower = ToLowerCopy(path); + if (lower.size() >= 9 && lower.rfind(".cnmt.nca") == lower.size() - 9) { + if (auto vf = m_vfs->OpenFile(path, OpenMode::Read)) { + ParseLooseCnmtNca(vf, Common::FS::ToUTF8String(entry.path().parent_path().u8string()), is_update); + } + } + } +} + +void ExternalContentIndexer::ParseContainerNSP(VirtualFile file, bool is_update) { + if (file == nullptr) return; + NSP nsp(file); + if (nsp.GetStatus() != Loader::ResultStatus::Success) { + LOG_WARNING(Loader, "ExternalContent: NSP parse failed"); + return; + } + + const auto title_map = nsp.GetNCAs(); + if (title_map.empty()) return; + + for (const auto& [title_id, nca_map] : title_map) { + std::shared_ptr meta_nca; + for (const auto& [key, nca_ptr] : nca_map) { + if (nca_ptr && nca_ptr->GetType() == NCAContentType::Meta) { + meta_nca = nca_ptr; + break; + } + } + if (!meta_nca) + continue; + + auto cnmt_opt = ExtractCnmtFromMetaNca(*meta_nca); + if (!cnmt_opt) + continue; + const auto& cnmt = *cnmt_opt; + + const auto base_id = BaseTitleId(title_id); + + if (is_update && cnmt.GetType() == TitleType::Update) { + ParsedUpdate candidate{}; + // Register updates under their Update TID so PatchManager can find/apply them + candidate.title_id = FileSys::GetUpdateTitleID(base_id); + candidate.version = cnmt.GetTitleVersion(); + for (const auto& rec : cnmt.GetContentRecords()) { + const auto it = nca_map.find({cnmt.GetType(), rec.type}); + if (it != nca_map.end() && it->second) { + candidate.ncas[rec.type] = it->second->GetBaseFile(); + } + } + + auto& best = m_best_update_by_title[base_id]; + if (best.title_id ==0 || candidate.version > best.version) { + best = 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& best = m_best_update_by_title[base_id]; + if (best.title_id ==0 || candidate.version > best.version) { + best = std::move(candidate); + } + } else if (cnmt.GetType() == TitleType::AOC) { + const auto dlc_title_id = cnmt.GetTitleID(); + for (const auto& rec : cnmt.GetContentRecords()) { + const auto file_name = Common::HexToString(rec.nca_id) + ".nca"; + const auto full = Common::FS::ToUTF8String((fs::path(Common::FS::ToU8String(folder)) / fs::path(file_name)).u8string()); + if (auto vf = m_vfs->OpenFile(full, OpenMode::Read)) { + ParsedDlcRecord dl{dlc_title_id, {}, vf}; + m_all_dlc.push_back(std::move(dl)); + } + } + } +} + +std::optional ExternalContentIndexer::ExtractCnmtFromMetaNca(const NCA& meta_nca) { + if (meta_nca.GetStatus() != Loader::ResultStatus::Success) + return std::nullopt; + + const auto subs = meta_nca.GetSubdirectories(); + if (subs.empty() || !subs[0]) + return std::nullopt; + + const auto files = subs[0]->GetFiles(); + if (files.empty() || !files[0]) + return std::nullopt; + + CNMT cnmt(files[0]); + return cnmt; +} + +ExternalContentIndexer::TitleID ExternalContentIndexer::BaseTitleId(TitleID id) { + return FileSys::GetBaseTitleID(id); +} + +bool ExternalContentIndexer::IsMeta(const NCA& nca) { + return nca.GetType() == NCAContentType::Meta; +} + +void ExternalContentIndexer::Commit() { + // Updates: for now just register the highest version per DLC + for (auto& [base_title, upd] : m_best_update_by_title) { + for (const auto& kv : upd.ncas) { + const auto rec_type = kv.first; + const auto& file = kv.second; + if (!file) continue; + // Use the Update TitleID for provider registration so core queries by update_tid succeed + const auto update_tid = FileSys::GetUpdateTitleID(base_title); + m_provider.AddEntry(TitleType::Update, rec_type, update_tid, file); + } + } + // DLC: additiv + 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 {} titles with updates, {} DLC records", + m_best_update_by_title.size(), m_all_dlc.size()); +} + +} // namespace FileSys} // namespace FileSys \ No newline at end of file diff --git a/src/core/file_sys/external_content_index.h b/src/core/file_sys/external_content_index.h new file mode 100644 index 0000000000..47994b5e38 --- /dev/null +++ b/src/core/file_sys/external_content_index.h @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include "common/common_types.h" +#include "core/file_sys/registered_cache.h" +#include "core/file_sys/vfs/vfs.h" +#include "core/file_sys/nca_metadata.h" + +namespace FileSys { + +class ManualContentProvider; +class NCA; + +struct ExternalContentPaths { + std::vector update_dirs; + std::vector dlc_dirs; +}; + +class ExternalContentIndexer { +public: + ExternalContentIndexer(VirtualFilesystem vfs, + ManualContentProvider& provider, + ExternalContentPaths paths); + + void Rebuild(); + +private: + using TitleID = u64; + + struct ParsedUpdate { + TitleID title_id{}; + u32 version{}; + std::unordered_map ncas; + }; + + struct ParsedDlcRecord { + TitleID title_id{}; + NcaID nca_id{}; + VirtualFile file{}; + }; + + void IndexUpdatesDir(const std::string& dir); + void IndexDlcDir(const std::string& dir); + + void TryIndexFileAsContainer(const std::string& path, bool is_update); + void TryIndexLooseDir(const std::string& dir, bool is_update); + + void ParseContainerNSP(VirtualFile file, bool is_update); + void ParseLooseCnmtNca(VirtualFile meta_nca_file, const std::string& folder, bool is_update); + + static std::optional ExtractCnmtFromMetaNca(const NCA& meta_nca); + static TitleID BaseTitleId(TitleID id); + static bool IsMeta(const NCA& nca); + + void Commit(); + +private: + VirtualFilesystem m_vfs; + ManualContentProvider& m_provider; + ExternalContentPaths m_paths; + + std::unordered_map m_best_update_by_title; + + std::vector m_all_dlc; +}; + +} // namespace FileSys \ No newline at end of file diff --git a/src/core/file_sys/registered_cache.h b/src/core/file_sys/registered_cache.h index a7fc556737..3ef02ccb33 100644 --- a/src/core/file_sys/registered_cache.h +++ b/src/core/file_sys/registered_cache.h @@ -208,6 +208,7 @@ enum class ContentProviderUnionSlot { UserNAND, ///< User NAND SDMC, ///< SD Card FrontendManual, ///< Frontend-defined game list or similar + External ///< External Updates/DLCs (not installed to NAND) }; // Combines multiple ContentProvider(s) (i.e. SysNAND, UserNAND, SDMC) into one interface. diff --git a/src/core/hle/service/filesystem/filesystem.cpp b/src/core/hle/service/filesystem/filesystem.cpp index 9d7de4242e..b9003fdea1 100644 --- a/src/core/hle/service/filesystem/filesystem.cpp +++ b/src/core/hle/service/filesystem/filesystem.cpp @@ -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 +#include #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(); + system.RegisterContentProvider(FileSys::ContentProviderUnionSlot::External, + external_provider.get()); + } + + RebuildExternalContentIndex(); +} + +void FileSystemController::RebuildExternalContentIndex() { + if (external_provider == nullptr) { + LOG_WARNING(Service_FS, "External provider not initialized, skipping re-index."); + return; + } + + if (!Settings::values.external_dirs.empty()) { + FileSys::ExternalContentPaths paths{}; + for (const auto& dir : Settings::values.external_dirs) { + if (dir.empty()) + continue; + paths.update_dirs.push_back(dir); + paths.dlc_dirs.push_back(dir); + } + FileSys::ExternalContentIndexer indexer{system.GetFilesystem(), *this->external_provider, + std::move(paths)}; + indexer.Rebuild(); + } else { + external_provider->ClearAllEntries(); + } } void FileSystemController::Reset() { diff --git a/src/core/hle/service/filesystem/filesystem.h b/src/core/hle/service/filesystem/filesystem.h index 718500385b..c9f057d522 100644 --- a/src/core/hle/service/filesystem/filesystem.h +++ b/src/core/hle/service/filesystem/filesystem.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -5,6 +8,7 @@ #include #include +#include #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 gamecard; std::unique_ptr gamecard_registered; std::unique_ptr gamecard_placeholder; + std::unique_ptr external_provider; Core::System& system; }; diff --git a/src/frontend_common/config.cpp b/src/frontend_common/config.cpp index fa1383436e..51689da5d8 100644 --- a/src/frontend_common/config.cpp +++ b/src/frontend_common/config.cpp @@ -283,6 +283,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 +604,14 @@ void Config::SaveDataStorageValues() { WriteCategory(Settings::Category::DataStorage); + BeginArray(std::string("external_dirs")); + for (std::size_t i = 0; i < Settings::values.external_dirs.size(); ++i) { + SetArrayIndex(static_cast(i)); + WriteStringSetting(std::string("path"), Settings::values.external_dirs[i], + std::make_optional(std::string(""))); + } + EndArray(); + EndGroup(); } diff --git a/src/yuzu/configuration/configure_general.cpp b/src/yuzu/configuration/configure_general.cpp index 29168ed79e..3f895d6fb5 100644 --- a/src/yuzu/configuration/configure_general.cpp +++ b/src/yuzu/configuration/configure_general.cpp @@ -7,14 +7,18 @@ #include #include #include +#include +#include #include #include "common/settings.h" #include "core/core.h" +#include "core/hle/service/filesystem/filesystem.h" #include "ui_configure_general.h" #include "yuzu/configuration/configuration_shared.h" #include "yuzu/configuration/configure_general.h" #include "yuzu/configuration/shared_widget.h" #include "qt_common/config/uisettings.h" +#include "qt_common/util/game.h" ConfigureGeneral::ConfigureGeneral(const Core::System& system_, std::shared_ptr> group_, @@ -22,21 +26,45 @@ ConfigureGeneral::ConfigureGeneral(const Core::System& system_, : Tab(group_, parent), ui{std::make_unique()}, system{system_} { ui->setupUi(this); + apply_funcs.push_back([this](bool) { + Settings::values.external_dirs.clear(); + for (int i = 0; i < ui->external_dirs_list->count(); ++i) { + QListWidgetItem* item = ui->external_dirs_list->item(i); + if (item) { + Settings::values.external_dirs.push_back(item->text().toStdString()); + } + } + auto& fs_controller = const_cast(system).GetFileSystemController(); + fs_controller.RebuildExternalContentIndex(); + QtCommon::Game::ResetMetadata(false); + UISettings::values.is_game_list_reload_pending.exchange(true); + }); + Setup(builder); SetConfiguration(); 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()); +} diff --git a/src/yuzu/configuration/configure_general.h b/src/yuzu/configuration/configure_general.h index ada6526a6a..8903ba5af8 100644 --- a/src/yuzu/configuration/configure_general.h +++ b/src/yuzu/configuration/configure_general.h @@ -45,6 +45,11 @@ private: void changeEvent(QEvent* event) override; void RetranslateUI(); + void LoadExternalDirs(); + void OnAddDirClicked(); + void OnRemoveDirClicked(); + void OnDirSelectionChanged(); + std::function reset_callback; std::unique_ptr ui; diff --git a/src/yuzu/configuration/configure_general.ui b/src/yuzu/configuration/configure_general.ui index ef20891a32..493467d199 100644 --- a/src/yuzu/configuration/configure_general.ui +++ b/src/yuzu/configuration/configure_general.ui @@ -73,6 +73,59 @@ + + + + Additional Directories (Updates, DLC, etc.) + + + + + + This list contains directories that will be searched for game updates and DLC. + + + true + + + QAbstractItemView::ExtendedSelection + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Add + + + + + + + Remove + + + + + + + +