Browse Source

[fs/core] initial external content without NAND install

fs_external_dlcupdates
unknown 2 months ago
committed by crueter
parent
commit
5ba9d3fb9f
  1. 1
      src/common/settings.h
  2. 2
      src/core/CMakeLists.txt
  3. 271
      src/core/file_sys/external_content_index.cpp
  4. 74
      src/core/file_sys/external_content_index.h
  5. 1
      src/core/file_sys/registered_cache.h
  6. 35
      src/core/hle/service/filesystem/filesystem.cpp
  7. 7
      src/core/hle/service/filesystem/filesystem.h
  8. 21
      src/frontend_common/config.cpp
  9. 65
      src/yuzu/configuration/configure_general.cpp
  10. 5
      src/yuzu/configuration/configure_general.h
  11. 53
      src/yuzu/configuration/configure_general.ui

1
src/common/settings.h

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

2
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

271
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 <algorithm>
#include <filesystem>
#include <string>
#include <cctype>
#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<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& 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<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: 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

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, ParsedUpdate> m_best_update_by_title;
std::vector<ParsedDlcRecord> m_all_dlc;
};
} // namespace FileSys

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

35
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 <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() {

7
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 <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;
};

21
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<int>(i));
WriteStringSetting(std::string("path"), Settings::values.external_dirs[i],
std::make_optional(std::string("")));
}
EndArray();
EndGroup();
}

65
src/yuzu/configuration/configure_general.cpp

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

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

53
src/yuzu/configuration/configure_general.ui

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

Loading…
Cancel
Save